Skip to content

Commit 1ced15f

Browse files
committed
docs: rename ProjectConfig to FoundryContext; describe extension mechanism
1 parent b7f4bb9 commit 1ced15f

1 file changed

Lines changed: 82 additions & 31 deletions

File tree

docs/decisions/0003-project-context-injection.md

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -62,106 +62,157 @@ foundry.configure(project_name="bridge", version=__version__, ...)
6262
locate_subclasses(BaseService) # reads from global state
6363

6464
def locate_subclasses(_class):
65-
project_name = foundry.config.project_name
65+
project_name = foundry.context.name
6666
...
6767
```
6868

6969
* Pros: clean call sites; no threading of values
7070
* Cons: caller still computes the values (requirement #2 violated); global mutable state; harder to test
7171

72-
**#4 `ProjectConfig` dataclass + `from_package()` classmethod**
72+
**#4 `FoundryContext` Pydantic model + `from_package()` classmethod**
7373

74-
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.
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.
7575

7676
```python
77-
config = ProjectConfig.from_package("bridge")
78-
locate_subclasses(BaseService, config=config)
77+
ctx = FoundryContext.from_package("bridge")
78+
locate_subclasses(BaseService, context=ctx)
7979
```
8080

8181
* Pros: requirements #1 and #2 satisfied; typed; derivation logic lives once in the library
82-
* 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`
82+
* 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`
8383

84-
**#5 `ProjectConfig.from_package()` + `configure()` + `foundry.config` accessor (combination of #3 and #4)**
84+
**#5 `FoundryContext.from_package()` + `configure()` + `foundry.context` accessor (combination of #3 and #4)**
8585

86-
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.
86+
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.
8787

8888
```python
8989
# at startup — replaces _constants.py entirely
90-
foundry.configure(ProjectConfig.from_package("bridge"))
90+
foundry.configure(FoundryContext.from_package("bridge"))
9191

9292
# library functions use the configured default
9393
locate_subclasses(BaseService)
9494

95-
def locate_subclasses(_class: type, config: ProjectConfig | None = None) -> list:
96-
config = config or foundry.config
95+
def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list:
96+
context = context or foundry.context
9797
...
9898

9999
# projects read derived values back from the library
100-
print(foundry.config.user_agent)
100+
print(foundry.context.version_full)
101101

102102
# in tests — explicit override, no global state touched
103-
locate_subclasses(BaseService, config=ProjectConfig(name="test-project", ...))
103+
locate_subclasses(BaseService, context=FoundryContext(name="test-project", ...))
104104
```
105105

106106
* Pros: all four requirements satisfied; `_constants.py` can be deleted outright; ergonomic for production; testable without resetting global state
107-
* Cons: global mutable state, though contained — tests pass config explicitly and never need to reset it
107+
* Cons: global mutable state, though contained — tests pass context explicitly and never need to reset it
108108

109109
## Decision
110110

111-
I suggest we use **#5**.
111+
We use **#5**.
112112

113-
`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:
113+
### Naming
114+
115+
The central type is named `FoundryContext` (not `ProjectConfig` or `ProjectContext`). Rationale:
116+
117+
- "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.
118+
- "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.
119+
- The name is consistent with `SentryContext` (also runtime-computed, also nested within the same design).
120+
121+
### Structure
122+
123+
`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:
114124

115125
```python
116-
@dataclass
117-
class SentryContext:
126+
class SentryContext(BaseModel):
127+
model_config = ConfigDict(frozen=True)
128+
118129
is_container: bool
119130
is_cli: bool
120131
is_test: bool
121132
is_library: bool
122133

123134

124-
@dataclass
125-
class ProjectConfig:
135+
class FoundryContext(BaseModel):
136+
model_config = ConfigDict(frozen=True)
137+
126138
name: str
127139
version: str
128140
version_full: str
129141
environment: str
130142
env_file: list[Path]
131143
repository_url: str = ""
132144
documentation_url: str = ""
133-
sentry: SentryContext = field(default_factory=SentryContext)
145+
sentry: SentryContext = Field(default_factory=SentryContext)
134146
```
135147

136148
Each project calls `configure()` once at startup. This single line replaces `_constants.py` entirely:
137149

138150
```python
139-
foundry.configure(ProjectConfig.from_package("bridge"))
151+
foundry.configure(FoundryContext.from_package("bridge"))
140152
```
141153

142-
The configured `ProjectConfig` is accessible anywhere via `foundry.config`:
154+
The configured `FoundryContext` is accessible anywhere via `foundry.context`:
143155

144156
```python
145157
# before: from bridge.utils._constants import __version_full__, __project_name__
146158
# after:
147-
foundry.config.version_full
148-
foundry.config.name
159+
foundry.context.version_full
160+
foundry.context.name
149161
```
150162

151-
All public library functions fall back to `foundry.config` but accept an explicit override:
163+
All public library functions fall back to `foundry.context` but accept an explicit override:
152164

153165
```python
154-
def locate_subclasses(_class: type, config: ProjectConfig | None = None) -> list:
155-
config = config or foundry.config
166+
def locate_subclasses(_class: type, context: FoundryContext | None = None) -> list:
167+
context = context or foundry.context
156168
...
157169
```
158170

159171
`SentryContext` is kept separate from `SentrySettings` (which holds SDK configuration loaded from env vars). `SentryContext` is runtime-computed; `SentrySettings` is env-based.
160172

173+
### Extending FoundryContext
174+
175+
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:
176+
177+
```python
178+
class BridgeContext(FoundryContext):
179+
tenant_id: str = ""
180+
deployment_region: str = "eu-west-1"
181+
182+
@classmethod
183+
def from_package(cls, package_name: str) -> "BridgeContext":
184+
base = super().from_package(package_name)
185+
return cls(
186+
**base.model_dump(),
187+
tenant_id=os.getenv("TENANT_ID", ""),
188+
deployment_region=os.getenv("REGION", "eu-west-1"),
189+
)
190+
```
191+
192+
At startup the subclass instance is passed to `configure()` as usual:
193+
194+
```python
195+
foundry.configure(BridgeContext.from_package("bridge"))
196+
```
197+
198+
`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:
199+
200+
```python
201+
bridge_context = BridgeContext.from_package("bridge")
202+
foundry.configure(bridge_context)
203+
204+
# library uses foundry.context (FoundryContext) — no project-specific fields needed
205+
# project code uses bridge_context directly for its own extended fields
206+
bridge_context.tenant_id
207+
```
208+
209+
This avoids module-level generics (which are awkward in Python) while keeping both the library and project code fully typed without casts.
210+
161211
## Consequences
162212

163-
- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `foundry.config`.
213+
- `_constants.py` is eliminated entirely across all projects; derivation logic lives once in the library and derived values are read back via `foundry.context`.
164214
- New projects (API servers and CLI tools alike) require a single `configure()` call and no boilerplate.
165-
- Production call sites are clean — no config threading.
166-
- Tests can pass a `ProjectConfig` directly without touching or resetting global state.
215+
- Production call sites are clean — no context threading.
216+
- Tests can pass a `FoundryContext` directly without touching or resetting global state.
167217
- `SentryContext` nesting makes it clear that the mode flags are Sentry-specific and not general-purpose project metadata.
218+
- 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)