Skip to content

Commit 8e8f904

Browse files
olivermeyerclaude
andcommitted
feat(foundry): add PackageMetadata to FoundryContext
Introduces PackageMetadata (frozen Pydantic model) with description, author_name, author_email, repository_url, and documentation_url fields. PackageMetadata.from_name() populates all five from importlib.metadata. FoundryContext.from_package() now delegates to PackageMetadata.from_name() and exposes the result as ctx.metadata; the top-level repository_url / documentation_url fields are removed from FoundryContext. All callers (user_agent, sentry) updated to read from ctx.metadata. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 10aafe2 commit 8e8f904

8 files changed

Lines changed: 155 additions & 30 deletions

File tree

ATTRIBUTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ SOFTWARE.
360360

361361
```
362362

363-
## aignostics-foundry-core (0.7.0) - MIT License
363+
## aignostics-foundry-core (0.7.1) - MIT License
364364

365365
🏭 Foundational infrastructure for Foundry components.
366366

src/aignostics_foundry_core/AGENTS.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
2323
| **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 |
2424
| **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` |
2525
| **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory |
26-
| **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`, `database: DatabaseSettings \| None`) derived from package metadata and environment variables; `from_package()` populates `database` from `{env_prefix}DB_*` env vars when `{env_prefix}DB_URL` is present |
26+
| **foundry** | Project context injection | `PackageMetadata`, `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`, `metadata: PackageMetadata` (description, author_name, author_email), runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`, `database: DatabaseSettings \| None`) derived from package metadata and environment variables; `from_package()` populates `metadata` from `importlib.metadata` and `database` from `{env_prefix}DB_*` env vars when `{env_prefix}DB_URL` is present |
2727
| **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 |
2828
| **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status |
2929
| **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; `console`, `Panel`, and `Text` are imported lazily inside `load_settings` (error path only) |
@@ -41,16 +41,25 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
4141
application startup makes the context available everywhere in the library without threading values
4242
through call sites. Tests pass an explicit context override and never touch global state.
4343
- **Key Features**:
44+
- `PackageMetadata(BaseModel)` — frozen; fields: `description: str = ""`,
45+
`author_name: str | None = None`, `author_email: str | None = None`,
46+
`repository_url: str = ""`, `documentation_url: str = ""`.
47+
Constructor: `PackageMetadata.from_name(package_name)` — reads all five fields from
48+
`importlib.metadata` (``Summary``, ``Author-email``, ``Project-URL``).
49+
Defaults to empty values for direct construction (e.g. in tests).
4450
- `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `version_with_vcs_ref`, `environment`,
45-
`env_file: list[Path]`, `repository_url`, `documentation_url`, `python_version` (Python runtime
46-
version string, e.g. `"3.11.9"`), plus four runtime mode bool flags: `is_container`, `is_cli`,
51+
`env_file: list[Path]`, `env_prefix`, `python_version` (Python runtime version string,
52+
e.g. `"3.11.9"`), `metadata: PackageMetadata` (all package-derived fields: description,
53+
author, URLs; populated by `from_package()` via `PackageMetadata.from_name()`; defaults to
54+
empty `PackageMetadata()`), plus four runtime mode bool flags: `is_container`, `is_cli`,
4755
`is_test`, `is_library` (all default `False`), and `database: DatabaseSettings | None`
4856
(populated by `from_package()` when `{env_prefix}DB_URL` is set; `None` otherwise).
4957
- `FoundryContext.from_package(package_name)` — classmethod that derives all values from
5058
`importlib.metadata` and environment variables (`{NAME}_ENVIRONMENT`, `VCS_REF`, `COMMIT_SHA`,
5159
`BUILDER`, `BUILD_DATE`, `CI_RUN_ID`, `CI_RUN_NUMBER`, `{NAME}_ENV_FILE`,
5260
`{NAME}_RUNNING_IN_CONTAINER`, `PYTEST_RUNNING_{NAME}`). Environment fallback chain:
5361
`{NAME}_ENVIRONMENT``ENV``VERCEL_ENV``RAILWAY_ENVIRONMENT``"local"`.
62+
Calls `PackageMetadata.from_name(package_name)` to populate `ctx.metadata`.
5463
Also checks `{NAME}_DB_URL`: when present, constructs `DatabaseSettings(_env_prefix="{NAME}_DB_")`
5564
and stores it in `ctx.database`; otherwise `ctx.database` is `None`.
5665
- `set_context(ctx)` — installs *ctx* as the process-level singleton.

src/aignostics_foundry_core/foundry.py

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,57 @@ def _empty_path_list() -> list[Path]:
3737
return []
3838

3939

40+
class PackageMetadata(BaseModel):
41+
"""All package-derived metadata: description, author, and project URLs.
42+
43+
Populated via :meth:`from_name` from ``importlib.metadata``.
44+
Defaults to empty/``None`` values so contexts constructed directly (e.g. in tests)
45+
work without any extra setup.
46+
"""
47+
48+
model_config = {"frozen": True}
49+
50+
description: str = ""
51+
author_name: str | None = None
52+
author_email: str | None = None
53+
repository_url: str = ""
54+
documentation_url: str = ""
55+
56+
@classmethod
57+
def from_name(cls, package_name: str) -> PackageMetadata:
58+
"""Return a :class:`PackageMetadata` populated from installed package metadata.
59+
60+
Reads ``Summary``, ``Author-email``, and ``Project-URL`` entries from
61+
``importlib.metadata`` for *package_name*.
62+
63+
Args:
64+
package_name: The importable package name (e.g. ``"aignostics_foundry_core"``).
65+
66+
Returns:
67+
A fully populated, frozen :class:`PackageMetadata` instance.
68+
"""
69+
pkg_meta = metadata.metadata(package_name)
70+
authors = pkg_meta.get_all("Author-email") or []
71+
author = authors[0] if authors else None
72+
author_name = author.split("<")[0].strip() if author else None
73+
author_email = author.split("<")[1].strip(" >") if author else None
74+
description = pkg_meta.get("Summary") or ""
75+
repository_url = ""
76+
documentation_url = ""
77+
for url_entry in pkg_meta.get_all("Project-URL") or []:
78+
if url_entry.startswith("Source"):
79+
repository_url = url_entry.split(", ", 1)[1]
80+
elif url_entry.startswith("Documentation"):
81+
documentation_url = url_entry.split(", ", 1)[1]
82+
return cls(
83+
description=description,
84+
author_name=author_name,
85+
author_email=author_email,
86+
repository_url=repository_url,
87+
documentation_url=documentation_url,
88+
)
89+
90+
4091
class FoundryContext(BaseModel):
4192
"""Immutable project context carrying all project-specific values.
4293
@@ -71,14 +122,18 @@ class FoundryContext(BaseModel):
71122
environment: str
72123
env_file: list[Path] = Field(default_factory=_empty_path_list)
73124
env_prefix: str = ""
74-
repository_url: str = ""
75-
documentation_url: str = ""
76125
is_container: bool = False
77126
is_cli: bool = False
78127
is_test: bool = False
79128
is_library: bool = False
80129
python_version: str = ""
81130
project_path: Path | None = None
131+
metadata: PackageMetadata = Field(default_factory=PackageMetadata)
132+
"""Package-derived author and description metadata.
133+
134+
Populated by :meth:`from_package` from ``importlib.metadata``.
135+
Defaults to empty values when the context is constructed directly (e.g. in tests).
136+
"""
82137
database: DatabaseSettings | None = None
83138
"""Database settings resolved from ``{env_prefix}DB_*`` environment variables.
84139
@@ -119,7 +174,6 @@ def from_package(cls, package_name: str) -> FoundryContext:
119174
name_upper = name.upper()
120175
version = metadata.version(package_name)
121176
environment = _detect_environment(name_upper)
122-
repository_url, documentation_url = _extract_urls(package_name)
123177
project_path = _find_project_path(package_name)
124178
vcs_ref = os.environ.get("VCS_REF") or (project_path and _get_vcs_ref_from_git(project_path)) or "unknown"
125179
env_prefix = f"{name_upper}_"
@@ -138,8 +192,7 @@ def from_package(cls, package_name: str) -> FoundryContext:
138192
environment=environment,
139193
env_file=env_files,
140194
env_prefix=env_prefix,
141-
repository_url=repository_url,
142-
documentation_url=documentation_url,
195+
metadata=PackageMetadata.from_name(package_name),
143196
python_version=platform.python_version(),
144197
project_path=project_path,
145198
database=database,
@@ -284,19 +337,6 @@ def _any_env_file_has(key: str, env_files: list[Path]) -> bool:
284337
return any(key in dotenv_values(f) for f in env_files if f.is_file())
285338

286339

287-
def _extract_urls(package_name: str) -> tuple[str, str]:
288-
"""Return ``(repository_url, documentation_url)`` from package metadata."""
289-
pkg_metadata = metadata.metadata(package_name)
290-
repository_url = ""
291-
documentation_url = ""
292-
for url_entry in pkg_metadata.get_all("Project-URL") or []:
293-
if url_entry.startswith("Source"):
294-
repository_url = url_entry.split(", ", 1)[1]
295-
elif url_entry.startswith("Documentation"):
296-
documentation_url = url_entry.split(", ", 1)[1]
297-
return repository_url, documentation_url
298-
299-
300340
def _build_runtime_flags(name: str, name_upper: str) -> dict[str, bool]:
301341
"""Compute runtime mode flags from environment and process state.
302342

src/aignostics_foundry_core/sentry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,8 @@ def sentry_initialize(
275275
"aignx/base",
276276
{
277277
"project_name": ctx.name,
278-
"repository_url": ctx.repository_url,
279-
"documentation_url": ctx.documentation_url,
278+
"repository_url": ctx.metadata.repository_url,
279+
"documentation_url": ctx.metadata.documentation_url,
280280
"version_full": ctx.version_full,
281281
"in_container": ctx.is_container,
282282
"test_mode": ctx.is_test,

src/aignostics_foundry_core/user_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ def user_agent(*, context: "FoundryContext | None" = None) -> str:
4242
# Format: {project}-python-sdk/{version_full} ({platform}; +{repository_url}; {optional_parts})
4343
# TODO(oliverm): Find a way to not hard code python-sdk here. This was taken as such from Bridge.
4444
base_info = f"{ctx.name}-python-sdk/{ctx.version_full}"
45-
system_info = f"{platform.platform()}; +{ctx.repository_url}{optional_suffix}"
45+
system_info = f"{platform.platform()}; +{ctx.metadata.repository_url}{optional_suffix}"
4646

4747
return f"{base_info} ({system_info})"

tests/aignostics_foundry_core/foundry_test.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515
from pydantic import ValidationError
1616

17-
from aignostics_foundry_core.foundry import FoundryContext, get_context, reset_context, set_context
17+
from aignostics_foundry_core.foundry import FoundryContext, PackageMetadata, get_context, reset_context, set_context
1818
from tests.conftest import make_context
1919

2020
# Constants (SonarQube S1192)
@@ -38,6 +38,77 @@
3838
INIT_PY = "__init__.py"
3939

4040

41+
# ---------------------------------------------------------------------------
42+
# PackageMetadata — field defaults
43+
# ---------------------------------------------------------------------------
44+
45+
46+
@pytest.mark.unit
47+
def test_foundry_context_metadata_field_defaults() -> None:
48+
"""A directly-constructed FoundryContext has empty PackageMetadata defaults."""
49+
ctx = make_context()
50+
assert not ctx.metadata.description
51+
assert ctx.metadata.author_name is None
52+
assert ctx.metadata.author_email is None
53+
assert not ctx.metadata.repository_url
54+
assert not ctx.metadata.documentation_url
55+
56+
57+
# ---------------------------------------------------------------------------
58+
# PackageMetadata.from_name() — detail assertions
59+
# ---------------------------------------------------------------------------
60+
61+
62+
@pytest.mark.unit
63+
def test_package_metadata_from_name_description() -> None:
64+
"""PackageMetadata.from_name() returns a non-empty description (Summary field)."""
65+
pkg_meta = PackageMetadata.from_name(PACKAGE_NAME)
66+
assert isinstance(pkg_meta.description, str)
67+
assert pkg_meta.description
68+
69+
70+
@pytest.mark.unit
71+
def test_package_metadata_from_name_author_name() -> None:
72+
"""PackageMetadata.from_name() returns a non-None, non-empty author_name."""
73+
pkg_meta = PackageMetadata.from_name(PACKAGE_NAME)
74+
assert pkg_meta.author_name is not None
75+
assert pkg_meta.author_name
76+
77+
78+
@pytest.mark.unit
79+
def test_package_metadata_from_name_author_email() -> None:
80+
"""PackageMetadata.from_name() returns a non-None author_email containing '@'."""
81+
pkg_meta = PackageMetadata.from_name(PACKAGE_NAME)
82+
assert pkg_meta.author_email is not None
83+
assert "@" in pkg_meta.author_email
84+
85+
86+
@pytest.mark.unit
87+
def test_package_metadata_from_name_repository_url() -> None:
88+
"""PackageMetadata.from_name() returns a non-empty repository_url (Source URL)."""
89+
pkg_meta = PackageMetadata.from_name(PACKAGE_NAME)
90+
assert pkg_meta.repository_url
91+
92+
93+
@pytest.mark.unit
94+
def test_package_metadata_from_name_documentation_url() -> None:
95+
"""PackageMetadata.from_name() returns a non-empty documentation_url (Documentation URL)."""
96+
pkg_meta = PackageMetadata.from_name(PACKAGE_NAME)
97+
assert pkg_meta.documentation_url
98+
99+
100+
# ---------------------------------------------------------------------------
101+
# from_package() — wires metadata via PackageMetadata.from_name()
102+
# ---------------------------------------------------------------------------
103+
104+
105+
@pytest.mark.unit
106+
def test_from_package_metadata_is_package_metadata_instance() -> None:
107+
"""from_package() sets .metadata to a PackageMetadata populated via from_name()."""
108+
ctx = FoundryContext.from_package(PACKAGE_NAME)
109+
assert ctx.metadata == PackageMetadata.from_name(PACKAGE_NAME)
110+
111+
41112
@pytest.fixture(autouse=True)
42113
def _reset_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction]
43114
"""Reset global _context to None before and after every test.

tests/aignostics_foundry_core/user_agent_test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ def test_user_agent_uses_version_full(self, monkeypatch: pytest.MonkeyPatch) ->
5555
version_full=CTX_VERSION_FULL,
5656
version_with_vcs_ref=CTX_VERSION,
5757
environment="test",
58-
repository_url=CTX_REPOSITORY_URL,
5958
)
6059
result = user_agent(context=ctx)
6160

tests/conftest.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
from aignostics_foundry_core.database import DatabaseSettings
11-
from aignostics_foundry_core.foundry import FoundryContext
11+
from aignostics_foundry_core.foundry import FoundryContext, PackageMetadata
1212

1313
__all__ = ["make_context"]
1414

@@ -71,6 +71,7 @@ def make_context( # noqa: PLR0913
7171
repository_url: str = "",
7272
database: DatabaseSettings | None = None,
7373
env_file: list[Path] | None = None,
74+
metadata: PackageMetadata | None = None,
7475
**kwargs: bool,
7576
) -> FoundryContext:
7677
"""Create a minimal FoundryContext for testing.
@@ -81,13 +82,18 @@ def make_context( # noqa: PLR0913
8182
version: The version string (defaults to ``"0.0.0"``).
8283
environment: The deployment environment (defaults to ``"test"``).
8384
project_path: Optional path to the project root.
84-
repository_url: The project repository URL (defaults to ``""``).
85+
repository_url: Shorthand to set ``metadata.repository_url`` when *metadata* is
86+
not provided. Ignored when *metadata* is supplied explicitly.
8587
database: Optional :class:`~aignostics_foundry_core.database.DatabaseSettings`
8688
instance to attach to the context.
8789
env_file: Optional list of ``.env`` file paths to attach to the context.
90+
metadata: Optional :class:`~aignostics_foundry_core.foundry.PackageMetadata`
91+
to attach to the context. When ``None``, a ``PackageMetadata`` with
92+
``repository_url`` set from the *repository_url* argument is used.
8893
**kwargs: Optional boolean flags forwarded to :class:`FoundryContext`
8994
(``is_test``, ``is_cli``, ``is_container``, ``is_library``).
9095
"""
96+
resolved_metadata = metadata if metadata is not None else PackageMetadata(repository_url=repository_url)
9197
return FoundryContext(
9298
name=name,
9399
version=version,
@@ -96,8 +102,8 @@ def make_context( # noqa: PLR0913
96102
environment=environment,
97103
env_prefix=env_prefix,
98104
project_path=project_path,
99-
repository_url=repository_url,
100105
database=database,
101106
env_file=env_file or [],
107+
metadata=resolved_metadata,
102108
**kwargs, # type: ignore[arg-type]
103109
)

0 commit comments

Comments
 (0)