pydantic-takibiapi 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.
- pydantic_takibiapi-0.1.0/LICENSE +21 -0
- pydantic_takibiapi-0.1.0/PKG-INFO +94 -0
- pydantic_takibiapi-0.1.0/README.md +70 -0
- pydantic_takibiapi-0.1.0/pyproject.toml +49 -0
- pydantic_takibiapi-0.1.0/pyproject.toml.orig +37 -0
- pydantic_takibiapi-0.1.0/python/pydantic_takibiapi/__init__.py +52 -0
- pydantic_takibiapi-0.1.0/python/pydantic_takibiapi/app.py +22 -0
- pydantic_takibiapi-0.1.0/python/pydantic_takibiapi/model.py +41 -0
- pydantic_takibiapi-0.1.0/python/pydantic_takibiapi/py.typed +0 -0
- pydantic_takibiapi-0.1.0/python/pydantic_takibiapi/schemas.py +241 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 - present, pydantic-takibiapi Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydantic-takibiapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: takibiapi with pydantic models as the schemas, checked strictly
|
|
5
|
+
Keywords: openapi,asgi,pydantic,rest,api,validation
|
|
6
|
+
Author: nakita628
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: takibiapi>=0.1.0,<0.2
|
|
19
|
+
Requires-Dist: pydantic>=2.11
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Repository, https://github.com/nakita628/takibi
|
|
22
|
+
Project-URL: Issues, https://github.com/nakita628/takibi/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pydantic-takibiapi
|
|
26
|
+
|
|
27
|
+
[TakibiAPI](../takibiapi) with [pydantic](https://pydantic.dev/) models as the schemas, checked strictly: constraints, unknown keys and responses included.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install pydantic-takibiapi
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from pydantic_takibiapi import Context, Model, TakibiAPI, createRoute
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Message(Model):
|
|
40
|
+
message: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class getTakibiRoute(
|
|
44
|
+
createRoute(
|
|
45
|
+
{
|
|
46
|
+
"method": "get",
|
|
47
|
+
"path": "/takibi",
|
|
48
|
+
"responses": {200: {"description": "takibiš„", "content": {"application/json": {"schema": Message}}}},
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
):
|
|
52
|
+
"""Say takibiš„."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def getTakibiRouteHandler(c: Context[getTakibiRoute]):
|
|
56
|
+
return c.json(Message(message="takibiš„"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app = TakibiAPI()
|
|
60
|
+
app.openapi(getTakibiRoute, getTakibiRouteHandler)
|
|
61
|
+
app.doc("/doc", {"openapi": "3.1.0", "info": {"title": "takibi API", "version": "1.0.0"}})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
granian --interface rsgi myapp:app # or: uvicorn myapp:app
|
|
66
|
+
curl localhost:8000/takibi # {"message":"takibiš„"}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Import `TakibiAPI` from `pydantic_takibiapi`; everything else works as in Takibi.
|
|
70
|
+
|
|
71
|
+
## Models
|
|
72
|
+
|
|
73
|
+
Every model, nested ones included, must be strict: subclass `Model` (a `BaseModel` with `strict=True, extra="forbid"`). `app.openapi` raises `TypeError` otherwise. `Field` constraints, aliases and validators work as in pydantic.
|
|
74
|
+
|
|
75
|
+
## Checks
|
|
76
|
+
|
|
77
|
+
- **Requests**: `c.req.valid(...)` gives a checked model instance. Path, query, header and cookie text is converted (`"7"` ā `7`); JSON bodies are strict (`"1"` is not an `int`) and reject unknown keys. A failure gets **422** with pydantic's error types as `code`.
|
|
78
|
+
- **Responses**: `c.json(value, status)` checks `value` against the model declared for that status. A mismatch raises `ResponseValidationError` and answers **500** (details in the log, not in the response).
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
app.onError(lambda c: c.json({"error": str(c.error)}, 500)) # replaces the 500, ResponseValidationError included
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## OpenAPI
|
|
85
|
+
|
|
86
|
+
Schemas come from pydantic's JSON Schema; every model is a component (`Name-Input` / `Name-Output` when reading and writing differ). `"3.0.x"` documents are converted from it.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
From the repository root: `bazel test //pypi/pydantic-takibiapi:test`. See the root README.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibi/blob/main/pypi/pydantic-takibiapi/LICENSE) for more information.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pydantic-takibiapi
|
|
2
|
+
|
|
3
|
+
[TakibiAPI](../takibiapi) with [pydantic](https://pydantic.dev/) models as the schemas, checked strictly: constraints, unknown keys and responses included.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install pydantic-takibiapi
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pydantic_takibiapi import Context, Model, TakibiAPI, createRoute
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Message(Model):
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class getTakibiRoute(
|
|
20
|
+
createRoute(
|
|
21
|
+
{
|
|
22
|
+
"method": "get",
|
|
23
|
+
"path": "/takibi",
|
|
24
|
+
"responses": {200: {"description": "takibiš„", "content": {"application/json": {"schema": Message}}}},
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
):
|
|
28
|
+
"""Say takibiš„."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def getTakibiRouteHandler(c: Context[getTakibiRoute]):
|
|
32
|
+
return c.json(Message(message="takibiš„"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = TakibiAPI()
|
|
36
|
+
app.openapi(getTakibiRoute, getTakibiRouteHandler)
|
|
37
|
+
app.doc("/doc", {"openapi": "3.1.0", "info": {"title": "takibi API", "version": "1.0.0"}})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
granian --interface rsgi myapp:app # or: uvicorn myapp:app
|
|
42
|
+
curl localhost:8000/takibi # {"message":"takibiš„"}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Import `TakibiAPI` from `pydantic_takibiapi`; everything else works as in Takibi.
|
|
46
|
+
|
|
47
|
+
## Models
|
|
48
|
+
|
|
49
|
+
Every model, nested ones included, must be strict: subclass `Model` (a `BaseModel` with `strict=True, extra="forbid"`). `app.openapi` raises `TypeError` otherwise. `Field` constraints, aliases and validators work as in pydantic.
|
|
50
|
+
|
|
51
|
+
## Checks
|
|
52
|
+
|
|
53
|
+
- **Requests**: `c.req.valid(...)` gives a checked model instance. Path, query, header and cookie text is converted (`"7"` ā `7`); JSON bodies are strict (`"1"` is not an `int`) and reject unknown keys. A failure gets **422** with pydantic's error types as `code`.
|
|
54
|
+
- **Responses**: `c.json(value, status)` checks `value` against the model declared for that status. A mismatch raises `ResponseValidationError` and answers **500** (details in the log, not in the response).
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
app.onError(lambda c: c.json({"error": str(c.error)}, 500)) # replaces the 500, ResponseValidationError included
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## OpenAPI
|
|
61
|
+
|
|
62
|
+
Schemas come from pydantic's JSON Schema; every model is a component (`Name-Input` / `Name-Output` when reading and writing differ). `"3.0.x"` documents are converted from it.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
From the repository root: `bazel test //pypi/pydantic-takibiapi:test`. See the root README.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibi/blob/main/pypi/pydantic-takibiapi/LICENSE) for more information.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydantic-takibiapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "takibiapi with pydantic models as the schemas, checked strictly"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"openapi",
|
|
11
|
+
"asgi",
|
|
12
|
+
"pydantic",
|
|
13
|
+
"rest",
|
|
14
|
+
"api",
|
|
15
|
+
"validation",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"takibiapi>=0.1.0,<0.2",
|
|
30
|
+
"pydantic>=2.11",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "nakita628"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Repository = "https://github.com/nakita628/takibi"
|
|
38
|
+
Issues = "https://github.com/nakita628/takibi/issues"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.8,<0.14"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[tool.uv.build-backend]
|
|
45
|
+
module-root = "python"
|
|
46
|
+
module-name = "pydantic_takibiapi"
|
|
47
|
+
|
|
48
|
+
[tool.uv.sources.takibiapi]
|
|
49
|
+
workspace = true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pydantic-takibiapi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "takibiapi with pydantic models as the schemas, checked strictly"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "nakita628" }]
|
|
10
|
+
keywords = ["openapi", "asgi", "pydantic", "rest", "api", "validation"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["takibiapi>=0.1.0,<0.2", "pydantic>=2.11"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/nakita628/takibi"
|
|
26
|
+
Issues = "https://github.com/nakita628/takibi/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.8,<0.14"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-root = "python"
|
|
34
|
+
module-name = "pydantic_takibiapi"
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
takibiapi = { workspace = true }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""pydantic-takibiapi: takibi with pydantic models as the schemas, checked strictly.
|
|
2
|
+
|
|
3
|
+
from pydantic_takibiapi import Context, Model, Response, TakibiAPI, createRoute
|
|
4
|
+
|
|
5
|
+
Schemas are :class:`Model` subclasses (pydantic models with ``strict=True`` and
|
|
6
|
+
``extra="forbid"``). Requests are checked with pydantic before the handler runs, ``Field``
|
|
7
|
+
constraints included; responses are checked against the declared model; the OpenAPI document
|
|
8
|
+
comes from pydantic's JSON Schema.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pydantic_takibiapi.app import TakibiAPI
|
|
12
|
+
from pydantic_takibiapi.model import Model, ValidationErrorItem, ValidationProblem
|
|
13
|
+
from pydantic_takibiapi.schemas import PydanticSchemas
|
|
14
|
+
from takibiapi import (
|
|
15
|
+
Context,
|
|
16
|
+
Middleware,
|
|
17
|
+
Next,
|
|
18
|
+
OpenAPIConfig,
|
|
19
|
+
Request,
|
|
20
|
+
RequestConfig,
|
|
21
|
+
Response,
|
|
22
|
+
ResponseConfig,
|
|
23
|
+
ResponseValidationError,
|
|
24
|
+
Route,
|
|
25
|
+
RouteConfig,
|
|
26
|
+
ValidationError,
|
|
27
|
+
createRoute,
|
|
28
|
+
problem,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Context",
|
|
33
|
+
"Middleware",
|
|
34
|
+
"Model",
|
|
35
|
+
"Next",
|
|
36
|
+
"OpenAPIConfig",
|
|
37
|
+
"PydanticSchemas",
|
|
38
|
+
"Request",
|
|
39
|
+
"RequestConfig",
|
|
40
|
+
"Response",
|
|
41
|
+
"ResponseConfig",
|
|
42
|
+
"ResponseValidationError",
|
|
43
|
+
"Route",
|
|
44
|
+
"RouteConfig",
|
|
45
|
+
"TakibiAPI",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"ValidationErrorItem",
|
|
48
|
+
"ValidationProblem",
|
|
49
|
+
"createRoute",
|
|
50
|
+
"problem",
|
|
51
|
+
]
|
|
52
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""``TakibiAPI``: takibi with pydantic models as its schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import takibiapi
|
|
8
|
+
|
|
9
|
+
from .schemas import PydanticSchemas
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TakibiAPI(takibiapi.TakibiAPI):
|
|
13
|
+
"""``takibiapi.TakibiAPI`` whose routes are described with pydantic models (``Model``): requests
|
|
14
|
+
are checked with pydantic, constraints included, before the handler runs (422 when they do
|
|
15
|
+
not match), and ``c.json(...)`` checks the response against the declared model (500 when the
|
|
16
|
+
handler breaks its own contract). Everything else (routing, middleware, ASGI / RSGI, the
|
|
17
|
+
OpenAPI document) is takibi's."""
|
|
18
|
+
|
|
19
|
+
schemas: Any = PydanticSchemas()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["TakibiAPI"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""``Model``: the base of every schema, a pydantic model with the strict settings takibi requires."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Model(BaseModel):
|
|
11
|
+
"""A strict pydantic model: no type conversion in JSON (``"1"`` is not an ``int``; parameters
|
|
12
|
+
from the path, query, headers and cookies are still read from their text), and no keys the
|
|
13
|
+
model does not declare.
|
|
14
|
+
|
|
15
|
+
class Todo(Model):
|
|
16
|
+
id: int = Field(ge=1)
|
|
17
|
+
title: str = Field(min_length=1, max_length=100)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ValidationErrorItem(Model):
|
|
24
|
+
in_: Annotated[str, Field(alias="in", description="Where the value was sent: path, query, header, cookie or body")]
|
|
25
|
+
path: Annotated[str, Field(description="Dot-separated path inside that part", examples=["title"])]
|
|
26
|
+
code: Annotated[str, Field(description="The pydantic error type", examples=["string_too_short"])]
|
|
27
|
+
message: Annotated[str, Field(examples=["String should have at least 1 character"])]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationProblem(Model):
|
|
31
|
+
"""RFC 9457 Problem Details for a request part that does not match its schema."""
|
|
32
|
+
|
|
33
|
+
type: Annotated[str, Field(examples=["/problems/validation-error"])]
|
|
34
|
+
title: Annotated[str, Field(examples=["Unprocessable Content"])]
|
|
35
|
+
status: Annotated[int, Field(examples=[422])]
|
|
36
|
+
detail: str
|
|
37
|
+
instance: Annotated[str, Field(description="The request path")]
|
|
38
|
+
errors: list[ValidationErrorItem]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["Model", "ValidationErrorItem", "ValidationProblem"]
|
|
File without changes
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""``PydanticSchemas``: pydantic models as takibi's schemas (see ``takibiapi.schemas``).
|
|
2
|
+
|
|
3
|
+
Requests are checked with pydantic, constraints included (``Field(ge=1, min_length=1)``):
|
|
4
|
+
parameters from the path, query, headers and cookies are read from their text, JSON bodies
|
|
5
|
+
strictly (``"1"`` is not an ``int``) and without undeclared keys. Responses are checked against
|
|
6
|
+
the model the route declares for the status before they are written. Every model must be strict
|
|
7
|
+
(``Model``, or ``strict=True`` and ``extra="forbid"``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import enum
|
|
13
|
+
import functools
|
|
14
|
+
import types
|
|
15
|
+
import typing
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from typing import Any, Literal, Union, get_args, get_origin
|
|
18
|
+
|
|
19
|
+
import pydantic
|
|
20
|
+
from pydantic import BaseModel, TypeAdapter
|
|
21
|
+
from pydantic.fields import FieldInfo
|
|
22
|
+
from pydantic.json_schema import JsonSchemaMode
|
|
23
|
+
|
|
24
|
+
from takibiapi import ValidationError
|
|
25
|
+
|
|
26
|
+
from .model import ValidationProblem
|
|
27
|
+
|
|
28
|
+
REF_TEMPLATE = "#/components/schemas/{model}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _issues(exc: pydantic.ValidationError) -> list[dict[str, Any]]:
|
|
32
|
+
return [{"path": list(e["loc"]), "code": e["type"], "message": e["msg"]} for e in exc.errors(include_url=False)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _strict_error(tp: Any) -> str | None:
|
|
36
|
+
"""Why ``tp`` is not strict enough, or None: every model must be strict and forbid extra keys,
|
|
37
|
+
and plain dataclasses / TypedDicts (whose settings cannot say so) are not allowed."""
|
|
38
|
+
seen: set[int] = set()
|
|
39
|
+
|
|
40
|
+
def walk(tp: Any) -> Iterator[str]:
|
|
41
|
+
origin = get_origin(tp)
|
|
42
|
+
if origin is typing.Annotated:
|
|
43
|
+
yield from walk(get_args(tp)[0])
|
|
44
|
+
return
|
|
45
|
+
if isinstance(tp, type) and issubclass(tp, BaseModel):
|
|
46
|
+
if id(tp) in seen:
|
|
47
|
+
return
|
|
48
|
+
seen.add(id(tp))
|
|
49
|
+
config = tp.model_config
|
|
50
|
+
if config.get("strict") is not True or config.get("extra") != "forbid":
|
|
51
|
+
yield f"{tp.__name__} is not strict (subclass pydantic_takibiapi.Model)"
|
|
52
|
+
for field in tp.model_fields.values():
|
|
53
|
+
yield from walk(field.annotation)
|
|
54
|
+
return
|
|
55
|
+
if typing.is_typeddict(tp) or (isinstance(tp, type) and hasattr(tp, "__dataclass_fields__")):
|
|
56
|
+
yield f"{tp.__name__} is a {'TypedDict' if typing.is_typeddict(tp) else 'dataclass'} (use a Model)"
|
|
57
|
+
return
|
|
58
|
+
for arg in get_args(tp):
|
|
59
|
+
if not isinstance(arg, (str, int, float, bool, enum.Enum)) and arg is not Ellipsis and arg is not None:
|
|
60
|
+
yield from walk(arg)
|
|
61
|
+
|
|
62
|
+
return next(walk(tp), None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _strict_adapter(tp: Any) -> TypeAdapter[Any]:
|
|
66
|
+
error = _strict_error(tp)
|
|
67
|
+
if error is not None:
|
|
68
|
+
raise TypeError(error)
|
|
69
|
+
return TypeAdapter(tp)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _repeats(annotation: Any) -> bool:
|
|
73
|
+
"""Whether a query field collects every value of its key (``list[str]``, ``list[int] | None``)."""
|
|
74
|
+
args = get_args(annotation) if get_origin(annotation) in (Union, types.UnionType) else (annotation,)
|
|
75
|
+
return any(a in (list, tuple, set, frozenset) or get_origin(a) in (list, tuple, set, frozenset) for a in args)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Checker:
|
|
79
|
+
__slots__ = ("_adapter", "names")
|
|
80
|
+
|
|
81
|
+
def __init__(self, tp: Any, target: str) -> None:
|
|
82
|
+
self._adapter = _strict_adapter(tp)
|
|
83
|
+
if target == "json":
|
|
84
|
+
self.names: list[tuple[str, bool]] = []
|
|
85
|
+
return
|
|
86
|
+
if not (isinstance(tp, type) and issubclass(tp, BaseModel)):
|
|
87
|
+
raise TypeError(f"request {target} parts must be a Model, got {tp!r}")
|
|
88
|
+
self.names = [(f.alias or name, _repeats(f.annotation)) for name, f in tp.model_fields.items()]
|
|
89
|
+
|
|
90
|
+
def decode(self, value: Any, *, coerce: bool) -> Any:
|
|
91
|
+
try: # parameters arrive as text: convert ("7" -> 7) even though the model is strict
|
|
92
|
+
return self._adapter.validate_python(value, strict=False if coerce else None)
|
|
93
|
+
except pydantic.ValidationError as exc:
|
|
94
|
+
raise ValidationError(_issues(exc)) from None
|
|
95
|
+
|
|
96
|
+
def decode_json(self, data: bytes) -> Any:
|
|
97
|
+
try:
|
|
98
|
+
return self._adapter.validate_json(data)
|
|
99
|
+
except pydantic.ValidationError as exc:
|
|
100
|
+
errors = exc.errors(include_url=False)
|
|
101
|
+
if errors and errors[0]["type"] == "json_invalid":
|
|
102
|
+
raise ValueError(errors[0]["msg"]) from None
|
|
103
|
+
raise ValidationError(_issues(exc)) from None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Encoder:
|
|
107
|
+
__slots__ = ("_adapter",)
|
|
108
|
+
|
|
109
|
+
def __init__(self, tp: Any) -> None:
|
|
110
|
+
self._adapter = _strict_adapter(tp)
|
|
111
|
+
|
|
112
|
+
def encode_json(self, value: Any) -> bytes:
|
|
113
|
+
try:
|
|
114
|
+
checked = self._adapter.validate_python(value)
|
|
115
|
+
except pydantic.ValidationError as exc:
|
|
116
|
+
raise ValidationError(_issues(exc)) from None
|
|
117
|
+
return self._adapter.dump_json(checked)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _DocBuilder:
|
|
121
|
+
"""Collects every schema the document needs and generates them in one pydantic pass, so named
|
|
122
|
+
models are written once to ``components.schemas`` (with ``-Input`` / ``-Output`` variants when
|
|
123
|
+
a model reads and writes differently). ``of`` returns a placeholder dict, filled in place when
|
|
124
|
+
``components`` is read (``build_document`` reads it last)."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, openapi31: bool) -> None:
|
|
127
|
+
self.openapi31 = openapi31
|
|
128
|
+
self._pending: list[tuple[dict[str, Any], JsonSchemaMode, TypeAdapter[Any]]] = []
|
|
129
|
+
self._components: dict[str, Any] | None = None
|
|
130
|
+
|
|
131
|
+
def of(self, tp: Any, io: Literal["input", "output"] = "input") -> dict[str, Any]:
|
|
132
|
+
placeholder: dict[str, Any] = {}
|
|
133
|
+
mode: JsonSchemaMode = "validation" if io == "input" else "serialization"
|
|
134
|
+
self._pending.append((placeholder, mode, _adapter_for_doc(tp)))
|
|
135
|
+
return placeholder
|
|
136
|
+
|
|
137
|
+
def parameters(self, tp: Any, location: str) -> list[dict[str, Any]]:
|
|
138
|
+
if not (isinstance(tp, type) and issubclass(tp, BaseModel)):
|
|
139
|
+
raise TypeError(f"{location} parameters must be a Model, got {tp!r}")
|
|
140
|
+
out: list[dict[str, Any]] = []
|
|
141
|
+
for name, field in tp.model_fields.items():
|
|
142
|
+
param: dict[str, Any] = {
|
|
143
|
+
"name": field.alias or name,
|
|
144
|
+
"in": location,
|
|
145
|
+
"required": location == "path" or field.is_required(),
|
|
146
|
+
"schema": self.of(typing.Annotated[field.annotation, _without_alias(field)]),
|
|
147
|
+
}
|
|
148
|
+
if field.description:
|
|
149
|
+
param["description"] = field.description
|
|
150
|
+
out.append(param)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def components(self) -> dict[str, Any]:
|
|
155
|
+
if self._components is None:
|
|
156
|
+
inputs: list[tuple[int, JsonSchemaMode, TypeAdapter[Any]]] = [
|
|
157
|
+
(i, mode, adapter) for i, (_, mode, adapter) in enumerate(self._pending)
|
|
158
|
+
]
|
|
159
|
+
schemas, definitions = TypeAdapter.json_schemas(inputs, ref_template=REF_TEMPLATE)
|
|
160
|
+
for i, (placeholder, mode, _) in enumerate(self._pending):
|
|
161
|
+
placeholder.update(self._convert(_untitled(schemas[(i, mode)])))
|
|
162
|
+
defs = definitions.get("$defs", {})
|
|
163
|
+
self._components = {name: self._convert(schema) for name, schema in defs.items()}
|
|
164
|
+
return self._components
|
|
165
|
+
|
|
166
|
+
def _convert(self, schema: Any) -> Any:
|
|
167
|
+
return schema if self.openapi31 else to_openapi30(schema)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@functools.cache
|
|
171
|
+
def _adapter_for_doc(tp: Any) -> TypeAdapter[Any]:
|
|
172
|
+
return TypeAdapter(tp)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _without_alias(field: FieldInfo) -> FieldInfo:
|
|
176
|
+
"""The field's constraints and description, for a parameter's own schema."""
|
|
177
|
+
return FieldInfo.merge_field_infos(field, alias=None, validation_alias=None, serialization_alias=None, title=None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _untitled(schema: dict[str, Any]) -> dict[str, Any]:
|
|
181
|
+
return {k: v for k, v in schema.items() if k != "title"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def to_openapi30(schema: Any) -> Any:
|
|
185
|
+
"""pydantic's JSON Schema (2020-12, as OpenAPI 3.1 uses) written for OpenAPI 3.0: ``null`` in
|
|
186
|
+
unions becomes ``nullable``, ``const`` an ``enum``, ``examples`` an ``example``, numeric
|
|
187
|
+
``exclusiveMinimum`` / ``exclusiveMaximum`` the boolean form, and ``$ref`` siblings ``allOf``."""
|
|
188
|
+
if isinstance(schema, list):
|
|
189
|
+
return [to_openapi30(s) for s in schema]
|
|
190
|
+
if not isinstance(schema, dict):
|
|
191
|
+
return schema
|
|
192
|
+
out = {k: to_openapi30(v) if isinstance(v, (dict, list)) else v for k, v in schema.items()}
|
|
193
|
+
for key in ("anyOf", "oneOf"):
|
|
194
|
+
members = out.get(key)
|
|
195
|
+
if isinstance(members, list) and {"type": "null"} in members:
|
|
196
|
+
rest = [m for m in members if m != {"type": "null"}]
|
|
197
|
+
del out[key]
|
|
198
|
+
if len(rest) == 1 and "$ref" not in rest[0]:
|
|
199
|
+
out = {**rest[0], **out}
|
|
200
|
+
elif len(rest) == 1:
|
|
201
|
+
out["allOf"] = rest
|
|
202
|
+
else:
|
|
203
|
+
out[key] = rest
|
|
204
|
+
out["nullable"] = True
|
|
205
|
+
if isinstance(out.get("type"), list):
|
|
206
|
+
kinds = [t for t in out["type"] if t != "null"]
|
|
207
|
+
if len(kinds) < len(out["type"]):
|
|
208
|
+
out["nullable"] = True
|
|
209
|
+
out["type"] = kinds[0] if len(kinds) == 1 else kinds
|
|
210
|
+
if "const" in out:
|
|
211
|
+
out["enum"] = [out.pop("const")]
|
|
212
|
+
if isinstance(out.get("examples"), list):
|
|
213
|
+
examples = out.pop("examples")
|
|
214
|
+
if examples:
|
|
215
|
+
out["example"] = examples[0]
|
|
216
|
+
for exclusive, bound in (("exclusiveMinimum", "minimum"), ("exclusiveMaximum", "maximum")):
|
|
217
|
+
if isinstance(out.get(exclusive), (int, float)) and not isinstance(out.get(exclusive), bool):
|
|
218
|
+
out[bound] = out.pop(exclusive)
|
|
219
|
+
out[exclusive] = True
|
|
220
|
+
if "$ref" in out and len(out) > 1:
|
|
221
|
+
ref = out.pop("$ref")
|
|
222
|
+
out["allOf"] = [{"$ref": ref}, *out.get("allOf", [])]
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class PydanticSchemas:
|
|
227
|
+
"""pydantic models as takibi's schemas: request and response checks, constraints included."""
|
|
228
|
+
|
|
229
|
+
validation_problem: Any = ValidationProblem
|
|
230
|
+
|
|
231
|
+
def checker(self, tp: Any, target: str) -> _Checker:
|
|
232
|
+
return _Checker(tp, target)
|
|
233
|
+
|
|
234
|
+
def response_encoder(self, tp: Any) -> _Encoder:
|
|
235
|
+
return _Encoder(tp)
|
|
236
|
+
|
|
237
|
+
def builder(self, openapi31: bool) -> _DocBuilder:
|
|
238
|
+
return _DocBuilder(openapi31)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = ["PydanticSchemas", "to_openapi30"]
|