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
4 changes: 2 additions & 2 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, `python_version`, 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, `version_full`, `version_with_vcs_ref`, 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 @@ -41,7 +41,7 @@ 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**:
- `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `environment`,
- `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `version_with_vcs_ref`, `environment`,
`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`).
Expand Down
28 changes: 28 additions & 0 deletions src/aignostics_foundry_core/foundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ class FoundryContext(BaseModel):
Falls back to reading the current branch or commit SHA from ``.git/HEAD``
when ``VCS_REF`` is absent and :attr:`project_path` is set.
"""
version_with_vcs_ref: str
"""Compact version string with VCS ref and commit SHA only.

Derived from :attr:`version` with ``+<vcs_ref>[-<commit_sha>]`` appended
when ``VCS_REF`` and/or ``COMMIT_SHA`` environment variables are present.
Unlike :attr:`version_full`, this field omits CI build metadata
(``CI_RUN_ID``, ``CI_RUN_NUMBER``, ``BUILDER``, ``BUILD_DATE``).
Falls back to :attr:`version` when neither variable is set.
"""
environment: str
env_file: list[Path] = Field(default_factory=_empty_path_list)
env_prefix: str = ""
Expand Down Expand Up @@ -107,6 +116,7 @@ def from_package(cls, package_name: str) -> FoundryContext:
name=name,
version=version,
version_full=_build_version_full(version, vcs_ref),
version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref),
environment=environment,
env_file=_build_env_file_list(name, name_upper, environment),
env_prefix=f"{name_upper}_",
Expand Down Expand Up @@ -197,6 +207,24 @@ def _build_version_full(version: str, vcs_ref: str) -> str:
return result


def _build_version_with_vcs_ref(version: str, vcs_ref: str) -> str:
"""Append VCS ref and commit SHA to *version*, omitting CI build metadata.

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 ``+<vcs_ref>[-<commit_sha>]`` suffix,
or the bare *version* when both values are ``"unknown"``.
"""
commit_sha = os.getenv("COMMIT_SHA", "unknown")
parts = [p for p in [vcs_ref, commit_sha] if p != "unknown"]
if not parts:
return version
return version + "+" + "-".join(parts)


def _detect_environment(name_upper: str) -> str:
"""Return the deployment environment from environment variables."""
for env_var in [f"{name_upper}_ENVIRONMENT", "ENV", "VERCEL_ENV", "RAILWAY_ENVIRONMENT"]:
Expand Down
24 changes: 16 additions & 8 deletions src/aignostics_foundry_core/user_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@

import os
import platform
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from aignostics_foundry_core.foundry import FoundryContext
from aignostics_foundry_core.foundry import get_context

def user_agent(project_name: str, version: str, repository_url: str) -> str:

def user_agent(*, context: "FoundryContext | None" = None) -> str:
"""Generate a user agent string for HTTP requests.

Format: {project_name}-python-sdk/{version} ({platform}; +{repository_url}; {optional_parts})
Format: {name}-python-sdk/{version_full} ({platform}; +{repository_url}; {optional_parts})

Args:
project_name: The name of the project (e.g. "bridge").
version: The version string (e.g. "1.2.3").
repository_url: The URL of the project repository.
context: The :class:`~aignostics_foundry_core.foundry.FoundryContext` to use.
When ``None``, falls back to the process-level context installed via
:func:`~aignostics_foundry_core.foundry.set_context`.

Returns:
str: The user agent string.
"""
ctx = context or get_context()

current_test = os.getenv("PYTEST_CURRENT_TEST") # Set if running under pytest
github_run_id = os.getenv("GITHUB_RUN_ID") # Set if running in GitHub Actions
github_repository = os.getenv("GITHUB_REPOSITORY") # Set if running in GitHub Actions
Expand All @@ -32,8 +39,9 @@

optional_suffix = "; " + "; ".join(optional_parts) if optional_parts else ""

# Format: {project}-python-sdk/{version} ({platform}; +{repository_url}; {optional_parts})
base_info = f"{project_name}-python-sdk/{version}"
system_info = f"{platform.platform()}; +{repository_url}{optional_suffix}"
# Format: {project}-python-sdk/{version_full} ({platform}; +{repository_url}; {optional_parts})
# TODO(oliverm): Find a way to not hard code python-sdk here. This was taken as such from Bridge.

Check warning on line 43 in src/aignostics_foundry_core/user_agent.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=aignostics_foundry-python-core&issues=AZ1DTEM5ZhiS2cIBroEq&open=AZ1DTEM5ZhiS2cIBroEq&pullRequest=22
base_info = f"{ctx.name}-python-sdk/{ctx.version_full}"
system_info = f"{platform.platform()}; +{ctx.repository_url}{optional_suffix}"

return f"{base_info} ({system_info})"
18 changes: 9 additions & 9 deletions tests/aignostics_foundry_core/api/auth_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
require_internal_admin,
)
from aignostics_foundry_core.foundry import reset_context, set_context
from tests.conftest import make_context
from tests.conftest import TEST_PROJECT_PREFIX, make_context

_INTERNAL_ORG_ID = "org_internal_123"
_OTHER_ORG_ID = "org_other_456"
Expand All @@ -36,7 +36,7 @@ def _auth_context() -> Generator[None, None, None]: # pyright: ignore[reportUnu
Yields:
None
"""
set_context(make_context("foundry", "FOUNDRY_"))
set_context(make_context())
yield
reset_context()

