Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ SOFTWARE.

```

## aignostics-foundry-core (0.2.0) - MIT License
## aignostics-foundry-core (0.3.0) - MIT License

🏭 Foundational infrastructure for Foundry components.

Expand Down
15 changes: 8 additions & 7 deletions src/aignostics_foundry_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
| **user_agent** | Parameterised HTTP user-agent string builder | `user_agent(project_name, version, repository_url)` — builds `{project_name}-python-sdk/{version} (…)` string including platform info, current test, and GitHub Actions run URL |
| **gui** | NiceGUI page helpers, auth decorators, and nav builder | `GUINamespace` (configurable page decorator namespace), `gui` (default singleton), `page_public/authenticated/admin/internal/internal_admin` decorators, `get_gui_user`, `require_gui_user`, `BaseNavBuilder`, `NavItem`, `NavGroup`, `gui_get_nav_groups(*, context=None)`, `BasePageBuilder`, `gui_register_pages(*, context=None)`, `gui_run(*, context=None, …)`; constants `WINDOW_SIZE`, `BROWSER_RECONNECT_TIMEOUT`, `RESPONSE_TIMEOUT` |
| **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory |
| **foundry** | Project context injection | `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, environment, env files, URLs, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`) derived from package metadata and environment variables |
| **foundry** | Project context injection | `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, environment, env files, URLs, `python_version`, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`) derived from package metadata and environment variables |
| **di** | Dependency injection | `locate_subclasses(cls, *, context=None)`, `locate_implementations(cls, *, context=None)`, `load_modules(*, context=None)`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery |
| **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status |
| **settings** | Pydantic settings loading | `OpaqueSettings`, `load_settings`, `strip_to_none_before_validator`, `UNHIDE_SENSITIVE_INFO` for env-based settings with secret masking and user-friendly validation errors |
Expand All @@ -42,8 +42,9 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
through call sites. Tests pass an explicit context override and never touch global state.
- **Key Features**:
- `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `environment`,
`env_file: list[Path]`, `repository_url`, `documentation_url`, plus four runtime mode bool
flags: `is_container`, `is_cli`, `is_test`, `is_library` (all default `False`).
`env_file: list[Path]`, `repository_url`, `documentation_url`, `python_version` (Python runtime
version string, e.g. `"3.11.9"`), plus four runtime mode bool flags: `is_container`, `is_cli`,
`is_test`, `is_library` (all default `False`).
- `FoundryContext.from_package(package_name)` — classmethod that derives all values from
`importlib.metadata` and environment variables (`{NAME}_ENVIRONMENT`, `VCS_REF`, `COMMIT_SHA`,
`BUILDER`, `BUILD_DATE`, `CI_RUN_ID`, `CI_RUN_NUMBER`, `{NAME}_ENV_FILE`,
Expand Down Expand Up @@ -96,7 +97,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei

- **Purpose**: Provides Auth0 cookie-based session authentication dependencies for FastAPI routes. All project-specific settings (org ID, role claim) are loaded from `AuthSettings` whose env prefix is configurable at instantiation.
- **Key Features**:
- `AuthSettings(OpaqueSettings)` — reads from `FOUNDRY_AUTH_*` env vars by default; override prefix via constructor kwargs (e.g. `AuthSettings(_env_prefix="BRIDGE_AUTH_", _env_file=".env")`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role)
- `AuthSettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}AUTH_`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role)
- `UnauthenticatedError(Exception)` — raised when a user session is missing or invalid
- `ForbiddenError(ApiException)` — `status_code = 403`; raised when user lacks required role or org membership
- `get_auth_client(request)` — retrieves `AuthClient` from `request.app.state.auth_client`; raises `RuntimeError` if not configured
Expand Down Expand Up @@ -124,7 +125,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
- `build_versioned_api_tags(version_name, repository_url)` — OpenAPI tags for a single versioned sub-app
- `build_root_api_tags(base_url, versions)` — OpenAPI tags for the root app linking to each version's docs
- `get_versioned_api_instances(versions, build_metadata=None, *, context=None)` — loads project modules (resolved via context), creates one `FastAPI` per version, routes registered `VersionedAPIRouter` instances to the matching version
- `init_api(root_path, lifespan, exception_handler_registrations, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered
- `init_api(root_path, lifespan, exception_handler_registrations, versions=None, version_exception_handler_registrations=None, **fastapi_kwargs)` — creates a `FastAPI` with the standard Foundry exception handlers (`ApiException`, `RequestValidationError`, `ValidationError`, `Exception`) pre-registered; when *versions* is supplied, calls `get_versioned_api_instances` internally, optionally applies *version_exception_handler_registrations* to each sub-app, and mounts them at `/{version}` on the root app
- **Location**: `aignostics_foundry_core/api/core.py`
- **Dependencies**: `fastapi>=0.110,<1` (mandatory); `aignostics_foundry_core.di` (`load_modules`)
- **Import**: `from aignostics_foundry_core.api.core import VersionedAPIRouter, init_api, build_api_metadata, …` or `from aignostics_foundry_core.api import …`
Expand Down Expand Up @@ -153,7 +154,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
- **Purpose**: Bootstraps loguru as the primary logging framework, optionally redirecting stdlib `logging` via `InterceptHandler`. All project-specific constants are passed as parameters rather than hard-coded.
- **Key Features**:
- `InterceptHandler(logging.Handler)` — redirects stdlib log records to loguru, preserving original module/function/line metadata
- `LogSettings(BaseSettings)` — reads from `FOUNDRY_LOG_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env")`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging`
- `LogSettings(BaseSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}LOG_`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging`
- `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise
- **Location**: `aignostics_foundry_core/log.py`
- **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory)
Expand All @@ -165,7 +166,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei

