Skip to content

Commit 954cddc

Browse files
olivermeyerclaude
andcommitted
fix(sentry): derive env_prefix from FoundryContext
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aa935d9 commit 954cddc

7 files changed

Lines changed: 111 additions & 39 deletions

File tree

src/aignostics_foundry_core/AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
9797

9898
- **Purpose**: Provides Auth0 cookie-based session authentication dependencies for FastAPI routes. All project-specific settings (org ID, role claim) are loaded from `AuthSettings` whose env prefix is configurable at instantiation.
9999
- **Key Features**:
100-
- `AuthSettings(OpaqueSettings)`reads from `FOUNDRY_AUTH_*` env vars by default; override prefix via constructor kwargs (e.g. `AuthSettings(_env_prefix="BRIDGE_AUTH_", _env_file=".env")`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role)
100+
- `AuthSettings(OpaqueSettings)`uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}AUTH_`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role)
101101
- `UnauthenticatedError(Exception)` — raised when a user session is missing or invalid
102102
- `ForbiddenError(ApiException)``status_code = 403`; raised when user lacks required role or org membership
103103
- `get_auth_client(request)` — retrieves `AuthClient` from `request.app.state.auth_client`; raises `RuntimeError` if not configured
@@ -154,7 +154,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
154154
- **Purpose**: Bootstraps loguru as the primary logging framework, optionally redirecting stdlib `logging` via `InterceptHandler`. All project-specific constants are passed as parameters rather than hard-coded.
155155
- **Key Features**:
156156
- `InterceptHandler(logging.Handler)` — redirects stdlib log records to loguru, preserving original module/function/line metadata
157-
- `LogSettings(BaseSettings)`reads from `FOUNDRY_LOG_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env")`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging`
157+
- `LogSettings(BaseSettings)`uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}LOG_`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging`
158158
- `logging_initialize(filter_func, *, context)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; reads project name, version, and env file list from `context` (falls back to process-level context); embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise
159159
- **Location**: `aignostics_foundry_core/log.py`
160160
- **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory)
@@ -166,7 +166,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
166166

167167
- **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants.
168168
- **Key Features**:
169-
- `SentrySettings(OpaqueSettings)`reads from `FOUNDRY_SENTRY_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env")`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs`
169+
- `SentrySettings(OpaqueSettings)`uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs`
170170
- `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; suppresses noisy loggers; returns `True` on success, `False` otherwise
171171
- `set_sentry_user(user, role_claim)` — maps Auth0 user claims (`sub``id`, `email`, `name`, …) into Sentry scope; pass `None` to clear context; no-op when `sentry_sdk` is absent
172172
- **Location**: `aignostics_foundry_core/sentry.py`

src/aignostics_foundry_core/api/auth.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- Authentication dependencies (require_authenticated, require_admin, etc.)
66
- get_user: Get authenticated user from session
77
- get_auth_client: Get Auth0 client from app state
8-
- AuthSettings: Configurable auth settings (env prefix overridable via constructor kwargs)
8+
- AuthSettings: Auth settings whose env prefix is derived from the active FoundryContext
99
"""
1010

1111
import time
@@ -17,6 +17,7 @@
1717
from loguru import logger
1818
from pydantic_settings import SettingsConfigDict
1919

20+
from aignostics_foundry_core.foundry import get_context
2021
from aignostics_foundry_core.settings import OpaqueSettings, load_settings
2122

2223
from .exceptions import ApiException
@@ -31,17 +32,21 @@
3132

3233

3334
class AuthSettings(OpaqueSettings):
34-
"""Auth settings with configurable env prefix.
35+
"""Auth settings whose env prefix is derived from the active FoundryContext.
3536
36-
Override prefix at instantiation:
37-
``AuthSettings(_env_prefix="BRIDGE_AUTH_", _env_file=".env")``
37+
The effective prefix is ``{FoundryContext.env_prefix}AUTH_``, resolved at
38+
instantiation time via :func:`aignostics_foundry_core.foundry.get_context`.
3839
"""
3940

40-
model_config = SettingsConfigDict(env_prefix="FOUNDRY_AUTH_", extra="ignore")
41+
model_config = SettingsConfigDict(extra="ignore")
4142

4243
internal_org_id: str | None = None
4344
auth0_role_claim: str = DEFAULT_AUTH0_ROLE_CLAIM
4445

