Skip to content

Commit ec5cf50

Browse files
committed
docs: add project context injection ADR
1 parent 8614d4b commit ec5cf50

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# 3. Project context injection
2+
3+
Date: 2026-03-26
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
`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:
12+
13+
- `locate_subclasses()` needs the project name to scope its module walk
14+
- `boot()` needs the project name, version, and environment
15+
- `sentry_initialize()` needs project metadata plus runtime mode flags (`is_cli`, `is_container`, etc.)
16+
17+
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.
18+
19+
### Requirements
20+
21+
1. Library functions must receive project-specific values (project name, version, environment, mode flags, etc.)
22+
2. The logic for deriving these values (currently in `_constants.py`) should not be duplicated across every project that uses the library.
23+
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.).
24+
4. The solution must work for both long-lived API servers and short-lived CLI tools.
25+
26+
## Options
27+
28+
**#1 Explicit parameterization**
29+
30+
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.
31+
32+
```python
33+
sentry_initialize(
34+
project_name="bridge",
35+
version=__version_with_vcs_ref__,
36+
environment=__env__,
37+
is_container=__is_running_in_container__,
38+
...
39+
)
40+
```
41+
42+
* Pros: fully explicit, no hidden state
43+
* Cons: violates requirement #2 — every project must maintain its own `_constants.py` equivalent; long call signatures
44+
45+
**#2 Environment variables**
46+
47+
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).
48+
49+
```python
50+
project_name = os.getenv("FOUNDRY_CORE_PROJECT_NAME")
51+
```
52+
53+
* Pros: zero code coupling; works naturally in containerised deployments
54+
* 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)
55+
56+
**#3 Library-level `set_context()` init pattern**
57+
58+
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.
59+
60+
```python
61+
foundry.set_context(project_name="bridge", version=__version__, ...)
62+
locate_subclasses(BaseService) # reads from global state
63+
64+
def locate_subclasses(_class):
65+
project_name = foundry.context.name
66+
...
67+
```
68+
69+
* Pros: clean call sites; no threading of values
70+
* Cons: caller still computes the values (requirement #2 violated); global mutable state; harder to test
71+
72+
**#4 `FoundryContext` Pydantic model + `from_package()` classmethod**
73+
74+
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.
75+
76+
**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.
77+
78+
```python
79+
ctx = FoundryContext.from_package("bridge")
80+
locate_subclasses(BaseService, context=ctx)
81+
```
82+
83+
* Pros: requirements #1 and #2 satisfied; typed; derivation logic lives once in the library
84+
* 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`
85+
86+
**#5 `FoundryContext.from_package()` + `set_context()` + `foundry.context` accessor (combination of #3 and #4)**
87+
88+
Extends #4 with a `set_context()` 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.
89+
90+
```python
91+
# at startup — replaces _constants.py entirely
92+
foundry.set_context(FoundryContext.from_package("bridge"))
93+
94+
# library functions use the configured default
95+
locate_subclasses(BaseService)
96+
97+
def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list:
98+
context = context or foundry.context
99+
...
100+
101+
# projects read derived values back from the library
102+
print(foundry.context.version_full)
103+
104+
# in tests — explicit override, no global state touched
105+
locate_subclasses(BaseService, context=FoundryContext(name="test-project", ...))
106+
```
107+
108+
* Pros: all four requirements satisfied; `_constants.py` can be deleted outright; ergonomic for production; testable without resetting global state
109+
* Cons: global mutable state, though contained — tests pass context explicitly and never need to reset it
110+
111+
## Decision
112+
113+
We use **#5**.
114+
115+
### Naming
116+
117+
The central type is named `FoundryContext` (not `ProjectConfig` or `ProjectContext`). Rationale:
118+
119+
- "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.
120+
- "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.
121+
- The name is consistent with `SentryContext` (also runtime-computed, also nested within the same design).
122+
123+
### Structure
124+
125+
`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:
126+
127+
```python
128+
class SentryContext(BaseModel):
129+
model_config = ConfigDict(frozen=True)
130+
131+
is_container: bool
132+
is_cli: bool
133+
is_test: bool
134+
is_library: bool
135+
136+
137+
class FoundryContext(BaseModel):
138+
model_config = ConfigDict(frozen=True)
139+
140+
name: str
141+
version: str
142+
version_full: str
143+
environment: str
144+
env_file: list[Path]
145+
repository_url: str = ""
146+
documentation_url: str = ""
147+
sentry: SentryContext = Field(default_factory=SentryContext)
148+
```
149+
150+
Each project calls `set_context()` once at startup. This single line replaces `_constants.py` entirely:
151+
152+
```python
153+
foundry.set_context(FoundryContext.from_package("bridge"))
154+
```
155+
156+
The configured `FoundryContext` is accessible anywhere via `foundry.context`:
157+
158+
```python
159+
# before: from bridge.utils._constants import __version_full__, __project_name__
160+
# after:
161+
foundry.context.version_full
162+
foundry.context.name
163+
```
164+
165+
All public library functions fall back to `foundry.context` but accept an explicit override:
166+
167+
```python
168+
def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list:
169+
context = context or foundry.context
170+
...
171+
```
172+
173+
`SentryContext` is kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). `SentryContext` is runtime-computed; `SentrySettings` is env-based.
174+
175+
### Extending FoundryContext
176+
177+
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:
178+
179+
```python
180+
class BridgeContext(FoundryContext):
181+
tenant_id: str = ""
182+
deployment_region: str = "eu-west-1"
183+
184+
@classmethod
185+
def from_package(cls, package_name: str) -> "BridgeContext":
186+
base = super().from_package(package_name)
187+
return cls(
188+
**base.model_dump(),
189+
tenant_id=os.getenv("TENANT_ID", ""),
190+
deployment_region=os.getenv("REGION", "eu-west-1"),
191+
)
192+
```
193+
194+
At startup the subclass instance is passed to `set_context()` as usual:
195+
196+
```python
197+
foundry.set_context(BridgeContext.from_package("bridge"))
198+
```
199+
200+
`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:
201+
202+
```python
203+
bridge_context = BridgeContext.from_package("bridge")
204+
foundry.set_context(bridge_context)
205+
206+
# library uses foundry.context (FoundryContext) — no project-specific fields needed
207+
# project code uses bridge_context directly for its own extended fields
208+
bridge_context.tenant_id
209+
```
210+
211+
This avoids module-level generics (which are awkward in Python) while keeping both the library and project code fully typed without casts.
212+
213+
## Consequences
214+
215+
- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `foundry.context`.
216+
- New projects (API servers and CLI tools alike) require a single `set_context()` call and no boilerplate.
217+
- Production call sites are clean — no context threading.
218+
- Tests can pass a `FoundryContext` directly without touching or resetting global state.
219+
- `SentryContext` nesting makes it clear that the mode flags are Sentry-specific and not general-purpose project metadata.
220+
- Projects that need additional fields subclass `FoundryContext` and pass their subclass to `configure()`; they hold their own typed reference for project-specific access.

0 commit comments

Comments
 (0)