- **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants.
- **Key Features**:
- `SentrySettings(OpaqueSettings)` — reads from `FOUNDRY_SENTRY_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env")`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs`
- `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs`
- `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise
- `set_sentry_user(user, role_claim)` — maps Auth0 user claims (`sub` → `id`, `email`, `name`, …) into Sentry scope; pass `None` to clear context; no-op when `sentry_sdk` is absent
- **Location**: `aignostics_foundry_core/sentry.py`
Expand Down
15 changes: 10 additions & 5 deletions src/aignostics_foundry_core/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Authentication dependencies (require_authenticated, require_admin, etc.)
- get_user: Get authenticated user from session
- get_auth_client: Get Auth0 client from app state
- AuthSettings: Configurable auth settings (env prefix overridable via constructor kwargs)
- AuthSettings: Auth settings whose env prefix is derived from the active FoundryContext
"""

import time
Expand All @@ -17,6 +17,7 @@
from loguru import logger
from pydantic_settings import SettingsConfigDict

from aignostics_foundry_core.foundry import get_context
from aignostics_foundry_core.settings import OpaqueSettings, load_settings

from .exceptions import ApiException
Expand All @@ -31,17 +32,21 @@


class AuthSettings(OpaqueSettings):
"""Auth settings with configurable env prefix.
"""Auth settings whose env prefix is derived from the active FoundryContext.

Override prefix at instantiation:
``AuthSettings(_env_prefix="BRIDGE_AUTH_", _env_file=".env")``
The effective prefix is ``{FoundryContext.env_prefix}AUTH_``, resolved at
instantiation time via :func:`aignostics_foundry_core.foundry.get_context`.
"""

model_config = SettingsConfigDict(env_prefix="FOUNDRY_AUTH_", extra="ignore")
model_config = SettingsConfigDict(extra="ignore")

internal_org_id: str | None = None
auth0_role_claim: str = DEFAULT_AUTH0_ROLE_CLAIM

def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
super().__init__(_env_prefix=f"{get_context().env_prefix}AUTH_", **kwargs) # pyright: ignore[reportCallIssue]


