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
464 changes: 463 additions & 1 deletion ATTRIBUTIONS.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ classifiers = [
requires-python = ">=3.11, <3.15"

dependencies = [
"asyncpg>=0.29,<1",
"fastapi>=0.110,<1",
"loguru>=0.7,<1",
"platformdirs>=4,<5",
"psutil>=6",
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"rich>=14,<15",
"sentry-sdk>=2,<3",
"sqlalchemy[asyncio]>=2,<3",
"typer>=0.14,<1",
]

[dependency-groups]
Expand All @@ -81,6 +85,7 @@ dev = [
"pytest-watcher>=0.4.3,<1",
"pytest-xdist[psutil]>=3.6.1,<4",
"ruff>=0.14.8,<1",
"aiosqlite>=0.20,<1",
"tomli>=2.1.0",
"types-pyyaml>=6.0.12.20250402",
"types-requests>=2.32.0.20250328",
Expand Down
78 changes: 78 additions & 0 deletions src/aignostics_foundry_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ 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 |
| **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 |
| **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 |
| **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 |
| **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory |
| **di** | Dependency injection | `locate_subclasses`, `locate_implementations`, `load_modules`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery |
| **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status |
Expand Down Expand Up @@ -50,6 +55,19 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
- **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory)
- **Import**: `from aignostics_foundry_core.log import logging_initialize, LogSettings, InterceptHandler`

### sentry

**Configurable Sentry integration for error tracking and performance monitoring**

- **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
- `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`
- **Import**: `from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user`

### models

**Shared output format enum for CLI and API responses**
Expand Down Expand Up @@ -131,6 +149,66 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
- **Location**: `aignostics_foundry_core/health.py`
- **Dependencies**: `pydantic>=2`

### service

**FastAPI-injectable base service class**

- **Purpose**: Provides a reusable `BaseService` ABC that all module services extend. Encapsulates the FastAPI dependency-injection pattern (`Depends(Service.get_service())`) and enforces a consistent `health()` / `info()` interface across all services.
- **Key Features**:
- `BaseService(ABC)` — abstract base class; accepts an optional `settings_class` in `__init__` and loads it via `load_settings`
- `get_service()` — class method that returns a per-class-cached generator callable suitable for `Depends()`; caching is required for `dependency_overrides` to work in tests
- `key()` — returns the second-to-last component of `__module__` as a string identifier (e.g. `"mymodule"` from `"bridge.mymodule._service"`)
- `health()` — abstract `async` method; subclasses return a `Health` instance
- `info(mask_secrets)` — abstract `async` method; subclasses return a `dict[str, Any]`
- `settings()` — returns the loaded `BaseSettings` instance
- **Location**: `aignostics_foundry_core/service.py`
- **Dependencies**: `fastapi>=0.110,<1` (for typing/DI); `pydantic-settings>=2`; `aignostics_foundry_core.health`, `aignostics_foundry_core.settings`
- **Import**: `from aignostics_foundry_core.service import BaseService`

### cli

**Typer CLI preparation utilities**

- **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__`.
- `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`
- **Import**: `from aignostics_foundry_core.cli import prepare_cli, no_args_is_help_workaround`

### database

**Async SQLAlchemy session management**

- **Purpose**: Manages a process-level async database engine singleton, providing session injection for FastAPI routes, background jobs, and CLI commands. All Bridge-specific settings are replaced with explicit parameters.
- **Key Features**:
- `init_engine(db_url, pool_size=10, max_overflow=10, pool_timeout=30)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`).
- `dispose_engine()` — async; disposes the engine; called during application shutdown.
- `get_db_session()` — async generator; yields an `AsyncSession`; raises `RuntimeError` if engine not initialised. Use as a FastAPI `Depends` target.
- `execute_with_session(async_func, *args, **kwargs)` — async; runs `async_func` with a session injected as the `session` keyword argument. For background jobs and CLI helpers.
- `cli_run_with_db(async_func, *args, db_url, pool_size, max_overflow, pool_timeout, **kwargs)` — synchronous wrapper: initialises engine, runs the coroutine, then disposes. For CLI commands.
- `cli_run_with_engine(async_func, *args, db_url, pool_size, max_overflow, pool_timeout, **kwargs)` — like `cli_run_with_db` but does not inject a session; for jobs that manage sessions themselves.
- `with_engine(db_url, pool_size, max_overflow, pool_timeout)` — decorator factory; wraps an async function to initialise the engine before execution. For long-lived workers; does **not** dispose after running.
- Fork safety: `multiprocessing.util.register_after_fork` resets the engine in child processes automatically.
- **Location**: `aignostics_foundry_core/database.py`
- **Dependencies**: `sqlalchemy[asyncio]>=2,<3`, `asyncpg>=0.29,<1` (mandatory); `loguru` for structured logging
- **Import**: `from aignostics_foundry_core.database import init_engine, dispose_engine, get_db_session, execute_with_session, cli_run_with_db, cli_run_with_engine, with_engine`

### user_agent

**Parameterised HTTP user-agent string builder**

- **Purpose**: Generates a standard HTTP User-Agent header value for outgoing requests, embedding project identity, runtime platform info, and CI/test context
- **Key Features**:
- `user_agent(project_name, version, repository_url)` — returns a string in the format `{project_name}-python-sdk/{version} ({platform}; +{repository_url}[; {PYTEST_CURRENT_TEST}][; +{github_run_url}])`
- Automatically includes `PYTEST_CURRENT_TEST` env var when running under pytest
- Automatically includes a `github.com/…/actions/runs/…` URL when `GITHUB_RUN_ID` and `GITHUB_REPOSITORY` env vars are set
- No external dependencies (stdlib `os` and `platform` only)
- **Location**: `aignostics_foundry_core/user_agent.py`
- **Dependencies**: Python stdlib only
- **Import**: `from aignostics_foundry_core.user_agent import user_agent`

## Architecture

<!-- Document your package's architecture here. Consider including:
Expand Down
82 changes: 82 additions & 0 deletions src/aignostics_foundry_core/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Command-line interface (CLI) utilities."""

import sys
from pathlib import Path

import typer

from aignostics_foundry_core.di import locate_implementations


def prepare_cli(cli: typer.Typer, epilog: str, project_name: str) -> None:
"""Dynamically locate, register and prepare subcommands.

Args:
cli (typer.Typer): Typer instance
epilog (str): Epilog to add
project_name (str): Project name used for subcommand discovery
"""
for sub_cli in locate_implementations(typer.Typer, project_name):
if sub_cli != cli:
cli.add_typer(sub_cli)

cli.info.epilog = epilog
if not any(arg.endswith("typer") for arg in Path(sys.argv[0]).parts):
for command in cli.registered_commands:
command.epilog = cli.info.epilog

# add epilog for all subcommands
if not any(arg.endswith("typer") for arg in Path(sys.argv[0]).parts):
_add_epilog_recursively(cli, epilog)

# add no_args_is_help for all subcommands
_no_args_is_help_recursively(cli)


def no_args_is_help_workaround(ctx: typer.Context) -> None:
"""Workaround for Typer bug, see https://github.com/fastapi/typer/pull/1240.

Raises:
typer.Exit: If no subcommand is invoked, prints the help message and exits.
"""
if ctx.invoked_subcommand is None:
print(ctx.get_help())
raise typer.Exit


def _add_epilog_recursively(cli: typer.Typer, epilog: str) -> None:
"""Add epilog to all typers in the tree.

Args:
cli (typer.Typer): Typer instance
epilog (str): Epilog to add
"""
cli.info.epilog = epilog
for group in cli.registered_groups:
typer_instance = group.typer_instance
if (typer_instance is not cli) and typer_instance:
_add_epilog_recursively(typer_instance, epilog)
for command in cli.registered_commands:
command.epilog = cli.info.epilog


def _no_args_is_help_recursively(cli: typer.Typer) -> None:
"""Show help if no command is given by the user.

Args:
cli (typer.Typer): Typer instance
"""
# Apply workaround to the main CLI app itself
if not hasattr(cli, "no_args_callback_added"):
cli.callback(invoke_without_command=True)(no_args_is_help_workaround)
cli.no_args_callback_added = True # type: ignore[attr-defined]

# Apply workaround to all subcommands recursively
for group in cli.registered_groups:
typer_instance = group.typer_instance
if (typer_instance is not cli) and typer_instance:
# Add the callback workaround to each subcommand typer
if not hasattr(typer_instance, "no_args_callback_added"):
typer_instance.callback(invoke_without_command=True)(no_args_is_help_workaround)
typer_instance.no_args_callback_added = True # type: ignore[attr-defined]
_no_args_is_help_recursively(typer_instance)
Loading
Loading