From b7f4bb99d6becd20fe9e698112d0d76cd330d0f4 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 27 Mar 2026 10:41:33 +0100 Subject: [PATCH 1/3] docs: add project context injection ADR --- .../0003-project-context-injection.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/decisions/0003-project-context-injection.md diff --git a/docs/decisions/0003-project-context-injection.md b/docs/decisions/0003-project-context-injection.md new file mode 100644 index 0000000..d8933b3 --- /dev/null +++ b/docs/decisions/0003-project-context-injection.md @@ -0,0 +1,167 @@ +# 3. Project context injection + +Date: 2026-03-26 + +## Status + +Open + +## Context + +`aignostics_foundry_core` is used as a shared library across multiple projects (API servers, CLI tools). Several library functions need project-specific information to work correctly; for example: + +- `locate_subclasses()` needs the project name to scope its module walk +- `boot()` needs the project name, version, and environment +- `sentry_initialize()` needs project metadata plus runtime mode flags (`is_cli`, `is_container`, etc.) + +In Bridge, this is solved by `_constants.py`: a module that computes all of these values at import time using `__name__.split(".")[0]` to derive the project name, `importlib.metadata` for version, and environment variables for the rest. This works because `_constants.py` lives inside the `bridge` package, but cannot be reused as-is in the library, since e.g. `__name__.split(".")[0]` would return `"aignostics_foundry_core"` instead of the calling project's name. + +### Requirements + +1. Library functions must receive project-specific values (project name, version, environment, mode flags, etc.) +2. The logic for deriving these values (currently in `_constants.py`) should not be duplicated across every project that uses the library. +3. Derived values (e.g. `version_full`) must be readable by projects, not just passed into library functions — since they're referenced in many places (API metadata, user-agent strings, etc.). +4. The solution must work for both long-lived API servers and short-lived CLI tools. + +## Options + +**#1 Explicit parameterization** + +Each library function receives the values it needs as arguments. The caller is responsible for computing them — effectively re-implementing `_constants.py` in every project. + +```python +sentry_initialize( + project_name="bridge", + version=__version_with_vcs_ref__, + environment=__env__, + is_container=__is_running_in_container__, + ... +) +``` + +* Pros: fully explicit, no hidden state +* Cons: violates requirement #2 — every project must maintain its own `_constants.py` equivalent; long call signatures + +**#2 Environment variables** + +Projects set `FOUNDRY_CORE_PROJECT_NAME`, `FOUNDRY_CORE_VERSION`, etc.; the library reads them. The caller is still responsible for computing and exporting all derived values (requirement #2 violated in the same way as #1). + +```python +project_name = os.getenv("FOUNDRY_CORE_PROJECT_NAME") +``` + +* Pros: zero code coupling; works naturally in containerised deployments +* Cons: stringly typed; CLI tools are invoked locally where env vars are less reliable; doesn't satisfy requirement #3 (no typed accessor for derived values) + +**#3 Library-level `configure()` init pattern** + +A one-time call at startup sets global library state; all functions then read from it. This is similar to configuration/init pattern used by logging libraries and the Sentry SDK. + +```python +foundry.configure(project_name="bridge", version=__version__, ...) +locate_subclasses(BaseService) # reads from global state + +def locate_subclasses(_class): + project_name = foundry.config.project_name + ... +``` + +* Pros: clean call sites; no threading of values +* Cons: caller still computes the values (requirement #2 violated); global mutable state; harder to test + +**#4 `ProjectConfig` dataclass + `from_package()` classmethod** + +The library owns the derivation logic in `ProjectConfig.from_package(project_name)`, which reads from `importlib.metadata`, `sys.argv`, CI env vars, etc. Projects construct a config and pass it at call sites. + +```python +config = ProjectConfig.from_package("bridge") +locate_subclasses(BaseService, config=config) +``` + +* Pros: requirements #1 and #2 satisfied; typed; derivation logic lives once in the library +* Cons: requirement #3 only partially satisfied — projects must hold and thread their own `config` reference to read derived values, which doesn't fully eliminate `_constants.py` + +**#5 `ProjectConfig.from_package()` + `configure()` + `foundry.config` accessor (combination of #3 and #4)** + +Extends #4 with a `configure()` call that stores the config as library-level state, exposed back to callers via `foundry.config`. Library functions fall back to the configured default but accept an explicit `config` override for testing. + +```python +# at startup — replaces _constants.py entirely +foundry.configure(ProjectConfig.from_package("bridge")) + +# library functions use the configured default +locate_subclasses(BaseService) + +def locate_subclasses(_class: type, config: ProjectConfig | None = None) -> list: + config = config or foundry.config + ... + +# projects read derived values back from the library +print(foundry.config.user_agent) + +# in tests — explicit override, no global state touched +locate_subclasses(BaseService, config=ProjectConfig(name="test-project", ...)) +``` + +* Pros: all four requirements satisfied; `_constants.py` can be deleted outright; ergonomic for production; testable without resetting global state +* Cons: global mutable state, though contained — tests pass config explicitly and never need to reset it + +## Decision + +I suggest we use **#5**. + +`ProjectConfig` holds project identity and build metadata. 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 `ProjectConfig` directly: + +```python +@dataclass +class SentryContext: + is_container: bool + is_cli: bool + is_test: bool + is_library: bool + + +@dataclass +class ProjectConfig: + name: str + version: str + version_full: str + environment: str + env_file: list[Path] + repository_url: str = "" + documentation_url: str = "" + sentry: SentryContext = field(default_factory=SentryContext) +``` + +Each project calls `configure()` once at startup. This single line replaces `_constants.py` entirely: + +```python +foundry.configure(ProjectConfig.from_package("bridge")) +``` + +The configured `ProjectConfig` is accessible anywhere via `foundry.config`: + +```python +# before: from bridge.utils._constants import __version_full__, __project_name__ +# after: +foundry.config.version_full +foundry.config.name +``` + +All public library functions fall back to `foundry.config` but accept an explicit override: + +```python +def locate_subclasses(_class: type, config: ProjectConfig | None = None) -> list: + config = config or foundry.config + ... +``` + +`SentryContext` is kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). `SentryContext` is runtime-computed; `SentrySettings` is env-based. + +## Consequences + +- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `foundry.config`. +- New projects (API servers and CLI tools alike) require a single `configure()` call and no boilerplate. +- Production call sites are clean — no config threading. +- Tests can pass a `ProjectConfig` 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. From 1ced15fec22f0fa71293204ea91b0d4f6e8887fb Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 27 Mar 2026 11:56:50 +0100 Subject: [PATCH 2/3] docs: rename ProjectConfig to FoundryContext; describe extension mechanism --- .../0003-project-context-injection.md | 113 +++++++++++++----- 1 file changed, 82 insertions(+), 31 deletions(-) diff --git a/docs/decisions/0003-project-context-injection.md b/docs/decisions/0003-project-context-injection.md index d8933b3..2a99151 100644 --- a/docs/decisions/0003-project-context-injection.md +++ b/docs/decisions/0003-project-context-injection.md @@ -62,67 +62,79 @@ foundry.configure(project_name="bridge", version=__version__, ...) locate_subclasses(BaseService) # reads from global state def locate_subclasses(_class): - project_name = foundry.config.project_name + project_name = foundry.context.name ... ``` * Pros: clean call sites; no threading of values * Cons: caller still computes the values (requirement #2 violated); global mutable state; harder to test -**#4 `ProjectConfig` dataclass + `from_package()` classmethod** +**#4 `FoundryContext` Pydantic model + `from_package()` classmethod** -The library owns the derivation logic in `ProjectConfig.from_package(project_name)`, which reads from `importlib.metadata`, `sys.argv`, CI env vars, etc. Projects construct a config and pass it at call sites. +The library owns the derivation logic in `FoundryContext.from_package(project_name)`, which reads from `importlib.metadata`, `sys.argv`, CI env vars, etc. Projects construct a context and pass it at call sites. ```python -config = ProjectConfig.from_package("bridge") -locate_subclasses(BaseService, config=config) +ctx = FoundryContext.from_package("bridge") +locate_subclasses(BaseService, context=ctx) ``` * Pros: requirements #1 and #2 satisfied; typed; derivation logic lives once in the library -* Cons: requirement #3 only partially satisfied — projects must hold and thread their own `config` reference to read derived values, which doesn't fully eliminate `_constants.py` +* Cons: requirement #3 only partially satisfied — projects must hold and thread their own `context` reference to read derived values, which doesn't fully eliminate `_constants.py` -**#5 `ProjectConfig.from_package()` + `configure()` + `foundry.config` accessor (combination of #3 and #4)** +**#5 `FoundryContext.from_package()` + `configure()` + `foundry.context` accessor (combination of #3 and #4)** -Extends #4 with a `configure()` call that stores the config as library-level state, exposed back to callers via `foundry.config`. Library functions fall back to the configured default but accept an explicit `config` override for testing. +Extends #4 with a `configure()` call that stores the context as library-level state, exposed back to callers via `foundry.context`. Library functions fall back to the configured default but accept an explicit `context` override for testing. ```python # at startup — replaces _constants.py entirely -foundry.configure(ProjectConfig.from_package("bridge")) +foundry.configure(FoundryContext.from_package("bridge")) # library functions use the configured default locate_subclasses(BaseService) -def locate_subclasses(_class: type, config: ProjectConfig | None = None) -> list: - config = config or foundry.config +def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list: + context = context or foundry.context ... # projects read derived values back from the library -print(foundry.config.user_agent) +print(foundry.context.version_full) # in tests — explicit override, no global state touched -locate_subclasses(BaseService, config=ProjectConfig(name="test-project", ...)) +locate_subclasses(BaseService, context=FoundryContext(name="test-project", ...)) ``` * Pros: all four requirements satisfied; `_constants.py` can be deleted outright; ergonomic for production; testable without resetting global state -* Cons: global mutable state, though contained — tests pass config explicitly and never need to reset it +* Cons: global mutable state, though contained — tests pass context explicitly and never need to reset it ## Decision -I suggest we use **#5**. +We use **#5**. -`ProjectConfig` holds project identity and build metadata. 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 `ProjectConfig` directly: +### Naming + +The central type is named `FoundryContext` (not `ProjectConfig` or `ProjectContext`). Rationale: + +- "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: ```python -@dataclass -class SentryContext: +class SentryContext(BaseModel): + model_config = ConfigDict(frozen=True) + is_container: bool is_cli: bool is_test: bool is_library: bool -@dataclass -class ProjectConfig: +class FoundryContext(BaseModel): + model_config = ConfigDict(frozen=True) + name: str version: str version_full: str @@ -130,38 +142,77 @@ class ProjectConfig: env_file: list[Path] repository_url: str = "" documentation_url: str = "" - sentry: SentryContext = field(default_factory=SentryContext) + sentry: SentryContext = Field(default_factory=SentryContext) ``` Each project calls `configure()` once at startup. This single line replaces `_constants.py` entirely: ```python -foundry.configure(ProjectConfig.from_package("bridge")) +foundry.configure(FoundryContext.from_package("bridge")) ``` -The configured `ProjectConfig` is accessible anywhere via `foundry.config`: +The configured `FoundryContext` is accessible anywhere via `foundry.context`: ```python # before: from bridge.utils._constants import __version_full__, __project_name__ # after: -foundry.config.version_full -foundry.config.name +foundry.context.version_full +foundry.context.name ``` -All public library functions fall back to `foundry.config` but accept an explicit override: +All public library functions fall back to `foundry.context` but accept an explicit override: ```python -def locate_subclasses(_class: type, config: ProjectConfig | None = None) -> list: - config = config or foundry.config +def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list: + context = context or foundry.context ... ``` `SentryContext` is kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). `SentryContext` is runtime-computed; `SentrySettings` is env-based. +### Extending FoundryContext + +Projects that need additional context fields beyond the base set can subclass `FoundryContext`. The subclass overrides `from_package()` to compute its extra fields, using `model_dump()` to forward all base fields: + +```python +class BridgeContext(FoundryContext): + tenant_id: str = "" + deployment_region: str = "eu-west-1" + + @classmethod + def from_package(cls, package_name: str) -> "BridgeContext": + base = super().from_package(package_name) + return cls( + **base.model_dump(), + tenant_id=os.getenv("TENANT_ID", ""), + deployment_region=os.getenv("REGION", "eu-west-1"), + ) +``` + +At startup the subclass instance is passed to `configure()` as usual: + +```python +foundry.configure(BridgeContext.from_package("bridge")) +``` + +`foundry.context` is typed as `FoundryContext` — sufficient for all library functions. Project code that needs access to the extended fields keeps its own reference to the concrete instance: + +```python +bridge_context = BridgeContext.from_package("bridge") +foundry.configure(bridge_context) + +# library uses foundry.context (FoundryContext) — no project-specific fields needed +# project code uses bridge_context directly for its own extended fields +bridge_context.tenant_id +``` + +This avoids module-level generics (which are awkward in Python) while keeping both the library and project code fully typed without casts. + ## Consequences -- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `foundry.config`. +- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `foundry.context`. - New projects (API servers and CLI tools alike) require a single `configure()` call and no boilerplate. -- Production call sites are clean — no config threading. -- Tests can pass a `ProjectConfig` directly without touching or resetting global state. +- 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. +- Projects that need additional fields subclass `FoundryContext` and pass their subclass to `configure()`; they hold their own typed reference for project-specific access. From c513c25a7e9a512662b19168ff03cd85fe4f6279 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Fri, 27 Mar 2026 11:58:07 +0100 Subject: [PATCH 3/3] docs: explain pydantic choice --- docs/decisions/0003-project-context-injection.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/decisions/0003-project-context-injection.md b/docs/decisions/0003-project-context-injection.md index 2a99151..29d321a 100644 --- a/docs/decisions/0003-project-context-injection.md +++ b/docs/decisions/0003-project-context-injection.md @@ -73,6 +73,8 @@ def locate_subclasses(_class): The library owns the derivation logic in `FoundryContext.from_package(project_name)`, which reads from `importlib.metadata`, `sys.argv`, CI env vars, etc. Projects construct a context and pass it at call sites. +**Why Pydantic:** a frozen Pydantic model provides an immutable, typed data structure with built-in validation and convenient construction from dicts. It also plays well with subclassing for projects that need extra fields. It is already installed as a dependency and is used for `SentrySettings`, so it fits well within the existing codebase. + ```python ctx = FoundryContext.from_package("bridge") locate_subclasses(BaseService, context=ctx)