Expand Down Expand Up @@ -105,8 +105,8 @@ def test_auth_settings_role_claim_value(self) -> None:

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

Expand Down Expand Up @@ -266,7 +266,7 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey

async def test_internal_org_member_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""require_internal returns None without raising when user is in the internal org."""
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
request = MagicMock()
user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID, "exp": int(time.time()) + 3600}
fake_client = MagicMock()
Expand Down Expand Up @@ -304,8 +304,8 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey

async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""require_internal_admin raises ForbiddenError when user is in internal org but lacks admin role."""
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False)
request = MagicMock()
user = {
"sub": _USER_SUB,
Expand All @@ -322,8 +322,8 @@ async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch:

async def test_internal_admin_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""require_internal_admin returns None without raising when user is internal org admin."""
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False)
request = MagicMock()
user = {
"sub": _USER_SUB,
Expand Down
31 changes: 15 additions & 16 deletions tests/aignostics_foundry_core/boot_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@

import aignostics_foundry_core.boot as boot_mod
from aignostics_foundry_core.foundry import set_context
from tests.conftest import make_context
from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context

_PROJECT = "testapp"
_OTHER_PROJECT = "otherapp"


Expand All @@ -27,7 +26,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(context=make_context(_PROJECT), sentry_integrations=None) # must not raise
boot_mod.boot(context=make_context(), sentry_integrations=None) # must not raise


@pytest.mark.unit
Expand All @@ -41,8 +40,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(context=make_context(_PROJECT), sentry_integrations=None)
boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None)
boot_mod.boot(context=make_context(), sentry_integrations=None)
boot_mod.boot(context=make_context(), sentry_integrations=None)

assert mock_logging.call_count == 1

Expand All @@ -55,14 +54,14 @@ def test_parse_env_args_injects_matching_vars(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setattr(boot_mod, "sentry_initialize", MagicMock(return_value=False))
monkeypatch.setattr(boot_mod, "truststore", None)
monkeypatch.setattr(boot_mod, "certifi", None)
monkeypatch.delitem(os.environ, "TESTAPP_FOO", raising=False)
monkeypatch.setattr(sys, "argv", ["script.py", "--env", "TESTAPP_FOO=bar"])
monkeypatch.delitem(os.environ, f"{TEST_PROJECT_PREFIX}FOO", raising=False)
monkeypatch.setattr(sys, "argv", ["script.py", "--env", f"{TEST_PROJECT_PREFIX}FOO=bar"])

boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None)
boot_mod.boot(context=make_context(), sentry_integrations=None)

assert os.environ.get("TESTAPP_FOO") == "bar"
assert os.environ.get(f"{TEST_PROJECT_PREFIX}FOO") == "bar"
assert "--env" not in sys.argv
assert "TESTAPP_FOO=bar" not in sys.argv
assert f"{TEST_PROJECT_PREFIX}FOO=bar" not in sys.argv


@pytest.mark.unit
Expand All @@ -79,7 +78,7 @@ 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(context=make_context(_PROJECT), sentry_integrations=None)
boot_mod.boot(context=make_context(), sentry_integrations=None)

assert "SSL_CERT_FILE" in os.environ

Expand All @@ -94,11 +93,11 @@ def test_boot_uses_global_context_when_none_provided(monkeypatch: pytest.MonkeyP
monkeypatch.setattr(boot_mod, "truststore", None)
monkeypatch.setattr(boot_mod, "certifi", None)

set_context(make_context(_PROJECT))
set_context(make_context())
boot_mod.boot(sentry_integrations=None)

call_ctx = mock_logging.call_args.kwargs["context"]
assert call_ctx.name == _PROJECT
assert call_ctx.name == TEST_PROJECT_NAME


@pytest.mark.unit
Expand All @@ -111,9 +110,9 @@ def test_boot_explicit_context_overrides_global(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(boot_mod, "truststore", None)
monkeypatch.setattr(boot_mod, "certifi", None)

set_context(make_context(_OTHER_PROJECT))
explicit_ctx = make_context(_PROJECT)
set_context(make_context())
explicit_ctx = make_context(_OTHER_PROJECT)
boot_mod.boot(context=explicit_ctx, sentry_integrations=None)

call_ctx = mock_sentry.call_args.kwargs["context"]
assert call_ctx.name == _PROJECT
assert call_ctx.name == _OTHER_PROJECT
13 changes: 6 additions & 7 deletions tests/aignostics_foundry_core/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from tests.conftest import make_context

_LOCATE_IMPLEMENTATIONS_PATH = "aignostics_foundry_core.cli.locate_implementations"
_PROJECT_NAME = "myproj"
_MY_EPILOG = "My epilog"


Expand Down Expand Up @@ -42,14 +41,14 @@ class TestPrepareCli:
def test_sets_epilog(self) -> None:
"""prepare_cli sets the epilog on the CLI app."""
cli = typer.Typer()
prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME))
prepare_cli(cli, _MY_EPILOG, context=make_context())

assert cli.info.epilog == _MY_EPILOG

def test_adds_no_args_is_help_callback(self) -> None:
"""prepare_cli installs the no_args_is_help workaround callback."""
cli = typer.Typer()
prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME))
prepare_cli(cli, _MY_EPILOG, context=make_context())

assert hasattr(cli, "no_args_callback_added")
assert cli.no_args_callback_added is True # type: ignore[attr-defined]
Expand All @@ -60,7 +59,7 @@ def test_prepare_cli_propagates_epilog_to_sub_typer(self) -> None:
sub = typer.Typer()
cli.add_typer(sub)
with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[]):
prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME))
prepare_cli(cli, _MY_EPILOG, context=make_context())

assert sub.info.epilog == _MY_EPILOG

Expand All @@ -70,7 +69,7 @@ def test_prepare_cli_installs_callback_on_sub_typer(self) -> None:
sub = typer.Typer()
cli.add_typer(sub)
with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[]):
prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME))
prepare_cli(cli, _MY_EPILOG, context=make_context())

assert hasattr(sub, "no_args_callback_added")

Expand All @@ -79,7 +78,7 @@ def test_prepare_cli_adds_discovered_subcommands(self) -> None:
cli = typer.Typer()
sub_cli = typer.Typer()
with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[sub_cli]):
prepare_cli(cli, "epilog", context=make_context(_PROJECT_NAME))
prepare_cli(cli, "epilog", context=make_context())

registered = [g.typer_instance for g in cli.registered_groups]
assert sub_cli in registered
Expand All @@ -88,7 +87,7 @@ def test_prepare_cli_skips_self_in_discovery(self) -> None:
"""prepare_cli does not add cli to itself when it appears in discovered results."""
cli = typer.Typer()
with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[cli]):
prepare_cli(cli, "epilog", context=make_context(_PROJECT_NAME))
prepare_cli(cli, "epilog", context=make_context())

registered = [g.typer_instance for g in cli.registered_groups]
assert cli not in registered
14 changes: 4 additions & 10 deletions tests/aignostics_foundry_core/console_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from rich.console import Console

from aignostics_foundry_core.console import console
from aignostics_foundry_core.foundry import FoundryContext, reset_context, set_context
from aignostics_foundry_core.foundry import reset_context, set_context
from tests.conftest import make_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"
Expand Down Expand Up @@ -48,15 +48,9 @@ def test_console_default_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.sequential
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,
)
ctx = make_context()
set_context(ctx)
monkeypatch.setenv(f"{CUSTOM_ENV_PREFIX}CONSOLE_WIDTH", CUSTOM_WIDTH)
monkeypatch.setenv(f"{ctx.env_prefix}CONSOLE_WIDTH", CUSTOM_WIDTH)
reloaded = importlib.reload(sys.modules[CONSOLE_MODULE])
assert reloaded.console.width == EXPECTED_CUSTOM_WIDTH

Expand Down
Loading
Loading