csrd-models 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_models-0.1.0/.gitignore +217 -0
- csrd_models-0.1.0/PKG-INFO +11 -0
- csrd_models-0.1.0/README.md +23 -0
- csrd_models-0.1.0/pyproject.toml +22 -0
- csrd_models-0.1.0/src/csrd/models/__init__.py +21 -0
- csrd_models-0.1.0/src/csrd/models/_base_model.py +9 -0
- csrd_models-0.1.0/src/csrd/models/_base_settings.py +12 -0
- csrd_models-0.1.0/src/csrd/models/claims.py +20 -0
- csrd_models-0.1.0/src/csrd/models/errors.py +71 -0
- csrd_models-0.1.0/src/csrd/models/message.py +5 -0
- csrd_models-0.1.0/src/csrd/models/model_parser/__init__.py +12 -0
- csrd_models-0.1.0/src/csrd/models/model_parser/_default_model_extractor.py +17 -0
- csrd_models-0.1.0/src/csrd/models/model_parser/_types.py +6 -0
- csrd_models-0.1.0/src/csrd/models/model_parser/model_parser_mixin.py +76 -0
- csrd_models-0.1.0/src/csrd/models/model_parser/payload_extractor_protocol.py +5 -0
- csrd_models-0.1.0/src/csrd/models/py.typed +0 -0
- csrd_models-0.1.0/tests/test_base_model.py +36 -0
- csrd_models-0.1.0/tests/test_claims.py +36 -0
- csrd_models-0.1.0/tests/test_errors.py +87 -0
- csrd_models-0.1.0/tests/test_message.py +14 -0
- csrd_models-0.1.0/tests/test_model_parser.py +94 -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,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-models
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Base Pydantic models and settings
|
|
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/models
|
|
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: pydantic-settings<3,>=2.10
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# csrd-models
|
|
2
|
+
|
|
3
|
+
Base Pydantic models, settings, and parsing utilities for FastAPI microservices.
|
|
4
|
+
|
|
5
|
+
**Package**: `csrd.models` · **Import**: `from csrd.models import BaseModel, BaseSettings`
|
|
6
|
+
|
|
7
|
+
## What's included
|
|
8
|
+
|
|
9
|
+
- `BaseModel` / `BaseSettings` with camelCase alias config and `populate_by_name=True`
|
|
10
|
+
- `ModelParserMixin` — response parsing mixin for delegates and repositories
|
|
11
|
+
- `PayloadExtractor` / `DefaultExtractor` — pluggable payload extraction
|
|
12
|
+
- `UserClaims` — JWT user claims dataclass
|
|
13
|
+
- `APIErrorResponse`, `ErrorMeta`, `Error`, `APIVersion` — structured error response models
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv pip install "csrd-models @ git+ssh://git@github.com/csrd-api/fastapi-common.git#subdirectory=packages/models"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Dependencies
|
|
22
|
+
|
|
23
|
+
None (Tier 1 — standalone)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-models"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Base Pydantic models and settings"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.10,<3",
|
|
9
|
+
"pydantic-settings>=2.10,<3",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
14
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/models"
|
|
15
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/csrd"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from . import model_parser
|
|
2
|
+
from ._base_model import BaseModel, model_config
|
|
3
|
+
from ._base_settings import BaseSettings, settings_config
|
|
4
|
+
from .claims import UserClaims
|
|
5
|
+
from .errors import APIErrorResponse, APIVersion, Error, ErrorMeta, SerializerMixin
|
|
6
|
+
from .message import Message
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"APIErrorResponse",
|
|
10
|
+
"APIVersion",
|
|
11
|
+
"BaseModel",
|
|
12
|
+
"BaseSettings",
|
|
13
|
+
"Error",
|
|
14
|
+
"ErrorMeta",
|
|
15
|
+
"Message",
|
|
16
|
+
"SerializerMixin",
|
|
17
|
+
"UserClaims",
|
|
18
|
+
"model_config",
|
|
19
|
+
"model_parser",
|
|
20
|
+
"settings_config",
|
|
21
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pydantic import BaseModel as _BaseModel
|
|
2
|
+
from pydantic import ConfigDict
|
|
3
|
+
from pydantic.alias_generators import to_camel
|
|
4
|
+
|
|
5
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseModel(_BaseModel):
|
|
9
|
+
model_config = model_config
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings as _BaseSettings
|
|
2
|
+
from pydantic_settings import SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
settings_config = SettingsConfigDict(
|
|
5
|
+
env_file=(".default_env", ".env"), env_file_encoding="utf-8", case_sensitive=False
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseSettings(_BaseSettings, frozen=True): # type: ignore[misc]
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_file=(".default_env", ".env"), env_file_encoding="utf-8", case_sensitive=False
|
|
12
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import UTC, datetime, timedelta
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class UserClaims:
|
|
7
|
+
"""Generic user claims for API authentication."""
|
|
8
|
+
|
|
9
|
+
sub: str = ""
|
|
10
|
+
user_name: str = ""
|
|
11
|
+
authorities: list[str] = field(default_factory=list)
|
|
12
|
+
iat: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
13
|
+
exp: datetime | None = None
|
|
14
|
+
|
|
15
|
+
def __post_init__(self) -> None:
|
|
16
|
+
if self.exp is None:
|
|
17
|
+
self.exp = self.iat + timedelta(hours=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ("UserClaims",)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Minimal REST API error models extracted from REST API error models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import ConfigDict, TypeAdapter
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
9
|
+
|
|
10
|
+
CONFIG = ConfigDict(alias_generator=to_camel, populate_by_name=True, arbitrary_types_allowed=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SerializerMixin:
|
|
14
|
+
def as_dict(self, **kwargs: Any) -> dict[str, Any]:
|
|
15
|
+
result = TypeAdapter(type(self)).dump_python(self, **kwargs)
|
|
16
|
+
return dict(result) # type: ignore[arg-type]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class APIVersion:
|
|
21
|
+
"""API version information for error responses."""
|
|
22
|
+
|
|
23
|
+
requested: str | None = None
|
|
24
|
+
served: str | None = None
|
|
25
|
+
|
|
26
|
+
__pydantic_config__ = CONFIG
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ErrorMeta:
|
|
31
|
+
"""Metadata for error responses."""
|
|
32
|
+
|
|
33
|
+
status: int = field(metadata={"description": "HTTP status code"})
|
|
34
|
+
error: str = field(metadata={"description": "Error message"})
|
|
35
|
+
path: str = field(metadata={"description": "Request path"})
|
|
36
|
+
api_version: APIVersion = field(metadata={"description": "API version information"})
|
|
37
|
+
timestamp: datetime = field(
|
|
38
|
+
default_factory=lambda: datetime.now(UTC), metadata={"description": "Error timestamp"}
|
|
39
|
+
)
|
|
40
|
+
method: str | None = field(default=None, metadata={"description": "HTTP method"})
|
|
41
|
+
request_id: str | None = field(
|
|
42
|
+
default=None, metadata={"description": "Unique request identifier"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__pydantic_config__ = CONFIG
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Error:
|
|
50
|
+
"""Individual error details."""
|
|
51
|
+
|
|
52
|
+
title: str = field(metadata={"description": "Error title"})
|
|
53
|
+
detail: str | None = field(default=None, metadata={"description": "Detailed error description"})
|
|
54
|
+
code: str | None = field(
|
|
55
|
+
default=None, metadata={"description": "Application-specific error code"}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__pydantic_config__ = CONFIG
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class APIErrorResponse(SerializerMixin):
|
|
63
|
+
"""Standard API error response format."""
|
|
64
|
+
|
|
65
|
+
meta: ErrorMeta = field(metadata={"description": "Error metadata"})
|
|
66
|
+
errors: list[Error] = field(metadata={"description": "List of errors"})
|
|
67
|
+
|
|
68
|
+
__pydantic_config__ = CONFIG
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ("APIErrorResponse", "APIVersion", "Error", "ErrorMeta", "SerializerMixin")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from ._default_model_extractor import DefaultExtractor
|
|
2
|
+
from ._types import ParsedResponse, ResponseModelType
|
|
3
|
+
from .model_parser_mixin import ModelParserMixin
|
|
4
|
+
from .payload_extractor_protocol import PayloadExtractor
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
"DefaultExtractor",
|
|
8
|
+
"ModelParserMixin",
|
|
9
|
+
"ParsedResponse",
|
|
10
|
+
"PayloadExtractor",
|
|
11
|
+
"ResponseModelType",
|
|
12
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .payload_extractor_protocol import PayloadExtractor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DefaultExtractor(PayloadExtractor):
|
|
7
|
+
def extract(self, source: Any):
|
|
8
|
+
if hasattr(source, "json") and callable(source.json):
|
|
9
|
+
result = source.json()
|
|
10
|
+
# Guard against .json() returning a string (e.g. httpx Response)
|
|
11
|
+
# — callers expect a dict or list, not a raw JSON string.
|
|
12
|
+
if isinstance(result, str):
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
return json.loads(result)
|
|
16
|
+
return result
|
|
17
|
+
return source
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, get_args, get_origin
|
|
3
|
+
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from ._default_model_extractor import DefaultExtractor
|
|
7
|
+
from ._types import ParsedResponse, ResponseModelType
|
|
8
|
+
from .payload_extractor_protocol import PayloadExtractor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModelParserMixin:
|
|
12
|
+
"""
|
|
13
|
+
A mixin that applies a Pydantic model to various input formats,
|
|
14
|
+
with optional support for custom model handlers.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_extractor: PayloadExtractor | None
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, extractor: PayloadExtractor | None = None):
|
|
20
|
+
self._extractor = extractor
|
|
21
|
+
|
|
22
|
+
def apply_model(
|
|
23
|
+
self,
|
|
24
|
+
source: Any,
|
|
25
|
+
*,
|
|
26
|
+
model: ResponseModelType | None = None,
|
|
27
|
+
model_handler: Callable | None = None,
|
|
28
|
+
exception: type[Exception] | None = None,
|
|
29
|
+
extractor: PayloadExtractor | None = None,
|
|
30
|
+
) -> ParsedResponse:
|
|
31
|
+
"""
|
|
32
|
+
Convert source data into a parsed model or passthrough object.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
source: Input data (dict, list, or raw object) to parse.
|
|
36
|
+
model: Optional model class or List[Model] to parse against.
|
|
37
|
+
model_handler: Optional custom callable to handle parsing.
|
|
38
|
+
exception: Optional exception type to raise on failure.
|
|
39
|
+
extractor: Optional extraction handler callable to handle parsing.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
ParsedResponse: The parsed object(s) or raw source.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
if model_handler:
|
|
46
|
+
return model_handler(source, model)
|
|
47
|
+
|
|
48
|
+
extractor = extractor or self._extractor
|
|
49
|
+
data = self._resolve_payload(source, extractor)
|
|
50
|
+
|
|
51
|
+
if model is None:
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
if get_origin(model) is list:
|
|
55
|
+
item_type = get_args(model)[0]
|
|
56
|
+
if not isinstance(data, list):
|
|
57
|
+
raise TypeError("Expected list in input payload")
|
|
58
|
+
return [item_type(**item) for item in data]
|
|
59
|
+
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
raise TypeError("Expected dict in input payload")
|
|
62
|
+
|
|
63
|
+
return model(**data)
|
|
64
|
+
|
|
65
|
+
except (ValidationError, TypeError, ValueError, KeyError) as e:
|
|
66
|
+
if exception:
|
|
67
|
+
raise exception(e) from e
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _resolve_payload(source: Any, extractor: PayloadExtractor | None = None) -> Any:
|
|
72
|
+
"""
|
|
73
|
+
Uses a pluggable extractor to transform source into raw payload.
|
|
74
|
+
"""
|
|
75
|
+
extractor = extractor or DefaultExtractor()
|
|
76
|
+
return extractor.extract(source)
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tests for BaseModel camelCase aliasing and model_config."""
|
|
2
|
+
|
|
3
|
+
from csrd.models import BaseModel, model_config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SampleModel(BaseModel):
|
|
7
|
+
first_name: str
|
|
8
|
+
last_name: str
|
|
9
|
+
is_active: bool = True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SampleWithConfig(BaseModel):
|
|
13
|
+
model_config = model_config
|
|
14
|
+
hello_world: str = "hi"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestBaseModelAliasing:
|
|
18
|
+
def test_camel_case_serialization(self):
|
|
19
|
+
m = SampleModel(firstName="Alice", lastName="Smith")
|
|
20
|
+
dumped = m.model_dump(by_alias=True)
|
|
21
|
+
assert dumped == {"firstName": "Alice", "lastName": "Smith", "isActive": True}
|
|
22
|
+
|
|
23
|
+
def test_camel_case_deserialization(self):
|
|
24
|
+
m = SampleModel.model_validate({"firstName": "Bob", "lastName": "Jones"})
|
|
25
|
+
assert m.first_name == "Bob"
|
|
26
|
+
assert m.last_name == "Jones"
|
|
27
|
+
|
|
28
|
+
def test_snake_case_dump(self):
|
|
29
|
+
m = SampleModel(firstName="Carol", lastName="Doe")
|
|
30
|
+
dumped = m.model_dump()
|
|
31
|
+
assert dumped == {"first_name": "Carol", "last_name": "Doe", "is_active": True}
|
|
32
|
+
|
|
33
|
+
def test_populate_by_name_with_config(self):
|
|
34
|
+
"""SampleWithConfig uses model_config with populate_by_name=True."""
|
|
35
|
+
m = SampleWithConfig(hello_world="hey")
|
|
36
|
+
assert m.hello_world == "hey"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tests for UserClaims dataclass."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from csrd.models.claims import UserClaims
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestUserClaims:
|
|
9
|
+
def test_defaults(self):
|
|
10
|
+
claims = UserClaims()
|
|
11
|
+
assert claims.sub == ""
|
|
12
|
+
assert claims.user_name == ""
|
|
13
|
+
assert claims.authorities == []
|
|
14
|
+
assert claims.iat is not None
|
|
15
|
+
assert claims.exp is not None
|
|
16
|
+
|
|
17
|
+
def test_exp_auto_generated(self):
|
|
18
|
+
claims = UserClaims(sub="user1")
|
|
19
|
+
assert claims.exp == claims.iat + timedelta(hours=1)
|
|
20
|
+
|
|
21
|
+
def test_custom_exp_preserved(self):
|
|
22
|
+
custom_exp = datetime(2030, 1, 1, tzinfo=UTC)
|
|
23
|
+
claims = UserClaims(sub="user1", exp=custom_exp)
|
|
24
|
+
assert claims.exp == custom_exp
|
|
25
|
+
|
|
26
|
+
def test_authorities_list(self):
|
|
27
|
+
claims = UserClaims(sub="admin", authorities=["ROLE_ADMIN", "ROLE_USER"])
|
|
28
|
+
assert len(claims.authorities) == 2
|
|
29
|
+
assert "ROLE_ADMIN" in claims.authorities
|
|
30
|
+
|
|
31
|
+
def test_mutable_authorities(self):
|
|
32
|
+
claims = UserClaims()
|
|
33
|
+
claims.authorities.append("NEW_ROLE")
|
|
34
|
+
# Verify the default_factory creates independent lists
|
|
35
|
+
claims2 = UserClaims()
|
|
36
|
+
assert "NEW_ROLE" not in claims2.authorities
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tests for error models and SerializerMixin."""
|
|
2
|
+
|
|
3
|
+
from csrd.models.errors import APIErrorResponse, APIVersion, Error, ErrorMeta
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestAPIVersion:
|
|
7
|
+
def test_defaults(self):
|
|
8
|
+
v = APIVersion()
|
|
9
|
+
assert v.requested is None
|
|
10
|
+
assert v.served is None
|
|
11
|
+
|
|
12
|
+
def test_camel_case_serialization(self):
|
|
13
|
+
from pydantic import TypeAdapter
|
|
14
|
+
|
|
15
|
+
v = APIVersion(requested="2025-06-20", served="2025-06-20")
|
|
16
|
+
dumped = TypeAdapter(APIVersion).dump_python(v, by_alias=True)
|
|
17
|
+
assert dumped["requested"] == "2025-06-20"
|
|
18
|
+
assert dumped["served"] == "2025-06-20"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestErrorMeta:
|
|
22
|
+
def test_required_fields(self):
|
|
23
|
+
meta = ErrorMeta(
|
|
24
|
+
status=404,
|
|
25
|
+
error="Not Found",
|
|
26
|
+
path="/api/test",
|
|
27
|
+
api_version=APIVersion(),
|
|
28
|
+
)
|
|
29
|
+
assert meta.status == 404
|
|
30
|
+
assert meta.error == "Not Found"
|
|
31
|
+
assert meta.timestamp is not None
|
|
32
|
+
|
|
33
|
+
def test_optional_fields(self):
|
|
34
|
+
meta = ErrorMeta(
|
|
35
|
+
status=500,
|
|
36
|
+
error="Internal Server Error",
|
|
37
|
+
path="/api/test",
|
|
38
|
+
api_version=APIVersion(),
|
|
39
|
+
method="GET",
|
|
40
|
+
request_id="abc-123",
|
|
41
|
+
)
|
|
42
|
+
assert meta.method == "GET"
|
|
43
|
+
assert meta.request_id == "abc-123"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestError:
|
|
47
|
+
def test_minimal(self):
|
|
48
|
+
err = Error(title="Something went wrong")
|
|
49
|
+
assert err.title == "Something went wrong"
|
|
50
|
+
assert err.detail is None
|
|
51
|
+
assert err.code is None
|
|
52
|
+
|
|
53
|
+
def test_full(self):
|
|
54
|
+
err = Error(title="Validation", detail="Field required", code="FIELD_REQUIRED")
|
|
55
|
+
assert err.code == "FIELD_REQUIRED"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestAPIErrorResponse:
|
|
59
|
+
def test_as_dict(self):
|
|
60
|
+
resp = APIErrorResponse(
|
|
61
|
+
meta=ErrorMeta(
|
|
62
|
+
status=400,
|
|
63
|
+
error="Bad Request",
|
|
64
|
+
path="/api/test",
|
|
65
|
+
api_version=APIVersion(requested="v1", served="v1"),
|
|
66
|
+
),
|
|
67
|
+
errors=[Error(title="Invalid input")],
|
|
68
|
+
)
|
|
69
|
+
d = resp.as_dict(by_alias=True, mode="json")
|
|
70
|
+
assert d["meta"]["status"] == 400
|
|
71
|
+
assert d["meta"]["error"] == "Bad Request"
|
|
72
|
+
assert d["meta"]["apiVersion"]["requested"] == "v1"
|
|
73
|
+
assert len(d["errors"]) == 1
|
|
74
|
+
assert d["errors"][0]["title"] == "Invalid input"
|
|
75
|
+
|
|
76
|
+
def test_empty_errors(self):
|
|
77
|
+
resp = APIErrorResponse(
|
|
78
|
+
meta=ErrorMeta(
|
|
79
|
+
status=500,
|
|
80
|
+
error="Internal Server Error",
|
|
81
|
+
path="/",
|
|
82
|
+
api_version=APIVersion(),
|
|
83
|
+
),
|
|
84
|
+
errors=[],
|
|
85
|
+
)
|
|
86
|
+
d = resp.as_dict(by_alias=True)
|
|
87
|
+
assert d["errors"] == []
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Tests for Message model."""
|
|
2
|
+
|
|
3
|
+
from csrd.models import Message
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestMessage:
|
|
7
|
+
def test_create(self):
|
|
8
|
+
m = Message(detail="hello")
|
|
9
|
+
assert m.detail == "hello"
|
|
10
|
+
|
|
11
|
+
def test_camel_case(self):
|
|
12
|
+
"""Message inherits BaseModel — verify aliasing works."""
|
|
13
|
+
dumped = Message(detail="test").model_dump(by_alias=True)
|
|
14
|
+
assert "detail" in dumped
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tests for ModelParserMixin."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import BaseModel, ValidationError
|
|
5
|
+
|
|
6
|
+
from csrd.models.model_parser import ModelParserMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Item(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
price: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestModelParserMixin:
|
|
15
|
+
def setup_method(self):
|
|
16
|
+
self.parser = ModelParserMixin()
|
|
17
|
+
|
|
18
|
+
def test_apply_model_dict_to_model(self):
|
|
19
|
+
result = self.parser.apply_model({"name": "Widget", "price": 9.99}, model=Item)
|
|
20
|
+
assert isinstance(result, Item)
|
|
21
|
+
assert result.name == "Widget"
|
|
22
|
+
assert result.price == 9.99
|
|
23
|
+
|
|
24
|
+
def test_apply_model_list_to_list_model(self):
|
|
25
|
+
data = [{"name": "A", "price": 1.0}, {"name": "B", "price": 2.0}]
|
|
26
|
+
result = self.parser.apply_model(data, model=list[Item])
|
|
27
|
+
assert isinstance(result, list)
|
|
28
|
+
assert len(result) == 2
|
|
29
|
+
assert result[0].name == "A"
|
|
30
|
+
assert result[1].name == "B"
|
|
31
|
+
|
|
32
|
+
def test_apply_model_no_model_passthrough(self):
|
|
33
|
+
data = {"key": "value"}
|
|
34
|
+
result = self.parser.apply_model(data)
|
|
35
|
+
assert result == data
|
|
36
|
+
|
|
37
|
+
def test_apply_model_none_model_passthrough(self):
|
|
38
|
+
data = [1, 2, 3]
|
|
39
|
+
result = self.parser.apply_model(data, model=None)
|
|
40
|
+
assert result == [1, 2, 3]
|
|
41
|
+
|
|
42
|
+
def test_apply_model_dict_expected_but_list_given(self):
|
|
43
|
+
with pytest.raises(TypeError, match="Expected dict"):
|
|
44
|
+
self.parser.apply_model([1, 2], model=Item)
|
|
45
|
+
|
|
46
|
+
def test_apply_model_list_expected_but_dict_given(self):
|
|
47
|
+
with pytest.raises(TypeError, match="Expected list"):
|
|
48
|
+
self.parser.apply_model({"name": "X", "price": 1.0}, model=list[Item])
|
|
49
|
+
|
|
50
|
+
def test_apply_model_validation_error(self):
|
|
51
|
+
with pytest.raises(ValidationError):
|
|
52
|
+
self.parser.apply_model({"name": "X"}, model=Item) # missing price
|
|
53
|
+
|
|
54
|
+
def test_apply_model_custom_exception(self):
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
self.parser.apply_model({"wrong": "data"}, model=Item, exception=ValueError)
|
|
57
|
+
|
|
58
|
+
def test_apply_model_with_handler(self):
|
|
59
|
+
def custom_handler(source, model):
|
|
60
|
+
return "custom_result"
|
|
61
|
+
|
|
62
|
+
result = self.parser.apply_model(
|
|
63
|
+
{"name": "X", "price": 1.0}, model=Item, model_handler=custom_handler
|
|
64
|
+
)
|
|
65
|
+
assert result == "custom_result"
|
|
66
|
+
|
|
67
|
+
def test_apply_model_with_json_source(self):
|
|
68
|
+
"""Test DefaultExtractor handles objects with .json() method."""
|
|
69
|
+
|
|
70
|
+
class FakeResponse:
|
|
71
|
+
def json(self):
|
|
72
|
+
return {"name": "FromJSON", "price": 5.0}
|
|
73
|
+
|
|
74
|
+
result = self.parser.apply_model(FakeResponse(), model=Item)
|
|
75
|
+
assert result.name == "FromJSON"
|
|
76
|
+
|
|
77
|
+
def test_custom_extractor(self):
|
|
78
|
+
class UpperExtractor:
|
|
79
|
+
def extract(self, source):
|
|
80
|
+
return {k.upper(): v for k, v in source.items()}
|
|
81
|
+
|
|
82
|
+
# Use extractor at parse time
|
|
83
|
+
parser = ModelParserMixin()
|
|
84
|
+
|
|
85
|
+
class UpperModel(BaseModel):
|
|
86
|
+
NAME: str
|
|
87
|
+
PRICE: float
|
|
88
|
+
|
|
89
|
+
result = parser.apply_model(
|
|
90
|
+
{"name": "test", "price": 1.0},
|
|
91
|
+
model=UpperModel,
|
|
92
|
+
extractor=UpperExtractor(),
|
|
93
|
+
)
|
|
94
|
+
assert result.NAME == "test"
|