class UnauthenticatedError(Exception):
"""Raised when user is not authenticated."""
Expand Down
23 changes: 20 additions & 3 deletions src/aignostics_foundry_core/api/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,20 +394,30 @@ def init_api(
root_path: str = "",
lifespan: Any | None = None, # noqa: ANN401
exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None,
versions: list[str] | None = None,
version_exception_handler_registrations: list[tuple[type[Exception], Any]] | None = None,
**fastapi_kwargs: Any, # noqa: ANN401
) -> FastAPI:
"""Initialise a FastAPI application with standard exception handlers.

This is a generic factory that creates a ``FastAPI`` instance and registers
the standard Foundry exception handlers. Versioned sub-application mounting
(Bridge-specific) is left to the caller; use ``get_versioned_api_instances``
and ``FastAPI.mount`` for that pattern.
the standard Foundry exception handlers. When *versions* is supplied the
function also creates versioned sub-applications via
``get_versioned_api_instances``, optionally applies per-version exception
handlers, and mounts each sub-app at ``/{version}`` on the root app.

Args:
root_path: ASGI root path (useful for reverse-proxy setups).
lifespan: Optional async context manager for application lifespan.
exception_handler_registrations: Additional ``(exc_class, handler)`` pairs
to register before the standard handlers.
versions: Optional list of API version names (e.g. ``["v1", "v2"]``).
When provided, ``get_versioned_api_instances`` is called internally
and each resulting sub-app is mounted at ``/{version}`` on the root
app.
version_exception_handler_registrations: ``(exc_class, handler)`` pairs
to register on *every* versioned sub-app before mounting. Only used
when *versions* is also provided.
**fastapi_kwargs: Extra keyword arguments forwarded to ``FastAPI()``.

Returns:
Expand All @@ -429,4 +439,11 @@ def init_api(
api.add_exception_handler(exc_class_or_status_code=ValidationError, handler=validation_exception_handler)
api.add_exception_handler(exc_class_or_status_code=Exception, handler=unhandled_exception_handler)

if versions:
versioned_apps = get_versioned_api_instances(versions)
for version_name, version_app in versioned_apps.items():
for exc_class, handler in version_exception_handler_registrations or []:
version_app.add_exception_handler(exc_class_or_status_code=exc_class, handler=handler)
api.mount(f"/{version_name}", version_app)

return api
3 changes: 3 additions & 0 deletions src/aignostics_foundry_core/foundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import importlib.util
import os
import platform
import string
import sys
from importlib import metadata
Expand Down Expand Up @@ -64,6 +65,7 @@ class FoundryContext(BaseModel):
is_cli: bool = False
is_test: bool = False
is_library: bool = False
python_version: str = ""
project_path: Path | None = None
"""Absolute path to the project/repo root (directory containing ``.git``).

Expand Down Expand Up @@ -110,6 +112,7 @@ def from_package(cls, package_name: str) -> FoundryContext:
env_prefix=f"{name_upper}_",
repository_url=repository_url,
documentation_url=documentation_url,
python_version=platform.python_version(),
project_path=project_path,
**_build_runtime_flags(name, name_upper),
)
Expand Down
17 changes: 9 additions & 8 deletions src/aignostics_foundry_core/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal

import platformdirs
from loguru import logger
Expand Down Expand Up @@ -120,19 +120,20 @@ def patcher(record_dict: "Record") -> None:
class LogSettings(BaseSettings):
"""Settings for configuring logging behaviour.

Reads from environment variables with the ``FOUNDRY_LOG_`` prefix by
default. Callers can supply a project-specific prefix or env file at
instantiation time using Pydantic Settings v2 constructor kwargs::

settings = LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env")
Reads environment variables using the prefix derived from the active
:class:`~aignostics_foundry_core.foundry.FoundryContext` (e.g.
``MYPROJECT_LOG_`` when the context's ``env_prefix`` is ``MYPROJECT_``).
"""

model_config = SettingsConfigDict(
env_prefix="FOUNDRY_LOG_",
extra="ignore",
env_file_encoding="utf-8",
)

def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
super().__init__(_env_prefix=f"{get_context().env_prefix}LOG_", **kwargs) # pyright: ignore[reportCallIssue]

