csrd-context 0.1.0__tar.gz
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.
- csrd_context-0.1.0/.gitignore +217 -0
- csrd_context-0.1.0/PKG-INFO +12 -0
- csrd_context-0.1.0/README.md +25 -0
- csrd_context-0.1.0/pyproject.toml +26 -0
- csrd_context-0.1.0/src/csrd/context/__init__.py +51 -0
- csrd_context-0.1.0/src/csrd/context/_constants.py +6 -0
- csrd_context-0.1.0/src/csrd/context/_contextvars.py +184 -0
- csrd_context-0.1.0/src/csrd/context/_fastapi_headers.py +19 -0
- csrd_context-0.1.0/src/csrd/context/_models.py +34 -0
- csrd_context-0.1.0/src/csrd/context/middleware/__init__.py +4 -0
- csrd_context-0.1.0/src/csrd/context/middleware/_logging.py +121 -0
- csrd_context-0.1.0/src/csrd/context/middleware/_request.py +51 -0
- csrd_context-0.1.0/src/csrd/context/platform.py +21 -0
- csrd_context-0.1.0/src/csrd/context/py.typed +0 -0
- csrd_context-0.1.0/tests/test_contextvars.py +115 -0
- csrd_context-0.1.0/tests/test_middleware.py +52 -0
- csrd_context-0.1.0/tests/test_models.py +45 -0
- csrd_context-0.1.0/tests/test_platform.py +30 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
/env/
|
|
142
|
+
/venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
*.db
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Import linter cache
|
|
213
|
+
.import_linter_cache/
|
|
214
|
+
|
|
215
|
+
# IDE
|
|
216
|
+
.idea/
|
|
217
|
+
.idea/*
|
|
@@ -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,25 @@
|
|
|
1
|
+
# csrd-context
|
|
2
|
+
|
|
3
|
+
Request context, header utilities, and logging middleware for FastAPI.
|
|
4
|
+
|
|
5
|
+
**Package**: `csrd.context` · **Import**: `from csrd.context import ...`
|
|
6
|
+
|
|
7
|
+
## What's included
|
|
8
|
+
|
|
9
|
+
- Framework-agnostic context variable system (headers, path params, query params, API version)
|
|
10
|
+
- `RequestContextMiddleware` — captures request headers into context (raw ASGI, streaming-safe)
|
|
11
|
+
- `HTTPLoggingMiddleware` — structured HTTP request/response logging (raw ASGI, streaming-safe)
|
|
12
|
+
- Platform context variables (`user_info_context`, `hit_id_context`, `app_id_context`)
|
|
13
|
+
- `get_headers()`, `get_hit_id()`, `get_app_id()` — accessors for the current request context
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
This package is part of the `fastapi-common` monorepo. Install via git:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv pip install "csrd-context @ git+ssh://git@github.com/csrd-api/fastapi-common.git#subdirectory=packages/context"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Dependencies
|
|
24
|
+
|
|
25
|
+
- `csrd-models` (Tier 1 — no other `csrd.*` sibling dependencies)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-context"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Request context, header utilities, and logging middleware for FastAPI"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"starlette>=0.36",
|
|
9
|
+
"fastapi>=0.115,<1",
|
|
10
|
+
"csrd-models",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.uv.sources]
|
|
14
|
+
csrd-models = { workspace = true }
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
18
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/context"
|
|
19
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/csrd"]
|
|
@@ -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,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,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")
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tests for csrd.context contextvars system."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from csrd.context._contextvars import (
|
|
6
|
+
configure_headers_context_provider,
|
|
7
|
+
get_api_version,
|
|
8
|
+
get_app_id,
|
|
9
|
+
get_headers,
|
|
10
|
+
get_hit_id,
|
|
11
|
+
get_path_params,
|
|
12
|
+
get_query_params,
|
|
13
|
+
reset_api_version_context,
|
|
14
|
+
reset_global_configuration,
|
|
15
|
+
reset_headers_context,
|
|
16
|
+
reset_path_params,
|
|
17
|
+
reset_query_params,
|
|
18
|
+
set_api_version_context,
|
|
19
|
+
set_headers_context,
|
|
20
|
+
set_path_params,
|
|
21
|
+
set_query_params,
|
|
22
|
+
)
|
|
23
|
+
from csrd.context._models import PathValue
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestPathParamsContext:
|
|
27
|
+
def test_set_and_get(self):
|
|
28
|
+
token = set_path_params(PathValue({"id": "42"}))
|
|
29
|
+
try:
|
|
30
|
+
params = get_path_params()
|
|
31
|
+
assert params["id"] == "42"
|
|
32
|
+
assert params.id == "42"
|
|
33
|
+
finally:
|
|
34
|
+
reset_path_params(token)
|
|
35
|
+
|
|
36
|
+
def test_default_empty(self):
|
|
37
|
+
params = get_path_params()
|
|
38
|
+
assert len(params) == 0
|
|
39
|
+
|
|
40
|
+
def test_reset(self):
|
|
41
|
+
token = set_path_params(PathValue({"x": "y"}))
|
|
42
|
+
reset_path_params(token)
|
|
43
|
+
assert len(get_path_params()) == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestQueryParamsContext:
|
|
47
|
+
def test_set_and_get(self):
|
|
48
|
+
token = set_query_params(PathValue({"page": "2"}))
|
|
49
|
+
try:
|
|
50
|
+
params = get_query_params()
|
|
51
|
+
assert params["page"] == "2"
|
|
52
|
+
finally:
|
|
53
|
+
reset_query_params(token)
|
|
54
|
+
|
|
55
|
+
def test_default_empty(self):
|
|
56
|
+
params = get_query_params()
|
|
57
|
+
assert len(params) == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestApiVersionContext:
|
|
61
|
+
def test_set_and_get(self):
|
|
62
|
+
token = set_api_version_context("2025-06-20")
|
|
63
|
+
try:
|
|
64
|
+
assert get_api_version() == "2025-06-20"
|
|
65
|
+
finally:
|
|
66
|
+
reset_api_version_context(token)
|
|
67
|
+
|
|
68
|
+
def test_default_none(self):
|
|
69
|
+
assert get_api_version() is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestHeadersContext:
|
|
73
|
+
def setup_method(self):
|
|
74
|
+
reset_global_configuration()
|
|
75
|
+
|
|
76
|
+
def teardown_method(self):
|
|
77
|
+
reset_global_configuration()
|
|
78
|
+
|
|
79
|
+
def test_unconfigured_returns_empty_dict(self):
|
|
80
|
+
headers = get_headers()
|
|
81
|
+
assert headers == {}
|
|
82
|
+
|
|
83
|
+
def test_configure_and_use(self):
|
|
84
|
+
from contextvars import ContextVar
|
|
85
|
+
|
|
86
|
+
cv: ContextVar[dict] = ContextVar("test_headers")
|
|
87
|
+
cv.set({})
|
|
88
|
+
|
|
89
|
+
configure_headers_context_provider(
|
|
90
|
+
get_headers=cv.get,
|
|
91
|
+
set_headers=cv.set,
|
|
92
|
+
reset_headers=cv.reset,
|
|
93
|
+
)
|
|
94
|
+
token = set_headers_context({"x-client-app-id": "myapp", "x-client-hit-id": "hit123"})
|
|
95
|
+
try:
|
|
96
|
+
headers = get_headers()
|
|
97
|
+
assert headers["x-client-app-id"] == "myapp"
|
|
98
|
+
assert get_app_id() == "myapp"
|
|
99
|
+
assert get_hit_id() == "hit123"
|
|
100
|
+
finally:
|
|
101
|
+
reset_headers_context(token)
|
|
102
|
+
|
|
103
|
+
def test_set_headers_before_configure_raises(self):
|
|
104
|
+
with pytest.raises(RuntimeError, match="not configured"):
|
|
105
|
+
set_headers_context({"key": "val"})
|
|
106
|
+
|
|
107
|
+
def test_reset_headers_none_token_is_noop(self):
|
|
108
|
+
# Should not raise
|
|
109
|
+
reset_headers_context(None)
|
|
110
|
+
|
|
111
|
+
def test_get_app_id_unconfigured(self):
|
|
112
|
+
assert get_app_id() is None
|
|
113
|
+
|
|
114
|
+
def test_get_hit_id_unconfigured(self):
|
|
115
|
+
assert get_hit_id() is None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for RequestContextMiddleware and HTTPLoggingMiddleware."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from httpx import ASGITransport, AsyncClient
|
|
6
|
+
|
|
7
|
+
from csrd.context import RequestContextMiddleware, get_headers
|
|
8
|
+
from csrd.context._contextvars import reset_global_configuration
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def app():
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
|
|
15
|
+
@app.get("/test")
|
|
16
|
+
async def test_endpoint():
|
|
17
|
+
headers = get_headers()
|
|
18
|
+
return {
|
|
19
|
+
"x-custom": headers.get("x-custom", "missing"),
|
|
20
|
+
"has_headers": len(dict(headers)) > 0,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
app.add_middleware(RequestContextMiddleware)
|
|
24
|
+
return app
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def anyio_backend():
|
|
29
|
+
return "asyncio"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestRequestContextMiddleware:
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_headers_captured(self, app):
|
|
35
|
+
reset_global_configuration()
|
|
36
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
37
|
+
resp = await client.get("/test", headers={"x-custom": "hello"})
|
|
38
|
+
assert resp.status_code == 200
|
|
39
|
+
data = resp.json()
|
|
40
|
+
assert data["x-custom"] == "hello"
|
|
41
|
+
assert data["has_headers"] is True
|
|
42
|
+
reset_global_configuration()
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_headers_isolated_between_requests(self, app):
|
|
46
|
+
reset_global_configuration()
|
|
47
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
48
|
+
resp1 = await client.get("/test", headers={"x-custom": "first"})
|
|
49
|
+
resp2 = await client.get("/test", headers={"x-custom": "second"})
|
|
50
|
+
assert resp1.json()["x-custom"] == "first"
|
|
51
|
+
assert resp2.json()["x-custom"] == "second"
|
|
52
|
+
reset_global_configuration()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for PathValue dict wrapper."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from csrd.context._models import PathValue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestPathValue:
|
|
9
|
+
def test_dict_access(self):
|
|
10
|
+
pv = PathValue({"user_id": "123", "org": "acme"})
|
|
11
|
+
assert pv["user_id"] == "123"
|
|
12
|
+
|
|
13
|
+
def test_dot_notation(self):
|
|
14
|
+
pv = PathValue({"user_id": "123"})
|
|
15
|
+
assert pv.user_id == "123"
|
|
16
|
+
|
|
17
|
+
def test_dot_notation_missing_raises_attribute_error(self):
|
|
18
|
+
pv = PathValue()
|
|
19
|
+
with pytest.raises(AttributeError, match="no key"):
|
|
20
|
+
_ = pv.missing_key
|
|
21
|
+
|
|
22
|
+
def test_set_via_dot_notation(self):
|
|
23
|
+
pv = PathValue()
|
|
24
|
+
pv.name = "Alice"
|
|
25
|
+
assert pv["name"] == "Alice"
|
|
26
|
+
|
|
27
|
+
def test_del_via_dot_notation(self):
|
|
28
|
+
pv = PathValue({"key": "val"})
|
|
29
|
+
del pv.key
|
|
30
|
+
assert "key" not in pv
|
|
31
|
+
|
|
32
|
+
def test_del_missing_raises(self):
|
|
33
|
+
pv = PathValue()
|
|
34
|
+
with pytest.raises(AttributeError):
|
|
35
|
+
del pv.nope
|
|
36
|
+
|
|
37
|
+
def test_dunder_attrs_use_regular_path(self):
|
|
38
|
+
pv = PathValue()
|
|
39
|
+
with pytest.raises(AttributeError):
|
|
40
|
+
_ = pv.__nonexistent__
|
|
41
|
+
|
|
42
|
+
def test_empty_defaults(self):
|
|
43
|
+
pv = PathValue()
|
|
44
|
+
assert len(pv) == 0
|
|
45
|
+
assert dict(pv) == {}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Tests for platform-level context variables."""
|
|
2
|
+
|
|
3
|
+
from csrd.context.platform import app_id_context, hit_id_context, user_info_context
|
|
4
|
+
from csrd.models.claims import UserClaims
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestPlatformContextVars:
|
|
8
|
+
def test_user_info_default_none(self):
|
|
9
|
+
assert user_info_context.get() is None
|
|
10
|
+
|
|
11
|
+
def test_user_info_set_and_get(self):
|
|
12
|
+
claims = UserClaims(sub="user1", user_name="alice")
|
|
13
|
+
token = user_info_context.set(claims)
|
|
14
|
+
try:
|
|
15
|
+
assert user_info_context.get().sub == "user1"
|
|
16
|
+
finally:
|
|
17
|
+
user_info_context.reset(token)
|
|
18
|
+
|
|
19
|
+
def test_hit_id_default(self):
|
|
20
|
+
assert hit_id_context.get() == "unknown"
|
|
21
|
+
|
|
22
|
+
def test_app_id_default(self):
|
|
23
|
+
assert app_id_context.get() == "unknown"
|
|
24
|
+
|
|
25
|
+
def test_hit_id_set(self):
|
|
26
|
+
token = hit_id_context.set("req-abc-123")
|
|
27
|
+
try:
|
|
28
|
+
assert hit_id_context.get() == "req-abc-123"
|
|
29
|
+
finally:
|
|
30
|
+
hit_id_context.reset(token)
|