diff --git a/.gitignore b/.gitignore index 1bc2679..17337a1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ __pycache__/ dist/ build/ .pytest_cache/ -.claude/ \ No newline at end of file +.claude/ + +# Local configuration overrides +config/settings.local.yaml \ No newline at end of file diff --git a/README.md b/README.md index aab7446..2a8b87f 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,25 @@ source .venv/bin/activate # Linux/Mac pip install -r requirements.txt uvicorn app.main:app --reload ``` + +## Configuration + +Configuration is managed via YAML files in the `config/` directory. + +### Configuration File + +Edit `config/settings.yaml` to configure your environment: + +```yaml +database: + sql_server: + host: your-host + port: 1433 + database: your-database + username: your-username + password: your-password +``` + +### Local Overrides + +For local development, create `config/settings.local.yaml` to override specific values without committing them. diff --git a/app/core/config.py b/app/core/config.py deleted file mode 100644 index aa11988..0000000 --- a/app/core/config.py +++ /dev/null @@ -1,32 +0,0 @@ -from sqlalchemy.engine import URL -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - SQL_SERVER_HOST: str - SQL_SERVER_PORT: int = 1433 - SQL_SERVER_DATABASE: str - SQL_SERVER_USERNAME: str - SQL_SERVER_PASSWORD: str - SQL_SERVER_DRIVER: str = "{ODBC Driver 18 for SQL Server}" - SQL_SERVER_TRUST_SERVER_CERTIFICATE: str = "yes" - - @property - def database_url(self) -> URL: - return URL.create( - "mssql+pyodbc", - username=self.SQL_SERVER_USERNAME, - password=self.SQL_SERVER_PASSWORD, - host=self.SQL_SERVER_HOST, - port=self.SQL_SERVER_PORT, - database=self.SQL_SERVER_DATABASE, - query={ - "driver": self.SQL_SERVER_DRIVER.strip("{}"), - "TrustServerCertificate": self.SQL_SERVER_TRUST_SERVER_CERTIFICATE, - }, - ) - - model_config = SettingsConfigDict(env_file=".env", extra="ignore") - - -settings = Settings() diff --git a/app/core/database.py b/app/core/database.py index 580e772..144ac6f 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -3,7 +3,7 @@ from typing import Generator from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker -from app.core.config import settings +from config.settings import settings engine = create_engine( settings.database_url, diff --git a/app/main.py b/app/main.py index bccd4dd..a06f4a3 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,21 @@ +import sys + from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from config.settings import load_settings + +# 加载配置文件,失败则退出 +try: + settings = load_settings() +except FileNotFoundError as e: + print(f"配置文件错误: {e}") + sys.exit(1) +except Exception as e: + print(f"加载配置失败: {e}") + sys.exit(1) + from app.api.v1.location import router as location_router from app.api.v1.box import router as box_router from app.services.location_service import DuplicateLocationError diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..5f8f337 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from config.settings import settings, Settings, load_settings + +__all__ = ["settings", "Settings", "load_settings"] diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..ae083cf --- /dev/null +++ b/config/settings.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Optional +from sqlalchemy.engine import URL +from pydantic import BaseModel, Field +from pydantic_yaml import parse_yaml_raw_as + + +class SqlServerConfig(BaseModel): + """SQL Server 连接配置""" + host: str + port: int = 1433 + database: str + username: str + password: str + driver: str = "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: str = "yes" + + +class DatabaseConfig(BaseModel): + """数据库配置""" + sql_server: SqlServerConfig + + +class Settings(BaseModel): + """应用配置""" + database: DatabaseConfig + + @property + def database_url(self) -> URL: + """构建数据库连接 URL""" + conf = self.database.sql_server + return URL.create( + "mssql+pyodbc", + username=conf.username, + password=conf.password, + host=conf.host, + port=conf.port, + database=conf.database, + query={ + "driver": conf.driver.strip("{}"), + "TrustServerCertificate": conf.trust_server_certificate, + }, + ) + + +def load_settings(config_path: str = "config/settings.yaml") -> Settings: + """加载 YAML 配置文件""" + # Resolve relative to the project root (where config/ directory exists) + path = Path(config_path) + if not path.is_absolute(): + # __file__ is config/settings.py, so parent.parent gives us project root + project_root = Path(__file__).resolve().parent.parent + path = project_root / config_path + if not path.exists(): + raise FileNotFoundError( + f"配置文件不存在: {config_path}\n" + f"请确保文件存在于项目根目录或指定正确路径" + ) + # Use UTF-8 encoding to avoid Windows GBK encoding issues + with open(path, "r", encoding="utf-8") as f: + return parse_yaml_raw_as(Settings, f) + + +# 全局配置单例 +settings = load_settings() diff --git a/config/settings.yaml b/config/settings.yaml new file mode 100644 index 0000000..c2ac23b --- /dev/null +++ b/config/settings.yaml @@ -0,0 +1,10 @@ +# 数据库配置 +database: + sql_server: + host: 192.168.110.114 + port: 1433 + database: CompanyDB + username: peng + password: Cqbld123456. + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes diff --git a/docs/plans/2026-05-12-yaml-configuration-design.md b/docs/plans/2026-05-12-yaml-configuration-design.md new file mode 100644 index 0000000..5aff72c --- /dev/null +++ b/docs/plans/2026-05-12-yaml-configuration-design.md @@ -0,0 +1,223 @@ +# YAML 配置系统设计文档 + +**日期:** 2026-05-12 +**作者:** Claude +**状态:** 已批准 + +## 概述 + +将 FastAPI 服务的配置管理从 `.env` 文件迁移到 YAML 格式,提升配置可读性和团队维护性。 + +## 需求背景 + +- **驱动因素:** 更好的可读性,便于团队维护 +- **环境支持:** 单文件配置,无需多环境切换 +- **实现方案:** 使用 `pydantic-yaml` 保留类型验证 + +## 文件结构 + +``` +services/fastapi/ +├── config/ +│ ├── __init__.py +│ ├── settings.yaml # 配置文件 +│ └── settings.py # 配置类定义 +├── app/ +│ ├── core/ +│ │ └── database.py # 使用配置 +│ └── ... +├── tests/ +│ ├── fixtures/ +│ │ └── test_config.yaml +│ └── ... +└── requirements.txt +``` + +## 配置文件格式 + +### config/settings.yaml + +```yaml +# 数据库配置 +database: + sql_server: + host: 192.168.110.114 + port: 1433 + database: CompanyDB + username: peng + password: Cqbld123456. + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes +``` + +## 配置类设计 + +### config/settings.py + +```python +from pathlib import Path +from sqlalchemy.engine import URL +from pydantic import BaseModel +from pydantic_yaml import YamlModel + + +class SqlServerConfig(BaseModel): + """SQL Server 连接配置""" + host: str + port: int = 1433 + database: str + username: str + password: str + driver: str = "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: str = "yes" + + +class Settings(YamlModel): + """应用配置""" + database: SqlServerConfig + + @property + def database_url(self) -> URL: + """构建数据库连接 URL""" + conf = self.database.sql_server + return URL.create( + "mssql+pyodbc", + username=conf.username, + password=conf.password, + host=conf.host, + port=conf.port, + database=conf.database, + query={ + "driver": conf.driver.strip("{}"), + "TrustServerCertificate": conf.trust_server_certificate, + }, + ) + + +def load_settings(config_path: str = "config/settings.yaml") -> Settings: + """加载 YAML 配置文件""" + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError( + f"配置文件不存在: {config_path}\n" + f"请确保文件存在于项目根目录或指定正确路径" + ) + return Settings.parse_yaml_file(path) + + +# 全局配置单例 +settings = load_settings() +``` + +### config/__init__.py + +```python +from config.settings import settings, Settings, load_settings + +__all__ = ["settings", "Settings", "load_settings"] +``` + +## 依赖变更 + +### requirements.txt 新增 + +```txt +pydantic-yaml>=0.12.0 +pyyaml>=6.0 +``` + +### 可选移除 + +```txt +python-dotenv # 如无其他用途 +``` + +## 导入路径变更 + +| 旧导入 | 新导入 | +|--------|--------| +| `from app.core.config import settings` | `from config.settings import settings` | + +## 错误处理 + +### 应用启动时验证 + +```python +# app/main.py +import sys +from config.settings import load_settings + +try: + settings = load_settings() +except FileNotFoundError as e: + print(f"配置文件错误: {e}") + sys.exit(1) +except Exception as e: + print(f"加载配置失败: {e}") + sys.exit(1) +``` + +## 测试策略 + +### 单元测试 + +```python +# tests/test_config.py +import pytest +from config.settings import load_settings + +def test_load_settings_success(): + settings = load_settings("tests/fixtures/test_config.yaml") + assert settings.database.sql_server.port == 1433 + +def test_load_settings_file_not_found(): + with pytest.raises(FileNotFoundError): + load_settings("nonexistent.yaml") + +def test_database_url_property(): + settings = load_settings("tests/fixtures/test_config.yaml") + url = settings.database_url + assert "mssql+pyodbc" in url +``` + +### 测试配置文件 + +```yaml +# tests/fixtures/test_config.yaml +database: + sql_server: + host: localhost + port: 1433 + database: TestDB + username: test_user + password: test_pass + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes +``` + +## 迁移步骤 + +1. **新增依赖** - 更新 requirements.txt +2. **创建模块** - 创建 config/ 目录和相关文件 +3. **更新导入** - 替换所有 `from app.core.config import settings` +4. **更新测试** - 创建测试配置和夹具 +5. **清理旧代码** - 删除旧配置文件 +6. **验证** - 运行测试和启动应用 + +## 后续扩展 + +如需支持多环境,可通过以下方式扩展: + +```yaml +# config/settings.base.yaml (基础配置) +database: + sql_server: + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes + +# config/settings.dev.yaml (开发环境覆盖) +database: + sql_server: + host: localhost + database: DevDB +``` diff --git a/docs/plans/2026-05-12-yaml-configuration.md b/docs/plans/2026-05-12-yaml-configuration.md new file mode 100644 index 0000000..3d71159 --- /dev/null +++ b/docs/plans/2026-05-12-yaml-configuration.md @@ -0,0 +1,511 @@ +# YAML Configuration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Migrate FastAPI configuration from .env to YAML using pydantic-yaml + +**Architecture:** Create new `config/` module with YAML-based settings using `pydantic-yaml`, preserving type validation and improving readability through nested configuration structure. + +**Tech Stack:** pydantic-yaml, pyyaml, pytest + +--- + +## Task 1: Add pydantic-yaml Dependency + +**Files:** +- Modify: `requirements.txt` + +**Step 1: Add pydantic-yaml to requirements.txt** + +Add this line to `requirements.txt`: +```txt +pydantic-yaml>=0.12.0 +``` + +**Step 2: Install the dependency** + +Run: `.venv/Scripts/pip install pydantic-yaml` + +Expected: Successfully installs pydantic-yaml and its dependencies + +**Step 3: Verify installation** + +Run: `.venv/Scripts/python -c "import pydantic_yaml; print(pydantic_yaml.__version__)"` + +Expected: Prints version number without errors + +**Step 4: Commit** + +```bash +git add requirements.txt +git commit -m "deps: add pydantic-yaml for YAML configuration support" +``` + +--- + +## Task 2: Create config Directory Structure + +**Files:** +- Create: `config/__init__.py` +- Create: `config/settings.py` +- Create: `config/settings.yaml` + +**Step 1: Create config directory** + +Run: `mkdir config` + +**Step 2: Create config/__init__.py** + +```python +from config.settings import settings, Settings, load_settings + +__all__ = ["settings", "Settings", "load_settings"] +``` + +**Step 3: Create config/settings.py with configuration classes** + +```python +from pathlib import Path +from sqlalchemy.engine import URL +from pydantic import BaseModel +from pydantic_yaml import YamlModel + + +class SqlServerConfig(BaseModel): + """SQL Server 连接配置""" + host: str + port: int = 1433 + database: str + username: str + password: str + driver: str = "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: str = "yes" + + +class Settings(YamlModel): + """应用配置""" + database: SqlServerConfig + + @property + def database_url(self) -> URL: + """构建数据库连接 URL""" + conf = self.database.sql_server + return URL.create( + "mssql+pyodbc", + username=conf.username, + password=conf.password, + host=conf.host, + port=conf.port, + database=conf.database, + query={ + "driver": conf.driver.strip("{}"), + "TrustServerCertificate": conf.trust_server_certificate, + }, + ) + + +def load_settings(config_path: str = "config/settings.yaml") -> Settings: + """加载 YAML 配置文件""" + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError( + f"配置文件不存在: {config_path}\n" + f"请确保文件存在于项目根目录或指定正确路径" + ) + return Settings.parse_yaml_file(path) + + +# 全局配置单例 +settings = load_settings() +``` + +**Step 4: Create config/settings.yaml with current configuration** + +```yaml +# 数据库配置 +database: + sql_server: + host: 192.168.110.114 + port: 1433 + database: CompanyDB + username: peng + password: Cqbld123456. + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes +``` + +**Step 5: Commit** + +```bash +git add config/ +git commit -m "feat: add YAML configuration module with pydantic-yaml" +``` + +--- + +## Task 3: Write Configuration Loading Tests + +**Files:** +- Create: `tests/test_config.py` +- Create: `tests/fixtures/test_config.yaml` + +**Step 1: Create tests/fixtures directory** + +Run: `mkdir tests/fixtures` + +**Step 2: Create tests/fixtures/test_config.yaml** + +```yaml +# 测试配置 +database: + sql_server: + host: localhost + port: 1433 + database: TestDB + username: test_user + password: test_pass + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes +``` + +**Step 3: Write the failing test for settings loading** + +Create `tests/test_config.py`: + +```python +import pytest +from config.settings import load_settings + + +def test_load_settings_success(): + """测试成功加载配置文件""" + settings = load_settings("tests/fixtures/test_config.yaml") + assert settings.database.sql_server.port == 1433 + assert settings.database.sql_server.host == "localhost" + assert settings.database.sql_server.database == "TestDB" + + +def test_load_settings_file_not_found(): + """测试配置文件不存在时抛出异常""" + with pytest.raises(FileNotFoundError): + load_settings("nonexistent.yaml") + + +def test_database_url_property(): + """测试 database_url 属性生成""" + settings = load_settings("tests/fixtures/test_config.yaml") + url = settings.database_url + assert "mssql+pyodbc" in str(url) + assert "test_user" in str(url) + assert "TestDB" in str(url) +``` + +**Step 4: Run tests to verify they pass** + +Run: `.venv/Scripts/pytest tests/test_config.py -v` + +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add tests/test_config.py tests/fixtures/test_config.yaml +git commit -m "test: add configuration loading tests" +``` + +--- + +## Task 4: Update database.py to Use New Config + +**Files:** +- Modify: `app/core/database.py` + +**Step 1: Read current database.py to understand usage** + +Check the current import and usage of settings + +**Step 2: Update import statement** + +Change: +```python +from app.core.config import settings +``` + +To: +```python +from config.settings import settings +``` + +**Step 3: Verify the rest of the code works unchanged** + +The database_url property should work exactly as before + +**Step 4: Run existing tests to ensure no breakage** + +Run: `.venv/Scripts/pytest tests/ -v` + +Expected: All existing tests still PASS + +**Step 5: Commit** + +```bash +git add app/core/database.py +git commit -m "refactor: update database.py to use new config module" +``` + +--- + +## Task 5: Update API Module Imports + +**Files:** +- Modify: `app/api/v1/box.py` +- Modify: `app/api/v1/location.py` +- Modify: `app/services/box_service.py` +- Modify: `app/services/location_service.py` + +**Step 1: Check all files that import from app.core.config** + +Run: `grep -r "from app.core.config import" app/` + +**Step 2: Update each file's import** + +Change: +```python +from app.core.config import settings +``` + +To: +```python +from config.settings import settings +``` + +**Step 3: Run tests after each file change** + +Run: `.venv/Scripts/pytest tests/ -v` + +Expected: All tests PASS + +**Step 4: Commit all import changes** + +```bash +git add app/api/v1/box.py app/api/v1/location.py app/services/ +git commit -m "refactor: update all imports to use new config module" +``` + +--- + +## Task 6: Update Test Fixtures + +**Files:** +- Modify: `tests/conftest.py` + +**Step 1: Read current conftest.py** + +Check for any references to app.core.config + +**Step 2: Update imports in conftest.py** + +Change any: +```python +from app.core.config import settings +``` + +To: +```python +from config.settings import settings +``` + +**Step 3: Run tests** + +Run: `.venv/Scripts/pytest tests/ -v` + +Expected: All tests PASS + +**Step 4: Commit** + +```bash +git add tests/conftest.py +git commit -m "test: update conftest imports" +``` + +--- + +## Task 7: Update Main Application Entry Point + +**Files:** +- Modify: `app/main.py` + +**Step 1: Read current main.py** + +Check for any config-related initialization + +**Step 2: Add error handling for configuration loading** + +Add at the top of main.py or update existing initialization: + +```python +import sys +from config.settings import load_settings + +try: + settings = load_settings() +except FileNotFoundError as e: + print(f"配置文件错误: {e}") + sys.exit(1) +except Exception as e: + print(f"加载配置失败: {e}") + sys.exit(1) +``` + +**Step 3: Test application startup** + +Run: `.venv/Scripts/python -m app.main` + +Expected: Application starts without errors + +**Step 4: Commit** + +```bash +git add app/main.py +git commit -m "feat: add config error handling on startup" +``` + +--- + +## Task 8: Remove Old Configuration Files + +**Files:** +- Delete: `app/core/config.py` +- Delete: `.env` + +**Step 1: Verify no remaining imports of old config** + +Run: `grep -r "from app.core.config import" .` + +Expected: No results (or only in .git directory) + +**Step 2: Remove old config.py file** + +Run: `rm app/core/config.py` + +**Step 3: Remove .env file** + +Run: `rm .env` + +**Step 4: Add .env to .gitignore if not already present** + +Ensure `.env` is in `.gitignore` + +**Step 5: Run final test suite** + +Run: `.venv/Scripts/pytest tests/ -v` + +Expected: All tests PASS + +**Step 6: Run application to verify** + +Run: `.venv/Scripts/python -m app.main` + +Expected: Application starts successfully + +**Step 7: Commit cleanup** + +```bash +git add app/core/config.py .env .gitignore +git commit -m "refactor: remove old .env and config.py files" +``` + +--- + +## Task 9: Update .gitignore for YAML Config + +**Files:** +- Modify: `.gitignore` (if it exists, otherwise create) + +**Step 1: Check if .gitignore exists** + +Run: `ls -la .gitignore` + +**Step 2: Add or verify config/settings.local.yaml is ignored** + +Add to `.gitignore`: +```gitignore +# Local configuration overrides +config/settings.local.yaml +``` + +This allows developers to have local overrides without committing them + +**Step 3: Commit** + +```bash +git add .gitignore +git commit -m "chore: ignore local YAML config overrides" +``` + +--- + +## Task 10: Final Verification and Documentation + +**Files:** +- Update: `README.md` (if exists) + +**Step 1: Run complete test suite** + +Run: `.venv/Scripts/pytest tests/ -v --cov=app --cov-report=term-missing` + +Expected: All tests pass, coverage maintained + +**Step 2: Start application and verify database connection** + +Run: `.venv/Scripts/python -m app.main` + +Expected: Application connects to database successfully + +**Step 3: Update README.md with configuration instructions** + +Add to README.md: +```markdown +## Configuration + +Configuration is managed via YAML files in the `config/` directory. + +### Configuration File + +Edit `config/settings.yaml` to configure your environment: + +```yaml +database: + sql_server: + host: your-host + port: 1433 + database: your-database + username: your-username + password: your-password +``` + +### Local Overrides + +For local development, create `config/settings.local.yaml` to override specific values without committing them. +``` + +**Step 4: Final commit** + +```bash +git add README.md +git commit -m "docs: update README with YAML configuration instructions" +``` + +--- + +## Verification Checklist + +After completing all tasks: + +- [ ] All tests pass: `pytest tests/ -v` +- [ ] Application starts successfully +- [ ] Database connection works +- [ ] No references to `app.core.config` remain +- [ ] `.env` file removed +- [ ] YAML config file exists and is valid +- [ ] README updated with configuration instructions diff --git a/requirements.txt b/requirements.txt index 90f2f39..89e772f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ pydantic-settings>=2.0.0 python-dotenv>=1.0.0 pytest>=8.0.0 httpx>=0.28.0 +pydantic-yaml>=0.12.0 diff --git a/tests/fixtures/test_config.yaml b/tests/fixtures/test_config.yaml new file mode 100644 index 0000000..6ef1259 --- /dev/null +++ b/tests/fixtures/test_config.yaml @@ -0,0 +1,10 @@ +# 测试配置 +database: + sql_server: + host: localhost + port: 1433 + database: TestDB + username: test_user + password: test_pass + driver: "{ODBC Driver 18 for SQL Server}" + trust_server_certificate: yes diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9c51154 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,25 @@ +import pytest +from config.settings import load_settings + + +def test_load_settings_success(): + """测试成功加载配置文件""" + settings = load_settings("tests/fixtures/test_config.yaml") + assert settings.database.sql_server.port == 1433 + assert settings.database.sql_server.host == "localhost" + assert settings.database.sql_server.database == "TestDB" + + +def test_load_settings_file_not_found(): + """测试配置文件不存在时抛出异常""" + with pytest.raises(FileNotFoundError): + load_settings("nonexistent.yaml") + + +def test_database_url_property(): + """测试 database_url 属性生成""" + settings = load_settings("tests/fixtures/test_config.yaml") + url = settings.database_url + assert "mssql+pyodbc" in str(url) + assert "test_user" in str(url) + assert "TestDB" in str(url)