From 52a1c2742b64a99883eb5c4ed6dac006595898ac Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Mon, 30 Mar 2026 17:13:10 +0200 Subject: [PATCH 1/3] feat(foundry): add python_version to FoundryContext Co-Authored-By: Claude Sonnet 4.6 --- ATTRIBUTIONS.md | 2 +- src/aignostics_foundry_core/AGENTS.md | 7 ++++--- src/aignostics_foundry_core/foundry.py | 3 +++ tests/aignostics_foundry_core/foundry_test.py | 8 ++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 5947afa..2f1431b 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -360,7 +360,7 @@ SOFTWARE. ``` -## aignostics-foundry-core (0.2.0) - MIT License +## aignostics-foundry-core (0.3.0) - MIT License 🏭 Foundational infrastructure for Foundry components. diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index a04aa44..e31b0d3 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -23,7 +23,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **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, environment, env files, URLs, 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, environment, env files, URLs, `python_version`, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`) derived from package metadata and environment variables | | **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 | @@ -42,8 +42,9 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei through call sites. Tests pass an explicit context override and never touch global state. - **Key Features**: - `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `environment`, - `env_file: list[Path]`, `repository_url`, `documentation_url`, plus four runtime mode bool - flags: `is_container`, `is_cli`, `is_test`, `is_library` (all default `False`). + `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`). - `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`, diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py index ecf486c..0c4664e 100644 --- a/src/aignostics_foundry_core/foundry.py +++ b/src/aignostics_foundry_core/foundry.py @@ -21,6 +21,7 @@ import importlib.util import os +import platform import string import sys from importlib import metadata @@ -64,6 +65,7 @@ class FoundryContext(BaseModel): is_cli: bool = False is_test: bool = False is_library: bool = False + python_version: str = "" project_path: Path | None = None """Absolute path to the project/repo root (directory containing ``.git``). @@ -110,6 +112,7 @@ def from_package(cls, package_name: str) -> FoundryContext: env_prefix=f"{name_upper}_", repository_url=repository_url, documentation_url=documentation_url, + python_version=platform.python_version(), project_path=project_path, **_build_runtime_flags(name, name_upper), ) diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index 1e043cf..2e2a68f 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -2,6 +2,7 @@ import importlib.metadata import importlib.util +import platform import sys from collections.abc import Generator from importlib.machinery import ModuleSpec @@ -45,6 +46,13 @@ def reset_context(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None # --------------------------------------------------------------------------- +@pytest.mark.unit +def test_from_package_python_version_matches_platform() -> None: + """from_package() sets .python_version to platform.python_version().""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.python_version == platform.python_version() + + @pytest.mark.unit def test_from_package_returns_correct_name() -> None: """from_package() sets .name to the package_name argument.""" From aa935d910ffa5cc00c7f239c3a032b1d75fa59a8 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Mon, 30 Mar 2026 17:22:41 +0200 Subject: [PATCH 2/3] feat(api): add versions param to init_api() Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics_foundry_core/AGENTS.md | 2 +- src/aignostics_foundry_core/api/core.py | 23 +++++- .../aignostics_foundry_core/api/core_test.py | 70 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index e31b0d3..5fd939b 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -125,7 +125,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - `build_versioned_api_tags(version_name, repository_url)` — OpenAPI tags for a single versioned sub-app - `build_root_api_tags(base_url, versions)` — OpenAPI tags for the root app linking to each version's docs - `get_versioned_api_instances(versions, build_metadata=None, *, context=None)` — loads project modules (resolved via context), creates one `FastAPI` per version, routes registered `VersionedAPIRouter` instances to the matching version - - `init_api(root_path, lifespan, exception_handler_registrations, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered + - `init_api(root_path, lifespan, exception_handler_registrations, versions=None, version_exception_handler_registrations=None, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered; when *versions* is supplied, calls `get_versioned_api_instances` internally, optionally applies *version_exception_handler_registrations* to each sub-app, and mounts them at `/{version}` on the root app - **Location**: `aignostics_foundry_core/api/core.py` - **Dependencies**: `fastapi>=0.110,<1` (mandatory); `aignostics_foundry_core.di` (`load_modules`) - **Import**: `from aignostics_foundry_core.api.core import VersionedAPIRouter, init_api, build_api_metadata, …` or `from aignostics_foundry_core.api import …` diff --git a/src/aignostics_foundry_core/api/core.py b/src/aignostics_foundry_core/api/core.py index 318738b..54f18b6 100644 --- a/src/aignostics_foundry_core/api/core.py +++ b/src/aignostics_foundry_core/api/core.py @@ -394,20 +394,30 @@ def init_api( root_path: str = "", lifespan: Any | None = None, # noqa: ANN401 exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None, + versions: list[str] | None = None, + version_exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None, **fastapi_kwargs: Any, # noqa: ANN401 ) -> FastAPI: """Initialise a FastAPI application with standard exception handlers. This is a generic factory that creates a ``FastAPI`` instance and registers - the standard Foundry exception handlers. Versioned sub-application mounting - (Bridge-specific) is left to the caller; use ``get_versioned_api_instances`` - and ``FastAPI.mount`` for that pattern. + the standard Foundry exception handlers. When *versions* is supplied the + function also creates versioned sub-applications via + ``get_versioned_api_instances``, optionally applies per-version exception + handlers, and mounts each sub-app at ``/{version}`` on the root app. Args: root_path: ASGI root path (useful for reverse-proxy setups). lifespan: Optional async context manager for application lifespan. exception_handler_registrations: Additional ``(exc_class, handler)`` pairs to register before the standard handlers. + versions: Optional list of API version names (e.g. ``["v1", "v2"]``). + When provided, ``get_versioned_api_instances`` is called internally + and each resulting sub-app is mounted at ``/{version}`` on the root + app. + version_exception_handler_registrations: ``(exc_class, handler)`` pairs + to register on *every* versioned sub-app before mounting. Only used + when *versions* is also provided. **fastapi_kwargs: Extra keyword arguments forwarded to ``FastAPI()``. Returns: @@ -429,4 +439,11 @@ def init_api( api.add_exception_handler(exc_class_or_status_code=ValidationError, handler=validation_exception_handler) api.add_exception_handler(exc_class_or_status_code=Exception, handler=unhandled_exception_handler) + if versions: + versioned_apps = get_versioned_api_instances(versions) + for version_name, version_app in versioned_apps.items(): + for exc_class, handler in version_exception_handler_registrations or []: + version_app.add_exception_handler(exc_class_or_status_code=exc_class, handler=handler) + api.mount(f"/{version_name}", version_app) + return api diff --git a/tests/aignostics_foundry_core/api/core_test.py b/tests/aignostics_foundry_core/api/core_test.py index 87c1af8..bb4b477 100644 --- a/tests/aignostics_foundry_core/api/core_test.py +++ b/tests/aignostics_foundry_core/api/core_test.py @@ -244,3 +244,73 @@ def handler(request: object, exc: Exception) -> None: assert isinstance(app, FastAPI) assert ValueError in app.exception_handlers + + +VERSION_V1 = "v1" +VERSION_V2 = "v2" +MOUNT_PATH_V1 = "/v1" +MOUNT_PATH_V2 = "/v2" + + +@pytest.mark.unit +def test_init_api_mounts_versioned_apps(monkeypatch: pytest.MonkeyPatch) -> None: + """init_api mounts each versioned sub-app at /{version} on the root app.""" + from typing import Any + + from fastapi import FastAPI + from starlette.routing import Mount + + import aignostics_foundry_core.api.core as core_module + from aignostics_foundry_core.api.core import init_api + + stub_v1 = FastAPI() + stub_v2 = FastAPI() + + def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, FastAPI]: # noqa: ANN401 + return {VERSION_V1: stub_v1, VERSION_V2: stub_v2} + + monkeypatch.setattr(core_module, "get_versioned_api_instances", fake_get_versioned) + + app = init_api(versions=[VERSION_V1, VERSION_V2]) + + mount_paths = [r.path for r in app.routes if isinstance(r, Mount)] + assert MOUNT_PATH_V1 in mount_paths + assert MOUNT_PATH_V2 in mount_paths + + +@pytest.mark.unit +def test_init_api_applies_version_exception_handlers(monkeypatch: pytest.MonkeyPatch) -> None: + """init_api applies version_exception_handler_registrations to each versioned sub-app.""" + from typing import Any + from unittest.mock import MagicMock + + import aignostics_foundry_core.api.core as core_module + from aignostics_foundry_core.api.core import init_api + + stub_v1 = MagicMock() + stub_v2 = MagicMock() + + def fake_get_versioned(versions: list[str], **_: Any) -> dict[str, MagicMock]: # noqa: ANN401 + return {VERSION_V1: stub_v1, VERSION_V2: stub_v2} + + monkeypatch.setattr(core_module, "get_versioned_api_instances", fake_get_versioned) + + def my_handler(request: object, exc: Exception) -> None: ... + + init_api(versions=[VERSION_V1, VERSION_V2], version_exception_handler_registrations=[(ValueError, my_handler)]) + + stub_v1.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler) + stub_v2.add_exception_handler.assert_called_once_with(exc_class_or_status_code=ValueError, handler=my_handler) + + +@pytest.mark.unit +def test_init_api_without_versions_unchanged() -> None: + """init_api without versions adds no Mount routes (backward compatibility).""" + from starlette.routing import Mount + + from aignostics_foundry_core.api.core import init_api + + app = init_api() + + mount_routes = [r for r in app.routes if isinstance(r, Mount)] + assert mount_routes == [] From 954cddc263d6b0b6fb40b8570e05260bb2a8c966 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Mon, 30 Mar 2026 18:02:56 +0200 Subject: [PATCH 3/3] fix(sentry): derive env_prefix from FoundryContext Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics_foundry_core/AGENTS.md | 6 +-- src/aignostics_foundry_core/api/auth.py | 15 ++++-- src/aignostics_foundry_core/log.py | 17 +++--- src/aignostics_foundry_core/sentry.py | 14 ++--- .../aignostics_foundry_core/api/auth_test.py | 15 ++++++ tests/aignostics_foundry_core/log_test.py | 53 ++++++++++++++----- tests/aignostics_foundry_core/sentry_test.py | 30 +++++++++-- 7 files changed, 111 insertions(+), 39 deletions(-) diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 5fd939b..4a64274 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -97,7 +97,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Provides Auth0 cookie-based session authentication dependencies for FastAPI routes. All project-specific settings (org ID, role claim) are loaded from `AuthSettings` whose env prefix is configurable at instantiation. - **Key Features**: - - `AuthSettings(OpaqueSettings)` — reads from `FOUNDRY_AUTH_*` env vars by default; override prefix via constructor kwargs (e.g. `AuthSettings(_env_prefix="BRIDGE_AUTH_", _env_file=".env")`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role) + - `AuthSettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}AUTH_`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role) - `UnauthenticatedError(Exception)` — raised when a user session is missing or invalid - `ForbiddenError(ApiException)` — `status_code = 403`; raised when user lacks required role or org membership - `get_auth_client(request)` — retrieves `AuthClient` from `request.app.state.auth_client`; raises `RuntimeError` if not configured @@ -154,7 +154,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps loguru as the primary logging framework, optionally redirecting stdlib `logging` via `InterceptHandler`. All project-specific constants are passed as parameters rather than hard-coded. - **Key Features**: - `InterceptHandler(logging.Handler)` — redirects stdlib log records to loguru, preserving original module/function/line metadata - - `LogSettings(BaseSettings)` — reads from `FOUNDRY_LOG_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env")`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging` + - `LogSettings(BaseSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}LOG_`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging` - `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise - **Location**: `aignostics_foundry_core/log.py` - **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory) @@ -166,7 +166,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants. - **Key Features**: - - `SentrySettings(OpaqueSettings)` — reads from `FOUNDRY_SENTRY_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env")`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` + - `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs` - `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise - `set_sentry_user(user, role_claim)` — maps Auth0 user claims (`sub` → `id`, `email`, `name`, …) into Sentry scope; pass `None` to clear context; no-op when `sentry_sdk` is absent - **Location**: `aignostics_foundry_core/sentry.py` diff --git a/src/aignostics_foundry_core/api/auth.py b/src/aignostics_foundry_core/api/auth.py index fbbd6ee..8cf7142 100644 --- a/src/aignostics_foundry_core/api/auth.py +++ b/src/aignostics_foundry_core/api/auth.py @@ -5,7 +5,7 @@ - Authentication dependencies (require_authenticated, require_admin, etc.) - get_user: Get authenticated user from session - get_auth_client: Get Auth0 client from app state -- AuthSettings: Configurable auth settings (env prefix overridable via constructor kwargs) +- AuthSettings: Auth settings whose env prefix is derived from the active FoundryContext """ import time @@ -17,6 +17,7 @@ from loguru import logger from pydantic_settings import SettingsConfigDict +from aignostics_foundry_core.foundry import get_context from aignostics_foundry_core.settings import OpaqueSettings, load_settings from .exceptions import ApiException @@ -31,17 +32,21 @@ class AuthSettings(OpaqueSettings): - """Auth settings with configurable env prefix. + """Auth settings whose env prefix is derived from the active FoundryContext. - Override prefix at instantiation: - ``AuthSettings(_env_prefix="BRIDGE_AUTH_", _env_file=".env")`` + The effective prefix is ``{FoundryContext.env_prefix}AUTH_``, resolved at + instantiation time via :func:`aignostics_foundry_core.foundry.get_context`. """ - model_config = SettingsConfigDict(env_prefix="FOUNDRY_AUTH_", extra="ignore") + model_config = SettingsConfigDict(extra="ignore") internal_org_id: str | None = None auth0_role_claim: str = DEFAULT_AUTH0_ROLE_CLAIM + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 + """Initialise settings, deriving env_prefix from the active FoundryContext.""" + super().__init__(_env_prefix=f"{get_context().env_prefix}AUTH_", **kwargs) # pyright: ignore[reportCallIssue] + class UnauthenticatedError(Exception): """Raised when user is not authenticated.""" diff --git a/src/aignostics_foundry_core/log.py b/src/aignostics_foundry_core/log.py index e7c71b0..3810592 100644 --- a/src/aignostics_foundry_core/log.py +++ b/src/aignostics_foundry_core/log.py @@ -14,7 +14,7 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal import platformdirs from loguru import logger @@ -120,19 +120,20 @@ def patcher(record_dict: "Record") -> None: class LogSettings(BaseSettings): """Settings for configuring logging behaviour. - Reads from environment variables with the ``FOUNDRY_LOG_`` prefix by - default. Callers can supply a project-specific prefix or env file at - instantiation time using Pydantic Settings v2 constructor kwargs:: - - settings = LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env") + Reads environment variables using the prefix derived from the active + :class:`~aignostics_foundry_core.foundry.FoundryContext` (e.g. + ``MYPROJECT_LOG_`` when the context's ``env_prefix`` is ``MYPROJECT_``). """ model_config = SettingsConfigDict( - env_prefix="FOUNDRY_LOG_", extra="ignore", env_file_encoding="utf-8", ) + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 + """Initialise settings, deriving env_prefix from the active FoundryContext.""" + super().__init__(_env_prefix=f"{get_context().env_prefix}LOG_", **kwargs) # pyright: ignore[reportCallIssue] + level: Literal["CRITICAL", "ERROR", "WARNING", "SUCCESS", "INFO", "DEBUG", "TRACE"] = Field( default="INFO", description="Log level, see https://loguru.readthedocs.io/en/stable/api/logger.html", @@ -182,7 +183,7 @@ def logging_initialize( :func:`~aignostics_foundry_core.foundry.set_context`. """ ctx = context or get_context() - settings = LogSettings(_env_prefix=f"{ctx.env_prefix}LOG_", _env_file=ctx.env_file) # pyright: ignore[reportCallIssue] + settings = LogSettings(_env_file=ctx.env_file) # pyright: ignore[reportCallIssue] logger.remove() # Remove all default loggers diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index ccbf848..eb134d2 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -118,19 +118,20 @@ def _validate_https_dsn(value: SecretStr | None) -> SecretStr | None: class SentrySettings(OpaqueSettings): """Configuration settings for Sentry integration. - Reads from environment variables with the ``FOUNDRY_SENTRY_`` prefix by - default. Callers can supply a project-specific prefix or env file at - instantiation time using Pydantic Settings v2 constructor kwargs:: - - settings = SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env") + Reads environment variables using the prefix derived from the active + :class:`~aignostics_foundry_core.foundry.FoundryContext` (e.g. + ``MYPROJECT_SENTRY_`` when the context's ``env_prefix`` is ``MYPROJECT_``). """ model_config = SettingsConfigDict( - env_prefix="FOUNDRY_SENTRY_", env_file_encoding="utf-8", extra="ignore", ) + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 + """Initialise settings, deriving env_prefix from the active FoundryContext.""" + super().__init__(_env_prefix=f"{get_context().env_prefix}SENTRY_", **kwargs) # pyright: ignore[reportCallIssue] + enabled: Annotated[ bool, Field( @@ -245,7 +246,6 @@ def sentry_initialize( ctx = context or get_context() settings = SentrySettings( - _env_prefix=f"{ctx.env_prefix}SENTRY_", # pyright: ignore[reportCallIssue] _env_file=ctx.env_file, # pyright: ignore[reportCallIssue] ) diff --git a/tests/aignostics_foundry_core/api/auth_test.py b/tests/aignostics_foundry_core/api/auth_test.py index 260bd68..436fef9 100644 --- a/tests/aignostics_foundry_core/api/auth_test.py +++ b/tests/aignostics_foundry_core/api/auth_test.py @@ -18,10 +18,12 @@ require_internal, require_internal_admin, ) +from tests.conftest import make_context _PATCH_GET_USER = "aignostics_foundry_core.api.auth.get_user" _PATCH_GET_AUTH_CLIENT = "aignostics_foundry_core.api.auth.get_auth_client" _PATCH_SET_SENTRY_USER = "aignostics_foundry_core.sentry.set_sentry_user" +_PATCH_GET_CONTEXT = "aignostics_foundry_core.api.auth.get_context" _INTERNAL_ORG_ID = "org_internal_123" _OTHER_ORG_ID = "org_other_456" _USER_NOT_AUTHENTICATED = "User is not authenticated" @@ -29,6 +31,12 @@ _USER_EMAIL = "x@x.com" +@pytest.fixture(autouse=True) +def _stub_auth_get_context(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction] + """Stub get_context for all auth tests to preserve FOUNDRY_AUTH_* env var names.""" + monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("foundry", "FOUNDRY_")) + + @pytest.mark.unit class TestUnauthenticatedError: """Tests for UnauthenticatedError.""" @@ -91,6 +99,13 @@ def test_auth_settings_role_claim_value(self) -> None: """The default role claim is the Aignostics platform bridge claim URL.""" assert DEFAULT_AUTH0_ROLE_CLAIM == "https://aignostics-platform-bridge/role" + def test_auth_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + """AuthSettings reads env vars from the prefix supplied by FoundryContext.""" + monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("proj", "PROJ_")) + monkeypatch.setenv("PROJ_AUTH_AUTH0_ROLE_CLAIM", "https://custom/role") + settings = AuthSettings() + assert settings.auth0_role_claim == "https://custom/role" + @pytest.mark.unit class TestGetUser: diff --git a/tests/aignostics_foundry_core/log_test.py b/tests/aignostics_foundry_core/log_test.py index 32a680f..a639dc8 100644 --- a/tests/aignostics_foundry_core/log_test.py +++ b/tests/aignostics_foundry_core/log_test.py @@ -25,9 +25,16 @@ class TestLoggingInitialize: """Behavioural tests for logging_initialize().""" + @pytest.fixture(autouse=True) + def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "aignostics_foundry_core.log.get_context", + lambda: make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"), + ) + def test_logging_initialize_adds_stderr_handler(self, capsys: pytest.CaptureFixture[str]) -> None: """After initialization with defaults, a log message appears on stderr.""" - logging_initialize(context=make_context(_PROJECT)) + logging_initialize() from loguru import logger logger.info(_MARKER_MESSAGE) @@ -39,7 +46,7 @@ def test_logging_initialize_skips_stderr_when_disabled( ) -> None: """When stderr is disabled via env var, no output is written to stderr.""" monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_STDERR_ENABLED", "false") - logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_")) + logging_initialize() from loguru import logger logger.info(_MARKER_MESSAGE) @@ -48,7 +55,7 @@ def test_logging_initialize_skips_stderr_when_disabled( def test_intercept_handler_redirects_stdlib_log(self, capsys: pytest.CaptureFixture[str]) -> None: """After initialization, stdlib logging messages are forwarded to loguru (and thus stderr).""" - logging_initialize(context=make_context(_PROJECT)) + logging_initialize() stdlib_logging.getLogger("test.intercept").warning(_STDLIB_MESSAGE) captured = capsys.readouterr() assert _STDLIB_MESSAGE in captured.err @@ -60,7 +67,7 @@ def test_logging_initialize_file_handler_writes_to_file( log_file = tmp_path / "test.log" monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_ENABLED", "true") monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_NAME", str(log_file)) - logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_")) + logging_initialize() from loguru import logger logger.info(_FILE_HANDLER_MARKER) @@ -69,7 +76,7 @@ def test_logging_initialize_file_handler_writes_to_file( def test_logging_initialize_filter_func_is_applied(self, capsys: pytest.CaptureFixture[str]) -> None: """A filter_func returning False suppresses all output from the handler.""" - logging_initialize(filter_func=lambda _: False, context=make_context(_PROJECT)) + logging_initialize(filter_func=lambda _: False) from loguru import logger logger.info(_FILTER_MARKER) @@ -77,8 +84,8 @@ def test_logging_initialize_filter_func_is_applied(self, capsys: pytest.CaptureF def test_logging_initialize_replaces_handlers_on_repeated_calls(self, capsys: pytest.CaptureFixture[str]) -> None: """Repeated calls replace existing handlers rather than accumulating them.""" - logging_initialize(context=make_context(_PROJECT)) - logging_initialize(context=make_context(_PROJECT)) + logging_initialize() + logging_initialize() capsys.readouterr() # Drain any buffered output from initialization from loguru import logger @@ -87,13 +94,13 @@ def test_logging_initialize_replaces_handlers_on_repeated_calls(self, capsys: py def test_intercept_handler_drops_sentry_messages(self, capsys: pytest.CaptureFixture[str]) -> None: """InterceptHandler silently drops stdlib log messages containing 'sentry.io'.""" - logging_initialize(context=make_context(_PROJECT)) + logging_initialize() stdlib_logging.getLogger("test.sentry").warning(_SENTRY_MARKER) assert _SENTRY_MARKER not in capsys.readouterr().err def test_logging_initialize_suppresses_psycopg_loggers(self) -> None: """After logging_initialize(), psycopg loggers are set to WARNING to suppress noise.""" - logging_initialize(context=make_context(_PROJECT)) + logging_initialize() assert stdlib_logging.getLogger("psycopg").level == stdlib_logging.WARNING assert stdlib_logging.getLogger("psycopg.pool").level == stdlib_logging.WARNING @@ -102,7 +109,7 @@ def test_logging_initialize_uses_context_project_name( ) -> None: """logging_initialize reads env-var prefix from context.name.""" monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_LEVEL", "DEBUG") - logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_")) + logging_initialize() from loguru import logger logger.debug(_MARKER_MESSAGE) @@ -111,9 +118,13 @@ def test_logging_initialize_uses_context_project_name( def test_logging_initialize_respects_env_prefix_from_context( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """logging_initialize uses ctx.env_prefix to resolve settings env vars.""" + """LogSettings reads env vars from the prefix of the active get_context().""" + monkeypatch.setattr( + "aignostics_foundry_core.log.get_context", + lambda: make_context("myproject", env_prefix="MYPROJECT_"), + ) monkeypatch.setenv("MYPROJECT_LOG_STDERR_ENABLED", "false") - logging_initialize(context=make_context("myproject", env_prefix="MYPROJECT_")) + logging_initialize() from loguru import logger logger.info(_MARKER_MESSAGE) @@ -124,6 +135,24 @@ def test_logging_initialize_respects_env_prefix_from_context( class TestLogSettings: """Behavioural tests for LogSettings validation.""" + @pytest.fixture(autouse=True) + def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "aignostics_foundry_core.log.get_context", + lambda: make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"), + ) + + @pytest.mark.unit + def test_log_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + """LogSettings reads env vars using the env_prefix from the active FoundryContext.""" + monkeypatch.setattr( + "aignostics_foundry_core.log.get_context", + lambda: make_context("proj", env_prefix="PROJ_"), + ) + monkeypatch.setenv("PROJ_LOG_STDERR_ENABLED", "false") + settings = LogSettings() # pyright: ignore[reportCallIssue] + assert settings.stderr_enabled is False + def test_log_settings_file_name_validation_rejects_directory(self, tmp_path: Path) -> None: """Passing an existing directory as file_name raises ValidationError when file_enabled.""" with pytest.raises(ValidationError): diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 03e339d..fab5108 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -41,10 +41,14 @@ def _mk_ctx( class TestSentryInitialize: """Behavioural tests for sentry_initialize().""" + @pytest.fixture(autouse=True) + def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("aignostics_foundry_core.sentry.get_context", _mk_ctx) + def test_sentry_initialize_returns_false_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when TESTPROJECT_SENTRY_ENABLED is not set (default False).""" monkeypatch.delenv(f"{_SENTRY_PREFIX}ENABLED", raising=False) - result = sentry_initialize(integrations=None, context=_mk_ctx()) + result = sentry_initialize(integrations=None) assert result is False def test_sentry_initialize_returns_false_when_sdk_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -52,7 +56,7 @@ def test_sentry_initialize_returns_false_when_sdk_absent(self, monkeypatch: pyte monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) with patch("aignostics_foundry_core.sentry.find_spec", return_value=None): - result = sentry_initialize(integrations=None, context=_mk_ctx()) + result = sentry_initialize(integrations=None) assert result is False def test_sentry_initialize_returns_true_and_calls_init_when_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -64,7 +68,7 @@ def test_sentry_initialize_returns_true_and_calls_init_when_enabled(self, monkey patch(_SENTRY_SDK_SET_CONTEXT), patch(_SENTRY_SDK_IGNORE_LOGGER), ): - result = sentry_initialize(integrations=None, context=_mk_ctx()) + result = sentry_initialize(integrations=None) assert result is True mock_init.assert_called_once() assert mock_init.call_args.kwargs["release"] == f"{_PROJECT}@{_VERSION}" @@ -73,7 +77,7 @@ def test_sentry_initialize_returns_false_when_enabled_but_dsn_none(self, monkeyp """Returns False when enabled but no DSN is configured.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.delenv(f"{_SENTRY_PREFIX}DSN", raising=False) - result = sentry_initialize(integrations=None, context=_mk_ctx()) + result = sentry_initialize(integrations=None) assert result is False def test_sentry_initialize_uses_context_project_name(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -122,6 +126,10 @@ def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.M class TestSentrySettingsDsnValidation: """Tests for SentrySettings DSN edge-case validation paths.""" + @pytest.fixture(autouse=True) + def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("aignostics_foundry_core.sentry.get_context", _mk_ctx) + def test_dsn_missing_scheme_raises(self) -> None: """DSN without a URL scheme raises ValidationError.""" with pytest.raises(ValidationError): @@ -142,6 +150,10 @@ def test_dsn_missing_at_sign_raises(self) -> None: class TestSentrySettings: """Behavioural tests for SentrySettings validation.""" + @pytest.fixture(autouse=True) + def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("aignostics_foundry_core.sentry.get_context", _mk_ctx) + def test_sentry_settings_rejects_invalid_dsn_http_scheme(self) -> None: """DSN with http:// scheme raises ValidationError.""" with pytest.raises(ValidationError): @@ -170,6 +182,16 @@ def test_sentry_settings_default_disabled(self) -> None: settings = SentrySettings() # pyright: ignore[reportCallIssue] assert settings.enabled is False + def test_sentry_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + """SentrySettings reads env vars from the prefix supplied by FoundryContext.""" + monkeypatch.setattr( + "aignostics_foundry_core.sentry.get_context", + lambda: _mk_ctx(env_prefix="PROJ_"), + ) + monkeypatch.setenv("PROJ_SENTRY_ENABLED", "true") + settings = SentrySettings() # pyright: ignore[reportCallIssue] + assert settings.enabled is True + @pytest.mark.unit class TestSetSentryUser: