diff --git a/.codex/skills/iti-flask-framework/SKILL.md b/.codex/skills/iti-flask-framework/SKILL.md index eeacb1c..c07a540 100644 --- a/.codex/skills/iti-flask-framework/SKILL.md +++ b/.codex/skills/iti-flask-framework/SKILL.md @@ -20,7 +20,7 @@ iTi-Flask 是 FastAPI 后端框架基座。 ## 代码入口 - `iti/app.py`:`create_app`、中间件、错误处理、自动 envelope、生命周期。 -- `iti/config.py`:dataclass 配置、`.env` 加载、MySQL 默认值。 +- `iti/config.py`:dataclass 配置、`.env` 加载、数据库默认连接串。 - `iti/db/*`:SQLAlchemy 2 base/session、Alembic metadata。 - `iti/auth/*`:JWT、Principal、Actor、权限依赖、服务 token 依赖。 - `iti/modules/*`:模块协议、权限元数据、菜单 seed 元数据。 @@ -58,7 +58,7 @@ iTi-Flask 是 FastAPI 后端框架基座。 - `copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja` - 需要保持一致的已生成项目 skill 副本 - 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。 -- 已生成项目同步框架依赖用 `iticli sync flask`。 +- 已生成项目同步框架依赖用 `iticli update framework`。 - 已生成项目检查和同步模板用 `iticli template check`、`iticli template update`。 - 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini`。 - 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。 diff --git a/README.md b/README.md index ab03f7e..0bd76fc 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ AI 修改框架代码或文档时优先读: - FastAPI 应用工厂。 - dataclass 配置和 `.env` 加载。 -- MySQL 默认数据库配置。 +- MySQL 默认数据库配置,PostgreSQL 可选。 - SQLAlchemy 2 和 Alembic。 - JWT、权限依赖、错误处理、响应包装。 - 用户 token / 服务 token 的统一 Actor 依赖。 @@ -75,6 +75,7 @@ iticli run dev 8000 ```bash iticli create ../my-business-app +iticli create --database postgresql ../my-postgres-app cd ../my-business-app iticli init iticli run dev 8000 @@ -83,7 +84,7 @@ iticli run dev 8000 同步框架依赖和模板骨架: ```bash -iticli sync flask +iticli update framework iticli template check iticli template update ``` diff --git a/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja b/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja index 154b5fe..f5c05d6 100644 --- a/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja +++ b/copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja @@ -22,7 +22,7 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask - 当前项目未默认注册 iTi-System。需要系统域能力时再显式引入。 {% endif -%} - 修改代码、架构、目录结构、脚本命令或测试方式后,同步更新这个 skill。 -- 同步框架依赖用 `iticli sync flask`。 +- 同步框架依赖用 `iticli update framework`。 - 检查和同步框架模板用 `iticli template check`、`iticli template update`。 ## 项目结构 @@ -36,7 +36,7 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask - `tests/`:pytest 路由和行为测试。 - `Dockerfile`:容器镜像构建入口。 - `docker-compose.yml`:本地 Compose 部署入口。 -- `docker-compose.with-db.yml`:本地 MySQL 叠加部署入口。 +- `docker-compose.with-db.yml`:本地数据库叠加部署入口。 - `.env.example`:本地和 Compose 环境变量样例。 - `.vscode/launch.json`:VSCode FastAPI 调试配置。 - `.dockerignore`:Docker 构建排除规则。 @@ -68,16 +68,16 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask ## 命令 - 安装开发依赖:`iticli install` -- 同步框架依赖:`iticli sync flask` +- 同步框架依赖:`iticli update framework` - 检查模板更新:`iticli template check` - 同步模板骨架:`iticli template update` - 运行测试:`iticli test` - 本地启动:`iticli run dev 8000` - 构建 Docker 镜像:`iticli docker build` - 启动 Docker Compose:`iticli docker up` -- 启动 Docker Compose 和 MySQL:`iticli docker up --db` +- 启动 Docker Compose 和数据库:`iticli docker up --db` - 停止 Docker Compose:`iticli docker down` -- 停止 Docker Compose 和 MySQL:`iticli docker down --db` +- 停止 Docker Compose 和数据库:`iticli docker down --db` - 查看应用容器日志:`iticli docker logs` - 创建 migration:`iticli migrate revision "alice add order table"` - 执行 migration:`iticli migrate` @@ -85,4 +85,4 @@ description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask - 查看当前 Alembic 版本:`iticli migrate current` - 初始化项目:`iticli init` -{{ "系统包相关命令:\n\n- 同步系统 migration:`iticli sync system`\n- 初始化系统项目:`iticli init system`\n\n" if include_system else "" -}} \ No newline at end of file +{{ "系统包相关命令:\n\n- 更新系统依赖:`iticli update system`\n- 初始化系统项目:`iticli init system`\n\n" if include_system else "" -}} diff --git a/copier-template/.env.example.jinja b/copier-template/.env.example.jinja index f585825..f6b512d 100644 --- a/copier-template/.env.example.jinja +++ b/copier-template/.env.example.jinja @@ -1,5 +1,6 @@ APP_ENV=prod APP_PORT=8000 +DATABASE_DIALECT={{ database_dialect }} MYSQL_HOST=host.docker.internal MYSQL_PORT=3306 @@ -8,6 +9,12 @@ MYSQL_DATABASE=app MYSQL_USER=app MYSQL_PASSWORD=change-me +POSTGRES_HOST=host.docker.internal +POSTGRES_PORT=5432 +POSTGRES_DB=app +POSTGRES_USER=app +POSTGRES_PASSWORD=change-me + SECRET_KEY=change-me JWT_SECRET_KEY=change-me LOG_FILE_ENABLED=true diff --git a/copier-template/README.md.jinja b/copier-template/README.md.jinja index 07de981..48bf195 100644 --- a/copier-template/README.md.jinja +++ b/copier-template/README.md.jinja @@ -69,8 +69,13 @@ iticli docker logs iticli docker down ``` +{% if database_dialect == "postgresql" %} +`docker-compose.yml` 默认只启动应用,数据库使用外部 PostgreSQL。 +需要本地 PostgreSQL 时使用: +{% else %} `docker-compose.yml` 默认只启动应用,数据库使用外部 MySQL。 需要本地 MySQL 时使用: +{% endif %} ```bash iticli docker up --db @@ -78,14 +83,14 @@ iticli docker down --db ``` 应用容器启动时会先执行 migration{% if include_system %} 和系统 seed{% endif %}。 -本地运行数据写入 `runtime/`,MySQL 数据写入 Compose volume。 +本地运行数据写入 `runtime/`,数据库数据写入 Compose volume。 ## 同步更新 同步框架包: ```bash -iticli sync flask +iticli update framework ``` 检查模板: diff --git a/copier-template/config.py.jinja b/copier-template/config.py.jinja index b6618e2..28bfe76 100644 --- a/copier-template/config.py.jinja +++ b/copier-template/config.py.jinja @@ -1,5 +1,9 @@ from __future__ import annotations +{% if database_dialect == "postgresql" -%} +import os + +{% endif -%} from pathlib import Path from iti.config import ( @@ -22,6 +26,33 @@ def apply_project_config(config) -> None: config.base_dir = BASE_DIR config.file_storage["LOCAL"]["base_path"] = runtime_path("uploads") config.log_dir = runtime_path("logs") +{% if database_dialect == "postgresql" %} + apply_database_config(config) + + +def apply_database_config(config) -> None: + if os.getenv("DATABASE_URL"): + return + database = os.getenv("POSTGRES_DB", default_database_name(config.app_env)) + config.database_url = default_postgresql_url(database) + + +def default_postgresql_url(database: str) -> str: + return ( + f"postgresql+psycopg://{os.getenv('POSTGRES_USER', 'postgres')}:" + f"{os.getenv('POSTGRES_PASSWORD', 'password')}@" + f"{os.getenv('POSTGRES_HOST', '127.0.0.1')}:" + f"{os.getenv('POSTGRES_PORT', '5432')}/{database}" + ) + + +def default_database_name(app_env: str) -> str: + if app_env == "test": + return "{{ project_slug | lower | replace('-', '_') }}_test" + if app_env == "prod": + return "{{ project_slug | lower | replace('-', '_') }}" + return "{{ project_slug | lower | replace('-', '_') }}_dev" +{% endif %} class DevConfig(BaseDevConfig): diff --git a/copier-template/docker-compose.with-db.yml.jinja b/copier-template/docker-compose.with-db.yml.jinja index 9b5ba56..0525df1 100644 --- a/copier-template/docker-compose.with-db.yml.jinja +++ b/copier-template/docker-compose.with-db.yml.jinja @@ -1,13 +1,35 @@ services: app: environment: + DATABASE_DIALECT: {{ database_dialect }} +{% if database_dialect == "postgresql" %} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 +{% else %} MYSQL_HOST: db MYSQL_PORT: 3306 +{% endif %} depends_on: db: condition: service_healthy db: +{% if database_dialect == "postgresql" %} + image: postgres:16 + environment: + POSTGRES_DB: ${POSTGRES_DB:-app} + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 +{% else %} image: mysql:8.4 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root-password} @@ -26,6 +48,11 @@ services: interval: 10s timeout: 5s retries: 10 +{% endif %} volumes: +{% if database_dialect == "postgresql" %} + postgres-data: +{% else %} mysql-data: +{% endif %} diff --git a/copier-template/docker-compose.yml.jinja b/copier-template/docker-compose.yml.jinja index 338092f..c4ed5bb 100644 --- a/copier-template/docker-compose.yml.jinja +++ b/copier-template/docker-compose.yml.jinja @@ -7,11 +7,17 @@ services: APP_ENV: ${APP_ENV:-prod} SECRET_KEY: ${SECRET_KEY:-change-me} JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me} + DATABASE_DIALECT: ${DATABASE_DIALECT:-{{ database_dialect }}} MYSQL_HOST: ${MYSQL_HOST:-host.docker.internal} MYSQL_PORT: ${MYSQL_PORT:-3306} MYSQL_DATABASE: ${MYSQL_DATABASE:-app} MYSQL_USER: ${MYSQL_USER:-app} MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me} + POSTGRES_HOST: ${POSTGRES_HOST:-host.docker.internal} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-app} + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true} extra_hosts: - "host.docker.internal:host-gateway" @@ -21,6 +27,6 @@ services: - ./runtime:/app/runtime command: > sh -c "{% if include_system %}uv run iti-system migrations sync --target migrations/versions && - {% endif %}uv run alembic -c migrations/alembic.ini upgrade head && + {% endif %}uv run python -m alembic -c migrations/alembic.ini upgrade head && {% if include_system %}PYTHONPATH=. uv run iti-system seed system app:create_app && {% endif %}uv run uvicorn main:app --host 0.0.0.0 --port 8000" diff --git a/copier-template/migrations/README.md.jinja b/copier-template/migrations/README.md.jinja index 4a15d14..23e42f3 100644 --- a/copier-template/migrations/README.md.jinja +++ b/copier-template/migrations/README.md.jinja @@ -3,8 +3,8 @@ 本目录是业务项目唯一的 Alembic migration 流。 ```bash -uv run alembic -c migrations/alembic.ini revision --autogenerate -m "alice add example table" -uv run alembic -c migrations/alembic.ini upgrade head +uv run python -m alembic -c migrations/alembic.ini revision --autogenerate -m "alice add example table" +uv run python -m alembic -c migrations/alembic.ini upgrade head ``` `versions/` 下的 migration 文件必须提交到 Git。 diff --git a/copier-template/pyproject.toml.jinja b/copier-template/pyproject.toml.jinja index 94ca8c9..25981f6 100644 --- a/copier-template/pyproject.toml.jinja +++ b/copier-template/pyproject.toml.jinja @@ -11,6 +11,7 @@ requires-python = ">=3.11" dependencies = [ "iti-flask @ git+{{ framework_git }}{% if framework_tag %}@{{ framework_tag }}{% endif %}", {% if include_system %} "iti-system @ git+{{ system_git }}{% if system_tag %}@{{ system_tag }}{% endif %}", +{% endif %}{% if database_dialect == "postgresql" %} "psycopg[binary]>=3.2.0", {% endif -%} ] diff --git a/copier.yml b/copier.yml index 7522cd3..a8257b1 100644 --- a/copier.yml +++ b/copier.yml @@ -33,6 +33,14 @@ include_system: help: 是否引入 iTi-System 系统业务包 default: false +database_dialect: + type: str + help: 默认数据库类型 + default: mysql + choices: + MySQL: mysql + PostgreSQL: postgresql + system_git: type: str help: iTi-System Git 地址 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c8119f..9700f4e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ iTi-Flask 是 FastAPI 框架基座。 ## 分层 - `iti.app`:应用工厂,组装 FastAPI、错误处理、模块、服务客户端、任务 runner、健康检查。 -- `iti.config`:dataclass 配置,默认 dev/test/prod 均使用 MySQL。 +- `iti.config`:dataclass 配置,默认 MySQL,可通过 `DATABASE_DIALECT=postgresql` 使用 PostgreSQL。 - `iti.db`:SQLAlchemy 2 `Base`、session、Alembic metadata。 - `iti.auth`:JWT、Principal、Actor、用户权限依赖、服务 token 依赖。 - `iti.responses`:自动 envelope、`@raw_response`、响应工具。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a4a9fa5..deb7b1d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -21,22 +21,33 @@ app = create_app(config_name="test", config_mapping=config) ## 默认数据库 dev/test/prod 默认都使用 MySQL。 +设置 `DATABASE_DIALECT=postgresql` 时,默认连接串改为 PostgreSQL。 +`DATABASE_URL` 始终优先。 ```text mysql+pymysql://root:password@127.0.0.1:3306/iti_dev?charset=utf8mb4 mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4 mysql+pymysql://root:password@127.0.0.1:3306/iti_prod?charset=utf8mb4 +postgresql+psycopg://postgres:password@127.0.0.1:5432/iti_dev ``` 可用环境变量覆盖: ```bash DATABASE_URL=mysql+pymysql://user:pass@host:3306/app?charset=utf8mb4 +DATABASE_DIALECT=mysql MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=root MYSQL_PASSWORD=password MYSQL_DATABASE=iti_dev + +DATABASE_DIALECT=postgresql +POSTGRES_HOST=127.0.0.1 +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=password +POSTGRES_DB=iti_dev ``` 单元测试可以传入 SQLite 配置。 diff --git a/docs/COPIER_TEMPLATE.md b/docs/COPIER_TEMPLATE.md index 7b91bee..3d93df8 100644 --- a/docs/COPIER_TEMPLATE.md +++ b/docs/COPIER_TEMPLATE.md @@ -24,6 +24,7 @@ copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-pro ```bash iticli create ../my-business-app +iticli create --database postgresql ../my-postgres-app cd ../my-business-app cp .env.example .env iticli init @@ -48,6 +49,7 @@ iticli run dev 8000 | `framework_git` | iTi-Flask Git 地址 | | `framework_tag` | iTi-Flask Git tag | | `include_system` | 是否引入 iTi-System | +| `database_dialect` | 默认数据库类型,`mysql` 或 `postgresql` | | `system_git` | iTi-System Git 地址 | | `system_tag` | iTi-System Git tag | @@ -109,7 +111,7 @@ iticli migrate 带 `iti-system` 的项目还会有: ```bash -iticli sync system +iticli update system iticli init system ``` @@ -118,11 +120,11 @@ iticli init system 业务项目同步框架依赖: ```bash -iticli sync flask +iticli update framework ``` -该命令执行 `uv sync --upgrade-package iti-flask`。 -需要同时升级框架和系统包时使用 `iticli sync all`。 +该命令会更新 `pyproject.toml` 中的框架 tag,并执行 `uv sync --upgrade-package iti-flask`。 +需要同时升级框架和系统包时使用 `iticli update all`。 业务项目检查模板版本: @@ -151,8 +153,8 @@ iticli template update 模板生成: - `Dockerfile`:基于 `python:3.11-slim`,使用 `uv sync --frozen --no-dev` 安装运行依赖。 -- `docker-compose.yml`:启动应用,默认连接外部 MySQL。 -- `docker-compose.with-db.yml`:叠加启动 MySQL 8.4。 +- `docker-compose.yml`:启动应用,默认连接外部数据库。 +- `docker-compose.with-db.yml`:按 `database_dialect` 叠加启动 MySQL 8.4 或 PostgreSQL 16。 - `.env.example`:Compose 和本地运行共用的环境变量样例。 - `.dockerignore`:排除虚拟环境、运行数据和本地密钥。 diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 1b34ad9..d7cbbf7 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -7,23 +7,23 @@ iTi-Flask 使用 Alembic 管理数据库 schema。 - 每个业务项目只有一条 Alembic migration 流。 - `migrations/versions` 必须提交。 - 已发布 migration 不回改。 -- 生产只执行 `alembic upgrade head`。 +- 生产只执行 `python -m alembic upgrade head`。 - `iti-system` 的 migration 通过 CLI 同步进业务项目 migration 流。 ## 命令 ```bash -uv run alembic revision --autogenerate -m "alice add workorder priority" -uv run alembic upgrade head -uv run alembic current -uv run alembic heads +uv run python -m alembic revision --autogenerate -m "alice add workorder priority" +uv run python -m alembic upgrade head +uv run python -m alembic current +uv run python -m alembic heads ``` 多个 head: ```bash -uv run alembic merge heads -m "alice merge heads before release" -uv run alembic upgrade head +uv run python -m alembic merge heads -m "alice merge heads before release" +uv run python -m alembic upgrade head ``` ## 模型发现 @@ -42,7 +42,7 @@ class Example(Base): ```bash uv run iti-system migrations sync --target migrations/versions -uv run alembic upgrade head +uv run python -m alembic upgrade head ``` 业务项目可在同步后继续新增自己的 migration。 diff --git a/docs/TESTING_DEPLOYMENT.md b/docs/TESTING_DEPLOYMENT.md index e07967d..faabaae 100644 --- a/docs/TESTING_DEPLOYMENT.md +++ b/docs/TESTING_DEPLOYMENT.md @@ -1,7 +1,7 @@ # 测试与部署方案 -本轮开发验证不依赖 Docker,不依赖真实 MySQL。 -真实 MySQL 和 Docker Compose 属于集成验证阶段。 +本轮开发验证不依赖 Docker,不依赖真实数据库。 +真实 MySQL/PostgreSQL 和 Docker Compose 属于集成验证阶段。 ## 本地轻量验证 @@ -31,12 +31,29 @@ CREATE DATABASE my_business_app_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unico ```bash export DATABASE_URL='mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4' -uv run alembic upgrade head +uv run python -m alembic upgrade head uv run iti-system migrations sync --target migrations/versions PYTHONPATH=. uv run iti-system seed system app:create_app uv run uvicorn main:app --reload ``` +## PostgreSQL 集成验证 + +准备独立测试库: + +```sql +CREATE DATABASE iti_test; +CREATE DATABASE my_business_app_test; +``` + +执行: + +```bash +export DATABASE_URL='postgresql+psycopg://postgres:password@127.0.0.1:5432/iti_test' +uv run python -m alembic upgrade head +uv run uvicorn main:app --reload +``` + 验证: ```bash @@ -57,7 +74,7 @@ iticli docker logs iticli docker down ``` -如需本地 MySQL: +如需本地数据库: ```bash iticli docker up --db diff --git a/iti/config.py b/iti/config.py index 8111fec..adfb06b 100644 --- a/iti/config.py +++ b/iti/config.py @@ -9,6 +9,12 @@ from dotenv import load_dotenv BASE_DIR = Path(os.getenv("ITI_BASE_DIR", Path.cwd())).resolve() +SUPPORTED_DATABASE_DIALECTS = {"mysql", "postgresql"} +_DATABASE_DIALECT_ALIASES = { + "mysql": "mysql", + "postgres": "postgresql", + "postgresql": "postgresql", +} def get_env_name(default: str = "dev") -> str: @@ -33,6 +39,20 @@ def env_bool(key: str, default: bool = False) -> bool: return value.lower() in {"1", "true", "yes", "on"} +def normalize_database_dialect(dialect: str) -> str: + normalized = _DATABASE_DIALECT_ALIASES.get(dialect.strip().lower()) + if normalized is None: + supported = ", ".join(sorted(SUPPORTED_DATABASE_DIALECTS)) + raise ValueError(f"unsupported database dialect: {dialect!r}, supported: {supported}") + return normalized + + +def get_database_dialect(default: str = "mysql") -> str: + return normalize_database_dialect( + os.getenv("DATABASE_DIALECT") or os.getenv("DB_DIALECT") or default + ) + + def default_mysql_url(database: str) -> str: return ( f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:" @@ -42,6 +62,22 @@ def default_mysql_url(database: str) -> str: ) +def default_postgresql_url(database: str) -> str: + return ( + f"postgresql+psycopg://{os.getenv('POSTGRES_USER', 'postgres')}:" + f"{os.getenv('POSTGRES_PASSWORD', 'password')}@" + f"{os.getenv('POSTGRES_HOST', '127.0.0.1')}:" + f"{os.getenv('POSTGRES_PORT', '5432')}/{database}" + ) + + +def default_database_url(database: str, dialect: str | None = None) -> str: + dialect = normalize_database_dialect(dialect) if dialect else get_database_dialect() + if dialect == "postgresql": + return default_postgresql_url(os.getenv("POSTGRES_DB", database)) + return default_mysql_url(os.getenv("MYSQL_DATABASE", database)) + + load_env_file() @@ -65,7 +101,7 @@ class BaseConfig: database_url: str = field( default_factory=lambda: os.getenv( - "DATABASE_URL", default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_dev")) + "DATABASE_URL", default_database_url("iti_dev") ) ) sqlalchemy_echo: bool = False @@ -131,7 +167,7 @@ class DevConfig(BaseConfig): debug=True, database_url=os.getenv( "DATABASE_URL", - default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_dev")), + default_database_url("iti_dev"), ), sqlalchemy_echo=env_bool("SQLALCHEMY_ECHO", False), jwt_access_token_expires_seconds=24 * 3600, @@ -147,7 +183,7 @@ class TestConfig(BaseConfig): testing=True, database_url=os.getenv( "DATABASE_URL", - default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_test")), + default_database_url("iti_test"), ), ratelimit_enabled=False, log_file_enabled=False, @@ -162,7 +198,7 @@ class ProdConfig(BaseConfig): debug=False, database_url=os.getenv( "DATABASE_URL", - default_mysql_url(os.getenv("MYSQL_DATABASE", "iti_prod")), + default_database_url("iti_prod"), ), secret_key=os.getenv("SECRET_KEY", ""), jwt_secret_key=os.getenv("JWT_SECRET_KEY", ""), diff --git a/pyproject.toml b/pyproject.toml index b0b5a47..1ab9c61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ storage-minio = ["minio>=7.2.18"] erp = ["pyodbc>=5.3.0"] excel = ["pandas>=2.3.3", "openpyxl>=3.1.5"] image = ["Pillow>=12.0.0"] +postgres = ["psycopg[binary]>=3.2.0"] prod = ["gunicorn>=22.0.0"] dev = [ "mypy>=1.0.0", diff --git a/tests/test_config.py b/tests/test_config.py index be2aadb..d3cfb1a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,13 @@ -from iti.config import BaseConfig, TestConfig as FrameworkTestConfig, default_mysql_url +import pytest + +from iti.config import ( + BaseConfig, + TestConfig as FrameworkTestConfig, + default_database_url, + default_mysql_url, + default_postgresql_url, + get_database_dialect, +) def test_default_mysql_url_uses_mysql_driver(monkeypatch): @@ -12,8 +21,20 @@ def test_default_mysql_url_uses_mysql_driver(monkeypatch): ) +def test_default_postgresql_url_uses_psycopg_driver(monkeypatch): + monkeypatch.setenv("POSTGRES_USER", "u") + monkeypatch.setenv("POSTGRES_PASSWORD", "p") + monkeypatch.setenv("POSTGRES_HOST", "db") + monkeypatch.setenv("POSTGRES_PORT", "5433") + + assert default_postgresql_url("iti_test") == ( + "postgresql+psycopg://u:p@db:5433/iti_test" + ) + + def test_test_config_default_database_is_mysql(monkeypatch): monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("DATABASE_DIALECT", raising=False) monkeypatch.setenv("MYSQL_DATABASE", "iti_test") config = FrameworkTestConfig() @@ -23,6 +44,31 @@ def test_test_config_default_database_is_mysql(monkeypatch): assert config.ratelimit_enabled is False +def test_test_config_can_default_to_postgresql(monkeypatch): + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setenv("DATABASE_DIALECT", "postgresql") + monkeypatch.setenv("POSTGRES_DB", "iti_test") + + config = FrameworkTestConfig() + + assert config.database_url.startswith("postgresql+psycopg://") + assert config.database_url.endswith("/iti_test") + assert config.testing is True + + +def test_default_database_url_accepts_postgres_alias(monkeypatch): + monkeypatch.setenv("POSTGRES_DB", "custom_db") + + assert default_database_url("fallback", "postgres").endswith("/custom_db") + + +def test_get_database_dialect_rejects_unknown(monkeypatch): + monkeypatch.setenv("DATABASE_DIALECT", "oracle") + + with pytest.raises(ValueError): + get_database_dialect() + + def test_base_config_can_be_overridden_for_unit_tests(): config = BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True)