diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 2b8d94c..5947afa 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -360,7 +360,7 @@ SOFTWARE. ``` -## aignostics-foundry-core (0.1.0) - MIT License +## aignostics-foundry-core (0.2.0) - MIT License 🏭 Foundational infrastructure for Foundry components. diff --git a/docs/decisions/0003-project-context-injection.md b/docs/decisions/0003-project-context-injection.md new file mode 100644 index 0000000..87f6127 --- /dev/null +++ b/docs/decisions/0003-project-context-injection.md @@ -0,0 +1,229 @@ +# 3. Project context injection + +Date: 2026-03-26 + +## Status + +Accepted + +## Context + +`aignostics_foundry_core` is used as a shared library across multiple projects (API servers, CLI tools). Several library functions need project-specific information to work correctly; for example: + +- `locate_subclasses()` needs the project name to scope its module walk +- `boot()` needs the project name, version, and environment +- `sentry_initialize()` needs project metadata plus runtime mode flags (`is_cli`, `is_container`, etc.) + +In Bridge, this is solved by `_constants.py`: a module that computes all of these values at import time using `__name__.split(".")[0]` to derive the project name, `importlib.metadata` for version, and environment variables for the rest. This works because `_constants.py` lives inside the `bridge` package, but cannot be reused as-is in the library, since e.g. `__name__.split(".")[0]` would return `"aignostics_foundry_core"` instead of the calling project's name. + +### Requirements + +1. Library functions must receive project-specific values (project name, version, environment, mode flags, etc.) +2. The logic for deriving these values (currently in `_constants.py`) should not be duplicated across every project that uses the library. +3. Derived values (e.g. `version_full`) must be readable by projects, not just passed into library functions — since they're referenced in many places (API metadata, user-agent strings, etc.). +4. The solution must work for both long-lived API servers and short-lived CLI tools. + +## Options + +**#1 Explicit parameterization** + +Each library function receives the values it needs as arguments. The caller is responsible for computing them — effectively re-implementing `_constants.py` in every project. + +```python +sentry_initialize( + project_name="bridge", + version=__version_with_vcs_ref__, + environment=__env__, + is_container=__is_running_in_container__, + ... +) +``` + +* Pros: fully explicit, no hidden state +* Cons: violates requirement #2 — every project must maintain its own `_constants.py` equivalent; long call signatures + +**#2 Environment variables** + +Projects set `FOUNDRY_CORE_PROJECT_NAME`, `FOUNDRY_CORE_VERSION`, etc.; the library reads them. The caller is still responsible for computing and exporting all derived values (requirement #2 violated in the same way as #1). + +```python +project_name = os.getenv("FOUNDRY_CORE_PROJECT_NAME") +``` + +* Pros: zero code coupling; works naturally in containerised deployments +* Cons: stringly typed; CLI tools are invoked locally where env vars are less reliable; doesn't satisfy requirement #3 (no typed accessor for derived values) + +**#3 Library-level `set_context()` init pattern** + +A one-time call at startup sets global library state; all functions then read from it. This is similar to configuration/init pattern used by logging libraries and the Sentry SDK. + +```python +set_context(project_name="bridge", version=__version__, ...) +locate_subclasses(BaseService) # reads from global state + +def locate_subclasses(_class): + project_name = get_context().name + ... +``` + +* Pros: clean call sites; no threading of values +* Cons: caller still computes the values (requirement #2 violated); global mutable state; harder to test + +**#4 `FoundryContext` Pydantic model + `from_package()` classmethod** + +The library owns the derivation logic in `FoundryContext.from_package(project_name)`, which reads from `importlib.metadata`, `sys.argv`, CI env vars, etc. Projects construct a context and pass it at call sites. + +**Why Pydantic:** a frozen Pydantic model provides an immutable, typed data structure with built-in validation and convenient construction from dicts. It also plays well with subclassing for projects that need extra fields. It is already installed as a dependency and is used for `SentrySettings`, so it fits well within the existing codebase. + +```python +ctx = FoundryContext.from_package("bridge") +locate_subclasses(BaseService, context=ctx) +``` + +* Pros: requirements #1 and #2 satisfied; typed; derivation logic lives once in the library +* Cons: requirement #3 only partially satisfied — projects must hold and thread their own `context` reference to read derived values, which doesn't fully eliminate `_constants.py` + +**#5 `FoundryContext.from_package()` + `set_context()` + `get_context()` (combination of #3 and #4)** + +Extends #4 with a `set_context()` call that stores the context as library-level state, retrieved via `get_context()`. Library functions fall back to the configured default but accept an explicit `context` override for testing. + +```python +# at startup — replaces _constants.py entirely +set_context(FoundryContext.from_package("bridge")) + +# library functions use the configured default +locate_subclasses(BaseService) + +def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list: + context = context or get_context() + ... + +# projects read derived values back from the library +print(get_context().version_full) + +# in tests — explicit override, no global state touched +locate_subclasses(BaseService, context=FoundryContext(name="test-project", ...)) +``` + +* Pros: all four requirements satisfied; `_constants.py` can be deleted outright; ergonomic for production; testable without resetting global state +* Cons: global mutable state, though contained — tests pass context explicitly and never need to reset it + +## Decision + +We use **#5**. + +### Naming + +The central type is named `FoundryContext` (not `ProjectConfig` or `ProjectContext`). Rationale: + +- "Config" was rejected because it implies values loaded from env vars or files; this object is derived at startup from `importlib.metadata`, `sys.argv`, and env vars — it is computed context, not configuration input. The existing `SentrySettings` type already uses the "settings/config" pattern for env-based values. +- "Project" prefix was considered but doesn't communicate which library owns the type. Since `FoundryContext` is specifically the library's handle on a project, naming it after the library makes the dependency explicit and aids discoverability. +- The name is consistent with `SentryContext` (also runtime-computed, also nested within the same design). + +### Structure + +`FoundryContext` is a frozen Pydantic model, making all instances immutable after construction. Runtime mode flags (`is_container`, `is_cli`, `is_test`, `is_library`) are only consumed by `sentry_initialize()`, so they live in a nested `SentryContext` rather than on `FoundryContext` directly: + +```python +class SentryContext(BaseModel): + model_config = ConfigDict(frozen=True) + + is_container: bool + is_cli: bool + is_test: bool + is_library: bool + + +class FoundryContext(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str + version: str + version_full: str + environment: str + env_file: list[Path] + repository_url: str = "" + documentation_url: str = "" + sentry: SentryContext = Field(default_factory=SentryContext) +``` + +Each project calls `set_context()` once at startup. This single line replaces `_constants.py` entirely: + +```python +from aignostics_foundry_core.foundry import FoundryContext, set_context + +set_context(FoundryContext.from_package("bridge")) +``` + +The configured `FoundryContext` is accessible anywhere via `get_context()`: + +```python +from aignostics_foundry_core.foundry import get_context + +# before: from bridge.utils._constants import __version_full__, __project_name__ +# after: +get_context().version_full +get_context().name +``` + +All public library functions fall back to `get_context()` but accept an explicit override: + +```python +from aignostics_foundry_core.foundry import get_context + + +def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list: + context = context or get_context() + ... +``` + +`SentryContext` is kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). `SentryContext` is runtime-computed; `SentrySettings` is env-based. + +### Extending FoundryContext + +Projects that need additional context fields beyond the base set can subclass `FoundryContext`. The subclass overrides `from_package()` to compute its extra fields, using `model_dump()` to forward all base fields: + +```python +class BridgeContext(FoundryContext): + tenant_id: str = "" + deployment_region: str = "eu-west-1" + + @classmethod + def from_package(cls, package_name: str) -> "BridgeContext": + base = super().from_package(package_name) + return cls( + **base.model_dump(), + tenant_id=os.getenv("TENANT_ID", ""), + deployment_region=os.getenv("REGION", "eu-west-1"), + ) +``` + +At startup the subclass instance is passed to `set_context()` as usual: + +```python +foundry.set_context(BridgeContext.from_package("bridge")) +``` + +`get_context()` returns `FoundryContext` — sufficient for all library functions. Project code that needs access to the extended fields keeps its own reference to the concrete instance: + +```python +from aignostics_foundry_core.foundry import set_context + +bridge_context = BridgeContext.from_package("bridge") +set_context(bridge_context) + +# library uses get_context() → FoundryContext — no project-specific fields needed +# project code uses bridge_context directly for its own extended fields +bridge_context.tenant_id +``` + +This avoids module-level generics (which are awkward in Python) while keeping both the library and project code fully typed without casts. + +## Consequences + +- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `get_context()`. +- New projects (API servers and CLI tools alike) require a single `set_context()` call and no boilerplate. +- Production call sites are clean — no context threading. +- Tests can pass a `FoundryContext` directly without touching or resetting global state. +- `SentryContext` nesting makes it clear that the mode flags are Sentry-specific and not general-purpose project metadata. +- Projects that need additional fields subclass `FoundryContext` and pass their subclass to `set_context()`; they hold their own typed reference for project-specific access. diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 1fdb22c..28eb29b 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -23,6 +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`, `BasePageBuilder`, `gui_register_pages`, `gui_run`; 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`, `SentryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, environment, env files, URLs, Sentry flags) derived from package metadata and environment variables | | **di** | Dependency injection | `locate_subclasses`, `locate_implementations`, `load_modules`, `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 | @@ -31,6 +32,49 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei +### foundry + +**Project context injection — single startup call replaces all per-project `_constants.py` files** + +- **Purpose**: Provides `FoundryContext` — a frozen Pydantic model that owns all derivation logic for + project-specific values. One `set_context(FoundryContext.from_package("myproject"))` call at + application startup makes the context available everywhere in the library without threading values + through call sites. Tests pass an explicit context override and never touch global state. +- **Key Features**: + - `SentryContext(BaseModel)` — frozen; four bool flags (`is_container`, `is_cli`, `is_test`, + `is_library`) all defaulting to `False`. + - `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `environment`, + `env_file: list[Path]`, `repository_url`, `documentation_url`, `sentry: SentryContext`. + - `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"`. + - `set_context(ctx)` — installs *ctx* as the process-level singleton. + - `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` +- **Dependencies**: `pydantic>=2`, Python stdlib (`importlib.metadata`, `os`, `sys`, `pathlib`) +- **Import**: + ```python + from aignostics_foundry_core.foundry import FoundryContext, SentryContext, set_context, get_context + ``` +- **Usage example**: + ```python + # Application startup (e.g. main.py or boot.py): + from aignostics_foundry_core.foundry import FoundryContext, set_context, get_context + + set_context(FoundryContext.from_package("myproject")) + + # Library code — no threading of values through parameters: + ctx = get_context() # raises RuntimeError if startup omitted set_context() + logger.info(f"Starting {ctx.name} {ctx.version} in {ctx.environment}") + + # Tests — pass context explicitly, do not call set_context(): + ctx = FoundryContext(name="test", version="0.0.0", version_full="0.0.0", environment="test") + result = my_library_function(context=ctx) + ``` + ### api.exceptions **API exception hierarchy and FastAPI exception handlers** diff --git a/src/aignostics_foundry_core/api/core.py b/src/aignostics_foundry_core/api/core.py index b9398bc..318738b 100644 --- a/src/aignostics_foundry_core/api/core.py +++ b/src/aignostics_foundry_core/api/core.py @@ -6,9 +6,15 @@ - API initialization and metadata building """ +from __future__ import annotations + from typing import TYPE_CHECKING, Any, ClassVar, Self, cast from aignostics_foundry_core.di import load_modules +from aignostics_foundry_core.foundry import get_context + +if TYPE_CHECKING: + from aignostics_foundry_core.foundry import FoundryContext from .exceptions import ( AccessDeniedException, @@ -50,10 +56,10 @@ class VersionedAPIRouter: """ # Class variable to track all created instances - _instances: ClassVar[list["VersionedAPIRouter"]] = [] + _instances: ClassVar[list[VersionedAPIRouter]] = [] @classmethod - def get_instances(cls) -> list["VersionedAPIRouter"]: + def get_instances(cls) -> list[VersionedAPIRouter]: """Get all created router instances. Returns: @@ -117,7 +123,7 @@ def create_public_router( prefix: str | None = None, extra_tags: list[str] | None = None, extra_dependencies: list[Any] | None = None, -) -> "APIRouter": +) -> APIRouter: """Create a public API router (no authentication required). Args: @@ -143,7 +149,7 @@ def create_authenticated_router( prefix: str | None = None, extra_tags: list[str] | None = None, extra_dependencies: list[Any] | None = None, -) -> "APIRouter": +) -> APIRouter: """Create an authenticated API router (requires valid Auth0 session). Args: @@ -173,7 +179,7 @@ def create_admin_router( prefix: str | None = None, extra_tags: list[str] | None = None, extra_dependencies: list[Any] | None = None, -) -> "APIRouter": +) -> APIRouter: """Create an admin API router (requires admin role). Args: @@ -203,7 +209,7 @@ def create_internal_router( prefix: str | None = None, extra_tags: list[str] | None = None, extra_dependencies: list[Any] | None = None, -) -> "APIRouter": +) -> APIRouter: """Create an internal API router (requires internal org membership). Args: @@ -233,7 +239,7 @@ def create_internal_admin_router( prefix: str | None = None, extra_tags: list[str] | None = None, extra_dependencies: list[Any] | None = None, -) -> "APIRouter": +) -> APIRouter: """Create an internal admin API router (requires internal org + admin role). Args: @@ -344,28 +350,31 @@ def build_root_api_tags(base_url: str, versions: list[str]) -> list[dict[str, An def get_versioned_api_instances( - project_name: str, versions: list[str], build_metadata: dict[str, Any] | None = None, -) -> "dict[str, FastAPI]": + *, + context: FoundryContext | None = None, +) -> dict[str, FastAPI]: """Build per-version FastAPI instances and route registered routers to them. - Loads all modules in *project_name* so that ``VersionedAPIRouter`` instances - created at module import time are registered. Each router whose ``version`` - attribute matches a name in *versions* is included in the corresponding + Loads all modules in the configured project package so that ``VersionedAPIRouter`` + instances created at module import time are registered. Each router whose + ``version`` attribute matches a name in *versions* is included in the corresponding FastAPI sub-application. Args: - project_name: Package name whose modules should be loaded (to trigger router registration). versions: Ordered list of API version names (e.g., ``["v1", "v2"]``). build_metadata: Optional extra kwargs forwarded to each ``FastAPI()`` constructor. + context: Project context supplying the package name. When ``None``, + the global context installed via + :func:`aignostics_foundry_core.foundry.set_context` is used. Returns: Mapping from version name to its configured ``FastAPI`` instance. """ from fastapi import FastAPI # noqa: PLC0415 - load_modules(project_name) + load_modules(context=context or get_context()) api_instances: dict[str, FastAPI] = {version: FastAPI(**(build_metadata or {})) for version in versions} for router in VersionedAPIRouter.get_instances(): @@ -386,7 +395,7 @@ def init_api( lifespan: Any | None = None, # noqa: ANN401 exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None, **fastapi_kwargs: Any, # noqa: ANN401 -) -> "FastAPI": +) -> FastAPI: """Initialise a FastAPI application with standard exception handlers. This is a generic factory that creates a ``FastAPI`` instance and registers diff --git a/src/aignostics_foundry_core/cli.py b/src/aignostics_foundry_core/cli.py index 09692de..31a57bf 100644 --- a/src/aignostics_foundry_core/cli.py +++ b/src/aignostics_foundry_core/cli.py @@ -1,22 +1,31 @@ """Command-line interface (CLI) utilities.""" +from __future__ import annotations + import sys from pathlib import Path +from typing import TYPE_CHECKING import typer from aignostics_foundry_core.di import locate_implementations +from aignostics_foundry_core.foundry import get_context + +if TYPE_CHECKING: + from aignostics_foundry_core.foundry import FoundryContext -def prepare_cli(cli: typer.Typer, epilog: str, project_name: str) -> None: +def prepare_cli(cli: typer.Typer, epilog: str, *, context: FoundryContext | None = None) -> None: """Dynamically locate, register and prepare subcommands. Args: cli (typer.Typer): Typer instance epilog (str): Epilog to add - project_name (str): Project name used for subcommand discovery + context: Project context used for subcommand discovery. When ``None``, + the global context installed via + :func:`aignostics_foundry_core.foundry.set_context` is used. """ - for sub_cli in locate_implementations(typer.Typer, project_name): + for sub_cli in locate_implementations(typer.Typer, context=context or get_context()): if sub_cli != cli: cli.add_typer(sub_cli) diff --git a/src/aignostics_foundry_core/di.py b/src/aignostics_foundry_core/di.py index 4843f0e..6537d6b 100644 --- a/src/aignostics_foundry_core/di.py +++ b/src/aignostics_foundry_core/di.py @@ -1,12 +1,20 @@ """Dependency injection using dynamic import and discovery of implementations and subclasses.""" +from __future__ import annotations + import importlib import pkgutil -from collections.abc import Callable from functools import lru_cache from importlib.metadata import entry_points from inspect import isclass -from typing import Any +from typing import TYPE_CHECKING, Any + +from aignostics_foundry_core.foundry import get_context + +if TYPE_CHECKING: + from collections.abc import Callable + + from aignostics_foundry_core.foundry import FoundryContext _implementation_cache: dict[tuple[Any, str], list[Any]] = {} _subclass_cache: dict[tuple[Any, str], list[Any]] = {} @@ -32,15 +40,19 @@ def discover_plugin_packages() -> tuple[str, ...]: return tuple(ep.value for ep in eps) -def load_modules(project_name: str) -> None: - """Import all top-level submodules of the given project package. +def load_modules(*, context: FoundryContext | None = None) -> None: + """Import all top-level submodules of the configured project package. Args: - project_name: The importable package name to scan (e.g. ``"bridge"``). + context: Project context supplying the package name. When ``None``, + the global context installed via :func:`aignostics_foundry_core.foundry.set_context` + is used; :func:`~aignostics_foundry_core.foundry.get_context` raises + ``RuntimeError`` if no context has been configured. """ - package = importlib.import_module(project_name) + ctx = context or get_context() + package = importlib.import_module(ctx.name) for _, name, _ in pkgutil.iter_modules(package.__path__): - importlib.import_module(f"{project_name}.{name}") + importlib.import_module(f"{ctx.name}.{name}") def _scan_packages_deep( @@ -117,7 +129,7 @@ def _scan_packages_shallow( return results -def locate_implementations(_class: type[Any], project_name: str) -> list[Any]: +def locate_implementations(_class: type[Any], *, context: FoundryContext | None = None) -> list[Any]: """Dynamically discover all instances of some class. Searches plugin top-level exports first (shallow scan), then deep-scans all @@ -125,19 +137,21 @@ def locate_implementations(_class: type[Any], project_name: str) -> list[Any]: points; only their top-level ``__init__.py`` exports are examined (submodules are not walked). The main package retains full deep-scan behaviour. - Cache keys include *project_name* to avoid cross-project cache pollution when - multiple projects share this library. + Cache keys include the context name to avoid cross-project cache pollution + when multiple projects share this library. Args: _class: Class to search for. - project_name: Importable package name of the calling project - (e.g. ``"bridge"``). Used as the deep-scan root and as part of the - cache key. + context: Project context supplying the package name. When ``None``, + the global context installed via :func:`aignostics_foundry_core.foundry.set_context` + is used; :func:`~aignostics_foundry_core.foundry.get_context` raises + ``RuntimeError`` if no context has been configured. Returns: List of discovered instances of the given class. """ - cache_key = (_class, project_name) + ctx = context or get_context() + cache_key = (_class, ctx.name) if cache_key in _implementation_cache: return _implementation_cache[cache_key] @@ -146,13 +160,13 @@ def predicate(member: object) -> bool: results = [ *_scan_packages_shallow(discover_plugin_packages(), predicate), - *_scan_packages_deep(project_name, predicate), + *_scan_packages_deep(ctx.name, predicate), ] _implementation_cache[cache_key] = results return results -def locate_subclasses(_class: type[Any], project_name: str) -> list[Any]: +def locate_subclasses(_class: type[Any], *, context: FoundryContext | None = None) -> list[Any]: """Dynamically discover all classes that are subclasses of some type. Searches plugin top-level exports first (shallow scan), then deep-scans all @@ -160,19 +174,21 @@ def locate_subclasses(_class: type[Any], project_name: str) -> list[Any]: points; only their top-level ``__init__.py`` exports are examined (submodules are not walked). The main package retains full deep-scan behaviour. - Cache keys include *project_name* to avoid cross-project cache pollution when - multiple projects share this library. + Cache keys include the context name to avoid cross-project cache pollution + when multiple projects share this library. Args: _class: Parent class of subclasses to search for. - project_name: Importable package name of the calling project - (e.g. ``"bridge"``). Used as the deep-scan root and as part of the - cache key. + context: Project context supplying the package name. When ``None``, + the global context installed via :func:`aignostics_foundry_core.foundry.set_context` + is used; :func:`~aignostics_foundry_core.foundry.get_context` raises + ``RuntimeError`` if no context has been configured. Returns: List of discovered subclasses of the given class. """ - cache_key = (_class, project_name) + ctx = context or get_context() + cache_key = (_class, ctx.name) if cache_key in _subclass_cache: return _subclass_cache[cache_key] @@ -181,7 +197,7 @@ def predicate(member: object) -> bool: results = [ *_scan_packages_shallow(discover_plugin_packages(), predicate), - *_scan_packages_deep(project_name, predicate), + *_scan_packages_deep(ctx.name, predicate), ] _subclass_cache[cache_key] = results return results diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py new file mode 100644 index 0000000..92f23d9 --- /dev/null +++ b/src/aignostics_foundry_core/foundry.py @@ -0,0 +1,240 @@ +"""Project context injection for Foundry components. + +Provides :class:`FoundryContext` — a frozen Pydantic model that derives all +project-specific values (name, version, environment, env files, URLs, Sentry +mode flags) from package metadata and environment variables at call time. + +Typical usage:: + + from aignostics_foundry_core.foundry import FoundryContext, get_context, set_context + + set_context(FoundryContext.from_package("myproject")) + + # Anywhere in the library: + ctx = get_context() # raises RuntimeError if set_context() was not called + +References: + docs/decisions/0003-project-context-injection.md +""" + +from __future__ import annotations + +import os +import sys +from importlib import metadata +from pathlib import Path + +from pydantic import BaseModel, Field + + +def _empty_path_list() -> list[Path]: + return [] + + +class SentryContext(BaseModel): + """Sentry mode flags derived from the runtime environment. + + All flags default to ``False`` so that a plain ``SentryContext()`` is safe to + use as a default value. + """ + + model_config = {"frozen": True} + + is_container: bool = False + is_cli: bool = False + is_test: bool = False + is_library: bool = False + + +class FoundryContext(BaseModel): + """Immutable project context carrying all project-specific values. + + Construct via :meth:`from_package` rather than directly so that all + derivation logic is centralised. + + References: + docs/decisions/0003-project-context-injection.md + """ + + model_config = {"frozen": True} + + name: str + version: str + version_full: str + environment: str + env_file: list[Path] = Field(default_factory=_empty_path_list) + repository_url: str = "" + documentation_url: str = "" + sentry: SentryContext = Field(default_factory=SentryContext) + + @classmethod + def from_package(cls, package_name: str) -> FoundryContext: + """Create a :class:`FoundryContext` by inspecting package metadata and the environment. + + Ports the full derivation logic previously duplicated in each project's + ``_constants.py``. The following environment variables are read: + + * ``VCS_REF``, ``COMMIT_SHA``, ``BUILDER``, ``BUILD_DATE``, + ``CI_RUN_ID``, ``CI_RUN_NUMBER`` — build metadata for :attr:`version_full`. + * ``{NAME}_ENVIRONMENT``, ``ENV``, ``VERCEL_ENV``, ``RAILWAY_ENVIRONMENT`` + — deployment environment. + * ``{NAME}_ENV_FILE`` — optional extra env-file path inserted at index 2 of + :attr:`env_file`. + * ``{NAME}_RUNNING_IN_CONTAINER`` — sets :attr:`SentryContext.is_container`. + * ``PYTEST_RUNNING_{NAME}`` — controls :attr:`SentryContext.is_test` / + :attr:`SentryContext.is_library`. + + Args: + package_name: The importable package name (e.g. ``"bridge"``). + + Returns: + A populated, frozen :class:`FoundryContext`. + """ + name = package_name + name_upper = name.upper() + version = metadata.version(package_name) + environment = _detect_environment(name_upper) + repository_url, documentation_url = _extract_urls(package_name) + + return cls( + name=name, + version=version, + version_full=_build_version_full(version), + environment=environment, + env_file=_build_env_file_list(name, name_upper, environment), + repository_url=repository_url, + documentation_url=documentation_url, + sentry=_build_sentry_context(name, name_upper), + ) + + +def _build_version_full(version: str) -> str: + """Append build metadata to *version* from environment variables. + + Returns: + The version string with optional ``+`` suffix. + """ + vcs_ref = os.getenv("VCS_REF", "unknown") + commit_sha = os.getenv("COMMIT_SHA", "unknown") + builder = os.getenv("BUILDER", "uv") + build_date = os.getenv("BUILD_DATE", "unknown") + ci_run_id = os.getenv("CI_RUN_ID", "unknown") + ci_run_number = os.getenv("CI_RUN_NUMBER", "unknown") + + all_values = [vcs_ref, commit_sha, builder, build_date, ci_run_number, ci_run_id] + if not any(val != "unknown" for val in all_values): + return version + + vcs_parts = [p for p in [vcs_ref, commit_sha] if p != "unknown"] + extra_parts: list[str] = [] + if ci_run_id != "unknown": + extra_parts.append(f"run.{ci_run_id}") + if ci_run_number != "unknown": + extra_parts.append(f"build.{ci_run_number}") + if builder != "unknown": + extra_parts.append(f"builder.{builder}") + if build_date != "unknown": + extra_parts.append(f"built.{build_date}") + + result = version + "+" + "-".join(vcs_parts) + if extra_parts: + result += "---" + "---".join(extra_parts) + return result + + +def _detect_environment(name_upper: str) -> str: + """Return the deployment environment from environment variables.""" + for env_var in [f"{name_upper}_ENVIRONMENT", "ENV", "VERCEL_ENV", "RAILWAY_ENVIRONMENT"]: + value = os.getenv(env_var) + if value: + return value + return "local" + + +def _build_env_file_list(name: str, name_upper: str, environment: str) -> list[Path]: + """Build the ordered list of env files for *name* in *environment*. + + Returns: + Ordered list of candidate env-file paths. + """ + paths: list[Path] = [ + Path.home() / f".{name}" / ".env", + Path.home() / f".{name}" / f".env.{environment}", + Path(".env"), + Path(f".env.{environment}"), + ] + extra = os.getenv(f"{name_upper}_ENV_FILE") + if extra: + paths.insert(2, Path(extra)) + return paths + + +def _extract_urls(package_name: str) -> tuple[str, str]: + """Return ``(repository_url, documentation_url)`` from package metadata.""" + pkg_metadata = metadata.metadata(package_name) + repository_url = "" + documentation_url = "" + for url_entry in pkg_metadata.get_all("Project-URL") or []: + if url_entry.startswith("Source"): + repository_url = url_entry.split(", ", 1)[1] + elif url_entry.startswith("Documentation"): + documentation_url = url_entry.split(", ", 1)[1] + return repository_url, documentation_url + + +def _build_sentry_context(name: str, name_upper: str) -> SentryContext: + """Build :class:`SentryContext` flags from environment and process state. + + Returns: + A populated, frozen :class:`SentryContext`. + """ + is_container = bool(os.getenv(f"{name_upper}_RUNNING_IN_CONTAINER")) + is_cli = sys.argv[0].endswith(name) or (len(sys.argv) > 1 and sys.argv[1] == name) + pytest_running = bool(os.getenv(f"PYTEST_RUNNING_{name_upper}")) + return SentryContext( + is_container=is_container, + is_cli=is_cli, + is_test="pytest" in sys.modules and pytest_running, + is_library=not is_cli and not pytest_running, + ) + + +# Module-level context singleton — set via set_context(), read via get_context(). +_context: FoundryContext | None = None + + +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. + + Args: + ctx: The :class:`FoundryContext` to install. + + References: + docs/decisions/0003-project-context-injection.md + """ + global _context # noqa: PLW0603 + _context = ctx + + +def get_context() -> FoundryContext: + """Return the global project context. + + Returns: + The configured :class:`FoundryContext`. + + Raises: + RuntimeError: If :func:`set_context` has not been called yet. + + References: + docs/decisions/0003-project-context-injection.md + """ + if _context is None: + msg = ( + "get_context() called before set_context() was called. " + "Call set_context(FoundryContext.from_package(...)) at application startup." + ) + raise RuntimeError(msg) + return _context diff --git a/src/aignostics_foundry_core/gui/core.py b/src/aignostics_foundry_core/gui/core.py index 59fd9f7..e47c4d2 100644 --- a/src/aignostics_foundry_core/gui/core.py +++ b/src/aignostics_foundry_core/gui/core.py @@ -7,14 +7,21 @@ - Constants: WINDOW_SIZE, BROWSER_RECONNECT_TIMEOUT, RESPONSE_TIMEOUT """ -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import Any +from __future__ import annotations -from fastapi import FastAPI -from fastapi.routing import APIRouter +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any from aignostics_foundry_core.di import locate_subclasses +from aignostics_foundry_core.foundry import get_context + +if TYPE_CHECKING: + from collections.abc import Callable + + from fastapi import FastAPI + from fastapi.routing import APIRouter + + from aignostics_foundry_core.foundry import FoundryContext WINDOW_SIZE = (1280, 768) BROWSER_RECONNECT_TIMEOUT = 60 * 60 * 24 * 7 # 7 days @@ -42,16 +49,18 @@ def register_pages() -> None: """Register NiceGUI pages.""" -def gui_register_pages(project_name: str) -> None: +def gui_register_pages(*, context: FoundryContext | None = None) -> None: """Register pages from all discovered PageBuilders. - Discovers all ``BasePageBuilder`` subclasses for the given project and calls - ``register_pages()`` on each one. + Discovers all ``BasePageBuilder`` subclasses for the configured project and + calls ``register_pages()`` on each one. Args: - project_name: Project name passed to locate_subclasses for discovery. + context: Project context used for PageBuilder discovery. When ``None``, + the global context installed via + :func:`aignostics_foundry_core.foundry.set_context` is used. """ - page_builders = locate_subclasses(BasePageBuilder, project_name) + page_builders = locate_subclasses(BasePageBuilder, context=context or get_context()) for page_builder in page_builders: page_builder: BasePageBuilder # type: ignore[no-redef] page_builder.register_pages() @@ -103,7 +112,6 @@ def redirect_to_api_docs() -> RedirectResponse: # pyright: ignore[reportUnusedF def gui_run( # noqa: PLR0913, PLR0917 - project_name: str, show: bool = False, host: str | None = None, port: int | None = None, @@ -113,15 +121,17 @@ def gui_run( # noqa: PLR0913, PLR0917 auth_router: APIRouter | None = None, startup_callbacks: list[Callable[[], Any]] | None = None, shutdown_callbacks: list[Callable[[], Any]] | None = None, + *, + context: FoundryContext | None = None, ) -> None: """Start the NiceGUI application. Args: - project_name: Project name for page builder discovery. show: Whether to open a browser window on startup. host: Host to bind to. Defaults to NiceGUI's default. port: Port to listen on. Defaults to an open port found automatically. - title: Title shown in the browser tab. Defaults to ``project_name``. + title: Title shown in the browser tab. Defaults to the project name + from *context*. watch: Whether to reload on source file changes. fastapi_app: Optional FastAPI application to mount at ``/api``. When provided, ``/docs`` is redirected to ``/api/docs``, and the @@ -133,18 +143,23 @@ def gui_run( # noqa: PLR0913, PLR0917 ``app.on_startup``. Use this to initialise a database engine etc. shutdown_callbacks: Optional list of callables registered via ``app.on_shutdown``. Use this to dispose resources on shutdown. + context: Project context used for page builder discovery and window + title. When ``None``, the global context installed via + :func:`aignostics_foundry_core.foundry.set_context` is used. """ from nicegui import app, ui # noqa: PLC0415 from nicegui import native as native_app # noqa: PLC0415 + ctx = context or get_context() + _register_callbacks(app, startup_callbacks, shutdown_callbacks) - gui_register_pages(project_name) + gui_register_pages(context=ctx) if fastapi_app is not None: _mount_fastapi_app(app, fastapi_app, auth_router) ui.run( # pyright: ignore[reportUnknownMemberType] - title=title or project_name, + title=title or ctx.name, native=False, reload=watch, host=host, diff --git a/src/aignostics_foundry_core/gui/nav.py b/src/aignostics_foundry_core/gui/nav.py index bd73bd5..ffff317 100644 --- a/src/aignostics_foundry_core/gui/nav.py +++ b/src/aignostics_foundry_core/gui/nav.py @@ -7,10 +7,17 @@ - gui_get_nav_groups: Collect and sort navigation groups from all NavBuilders """ +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field +from typing import TYPE_CHECKING from aignostics_foundry_core.di import locate_subclasses +from aignostics_foundry_core.foundry import get_context + +if TYPE_CHECKING: + from aignostics_foundry_core.foundry import FoundryContext @dataclass @@ -128,16 +135,18 @@ def get_nav_use_expansion() -> bool: return True -def gui_get_nav_groups(project_name: str) -> list[NavGroup]: +def gui_get_nav_groups(*, context: FoundryContext | None = None) -> list[NavGroup]: """Collect navigation groups from all NavBuilders. Args: - project_name: Project name passed to locate_subclasses for discovery. + context: Project context used for NavBuilder discovery. When ``None``, + the global context installed via + :func:`aignostics_foundry_core.foundry.set_context` is used. Returns: Navigation groups sorted by position (lower = higher in sidebar). """ - nav_builders = locate_subclasses(BaseNavBuilder, project_name) + nav_builders = locate_subclasses(BaseNavBuilder, context=context or get_context()) groups: list[NavGroup] = [] for nav_builder in nav_builders: diff --git a/tests/aignostics_foundry_core/api/core_test.py b/tests/aignostics_foundry_core/api/core_test.py index cf47ec5..87c1af8 100644 --- a/tests/aignostics_foundry_core/api/core_test.py +++ b/tests/aignostics_foundry_core/api/core_test.py @@ -2,6 +2,8 @@ import pytest +from tests.conftest import make_context + TITLE_KEY = "title" TEST_TITLE = "My API" MODULE_TAG = "test-module" @@ -221,7 +223,7 @@ def test_get_versioned_api_instances_returns_fastapi_per_version() -> None: from aignostics_foundry_core.api.core import VersionedAPIRouter, get_versioned_api_instances VersionedAPIRouter(VERSION_GVI) - result = get_versioned_api_instances("aignostics_foundry_core", [VERSION_GVI]) + result = get_versioned_api_instances([VERSION_GVI], context=make_context("aignostics_foundry_core")) assert VERSION_GVI in result assert isinstance(result[VERSION_GVI], FastAPI) diff --git a/tests/aignostics_foundry_core/cli_test.py b/tests/aignostics_foundry_core/cli_test.py index cc8fdbb..43c2ed6 100644 --- a/tests/aignostics_foundry_core/cli_test.py +++ b/tests/aignostics_foundry_core/cli_test.py @@ -6,6 +6,7 @@ import typer from aignostics_foundry_core.cli import no_args_is_help_workaround, prepare_cli +from tests.conftest import make_context _LOCATE_IMPLEMENTATIONS_PATH = "aignostics_foundry_core.cli.locate_implementations" _PROJECT_NAME = "myproj" @@ -41,14 +42,14 @@ class TestPrepareCli: def test_sets_epilog(self) -> None: """prepare_cli sets the epilog on the CLI app.""" cli = typer.Typer() - prepare_cli(cli, _MY_EPILOG, _PROJECT_NAME) + prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) assert cli.info.epilog == _MY_EPILOG def test_adds_no_args_is_help_callback(self) -> None: """prepare_cli installs the no_args_is_help workaround callback.""" cli = typer.Typer() - prepare_cli(cli, _MY_EPILOG, _PROJECT_NAME) + prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) assert hasattr(cli, "no_args_callback_added") assert cli.no_args_callback_added is True # type: ignore[attr-defined] @@ -59,7 +60,7 @@ def test_prepare_cli_propagates_epilog_to_sub_typer(self) -> None: sub = typer.Typer() cli.add_typer(sub) with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[]): - prepare_cli(cli, _MY_EPILOG, _PROJECT_NAME) + prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) assert sub.info.epilog == _MY_EPILOG @@ -69,7 +70,7 @@ def test_prepare_cli_installs_callback_on_sub_typer(self) -> None: sub = typer.Typer() cli.add_typer(sub) with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[]): - prepare_cli(cli, _MY_EPILOG, _PROJECT_NAME) + prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) assert hasattr(sub, "no_args_callback_added") @@ -78,7 +79,7 @@ def test_prepare_cli_adds_discovered_subcommands(self) -> None: cli = typer.Typer() sub_cli = typer.Typer() with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[sub_cli]): - prepare_cli(cli, "epilog", _PROJECT_NAME) + prepare_cli(cli, "epilog", context=make_context(_PROJECT_NAME)) registered = [g.typer_instance for g in cli.registered_groups] assert sub_cli in registered @@ -87,7 +88,7 @@ def test_prepare_cli_skips_self_in_discovery(self) -> None: """prepare_cli does not add cli to itself when it appears in discovered results.""" cli = typer.Typer() with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[cli]): - prepare_cli(cli, "epilog", _PROJECT_NAME) + prepare_cli(cli, "epilog", context=make_context(_PROJECT_NAME)) registered = [g.typer_instance for g in cli.registered_groups] assert cli not in registered diff --git a/tests/aignostics_foundry_core/di_test.py b/tests/aignostics_foundry_core/di_test.py index 42470a9..ecce508 100644 --- a/tests/aignostics_foundry_core/di_test.py +++ b/tests/aignostics_foundry_core/di_test.py @@ -8,6 +8,7 @@ import pytest from aignostics_foundry_core import di +from tests.conftest import make_context # Constants to avoid duplication (SonarQube S1192) MAIN_PKG = "my_project" @@ -194,7 +195,7 @@ def test_load_modules_imports_the_package_itself() -> None: patch.object(di.importlib, "import_module", return_value=pkg) as mock_import, patch.object(di.pkgutil, "iter_modules", return_value=[]), ): - di.load_modules(MAIN_PKG) + di.load_modules(context=make_context(MAIN_PKG)) mock_import.assert_any_call(MAIN_PKG) @@ -211,7 +212,7 @@ def test_load_modules_imports_each_top_level_submodule() -> None: return_value=[("", SUBMOD_A, False), ("", SUBMOD_B, False)], ), ): - di.load_modules(MAIN_PKG) + di.load_modules(context=make_context(MAIN_PKG)) mock_import.assert_any_call(f"{MAIN_PKG}.{SUBMOD_A}") mock_import.assert_any_call(f"{MAIN_PKG}.{SUBMOD_B}") @@ -238,7 +239,7 @@ def test_locate_implementations_searches_plugins(clear_caches: None) -> None: ), patch.object(di.pkgutil, "iter_modules", return_value=[]), ): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert plugin_instance in result @@ -267,7 +268,7 @@ def test_locate_implementations_only_finds_plugin_top_level_exports(clear_caches ), patch.object(di.pkgutil, "iter_modules", return_value=[]), ): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert top_instance in result assert sub_instance not in result @@ -282,7 +283,7 @@ def test_locate_implementations_handles_broken_plugin_package(clear_caches: None main_mod.main_instance = main_instance # type: ignore[attr-defined] with _broken_plugin_package_patches(main_pkg, main_mod): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert main_instance in result @@ -297,7 +298,7 @@ def test_locate_implementations_handles_plugin_with_no_matching_top_level_member main_mod.main_instance = main_instance # type: ignore[attr-defined] with _no_match_plugin_patches(plugin_pkg, main_pkg, main_mod): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert main_instance in result @@ -322,7 +323,7 @@ def test_locate_implementations_deep_scans_main_package(clear_caches: None) -> N ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert main_instance in result @@ -351,7 +352,7 @@ class PluginSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert PluginSub in result @@ -384,7 +385,7 @@ class SubSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert TopSub in result assert SubSub not in result @@ -402,7 +403,7 @@ class MainSub(_DummyBase): main_mod.MainSub = MainSub # type: ignore[attr-defined] with _broken_plugin_package_patches(main_pkg, main_mod): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert MainSub in result @@ -420,7 +421,7 @@ class MainSub(_DummyBase): main_mod.MainSub = MainSub # type: ignore[attr-defined] with _no_match_plugin_patches(plugin_pkg, main_pkg, main_mod): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert MainSub in result @@ -448,7 +449,7 @@ class MainSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert MainSub in result @@ -478,7 +479,7 @@ def test_locate_implementations_no_plugins_detects_main_package(clear_caches: No ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert instance in result @@ -506,7 +507,7 @@ class LocalSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert LocalSub in result @@ -534,7 +535,7 @@ def test_clear_caches_resets_implementation_cache() -> None: ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result_before = di.locate_implementations(_DummyBase, MAIN_PKG) + result_before = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert instance_v1 in result_before di.clear_caches() @@ -553,7 +554,7 @@ def test_clear_caches_resets_implementation_cache() -> None: ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result_after = di.locate_implementations(_DummyBase, MAIN_PKG) + result_after = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert instance_v2 in result_after assert instance_v1 not in result_after @@ -580,7 +581,7 @@ class SubV1(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result_before = di.locate_subclasses(_DummyBase, MAIN_PKG) + result_before = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert SubV1 in result_before di.clear_caches() @@ -600,7 +601,7 @@ class SubV2(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result_after = di.locate_subclasses(_DummyBase, MAIN_PKG) + result_after = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert SubV2 in result_after assert SubV1 not in result_after @@ -631,8 +632,8 @@ def test_locate_implementations_caches_result_on_second_call(clear_caches: None) ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]) as mock_iter, ): - di.locate_implementations(_DummyBase, MAIN_PKG) - di.locate_implementations(_DummyBase, MAIN_PKG) + di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) + di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert mock_iter.call_count == 1 @@ -660,8 +661,8 @@ class LocalSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]) as mock_iter, ): - di.locate_subclasses(_DummyBase, MAIN_PKG) - di.locate_subclasses(_DummyBase, MAIN_PKG) + di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) + di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert mock_iter.call_count == 1 @@ -717,7 +718,7 @@ class LocalSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert LocalSub in result assert _DummyBase not in result @@ -740,7 +741,7 @@ def test_locate_implementations_handles_broken_main_package_submodule(clear_cach ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert result == [] @@ -762,7 +763,7 @@ def test_locate_subclasses_handles_broken_main_package_submodule(clear_caches: N ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert result == [] @@ -793,7 +794,7 @@ def test_locate_implementations_combines_plugin_and_main_package_results(clear_c ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_implementations(_DummyBase, MAIN_PKG) + result = di.locate_implementations(_DummyBase, context=make_context(MAIN_PKG)) assert plugin_instance in result assert main_instance in result @@ -829,7 +830,7 @@ class MainSub(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result = di.locate_subclasses(_DummyBase, MAIN_PKG) + result = di.locate_subclasses(_DummyBase, context=make_context(MAIN_PKG)) assert PluginSub in result assert MainSub in result @@ -863,8 +864,8 @@ def test_locate_implementations_cache_isolated_by_project_name(clear_caches: Non ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result_a = di.locate_implementations(_DummyBase, PROJ_A) - result_b = di.locate_implementations(_DummyBase, PROJ_B) + result_a = di.locate_implementations(_DummyBase, context=make_context(PROJ_A)) + result_b = di.locate_implementations(_DummyBase, context=make_context(PROJ_B)) assert instance_a in result_a assert instance_b not in result_a @@ -904,10 +905,67 @@ class SubB(_DummyBase): ), patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), ): - result_a = di.locate_subclasses(_DummyBase, PROJ_A) - result_b = di.locate_subclasses(_DummyBase, PROJ_B) + result_a = di.locate_subclasses(_DummyBase, context=make_context(PROJ_A)) + result_b = di.locate_subclasses(_DummyBase, context=make_context(PROJ_B)) assert SubA in result_a assert SubB not in result_a assert SubB in result_b assert SubA not in result_b + + +# --------------------------------------------------------------------------- +# Context fallback — global context and error cases +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_locate_subclasses_raises_without_context(clear_caches: None, monkeypatch: pytest.MonkeyPatch) -> None: + """locate_subclasses raises RuntimeError when no context arg and no global context.""" + import aignostics_foundry_core.foundry as _foundry_mod + + monkeypatch.setattr(_foundry_mod, "_context", None) + with pytest.raises(RuntimeError, match="get_context\\(\\) called before set_context"): + di.locate_subclasses(_DummyBase) + + +@pytest.mark.unit +def test_locate_implementations_raises_without_context(clear_caches: None, monkeypatch: pytest.MonkeyPatch) -> None: + """locate_implementations raises RuntimeError when no context arg and no global context.""" + import aignostics_foundry_core.foundry as _foundry_mod + + monkeypatch.setattr(_foundry_mod, "_context", None) + with pytest.raises(RuntimeError, match="get_context\\(\\) called before set_context"): + di.locate_implementations(_DummyBase) + + +@pytest.mark.unit +def test_locate_subclasses_uses_global_context(clear_caches: None, monkeypatch: pytest.MonkeyPatch) -> None: + """locate_subclasses uses the global context when no explicit context is passed.""" + + class GlobalSub(_DummyBase): + pass + + main_pkg = _mock_package() + main_mod = ModuleType(MAIN_PKG_MYMODULE) + main_mod.GlobalSub = GlobalSub # type: ignore[attr-defined] + + import aignostics_foundry_core.foundry as _foundry_mod + + monkeypatch.setattr(_foundry_mod, "_context", make_context(MAIN_PKG)) + + with ( + patch.object(di, "discover_plugin_packages", return_value=()), + patch.object( + di.importlib, + "import_module", + side_effect=_make_import_side_effect({ + MAIN_PKG: main_pkg, + MAIN_PKG_MYMODULE: main_mod, + }), + ), + patch.object(di.pkgutil, "iter_modules", return_value=[("", MYMODULE, False)]), + ): + result = di.locate_subclasses(_DummyBase) # no explicit context + + assert GlobalSub in result diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py new file mode 100644 index 0000000..1dcde79 --- /dev/null +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -0,0 +1,251 @@ +"""Tests for the foundry module — FoundryContext, SentryContext, set_context, get_context.""" + +import importlib.metadata +import sys +from collections.abc import Generator +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from aignostics_foundry_core.foundry import FoundryContext, SentryContext, get_context, set_context + +# Constants (SonarQube S1192) +PACKAGE_NAME = "aignostics_foundry_core" +STAGING = "staging" +ERROR_MSG_FRAGMENT = "set_context" +VCS_REF_VALUE = "abc123" +COMMIT_SHA_VALUE = "deadbeef" +CI_RUN_ID_VALUE = "99" +CI_RUN_NUMBER_VALUE = "42" +BUILD_DATE_VALUE = "2024-01-15" +BUILDER_UNKNOWN = "unknown" + + +@pytest.fixture(autouse=True) +def reset_context(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: + """Reset global _context to None before and after every test. + + Yields: + None + """ + monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) + yield + monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) + + +# --------------------------------------------------------------------------- +# from_package — name and version +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_returns_correct_name() -> None: + """from_package() sets .name to the package_name argument.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.name == PACKAGE_NAME + + +@pytest.mark.unit +def test_from_package_returns_version_from_metadata() -> None: + """from_package() sets .version from importlib.metadata.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.version == importlib.metadata.version(PACKAGE_NAME) + + +# --------------------------------------------------------------------------- +# from_package — environment derivation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_environment_from_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + """from_package() reads environment from the {NAME}_ENVIRONMENT env var.""" + monkeypatch.setenv(f"{PACKAGE_NAME.upper()}_ENVIRONMENT", STAGING) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.environment == STAGING + + +@pytest.mark.unit +def test_from_package_environment_defaults_to_local(monkeypatch: pytest.MonkeyPatch) -> None: + """from_package() defaults environment to 'local' when no env var is set.""" + for var in [f"{PACKAGE_NAME.upper()}_ENVIRONMENT", "ENV", "VERCEL_ENV", "RAILWAY_ENVIRONMENT"]: + monkeypatch.delenv(var, raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.environment == "local" + + +# --------------------------------------------------------------------------- +# from_package — version_full build metadata +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_version_full_equals_version_when_no_build_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full equals version when all build metadata env vars are absent or 'unknown'. + + BUILDER defaults to 'uv', so it must be explicitly set to 'unknown' to + make the any() guard False and skip the version_full enrichment block. + """ + monkeypatch.setenv("BUILDER", BUILDER_UNKNOWN) + for var in ["VCS_REF", "COMMIT_SHA", "BUILD_DATE", "CI_RUN_ID", "CI_RUN_NUMBER"]: + monkeypatch.delenv(var, raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.version_full == ctx.version + + +@pytest.mark.unit +def test_from_package_version_full_includes_vcs_ref(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full contains VCS_REF and starts with '{version}+' when VCS_REF is set.""" + monkeypatch.setenv("VCS_REF", VCS_REF_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.version_full.startswith(ctx.version + "+") + assert VCS_REF_VALUE in ctx.version_full + + +@pytest.mark.unit +def test_from_package_version_full_includes_commit_sha(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full contains COMMIT_SHA when COMMIT_SHA is set.""" + monkeypatch.setenv("COMMIT_SHA", COMMIT_SHA_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert COMMIT_SHA_VALUE in ctx.version_full + + +@pytest.mark.unit +def test_from_package_version_full_joins_vcs_ref_and_commit_sha_with_dash(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full joins VCS_REF and COMMIT_SHA with '-' when both are set.""" + monkeypatch.setenv("VCS_REF", VCS_REF_VALUE) + monkeypatch.setenv("COMMIT_SHA", COMMIT_SHA_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert f"{VCS_REF_VALUE}-{COMMIT_SHA_VALUE}" in ctx.version_full + + +@pytest.mark.unit +def test_from_package_version_full_includes_ci_run_id(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full contains 'run.{CI_RUN_ID}' when CI_RUN_ID is set.""" + monkeypatch.setenv("CI_RUN_ID", CI_RUN_ID_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert f"run.{CI_RUN_ID_VALUE}" in ctx.version_full + + +@pytest.mark.unit +def test_from_package_version_full_includes_ci_run_number(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full contains 'build.{CI_RUN_NUMBER}' when CI_RUN_NUMBER is set.""" + monkeypatch.setenv("CI_RUN_NUMBER", CI_RUN_NUMBER_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert f"build.{CI_RUN_NUMBER_VALUE}" in ctx.version_full + + +@pytest.mark.unit +def test_from_package_version_full_includes_build_date(monkeypatch: pytest.MonkeyPatch) -> None: + """version_full contains 'built.{BUILD_DATE}' when BUILD_DATE is set.""" + monkeypatch.setenv("BUILD_DATE", BUILD_DATE_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert f"built.{BUILD_DATE_VALUE}" in ctx.version_full + + +@pytest.mark.unit +def test_from_package_version_full_omits_builder_and_extra_when_all_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """version_full has no 'builder.' label and no '---' separator when BUILDER is 'unknown'. + + Applies when no CI metadata vars are set, even though VCS_REF is present. + """ + monkeypatch.setenv("VCS_REF", VCS_REF_VALUE) + monkeypatch.setenv("BUILDER", BUILDER_UNKNOWN) + for var in ["COMMIT_SHA", "CI_RUN_ID", "CI_RUN_NUMBER", "BUILD_DATE"]: + monkeypatch.delenv(var, raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert "builder." not in ctx.version_full + assert "---" not in ctx.version_full + + +# --------------------------------------------------------------------------- +# from_package — env_file +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_env_file_contains_home_dotfile() -> None: + """from_package() includes ~/.{name}/.env as the first entry in env_file.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + expected = Path.home() / f".{PACKAGE_NAME}" / ".env" + assert expected in ctx.env_file + + +@pytest.mark.unit +def test_from_package_custom_env_file_inserted_at_index_two(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """When {NAME}_ENV_FILE is set, the custom path is inserted at index 2 of env_file.""" + custom = str(tmp_path / "custom.env") + monkeypatch.setenv(f"{PACKAGE_NAME.upper()}_ENV_FILE", custom) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.env_file[2] == Path(custom) + + +# --------------------------------------------------------------------------- +# from_package — SentryContext flags +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_sentry_is_test_when_pytest_running_env_set(monkeypatch: pytest.MonkeyPatch) -> None: + """sentry.is_test is True when pytest is in sys.modules and PYTEST_RUNNING_{NAME} is set.""" + monkeypatch.setenv(f"PYTEST_RUNNING_{PACKAGE_NAME.upper()}", "1") + # pytest is already in sys.modules when tests run + assert "pytest" in sys.modules + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.sentry.is_test is True + + +# --------------------------------------------------------------------------- +# Model immutability +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_foundry_context_is_frozen() -> None: + """Assigning a field on a frozen FoundryContext raises ValidationError.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + with pytest.raises(ValidationError): + ctx.name = "mutated" # type: ignore[misc] + + +@pytest.mark.unit +def test_sentry_context_defaults_all_false() -> None: + """SentryContext() has all mode flags set to False by default.""" + sentry = SentryContext() + assert sentry.is_container is False + assert sentry.is_cli is False + assert sentry.is_test is False + assert sentry.is_library is False + + +# --------------------------------------------------------------------------- +# set_context / get_context +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_set_context_makes_context_accessible() -> None: + """After set_context(ctx), get_context() returns the same ctx.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + set_context(ctx) + assert get_context() is ctx + + +@pytest.mark.unit +def test_context_raises_before_set_context() -> None: + """get_context() before set_context() raises RuntimeError.""" + with pytest.raises(RuntimeError, match=ERROR_MSG_FRAGMENT): + get_context() + + +@pytest.mark.unit +def test_set_context_replaces_previous_context() -> None: + """Calling set_context() twice makes get_context() return the second context.""" + ctx1 = FoundryContext.from_package(PACKAGE_NAME) + ctx2 = FoundryContext(name="other", version="0.0.0", version_full="0.0.0", environment="test") + set_context(ctx1) + set_context(ctx2) + assert get_context() is ctx2 diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index 4de4677..99f4f58 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -21,6 +21,7 @@ NavItem, gui_get_nav_groups, ) +from tests.conftest import make_context _PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user" _PATCH_GET_AUTH_CLIENT = "aignostics_foundry_core.gui.auth.get_auth_client" @@ -91,7 +92,7 @@ class TestGuiGetNavGroups: def test_returns_empty_list_when_no_builders(self) -> None: """gui_get_nav_groups returns [] when no NavBuilders are discovered.""" with patch(_PATH_NAV_LOCATE, return_value=[]): - result = gui_get_nav_groups("myproject") + result = gui_get_nav_groups(context=make_context("myproject")) assert result == [] def test_collects_group_from_single_builder(self) -> None: @@ -108,7 +109,7 @@ def get_nav_items() -> list[NavItem]: return items with patch(_PATH_NAV_LOCATE, return_value=[FakeBuilder]): - result = gui_get_nav_groups("myproject") + result = gui_get_nav_groups(context=make_context("myproject")) assert len(result) == 1 assert result[0].name == "Fake" @@ -144,7 +145,7 @@ def get_nav_position() -> int: return 100 with patch(_PATH_NAV_LOCATE, return_value=[LowPriority, HighPriority]): - result = gui_get_nav_groups("myproject") + result = gui_get_nav_groups(context=make_context("myproject")) assert [g.name for g in result] == ["High", "Low"] @@ -161,7 +162,7 @@ def get_nav_items() -> list[NavItem]: return [] with patch(_PATH_NAV_LOCATE, return_value=[EmptyBuilder]): - result = gui_get_nav_groups(_PROJECT_NAME) + result = gui_get_nav_groups(context=make_context(_PROJECT_NAME)) assert result == [] @@ -191,7 +192,7 @@ def get_nav_position() -> int: return 100 with patch(_PATH_NAV_LOCATE, return_value=[DefaultPositionBuilder, ExplicitPositionBuilder]): - result = gui_get_nav_groups(_PROJECT_NAME) + result = gui_get_nav_groups(context=make_context(_PROJECT_NAME)) assert [g.name for g in result] == ["Explicit", "Default"] @@ -250,7 +251,7 @@ def test_calls_register_pages_on_each_builder(self) -> None: builder_b = MagicMock(spec=BasePageBuilder) with patch(_PATH_CORE_LOCATE, return_value=[builder_a, builder_b]): - gui_register_pages("myproject") + gui_register_pages(context=make_context("myproject")) builder_a.register_pages.assert_called_once() builder_b.register_pages.assert_called_once() @@ -258,7 +259,7 @@ def test_calls_register_pages_on_each_builder(self) -> None: def test_no_error_when_no_builders_found(self) -> None: """gui_register_pages silently succeeds when no builders are discovered.""" with patch(_PATH_CORE_LOCATE, return_value=[]): - gui_register_pages(_PROJECT_NAME) # must not raise + gui_register_pages(context=make_context(_PROJECT_NAME)) # must not raise # --------------------------------------------------------------------------- @@ -295,12 +296,12 @@ class TestGuiRun: """Tests for gui_run behaviour.""" def _call_gui_run(self, nicegui_mock: MagicMock, **kwargs: object) -> None: - """Call gui_run(_PROJECT_NAME) with nicegui and locate_subclasses mocked.""" + """Call gui_run with context and nicegui and locate_subclasses mocked.""" with ( patch.dict(sys.modules, {"nicegui": nicegui_mock, "starlette.responses": MagicMock()}), patch(_PATH_CORE_LOCATE, return_value=[]), ): - gui_run(_PROJECT_NAME, **kwargs) # type: ignore[arg-type] + gui_run(context=make_context(_PROJECT_NAME), **kwargs) # type: ignore[arg-type] def test_ui_run_called_with_project_name_as_title(self) -> None: """When title is empty, ui.run receives project_name as title.""" @@ -430,14 +431,15 @@ def test_config_state_copied(self) -> None: assert app_mock.state.config is config def test_gui_register_pages_called(self) -> None: - """locate_subclasses is invoked with BasePageBuilder and the project name.""" + """locate_subclasses is invoked with BasePageBuilder and the configured context.""" nicegui_mock, _, _ = _make_nicegui_app_mock() + ctx = make_context(_PROJECT_NAME) with ( patch.dict(sys.modules, {"nicegui": nicegui_mock, "starlette.responses": MagicMock()}), patch(_PATH_CORE_LOCATE, return_value=[]) as locate_mock, ): - gui_run(_PROJECT_NAME) - locate_mock.assert_called_once_with(BasePageBuilder, _PROJECT_NAME) + gui_run(context=ctx) + locate_mock.assert_called_once_with(BasePageBuilder, context=ctx) # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index 7eade93..85252fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,10 @@ import psutil import pytest +from aignostics_foundry_core.foundry import FoundryContext + +__all__ = ["make_context"] + logger = logging.getLogger(__name__) @@ -49,3 +53,8 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: """ if exitstatus == 5: session.exitstatus = 0 + + +def make_context(name: str) -> FoundryContext: + """Create a minimal FoundryContext for testing.""" + return FoundryContext(name=name, version="0.0.0", version_full="0.0.0", environment="test")