level: Literal["CRITICAL", "ERROR", "WARNING", "SUCCESS", "INFO", "DEBUG", "TRACE"] = Field(
default="INFO",
description="Log level, see https://loguru.readthedocs.io/en/stable/api/logger.html",
Expand Down Expand Up @@ -182,7 +183,7 @@ def logging_initialize(
:func:`~aignostics_foundry_core.foundry.set_context`.
"""
ctx = context or get_context()
settings = LogSettings(_env_prefix=f"{ctx.env_prefix}LOG_", _env_file=ctx.env_file) # pyright: ignore[reportCallIssue]
settings = LogSettings(_env_file=ctx.env_file) # pyright: ignore[reportCallIssue]

logger.remove() # Remove all default loggers

Expand Down
14 changes: 7 additions & 7 deletions src/aignostics_foundry_core/sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,20 @@ def _validate_https_dsn(value: SecretStr | None) -> SecretStr | None:
class SentrySettings(OpaqueSettings):
"""Configuration settings for Sentry integration.

Reads from environment variables with the ``FOUNDRY_SENTRY_`` prefix by
default. Callers can supply a project-specific prefix or env file at
instantiation time using Pydantic Settings v2 constructor kwargs::

settings = SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env")
Reads environment variables using the prefix derived from the active
:class:`~aignostics_foundry_core.foundry.FoundryContext` (e.g.
``MYPROJECT_SENTRY_`` when the context's ``env_prefix`` is ``MYPROJECT_``).
"""

model_config = SettingsConfigDict(
env_prefix="FOUNDRY_SENTRY_",
env_file_encoding="utf-8",
extra="ignore",
)

def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
super().__init__(_env_prefix=f"{get_context().env_prefix}SENTRY_", **kwargs) # pyright: ignore[reportCallIssue]

enabled: Annotated[
bool,
Field(
Expand Down Expand Up @@ -245,7 +246,6 @@ def sentry_initialize(
ctx = context or get_context()

settings = SentrySettings(
_env_prefix=f"{ctx.env_prefix}SENTRY_", # pyright: ignore[reportCallIssue]
_env_file=ctx.env_file, # pyright: ignore[reportCallIssue]
)

Expand Down
15 changes: 15 additions & 0 deletions tests/aignostics_foundry_core/api/auth_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,25 @@
require_internal,
require_internal_admin,
)
from tests.conftest import make_context

_PATCH_GET_USER = "aignostics_foundry_core.api.auth.get_user"
_PATCH_GET_AUTH_CLIENT = "aignostics_foundry_core.api.auth.get_auth_client"
_PATCH_SET_SENTRY_USER = "aignostics_foundry_core.sentry.set_sentry_user"
_PATCH_GET_CONTEXT = "aignostics_foundry_core.api.auth.get_context"
_INTERNAL_ORG_ID = "org_internal_123"
_OTHER_ORG_ID = "org_other_456"
_USER_NOT_AUTHENTICATED = "User is not authenticated"
_USER_SUB = "auth0|x"
_USER_EMAIL = "x@x.com"


@pytest.fixture(autouse=True)
def _stub_auth_get_context(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction]
"""Stub get_context for all auth tests to preserve FOUNDRY_AUTH_* env var names."""
monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("foundry", "FOUNDRY_"))


@pytest.mark.unit
class TestUnauthenticatedError:
"""Tests for UnauthenticatedError."""
Expand Down Expand Up @@ -91,6 +99,13 @@ def test_auth_settings_role_claim_value(self) -> None:
"""The default role claim is the Aignostics platform bridge claim URL."""
assert DEFAULT_AUTH0_ROLE_CLAIM == "https://aignostics-platform-bridge/role"

def test_auth_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""AuthSettings reads env vars from the prefix supplied by FoundryContext."""
monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("proj", "PROJ_"))
monkeypatch.setenv("PROJ_AUTH_AUTH0_ROLE_CLAIM", "https://custom/role")
settings = AuthSettings()
assert settings.auth0_role_claim == "https://custom/role"


@pytest.mark.unit
class TestGetUser:
Expand Down
Loading
Loading