diff --git a/README.md b/README.md index a5759dc..5d3d494 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,6 @@ [![Pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/) [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/aignostics/foundry-python) -> [!NOTE] -> This is your project README - please feel free to update as you see fit. -> For first steps after scaffolding, check out [FOUNDRY_README.md](FOUNDRY_README.md). - ---- - Foundational infrastructure for Foundry components. ## Prerequisites @@ -34,6 +28,61 @@ Or follow the [installation guide](https://mise.jdx.dev/getting-started.html) fo ## Usage +### Initialise the context at application startup + +Call `set_context()` once, before any library code runs, then call `boot()` to +initialise logging, the SSL trust chain, and optional Sentry integration: + +```python +# main.py +from aignostics_foundry_core.foundry import FoundryContext, set_context +from aignostics_foundry_core.boot import boot + +set_context(FoundryContext.from_package("myproject")) +boot() +``` + +`FoundryContext.from_package()` derives everything from package metadata and +environment variables: + +- `name`, `version`, `version_full` — from `importlib.metadata` +- `environment` — from `MYPROJECT_ENVIRONMENT` → `ENV` → `VERCEL_ENV` → + `RAILWAY_ENVIRONMENT` → `"local"` +- `is_container`, `is_cli`, `is_test`, `is_library` — detected automatically +- `env_prefix` (`"MYPROJECT_"`) — used by every settings class in the library + +### Access the context from any module + +```python +from aignostics_foundry_core.foundry import get_context + +ctx = get_context() +print(f"Running {ctx.name} v{ctx.version_full} in {ctx.environment}") +# → Running myproject v1.2.3+main---run.12345 in staging +``` + +`get_context()` raises `RuntimeError` with a clear message if `set_context()` +was never called. + +### Pass context explicitly in tests + +Never call `set_context()` in tests. Pass a `FoundryContext` directly to each +function via its optional `context` parameter instead: + +```python +from aignostics_foundry_core.foundry import FoundryContext +from aignostics_foundry_core.log import logging_initialize + +ctx = FoundryContext(name="myproject", version="0.0.0", version_full="0.0.0", environment="test") +logging_initialize(context=ctx) +``` + +All public library functions (`logging_initialize`, `sentry_initialize`, `boot`, +`load_modules`, etc.) accept an optional `context` keyword argument and fall +back to `get_context()` when it is `None`. + +### Health API + ```python from aignostics_foundry_core.health import Health, HealthStatus diff --git a/docs/decisions/0003-project-context-injection.md b/docs/decisions/0003-project-context-injection.md index 87f6127..fd3de32 100644 --- a/docs/decisions/0003-project-context-injection.md +++ b/docs/decisions/0003-project-context-injection.md @@ -118,22 +118,12 @@ The central type is named `FoundryContext` (not `ProjectConfig` or `ProjectConte - "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: +`FoundryContext` is a frozen Pydantic model, making all instances immutable after construction. Runtime mode flags (`is_container`, `is_cli`, `is_test`, `is_library`) live directly on `FoundryContext`. They describe process runtime mode — not Sentry internals — and are consumed by `boot()`, `sentry_initialize()`, and any other code that needs to know how the process is running. `SentrySettings` (env-based SDK configuration) remains separate. ```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) @@ -144,7 +134,10 @@ class FoundryContext(BaseModel): env_file: list[Path] repository_url: str = "" documentation_url: str = "" - sentry: SentryContext = Field(default_factory=SentryContext) + is_container: bool = False + is_cli: bool = False + is_test: bool = False + is_library: bool = False ``` Each project calls `set_context()` once at startup. This single line replaces `_constants.py` entirely: @@ -177,7 +170,7 @@ def locate_subclasses(_class: type, context: FoundryContext | None = None) -> li ... ``` -`SentryContext` is kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). `SentryContext` is runtime-computed; `SentrySettings` is env-based. +The four runtime mode flags are kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). The flags are runtime-computed; `SentrySettings` is env-based. ### Extending FoundryContext @@ -225,5 +218,5 @@ This avoids module-level generics (which are awkward in Python) while keeping bo - 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. +- Runtime mode flags live directly on `FoundryContext`, making them accessible to any code that cares about how the process is running — not just Sentry. `boot()` uses `context.is_library` directly without a separate parameter. - 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 28eb29b..a04aa44 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -12,19 +12,19 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **process** | Current process introspection | `ProcessInfo`, `ParentProcessInfo` Pydantic models and `get_process_info()` for runtime process metadata; `SUBPROCESS_CREATION_FLAGS` for subprocess creation | | **api.exceptions** | API exception hierarchy and FastAPI handlers | `ApiException` (500), `NotFoundException` (404), `AccessDeniedException` (401); `api_exception_handler`, `unhandled_exception_handler`, `validation_exception_handler` for FastAPI registration | | **api.auth** | Auth0 authentication FastAPI dependencies | `AuthSettings` (env-prefix configurable), `UnauthenticatedError`, `ForbiddenError` (403); `get_auth_client`, `get_user`, `require_authenticated`, `require_admin`, `require_internal`, `require_internal_admin` FastAPI dependencies; Auth0 cookie security schemes | -| **api.core** | Versioned API router and FastAPI factory | `VersionedAPIRouter` (tracks all created instances), `API_TAG_*` constants, `create_public/authenticated/admin/internal/internal_admin_router` factories, `build_api_metadata`, `build_versioned_api_tags`, `build_root_api_tags`, `get_versioned_api_instances`, `init_api()` | +| **api.core** | Versioned API router and FastAPI factory | `VersionedAPIRouter` (tracks all created instances), `API_TAG_*` constants, `create_public/authenticated/admin/internal/internal_admin_router` factories, `build_api_metadata`, `build_versioned_api_tags`, `build_root_api_tags`, `get_versioned_api_instances(versions, build_metadata=None, *, context=None)`, `init_api()` | | **api** | Consolidated API sub-package | Re-exports all public symbols from `api.exceptions`, `api.auth`, and `api.core`; import any API symbol directly from `aignostics_foundry_core.api` | -| **log** | Configurable loguru logging initialisation | `logging_initialize(project_name, version, env_file, filter_func)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | -| **sentry** | Configurable Sentry integration | `sentry_initialize(project_name, version, environment, integrations, …)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | +| **log** | Configurable loguru logging initialisation | `logging_initialize(filter_func=None, *, context=None)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | +| **sentry** | Configurable Sentry integration | `sentry_initialize(integrations, *, context=None)`, `SentrySettings` (env-prefix configurable), `set_sentry_user(user, role_claim)` for Auth0 user context | | **service** | FastAPI-injectable base service | `BaseService` ABC with `get_service()` (cached per-class FastAPI `Depends` factory), `key()`, and abstract `health()` / `info()` methods; concrete subclasses implement health checks and module info | | **database** | Async SQLAlchemy session management | `init_engine(db_url, pool_size, max_overflow, pool_timeout)`, `dispose_engine()`, `get_db_session()` (FastAPI dependency), `execute_with_session(func, …)`, `cli_run_with_db(func, …, db_url)`, `cli_run_with_engine(func, …, db_url)`, `with_engine(db_url)` decorator factory; auto-resets engine after `fork()` | -| **cli** | Typer CLI preparation utilities | `prepare_cli(cli, epilog, project_name)` — discovers and registers subcommands via `locate_implementations`, sets epilog recursively, installs `no_args_is_help` workaround; `no_args_is_help_workaround(ctx)` — raises `typer.Exit` when no subcommand is invoked | -| **boot** | Application / library boot sequence | `boot(project_name, version, sentry_integrations, is_library_mode, log_filter, show_cmdline)` — runs once per process: parses `--env` CLI args, initialises logging and Sentry, amends the SSL trust chain via *truststore* and *certifi*, and logs boot/shutdown messages | +| **cli** | Typer CLI preparation utilities | `prepare_cli(cli, epilog, *, context=None)` — discovers and registers subcommands via `locate_implementations`, sets epilog recursively, installs `no_args_is_help` workaround; `no_args_is_help_workaround(ctx)` — raises `typer.Exit` when no subcommand is invoked | +| **boot** | Application / library boot sequence | `boot(context, sentry_integrations, log_filter, show_cmdline)` — runs once per process: parses `--env` CLI args, initialises logging and Sentry, amends the SSL trust chain via *truststore* and *certifi*, and logs boot/shutdown messages | | **user_agent** | Parameterised HTTP user-agent string builder | `user_agent(project_name, version, repository_url)` — builds `{project_name}-python-sdk/{version} (…)` string including platform info, current test, and GitHub Actions run URL | -| **gui** | NiceGUI page helpers, auth decorators, and nav builder | `GUINamespace` (configurable page decorator namespace), `gui` (default singleton), `page_public/authenticated/admin/internal/internal_admin` decorators, `get_gui_user`, `require_gui_user`, `BaseNavBuilder`, `NavItem`, `NavGroup`, `gui_get_nav_groups`, `BasePageBuilder`, `gui_register_pages`, `gui_run`; constants `WINDOW_SIZE`, `BROWSER_RECONNECT_TIMEOUT`, `RESPONSE_TIMEOUT` | +| **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`, `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 | +| **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 | +| **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 | @@ -41,10 +41,9 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei 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`. + `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`). - `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`, @@ -57,7 +56,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Dependencies**: `pydantic>=2`, Python stdlib (`importlib.metadata`, `os`, `sys`, `pathlib`) - **Import**: ```python - from aignostics_foundry_core.foundry import FoundryContext, SentryContext, set_context, get_context + from aignostics_foundry_core.foundry import FoundryContext, set_context, get_context ``` - **Usage example**: ```python @@ -124,7 +123,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - `build_api_metadata(title, description, author_name, author_email, repository_url, documentation_url, version)` — returns a `dict` suitable for `FastAPI(**metadata)` - `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(project_name, versions, build_metadata)` — loads project modules, creates one `FastAPI` per version, routes registered `VersionedAPIRouter` instances to the matching version + - `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 - **Location**: `aignostics_foundry_core/api/core.py` - **Dependencies**: `fastapi>=0.110,<1` (mandatory); `aignostics_foundry_core.di` (`load_modules`) @@ -136,7 +135,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Provides a single, idempotent `boot()` entry-point that initialises the full observability and SSL stack in the correct order. All project-specific metadata is injected as parameters so the function is reusable across any project. - **Key Features**: - - `boot(project_name, version, sentry_integrations, is_library_mode, log_filter, show_cmdline)` — runs once per process; subsequent calls are silent no-ops + - `boot(context, sentry_integrations, log_filter, show_cmdline)` — runs once per process; subsequent calls are silent no-ops - Parses `--env`/`-e KEY=VALUE` CLI arguments: vars matching `{PROJECT_NAME_UPPER}_*` are injected into `os.environ` and removed from `sys.argv` - Calls `logging_initialize` with project metadata - Calls `_amend_ssl_trust_chain`: injects *truststore* into the SSL context (if available) and sets `SSL_CERT_FILE` to the *certifi* bundle path when no system CA bundle is detected @@ -155,7 +154,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **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` - - `logging_initialize(project_name, version, env_file, filter_func)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise + - `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) - **Import**: `from aignostics_foundry_core.log import logging_initialize, LogSettings, InterceptHandler` @@ -167,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` - - `sentry_initialize(project_name, version, environment, integrations, repository_url, documentation_url, is_container, is_test, is_cli, is_library, env_prefix, env_file)` — initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise + - `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` - **Dependencies**: `sentry-sdk>=2,<3` (mandatory); `loguru>=0.7,<1` @@ -230,9 +229,9 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Key Features**: - `PLUGIN_ENTRY_POINT_GROUP: str` — `"aignostics.plugins"` entry-point group constant - `discover_plugin_packages()` — discovers plugin packages registered via `[project.entry-points."aignostics.plugins"]`; LRU-cached - - `load_modules(project_name)` — imports all top-level submodules of the given package - - `locate_implementations(_class, project_name)` — finds all instances of `_class` via shallow plugin scan + deep project scan; cached per `(_class, project_name)` to prevent cross-project pollution - - `locate_subclasses(_class, project_name)` — finds all subclasses of `_class` via shallow plugin scan + deep project scan; cached per `(_class, project_name)` + - `load_modules(*, context=None)` — imports all top-level submodules of the package named by `context`; falls back to the global context set via `set_context()`; raises `RuntimeError` if neither is available + - `locate_implementations(_class, *, context=None)` — finds all instances of `_class` via shallow plugin scan + deep project scan; cached per `(_class, context.name)` to prevent cross-project pollution; raises `RuntimeError` if no context configured + - `locate_subclasses(_class, *, context=None)` — finds all subclasses of `_class` via shallow plugin scan + deep project scan; cached per `(_class, context.name)`; raises `RuntimeError` if no context configured - `clear_caches()` — resets all module-level caches (`_implementation_cache`, `_subclass_cache`, `discover_plugin_packages` LRU cache) - Two internal scan helpers: `_scan_packages_shallow` (plugin top-level exports only) and `_scan_packages_deep` (full submodule walk for the main project) - **Location**: `aignostics_foundry_core/di.py` @@ -276,7 +275,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Provides helpers to bootstrap a Typer application with auto-discovered subcommands, recursive epilog propagation, and a workaround for the Typer `no_args_is_help` bug. - **Key Features**: - - `prepare_cli(cli, epilog, project_name)` — discovers all `typer.Typer` instances via `locate_implementations(typer.Typer, project_name)`, adds them as sub-typers (skipping `cli` itself), sets `cli.info.epilog`, propagates the epilog to all nested commands via `_add_epilog_recursively`, and installs `no_args_is_help_workaround` via `_no_args_is_help_recursively`. Bridge callers pass `project_name=__project_name__`. + - `prepare_cli(cli, epilog, *, context=None)` — discovers all `typer.Typer` instances via `locate_implementations(typer.Typer, context=context)`, adds them as sub-typers (skipping `cli` itself), sets `cli.info.epilog`, propagates the epilog to all nested commands via `_add_epilog_recursively`, and installs `no_args_is_help_workaround` via `_no_args_is_help_recursively`. Pass a `FoundryContext` explicitly or rely on the global context set via `set_context()`. - `no_args_is_help_workaround(ctx)` — Typer callback that prints help and raises `typer.Exit` when `ctx.invoked_subcommand is None`; workaround for https://github.com/fastapi/typer/pull/1240. - **Location**: `aignostics_foundry_core/cli.py` - **Dependencies**: `typer>=0.14,<1` (mandatory); `aignostics_foundry_core.di` diff --git a/src/aignostics_foundry_core/boot.py b/src/aignostics_foundry_core/boot.py index ba3fe2f..191d000 100644 --- a/src/aignostics_foundry_core/boot.py +++ b/src/aignostics_foundry_core/boot.py @@ -17,6 +17,7 @@ from loguru import logger +from aignostics_foundry_core.foundry import get_context from aignostics_foundry_core.log import logging_initialize from aignostics_foundry_core.process import get_process_info from aignostics_foundry_core.sentry import sentry_initialize @@ -38,14 +39,14 @@ from loguru import Record from sentry_sdk.integrations import Integration + from aignostics_foundry_core.foundry import FoundryContext + _boot_called = False -def boot( # noqa: PLR0913, PLR0917 - project_name: str, - version: str, - sentry_integrations: list[Integration] | None, - is_library_mode: bool = False, +def boot( + context: FoundryContext | None = None, + sentry_integrations: list[Integration] | None = None, log_filter: Callable[[Record], bool] | None = None, show_cmdline: bool = True, ) -> None: @@ -64,14 +65,13 @@ def boot( # noqa: PLR0913, PLR0917 6. Register an atexit shutdown message. Args: - project_name: Project identifier (e.g. ``"bridge"``). Used as the - env-var prefix for ``--env`` injection and as the logging / - Sentry release name. - version: Full version string (e.g. ``"1.2.3"``). + context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing + project name, version, environment, and runtime mode flags for logging, + Sentry, and ``--env`` argument injection. When ``None``, the process-level + context registered via :func:`~aignostics_foundry_core.foundry.set_context` + is used. sentry_integrations: List of Sentry SDK integrations to register, or ``None`` to skip Sentry initialisation. - is_library_mode: When ``True`` the boot message includes - ``", library-mode"`` to distinguish library from app boots. log_filter: Optional loguru filter callable forwarded to :func:`~aignostics_foundry_core.log.logging_initialize`. show_cmdline: Whether to include the process command line in the @@ -82,24 +82,16 @@ def boot( # noqa: PLR0913, PLR0917 return _boot_called = True - _parse_env_args(project_name) - logging_initialize(project_name=project_name, version=version, filter_func=log_filter) + ctx = context or get_context() + _parse_env_args(ctx.name) + logging_initialize(filter_func=log_filter, context=ctx) _amend_ssl_trust_chain() - environment = os.environ.get(f"{project_name.upper()}_ENVIRONMENT", "production") sentry_initialize( - project_name=project_name, - version=version, - environment=environment, integrations=sentry_integrations, - is_library=is_library_mode, - ) - _log_boot_message( - project_name=project_name, - version=version, - is_library_mode=is_library_mode, - show_cmdline=show_cmdline, + context=ctx, ) - _register_shutdown_message(project_name=project_name, version=version) + _log_boot_message(context=ctx, show_cmdline=show_cmdline) + _register_shutdown_message(project_name=ctx.name, version=ctx.version) logger.trace("Boot sequence completed successfully.") @@ -164,23 +156,20 @@ def _amend_ssl_trust_chain() -> None: def _log_boot_message( - project_name: str, - version: str, - is_library_mode: bool, + context: FoundryContext, show_cmdline: bool = True, ) -> None: """Log a boot message including version, PID, and parent process info. Args: - project_name: Project name for the boot message. - version: Version string for the boot message. - is_library_mode: Whether to append ``", library-mode"`` to the message. + context: Project context supplying name, version, library mode flag, and + project root path. show_cmdline: Whether to append the process command line. """ - process_info = get_process_info() - mode_suffix = ", library-mode" if is_library_mode else "" + process_info = get_process_info(context=context) + mode_suffix = ", library-mode" if context.is_library else "" message = ( - f"⭐ Booting {project_name} v{version} " + f"⭐ Booting {context.name} v{context.version} " f"(project root {process_info.project_root}, pid {process_info.pid}), " f"parent '{process_info.parent.name}' (pid {process_info.parent.pid}){mode_suffix}" ) diff --git a/src/aignostics_foundry_core/console.py b/src/aignostics_foundry_core/console.py index aef8f4d..8beae22 100644 --- a/src/aignostics_foundry_core/console.py +++ b/src/aignostics_foundry_core/console.py @@ -9,11 +9,21 @@ def _get_console() -> Console: """Get a themed rich console. - The console width can be set via the AIGNOSTICS_CONSOLE_WIDTH environment variable. + The console width is controlled by ``{env_prefix}CONSOLE_WIDTH`` when a + ``FoundryContext`` is available (e.g. ``MYPROJECT_CONSOLE_WIDTH``). + If no context has been set the width defaults to Rich's auto-detection. Returns: Console: The themed rich console. """ + try: + from aignostics_foundry_core.foundry import get_context # noqa: PLC0415 + + env_var = f"{get_context().env_prefix}CONSOLE_WIDTH" + width: int | None = int(os.environ.get(env_var, "0")) or None + except RuntimeError: + width = None + return Console( theme=Theme({ "logging.level.info": "purple4", @@ -23,7 +33,7 @@ def _get_console() -> Console: "warning": "yellow1", "error": "red1", }), - width=int(os.environ.get("AIGNOSTICS_CONSOLE_WIDTH", "0")) or None, + width=width, legacy_windows=False, # Modern Windows (10+) doesn't need width adjustment ) diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py index 92f23d9..ecf486c 100644 --- a/src/aignostics_foundry_core/foundry.py +++ b/src/aignostics_foundry_core/foundry.py @@ -1,7 +1,7 @@ """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 +project-specific values (name, version, environment, env files, URLs, runtime mode flags) from package metadata and environment variables at call time. Typical usage:: @@ -19,7 +19,9 @@ from __future__ import annotations +import importlib.util import os +import string import sys from importlib import metadata from pathlib import Path @@ -31,21 +33,6 @@ 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. @@ -61,11 +48,29 @@ class FoundryContext(BaseModel): name: str version: str version_full: str + """Version string with optional build metadata suffix. + + Derived from :attr:`version` with ``+`` appended when build + environment variables (``VCS_REF``, ``COMMIT_SHA``, etc.) are present. + Falls back to reading the current branch or commit SHA from ``.git/HEAD`` + when ``VCS_REF`` is absent and :attr:`project_path` is set. + """ environment: str env_file: list[Path] = Field(default_factory=_empty_path_list) + env_prefix: str = "" repository_url: str = "" documentation_url: str = "" - sentry: SentryContext = Field(default_factory=SentryContext) + is_container: bool = False + is_cli: bool = False + is_test: bool = False + is_library: bool = False + project_path: Path | None = None + """Absolute path to the project/repo root (directory containing ``.git``). + + Populated by walking up from the installed package location to find the git + root. ``None`` when the package is installed into site-packages without a + source checkout (i.e. no ``.git`` directory is found in any ancestor). + """ @classmethod def from_package(cls, package_name: str) -> FoundryContext: @@ -80,9 +85,8 @@ def from_package(cls, package_name: str) -> FoundryContext: — 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`. + * ``{NAME}_RUNNING_IN_CONTAINER`` — sets :attr:`is_container`. + * ``PYTEST_RUNNING_{NAME}`` — controls :attr:`is_test` / :attr:`is_library`. Args: package_name: The importable package name (e.g. ``"bridge"``). @@ -95,26 +99,74 @@ def from_package(cls, package_name: str) -> FoundryContext: version = metadata.version(package_name) environment = _detect_environment(name_upper) repository_url, documentation_url = _extract_urls(package_name) - + project_path = _find_project_path(package_name) + vcs_ref = os.environ.get("VCS_REF") or (project_path and _get_vcs_ref_from_git(project_path)) or "unknown" return cls( name=name, version=version, - version_full=_build_version_full(version), + version_full=_build_version_full(version, vcs_ref), environment=environment, env_file=_build_env_file_list(name, name_upper, environment), + env_prefix=f"{name_upper}_", repository_url=repository_url, documentation_url=documentation_url, - sentry=_build_sentry_context(name, name_upper), + project_path=project_path, + **_build_runtime_flags(name, name_upper), ) -def _build_version_full(version: str) -> str: +def _find_project_path(package_name: str) -> Path | None: + """Walk up from the installed package location to find the git root. + + Args: + package_name: The importable package name (e.g. ``"aignostics_foundry_core"``). + + Returns: + The directory containing ``.git``, or ``None`` if not found (e.g. the + package is installed into site-packages without a source checkout). + """ + spec = importlib.util.find_spec(package_name) + if spec is None or spec.origin is None: + return None + current = Path(spec.origin).parent + for directory in [current, *current.parents]: + if (directory / ".git").exists(): + return directory + return None + + +def _get_vcs_ref_from_git(project_path: Path) -> str: + """Read the current VCS ref from ``.git/HEAD``. + + Args: + project_path: The repository root (directory containing ``.git``). + + Returns: + Branch name if on a branch, short SHA (7 chars) if in detached HEAD + state, or ``"unknown"`` if the file is missing, unreadable, or in an + unexpected format. + """ + try: + content = (project_path / ".git" / "HEAD").read_text().strip() + except OSError: + return "unknown" + if content.startswith("ref: refs/heads/"): + return content[len("ref: refs/heads/") :] + if len(content) == 40 and all(c in string.hexdigits for c in content): # noqa: PLR2004 + return content[:7] + return "unknown" + + +def _build_version_full(version: str, vcs_ref: str) -> str: """Append build metadata to *version* from environment variables. + Args: + version: The base version string (e.g. ``"1.2.3"``). + vcs_ref: The VCS ref string (branch name, short SHA, or ``"unknown"``). + 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") @@ -182,21 +234,21 @@ def _extract_urls(package_name: str) -> tuple[str, str]: return repository_url, documentation_url -def _build_sentry_context(name: str, name_upper: str) -> SentryContext: - """Build :class:`SentryContext` flags from environment and process state. +def _build_runtime_flags(name: str, name_upper: str) -> dict[str, bool]: + """Compute runtime mode flags from environment and process state. Returns: - A populated, frozen :class:`SentryContext`. + A dict with ``is_container``, ``is_cli``, ``is_test``, and ``is_library`` keys. """ 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, - ) + return { + "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(). diff --git a/src/aignostics_foundry_core/log.py b/src/aignostics_foundry_core/log.py index 9226195..e7c71b0 100644 --- a/src/aignostics_foundry_core/log.py +++ b/src/aignostics_foundry_core/log.py @@ -21,11 +21,15 @@ from pydantic import Field, ValidationInfo, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from aignostics_foundry_core.foundry import get_context + if TYPE_CHECKING: from collections.abc import Callable from loguru import Record + from aignostics_foundry_core.foundry import FoundryContext + _DEFAULT_PROJECT = "foundry" @@ -159,34 +163,33 @@ def validate_file_name_when_enabled(cls, file_name: str, info: ValidationInfo) - def logging_initialize( - project_name: str = _DEFAULT_PROJECT, - version: str = "dev", - env_file: str | None = None, filter_func: "Callable[[Record], bool] | None" = None, + *, + context: "FoundryContext | None" = None, ) -> None: """Initialize logging configuration. Removes all existing loguru handlers, then adds stderr and/or file handlers based on settings read from environment variables with the - ``{project_name.upper()}_LOG_`` prefix. + ``{ctx.env_prefix}LOG_`` prefix (derived from the context). Args: - project_name: Application / project name used as the env-var prefix - (e.g. ``"bridge"`` → ``BRIDGE_LOG_*``) and embedded in log - record extras. - version: Application version string embedded in log record extras. - env_file: Optional path to an ``.env`` file for settings overrides. filter_func: Optional loguru filter callable; receives a ``Record`` and returns ``True`` to keep the message, ``False`` to drop it. + context: Optional :class:`~aignostics_foundry_core.foundry.FoundryContext` + providing the project name and version. Falls back to the + process-level context installed via + :func:`~aignostics_foundry_core.foundry.set_context`. """ - settings = LogSettings(_env_prefix=f"{project_name.upper()}_LOG_", _env_file=env_file) # pyright: ignore[reportCallIssue] + ctx = context or get_context() + settings = LogSettings(_env_prefix=f"{ctx.env_prefix}LOG_", _env_file=ctx.env_file) # pyright: ignore[reportCallIssue] logger.remove() # Remove all default loggers logger.configure( extra={ - "project_name": project_name, - "version": version, + "project_name": ctx.name, + "version": ctx.version, "K_SERVICE": os.getenv("K_SERVICE", ""), } ) diff --git a/src/aignostics_foundry_core/process.py b/src/aignostics_foundry_core/process.py index 9163098..91b7cbc 100644 --- a/src/aignostics_foundry_core/process.py +++ b/src/aignostics_foundry_core/process.py @@ -1,11 +1,12 @@ """Process related utilities.""" import subprocess -from pathlib import Path import psutil from pydantic import BaseModel +from aignostics_foundry_core.foundry import FoundryContext, get_context + SUBPROCESS_CREATION_FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) @@ -19,23 +20,30 @@ class ParentProcessInfo(BaseModel): class ProcessInfo(BaseModel): """Information about the current process.""" - project_root: str + project_root: str | None pid: int parent: ParentProcessInfo cmdline: list[str] -def get_process_info() -> ProcessInfo: +def get_process_info(*, context: FoundryContext | None = None) -> ProcessInfo: """Get information about the current process and its parent. + Args: + context: Project context supplying the project path. When ``None``, + the global context installed via :func:`aignostics_foundry_core.foundry.set_context` + is used. + Returns: ProcessInfo: Object containing process information. """ current_process = psutil.Process() parent = current_process.parent() + ctx = context or get_context() + project_root = str(ctx.project_path) if ctx.project_path else None return ProcessInfo( - project_root=str(Path(__file__).parent.parent.parent), + project_root=project_root, pid=current_process.pid, parent=ParentProcessInfo( name=parent.name() if parent else None, diff --git a/src/aignostics_foundry_core/sentry.py b/src/aignostics_foundry_core/sentry.py index 654d7f9..ccbf848 100644 --- a/src/aignostics_foundry_core/sentry.py +++ b/src/aignostics_foundry_core/sentry.py @@ -9,11 +9,14 @@ from pydantic import AfterValidator, BeforeValidator, Field, PlainSerializer, SecretStr from pydantic_settings import SettingsConfigDict +from aignostics_foundry_core.foundry import get_context from aignostics_foundry_core.settings import OpaqueSettings, strip_to_none_before_validator if TYPE_CHECKING: from sentry_sdk.integrations import Integration + from aignostics_foundry_core.foundry import FoundryContext + _ERR_MSG_MISSING_SCHEME = "Sentry DSN is missing URL scheme (protocol)" _ERR_MSG_MISSING_NETLOC = "Sentry DSN is missing network location (domain)" _ERR_MSG_NON_HTTPS = "Sentry DSN must use HTTPS protocol for security" @@ -219,45 +222,32 @@ class SentrySettings(OpaqueSettings): ] -def sentry_initialize( # noqa: PLR0913, PLR0917 - project_name: str, - version: str, - environment: str, +def sentry_initialize( integrations: "list[Integration] | None", - repository_url: str = "", - documentation_url: str = "", - is_container: bool = False, - is_test: bool = False, - is_cli: bool = False, - is_library: bool = False, - env_prefix: str = "FOUNDRY_SENTRY_", - env_file: str | None = None, + *, + context: "FoundryContext | None" = None, ) -> bool: """Initialize Sentry integration. - All project-specific metadata is passed as explicit parameters rather than - read from project-level constants, making this function reusable across - any project. + All project-specific metadata is derived from *context* (or the global + context installed via :func:`~aignostics_foundry_core.foundry.set_context`). Args: - project_name: Project name used in the Sentry release tag and context. - version: Full version string (e.g. ``"1.2.3+abc1234"``). - environment: Deployment environment string (e.g. ``"production"``). integrations: List of Sentry SDK integrations to register, or ``None``. - repository_url: URL of the source repository (optional context field). - documentation_url: URL of the project documentation (optional context field). - is_container: Whether the application runs inside a container. - is_test: Whether the application is running in test mode. - is_cli: Whether the application is running as a CLI. - is_library: Whether the application is running in library mode. - env_prefix: Environment variable prefix for ``SentrySettings`` - (default ``"FOUNDRY_SENTRY_"``). - env_file: Optional path to an ``.env`` file for settings overrides. + context: :class:`~aignostics_foundry_core.foundry.FoundryContext` providing + project name, version, environment, URLs, and runtime mode flags. + Falls back to the global context set via + :func:`~aignostics_foundry_core.foundry.set_context`. Returns: bool: ``True`` if Sentry was initialised successfully, ``False`` otherwise. """ - settings = SentrySettings(_env_prefix=env_prefix, _env_file=env_file) # pyright: ignore[reportCallIssue] + 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] + ) if not find_spec("sentry_sdk") or not settings.enabled or settings.dsn is None: logger.trace("Sentry integration is disabled or sentry_sdk not found, initialization skipped.") @@ -267,8 +257,8 @@ def sentry_initialize( # noqa: PLR0913, PLR0917 from sentry_sdk.integrations.logging import ignore_logger # noqa: PLC0415 sentry_sdk.init( - release=f"{project_name}@{version}", - environment=environment, + release=f"{ctx.name}@{ctx.version_full}", + environment=ctx.environment, dsn=settings.dsn.get_secret_value().strip(), max_breadcrumbs=settings.max_breadcrumbs, debug=settings.debug, @@ -284,14 +274,14 @@ def sentry_initialize( # noqa: PLR0913, PLR0917 sentry_sdk.set_context( "aignx/base", { - "project_name": project_name, - "repository_url": repository_url, - "documentation_url": documentation_url, - "version_full": version, - "in_container": is_container, - "test_mode": is_test, - "cli_mode": is_cli, - "library_mode": is_library, + "project_name": ctx.name, + "repository_url": ctx.repository_url, + "documentation_url": ctx.documentation_url, + "version_full": ctx.version_full, + "in_container": ctx.is_container, + "test_mode": ctx.is_test, + "cli_mode": ctx.is_cli, + "library_mode": ctx.is_library, }, ) diff --git a/tests/aignostics_foundry_core/boot_test.py b/tests/aignostics_foundry_core/boot_test.py index 7aa1397..3cc4b9b 100644 --- a/tests/aignostics_foundry_core/boot_test.py +++ b/tests/aignostics_foundry_core/boot_test.py @@ -11,9 +11,11 @@ import pytest import aignostics_foundry_core.boot as boot_mod +from aignostics_foundry_core.foundry import set_context +from tests.conftest import make_context _PROJECT = "testapp" -_VERSION = "1.0.0" +_OTHER_PROJECT = "otherapp" @pytest.mark.unit @@ -25,7 +27,7 @@ def test_boot_can_be_called(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - boot_mod.boot(_PROJECT, _VERSION, sentry_integrations=None) # must not raise + boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) # must not raise @pytest.mark.unit @@ -39,8 +41,8 @@ def test_boot_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - boot_mod.boot(_PROJECT, _VERSION, sentry_integrations=None) - boot_mod.boot(_PROJECT, _VERSION, sentry_integrations=None) + boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) + boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) assert mock_logging.call_count == 1 @@ -56,7 +58,7 @@ def test_parse_env_args_injects_matching_vars(monkeypatch: pytest.MonkeyPatch) - monkeypatch.delitem(os.environ, "TESTAPP_FOO", raising=False) monkeypatch.setattr(sys, "argv", ["script.py", "--env", "TESTAPP_FOO=bar"]) - boot_mod.boot(_PROJECT, _VERSION, sentry_integrations=None) + boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) assert os.environ.get("TESTAPP_FOO") == "bar" assert "--env" not in sys.argv @@ -77,6 +79,41 @@ def test_boot_amends_ssl_trust_chain(monkeypatch: pytest.MonkeyPatch) -> None: mock_paths = types.SimpleNamespace(cafile=None) monkeypatch.setattr(ssl, "get_default_verify_paths", lambda: mock_paths) - boot_mod.boot(_PROJECT, _VERSION, sentry_integrations=None) + boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) assert "SSL_CERT_FILE" in os.environ + + +@pytest.mark.unit +def test_boot_uses_global_context_when_none_provided(monkeypatch: pytest.MonkeyPatch) -> None: + """boot() falls back to the process-level context when no context argument is given.""" + monkeypatch.setattr(boot_mod, "_boot_called", False) + mock_logging = MagicMock() + monkeypatch.setattr(boot_mod, "logging_initialize", mock_logging) + monkeypatch.setattr(boot_mod, "sentry_initialize", MagicMock(return_value=False)) + monkeypatch.setattr(boot_mod, "truststore", None) + monkeypatch.setattr(boot_mod, "certifi", None) + + set_context(make_context(_PROJECT)) + boot_mod.boot(sentry_integrations=None) + + call_ctx = mock_logging.call_args.kwargs["context"] + assert call_ctx.name == _PROJECT + + +@pytest.mark.unit +def test_boot_explicit_context_overrides_global(monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit context passed to boot() takes precedence over the global context.""" + monkeypatch.setattr(boot_mod, "_boot_called", False) + mock_sentry = MagicMock(return_value=False) + monkeypatch.setattr(boot_mod, "logging_initialize", MagicMock()) + monkeypatch.setattr(boot_mod, "sentry_initialize", mock_sentry) + monkeypatch.setattr(boot_mod, "truststore", None) + monkeypatch.setattr(boot_mod, "certifi", None) + + set_context(make_context(_OTHER_PROJECT)) + explicit_ctx = make_context(_PROJECT) + boot_mod.boot(context=explicit_ctx, sentry_integrations=None) + + call_ctx = mock_sentry.call_args.kwargs["context"] + assert call_ctx.name == _PROJECT diff --git a/tests/aignostics_foundry_core/console_test.py b/tests/aignostics_foundry_core/console_test.py index 7045d5f..42ed3a3 100644 --- a/tests/aignostics_foundry_core/console_test.py +++ b/tests/aignostics_foundry_core/console_test.py @@ -2,13 +2,27 @@ import importlib import sys +from collections.abc import Generator import pytest from rich.console import Console from aignostics_foundry_core.console import console +from aignostics_foundry_core.foundry import FoundryContext, set_context EXPECTED_THEME_KEYS = ["success", "info", "warning", "error", "debug", "logging.level.info"] +CUSTOM_ENV_PREFIX = "TESTPROJ_" +CUSTOM_WIDTH = "120" +EXPECTED_CUSTOM_WIDTH = 120 +CONSOLE_MODULE = "aignostics_foundry_core.console" + + +@pytest.fixture(autouse=True) +def reset_context(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: + """Reset global _context to None before and after every test.""" + monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) + yield + monkeypatch.setattr("aignostics_foundry_core.foundry._context", None) class TestConsole: @@ -22,18 +36,32 @@ def test_console_is_console_instance(self) -> None: @pytest.mark.unit @pytest.mark.sequential def test_console_default_width(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Module-level console has Rich's default width (80) when env var is not set.""" - monkeypatch.delenv("AIGNOSTICS_CONSOLE_WIDTH", raising=False) - reloaded = importlib.reload(sys.modules["aignostics_foundry_core.console"]) + """Module-level console has Rich's default width (80) when no context is set.""" + reloaded = importlib.reload(sys.modules[CONSOLE_MODULE]) assert reloaded.console.width == 80 @pytest.mark.unit @pytest.mark.sequential - def test_console_custom_width(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Module-level console uses width from AIGNOSTICS_CONSOLE_WIDTH env var.""" - monkeypatch.setenv("AIGNOSTICS_CONSOLE_WIDTH", "100") - reloaded = importlib.reload(sys.modules["aignostics_foundry_core.console"]) - assert reloaded.console.width == 100 + def test_console_width_uses_env_prefix_from_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Console width is read from {env_prefix}CONSOLE_WIDTH when a context is set.""" + ctx = FoundryContext( + name="testproj", + version="0.0.0", + version_full="0.0.0", + environment="test", + env_prefix=CUSTOM_ENV_PREFIX, + ) + set_context(ctx) + monkeypatch.setenv(f"{CUSTOM_ENV_PREFIX}CONSOLE_WIDTH", CUSTOM_WIDTH) + reloaded = importlib.reload(sys.modules[CONSOLE_MODULE]) + assert reloaded.console.width == EXPECTED_CUSTOM_WIDTH + + @pytest.mark.unit + @pytest.mark.sequential + def test_console_width_is_auto_when_no_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Console width defaults to Rich's auto-detection (80 in non-TTY) when no context is set.""" + reloaded = importlib.reload(sys.modules[CONSOLE_MODULE]) + assert reloaded.console.width == 80 @pytest.mark.unit def test_console_theme_contains_expected_keys(self) -> None: diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index 1dcde79..1e043cf 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -1,25 +1,31 @@ -"""Tests for the foundry module — FoundryContext, SentryContext, set_context, get_context.""" +"""Tests for the foundry module — FoundryContext, set_context, get_context.""" import importlib.metadata +import importlib.util import sys from collections.abc import Generator +from importlib.machinery import ModuleSpec from pathlib import Path import pytest from pydantic import ValidationError -from aignostics_foundry_core.foundry import FoundryContext, SentryContext, get_context, set_context +from aignostics_foundry_core.foundry import FoundryContext, get_context, set_context # Constants (SonarQube S1192) PACKAGE_NAME = "aignostics_foundry_core" STAGING = "staging" ERROR_MSG_FRAGMENT = "set_context" VCS_REF_VALUE = "abc123" +VCS_REF_OVERRIDE = "ci-override-ref" COMMIT_SHA_VALUE = "deadbeef" CI_RUN_ID_VALUE = "99" CI_RUN_NUMBER_VALUE = "42" BUILD_DATE_VALUE = "2024-01-15" BUILDER_UNKNOWN = "unknown" +GIT_BRANCH = "main" +GIT_SHA_FULL = "a" * 40 +GIT_SHA_SHORT = "a" * 7 @pytest.fixture(autouse=True) @@ -86,7 +92,14 @@ def test_from_package_version_full_equals_version_when_no_build_metadata(monkeyp 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. + find_spec is stubbed to None so that project_path is None and no git + fallback is attempted. """ + + def _find_spec_none(name: str, package: str | None = None) -> None: + return None + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_none) 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) @@ -161,6 +174,139 @@ def test_from_package_version_full_omits_builder_and_extra_when_all_unknown( assert "---" not in ctx.version_full +# --------------------------------------------------------------------------- +# from_package — project_path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_project_path_is_none_when_package_not_importable(monkeypatch: pytest.MonkeyPatch) -> None: + """from_package() sets project_path=None when importlib cannot locate the package spec.""" + + def _find_spec_none(name: str, package: str | None = None) -> None: + return None + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_none) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.project_path is None + + +@pytest.mark.unit +def test_from_package_project_path_is_none_when_no_git_ancestor( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """from_package() sets project_path=None when no .git directory exists in any ancestor.""" + fake_spec = ModuleSpec(PACKAGE_NAME, None, origin=str(tmp_path / PACKAGE_NAME / "__init__.py")) + + def _find_spec_no_git(name: str, package: str | None = None) -> ModuleSpec: + return fake_spec + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_no_git) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.project_path is None + + +@pytest.mark.unit +def test_from_package_project_path_resolves_git_root() -> None: + """from_package() resolves project_path to a directory containing .git.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.project_path is not None + assert (ctx.project_path / ".git").exists() + + +# --------------------------------------------------------------------------- +# from_package — vcs_ref resolution (git fallback) +# --------------------------------------------------------------------------- + + +def _fake_spec_for(tmp_path: Path) -> ModuleSpec: + """Return a ModuleSpec whose origin sits inside *tmp_path*.""" + return ModuleSpec(PACKAGE_NAME, None, origin=str(tmp_path / PACKAGE_NAME / "__init__.py")) + + +def _make_git_head(tmp_path: Path, content: str) -> None: + """Write *content* to ``tmp_path/.git/HEAD``.""" + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text(content) + + +@pytest.mark.unit +def test_from_package_vcs_ref_from_env_var_takes_precedence(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """VCS_REF env var wins over the local .git/HEAD branch name.""" + _make_git_head(tmp_path, f"ref: refs/heads/{GIT_BRANCH}") + + def _find_spec_tmp(name: str, package: str | None = None) -> ModuleSpec: + return _fake_spec_for(tmp_path) + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_tmp) + monkeypatch.setenv("VCS_REF", VCS_REF_OVERRIDE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert VCS_REF_OVERRIDE in ctx.version_full + assert GIT_BRANCH not in ctx.version_full + + +@pytest.mark.unit +def test_from_package_vcs_ref_reads_branch_from_git_head(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """When VCS_REF is absent, the branch name from .git/HEAD appears in version_full.""" + _make_git_head(tmp_path, f"ref: refs/heads/{GIT_BRANCH}") + + def _find_spec_tmp(name: str, package: str | None = None) -> ModuleSpec: + return _fake_spec_for(tmp_path) + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_tmp) + monkeypatch.delenv("VCS_REF", raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert GIT_BRANCH in ctx.version_full + + +@pytest.mark.unit +def test_from_package_vcs_ref_reads_short_sha_from_detached_head( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """When HEAD contains a 40-char SHA, the first 7 chars appear in version_full.""" + _make_git_head(tmp_path, GIT_SHA_FULL) + + def _find_spec_tmp(name: str, package: str | None = None) -> ModuleSpec: + return _fake_spec_for(tmp_path) + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_tmp) + monkeypatch.delenv("VCS_REF", raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert GIT_SHA_SHORT in ctx.version_full + + +@pytest.mark.unit +def test_from_package_vcs_ref_defaults_to_unknown_when_no_git( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When project_path is None (no git root found), vcs_ref falls back to 'unknown'.""" + + def _find_spec_none(name: str, package: str | None = None) -> None: + return None + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_none) + monkeypatch.delenv("VCS_REF", raising=False) + monkeypatch.setenv("BUILDER", BUILDER_UNKNOWN) + for var in ["COMMIT_SHA", "BUILD_DATE", "CI_RUN_ID", "CI_RUN_NUMBER"]: + monkeypatch.delenv(var, raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + # All metadata fields resolve to "unknown" → version_full equals base version + assert ctx.version_full == ctx.version + + +# --------------------------------------------------------------------------- +# from_package — env_prefix +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_env_prefix_is_upper_name() -> None: + """from_package() sets env_prefix to '{PACKAGE_NAME.upper()}_'.""" + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.env_prefix == f"{PACKAGE_NAME.upper()}_" + + # --------------------------------------------------------------------------- # from_package — env_file # --------------------------------------------------------------------------- @@ -184,18 +330,28 @@ def test_from_package_custom_env_file_inserted_at_index_two(monkeypatch: pytest. # --------------------------------------------------------------------------- -# from_package — SentryContext flags +# from_package — runtime mode 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.""" +def test_from_package_is_test_when_pytest_running_env_set(monkeypatch: pytest.MonkeyPatch) -> None: + """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 + assert ctx.is_test is True + + +@pytest.mark.unit +def test_foundry_context_mode_flags_default_to_false() -> None: + """FoundryContext constructed directly has all four mode flags as False.""" + ctx = FoundryContext(name="test", version="0.0.0", version_full="0.0.0", environment="test") + assert ctx.is_container is False + assert ctx.is_cli is False + assert ctx.is_test is False + assert ctx.is_library is False # --------------------------------------------------------------------------- @@ -211,16 +367,6 @@ def test_foundry_context_is_frozen() -> None: 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 # --------------------------------------------------------------------------- diff --git a/tests/aignostics_foundry_core/log_test.py b/tests/aignostics_foundry_core/log_test.py index 1159377..32a680f 100644 --- a/tests/aignostics_foundry_core/log_test.py +++ b/tests/aignostics_foundry_core/log_test.py @@ -8,6 +8,7 @@ from pydantic import ValidationError from aignostics_foundry_core.log import InterceptHandler, LogSettings, logging_initialize +from tests.conftest import make_context _PROJECT = "testfoundry" _VERSION = "0.0.1" @@ -26,7 +27,7 @@ class TestLoggingInitialize: 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(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT)) from loguru import logger logger.info(_MARKER_MESSAGE) @@ -38,7 +39,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(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_")) from loguru import logger logger.info(_MARKER_MESSAGE) @@ -47,7 +48,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(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT)) stdlib_logging.getLogger("test.intercept").warning(_STDLIB_MESSAGE) captured = capsys.readouterr() assert _STDLIB_MESSAGE in captured.err @@ -59,7 +60,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(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_")) from loguru import logger logger.info(_FILE_HANDLER_MARKER) @@ -68,7 +69,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(_PROJECT, _VERSION, filter_func=lambda _: False) + logging_initialize(filter_func=lambda _: False, context=make_context(_PROJECT)) from loguru import logger logger.info(_FILTER_MARKER) @@ -76,8 +77,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(_PROJECT, _VERSION) - logging_initialize(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT)) + logging_initialize(context=make_context(_PROJECT)) capsys.readouterr() # Drain any buffered output from initialization from loguru import logger @@ -86,16 +87,38 @@ 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(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT)) 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(_PROJECT, _VERSION) + logging_initialize(context=make_context(_PROJECT)) assert stdlib_logging.getLogger("psycopg").level == stdlib_logging.WARNING assert stdlib_logging.getLogger("psycopg.pool").level == stdlib_logging.WARNING + def test_logging_initialize_uses_context_project_name( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> 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()}_")) + from loguru import logger + + logger.debug(_MARKER_MESSAGE) + assert _MARKER_MESSAGE in capsys.readouterr().err + + 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.""" + monkeypatch.setenv("MYPROJECT_LOG_STDERR_ENABLED", "false") + logging_initialize(context=make_context("myproject", env_prefix="MYPROJECT_")) + from loguru import logger + + logger.info(_MARKER_MESSAGE) + assert _MARKER_MESSAGE not in capsys.readouterr().err + @pytest.mark.unit class TestLogSettings: diff --git a/tests/aignostics_foundry_core/process_test.py b/tests/aignostics_foundry_core/process_test.py index 24e373e..47518ee 100644 --- a/tests/aignostics_foundry_core/process_test.py +++ b/tests/aignostics_foundry_core/process_test.py @@ -5,20 +5,27 @@ import pytest +from aignostics_foundry_core.foundry import FoundryContext from aignostics_foundry_core.process import get_process_info +_CTX_NAME = "test" +_CTX_VERSION = "0.0.0" +_CTX_ENVIRONMENT = "test" + @pytest.mark.unit def test_get_process_info_returns_current_process() -> None: """get_process_info() returns info for the currently running process.""" - info = get_process_info() + ctx = FoundryContext(name=_CTX_NAME, version=_CTX_VERSION, version_full=_CTX_VERSION, environment=_CTX_ENVIRONMENT) + info = get_process_info(context=ctx) assert info.pid == os.getpid() @pytest.mark.unit def test_process_info_has_parent() -> None: """ProcessInfo.parent has a non-empty name and positive pid.""" - info = get_process_info() + ctx = FoundryContext(name=_CTX_NAME, version=_CTX_VERSION, version_full=_CTX_VERSION, environment=_CTX_ENVIRONMENT) + info = get_process_info(context=ctx) assert info.parent.name is not None assert len(info.parent.name) > 0 assert info.parent.pid is not None @@ -26,8 +33,28 @@ def test_process_info_has_parent() -> None: @pytest.mark.unit -def test_process_info_project_root_is_directory() -> None: - """ProcessInfo.project_root is an existing directory.""" - info = get_process_info() - project_root = Path(info.project_root) - assert project_root.is_dir() +def test_get_process_info_project_root_from_context(tmp_path: Path) -> None: + """project_root equals str(ctx.project_path) when project_path is set.""" + ctx = FoundryContext( + name=_CTX_NAME, + version=_CTX_VERSION, + version_full=_CTX_VERSION, + environment=_CTX_ENVIRONMENT, + project_path=tmp_path, + ) + info = get_process_info(context=ctx) + assert info.project_root == str(tmp_path) + + +@pytest.mark.unit +def test_get_process_info_project_root_none_when_path_not_set() -> None: + """project_root is None when context has project_path=None.""" + ctx = FoundryContext( + name=_CTX_NAME, + version=_CTX_VERSION, + version_full=_CTX_VERSION, + environment=_CTX_ENVIRONMENT, + project_path=None, + ) + info = get_process_info(context=ctx) + assert info.project_root is None diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 4c22bd5..03e339d 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -5,6 +5,7 @@ import pytest from pydantic import ValidationError +from aignostics_foundry_core.foundry import FoundryContext from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user _VALID_DSN = "https://abc123def456@o99999.ingest.de.sentry.io/1234567" @@ -13,6 +14,27 @@ _ENVIRONMENT = "test" _SENTRY_SET_USER = "sentry_sdk.set_user" _AUTH0_USER = "auth0|x" +_SENTRY_PREFIX = "TESTPROJECT_SENTRY_" +_SENTRY_SDK_INIT = "sentry_sdk.init" +_SENTRY_SDK_SET_CONTEXT = "sentry_sdk.set_context" +_SENTRY_SDK_IGNORE_LOGGER = "sentry_sdk.integrations.logging.ignore_logger" + + +def _mk_ctx( + name: str = _PROJECT, + version: str = _VERSION, + environment: str = _ENVIRONMENT, + env_prefix: str = "TESTPROJECT_", + **kwargs: bool, +) -> FoundryContext: + return FoundryContext( + name=name, + version=version, + version_full=version, + environment=environment, + env_prefix=env_prefix, + **kwargs, # type: ignore[arg-type] + ) @pytest.mark.unit @@ -20,60 +42,81 @@ class TestSentryInitialize: """Behavioural tests for sentry_initialize().""" def test_sentry_initialize_returns_false_when_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Returns False when FOUNDRY_SENTRY_ENABLED is not set (default False).""" - monkeypatch.delenv("FOUNDRY_SENTRY_ENABLED", raising=False) - result = sentry_initialize( - project_name=_PROJECT, - version=_VERSION, - environment=_ENVIRONMENT, - integrations=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()) assert result is False def test_sentry_initialize_returns_false_when_sdk_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when sentry_sdk is not importable (find_spec returns None).""" - monkeypatch.setenv("FOUNDRY_SENTRY_ENABLED", "true") - monkeypatch.setenv("FOUNDRY_SENTRY_DSN", _VALID_DSN) + 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( - project_name=_PROJECT, - version=_VERSION, - environment=_ENVIRONMENT, - integrations=None, - ) + result = sentry_initialize(integrations=None, context=_mk_ctx()) assert result is False def test_sentry_initialize_returns_true_and_calls_init_when_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns True and calls sentry_sdk.init with correct release when enabled with valid DSN.""" - monkeypatch.setenv("FOUNDRY_SENTRY_ENABLED", "true") - monkeypatch.setenv("FOUNDRY_SENTRY_DSN", _VALID_DSN) + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") + monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) with ( - patch("sentry_sdk.init") as mock_init, - patch("sentry_sdk.set_context"), - patch("sentry_sdk.integrations.logging.ignore_logger"), + patch(_SENTRY_SDK_INIT) as mock_init, + patch(_SENTRY_SDK_SET_CONTEXT), + patch(_SENTRY_SDK_IGNORE_LOGGER), ): - result = sentry_initialize( - project_name=_PROJECT, - version=_VERSION, - environment=_ENVIRONMENT, - integrations=None, - ) + result = sentry_initialize(integrations=None, context=_mk_ctx()) assert result is True mock_init.assert_called_once() assert mock_init.call_args.kwargs["release"] == f"{_PROJECT}@{_VERSION}" def test_sentry_initialize_returns_false_when_enabled_but_dsn_none(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when enabled but no DSN is configured.""" - monkeypatch.setenv("FOUNDRY_SENTRY_ENABLED", "true") - monkeypatch.delenv("FOUNDRY_SENTRY_DSN", raising=False) - result = sentry_initialize( - project_name=_PROJECT, - version=_VERSION, - environment=_ENVIRONMENT, - integrations=None, - ) + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") + monkeypatch.delenv(f"{_SENTRY_PREFIX}DSN", raising=False) + result = sentry_initialize(integrations=None, context=_mk_ctx()) assert result is False + def test_sentry_initialize_uses_context_project_name(self, monkeypatch: pytest.MonkeyPatch) -> None: + """sentry_sdk.init release tag uses the context project name.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") + monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) + ctx = _mk_ctx(name="ctxproject") + with ( + patch(_SENTRY_SDK_INIT) as mock_init, + patch(_SENTRY_SDK_SET_CONTEXT), + patch(_SENTRY_SDK_IGNORE_LOGGER), + ): + result = sentry_initialize(integrations=None, context=ctx) + assert result is True + assert mock_init.call_args.kwargs["release"].startswith("ctxproject@") + + def test_sentry_initialize_uses_context_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + """sentry_sdk.init environment arg matches context.environment.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") + monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) + ctx = _mk_ctx(environment="staging") + with ( + patch(_SENTRY_SDK_INIT) as mock_init, + patch(_SENTRY_SDK_SET_CONTEXT), + patch(_SENTRY_SDK_IGNORE_LOGGER), + ): + sentry_initialize(integrations=None, context=ctx) + assert mock_init.call_args.kwargs["environment"] == "staging" + + def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.MonkeyPatch) -> None: + """sentry_sdk.set_context receives runtime mode flags from the context.""" + monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") + monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) + ctx = _mk_ctx(is_test=True) + with ( + patch(_SENTRY_SDK_INIT), + patch(_SENTRY_SDK_SET_CONTEXT) as mock_set_ctx, + patch(_SENTRY_SDK_IGNORE_LOGGER), + ): + sentry_initialize(integrations=None, context=ctx) + ctx_data: dict[str, object] = mock_set_ctx.call_args.args[1] # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] + assert ctx_data["test_mode"] is True + @pytest.mark.unit class TestSentrySettingsDsnValidation: diff --git a/tests/conftest.py b/tests/conftest.py index 85252fb..2a11ca9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,6 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: session.exitstatus = 0 -def make_context(name: str) -> FoundryContext: +def make_context(name: str, env_prefix: str = "") -> FoundryContext: """Create a minimal FoundryContext for testing.""" - return FoundryContext(name=name, version="0.0.0", version_full="0.0.0", environment="test") + return FoundryContext(name=name, version="0.0.0", version_full="0.0.0", environment="test", env_prefix=env_prefix)