From 8708900fc022a742b1710315c3dc04b4714a227d Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Tue, 31 Mar 2026 18:22:14 +0200 Subject: [PATCH] fix(database): env var for db_name is NAME not DB_NAME Rename field `db_name` to `name` in `DatabaseSettings` so pydantic-settings constructs the env var as `{PREFIX}NAME` (e.g. `MYAPP_DB_NAME`) instead of the confusing double-prefix `{PREFIX}DB_NAME`. Co-Authored-By: Claude Sonnet 4.6 --- ATTRIBUTIONS.md | 2 +- src/aignostics_foundry_core/AGENTS.md | 48 +++++++++---------- src/aignostics_foundry_core/database.py | 10 ++-- .../database_settings_test.py | 24 ++++++++-- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 04c1638..6481f65 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -360,7 +360,7 @@ SOFTWARE. ``` -## aignostics-foundry-core (0.5.0) - MIT License +## aignostics-foundry-core (0.6.1) - MIT License 🏭 Foundational infrastructure for Foundry components. diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 193bb63..9a419e5 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -6,27 +6,27 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei -| Module | Purpose | Description | -|--------|---------|-------------| -| **models** | Shared output format enum | `OutputFormat` StrEnum with `YAML` and `JSON` values for use in CLI and API responses | -| **process** | Current process introspection | `ProcessInfo`, `ParentProcessInfo` Pydantic models and `get_process_info()` for runtime process metadata; `SUBPROCESS_CREATION_FLAGS` for subprocess creation | -| **api.exceptions** | API exception hierarchy and FastAPI handlers | `ApiException` (500), `NotFoundException` (404), `AccessDeniedException` (401); `api_exception_handler`, `unhandled_exception_handler`, `validation_exception_handler` for FastAPI registration | -| **api.auth** | Auth0 authentication FastAPI dependencies | `AuthSettings` (env-prefix configurable), `UnauthenticatedError`, `ForbiddenError` (403); `get_auth_client`, `get_user`, `require_authenticated`, `require_admin`, `require_internal`, `require_internal_admin` FastAPI dependencies; Auth0 cookie security schemes | -| **api.core** | Versioned API router and FastAPI factory | `VersionedAPIRouter` (tracks all created instances), `API_TAG_*` constants, `create_public/authenticated/admin/internal/internal_admin_router` factories, `build_api_metadata`, `build_versioned_api_tags`, `build_root_api_tags`, `get_versioned_api_instances(versions, build_metadata=None, *, context=None)`, `init_api()` | -| **api** | Consolidated API sub-package | Re-exports all public symbols from `api.exceptions`, `api.auth`, and `api.core`; import any API symbol directly from `aignostics_foundry_core.api` | -| **log** | Configurable loguru logging initialisation | `logging_initialize(filter_func=None, *, context=None)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | -| **sentry** | Configurable Sentry integration | `sentry_initialize(integrations, *, context=None)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | -| **service** | FastAPI-injectable base service | `BaseService` ABC with `get_service()` (cached per-class FastAPI `Depends` factory), `key()`, and abstract `health()` / `info()` methods; concrete subclasses implement health checks and module info | -| **database** | Async SQLAlchemy session management + DB settings | `DatabaseSettings` (`OpaqueSettings` subclass; env prefix defaults to `{ctx.env_prefix}DB_`; `get_url()` with optional `db_name` substitution); `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — all params optional, fall back to active context when `None`; `dispose_engine()`, `get_db_session()` (FastAPI dependency), `execute_with_session(func, …)`, `cli_run_with_db(func, …, db_url=None)`, `cli_run_with_engine(func, …, db_url=None)`, `with_engine` dual-mode decorator (supports `@with_engine`, `@with_engine()`, `@with_engine(db_url=…)`); auto-resets engine after `fork()` | -| **cli** | Typer CLI preparation utilities | `prepare_cli(cli, epilog, *, context=None)` — discovers and registers subcommands via `locate_implementations`, sets epilog recursively, installs `no_args_is_help` workaround; `no_args_is_help_workaround(ctx)` — raises `typer.Exit` when no subcommand is invoked | -| **boot** | Application / library boot sequence | `boot(context, sentry_integrations, log_filter, show_cmdline)` — runs once per process: parses `--env` CLI args, initialises logging and Sentry, amends the SSL trust chain via *truststore* and *certifi*, and logs boot/shutdown messages | -| **user_agent** | Parameterised HTTP user-agent string builder | `user_agent(project_name, version, repository_url)` — builds `{project_name}-python-sdk/{version} (…)` string including platform info, current test, and GitHub Actions run URL | -| **gui** | NiceGUI page helpers, auth decorators, and nav builder | `GUINamespace` (configurable page decorator namespace), `gui` (default singleton), `page_public/authenticated/admin/internal/internal_admin` decorators, `get_gui_user`, `require_gui_user`, `BaseNavBuilder`, `NavItem`, `NavGroup`, `gui_get_nav_groups(*, context=None)`, `BasePageBuilder`, `gui_register_pages(*, context=None)`, `gui_run(*, context=None, …)`; constants `WINDOW_SIZE`, `BROWSER_RECONNECT_TIMEOUT`, `RESPONSE_TIMEOUT` | -| **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | -| **foundry** | Project context injection | `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, `version_full`, `version_with_vcs_ref`, environment, env files, URLs, `python_version`, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`, `database: DatabaseSettings \| None`) derived from package metadata and environment variables; `from_package()` populates `database` from `{env_prefix}DB_*` env vars when `{env_prefix}DB_URL` is present | -| **di** | Dependency injection | `locate_subclasses(cls, *, context=None)`, `locate_implementations(cls, *, context=None)`, `load_modules(*, context=None)`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery | -| **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | -| **settings** | Pydantic settings loading | `OpaqueSettings`, `load_settings`, `strip_to_none_before_validator`, `UNHIDE_SENSITIVE_INFO` for env-based settings with secret masking and user-friendly validation errors; `console`, `Panel`, and `Text` are imported lazily inside `load_settings` (error path only) | +| Module | Purpose | Description | +|--------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **models** | Shared output format enum | `OutputFormat` StrEnum with `YAML` and `JSON` values for use in CLI and API responses | +| **process** | Current process introspection | `ProcessInfo`, `ParentProcessInfo` Pydantic models and `get_process_info()` for runtime process metadata; `SUBPROCESS_CREATION_FLAGS` for subprocess creation | +| **api.exceptions** | API exception hierarchy and FastAPI handlers | `ApiException` (500), `NotFoundException` (404), `AccessDeniedException` (401); `api_exception_handler`, `unhandled_exception_handler`, `validation_exception_handler` for FastAPI registration | +| **api.auth** | Auth0 authentication FastAPI dependencies | `AuthSettings` (env-prefix configurable), `UnauthenticatedError`, `ForbiddenError` (403); `get_auth_client`, `get_user`, `require_authenticated`, `require_admin`, `require_internal`, `require_internal_admin` FastAPI dependencies; Auth0 cookie security schemes | +| **api.core** | Versioned API router and FastAPI factory | `VersionedAPIRouter` (tracks all created instances), `API_TAG_*` constants, `create_public/authenticated/admin/internal/internal_admin_router` factories, `build_api_metadata`, `build_versioned_api_tags`, `build_root_api_tags`, `get_versioned_api_instances(versions, build_metadata=None, *, context=None)`, `init_api()` | +| **api** | Consolidated API sub-package | Re-exports all public symbols from `api.exceptions`, `api.auth`, and `api.core`; import any API symbol directly from `aignostics_foundry_core.api` | +| **log** | Configurable loguru logging initialisation | `logging_initialize(filter_func=None, *, context=None)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | +| **sentry** | Configurable Sentry integration | `sentry_initialize(integrations, *, context=None)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | +| **service** | FastAPI-injectable base service | `BaseService` ABC with `get_service()` (cached per-class FastAPI `Depends` factory), `key()`, and abstract `health()` / `info()` methods; concrete subclasses implement health checks and module info | +| **database** | Async SQLAlchemy session management + DB settings | `DatabaseSettings` (`OpaqueSettings` subclass; env prefix defaults to `{ctx.env_prefix}DB_`; `get_url()` with optional database `name` substitution); `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — all params optional, fall back to active context when `None`; `dispose_engine()`, `get_db_session()` (FastAPI dependency), `execute_with_session(func, …)`, `cli_run_with_db(func, …, db_url=None)`, `cli_run_with_engine(func, …, db_url=None)`, `with_engine` dual-mode decorator (supports `@with_engine`, `@with_engine()`, `@with_engine(db_url=…)`); auto-resets engine after `fork()` | +| **cli** | Typer CLI preparation utilities | `prepare_cli(cli, epilog, *, context=None)` — discovers and registers subcommands via `locate_implementations`, sets epilog recursively, installs `no_args_is_help` workaround; `no_args_is_help_workaround(ctx)` — raises `typer.Exit` when no subcommand is invoked | +| **boot** | Application / library boot sequence | `boot(context, sentry_integrations, log_filter, show_cmdline)` — runs once per process: parses `--env` CLI args, initialises logging and Sentry, amends the SSL trust chain via *truststore* and *certifi*, and logs boot/shutdown messages | +| **user_agent** | Parameterised HTTP user-agent string builder | `user_agent(project_name, version, repository_url)` — builds `{project_name}-python-sdk/{version} (…)` string including platform info, current test, and GitHub Actions run URL | +| **gui** | NiceGUI page helpers, auth decorators, and nav builder | `GUINamespace` (configurable page decorator namespace), `gui` (default singleton), `page_public/authenticated/admin/internal/internal_admin` decorators, `get_gui_user`, `require_gui_user`, `BaseNavBuilder`, `NavItem`, `NavGroup`, `gui_get_nav_groups(*, context=None)`, `BasePageBuilder`, `gui_register_pages(*, context=None)`, `gui_run(*, context=None, …)`; constants `WINDOW_SIZE`, `BROWSER_RECONNECT_TIMEOUT`, `RESPONSE_TIMEOUT` | +| **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | +| **foundry** | Project context injection | `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, `version_full`, `version_with_vcs_ref`, environment, env files, URLs, `python_version`, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`, `database: DatabaseSettings \| None`) derived from package metadata and environment variables; `from_package()` populates `database` from `{env_prefix}DB_*` env vars when `{env_prefix}DB_URL` is present | +| **di** | Dependency injection | `locate_subclasses(cls, *, context=None)`, `locate_implementations(cls, *, context=None)`, `load_modules(*, context=None)`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery | +| **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | +| **settings** | Pydantic settings loading | `OpaqueSettings`, `load_settings`, `strip_to_none_before_validator`, `UNHIDE_SENSITIVE_INFO` for env-based settings with secret masking and user-friendly validation errors; `console`, `Panel`, and `Text` are imported lazily inside `load_settings` (error path only) | ## Module Descriptions @@ -220,9 +220,9 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Provides a self-contained `OpaqueSettings` subclass that reads database connection parameters from env vars. The env prefix defaults to `{FoundryContext.env_prefix}DB_` when not supplied, enabling zero-boilerplate DB configuration once a `FoundryContext` is installed. - **Key Features**: - - `DatabaseSettings(OpaqueSettings)` — fields: `url: SecretStr` (required), `pool_size: int = 10`, `pool_max_overflow: int = 10`, `pool_timeout: float = 30.0`, `db_name: str | None = None` + - `DatabaseSettings(OpaqueSettings)` — fields: `url: SecretStr` (required), `pool_size: int = 10`, `pool_max_overflow: int = 10`, `pool_timeout: float = 30.0`, `name: str | None = None` - `__init__(_env_prefix=None, **kwargs)` — when `_env_prefix` is `None`, lazy-imports `get_context` and uses `f"{ctx.env_prefix}DB_"` as the prefix (avoids a circular import at module load time) - - `get_url() -> str` — returns the raw URL from the secret; if `db_name` is set, replaces the path component in the URL (e.g. `…/postgres` → `…/mydb`) while preserving scheme, host, port, query, and fragment + - `get_url() -> str` — returns the raw URL from the secret; if `name` is set, replaces the path component in the URL (e.g. `…/postgres` → `…/mydb`) while preserving scheme, host, port, query, and fragment - `model_config = SettingsConfigDict(extra="ignore")` — extra env vars are silently ignored - **Location**: `aignostics_foundry_core/database.py` (top of file, before engine globals) - **Dependencies**: `pydantic>=2`, `pydantic-settings>=2`, Python stdlib (`urllib.parse`) @@ -240,7 +240,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei url = settings.get_url() # "sqlite+aiosqlite:///test.db" # Override database name at runtime: - settings = DatabaseSettings(_env_prefix="MYAPP_DB_", url="postgresql+asyncpg://host/old", db_name="new") + settings = DatabaseSettings(_env_prefix="MYAPP_DB_", url="postgresql+asyncpg://host/old", name="new") url = settings.get_url() # "postgresql+asyncpg://host/new" ``` diff --git a/src/aignostics_foundry_core/database.py b/src/aignostics_foundry_core/database.py index c23f6c0..9da40df 100644 --- a/src/aignostics_foundry_core/database.py +++ b/src/aignostics_foundry_core/database.py @@ -48,7 +48,7 @@ class DatabaseSettings(OpaqueSettings): pool_size: int = 10 pool_max_overflow: int = 10 pool_timeout: float = 30.0 - db_name: str | None = None + name: str | None = None def __init__(self, _env_prefix: str | None = None, **kwargs: Any) -> None: # noqa: ANN401 """Initialise settings, deriving env prefix from the active FoundryContext when not given. @@ -68,17 +68,17 @@ def __init__(self, _env_prefix: str | None = None, **kwargs: Any) -> None: # no def get_url(self) -> str: """Return the database URL string, optionally substituting the database name. - When :attr:`db_name` is set, the path component of the URL is replaced with - ``/{db_name}``, leaving the scheme, host, port, query, and fragment unchanged. + When :attr:`name` is set, the path component of the URL is replaced with + ``/{name}``, leaving the scheme, host, port, query, and fragment unchanged. Returns: The database URL as a plain string. """ raw = self.url.get_secret_value() - if self.db_name is None: + if self.name is None: return raw parsed = urllib.parse.urlparse(raw) - return urllib.parse.urlunparse(parsed._replace(path=f"/{self.db_name}")) + return urllib.parse.urlunparse(parsed._replace(path=f"/{self.name}")) # Global engine and session maker - initialized once per process and kept open diff --git a/tests/aignostics_foundry_core/database_settings_test.py b/tests/aignostics_foundry_core/database_settings_test.py index ed58c99..20f8a52 100644 --- a/tests/aignostics_foundry_core/database_settings_test.py +++ b/tests/aignostics_foundry_core/database_settings_test.py @@ -19,6 +19,8 @@ OVERRIDE_POOL_SIZE = 5 OVERRIDE_POOL_MAX_OVERFLOW = 20 OVERRIDE_POOL_TIMEOUT = 60 +TEST_DB_PREFIX = "TEST_DB_" +TEST_DB_NAME_ENV = "TEST_DB_NAME" @pytest.fixture(autouse=True) @@ -36,15 +38,15 @@ def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUn @pytest.mark.unit def test_get_url_returns_plain_url_when_db_name_not_set() -> None: - """get_url() returns the raw secret value unchanged when db_name is None.""" + """get_url() returns the raw secret value unchanged when database name is None.""" settings = DatabaseSettings(_env_prefix="TEST_DB_", url=POSTGRES_URL) assert settings.get_url() == POSTGRES_URL @pytest.mark.unit def test_get_url_replaces_db_name_in_path() -> None: - """get_url() substitutes the path component when db_name is set.""" - settings = DatabaseSettings(_env_prefix="TEST_DB_", url=POSTGRES_URL, db_name="mydb") + """get_url() substitutes the path component when name is set.""" + settings = DatabaseSettings(_env_prefix="TEST_DB_", url=POSTGRES_URL, name="mydb") result = settings.get_url() assert result.endswith("/mydb") assert "postgres" not in result.split("/")[-1] @@ -52,8 +54,8 @@ def test_get_url_replaces_db_name_in_path() -> None: @pytest.mark.unit def test_get_url_preserves_scheme_and_host() -> None: - """Scheme, host, and port are intact after db_name substitution.""" - settings = DatabaseSettings(_env_prefix="TEST_DB_", url=POSTGRES_URL, db_name="mydb") + """Scheme, host, and port are intact after name substitution.""" + settings = DatabaseSettings(_env_prefix="TEST_DB_", url=POSTGRES_URL, name="mydb") result = settings.get_url() assert result.startswith("postgresql+asyncpg://") assert "localhost:5432" in result @@ -120,6 +122,18 @@ def test_pool_overrides_from_env(monkeypatch: pytest.MonkeyPatch) -> None: # --------------------------------------------------------------------------- +@pytest.mark.unit +def test_db_name_reads_from_name_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + """Setting {PREFIX}NAME populates name and get_url() substitutes the database name.""" + monkeypatch.setenv("TEST_DB_URL", POSTGRES_URL) + monkeypatch.setenv(TEST_DB_NAME_ENV, "mydb") + + settings = DatabaseSettings(_env_prefix=TEST_DB_PREFIX) + + assert settings.name == "mydb" + assert settings.get_url().endswith("/mydb") + + @pytest.mark.unit def test_url_is_masked_in_repr() -> None: """repr(settings) / str(settings) does not expose the raw URL."""