takibiapi 0.1.0__tar.gz → 0.2.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.
- {takibiapi-0.1.0 → takibiapi-0.2.0}/Cargo.toml +5 -6
- {takibiapi-0.1.0 → takibiapi-0.2.0}/PKG-INFO +19 -5
- {takibiapi-0.1.0 → takibiapi-0.2.0}/README.md +15 -2
- takibiapi-0.2.0/packages/takibiapi/CHANGELOG.md +21 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/README.md +15 -2
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/lib.rs +1 -2
- takibiapi-0.2.0/packages/takibiapi/tests/typing/routes.py +177 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/pyproject.toml +5 -4
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/__init__.py +5 -3
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/app.py +6 -2
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/context.py +75 -15
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/route.py +89 -59
- takibiapi-0.1.0/pypi/takibiapi/BUILD.bazel +0 -25
- takibiapi-0.1.0/pypi/takibiapi/CHANGELOG.md +0 -7
- takibiapi-0.1.0/pypi/takibiapi/python/BUILD.bazel +0 -29
- takibiapi-0.1.0/pypi/takibiapi/tests/typing/routes.py +0 -97
- {takibiapi-0.1.0 → takibiapi-0.2.0}/Cargo.lock +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/LICENSE +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/Cargo.toml +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/LICENSE +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/build.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/encode.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/engine.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/env.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/json.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/plan.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/src/router.rs +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_app.py +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_cookie.py +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_cors.py +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_json.py +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_router.py +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_schema.py +0 -0
- {takibiapi-0.1.0/pypi → takibiapi-0.2.0/packages}/takibiapi/tests/test_typing.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/_core.pyi +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/cookie.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/cors.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/openapi.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/problems.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/py.typed +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/router.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/schema.py +0 -0
- {takibiapi-0.1.0 → takibiapi-0.2.0}/python/takibiapi/schemas.py +0 -0
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
# Cargo workspace. The Rust extension crate lives next to its pyproject.toml in
|
|
1
|
+
# Cargo workspace. The Rust extension crate lives next to its pyproject.toml in packages/takibiapi,
|
|
2
2
|
# so that directory is both a uv and a Cargo workspace member. The crate has no version of its own:
|
|
3
|
-
# maturin takes the package version from pyproject.toml.
|
|
4
|
-
#
|
|
5
|
-
# pypi/takibiapi/src/lib.rs, where both build systems see them.
|
|
3
|
+
# maturin takes the package version from pyproject.toml. The lint levels are crate attributes in
|
|
4
|
+
# packages/takibiapi/src/lib.rs.
|
|
6
5
|
[workspace]
|
|
7
6
|
resolver = "2"
|
|
8
|
-
members = ["
|
|
7
|
+
members = ["packages/takibiapi"]
|
|
9
8
|
|
|
10
9
|
[workspace.package]
|
|
11
10
|
edition = "2021"
|
|
12
11
|
rust-version = "1.83"
|
|
13
12
|
license = "MIT"
|
|
14
|
-
repository = "https://github.com/nakita628/
|
|
13
|
+
repository = "https://github.com/nakita628/takibiapi"
|
|
15
14
|
|
|
16
15
|
[workspace.dependencies]
|
|
17
16
|
pyo3 = "0.29"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: takibiapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: Operating System :: OS Independent
|
|
@@ -18,8 +18,9 @@ Author: nakita628
|
|
|
18
18
|
License-Expression: MIT
|
|
19
19
|
Requires-Python: >=3.12
|
|
20
20
|
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
21
|
-
Project-URL:
|
|
22
|
-
Project-URL:
|
|
21
|
+
Project-URL: Funding, https://github.com/sponsors/nakita628
|
|
22
|
+
Project-URL: Issues, https://github.com/nakita628/takibiapi/issues
|
|
23
|
+
Project-URL: Repository, https://github.com/nakita628/takibiapi
|
|
23
24
|
|
|
24
25
|
# TakibiAPI
|
|
25
26
|
|
|
@@ -72,6 +73,19 @@ curl localhost:8000/takibi # {"message":"takibi🔥"}
|
|
|
72
73
|
|
|
73
74
|
A route is a class made with `createRoute`, and its handler takes `c: Context[thatRoute]`. `app.openapi(route, handler)` raises `TypeError` for anything else, and the type checker sees the route's types: `c.req.valid(...)` is the declared request type and `c.json(...)` takes only the declared responses.
|
|
74
75
|
|
|
76
|
+
The response's status is typed too. `c.json`, `c.text` and `c.body` take only a status the route declares (200 when none is given, so a route without 200 must name its status), and the handler must return what they build (a `TypedResponse`):
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
"responses": {201: {...Todo}, 404: {...Problem}}
|
|
80
|
+
|
|
81
|
+
return c.json(Todo(...), 201) # ok
|
|
82
|
+
return c.json(Todo(...), 200) # type error: 200 is not declared
|
|
83
|
+
return c.body(status=204) # type error: nor 204
|
|
84
|
+
return Response(b"...") # type error at app.openapi: not a declared response
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A `"default"` response allows any status. The statuses are inferred from the literal keys, so annotate shared responses with theirs: `errors: Mapping[Literal[400, 404], ResponseConfig[Problem]]`, spread as `{**errors, 200: ...}` (a `dict[int, ...]` is a type error). Which body goes with which status is not checked: `c.json(Todo(...), 404)` passes when both are declared.
|
|
88
|
+
|
|
75
89
|
## Requests
|
|
76
90
|
|
|
77
91
|
The request parts in `request` (`params`, `query`, `headers`, `cookies`, a JSON `body`) are checked against their **types** before the handler runs and read with `c.req.valid("param" | "query" | "header" | "cookie" | "json")`:
|
|
@@ -144,9 +158,9 @@ def getProfileRouteHandler(c: Context[getProfileRoute]):
|
|
|
144
158
|
|
|
145
159
|
## Development
|
|
146
160
|
|
|
147
|
-
From the repository root: `
|
|
161
|
+
From the repository root: `uv sync` builds the extension into the virtualenv, and `uv run pytest packages/takibiapi` runs this package's tests (on both the Rust engine and the pure-Python reference). See the root README.
|
|
148
162
|
|
|
149
163
|
## License
|
|
150
164
|
|
|
151
|
-
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/
|
|
165
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibiapi/blob/main/packages/takibiapi/LICENSE) for more information.
|
|
152
166
|
|
|
@@ -49,6 +49,19 @@ curl localhost:8000/takibi # {"message":"takibi🔥"}
|
|
|
49
49
|
|
|
50
50
|
A route is a class made with `createRoute`, and its handler takes `c: Context[thatRoute]`. `app.openapi(route, handler)` raises `TypeError` for anything else, and the type checker sees the route's types: `c.req.valid(...)` is the declared request type and `c.json(...)` takes only the declared responses.
|
|
51
51
|
|
|
52
|
+
The response's status is typed too. `c.json`, `c.text` and `c.body` take only a status the route declares (200 when none is given, so a route without 200 must name its status), and the handler must return what they build (a `TypedResponse`):
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
"responses": {201: {...Todo}, 404: {...Problem}}
|
|
56
|
+
|
|
57
|
+
return c.json(Todo(...), 201) # ok
|
|
58
|
+
return c.json(Todo(...), 200) # type error: 200 is not declared
|
|
59
|
+
return c.body(status=204) # type error: nor 204
|
|
60
|
+
return Response(b"...") # type error at app.openapi: not a declared response
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A `"default"` response allows any status. The statuses are inferred from the literal keys, so annotate shared responses with theirs: `errors: Mapping[Literal[400, 404], ResponseConfig[Problem]]`, spread as `{**errors, 200: ...}` (a `dict[int, ...]` is a type error). Which body goes with which status is not checked: `c.json(Todo(...), 404)` passes when both are declared.
|
|
64
|
+
|
|
52
65
|
## Requests
|
|
53
66
|
|
|
54
67
|
The request parts in `request` (`params`, `query`, `headers`, `cookies`, a JSON `body`) are checked against their **types** before the handler runs and read with `c.req.valid("param" | "query" | "header" | "cookie" | "json")`:
|
|
@@ -121,8 +134,8 @@ def getProfileRouteHandler(c: Context[getProfileRoute]):
|
|
|
121
134
|
|
|
122
135
|
## Development
|
|
123
136
|
|
|
124
|
-
From the repository root: `
|
|
137
|
+
From the repository root: `uv sync` builds the extension into the virtualenv, and `uv run pytest packages/takibiapi` runs this package's tests (on both the Rust engine and the pure-Python reference). See the root README.
|
|
125
138
|
|
|
126
139
|
## License
|
|
127
140
|
|
|
128
|
-
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/
|
|
141
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibiapi/blob/main/packages/takibiapi/LICENSE) for more information.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# takibiapi
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Typed response statuses: `c.json`, `c.text` and `c.body` take only a status the route declares (200 when none is given; any status when the route declares `"default"`), and a handler registered with `app.openapi` must return what they build, a `TypedResponse`. A `Response` built by hand, or a status the route does not declare, is a type error
|
|
8
|
+
- **Breaking (types only)**: the statuses are inferred from the literal keys of `responses`, so a status passed as an `int` variable, or shared responses typed `dict[int, ResponseConfig[...]]`, are type errors; annotate them with their codes, `Mapping[Literal[400, 404], ResponseConfig[Problem]]`. `Route` takes a sixth type parameter, the statuses. Nothing changes at run time
|
|
9
|
+
- Export `StatusCode` and `TypedResponse`
|
|
10
|
+
|
|
11
|
+
## 0.1.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Move to the takibiapi repository (https://github.com/nakita628/takibiapi); the project URLs point there
|
|
16
|
+
|
|
17
|
+
## 0.1.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- Initial release
|
|
@@ -49,6 +49,19 @@ curl localhost:8000/takibi # {"message":"takibi🔥"}
|
|
|
49
49
|
|
|
50
50
|
A route is a class made with `createRoute`, and its handler takes `c: Context[thatRoute]`. `app.openapi(route, handler)` raises `TypeError` for anything else, and the type checker sees the route's types: `c.req.valid(...)` is the declared request type and `c.json(...)` takes only the declared responses.
|
|
51
51
|
|
|
52
|
+
The response's status is typed too. `c.json`, `c.text` and `c.body` take only a status the route declares (200 when none is given, so a route without 200 must name its status), and the handler must return what they build (a `TypedResponse`):
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
"responses": {201: {...Todo}, 404: {...Problem}}
|
|
56
|
+
|
|
57
|
+
return c.json(Todo(...), 201) # ok
|
|
58
|
+
return c.json(Todo(...), 200) # type error: 200 is not declared
|
|
59
|
+
return c.body(status=204) # type error: nor 204
|
|
60
|
+
return Response(b"...") # type error at app.openapi: not a declared response
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A `"default"` response allows any status. The statuses are inferred from the literal keys, so annotate shared responses with theirs: `errors: Mapping[Literal[400, 404], ResponseConfig[Problem]]`, spread as `{**errors, 200: ...}` (a `dict[int, ...]` is a type error). Which body goes with which status is not checked: `c.json(Todo(...), 404)` passes when both are declared.
|
|
64
|
+
|
|
52
65
|
## Requests
|
|
53
66
|
|
|
54
67
|
The request parts in `request` (`params`, `query`, `headers`, `cookies`, a JSON `body`) are checked against their **types** before the handler runs and read with `c.req.valid("param" | "query" | "header" | "cookie" | "json")`:
|
|
@@ -121,8 +134,8 @@ def getProfileRouteHandler(c: Context[getProfileRoute]):
|
|
|
121
134
|
|
|
122
135
|
## Development
|
|
123
136
|
|
|
124
|
-
From the repository root: `
|
|
137
|
+
From the repository root: `uv sync` builds the extension into the virtualenv, and `uv run pytest packages/takibiapi` runs this package's tests (on both the Rust engine and the pure-Python reference). See the root README.
|
|
125
138
|
|
|
126
139
|
## License
|
|
127
140
|
|
|
128
|
-
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/
|
|
141
|
+
Distributed under the MIT License. See [LICENSE](https://github.com/nakita628/takibiapi/blob/main/packages/takibiapi/LICENSE) for more information.
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
//! parsing in Rust, with exactly the results of ``takibiapi.schema``'s Python checker and of Python's
|
|
3
3
|
//! ``json`` and ``urllib.parse`` (input it cannot match is left to them: ``None``).
|
|
4
4
|
|
|
5
|
-
// Lint levels live here rather than in Cargo.toml
|
|
6
|
-
// aspect, which does not read Cargo manifests) apply the same set.
|
|
5
|
+
// Lint levels live here rather than in Cargo.toml, next to the code they apply to.
|
|
7
6
|
#![warn(missing_debug_implementations, unused_qualifications)]
|
|
8
7
|
#![warn(clippy::all, clippy::pedantic)]
|
|
9
8
|
#![warn(
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# pyright: reportUnnecessaryTypeIgnoreComment=true
|
|
2
|
+
"""Static types of routes and handlers (checked by pyright; see typing_test.py)."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, TypedDict, reveal_type
|
|
7
|
+
|
|
8
|
+
from takibiapi import Context, Response, ResponseConfig, TakibiAPI, TypedResponse, createRoute
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TodoParams(TypedDict):
|
|
12
|
+
todoId: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Search(TypedDict):
|
|
16
|
+
q: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class NewTodo:
|
|
21
|
+
title: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Todo:
|
|
26
|
+
id: int
|
|
27
|
+
title: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Problem:
|
|
32
|
+
detail: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Other:
|
|
37
|
+
x: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class putTodoRoute(
|
|
41
|
+
createRoute(
|
|
42
|
+
{
|
|
43
|
+
"method": "put",
|
|
44
|
+
"path": "/todos/{todoId}",
|
|
45
|
+
"request": {
|
|
46
|
+
"params": TodoParams,
|
|
47
|
+
"query": Search,
|
|
48
|
+
"body": {"content": {"application/json": {"schema": NewTodo}}},
|
|
49
|
+
},
|
|
50
|
+
"responses": {
|
|
51
|
+
200: {"description": "ok", "content": {"application/json": {"schema": Todo}}},
|
|
52
|
+
404: {"description": "missing", "content": {"application/problem+json": {"schema": Problem}}},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
):
|
|
57
|
+
"""Update a todo."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def putTodoRouteHandler(c: Context[putTodoRoute]):
|
|
61
|
+
body = c.req.valid("json")
|
|
62
|
+
reveal_type(body, expected_text="NewTodo")
|
|
63
|
+
reveal_type(c.req.valid("param")["todoId"], expected_text="int")
|
|
64
|
+
reveal_type(c.req.valid("query"), expected_text="Search")
|
|
65
|
+
c.req.valid("header") # no headers type: Any
|
|
66
|
+
reveal_type(await c.req.json(), expected_text="Any") # the raw, unchecked body
|
|
67
|
+
reveal_type(c.req.param("todoId"), expected_text="str") # the raw text
|
|
68
|
+
reveal_type(c.req.query("q"), expected_text="str | None")
|
|
69
|
+
reveal_type(c.req.queries("tag"), expected_text="list[str]")
|
|
70
|
+
c.json(Other(x=1)) # pyright: ignore[reportArgumentType] # not a declared response body
|
|
71
|
+
c.json({"id": 1, "title": "x"}) # pyright: ignore[reportArgumentType] # a dict is not a Todo
|
|
72
|
+
reveal_type(c.json(Problem(detail="x"), 404), expected_text="TypedResponse")
|
|
73
|
+
c.json(Problem(detail="x"), 500) # pyright: ignore[reportArgumentType] # 500 is not declared
|
|
74
|
+
c.text("x", 500) # pyright: ignore[reportArgumentType] # nor for text
|
|
75
|
+
c.body(status=204) # pyright: ignore[reportArgumentType] # nor 204 without content
|
|
76
|
+
params = c.req.valid("param")
|
|
77
|
+
return c.json(Todo(id=params["todoId"], title=body.title), 200)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class getOtherRoute(
|
|
81
|
+
createRoute(
|
|
82
|
+
{
|
|
83
|
+
"method": "get",
|
|
84
|
+
"path": "/other",
|
|
85
|
+
"responses": {200: {"description": "ok", "content": {"application/json": {"schema": list[Search]}}}},
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def getOtherRouteHandler(c: Context[getOtherRoute]):
|
|
93
|
+
c.json([{"q": "x"}])
|
|
94
|
+
c.json([{"name": "x"}]) # pyright: ignore[reportArgumentType] # wrong TypedDict key
|
|
95
|
+
return c.json([])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
app = TakibiAPI()
|
|
99
|
+
app.openapi(putTodoRoute, putTodoRouteHandler)
|
|
100
|
+
app.openapi(getOtherRoute, getOtherRouteHandler)
|
|
101
|
+
app.openapi(putTodoRoute, getOtherRouteHandler) # pyright: ignore[reportArgumentType] # another route's handler
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# -- statuses ---------------------------------------------------------------------------------- #
|
|
105
|
+
|
|
106
|
+
errors: Mapping[Literal[400, 404], ResponseConfig[Problem]] = {
|
|
107
|
+
400: {"description": "bad", "content": {"application/json": {"schema": Problem}}},
|
|
108
|
+
404: {"description": "missing", "content": {"application/json": {"schema": Problem}}},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class createTodoRoute(
|
|
113
|
+
createRoute(
|
|
114
|
+
{
|
|
115
|
+
"method": "post",
|
|
116
|
+
"path": "/todos",
|
|
117
|
+
"responses": {
|
|
118
|
+
**errors,
|
|
119
|
+
201: {"description": "created", "content": {"application/json": {"schema": Todo}}},
|
|
120
|
+
204: {"description": "nothing to create"},
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def createTodoRouteHandler(c: Context[createTodoRoute]):
|
|
129
|
+
if c.req.query("dry"):
|
|
130
|
+
return c.body(status=204)
|
|
131
|
+
if c.req.query("bad"):
|
|
132
|
+
return c.json(Problem(detail="bad"), 400)
|
|
133
|
+
c.json(Todo(id=1, title="x")) # pyright: ignore[reportArgumentType] # 200 (the default) is not declared
|
|
134
|
+
return c.json(Todo(id=1, title="x"), 201)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
reveal_type(createTodoRouteHandler, expected_text="(c: Context[createTodoRoute]) -> TypedResponse")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class withDefaultRoute(
|
|
141
|
+
createRoute(
|
|
142
|
+
{
|
|
143
|
+
"method": "get",
|
|
144
|
+
"path": "/default",
|
|
145
|
+
"responses": {
|
|
146
|
+
200: {"description": "ok", "content": {"application/json": {"schema": Todo}}},
|
|
147
|
+
"default": {"description": "error", "content": {"application/json": {"schema": Problem}}},
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def withDefaultRouteHandler(c: Context[withDefaultRoute]):
|
|
156
|
+
if c.req.query("x"):
|
|
157
|
+
return c.text("teapot", 418) # "default" covers any status
|
|
158
|
+
return c.json(Problem(detail="x"), 503)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Keys typed `int` are not statuses (pyright reports it as either rule, depending on what it checks first).
|
|
162
|
+
untyped: dict[int, ResponseConfig[Problem]] = {}
|
|
163
|
+
createRoute({"method": "get", "path": "/u", "responses": {**untyped}}) # pyright: ignore[reportCallIssue, reportArgumentType]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def returnsResponse(c: Context[getOtherRoute]) -> Response:
|
|
167
|
+
return c.json([])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def returnsTyped(c: Context[getOtherRoute]) -> TypedResponse:
|
|
171
|
+
return c.json([])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
app.openapi(createTodoRoute, createTodoRouteHandler)
|
|
175
|
+
app.openapi(withDefaultRoute, withDefaultRouteHandler)
|
|
176
|
+
app.openapi(getOtherRoute, returnsTyped)
|
|
177
|
+
app.openapi(getOtherRoute, returnsResponse) # pyright: ignore[reportArgumentType] # a plain Response is not a declared one
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "takibiapi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "REST APIs whose OpenAPI document, request checks and handler types come from Python's own types"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -23,8 +23,9 @@ classifiers = [
|
|
|
23
23
|
dependencies = []
|
|
24
24
|
|
|
25
25
|
[project.urls]
|
|
26
|
-
Repository = "https://github.com/nakita628/
|
|
27
|
-
Issues = "https://github.com/nakita628/
|
|
26
|
+
Repository = "https://github.com/nakita628/takibiapi"
|
|
27
|
+
Issues = "https://github.com/nakita628/takibiapi/issues"
|
|
28
|
+
Funding = "https://github.com/sponsors/nakita628"
|
|
28
29
|
|
|
29
30
|
[build-system]
|
|
30
31
|
requires = ["maturin>=1.8,<2"]
|
|
@@ -33,5 +34,5 @@ build-backend = "maturin"
|
|
|
33
34
|
[tool.maturin]
|
|
34
35
|
module-name = "takibiapi._core"
|
|
35
36
|
features = ["pyo3/extension-module"]
|
|
36
|
-
manifest-path = "
|
|
37
|
+
manifest-path = "packages/takibiapi/Cargo.toml"
|
|
37
38
|
python-source = "python"
|
|
@@ -10,10 +10,10 @@ serves the OpenAPI document. Request parts are checked against their types only
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from takibiapi.app import Fallback, FallbackHandler, FallbackMiddleware, FallbackNext, Middleware, Next, TakibiAPI
|
|
13
|
-
from takibiapi.context import Context, Request, Response, ResponseValidationError, problem
|
|
13
|
+
from takibiapi.context import Context, Request, Response, ResponseValidationError, TypedResponse, problem
|
|
14
14
|
from takibiapi.openapi import OpenAPIConfig
|
|
15
15
|
from takibiapi.problems import ValidationProblem
|
|
16
|
-
from takibiapi.route import RequestConfig, ResponseConfig, Route, RouteConfig, createRoute
|
|
16
|
+
from takibiapi.route import RequestConfig, ResponseConfig, Route, RouteConfig, StatusCode, createRoute
|
|
17
17
|
from takibiapi.schema import ValidationError
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
@@ -32,10 +32,12 @@ __all__ = [
|
|
|
32
32
|
"ResponseValidationError",
|
|
33
33
|
"Route",
|
|
34
34
|
"RouteConfig",
|
|
35
|
+
"StatusCode",
|
|
35
36
|
"TakibiAPI",
|
|
37
|
+
"TypedResponse",
|
|
36
38
|
"ValidationError",
|
|
37
39
|
"ValidationProblem",
|
|
38
40
|
"createRoute",
|
|
39
41
|
"problem",
|
|
40
42
|
]
|
|
41
|
-
__version__ = "0.
|
|
43
|
+
__version__ = "0.2.0"
|
|
@@ -14,7 +14,7 @@ import typing
|
|
|
14
14
|
from collections.abc import Awaitable, Callable, Mapping
|
|
15
15
|
from typing import Any, Self, TypeVar, get_args, get_origin
|
|
16
16
|
|
|
17
|
-
from .context import Context, MalformedJSONError, RawHeaders, Request, Response, problem
|
|
17
|
+
from .context import Context, MalformedJSONError, RawHeaders, Request, Response, TypedResponse, problem
|
|
18
18
|
from .openapi import OpenAPIConfig, build_document, check_config
|
|
19
19
|
from .route import AnyRoute, route_class
|
|
20
20
|
from .router import new_router
|
|
@@ -90,11 +90,15 @@ class TakibiAPI:
|
|
|
90
90
|
self._documents: dict[int, bytes] = {}
|
|
91
91
|
|
|
92
92
|
# -- routes ------------------------------------------------------------------------------- #
|
|
93
|
-
def openapi(
|
|
93
|
+
def openapi(
|
|
94
|
+
self, route: type[_TR], handler: Callable[[Context[_TR]], TypedResponse | Awaitable[TypedResponse]]
|
|
95
|
+
) -> Self:
|
|
94
96
|
"""Register ``route`` with its handler.
|
|
95
97
|
|
|
96
98
|
The route must be declared as a class (``class getTodoRoute(createRoute({...}))``) and
|
|
97
99
|
the handler must take it as ``c: Context[getTodoRoute]``; anything else is a ``TypeError``.
|
|
100
|
+
For the type checker, the handler returns what ``c.json`` / ``c.text`` / ``c.body`` build,
|
|
101
|
+
with a status the route declares.
|
|
98
102
|
The request parts the route declares are checked against their types before the handler
|
|
99
103
|
runs (422 when they do not match) and read with ``c.req.valid(...)``.
|
|
100
104
|
"""
|
|
@@ -24,6 +24,7 @@ _Q = TypeVar("_Q")
|
|
|
24
24
|
_J = TypeVar("_J")
|
|
25
25
|
_H = TypeVar("_H")
|
|
26
26
|
_B = TypeVar("_B")
|
|
27
|
+
_S = TypeVar("_S")
|
|
27
28
|
|
|
28
29
|
JSON_TYPE = "application/json"
|
|
29
30
|
PROBLEM_JSON = "application/problem+json"
|
|
@@ -52,7 +53,15 @@ class Response:
|
|
|
52
53
|
return self.body.decode()
|
|
53
54
|
|
|
54
55
|
def __repr__(self) -> str:
|
|
55
|
-
return f"
|
|
56
|
+
return f"{type(self).__name__}({self.status}, {self.body[:60]!r})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TypedResponse(Response):
|
|
60
|
+
"""A response built by ``c.json`` / ``c.text`` / ``c.body``, whose status the type checker has
|
|
61
|
+
matched against the route's responses. A handler registered with ``app.openapi`` must return
|
|
62
|
+
one, so a ``Response`` built by hand is a type error there."""
|
|
63
|
+
|
|
64
|
+
__slots__ = ()
|
|
56
65
|
|
|
57
66
|
|
|
58
67
|
def problem(status: int, title: str, detail: str, instance: str, type_: str = "about:blank", **extra: Any) -> Response:
|
|
@@ -118,13 +127,13 @@ class Request(Generic[TR]):
|
|
|
118
127
|
|
|
119
128
|
# The checked part's type comes from the route: Route[params, query, json, headers, ...].
|
|
120
129
|
@overload
|
|
121
|
-
def valid(self: Request[Route[_P, Any, Any, Any, Any]], target: Literal["param"]) -> _P: ...
|
|
130
|
+
def valid(self: Request[Route[_P, Any, Any, Any, Any, Any]], target: Literal["param"]) -> _P: ...
|
|
122
131
|
@overload
|
|
123
|
-
def valid(self: Request[Route[Any, _Q, Any, Any, Any]], target: Literal["query"]) -> _Q: ...
|
|
132
|
+
def valid(self: Request[Route[Any, _Q, Any, Any, Any, Any]], target: Literal["query"]) -> _Q: ...
|
|
124
133
|
@overload
|
|
125
|
-
def valid(self: Request[Route[Any, Any, _J, Any, Any]], target: Literal["json"]) -> _J: ...
|
|
134
|
+
def valid(self: Request[Route[Any, Any, _J, Any, Any, Any]], target: Literal["json"]) -> _J: ...
|
|
126
135
|
@overload
|
|
127
|
-
def valid(self: Request[Route[Any, Any, Any, _H, Any]], target: Literal["header"]) -> _H: ...
|
|
136
|
+
def valid(self: Request[Route[Any, Any, Any, _H, Any, Any]], target: Literal["header"]) -> _H: ...
|
|
128
137
|
@overload
|
|
129
138
|
def valid(self, target: Literal["cookie"]) -> Any: ...
|
|
130
139
|
|
|
@@ -245,15 +254,30 @@ class Context(Generic[TR]):
|
|
|
245
254
|
self._vars: dict[str, Any] | None = None
|
|
246
255
|
self._responses = responses # {status: {media type: encoder}}: checked responses
|
|
247
256
|
|
|
257
|
+
# ``value`` is one of the route's response bodies, and ``status`` one of its statuses (any status
|
|
258
|
+
# when the route declares "default"). A status left out is 200, so it must be declared too.
|
|
259
|
+
@overload
|
|
248
260
|
def json(
|
|
249
|
-
self: Context[Route[Any, Any, Any, Any, _B]],
|
|
261
|
+
self: Context[Route[Any, Any, Any, Any, _B, Literal["default"]]],
|
|
250
262
|
value: _B,
|
|
251
263
|
status: int = 200,
|
|
252
264
|
headers: Mapping[str, str] | None = None,
|
|
253
|
-
) ->
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
) -> TypedResponse: ...
|
|
266
|
+
@overload
|
|
267
|
+
def json(
|
|
268
|
+
self: Context[Route[Any, Any, Any, Any, _B, _S]],
|
|
269
|
+
value: _B,
|
|
270
|
+
status: _S = 200,
|
|
271
|
+
headers: Mapping[str, str] | None = None,
|
|
272
|
+
) -> TypedResponse: ...
|
|
273
|
+
|
|
274
|
+
def json(
|
|
275
|
+
self: Context[Any], value: Any, status: Any = 200, headers: Mapping[str, str] | None = None
|
|
276
|
+
) -> TypedResponse:
|
|
277
|
+
"""A JSON response. ``value`` and ``status`` are typed by the route's responses (for the
|
|
278
|
+
type checker). With takibi's own schemas it is written as it is; schemas that check
|
|
279
|
+
responses (``pydantic-takibiapi``) raise :class:`ResponseValidationError` when it does not
|
|
280
|
+
match."""
|
|
257
281
|
if self._responses is not None:
|
|
258
282
|
encoders = self._responses.get(status)
|
|
259
283
|
if encoders:
|
|
@@ -271,11 +295,47 @@ class Context(Generic[TR]):
|
|
|
271
295
|
return self._respond(body, status, headers, JSON_TYPE)
|
|
272
296
|
return self._respond(dumps(value), status, headers, JSON_TYPE)
|
|
273
297
|
|
|
274
|
-
|
|
298
|
+
@overload
|
|
299
|
+
def text(
|
|
300
|
+
self: Context[Route[Any, Any, Any, Any, Any, Literal["default"]]],
|
|
301
|
+
text: str,
|
|
302
|
+
status: int = 200,
|
|
303
|
+
headers: Mapping[str, str] | None = None,
|
|
304
|
+
) -> TypedResponse: ...
|
|
305
|
+
@overload
|
|
306
|
+
def text(
|
|
307
|
+
self: Context[Route[Any, Any, Any, Any, Any, _S]],
|
|
308
|
+
text: str,
|
|
309
|
+
status: _S = 200,
|
|
310
|
+
headers: Mapping[str, str] | None = None,
|
|
311
|
+
) -> TypedResponse: ...
|
|
312
|
+
|
|
313
|
+
def text(
|
|
314
|
+
self: Context[Any], text: str, status: Any = 200, headers: Mapping[str, str] | None = None
|
|
315
|
+
) -> TypedResponse:
|
|
316
|
+
"""A ``text/plain`` response, with a status the route declares."""
|
|
275
317
|
return self._respond(text.encode(), status, headers, "text/plain; charset=utf-8")
|
|
276
318
|
|
|
277
|
-
|
|
278
|
-
|
|
319
|
+
@overload
|
|
320
|
+
def body(
|
|
321
|
+
self: Context[Route[Any, Any, Any, Any, Any, Literal["default"]]],
|
|
322
|
+
data: bytes = b"",
|
|
323
|
+
status: int = 200,
|
|
324
|
+
headers: Mapping[str, str] | None = None,
|
|
325
|
+
) -> TypedResponse: ...
|
|
326
|
+
@overload
|
|
327
|
+
def body(
|
|
328
|
+
self: Context[Route[Any, Any, Any, Any, Any, _S]],
|
|
329
|
+
data: bytes = b"",
|
|
330
|
+
status: _S = 200,
|
|
331
|
+
headers: Mapping[str, str] | None = None,
|
|
332
|
+
) -> TypedResponse: ...
|
|
333
|
+
|
|
334
|
+
def body(
|
|
335
|
+
self: Context[Any], data: bytes = b"", status: Any = 200, headers: Mapping[str, str] | None = None
|
|
336
|
+
) -> TypedResponse:
|
|
337
|
+
"""Raw bytes (``c.body(status=204)`` for no content), with a status the route declares; set
|
|
338
|
+
the content type in ``headers``."""
|
|
279
339
|
return self._respond(data, status, headers, None)
|
|
280
340
|
|
|
281
341
|
def header(self, name: str, value: str | None, *, append: bool = False) -> None:
|
|
@@ -308,10 +368,10 @@ class Context(Generic[TR]):
|
|
|
308
368
|
|
|
309
369
|
def _respond(
|
|
310
370
|
self, payload: bytes, status: int, headers: Mapping[str, str] | None, content_type: str | None
|
|
311
|
-
) ->
|
|
371
|
+
) -> TypedResponse:
|
|
312
372
|
merged: dict[str, HeaderValue] = {} if content_type is None else {"content-type": content_type}
|
|
313
373
|
if self._headers:
|
|
314
374
|
merged.update(self._headers)
|
|
315
375
|
if headers:
|
|
316
376
|
merged.update({k.lower(): v for k, v in headers.items()})
|
|
317
|
-
return
|
|
377
|
+
return TypedResponse(payload, status, merged)
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
The types are written to the OpenAPI document, request parts are checked against them (types
|
|
4
4
|
only: ``c.req.valid(...)``), and they type the handler: ``createRoute`` returns
|
|
5
|
-
``type[Route[P, Q, J, H, R]]`` (path params, query, JSON body, headers, response bodies
|
|
6
|
-
handler for the route takes ``c: Context[thatRoute]``, so ``c.req.valid("param")``
|
|
7
|
-
``c.json(...)`` takes ``R
|
|
5
|
+
``type[Route[P, Q, J, H, R, S]]`` (path params, query, JSON body, headers, response bodies, response
|
|
6
|
+
statuses), and a handler for the route takes ``c: Context[thatRoute]``, so ``c.req.valid("param")``
|
|
7
|
+
is ``P``, ``c.json(...)`` takes ``R`` with a status in ``S``, and the handler must return what
|
|
8
|
+
``c.json`` / ``c.text`` / ``c.body`` build. Responses are not checked at run time.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from __future__ import annotations
|
|
@@ -25,9 +26,27 @@ H = TypeVar("H")
|
|
|
25
26
|
# Response bodies the route declares (a union over its ``responses``) -> ``c.json(...)``. Covariant
|
|
26
27
|
# (with ReadOnly fields below) so several responses infer ``Todo | Problem``.
|
|
27
28
|
R = TypeVar("R", covariant=True)
|
|
29
|
+
# Response statuses the route declares: ``Literal[200, 404]`` (with ``"default"`` when declared). The
|
|
30
|
+
# responses' keys are inferred as this union when they are literals of ``StatusCode``; ``Route``
|
|
31
|
+
# takes it contravariantly, so ``c.json(..., status)`` accepts a status only when the route declares
|
|
32
|
+
# it (and any status when the route declares ``"default"``).
|
|
33
|
+
K = TypeVar("K")
|
|
34
|
+
S = TypeVar("S", contravariant=True)
|
|
28
35
|
|
|
29
36
|
Method = Literal["get", "post", "put", "patch", "delete", "head", "options", "trace"]
|
|
30
37
|
|
|
38
|
+
# The HTTP status codes of the IANA registry (and 418). A response key outside them, or one typed
|
|
39
|
+
# ``int``, is a type error: annotate shared responses with their codes,
|
|
40
|
+
# ``errors: Mapping[Literal[400, 404], ResponseConfig[Problem]]``.
|
|
41
|
+
StatusCode = Literal[
|
|
42
|
+
100, 101, 102, 103,
|
|
43
|
+
200, 201, 202, 203, 204, 205, 206, 207, 208, 226,
|
|
44
|
+
300, 301, 302, 303, 304, 305, 307, 308,
|
|
45
|
+
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
|
|
46
|
+
421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
|
|
47
|
+
500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511,
|
|
48
|
+
] # fmt: skip
|
|
49
|
+
|
|
31
50
|
_T_co = TypeVar("_T_co", covariant=True)
|
|
32
51
|
|
|
33
52
|
|
|
@@ -68,18 +87,19 @@ class ResponseMediaType(TypedDict, Generic[R], total=False):
|
|
|
68
87
|
|
|
69
88
|
|
|
70
89
|
class ResponseConfig(TypedDict, Generic[R], total=False):
|
|
71
|
-
"""One entry of ``responses``. Annotate shared ones with their body type, e.g.
|
|
72
|
-
``errors:
|
|
90
|
+
"""One entry of ``responses``. Annotate shared ones with their statuses and body type, e.g.
|
|
91
|
+
``errors: Mapping[Literal[400, 404], ResponseConfig[Problem]]``, and spread them:
|
|
92
|
+
``"responses": {**errors, 200: {...}}``."""
|
|
73
93
|
|
|
74
94
|
description: ReadOnly[str]
|
|
75
95
|
content: ReadOnly[Mapping[str, ResponseMediaType[R]]]
|
|
76
96
|
headers: ReadOnly[Mapping[str, Any]]
|
|
77
97
|
|
|
78
98
|
|
|
79
|
-
class _OperationConfig(TypedDict, Generic[R]):
|
|
99
|
+
class _OperationConfig(TypedDict, Generic[R, K]):
|
|
80
100
|
method: Method
|
|
81
101
|
path: str # "/todos/{todoId}"
|
|
82
|
-
responses: Mapping[
|
|
102
|
+
responses: Mapping[K, ResponseConfig[R]] # status code (or "default") -> response
|
|
83
103
|
summary: NotRequired[str]
|
|
84
104
|
description: NotRequired[str]
|
|
85
105
|
tags: NotRequired[list[str]]
|
|
@@ -89,7 +109,7 @@ class _OperationConfig(TypedDict, Generic[R]):
|
|
|
89
109
|
hide: NotRequired[bool] # served, but left out of the OpenAPI document
|
|
90
110
|
|
|
91
111
|
|
|
92
|
-
class RouteConfig(_OperationConfig[R], Generic[P, Q, J, H, R]):
|
|
112
|
+
class RouteConfig(_OperationConfig[R, K], Generic[P, Q, J, H, R, K]):
|
|
93
113
|
request: NotRequired[RequestConfig[P, Q, J, H]]
|
|
94
114
|
|
|
95
115
|
|
|
@@ -103,7 +123,7 @@ class _RequestP(TypedDict, Generic[P]):
|
|
|
103
123
|
cookies: NotRequired[type]
|
|
104
124
|
|
|
105
125
|
|
|
106
|
-
class _RouteP(_OperationConfig[R], Generic[P, R]):
|
|
126
|
+
class _RouteP(_OperationConfig[R, K], Generic[P, R, K]):
|
|
107
127
|
request: _RequestP[P]
|
|
108
128
|
|
|
109
129
|
|
|
@@ -112,7 +132,7 @@ class _RequestQ(TypedDict, Generic[Q]):
|
|
|
112
132
|
cookies: NotRequired[type]
|
|
113
133
|
|
|
114
134
|
|
|
115
|
-
class _RouteQ(_OperationConfig[R], Generic[Q, R]):
|
|
135
|
+
class _RouteQ(_OperationConfig[R, K], Generic[Q, R, K]):
|
|
116
136
|
request: _RequestQ[Q]
|
|
117
137
|
|
|
118
138
|
|
|
@@ -121,7 +141,7 @@ class _RequestH(TypedDict, Generic[H]):
|
|
|
121
141
|
cookies: NotRequired[type]
|
|
122
142
|
|
|
123
143
|
|
|
124
|
-
class _RouteH(_OperationConfig[R], Generic[H, R]):
|
|
144
|
+
class _RouteH(_OperationConfig[R, K], Generic[H, R, K]):
|
|
125
145
|
request: _RequestH[H]
|
|
126
146
|
|
|
127
147
|
|
|
@@ -130,7 +150,7 @@ class _RequestB(TypedDict, Generic[J]):
|
|
|
130
150
|
cookies: NotRequired[type]
|
|
131
151
|
|
|
132
152
|
|
|
133
|
-
class _RouteB(_OperationConfig[R], Generic[J, R]):
|
|
153
|
+
class _RouteB(_OperationConfig[R, K], Generic[J, R, K]):
|
|
134
154
|
request: _RequestB[J]
|
|
135
155
|
|
|
136
156
|
|
|
@@ -140,7 +160,7 @@ class _RequestPQ(TypedDict, Generic[P, Q]):
|
|
|
140
160
|
cookies: NotRequired[type]
|
|
141
161
|
|
|
142
162
|
|
|
143
|
-
class _RoutePQ(_OperationConfig[R], Generic[P, Q, R]):
|
|
163
|
+
class _RoutePQ(_OperationConfig[R, K], Generic[P, Q, R, K]):
|
|
144
164
|
request: _RequestPQ[P, Q]
|
|
145
165
|
|
|
146
166
|
|
|
@@ -150,7 +170,7 @@ class _RequestPH(TypedDict, Generic[P, H]):
|
|
|
150
170
|
cookies: NotRequired[type]
|
|
151
171
|
|
|
152
172
|
|
|
153
|
-
class _RoutePH(_OperationConfig[R], Generic[P, H, R]):
|
|
173
|
+
class _RoutePH(_OperationConfig[R, K], Generic[P, H, R, K]):
|
|
154
174
|
request: _RequestPH[P, H]
|
|
155
175
|
|
|
156
176
|
|
|
@@ -160,7 +180,7 @@ class _RequestPB(TypedDict, Generic[P, J]):
|
|
|
160
180
|
cookies: NotRequired[type]
|
|
161
181
|
|
|
162
182
|
|
|
163
|
-
class _RoutePB(_OperationConfig[R], Generic[P, J, R]):
|
|
183
|
+
class _RoutePB(_OperationConfig[R, K], Generic[P, J, R, K]):
|
|
164
184
|
request: _RequestPB[P, J]
|
|
165
185
|
|
|
166
186
|
|
|
@@ -170,7 +190,7 @@ class _RequestQH(TypedDict, Generic[Q, H]):
|
|
|
170
190
|
cookies: NotRequired[type]
|
|
171
191
|
|
|
172
192
|
|
|
173
|
-
class _RouteQH(_OperationConfig[R], Generic[Q, H, R]):
|
|
193
|
+
class _RouteQH(_OperationConfig[R, K], Generic[Q, H, R, K]):
|
|
174
194
|
request: _RequestQH[Q, H]
|
|
175
195
|
|
|
176
196
|
|
|
@@ -180,7 +200,7 @@ class _RequestQB(TypedDict, Generic[Q, J]):
|
|
|
180
200
|
cookies: NotRequired[type]
|
|
181
201
|
|
|
182
202
|
|
|
183
|
-
class _RouteQB(_OperationConfig[R], Generic[Q, J, R]):
|
|
203
|
+
class _RouteQB(_OperationConfig[R, K], Generic[Q, J, R, K]):
|
|
184
204
|
request: _RequestQB[Q, J]
|
|
185
205
|
|
|
186
206
|
|
|
@@ -190,7 +210,7 @@ class _RequestHB(TypedDict, Generic[J, H]):
|
|
|
190
210
|
cookies: NotRequired[type]
|
|
191
211
|
|
|
192
212
|
|
|
193
|
-
class _RouteHB(_OperationConfig[R], Generic[J, H, R]):
|
|
213
|
+
class _RouteHB(_OperationConfig[R, K], Generic[J, H, R, K]):
|
|
194
214
|
request: _RequestHB[J, H]
|
|
195
215
|
|
|
196
216
|
|
|
@@ -201,7 +221,7 @@ class _RequestPQH(TypedDict, Generic[P, Q, H]):
|
|
|
201
221
|
cookies: NotRequired[type]
|
|
202
222
|
|
|
203
223
|
|
|
204
|
-
class _RoutePQH(_OperationConfig[R], Generic[P, Q, H, R]):
|
|
224
|
+
class _RoutePQH(_OperationConfig[R, K], Generic[P, Q, H, R, K]):
|
|
205
225
|
request: _RequestPQH[P, Q, H]
|
|
206
226
|
|
|
207
227
|
|
|
@@ -212,7 +232,7 @@ class _RequestPQB(TypedDict, Generic[P, Q, J]):
|
|
|
212
232
|
cookies: NotRequired[type]
|
|
213
233
|
|
|
214
234
|
|
|
215
|
-
class _RoutePQB(_OperationConfig[R], Generic[P, Q, J, R]):
|
|
235
|
+
class _RoutePQB(_OperationConfig[R, K], Generic[P, Q, J, R, K]):
|
|
216
236
|
request: _RequestPQB[P, Q, J]
|
|
217
237
|
|
|
218
238
|
|
|
@@ -223,7 +243,7 @@ class _RequestPHB(TypedDict, Generic[P, J, H]):
|
|
|
223
243
|
cookies: NotRequired[type]
|
|
224
244
|
|
|
225
245
|
|
|
226
|
-
class _RoutePHB(_OperationConfig[R], Generic[P, J, H, R]):
|
|
246
|
+
class _RoutePHB(_OperationConfig[R, K], Generic[P, J, H, R, K]):
|
|
227
247
|
request: _RequestPHB[P, J, H]
|
|
228
248
|
|
|
229
249
|
|
|
@@ -234,7 +254,7 @@ class _RequestQHB(TypedDict, Generic[Q, J, H]):
|
|
|
234
254
|
cookies: NotRequired[type]
|
|
235
255
|
|
|
236
256
|
|
|
237
|
-
class _RouteQHB(_OperationConfig[R], Generic[Q, J, H, R]):
|
|
257
|
+
class _RouteQHB(_OperationConfig[R, K], Generic[Q, J, H, R, K]):
|
|
238
258
|
request: _RequestQHB[Q, J, H]
|
|
239
259
|
|
|
240
260
|
|
|
@@ -246,11 +266,11 @@ class _RequestPQHB(TypedDict, Generic[P, Q, J, H]):
|
|
|
246
266
|
cookies: NotRequired[type]
|
|
247
267
|
|
|
248
268
|
|
|
249
|
-
class _RoutePQHB(_OperationConfig[R], Generic[P, Q, J, H, R]):
|
|
269
|
+
class _RoutePQHB(_OperationConfig[R, K], Generic[P, Q, J, H, R, K]):
|
|
250
270
|
request: _RequestPQHB[P, Q, J, H]
|
|
251
271
|
|
|
252
272
|
|
|
253
|
-
class Route(Generic[P, Q, J, H, R]):
|
|
273
|
+
class Route(Generic[P, Q, J, H, R, S]):
|
|
254
274
|
"""An operation made by :func:`createRoute`. Declare it as a class, so that handlers can name
|
|
255
275
|
it::
|
|
256
276
|
|
|
@@ -279,7 +299,7 @@ class Route(Generic[P, Q, J, H, R]):
|
|
|
279
299
|
raise TypeError("routes are classes: use the route itself (app.openapi(route, handler))")
|
|
280
300
|
|
|
281
301
|
|
|
282
|
-
AnyRoute = Route[Any, Any, Any, Any, Any]
|
|
302
|
+
AnyRoute = Route[Any, Any, Any, Any, Any, Any]
|
|
283
303
|
|
|
284
304
|
|
|
285
305
|
def route_class(config: Mapping[str, Any], *, generated: bool = True) -> type[AnyRoute]:
|
|
@@ -294,109 +314,110 @@ _Q = TypeVar("_Q")
|
|
|
294
314
|
_J = TypeVar("_J")
|
|
295
315
|
_H = TypeVar("_H")
|
|
296
316
|
_R = TypeVar("_R")
|
|
317
|
+
_K = TypeVar("_K", bound=StatusCode | Literal["default"])
|
|
297
318
|
|
|
298
319
|
|
|
299
320
|
@overload
|
|
300
|
-
def createRoute(config: _OperationConfig[_R]) -> type[Route[Any, Any, Any, Any, _R]]: ...
|
|
321
|
+
def createRoute(config: _OperationConfig[_R, _K]) -> type[Route[Any, Any, Any, Any, _R, _K]]: ...
|
|
301
322
|
|
|
302
323
|
|
|
303
324
|
# A dict literal with a "request" key cannot match the overload above (TypedDicts reject extra
|
|
304
325
|
# keys), although pyright's overlap check treats the shapes below as its structural subtypes.
|
|
305
326
|
@overload
|
|
306
327
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
307
|
-
config: _RouteP[_P, _R],
|
|
308
|
-
) -> type[Route[_P, Any, Any, Any, _R]]: ...
|
|
328
|
+
config: _RouteP[_P, _R, _K],
|
|
329
|
+
) -> type[Route[_P, Any, Any, Any, _R, _K]]: ...
|
|
309
330
|
|
|
310
331
|
|
|
311
332
|
@overload
|
|
312
333
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
313
|
-
config: _RouteQ[_Q, _R],
|
|
314
|
-
) -> type[Route[Any, _Q, Any, Any, _R]]: ...
|
|
334
|
+
config: _RouteQ[_Q, _R, _K],
|
|
335
|
+
) -> type[Route[Any, _Q, Any, Any, _R, _K]]: ...
|
|
315
336
|
|
|
316
337
|
|
|
317
338
|
@overload
|
|
318
339
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
319
|
-
config: _RouteH[_H, _R],
|
|
320
|
-
) -> type[Route[Any, Any, Any, _H, _R]]: ...
|
|
340
|
+
config: _RouteH[_H, _R, _K],
|
|
341
|
+
) -> type[Route[Any, Any, Any, _H, _R, _K]]: ...
|
|
321
342
|
|
|
322
343
|
|
|
323
344
|
@overload
|
|
324
345
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
325
|
-
config: _RouteB[_J, _R],
|
|
326
|
-
) -> type[Route[Any, Any, _J, Any, _R]]: ...
|
|
346
|
+
config: _RouteB[_J, _R, _K],
|
|
347
|
+
) -> type[Route[Any, Any, _J, Any, _R, _K]]: ...
|
|
327
348
|
|
|
328
349
|
|
|
329
350
|
@overload
|
|
330
351
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
331
|
-
config: _RoutePQ[_P, _Q, _R],
|
|
332
|
-
) -> type[Route[_P, _Q, Any, Any, _R]]: ...
|
|
352
|
+
config: _RoutePQ[_P, _Q, _R, _K],
|
|
353
|
+
) -> type[Route[_P, _Q, Any, Any, _R, _K]]: ...
|
|
333
354
|
|
|
334
355
|
|
|
335
356
|
@overload
|
|
336
357
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
337
|
-
config: _RoutePH[_P, _H, _R],
|
|
338
|
-
) -> type[Route[_P, Any, Any, _H, _R]]: ...
|
|
358
|
+
config: _RoutePH[_P, _H, _R, _K],
|
|
359
|
+
) -> type[Route[_P, Any, Any, _H, _R, _K]]: ...
|
|
339
360
|
|
|
340
361
|
|
|
341
362
|
@overload
|
|
342
363
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
343
|
-
config: _RoutePB[_P, _J, _R],
|
|
344
|
-
) -> type[Route[_P, Any, _J, Any, _R]]: ...
|
|
364
|
+
config: _RoutePB[_P, _J, _R, _K],
|
|
365
|
+
) -> type[Route[_P, Any, _J, Any, _R, _K]]: ...
|
|
345
366
|
|
|
346
367
|
|
|
347
368
|
@overload
|
|
348
369
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
349
|
-
config: _RouteQH[_Q, _H, _R],
|
|
350
|
-
) -> type[Route[Any, _Q, Any, _H, _R]]: ...
|
|
370
|
+
config: _RouteQH[_Q, _H, _R, _K],
|
|
371
|
+
) -> type[Route[Any, _Q, Any, _H, _R, _K]]: ...
|
|
351
372
|
|
|
352
373
|
|
|
353
374
|
@overload
|
|
354
375
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
355
|
-
config: _RouteQB[_Q, _J, _R],
|
|
356
|
-
) -> type[Route[Any, _Q, _J, Any, _R]]: ...
|
|
376
|
+
config: _RouteQB[_Q, _J, _R, _K],
|
|
377
|
+
) -> type[Route[Any, _Q, _J, Any, _R, _K]]: ...
|
|
357
378
|
|
|
358
379
|
|
|
359
380
|
@overload
|
|
360
381
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
361
|
-
config: _RouteHB[_J, _H, _R],
|
|
362
|
-
) -> type[Route[Any, Any, _J, _H, _R]]: ...
|
|
382
|
+
config: _RouteHB[_J, _H, _R, _K],
|
|
383
|
+
) -> type[Route[Any, Any, _J, _H, _R, _K]]: ...
|
|
363
384
|
|
|
364
385
|
|
|
365
386
|
@overload
|
|
366
387
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
367
|
-
config: _RoutePQH[_P, _Q, _H, _R],
|
|
368
|
-
) -> type[Route[_P, _Q, Any, _H, _R]]: ...
|
|
388
|
+
config: _RoutePQH[_P, _Q, _H, _R, _K],
|
|
389
|
+
) -> type[Route[_P, _Q, Any, _H, _R, _K]]: ...
|
|
369
390
|
|
|
370
391
|
|
|
371
392
|
@overload
|
|
372
393
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
373
|
-
config: _RoutePQB[_P, _Q, _J, _R],
|
|
374
|
-
) -> type[Route[_P, _Q, _J, Any, _R]]: ...
|
|
394
|
+
config: _RoutePQB[_P, _Q, _J, _R, _K],
|
|
395
|
+
) -> type[Route[_P, _Q, _J, Any, _R, _K]]: ...
|
|
375
396
|
|
|
376
397
|
|
|
377
398
|
@overload
|
|
378
399
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
379
|
-
config: _RoutePHB[_P, _J, _H, _R],
|
|
380
|
-
) -> type[Route[_P, Any, _J, _H, _R]]: ...
|
|
400
|
+
config: _RoutePHB[_P, _J, _H, _R, _K],
|
|
401
|
+
) -> type[Route[_P, Any, _J, _H, _R, _K]]: ...
|
|
381
402
|
|
|
382
403
|
|
|
383
404
|
@overload
|
|
384
405
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
385
|
-
config: _RouteQHB[_Q, _J, _H, _R],
|
|
386
|
-
) -> type[Route[Any, _Q, _J, _H, _R]]: ...
|
|
406
|
+
config: _RouteQHB[_Q, _J, _H, _R, _K],
|
|
407
|
+
) -> type[Route[Any, _Q, _J, _H, _R, _K]]: ...
|
|
387
408
|
|
|
388
409
|
|
|
389
410
|
@overload
|
|
390
411
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
391
|
-
config: _RoutePQHB[_P, _Q, _J, _H, _R],
|
|
392
|
-
) -> type[Route[_P, _Q, _J, _H, _R]]: ...
|
|
412
|
+
config: _RoutePQHB[_P, _Q, _J, _H, _R, _K],
|
|
413
|
+
) -> type[Route[_P, _Q, _J, _H, _R, _K]]: ...
|
|
393
414
|
|
|
394
415
|
|
|
395
416
|
# Anything else (cookies only, a dict built at run time).
|
|
396
417
|
@overload
|
|
397
418
|
def createRoute( # pyright: ignore[reportOverlappingOverload]
|
|
398
|
-
config: RouteConfig[_P, _Q, _J, _H, _R],
|
|
399
|
-
) -> type[Route[_P, _Q, _J, _H, _R]]: ...
|
|
419
|
+
config: RouteConfig[_P, _Q, _J, _H, _R, _K],
|
|
420
|
+
) -> type[Route[_P, _Q, _J, _H, _R, _K]]: ...
|
|
400
421
|
|
|
401
422
|
|
|
402
423
|
def createRoute(config: Any) -> Any: # noqa: N802 (the conventional name)
|
|
@@ -405,4 +426,13 @@ def createRoute(config: Any) -> Any: # noqa: N802 (the conventional name)
|
|
|
405
426
|
return route_class(config)
|
|
406
427
|
|
|
407
428
|
|
|
408
|
-
__all__ = [
|
|
429
|
+
__all__ = [
|
|
430
|
+
"AnyRoute",
|
|
431
|
+
"RequestConfig",
|
|
432
|
+
"ResponseConfig",
|
|
433
|
+
"Route",
|
|
434
|
+
"RouteConfig",
|
|
435
|
+
"SchemaLike",
|
|
436
|
+
"StatusCode",
|
|
437
|
+
"createRoute",
|
|
438
|
+
]
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
load("//tools/pytest:defs.bzl", "pytest_test")
|
|
2
|
-
|
|
3
|
-
# The Rust engine's sources, built into takibiapi._core by //pypi/takibiapi/python:core.
|
|
4
|
-
filegroup(
|
|
5
|
-
name = "core_srcs",
|
|
6
|
-
srcs = glob(["src/**/*.rs"]),
|
|
7
|
-
visibility = ["//pypi/takibiapi/python:__pkg__"],
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
exports_files(
|
|
11
|
-
["src/lib.rs"],
|
|
12
|
-
visibility = ["//pypi/takibiapi/python:__pkg__"],
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
# tests/test_typing.py is not here: it shells out to pyright, which `just typecheck` runs over
|
|
16
|
-
# tests/typing directly.
|
|
17
|
-
pytest_test(
|
|
18
|
-
name = "test",
|
|
19
|
-
size = "small",
|
|
20
|
-
tests = glob(
|
|
21
|
-
["tests/test_*.py"],
|
|
22
|
-
exclude = ["tests/test_typing.py"],
|
|
23
|
-
),
|
|
24
|
-
deps = ["//pypi/takibiapi/python:takibiapi"],
|
|
25
|
-
)
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
load("@rules_python//python:defs.bzl", "py_library")
|
|
2
|
-
load("@rules_rust_pyo3//:defs.bzl", "pyo3_extension")
|
|
3
|
-
|
|
4
|
-
# takibiapi (`import takibiapi`): the framework. `imports = ["."]` makes the import resolve from
|
|
5
|
-
# this directory, as when the wheel is installed.
|
|
6
|
-
py_library(
|
|
7
|
-
name = "takibiapi",
|
|
8
|
-
srcs = glob(["takibiapi/*.py"]),
|
|
9
|
-
data = [
|
|
10
|
-
"takibiapi/_core.pyi",
|
|
11
|
-
"takibiapi/py.typed",
|
|
12
|
-
],
|
|
13
|
-
imports = ["."],
|
|
14
|
-
visibility = ["//visibility:public"],
|
|
15
|
-
deps = [":core"],
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
# takibiapi._core: the Rust engine (request checks, JSON, routing), placed next to the Python
|
|
19
|
-
# sources as takibiapi/_core.so. takibiapi.schema falls back to its Python checker without it.
|
|
20
|
-
pyo3_extension(
|
|
21
|
-
name = "core",
|
|
22
|
-
srcs = ["//pypi/takibiapi:core_srcs"],
|
|
23
|
-
crate_name = "_core",
|
|
24
|
-
crate_root = "//pypi/takibiapi:src/lib.rs",
|
|
25
|
-
edition = "2021",
|
|
26
|
-
imports = ["."],
|
|
27
|
-
module_name = "takibiapi._core",
|
|
28
|
-
stubs = False,
|
|
29
|
-
)
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
# pyright: reportUnnecessaryTypeIgnoreComment=true
|
|
2
|
-
"""Static types of routes and handlers (checked by pyright; see typing_test.py)."""
|
|
3
|
-
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from typing import TypedDict, reveal_type
|
|
6
|
-
|
|
7
|
-
from takibiapi import Context, TakibiAPI, createRoute
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class TodoParams(TypedDict):
|
|
11
|
-
todoId: int
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class Search(TypedDict):
|
|
15
|
-
q: str
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@dataclass
|
|
19
|
-
class NewTodo:
|
|
20
|
-
title: str
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@dataclass
|
|
24
|
-
class Todo:
|
|
25
|
-
id: int
|
|
26
|
-
title: str
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@dataclass
|
|
30
|
-
class Problem:
|
|
31
|
-
detail: str
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@dataclass
|
|
35
|
-
class Other:
|
|
36
|
-
x: int
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class putTodoRoute(
|
|
40
|
-
createRoute(
|
|
41
|
-
{
|
|
42
|
-
"method": "put",
|
|
43
|
-
"path": "/todos/{todoId}",
|
|
44
|
-
"request": {
|
|
45
|
-
"params": TodoParams,
|
|
46
|
-
"query": Search,
|
|
47
|
-
"body": {"content": {"application/json": {"schema": NewTodo}}},
|
|
48
|
-
},
|
|
49
|
-
"responses": {
|
|
50
|
-
200: {"description": "ok", "content": {"application/json": {"schema": Todo}}},
|
|
51
|
-
404: {"description": "missing", "content": {"application/problem+json": {"schema": Problem}}},
|
|
52
|
-
},
|
|
53
|
-
}
|
|
54
|
-
)
|
|
55
|
-
):
|
|
56
|
-
"""Update a todo."""
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
async def putTodoRouteHandler(c: Context[putTodoRoute]):
|
|
60
|
-
body = c.req.valid("json")
|
|
61
|
-
reveal_type(body, expected_text="NewTodo")
|
|
62
|
-
reveal_type(c.req.valid("param")["todoId"], expected_text="int")
|
|
63
|
-
reveal_type(c.req.valid("query"), expected_text="Search")
|
|
64
|
-
c.req.valid("header") # no headers type: Any
|
|
65
|
-
reveal_type(await c.req.json(), expected_text="Any") # the raw, unchecked body
|
|
66
|
-
reveal_type(c.req.param("todoId"), expected_text="str") # the raw text
|
|
67
|
-
reveal_type(c.req.query("q"), expected_text="str | None")
|
|
68
|
-
reveal_type(c.req.queries("tag"), expected_text="list[str]")
|
|
69
|
-
c.json(Other(x=1)) # pyright: ignore[reportArgumentType] # not a declared response body
|
|
70
|
-
c.json({"id": 1, "title": "x"}) # pyright: ignore[reportArgumentType] # a dict is not a Todo
|
|
71
|
-
c.json(Problem(detail="x"), 404)
|
|
72
|
-
params = c.req.valid("param")
|
|
73
|
-
return c.json(Todo(id=params["todoId"], title=body.title), 200)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
class getOtherRoute(
|
|
77
|
-
createRoute(
|
|
78
|
-
{
|
|
79
|
-
"method": "get",
|
|
80
|
-
"path": "/other",
|
|
81
|
-
"responses": {200: {"description": "ok", "content": {"application/json": {"schema": list[Search]}}}},
|
|
82
|
-
}
|
|
83
|
-
)
|
|
84
|
-
):
|
|
85
|
-
pass
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def getOtherRouteHandler(c: Context[getOtherRoute]):
|
|
89
|
-
c.json([{"q": "x"}])
|
|
90
|
-
c.json([{"name": "x"}]) # pyright: ignore[reportArgumentType] # wrong TypedDict key
|
|
91
|
-
return c.json([])
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
app = TakibiAPI()
|
|
95
|
-
app.openapi(putTodoRoute, putTodoRouteHandler)
|
|
96
|
-
app.openapi(getOtherRoute, getOtherRouteHandler)
|
|
97
|
-
app.openapi(putTodoRoute, getOtherRouteHandler) # pyright: ignore[reportArgumentType] # another route's handler
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|