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.
@@ -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,5 @@
1
+ from ._base_model import BaseModel
2
+
3
+
4
+ class Message(BaseModel):
5
+ detail: str
@@ -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,6 @@
1
+ from typing import Any, TypeVar
2
+
3
+ T = TypeVar("T")
4
+
5
+ ResponseModelType = type[T]
6
+ type ParsedResponse = Any # T | list[T] | bytes | dict — not expressible as a static type alias
@@ -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)
@@ -0,0 +1,5 @@
1
+ from typing import Any, Protocol
2
+
3
+
4
+ class PayloadExtractor(Protocol):
5
+ def extract(self, source: Any) -> Any: ...
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"