diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 530e4f6..04c1638 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -360,7 +360,7 @@ SOFTWARE. ``` -## aignostics-foundry-core (0.4.0) - MIT License +## aignostics-foundry-core (0.5.0) - MIT License 🏭 Foundational infrastructure for Foundry components. diff --git a/README.md b/README.md index 5d3d494..6da1d96 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,51 @@ All public library functions (`logging_initialize`, `sentry_initialize`, `boot`, `load_modules`, etc.) accept an optional `context` keyword argument and fall back to `get_context()` when it is `None`. +### Database + +Once a context is configured via `set_context()`, all database functions work +with no arguments — the URL and pool settings are read from the context: + +```python +from aignostics_foundry_core.database import init_engine, cli_run_with_db, with_engine + +# Zero-arg engine init — reads MYPROJECT_DB_URL, _DB_POOL_SIZE, etc. from env +init_engine() + +# CLI helper — initialises engine, runs coroutine, disposes engine +cli_run_with_db(my_async_func) + + +# Background job decorator — engine initialised before each invocation +@with_engine +async def my_job(): ... + + +# Override for a secondary database +@with_engine(db_url="postgresql+asyncpg://user:pass@host/secondary") +async def my_other_job(): ... +``` + +`FoundryContext.from_package()` activates database configuration automatically +when the following environment variables are present: + +| Variable | Required | Description | +|---|---|---| +| `{PREFIX}DB_URL` | yes (to activate) | Full database connection URL | +| `{PREFIX}DB_POOL_SIZE` | no | Connection pool size (default `10`) | +| `{PREFIX}DB_MAX_OVERFLOW` | no | Max pool overflow (default `10`) | +| `{PREFIX}DB_POOL_TIMEOUT` | no | Pool wait timeout in seconds (default `30.0`) | +| `{PREFIX}DB_NAME` | no | Override database name in the URL path | + +In tests, construct `DatabaseSettings` directly instead of setting env vars: + +```python +from aignostics_foundry_core.database import DatabaseSettings +from tests.conftest import make_context + +ctx = make_context(database=DatabaseSettings(_env_prefix="TEST_DB_", url="sqlite+aiosqlite:///test.db")) +``` + ### Health API ```python diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index e554aec..6a0cc01 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -17,16 +17,16 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **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 | `init_engine(db_url, pool_size, max_overflow, pool_timeout)`, `dispose_engine()`, `get_db_session()` (FastAPI dependency), `execute_with_session(func, …)`, `cli_run_with_db(func, …, db_url)`, `cli_run_with_engine(func, …, db_url)`, `with_engine(db_url)` decorator factory; auto-resets engine after `fork()` | +| **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, 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`) derived from package metadata and environment variables | +| **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 | +| **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 @@ -44,13 +44,18 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `version_with_vcs_ref`, `environment`, `env_file: list[Path]`, `repository_url`, `documentation_url`, `python_version` (Python runtime version string, e.g. `"3.11.9"`), plus four runtime mode bool flags: `is_container`, `is_cli`, - `is_test`, `is_library` (all default `False`). + `is_test`, `is_library` (all default `False`), and `database: DatabaseSettings | None` + (populated by `from_package()` when `{env_prefix}DB_URL` is set; `None` otherwise). - `FoundryContext.from_package(package_name)` — classmethod that derives all values from `importlib.metadata` and environment variables (`{NAME}_ENVIRONMENT`, `VCS_REF`, `COMMIT_SHA`, `BUILDER`, `BUILD_DATE`, `CI_RUN_ID`, `CI_RUN_NUMBER`, `{NAME}_ENV_FILE`, `{NAME}_RUNNING_IN_CONTAINER`, `PYTEST_RUNNING_{NAME}`). Environment fallback chain: `{NAME}_ENVIRONMENT` → `ENV` → `VERCEL_ENV` → `RAILWAY_ENVIRONMENT` → `"local"`. + Also checks `{NAME}_DB_URL`: when present, constructs `DatabaseSettings(_env_prefix="{NAME}_DB_")` + and stores it in `ctx.database`; otherwise `ctx.database` is `None`. - `set_context(ctx)` — installs *ctx* as the process-level singleton. + - `set_context(ctx)` also prepends `/third_party/` to `sys.path` when that + directory exists next to the package's `__init__.py` (idempotent; silent no-op otherwise). - `get_context()` — returns the installed context or raises `RuntimeError` with a helpful message if `set_context()` has not been called. - **Location**: `aignostics_foundry_core/foundry.py` @@ -209,6 +214,36 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Location**: `aignostics_foundry_core/console.py` - **Dependencies**: `rich>=13` +### DatabaseSettings + +**Database connection settings resolved from environment variables** + +- **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`, `max_overflow: int = 10`, `pool_timeout: float = 30.0`, `db_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 + - `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`) +- **Import**: + ```python + from aignostics_foundry_core.database import DatabaseSettings + ``` +- **Usage example**: + ```python + # Resolved from MYAPP_DB_URL etc. after set_context() is called: + settings = DatabaseSettings() + + # Explicit prefix — useful in from_package() or tests: + settings = DatabaseSettings(_env_prefix="MYAPP_DB_", url="sqlite+aiosqlite:///test.db") + 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") + url = settings.get_url() # "postgresql+asyncpg://host/new" + ``` + ### settings **Pydantic settings loading with secret masking and user-friendly validation errors** @@ -218,7 +253,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - `UNHIDE_SENSITIVE_INFO: str` — context key constant to reveal secrets in `model_dump()` - `strip_to_none_before_validator(v)` — before-validator that strips whitespace and converts empty strings to `None` - `OpaqueSettings(BaseSettings)` — base class with `serialize_sensitive_info` (masks `SecretStr` fields) and `serialize_path_resolve` (resolves `Path` fields to absolute strings) - - `load_settings(settings_class)` — instantiates settings; on `ValidationError` prints a Rich `Panel` listing each invalid field and calls `sys.exit(78)` + - `load_settings(settings_class)` — instantiates settings; on `ValidationError` prints a Rich `Panel` listing each invalid field and calls `sys.exit(78)`. `rich.panel.Panel`, `rich.text.Text`, and `aignostics_foundry_core.console.console` are imported **lazily inside the `except` block** (error path only) to avoid a circular import chain at module load time. - **Location**: `aignostics_foundry_core/settings.py` - **Dependencies**: `pydantic>=2`, `pydantic-settings>=2`, `rich>=14` @@ -286,15 +321,15 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei **Async SQLAlchemy session management** -- **Purpose**: Manages a process-level async database engine singleton, providing session injection for FastAPI routes, background jobs, and CLI commands. All Bridge-specific settings are replaced with explicit parameters. +- **Purpose**: Manages a process-level async database engine singleton, providing session injection for FastAPI routes, background jobs, and CLI commands. All public functions accept optional DB-config params and fall back to the active `FoundryContext.database` when they are `None`. - **Key Features**: - - `init_engine(db_url, pool_size=10, max_overflow=10, pool_timeout=30)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`). + - `init_engine(db_url=None, pool_size=None, max_overflow=None, pool_timeout=None)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. When `db_url` is `None`, the URL and pool settings are resolved from `get_context().database`; raises `RuntimeError` if no context is installed or `ctx.database` is `None`. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`). - `dispose_engine()` — async; disposes the engine; called during application shutdown. - `get_db_session()` — async generator; yields an `AsyncSession`; raises `RuntimeError` if engine not initialised. Use as a FastAPI `Depends` target. - `execute_with_session(async_func, *args, **kwargs)` — async; runs `async_func` with a session injected as the `session` keyword argument. For background jobs and CLI helpers. - - `cli_run_with_db(async_func, *args, db_url, pool_size, max_overflow, pool_timeout, **kwargs)` — synchronous wrapper: initialises engine, runs the coroutine, then disposes. For CLI commands. - - `cli_run_with_engine(async_func, *args, db_url, pool_size, max_overflow, pool_timeout, **kwargs)` — like `cli_run_with_db` but does not inject a session; for jobs that manage sessions themselves. - - `with_engine(db_url, pool_size, max_overflow, pool_timeout)` — decorator factory; wraps an async function to initialise the engine before execution. For long-lived workers; does **not** dispose after running. + - `cli_run_with_db(async_func, *args, db_url=None, pool_size=None, max_overflow=None, pool_timeout=None, **kwargs)` — synchronous wrapper: initialises engine, runs the coroutine, then disposes. All DB-config params optional; fall back to context when `None`. For CLI commands. + - `cli_run_with_engine(async_func, *args, db_url=None, pool_size=None, max_overflow=None, pool_timeout=None, **kwargs)` — like `cli_run_with_db` but does not inject a session; for jobs that manage sessions themselves. + - `with_engine` — dual-mode decorator; supports `@with_engine` (no-parens), `@with_engine()` (empty parens), and `@with_engine(db_url=…, …)` (explicit params). All params optional; fall back to context when absent. For long-lived workers; does **not** dispose after running. - Fork safety: `multiprocessing.util.register_after_fork` resets the engine in child processes automatically. - **Location**: `aignostics_foundry_core/database.py` - **Dependencies**: `sqlalchemy[asyncio]>=2,<3`, `asyncpg>=0.29,<1` (mandatory); `loguru` for structured logging diff --git a/src/aignostics_foundry_core/api/auth.py b/src/aignostics_foundry_core/api/auth.py index 8cf7142..8446148 100644 --- a/src/aignostics_foundry_core/api/auth.py +++ b/src/aignostics_foundry_core/api/auth.py @@ -28,6 +28,7 @@ AUTH0_COOKIE_SCHEME_DESCRIPTION = "Auth0 session cookie authentication scheme." AUTH0_ROLE_ADMIN = "admin" USER_NOT_AUTHENTICATED = "User is not authenticated" +# TODO(oliverm): remove the default; it should not reference Bridge DEFAULT_AUTH0_ROLE_CLAIM = "https://aignostics-platform-bridge/role" @@ -40,8 +41,8 @@ class AuthSettings(OpaqueSettings): model_config = SettingsConfigDict(extra="ignore") - internal_org_id: str | None = None - auth0_role_claim: str = DEFAULT_AUTH0_ROLE_CLAIM + internal_org_id: str | None = None # TODO(oliverm): make mandatory + auth0_role_claim: str = DEFAULT_AUTH0_ROLE_CLAIM # TODO(oliverm): make mandatory and remove default def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 """Initialise settings, deriving env_prefix from the active FoundryContext.""" diff --git a/src/aignostics_foundry_core/database.py b/src/aignostics_foundry_core/database.py index 750a03f..e910f54 100644 --- a/src/aignostics_foundry_core/database.py +++ b/src/aignostics_foundry_core/database.py @@ -13,12 +13,74 @@ import functools import multiprocessing.util -from collections.abc import AsyncGenerator, Callable +import urllib.parse +from collections.abc import AsyncGenerator from typing import Any from loguru import logger +from pydantic import SecretStr +from pydantic_settings import SettingsConfigDict from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from aignostics_foundry_core.settings import OpaqueSettings + + +class DatabaseSettings(OpaqueSettings): + """Database connection settings whose env prefix is derived from the active FoundryContext. + + The effective prefix defaults to ``{FoundryContext.env_prefix}DB_``, resolved at + instantiation time via :func:`aignostics_foundry_core.foundry.get_context`. Pass + ``_env_prefix`` explicitly to bypass the context lookup (required inside + :meth:`FoundryContext.from_package` to avoid circular imports). + + Environment variables (with default prefix ``{NAME}_DB_``): + + * ``{PREFIX}URL`` — required; the full database connection URL + * ``{PREFIX}POOL_SIZE`` — optional; connection pool size (default ``10``) + * ``{PREFIX}MAX_OVERFLOW`` — optional; maximum pool overflow (default ``10``) + * ``{PREFIX}POOL_TIMEOUT`` — optional; pool checkout timeout in seconds (default ``30.0``) + * ``{PREFIX}NAME`` — optional; override the database name in the URL path component + """ + + model_config = SettingsConfigDict(extra="ignore") + + url: SecretStr + pool_size: int = 10 + max_overflow: int = 10 + pool_timeout: float = 30.0 + db_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. + + Args: + _env_prefix: Optional explicit environment variable prefix (e.g. ``"MYAPP_DB_"``). + When ``None``, the prefix is derived from the active FoundryContext as + ``f"{get_context().env_prefix}DB_"``. + **kwargs: Forwarded to :class:`~pydantic_settings.BaseSettings`. + """ + if _env_prefix is None: + from aignostics_foundry_core.foundry import get_context # noqa: PLC0415 + + _env_prefix = f"{get_context().env_prefix}DB_" + super().__init__(_env_prefix=_env_prefix, **kwargs) # pyright: ignore[reportCallIssue] + + 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. + + Returns: + The database URL as a plain string. + """ + raw = self.url.get_secret_value() + if self.db_name is None: + return raw + parsed = urllib.parse.urlparse(raw) + return urllib.parse.urlunparse(parsed._replace(path=f"/{self.db_name}")) + + # Global engine and session maker - initialized once per process and kept open _engine: AsyncEngine | None = None _async_session_maker: async_sessionmaker[AsyncSession] | None = None @@ -58,11 +120,56 @@ class _DatabaseModuleSentinel: multiprocessing.util.register_after_fork(_module_sentinel, lambda _obj: _reset_engine_after_fork()) +_DEFAULT_POOL_SIZE = 10 +_DEFAULT_MAX_OVERFLOW = 10 +_DEFAULT_POOL_TIMEOUT = 30.0 + + +def _resolve_db_params( + db_url: str | None, + pool_size: int | None, + max_overflow: int | None, + pool_timeout: float | None, +) -> tuple[str, int, int, float]: + """Resolve database connection parameters, falling back to the active context. + + When ``db_url`` is ``None``, all four values are sourced from + ``get_context().database``. When ``db_url`` is provided, any ``None`` pool + params are replaced by their module-level defaults. + + Returns: + A tuple of ``(db_url, pool_size, max_overflow, pool_timeout)``. + + Raises: + RuntimeError: If ``db_url`` is ``None`` and no context is installed, or + the context has no ``database`` configured. + """ + if db_url is None: + from aignostics_foundry_core.foundry import get_context # noqa: PLC0415 + + ctx = get_context() + if ctx.database is None: + msg = f"No database URL configured. Set {ctx.env_prefix}DB_URL or pass db_url explicitly." + raise RuntimeError(msg) + return ( + ctx.database.get_url(), + pool_size if pool_size is not None else ctx.database.pool_size, + max_overflow if max_overflow is not None else ctx.database.max_overflow, + pool_timeout if pool_timeout is not None else ctx.database.pool_timeout, + ) + return ( + db_url, + pool_size if pool_size is not None else _DEFAULT_POOL_SIZE, + max_overflow if max_overflow is not None else _DEFAULT_MAX_OVERFLOW, + pool_timeout if pool_timeout is not None else _DEFAULT_POOL_TIMEOUT, + ) + + def init_engine( - db_url: str, - pool_size: int = 10, - max_overflow: int = 10, - pool_timeout: float = 30, + db_url: str | None = None, + pool_size: int | None = None, + max_overflow: int | None = None, + pool_timeout: float | None = None, ) -> None: """Initialize the database engine singleton. @@ -73,14 +180,24 @@ def init_engine( For multiprocessing: Engine is automatically reset in child processes via multiprocessing.util.register_after_fork(). + When ``db_url`` is ``None``, the URL and pool settings are resolved from the + active :class:`~aignostics_foundry_core.foundry.FoundryContext`. A + :exc:`RuntimeError` is raised if no context is installed or the context has no + ``database`` configured. + Args: db_url: Database connection URL (e.g. ``postgresql+asyncpg://user:pass@host/db``). + When ``None``, resolved from the active context's ``database`` settings. pool_size: Number of connections to keep in the pool. Ignored for dialects that - do not support QueuePool (e.g. SQLite). + do not support QueuePool (e.g. SQLite). Defaults to the context value or 10. max_overflow: Number of additional connections above pool_size. Ignored for - dialects that do not support QueuePool. + dialects that do not support QueuePool. Defaults to the context value or 10. pool_timeout: Seconds to wait for a connection from the pool. Ignored for - dialects that do not support QueuePool. + dialects that do not support QueuePool. Defaults to the context value or 30. + + Raises: + RuntimeError: If ``db_url`` is ``None`` and no context is installed, or the + context has no ``database`` configured. """ global _engine, _async_session_maker # noqa: PLW0603 @@ -88,6 +205,8 @@ def init_engine( logger.trace("Database engine already initialized, reusing existing engine and connection pool.") return # Already initialized + db_url, pool_size, max_overflow, pool_timeout = _resolve_db_params(db_url, pool_size, max_overflow, pool_timeout) + logger.trace( "Initializing global database engine with pool_size={}, max_overflow={}, pool_timeout={}", pool_size, @@ -186,10 +305,10 @@ async def execute_with_session(async_func: Any, *args: Any, **kwargs: Any) -> An def cli_run_with_db( async_func: Any, # noqa: ANN401 *args: Any, # noqa: ANN401 - db_url: str, - pool_size: int = 10, - max_overflow: int = 10, - pool_timeout: float = 30, + db_url: str | None = None, + pool_size: int | None = None, + max_overflow: int | None = None, + pool_timeout: float | None = None, **kwargs: Any, # noqa: ANN401 ) -> Any: # noqa: ANN401 """Run an async database function from a synchronous CLI context. @@ -199,10 +318,14 @@ def cli_run_with_db( NOT for use in long-lived processes (API, workers) - use @with_engine decorator instead. + When ``db_url`` is ``None``, the URL and pool settings are resolved from the active + :class:`~aignostics_foundry_core.foundry.FoundryContext` (same behaviour as + :func:`init_engine`). + Args: async_func: The async function to run (receives ``session`` as a keyword argument). *args: Positional arguments forwarded to ``async_func``. - db_url: Database connection URL. + db_url: Database connection URL. When ``None``, resolved from the active context. pool_size: Connection pool size (ignored for SQLite). max_overflow: Max overflow connections (ignored for SQLite). pool_timeout: Pool wait timeout in seconds (ignored for SQLite). @@ -229,10 +352,10 @@ def cli_run_with_db( def cli_run_with_engine( async_func: Any, # noqa: ANN401 *args: Any, # noqa: ANN401 - db_url: str, - pool_size: int = 10, - max_overflow: int = 10, - pool_timeout: float = 30, + db_url: str | None = None, + pool_size: int | None = None, + max_overflow: int | None = None, + pool_timeout: float | None = None, **kwargs: Any, # noqa: ANN401 ) -> Any: # noqa: ANN401 """Run an async function with initialized database engine from a synchronous CLI context. @@ -242,10 +365,14 @@ def cli_run_with_engine( NOT for use in long-lived processes (API, workers) - use @with_engine decorator instead. + When ``db_url`` is ``None``, the URL and pool settings are resolved from the active + :class:`~aignostics_foundry_core.foundry.FoundryContext` (same behaviour as + :func:`init_engine`). + Args: async_func: The async function to run (does not require a session parameter). *args: Positional arguments forwarded to ``async_func``. - db_url: Database connection URL. + db_url: Database connection URL. When ``None``, resolved from the active context. pool_size: Connection pool size (ignored for SQLite). max_overflow: Max overflow connections (ignored for SQLite). pool_timeout: Pool wait timeout in seconds (ignored for SQLite). @@ -269,41 +396,58 @@ def cli_run_with_engine( def with_engine( - db_url: str, - pool_size: int = 10, - max_overflow: int = 10, - pool_timeout: float = 30, -) -> Callable[[Any], Any]: - """Decorator factory to ensure database engine is initialized for async functions. + func: Any | None = None, # noqa: ANN401 + *, + db_url: str | None = None, + pool_size: int | None = None, + max_overflow: int | None = None, + pool_timeout: float | None = None, +) -> Any: # noqa: ANN401 + """Decorator (or decorator factory) to ensure database engine is initialized for async functions. + + Supports two calling conventions: + + * ``@with_engine`` — no-parens form; resolves URL and pool settings from the + active :class:`~aignostics_foundry_core.foundry.FoundryContext`. + * ``@with_engine()`` or ``@with_engine(db_url=..., ...)`` — explicit-parens form; + any omitted params are resolved from the active context. - This decorator wraps an async function to automatically initialize the database - engine singleton before execution. The connection pool persists across all jobs - in the process for efficiency. Useful for background jobs and workers. + The connection pool persists across all jobs in the process for efficiency. + Useful for background jobs and workers. For multiprocessing: Engine is automatically reset in child processes via multiprocessing.util.register_after_fork(). Args: - db_url: Database connection URL. + func: The async function to decorate (only when used as ``@with_engine`` + without parentheses). Do not pass explicitly. + db_url: Database connection URL. When ``None``, resolved from the active context. pool_size: Connection pool size (ignored for SQLite). max_overflow: Max overflow connections (ignored for SQLite). pool_timeout: Pool wait timeout in seconds (ignored for SQLite). Returns: - A decorator that wraps an async function with engine initialization. + The decorated async function (no-parens form) or a decorator (parens form). - Example: - @with_engine(db_url="postgresql+asyncpg://user:pass@host/db") + Example:: + + # Context-aware — no arguments needed once set_context() is called: + @with_engine async def my_job(): result = await execute_with_session(some_db_operation) return result + + + # Explicit URL (e.g. secondary database): + @with_engine(db_url="postgresql+asyncpg://user:pass@host/db") + async def my_other_job(): ... """ - def decorator(func: Any) -> Any: # noqa: ANN401 - func_name = getattr(func, "__name__", str(func)) + def decorator(f: Any) -> Any: # noqa: ANN401 + func_name = getattr(f, "__name__", str(f)) logger.trace("Applying with_engine decorator to function {}", func_name) - @functools.wraps(func) + @functools.wraps(f) async def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 logger.trace("Initializing database engine in with_engine wrapper for function {}", func_name) init_engine(db_url=db_url, pool_size=pool_size, max_overflow=max_overflow, pool_timeout=pool_timeout) @@ -311,7 +455,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 try: logger.trace("Executing function {} within with_engine wrapper", func_name) - result = await func(*args, **kwargs) + result = await f(*args, **kwargs) logger.trace("Successfully executed function {} within with_engine wrapper", func_name) return result except Exception: @@ -320,4 +464,6 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 return wrapper - return decorator + if func is not None: # called as @with_engine (no parens) + return decorator(func) + return decorator # called as @with_engine() or @with_engine(db_url=...) diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py index ae2ffd8..b68a661 100644 --- a/src/aignostics_foundry_core/foundry.py +++ b/src/aignostics_foundry_core/foundry.py @@ -29,6 +29,8 @@ from pydantic import BaseModel, Field +from aignostics_foundry_core.database import DatabaseSettings + def _empty_path_list() -> list[Path]: return [] @@ -76,6 +78,13 @@ class FoundryContext(BaseModel): is_library: bool = False python_version: str = "" project_path: Path | None = None + database: DatabaseSettings | None = None + """Database settings resolved from ``{env_prefix}DB_*`` environment variables. + + Populated by :meth:`from_package` when ``{env_prefix}DB_URL`` is present in the + environment. ``None`` when the variable is absent or when the context is constructed + directly (e.g. in tests). + """ """Absolute path to the project/repo root (directory containing ``.git``). Populated by walking up from the installed package location to find the git @@ -112,6 +121,8 @@ def from_package(cls, package_name: str) -> FoundryContext: repository_url, documentation_url = _extract_urls(package_name) project_path = _find_project_path(package_name) vcs_ref = os.environ.get("VCS_REF") or (project_path and _get_vcs_ref_from_git(project_path)) or "unknown" + env_prefix = f"{name_upper}_" + database = DatabaseSettings(_env_prefix=f"{env_prefix}DB_") if os.environ.get(f"{env_prefix}DB_URL") else None return cls( name=name, version=version, @@ -119,11 +130,12 @@ def from_package(cls, package_name: str) -> FoundryContext: version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref), environment=environment, env_file=_build_env_file_list(name, name_upper, environment), - env_prefix=f"{name_upper}_", + env_prefix=env_prefix, repository_url=repository_url, documentation_url=documentation_url, python_version=platform.python_version(), project_path=project_path, + database=database, **_build_runtime_flags(name, name_upper), ) @@ -286,12 +298,37 @@ def _build_runtime_flags(name: str, name_upper: str) -> dict[str, bool]: _context: FoundryContext | None = None +def _inject_third_party_path(package_name: str) -> None: + """Prepend ``/third_party/`` to :data:`sys.path` when it exists. + + Looks up *package_name* via :func:`importlib.util.find_spec` and, if a + ``third_party/`` subdirectory sits next to the package's ``__init__.py``, + inserts it at ``sys.path[0]``. The insertion is idempotent — calling this + a second time does not duplicate the entry. + + Args: + package_name: The importable name of the package whose ``third_party/`` + directory should be injected (e.g. ``"myproject"``). + """ + spec = importlib.util.find_spec(package_name) + if spec is None or spec.origin is None: + return + third_party = Path(spec.origin).parent / "third_party" + if third_party.is_dir() and str(third_party) not in sys.path: + sys.path.insert(0, str(third_party)) + + def set_context(ctx: FoundryContext) -> None: """Install *ctx* as the global project context. Subsequent calls to :func:`get_context` will return *ctx*. Calling this a second time replaces the previously installed context. + As a side effect, prepends ``/third_party/`` to + :data:`sys.path` when that directory exists next to the package's + ``__init__.py``. The injection is idempotent and silent when the directory + is absent or the package cannot be located. + Args: ctx: The :class:`FoundryContext` to install. @@ -300,6 +337,7 @@ def set_context(ctx: FoundryContext) -> None: """ global _context # noqa: PLW0603 _context = ctx + _inject_third_party_path(ctx.name) def reset_context() -> None: diff --git a/src/aignostics_foundry_core/settings.py b/src/aignostics_foundry_core/settings.py index 01cd211..2ae17cc 100644 --- a/src/aignostics_foundry_core/settings.py +++ b/src/aignostics_foundry_core/settings.py @@ -6,10 +6,6 @@ from pydantic import FieldSerializationInfo, SecretStr, ValidationError from pydantic_settings import BaseSettings -from rich.panel import Panel -from rich.text import Text - -from aignostics_foundry_core.console import console _T = TypeVar("_T", bound=BaseSettings) @@ -86,6 +82,11 @@ def load_settings(settings_class: type[_T]) -> _T: try: return settings_class() except ValidationError as e: + from rich.panel import Panel # noqa: PLC0415 + from rich.text import Text # noqa: PLC0415 + + from aignostics_foundry_core.console import console # noqa: PLC0415 + errors = e.errors() text = Text() text.append( diff --git a/tests/aignostics_foundry_core/database_settings_test.py b/tests/aignostics_foundry_core/database_settings_test.py new file mode 100644 index 0000000..f69b7b4 --- /dev/null +++ b/tests/aignostics_foundry_core/database_settings_test.py @@ -0,0 +1,129 @@ +"""Tests for DatabaseSettings.""" + +from collections.abc import Generator + +import pytest + +from aignostics_foundry_core.database import DatabaseSettings +from aignostics_foundry_core.foundry import reset_context, set_context +from tests.conftest import make_context + +# Constants (SonarQube S1192) +POSTGRES_URL = "postgresql+asyncpg://user:pass@localhost:5432/postgres" +SQLITE_URL = "sqlite+aiosqlite:///test.db" +CUSTOM_PREFIX = "CUSTOM_DB_" +CUSTOM_PREFIX_URL_ENV = "CUSTOM_DB_URL" +DEFAULT_POOL_SIZE = 10 +DEFAULT_MAX_OVERFLOW = 10 +DEFAULT_POOL_TIMEOUT = 30 +OVERRIDE_POOL_SIZE = 5 +OVERRIDE_MAX_OVERFLOW = 20 +OVERRIDE_POOL_TIMEOUT = 60 + + +@pytest.fixture(autouse=True) +def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] + """Reset global context before and after every test.""" + reset_context() + yield + reset_context() + + +# --------------------------------------------------------------------------- +# get_url behaviour +# --------------------------------------------------------------------------- + + +@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.""" + 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") + result = settings.get_url() + assert result.endswith("/mydb") + assert "postgres" not in result.split("/")[-1] + + +@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") + result = settings.get_url() + assert result.startswith("postgresql+asyncpg://") + assert "localhost:5432" in result + + +# --------------------------------------------------------------------------- +# env-prefix resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_default_env_prefix_reads_from_context(monkeypatch: pytest.MonkeyPatch) -> None: + """Constructing DatabaseSettings() without _env_prefix reads from {ctx.env_prefix}DB_URL.""" + ctx = make_context(env_prefix="MYAPP_") + set_context(ctx) + monkeypatch.setenv("MYAPP_DB_URL", POSTGRES_URL) + + settings = DatabaseSettings() + assert settings.get_url() == POSTGRES_URL + + +@pytest.mark.unit +def test_explicit_env_prefix_overrides_context(monkeypatch: pytest.MonkeyPatch) -> None: + """Passing _env_prefix reads from that prefix regardless of the active context.""" + ctx = make_context(env_prefix="MYAPP_") + set_context(ctx) + monkeypatch.setenv("MYAPP_DB_URL", "postgresql+asyncpg://wrong/wrong") + monkeypatch.setenv(CUSTOM_PREFIX_URL_ENV, POSTGRES_URL) + + settings = DatabaseSettings(_env_prefix=CUSTOM_PREFIX) + assert settings.get_url() == POSTGRES_URL + + +# --------------------------------------------------------------------------- +# Pool parameter defaults and overrides +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_pool_defaults_are_applied() -> None: + """pool_size, max_overflow, pool_timeout take their defaults when not set in env.""" + settings = DatabaseSettings(_env_prefix="TEST_DB_", url=SQLITE_URL) + assert settings.pool_size == DEFAULT_POOL_SIZE + assert settings.max_overflow == DEFAULT_MAX_OVERFLOW + assert int(settings.pool_timeout) == DEFAULT_POOL_TIMEOUT + + +@pytest.mark.unit +def test_pool_overrides_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Pool params read from {PREFIX}POOL_SIZE, {PREFIX}MAX_OVERFLOW, {PREFIX}POOL_TIMEOUT.""" + monkeypatch.setenv("TEST_DB_URL", SQLITE_URL) + monkeypatch.setenv("TEST_DB_POOL_SIZE", str(OVERRIDE_POOL_SIZE)) + monkeypatch.setenv("TEST_DB_MAX_OVERFLOW", str(OVERRIDE_MAX_OVERFLOW)) + monkeypatch.setenv("TEST_DB_POOL_TIMEOUT", str(OVERRIDE_POOL_TIMEOUT)) + + settings = DatabaseSettings(_env_prefix="TEST_DB_") + assert settings.pool_size == OVERRIDE_POOL_SIZE + assert settings.max_overflow == OVERRIDE_MAX_OVERFLOW + assert int(settings.pool_timeout) == OVERRIDE_POOL_TIMEOUT + + +# --------------------------------------------------------------------------- +# Secret masking +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_url_is_masked_in_repr() -> None: + """repr(settings) / str(settings) does not expose the raw URL.""" + settings = DatabaseSettings(_env_prefix="TEST_DB_", url=POSTGRES_URL) + representation = repr(settings) + assert "pass" not in representation + assert "**" in representation or "SecretStr" in representation diff --git a/tests/aignostics_foundry_core/database_test.py b/tests/aignostics_foundry_core/database_test.py index 2d9ce26..02d2a65 100644 --- a/tests/aignostics_foundry_core/database_test.py +++ b/tests/aignostics_foundry_core/database_test.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from aignostics_foundry_core.database import ( + DatabaseSettings, cli_run_with_db, cli_run_with_engine, dispose_engine, @@ -16,18 +17,23 @@ init_engine, with_engine, ) +from aignostics_foundry_core.foundry import reset_context, set_context +from tests.conftest import make_context NON_SQLITE_DB_URL = "postgresql+asyncpg://u:p@localhost/db" +DB_URL_ERROR_FRAGMENT = "DB_URL" SESSION_KWARG = "session" @pytest.fixture(autouse=True) async def reset_engine() -> AsyncGenerator[None, None]: # pyright: ignore[reportUnusedFunction] - """Ensure engine state is clean before and after each test.""" + """Ensure engine and context state are clean before and after each test.""" await dispose_engine() + reset_context() yield await dispose_engine() + reset_context() @pytest.fixture @@ -164,7 +170,7 @@ class TestWithEngine: """Tests for the with_engine decorator factory.""" @pytest.mark.unit - async def test_with_engine_decorator_initialises_engine(self, sqlite_url: str) -> None: + async def test_with_engine_explicit_url_still_works(self, sqlite_url: str) -> None: """A function decorated with @with_engine(db_url=...) runs without error.""" calls: list[bool] = [] @@ -187,3 +193,118 @@ async def failing_job() -> None: # noqa: RUF029 with pytest.raises(RuntimeError, match=err_msg): await failing_job() + + +class TestInitEngineContextAware: + """Tests for context-aware init_engine fallback behaviour.""" + + @pytest.mark.unit + async def test_init_engine_uses_context_url_when_no_explicit_url(self, sqlite_url: str) -> None: + """init_engine() with no args uses the URL from the active context database.""" + db_settings = DatabaseSettings(_env_prefix="TEST_DB_", url=sqlite_url) + set_context(make_context(database=db_settings)) + + init_engine() # no db_url argument + + await execute_with_session(noop) # confirms engine is functional + + @pytest.mark.unit + async def test_init_engine_raises_when_no_url_and_no_context_database(self) -> None: + """init_engine() raises RuntimeError when context.database is None.""" + set_context(make_context(database=None)) + + with pytest.raises(RuntimeError, match=DB_URL_ERROR_FRAGMENT): + init_engine() + + @pytest.mark.unit + async def test_init_engine_raises_when_context_not_set(self) -> None: + """init_engine() raises RuntimeError when no context has been installed.""" + # reset_engine fixture already cleared context — calling without set_context() + with pytest.raises(RuntimeError): + init_engine() + + @pytest.mark.unit + async def test_init_engine_explicit_url_takes_precedence_over_context( + self, sqlite_url: str, tmp_path: Path + ) -> None: + """Explicit db_url overrides the URL stored in the active context.""" + other_url = f"sqlite+aiosqlite:///{tmp_path}/other.db" + db_settings = DatabaseSettings(_env_prefix="TEST_DB_", url=other_url) + set_context(make_context(database=db_settings)) + + # Pass a different URL explicitly — should not raise + init_engine(db_url=sqlite_url) + + await execute_with_session(noop) # engine works → explicit URL was used + + +class TestCliRunWithDbContextAware: + """Tests for context-aware cli_run_with_db fallback.""" + + @pytest.mark.unit + async def test_cli_run_with_db_uses_context_url(self, sqlite_url: str) -> None: + """cli_run_with_db with no db_url uses the URL from the active context.""" + + async def return_42(**_: object) -> int: + await asyncio.sleep(0) + return 42 + + db_settings = DatabaseSettings(_env_prefix="TEST_DB_", url=sqlite_url) + set_context(make_context(database=db_settings)) + + result = await asyncio.to_thread(cli_run_with_db, return_42) + + assert result == 42 + + +class TestCliRunWithEngineContextAware: + """Tests for context-aware cli_run_with_engine fallback.""" + + @pytest.mark.unit + async def test_cli_run_with_engine_uses_context_url(self, sqlite_url: str) -> None: + """cli_run_with_engine with no db_url uses the URL from the active context.""" + + async def return_hello() -> str: + await asyncio.sleep(0) + return "hello" + + db_settings = DatabaseSettings(_env_prefix="TEST_DB_", url=sqlite_url) + set_context(make_context(database=db_settings)) + + result = await asyncio.to_thread(cli_run_with_engine, return_hello) + + assert result == "hello" + + +class TestWithEngineContextAware: + """Tests for context-aware with_engine decorator.""" + + @pytest.mark.unit + async def test_with_engine_no_parens_uses_context(self, sqlite_url: str) -> None: + """@with_engine (no parens) resolves the URL from the active context.""" + db_settings = DatabaseSettings(_env_prefix="TEST_DB_", url=sqlite_url) + set_context(make_context(database=db_settings)) + calls: list[bool] = [] + + @with_engine + async def my_job() -> None: # noqa: RUF029 + calls.append(True) + + await my_job() + + assert calls == [True] + + @pytest.mark.unit + async def test_with_engine_empty_parens_uses_context(self, sqlite_url: str) -> None: + """@with_engine() (empty parens) resolves the URL from the active context.""" + db_settings = DatabaseSettings(_env_prefix="TEST_DB_", url=sqlite_url) + set_context(make_context(database=db_settings)) + calls: list[bool] = [] + + @with_engine() + async def my_job() -> None: # noqa: RUF029 + calls.append(True) + + await my_job() + + assert calls == [True] diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index f7a3bf7..e424aaf 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -2,8 +2,11 @@ import importlib.metadata import importlib.util +import os import platform +import subprocess import sys +import textwrap from collections.abc import Generator from importlib.machinery import ModuleSpec from pathlib import Path @@ -17,6 +20,9 @@ # Constants (SonarQube S1192) PACKAGE_NAME = "aignostics_foundry_core" STAGING = "staging" +SQLITE_URL = "sqlite+aiosqlite:///test.db" +DB_URL_ENV_KEY = f"{PACKAGE_NAME.upper()}_DB_URL" +DB_POOL_SIZE_ENV_KEY = f"{PACKAGE_NAME.upper()}_DB_POOL_SIZE" ERROR_MSG_FRAGMENT = "set_context" VCS_REF_VALUE = "abc123" VCS_REF_OVERRIDE = "ci-override-ref" @@ -28,6 +34,7 @@ GIT_BRANCH = "main" GIT_SHA_FULL = "a" * 40 GIT_SHA_SHORT = "a" * 7 +INIT_PY = "__init__.py" @pytest.fixture(autouse=True) @@ -205,7 +212,7 @@ def test_from_package_project_path_is_none_when_no_git_ancestor( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """from_package() sets project_path=None when no .git directory exists in any ancestor.""" - fake_spec = ModuleSpec(PACKAGE_NAME, None, origin=str(tmp_path / PACKAGE_NAME / "__init__.py")) + fake_spec = ModuleSpec(PACKAGE_NAME, None, origin=str(tmp_path / PACKAGE_NAME / INIT_PY)) def _find_spec_no_git(name: str, package: str | None = None) -> ModuleSpec: return fake_spec @@ -230,7 +237,7 @@ def test_from_package_project_path_resolves_git_root() -> None: def _fake_spec_for(tmp_path: Path) -> ModuleSpec: """Return a ModuleSpec whose origin sits inside *tmp_path*.""" - return ModuleSpec(PACKAGE_NAME, None, origin=str(tmp_path / PACKAGE_NAME / "__init__.py")) + return ModuleSpec(PACKAGE_NAME, None, origin=str(tmp_path / PACKAGE_NAME / INIT_PY)) def _make_git_head(tmp_path: Path, content: str) -> None: @@ -422,6 +429,96 @@ def test_reset_context_is_idempotent_when_no_context_set() -> None: reset_context() # no prior set_context() — must not raise +# --------------------------------------------------------------------------- +# set_context — third_party sys.path injection +# --------------------------------------------------------------------------- + +THIRD_PARTY = "third_party" + + +@pytest.mark.unit +def test_set_context_prepends_third_party_dir_to_sys_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """When third_party/ exists next to __init__.py, it is at sys.path[0] after set_context().""" + pkg_dir = tmp_path / PACKAGE_NAME + pkg_dir.mkdir() + (pkg_dir / INIT_PY).touch() + third_party_dir = pkg_dir / THIRD_PARTY + third_party_dir.mkdir() + + def _find_spec_with_third_party(name: str, package: str | None = None) -> ModuleSpec: + return _fake_spec_for(tmp_path) + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_with_third_party) + monkeypatch.setattr(sys, "path", sys.path[:]) + + ctx = make_context() + set_context(ctx) + + assert sys.path[0] == str(third_party_dir) + + +@pytest.mark.unit +def test_set_context_skips_missing_third_party_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """When no third_party/ dir exists, sys.path is unchanged after set_context().""" + pkg_dir = tmp_path / PACKAGE_NAME + pkg_dir.mkdir() + (pkg_dir / INIT_PY).touch() + # No third_party/ subdirectory created + + def _find_spec_no_third_party(name: str, package: str | None = None) -> ModuleSpec: + return _fake_spec_for(tmp_path) + + original_path = sys.path[:] + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_no_third_party) + monkeypatch.setattr(sys, "path", sys.path[:]) + + ctx = make_context() + set_context(ctx) + + assert sys.path == original_path + + +@pytest.mark.unit +def test_set_context_is_idempotent_for_sys_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Calling set_context() twice leaves exactly one entry for third_party/ in sys.path.""" + pkg_dir = tmp_path / PACKAGE_NAME + pkg_dir.mkdir() + (pkg_dir / INIT_PY).touch() + third_party_dir = pkg_dir / THIRD_PARTY + third_party_dir.mkdir() + + def _find_spec_with_third_party(name: str, package: str | None = None) -> ModuleSpec: + return _fake_spec_for(tmp_path) + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_with_third_party) + monkeypatch.setattr(sys, "path", sys.path[:]) + + ctx = make_context() + set_context(ctx) + set_context(ctx) + + assert sys.path.count(str(third_party_dir)) == 1 + + +@pytest.mark.unit +def test_set_context_skips_injection_when_spec_not_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When find_spec returns None, sys.path is unchanged after set_context().""" + + def _find_spec_none(name: str, package: str | None = None) -> None: + return None + + original_path = sys.path[:] + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_none) + monkeypatch.setattr(sys, "path", sys.path[:]) + + ctx = make_context() + set_context(ctx) + + assert sys.path == original_path + + # --------------------------------------------------------------------------- # from_package — version_with_vcs_ref # --------------------------------------------------------------------------- @@ -491,3 +588,107 @@ def test_from_package_version_with_vcs_ref_excludes_ci_metadata(monkeypatch: pyt ctx = FoundryContext.from_package(PACKAGE_NAME) for fragment in ["---", "run.", "build.", "builder.", "built."]: assert fragment not in ctx.version_with_vcs_ref + + +# --------------------------------------------------------------------------- +# from_package — database field +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_database_is_none_when_db_url_not_set(monkeypatch: pytest.MonkeyPatch) -> None: + """from_package() sets database=None when {PREFIX}DB_URL is absent.""" + monkeypatch.delenv(DB_URL_ENV_KEY, raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.database is None + + +@pytest.mark.unit +def test_from_package_database_populated_when_db_url_set(monkeypatch: pytest.MonkeyPatch) -> None: + """from_package() populates database when {PREFIX}DB_URL is set.""" + monkeypatch.setenv(DB_URL_ENV_KEY, SQLITE_URL) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.database is not None + assert ctx.database.get_url() == SQLITE_URL + + +@pytest.mark.unit +def test_from_package_database_pool_size_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + """from_package() reads pool_size from {PREFIX}DB_POOL_SIZE when DB_URL is also set.""" + monkeypatch.setenv(DB_URL_ENV_KEY, SQLITE_URL) + monkeypatch.setenv(DB_POOL_SIZE_ENV_KEY, "5") + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.database is not None + assert ctx.database.pool_size == 5 + + +# --------------------------------------------------------------------------- +# from_package — database field — import-order integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_foundry_imported_before_database() -> None: + """Importing foundry then database and calling from_package() with DB_URL set exits cleanly.""" + script = textwrap.dedent(f""" + from aignostics_foundry_core.foundry import FoundryContext + from aignostics_foundry_core.database import DatabaseSettings + ctx = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx.database is not None + assert ctx.database.get_url() == "{SQLITE_URL}" + """) + env = os.environ.copy() + env[DB_URL_ENV_KEY] = SQLITE_URL + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_database_imported_before_foundry() -> None: + """Importing database then foundry and calling from_package() with DB_URL set exits cleanly.""" + script = textwrap.dedent(f""" + from aignostics_foundry_core.database import DatabaseSettings + from aignostics_foundry_core.foundry import FoundryContext + ctx = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx.database is not None + assert ctx.database.get_url() == "{SQLITE_URL}" + """) + env = os.environ.copy() + env[DB_URL_ENV_KEY] = SQLITE_URL + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_from_package_called_twice_is_safe() -> None: + """Calling from_package() twice in the same process exits cleanly.""" + script = textwrap.dedent(f""" + from aignostics_foundry_core.foundry import FoundryContext + ctx1 = FoundryContext.from_package("{PACKAGE_NAME}") + ctx2 = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx1.database is not None + assert ctx2.database is not None + """) + env = os.environ.copy() + env[DB_URL_ENV_KEY] = SQLITE_URL + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_make_context_without_prior_from_package() -> None: + """Constructing FoundryContext directly (no from_package()) has database=None.""" + script = textwrap.dedent(""" + from aignostics_foundry_core.foundry import FoundryContext + ctx = FoundryContext( + name="test", + version="0.0.0", + version_full="0.0.0", + version_with_vcs_ref="0.0.0", + environment="test", + env_prefix="TEST_", + ) + assert ctx.database is None + """) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr diff --git a/tests/aignostics_foundry_core/settings_test.py b/tests/aignostics_foundry_core/settings_test.py index 97fff10..f4e6f94 100644 --- a/tests/aignostics_foundry_core/settings_test.py +++ b/tests/aignostics_foundry_core/settings_test.py @@ -18,6 +18,7 @@ _SECRET_VALUE = "sensitive" # noqa: S105 _MASKED_VALUE = "**********" +_AIGNOSTICS_FOUNDRY_CORE_CONSOLE = "aignostics_foundry_core.console" class _TheTestSettings(OpaqueSettings): @@ -144,7 +145,7 @@ def test_load_settings_with_env_prefix(self) -> None: @pytest.mark.unit @patch("sys.exit") - @patch("aignostics_foundry_core.settings.console.print") + @patch("aignostics_foundry_core.console.console.print") def test_load_settings_validation_error_exits(self, mock_console_print: MagicMock, mock_exit: MagicMock) -> None: """Test that validation error prints a Rich Panel and calls sys.exit(78).""" from rich.panel import Panel @@ -156,9 +157,40 @@ def test_load_settings_validation_error_exits(self, mock_console_print: MagicMoc panel_arg = mock_console_print.call_args[0][0] assert isinstance(panel_arg, Panel) + @pytest.mark.unit + def test_load_settings_success_does_not_import_console(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Successful load must not trigger the console import (lazy-import check).""" + import sys + + monkeypatch.setenv("REQUIRED_VALUE", "test_value") + # Temporarily remove the console module so we can detect if it gets imported. + console_module = sys.modules.pop(_AIGNOSTICS_FOUNDRY_CORE_CONSOLE, None) + try: + load_settings(_TheTestSettings) + assert _AIGNOSTICS_FOUNDRY_CORE_CONSOLE not in sys.modules + finally: + if console_module is not None: + sys.modules[_AIGNOSTICS_FOUNDRY_CORE_CONSOLE] = console_module + + @pytest.mark.unit + @patch("sys.exit") + @patch("aignostics_foundry_core.console.console.print") + def test_load_settings_invalid_prints_panel_and_exits( + self, mock_console_print: MagicMock, mock_exit: MagicMock + ) -> None: + """Lazy-imported console still renders a Rich Panel on ValidationError.""" + from rich.panel import Panel + + load_settings(_TheTestSettings) # missing REQUIRED_VALUE → ValidationError + + mock_exit.assert_called_once_with(78) + assert mock_console_print.call_count == 1 + panel_arg = mock_console_print.call_args[0][0] + assert isinstance(panel_arg, Panel) + @pytest.mark.unit @patch("sys.exit") - @patch("aignostics_foundry_core.settings.console.print") + @patch("aignostics_foundry_core.console.console.print") def test_load_settings_validation_error_integer_loc( self, mock_console_print: MagicMock, mock_exit: MagicMock ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 810de4d..897536e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import psutil import pytest +from aignostics_foundry_core.database import DatabaseSettings from aignostics_foundry_core.foundry import FoundryContext __all__ = ["make_context"] @@ -68,6 +69,7 @@ def make_context( # noqa: PLR0913 environment: str = "test", project_path: Path | None = None, repository_url: str = "", + database: DatabaseSettings | None = None, **kwargs: bool, ) -> FoundryContext: """Create a minimal FoundryContext for testing. @@ -79,6 +81,8 @@ def make_context( # noqa: PLR0913 environment: The deployment environment (defaults to ``"test"``). project_path: Optional path to the project root. repository_url: The project repository URL (defaults to ``""``). + database: Optional :class:`~aignostics_foundry_core.database.DatabaseSettings` + instance to attach to the context. **kwargs: Optional boolean flags forwarded to :class:`FoundryContext` (``is_test``, ``is_cli``, ``is_container``, ``is_library``). """ @@ -91,5 +95,6 @@ def make_context( # noqa: PLR0913 env_prefix=env_prefix, project_path=project_path, repository_url=repository_url, + database=database, **kwargs, # type: ignore[arg-type] )