csrd-context 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ from ._contextvars import (
2
+ configure_headers_context_provider,
3
+ get_api_version,
4
+ get_app_id,
5
+ get_headers,
6
+ get_hit_id,
7
+ get_path_params,
8
+ get_query_params,
9
+ reset_api_version_context,
10
+ reset_global_configuration,
11
+ reset_headers_context,
12
+ reset_path_params,
13
+ reset_query_params,
14
+ set_api_version_context,
15
+ set_headers_context,
16
+ set_path_params,
17
+ set_query_params,
18
+ )
19
+ from ._models import PathValue
20
+ from .middleware import HTTPLoggingMiddleware, RequestContextMiddleware
21
+ from .platform import app_id_context, hit_id_context, user_info_context
22
+
23
+ __all__ = (
24
+ "HTTPLoggingMiddleware",
25
+ # Models
26
+ "PathValue",
27
+ # Middleware
28
+ "RequestContextMiddleware",
29
+ "app_id_context",
30
+ # Context accessors
31
+ "configure_headers_context_provider",
32
+ "get_api_version",
33
+ "get_app_id",
34
+ "get_headers",
35
+ "get_hit_id",
36
+ "get_path_params",
37
+ "get_query_params",
38
+ "hit_id_context",
39
+ "reset_api_version_context",
40
+ "reset_global_configuration",
41
+ "reset_headers_context",
42
+ "reset_path_params",
43
+ "reset_query_params",
44
+ "set_api_version_context",
45
+ # Context setters
46
+ "set_headers_context",
47
+ "set_path_params",
48
+ "set_query_params",
49
+ # Platform contextvars
50
+ "user_info_context",
51
+ )
@@ -0,0 +1,6 @@
1
+ """Header name constants used by context infrastructure."""
2
+
3
+ APP_ID_HEADER_NAME = "x-client-app-id"
4
+ HIT_ID_HEADER_NAME = "x-client-hit-id"
5
+
6
+ __all__ = ("APP_ID_HEADER_NAME", "HIT_ID_HEADER_NAME")
@@ -0,0 +1,184 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from contextvars import ContextVar, Token
4
+ from typing import Any
5
+
6
+ from ._constants import APP_ID_HEADER_NAME, HIT_ID_HEADER_NAME
7
+ from ._models import PathValue
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Request-scoped context variables and accessors.
12
+ #
13
+ # The ``configure_*`` functions below set module-level state and must be called
14
+ # once during application startup, before any requests are served.
15
+
16
+ _PATH_CONTEXT_KEY = "path_context"
17
+ _QUERY_CONTEXT_KEY = "query_context"
18
+ _API_VERSION_CONTEXT_KEY = "api_version_context"
19
+
20
+ _path_context: ContextVar[PathValue | None] = ContextVar(_PATH_CONTEXT_KEY, default=None)
21
+ _query_context: ContextVar[PathValue | None] = ContextVar(_QUERY_CONTEXT_KEY, default=None)
22
+ _api_version_context: ContextVar[str | None] = ContextVar(_API_VERSION_CONTEXT_KEY, default=None)
23
+
24
+ _headers_getter: Callable[[], Any] | None = None
25
+ _headers_setter: Callable[[Any], Any] | None = None
26
+ _headers_resetter: Callable[[Any], None] | None = None
27
+ _unconfigured_headers_warned = False
28
+
29
+
30
+ def reset_global_configuration() -> None:
31
+ """Reset all module-level configuration to defaults.
32
+
33
+ Intended for test teardown to prevent cross-test contamination.
34
+ """
35
+ global _headers_getter, _headers_setter, _headers_resetter
36
+ global _unconfigured_headers_warned
37
+ _headers_getter = None
38
+ _headers_setter = None
39
+ _headers_resetter = None
40
+ _unconfigured_headers_warned = False
41
+
42
+
43
+ def configure_headers_context_provider(
44
+ *,
45
+ get_headers: Callable[[], Any],
46
+ set_headers: Callable[[Any], Any],
47
+ reset_headers: Callable[[Any], None],
48
+ ) -> None:
49
+ """Configure framework-provided header context accessors.
50
+
51
+ Must be called during application startup, before serving requests.
52
+ """
53
+
54
+ global _headers_getter, _headers_setter, _headers_resetter
55
+ if _headers_getter is not None:
56
+ logger.warning("Overwriting previously configured headers_context_provider")
57
+ _headers_getter = get_headers
58
+ _headers_setter = set_headers
59
+ _headers_resetter = reset_headers
60
+
61
+
62
+ def set_headers_context(headers: Any) -> Any:
63
+ """Set current request headers in the configured framework context."""
64
+ if _headers_setter is None:
65
+ raise RuntimeError(
66
+ "Headers context not configured. "
67
+ "Call configure_headers_context_provider() "
68
+ "before using the context system."
69
+ )
70
+ return _headers_setter(headers)
71
+
72
+
73
+ def reset_headers_context(token: Any) -> None:
74
+ """Reset current request headers from the configured framework context."""
75
+ if token is None:
76
+ return
77
+ if _headers_resetter is None:
78
+ raise RuntimeError(
79
+ "Headers context not configured. "
80
+ "Call configure_headers_context_provider() "
81
+ "before using the context system."
82
+ )
83
+ _headers_resetter(token)
84
+
85
+
86
+ def set_path_params(path_params: PathValue) -> Token[PathValue | None]:
87
+ """Store path parameters for the current async context; returns a token for reset."""
88
+ return _path_context.set(path_params)
89
+
90
+
91
+ def reset_path_params(token: Token[PathValue | None]) -> None:
92
+ """Restore path parameters to their previous value."""
93
+ _path_context.reset(token)
94
+
95
+
96
+ def set_query_params(query_params: PathValue) -> Token[PathValue | None]:
97
+ """Store query parameters for the current async context; returns a token for reset."""
98
+ return _query_context.set(query_params)
99
+
100
+
101
+ def reset_query_params(token: Token[PathValue | None]) -> None:
102
+ """Restore query parameters to their previous value."""
103
+ _query_context.reset(token)
104
+
105
+
106
+ def set_api_version_context(version: str | None) -> Token[str | None]:
107
+ """Store the resolved API version for the current async context."""
108
+ return _api_version_context.set(version)
109
+
110
+
111
+ def reset_api_version_context(token: Token[str | None]) -> None:
112
+ """Restore the API version to its previous value."""
113
+ _api_version_context.reset(token)
114
+
115
+
116
+ def get_path_params() -> PathValue:
117
+ """Return request path parameters captured in the current context."""
118
+ params = _path_context.get()
119
+ if params is None:
120
+ return PathValue()
121
+ return params
122
+
123
+
124
+ def get_query_params() -> PathValue:
125
+ """Return request query parameters captured in the current context."""
126
+ params = _query_context.get()
127
+ if params is None:
128
+ return PathValue()
129
+ return params
130
+
131
+
132
+ def get_api_version() -> str | None:
133
+ """Return resolved API version for the current request context."""
134
+ return _api_version_context.get()
135
+
136
+
137
+ def get_headers() -> Any:
138
+ """Return current request headers captured during dispatch."""
139
+ global _unconfigured_headers_warned
140
+ if _headers_getter is None:
141
+ if not _unconfigured_headers_warned:
142
+ _unconfigured_headers_warned = True
143
+ logger.warning(
144
+ "Headers context provider not configured. "
145
+ "get_headers(), get_app_id(), and get_hit_id() will return empty values. "
146
+ "Call configure_headers_context_provider() during startup."
147
+ )
148
+ return {}
149
+ headers = _headers_getter()
150
+ if headers is None:
151
+ return {}
152
+ return headers
153
+
154
+
155
+ def get_app_id() -> str | None:
156
+ """Return the current request app-id header value."""
157
+ val = get_headers().get(APP_ID_HEADER_NAME, None)
158
+ return str(val) if val is not None else None
159
+
160
+
161
+ def get_hit_id() -> str | None:
162
+ """Return the current request hit-id header value."""
163
+ val = get_headers().get(HIT_ID_HEADER_NAME, None)
164
+ return str(val) if val is not None else None
165
+
166
+
167
+ __all__ = (
168
+ "configure_headers_context_provider",
169
+ "get_api_version",
170
+ "get_app_id",
171
+ "get_headers",
172
+ "get_hit_id",
173
+ "get_path_params",
174
+ "get_query_params",
175
+ "reset_api_version_context",
176
+ "reset_global_configuration",
177
+ "reset_headers_context",
178
+ "reset_path_params",
179
+ "reset_query_params",
180
+ "set_api_version_context",
181
+ "set_headers_context",
182
+ "set_path_params",
183
+ "set_query_params",
184
+ )
@@ -0,0 +1,19 @@
1
+ """FastAPI-specific context variable for request headers."""
2
+
3
+ from contextvars import ContextVar
4
+
5
+ from starlette.datastructures import Headers
6
+
7
+ HEADERS_KEY = "request_headers"
8
+
9
+ _EMPTY_HEADERS = Headers()
10
+
11
+ headers_context: ContextVar[Headers] = ContextVar(HEADERS_KEY, default=_EMPTY_HEADERS)
12
+
13
+
14
+ def get_headers() -> Headers:
15
+ """Return request headers stored in the current context."""
16
+ return headers_context.get()
17
+
18
+
19
+ __all__ = ("HEADERS_KEY", "get_headers", "headers_context")
@@ -0,0 +1,34 @@
1
+ from typing import Any
2
+
3
+
4
+ class PathValue(dict[str, Any]):
5
+ """Dictionary wrapper that also supports dot-notation for key access."""
6
+
7
+ def __getattr__(self, item: str) -> Any:
8
+ """Return dict values via dot-notation, raising AttributeError for missing keys."""
9
+ if item.startswith("__"):
10
+ raise AttributeError(item)
11
+ try:
12
+ return self[item]
13
+ except KeyError:
14
+ raise AttributeError(f"'{type(self).__name__}' has no key '{item}'") from None
15
+
16
+ def __setattr__(self, key: str, value: Any) -> None:
17
+ """Map attribute assignment to dictionary item assignment."""
18
+ if key.startswith("__"):
19
+ super().__setattr__(key, value)
20
+ return
21
+ self[key] = value
22
+
23
+ def __delattr__(self, item: str) -> None:
24
+ """Map attribute deletion to dictionary item deletion."""
25
+ if item.startswith("__"):
26
+ super().__delattr__(item)
27
+ return
28
+ try:
29
+ del self[item]
30
+ except KeyError as exc:
31
+ raise AttributeError(item) from exc
32
+
33
+
34
+ __all__ = ("PathValue",)
@@ -0,0 +1,4 @@
1
+ from ._logging import REQUEST_SCOPE_KEY, HTTPLoggingMiddleware, RequestScope
2
+ from ._request import RequestContextMiddleware
3
+
4
+ __all__ = ("REQUEST_SCOPE_KEY", "HTTPLoggingMiddleware", "RequestContextMiddleware", "RequestScope")
@@ -0,0 +1,121 @@
1
+ """HTTP logging middleware for FastAPI applications.
2
+
3
+ Implemented as a raw ASGI middleware (no ``BaseHTTPMiddleware``) so that
4
+ ``StreamingResponse`` and SSE endpoints are not buffered.
5
+ """
6
+
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from collections import defaultdict
11
+ from http import HTTPStatus
12
+ from typing import Any, TypedDict
13
+
14
+ from starlette.requests import Request
15
+ from starlette.routing import Match
16
+ from starlette.types import ASGIApp, Receive, Scope, Send
17
+
18
+ from csrd.models.claims import UserClaims
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ REQUEST_SCOPE_KEY = "__DS__"
23
+
24
+
25
+ class RequestScope(TypedDict, total=False):
26
+ hit_id: str
27
+ app_id: str
28
+ user_info: UserClaims | None
29
+
30
+
31
+ class HTTPLoggingMiddleware:
32
+ """Raw ASGI middleware to log HTTP request details including timing,
33
+ user context, and response information.
34
+
35
+ Unlike ``BaseHTTPMiddleware``, this does **not** buffer the response
36
+ body, so ``StreamingResponse`` and SSE endpoints work correctly.
37
+ """
38
+
39
+ def __init__(self, app: ASGIApp) -> None:
40
+ self.app = app
41
+
42
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
43
+ if scope["type"] != "http":
44
+ await self.app(scope, receive, send)
45
+ return
46
+
47
+ request = Request(scope)
48
+ scope[REQUEST_SCOPE_KEY] = RequestScope()
49
+
50
+ elapsed = -time.perf_counter()
51
+
52
+ query_params: dict[str, list[str]] = defaultdict(list)
53
+ for key, value in request.query_params.multi_items():
54
+ query_params[key].append(value)
55
+
56
+ extras: dict[str, Any] = {
57
+ "method": request.method,
58
+ "uri": request.url.path,
59
+ "uri_mapping": self._get_route_path(request),
60
+ "query_params": query_params,
61
+ "hit_id": request.headers.get("x-client-hit-id") or str(uuid.uuid4()),
62
+ "app_id": request.headers.get("x-client-app-id", "unknown"),
63
+ }
64
+
65
+ logger.info("http.request.start", extra=extras)
66
+
67
+ # Track response status via a wrapper around send
68
+ status_holder: dict[str, int] = {"status": 0}
69
+
70
+ async def send_wrapper(message: Any) -> None:
71
+ if message["type"] == "http.response.start":
72
+ status_holder["status"] = message["status"]
73
+ await send(message)
74
+
75
+ exc_info = None
76
+ level = logging.INFO
77
+ try:
78
+ await self.app(scope, receive, send_wrapper)
79
+ status = status_holder["status"]
80
+ level = self._get_log_level(status)
81
+ except Exception as exc:
82
+ status = getattr(exc, "status_code", 500)
83
+ extras["error"] = exc.__cause__
84
+ exc_info = exc
85
+ level = logging.ERROR
86
+ raise
87
+ finally:
88
+ ds_scope = scope.get(REQUEST_SCOPE_KEY) or {}
89
+ extras.update(
90
+ hit_id=ds_scope.get("hit_id", "unknown"),
91
+ app_id=ds_scope.get("app_id", "unknown"),
92
+ )
93
+ if user_info := ds_scope.get("user_info"):
94
+ extras.update(
95
+ user_id=user_info.sub,
96
+ user_email=user_info.user_name,
97
+ )
98
+ elapsed += time.perf_counter()
99
+ extras["elapsed_millis"] = int(elapsed * 1000)
100
+ extras["status"] = status
101
+ logger.log(level, "http.request.complete", exc_info=exc_info, extra=extras)
102
+
103
+ @staticmethod
104
+ def _get_log_level(status: int) -> int:
105
+ http_status = HTTPStatus(status)
106
+ if http_status.is_informational or http_status.is_success or http_status.is_redirection:
107
+ return logging.INFO
108
+ if http_status.is_client_error:
109
+ return logging.WARNING
110
+ return logging.ERROR
111
+
112
+ @staticmethod
113
+ def _get_route_path(request: Request) -> str:
114
+ for route in request.app.routes:
115
+ match, _ = route.matches(request.scope)
116
+ if match == Match.FULL:
117
+ return str(route.path)
118
+ return request.url.path
119
+
120
+
121
+ __all__ = ("REQUEST_SCOPE_KEY", "HTTPLoggingMiddleware", "RequestScope")
@@ -0,0 +1,51 @@
1
+ """Request context middleware for setting up headers context.
2
+
3
+ Implemented as a raw ASGI middleware (no ``BaseHTTPMiddleware``) so that
4
+ ``StreamingResponse`` and SSE endpoints are not buffered.
5
+ """
6
+
7
+ from starlette.requests import Request
8
+ from starlette.types import ASGIApp, Receive, Scope, Send
9
+
10
+ from .._contextvars import (
11
+ configure_headers_context_provider,
12
+ reset_headers_context,
13
+ set_headers_context,
14
+ )
15
+ from .._fastapi_headers import headers_context
16
+
17
+
18
+ def _setup_fastapi_headers_provider() -> None:
19
+ """Wire the FastAPI headers ContextVar as the headers provider."""
20
+ configure_headers_context_provider(
21
+ get_headers=headers_context.get,
22
+ set_headers=headers_context.set,
23
+ reset_headers=headers_context.reset,
24
+ )
25
+
26
+
27
+ class RequestContextMiddleware:
28
+ """Raw ASGI middleware that captures request headers into context variables.
29
+
30
+ Unlike ``BaseHTTPMiddleware``, this does **not** buffer the response body,
31
+ so ``StreamingResponse`` and SSE endpoints work correctly.
32
+ """
33
+
34
+ def __init__(self, app: ASGIApp, **kwargs: object) -> None:
35
+ self.app = app
36
+ _setup_fastapi_headers_provider()
37
+
38
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
39
+ if scope["type"] != "http":
40
+ await self.app(scope, receive, send)
41
+ return
42
+
43
+ request = Request(scope)
44
+ token = set_headers_context(request.headers)
45
+ try:
46
+ await self.app(scope, receive, send)
47
+ finally:
48
+ reset_headers_context(token)
49
+
50
+
51
+ __all__ = ("RequestContextMiddleware",)
@@ -0,0 +1,21 @@
1
+ """Platform-level context variables for user info, hit-id, and app-id."""
2
+
3
+ from contextvars import ContextVar
4
+ from typing import Any
5
+
6
+ __all__ = (
7
+ "APP_ID_KEY",
8
+ "HIT_ID_KEY",
9
+ "USER_INFO_KEY",
10
+ "app_id_context",
11
+ "hit_id_context",
12
+ "user_info_context",
13
+ )
14
+
15
+ USER_INFO_KEY = "user_info"
16
+ HIT_ID_KEY = "hit_id"
17
+ APP_ID_KEY = "app_id"
18
+
19
+ user_info_context: ContextVar[Any | None] = ContextVar(USER_INFO_KEY, default=None)
20
+ hit_id_context: ContextVar[str] = ContextVar(HIT_ID_KEY, default="unknown")
21
+ app_id_context: ContextVar[str] = ContextVar(APP_ID_KEY, default="unknown")
csrd/context/py.typed ADDED
File without changes
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: csrd-context
3
+ Version: 0.1.0
4
+ Summary: Request context, header utilities, and logging middleware for FastAPI
5
+ Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
+ Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/context
7
+ Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: csrd-models
11
+ Requires-Dist: fastapi<1,>=0.115
12
+ Requires-Dist: starlette>=0.36
@@ -0,0 +1,13 @@
1
+ csrd/context/__init__.py,sha256=pKZhboGarvcEV2D_pdEtDNP6JMSCFYVKBM7KFiAh8QY,1276
2
+ csrd/context/_constants.py,sha256=8pgg9TI3EOODbYnkKXSMvxaMOO0n4nsKvyphdXR0gak,195
3
+ csrd/context/_contextvars.py,sha256=sRxi-0EwbUk8IwvdKYTCauLyHOt-4Han16_aE6VXlBg,5984
4
+ csrd/context/_fastapi_headers.py,sha256=aJVhMHTjFPpaWU9q1CcwCkT8_jWCSTvdEZNhxCGlbtE,483
5
+ csrd/context/_models.py,sha256=okTHUDODPi6GlLkmHHCHaEeNipT-Ab4TAdDkf7xZB70,1106
6
+ csrd/context/platform.py,sha256=XMQRT7uvsQdRwR6PRyWbEvXbobAPdnG7E9TvjjXCDFQ,584
7
+ csrd/context/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ csrd/context/middleware/__init__.py,sha256=kd3UP5e-lhEe825FDbaWwtHIGm3R3iakmX7JsvB7864,226
9
+ csrd/context/middleware/_logging.py,sha256=ONKzJcdbJk8zmgnw7FsIcfb5B1M3FH2qLTfCGA378Gs,4051
10
+ csrd/context/middleware/_request.py,sha256=wvJsL1xa7WXkvsVi2IVxLZRJ71NcrTA7_JRBcpv1bwI,1592
11
+ csrd_context-0.1.0.dist-info/METADATA,sha256=g9FMm81C4hEu4PcFq-9q8qEBS-B-rPVfN4KAy9BiOpI,519
12
+ csrd_context-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ csrd_context-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any