├── .coveragerc
├── .flake8
├── .github
└── workflows
│ ├── ci-pipeline.yml
│ └── python-publish.yml
├── LICENSE
├── README.md
├── assets
└── mindmap.svg
├── badges
└── coverage.svg
├── docs
├── css
│ └── style.css
├── img
│ ├── concurrency_parallelism
│ │ └── bursts.png
│ ├── favicon.png
│ ├── http_requests
│ │ ├── body.png
│ │ ├── client_server.png
│ │ ├── path.png
│ │ ├── query.png
│ │ └── swagger.png
│ ├── icon-white.svg
│ ├── making_aliases
│ │ ├── bestseller.png
│ │ ├── bestseller_response.png
│ │ └── list-item.png
│ └── sponsors
│ │ └── croco_banner.svg
├── index.md
├── js
│ └── script.js
├── learn
│ ├── concurrency_parallelism.md
│ ├── http_requests.md
│ ├── index.md
│ ├── type_hints.md
│ └── user_guide
│ │ ├── first_steps.md
│ │ ├── making_aliases.md
│ │ ├── managing_requests.md
│ │ ├── motivation.md
│ │ ├── params_response.md
│ │ ├── preparers_finalizers.md
│ │ └── routed_model.md
├── overrides
│ └── main.html
├── people.md
└── reference
│ ├── api_model.md
│ ├── case_converters.md
│ ├── index.md
│ ├── manager.md
│ ├── parameters.md
│ ├── rate_limit.md
│ ├── routed_function.md
│ └── router.md
├── mkdocs.yml
├── pyproject.toml
├── sensei
├── __init__.py
├── _internal
│ ├── __init__.py
│ ├── _core
│ │ ├── __init__.py
│ │ ├── _callable_handler.py
│ │ ├── _compat.py
│ │ ├── _endpoint.py
│ │ ├── _params.py
│ │ ├── _requester.py
│ │ ├── _route.py
│ │ ├── _types.py
│ │ ├── api_model.py
│ │ ├── args.py
│ │ ├── params_functions.py
│ │ └── router.py
│ └── tools
│ │ ├── __init__.py
│ │ ├── chained_map.py
│ │ ├── types.py
│ │ └── utils.py
├── _utils.py
├── cases.py
├── client
│ ├── __init__.py
│ ├── exceptions.py
│ ├── manager.py
│ └── rate_limiter.py
└── types.py
└── tests
├── __init__.py
├── base_user.py
├── conftest.py
├── mock_api.py
├── test_api_model.py
├── test_async.py
├── test_cases.py
├── test_chained_map.py
├── test_client.py
├── test_router.py
├── test_sync.py
└── test_validation.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | omit =
3 | sensei/_internal/_core/_types.py
4 | sensei/types.py
5 | sensei/_compat.py
6 | sensei/_params.py
7 | sensei/params_functions.py
8 | sensei/_base_client.py
9 | tests/*
10 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 120
3 | exclude =
4 | .git,
5 | __pycache__,
6 | sensei/_compat.py
7 | sensei/__init__.py
8 | sensei/client/__init__.py
9 | sensei/_internal/__init__.py
10 | sensei/_internal/tools/__init__.py
11 | sensei/_internal/_core/__init__.py
12 |
--------------------------------------------------------------------------------
/.github/workflows/ci-pipeline.yml:
--------------------------------------------------------------------------------
1 | name: CI Pipeline
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - 'badges/**' # Ignore badge updates to avoid triggering the workflow again
9 | - '.github/workflows/**'
10 | pull_request:
11 | branches:
12 | - main
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | # Checkout the code
20 | - name: Checkout code
21 | uses: actions/checkout@v2
22 |
23 | # Set up Python environment
24 | - name: Set up Python
25 | uses: actions/setup-python@v2
26 | with:
27 | python-version: 3.9
28 |
29 | - name: Setup Poetry
30 | uses: Gr1N/setup-poetry@v9
31 |
32 | # Install dependencies
33 | - name: Install dependencies
34 | run: |
35 | poetry install
36 |
37 | # Run flake8 linting
38 | - name: Run linting with flake8
39 | run: |
40 | poetry run flake8 sensei
41 |
42 | # Run pytest and generate coverage report
43 | - name: Run tests and generate coverage
44 | run: |
45 | poetry run coverage run -m pytest tests
46 |
47 | # Fail if coverage is less than 90%
48 | - name: Check coverage threshold
49 | id: coverage_check
50 | run: |
51 | coverage_percent=$(poetry run coverage report | grep TOTAL | awk '{print $4}' | sed 's/%//')
52 | echo "Current coverage: $coverage_percent%"
53 | if (( $(echo "$coverage_percent < 90" | bc -l) )); then
54 | echo "Test coverage is below 90%."
55 | exit 1
56 | fi
57 | echo "coverage_percent=$coverage_percent" >> $GITHUB_OUTPUT
58 | shell: bash
59 |
--------------------------------------------------------------------------------
/.github/workflows/python-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflow will upload a Python Package using Twine when a release is created
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3 |
4 | # This workflow uses actions that are not certified by GitHub.
5 | # They are provided by a third-party and are governed by
6 | # separate terms of service, privacy policy, and support
7 | # documentation.
8 |
9 | name: Upload Python Package
10 |
11 | on:
12 | release:
13 | types: [published]
14 |
15 | permissions:
16 | contents: read
17 |
18 | jobs:
19 | deploy:
20 |
21 | runs-on: ubuntu-latest
22 |
23 | steps:
24 | - uses: actions/checkout@v4
25 | - name: Set up Python
26 | uses: actions/setup-python@v3
27 | with:
28 | python-version: '3.x'
29 | - name: Clear directory
30 | run: rm -rf dist
31 | - name: Install dependencies
32 | run: |
33 | python -m pip install --upgrade pip
34 | pip install build
35 | - name: Build package
36 | run: python -m build
37 | - name: Publish package
38 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
39 | with:
40 | user: __token__
41 | password: ${{ secrets.PYPI_TOKEN }}
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Belenkov Alexey
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # sensei
2 |
3 |
4 |
5 |
6 |
7 |
8 | *Build robust HTTP Requests and best API clients with minimal implementation*
9 |
10 | [](https://pypi.org/project/sensei/)
11 | [](https://pypi.org/project/sensei/)
12 | [](https://pypi.org/project/sensei/)
13 |
14 | The Python framework that provides a quick way to build robust HTTP requests and best API clients. Use type hints, to build requests, with
15 | little or no implementation.
16 |
17 | ---
18 |
19 | **Documentation:** [https://sensei.crocofactory.dev](https://sensei.crocofactory.dev)
20 |
21 | **Source code:** [https://github.com/CrocoFactory/sensei](https://github.com/CrocoFactory/sensei)
22 |
23 | ---
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | There are key features provided by `sensei`:
32 |
33 | - **Fast:** Do not write any request-handling code, dedicate responsibility to the function's interface(signature) 🚀
34 | - **Short:** Avoid code duplication 🧹
35 | - **Sync/Async:** Implement sync and async quickly, without headaches ⚡
36 | - **Robust:** Auto validation data before and after request 🛡️️
37 |
38 | Table of Contents:
39 | 1. [First Request](#first-request)
40 | 2. [Comparison](#comparison)
41 | 3. [OOP Style](#oop-style)
42 | 4. [Installing](#installing)
43 |
44 | ## First Request
45 |
46 | Do you want to see the simplest and most robust HTTP Request? He's already here!
47 |
48 | ```python
49 | from typing import Annotated
50 | from sensei import Router, Path, APIModel
51 |
52 | router = Router('https://pokeapi.co/api/v2/')
53 |
54 |
55 | class Pokemon(APIModel):
56 | name: str
57 | id: int
58 | height: int
59 | weight: int
60 |
61 |
62 | @router.get('/pokemon/{name}')
63 | def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
64 | pass
65 |
66 |
67 | pokemon = get_pokemon(name="pikachu")
68 | print(pokemon) # Pokemon(name='pikachu' id=25 height=4 weight=60)
69 | ```
70 |
71 | Didn't it seem to you that the function doesn't contain the code? **Sensei writes it instead of you!**
72 |
73 | Moreover, Sensei abstracts away much of the manual work, letting developers focus on function signatures while the framework
74 | handles the API logic and data validation. This enables a declarative style for your apps.
75 |
76 | The example of [First Request](#first-request) demonstrates a simple and robust HTTP request using the Sensei framework.
77 | Here's the key breakdown of the process:
78 |
79 | #### 1. Importing Dependencies:
80 |
81 | - `Router` manages API endpoints and routing.
82 | - `Path` specifies and validates route parameters.
83 | - `APIModel` defines models for structuring API responses (similar to `pydantic.BaseModel`).
84 |
85 | #### 2. Creating the Router:
86 |
87 | The `Router` is initialized with the base URL of the *PokéAPI*. All subsequent requests will use this as the base path.
88 |
89 | #### 3. Defining the Model:
90 |
91 | The `Pokemon` class represents the data structure for a Pokémon, with fields like `name`, `id`, `height`, and `weight`.
92 | It inherits from `APIModel`, which provides validation and serialization.
93 |
94 | #### 4. Creating the Endpoint:
95 |
96 | The `get_pokemon` function is a routed function decorated with `@router.get`, defining a GET request for
97 | `/pokemon/{name}`.
98 | This uses `Annotated` to ensure that `name` is a string and adheres to the validation rule (max length of 300).
99 |
100 | #### 5. Making the Request:
101 |
102 | By calling `get_pokemon(name="pikachu")`, Sensei automatically handles validation, makes the HTTP request,
103 | and maps the API response into the `Pokemon` model. The code omits the function body since Sensei handles calls through
104 | the function's signature.
105 |
106 | ## Comparison
107 |
108 | **Sensei** 👍: It provides a high level of abstraction. Sensei simplifies creating API wrappers, offering decorators for
109 | easy routing, data validation, and automatic mapping of API responses to models. This reduces boilerplate and improves
110 | code readability and maintainability.
111 |
112 | **Bare HTTP Client** 👎: A bare HTTP client like `requests` or `httpx` requires manually managing requests,
113 | handling response parsing, data validation, and error handling. You have to write repetitive code for each endpoint.
114 |
115 | ## OOP Style
116 |
117 | There is a wonderful OOP approach proposed by Sensei:
118 |
119 | ```python
120 | class User(APIModel):
121 | email: EmailStr
122 | id: PositiveInt
123 | first_name: str
124 | last_name: str
125 | avatar: AnyHttpUrl
126 |
127 | @classmethod
128 | @router.get('/users')
129 | def query(
130 | cls,
131 | page: Annotated[int, Query()] = 1,
132 | per_page: Annotated[int, Query(le=7)] = 3
133 | ) -> list[Self]:
134 | pass
135 |
136 | @classmethod
137 | @router.get('/users/{id_}')
138 | def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self:
139 | pass
140 |
141 | @router.post('/token')
142 | def login(self) -> str:
143 | pass
144 |
145 | @login.prepare
146 | def _login_in(self, args: Args) -> Args:
147 | args.json_['email'] = self.email
148 | return args
149 |
150 | @login.finalize
151 | def _login_out(self, response: Response) -> str:
152 | return response.json()['token']
153 |
154 | user = User.get(1)
155 | user.login() # User(id=1, email="john@example.com", first_name="John", ...)
156 | ```
157 |
158 | When Sensei doesn't know how to handle a request, you can do it yourself, using preprocessing as `prepare` and
159 | postprocessing as `finalize`
160 |
161 | ## Installing
162 | To install `sensei` from PyPi, you can use that:
163 |
164 | ```shell
165 | pip install sensei
166 | ```
167 |
168 | To install `sensei` from GitHub, use that:
169 |
170 | ```shell
171 | pip install git+https://github.com/CrocoFactory/sensei.git
172 | ```
--------------------------------------------------------------------------------
/assets/mindmap.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Golden Rules Sync/Async Validation Rate Limits Relevant response DRY pydantic Benefits Primary Data Discarding Fields Refactoring
--------------------------------------------------------------------------------
/badges/coverage.svg:
--------------------------------------------------------------------------------
1 | coverage: 97% coverage coverage 97% 96%
--------------------------------------------------------------------------------
/docs/css/style.css:
--------------------------------------------------------------------------------
1 | details.failure, .admonition.failure {
2 | color: #F75464;
3 | }
4 |
5 | details.failure p:not(.admonition-title), .admonition.failure p:not(.admonition-title) {
6 | white-space: pre-wrap;
7 | }
8 |
9 | /* API documentation link admonition */
10 | :root {
11 | --md-admonition-icon--api: url('data:image/svg+xml;charset=utf-8, ')
12 | }
13 | .md-typeset .admonition.api, .md-typeset details.api {
14 | border-color: #448aff;
15 | }
16 | .md-typeset .api > .admonition-title, .md-typeset .api > summary {
17 | background-color: #448aff1a;
18 | }
19 | .md-typeset .api > .admonition-title::before, .md-typeset .api > summary::before {
20 | background-color: #448aff;
21 | -webkit-mask-image: var(--md-admonition-icon--api);
22 | mask-image: var(--md-admonition-icon--api);
23 | }
24 |
25 | a.internal-link, a.external-link {
26 | border-bottom: .05rem dotted var(--md-default-fg-color--light)
27 | }
28 |
29 | a.external-link::after {
30 | content: "\00A0[↪]";
31 | }
32 |
33 | a.internal-link::after {
34 | content: "\00A0↪";
35 | }
36 |
37 | a.announce-link:link,
38 | a.announce-link:visited {
39 | color: #fff;
40 | }
41 |
42 | a.announce-link:hover {
43 | color: var(--md-accent-fg-color);
44 | }
45 |
46 | .announce-wrapper {
47 | display: flex;
48 | flex-direction: row;
49 | justify-content: space-between;
50 | flex-wrap: wrap;
51 | align-items: center;
52 | }
53 |
54 | .announce-wrapper div.item {
55 | display: none;
56 | }
57 |
58 | .announce-wrapper .sponsor-badge {
59 | display: block;
60 | position: absolute;
61 | top: -10px;
62 | right: 0;
63 | font-size: 0.5rem;
64 | color: #999;
65 | background-color: #666;
66 | border-radius: 10px;
67 | padding: 0 10px;
68 | z-index: 10;
69 | }
70 |
71 | .announce-wrapper .sponsor-image {
72 | display: block;
73 | border-radius: 20px;
74 | }
75 |
76 | .announce-wrapper>div {
77 | min-height: 40px;
78 | display: flex;
79 | align-items: center;
80 | }
81 |
82 | .twitter {
83 | color: #00acee;
84 | }
85 |
86 | .md-ellipsis {
87 | font-weight: normal;
88 | }
89 |
90 | .user-list {
91 | display: flex;
92 | flex-wrap: wrap;
93 | margin-bottom: 2rem;
94 | }
95 |
96 | .user-list-center {
97 | justify-content: space-evenly;
98 | }
99 |
100 | .user {
101 | margin: 1em;
102 | min-width: 7em;
103 | }
104 |
105 | .user .avatar-wrapper {
106 | width: 80px;
107 | height: 80px;
108 | margin: 10px auto;
109 | overflow: hidden;
110 | border-radius: 50%;
111 | position: relative;
112 | }
113 |
114 | .user .avatar-wrapper img {
115 | position: absolute;
116 | top: 50%;
117 | left: 50%;
118 | transform: translate(-50%, -50%);
119 | }
120 |
121 | .user .title {
122 | text-align: center;
123 | }
124 |
125 | .user .count {
126 | font-size: 80%;
127 | text-align: center;
128 | }
--------------------------------------------------------------------------------
/docs/img/concurrency_parallelism/bursts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/concurrency_parallelism/bursts.png
--------------------------------------------------------------------------------
/docs/img/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/favicon.png
--------------------------------------------------------------------------------
/docs/img/http_requests/body.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/http_requests/body.png
--------------------------------------------------------------------------------
/docs/img/http_requests/client_server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/http_requests/client_server.png
--------------------------------------------------------------------------------
/docs/img/http_requests/path.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/http_requests/path.png
--------------------------------------------------------------------------------
/docs/img/http_requests/query.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/http_requests/query.png
--------------------------------------------------------------------------------
/docs/img/http_requests/swagger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/http_requests/swagger.png
--------------------------------------------------------------------------------
/docs/img/icon-white.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/img/making_aliases/bestseller.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/making_aliases/bestseller.png
--------------------------------------------------------------------------------
/docs/img/making_aliases/bestseller_response.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/making_aliases/bestseller_response.png
--------------------------------------------------------------------------------
/docs/img/making_aliases/list-item.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/docs/img/making_aliases/list-item.png
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # sensei
2 |
3 |
4 |
5 |
6 |
7 |
8 | *Build robust HTTP Requests and best API clients with minimal implementation*
9 |
10 | [](https://pypi.org/project/sensei/)
11 | [](https://pypi.org/project/sensei/)
12 | [](https://pypi.org/project/sensei/)
13 |
14 | The Python framework that provides a quick way to build robust HTTP requests and best API clients. Use type hints, to build requests, with
15 | little or no implementation.
16 |
17 | ---
18 |
19 | **Documentation:** [https://sensei.crocofactory.dev](https://sensei.crocofactory.dev)
20 |
21 | **Source code:** [https://github.com/CrocoFactory/sensei](https://github.com/CrocoFactory/sensei)
22 |
23 | ---
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | There are key features provided by `sensei`:
32 |
33 | - **Fast:** Do not write any request-handling code, dedicate responsibility to the function's interface(signature) 🚀
34 | - **Short:** Avoid code duplication 🧹
35 | - **Sync/Async:** Implement sync and async quickly, without headaches ⚡
36 | - **Robust:** Auto validation data before and after request 🛡️️
37 |
38 | ## First Request
39 |
40 | Do you want to see the simplest and most robust HTTP Request? He's already here!
41 |
42 | ```python
43 | from typing import Annotated
44 | from sensei import Router, Path, APIModel
45 |
46 | router = Router('https://pokeapi.co/api/v2/')
47 |
48 |
49 | class Pokemon(APIModel):
50 | name: str
51 | id: int
52 | height: int
53 | weight: int
54 |
55 |
56 | @router.get('/pokemon/{name}')
57 | def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
58 | pass
59 |
60 |
61 | pokemon = get_pokemon(name="pikachu")
62 | print(pokemon) # Pokemon(name='pikachu' id=25 height=4 weight=60)
63 | ```
64 |
65 | Didn't it seem to you that the function doesn't contain the code? **Sensei writes it instead of you!**
66 |
67 | Moreover, Sensei abstracts away much of the manual work, letting developers focus on function signatures while the framework
68 | handles the API logic and data validation. This enables a declarative style for your apps.
69 |
70 | The example of [First Request](#first-request) demonstrates a simple and robust HTTP request using the Sensei framework.
71 | Here's the key breakdown of the process:
72 |
73 | #### 1. Importing Dependencies:
74 |
75 | - `Router` manages API endpoints and routing.
76 | - `Path` specifies and validates route parameters.
77 | - `APIModel` defines models for structuring API responses (similar to `pydantic.BaseModel`).
78 |
79 | #### 2. Creating the Router:
80 |
81 | The `Router` is initialized with the base URL of the *PokéAPI*. All subsequent requests will use this as the base path.
82 |
83 | #### 3. Defining the Model:
84 |
85 | The `Pokemon` class represents the data structure for a Pokémon, with fields like `name`, `id`, `height`, and `weight`.
86 | It inherits from `APIModel`, which provides validation and serialization.
87 |
88 | #### 4. Creating the Endpoint:
89 |
90 | The `get_pokemon` function is a routed function decorated with `@router.get`, defining a GET request for
91 | `/pokemon/{name}`.
92 | This uses `Annotated` to ensure that `name` is a string and adheres to the validation rule (max length of 300).
93 |
94 | #### 5. Making the Request:
95 |
96 | By calling `get_pokemon(name="pikachu")`, Sensei automatically handles validation, makes the HTTP request,
97 | and maps the API response into the `Pokemon` model. The code omits the function body since Sensei handles calls through
98 | the function's signature.
99 |
100 | ## Comparison
101 |
102 | **Sensei** 👍: It provides a high level of abstraction. Sensei simplifies creating API wrappers, offering decorators for
103 | easy routing, data validation, and automatic mapping of API responses to models. This reduces boilerplate and improves
104 | code readability and maintainability.
105 |
106 | **Bare HTTP Client** 👎: A bare HTTP client like `requests` or `httpx` requires manually managing requests,
107 | handling response parsing, data validation, and error handling. You have to write repetitive code for each endpoint.
108 |
109 | ## OOP Style
110 |
111 | There is a wonderful OOP approach proposed by Sensei:
112 |
113 | ```python
114 | class User(APIModel):
115 | email: EmailStr
116 | id: PositiveInt
117 | first_name: str
118 | last_name: str
119 | avatar: AnyHttpUrl
120 |
121 | @classmethod
122 | @router.get('/users')
123 | def query(
124 | cls,
125 | page: Annotated[int, Query()] = 1,
126 | per_page: Annotated[int, Query(le=7)] = 3
127 | ) -> list[Self]:
128 | pass
129 |
130 | @classmethod
131 | @router.get('/users/{id_}')
132 | def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self:
133 | pass
134 |
135 | @router.post('/token')
136 | def login(self) -> str:
137 | pass
138 |
139 | @login.prepare
140 | def _login_in(self, args: Args) -> Args:
141 | args.json_['email'] = self.email
142 | return args
143 |
144 | @login.finalize
145 | def _login_out(self, response: Response) -> str:
146 | return response.json()['token']
147 |
148 | user = User.get(1)
149 | user.login() # User(id=1, email="john@example.com", first_name="John", ...)
150 | ```
151 |
152 | When Sensei doesn't know how to handle a request, you can do it yourself, using preprocessing as `prepare` and
153 | postprocessing as `finalize`
154 |
155 | ## Installing
156 | To install `sensei` from PyPi, you can use that:
157 |
158 | ```shell
159 | pip install sensei
160 | ```
161 |
162 | To install `sensei` from GitHub, use that:
163 |
164 | ```shell
165 | pip install git+https://github.com/CrocoFactory/sensei.git
166 | ```
--------------------------------------------------------------------------------
/docs/js/script.js:
--------------------------------------------------------------------------------
1 | function shuffle(array) {
2 | var currentIndex = array.length, temporaryValue, randomIndex;
3 | while (0 !== currentIndex) {
4 | randomIndex = Math.floor(Math.random() * currentIndex);
5 | currentIndex -= 1;
6 | temporaryValue = array[currentIndex];
7 | array[currentIndex] = array[randomIndex];
8 | array[randomIndex] = temporaryValue;
9 | }
10 | return array;
11 | }
12 |
13 | async function showRandomAnnouncement(groupId, timeInterval) {
14 | const announceFastAPI = document.getElementById(groupId);
15 | if (announceFastAPI) {
16 | let children = [].slice.call(announceFastAPI.children);
17 | children = shuffle(children)
18 | let index = 0
19 | const announceRandom = () => {
20 | children.forEach((el, i) => { el.style.display = "none" });
21 | children[index].style.display = "block"
22 | index = (index + 1) % children.length
23 | }
24 | announceRandom()
25 | setInterval(announceRandom, timeInterval
26 | )
27 | }
28 | }
29 |
30 | async function main() {
31 | showRandomAnnouncement('announce-left', 5000)
32 | showRandomAnnouncement('announce-right', 10000)
33 | }
34 | document$.subscribe(() => {
35 | main()
36 | })
37 |
--------------------------------------------------------------------------------
/docs/learn/index.md:
--------------------------------------------------------------------------------
1 | # Learn
2 |
3 | This guide is designed to help you understand the core concepts of Sensei, a powerful framework created to simplify
4 | the process of building API wrappers.
--------------------------------------------------------------------------------
/docs/learn/type_hints.md:
--------------------------------------------------------------------------------
1 | ## What are Python Type Hints?
2 |
3 | Python is a dynamically typed language, meaning you don't need to declare the types of variables. This flexibility makes
4 | it easy to write code quickly but can lead to bugs and misunderstandings as the codebase grows. **Type hints**,
5 | introduced in **PEP 484**, allow developers to explicitly specify the type of variables, function parameters, and
6 | return values without enforcing them at runtime. Type hints are optional annotations that help make the code more
7 | readable, easier to maintain, and reliable.
8 |
9 | Here’s an example of a function without type hints:
10 |
11 | ```python
12 | def add(x, y):
13 | return x + y
14 | ```
15 |
16 | At first glance, it’s unclear what types `x` and `y` are supposed to be. Are they integers, floats, strings? With type
17 | hints, we can be explicit about what the function expects:
18 |
19 | ```python
20 | def add(x: int, y: int) -> int:
21 | return x + y
22 | ```
23 |
24 | In this version, it's clear that the function takes two integers and returns an integer. Type hints provide clarity
25 | for both the author of the code and others reading it.
26 |
27 | ## Why Use Type Hints?
28 |
29 | * **Improved Readability**
30 | Type hints make code easier to read and understand. When someone looks at a function signature, they instantly know
31 | the types expected by the function and what it returns. This improves communication between developers and can
32 | reduce the time spent trying to understand someone else's code.
33 | ```python
34 | def greet(name: str) -> str:
35 | return f"Hello, {name}!"
36 | ```
37 | It's clear that `greet` expects a string as input and returns a string.
38 |
39 | * **Early Error Detection**
40 | Type hints don't enforce types at runtime, but when combined with static type checkers like **mypy**, they can catch
41 | potential type-related bugs before running the code. This is especially useful in large projects where incorrect data
42 | types can lead to runtime errors.
43 | ```python
44 | def average(numbers: list[float]) -> float:
45 | return sum(numbers) / len(numbers)
46 | ```
47 | If you mistakenly pass a list of strings to this function, tools like `mypy` will raise a warning before you even run
48 | the code, making debugging easier.
49 |
50 | * **Better IDE Support and Autocompletion**
51 | Modern IDEs such as **PyCharm**, **VSCode**, or **PyDev** leverage type hints to provide better autocompletion and
52 | static analysis. When you use type hints, your IDE can offer more accurate code suggestions, catch type mismatches
53 | early, and even provide documentation for functions based on the hints.
54 |
55 | * **Documentation Enhancement**
56 | Type hints can serve as an additional form of documentation. For example, instead of writing long comments about what
57 | each argument in a function should be, type hints can provide that information directly. This leads to self-documenting
58 | code that’s more maintainable.
59 |
60 | * **Simplified Refactoring**
61 | When refactoring code, it can be challenging to track what types each variable and function expects. With type hints,
62 | you have a clear map of types in your project, making it easier to change the structure of your code while ensuring
63 | type consistency.
64 |
65 | ## Common Type Hinting Syntax
66 |
67 | Here are some common type hint patterns you'll encounter:
68 |
69 | * **Basic Types**
70 | ```python
71 | def square(x: int) -> int:
72 | return x * x
73 | ```
74 |
75 | * **Union Types**
76 | A union type is used when a variable or function can accept more than one type. Before Python 3.10, you would use
77 | Union from the typing module to represent multiple types:
78 | ```python
79 | from typing import Union
80 |
81 | IntFloat = Union[int, float]
82 |
83 | def add(x: IntFloat, y: IntFloat) -> IntFloat:
84 | return x + y
85 | ```
86 | This indicates that x and y can either be int or float, and the return value can also be of either type.
87 | Starting in Python 3.10, you can use the | operator as a more concise alternative to Union. This makes the code cleaner and easier to read:
88 | ```python
89 | IntFloat = int | float
90 |
91 | def add(x: IntFloat, y: IntFloat) -> IntFloat:
92 | return x + y
93 | ```
94 |
95 | * **Using Collections**
96 | You can specify the types inside collections like lists, dictionaries, and tuples.
97 | ```python
98 | def process(numbers: list[int]) -> tuple[float, dict[str, int]]:
99 | avg = sum(numbers) / len(numbers)
100 | counts = {str(i): numbers.count(i) for i in numbers}
101 | return avg, counts
102 | ```
103 |
104 | * **Function Types**
105 | You can also hint that a variable is a function with a specific signature.
106 | ```python
107 | from typing import Callable
108 |
109 | def execute(func: Callable[[int, int], int], a: int, b: int) -> int:
110 | return func(a, b)
111 | ```
112 |
113 | * **Generics**
114 | For functions that work with multiple types, you can use type variables:
115 | ```python
116 | from typing import TypeVar
117 |
118 | T = TypeVar('T')
119 |
120 | def get_first(items: list[T]) -> T:
121 | return items[0]
122 | ```
123 |
124 | ## Advantages Over Dynamic Typing Alone
125 |
126 | 1. **Reduced Bugs**
127 | Type hints force you to think about your data flow and the types you’re working with. This leads to fewer bugs caused
128 | by unexpected types.
129 |
130 | 2. **Scalability**
131 | As codebases grow, maintaining code without knowing the types of variables can become a headache. Type hints help
132 | scale code more efficiently by providing a structured way to manage variable types and function expectations.
133 |
134 | 3. **Collaboration**
135 | In a team environment, type hints create a clear contract between functions and developers, reducing the need for
136 | back-and-forth discussions about data types and function behavior.
137 |
138 | 4. **Performance**
139 | While Python type hints don’t directly impact runtime performance (since they aren’t enforced at runtime), they can
140 | improve development speed by catching errors early, reducing debugging time, and enhancing the overall quality of
141 | the codebase.
142 |
143 | ## Conclusion
144 |
145 | Python type hints offer significant advantages in terms of readability, maintainability, and error detection, particularly as your project grows in complexity. Although optional, they serve as valuable documentation and can prevent many issues before they arise, especially when combined with static analysis tools like `mypy`. By introducing type hints, you can make your code more robust and reliable while maintaining Python’s flexible, dynamic nature.
146 |
147 | In summary, type hints provide:
148 |
149 | - Enhanced readability
150 | - Early error detection
151 | - Better autocompletion and IDE support
152 | - Clearer documentation
153 | - Simplified refactoring
--------------------------------------------------------------------------------
/docs/learn/user_guide/managing_requests.md:
--------------------------------------------------------------------------------
1 | The base of Sensei HTTP requests is the [`httpx`](https://www.python-httpx.org) library.
2 | When Sensei makes requests it uses`httpx.Client` (or `httpx.AsyncClient`) object.
3 | You, too, might use these objects, and you don't suspect it.
4 |
5 | /// tip
6 | If you don't know, why [`httpx`](https://www.python-httpx.org) is better than the `requests` library, you should read
7 | [HTTP Requests/Introducing `httpx`](/learn/http_requests.html#introducing-httpx){.internal-link}
8 | ///
9 |
10 | When you make a simple request, like the following:
11 |
12 | ```python
13 | import httpx
14 |
15 | response = httpx.get('https://example-api.com', params={'page': 1})
16 | print(response.json())
17 | ```
18 |
19 | `httpx` follows this algorithm:
20 |
21 | ```mermaid
22 | sequenceDiagram
23 | participant httpx.get(...)
24 | participant httpx.Client
25 | participant API
26 |
27 | httpx.get(...)->>httpx.Client: Open Client
28 | httpx.Client->>API: Make Request
29 | API-->>httpx.Client: Response
30 | httpx.Client-->>httpx.get(...): Wrap in httpx.Response
31 | httpx.get(...)->>httpx.Client: Close Client
32 | ```
33 |
34 | If you have used `requests`, it does the same. But `httpx.Client` corresponds to `requests.Session`.
35 |
36 | ??? note "Technical Details"
37 | Here is the implementation of the `get` function in `requests` and `httpx`.
38 |
39 | === "httpx"
40 | Here most of the arguments are omitted and replaced with `...`
41 | ```python
42 | def request(
43 | method: str,
44 | url: URL | str,
45 | *,
46 | params: QueryParamTypes | None = None,
47 | headers: HeaderTypes | None = None,
48 | ...
49 | ) -> Response:
50 | with Client(...) as client:
51 | return client.request(
52 | method=method,
53 | url=url,
54 | params=params,
55 | headers=headers,
56 | ...
57 | )
58 |
59 |
60 | def get(
61 | url: URL | str,
62 | *,
63 | params: QueryParamTypes | None = None,
64 | headers: HeaderTypes | None = None,
65 | ...
66 | ) -> Response:
67 | return request(
68 | "GET",
69 | url,
70 | params=params,
71 | headers=headers,
72 | ...
73 | )
74 | ```
75 |
76 | === "requests"
77 | ```python
78 | def request(method, url, **kwargs):
79 | with sessions.Session() as session:
80 | return session.request(method=method, url=url, **kwargs)
81 |
82 | def get(url, params=None, **kwargs):
83 | return request("get", url, params=params, **kwargs)
84 | ```
85 |
86 | If you make dozens of requests, like this code:
87 |
88 | ```python
89 | import httpx
90 |
91 | urls = [...]
92 | params_list = [{...}, ...]
93 |
94 | for url, params in zip(urls, params_list):
95 | response = httpx.get(url, params=params)
96 | print(response.json())
97 | ```
98 |
99 | `httpx` will open the client for each request. It slows your application. The better
100 | solution is to use a single client instance. You can close it whenever you want.
101 |
102 | ```python
103 | import httpx
104 |
105 | urls = [...]
106 | params_list = [{...}, ...]
107 |
108 | with httpx.Client() as client:
109 | for url, params in zip(urls, params_list):
110 | response = client.get(url, params=params)
111 | print(response.json())
112 | ```
113 |
114 | In the example above `httpx.Client` is closed after the last statement inside `with` block. You can close it
115 | manually, calling the `close` method.
116 |
117 | ```python
118 | import httpx
119 |
120 | urls = [...]
121 | params_list = [{...}, ...]
122 |
123 | client = httpx.Client()
124 |
125 | for url, params in zip(urls, params_list):
126 | response = client.get(url, params=params)
127 | print(response.json())
128 |
129 | client.close()
130 | ```
131 |
132 | Furthermore, a client can be used for advanced request configuration. You can read
133 | [the article](https://www.python-httpx.org/advanced/clients/){.external-link} from the [`httpx`](https://www.python-httpx.org)
134 | documentation, to learn more about `httpx.Client`.
135 |
136 | When you call routed functions, Sensei makes the same: Open client → Make request → Close client.
137 | How you can use your client so that you will close whenever you want? Let's introduce `Manager`
138 |
139 | ## Manager
140 |
141 | `Manager` serves as a bridge between the application and Sensei, to dynamically provide a client for routed function calls.
142 | It separately stores `httpx.AsyncClient` and `httpx.Client`.
143 | To use `Manager` you need to create it and pass it to the router.
144 |
145 | !!! example
146 | ```python
147 | from sensei import Manager, Router, Client
148 |
149 | manager = Manager()
150 | router = Router('httpx://example-api.com', manager=manager)
151 |
152 | @router.get('/users/{id_}')
153 | def get_user(id_: int) -> User:
154 | pass
155 |
156 | with Client(base_url=router.base_url) as client:
157 | manager.set(client)
158 | user = get_user(1)
159 | print(user)
160 | manager.pop()
161 | ```
162 |
163 | You can import `httpx.Client` from `sensei` or `httpx`. They are the same classes.
164 |
165 | === "sensei"
166 | ```python
167 | from sensei import Client
168 | ```
169 |
170 | === "httpx"
171 | ```python
172 | from httpx import Client
173 | ```
174 |
175 | Let's explore common actions.
176 |
177 | ### Setting
178 |
179 | You must know, that `Manager` can store only one instance of a client of each type (one `httpx.AsyncClient` and one `httpx.Client`)
180 |
181 | There are two ways to set client.
182 |
183 | === "At creation"
184 | ```python
185 | from sensei import Manager, Router, Client, AsyncClient
186 |
187 | base_url = 'httpx://example-api.com'
188 | client = Client(base_url=base_url)
189 | aclient = AsyncClient(base_url=base_url)
190 |
191 | manager = Manager(sync_client=client, async_client=aclient)
192 | router = Router(base_url, manager=manager)
193 | ```
194 |
195 | === "Delayed"
196 | ```python
197 | from sensei import Manager, Router, Client, AsyncClient
198 |
199 | manager = Manager()
200 | router = Router('httpx://example-api.com', manager=manager)
201 |
202 | client = Client(base_url=router.base_url)
203 | aclient = AsyncClient(base_url=router.base_url)
204 |
205 | manager.set(client)
206 | manager.set(aclient)
207 | ```
208 |
209 | /// warning
210 | Client's base URL and router's base URL must be equal
211 |
212 | ??? failure "ValueError"
213 | ```python
214 | from sensei import Client, Manager, Router
215 |
216 | client = Client(base_url='https://order-api.com')
217 | manager = Manager(client)
218 |
219 | router = Router(host='https://user-api.com', manager=manager)
220 |
221 | @router.get('/users/{id_}')
222 | def get_user(id_: int) -> User:
223 | pass
224 |
225 | print(get_user(1))
226 | ```
227 | ValueError: Client base url must be equal to Router base url
228 | ///
229 |
230 | ### Retrieving
231 |
232 | There are two ways to retrieve a client.
233 |
234 | === "Get"
235 | This returns a client without removing it from `Manager`. If `required=True` (default is `True`) in the `Manager` constructor, the error
236 | will be thrown if the client is not set.
237 |
238 | ```python
239 | manager = Manager()
240 |
241 | manager = Manager(sync_client=client, async_client=aclient)
242 | client = manager.get(is_async=False)
243 | aclient = manager.get(is_async=True)
244 | print(client, aclient)
245 | ```
246 |
247 | === "Pop"
248 | This return client and removes it from `Manager`
249 |
250 | ```python
251 | manager = Manager()
252 |
253 | manager = Manager(sync_client=client, async_client=aclient)
254 | client = manager.pop(is_async=False)
255 | aclient = manager.pop(is_async=True)
256 | print(client, aclient)
257 | ```
258 |
259 |
260 | ### Is empty
261 |
262 | You can check whether a client is empty:
263 |
264 | ```python
265 | manager = Manager()
266 |
267 | manager = Manager(sync_client=client)
268 | manager.pop()
269 | print(manager.empty()) # Output: True
270 | ```
271 |
272 | ## Rate Limiting
273 |
274 | Many APIs enforce [rate limits](https://en.wikipedia.org/wiki/Rate_limiting){.external-link} to control how frequently clients can make
275 | requests. You can add automatic waiting between requests, based on the period and the maximum number of requests allowed per this
276 | period. This is achieved through a `RateLimit` instance
277 |
278 | This code is equivalent to **5 requests per second**.
279 |
280 | ```python
281 | from sensei import RateLimit, Router
282 |
283 | calls, period = 5, 1
284 | rate_limit = RateLimit(calls, period)
285 | router = Router('https://example-api.com', rate_limit=rate_limit)
286 | ```
287 |
288 | The `RateLimit` class implements a [token bucket](https://en.wikipedia.org/wiki/Token_bucket){.external-link} rate-limiting
289 | system. Tokens are added at a fixed rate, and each request uses one token.
290 | If tokens run out, Sensei waits until new tokens are available, preventing rate-limit violations.
291 | If a token was consumed, the new one will appear in `period / calls` seconds. That is **5 requests per second** is equivalent
292 | **1 token per 1/5 seconds**.
293 |
294 | In the following example, the code will be paused for 1 second after each request:
295 |
296 | ```python
297 | from sensei import RateLimit, Router
298 |
299 | calls, period = 1, 1
300 | rate_limit = RateLimit(calls, period)
301 | router = Router('https://example-api.com', rate_limit=rate_limit)
302 |
303 | @router.get('/users/{id_}')
304 | def get_user(id_: int) -> User:
305 | pass
306 |
307 | for i in range(5):
308 | get_user(i) # (1)!
309 | ```
310 |
311 | 1. Here code will be paused for 1 second after each iteration
312 |
313 | If you want to use another rate-limiting system, you can implement the `IRateLimit` interface and use it like before.
314 | Namely, you need to implement the following two methods.
315 |
316 | ```python
317 | from sensei.types import IRateLimit
318 |
319 | class CustomLimit(IRateLimit):
320 | async def async_wait_for_slot(self) -> None:
321 | ...
322 |
323 | def wait_for_slot(self) -> None:
324 | ...
325 | ```
326 |
327 | ## Setting Port
328 |
329 | If you connect to some local API, that allows configuring port, you can make a dynamic URL with `{port}` placeholder.
330 | In addition, you can change `port` attribute in `Router`.
331 | Here is an example:
332 |
333 | ```python
334 | from sensei import Router
335 |
336 | router = Router(host='https://local-api.com:{port}/api/v2', port=3000)
337 | print(router.base_url) # Output: https://local-api.com:3000/api/v2
338 |
339 | router.port = 4000
340 | print(router.base_url) # Output: https://local-api.com:4000/api/v2
341 | ```
342 |
343 | If `{port}` placeholder is not provided, the port will be appended to the end of the URL
344 |
345 | ```python
346 | from sensei import Router
347 |
348 | router = Router(host='https://local-api.com', port=3000)
349 | print(router.base_url) # Output: https://local-api.com:3000
350 | ```
351 |
352 | ## Recap
353 |
354 | Here’s a recap of working with `httpx` clients for efficient HTTP request management:
355 |
356 | ### Sensei’s `Manager` and Routing System
357 | - The `Manager` provides a single client for multiple requests, managing both `httpx.Client` and `httpx.AsyncClient` instances.
358 | - `Manager` can store one synchronous and one asynchronous client, ensuring only one of each type is available at any time.
359 | - To link the `Manager` to requests, create a `Router` instance that defines a base URL for all endpoints.
360 |
361 | ### Managing Clients with `Manager`
362 | - **Setting Clients**: Assign clients to the `Manager` when created or later with `.set()`.
363 | - **Retrieving Clients**: Use `.get()` to access clients without removal or `.pop()` to retrieve and remove the client from `Manager`.
364 | - **Checking Clients**: Use `.empty()` to check if there are no clients in `Manager`.
365 |
366 | ### Rate Limiting with `RateLimit`
367 | - APIs often have rate limits, and `Sensei` includes a `RateLimit` class to enforce these.
368 | - Set calls per second or minute to prevent exceeding rate limits.
369 | - Implement custom rate-limiting by subclassing `IRateLimit`.
370 |
371 | ### Configuring Ports Dynamically
372 | - Specify a `{port}` placeholder in the base URL to dynamically adjust the API port as needed with `Router`.
373 |
374 | By efficiently managing HTTP clients and rate limits, `Sensei` optimizes API interactions, reducing latency and improving performance.
--------------------------------------------------------------------------------
/docs/overrides/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block extrahead %}
4 |
5 | {% endblock %}
6 |
7 | {% block announce %}
8 |
9 |
10 |
17 |
18 |
19 |
20 |
21 |
22 |
30 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------
/docs/people.md:
--------------------------------------------------------------------------------
1 | Hello! 👋 I'm the creator of **Sensei**.
2 |
3 |
13 |
14 | The key ideas of the framework were borrowed from backend framework [FastAPI](https://fastapi.tiangolo.com){.external-link}.
15 | Thanks its author [tiangolo](https://github.com/tiangolo).
16 | It can be said that Sensei performs the inverse task with respect to FastAPI.
17 |
18 | If you want to join to the contributors, you can contact me: [axbelenkov@gmail.com](mailto:axbelenkov@gmail.com).
19 |
20 | The framework was created with the support of [Croco Factory](https://crocofactory.dev).
21 |
--------------------------------------------------------------------------------
/docs/reference/api_model.md:
--------------------------------------------------------------------------------
1 | # APIModel
2 |
3 | :::sensei.APIModel
4 | options:
5 | members: true
6 | inherited_members: true
--------------------------------------------------------------------------------
/docs/reference/case_converters.md:
--------------------------------------------------------------------------------
1 | # Case Converters
2 | :::sensei.cases
--------------------------------------------------------------------------------
/docs/reference/index.md:
--------------------------------------------------------------------------------
1 | # Reference
2 |
3 | Here’s the reference for the API, including all components of Sensei such as classes, functions, parameters and attributes.
4 | If you're looking to learn Sensei, you would benefit more from exploring the [User Guide](/learn/learn.html).
--------------------------------------------------------------------------------
/docs/reference/manager.md:
--------------------------------------------------------------------------------
1 | # Manager
2 |
3 | :::sensei.Manager
--------------------------------------------------------------------------------
/docs/reference/parameters.md:
--------------------------------------------------------------------------------
1 | # Parameters
2 |
3 | Request parameters are functions returning an object used to provide the parameter metadata before request, such as
4 | param type, validation, alias, etc.
5 |
6 | These functions include:
7 |
8 | - Query
9 | - Body
10 | - Path
11 | - Form
12 | - File
13 | - Header
14 | - Cookie
15 |
16 | Import them directly from `sensei`
17 |
18 | ```python
19 | from sensei import Query, Body, Path, Form, File, Header, Cookie
20 | ```
21 |
22 | There are two ways how to use them:
23 |
24 | === "Annotated"
25 | ```python
26 | @router.get('/users/{id_}')
27 | def get_user(id_: Annotated[int, Path()]) -> User:
28 | pass
29 | ```
30 |
31 | === "Direct"
32 | ```python
33 | @router.get('/users/{id_}')
34 | def get_user(id_: int = Path()) -> User:
35 | pass
36 | ```
37 |
38 |
39 |
40 | :::sensei.Query
41 | :::sensei.Body
42 | :::sensei.Path
43 | :::sensei.Form
44 | :::sensei.File
45 | :::sensei.Header
46 | :::sensei.Cookie
--------------------------------------------------------------------------------
/docs/reference/rate_limit.md:
--------------------------------------------------------------------------------
1 | # RateLimit
2 | :::sensei.RateLimit
3 | :::sensei.types.IRateLimit
--------------------------------------------------------------------------------
/docs/reference/routed_function.md:
--------------------------------------------------------------------------------
1 | # Routed Function
2 |
3 | ::: sensei._internal._core._types.RoutedFunction
--------------------------------------------------------------------------------
/docs/reference/router.md:
--------------------------------------------------------------------------------
1 | ::: sensei.Router
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Sensei
2 | site_description: The Python framework that provides a quick way to build robust HTTP requests and best API clients. Use type hints, to build requests, with little or no implementation.
3 | site_url: https://sensei.crocofactory.dev
4 | use_directory_urls: false
5 |
6 |
7 | theme:
8 | name: material
9 | custom_dir: docs/overrides
10 | palette:
11 | - media: "(prefers-color-scheme)"
12 | toggle:
13 | icon: material/lightbulb-auto
14 | name: Switch to light mode
15 | - media: '(prefers-color-scheme: light)'
16 | scheme: default
17 | primary: red
18 | accent: pink
19 | toggle:
20 | icon: material/lightbulb
21 | name: Switch to dark mode
22 | - media: '(prefers-color-scheme: dark)'
23 | scheme: slate
24 | primary: red
25 | accent: pink
26 | toggle:
27 | icon: material/lightbulb-outline
28 | name: Switch to system preference
29 | features:
30 | - content.code.annotate
31 | - content.code.copy
32 | - content.footnote.tooltips
33 | - content.tabs.link
34 | - content.tooltips
35 | - navigation.footer
36 | - navigation.indexes
37 | - navigation.instant
38 | - navigation.instant.prefetch
39 | - navigation.instant.progress
40 | - navigation.path
41 | - navigation.tabs
42 | - navigation.tabs.sticky
43 | - navigation.top
44 | - navigation.tracking
45 | - search.highlight
46 | - search.share
47 | - search.suggest
48 | - toc.follow
49 |
50 | logo: img/icon-white.svg
51 | favicon: img/favicon.png
52 | language: en
53 |
54 | extra:
55 | analytics:
56 | provider: google
57 | property: G-BBNVDQKG5R
58 | feedback:
59 | title: Was this page helpful?
60 | ratings:
61 | - icon: material/emoticon-happy-outline
62 | name: This page was helpful
63 | data: 1
64 | note: >-
65 | Thanks for your feedback!
66 | - icon: material/emoticon-sad-outline
67 | name: This page could be improved
68 | data: 0
69 | note: >-
70 | Thanks for your feedback!
71 | social:
72 | - icon: fontawesome/brands/github
73 | link: https://github.com/CrocoFactory/sensei
74 | - icon: fontawesome/brands/x-twitter
75 | link: https://x.com/CrocoFactory
76 | - icon: fontawesome/solid/globe
77 | link: https://crocofactory.dev
78 |
79 | repo_name: CrocoFactory/sensei
80 | repo_url: https://github.com/CrocoFactory/sensei
81 |
82 | nav:
83 | - Sensei: index.md
84 | - Learn:
85 | - learn/index.md
86 | - Type Hints: learn/type_hints.md
87 | - Concurrency/Parallelism: learn/concurrency_parallelism.md
88 | - HTTP Requests: learn/http_requests.md
89 | - User Guide:
90 | - Motivation: learn/user_guide/motivation.md
91 | - First Steps: learn/user_guide/first_steps.md
92 | - Params/Response: learn/user_guide/params_response.md
93 | - Making Aliases: learn/user_guide/making_aliases.md
94 | - Preparers/Finalizers: learn/user_guide/preparers_finalizers.md
95 | - Routed Model: learn/user_guide/routed_model.md
96 | - Managing Requests: learn/user_guide/managing_requests.md
97 | - Reference:
98 | - reference/index.md
99 | - reference/api_model.md
100 | - reference/case_converters.md
101 | - reference/manager.md
102 | - reference/parameters.md
103 | - reference/rate_limit.md
104 | - reference/routed_function.md
105 | - reference/router.md
106 | - Sensei People: people.md
107 |
108 | plugins:
109 | social:
110 | mkdocstrings:
111 | handlers:
112 | python:
113 | options:
114 | docstring_style: google
115 | show_root_heading: true
116 | show_if_no_docstring: true
117 | preload_modules:
118 | - httpx
119 | inherited_members: true
120 | members_order: source
121 | separate_signature: true
122 | filters:
123 | - '!^_'
124 | docstring_section_style: spacy
125 | merge_init_into_class: true
126 | signature_crossrefs: true
127 | show_symbol_type_heading: true
128 | show_symbol_type_toc: true
129 |
130 | markdown_extensions:
131 | admonition:
132 | abbr:
133 | attr_list:
134 | footnotes:
135 | md_in_html:
136 | tables:
137 | toc:
138 | permalink: true
139 |
140 | pymdownx.betterem:
141 | smart_enable: all
142 | pymdownx.caret:
143 | pymdownx.highlight:
144 | line_spans: __span
145 | pymdownx.inlinehilite:
146 | pymdownx.keys:
147 | pymdownx.mark:
148 |
149 | pymdownx.superfences:
150 | custom_fences:
151 | - name: mermaid
152 | class: mermaid
153 | format: !!python/name:pymdownx.superfences.fence_code_format
154 | pymdownx.tilde:
155 |
156 | pymdownx.tabbed:
157 | alternate_style: true
158 |
159 | pymdownx.details:
160 | pymdownx.blocks.admonition:
161 | types:
162 | - note
163 | - attention
164 | - caution
165 | - danger
166 | - error
167 | - tip
168 | - hint
169 | - warning
170 | - info
171 | - check
172 | pymdownx.blocks.details:
173 | pymdownx.blocks.tab:
174 | alternate_style: True
175 |
176 | mdx_include:
177 | markdown_include_variants:
178 |
179 | extra_css:
180 | - css/style.css
181 |
182 | extra_javascript:
183 | - js/script.js
184 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = 'sensei'
3 | version = '0.1.1.post3'
4 | description = 'The Python framework that provides a quick way to build robust HTTP requests and best API clients. Use type hints, to build requests, with little or no implementation.'
5 | authors = ['Alexey ']
6 | license = 'MIT'
7 | readme = 'README.md'
8 | keywords = ['api', 'client', 'api-client', 'api-wrapper', 'python-client', 'http-client', 'rest-api', 'pydantic', 'httpx', 'http-requests', 'requests']
9 | classifiers = [
10 | 'Development Status :: 4 - Beta',
11 | 'Intended Audience :: Developers',
12 | 'Topic :: Software Development :: Libraries :: Python Modules',
13 | 'Topic :: Software Development :: Libraries :: Application Frameworks',
14 | 'Framework :: Pydantic',
15 | 'Programming Language :: Python :: 3 :: Only',
16 | 'Programming Language :: Python :: 3.9',
17 | 'Programming Language :: Python :: 3.10',
18 | 'Programming Language :: Python :: 3.11',
19 | 'Programming Language :: Python :: 3.12',
20 | 'Programming Language :: Python :: 3.13',
21 | 'License :: OSI Approved :: MIT License',
22 | 'Operating System :: OS Independent',
23 | 'Typing :: Typed'
24 | ]
25 | packages = [{ include = 'sensei' }]
26 |
27 | [project.urls]
28 | homepage = "https://sensei.crocofactory.dev"
29 | documentation = "https://sensei.crocofactory.dev"
30 | source = "https://github.com/CrocoFactory/sensei"
31 | download = "https://pypi.org/project/sensei/#files"
32 | tracker = "https://github.com/CrocoFactory/sensei/issues"
33 |
34 |
35 | [tool.poetry.dependencies]
36 | python = '^3.9'
37 | typing-extensions = "^4.12.2"
38 | httpx = "^0.27.2"
39 | pydantic = "^2.9.2"
40 | httpcore = "^1.0.6"
41 |
42 | [tool.poetry.group.docs]
43 | optional = true
44 |
45 | [tool.poetry.group.docs.dependencies]
46 | mkdocs = "^1.6.1"
47 | mkdocs-material = "^9.5.39"
48 | markdown-include-variants = "^0.0.2"
49 | mkdocstrings-python = "^1.12.2"
50 | mdx-include = "^1.4.2"
51 | pillow = "10.2"
52 | cairosvg = "2.6"
53 |
54 | [tool.poetry.group.dev.dependencies]
55 | build = "^1.2.1"
56 | twine = "^5.1.1"
57 | flake8 = "^7.1.1"
58 | respx = "^0.21.1"
59 | pyjwt = "^2.9.0"
60 | email-validator = "^2.2.0"
61 | coverage = "^7.6.1"
62 | pytest-asyncio = "^0.24.0"
63 | pytest = "^8.3.3"
64 |
65 | [build-system]
66 | requires = ['poetry-core']
67 | build-backend = 'poetry.core.masonry.api'
68 |
--------------------------------------------------------------------------------
/sensei/__init__.py:
--------------------------------------------------------------------------------
1 | from ._internal import Path, Query, Cookie, Header, Body, File, Form
2 | from ._internal import Router, Args, APIModel
3 | from ._utils import format_str, placeholders
4 | from .cases import *
5 | from .client import RateLimit, Manager
6 | from .types import Json
7 | from httpx import Client, AsyncClient
8 |
--------------------------------------------------------------------------------
/sensei/_internal/__init__.py:
--------------------------------------------------------------------------------
1 | from ._core import Args, Router, APIModel, Path, Query, Cookie, Header, Body, File, Form, Undefined, RoutedFunction
2 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/__init__.py:
--------------------------------------------------------------------------------
1 | from ._compat import Undefined
2 | from ._types import RoutedFunction
3 | from .api_model import APIModel
4 | from .args import Args
5 | from .params_functions import Path, Query, Cookie, Header, Body, File, Form
6 | from .router import Router
7 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/_callable_handler.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import inspect
4 | from inspect import isclass
5 | from typing import Callable, TypeVar, Generic, Any, get_origin, get_args
6 |
7 | from httpx import Client, AsyncClient
8 | from typing_extensions import Self
9 |
10 | from sensei._utils import normalize_url
11 | from sensei.types import IResponse, BaseClient
12 | from ._endpoint import Endpoint, ResponseModel, RESPONSE_TYPES
13 | from ._requester import Requester
14 | from ._types import IRouter, Hooks
15 | from .args import Args
16 | from ..tools import HTTPMethod, args_to_kwargs, MethodType
17 | from ..tools.utils import is_coroutine_function, identical
18 |
19 | _Client = TypeVar('_Client', bound=BaseClient)
20 | _RequestArgs = tuple[tuple[Any, ...], dict[str, Any]]
21 |
22 |
23 | class _CallableHandler(Generic[_Client]):
24 | __slots__ = (
25 | '_func',
26 | '_method',
27 | '_path',
28 | '_request_args',
29 | '_method_type',
30 | '_temp_client',
31 | '_response_finalizer',
32 | '_preparer',
33 | '_case_converters',
34 | '_json_finalizer',
35 | '_router'
36 | )
37 |
38 | def __init__(
39 | self,
40 | *,
41 | path: str,
42 | method: HTTPMethod,
43 | router: IRouter,
44 | func: Callable,
45 | request_args: _RequestArgs,
46 | method_type: MethodType,
47 | hooks: Hooks,
48 | skip_preparer: bool = False,
49 | skip_finalizer: bool = False,
50 | ):
51 | self._func = func
52 | self._router = router
53 | self._method = method
54 | self._path = path
55 |
56 | self._request_args = request_args
57 | self._method_type = method_type
58 | self._temp_client: _Client | None = None
59 | self._case_converters = hooks.case_converters
60 |
61 | if skip_preparer:
62 | hooks.prepare_args = identical
63 |
64 | if skip_finalizer:
65 | hooks.finalize_json = identical
66 |
67 | post_preparer = hooks.post_preparer
68 | pre_preparer = hooks.prepare_args
69 |
70 | json_finalizer = hooks.finalize_json
71 | response_finalizer = hooks.response_finalizer
72 |
73 | if is_coroutine_function(post_preparer):
74 | async def preparer(value: Args) -> Args:
75 | return await post_preparer(pre_preparer(value))
76 | else:
77 | def preparer(value: Args) -> Args:
78 | return post_preparer(pre_preparer(value))
79 |
80 | if response_finalizer:
81 | if is_coroutine_function(response_finalizer):
82 | async def finalizer(value: IResponse) -> ResponseModel:
83 | return await response_finalizer(value)
84 | else:
85 | def finalizer(value: IResponse) -> ResponseModel:
86 | return response_finalizer(value)
87 | else:
88 | finalizer = response_finalizer
89 |
90 | self._preparer = preparer
91 | self._response_finalizer = finalizer
92 |
93 | self._json_finalizer = json_finalizer
94 |
95 | def __make_endpoint(self) -> Endpoint:
96 | params = {}
97 | func = self._func
98 | method_type = self._method_type
99 | sig = inspect.signature(func)
100 |
101 | args = sig.parameters.values()
102 |
103 | skipped = False
104 |
105 | for param in args:
106 | if MethodType.self_method(method_type) and not skipped:
107 | skipped = True
108 | continue
109 |
110 | if param.default and param.default is not inspect.Parameter.empty:
111 | params[param.name] = param.annotation, param.default
112 | else:
113 | params[param.name] = param.annotation
114 |
115 | return_type = sig.return_annotation if sig.return_annotation is not inspect.Signature.empty else None
116 |
117 | old_single_self = False
118 | old_list_self = False
119 | func_self = getattr(func, '__self__', None)
120 | is_list = get_origin(return_type) is list
121 |
122 | single_list = list_elem = False
123 | if is_list:
124 | single_list = len((args := get_args(return_type))) == 1
125 | list_elem = args[0]
126 |
127 | if func_self is not None:
128 | class_name = func_self.__name__ if isclass(func_self) else func_self.__class__.__name__
129 |
130 | if is_list and single_list and isinstance(list_elem, str):
131 | return_type = list_elem
132 |
133 | if isinstance(return_type, str):
134 | if not is_list:
135 | old_single_self = class_name == return_type
136 | else:
137 | old_list_self = class_name == return_type
138 |
139 | if not Endpoint.is_response_type(return_type):
140 | if return_type is Self or old_single_self:
141 | if MethodType.self_method(method_type):
142 | return_type = func.__self__ # type: ignore
143 | else:
144 | raise ValueError('Response "Self" is only for instance and class methods')
145 | elif (is_list and single_list and list_elem is Self) or old_list_self:
146 | if method_type is MethodType.CLASS:
147 | return_type = list[func.__self__] # type: ignore
148 | else:
149 | raise ValueError('Response "list[Self]" is only for class methods')
150 | elif self._response_finalizer is None:
151 | raise ValueError(f'Response finalizer must be set, if response is not from: {RESPONSE_TYPES}')
152 |
153 | endpoint = Endpoint(
154 | self._path,
155 | self._method,
156 | params=params,
157 | response=return_type,
158 | case_converters=self._case_converters,
159 | )
160 | return endpoint
161 |
162 | def _make_requester(self, client: BaseClient) -> Requester:
163 | endpoint = self.__make_endpoint()
164 | requester = Requester(
165 | client,
166 | endpoint,
167 | rate_limit=self._router.rate_limit,
168 | response_finalizer=self._response_finalizer,
169 | json_finalizer=self._json_finalizer,
170 | preparer=self._preparer,
171 | case_converters=self._case_converters,
172 | )
173 | return requester
174 |
175 | def _get_request_args(self, client: BaseClient) -> tuple[Requester, dict]:
176 | if normalize_url(str(client.base_url)) != normalize_url(str(self._router.base_url)):
177 | raise ValueError('Client base url must be equal to Router base url')
178 |
179 | requester = self._make_requester(client)
180 | kwargs = args_to_kwargs(self._func, *self._request_args[0], **self._request_args[1])
181 |
182 | method_type = self._method_type
183 |
184 | if MethodType.self_method(method_type):
185 | kwargs.popitem(False)
186 |
187 | return requester, kwargs
188 |
189 |
190 | class AsyncCallableHandler(_CallableHandler[AsyncClient], Generic[ResponseModel]):
191 | async def __aenter__(self) -> ResponseModel:
192 | router = self._router
193 | manager = router.manager
194 |
195 | client = None
196 | if manager is not None:
197 | client = manager.get(is_async=True)
198 |
199 | if manager is None or client is None:
200 | client = AsyncClient(base_url=self._router.base_url)
201 | await client.__aenter__()
202 | self._temp_client = client
203 | else:
204 | client = manager.get(True)
205 |
206 | requester, kwargs = self._get_request_args(client)
207 |
208 | return await requester.request(**kwargs)
209 |
210 | async def __aexit__(self, exc_type, exc_val, exc_tb):
211 | if client := self._temp_client:
212 | await client.__aexit__(exc_type, exc_val, exc_tb)
213 | self._temp_client = None
214 |
215 |
216 | class CallableHandler(_CallableHandler[Client], Generic[ResponseModel]):
217 | def __enter__(self) -> ResponseModel:
218 | router = self._router
219 | manager = router.manager
220 |
221 | client = None
222 | if manager is not None:
223 | client = manager.get()
224 |
225 | if manager is None or client is None:
226 | client = Client(base_url=self._router.base_url)
227 | client.__enter__()
228 | self._temp_client = client
229 | else:
230 | client = manager.get()
231 |
232 | requester, kwargs = self._get_request_args(client)
233 |
234 | return requester.request(**kwargs)
235 |
236 | def __exit__(self, exc_type, exc_val, exc_tb):
237 | if client := self._temp_client:
238 | client.__exit__(exc_type, exc_val, exc_tb)
239 | self._temp_client = None
240 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/_compat.py:
--------------------------------------------------------------------------------
1 | from pydantic.version import VERSION as P_VERSION
2 |
3 | PYDANTIC_VERSION = P_VERSION
4 | PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.")
5 |
6 | if PYDANTIC_V2:
7 | from pydantic_core import PydanticUndefined
8 | Undefined = PydanticUndefined
9 | else:
10 | pass
11 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/_endpoint.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from functools import partial
4 | from typing import Any
5 | from typing import TypedDict
6 | from typing import get_args, Callable, Generic, get_origin, TypeVar
7 |
8 | from pydantic import BaseModel, ConfigDict
9 | from pydantic.fields import FieldInfo
10 |
11 | from sensei._utils import format_str
12 | from sensei.types import IResponse
13 | from ._params import Query, Body, Form, File, Cookie, Header, Param
14 | from .args import Args
15 | from ..tools import ChainedMap, accept_body, HTTPMethod
16 | from ..tools import split_params, make_model, validate_method
17 |
18 | _CaseConverter = Callable[[str], str]
19 | _CaseConverters = dict[str, _CaseConverter]
20 |
21 |
22 | ResponseModel = TypeVar(
23 | 'ResponseModel',
24 | type[BaseModel],
25 | str,
26 | dict,
27 | bytes,
28 | list[dict],
29 | BaseModel,
30 | list[BaseModel],
31 | )
32 |
33 | _ConditionChecker = Callable[[type[ResponseModel]], bool]
34 | _ResponseHandler = Callable[[type[ResponseModel], IResponse], ResponseModel]
35 | _PartialHandler = Callable[[IResponse], ResponseModel]
36 |
37 | RESPONSE_TYPES = ResponseModel.__constraints__
38 |
39 |
40 | class _RequestParams(TypedDict, total=False):
41 | params: dict[str, Any]
42 | json: dict[str, Any]
43 | headers: dict[str, Any]
44 | cookies: dict[str, Any]
45 | data: dict[str, Any]
46 | files: dict[str, Any]
47 |
48 |
49 | class ParamsParser:
50 | def __init__(self, method: HTTPMethod, case_converters: _CaseConverters):
51 | self._method = method
52 | self._case_converters = case_converters
53 |
54 | def __call__(
55 | self,
56 | fields: dict[str, FieldInfo],
57 | params: dict[str, Any]
58 | ) -> _RequestParams:
59 | new_params = {
60 | 'params': {},
61 | 'json': {},
62 | 'headers': {},
63 | 'cookies': {},
64 | 'data': {},
65 | 'files': {}
66 | }
67 |
68 | annotation_to_label = {
69 | Query: 'params',
70 | Body: 'json',
71 | Form: 'data',
72 | File: 'files',
73 | Cookie: 'cookies',
74 | Header: 'headers',
75 | }
76 |
77 | label_to_converter = {
78 | 'params': 'query_case',
79 | 'json': 'body_case',
80 | 'data': 'body_case',
81 | 'files': 'body_case',
82 | 'cookies': 'cookie_case',
83 | 'headers': 'header_case',
84 | }
85 |
86 | type_to_converter = ChainedMap[type[Param], _CaseConverter](
87 | annotation_to_label,
88 | label_to_converter,
89 | self._case_converters
90 | )
91 | type_to_params = ChainedMap[type[Param], dict[str, Any]](annotation_to_label, new_params)
92 |
93 | has_body = False
94 | has_file_body = False
95 | has_not_embed = False
96 | has_embed = False
97 |
98 | media_type = None
99 |
100 | for key, value in fields.items():
101 | types = Body, Cookie, Header, Query, File, Form
102 | if isinstance(value, types):
103 | condition = isinstance(value, Body)
104 | new_params_key = 'data'
105 | if condition:
106 | multipart = 'multipart/form-data'
107 | form_content = ('multipart/form-data', 'application/x-www-form-urlencoded')
108 |
109 | not_equal = media_type != value.media_type
110 | not_multipart = multipart not in (media_type, value.media_type)
111 | not_form = value.media_type not in form_content or media_type not in form_content
112 |
113 | if media_type is not None and (not_equal and (not_multipart or not_form)):
114 | raise ValueError(f'Body parameters cannot have different media types. You try to use '
115 | f'{value.media_type} and {media_type}')
116 |
117 | media_type = value.media_type
118 |
119 | media_type_map = {
120 | 'application/json': 'json',
121 | 'application/vnd.api+json': 'json',
122 | 'application/ld+json': 'json',
123 | 'multipart/form-data': ('files', 'data'),
124 | 'application/x-www-form-urlencoded': 'data'
125 | }
126 |
127 | if result := media_type_map.get(value.media_type):
128 | new_params_key = result
129 |
130 | if isinstance(result, tuple):
131 | if isinstance(value, File):
132 | new_params_key = result[0]
133 | else:
134 | new_params_key = result[1]
135 | else:
136 | new_params_key = 'data'
137 | new_params['headers']['Content-Type'] = value.media_type
138 |
139 | param_type = type(value)
140 |
141 | converter = type_to_converter[param_type]
142 | converted = converter(key)
143 |
144 | alias = value.alias
145 | result_key = alias if alias else converted
146 |
147 | if condition:
148 | if not value.embed:
149 | if has_embed:
150 | raise ValueError('Embed and non-embed variants of body are provided.')
151 |
152 | if value.media_type == 'multipart/form-data':
153 | if isinstance(value, File):
154 | if has_file_body:
155 | raise ValueError('Multiple variants of non-embed file body are provided.')
156 | has_file_body = True
157 | else:
158 | has_body = True
159 |
160 | if has_body:
161 | raise ValueError('Multiple variants of non-embed body are provided.')
162 | else:
163 | has_body = True
164 |
165 | has_not_embed = True
166 | new_params[new_params_key] = params[key]
167 | else:
168 | if has_not_embed:
169 | raise ValueError('Embed and non-embed variants of body are provided.')
170 | has_embed = True
171 | new_params[new_params_key][result_key] = params[key]
172 | else:
173 | type_to_params[param_type][result_key] = params[key]
174 | else:
175 | param_type = Body if accept_body(self._method) else Query
176 |
177 | converted = type_to_converter[param_type](key)
178 | type_to_params[param_type][converted] = params[key]
179 |
180 | new_params = {k: v for k, v in new_params.items() if v}
181 | return new_params
182 |
183 |
184 | class Endpoint(Generic[ResponseModel]):
185 | __slots__ = (
186 | "_path",
187 | "_method",
188 | "_parser",
189 | "_params_model",
190 | "_response_model",
191 | )
192 |
193 | _response_handle_map: dict[_ConditionChecker, _ResponseHandler] = {
194 | lambda model: isinstance(model, type(BaseModel)): lambda model, response: model(**response.json()),
195 | lambda model: model is str: lambda model, response: response.text,
196 | lambda model: dict in (model, get_origin(model)): lambda model, response: (
197 | response.json() if response.request.method not in ('HEAD', 'OPTIONS') else dict(
198 | list(response.headers.items()))
199 | ),
200 | lambda model: model is bytes: lambda model, response: response.content,
201 | lambda model: isinstance(model, BaseModel): lambda model, response: model,
202 | lambda model: model is None: lambda model, response: None,
203 | lambda model:
204 | (get_origin(model) is list and len(get_args(model)) == 1 and isinstance(get_args(model)[0], type(BaseModel))):
205 | lambda model, response: [get_args(model)[0](**value) for value in response.json()],
206 | lambda model: (get_origin(model) is list and len(get_args(model)) == 1
207 | and ((arg := get_args(model)[0]) is dict or get_origin(arg) is dict)):
208 | lambda model, response: response.json(),
209 | }
210 |
211 | def __init__(
212 | self,
213 | path: str,
214 | method: HTTPMethod,
215 | /, *,
216 | params: dict[str, Any] | None = None,
217 | response: type[ResponseModel] | None = None,
218 | case_converters: _CaseConverters
219 | ):
220 | validate_method(method)
221 |
222 | self._path = path
223 | self._method = method
224 |
225 | params_model = self._make_model('Params', params)
226 |
227 | self._params_model = params_model
228 | self._response_model = response
229 | self._parser = ParamsParser(method, case_converters)
230 |
231 | @property
232 | def path(self) -> str:
233 | return self._path
234 |
235 | @property
236 | def method(self) -> HTTPMethod:
237 | return self._method
238 |
239 | @property
240 | def params_model(self) -> type[BaseModel] | None:
241 | return self._params_model
242 |
243 | @property
244 | def response_model(self) -> type[ResponseModel] | None:
245 | return self._response_model
246 |
247 | @staticmethod
248 | def _make_model(
249 | model_name: str,
250 | model_args: dict[str, Any] | None,
251 | model_config: ConfigDict | None = None,
252 | ) -> type[BaseModel] | None:
253 | if model_args:
254 | return make_model(model_name, model_args, model_config)
255 | else:
256 | return None
257 |
258 | @classmethod
259 | def _handle_if_condition(cls, model: type[ResponseModel]) -> _PartialHandler:
260 | for checker, handler in cls._response_handle_map.items():
261 | if checker(model):
262 | result = partial(handler, model)
263 | break
264 | else:
265 | raise ValueError(f'Unsupported response type {model}')
266 |
267 | return result
268 |
269 | @classmethod
270 | def is_response_type(cls, value: Any) -> bool:
271 | try:
272 | cls._handle_if_condition(value)
273 | return True
274 | except ValueError:
275 | return False
276 |
277 | def validate_response(self, response: Any) -> None:
278 | response_model = self.response_model
279 |
280 | if isinstance(response_model, BaseModel):
281 | response_model = type(response_model)
282 |
283 | class ValidationModel(BaseModel):
284 | model_config = ConfigDict(arbitrary_types_allowed=True)
285 | result: response_model
286 |
287 | ValidationModel(result=response)
288 |
289 | def get_args(self, **kwargs) -> Args:
290 | params_model = self.params_model
291 | path = self.path
292 | if params_model:
293 | url, request_params = self._get_init_args(params_model, **kwargs)
294 | else:
295 | url = path
296 | request_params = {}
297 |
298 | return Args(
299 | url=url,
300 | **request_params
301 | )
302 |
303 | def get_response(self, *, response_obj: IResponse) -> ResponseModel | None:
304 | response_model = self.response_model
305 |
306 | result = self._handle_if_condition(response_model)(response_obj)
307 |
308 | to_validate = result
309 |
310 | if isinstance(response_model, BaseModel):
311 | response_model = type(response_model)
312 | to_validate = response_model(**result.model_dump(mode='json', by_alias=True))
313 |
314 | class ValidationModel(BaseModel):
315 | result: response_model
316 |
317 | ValidationModel(result=to_validate)
318 |
319 | return result
320 |
321 | def _get_init_args(
322 | self,
323 | params_model: type[BaseModel],
324 | **kwargs
325 | ) -> tuple[str, dict[str, Any]]:
326 | path = self.path
327 | params_model_instance = params_model(**kwargs)
328 | params_all = params_model_instance.model_dump(mode='python', by_alias=True)
329 | params, path_params = split_params(path, params_all)
330 |
331 | fields = params_model.model_fields.copy()
332 | fields, _ = split_params(path, fields)
333 |
334 | request_params = self._parser(fields, params)
335 |
336 | url = format_str(path, path_params, True)
337 | return url, request_params
338 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/_requester.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import inspect
4 | from abc import ABC, abstractmethod
5 | from typing import Generic, Any
6 |
7 | from httpx import Client, AsyncClient, Response
8 |
9 | from sensei._utils import placeholders
10 | from sensei.client.rate_limiter import AsyncRateLimiter, RateLimiter
11 | from sensei.types import IResponse, Json, BaseClient, IRateLimit
12 | from ._endpoint import Endpoint, Args, ResponseModel
13 | from ._types import JsonFinalizer, ResponseFinalizer, Preparer, CaseConverters, CaseConverter
14 | from ..tools import identical
15 |
16 |
17 | class _DecoratedResponse(IResponse):
18 | __slots__ = (
19 | "_response",
20 | "_json_finalizer",
21 | "_response_case"
22 | )
23 |
24 | def __init__(
25 | self,
26 | response: Response,
27 | json_finalizer: JsonFinalizer = identical,
28 | response_case: CaseConverter = identical,
29 | ):
30 | self._response = response
31 | self._json_finalizer = json_finalizer
32 | self._response_case = response_case
33 |
34 | def __getattribute__(self, attr: str) -> Any:
35 | if attr not in ('json', '_response', '_json_finalizer', '_response_case'):
36 | return getattr(self._response, attr)
37 | else:
38 | return super().__getattribute__(attr)
39 |
40 | def json(self) -> Json:
41 | case = self._response_case
42 | json = self._response.json()
43 | json = {case(k): v for k, v in json.items()}
44 | return self._json_finalizer(json)
45 |
46 |
47 | class Requester(ABC, Generic[ResponseModel]):
48 | __slots__ = (
49 | "_client",
50 | "_post_preparer",
51 | "_response_finalizer",
52 | "_endpoint",
53 | "_json_finalizer",
54 | "_preparer",
55 | "_rate_limit",
56 | "_is_async_preparer",
57 | "_is_async_response_finalizer",
58 | "_case_converters"
59 | )
60 |
61 | def __new__(
62 | cls,
63 | client: BaseClient,
64 | endpoint: Endpoint,
65 | *,
66 | rate_limit: IRateLimit,
67 | case_converters: CaseConverters,
68 | response_finalizer: ResponseFinalizer | None = None,
69 | json_finalizer: JsonFinalizer = identical,
70 | preparer: Preparer = identical,
71 | response_case: CaseConverter = identical,
72 | ):
73 | if isinstance(client, AsyncClient):
74 | return super().__new__(_AsyncRequester)
75 | elif isinstance(client, Client):
76 | return super().__new__(_Requester)
77 | else:
78 | raise ValueError("Client must be an instance of AsyncClient or Client")
79 |
80 | def __init__(
81 | self,
82 | client: BaseClient,
83 | endpoint: Endpoint,
84 | *,
85 | rate_limit: IRateLimit,
86 | case_converters: CaseConverters,
87 | response_finalizer: ResponseFinalizer | None = None,
88 | json_finalizer: JsonFinalizer = identical,
89 | preparer: Preparer = identical,
90 | ):
91 | self._client = client
92 |
93 | self._response_finalizer = response_finalizer or self._finalize
94 | self._endpoint = endpoint
95 | self._json_finalizer = json_finalizer
96 | self._preparer = preparer
97 | self._rate_limit = rate_limit
98 | self._is_async_preparer = inspect.iscoroutinefunction(self._preparer)
99 | self._is_async_response_finalizer = inspect.iscoroutinefunction(self._response_finalizer)
100 | self._case_converters = case_converters
101 |
102 | def _finalize(self, response: IResponse) -> ResponseModel:
103 | endpoint = self._endpoint
104 | return endpoint.get_response(response_obj=response)
105 |
106 | @abstractmethod
107 | def request(self, **kwargs) -> ResponseModel:
108 | pass
109 |
110 | def _dump_args(self, args: Args) -> dict[str, Any]:
111 | endpoint = self._endpoint
112 | args = args.model_dump(mode="json", exclude_none=True, by_alias=True)
113 | if placeholders(url := args['url']):
114 | raise ValueError(f'Path params of {url} params must be passed')
115 | return {'method': endpoint.method, **args}
116 |
117 |
118 | class _AsyncRequester(Requester):
119 | async def _call_preparer(self, args: Args) -> Args:
120 | result = self._preparer(args)
121 | if self._is_async_preparer:
122 | result = await result
123 | return result
124 |
125 | async def _call_response_finalizer(self, response: IResponse) -> ResponseModel:
126 | result = self._response_finalizer(response)
127 | if self._is_async_response_finalizer:
128 | result = await result
129 | return result
130 |
131 | async def _get_args(self, **kwargs) -> dict[str, Any]:
132 | endpoint = self._endpoint
133 | args = endpoint.get_args(**kwargs)
134 | args = await self._call_preparer(args)
135 | return self._dump_args(args)
136 |
137 | async def request(self, **kwargs) -> ResponseModel:
138 | client = self._client
139 | args = await self._get_args(**kwargs)
140 |
141 | rate_limit = self._rate_limit
142 | if rate_limit:
143 | await AsyncRateLimiter(rate_limit).wait_for_slot()
144 |
145 | response = await client.request(**args)
146 | response.raise_for_status()
147 | case = self._case_converters['response_case']
148 | response = _DecoratedResponse(response, json_finalizer=self._json_finalizer, response_case=case)
149 | response = await self._call_response_finalizer(response)
150 | self._endpoint.validate_response(response)
151 | return response
152 |
153 |
154 | class _Requester(Requester):
155 | def _call_preparer(self, args: Args) -> Args:
156 | result = self._preparer(args)
157 | if self._is_async_preparer:
158 | raise ValueError("If preparer is async, the route function must match it")
159 | return result
160 |
161 | def _call_response_finalizer(self, response: IResponse) -> ResponseModel:
162 | result = self._response_finalizer(response)
163 | if self._is_async_response_finalizer:
164 | raise ValueError("If response finalizer is async, the route function must match it")
165 | return result
166 |
167 | def _get_args(self, **kwargs) -> dict[str, Any]:
168 | endpoint = self._endpoint
169 | args = endpoint.get_args(**kwargs)
170 | args = self._call_preparer(args)
171 | return self._dump_args(args)
172 |
173 | def request(self, **kwargs) -> ResponseModel:
174 | client = self._client
175 | args = self._get_args(**kwargs)
176 |
177 | rate_limit = self._rate_limit
178 | if rate_limit:
179 | RateLimiter(rate_limit).wait_for_slot()
180 |
181 | response = client.request(**args)
182 | response.raise_for_status()
183 | case = self._case_converters['response_case']
184 | response = _DecoratedResponse(response, json_finalizer=self._json_finalizer, response_case=case)
185 | response = self._call_response_finalizer(response)
186 | self._endpoint.validate_response(response)
187 | return response
188 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/_route.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import inspect
4 | from abc import ABC, abstractmethod
5 | from functools import wraps, partial
6 | from typing import Callable
7 |
8 | from ._callable_handler import CallableHandler, AsyncCallableHandler
9 | from ._requester import ResponseFinalizer, Preparer
10 | from ._types import IRouter, Hooks
11 | from ..tools import HTTPMethod, MethodType
12 |
13 |
14 | class Route(ABC):
15 | __slots__ = (
16 | '_path',
17 | '_method',
18 | '_func',
19 | '_method_type',
20 | '_is_async',
21 | '__self__',
22 | '_router',
23 | '_hooks',
24 | '_skip_preparer',
25 | '_skip_finalizer'
26 | )
27 |
28 | def __new__(
29 | cls,
30 | path: str,
31 | method: HTTPMethod,
32 | router: IRouter,
33 | *,
34 | func: Callable,
35 | hooks: Hooks,
36 | skip_preparer: bool = False,
37 | skip_finalizer: bool = False,
38 | ):
39 | if inspect.iscoroutinefunction(func):
40 | instance = super().__new__(_AsyncRoute)
41 | is_async = True
42 | else:
43 | instance = super().__new__(_SyncRoute)
44 | is_async = False
45 |
46 | instance._is_async = is_async
47 |
48 | return instance
49 |
50 | def __init__(
51 | self,
52 | path: str,
53 | method: HTTPMethod,
54 | router: IRouter,
55 | *,
56 | func: Callable,
57 | hooks: Hooks,
58 | skip_preparer: bool = False,
59 | skip_finalizer: bool = False,
60 | ):
61 | self._path = path
62 | self._method = method
63 | self._func = func
64 | self._router = router
65 |
66 | self._hooks = hooks
67 | self._skip_preparer = skip_preparer
68 | self._skip_finalizer = skip_finalizer
69 |
70 | self._method_type: MethodType = MethodType.STATIC
71 |
72 | self.__self__: object | None = None
73 |
74 | @property
75 | def path(self) -> str:
76 | return self._path
77 |
78 | @property
79 | def method(self) -> HTTPMethod:
80 | return self._method
81 |
82 | @property
83 | def is_async(self) -> bool:
84 | return self._is_async
85 |
86 | @abstractmethod
87 | def __call__(self, *args, **kwargs):
88 | pass
89 |
90 | @property
91 | def method_type(self) -> MethodType:
92 | return self._method_type
93 |
94 | @method_type.setter
95 | def method_type(self, value: MethodType):
96 | if isinstance(value, MethodType):
97 | self._method_type = value
98 | else:
99 | raise TypeError(f'Method type must be an instance of {MethodType}')
100 |
101 | @property
102 | def hooks(self) -> Hooks:
103 | return self._hooks
104 |
105 | def _get_wrapper(self, func: Callable[..., ...]):
106 | @wraps(func)
107 | def wrapper(*args, **kwargs):
108 | new_func = func
109 |
110 | if self.__self__ is not None:
111 | new_func = partial(func, self.__self__)
112 |
113 | return new_func(*args, **kwargs)
114 |
115 | return wrapper
116 |
117 | def finalize(self, func: ResponseFinalizer | None = None) -> Callable:
118 | """
119 | Args:
120 | func (ResponseFinalizer | None):
121 | Response finalizer, used to modify final response, primarily when the response type of routed
122 | function is not from category of automatically handled types. Executed after router's __finalize_json__
123 |
124 | Returns:
125 | ResponseFinalizer: Wrapped function, used to finalize response
126 | """
127 | def decorator(func: ResponseFinalizer) -> ResponseFinalizer:
128 | self._hooks.response_finalizer = self._get_wrapper(func)
129 | return func
130 |
131 | if func is None:
132 | return decorator
133 | else:
134 | return decorator(func)
135 |
136 | def prepare(self, func: Preparer | None = None) -> Callable:
137 | """
138 | Args:
139 | func (Preparer | None):
140 | Args preparer, used to prepare the args for request before it.
141 | The final value also must be `Args` instance.
142 | Executed after router's __prepare_args__
143 |
144 | Returns:
145 | Preparer: Wrapped function, used to prepare the args for request before it
146 | """
147 | def decorator(func: Preparer) -> Preparer:
148 | self._hooks.post_preparer = self._get_wrapper(func)
149 | return func
150 |
151 | if func is None:
152 | return decorator
153 | else:
154 | return decorator(func)
155 |
156 |
157 | class _SyncRoute(Route):
158 | def __call__(self, *args, **kwargs):
159 | with CallableHandler(
160 | func=self._func,
161 | router=self._router,
162 | request_args=(args, kwargs),
163 | method_type=self._method_type,
164 | path=self.path,
165 | method=self._method,
166 | hooks=self._hooks,
167 | skip_preparer=self._skip_preparer,
168 | skip_finalizer=self._skip_finalizer,
169 | ) as response:
170 | return response
171 |
172 |
173 | class _AsyncRoute(Route):
174 | async def __call__(self, *args, **kwargs):
175 | async with AsyncCallableHandler(
176 | func=self._func,
177 | router=self._router,
178 | request_args=(args, kwargs),
179 | method_type=self._method_type,
180 | path=self.path,
181 | method=self._method,
182 | hooks=self._hooks,
183 | skip_preparer=self._skip_preparer,
184 | skip_finalizer=self._skip_finalizer,
185 | ) as response:
186 | return response
187 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/_types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from abc import abstractmethod, ABC
4 | from enum import Enum
5 | from typing import Protocol, TypeVar, Callable, Any, Mapping, Union, Awaitable, Literal, get_args, Optional
6 |
7 | from httpx import URL
8 | from pydantic import validate_call, BaseModel, ConfigDict
9 | from typing_extensions import Self, TypeGuard
10 |
11 | from sensei.client import Manager
12 | from sensei.types import IRateLimit, Json
13 | from .args import Args
14 | from ..tools import MethodType, identical, HTTPMethod
15 |
16 | CaseConverter = Callable[[str], str]
17 |
18 | _RT = TypeVar('_RT')
19 |
20 |
21 | class RoutedMethod(Protocol):
22 | __func__: RoutedFunction
23 | __name__: str
24 | __doc__: str
25 | __route__: IRoute
26 |
27 |
28 | class RoutedFunction(Callable[..., _RT]):
29 | """
30 | Function produced by routed decorators, such as:
31 |
32 | * `@router.get`
33 | * `@router.post`
34 | * `@router.put`
35 | * `@router.delete`
36 | * `@router.patch`
37 | * `@router.head`
38 | * `@router.options`
39 |
40 | Example:
41 | ```python
42 | from sensei import Router
43 |
44 | router = Router('https://api.example.com')
45 |
46 | @router.get('/users')
47 | def get_user(id: int) -> User:
48 | pass
49 | ```
50 | """
51 | def __call__(self, *args, **kwargs) -> _RT:
52 | ...
53 |
54 | def prepare(self, preparer: Preparer | None = None) -> Preparer:
55 | """
56 | Attach preparer to the routed function. Preparer is a function that takes an instance of `Args` as the argument
57 | and returns the modified. Preparers are used for request preparation. That means adding or changing arguments
58 | before request. Can be represented as **async function**.
59 |
60 | Preparers are executed after internal argument parsing. So, all request parameters are available in
61 | `Args` model within a preparer.
62 |
63 | Example:
64 | ```python
65 | from sensei import APIModel, format_str, Router, Args
66 | from pydantic import NonNegativeInt, EmailStr
67 |
68 | router = Router('https://api.example.com')
69 |
70 | class User(APIModel):
71 | id: NonNegativeInt
72 | email: EmailStr
73 | nickname: str
74 |
75 | @router.patch('/users/{id_}')
76 | def update(
77 | self,
78 | name: str,
79 | job: str
80 | ) -> None:
81 | pass
82 |
83 | @update.prepare
84 | def _update_in(self, args: Args) -> Args:
85 | args.url = format_str(args.url, {'id_': self.id})
86 | return args
87 | ```
88 |
89 | Args:
90 | preparer (Preparer):
91 | Function that takes an instance of `Args` as the argument and returns the modified
92 |
93 | Returns (Preparer): Function that was passed
94 | """
95 | ...
96 |
97 | def finalize(self, finalizer: ResponseFinalizer | None = None) -> ResponseFinalizer:
98 | """
99 | Attach response finalizer to the routed function. Response Finalizer is a function that takes an instance of
100 | `httpx.Response` as the argument and returns the result of calling the associated routed function.
101 | The return value must be of the same type as the routed function. Can be represented as **async function**.
102 |
103 | Response Finalizers are used for response transformation, which can't be performed automatically if you set a
104 | corresponding response type from the category of automatically handled.
105 |
106 | Example:
107 | ```python
108 | from sensei import Router, APIModel, Form
109 | from pydantic import EmailStr
110 | from typing import Annotated
111 | from httpx import Response
112 |
113 | router = Router('https://api.example.com')
114 |
115 | class UserCredentials(APIModel):
116 | email: EmailStr
117 | password: str
118 |
119 | @router.post('/register')
120 | def sign_up(user: Annotated[UserCredentials, Form(embed=False)]) -> str:
121 | pass
122 |
123 | @sign_up.finalize
124 | def _sign_up_out(response: Response) -> str:
125 | print(f'Finalizing response for request {response.request.url}')
126 | return response.json()['token']
127 |
128 |
129 | token = sign_up(UserCredentials(
130 | email='john@example.com',
131 | password='secret_password')
132 | )
133 | print(f'JWT token: {token}')
134 | ```
135 |
136 | Args:
137 | finalizer (ResponseFinalizer):
138 | Function that takes an instance of `httpx.Response` as the argument and returns the result of calling
139 | the routed function. The return value must be of the same type as the routed function.
140 |
141 | Returns:
142 | Function that was passed
143 | """
144 | ...
145 |
146 | __method_type__: MethodType
147 | __name__: str
148 | __doc__: str
149 | __route__: IRoute
150 | __sensei_routed_function__: bool = True
151 |
152 |
153 | class IRequest(Protocol):
154 | @property
155 | def headers(self) -> Mapping[str, Any]:
156 | pass
157 |
158 | @property
159 | def method(self) -> str:
160 | pass
161 |
162 | @property
163 | def url(self) -> Any:
164 | pass
165 |
166 |
167 | class IResponse(Protocol):
168 | __slots__ = ()
169 |
170 | def __await__(self):
171 | pass
172 |
173 | def json(self) -> Json:
174 | pass
175 |
176 | def raise_for_status(self) -> Self:
177 | pass
178 |
179 | @property
180 | def request(self) -> IRequest:
181 | pass
182 |
183 | @property
184 | def text(self) -> str:
185 | pass
186 |
187 | @property
188 | def status_code(self) -> int:
189 | pass
190 |
191 | @property
192 | def content(self) -> bytes:
193 | pass
194 |
195 | @property
196 | def headers(self) -> Mapping[str, Any]:
197 | pass
198 |
199 |
200 | class IRouter(ABC):
201 | __slots__ = ()
202 |
203 | @property
204 | @abstractmethod
205 | def base_url(self) -> URL:
206 | pass
207 |
208 | @property
209 | @abstractmethod
210 | def manager(self) -> Manager:
211 | pass
212 |
213 | @property
214 | @abstractmethod
215 | def port(self) -> int:
216 | pass
217 |
218 | @property
219 | @abstractmethod
220 | def rate_limit(self) -> IRateLimit:
221 | pass
222 |
223 | @abstractmethod
224 | def get(
225 | self,
226 | path: str,
227 | /, *,
228 | query_case: CaseConverter | None = None,
229 | cookie_case: CaseConverter | None = None,
230 | header_case: CaseConverter | None = None,
231 | skip_finalizer: bool = False,
232 | ) -> RoutedFunction:
233 | pass
234 |
235 | @abstractmethod
236 | def post(
237 | self,
238 | path: str,
239 | /, *,
240 | query_case: CaseConverter | None = None,
241 | body_case: CaseConverter | None = None,
242 | cookie_case: CaseConverter | None = None,
243 | header_case: CaseConverter | None = None,
244 | skip_finalizer: bool = False,
245 | ) -> RoutedFunction:
246 | pass
247 |
248 | @abstractmethod
249 | def patch(
250 | self,
251 | path: str,
252 | /, *,
253 | query_case: CaseConverter | None = None,
254 | body_case: CaseConverter | None = None,
255 | cookie_case: CaseConverter | None = None,
256 | header_case: CaseConverter | None = None,
257 | skip_finalizer: bool = False,
258 | ) -> RoutedFunction:
259 | pass
260 |
261 | @abstractmethod
262 | def put(
263 | self,
264 | path: str,
265 | /, *,
266 | query_case: CaseConverter | None = None,
267 | body_case: CaseConverter | None = None,
268 | cookie_case: CaseConverter | None = None,
269 | header_case: CaseConverter | None = None,
270 | skip_finalizer: bool = False,
271 | ) -> RoutedFunction:
272 | pass
273 |
274 | @abstractmethod
275 | def delete(
276 | self,
277 | path: str,
278 | /, *,
279 | query_case: CaseConverter | None = None,
280 | cookie_case: CaseConverter | None = None,
281 | header_case: CaseConverter | None = None,
282 | skip_finalizer: bool = False,
283 | ) -> RoutedFunction:
284 | pass
285 |
286 | @abstractmethod
287 | def options(
288 | self,
289 | path: str,
290 | /, *,
291 | query_case: CaseConverter | None = None,
292 | cookie_case: CaseConverter | None = None,
293 | header_case: CaseConverter | None = None,
294 | skip_finalizer: bool = False,
295 | ) -> RoutedFunction:
296 | pass
297 |
298 | @abstractmethod
299 | def head(
300 | self,
301 | path: str,
302 | /, *,
303 | query_case: CaseConverter | None = None,
304 | cookie_case: CaseConverter | None = None,
305 | header_case: CaseConverter | None = None,
306 | skip_finalizer: bool = False,
307 | ) -> RoutedFunction:
308 | pass
309 |
310 | @property
311 | @abstractmethod
312 | def default_case(self) -> CaseConverter:
313 | pass
314 |
315 | @property
316 | @abstractmethod
317 | def query_case(self) -> CaseConverter:
318 | pass
319 |
320 | @property
321 | @abstractmethod
322 | def body_case(self) -> CaseConverter:
323 | pass
324 |
325 | @property
326 | @abstractmethod
327 | def cookie_case(self) -> CaseConverter:
328 | pass
329 |
330 | @property
331 | @abstractmethod
332 | def header_case(self) -> CaseConverter:
333 | pass
334 |
335 | @property
336 | @abstractmethod
337 | def response_case(self) -> CaseConverter:
338 | pass
339 |
340 |
341 | class IRoute(ABC):
342 | @property
343 | @abstractmethod
344 | def path(self) -> str:
345 | pass
346 |
347 | @property
348 | @abstractmethod
349 | def method(self) -> HTTPMethod:
350 | pass
351 |
352 | @property
353 | @abstractmethod
354 | def is_async(self) -> bool:
355 | pass
356 |
357 | @property
358 | @abstractmethod
359 | def method_type(self) -> MethodType:
360 | pass
361 |
362 | @method_type.setter
363 | @abstractmethod
364 | def method_type(self, value: MethodType):
365 | pass
366 |
367 | @property
368 | @abstractmethod
369 | def hooks(self) -> Hooks:
370 | pass
371 |
372 | @abstractmethod
373 | def finalize(self, func: ResponseFinalizer | None = None) -> Callable:
374 | pass
375 |
376 | @abstractmethod
377 | def prepare(self, func: Preparer | None = None) -> Callable:
378 | pass
379 |
380 |
381 | _KT = TypeVar('_KT')
382 | _VT = TypeVar('_VT')
383 |
384 | ConverterName = Literal['default_case', 'query_case', 'body_case', 'cookie_case', 'header_case', 'response_case']
385 |
386 |
387 | class _MappingGetter(dict[_KT, _VT]):
388 | def __init__(
389 | self,
390 | dict_getter: Callable[[], dict[_KT, _VT]]
391 | ):
392 | __dict = dict_getter()
393 | super().__init__(__dict)
394 | self.__getter = dict_getter
395 |
396 | def __getitem__(self, item: _KT) -> _VT:
397 | return self.__getter()[item]
398 |
399 | def __setitem__(self, key: _KT, value: _VT) -> None:
400 | raise TypeError(f'{self.__class__.__name__} does not support item assignment')
401 |
402 |
403 | class CaseConverters(_MappingGetter[ConverterName, CaseConverter]):
404 | def __init__(
405 | self,
406 | router: IRouter,
407 | *,
408 | default_case: CaseConverter | None = None,
409 | query_case: CaseConverter | None = None,
410 | body_case: CaseConverter | None = None,
411 | cookie_case: CaseConverter | None = None,
412 | header_case: CaseConverter | None = None,
413 | response_case: CaseConverter | None = None,
414 | ):
415 | self.__router = router
416 | self._defaults = {
417 | 'query_case': query_case,
418 | 'body_case': body_case,
419 | 'cookie_case': cookie_case,
420 | 'header_case': header_case,
421 | 'response_case': response_case,
422 | }
423 |
424 | self._sub_defaults = {}
425 |
426 | self._default_case = default_case or router.default_case
427 |
428 | super().__init__(self.__getter)
429 |
430 | @property
431 | @validate_call(validate_return=True)
432 | def defaults(self) -> dict[ConverterName, Optional[CaseConverter]]:
433 | return self._sub_defaults
434 |
435 | @defaults.setter
436 | @validate_call(validate_return=True)
437 | def defaults(self, value: dict[ConverterName, CaseConverter]) -> None:
438 | self._sub_defaults = value
439 |
440 | def __setitem__(self, key, value):
441 | is_default = key == 'default_case'
442 | if key not in self._defaults and not is_default:
443 | raise KeyError(f'{key} is not a valid key')
444 | else:
445 | if is_default:
446 | self._default_case = value
447 | else:
448 | self._defaults[key] = value
449 |
450 | def __getitem__(self, item: ConverterName) -> CaseConverter:
451 | converter = super().__getitem__(item)
452 | if converter is None:
453 | converter = identical
454 |
455 | return converter
456 |
457 | def __getter(self) -> dict[str, CaseConverter]:
458 | router = self.__router
459 | converters = self._defaults.copy()
460 | default = self._default_case
461 |
462 | for key, converter in converters.items():
463 | converter = converter or self.defaults.get(key)
464 | if converter is None:
465 | router_converter = getattr(router, f'{key}')
466 | converters[key] = default if router_converter is None else router_converter
467 | else:
468 | converters[key] = converter
469 |
470 | return converters
471 |
472 |
473 | class ModelHook(Enum):
474 | JSON_FINALIZER = "__finalize_json__"
475 | ARGS_PREPARER = "__prepare_args__"
476 |
477 | DEFAULT_CASE = "__default_case__"
478 | QUERY_CASE = "__query_case__"
479 | BODY_CASE = "__body_case__"
480 | COOKIE_CASE = "__cookie_case__"
481 | HEADER_CASE = "__header_case__"
482 | RESPONSE_CASE = "__response_case__"
483 |
484 | @classmethod
485 | def values(cls) -> list[str]:
486 | return [member.value for member in cls]
487 |
488 | def is_case_hook(self) -> bool:
489 | return self.value.endswith("_case__")
490 |
491 |
492 | class Hooks(BaseModel):
493 | model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
494 |
495 | prepare_args: Preparer = identical
496 | post_preparer: Preparer = identical
497 | finalize_json: JsonFinalizer = identical
498 | response_finalizer: Optional[ResponseFinalizer] = None
499 | case_converters: CaseConverters
500 |
501 | @staticmethod
502 | def _is_converter_name(name: str) -> TypeGuard[ConverterName]:
503 | return name in get_args(ConverterName)
504 |
505 | @validate_call(validate_return=True)
506 | def set_model_hooks(self, hooks: dict[ModelHook, Callable]) -> None:
507 | case_hooks = {}
508 | for key, value in hooks.items():
509 | stripped = key.value[2:-2]
510 | if key.is_case_hook():
511 | if self._is_converter_name(stripped):
512 | case_hooks[stripped] = value
513 | else:
514 | raise ValueError('Unsupported case hook')
515 | else:
516 | setattr(self, stripped, value)
517 |
518 | self.case_converters.defaults = case_hooks
519 |
520 |
521 | Preparer = Callable[[Args], Union[Args, Awaitable[Args]]]
522 | ResponseFinalizer = Callable[[IResponse], Any]
523 | JsonFinalizer = Callable[[Json], Json]
524 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/api_model.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable
2 |
3 | from pydantic import BaseModel
4 | from pydantic._internal._model_construction import ModelMetaclass
5 | from typing_extensions import TypeGuard
6 |
7 | from sensei.types import Json
8 | from ._types import RoutedMethod, ModelHook, RoutedFunction
9 | from .args import Args
10 | from ..tools import is_staticmethod, is_classmethod, is_instancemethod, bind_attributes, is_method
11 |
12 |
13 | class _Namespace(dict):
14 | def __init__(self, *args, **kwargs):
15 | super().__init__(*args, **kwargs)
16 | self._routed_functions = set()
17 |
18 | @property
19 | def routed_functions(self) -> set[RoutedFunction]:
20 | return self._routed_functions
21 |
22 | @staticmethod
23 | def _decorate_method(method: RoutedMethod) -> None:
24 | preparer = method.__func__.prepare
25 | finalizer = method.__func__.finalize
26 |
27 | bind_attributes(method, finalizer, preparer) # type: ignore
28 |
29 | @staticmethod
30 | def _is_routed_function(obj: Any) -> TypeGuard[RoutedMethod]:
31 | cond = False
32 | if is_method(obj):
33 | if is_staticmethod(obj) or is_classmethod(obj):
34 | obj = obj.__func__
35 | cond = getattr(obj, '__sensei_routed_function__', None) is True
36 | return cond
37 |
38 | def __setitem__(self, key: Any, value: Any):
39 | if self._is_routed_function(value):
40 | if is_staticmethod(value) or is_classmethod(value):
41 | self._decorate_method(value)
42 | self._routed_functions.add(value.__func__)
43 | else:
44 | self._routed_functions.add(value)
45 | elif key in ModelHook.values():
46 | if is_instancemethod(value):
47 | raise ValueError(f'Class hook {value.__name__} cannot be instance method')
48 |
49 | super().__setitem__(key, value)
50 |
51 |
52 | class _ModelBase(BaseModel):
53 | @staticmethod
54 | def __finalize_json__(json: Json) -> Json:
55 | """
56 | Hook used to finalize the JSON response. It's applied for each routed method, associated with the model
57 | The final value must be JSON serializable. Can be represented as **async function**.
58 |
59 | JSON finalizer is used for JSON response transformation before internal or user-defined response finalizing.
60 |
61 | Example:
62 | ```python
63 | from sensei import Router, APIModel, Path
64 | from typing import Any, Annotated
65 |
66 |
67 | router = Router('https://reqres.in/api')
68 |
69 |
70 | class User(APIModel):
71 | email: str
72 | id: int
73 | first_name: str
74 | last_name: str
75 | avatar: str
76 |
77 | @staticmethod
78 | def __finalize_json__(json: dict[str, Any]) -> dict[str, Any]:
79 | return json['data']
80 |
81 | @classmethod
82 | @router.get('/users/{id_}')
83 | def get(cls, id_: Annotated[int, Path()]) -> "User":
84 | pass
85 | ```
86 |
87 | Args:
88 | json (Json): The original JSON response.
89 |
90 | Returns:
91 | Json: The finalized JSON response.
92 | """
93 | return json
94 |
95 | @staticmethod
96 | def __prepare_args__(args: Args) -> Args:
97 | """
98 | Hook used to prepare the arguments for the request before it is sent. It's applied for
99 | each routed method, associated with the model. The final value must be an instance of `Args`.
100 | Can be represented as **async function**.
101 |
102 | Preparer is executed after internal argument parsing. So, all request parameters are available in
103 | `Args` model within a preparer.
104 |
105 | Example:
106 | ```python
107 | from sensei import APIModel, Router, Args, Path
108 |
109 | class Context:
110 | token: str
111 |
112 | router = Router('https://api.example.com')
113 |
114 |
115 | class User(APIModel):
116 | email: str
117 | id: int
118 | first_name: str
119 | last_name: str
120 | avatar: str
121 |
122 | @staticmethod
123 | def __prepare_args__(args: Args) -> Args:
124 | args.headers['Authorization'] = f'Bearer {Context.token}'
125 | return args
126 |
127 | @classmethod
128 | @router.get('/users/{id_}')
129 | def get(cls, id_: Annotated[int, Path()]) -> "User":
130 | pass
131 | ```
132 |
133 | Args:
134 | args (Args): The original arguments.
135 |
136 | Returns:
137 | Args: The prepared arguments.
138 | """
139 | return args
140 |
141 | @staticmethod
142 | def __default_case__(s: str) -> str:
143 | """
144 | Hook used to convert the case of all parameters.
145 |
146 | Args:
147 | s (str): The original string.
148 |
149 | Returns:
150 | str: The converted string.
151 | """
152 | return s
153 |
154 | @staticmethod
155 | def __query_case__(s: str) -> str:
156 | """
157 | Hook used to convert the case of query parameters.
158 |
159 | Example:
160 | ```python
161 | from sensei import Router, APIModel, Path, camel_case
162 | from typing import Any, Annotated
163 |
164 |
165 | router = Router('https://reqres.in/api')
166 |
167 |
168 | class User(APIModel):
169 | email: str
170 | id: int
171 | first_name: str
172 | last_name: str
173 | avatar: str
174 |
175 | @staticmethod
176 | def __query_case__(s: str) -> str:
177 | return camel_case(s)
178 |
179 | @classmethod
180 | @router.get('/users/{id_}')
181 | def get(cls, id_: Annotated[int, Path()]) -> "User":
182 | pass
183 | ```
184 |
185 | Args:
186 | s (str): The original string.
187 |
188 | Returns:
189 | str: The converted string.
190 | """
191 | return s
192 |
193 | @staticmethod
194 | def __body_case__(s: str) -> str:
195 | """
196 | Hook used to convert the case of body.
197 |
198 | Args:
199 | s (str): The original string.
200 |
201 | Returns:
202 | str: The converted string.
203 | """
204 | return s
205 |
206 | @staticmethod
207 | def __cookie_case__(s: str) -> str:
208 | """
209 | Hook used to convert the case of cookies.
210 |
211 | Args:
212 | s (str): The original string.
213 |
214 | Returns:
215 | str: The converted string.
216 | """
217 | return s
218 |
219 | @staticmethod
220 | def __header_case__(s: str) -> str:
221 | """
222 | Hook used to convert the case of headers.
223 |
224 | Args:
225 | s (str): The original string.
226 |
227 | Returns:
228 | str: The converted string.
229 | """
230 | return s
231 |
232 | @staticmethod
233 | def __response_case__(s: str) -> str:
234 | """
235 | Hook used to convert the case of JSON response keys.
236 |
237 | Args:
238 | s (str): The original string.
239 |
240 | Returns:
241 | str: The converted string.
242 | """
243 | return s
244 |
245 | def __str__(self):
246 | """
247 | Get the string representation of the model. Wraps `pydantic` representation through the class name and
248 | parenthesis.
249 |
250 | Example:
251 | ```python
252 | @router.get('/pokemon/{name}')
253 | def get_pokemon(name: Annotated[str, Path(max_length=300)]) -> Pokemon:
254 | pass
255 |
256 |
257 | pokemon = get_pokemon(name="pikachu")
258 | print(pokemon)
259 | ```
260 |
261 | ```text
262 | Pokemon(name='pikachu' id=25 height=4 weight=60)
263 | ```
264 |
265 |
266 | Returns:
267 | str: String representation of the model
268 | """
269 | return f'{self.__class__.__name__}({super().__str__()})'
270 |
271 |
272 | class _ModelMeta(ModelMetaclass):
273 | @classmethod
274 | def __prepare__(metacls, name, bases):
275 | namespace = _Namespace()
276 | return namespace
277 |
278 | def __new__(
279 | cls,
280 | cls_name: str,
281 | bases: tuple[type[Any], ...],
282 | namespace: _Namespace,
283 | ):
284 | obj = super().__new__(cls, cls_name, bases, namespace)
285 |
286 | hooks = cls.__collect_hooks(obj)
287 |
288 | routed_functions = namespace.routed_functions
289 | for fun in routed_functions:
290 | fun.__route__.hooks.set_model_hooks(hooks)
291 |
292 | obj.__router__ = None
293 |
294 | return obj
295 |
296 | @staticmethod
297 | def __collect_hooks(obj: object) -> dict[ModelHook, Callable]:
298 | hooks = {}
299 | for value in ModelHook.values():
300 | hook = getattr(obj, value, None)
301 |
302 | is_defined = hook is not getattr(_ModelBase, value, None)
303 | if hook and is_defined:
304 | hooks[value] = hook
305 | return hooks # type: ignore
306 |
307 |
308 | class APIModel(_ModelBase, metaclass=_ModelMeta):
309 | """
310 | Base class used to define models for structuring API responses.
311 | There is the OOP style of making Sensei models when an `APIModel` class performs both validation and making
312 | requests through its routed methods. This style is called **Routed Model**.
313 | To use this style, you need to implement a model derived from `APIModel` and add inside routed methods.
314 |
315 | You can apply the same techniques as for
316 | [`pydantic.BaseModel`](https://docs.pydantic.dev/2.9/concepts/models/){.external-link}
317 |
318 | Import it directly from Sensei:
319 |
320 | ```python
321 | from sensei import APIModel
322 | ```
323 |
324 | !!! example
325 | === "Simple Model"
326 | ```python
327 | from typing import Annotated, Any
328 | from sensei import Router, Path, APIModel
329 |
330 | router = Router('https://example.com/api')
331 |
332 | class User(APIModel):
333 | email: str
334 | id: int
335 | first_name: str
336 | last_name: str
337 | avatar: str
338 |
339 | @router.get('/users/{id_}')
340 | def get_user(id_: Annotated[int, Path()]) -> User:
341 | pass
342 |
343 | user = get_user(1)
344 | print(user.email)
345 | ```
346 |
347 | === "Routed Model"
348 | ```python
349 | from typing import Annotated, Any
350 | from sensei import Router, Path, APIModel
351 |
352 | router = Router('https://example.com/api')
353 |
354 | class User(APIModel):
355 | email: str
356 | id: int
357 | first_name: str
358 | last_name: str
359 | avatar: str
360 |
361 | @classmethod
362 | @router.get('/users/{id_}')
363 | def get(cls, id_: Annotated[int, Path()]) -> "User":
364 | pass
365 |
366 | user = User.get(1)
367 | print(user.email)
368 | ```
369 | """
370 | pass
371 |
--------------------------------------------------------------------------------
/sensei/_internal/_core/args.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from pydantic import BaseModel, ConfigDict, Field
4 |
5 | from sensei.types import Json
6 |
7 |
8 | class Args(BaseModel):
9 | """
10 | Model used in preparers as input and output argument. Stores request arguments
11 |
12 | Attributes:
13 | url (str): URL to which the request will be made.
14 | params (dict[str, Any]): Dictionary of query parameters to be included in the URL.
15 | data (dict[str, Any]): Dictionary of payload for the request body.
16 | json_ (Json): JSON payload for the request body.
17 | The field is aliased as 'json' and defaults to an empty dictionary.
18 | files (dict[str, Any]): File payload for the request body.
19 | headers (dict[str, Any]): Dictionary of HTTP headers to be sent with the request.
20 | cookies (dict[str, Any]): Dictionary of cookies to be included in the request.
21 | """
22 |
23 | model_config = ConfigDict(validate_assignment=True)
24 |
25 | url: str
26 | params: dict[str, Any] = {}
27 | json_: Json = Field({}, alias="json")
28 | data: Any = {}
29 | headers: dict[str, Any] = {}
30 | cookies: dict[str, Any] = {}
31 | files: dict[str, Any] = {}
32 |
33 | def model_dump(self, *args, **kwargs) -> dict[str, Any]:
34 | data = super().model_dump(*args, exclude={'files'}, **kwargs)
35 | data['files'] = self.files
36 | return self._exclude_none(data)
37 |
38 | @classmethod
39 | def _exclude_none(cls, data: dict[str, Any]) -> dict[str, Any]:
40 | if not isinstance(data, dict):
41 | return data
42 | return {k: cls._exclude_none(v) for k, v in data.items() if v is not None}
43 |
--------------------------------------------------------------------------------
/sensei/_internal/tools/__init__.py:
--------------------------------------------------------------------------------
1 | from .chained_map import ChainedMap
2 | from .types import HTTPMethod, MethodType
3 | from .utils import (make_model, split_params, accept_body, validate_method, args_to_kwargs, set_method_type, identical,
4 | is_staticmethod, is_classmethod, is_selfmethod, bind_attributes, is_method, is_instancemethod)
5 |
--------------------------------------------------------------------------------
/sensei/_internal/tools/chained_map.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Mapping
2 | from typing import Iterator, TypeVar, Any, Hashable
3 |
4 | _KT = TypeVar('_KT')
5 | _VT = TypeVar('_VT')
6 |
7 |
8 | class ChainedMap(Mapping[_KT, _VT]):
9 | def __init__(self, *dicts: dict[Any, Any]) -> None:
10 | self._dicts: tuple[dict[Any, Any], ...] = dicts
11 |
12 | def __getitem__(self, key: _KT) -> _VT:
13 | for d in self._dicts:
14 | if key in d:
15 | value = d[key]
16 | if any(value in next_d for next_d in self._dicts if isinstance(value, Hashable) and next_d is not d):
17 | return self.__getitem__(value)
18 | return value
19 | raise KeyError(key)
20 |
21 | def __iter__(self) -> Iterator[Any]:
22 | for key in self._dicts[0]:
23 | yield from self.trace(key)
24 |
25 | def __len__(self) -> int:
26 | unique_keys = {key for d in self._dicts for key in d}
27 | return len(unique_keys)
28 |
29 | def trace(self, key: _KT) -> Iterator[Any]:
30 | """Yields the key and follows its chain through the dictionaries."""
31 | seen = []
32 |
33 | current_value = key
34 | while True:
35 | if current_value in seen:
36 | break
37 | seen.append(current_value)
38 | yield current_value
39 | for d in self._dicts:
40 | try:
41 | if current_value in d:
42 | current_value = d[current_value]
43 | break
44 | except TypeError:
45 | pass
46 | else:
47 | break
48 |
--------------------------------------------------------------------------------
/sensei/_internal/tools/types.py:
--------------------------------------------------------------------------------
1 | from typing import Literal
2 | from enum import Enum
3 | from typing_extensions import Self
4 |
5 | HTTPMethod = Literal[
6 | "GET",
7 | "POST",
8 | "PUT",
9 | "DELETE",
10 | "PATCH",
11 | "HEAD",
12 | "OPTIONS",
13 | "CONNECT",
14 | "TRACE"
15 | ]
16 |
17 |
18 | class MethodType(Enum):
19 | INSTANCE = "instance"
20 | CLASS = "class"
21 | STATIC = "static"
22 |
23 | @classmethod
24 | def self_method(cls, method: Self) -> bool:
25 | return method in (cls.CLASS, cls.INSTANCE)
26 |
--------------------------------------------------------------------------------
/sensei/_internal/tools/utils.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import sys
3 | from collections import OrderedDict
4 | from functools import wraps
5 | from typing import Any, get_args, Callable, TypeVar, Optional, Protocol
6 |
7 | from pydantic import BaseModel, ConfigDict
8 | from pydantic._internal._model_construction import ModelMetaclass
9 | from pydantic.fields import FieldInfo
10 |
11 | from sensei._utils import placeholders
12 | from .types import HTTPMethod, MethodType
13 |
14 | _T = TypeVar("_T")
15 |
16 |
17 | def make_model(
18 | model_name: str,
19 | model_args: dict[str, Any],
20 | model_config: Optional[ConfigDict] = None,
21 | ) -> type[BaseModel]:
22 | annotations = {}
23 | defaults = {}
24 | for key, arg in model_args.items():
25 | if isinstance(arg, (tuple, list)):
26 | annotations[key] = arg[0]
27 | if len(arg) == 2:
28 | defaults[key] = arg[1]
29 | else:
30 | annotations[key] = arg
31 |
32 | namespace = {
33 | '__module__': sys.modules[__name__],
34 | '__qualname__': model_name,
35 | '__annotations__': annotations,
36 | **defaults
37 | }
38 |
39 | if model_config:
40 | namespace['model_config'] = model_config
41 |
42 | model: type[BaseModel] = ModelMetaclass( # type: ignore
43 | model_name,
44 | (BaseModel,),
45 | namespace
46 | )
47 | return model
48 |
49 |
50 | def split_params(url: str, params: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
51 | path_params_names = placeholders(url)
52 |
53 | path_params = {}
54 | for path_param_name in path_params_names:
55 | if (value := params.get(path_param_name)) is not None:
56 | path_params[path_param_name] = value
57 | del params[path_param_name]
58 |
59 | return params, path_params
60 |
61 |
62 | def accept_body(method: HTTPMethod) -> bool:
63 | if method not in {'DELETE', 'GET', 'TRACE', 'OPTIONS', 'HEAD'}:
64 | return True
65 | else:
66 | return False
67 |
68 |
69 | def validate_method(method: HTTPMethod) -> bool:
70 | methods = get_args(HTTPMethod)
71 | if method not in methods:
72 | raise ValueError(f'Invalid HTTP method "{method}". '
73 | f'Standard HTTP methods defined by the HTTP/1.1 protocol: {methods}')
74 | else:
75 | return True
76 |
77 |
78 | def args_to_kwargs(func: Callable, *args, **kwargs) -> OrderedDict[str, Any]:
79 | sig = inspect.signature(func)
80 |
81 | bound_args = sig.bind_partial(*args, **kwargs)
82 |
83 | args = OrderedDict(bound_args.arguments)
84 | bound_args.apply_defaults()
85 | new_args = OrderedDict(bound_args.arguments)
86 |
87 | dif = new_args.keys() - args.keys()
88 | for k, v in new_args.items():
89 | v = v.default if isinstance(v, FieldInfo) and k in dif else v
90 | new_args[k] = v
91 |
92 | return new_args
93 |
94 |
95 | def set_method_type(func: Callable):
96 | @wraps(func)
97 | def wrapper(*args, **kwargs):
98 | if not args:
99 | method_type = MethodType.STATIC
100 | else:
101 | first_arg = args[0]
102 |
103 | func_name = getattr(first_arg, func.__name__, None)
104 | is_first_self = hasattr(first_arg, func.__name__) and getattr(func_name, '__self__', None) is first_arg
105 |
106 | if is_first_self:
107 | if inspect.isclass(first_arg):
108 | method_type = MethodType.CLASS
109 | else:
110 | method_type = MethodType.INSTANCE
111 | else:
112 | method_type = MethodType.STATIC
113 |
114 | setattr(wrapper, '__method_type__', method_type)
115 |
116 | return func(*args, **kwargs)
117 |
118 | return wrapper
119 |
120 |
121 | def identical(value: _T) -> _T:
122 | return value
123 |
124 |
125 | def is_coroutine_function(func: Callable) -> bool:
126 | return (inspect.iscoroutinefunction(func) or
127 | (hasattr(func, '__wrapped__') and inspect.iscoroutinefunction(func.__wrapped__)))
128 |
129 |
130 | class _NamedObj(Protocol):
131 | __name__ = ...
132 |
133 |
134 | def is_classmethod(obj: Any) -> bool:
135 | return isinstance(obj, classmethod)
136 |
137 |
138 | def is_staticmethod(obj: Any) -> bool:
139 | return isinstance(obj, staticmethod)
140 |
141 |
142 | def is_instancemethod(obj: Any) -> bool:
143 | return inspect.isfunction(obj)
144 |
145 |
146 | def is_selfmethod(obj: Any) -> bool:
147 | return is_classmethod(obj) or is_instancemethod(obj)
148 |
149 |
150 | def is_method(obj: Any) -> bool:
151 | return is_selfmethod(obj) or is_staticmethod(obj)
152 |
153 |
154 | def bind_attributes(obj: _T, *named_objects: tuple[_NamedObj]) -> _T:
155 | for named_obj in named_objects:
156 | setattr(obj, named_obj.__name__, named_obj)
157 |
158 | return obj
159 |
--------------------------------------------------------------------------------
/sensei/_utils.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Any, TypeVar
3 | from urllib.parse import urlparse, urlunparse
4 |
5 | _T = TypeVar("_T")
6 |
7 |
8 | def placeholders(url: str) -> list[str]:
9 | """
10 | Extracts placeholder names from a string.
11 |
12 | This function searches the string for placeholders in the format `{param_name}`
13 | and returns a list of all parameter names found in the string.
14 |
15 | Args:
16 | url (str): The string containing placeholders in the format `{param_name}`.
17 |
18 | Returns:
19 | list[str]: A list of placeholder names found in the string. Each name is a string
20 | corresponding to the `param_name` inside `{}` brackets.
21 |
22 | Example:
23 | >>> url = "https://example.com/users/{user_id}/posts/{post_id}"
24 | >>> result = placeholders(url)
25 | ["user_id", "post_id"]
26 | """
27 | pattern = r'\{(\w+)\}'
28 |
29 | parameters = re.findall(pattern, url)
30 |
31 | return parameters
32 |
33 |
34 | def format_str(s: str, values: dict[str, Any], ignore_missed: bool = False) -> str:
35 | """
36 | Replaces placeholders in the string with corresponding values from the provided dictionary.
37 |
38 | This function searches the string for placeholders in the format `{param_name}` and replaces
39 | them with the value from the `values` dictionary where the key is `param_name`. If no corresponding
40 | value is found for a placeholder, it raises KeyError
41 |
42 | Args:
43 | s (str): String containing placeholders in the format `{param_name}`.
44 | values (dict[str, Any]): Dictionary where keys are parameter names and values are
45 | used to replace the placeholders in the URL.
46 | ignore_missed: Whether to ignore missed values
47 |
48 | Returns:
49 | str: String with placeholders replaced by corresponding values from the `values` dictionary.
50 |
51 | Raises
52 | KeyError: If no corresponding value is found for a placeholder
53 |
54 | Example:
55 | >>> url = "https://example.com/users/{user_id}/posts/{post_id}"
56 | >>> values = {"user_id": 42, "post_id": 1001}
57 | >>> format_str(url, values)
58 | https://example.com/users/42/posts/1001
59 | """
60 | try:
61 | return s.format(**values)
62 | except KeyError:
63 | if not ignore_missed:
64 | raise
65 | else:
66 | keys = placeholders(s)
67 | values = values | {k: f'{"{"+ k +"}"}' for k in keys}
68 | return s.format(**values)
69 |
70 |
71 | def normalize_url(url: str) -> str:
72 | parsed = urlparse(url)
73 |
74 | path = parsed.path.rstrip('/') if len(parsed.path) == 1 or not parsed.path.endswith('//') else parsed.path
75 |
76 | normalized_url = urlunparse(parsed._replace(path=path))
77 | return normalized_url # type: ignore
78 |
79 |
80 | def get_base_url(host: str, port: int) -> str:
81 | host = normalize_url(host)
82 | if 'port' in placeholders(host):
83 | api_url = format_str(host, {'port': port})
84 | elif port is not None:
85 | api_url = f'{host}:{port}'
86 | else:
87 | api_url = host
88 |
89 | return api_url
90 |
--------------------------------------------------------------------------------
/sensei/cases.py:
--------------------------------------------------------------------------------
1 | """
2 | Module containing case converters.
3 |
4 | **Case Converter** is a function that takes the string of one case and converts it to the string of another case
5 | and similar structure.
6 |
7 | Import them directly from Sensei:
8 |
9 | ```python
10 | from sensei import camel_case, snake_case, pascal_case, constant_case, kebab_case, header_case
11 | ```
12 |
13 | They can be applied at different levels:
14 |
15 | === "Router Level"
16 | ```python
17 | from sensei import Router, camel_case, snake_case
18 |
19 | router = Router(
20 | 'https://api.example.com',
21 | body_case=camel_case,
22 | response_case=snake_case
23 | )
24 |
25 | @router.post('/users')
26 | def create_user(first_name: str, birth_city: str, ...) -> User:
27 | pass
28 | ```
29 |
30 | === "Route Level"
31 | ```python
32 | from sensei import Router, camel_case, snake_case
33 |
34 | router = Router('https://api.example.com')
35 |
36 | @router.post('/users', body_case=camel_case, response_case=snake_case)
37 | def create_user(first_name: str, birth_city: str, ...) -> User:
38 | pass
39 | ```
40 |
41 | === "Routed Model Level"
42 |
43 | ```python
44 | router = Router(host, response_case=camel_case)
45 |
46 | class User(APIModel):
47 | def __header_case__(self, s: str) -> str:
48 | return kebab_case(s)
49 |
50 | @staticmethod
51 | def __response_case__(s: str) -> str:
52 | return snake_case(s)
53 |
54 | @classmethod
55 | @router.get('/users/{id_}')
56 | def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self: pass
57 | ```
58 | """
59 |
60 | import re
61 |
62 | __all__ = [
63 | 'snake_case',
64 | 'camel_case',
65 | 'pascal_case',
66 | 'constant_case',
67 | 'kebab_case',
68 | 'header_case'
69 | ]
70 |
71 |
72 | def snake_case(s: str) -> str:
73 | """
74 | Convert a string to the snake_case.
75 |
76 | Example:
77 | ```python
78 | print(snake_case('myParam'))
79 | ```
80 |
81 | ```text
82 | my_param
83 | ```
84 |
85 | Args:
86 | s (str): The string to convert.
87 | """
88 | s = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s)
89 | s = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s)
90 | s = re.sub(r'\W+', '_', s).lower()
91 | s = re.sub(r'_+', '_', s)
92 | return s
93 |
94 |
95 | def camel_case(s: str) -> str:
96 | """
97 | Convert a string to the camelCase.
98 |
99 | Example:
100 | ```python
101 | print(snake_case('my_param'))
102 | ```
103 |
104 | ```text
105 | myParam
106 | ```
107 |
108 | Args:
109 | s (str): The string to convert.
110 | """
111 | s = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s)
112 | s = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s)
113 | s = re.sub(r'\W+', '_', s)
114 | words = s.split('_')
115 | capitalized_words = [word.capitalize() for word in words]
116 | return capitalized_words[0].lower() + ''.join(capitalized_words[1:])
117 |
118 |
119 | def pascal_case(s: str) -> str:
120 | """
121 | Convert a string to the PascalCase.
122 |
123 | Example:
124 | ```python
125 | print(snake_case('my_param'))
126 | ```
127 |
128 | ```text
129 | MyParam
130 | ```
131 |
132 | Args:
133 | s (str): The string to convert.
134 | """
135 | s = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s)
136 | s = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s)
137 | s = re.sub(r'\W+', '_', s)
138 | words = s.split('_')
139 | capitalized_words = [word.capitalize() for word in words]
140 | return ''.join(capitalized_words)
141 |
142 |
143 | def constant_case(s: str) -> str:
144 | """
145 | Convert a string to the CONSTANT_CASE.
146 |
147 | Example:
148 | ```python
149 | print(snake_case('myParam'))
150 | ```
151 |
152 | ```text
153 | MY_PARAM
154 | ```
155 |
156 | Args:
157 | s (str): The string to convert.
158 | """
159 | s = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', s)
160 | s = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s)
161 | s = re.sub(r'[\W_]+', '_', s)
162 | return s.upper()
163 |
164 |
165 | def kebab_case(s: str) -> str:
166 | """
167 | Convert a string to the kebab-case.
168 |
169 | Example:
170 | ```python
171 | print(snake_case('myParam'))
172 | ```
173 |
174 | ```text
175 | my-param
176 | ```
177 |
178 | Args:
179 | s (str): The string to convert.
180 | """
181 | s = re.sub(r"(\s|_|-)+", " ", s)
182 | s = re.sub(r"[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+",
183 | lambda mo: ' ' + mo.group(0).lower(), s)
184 | s = '-'.join(s.split())
185 | return s
186 |
187 |
188 | def header_case(s: str) -> str:
189 | """
190 | Convert a string to Header-Case.
191 |
192 | Example:
193 | ```python
194 | print(snake_case('myParam'))
195 | ```
196 |
197 | ```text
198 | My-Param
199 | ```
200 |
201 | Args:
202 | s (str): The string to convert.
203 | """
204 | s = re.sub('(.)([A-Z][a-z]+)', r'\1 \2', s)
205 | s = re.sub('([a-z0-9])([A-Z])', r'\1 \2', s)
206 | s = re.sub(r'[_\W]+', ' ', s)
207 | words = s.split()
208 | capitalized_words = [word.capitalize() for word in words]
209 | return '-'.join(capitalized_words)
210 |
--------------------------------------------------------------------------------
/sensei/client/__init__.py:
--------------------------------------------------------------------------------
1 | from .manager import Manager
2 | from .rate_limiter import RateLimit
3 |
--------------------------------------------------------------------------------
/sensei/client/exceptions.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from typing import Protocol
3 |
4 |
5 | class _NamedObj(Protocol):
6 | __name__ = ...
7 |
8 |
9 | class CollectionLimitError(ValueError):
10 | def __init__(self, collection: _NamedObj, elements: Iterable[tuple[int, _NamedObj]]):
11 | super().__init__(
12 | f'{collection.__name__} size limit exceeded. '
13 | f'It can contain only '
14 | f'{", ".join([str(limit) + " " + cls.__name__ + ("s" if limit != 1 else "") for limit, cls in elements])}.'
15 | )
16 |
--------------------------------------------------------------------------------
/sensei/client/manager.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from httpx._client import Client, AsyncClient, BaseClient
4 |
5 | from .exceptions import CollectionLimitError
6 |
7 |
8 | class Manager:
9 | __slots__ = ('_sync_client', '_async_client', '_required')
10 |
11 | def __init__(
12 | self,
13 | sync_client: Optional[Client] = None,
14 | async_client: Optional[AsyncClient] = None,
15 | *,
16 | required: bool = True,
17 | ) -> None:
18 | """
19 | This class serves as a bridge between the application and Sensei, to dynamically provide a client for
20 | routed function calls.
21 | It separately stores `httpx.AsyncClient` and `httpx.Client`.
22 | To use `Manager`, you need to create it and pass it to the router.
23 |
24 | Import it directly from Sensei:
25 |
26 | ```python
27 | from sensei import Manager
28 | ```
29 |
30 | Example:
31 | ```python
32 | from sensei import Manager, Router, Client
33 |
34 | manager = Manager()
35 | router = Router('httpx://example-api.com', manager=manager)
36 |
37 | @router.get('/users/{id_}')
38 | def get_user(id_: int) -> User:
39 | pass
40 |
41 | with Client(base_url=router.base_url) as client:
42 | manager.set(client)
43 | user = get_user(1)
44 | print(user)
45 | manager.pop()
46 | ```
47 |
48 | Args:
49 | sync_client (Client): An instance of `httpx.Client`.
50 | async_client (AsyncClient): An instance of `httpx.AsyncClient`.
51 | required (bool): Whether to throw the error in `get` if a client is not set
52 |
53 | Raises:
54 | TypeError: If the provided client is not an instance of AsyncClient or Client.
55 | """
56 | self._sync_client = self._validate_client(sync_client, True)
57 | self._async_client = self._validate_client(async_client, True, True)
58 | self._required = required
59 |
60 | @staticmethod
61 | def _validate_client(client: BaseClient, nullable: bool = False, is_async: bool = False) -> Optional[BaseClient]:
62 | if nullable and client is None:
63 | return client
64 | elif is_async and not isinstance(client, AsyncClient):
65 | raise TypeError(f"Client must be an instance of {AsyncClient}.")
66 | elif not is_async and not isinstance(client, Client):
67 | raise TypeError(f"Client must be an instance of {Client}.")
68 |
69 | return client
70 |
71 | def _get_client(self, is_async: bool, pop: bool = False, required: bool = False) -> Optional[BaseClient]:
72 | if is_async:
73 | set_client = self._async_client
74 | if pop:
75 | self._async_client = None
76 | else:
77 | set_client = self._sync_client
78 | if pop:
79 | self._sync_client = None
80 |
81 | if set_client is None and required:
82 | client_type = AsyncClient if is_async else Client
83 | raise AttributeError(f"{client_type} is not set")
84 |
85 | return set_client
86 |
87 | def set(self, client: BaseClient) -> None:
88 | """
89 | Set a client instance in the manager if no client is currently set. If a client of the provided type is
90 | already set, it raises a `CollectionLimitError`.
91 |
92 | Example:
93 | ```python
94 | from sensei import Manager, Router, Client, AsyncClient
95 |
96 | manager = Manager()
97 | router = Router('httpx://example-api.com', manager=manager)
98 |
99 | client = Client(base_url=router.base_url)
100 | aclient = AsyncClient(base_url=router.base_url)
101 |
102 | manager.set(client)
103 | manager.set(aclient)
104 | ```
105 |
106 | Args:
107 | client (BaseClient): The client instance to set.
108 |
109 | Raises:
110 | CollectionLimitError: If a client of the provided type is already set.
111 | TypeError: If the provided client is not an instance of AsyncClient or Client.
112 | """
113 | is_async = isinstance(client, AsyncClient)
114 | set_client = self._get_client(is_async)
115 |
116 | if set_client is None:
117 | if is_async:
118 | self._async_client = self._validate_client(client, is_async=True)
119 | else:
120 | self._sync_client = self._validate_client(client)
121 | else:
122 | raise CollectionLimitError(self.__class__, [(1, Client), (1, AsyncClient)])
123 |
124 | def pop(self, is_async: bool = False) -> BaseClient:
125 | """
126 | Remove and return the currently set client.
127 |
128 | Example:
129 | ```python
130 | manager = Manager()
131 |
132 | manager = Manager(sync_client=client, async_client=aclient)
133 | client = manager.pop(is_async=False)
134 | aclient = manager.pop(is_async=True)
135 | print(client, aclient)
136 | ```
137 |
138 | Args:
139 | is_async (bool): Whether client instance is async
140 |
141 | Returns:
142 | BaseClient: The client instance that was managed.
143 |
144 | Raises:
145 | AttributeError: If no client is set.
146 | """
147 | set_client = self._get_client(is_async, True, True)
148 | return set_client
149 |
150 | def empty(self, is_async: bool = False) -> bool:
151 | """
152 | Check if the manager has a client of the provided type set.
153 |
154 | Example:
155 | ```python
156 | manager = Manager()
157 |
158 | manager = Manager(sync_client=client)
159 | manager.pop()
160 | print(manager.empty()) # Output: True
161 | ```
162 |
163 | Args:
164 | is_async (bool): Whether client instance is async
165 |
166 | Returns:
167 | bool: True if no client is set, False otherwise.
168 | """
169 | set_client = self._get_client(is_async)
170 |
171 | return set_client is None
172 |
173 | def get(self, is_async: bool = False) -> BaseClient:
174 | """
175 | Retrieve the currently set client of the provided type.
176 | This method returns the managed client without removing it.
177 |
178 | Example:
179 | ```python
180 | manager = Manager()
181 |
182 | manager = Manager(sync_client=client, async_client=aclient)
183 | client = manager.get(is_async=False)
184 | aclient = manager.get(is_async=True)
185 | print(client, aclient)
186 | ```
187 |
188 | Args:
189 | is_async (bool): Whether client instance is async
190 |
191 | Returns:
192 | BaseClient: The client instance that is being managed.
193 |
194 | Raises:
195 | AttributeError: If no client is set.
196 | """
197 | set_client = self._get_client(is_async, required=self._required)
198 |
199 | return set_client
200 |
--------------------------------------------------------------------------------
/sensei/client/rate_limiter.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import threading
3 | from abc import ABC, abstractmethod
4 | from time import time, sleep
5 |
6 | from sensei.types import IRateLimit
7 |
8 |
9 | class RateLimit(IRateLimit):
10 | """
11 | The class that manages rate limiting by maintaining tokens and enforcing rate limits.
12 | This class implements a [token bucket](https://en.wikipedia.org/wiki/Token_bucket){.external-link}
13 | rate-limiting system.
14 |
15 | Example:
16 | ```python
17 | from sensei import RateLimit, Router
18 |
19 | calls, period = 1, 1
20 | rate_limit = RateLimit(calls, period)
21 | router = Router('https://example-api.com', rate_limit=rate_limit)
22 |
23 | @router.get('/users/{id_}')
24 | def get_user(id_: int) -> User:
25 | pass
26 |
27 | for i in range(5):
28 | get_user(i) # Here code will be paused for 1 second after each iteration
29 | ```
30 |
31 | Args:
32 | calls (int): The maximum number of requests allowed per period.
33 | period (int): The time period in seconds for the rate limit.
34 | """
35 |
36 | __slots__ = "_tokens", "_last_checked", "_async_lock", "_thread_lock"
37 |
38 | def __init__(self, calls: int, period: int) -> None:
39 | super().__init__(calls, period)
40 | self._tokens: int = calls
41 | self._last_checked: float = time()
42 | self._async_lock: asyncio.Lock = asyncio.Lock()
43 | self._thread_lock: threading.Lock = threading.Lock()
44 |
45 | def __acquire(self) -> bool:
46 | now: float = time()
47 | elapsed: float = now - self._last_checked
48 |
49 | self._tokens += int(elapsed / self._period * self._calls)
50 | self._tokens = min(self._tokens, self._calls)
51 | self._last_checked = now
52 |
53 | if self._tokens > 0:
54 | self._tokens -= 1
55 | return True
56 | else:
57 | return False
58 |
59 | async def _async_acquire(self) -> bool:
60 | """
61 | Asynchronously attempt to acquire a token.
62 |
63 | Returns:
64 | bool: True if a token was acquired, False otherwise.
65 | """
66 | async with self._async_lock:
67 | return self.__acquire()
68 |
69 | async def async_wait_for_slot(self) -> None:
70 | """Asynchronously wait until a slot becomes available by periodically attempting to acquire a token."""
71 | while not await self._async_acquire():
72 | await asyncio.sleep(self._period / self._calls)
73 |
74 | def _acquire(self) -> bool:
75 | """
76 | Synchronously attempt to acquire a token.
77 |
78 | Returns:
79 | bool: True if a token was acquired, False otherwise.
80 | """
81 | with self._thread_lock:
82 | return self.__acquire()
83 |
84 | def wait_for_slot(self) -> None:
85 | """Synchronously wait until a slot becomes available by periodically attempting to acquire a token."""
86 | while not self._acquire():
87 | sleep(self._period / self._calls)
88 |
89 |
90 | class _BaseLimiter(ABC):
91 | def __init__(self, rate_limit: IRateLimit) -> None:
92 | self._rate_limit: IRateLimit = rate_limit
93 |
94 | @abstractmethod
95 | def wait_for_slot(self) -> None:
96 | pass
97 |
98 |
99 | class AsyncRateLimiter(_BaseLimiter):
100 | """
101 | Asynchronous rate limiter that manages request rate limiting using async methods.
102 |
103 | Args:
104 | rate_limit (IRateLimit): An instance of RateLimit to share between limiters.
105 | """
106 |
107 | def __init__(self, rate_limit: IRateLimit) -> None:
108 | super().__init__(rate_limit)
109 |
110 | async def wait_for_slot(self) -> None:
111 | """Asynchronously wait until a slot becomes available by periodically acquiring a token."""
112 | await self._rate_limit.async_wait_for_slot()
113 |
114 |
115 | class RateLimiter(_BaseLimiter):
116 | """
117 | Synchronous rate limiter that manages request rate limiting using synchronous methods.
118 |
119 | Args:
120 | rate_limit (IRateLimit): An instance of RateLimit to share between limiters.
121 | """
122 |
123 | def __init__(self, rate_limit: IRateLimit) -> None:
124 | super().__init__(rate_limit)
125 |
126 | def wait_for_slot(self) -> None:
127 | """Synchronously wait until a slot becomes available by periodically acquiring a token."""
128 | self._rate_limit.wait_for_slot()
129 |
--------------------------------------------------------------------------------
/sensei/types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from abc import ABC, abstractmethod
4 | from typing import Protocol, Mapping, Any, Union
5 |
6 | from httpx import AsyncClient, Client
7 | from typing_extensions import Self
8 |
9 | Json = Union[dict, list[dict]]
10 |
11 |
12 | class IRateLimit(ABC):
13 | __slots__ = "_calls", "_period"
14 |
15 | def __init__(self, calls: int, period: int) -> None:
16 | """
17 | The interface that can be used to implement a custom rate limiting system.
18 |
19 | The following methods have to be implemented:
20 |
21 | - async_wait_for_slot
22 | - wait_for_slot
23 |
24 | Example:
25 | ```python
26 | from sensei.types import IRateLimit
27 |
28 | class CustomLimit(IRateLimit):
29 | async def async_wait_for_slot(self) -> None:
30 | ...
31 |
32 | def wait_for_slot(self) -> None:
33 | ...
34 | ```
35 |
36 | Args:
37 | calls (int): The maximum number of requests allowed per period.
38 | period (int): The time period in seconds for the rate limit.
39 | """
40 | self._calls: int = calls
41 | self._period: int = period
42 |
43 | @property
44 | def period(self) -> int:
45 | return self._period
46 |
47 | @period.setter
48 | def period(self, period: int) -> None:
49 | self._period = period
50 |
51 | @property
52 | def calls(self):
53 | return self._calls
54 |
55 | @calls.setter
56 | def calls(self, rate_limit: int) -> None:
57 | self._calls = rate_limit
58 |
59 | @abstractmethod
60 | async def async_wait_for_slot(self) -> None:
61 | """
62 | Wait until a slot becomes available.
63 | """
64 | pass
65 |
66 | @abstractmethod
67 | def wait_for_slot(self) -> None:
68 | """
69 | Wait until a slot becomes.
70 | """
71 | pass
72 |
73 | def _rate(self) -> float:
74 | return self._calls / self._period
75 |
76 | def __eq__(self, other: "IRateLimit") -> bool:
77 | return self._rate() == other._rate()
78 |
79 | def __lt__(self, other: "IRateLimit") -> bool:
80 | return self._rate() < other._rate()
81 |
82 | def __le__(self, other: "IRateLimit") -> bool:
83 | return self._rate() <= other._rate()
84 |
85 | def __gt__(self, other: "IRateLimit") -> bool:
86 | return self._rate() > other._rate()
87 |
88 | def __ge__(self, other: "IRateLimit") -> bool:
89 | return self._rate() >= other._rate()
90 |
91 |
92 | class IRequest(Protocol):
93 | @property
94 | def headers(self) -> Mapping[str, Any]:
95 | pass
96 |
97 | @property
98 | def method(self) -> str:
99 | pass
100 |
101 | @property
102 | def url(self) -> Any:
103 | pass
104 |
105 |
106 | class IResponse(Protocol):
107 | __slots__ = ()
108 |
109 | def __await__(self):
110 | pass
111 |
112 | def json(self) -> Json:
113 | pass
114 |
115 | def raise_for_status(self) -> Self:
116 | pass
117 |
118 | @property
119 | def request(self) -> IRequest:
120 | pass
121 |
122 | @property
123 | def text(self) -> str:
124 | pass
125 |
126 | @property
127 | def status_code(self) -> int:
128 | pass
129 |
130 | @property
131 | def content(self) -> bytes:
132 | pass
133 |
134 | @property
135 | def headers(self) -> Mapping[str, Any]:
136 | pass
137 |
138 |
139 | BaseClient = Union[AsyncClient, Client]
140 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CrocoFactory/sensei/8fbceda15da9b8c84ef7ffe1f78deb9c2eeb69c5/tests/__init__.py
--------------------------------------------------------------------------------
/tests/base_user.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from abc import ABC, abstractmethod
3 | from typing import Annotated, Literal, Any, Union, List
4 |
5 | from pydantic import BaseModel, EmailStr
6 | from typing_extensions import Self
7 |
8 | from sensei import Query, Path, Body
9 |
10 |
11 | class UserCredentials(BaseModel):
12 | email: EmailStr
13 | password: str
14 |
15 |
16 | class BaseUser(ABC):
17 | email: str
18 | id: int
19 | first_name: str
20 | last_name: str
21 | avatar: str
22 |
23 | @classmethod
24 | @abstractmethod
25 | def list(
26 | cls,
27 | page: Annotated[int, Query(1)] = 1,
28 | per_page: Annotated[int, Query(3, le=7)] = 3
29 | ) -> list[Self]:
30 | ...
31 |
32 | @classmethod
33 | @abstractmethod
34 | def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self: ...
35 |
36 | @abstractmethod
37 | def delete(self) -> Self: ...
38 |
39 | @abstractmethod
40 | def login(self) -> str: ...
41 |
42 | @abstractmethod
43 | def update(
44 | self,
45 | name: Annotated[str, Query()],
46 | job: Annotated[str, Query()]
47 | ) -> datetime.datetime:
48 | ...
49 |
50 | @abstractmethod
51 | def change(
52 | self,
53 | name: Annotated[str, Query()],
54 | job: Annotated[str, Query()]
55 | ) -> bytes:
56 | ...
57 |
58 | @classmethod
59 | @abstractmethod
60 | def sign_up(
61 | cls,
62 | user: Annotated[UserCredentials, Body(embed=True, media_type='application/x-www-form-urlencoded')]
63 | ) -> str:
64 | ...
65 |
66 | @classmethod
67 | @abstractmethod
68 | def user_headers(cls) -> dict[str, Any]: ...
69 |
70 | @classmethod
71 | @abstractmethod
72 | def allowed_http_methods(cls) -> List[str]: ...
73 |
74 | @abstractmethod
75 | def model_dump(
76 | self,
77 | *,
78 | mode: Union[Literal['json', 'python'], str] = 'python',
79 | by_alias: bool = False,
80 | exclude_unset: bool = False,
81 | exclude_defaults: bool = False,
82 | exclude_none: bool = False,
83 | ) -> dict[str, Any]:
84 | pass
85 |
86 | @classmethod
87 | def test_validate(cls, obj: Self) -> bool:
88 | result = obj.model_dump(mode='json').keys()
89 | desired = cls.__annotations__.keys()
90 | return isinstance(obj, cls) and result == desired
91 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import datetime
3 | from typing import Any, Callable, Annotated, List
4 |
5 | import pytest
6 | from httpx import Response
7 | from pydantic import EmailStr, PositiveInt, AnyHttpUrl
8 | from typing_extensions import Self
9 |
10 | from sensei import Router, APIModel, Args, snake_case, Query, Path, format_str, Body
11 | from .base_user import BaseUser, UserCredentials
12 |
13 |
14 | @pytest.fixture(scope="session")
15 | def base_url() -> str:
16 | return 'https://reqres.in/api'
17 |
18 |
19 | @pytest.fixture()
20 | def router(base_url) -> Router:
21 | router = Router(base_url)
22 | return router
23 |
24 |
25 | @pytest.fixture()
26 | def base_maker() -> Callable[[Router], type[APIModel]]:
27 | def model_base(router) -> type[APIModel]:
28 | class BaseModel(APIModel):
29 | @classmethod
30 | def __finalize_json__(cls, json: dict[str, Any]) -> dict[str, Any]:
31 | return json['data']
32 |
33 | @classmethod
34 | def __prepare_args__(cls, args: Args) -> Args:
35 | args.headers['X-Token'] = 'secret_token'
36 | return args
37 |
38 | @classmethod
39 | def __response_case__(cls, s: str) -> str:
40 | return snake_case(s)
41 |
42 | return BaseModel
43 | return model_base
44 |
45 |
46 | @pytest.fixture
47 | def sync_maker() -> Callable[[Router, type[APIModel]], type[BaseUser]]:
48 | def make_model(router: Router, base: type[APIModel]) -> type[BaseUser]:
49 | class User(base, BaseUser):
50 | email: EmailStr
51 | id: PositiveInt
52 | first_name: str
53 | last_name: str
54 | avatar: AnyHttpUrl
55 |
56 | @classmethod
57 | @router.get('/users')
58 | def list(
59 | cls,
60 | page: Annotated[int, Query()] = 1,
61 | per_page: Annotated[int, Query(le=7)] = 3
62 | ) -> list[Self]:
63 | ...
64 |
65 | @classmethod
66 | @router.get('/users/{id_}')
67 | def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self: ...
68 |
69 | @router.delete('/users/{id_}')
70 | def delete(self) -> Self: ...
71 |
72 | @delete.prepare
73 | def _delete_in(self, args: Args) -> Args:
74 | url = args.url
75 | url = format_str(url, {'id_': self.id})
76 | args.url = url
77 | return args
78 |
79 | @router.post('/token')
80 | def login(self) -> str: ...
81 |
82 | @login.prepare
83 | def _login_in(self, args: Args) -> Args:
84 | args.json_['email'] = self.email
85 | return args
86 |
87 | @login.finalize
88 | def _login_out(self, response: Response) -> str:
89 | return response.json()['token']
90 |
91 | @router.patch('/users/{id_}', skip_finalizer=True)
92 | def update(
93 | self,
94 | name: str,
95 | job: str
96 | ) -> datetime.datetime:
97 | ...
98 |
99 | @update.prepare
100 | def _update_in(self, args: Args) -> Args:
101 | args.url = format_str(args.url, {'id_': self.id})
102 | return args
103 |
104 | @update.finalize()
105 | def _update_out(self, response: Response) -> datetime.datetime:
106 | json_ = response.json()
107 | result = datetime.datetime.strptime(json_['updated_at'], "%Y-%m-%dT%H:%M:%S.%fZ")
108 | self.first_name = json_['name']
109 | return result
110 |
111 | @router.put('/users/{id_}', skip_finalizer=True)
112 | def change(
113 | self,
114 | name: Annotated[str, Query()],
115 | job: Annotated[str, Query()]
116 | ) -> bytes:
117 | ...
118 |
119 | @change.prepare
120 | def _change_in(self, args: Args) -> Args:
121 | args.url = format_str(args.url, {'id_': self.id})
122 | return args
123 |
124 | @classmethod
125 | @router.post('/register', skip_finalizer=True)
126 | def sign_up(
127 | cls,
128 | user: Annotated[UserCredentials, Body(embed=False, media_type='application/x-www-form-urlencoded')]
129 | ) -> str:
130 | ...
131 |
132 | @classmethod
133 | @sign_up.finalize
134 | def _sign_up_out(cls, response: Response) -> str:
135 | return response.json()['token']
136 |
137 | @classmethod
138 | @router.head('/users')
139 | def user_headers(cls) -> dict[str, Any]: ...
140 |
141 | @classmethod
142 | @router.options('/users')
143 | def allowed_http_methods(cls) -> List[str]: ...
144 |
145 | @allowed_http_methods.finalize
146 | def _allowed_http_methods_out(self, response: Response) -> List[str]:
147 | headers = response.headers
148 | return headers['access-control-allow-methods'].split(',')
149 |
150 | return User
151 | return make_model
152 |
153 |
154 | @pytest.fixture
155 | def async_maker() -> Callable[[Router, type[APIModel]], type[BaseUser]]:
156 | def make_model(router: Router, base: type[APIModel]) -> type[BaseUser]:
157 | class User(base, BaseUser):
158 | email: EmailStr
159 | id: PositiveInt
160 | first_name: str
161 | last_name: str
162 | avatar: AnyHttpUrl
163 |
164 | @classmethod
165 | @router.get('/users')
166 | async def list(
167 | cls,
168 | page: Annotated[int, Query()] = 1,
169 | per_page: Annotated[int, Query(le=7)] = 3
170 | ) -> list[Self]:
171 | ...
172 |
173 | @classmethod
174 | @router.get('/users/{id_}')
175 | async def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self: ...
176 |
177 | @router.delete('/users/{id_}')
178 | async def delete(self) -> Self: ...
179 |
180 | @delete.prepare
181 | async def _delete_in(self, args: Args) -> Args:
182 | url = args.url
183 | url = format_str(url, {'id_': self.id})
184 | args.url = url
185 | return args
186 |
187 | @router.post('/token')
188 | async def login(self) -> str: ...
189 |
190 | @login.prepare
191 | async def _login_in(self, args: Args) -> Args:
192 | args.json_['email'] = self.email
193 | return args
194 |
195 | @login.finalize
196 | async def _login_out(self, response: Response) -> str:
197 | return response.json()['token']
198 |
199 | @router.patch('/users/{id_}', skip_finalizer=True)
200 | async def update(
201 | self,
202 | name: str,
203 | job: str
204 | ) -> datetime.datetime:
205 | ...
206 |
207 | @update.prepare
208 | async def _update_in(self, args: Args) -> Args:
209 | args.url = format_str(args.url, {'id_': self.id})
210 | await asyncio.sleep(1.5)
211 | return args
212 |
213 | @update.finalize
214 | async def _update_out(self, response: Response) -> datetime.datetime:
215 | json_ = response.json()
216 | result = datetime.datetime.strptime(json_['updated_at'], "%Y-%m-%dT%H:%M:%S.%fZ")
217 | await asyncio.sleep(1.5)
218 | self.first_name = json_['name']
219 | return result
220 |
221 | @router.put('/users/{id_}', skip_finalizer=True)
222 | async def change(
223 | self,
224 | name: Annotated[str, Query()],
225 | job: Annotated[str, Query()]
226 | ) -> bytes:
227 | ...
228 |
229 | @change.prepare
230 | def _change_in(self, args: Args) -> Args:
231 | args.url = format_str(args.url, {'id_': self.id})
232 | return args
233 |
234 | @classmethod
235 | @router.post('/register', skip_finalizer=True)
236 | async def sign_up(
237 | cls,
238 | user: Annotated[UserCredentials, Body(embed=False, media_type='application/x-www-form-urlencoded')]
239 | ) -> str:
240 | ...
241 |
242 | @classmethod
243 | @sign_up.finalize
244 | async def _sign_up_out(cls, response: Response) -> str:
245 | return response.json()['token']
246 |
247 | @classmethod
248 | @router.head('/users')
249 | async def user_headers(cls) -> dict[str, Any]: ...
250 |
251 | @classmethod
252 | @router.options('/users')
253 | async def allowed_http_methods(cls) -> List[str]: ...
254 |
255 | @allowed_http_methods.finalize
256 | async def _allowed_http_methods_out(self, response: Response) -> List[str]:
257 | headers = response.headers
258 | return headers['access-control-allow-methods'].split(',')
259 |
260 | return User
261 | return make_model
262 |
263 |
--------------------------------------------------------------------------------
/tests/mock_api.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import time
4 | from abc import ABC, abstractmethod
5 | from enum import Enum
6 | from http import HTTPStatus as Status
7 | from typing import Callable
8 | from urllib.parse import parse_qs
9 |
10 | import jwt
11 | import requests
12 | from httpx import Response, Request
13 | from pydantic import BaseModel
14 | from respx import MockRouter
15 |
16 | Responser = Callable[[Request], Response]
17 | SECRET_TOKEN = os.urandom(32).hex()
18 | JWT_ALGORITHM = 'HS256'
19 |
20 |
21 | def get_jwt_token(email: str) -> str:
22 | payload = {
23 | 'sub': email,
24 | 'exp': time.time() + 10 * 60
25 | }
26 | token = jwt.encode(payload, SECRET_TOKEN, algorithm=JWT_ALGORITHM)
27 | return token
28 |
29 |
30 | def form_to_json(form_str: str) -> dict:
31 | parsed_dict = {k: v[0] for k, v in parse_qs(form_str).items()}
32 | return parsed_dict
33 |
34 |
35 | class _HTTPMethod(str, Enum):
36 | GET = "GET"
37 | POST = "POST"
38 | PUT = "PUT"
39 | DELETE = "DELETE"
40 | PATCH = "PATCH"
41 | HEAD = "HEAD",
42 | OPTIONS = "OPTIONS"
43 | CONNECT = "CONNECT"
44 | TRACE = "TRACE"
45 |
46 | def __str__(self) -> str:
47 | return self.value
48 |
49 |
50 | class Endpoint(BaseModel):
51 | responser: Responser
52 | method: _HTTPMethod
53 | path: str
54 |
55 |
56 | class _Endpoint(ABC):
57 | endpoints: list[Endpoint] = []
58 |
59 | def __init_subclass__(cls, **kwargs):
60 | super().__init_subclass__(**kwargs)
61 | _Endpoint.endpoints.append(Endpoint(
62 | responser=cls.responser,
63 | method=cls.method(), # type: ignore
64 | path=cls.path() # type: ignore
65 | ))
66 |
67 | @staticmethod
68 | @abstractmethod
69 | def responser(request: Request) -> Response:
70 | pass
71 |
72 | @staticmethod
73 | @abstractmethod
74 | def method() -> str:
75 | pass
76 |
77 | @staticmethod
78 | @abstractmethod
79 | def path() -> str:
80 | pass
81 |
82 |
83 | class Token(_Endpoint):
84 | @staticmethod
85 | def responser(request: Request) -> Response:
86 | json_ = json.loads(request.content.decode())
87 | email = json_['email']
88 | token = get_jwt_token(email)
89 | return Response(status_code=Status.CREATED.value, json={'data': {'token': token}})
90 |
91 | @staticmethod
92 | def method() -> str:
93 | return "POST"
94 |
95 | @staticmethod
96 | def path() -> str:
97 | return "/token"
98 |
99 |
100 | class UploadImage(_Endpoint):
101 | @staticmethod
102 | def responser(request: Request) -> Response:
103 | image = request.content
104 | res = requests.post('"https://httpbin.org/post"', )
105 |
106 | if not image:
107 | return Response(status_code=Status.BAD_REQUEST)
108 | return Response(status_code=Status.CREATED, json={'icon': str(image)[2:-1]})
109 |
110 | @staticmethod
111 | def method() -> str:
112 | return "POST"
113 |
114 | @staticmethod
115 | def path() -> str:
116 | return "/upload_image"
117 |
118 |
119 | class Register(_Endpoint):
120 | @staticmethod
121 | def responser(request: Request) -> Response:
122 | user = form_to_json(request.content.decode())
123 |
124 | token = get_jwt_token(user['email'])
125 | return Response(status_code=Status.CREATED, json={'token': token})
126 |
127 | @staticmethod
128 | def method() -> str:
129 | return "POST"
130 |
131 | @staticmethod
132 | def path() -> str:
133 | return "/register"
134 |
135 |
136 | def mock_api(router: MockRouter, base_url: str, path: str) -> None:
137 | api = _Endpoint.endpoints
138 |
139 | for endpoint in api:
140 | if path == endpoint.path:
141 | url = base_url + endpoint.path
142 | router.request(method=endpoint.method, url=url).mock(side_effect=endpoint.responser)
143 | break
144 | else:
145 | raise ValueError('Path not found')
146 |
--------------------------------------------------------------------------------
/tests/test_api_model.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Annotated
2 |
3 | from httpx import Response
4 |
5 | from sensei import APIModel, Args, snake_case, camel_case, kebab_case, header_case, Cookie, Body, Header, \
6 | constant_case, Router
7 |
8 |
9 | class TestAPIModel:
10 | def test_str(self):
11 | class Validation(APIModel):
12 | attr: int
13 |
14 | obj = Validation(attr=1)
15 | assert str(obj) == "Validation(attr=1)"
16 |
17 | def test_hooks(self, base_url, base_maker):
18 | router = Router(base_url, default_case=camel_case)
19 |
20 | class Base(APIModel):
21 | @classmethod
22 | def __finalize_json__(cls, json: dict[str, Any]) -> dict[str, Any]:
23 | return json['data']
24 |
25 | @classmethod
26 | def __prepare_args__(cls, args: Args) -> Args:
27 | args.headers['X-Token'] = 'secret_token'
28 | return args
29 |
30 | @staticmethod
31 | def __header_case__(s: str) -> str:
32 | return kebab_case(s)
33 |
34 | @staticmethod
35 | def __response_case__(s: str) -> str:
36 | return snake_case(s)
37 |
38 | class Validation(Base):
39 | @classmethod
40 | def __cookie_case__(cls, s: str) -> str:
41 | return header_case(s)
42 |
43 | @classmethod
44 | @router.patch('/users/{id_}', header_case=constant_case, skip_finalizer=True)
45 | def update(
46 | cls,
47 | id_: int,
48 | my_cookie: Annotated[str, Cookie()],
49 | my_body: Annotated[str, Body()],
50 | my_extra: Annotated[str, Body(alias='My-Extra')],
51 | m_token: Annotated[str, Header()],
52 | ) -> dict: ...
53 |
54 | @classmethod
55 | @update.prepare()
56 | def _get_in(cls, args: Args) -> Args:
57 | assert args.cookies.get('My-Cookie')
58 | assert args.json_.get('myBody')
59 | assert args.headers.get('M_TOKEN')
60 | assert args.json_.get('My-Extra')
61 | return args
62 |
63 | @classmethod
64 | @update.finalize
65 | def _get_out(cls, response: Response) -> dict:
66 | json_ = response.json()
67 | for key in json_:
68 | assert snake_case(key) == key
69 | return json_
70 |
71 | res = Validation.update(1, 'cookie', 'body', 'extra', 'header')
72 | assert isinstance(res, dict)
73 |
74 | def test_decorating_methods(self, router, base_maker):
75 | base = base_maker(router)
76 |
77 | class Model(base):
78 | def method(self) -> str:
79 | return 'My Method'
80 |
81 | @router.get('/users')
82 | def routed(self): ...
83 |
84 | assert getattr(Model.method, 'finalize', None) is None
85 | assert getattr(Model.routed, 'prepare', None) is not None
86 |
--------------------------------------------------------------------------------
/tests/test_async.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import jwt
4 | import pytest
5 | import respx
6 | from pydantic_core import ValidationError
7 |
8 | from tests.base_user import BaseUser, UserCredentials
9 | from tests.mock_api import mock_api, SECRET_TOKEN, JWT_ALGORITHM
10 |
11 |
12 | class TestAsync:
13 | @pytest.fixture
14 | def user_model(self, base_maker, async_maker, router) -> type[BaseUser]:
15 | base = base_maker(router)
16 | return async_maker(router, base)
17 |
18 | @pytest.mark.asyncio
19 | async def test_get(self, user_model):
20 | user = await user_model.get(1) # type: ignore
21 | assert user_model.test_validate(user)
22 |
23 | @pytest.mark.asyncio
24 | async def test_list(self, user_model):
25 | users = await user_model.list(per_page=6) # type: ignore
26 | assert all(user_model.test_validate(user) for user in users)
27 |
28 | with pytest.raises(ValidationError):
29 | await user_model.list(per_page=9) # type: ignore
30 |
31 | @pytest.mark.asyncio
32 | async def test_delete(self, user_model):
33 | user = await user_model.get(1) # type: ignore
34 | assert user_model.test_validate(await user.delete())
35 |
36 | user = await user_model.get(1) # type: ignore
37 | user.id = -100
38 |
39 | with pytest.raises(ValidationError):
40 | await user.delete() # type: ignore
41 |
42 | @pytest.mark.asyncio
43 | async def test_login(self, user_model, base_url):
44 | user = await user_model.get(1) # type: ignore
45 |
46 | async with respx.mock() as mock:
47 | mock_api(mock, base_url, '/token')
48 | token = await user.login() # type: ignore
49 |
50 | assert isinstance(token, str)
51 |
52 | payload = jwt.decode(token, SECRET_TOKEN, algorithms=JWT_ALGORITHM)
53 | assert payload['sub'] == user.email
54 |
55 | @pytest.mark.asyncio
56 | async def test_update(self, user_model):
57 | user = await user_model.get(1) # type: ignore
58 |
59 | res = await user.update(name="Brandy", job="Data Scientist") # type: ignore
60 | assert user.first_name == "Brandy"
61 | assert isinstance(res, datetime.datetime)
62 |
63 | @pytest.mark.asyncio
64 | async def test_change(self, user_model):
65 | user = await user_model.get(1) # type: ignore
66 | res = await user.change(name="Brandy", job="Data Scientist") # type: ignore
67 | assert isinstance(res, bytes)
68 |
69 | @pytest.mark.asyncio
70 | async def test_sign_up(self, user_model, base_url):
71 | email = "helloworld@gmail.com"
72 |
73 | with respx.mock() as mock:
74 | mock_api(mock, base_url, '/register')
75 | token = await user_model.sign_up(UserCredentials(email=email, password="mypassword")) # type: ignore
76 |
77 | assert isinstance(token, str)
78 |
79 | payload = jwt.decode(token, SECRET_TOKEN, algorithms=JWT_ALGORITHM)
80 | assert payload['sub'] == email
81 |
82 | @pytest.mark.asyncio
83 | async def test_user_headers(self, user_model):
84 | headers = await user_model.user_headers() # type: ignore
85 | assert isinstance(headers, dict)
86 |
87 | @pytest.mark.asyncio
88 | async def test_allowed_methods(self, user_model):
89 | methods = sorted(['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'])
90 | assert methods == sorted(await user_model.allowed_http_methods()) # type: ignore
91 |
--------------------------------------------------------------------------------
/tests/test_cases.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from sensei import snake_case, camel_case, pascal_case, constant_case, kebab_case, header_case
3 |
4 |
5 | class TestCases:
6 | @pytest.fixture(scope='class')
7 | def strings(self):
8 | return [
9 | "snake_case",
10 | "camelCase",
11 | "PascalCase",
12 | "CONSTANT_CASE",
13 | "kebab-case",
14 | "Header-Case",
15 | "weird_Case",
16 | "Weird_CASE"
17 | ]
18 |
19 | def test_snake_case(self, strings):
20 | result = [snake_case(s) for s in strings]
21 | assert result == [
22 | "snake_case",
23 | "camel_case",
24 | "pascal_case",
25 | "constant_case",
26 | "kebab_case",
27 | "header_case",
28 | "weird_case",
29 | "weird_case"
30 | ]
31 |
32 | def test_camel_case(self, strings):
33 | result = [camel_case(s) for s in strings]
34 | assert result == [
35 | "snakeCase",
36 | "camelCase",
37 | "pascalCase",
38 | "constantCase",
39 | "kebabCase",
40 | "headerCase",
41 | "weirdCase",
42 | "weirdCase"
43 | ]
44 |
45 | def test_pascal_case(self, strings):
46 | result = [pascal_case(s) for s in strings]
47 | assert result == [
48 | "SnakeCase",
49 | "CamelCase",
50 | "PascalCase",
51 | "ConstantCase",
52 | "KebabCase",
53 | "HeaderCase",
54 | "WeirdCase",
55 | "WeirdCase"
56 | ]
57 |
58 | def test_constant_case(self, strings):
59 | result = [constant_case(s) for s in strings]
60 | assert result == [
61 | "SNAKE_CASE",
62 | "CAMEL_CASE",
63 | "PASCAL_CASE",
64 | "CONSTANT_CASE",
65 | "KEBAB_CASE",
66 | "HEADER_CASE",
67 | "WEIRD_CASE",
68 | "WEIRD_CASE"
69 | ]
70 |
71 | def test_kebab_case(self, strings):
72 | result = [kebab_case(s) for s in strings]
73 |
74 | assert result == [
75 | "snake-case",
76 | "camel-case",
77 | "pascal-case",
78 | "constant-case",
79 | "kebab-case",
80 | "header-case",
81 | "weird-case",
82 | "weird-case"
83 | ]
84 |
85 | def test_header_case(self, strings):
86 | result = [header_case(s) for s in strings]
87 |
88 | assert result == [
89 | "Snake-Case",
90 | "Camel-Case",
91 | "Pascal-Case",
92 | "Constant-Case",
93 | "Kebab-Case",
94 | "Header-Case",
95 | "Weird-Case",
96 | "Weird-Case"
97 | ]
--------------------------------------------------------------------------------
/tests/test_chained_map.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from sensei._internal.tools import ChainedMap
3 |
4 |
5 | class TestChainedMap:
6 | def test_getitem_simple(self):
7 | """Test __getitem__ with simple keys."""
8 | cm = ChainedMap({'a': 1, 'b': 2})
9 | assert cm['a'] == 1
10 | assert cm['b'] == 2
11 |
12 | def test_getitem_chain(self):
13 | """Test __getitem__ with chained keys."""
14 | cm = ChainedMap({'a': 'b'}, {'b': 'c'}, {'c': 3})
15 | assert cm['a'] == 3 # 'a' -> 'b' -> 'c' -> 3
16 |
17 | def test_getitem_missing(self):
18 | """Test __getitem__ when the key is missing."""
19 | cm = ChainedMap({'a': 1})
20 | with pytest.raises(KeyError):
21 | _ = cm['nonexistent']
22 |
23 | def test_getitem_with_loop(self):
24 | """Test __getitem__ when there is a loop in the key chain."""
25 | cm = ChainedMap({'a': 'b'}, {'b': 'a'})
26 | with pytest.raises(RecursionError):
27 | _ = cm['a']
28 |
29 | def test_getitem_no_chain(self):
30 | """Test __getitem__ when there is no further chaining."""
31 | cm = ChainedMap({'a': 'b'}, {'c': 'd'})
32 | assert cm['a'] == 'b'
33 |
34 | def test_getitem_value_in_later_dict(self):
35 | """Test __getitem__ when the value is in a later dictionary."""
36 | cm = ChainedMap({'a': 'b'}, {'b': 2})
37 | assert cm['a'] == 2 # 'a' -> 'b' -> 2
38 |
39 | def test_getitem_value_in_previous_dict(self):
40 | """Test __getitem__ when the value is in a previous dictionary."""
41 | cm = ChainedMap({'b': 2}, {'a': 'b'})
42 | assert cm['a'] == 2 # 'a' -> 'b' -> 2
43 |
44 | def test_len(self):
45 | """Test __len__ method."""
46 | cm = ChainedMap({'a': 1, 'b': 2}, {'c': 3})
47 | assert len(cm) == 3
48 |
49 | def test_len_with_duplicate_keys(self):
50 | """Test __len__ with duplicate keys in different dictionaries."""
51 | cm = ChainedMap({'a': 1}, {'a': 2}, {'b': 3})
52 | assert len(cm) == 2
53 |
54 | def test_iter(self):
55 | """Test __iter__ method."""
56 | cm = ChainedMap({'a': 'b'}, {'b': 'c'}, {'c': 3})
57 | keys = list(cm)
58 | assert keys == ['a', 'b', 'c', 3]
59 |
60 | def test_iter_with_duplicate_keys(self):
61 | """Test __iter__ with duplicate keys."""
62 | cm = ChainedMap({'a': 1}, {'a': 2}, {'b': 3})
63 | keys = list(cm)
64 | assert keys == ['a', 1]
65 |
66 | def test_trace(self):
67 | """Test the trace method."""
68 | cm = ChainedMap({'a': 'b'}, {'b': 'c'}, {'c': 3})
69 | trace_a = list(cm.trace('a'))
70 | assert trace_a == ['a', 'b', 'c', 3]
71 |
72 | def test_trace_with_unhashable_value(self):
73 | """Test trace when the value is unhashable."""
74 | cm = ChainedMap({'a': ['list']})
75 | trace_a = list(cm.trace('a'))
76 | assert trace_a == ['a', ['list']]
77 |
--------------------------------------------------------------------------------
/tests/test_client.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from sensei import Manager, Client, Router, AsyncClient, RateLimit
4 | from sensei.client.exceptions import CollectionLimitError
5 |
6 |
7 | class TestClient:
8 | def test_manager_validation(self, sync_maker, base_maker, base_url):
9 | client = Client(base_url='https://google.com')
10 | aclient = AsyncClient(base_url='https://google.com')
11 | manager = Manager(client)
12 |
13 | with pytest.raises(CollectionLimitError):
14 | manager.set(client)
15 |
16 | manager.set(aclient)
17 |
18 | manager.pop()
19 |
20 | with pytest.raises(TypeError):
21 | manager.set(None)
22 |
23 | with pytest.raises(AttributeError):
24 | manager.pop()
25 |
26 | manager.get(is_async=True)
27 | manager.pop(is_async=True)
28 |
29 | with pytest.raises(AttributeError):
30 | manager.get()
31 |
32 | router = Router(host=base_url, manager=manager)
33 | base = base_maker(router)
34 | model = sync_maker(router, base)
35 |
36 | with pytest.raises(AttributeError):
37 | model.get(1)
38 |
39 | manager = Manager(required=False)
40 |
41 | router = Router(host=base_url, manager=manager)
42 | base = base_maker(router)
43 | model = sync_maker(router, base)
44 |
45 | model.get(1)
46 |
47 | def test_sync_manager(self, sync_maker, base_maker, base_url):
48 | client = Client(base_url=base_url)
49 |
50 | with client as client:
51 | manager = Manager(client)
52 | router = Router(host=base_url, manager=manager)
53 |
54 | base = base_maker(router)
55 | model = sync_maker(router, base)
56 | model.get(1)
57 |
58 | assert client.is_closed
59 |
60 | @pytest.mark.asyncio
61 | async def test_async_manager(self, async_maker, base_maker, base_url):
62 | client = AsyncClient(base_url=base_url)
63 |
64 | async with client as client:
65 | manager = Manager(async_client=client)
66 | router = Router(host=base_url, manager=manager)
67 |
68 | base = base_maker(router)
69 | model = async_maker(router, base)
70 | await model.get(1) # type: ignore
71 |
72 | assert client.is_closed
73 |
74 | def test_rate_limit(self, base_url, sync_maker, base_maker):
75 | rate_limit = RateLimit(2, 1)
76 | router = Router(host=base_url, rate_limit=rate_limit)
77 |
78 | base = base_maker(router)
79 | model = sync_maker(router, base)
80 |
81 | model.get(1)
82 | assert rate_limit._tokens == 1
83 |
84 | @pytest.mark.asyncio
85 | async def test_async_rate_limit(self, base_url, async_maker, base_maker):
86 | rate_limit = RateLimit(2, 1)
87 | router = Router(host=base_url, rate_limit=rate_limit)
88 |
89 | base = base_maker(router)
90 | model = async_maker(router, base)
91 |
92 | await model.get(1) # type: ignore
93 | assert rate_limit._tokens == 1
94 |
--------------------------------------------------------------------------------
/tests/test_router.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Annotated, Any, Optional
3 |
4 | import pytest
5 | from pydantic import EmailStr, PositiveInt, AnyHttpUrl
6 | from typing_extensions import assert_type
7 |
8 | from sensei import Client, Manager, Router, Path, Query, Header, Cookie, Args, Body, Form, File, APIModel
9 |
10 |
11 | class TestRouter:
12 | def test_client_validation(self, sync_maker, base_maker, base_url):
13 | def validate(client: Client):
14 | manager = Manager(client)
15 | router = Router(host=base_url, manager=manager)
16 | base = base_maker(router)
17 | model = sync_maker(router, base)
18 |
19 | with pytest.raises(ValueError):
20 | model.get(1)
21 |
22 | client1 = Client(base_url='https://google.com')
23 | validate(client1)
24 | client3 = Client(base_url=f'{base_url}//')
25 | validate(client3)
26 |
27 | manager = Manager(Client(base_url=f'{base_url}/'))
28 | router = Router(host=base_url, manager=manager)
29 | base = base_maker(router)
30 | model = sync_maker(router, base)
31 | model.get(1)
32 |
33 |
34 | def test_function_style(self, base_url):
35 | def __finalize_json__(json: dict[str, Any]) -> dict[str, Any]:
36 | return json['data']
37 |
38 | router = Router(host=base_url, __finalize_json__=__finalize_json__)
39 |
40 | @router.get('/users')
41 | def query(page: int = 1, per_page: Annotated[int, Query(le=7)] = 3) -> list[dict]: ...
42 |
43 | @router.get('/users/{id_}')
44 | def get(id_: Annotated[int, Path(alias='id')]) -> dict: ...
45 |
46 | keys = {'email', 'id', 'first_name', 'last_name', 'avatar'}
47 | assert set(query(per_page=4)[0].keys()) == keys
48 | assert set(get(1).keys()) == keys
49 |
50 | def test_params(self, router, base_url):
51 | preparer_executed = False
52 |
53 | def validate(dict_: dict, key: str, len_: int = 1) -> None:
54 | assert (dict_.get(key) and len(dict_) == len_)
55 |
56 | @router.get('/users/{id_}')
57 | def get(
58 | email: str,
59 | cookie: Annotated[str, Cookie()],
60 | body: Annotated[str, Body()],
61 | id_: Annotated[int, Path(alias='id')],
62 | x_token: Annotated[str, Header()],
63 | ) -> str: ...
64 |
65 | @get.prepare()
66 | def _get_in(args: Args) -> Args:
67 | nonlocal preparer_executed
68 |
69 | assert args.url == '/users/1'
70 | validate(args.params, 'email')
71 | validate(args.json_, 'body')
72 | validate(args.headers, 'X-Token')
73 |
74 | preparer_executed = True
75 |
76 | return args
77 |
78 | get('email', 'cookie', 'body', 1, 'xtoken') # type: ignore
79 |
80 | assert preparer_executed
81 | preparer_executed = False
82 |
83 | @router.get('/users')
84 | def get(
85 | form: Annotated[str, Form()],
86 | form2: Annotated[str, Body(media_type="multipart/form-data")],
87 | my_file: Annotated[bytes, File()]
88 | ) -> str: ...
89 |
90 | @get.prepare()
91 | def _get_in(args: Args) -> Args:
92 | nonlocal preparer_executed
93 |
94 | validate(args.data, 'form', 2)
95 | validate(args.data, 'form2', 2)
96 | validate(args.files, 'my_file')
97 |
98 | preparer_executed = True
99 |
100 | return args
101 |
102 | get('form', 'form2', b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x02\xb8\x00\x00\x03\x10\x08\x06\x00\x00\x00\xcc\xf8\x16\x18\x00\x00\x0cNiCCPICC02\x04\x8a\n\x08\xb8E9uF\x80\x00\x01\x02\x04\x08\x10 \xd0\xb6\xc0\xff\x03$\xc0\xf7*\xd5\xc0\xb9%\x00\x00\x00\x00IEND\xaeB`\x82')
103 | assert preparer_executed
104 |
105 | def test_media_type(self, router, base_url):
106 | preparer_executed = False
107 | media_type = "application/xml"
108 |
109 | @router.get('/users')
110 | def get(
111 | my_xml: Annotated[str, Body(media_type=media_type)],
112 | ) -> str: ...
113 |
114 | @get.prepare()
115 | def _get_in(args: Args) -> Args:
116 | nonlocal preparer_executed
117 | assert args.headers['Content-Type'] == media_type
118 | assert args.data['my_xml'] == ' '
119 |
120 | preparer_executed = True
121 | return args
122 |
123 | get(my_xml=' ')
124 | assert preparer_executed
125 |
126 | def test_old_self(self, router, base_url, base_maker):
127 | base = base_maker(router)
128 |
129 | class User(base):
130 | email: EmailStr
131 | id: PositiveInt
132 | first_name: str
133 | last_name: str
134 | avatar: AnyHttpUrl
135 |
136 | @classmethod
137 | @router.get('/users')
138 | def list(
139 | cls,
140 | page: Annotated[int, Query()] = 1,
141 | per_page: Annotated[int, Query(le=7)] = 3
142 | ) -> list["User"]:
143 | ...
144 |
145 | @classmethod
146 | @router.get('/users/{id_}')
147 | def get(cls, id_: Annotated[int, Path(alias='id')]) -> "User": ...
148 |
149 | user = User.get(1)
150 | assert_type(user, User)
151 | users = User.list()
152 |
153 | for user in users:
154 | assert_type(user, User)
155 |
156 | def test_formatting(self):
157 | router = Router(host='https://domain.com:{port}/sumdomain', port=3000)
158 | assert str(router.base_url) == 'https://domain.com:3000/sumdomain'
159 | router._port = 4000
160 | assert str(router.base_url) == 'https://domain.com:4000/sumdomain'
161 |
162 | def test_props(self):
163 | client = Client(base_url='https://google.com:3000')
164 | manager = Manager(client)
165 | router = Router(manager=manager, host='https://google.com', port=3000)
166 |
167 | @router.get('/validate')
168 | def validate() -> None:
169 | ...
170 |
171 | router._port = 4000
172 | router.manager = Manager(Client(base_url='https://google.com:4000'))
173 |
174 | @validate.prepare()
175 | def _validate_in(args: Args) -> Args:
176 | raise ReferenceError
177 |
178 | try:
179 | validate()
180 | except ReferenceError:
181 | pass
182 |
183 | router._port = None
184 |
185 | def test_unset_params(self, base_url, router):
186 | router = Router(base_url, __finalize_json__=lambda x: x['data'])
187 |
188 | class Company(APIModel):
189 | name: Optional[str] = None
190 | location: Optional[str] = None
191 |
192 | class Job(APIModel):
193 | company: Optional[Company] = None
194 | start: datetime = None
195 |
196 | class User(APIModel):
197 | email: EmailStr
198 | id: PositiveInt
199 | first_name: str
200 | last_name: str
201 | job: Optional[Job] = None
202 | avatar: AnyHttpUrl
203 |
204 | @router.get('/users')
205 | def list_users(
206 | page: Annotated[int, Query()] = 1,
207 | per_page: Annotated[int, Query(le=7)] = 3,
208 | job: Optional[Job] = None,
209 | ) -> list[User]:
210 | ...
211 |
212 | def serialize_args(*args, **kwargs) -> dict[str, Any]:
213 | serialized = {}
214 |
215 | @list_users.prepare()
216 | def _prepare1(args: Args) -> Args:
217 | nonlocal serialized
218 | serialized = args.model_dump()
219 | return args
220 |
221 | list_users(*args, **kwargs)
222 | return serialized
223 |
224 | args = serialize_args()
225 | assert 'job' not in args['params']
226 |
227 | job = {'company': {'name': 'Croco'}}
228 | args = serialize_args(job=job)
229 | assert args['params']['job'] == job
--------------------------------------------------------------------------------
/tests/test_sync.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import jwt
4 | import pytest
5 | import respx
6 | from pydantic_core import ValidationError
7 |
8 | from tests.base_user import BaseUser, UserCredentials
9 | from tests.mock_api import mock_api, SECRET_TOKEN, JWT_ALGORITHM
10 |
11 |
12 | class TestSync:
13 | @pytest.fixture
14 | def user_model(self, base_maker, sync_maker, router) -> type[BaseUser]:
15 | base = base_maker(router)
16 | return sync_maker(router, base)
17 |
18 | def test_get(self, user_model):
19 | user = user_model.get(1)
20 | assert user_model.test_validate(user)
21 |
22 | def test_list(self, user_model):
23 | users = user_model.list(per_page=6)
24 | assert all(user_model.test_validate(user) for user in users)
25 |
26 | with pytest.raises(ValidationError):
27 | user_model.list(per_page=9)
28 |
29 | def test_delete(self, user_model):
30 | user = user_model.get(1)
31 | assert user_model.test_validate(user.delete())
32 |
33 | user = user_model.get(1)
34 | user.id = -100
35 |
36 | with pytest.raises(ValidationError):
37 | user.delete()
38 |
39 | def test_login(self, user_model, base_url):
40 | user = user_model.get(1)
41 |
42 | with respx.mock() as mock:
43 | mock_api(mock, base_url, '/token')
44 | token = user.login()
45 |
46 | assert isinstance(token, str)
47 |
48 | payload = jwt.decode(token, SECRET_TOKEN, algorithms=JWT_ALGORITHM)
49 | assert payload['sub'] == user.email
50 |
51 | def test_update(self, user_model):
52 | user = user_model.get(1)
53 |
54 | res = user.update(name="Brandy", job="Data Scientist")
55 | assert user.first_name == "Brandy"
56 | assert isinstance(res, datetime.datetime)
57 |
58 | def test_change(self, user_model):
59 | user = user_model.get(1)
60 | res = user.change(name="Brandy", job="Data Scientist")
61 | assert isinstance(res, bytes)
62 |
63 | def test_sign_up(self, user_model, base_url):
64 | email = "helloworld@gmail.com"
65 |
66 | with respx.mock() as mock:
67 | mock_api(mock, base_url, '/register')
68 | token = user_model.sign_up(UserCredentials(email=email, password="mypassword"))
69 |
70 | assert isinstance(token, str)
71 |
72 | payload = jwt.decode(token, SECRET_TOKEN, algorithms=JWT_ALGORITHM)
73 | assert payload['sub'] == email
74 |
75 | def test_user_headers(self, user_model):
76 | headers = user_model.user_headers()
77 | assert isinstance(headers, dict)
78 |
79 | def test_allowed_methods(self, user_model):
80 | methods = sorted(['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'])
81 | assert methods == sorted(user_model.allowed_http_methods())
82 |
--------------------------------------------------------------------------------
/tests/test_validation.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | import pytest
4 | from httpx import HTTPStatusError, Response
5 | from pydantic import ValidationError
6 | from typing_extensions import Self
7 |
8 | from sensei import APIModel, Body, Form, File
9 |
10 |
11 | class TestValidation:
12 | def test_response_types(self, router):
13 | class _ValidationModel(APIModel):
14 | @staticmethod
15 | @router.get('/test1')
16 | def test() -> Self: ...
17 |
18 | @staticmethod
19 | @router.get('/test2')
20 | def test2() -> list[Self]: ...
21 |
22 | @staticmethod
23 | @router.get('/test3')
24 | def test3() -> set: ...
25 |
26 | with pytest.raises(ValueError):
27 | _ValidationModel.test()
28 |
29 | with pytest.raises(ValueError):
30 | _ValidationModel.test2()
31 |
32 | with pytest.raises(ValueError):
33 | _ValidationModel.test3()
34 |
35 | def test_args_validation(self, router):
36 | class _ValidationModel(APIModel):
37 | @router.delete('/users/{id_}')
38 | def delete(self) -> Self: ...
39 |
40 | with pytest.raises(ValueError):
41 | print(_ValidationModel().delete())
42 |
43 | def test_raise_for_status(self, router, base_maker, sync_maker):
44 | base = base_maker(router)
45 | model = sync_maker(router, base)
46 |
47 | with pytest.raises(HTTPStatusError):
48 | model.get(0)
49 |
50 | def test_body_validation(self, router):
51 | class _ValidationModel(APIModel):
52 | @classmethod
53 | @router.post('/users/{id_}')
54 | def create(cls, body1: Annotated[dict, Body(embed=False)],
55 | body2: Annotated[dict, Body(embed=False)]) -> Self: ...
56 |
57 | @classmethod
58 | @router.post('/users/{id_}')
59 | def create2(cls, body1: Annotated[dict, Body(embed=False)],
60 | body2: Annotated[dict, Body(embed=True)]) -> Self: ...
61 |
62 | @classmethod
63 | @router.post('/users/{id_}')
64 | def create3(cls, body1: Annotated[dict, Body(embed=True)],
65 | body2: Annotated[dict, Body(embed=False)]) -> Self: ...
66 |
67 | @classmethod
68 | @router.post('/users/{id_}')
69 | def create4(cls, body1: Annotated[dict, Body()], body2: Annotated[dict, Form()]) -> Self: ...
70 |
71 | @classmethod
72 | @router.post('/users/{id_}')
73 | def create5(cls, body1: Annotated[dict, File(embed=False)],
74 | body2: Annotated[dict, Form(embed=False)]) -> Self: ...
75 |
76 | @classmethod
77 | @router.post('/users/{id_}')
78 | def create6(cls, body1: Annotated[dict, Form(embed=False)],
79 | body2: Annotated[dict, Form(embed=False)]) -> Self: ...
80 |
81 | with pytest.raises(ValueError):
82 | _ValidationModel.create(body1={}, body2={})
83 |
84 | with pytest.raises(ValueError):
85 | _ValidationModel.create2(body1={}, body2={})
86 |
87 | with pytest.raises(ValueError):
88 | _ValidationModel.create3(body1={}, body2={})
89 |
90 | with pytest.raises(ValueError):
91 | _ValidationModel.create4(body1={}, body2={})
92 |
93 | with pytest.raises(ValueError):
94 | _ValidationModel.create5(body1={}, body2={})
95 |
96 | with pytest.raises(ValueError):
97 | _ValidationModel.create6(body1={}, body2={})
98 |
99 | def test_response_validation(self, router):
100 | @router.get('/users')
101 | def get_users() -> str: ...
102 |
103 | @get_users.finalize()
104 | def _finalize_users(response: Response) -> int:
105 | return 1
106 |
107 | with pytest.raises(ValidationError):
108 | get_users()
109 |
--------------------------------------------------------------------------------