46+
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
47+
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
48+
super().__init__(_env_prefix=f"{get_context().env_prefix}AUTH_", **kwargs) # pyright: ignore[reportCallIssue]
49+
4550

4651
class UnauthenticatedError(Exception):
4752
"""Raised when user is not authenticated."""

src/aignostics_foundry_core/log.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import os
1515
import sys
1616
from pathlib import Path
17-
from typing import TYPE_CHECKING, Literal
17+
from typing import TYPE_CHECKING, Any, Literal
1818

1919
import platformdirs
2020
from loguru import logger
@@ -120,19 +120,20 @@ def patcher(record_dict: "Record") -> None:
120120
class LogSettings(BaseSettings):
121121
"""Settings for configuring logging behaviour.
122122
123-
Reads from environment variables with the ``FOUNDRY_LOG_`` prefix by
124-
default. Callers can supply a project-specific prefix or env file at
125-
instantiation time using Pydantic Settings v2 constructor kwargs::
126-
127-
settings = LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env")
123+
Reads environment variables using the prefix derived from the active
124+
:class:`~aignostics_foundry_core.foundry.FoundryContext` (e.g.
125+
``MYPROJECT_LOG_`` when the context's ``env_prefix`` is ``MYPROJECT_``).
128126
"""
129127

130128
model_config = SettingsConfigDict(
131-
env_prefix="FOUNDRY_LOG_",
132129
extra="ignore",
133130
env_file_encoding="utf-8",
134131
)
135132

