├── .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 | Logo Banner 5 |


6 |
7 | 8 | *Build robust HTTP Requests and best API clients with minimal implementation* 9 | 10 | [![Python versions](https://img.shields.io/pypi/pyversions/sensei?color=%23F94526)](https://pypi.org/project/sensei/) 11 | [![PyPi Version](https://img.shields.io/pypi/v/sensei?color=%23F94526)](https://pypi.org/project/sensei/) 12 | [![Coverage](https://raw.githubusercontent.com/CrocoFactory/sensei/main/badges/coverage.svg)](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 | Mindmap 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 RulesSync/AsyncValidationRate LimitsRelevant responseDRYpydanticBenefitsPrimary DataDiscarding FieldsRefactoring -------------------------------------------------------------------------------- /badges/coverage.svg: -------------------------------------------------------------------------------- 1 | coverage: 97%coverage96% -------------------------------------------------------------------------------- /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 | Logo Banner 5 |


6 |
7 | 8 | *Build robust HTTP Requests and best API clients with minimal implementation* 9 | 10 | [![Python versions](https://img.shields.io/pypi/pyversions/sensei?color=%23F94526)](https://pypi.org/project/sensei/) 11 | [![PyPi Version](https://img.shields.io/pypi/v/sensei?color=%23F94526)](https://pypi.org/project/sensei/) 12 | [![Coverage](https://raw.githubusercontent.com/CrocoFactory/sensei/main/badges/coverage.svg)](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 | Mindmap 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 |
23 | 29 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /docs/people.md: -------------------------------------------------------------------------------- 1 | Hello! 👋 I'm the creator of **Sensei**. 2 | 3 |
4 | 5 |
6 | 7 |
8 |
@blnkoff
9 |
10 |
11 | 12 |
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 | --------------------------------------------------------------------------------