133+
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
134+
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
135+
super().__init__(_env_prefix=f"{get_context().env_prefix}LOG_", **kwargs) # pyright: ignore[reportCallIssue]
136+
136137
level: Literal["CRITICAL", "ERROR", "WARNING", "SUCCESS", "INFO", "DEBUG", "TRACE"] = Field(
137138
default="INFO",
138139
description="Log level, see https://loguru.readthedocs.io/en/stable/api/logger.html",
@@ -182,7 +183,7 @@ def logging_initialize(
182183
:func:`~aignostics_foundry_core.foundry.set_context`.
183184
"""
184185
ctx = context or get_context()
185-
settings = LogSettings(_env_prefix=f"{ctx.env_prefix}LOG_", _env_file=ctx.env_file) # pyright: ignore[reportCallIssue]
186+
settings = LogSettings(_env_file=ctx.env_file) # pyright: ignore[reportCallIssue]
186187

187188
logger.remove() # Remove all default loggers
188189

src/aignostics_foundry_core/sentry.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,20 @@ def _validate_https_dsn(value: SecretStr | None) -> SecretStr | None:
118118
class SentrySettings(OpaqueSettings):
119119
"""Configuration settings for Sentry integration.
120120
121-
Reads from environment variables with the ``FOUNDRY_SENTRY_`` prefix by
122-
default. Callers can supply a project-specific prefix or env file at
123-
instantiation time using Pydantic Settings v2 constructor kwargs::
124-
125-
settings = SentrySettings(_env_prefix="BRIDGE_SENTRY_", _env_file=".env")
121+
Reads environment variables using the prefix derived from the active
122+
:class:`~aignostics_foundry_core.foundry.FoundryContext` (e.g.
123+
``MYPROJECT_SENTRY_`` when the context's ``env_prefix`` is ``MYPROJECT_``).
126124
"""
127125

128126
model_config = SettingsConfigDict(
129-
env_prefix="FOUNDRY_SENTRY_",
130127
env_file_encoding="utf-8",
131128
extra="ignore",
132129
)
133130

131+
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
132+
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
133+
super().__init__(_env_prefix=f"{get_context().env_prefix}SENTRY_", **kwargs) # pyright: ignore[reportCallIssue]
134+
134135
enabled: Annotated[
135136
bool,
136137
Field(
@@ -245,7 +246,6 @@ def sentry_initialize(
245246
ctx = context or get_context()
246247

247248
settings = SentrySettings(
248-
_env_prefix=f"{ctx.env_prefix}SENTRY_", # pyright: ignore[reportCallIssue]
249249
_env_file=ctx.env_file, # pyright: ignore[reportCallIssue]
250250
)
251251

tests/aignostics_foundry_core/api/auth_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,25 @@
1818
require_internal,
1919
require_internal_admin,
2020
)
21+
from tests.conftest import make_context
2122

2223
_PATCH_GET_USER = "aignostics_foundry_core.api.auth.get_user"
2324
_PATCH_GET_AUTH_CLIENT = "aignostics_foundry_core.api.auth.get_auth_client"
2425
_PATCH_SET_SENTRY_USER = "aignostics_foundry_core.sentry.set_sentry_user"
26+
_PATCH_GET_CONTEXT = "aignostics_foundry_core.api.auth.get_context"
2527
_INTERNAL_ORG_ID = "org_internal_123"
2628
_OTHER_ORG_ID = "org_other_456"
2729
_USER_NOT_AUTHENTICATED = "User is not authenticated"
2830
_USER_SUB = "auth0|x"
2931
_USER_EMAIL = "x@x.com"
3032

3133

34+
@pytest.fixture(autouse=True)
35+
def _stub_auth_get_context(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction]
36+
"""Stub get_context for all auth tests to preserve FOUNDRY_AUTH_* env var names."""
37+
monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("foundry", "FOUNDRY_"))
38+
39+
3240
@pytest.mark.unit
3341
class TestUnauthenticatedError:
3442
"""Tests for UnauthenticatedError."""
@@ -91,6 +99,13 @@ def test_auth_settings_role_claim_value(self) -> None:
9199
"""The default role claim is the Aignostics platform bridge claim URL."""
92100
assert DEFAULT_AUTH0_ROLE_CLAIM == "https://aignostics-platform-bridge/role"
93101

102+
def test_auth_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
103+
"""AuthSettings reads env vars from the prefix supplied by FoundryContext."""
104+
monkeypatch.setattr(_PATCH_GET_CONTEXT, lambda: make_context("proj", "PROJ_"))
105+
monkeypatch.setenv("PROJ_AUTH_AUTH0_ROLE_CLAIM", "https://custom/role")
106+
settings = AuthSettings()
107+
assert settings.auth0_role_claim == "https://custom/role"
108+
94109

95110
@pytest.mark.unit
96111
class TestGetUser:

tests/aignostics_foundry_core/log_test.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@
2525
class TestLoggingInitialize:
2626
"""Behavioural tests for logging_initialize()."""
2727

28+
@pytest.fixture(autouse=True)
29+
def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None:
30+
monkeypatch.setattr(
31+
"aignostics_foundry_core.log.get_context",
32+
lambda: make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"),
33+
)
34+
2835
def test_logging_initialize_adds_stderr_handler(self, capsys: pytest.CaptureFixture[str]) -> None:
2936
"""After initialization with defaults, a log message appears on stderr."""
30-
logging_initialize(context=make_context(_PROJECT))
37+
logging_initialize()
3138
from loguru import logger
3239

3340
logger.info(_MARKER_MESSAGE)
@@ -39,7 +46,7 @@ def test_logging_initialize_skips_stderr_when_disabled(
3946
) -> None:
4047
"""When stderr is disabled via env var, no output is written to stderr."""
4148
monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_STDERR_ENABLED", "false")
42-
logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"))
49+
logging_initialize()
4350
from loguru import logger
4451

4552
logger.info(_MARKER_MESSAGE)
@@ -48,7 +55,7 @@ def test_logging_initialize_skips_stderr_when_disabled(
4855

4956
def test_intercept_handler_redirects_stdlib_log(self, capsys: pytest.CaptureFixture[str]) -> None:
5057
"""After initialization, stdlib logging messages are forwarded to loguru (and thus stderr)."""
51-
logging_initialize(context=make_context(_PROJECT))
58+
logging_initialize()
5259
stdlib_logging.getLogger("test.intercept").warning(_STDLIB_MESSAGE)
5360
captured = capsys.readouterr()
5461
assert _STDLIB_MESSAGE in captured.err
@@ -60,7 +67,7 @@ def test_logging_initialize_file_handler_writes_to_file(
6067
log_file = tmp_path / "test.log"
6168
monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_ENABLED", "true")
6269
monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_NAME", str(log_file))
63-
logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"))
70+
logging_initialize()
6471
from loguru import logger
6572

6673
logger.info(_FILE_HANDLER_MARKER)
@@ -69,16 +76,16 @@ def test_logging_initialize_file_handler_writes_to_file(
6976

7077
def test_logging_initialize_filter_func_is_applied(self, capsys: pytest.CaptureFixture[str]) -> None:
7178
"""A filter_func returning False suppresses all output from the handler."""
72-
logging_initialize(filter_func=lambda _: False, context=make_context(_PROJECT))
79+
logging_initialize(filter_func=lambda _: False)
7380
from loguru import logger
7481

7582
logger.info(_FILTER_MARKER)
7683
assert _FILTER_MARKER not in capsys.readouterr().err
7784

7885
def test_logging_initialize_replaces_handlers_on_repeated_calls(self, capsys: pytest.CaptureFixture[str]) -> None:
7986
"""Repeated calls replace existing handlers rather than accumulating them."""
80-
logging_initialize(context=make_context(_PROJECT))
81-
logging_initialize(context=make_context(_PROJECT))
87+
logging_initialize()
88+
logging_initialize()
8289
capsys.readouterr() # Drain any buffered output from initialization
8390
from loguru import logger
8491

@@ -87,13 +94,13 @@ def test_logging_initialize_replaces_handlers_on_repeated_calls(self, capsys: py
8794

8895
def test_intercept_handler_drops_sentry_messages(self, capsys: pytest.CaptureFixture[str]) -> None:
8996
"""InterceptHandler silently drops stdlib log messages containing 'sentry.io'."""
90-
logging_initialize(context=make_context(_PROJECT))
97+
logging_initialize()
9198
stdlib_logging.getLogger("test.sentry").warning(_SENTRY_MARKER)
9299
assert _SENTRY_MARKER not in capsys.readouterr().err
93100

94101
def test_logging_initialize_suppresses_psycopg_loggers(self) -> None:
95102
"""After logging_initialize(), psycopg loggers are set to WARNING to suppress noise."""
96-
logging_initialize(context=make_context(_PROJECT))
103+
logging_initialize()
97104
assert stdlib_logging.getLogger("psycopg").level == stdlib_logging.WARNING
98105
assert stdlib_logging.getLogger("psycopg.pool").level == stdlib_logging.WARNING
99106

@@ -102,7 +109,7 @@ def test_logging_initialize_uses_context_project_name(
102109
) -> None:
103110
"""logging_initialize reads env-var prefix from context.name."""
104111
monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_LEVEL", "DEBUG")
105-
logging_initialize(context=make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"))
112+
logging_initialize()
106113
from loguru import logger
107114

108115
logger.debug(_MARKER_MESSAGE)
@@ -111,9 +118,13 @@ def test_logging_initialize_uses_context_project_name(
111118
def test_logging_initialize_respects_env_prefix_from_context(
112119
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
113120
) -> None:
114-
"""logging_initialize uses ctx.env_prefix to resolve settings env vars."""
121+
"""LogSettings reads env vars from the prefix of the active get_context()."""
122+
monkeypatch.setattr(
123+
"aignostics_foundry_core.log.get_context",
124+
lambda: make_context("myproject", env_prefix="MYPROJECT_"),
125+
)
115126
monkeypatch.setenv("MYPROJECT_LOG_STDERR_ENABLED", "false")
116-
logging_initialize(context=make_context("myproject", env_prefix="MYPROJECT_"))
127+
logging_initialize()
117128
from loguru import logger
118129

119130
logger.info(_MARKER_MESSAGE)
@@ -124,6 +135,24 @@ def test_logging_initialize_respects_env_prefix_from_context(
124135
class TestLogSettings:
125136
"""Behavioural tests for LogSettings validation."""
126137

138+
@pytest.fixture(autouse=True)
139+
def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None:
140+
monkeypatch.setattr(
141+
"aignostics_foundry_core.log.get_context",
142+
lambda: make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"),
143+
)
144+
145+
@pytest.mark.unit
146+
def test_log_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
147+
"""LogSettings reads env vars using the env_prefix from the active FoundryContext."""
148+
monkeypatch.setattr(
149+
"aignostics_foundry_core.log.get_context",
150+
lambda: make_context("proj", env_prefix="PROJ_"),
151+
)
152+
monkeypatch.setenv("PROJ_LOG_STDERR_ENABLED", "false")
153+
settings = LogSettings() # pyright: ignore[reportCallIssue]
154+
assert settings.stderr_enabled is False
155+
127156
def test_log_settings_file_name_validation_rejects_directory(self, tmp_path: Path) -> None:
128157
"""Passing an existing directory as file_name raises ValidationError when file_enabled."""
129158
with pytest.raises(ValidationError):

0 commit comments

Comments
 (0)