├── .config └── mkdocs.yml ├── .github ├── dependabot.yml └── workflows │ └── test_publish.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── ChangeLog.md ├── License.txt ├── Readme.md ├── docs ├── adr │ └── 2024-10-17 serializing body.md ├── index.md ├── reference │ └── lapidary.md └── usage │ ├── auth.md │ ├── client.md │ ├── installation.md │ └── operation.md ├── poetry.lock ├── pyproject.toml ├── src └── lapidary │ └── runtime │ ├── __init__.py │ ├── _httpx.py │ ├── annotations.py │ ├── auth.py │ ├── client_base.py │ ├── http_consts.py │ ├── metattype.py │ ├── middleware.py │ ├── mime.py │ ├── model │ ├── __init__.py │ ├── annotations.py │ ├── api_key.py │ ├── auth.py │ ├── error.py │ ├── op.py │ ├── param_serialization.py │ ├── request.py │ └── response.py │ ├── operation.py │ ├── paging.py │ ├── py.typed │ ├── pycompat.py │ ├── type_adapter.py │ └── types_.py └── tests ├── __init__.py ├── client.py ├── test_annotations.py ├── test_body_serialization.py ├── test_client.py ├── test_find_mime.py ├── test_httpx.py ├── test_metattype.py ├── test_param_serialization.py └── test_request.py /.config/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Lapidary runtime 2 | repo_url: https://github.com/python-lapidary/lapidary 3 | 4 | docs_dir: ../docs 5 | 6 | nav: 7 | - index.md 8 | - Usage: 9 | - usage/installation.md 10 | - usage/client.md 11 | - usage/operation.md 12 | - usage/auth.md 13 | - Reference: 14 | - reference/lapidary.md 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | groups: 8 | python-packages: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: github-actions 12 | directory: / 13 | schedule: 14 | interval: monthly 15 | -------------------------------------------------------------------------------- /.github/workflows/test_publish.yaml: -------------------------------------------------------------------------------- 1 | name: Test and publish 2 | 3 | on: 4 | - push 5 | 6 | concurrency: 7 | group: tests-${{ github.head_ref || github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.9' 21 | 22 | - name: Set up environment 23 | run: pip install pre-commit 24 | 25 | - name: Cache Pre-commit 26 | id: pre-commit-cache 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.cache/pre-commit 30 | key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 31 | restore-keys: | 32 | pre-commit- 33 | 34 | - name: Run pre-commit 35 | run: pre-commit run -a 36 | 37 | tests: 38 | name: ${{ matrix.python-version }} 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | python-version: 43 | - "3.9" 44 | - "3.10" 45 | - "3.11" 46 | - "3.12" 47 | - "3.13" 48 | fail-fast: false 49 | defaults: 50 | run: 51 | shell: bash 52 | steps: 53 | - uses: actions/checkout@v4 54 | 55 | - name: Install Poetry 56 | uses: packetcoders/action-setup-cache-python-poetry@main 57 | with: 58 | python-version: ${{ matrix.python-version }} 59 | poetry-version: 2.1.1 60 | 61 | - name: Install dependencies 62 | run: poetry install 63 | 64 | - name: Run pytest 65 | run: poetry run pytest -s 66 | 67 | publish: 68 | runs-on: ubuntu-latest 69 | if: startsWith(github.ref, 'refs/tags/v') 70 | needs: 71 | - lint 72 | - tests 73 | 74 | permissions: 75 | id-token: write 76 | 77 | steps: 78 | - name: Checkout repository 79 | uses: actions/checkout@v4 80 | - name: Install Poetry 81 | uses: packetcoders/action-setup-cache-python-poetry@main 82 | with: 83 | python-version: 3.12 84 | poetry-version: 2.1.1 85 | - name: Mint token 86 | id: mint 87 | uses: tschm/token-mint-action@v1.0.3 88 | - name: Publish the package 89 | run: poetry publish --build -u __token__ -p '${{ steps.mint.outputs.api-token }}' 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /dist/ 3 | /.pytest_cache/ 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_language_version: 4 | python: '3.9' 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: trailing-whitespace 10 | - id: end-of-file-fixer 11 | - id: check-added-large-files 12 | - id: check-toml 13 | - id: debug-statements 14 | - id: check-yaml 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.11.8 17 | hooks: 18 | - id: ruff 19 | name: ruff-check 20 | pass_filenames: false 21 | args: 22 | - --fix 23 | - id: ruff-format 24 | pass_filenames: false 25 | - repo: https://github.com/python-poetry/poetry 26 | rev: 2.1.2 27 | hooks: 28 | - id: poetry-check 29 | - id: poetry-lock 30 | - repo: https://github.com/pre-commit/mirrors-mypy 31 | rev: v1.15.0 32 | hooks: 33 | - id: mypy 34 | pass_filenames: false 35 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and the format of this file is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 7 | 8 | ## [0.12.3] - 2025-03-01 9 | ### Fixed 10 | 11 | - Fix handling arrays. 12 | - Handle models with future annotations enabled. 13 | - Fixed variable name clash. 14 | 15 | 16 | ## [0.12.2] - 2024-11-20 17 | 18 | ### Fixed 19 | 20 | - Use pydantic to serialize non-encapsulated non-primitive method parameters. 21 | 22 | 23 | ## [0.12.1] - 2024-11-19 24 | 25 | ### Fixed 26 | 27 | - Don't raise exception on responses without content-type header if an empty response map was provided. 28 | 29 | 30 | ## [0.12.0] - 2024-10-21 31 | 32 | ### Added 33 | - Accept session_factory in `ClientBase.__init__`. 34 | - Helper function to iterate over pages. 35 | - Accept middleware. 36 | 37 | ### Fixed 38 | - Handling collections in request bodies. 39 | - Dropped dependency on std package cgi, which allows running under python 3.13 . 40 | 41 | 42 | ## [0.11.0] - 2024-08-13 43 | 44 | ### Added 45 | 46 | - Always return response body and headers (metadata, possibly `None`) model as result of operation methods. 47 | - Use pydantic to encode request parameters and decode response headers. 48 | - Response annotation accepts metadata model, which can be used to send headers and or parameters. 49 | - Raise exceptions for error responses and undeclared responses. 50 | 51 | ### Changed 52 | 53 | - Serialization styles are now classes. 54 | - Response annotation uses explicit Body argument 55 | - Instead of response models, return values are always a `tuple[body, metadata]` 56 | 57 | 58 | ## [0.10.0] - 2024-06-01 59 | ### Added 60 | - Support response envelope objects to allow returning headers together with the body model. 61 | 62 | 63 | ## [0.9.1] - 2024-05-25 64 | ### Fixed 65 | - Moved pytest-asyncio dependency to dev group. 66 | 67 | 68 | ## [0.9.0] - 2024-05-16 69 | ### Added 70 | - Added a default user-agent header. 71 | - Added support for security scheme combinations. 72 | 73 | ### Changed 74 | - Changed the programming paradigm from dynamic with scaffolding to declarative via annotations. 75 | - Changed authentication model to NamedAuth. 76 | - Updated required python version to 3.9, following httpx-auth 77 | 78 | ### Removed 79 | - Removed support for invalid HTTP status codes patterns, like 20X ([OAS3.1 4.8.16.2](https://spec.openapis.org/oas/v3.1.0#patterned-fields-0)). 80 | - Removed Absent class #50. 81 | 82 | 83 | ## [0.8.0](https://github.com/python-lapidary/lapidary/releases/tag/v0.8.0) - 2023-01-02 84 | ### Added 85 | - Support for arbitrary specification extensions (x- properties). 86 | - Support iterator result and paging plugin. 87 | 88 | ### Fixed 89 | - Property cross-validation (e.g. only one property of example and examples is allowed). 90 | - Bearer security scheme. 91 | 92 | 93 | ## [0.7.3](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.3) - 2022-12-15 94 | ### Fixed 95 | - None error on missing x-lapidary-responses-global 96 | - Enum params are rendered as their string representation instead of value. 97 | 98 | 99 | ## [0.7.2](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.2) - 2022-12-15 100 | ### Fixed 101 | - platformdirs dependency missing from pyproject. 102 | 103 | 104 | ## [0.7.1](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.1) - 2022-12-15 105 | ### Fixed 106 | - Error while constructing a type. 107 | 108 | 109 | ## [0.7.0](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.0) - 2022-12-15 110 | ### Added 111 | - Support for api responses. 112 | - py.typed for mypy 113 | - Support auth object. 114 | 115 | ### Changed 116 | - Migrated project to monorepo 117 | - Unified versioning lapidary-render 118 | - Changed some models to better suite a dynamic library. 119 | 120 | ### Fixed 121 | - Dynamically creating generic types 122 | 123 | [unreleased]: https://github.com/python-lapidary/lapidary/compare/v0.12.3...HEAD 124 | [0.12.3]: https://github.com/python-lapidary/lapidary/compare/v0.12.2...v0.12.3 125 | [0.12.2]: https://github.com/python-lapidary/lapidary/compare/v0.12.1...v0.12.2 126 | [0.12.1]: https://github.com/python-lapidary/lapidary/compare/v0.12.0...v0.12.1 127 | [0.12.0]: https://github.com/python-lapidary/lapidary/compare/v0.11.0...v0.12.0 128 | [0.11.0]: https://github.com/python-lapidary/lapidary/compare/v0.10.0...v0.11.0 129 | [0.10.0]: https://github.com/python-lapidary/lapidary/compare/v0.9.1...v0.10.0 130 | [0.9.1]: https://github.com/python-lapidary/lapidary/compare/v0.9.0...v0.9.1 131 | [0.9.0]: https://github.com/python-lapidary/lapidary/compare/v0.8.0...v0.9.0 132 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022-2024 Rafael Krupinski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Lapidary 2 | 3 | [![test](https://github.com/python-lapidary/lapidary/actions/workflows/test_publish.yaml/badge.svg)](https://github.com/python-lapidary/lapidary/actions/workflows/test_publish.yaml) 4 | 5 | Python DSL for Web API clients. 6 | 7 | ## Why 8 | 9 | DRY. Web API clients follow a relatively small set of patterns and writing them is rather repetitive task. Encoding these patterns in form of a DSL library frees its users from implementing the same patterns over and over again. 10 | 11 | ## How 12 | 13 | Lapidary is an internal DSL made of decorators and annotations, that can be used to describe Web APIs similarly to OpenAPI 14 | ([lapidary-render](https://github.com/python-lapidary/lapidary-render/) can convert a large subset of OpenAPI 3.0 to Lapidary). 15 | 16 | At runtime, the library interprets user-provided function declarations (without bodies), and makes them behave as declared. If a function accepts parameter of type `X` and returns `Y`, Lapidary will try to convert `X` to HTTP request and the response to `Y`. 17 | 18 | ### Example: 19 | 20 | ```python 21 | class CatClient(ClientBase): 22 | """This class is a working API client""" 23 | 24 | def __init__(self): 25 | super().__init__( 26 | base_url='https://example.com/api', 27 | ) 28 | 29 | @get('/cat') 30 | async def list_cats(self: Self) -> Annotated[ 31 | tuple[list[Cat], CatListMeta], 32 | Responses({ 33 | '2XX': Response( 34 | Body({ 35 | 'application/json': list[Cat], 36 | }), 37 | CatListMeta 38 | ), 39 | }) 40 | ]: 41 | pass 42 | 43 | client = CatClient() 44 | cats_body, cats_meta = await client.list_cats() 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/adr/2024-10-17 serializing body.md: -------------------------------------------------------------------------------- 1 | # Serializing request body 2 | 3 | ## The Current solution (0.11.0) 4 | 5 | Currently lapidary checks type of value of the body parameter and tries to match against the type map passed to the `Body` annotation. 6 | 7 | Problem starts when body is a collection: `type()` returns the type of only the collection (e.g. `list`), so the type matching fails. 8 | 9 | ## Possible alternatives 10 | 11 | 1. Check the type of the first item, but there's never a guarantee that the passed collection is homogenic. 12 | Both JSON Schema and python typing support heterogenic collections. 13 | 14 | 2. Check type of all items is out of the question for performance reasons, and pydantic does it anyway during serialization. 15 | 16 | 3. Try to serialize the value with a TypeAdapter for each type in the type map. The first successful attempt also determines the body content type. 17 | 4. Either accept extra parameter `body_type: type` or accept body as tuple with the type explicitly declared: `body: T | Union[T, type]`. 18 | 19 | The last two solutions seem feasible. 20 | Trying every type would incur a performance hit for unions of complex types, but 21 | - it would handle simpler cases well, 22 | - keep lapidary compatible with lapidary-render, 23 | 24 | Lapidary could still accept optional type parameter but use the other method as a fallback for when user doesn't pass the type. 25 | 26 | # Accepted solution 27 | 28 | Lapidary will not check the type of body value, instead it will try serializing it with every type mentioned in the type map in `Body` annotation. 29 | 30 | Lapidary should implement an optional explicit body type parameter in a future version, either as a separate parameter, or as a tuple together with the body value. 31 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Lapidary 2 | 3 | Python DSL for Web API clients. 4 | 5 | ## Features 6 | 7 | - [x] Write Web API clients declaratively 8 | - [x] Use pydantic models for JSON data 9 | - [ ] Compatibility with OpenAPI 3.0 and 3.1 10 | 11 | ## Installation 12 | 13 | ```console 14 | pip install lapidary 15 | ``` 16 | 17 | or with Poetry 18 | 19 | ```console 20 | poetry add lapidary 21 | ``` 22 | 23 | ## Usage 24 | 25 | With Lapidary, you define an API client by creating a class that mirrors the API structure, akin to OpenAPI but through 26 | decorated and annotated Python methods. Calling these method handles making HTTP requests and transforming the responses 27 | back into Python objects. 28 | 29 | ```python 30 | from collections.abc import Awaitable 31 | from typing import Annotated, Self 32 | from lapidary.runtime import * 33 | 34 | # Define models 35 | 36 | class Cat(ModelBase): 37 | id: int 38 | name: str 39 | 40 | # Declare the client 41 | 42 | class CatClient(ClientBase): 43 | def __init__( 44 | self, 45 | base_url='http://localhost:8080/api', 46 | ): 47 | super().__init__(base_url=base_url) 48 | 49 | @get('/cat/{id}') 50 | async def cat_get( 51 | self: Self, 52 | *, 53 | id: Annotated[int, Path], 54 | ) -> Annotated[Awaitable[Cat], Responses({ 55 | '2XX': Response(Body({ 56 | 'application/json': Cat 57 | })), 58 | })]: 59 | pass 60 | 61 | # User code 62 | 63 | async def main(): 64 | client = CatClient() 65 | cat = await client.cat_get(id=7) 66 | ``` 67 | 68 | See [this test file](https://github.com/python-lapidary/lapidary/blob/develop/tests/test_client.py) for a working 69 | example. 70 | 71 | [Full documentation](https://lapidary.dev) 72 | 73 | Also check the [library of clients](https://github.com/orgs/lapidary-library/repositories). 74 | -------------------------------------------------------------------------------- /docs/reference/lapidary.md: -------------------------------------------------------------------------------- 1 | # Lapidary Reference 2 | 3 | ::: lapidary.runtime 4 | -------------------------------------------------------------------------------- /docs/usage/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | ## Model 4 | 5 | Lapidary allows API client authors to declare security requirements using maps that specify acceptable security schemes 6 | per request. OAuth2 schemes require specifying scopes, while other schemes use an empty list. 7 | 8 | For example, a call might require authentication with either two specific schemes together (auth1 and auth2) or another 9 | pair (auth3 and auth4): 10 | 11 | ```python 12 | security = [ 13 | { 14 | 'auth1': ['scope1'], 15 | 'auth2': ['scope1', 'scope2'], 16 | }, 17 | { 18 | 'auth3': ['scope3'], 19 | 'auth4': [], 20 | }, 21 | ] 22 | ``` 23 | 24 | Lapidary also supports optional authentication, allowing for certain operations, such as a login endpoint, to forego the 25 | global security requirements specified for the API: 26 | 27 | ```python 28 | security = [ 29 | {'auth': []}, 30 | {}, # unauthenticated calls allowed 31 | ] 32 | ``` 33 | 34 | You can also use this method to disable global security requirement for a particular operation (e.g. login endpoint). 35 | 36 | ## Usage 37 | 38 | Lapidary handles security schemes through httpx.Auth instances, wrapped in `NamedAuth` tuple. 39 | You can define security requirements globally in the client `__init__()` or at the operation level with decorators, where 40 | operation-level declarations override global settings. 41 | 42 | Lapidary validates security requirements at runtime, ensuring that any method call is accompanied by the necessary 43 | authentication, as specified by its security requirements. This process involves matching the provided authentication 44 | against the declared requirements before proceeding with the request. To meet these requirements, the user must have 45 | previously configured the necessary Auth instances using lapidary_authenticate. 46 | 47 | ```python 48 | from lapidary.runtime import * 49 | from lapidary.runtime.auth import HeaderApiKey 50 | from typing import Self, Annotated 51 | 52 | 53 | class MyClient(ClientBase): 54 | def __init__(self): 55 | super().__init__( 56 | base_url=..., 57 | security=[{'apiKeyAuth': []}], 58 | ) 59 | 60 | @get('/api/operation', security=[{'admin_only': []}]) 61 | async def my_op(self: Self) -> ...: 62 | pass 63 | 64 | @post('/api/login', security=()) 65 | async def login( 66 | self: Self, 67 | user: Annotated[str, ...], 68 | password: Annotated[str, ...], 69 | ) -> ...: 70 | pass 71 | 72 | # User code 73 | async def main(): 74 | client = MyClient() 75 | 76 | token = await client.login().token 77 | client.lapidary_authenticate(apiKeyAuth=HeaderApiKey(token)) 78 | await client.my_op() 79 | 80 | # optionally 81 | client.lapidary_deauthenticate('apiKeyAuth') 82 | ``` 83 | 84 | `lapidary_authenticate` also accepts tuples of Auth instances with names, so this is possible: 85 | 86 | ```python 87 | def admin_auth(api_key: str) -> NamedAuth: 88 | return 'admin_only', HeaderApiKey(api_key) 89 | 90 | 91 | client.lapidary_authencticate(admin_auth('my token')) 92 | ``` 93 | 94 | ## De-registering Auth instances 95 | 96 | To remove an auth instance, thereby allowing for unauthenticated calls or the use of alternative security schemes, 97 | lapidary_deauthenticate is used: 98 | 99 | ```python 100 | client.lapidary_deauthenticate('apiKeyAuth') 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/usage/client.md: -------------------------------------------------------------------------------- 1 | # Client class 2 | 3 | The core of the Lapidary API client is a single class that contains all the methods for API operations. This class is 4 | built around an `httpx.AsyncClient` instance to manage HTTP requests and responses. 5 | 6 | Example usage: 7 | 8 | ```python 9 | from lapidary.runtime import * 10 | 11 | 12 | class CatClient(ClientBase): 13 | ... 14 | ``` 15 | 16 | # `__init__()` method 17 | 18 | Implementing the `__init__()` method is optional but useful for specifying default values for settings like 19 | the `base_url` of the API. 20 | 21 | Example implementation: 22 | 23 | ```python 24 | import lapidary.runtime 25 | 26 | 27 | class CatClient(lapidary.runtime.ClientBase): 28 | def __init__( 29 | self, 30 | base_url='https://example.com/api', 31 | **kwargs 32 | ): 33 | super().__init__( 34 | base_url=base_url, 35 | **kwargs 36 | ) 37 | ... 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/usage/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Lapidary is a library, you can add it to your existing project, but I recommend creating a separate project for each remote service. 4 | 5 | ## Using poetry 6 | 7 | With `poetry` you can install `lapidary` simply by typing: 8 | ```shell 9 | poetry add lapidary 10 | ``` 11 | 12 | ## Using lapidary-render 13 | 14 | If you have OpenAPI 3.0 document, you can initialize a project with lapidary-render: 15 | 16 | ```shell 17 | lapidary-render init 18 | ``` 19 | 20 | or via pipx: 21 | 22 | ```shell 23 | pipx run lapidary-render init 24 | ``` 25 | 26 | See [the documentation](/lapidary-render/) for more details. 27 | -------------------------------------------------------------------------------- /docs/usage/operation.md: -------------------------------------------------------------------------------- 1 | Methods decorated with one of @get, @post, @put, etc. are transformed into operation methods. Invoking these methods 2 | initiates an HTTP request-response cycle. Lapidary is designed to be compatible with the HTTP methods defined in OpenAPI 3 | 3.x, which include all methods defined in RFC 9110, with the exception of CONNECT. Methods in your client that aren't 4 | decorated with these operation decorators are simply ignored. 5 | 6 | !!! note methods 7 | 8 | Python methods and HTTP methods represent two distinct concepts. 9 | 10 | Throughout this documentation, the term `method` in a programming context always refers to a Python method (defined with `def`), whereas `HTTP methods` (GET, POST, etc.) are specified as such. 11 | 12 | ```python 13 | from lapidary.runtime import ClientBase, get 14 | 15 | 16 | class CatClient(ClientBase): 17 | 18 | @get('/cats') 19 | async def list_cats(...): 20 | pass 21 | ``` 22 | 23 | !!! note 24 | 25 | In the examples below, methods are depicted as standalone functions, with the encapsulating class structure omitted. 26 | 27 | ```python 28 | @get('/cats') # method and path 29 | async def list_cats(...): 30 | pass 31 | ``` 32 | 33 | ## Parameters 34 | 35 | Parameters within Lapidary are designed to represent different components of an HTTP request, including headers, 36 | cookies, query parameters, path parameters, and the body of the request. 37 | 38 | It's essential that every parameter, including self, is annotated to define its role and type explicitly. Note that * 39 | args and **kwargs are not supported in this structure to maintain clarity and specificity in request definition. 40 | 41 | ### Query parameters 42 | 43 | Query parameters are elements added to the URL following the '?' character, serving to modify or refine the request. An 44 | example format is https://example.com/path?param=value. 45 | 46 | To declare a query parameter in Lapidary, use the Query() annotation: 47 | 48 | ```python 49 | @get('/cats') 50 | async def list_cats( 51 | self: Self, 52 | color: Annotated[str, Query], 53 | ): 54 | pass 55 | ``` 56 | 57 | Calling a method like this: 58 | 59 | ```python 60 | await client.list_cats(color='black') 61 | ``` 62 | 63 | results in a GET request being sent to the following URL: https://example.com/cats?color=black. 64 | 65 | This illustrates how arguments passed to the method are directly mapped to query parameters in the URL, forming a 66 | complete HTTP request based on the method's decoration and the parameters' annotations. 67 | 68 | ### Path parameters 69 | 70 | Path parameters are variables embedded within the path of a request URL, such as http://example.com/cat/{cat_id}. These 71 | parameters are essential for accessing specific resources or performing operations on them. 72 | 73 | To define a path parameter in Lapidary, you use the path variable inside the decorator URL path and annotate the method 74 | parameter with Path(). Here is an example of how to define and use a path parameter: 75 | 76 | ```python 77 | @get('/cat/{cat_id}') 78 | async def get_cat( 79 | self: Self, 80 | cat_id: Annotated[str, Path], 81 | ): 82 | pass 83 | ``` 84 | 85 | When you call this method like so: 86 | 87 | ```python 88 | await client.get_cat(cat_id=1) 89 | ``` 90 | 91 | it constructs and sends a GET request to https://example.com/cat/1. This demonstrates the method's ability to 92 | dynamically incorporate the provided argument (cat_id=1) into the request URL as a path parameter. 93 | 94 | ## Headers 95 | 96 | ### Non-cookie headers 97 | 98 | Header parameters are utilized to add HTTP headers to a request. These can be defined using the `Header` annotation in a 99 | method declaration, specifying the header name and the expected value type. 100 | 101 | Example: 102 | 103 | ```python 104 | @get('/cats') 105 | async def list_cats( 106 | self: Self, 107 | version: Annotated[str, Header], 108 | ): 109 | pass 110 | ``` 111 | 112 | Invoking this method with: 113 | 114 | ```python 115 | await client.list_cats(version='2') 116 | ``` 117 | 118 | results in the execution of a GET request that includes the header `version: 2`. 119 | 120 | Note: The Cookie, Header, Param, and Query annotations all accept parameters such as name, style, and explode as defined 121 | by OpenAPI. 122 | 123 | ### Cookie headers 124 | 125 | To add a cookie to the request, you use the Cookie parameter. This adds a name=value pair to the Cookie header of the 126 | HTTP request. 127 | 128 | Example: 129 | 130 | ```python 131 | @get('/cats') 132 | async def list_cats( 133 | self: Self, 134 | cookie_key: Annotated[str, Cookie('key')], 135 | ): 136 | pass 137 | ``` 138 | 139 | Calling this method as 140 | 141 | ```python 142 | await client.list_cats(cookie_key='value') 143 | ``` 144 | 145 | will send a GET request that includes the header Cookie: key=value. 146 | 147 | ## Request body 148 | 149 | To mark a parameter for serialization into the HTTP body, annotate it with RequestBody. Each method can include only one 150 | such parameter. 151 | 152 | Example: 153 | 154 | ```python 155 | @POST('/cat') 156 | async def add_cat( 157 | self: Self, 158 | cat: Annotated[Cat, Body({ 159 | 'application/json': Cat, 160 | })], 161 | ): 162 | pass 163 | ``` 164 | 165 | The parameter type, such as Cat in this example, should be a basic scalar (e.g., str, int, float, date, datetime, UUID) 166 | or a Pydantic model, to facilitate proper serialization. 167 | 168 | Invoking this method constructs a POST request with Content-Type: application/json header. The cat object is serialized 169 | to JSON using Pydantic's BaseModel.model_dump_json() and included in the body of the request. 170 | 171 | ## Return type 172 | 173 | The Responses annotation plays a crucial role in mapping HTTP status codes and Content-Type headers to specific return 174 | types. This mechanism allows developers to define how responses are parsed and returned. 175 | 176 | The return type is specified in two places: 177 | 178 | 1. At the method signature level - The declared return type here should reflect the expected successful response 179 | structure. It can be a single type or a Union of types, accommodating various potential non-error response bodies. 180 | 181 | 2. Within the `Responses` annotation - This details the specific type or types to be used for parsing the response body, 182 | depending on the response's HTTP status code and content type matching those specified. 183 | 184 | !!! Note 185 | 186 | The type hint in the method's annotation must match the response types specified within the Responses() annotation, excluding exception types. For details on how to handle exception types, see the next section. 187 | 188 | Example: 189 | 190 | ```python 191 | @get('/cat') 192 | async def list_cats(self: Self) -> Annotated[ 193 | tuple[List[Cat], None], 194 | Responses({ 195 | '2XX': Response(Body({ 196 | 'application/json': List[Cat], 197 | })), 198 | }) 199 | ]: 200 | pass 201 | ``` 202 | 203 | In this setup, the Responses dictionary specifies that for responses with a 2XX status code and a Content-Type of 204 | application/json, the response body will be parsed as a list of Cat objects. This explicit declaration ensures that the 205 | method's return type is tightly coupled with the anticipated successful response structure, providing clarity and type 206 | safety for API interactions. 207 | 208 | 209 | ### Mapping headers and response status code 210 | 211 | Lapidary operation methods always return a tuple. The first element is the response body, the second is the response metadata (headers and/or status code), each of them being optional. 212 | 213 | Example: 214 | 215 | ```python 216 | 217 | class CatListMeta(ModelBase): 218 | total_count: Annotated[int, Header('Total-Count')] 219 | status_code: Annotated[int, StatusCode] 220 | 221 | 222 | class CatClient(ClientBase): 223 | @get('/cat') 224 | async def list_cats(self: Self) -> Annotated[ 225 | tuple[list[Cat], CatListMeta], 226 | Responses({ 227 | '2XX': Response( 228 | Body({ 229 | 'application/json': list[Cat], 230 | }), 231 | CatListMeta 232 | ), 233 | }) 234 | ]: 235 | pass 236 | 237 | client = CatClient() 238 | cats_body, cats_meta = await client.list_cats() 239 | assert cats_body.body == [Cat(...)] 240 | assert cats_meta.count == 1 241 | assert cats_meta.status_code == 200 242 | ``` 243 | 244 | 245 | ### Handling error responses 246 | 247 | Lapidary maps HTTP error responses to exceptions. 248 | 249 | ```python 250 | class ErrorModel(ModelBase): 251 | error_code: int 252 | error_message: str 253 | 254 | 255 | @get('/cat') 256 | async def list_cats( 257 | self: Self, 258 | ) -> Annotated[ 259 | tuple[List[Cat], None], 260 | Responses({ 261 | '2XX': Response(...), 262 | '4XX': Response(Body({ 263 | 'application/json': ErrorModel, 264 | })) 265 | }), 266 | ]: 267 | pass 268 | ``` 269 | 270 | Responses with status code 400 and up will cause `HttpErrorResponse` to be risen as long as they're declared in the response map. 271 | 272 | ```python 273 | try: 274 | await client.list_cats() 275 | except HttpErrorResponse as e: 276 | assert e.status_code == 400 277 | assert e.headers is None 278 | assert isinstance(e.body, ErrorModel) 279 | ``` 280 | 281 | Any responses not declared in the response map, regardless of their status code, raise `UnexpectedResponse`. 282 | 283 | ```python 284 | try: 285 | await client.list_cats() 286 | except UnexpectedResponse as e: 287 | assert isinstance(e.response, httpx.response) 288 | ``` 289 | 290 | !!! note 291 | 292 | Exception types mapped to responses in the Responses annotation should not be included in the method's return type hint. They are exclusively declared within the Responses framework for appropriate processing. 293 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.7.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["main"] 10 | files = [ 11 | {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, 12 | {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, 13 | ] 14 | 15 | [[package]] 16 | name = "anyio" 17 | version = "4.9.0" 18 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 19 | optional = false 20 | python-versions = ">=3.9" 21 | groups = ["main", "dev"] 22 | files = [ 23 | {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, 24 | {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, 25 | ] 26 | 27 | [package.dependencies] 28 | exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} 29 | idna = ">=2.8" 30 | sniffio = ">=1.1" 31 | typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} 32 | 33 | [package.extras] 34 | doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] 35 | test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] 36 | trio = ["trio (>=0.26.1)"] 37 | 38 | [[package]] 39 | name = "certifi" 40 | version = "2025.4.26" 41 | description = "Python package for providing Mozilla's CA Bundle." 42 | optional = false 43 | python-versions = ">=3.6" 44 | groups = ["main"] 45 | files = [ 46 | {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, 47 | {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, 48 | ] 49 | 50 | [[package]] 51 | name = "colorama" 52 | version = "0.4.6" 53 | description = "Cross-platform colored terminal text." 54 | optional = false 55 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 56 | groups = ["dev"] 57 | markers = "sys_platform == \"win32\"" 58 | files = [ 59 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 60 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 61 | ] 62 | 63 | [[package]] 64 | name = "exceptiongroup" 65 | version = "1.2.2" 66 | description = "Backport of PEP 654 (exception groups)" 67 | optional = false 68 | python-versions = ">=3.7" 69 | groups = ["main", "dev"] 70 | markers = "python_version < \"3.11\"" 71 | files = [ 72 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 73 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 74 | ] 75 | 76 | [package.extras] 77 | test = ["pytest (>=6)"] 78 | 79 | [[package]] 80 | name = "h11" 81 | version = "0.16.0" 82 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 83 | optional = false 84 | python-versions = ">=3.8" 85 | groups = ["main"] 86 | files = [ 87 | {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, 88 | {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, 89 | ] 90 | 91 | [[package]] 92 | name = "h2" 93 | version = "4.2.0" 94 | description = "Pure-Python HTTP/2 protocol implementation" 95 | optional = false 96 | python-versions = ">=3.9" 97 | groups = ["main"] 98 | files = [ 99 | {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, 100 | {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, 101 | ] 102 | 103 | [package.dependencies] 104 | hpack = ">=4.1,<5" 105 | hyperframe = ">=6.1,<7" 106 | 107 | [[package]] 108 | name = "hpack" 109 | version = "4.1.0" 110 | description = "Pure-Python HPACK header encoding" 111 | optional = false 112 | python-versions = ">=3.9" 113 | groups = ["main"] 114 | files = [ 115 | {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, 116 | {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, 117 | ] 118 | 119 | [[package]] 120 | name = "httpcore" 121 | version = "1.0.9" 122 | description = "A minimal low-level HTTP client." 123 | optional = false 124 | python-versions = ">=3.8" 125 | groups = ["main"] 126 | files = [ 127 | {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, 128 | {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, 129 | ] 130 | 131 | [package.dependencies] 132 | certifi = "*" 133 | h11 = ">=0.16" 134 | 135 | [package.extras] 136 | asyncio = ["anyio (>=4.0,<5.0)"] 137 | http2 = ["h2 (>=3,<5)"] 138 | socks = ["socksio (==1.*)"] 139 | trio = ["trio (>=0.22.0,<1.0)"] 140 | 141 | [[package]] 142 | name = "httpx" 143 | version = "0.28.1" 144 | description = "The next generation HTTP client." 145 | optional = false 146 | python-versions = ">=3.8" 147 | groups = ["main"] 148 | files = [ 149 | {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, 150 | {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, 151 | ] 152 | 153 | [package.dependencies] 154 | anyio = "*" 155 | certifi = "*" 156 | h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} 157 | httpcore = "==1.*" 158 | idna = "*" 159 | 160 | [package.extras] 161 | brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] 162 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 163 | http2 = ["h2 (>=3,<5)"] 164 | socks = ["socksio (==1.*)"] 165 | zstd = ["zstandard (>=0.18.0)"] 166 | 167 | [[package]] 168 | name = "httpx-auth" 169 | version = "0.23.1" 170 | description = "Authentication for HTTPX" 171 | optional = false 172 | python-versions = ">=3.9" 173 | groups = ["main"] 174 | files = [ 175 | {file = "httpx_auth-0.23.1-py3-none-any.whl", hash = "sha256:04f8bd0824efe3d9fb79690cc670b0da98ea809babb7aea04a72f334d4fd5ec5"}, 176 | {file = "httpx_auth-0.23.1.tar.gz", hash = "sha256:27b5a6022ad1b41a303b8737fa2e3e4bce6bbbe7ab67fed0b261359be62e0434"}, 177 | ] 178 | 179 | [package.dependencies] 180 | httpx = "==0.28.*" 181 | 182 | [package.extras] 183 | testing = ["pyjwt (==2.*)", "pytest-asyncio (==0.25.*)", "pytest-cov (==6.*)", "pytest_httpx (==0.35.*)", "time-machine (==2.*)"] 184 | 185 | [[package]] 186 | name = "hyperframe" 187 | version = "6.1.0" 188 | description = "Pure-Python HTTP/2 framing" 189 | optional = false 190 | python-versions = ">=3.9" 191 | groups = ["main"] 192 | files = [ 193 | {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, 194 | {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, 195 | ] 196 | 197 | [[package]] 198 | name = "idna" 199 | version = "3.10" 200 | description = "Internationalized Domain Names in Applications (IDNA)" 201 | optional = false 202 | python-versions = ">=3.6" 203 | groups = ["main", "dev"] 204 | files = [ 205 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 206 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 207 | ] 208 | 209 | [package.extras] 210 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 211 | 212 | [[package]] 213 | name = "iniconfig" 214 | version = "2.1.0" 215 | description = "brain-dead simple config-ini parsing" 216 | optional = false 217 | python-versions = ">=3.8" 218 | groups = ["dev"] 219 | files = [ 220 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 221 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 222 | ] 223 | 224 | [[package]] 225 | name = "packaging" 226 | version = "25.0" 227 | description = "Core utilities for Python packages" 228 | optional = false 229 | python-versions = ">=3.8" 230 | groups = ["dev"] 231 | files = [ 232 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 233 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 234 | ] 235 | 236 | [[package]] 237 | name = "pluggy" 238 | version = "1.5.0" 239 | description = "plugin and hook calling mechanisms for python" 240 | optional = false 241 | python-versions = ">=3.8" 242 | groups = ["dev"] 243 | files = [ 244 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 245 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 246 | ] 247 | 248 | [package.extras] 249 | dev = ["pre-commit", "tox"] 250 | testing = ["pytest", "pytest-benchmark"] 251 | 252 | [[package]] 253 | name = "pydantic" 254 | version = "2.11.4" 255 | description = "Data validation using Python type hints" 256 | optional = false 257 | python-versions = ">=3.9" 258 | groups = ["main"] 259 | files = [ 260 | {file = "pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb"}, 261 | {file = "pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d"}, 262 | ] 263 | 264 | [package.dependencies] 265 | annotated-types = ">=0.6.0" 266 | pydantic-core = "2.33.2" 267 | typing-extensions = ">=4.12.2" 268 | typing-inspection = ">=0.4.0" 269 | 270 | [package.extras] 271 | email = ["email-validator (>=2.0.0)"] 272 | timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] 273 | 274 | [[package]] 275 | name = "pydantic-core" 276 | version = "2.33.2" 277 | description = "Core functionality for Pydantic validation and serialization" 278 | optional = false 279 | python-versions = ">=3.9" 280 | groups = ["main"] 281 | files = [ 282 | {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, 283 | {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, 284 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, 285 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, 286 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, 287 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, 288 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, 289 | {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, 290 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, 291 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, 292 | {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, 293 | {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, 294 | {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, 295 | {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, 296 | {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, 297 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, 298 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, 299 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, 300 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, 301 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, 302 | {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, 303 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, 304 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, 305 | {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, 306 | {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, 307 | {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, 308 | {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, 309 | {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, 310 | {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, 311 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, 312 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, 313 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, 314 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, 315 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, 316 | {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, 317 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, 318 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, 319 | {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, 320 | {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, 321 | {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, 322 | {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, 323 | {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, 324 | {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, 325 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, 326 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, 327 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, 328 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, 329 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, 330 | {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, 331 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, 332 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, 333 | {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, 334 | {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, 335 | {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, 336 | {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, 337 | {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, 338 | {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, 339 | {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, 340 | {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, 341 | {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, 342 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, 343 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, 344 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, 345 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, 346 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, 347 | {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, 348 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, 349 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, 350 | {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, 351 | {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, 352 | {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, 353 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, 354 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, 355 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, 356 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, 357 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, 358 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, 359 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, 360 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, 361 | {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, 362 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, 363 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, 364 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, 365 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, 366 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, 367 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, 368 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, 369 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, 370 | {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, 371 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, 372 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, 373 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, 374 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, 375 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, 376 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, 377 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, 378 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, 379 | {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, 380 | {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, 381 | ] 382 | 383 | [package.dependencies] 384 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 385 | 386 | [[package]] 387 | name = "pytest" 388 | version = "8.3.5" 389 | description = "pytest: simple powerful testing with Python" 390 | optional = false 391 | python-versions = ">=3.8" 392 | groups = ["dev"] 393 | files = [ 394 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 395 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 396 | ] 397 | 398 | [package.dependencies] 399 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 400 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 401 | iniconfig = "*" 402 | packaging = "*" 403 | pluggy = ">=1.5,<2" 404 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 405 | 406 | [package.extras] 407 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 408 | 409 | [[package]] 410 | name = "pytest-asyncio" 411 | version = "0.26.0" 412 | description = "Pytest support for asyncio" 413 | optional = false 414 | python-versions = ">=3.9" 415 | groups = ["dev"] 416 | files = [ 417 | {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, 418 | {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, 419 | ] 420 | 421 | [package.dependencies] 422 | pytest = ">=8.2,<9" 423 | typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} 424 | 425 | [package.extras] 426 | docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] 427 | testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] 428 | 429 | [[package]] 430 | name = "python-mimeparse" 431 | version = "2.0.0" 432 | description = "A module provides basic functions for parsing mime-type names and matching them against a list of media-ranges." 433 | optional = false 434 | python-versions = ">=3.8" 435 | groups = ["main"] 436 | files = [ 437 | {file = "python_mimeparse-2.0.0-py3-none-any.whl", hash = "sha256:574062a06f2e1d416535c8d3b83ccc6ebe95941e74e2c5939fc010a12e37cc09"}, 438 | {file = "python_mimeparse-2.0.0.tar.gz", hash = "sha256:5b9a9dcf7aa82465e31bd667f5cb7000604811dce83554f1c8a43693a32cb303"}, 439 | ] 440 | 441 | [package.extras] 442 | test = ["pytest"] 443 | 444 | [[package]] 445 | name = "sniffio" 446 | version = "1.3.1" 447 | description = "Sniff out which async library your code is running under" 448 | optional = false 449 | python-versions = ">=3.7" 450 | groups = ["main", "dev"] 451 | files = [ 452 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 453 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 454 | ] 455 | 456 | [[package]] 457 | name = "starlette" 458 | version = "0.46.2" 459 | description = "The little ASGI library that shines." 460 | optional = false 461 | python-versions = ">=3.9" 462 | groups = ["dev"] 463 | files = [ 464 | {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, 465 | {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, 466 | ] 467 | 468 | [package.dependencies] 469 | anyio = ">=3.6.2,<5" 470 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 471 | 472 | [package.extras] 473 | full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] 474 | 475 | [[package]] 476 | name = "tomli" 477 | version = "2.2.1" 478 | description = "A lil' TOML parser" 479 | optional = false 480 | python-versions = ">=3.8" 481 | groups = ["dev"] 482 | markers = "python_version < \"3.11\"" 483 | files = [ 484 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 485 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 486 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 487 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 488 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 489 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 490 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 491 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 492 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 493 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 494 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 495 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 496 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 497 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 498 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 499 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 500 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 501 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 502 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 503 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 504 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 505 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 506 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 507 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 508 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 509 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 510 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 511 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 512 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 513 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 514 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 515 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 516 | ] 517 | 518 | [[package]] 519 | name = "typing-extensions" 520 | version = "4.13.2" 521 | description = "Backported and Experimental Type Hints for Python 3.8+" 522 | optional = false 523 | python-versions = ">=3.8" 524 | groups = ["main", "dev"] 525 | files = [ 526 | {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, 527 | {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, 528 | ] 529 | 530 | [[package]] 531 | name = "typing-inspection" 532 | version = "0.4.0" 533 | description = "Runtime typing introspection tools" 534 | optional = false 535 | python-versions = ">=3.9" 536 | groups = ["main"] 537 | files = [ 538 | {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, 539 | {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, 540 | ] 541 | 542 | [package.dependencies] 543 | typing-extensions = ">=4.12.0" 544 | 545 | [metadata] 546 | lock-version = "2.1" 547 | python-versions = "^3.9" 548 | content-hash = "9d7ad9c7a7b9f458f94f7888588f4e45426ea36f1d05e73c0709d6ad9381f151" 549 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [{ name = "Rafał Krupiński", email = "rafal@lapidary.dev" }] 3 | classifiers = [ 4 | "Development Status :: 3 - Alpha", 5 | "Framework :: AsyncIO", 6 | "Intended Audience :: Developers", 7 | "Intended Audience :: Information Technology", 8 | "Operating System :: OS Independent", 9 | "Topic :: Internet :: WWW/HTTP", 10 | "Topic :: Internet", 11 | "Topic :: Software Development :: Libraries :: Python Modules", 12 | "Topic :: Software Development :: Libraries", 13 | "Topic :: Software Development", 14 | "Typing :: Typed", 15 | ] 16 | description = "Python async OpenAPI client library" 17 | dynamic = ["dependencies"] 18 | license = "MIT" 19 | name = "lapidary" 20 | readme = "Readme.md" 21 | requires-python = ">=3.9" 22 | version = "0.12.3" 23 | 24 | [project.urls] 25 | Homepage = "https://lapidary.dev/" 26 | Changelog = "https://github.com/python-lapidary/lapidary/blob/develop/ChangeLog.md" 27 | 28 | [tool.poetry] 29 | packages = [{ include = "lapidary", from = "src" }] 30 | 31 | [tool.poetry.dependencies] 32 | python = "^3.9" 33 | httpx = { extras = ["http2"], version = "^0.28" } 34 | httpx-auth = "^0.23" 35 | pydantic = "^2.11" 36 | python-mimeparse = "^2" 37 | typing-extensions = "^4.9" 38 | 39 | [tool.poetry.group.dev.dependencies] 40 | pytest = "^8" 41 | pytest-asyncio = "^0.26" 42 | starlette = "^0.46" 43 | 44 | [build-system] 45 | requires = ["poetry-core>=2.1.0"] 46 | build-backend = "poetry.core.masonry.api" 47 | 48 | [tool.pytest.ini_options] 49 | testpaths = [ 50 | "tests", 51 | ] 52 | pythonpath = [ 53 | "src", 54 | "tests" 55 | ] 56 | addopts = "--color=yes" 57 | 58 | [tool.mypy] 59 | mypy_path = "src" 60 | namespace_packages = true 61 | python_version = "3.9" 62 | packages = ["lapidary.runtime"] 63 | 64 | [tool.ruff] 65 | target-version = "py39" 66 | line-length = 140 67 | 68 | [tool.ruff.lint] 69 | #extend-select = ["E501", "E4", "E7", "E9", "F", "B", 'UP', 'D'] 70 | select = ['F', 'E', 'W', 'C90', 'I', 'UP'] 71 | ignore = ['F842', 'UP007'] 72 | 73 | [tool.ruff.format] 74 | quote-style = "single" 75 | 76 | [tool.ruff.lint.isort] 77 | combine-as-imports = true 78 | known-first-party = ["lapidary.runtime"] 79 | known-local-folder = ["src"] 80 | -------------------------------------------------------------------------------- /src/lapidary/runtime/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | 'Body', 3 | 'ClientBase', 4 | 'ClientArgs', 5 | 'Cookie', 6 | 'lapidary_user_agent', 7 | 'Form', 8 | 'FormExplode', 9 | 'Header', 10 | 'HttpErrorResponse', 11 | 'HttpxMiddleware', 12 | 'LapidaryError', 13 | 'LapidaryResponseError', 14 | 'Metadata', 15 | 'ModelBase', 16 | 'NamedAuth', 17 | 'Path', 18 | 'Query', 19 | 'Response', 20 | 'Responses', 21 | 'SecurityRequirements', 22 | 'SessionFactory', 23 | 'SimpleMultimap', 24 | 'SimpleString', 25 | 'StatusCode', 26 | 'UnexpectedResponse', 27 | 'delete', 28 | 'get', 29 | 'head', 30 | 'iter_pages', 31 | 'patch', 32 | 'post', 33 | 'put', 34 | 'trace', 35 | ) 36 | 37 | from .annotations import Body, Cookie, Header, Metadata, Path, Query, Response, Responses, StatusCode 38 | from .client_base import ClientBase, lapidary_user_agent 39 | from .middleware import HttpxMiddleware 40 | from .model import ModelBase 41 | from .model.error import HttpErrorResponse, LapidaryError, LapidaryResponseError, UnexpectedResponse 42 | from .model.param_serialization import Form, FormExplode, SimpleMultimap, SimpleString 43 | from .operation import delete, get, head, patch, post, put, trace 44 | from .paging import iter_pages 45 | from .types_ import ClientArgs, NamedAuth, SecurityRequirements, SessionFactory 46 | -------------------------------------------------------------------------------- /src/lapidary/runtime/_httpx.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import typing_extensions as typing 3 | 4 | UseClientDefault: typing.TypeAlias = httpx._client.UseClientDefault # pylint: disable=protected-access 5 | PrimitiveData: typing.TypeAlias = httpx._types.PrimitiveData # pylint: disable=protected-access 6 | ParamValue: typing.TypeAlias = typing.Union[PrimitiveData, typing.Sequence[PrimitiveData]] 7 | AuthType: typing.TypeAlias = typing.Union[httpx.Auth, UseClientDefault, None] 8 | -------------------------------------------------------------------------------- /src/lapidary/runtime/annotations.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses as dc 3 | from collections.abc import Mapping 4 | 5 | import pydantic 6 | import typing_extensions as typing 7 | 8 | from .model.param_serialization import FormExplode, MultimapSerializationStyle, SimpleMultimap, SimpleString, StringSerializationStyle 9 | from .types_ import MimeType, StatusCodeRange 10 | 11 | 12 | class WebArg(abc.ABC): 13 | """Base class for web-processed parameters.""" 14 | 15 | 16 | @dc.dataclass 17 | class Body(WebArg): 18 | """ 19 | Link content type headers with a python type. 20 | When used with a method parameter, it tells lapidary what content-type header to send for a given body type. 21 | When used in return annotation, it tells lapidary the type to process the response body as. 22 | 23 | Example use with parameter: 24 | 25 | ```python 26 | body: Body({'application/json': BodyModel}) 27 | ``` 28 | """ 29 | 30 | content: Mapping[MimeType, type] 31 | 32 | 33 | class Metadata(WebArg): 34 | """ 35 | Annotation for models that hold other WebArg fields. 36 | Can be used to group request parameters as an alternative to passing parameters directly. 37 | 38 | Example: 39 | ```python 40 | class RequestMetadata(pydantic.BaseModel): 41 | my_header: typing.Annotated[ 42 | str, 43 | Header('my-header'), 44 | ] 45 | 46 | class Client(ApiClient): 47 | @get(...) 48 | async def my_method( 49 | headers: Annotated[RequestMetadata, Metadata] 50 | ): 51 | ``` 52 | """ 53 | 54 | 55 | class Param(WebArg, abc.ABC): 56 | """Base class for web parameters (headers, query and path parameters, including cookies)""" 57 | 58 | style: typing.Any 59 | alias: typing.Optional[str] 60 | 61 | def __init__(self, alias: typing.Optional[str], /) -> None: 62 | self.alias = alias 63 | 64 | 65 | class Header(Param): 66 | """Mark parameter as HTTP Header""" 67 | 68 | def __init__( 69 | self, 70 | alias: typing.Optional[str] = None, 71 | /, 72 | *, 73 | style: type[MultimapSerializationStyle] = SimpleMultimap, 74 | ) -> None: 75 | """ 76 | :param alias: Header name, if different than the name of the annotated parameter 77 | :param style: Serialization style 78 | """ 79 | super().__init__(alias) 80 | self.style = style 81 | 82 | 83 | class Cookie(Param): 84 | def __init__( 85 | self, 86 | alias: typing.Optional[str] = None, 87 | /, 88 | *, 89 | style: type[MultimapSerializationStyle] = FormExplode, 90 | ) -> None: 91 | """ 92 | :param alias: Cookie name, if different than the name of the annotated parameter 93 | :param style: Serialization style 94 | """ 95 | super().__init__(alias) 96 | self.style = style 97 | 98 | 99 | class Path(Param): 100 | def __init__( 101 | self, 102 | alias: typing.Optional[str] = None, 103 | /, 104 | *, 105 | style: type[StringSerializationStyle] = SimpleString, 106 | ) -> None: 107 | """ 108 | :param alias: Path parameter name, if different than the name of the annotated parameter 109 | :param style: Serialization style 110 | """ 111 | super().__init__(alias) 112 | self.style = style 113 | 114 | 115 | class Query(Param): 116 | def __init__( 117 | self, 118 | alias: typing.Optional[str] = None, 119 | /, 120 | *, 121 | style: type[MultimapSerializationStyle] = FormExplode, 122 | ) -> None: 123 | """ 124 | :param alias: Query parameter name, if different than the name of the annotated parameter 125 | :param style: Serialization style 126 | """ 127 | super().__init__(alias) 128 | self.style = style 129 | 130 | 131 | @dc.dataclass 132 | class Link(WebArg): 133 | alias: str 134 | 135 | 136 | class StatusCode(WebArg): 137 | pass 138 | 139 | 140 | @dc.dataclass 141 | class Response: 142 | body: Body 143 | headers: typing.Optional[type[pydantic.BaseModel]] = None 144 | 145 | 146 | @dc.dataclass 147 | class Responses(WebArg): 148 | """ 149 | Mapping between response code, headers, media type and body type. 150 | The simplified structure is: 151 | 152 | response code => ( 153 | body: content type => body model type 154 | headers model 155 | ) 156 | 157 | The structure follows OpenAPI 3. 158 | """ 159 | 160 | responses: Mapping[StatusCodeRange, Response] 161 | """ 162 | Map of status code match to Response. 163 | Key may be: 164 | 165 | - any HTTP status code 166 | - HTTP status code range, i.e. 1XX, 2XX, etc 167 | - "default" 168 | 169 | The most specific value takes precedence. 170 | 171 | Value is [Body][lapidary.runtime.Body] 172 | """ 173 | -------------------------------------------------------------------------------- /src/lapidary/runtime/auth.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'CookieApiKey', 3 | 'HeaderApiKey', 4 | 'QueryApiKey', 5 | ] 6 | 7 | from httpx_auth import HeaderApiKey, QueryApiKey 8 | 9 | from .model.api_key import CookieApiKey 10 | -------------------------------------------------------------------------------- /src/lapidary/runtime/client_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import logging 5 | 6 | import httpx 7 | import typing_extensions as typing 8 | 9 | from .http_consts import USER_AGENT 10 | from .middleware import HttpxMiddleware 11 | from .model.auth import AuthRegistry 12 | 13 | if typing.TYPE_CHECKING: 14 | import types 15 | from collections.abc import Iterable, Sequence 16 | 17 | from .types_ import ClientArgs, NamedAuth, SecurityRequirements, SessionFactory 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def lapidary_user_agent() -> str: 23 | from importlib.metadata import version 24 | 25 | return f'Lapidary/{version("lapidary")}' 26 | 27 | 28 | class ClientBase(abc.ABC): 29 | """Base for Client classes""" 30 | 31 | def __init__( 32 | self, 33 | security: Iterable[SecurityRequirements] | None = None, 34 | session_factory: SessionFactory = httpx.AsyncClient, 35 | middlewares: Sequence[HttpxMiddleware] = (), 36 | **httpx_kwargs: typing.Unpack[ClientArgs], 37 | ) -> None: 38 | """ 39 | :param security: Security requirements as a mapping of name => list of scopes 40 | :param session_factory: `httpx.AsyncClient` or a subclass type 41 | :param middlewares: list of middlewares to process HTTP requests and responses 42 | :param httpx_kwargs: keyword arguments to pass to session_factory 43 | """ 44 | self._client = session_factory(**httpx_kwargs) 45 | if USER_AGENT not in self._client.headers: 46 | self._client.headers[USER_AGENT] = lapidary_user_agent() 47 | 48 | self._auth_registry = AuthRegistry(security) 49 | self._middlewares = middlewares 50 | 51 | async def __aenter__(self: typing.Self) -> typing.Self: 52 | await self._client.__aenter__() 53 | return self 54 | 55 | async def __aexit__( 56 | self, 57 | exc_type: type[BaseException] | None = None, 58 | exc_value: BaseException | None = None, 59 | traceback: types.TracebackType | None = None, 60 | ) -> bool | None: 61 | return await self._client.__aexit__(exc_type, exc_value, traceback) 62 | 63 | def lapidary_authenticate(self, *auth_args: NamedAuth, **auth_kwargs: httpx.Auth) -> None: 64 | """ 65 | Register named Auth instances for future use with methods that require authentication. 66 | Accepts named [`Auth`][httpx.Auth] as tuples name, auth or as named arguments 67 | """ 68 | if auth_args: 69 | # make python complain about duplicate names 70 | self.lapidary_authenticate(**dict(auth_args), **auth_kwargs) 71 | 72 | self._auth_registry.authenticate(auth_kwargs) 73 | 74 | def lapidary_deauthenticate(self, *sec_names: str) -> None: 75 | """Remove reference to a given Auth instance. 76 | Calling with no parameters removes all references""" 77 | 78 | self._auth_registry.deauthenticate(sec_names) 79 | -------------------------------------------------------------------------------- /src/lapidary/runtime/http_consts.py: -------------------------------------------------------------------------------- 1 | ACCEPT = 'Accept' 2 | CONTENT_TYPE = 'Content-Type' 3 | USER_AGENT = 'User-Agent' 4 | 5 | MIME_JSON = 'application/json' 6 | -------------------------------------------------------------------------------- /src/lapidary/runtime/metattype.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | from collections.abc import Iterable, Mapping 4 | 5 | import typing_extensions 6 | 7 | 8 | def make_not_optional(typ: typing.Any) -> typing.Any: 9 | if typing.get_origin(typ) in (typing.Union, typing_extensions.Union): 10 | arg_types = typing.get_args(typ) 11 | non_none_types = tuple(make_not_optional(arg_typ) for arg_typ in arg_types if arg_typ is not type(None)) 12 | if len(non_none_types) == 1: 13 | return non_none_types[0] 14 | return typing_extensions.Union[non_none_types] 15 | else: 16 | return typ 17 | 18 | 19 | def is_array_like(typ: typing.Any) -> bool: 20 | typ = unwrap_origin(typ) 21 | return inspect.isclass(typ) and issubclass(typ, Iterable) and not (typ in (str, bytes) or issubclass(typ, Mapping)) 22 | 23 | 24 | def unwrap_origin(typ: typing.Any) -> typing.Any: 25 | return typing.get_origin(typ) or typ 26 | -------------------------------------------------------------------------------- /src/lapidary/runtime/middleware.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Generic, TypeVar 3 | 4 | import httpx 5 | 6 | State = TypeVar('State') 7 | 8 | 9 | class HttpxMiddleware(Generic[State]): 10 | """ 11 | Base class for HTTP middleware. 12 | """ 13 | 14 | @abc.abstractmethod 15 | async def handle_request(self, request: httpx.Request) -> State: 16 | """Called for each request after it's generated for a method call but before it's sent to the remote server. 17 | Any returned value will be passed back to handle_response. 18 | """ 19 | 20 | async def handle_response(self, response: httpx.Response, request: httpx.Request, state: State) -> None: 21 | """Called for each response after it's been received from the remote server and before it's converted to the return type as defined 22 | in the python method. 23 | 24 | state is the value returned by handle_request 25 | """ 26 | -------------------------------------------------------------------------------- /src/lapidary/runtime/mime.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Collection 2 | from typing import Optional 3 | 4 | import mimeparse 5 | 6 | 7 | def find_mime(supported_mimes: Optional[Collection[str]], search_mime: str) -> Optional[str]: 8 | if supported_mimes is None or len(supported_mimes) == 0: 9 | return None 10 | match = mimeparse.best_match(supported_mimes, search_mime) 11 | return match if match != '' else None 12 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/__init__.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | class ModelBase(pydantic.BaseModel): 5 | model_config = pydantic.ConfigDict( 6 | extra='allow', 7 | populate_by_name=True, 8 | ) 9 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/annotations.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Iterable 3 | 4 | import pydantic.fields 5 | import typing_extensions as typing 6 | 7 | T = typing.TypeVar('T') 8 | 9 | 10 | def find_annotation(annotated: type, annotation_type: type[T]) -> tuple[type, T]: 11 | """Return the return type and an annotation matching type `annotation_type`.""" 12 | assert not isinstance(annotated, typing.ForwardRef) 13 | return_type, *annotations = typing.get_args(annotated) 14 | return return_type, _find_annotation(annotations, annotation_type) 15 | 16 | 17 | def find_field_annotation(field_info: pydantic.fields.FieldInfo, annotation_type: type[T]) -> tuple[type, T]: 18 | return field_info.annotation, _find_annotation(field_info.metadata, annotation_type) 19 | 20 | 21 | def _find_annotation(annotations: Iterable[typing.Any], annotation_type: type[T]) -> T: 22 | matching = [ 23 | annotation 24 | for annotation in annotations 25 | if (issubclass(annotation, annotation_type) if inspect.isclass(annotation) else isinstance(annotation, annotation_type)) 26 | ] 27 | 28 | if len(matching) != 1: 29 | raise TypeError(f'Expected exactly one {annotation_type.__name__} annotation') 30 | else: 31 | anno = matching[0] 32 | if callable(anno): 33 | return anno() 34 | else: 35 | return anno 36 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/api_key.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import httpx_auth as authx 3 | import typing_extensions as typing 4 | 5 | 6 | class CookieApiKey(httpx.Auth, authx.SupportMultiAuth): 7 | """Describes an API Key requests authentication.""" 8 | 9 | def __init__(self, api_key: str, cookie_name: typing.Optional[str] = None): 10 | """ 11 | :param api_key: The API key that will be sent. 12 | :param cookie_name: Name of the query parameter. "api_key" by default. 13 | """ 14 | self.api_key = api_key 15 | if not api_key: 16 | raise ValueError('API Key is mandatory.') 17 | self.cookie_parameter_name = cookie_name or 'api_key' 18 | 19 | def auth_flow(self, request: httpx.Request) -> typing.Generator[httpx.Request, httpx.Response, None]: 20 | request.headers['Cookie'] = f'{self.cookie_parameter_name}={self.api_key}' 21 | yield request 22 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/auth.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping, MutableMapping 2 | from typing import Optional 3 | 4 | import httpx 5 | 6 | from .._httpx import AuthType 7 | from ..types_ import MultiAuth, SecurityRequirements 8 | 9 | 10 | class AuthRegistry: 11 | def __init__(self, security: Optional[Iterable[SecurityRequirements]]): 12 | # Every Auth instance the user code authenticated with 13 | self._auth: MutableMapping[str, httpx.Auth] = {} 14 | 15 | # (Multi)Auth instance for every operation and the client 16 | self._auth_cache: MutableMapping[str, httpx.Auth] = {} 17 | 18 | # Client-wide security requirements 19 | self._security = security 20 | 21 | def resolve_auth(self, name: str, security: Optional[Iterable[SecurityRequirements]]) -> AuthType: 22 | if security: 23 | sec_name = name 24 | sec_source = security 25 | elif self._security: 26 | sec_name = '*' 27 | sec_source = self._security 28 | else: 29 | sec_name = None 30 | sec_source = None 31 | 32 | if sec_source: 33 | assert sec_name 34 | if sec_name not in self._auth_cache: 35 | auth = self._mk_auth(sec_source) 36 | self._auth_cache[sec_name] = auth 37 | else: 38 | auth = self._auth_cache[sec_name] 39 | return auth 40 | else: 41 | return None 42 | 43 | def _mk_auth(self, security: Iterable[SecurityRequirements]) -> httpx.Auth: 44 | security = list(security) 45 | assert security 46 | last_error: Optional[Exception] = None 47 | for requirements in security: 48 | try: 49 | auth = _build_auth(self._auth, requirements) 50 | break 51 | except ValueError as ve: 52 | last_error = ve 53 | continue 54 | else: 55 | assert last_error 56 | # due to asserts and break above, we never enter here, unless ValueError was raised 57 | raise last_error # noqa 58 | return auth 59 | 60 | def authenticate(self, auth_models: Mapping[str, httpx.Auth]) -> None: 61 | self._auth.update(auth_models) 62 | self._auth_cache.clear() 63 | 64 | def deauthenticate(self, sec_names: Iterable[str]) -> None: 65 | if sec_names: 66 | for sec_name in sec_names: 67 | del self._auth[sec_name] 68 | else: 69 | self._auth.clear() 70 | self._auth_cache.clear() 71 | 72 | 73 | def _build_auth(schemes: Mapping[str, httpx.Auth], requirements: SecurityRequirements) -> httpx.Auth: 74 | auth_flows = [] 75 | for scheme, scopes in requirements.items(): 76 | auth_flow = schemes.get(scheme) 77 | if not auth_flow: 78 | raise ValueError('Not authenticated', scheme) 79 | auth_flows.append(auth_flow) 80 | return MultiAuth(*auth_flows) 81 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Names of HttpErrorResponse and UnexpectedResponse don't end with `Error` in order to avoid double `Error` in the name. 3 | So because both errors really wrap a HTTP response, both their names end with `Response` for consistency. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import typing_extensions as typing 9 | 10 | if typing.TYPE_CHECKING: 11 | import httpx 12 | 13 | 14 | class LapidaryError(Exception): 15 | pass 16 | 17 | 18 | class LapidaryResponseError(LapidaryError): 19 | """Base class for errors that wrap the response""" 20 | 21 | 22 | Body = typing.TypeVar('Body') 23 | Headers = typing.TypeVar('Headers') 24 | 25 | 26 | class HttpErrorResponse(typing.Generic[Body, Headers], LapidaryResponseError): 27 | """ 28 | Base error class for declared HTTP error responses - 4XX & 5XX. 29 | Python doesn't fully support parametrized exception types, but extending types can concretize it. 30 | """ 31 | 32 | def __init__(self, status_code: int, headers: Headers, body: Body): 33 | super().__init__() 34 | self.status_code = status_code 35 | self.headers = headers 36 | self.body = body 37 | 38 | 39 | class UnexpectedResponse(LapidaryResponseError): 40 | """Raised when the remote server responded with code and content-type pair that wasn't declared in the method return annotation""" 41 | 42 | def __init__(self, response: httpx.Response): 43 | self.response = response 44 | self.content_type = response.headers.get('content-type') 45 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/op.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Awaitable, Callable 3 | 4 | import typing_extensions as typing 5 | 6 | from .error import HttpErrorResponse 7 | from .request import RequestAdapter, prepare_request_adapter 8 | from .response import ResponseMessageExtractor, mk_response_extractor 9 | 10 | if typing.TYPE_CHECKING: 11 | from ..client_base import ClientBase 12 | from ..operation import Operation 13 | 14 | 15 | def process_operation_method(fn: Callable, op: 'Operation') -> tuple[RequestAdapter, ResponseMessageExtractor]: 16 | sig = inspect.signature(fn) 17 | type_hints = typing.get_type_hints(fn, include_extras=True) 18 | params = {name: param.replace(annotation=type_hints[name]) for name, param in sig.parameters.items()} 19 | try: 20 | response_extractor, media_types = mk_response_extractor(type_hints['return']) 21 | request_adapter = prepare_request_adapter(fn.__name__, params, op, media_types) 22 | return request_adapter, response_extractor 23 | except TypeError as error: 24 | raise TypeError(fn.__name__) from error 25 | 26 | 27 | def mk_exchange_fn( 28 | op_method: Callable, 29 | op_decorator: 'Operation', 30 | ) -> Callable[..., Awaitable[typing.Any]]: 31 | request_adapter, response_handler = process_operation_method(op_method, op_decorator) 32 | 33 | async def exchange(self: 'ClientBase', **kwargs) -> typing.Any: 34 | request, auth = request_adapter.build_request(self, kwargs) 35 | 36 | mw_state = [] 37 | for mw in self._middlewares: 38 | mw_state.append(await mw.handle_request(request)) 39 | 40 | response = await self._client.send(request, auth=auth) 41 | 42 | await response.aread() 43 | 44 | for mw, state in zip(reversed(self._middlewares), reversed(mw_state)): 45 | await mw.handle_response(response, request, state) 46 | 47 | status_code, result = response_handler.handle_response(response) 48 | if status_code >= 400: 49 | raise HttpErrorResponse(status_code, result[1], result[0]) 50 | else: 51 | return result 52 | 53 | return exchange 54 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/param_serialization.py: -------------------------------------------------------------------------------- 1 | """Converting between types returned by pydantic and accepted by httpx""" 2 | 3 | # Some encoding methods are missing. Please raise an issue if you need them. 4 | import abc 5 | import datetime as dt 6 | import itertools 7 | import uuid 8 | from collections.abc import Iterable, Mapping 9 | from decimal import Decimal 10 | 11 | import typing_extensions as typing 12 | 13 | from ..metattype import make_not_optional, unwrap_origin 14 | 15 | # Some basic scalar types that we're sure are passed unchanged by pydantic 16 | PYTHON_SCALARS = ( 17 | bool, 18 | float, 19 | int, 20 | str, 21 | ) 22 | 23 | # Some basic scalar types that we're sure are handled by pydantic 24 | SCALAR_TYPES = ( 25 | *PYTHON_SCALARS, 26 | Decimal, 27 | dt.date, 28 | dt.datetime, 29 | dt.time, 30 | uuid.UUID, 31 | ) 32 | 33 | ScalarType: typing.TypeAlias = typing.Union[PYTHON_SCALARS] # type: ignore[valid-type] 34 | ArrayType: typing.TypeAlias = typing.Iterable[ScalarType] 35 | ObjectType: typing.TypeAlias = typing.Mapping[str, typing.Optional[ScalarType]] 36 | ValueType: typing.TypeAlias = typing.Union[str, ArrayType, ObjectType] 37 | Entry: typing.TypeAlias = tuple[str, ScalarType] 38 | Multimap: typing.TypeAlias = Iterable[Entry] 39 | 40 | 41 | class SerializationError(ValueError): 42 | pass 43 | 44 | 45 | class StringSerializationStyle(abc.ABC): 46 | """Used for serializing path parameters""" 47 | 48 | @classmethod 49 | def serialize(cls, name: str, value: ValueType) -> str: 50 | if isinstance(value, PYTHON_SCALARS): 51 | return cls.serialize_scalar(name, value) 52 | elif isinstance(value, Mapping): 53 | return cls.serialize_object(name, value) 54 | elif isinstance(value, Iterable): 55 | return cls.serialize_array(name, value) 56 | else: 57 | raise SerializationError(type(value)) 58 | 59 | @classmethod 60 | @abc.abstractmethod 61 | def serialize_object(cls, name: str, value: ObjectType) -> str: 62 | pass 63 | 64 | @classmethod 65 | @abc.abstractmethod 66 | def serialize_array(cls, name: str, value: ArrayType) -> str: 67 | pass 68 | 69 | @classmethod 70 | @abc.abstractmethod 71 | def serialize_scalar(cls, name: str, value: ScalarType) -> str: 72 | pass 73 | 74 | 75 | class MultimapSerializationStyle(abc.ABC): 76 | @classmethod 77 | def serialize(cls, name: str, value: ValueType) -> Multimap: 78 | if value is None: 79 | return () 80 | elif isinstance(value, PYTHON_SCALARS): 81 | return cls.serialize_scalar(name, value) 82 | elif isinstance(value, Mapping): 83 | return cls.serialize_object(name, value) 84 | elif isinstance(value, Iterable): 85 | return cls.serialize_array(name, value) 86 | else: 87 | raise SerializationError(type(value)) 88 | 89 | @classmethod 90 | @abc.abstractmethod 91 | def serialize_object(cls, name: str, value: ObjectType) -> Multimap: 92 | pass 93 | 94 | @classmethod 95 | @abc.abstractmethod 96 | def serialize_array(cls, name: str, value: ArrayType) -> Multimap: 97 | pass 98 | 99 | @classmethod 100 | @abc.abstractmethod 101 | def serialize_scalar(cls, name: str, value: ScalarType) -> Multimap: 102 | pass 103 | 104 | @classmethod 105 | def deserialize(cls, value: typing.Any, target: type) -> ValueType: 106 | target = unwrap_origin(make_not_optional(target)) 107 | if target in PYTHON_SCALARS: 108 | return cls.deserialize_scalar(value, target) 109 | elif issubclass(target, Mapping): 110 | return cls.deserialize_object(value, target) 111 | elif issubclass(target, Iterable): 112 | return cls.deserialize_array(value, target) 113 | else: 114 | raise SerializationError(type(value), target) 115 | 116 | @classmethod 117 | def deserialize_scalar(cls, value: str, _) -> ScalarType: 118 | # leave handling to pydantic 119 | return value 120 | 121 | @classmethod 122 | @abc.abstractmethod 123 | def deserialize_object(cls, value: str, _) -> ValueType: 124 | pass 125 | 126 | @classmethod 127 | @abc.abstractmethod 128 | def deserialize_array(cls, value: str, _) -> ArrayType: 129 | pass 130 | 131 | 132 | class SimpleMultimap(MultimapSerializationStyle): 133 | @classmethod 134 | def serialize_object(cls, name: str, value: ObjectType) -> Multimap: 135 | return [(name, ','.join(itertools.chain.from_iterable(cls._scalar_as_entry(k, v) for k, v in value.items())))] 136 | 137 | @classmethod 138 | def serialize_array(cls, name: str, value: ArrayType) -> Multimap: 139 | return [(name, ','.join(cls._serialize_scalar(scalar) for scalar in value))] 140 | 141 | @classmethod 142 | def serialize_scalar(cls, name: str, value: ScalarType) -> Multimap: 143 | return [cls._scalar_as_entry(name, value)] if value else () 144 | 145 | @staticmethod 146 | def _serialize_scalar(value: ScalarType) -> str: 147 | return str(value) 148 | 149 | @classmethod 150 | def _scalar_as_entry(cls, name: str, value: ScalarType) -> Entry: 151 | return name, cls._serialize_scalar(value) 152 | 153 | @classmethod 154 | def deserialize_object(cls, value: str, _) -> ValueType: 155 | tokens = value.split(',') 156 | return dict((k, cls.deserialize_scalar(v, None)) for k, v in zip(tokens[::2], tokens[1::2])) 157 | 158 | @classmethod 159 | def deserialize_array(cls, value: str, _) -> ArrayType: 160 | return [cls.deserialize_scalar(scalar, None) for scalar in value.split(',')] 161 | 162 | 163 | class SimpleString(StringSerializationStyle): 164 | @classmethod 165 | def serialize_object(cls, name: str, value: ObjectType) -> str: 166 | return ','.join(entry for name, entry in SimpleMultimap.serialize_object(name, value)) 167 | 168 | @classmethod 169 | def serialize_array(cls, name: str, value: ArrayType) -> str: 170 | return ','.join(entry for name, entry in SimpleMultimap.serialize_array(name, value)) 171 | 172 | @classmethod 173 | def serialize_scalar(cls, name: str, value: ScalarType) -> str: 174 | return SimpleMultimap._serialize_scalar(value) 175 | 176 | 177 | # form 178 | 179 | 180 | class FormExplode(MultimapSerializationStyle): 181 | @classmethod 182 | def serialize_scalar(cls, name: str, value: ScalarType) -> Multimap: 183 | return SimpleMultimap.serialize_scalar(name, value) 184 | 185 | @classmethod 186 | def serialize_array(cls, name: str, value: ArrayType) -> Multimap: 187 | return itertools.chain.from_iterable(cls.serialize_scalar(name, item) for item in value) 188 | 189 | @classmethod 190 | def serialize_object(cls, _name: str, value: ObjectType) -> Multimap: 191 | """Disregard name, return a map of {key: value}""" 192 | return itertools.chain.from_iterable(cls.serialize_scalar(key, item) for key, item in value.items() if item) 193 | 194 | @classmethod 195 | def deserialize_array(cls, value: str, _) -> ArrayType: 196 | raise NotImplementedError 197 | 198 | 199 | Form = SimpleMultimap 200 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/request.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses as dc 3 | import functools as ft 4 | import inspect 5 | from collections.abc import Callable, Iterable, Mapping, MutableMapping 6 | 7 | import httpx 8 | import mimeparse 9 | import pydantic 10 | import typing_extensions as typing 11 | 12 | from ..annotations import Body, Cookie, Header, Metadata, Param, Path, Query, WebArg 13 | from ..http_consts import ACCEPT, CONTENT_TYPE, MIME_JSON 14 | from ..metattype import is_array_like, make_not_optional 15 | from ..types_ import Dumper, MimeType, RequestFactory, SecurityRequirements, Signature 16 | from .annotations import ( 17 | find_annotation, 18 | find_field_annotation, 19 | ) 20 | from .param_serialization import SCALAR_TYPES, Multimap, ScalarType 21 | 22 | if typing.TYPE_CHECKING: 23 | from ..client_base import ClientBase 24 | from ..operation import Operation 25 | import logging 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | @dc.dataclass 31 | class RequestBuilder: # pylint: disable=too-many-instance-attributes 32 | """ 33 | Class for incremental building requests. A holder for all required parameters of AsyncClient.send(). 34 | Can be used like a mutable version of httpx.Request, extended with path parameters. 35 | """ 36 | 37 | request_factory: RequestFactory 38 | 39 | method: str 40 | path: str 41 | 42 | cookies: httpx.Cookies = dc.field(default_factory=httpx.Cookies) 43 | headers: httpx.Headers = dc.field(default_factory=httpx.Headers) 44 | path_params: typing.MutableMapping[str, ScalarType] = dc.field(default_factory=dict) 45 | query_params: list[tuple[str, str]] = dc.field(default_factory=list) 46 | 47 | content: typing.Optional[httpx._types.RequestContent] = None 48 | 49 | def __call__(self) -> httpx.Request: 50 | assert self.method 51 | assert self.path 52 | 53 | return self.request_factory( 54 | self.method, 55 | self.path.format_map(self.path_params), 56 | content=self.content, 57 | params=httpx.QueryParams(self.query_params), 58 | headers=self.headers, 59 | cookies=self.cookies, 60 | ) 61 | 62 | 63 | class RequestContributor(abc.ABC): 64 | @abc.abstractmethod 65 | def update_builder(self, builder: RequestBuilder, value: typing.Any) -> None: 66 | pass 67 | 68 | 69 | PST = typing.TypeVar('PST', Multimap, str) 70 | 71 | 72 | @dc.dataclass 73 | class ParamContributor(typing.Generic[PST], RequestContributor, abc.ABC): 74 | param: Param 75 | python_name: str 76 | python_type: type 77 | _serialize: Callable[[str, typing.Any], PST] = dc.field(init=False) 78 | 79 | def __post_init__(self): 80 | non_optional_type = make_not_optional(self.python_type) 81 | if non_optional_type in SCALAR_TYPES: 82 | self._serialize = self.param.style.serialize_scalar 83 | elif is_array_like(non_optional_type): 84 | self._serialize = self.param.style.serialize_array 85 | else: 86 | self._serialize = self.param.style.serialize 87 | 88 | def http_name(self) -> str: 89 | return self.param.alias or self.python_name 90 | 91 | 92 | class DictParamContributor(ParamContributor, abc.ABC): 93 | def update_builder(self, builder: RequestBuilder, value: typing.Any) -> None: 94 | part = self._get_builder_part(builder) 95 | http_name = self.http_name() 96 | part.update(self._serialize(http_name, value)) 97 | 98 | @staticmethod 99 | @abc.abstractmethod 100 | def _get_builder_part(builder: RequestBuilder) -> MutableMapping[str, str]: 101 | pass 102 | 103 | 104 | class HeaderContributor(DictParamContributor): 105 | @staticmethod 106 | def _get_builder_part(builder: RequestBuilder) -> MutableMapping[str, str]: 107 | return builder.headers 108 | 109 | 110 | class CookieContributor(ParamContributor): 111 | @staticmethod 112 | def _get_builder_part(builder: RequestBuilder) -> MutableMapping[str, str]: 113 | return builder.cookies 114 | 115 | def update_builder(self, builder: RequestBuilder, value: Multimap) -> None: 116 | for key, value in value: 117 | builder.cookies.set(key, value, builder.path) 118 | 119 | 120 | class PathContributor(ParamContributor): 121 | def update_builder(self, builder: RequestBuilder, value: typing.Any) -> None: 122 | builder.path_params[self.http_name()] = self._serialize(self.http_name(), value) 123 | 124 | 125 | class QueryContributor(ParamContributor): 126 | def update_builder(self, builder: RequestBuilder, value: typing.Any) -> None: 127 | http_name = self.http_name() 128 | builder.query_params.extend(self._serialize(http_name, value)) 129 | 130 | 131 | CONTRIBUTOR_MAP = { 132 | Path: PathContributor, 133 | Query: QueryContributor, 134 | Header: HeaderContributor, 135 | Cookie: CookieContributor, 136 | } 137 | 138 | 139 | @dc.dataclass 140 | class ParamsContributor(RequestContributor): 141 | contributors: Mapping[str, RequestContributor] 142 | 143 | def update_builder(self, builder: RequestBuilder, headers_model: pydantic.BaseModel) -> None: 144 | raw_model = headers_model.model_dump(mode='json', exclude_unset=True) 145 | for field_name in headers_model.model_fields_set: 146 | assert field_name in type(headers_model).model_fields 147 | value = raw_model[field_name] 148 | if value is None: 149 | continue 150 | contributor = self.contributors[field_name] 151 | contributor.update_builder(builder, value) 152 | 153 | @classmethod 154 | def for_type(cls, model_type: type[pydantic.BaseModel]) -> typing.Self: 155 | contributors = {} 156 | for field_name, field_info in model_type.model_fields.items(): 157 | typ, webarg = find_field_annotation(field_info, Param) 158 | try: 159 | contributor = CONTRIBUTOR_MAP[type(webarg)](webarg, field_name, typ) # type: ignore[abstract] 160 | except KeyError: 161 | raise TypeError('Unsupported annotation', webarg) 162 | contributors[field_name] = contributor 163 | return cls(contributors=contributors) 164 | 165 | 166 | @dc.dataclass 167 | class FreeParamsContributor(ParamsContributor): 168 | model_type: type[pydantic.BaseModel] 169 | 170 | def update_builder(self, builder: RequestBuilder, free_params: Mapping[str, typing.Any]) -> None: 171 | try: 172 | model = self.model_type.model_validate(free_params) 173 | except pydantic.ValidationError as e: 174 | raise TypeError from e 175 | super().update_builder(builder, model) 176 | 177 | 178 | @dc.dataclass 179 | class BodyContributor: 180 | serializers: list[tuple[pydantic.TypeAdapter, str]] 181 | 182 | def update_builder(self, builder: 'RequestBuilder', value: typing.Any, media_type: MimeType = MIME_JSON) -> None: 183 | matched_media_type, content = self._dump(value, media_type) 184 | builder.headers[CONTENT_TYPE] = matched_media_type 185 | builder.content = content 186 | 187 | def _dump(self, value: typing.Any, media_type: MimeType = MIME_JSON): 188 | for type_adapter, media_type_ in self.serializers: 189 | if not BodyContributor._media_matches(media_type_, media_type): 190 | logger.debug('Ignoring unsupported media_type: %s', media_type) 191 | continue 192 | try: 193 | raw = type_adapter.dump_json(value, exclude_unset=True, by_alias=True) 194 | return media_type_, raw 195 | except pydantic.ValidationError: 196 | continue 197 | else: 198 | raise ValueError('Unsupported value') 199 | 200 | @classmethod 201 | def for_parameter(cls, annotation: type) -> typing.Self: 202 | body: Body 203 | _, body = find_annotation(annotation, Body) 204 | serializers = [ 205 | (pydantic.TypeAdapter(python_type), media_type) 206 | for media_type, python_type in body.content.items() 207 | if BodyContributor._media_matches(media_type) 208 | ] 209 | return cls(serializers) 210 | 211 | @staticmethod 212 | def _media_matches(media_type: str, match: str = MIME_JSON) -> bool: 213 | m_type, m_subtype, _ = mimeparse.parse_media_range(media_type) 214 | return f'{m_type}/{m_subtype}' == match 215 | 216 | 217 | @dc.dataclass 218 | class RequestObjectContributor(RequestContributor): 219 | contributors: Mapping[str, RequestContributor] # keys are params names 220 | 221 | body_param: typing.Optional[str] 222 | body_contributor: typing.Optional[BodyContributor] 223 | 224 | free_param_contributor: typing.Optional[FreeParamsContributor] 225 | free_param_names: Iterable[str] 226 | 227 | def update_builder(self, builder: RequestBuilder, kwargs: dict[str, typing.Any]) -> None: 228 | free_params: dict[str, typing.Any] = {} 229 | for name, value in kwargs.items(): 230 | if name == self.body_param: 231 | assert self.body_contributor is not None 232 | self.body_contributor.update_builder(builder, value) 233 | elif name in self.free_param_names: 234 | free_params[name] = value 235 | else: 236 | try: 237 | contributor = self.contributors[name] 238 | except KeyError: 239 | raise TypeError('Unexpected argument', name) from None 240 | contributor.update_builder(builder, value) 241 | if self.free_param_contributor: 242 | self.free_param_contributor.update_builder(builder, free_params) 243 | 244 | @classmethod 245 | def for_signature(cls, sig: Signature) -> typing.Self: 246 | contributors: dict[str, RequestContributor] = {} 247 | body_param: typing.Optional[str] = None 248 | body_contributor: typing.Optional[BodyContributor] = None 249 | 250 | free_params: dict[str, typing.Any] = {} # python name => annotation 251 | 252 | for param in sig.values(): 253 | if param.annotation is typing.Self: 254 | continue 255 | 256 | typ, web_arg = find_annotation(param.annotation, WebArg) 257 | 258 | if type(web_arg) in CONTRIBUTOR_MAP: 259 | default = ... if param.default is inspect.Parameter.empty else param.default 260 | free_params[param.name] = param.annotation, typ, web_arg, default 261 | elif isinstance(web_arg, Body): 262 | body_param = param.name 263 | body_contributor = BodyContributor.for_parameter(param.annotation) 264 | elif isinstance(web_arg, Metadata): 265 | contributors[param.name] = ParamsContributor.for_type(typing.cast(type[pydantic.BaseModel], typ)) 266 | else: 267 | raise TypeError('Unsupported annotation', web_arg) 268 | 269 | free_param_contributor, free_param_names = cls._mk_free_params_contributor(free_params) 270 | 271 | return cls( 272 | contributors=contributors, 273 | body_param=body_param, 274 | body_contributor=body_contributor, 275 | free_param_contributor=free_param_contributor, 276 | free_param_names=free_param_names, 277 | ) 278 | 279 | @staticmethod 280 | def _mk_free_params_contributor(free_params: Mapping[str, typing.Any]) -> tuple[typing.Optional[FreeParamsContributor], Iterable[str]]: 281 | if not free_params: 282 | return None, set() 283 | 284 | model_fields = {} 285 | contributors = {} 286 | for python_name, anno_tuple in free_params.items(): 287 | annotation, typ, web_arg, default = anno_tuple 288 | model_fields[python_name] = (annotation, default) 289 | contributors[python_name] = CONTRIBUTOR_MAP[type(web_arg)](web_arg, python_name, typ) # type: ignore[abstract,index,arg-type] 290 | model_type = pydantic.create_model('$name', **model_fields) 291 | free_param_contributor = FreeParamsContributor(contributors=contributors, model_type=model_type) 292 | return free_param_contributor, set(model_fields.keys()) 293 | 294 | 295 | @dc.dataclass 296 | class RequestAdapter: 297 | name: str 298 | http_method: str 299 | http_path_template: str 300 | contributor: RequestContributor 301 | accept: typing.Optional[Iterable[str]] 302 | security: typing.Optional[Iterable[SecurityRequirements]] 303 | 304 | def build_request( 305 | self, 306 | client: 'ClientBase', 307 | kwargs: dict[str, typing.Any], 308 | ) -> tuple[httpx.Request, typing.Optional[httpx.Auth]]: 309 | builder = RequestBuilder( 310 | typing.cast(RequestFactory, client._client.build_request), 311 | self.http_method, 312 | self.http_path_template, 313 | ) 314 | 315 | self.contributor.update_builder(builder, kwargs) 316 | 317 | accept_values: set[str] = set() 318 | if ACCEPT not in builder.headers and self.accept is not None: 319 | accept_values |= set(self.accept) 320 | builder.headers.update([(ACCEPT, value) for value in accept_values]) 321 | auth = client._auth_registry.resolve_auth(self.name, self.security) 322 | return builder(), auth 323 | 324 | 325 | def prepare_request_adapter(name: str, sig: Signature, operation: 'Operation', accept: Iterable[str]) -> RequestAdapter: 326 | return RequestAdapter( 327 | name, 328 | operation.method, 329 | operation.path, 330 | RequestObjectContributor.for_signature(sig), 331 | accept, 332 | operation.security, 333 | ) 334 | 335 | 336 | @dc.dataclass 337 | class PydanticDumper(Dumper): 338 | _type_adapter: pydantic.TypeAdapter 339 | 340 | def __call__(self, value: typing.Any) -> bytes: 341 | return self._type_adapter.dump_json(value, by_alias=True, exclude_defaults=True) 342 | 343 | 344 | @ft.cache 345 | def mk_pydantic_dumper(typ: type) -> Dumper: 346 | return PydanticDumper(pydantic.TypeAdapter(typ)) 347 | -------------------------------------------------------------------------------- /src/lapidary/runtime/model/response.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses as dc 3 | from collections.abc import Callable, Iterable, Mapping 4 | from typing import Optional 5 | 6 | import httpx 7 | import pydantic 8 | import typing_extensions as typing 9 | 10 | from ..annotations import Cookie, Header, Link, Param, Responses, StatusCode, WebArg 11 | from ..http_consts import CONTENT_TYPE 12 | from ..metattype import is_array_like, make_not_optional 13 | from ..mime import find_mime 14 | from ..type_adapter import TypeAdapter, mk_type_adapter 15 | from ..types_ import MimeType, StatusCodeRange, StatusCodeType 16 | from .annotations import find_annotation, find_field_annotation 17 | from .error import UnexpectedResponse 18 | from .param_serialization import SCALAR_TYPES, ValueType 19 | 20 | 21 | class ResponseExtractor(abc.ABC): 22 | @abc.abstractmethod 23 | def handle_response(self, response: 'httpx.Response') -> typing.Any: 24 | pass 25 | 26 | 27 | class NoopExtractor(ResponseExtractor): 28 | def handle_response(self, response: 'httpx.Response') -> None: 29 | return None 30 | 31 | 32 | _NOOP = NoopExtractor() 33 | 34 | 35 | @dc.dataclass 36 | class BodyExtractor(ResponseExtractor): 37 | type_adapter: typing.Optional[TypeAdapter] 38 | 39 | def handle_response(self, response: httpx.Response) -> typing.Any: 40 | try: 41 | return self.type_adapter(response.text) if self.type_adapter else None 42 | except pydantic.ValidationError as e: 43 | raise UnexpectedResponse(response) from e 44 | 45 | 46 | # header handling 47 | 48 | 49 | @dc.dataclass 50 | class ParamExtractor(ResponseExtractor, abc.ABC): 51 | param: Param 52 | python_name: str 53 | python_type: type 54 | _deserializer: Callable[[str], ValueType] = dc.field(init=False) 55 | 56 | def __post_init__(self): 57 | non_optional_type = make_not_optional(self.python_type) 58 | if non_optional_type in SCALAR_TYPES: 59 | self._deserialize = self.param.style.deserialize_scalar 60 | elif is_array_like(non_optional_type): 61 | self._deserialize = self.param.style.deserialize_array 62 | else: 63 | self._deserialize = self.param.style.deserialize 64 | 65 | def handle_response(self, response: 'httpx.Response') -> typing.Any: 66 | part = self._get_response_part(response) 67 | value = part[self.http_name()] 68 | if value is None: 69 | return None 70 | return self._deserialize(value, make_not_optional(self.python_type)) 71 | 72 | @staticmethod 73 | @abc.abstractmethod 74 | def _get_response_part(response: 'httpx.Response') -> Mapping[str, str]: 75 | pass 76 | 77 | def http_name(self) -> str: 78 | return self.param.alias or self.python_name 79 | 80 | 81 | class HeaderExtractor(ParamExtractor): 82 | @staticmethod 83 | def _get_response_part(response: 'httpx.Response') -> Mapping[str, str]: 84 | return response.headers 85 | 86 | 87 | class CookieExtractor(ParamExtractor): 88 | @staticmethod 89 | def _get_response_part(response: 'httpx.Response') -> Mapping[str, str]: 90 | return response.cookies 91 | 92 | 93 | class LinkExtractor(ParamExtractor): 94 | @staticmethod 95 | def _get_response_part(response: 'httpx.Response') -> Mapping[str, str]: 96 | if 'lapidary_links' not in dir(response): 97 | links = {} 98 | for link in response.links.values(): 99 | try: 100 | links[link['rel']] = link['url'] 101 | except KeyError: 102 | continue 103 | response.links_cached = links 104 | return response.lapidary_links 105 | 106 | 107 | class StatusCodeExtractor(ResponseExtractor): 108 | def __init__(self, _webarg: WebArg, _field_name: str, _typ: typing.Any) -> None: 109 | pass 110 | 111 | def handle_response(self, response: 'httpx.Response') -> int: 112 | return response.status_code 113 | 114 | 115 | EXTRACTOR_MAP = { 116 | Header: HeaderExtractor, 117 | Cookie: CookieExtractor, 118 | Link: LinkExtractor, 119 | StatusCode: StatusCodeExtractor, 120 | } 121 | 122 | 123 | @dc.dataclass 124 | class MetadataExtractor(ResponseExtractor): 125 | field_extractors: Mapping[str, ResponseExtractor] 126 | target_type_adapter: TypeAdapter 127 | 128 | def handle_response(self, response: httpx.Response) -> typing.Any: 129 | target_dict = {} 130 | for field_name, field_extractor in self.field_extractors.items(): 131 | try: 132 | raw_value = field_extractor.handle_response(response) 133 | target_dict[field_name] = raw_value 134 | except KeyError: 135 | continue 136 | 137 | return self.target_type_adapter(target_dict) 138 | 139 | @staticmethod 140 | def for_type(metadata_type: type[pydantic.BaseModel]) -> ResponseExtractor: 141 | header_extractors = {} 142 | for field_name, field_info in metadata_type.model_fields.items(): 143 | try: 144 | typ, webarg = find_field_annotation(field_info, WebArg) 145 | except TypeError: 146 | raise TypeError('Problem with annotations', field_name) 147 | try: 148 | extractor = EXTRACTOR_MAP[type(webarg)](webarg, field_name, typ) # type: ignore[abstract, arg-type] 149 | except KeyError: 150 | raise TypeError('Unsupported annotation', field_name, webarg) 151 | header_extractors[field_name] = extractor 152 | return MetadataExtractor(field_extractors=header_extractors, target_type_adapter=mk_type_adapter(metadata_type, json=False)) 153 | 154 | 155 | # wrap it up 156 | 157 | 158 | @dc.dataclass 159 | class TupleExtractor(ResponseExtractor): 160 | response_extractors: Iterable[ResponseExtractor] 161 | 162 | def handle_response(self, response: httpx.Response) -> tuple: 163 | return tuple(extractor.handle_response(response) for extractor in self.response_extractors) 164 | 165 | 166 | _NOOP_TUPLE = TupleExtractor(response_extractors=(_NOOP, _NOOP)) 167 | 168 | 169 | # similar structure to openapi responses 170 | ResponseExtractorMap: typing.TypeAlias = dict[StatusCodeRange, dict[Optional[MimeType], ResponseExtractor]] 171 | 172 | 173 | @dc.dataclass 174 | class ResponseMessageExtractor(ResponseExtractor): 175 | response_map: ResponseExtractorMap 176 | 177 | def handle_response(self, response: 'httpx.Response') -> tuple[StatusCodeType, tuple[typing.Any, typing.Any]]: 178 | extractor = self._find_extractor(response) 179 | return response.status_code, extractor.handle_response(response) 180 | 181 | def _find_extractor(self, response: httpx.Response) -> ResponseExtractor: 182 | if not self.response_map: 183 | raise UnexpectedResponse(response) 184 | 185 | status_code = str(response.status_code) 186 | for code_match in (status_code, status_code[0] + 'XX', 'default'): 187 | try: 188 | mime_map = self.response_map[code_match] 189 | break 190 | except KeyError: 191 | pass 192 | else: 193 | raise UnexpectedResponse(response) 194 | 195 | try: 196 | media_type = response.headers[CONTENT_TYPE] 197 | except KeyError: 198 | return _NOOP_TUPLE 199 | 200 | mime_match = find_mime([media_type_ for media_type_ in mime_map.keys() if media_type_ is not None], media_type) 201 | if not mime_match: 202 | raise UnexpectedResponse(response) 203 | return mime_map[mime_match] 204 | 205 | @staticmethod 206 | def for_annotated(responses: Responses) -> 'tuple[ResponseMessageExtractor, Iterable[str]]': 207 | # Ideally Lapidary should avoid the first Annotated argument (the type) and leave it to type checkers. 208 | # Instead it should focus on the `Responses` annotation. 209 | 210 | response_map: ResponseExtractorMap = {} 211 | media_types = set() 212 | for status_code, response in responses.responses.items(): 213 | response_map[status_code] = {} 214 | headers_extractor = MetadataExtractor.for_type(response.headers) if response.headers else _NOOP 215 | for media_type, typ in response.body.content.items(): 216 | response_map[status_code][media_type] = TupleExtractor( 217 | ( 218 | BodyExtractor(mk_type_adapter(typ, json=True)), 219 | headers_extractor, 220 | ) 221 | ) 222 | media_types.add(media_type) 223 | else: 224 | response_map[status_code][None] = TupleExtractor( 225 | ( 226 | _NOOP, 227 | headers_extractor, 228 | ) 229 | ) 230 | 231 | return ResponseMessageExtractor(response_map), media_types 232 | 233 | 234 | def mk_response_extractor(annotated: type) -> tuple[ResponseMessageExtractor, Iterable[MimeType]]: 235 | _, responses = find_annotation(annotated, Responses) 236 | return ResponseMessageExtractor.for_annotated(responses) 237 | -------------------------------------------------------------------------------- /src/lapidary/runtime/operation.py: -------------------------------------------------------------------------------- 1 | import dataclasses as dc 2 | import functools as ft 3 | from collections.abc import Callable, Iterable 4 | 5 | import typing_extensions as typing 6 | 7 | from .model.op import mk_exchange_fn 8 | from .types_ import SecurityRequirements 9 | 10 | OperationMethod = typing.TypeVar('OperationMethod', bound=typing.Callable) 11 | SimpleDecorator: typing.TypeAlias = Callable[[OperationMethod], OperationMethod] 12 | 13 | 14 | @dc.dataclass 15 | class Operation: 16 | method: str 17 | path: str 18 | security: typing.Optional[Iterable[SecurityRequirements]] = None 19 | 20 | def __call__(self, fn: OperationMethod) -> OperationMethod: 21 | exchange_fn = mk_exchange_fn(fn, self) 22 | return typing.cast(OperationMethod, ft.wraps(fn)(exchange_fn)) 23 | 24 | 25 | class MethodProto(typing.Protocol): 26 | def __call__(self, path: str, security: typing.Optional[Iterable[SecurityRequirements]] = None) -> typing.Callable: 27 | pass 28 | 29 | 30 | get: MethodProto = ft.partial(Operation, 'GET') 31 | put: MethodProto = ft.partial(Operation, 'PUT') 32 | post: MethodProto = ft.partial(Operation, 'POST') 33 | delete: MethodProto = ft.partial(Operation, 'DELETE') 34 | head: MethodProto = ft.partial(Operation, 'HEAD') 35 | patch: MethodProto = ft.partial(Operation, 'PATCH') 36 | trace: MethodProto = ft.partial(Operation, 'TRACE') 37 | -------------------------------------------------------------------------------- /src/lapidary/runtime/paging.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncIterable, Awaitable, Callable 2 | from typing import Optional, TypeVar 3 | 4 | from typing_extensions import ParamSpec 5 | 6 | P = ParamSpec('P') 7 | R = TypeVar('R') 8 | C = TypeVar('C') 9 | 10 | 11 | def iter_pages( 12 | fn: Callable[P, Awaitable[R]], 13 | cursor_param_name: str, 14 | get_cursor: Callable[[R], Optional[C]], 15 | ) -> Callable[P, AsyncIterable[R]]: 16 | """ 17 | Take a function that returns a pageg response and return a function that returns an async iterator that iterates over the pages. 18 | 19 | The returned function can be called with the same parameters as :param:`fn` (except for the cursor parameter), 20 | and returns an async iterator that yields results from :param:`fn`, handling pagination automatically. 21 | 22 | The function :param:`fn` will be called initially without the cursor parameter and then called with the cursor parameter 23 | as long as :param:`get_cursor` can extract a cursor from the result. 24 | 25 | **Example:** 26 | 27 | ```python 28 | async for page in iter_pages(client.fn, 'cursor', extractor_fn)(parameter=value): 29 | # Process page 30 | ``` 31 | 32 | Typically, an API will use the same paging pattern for all operations supporting it, so it's a good idea to write a shortcut function: 33 | 34 | ```python 35 | from lapidary.runtime import iter_pages as _iter_pages 36 | 37 | def iter_pages[P, R](fn: Callable[P, Awaitable[R]]) -> Callable[P, AsyncIterable[R]]: 38 | return _iter_pages(fn, 'cursor', lambda result: ...) 39 | ``` 40 | 41 | :param fn: An async function that retrieves a page of data. 42 | :param cursor_param_name: The name of the cursor parameter in :param:`fn`. 43 | :param get_cursor: A function that extracts a cursor value from the result of :param:`fn`. Return `None` to end the iteration. 44 | """ 45 | 46 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncIterable[R]: 47 | result = await fn(*args, **kwargs) # type: ignore[call-arg] 48 | yield result 49 | cursor = get_cursor(result) 50 | 51 | while cursor: 52 | kwargs[cursor_param_name] = cursor 53 | result = await fn(*args, **kwargs) # type: ignore[call-arg] 54 | yield result 55 | 56 | cursor = get_cursor(result) 57 | 58 | return wrapper 59 | -------------------------------------------------------------------------------- /src/lapidary/runtime/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-lapidary/lapidary/8b5dfaf9b306f6397de2dc33829c812883dc59a5/src/lapidary/runtime/py.typed -------------------------------------------------------------------------------- /src/lapidary/runtime/pycompat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | 4 | if sys.version_info >= (3, 10): 5 | # python 3.10 syntax `type | type` creates types.UnionType instance 6 | import types 7 | 8 | UNION_TYPES = (types.UnionType, typing.Union) 9 | else: 10 | UNION_TYPES = (typing.Union,) 11 | -------------------------------------------------------------------------------- /src/lapidary/runtime/type_adapter.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | import pydantic 4 | import typing_extensions as typing 5 | 6 | TypeAdapter: typing.TypeAlias = Callable[[typing.Any], typing.Any] 7 | 8 | 9 | def mk_type_adapter(typ: type, json: bool) -> TypeAdapter: 10 | adapter = pydantic.TypeAdapter(typ) 11 | return adapter.validate_json if json else adapter.validate_python 12 | -------------------------------------------------------------------------------- /src/lapidary/runtime/types_.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import ssl 5 | from collections.abc import Callable, Mapping, MutableMapping 6 | 7 | import httpx 8 | import httpx._transports.base 9 | import httpx_auth 10 | import typing_extensions as typing 11 | 12 | from . import _httpx 13 | 14 | MultiAuth: typing.TypeAlias = httpx_auth._authentication._MultiAuth # pylint: disable=protected-access 15 | NamedAuth: typing.TypeAlias = tuple[str, httpx.Auth] 16 | SecurityRequirements: typing.TypeAlias = typing.Mapping[str, typing.Iterable[str]] 17 | 18 | MimeType: typing.TypeAlias = str 19 | MimeMap: typing.TypeAlias = MutableMapping[MimeType, type] 20 | Signature: typing.TypeAlias = Mapping[str, typing.Any] 21 | StatusCodeRange: typing.TypeAlias = str 22 | StatusCodeType: typing.TypeAlias = int 23 | SessionFactory: typing.TypeAlias = Callable[..., httpx.AsyncClient] 24 | 25 | 26 | class Dumper(abc.ABC): 27 | @abc.abstractmethod 28 | def __call__(self, obj: typing.Any) -> bytes: 29 | pass 30 | 31 | 32 | class Parser(abc.ABC): 33 | @abc.abstractmethod 34 | def __call__(self, raw: bytes) -> typing.Any: 35 | pass 36 | 37 | 38 | class RequestFactory(typing.Protocol): 39 | """Protocol for httpx.BaseClient.build_request()""" 40 | 41 | def __call__( # pylint: disable=too-many-arguments 42 | self, 43 | method: str, 44 | url: str, 45 | *, 46 | content: httpx._types.RequestContent | None = None, 47 | _data: httpx._types.RequestData | None = None, 48 | _files: httpx._types.RequestFiles | None = None, 49 | _json: typing.Any | None = None, 50 | params: httpx._types.QueryParamTypes | None = None, 51 | headers: httpx._types.HeaderTypes | None = None, 52 | cookies: httpx._types.CookieTypes | None = None, 53 | _timeout: httpx._types.TimeoutTypes | _httpx.UseClientDefault = httpx.USE_CLIENT_DEFAULT, 54 | _extensions: httpx._types.RequestExtensions | None = None, 55 | ) -> httpx.Request: 56 | pass 57 | 58 | 59 | class ClientArgs(typing.TypedDict): 60 | auth: typing.NotRequired[httpx._types.AuthTypes] 61 | params: typing.NotRequired[httpx._types.QueryParamTypes] 62 | headers: typing.NotRequired[httpx._types.HeaderTypes] 63 | cookies: typing.NotRequired[httpx._types.CookieTypes] 64 | verify: typing.NotRequired[typing.Union[ssl.SSLContext, str, bool]] 65 | cert: typing.NotRequired[httpx._types.CertTypes] 66 | http1: typing.NotRequired[bool] 67 | http2: typing.NotRequired[bool] 68 | proxy: typing.NotRequired[httpx._types.ProxyTypes] 69 | proxies: typing.NotRequired[httpx._types.ProxyTypes] 70 | mounts: typing.NotRequired[typing.Mapping[str, httpx._transports.base.AsyncBaseTransport | None]] 71 | timeout: typing.NotRequired[httpx._types.TimeoutTypes] 72 | follow_redirects: typing.NotRequired[bool] 73 | limits: typing.NotRequired[httpx._config.Limits] 74 | max_redirects: typing.NotRequired[int] 75 | event_hooks: typing.NotRequired[typing.Mapping[str, list[typing.Callable[..., typing.Any]]]] 76 | base_url: typing.NotRequired[httpx._types.URLTypes] 77 | transport: typing.NotRequired[httpx._transports.base.AsyncBaseTransport] 78 | app: typing.NotRequired[typing.Callable[..., typing.Any]] 79 | trust_env: typing.NotRequired[bool] 80 | default_encoding: typing.NotRequired[str | typing.Callable[[bytes], str]] 81 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-lapidary/lapidary/8b5dfaf9b306f6397de2dc33829c812883dc59a5/tests/__init__.py -------------------------------------------------------------------------------- /tests/client.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | from lapidary.runtime import ClientBase 4 | 5 | 6 | class ClientTestBase(ClientBase): 7 | def __init__(self, client: httpx.AsyncClient): 8 | super().__init__(session_factory=lambda **_: client) 9 | -------------------------------------------------------------------------------- /tests/test_annotations.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | import pytest 4 | 5 | from lapidary.runtime import Header 6 | from lapidary.runtime.model.annotations import find_annotation 7 | 8 | 9 | def test_find_annotations(): 10 | t = Annotated[int, Header()] 11 | typ, header = find_annotation(t, Header) 12 | assert typ is int 13 | assert isinstance(header, Header) 14 | 15 | 16 | def test_find_annotations_class(): 17 | t = Annotated[int, Header] 18 | typ, header = find_annotation(t, Header) 19 | assert typ is int 20 | assert isinstance(header, Header) 21 | 22 | 23 | def test_find_annotations_multi(): 24 | t = Annotated[int, Header, Header()] 25 | with pytest.raises(Exception): 26 | find_annotation(t, Header) 27 | -------------------------------------------------------------------------------- /tests/test_body_serialization.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Generic, Optional, Union 2 | 3 | from client import ClientTestBase 4 | from httpx import AsyncClient 5 | from typing_extensions import Self, TypeVar 6 | 7 | from lapidary.runtime import Body, ModelBase, Responses, get 8 | from lapidary.runtime.model.op import process_operation_method 9 | 10 | 11 | class BodyModel(ModelBase): 12 | a: Optional[str] 13 | 14 | 15 | def test_serialize_str(): 16 | class Client(ClientTestBase): 17 | def op(self: Self, body: Annotated[str, Body({'application/json': str})]) -> Annotated[None, Responses({})]: 18 | pass 19 | 20 | client = ClientTestBase(AsyncClient()) 21 | 22 | adapter, response = process_operation_method(Client.op, get('/path')) 23 | request, auth = adapter.build_request(client, dict(body='a')) 24 | 25 | assert request.content == b'"a"' 26 | 27 | 28 | def test_serialize_obj(): 29 | class Client(ClientTestBase): 30 | def op(self: Self, body: Annotated[BodyModel, Body({'application/json': BodyModel})]) -> Annotated[None, Responses({})]: 31 | pass 32 | 33 | client = ClientTestBase(AsyncClient()) 34 | 35 | adapter, response = process_operation_method(Client.op, get('/path')) 36 | request, auth = adapter.build_request(client, dict(body=BodyModel(a='a'))) 37 | 38 | assert request.content == b'{"a":"a"}' 39 | 40 | 41 | def test_serialize_list(): 42 | class Client(ClientTestBase): 43 | def op(self: Self, body: Annotated[list[BodyModel], Body({'application/json': list[BodyModel]})]) -> Annotated[None, Responses({})]: 44 | pass 45 | 46 | client = ClientTestBase(AsyncClient()) 47 | 48 | adapter, response = process_operation_method(Client.op, get('/path')) 49 | request, auth = adapter.build_request(client, dict(body=[BodyModel(a='a')])) 50 | 51 | assert request.content == b'[{"a":"a"}]' 52 | 53 | 54 | T = TypeVar('T') 55 | 56 | 57 | class GenericBodyModel(ModelBase, Generic[T]): 58 | a: T 59 | 60 | 61 | def test_serialize_generic_str(): 62 | class Client(ClientTestBase): 63 | def op( 64 | self: Self, body: Annotated[list[GenericBodyModel[str]], Body({'application/json': list[GenericBodyModel[str]]})] 65 | ) -> Annotated[None, Responses({})]: 66 | pass 67 | 68 | client = ClientTestBase(AsyncClient()) 69 | 70 | adapter, response = process_operation_method(Client.op, get('/path')) 71 | request, auth = adapter.build_request(client, dict(body=[GenericBodyModel(a='a')])) 72 | 73 | assert request.content == b'[{"a":"a"}]' 74 | 75 | 76 | def test_serialize_generic_int(): 77 | class Client(ClientTestBase): 78 | def op( 79 | self: Self, body: Annotated[list[GenericBodyModel[int]], Body({'application/json': list[GenericBodyModel[int]]})] 80 | ) -> Annotated[None, Responses({})]: 81 | pass 82 | 83 | client = ClientTestBase(AsyncClient()) 84 | 85 | adapter, response = process_operation_method(Client.op, get('/path')) 86 | request, auth = adapter.build_request(client, dict(body=[GenericBodyModel(a=1)])) 87 | 88 | assert request.content == b'[{"a":1}]' 89 | 90 | 91 | def test_serialize_generic_obj(): 92 | class Client(ClientTestBase): 93 | def op( 94 | self: Self, body: Annotated[list[GenericBodyModel[BodyModel]], Body({'application/json': list[GenericBodyModel[BodyModel]]})] 95 | ) -> Annotated[None, Responses({})]: 96 | pass 97 | 98 | client = ClientTestBase(AsyncClient()) 99 | 100 | adapter, response = process_operation_method(Client.op, get('/path')) 101 | request, auth = adapter.build_request(client, dict(body=[GenericBodyModel(a=BodyModel(a='a'))])) 102 | 103 | assert request.content == b'[{"a":{"a":"a"}}]' 104 | 105 | 106 | def test_serialize_generic_union(): 107 | class Client(ClientTestBase): 108 | def op( 109 | self: Self, 110 | body: Annotated[ 111 | Union[list[GenericBodyModel[BodyModel]], GenericBodyModel[BodyModel], BodyModel], 112 | Body({'application/json': Union[list[GenericBodyModel[BodyModel]], GenericBodyModel[BodyModel], BodyModel]}), 113 | ], 114 | ) -> Annotated[None, Responses({})]: 115 | pass 116 | 117 | client = ClientTestBase(AsyncClient()) 118 | 119 | adapter, _ = process_operation_method(Client.op, get('/path')) 120 | 121 | request, _ = adapter.build_request(client, dict(body=[GenericBodyModel[BodyModel](a=BodyModel(a='a'))])) 122 | assert request.content == b'[{"a":{"a":"a"}}]' 123 | 124 | request, _ = adapter.build_request(client, dict(body=GenericBodyModel[BodyModel](a=BodyModel(a='a')))) 125 | assert request.content == b'{"a":{"a":"a"}}' 126 | 127 | request, _ = adapter.build_request(client, dict(body=BodyModel(a='a'))) 128 | assert request.content == b'{"a":"a"}' 129 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import email.utils 3 | 4 | import httpx 5 | import pydantic 6 | import pytest 7 | import pytest_asyncio 8 | import typing_extensions as typing 9 | from starlette.requests import Request 10 | from starlette.responses import JSONResponse 11 | 12 | from lapidary.runtime import ( 13 | Body, 14 | ClientBase, 15 | Header, 16 | HttpErrorResponse, 17 | Metadata, 18 | ModelBase, 19 | Path, 20 | Response, 21 | Responses, 22 | SimpleString, 23 | StatusCode, 24 | UnexpectedResponse, 25 | get, 26 | post, 27 | ) 28 | from lapidary.runtime.http_consts import MIME_JSON 29 | 30 | # model (common to both client and server) 31 | 32 | 33 | class Cat(pydantic.BaseModel): 34 | id: typing.Optional[int] = None 35 | name: str 36 | 37 | 38 | class AuthRequest(pydantic.BaseModel): 39 | login: str 40 | password: str 41 | 42 | 43 | class AuthResponse(pydantic.BaseModel): 44 | api_key: str 45 | 46 | 47 | class ServerErrorModel(ModelBase): 48 | msg: str 49 | 50 | 51 | DATE = dt.datetime(2024, 7, 28, 0, 55, tzinfo=dt.timezone.utc) 52 | 53 | # server 54 | 55 | 56 | async def cat_list(request: Request) -> JSONResponse: 57 | return_list = request.headers.get('return-list', 'false') == 'True' 58 | token = request.headers.get('token') 59 | 60 | serializer = pydantic.TypeAdapter(list[Cat]) 61 | data = [Cat(id=1, name='Tom')] 62 | headers = { 63 | 'X-Count': str(len(data)), 64 | 'Date': DATE.strftime('%a, %d %b %Y %H:%M:%S %Z'), 65 | 'returning_list': 'true' if return_list else 'false', 66 | } 67 | if token: 68 | headers['token'] = token 69 | return JSONResponse( 70 | serializer.dump_python(data), 71 | headers=headers, 72 | ) 73 | 74 | 75 | async def get_cat(request: Request) -> JSONResponse: 76 | cat_id = request.path_params['cat_id'] 77 | if cat_id != 1: 78 | return JSONResponse(pydantic.TypeAdapter(ServerErrorModel).dump_python(ServerErrorModel(msg='Cat not found')), 404) 79 | return JSONResponse(Cat(id=1, name='Tom').model_dump(), 200) 80 | 81 | 82 | class CatWriteDTO(pydantic.BaseModel): 83 | name: str 84 | model_config = pydantic.ConfigDict(extra='forbid') 85 | 86 | 87 | async def create_cat(request: Request) -> JSONResponse: 88 | try: 89 | cat = CatWriteDTO.model_validate_json(await request.body()) 90 | except pydantic.ValidationError as e: 91 | return JSONResponse(e.json(), 422) 92 | print(cat.model_dump()) 93 | return JSONResponse(Cat(name=cat.name, id=2).model_dump(), 201) 94 | 95 | 96 | async def login(request: Request) -> JSONResponse: 97 | body = AuthRequest.model_validate_json(await request.body()) 98 | assert body.login == 'login' 99 | assert body.password == 'passwd' 100 | 101 | return JSONResponse(AuthResponse(api_key="you're in").model_dump()) 102 | 103 | 104 | async def return_body(request: Request) -> JSONResponse: 105 | return JSONResponse(await request.json()) 106 | 107 | 108 | # Client 109 | 110 | 111 | class CatListRequestHeaders(pydantic.BaseModel): 112 | token: typing.Annotated[typing.Optional[str], Header] = None 113 | return_list: typing.Annotated[typing.Optional[bool], Header('return-list')] = None 114 | 115 | 116 | class CatListResponseHeaders(pydantic.BaseModel): 117 | count: typing.Annotated[int, Header('X-Count')] 118 | token: typing.Annotated[typing.Optional[str], Header] = None 119 | date: typing.Annotated[ 120 | dt.datetime, 121 | Header, 122 | pydantic.BeforeValidator(email.utils.parsedate_to_datetime), 123 | ] 124 | status_code: typing.Annotated[int, StatusCode] 125 | returning_list: typing.Annotated[bool, Header] 126 | 127 | 128 | class CatClient(ClientBase): 129 | def __init__( 130 | self, 131 | base_url='http://localhost', 132 | **httpx_args, 133 | ): 134 | super().__init__( 135 | base_url=base_url, 136 | **httpx_args, 137 | ) 138 | 139 | @get('/cat') 140 | async def cat_list( 141 | self: typing.Self, 142 | meta: typing.Annotated[CatListRequestHeaders, Metadata], 143 | ) -> typing.Annotated[ 144 | tuple[list[Cat], CatListResponseHeaders], 145 | Responses( 146 | { 147 | 'default': Response(Body({'application/json': list[Cat]}), CatListResponseHeaders), 148 | } 149 | ), 150 | ]: 151 | pass 152 | 153 | @get('/cat/{id}') 154 | async def cat_get( 155 | self: typing.Self, 156 | *, 157 | id: typing.Annotated[int, Path(style=SimpleString)], # pylint: disable=redefined-builtin 158 | ) -> typing.Annotated[ 159 | tuple[Cat, None], 160 | Responses( 161 | { 162 | '2XX': Response(Body({'application/json': Cat})), 163 | '4XX': Response(Body({'application/json': ServerErrorModel})), 164 | } 165 | ), 166 | ]: 167 | pass 168 | 169 | @post('/cat') 170 | async def cat_create( 171 | self: typing.Self, 172 | *, 173 | body: typing.Annotated[Cat, Body({'application/json': Cat})], 174 | ) -> typing.Annotated[ 175 | tuple[Cat, None], 176 | Responses( 177 | { 178 | '2XX': Response(Body({'application/json': Cat})), 179 | } 180 | ), 181 | ]: 182 | pass 183 | 184 | @post('/login') 185 | async def login( 186 | self: typing.Self, 187 | *, 188 | body: typing.Annotated[AuthRequest, Body({MIME_JSON: AuthRequest})], 189 | ) -> typing.Annotated[ 190 | tuple[AuthResponse, None], 191 | Responses( 192 | { 193 | '200': Response( 194 | Body( 195 | { 196 | MIME_JSON: AuthResponse, 197 | } 198 | ) 199 | ) 200 | } 201 | ), 202 | ]: 203 | pass 204 | 205 | @post('/body') 206 | async def body( 207 | self: typing.Self, body: typing.Annotated[pydantic.JsonValue, Body({'application/json': pydantic.JsonValue})] 208 | ) -> typing.Annotated[ 209 | tuple[pydantic.JsonValue, None], 210 | Responses( 211 | { 212 | '2XX': Response(Body({'application/json': pydantic.JsonValue})), 213 | } 214 | ), 215 | ]: 216 | pass 217 | 218 | 219 | @pytest_asyncio.fixture 220 | async def client() -> CatClient: 221 | from starlette.applications import Starlette 222 | from starlette.routing import Route 223 | 224 | app = Starlette( 225 | debug=True, 226 | routes=[ 227 | Route('/cat/{cat_id:int}', get_cat), 228 | Route('/cat', cat_list), 229 | Route('/cat', create_cat, methods=['POST']), 230 | Route('/login', login, methods=['POST']), 231 | Route('/body', return_body, methods=['POST']), 232 | ], 233 | ) 234 | 235 | return CatClient(transport=httpx.ASGITransport(app=app)) 236 | 237 | 238 | # tests 239 | 240 | 241 | @pytest.mark.asyncio 242 | async def test_request_response(client: CatClient): 243 | response_body, response_headers = await client.cat_list( 244 | meta=CatListRequestHeaders( 245 | token='header-value', 246 | return_list=True, 247 | ) 248 | ) 249 | assert response_headers.status_code == 200 250 | assert response_headers.count == 1 251 | assert response_headers.token == 'header-value' 252 | assert response_headers.date == DATE 253 | assert response_headers.returning_list is True 254 | assert response_body == [Cat(id=1, name='Tom')] 255 | 256 | response_body, response_headers = await client.cat_list() 257 | assert response_headers.token is None 258 | cat, _ = await client.cat_get(id=1) 259 | assert isinstance(cat, Cat) 260 | assert cat == Cat(id=1, name='Tom') 261 | 262 | 263 | @pytest.mark.asyncio 264 | async def test_response_auth(client: CatClient): 265 | response, _ = await client.login(body=AuthRequest(login='login', password='passwd')) 266 | 267 | assert response.api_key == "you're in" 268 | 269 | 270 | @pytest.mark.asyncio 271 | async def test_json_value(client: CatClient): 272 | response, _ = await client.body(body={'a': 'b'}) 273 | 274 | assert response == {'a': 'b'} 275 | 276 | 277 | @pytest.mark.asyncio 278 | async def test_error(client: CatClient): 279 | try: 280 | await client.cat_get(id=7) 281 | assert False, 'Expected ServerError' 282 | except HttpErrorResponse as e: 283 | assert isinstance(e.body, ServerErrorModel) 284 | assert e.body.msg == 'Cat not found' 285 | 286 | 287 | @pytest.mark.asyncio 288 | async def test_create(client: CatClient): 289 | body, _ = await client.cat_create(body=Cat(name='Benny')) 290 | assert body.id == 2 291 | assert body.name == 'Benny' 292 | 293 | 294 | @pytest.mark.asyncio 295 | async def test_create_error(client: CatClient): 296 | with pytest.raises(UnexpectedResponse) as error: 297 | await client.cat_create(body=Cat(id=1, name='Benny')) 298 | assert error.value.response.status_code == 422 299 | -------------------------------------------------------------------------------- /tests/test_find_mime.py: -------------------------------------------------------------------------------- 1 | from lapidary.runtime import mime 2 | from lapidary.runtime.http_consts import MIME_JSON 3 | 4 | 5 | def test_find_mime(): 6 | resolved = mime.find_mime([MIME_JSON], 'application/json; charset=utf-8') 7 | assert resolved == MIME_JSON 8 | -------------------------------------------------------------------------------- /tests/test_httpx.py: -------------------------------------------------------------------------------- 1 | """Never test third-party code...""" 2 | 3 | import httpx 4 | import pytest 5 | import pytest_asyncio 6 | from starlette.applications import Starlette 7 | from starlette.requests import Request 8 | from starlette.responses import JSONResponse 9 | from starlette.routing import Route 10 | 11 | 12 | @pytest_asyncio.fixture 13 | async def client_server() -> httpx.AsyncClient: 14 | async def request(request: Request): 15 | return JSONResponse( 16 | { 17 | 'url': str(request.url), 18 | 'param': request.query_params.getlist('param'), 19 | 'header': request.headers.getlist('param'), 20 | 'cookie': request.cookies.get('param'), 21 | } 22 | ) 23 | 24 | app = Starlette( 25 | debug=True, 26 | routes=[ 27 | Route('/', request), 28 | ], 29 | ) 30 | 31 | async with httpx.AsyncClient(base_url='http://example.com', transport=httpx.ASGITransport(app)) as client: 32 | yield client 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_query_array_serialization(client_server: httpx.AsyncClient): 37 | response = await client_server.get('http://example.com', params=httpx.QueryParams({'param': ('value1', 'value2')})) 38 | json = response.json() 39 | assert json['param'] == ['value1', 'value2'] 40 | assert json['url'] == 'http://example.com/?param=value1¶m=value2' 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_query_array_as_tuple_list_serialization(client_server: httpx.AsyncClient): 45 | response = await client_server.get('http://example.com', params=httpx.QueryParams([('param', 'value1'), ('param', 'value2')])) 46 | json = response.json() 47 | assert json['param'] == ['value1', 'value2'] 48 | assert json['url'] == 'http://example.com/?param=value1¶m=value2' 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_header_array_explode_serialization(client_server: httpx.AsyncClient): 53 | response = await client_server.get( 54 | 'http://example.com', 55 | headers=httpx.Headers( 56 | [ 57 | ('param', 'value1'), 58 | ('param', 'value2'), 59 | ] 60 | ), 61 | ) 62 | assert response.json().get('header') == ['value1', 'value2'] 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_header_array_serialization(client_server: httpx.AsyncClient): 67 | """Both httpx and starlette seem to ignore the comma in value""" 68 | response = await client_server.get( 69 | 'http://example.com', 70 | headers=httpx.Headers( 71 | { 72 | 'param': 'value1,value2', 73 | } 74 | ), 75 | ) 76 | assert response.json().get('header') == ['value1,value2'] 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_cookie_array_explode_serialization(client_server: httpx.AsyncClient): 81 | """Both httpx and starlette seem to ignore the comma in value""" 82 | response = await client_server.get( 83 | 'http://example.com', 84 | cookies=httpx.Cookies( 85 | [ 86 | ('param', 'value1'), 87 | ('param', 'value2'), 88 | ] 89 | ), 90 | ) 91 | assert response.json().get('cookie') == 'value2' 92 | -------------------------------------------------------------------------------- /tests/test_metattype.py: -------------------------------------------------------------------------------- 1 | import collections.abc 2 | import typing 3 | 4 | import typing_extensions 5 | 6 | from lapidary.runtime.metattype import make_not_optional 7 | 8 | 9 | def test_make_not_optional_str(): 10 | assert make_not_optional(str) is str 11 | 12 | 13 | def test_make_not_optional_typing_optional_str(): 14 | assert make_not_optional(typing.Optional[str]) is str 15 | 16 | 17 | def test_make_not_optional_typing_extensions_optional_str(): 18 | assert make_not_optional(typing_extensions.Optional[str]) is str 19 | 20 | 21 | def test_make_not_optional_iterable_str(): 22 | assert make_not_optional(collections.abc.Iterable[str]) == collections.abc.Iterable[str] 23 | -------------------------------------------------------------------------------- /tests/test_param_serialization.py: -------------------------------------------------------------------------------- 1 | from lapidary.runtime.model.param_serialization import FormExplode, SimpleMultimap, SimpleString 2 | 3 | # simple no-explode, multimap version 4 | 5 | 6 | def test_simple_multimap_string(): 7 | assert SimpleMultimap.serialize_scalar('name', 'value') == [('name', 'value')] 8 | 9 | 10 | def test_simple_multimap_scalar_none(): 11 | assert list(SimpleMultimap.serialize_scalar('name', None)) == [] 12 | 13 | 14 | def test_simple_multimap_none(): 15 | assert list(SimpleMultimap.serialize('name', None)) == [] 16 | 17 | 18 | def test_simple_multimap_int(): 19 | assert SimpleMultimap.serialize_scalar('name', 1) == [('name', '1')] 20 | 21 | 22 | def test_simple_multimap_list(): 23 | assert SimpleMultimap.serialize_array('name', ['value', 1]) == [('name', 'value,1')] 24 | 25 | 26 | def test_simple_multimap_object(): 27 | assert SimpleMultimap.serialize_object('name', {'key1': 'value', 'key2': 1}) == [('name', 'key1,value,key2,1')] 28 | 29 | 30 | def test_deser_simple_multimap_str(): 31 | assert SimpleMultimap.deserialize_scalar('value', str) == 'value' 32 | 33 | 34 | def test_deser_simple_multimap_str_generic(): 35 | assert SimpleMultimap.deserialize('value', str) == 'value' 36 | 37 | 38 | def test_deser_simple_multimap_array(): 39 | assert SimpleMultimap.deserialize_array('value1,value2', list[str]) == ['value1', 'value2'] 40 | 41 | 42 | def test_deser_simple_multimap_array_generic(): 43 | assert SimpleMultimap.deserialize('value1,value2', list[str]) == ['value1', 'value2'] 44 | 45 | 46 | def test_deser_simple_multimap_object(): 47 | assert SimpleMultimap.deserialize_object('name1,value1,name2,value2', dict[str, str]) == {'name1': 'value1', 'name2': 'value2'} 48 | 49 | 50 | def test_deser_simple_multimap_object_generic(): 51 | assert SimpleMultimap.deserialize('name1,value1,name2,value2', dict[str, str]) == {'name1': 'value1', 'name2': 'value2'} 52 | 53 | 54 | # simple no-explode, string version 55 | 56 | 57 | def test_simple_string_string(): 58 | assert SimpleString.serialize_scalar('name', 'value') == 'value' 59 | 60 | 61 | def test_simple_string_int(): 62 | assert SimpleString.serialize_scalar('name', 1) == '1' 63 | 64 | 65 | def test_simple_string_list(): 66 | assert SimpleString.serialize_array('name', ['value', 1]) == 'value,1' 67 | 68 | 69 | def test_simple_string_object(): 70 | assert SimpleString.serialize_object('name', {'key1': 'value', 'key2': 1}) == 'key1,value,key2,1' 71 | 72 | 73 | # form explode (multimap only) 74 | 75 | 76 | def test_form_explode_string(): 77 | assert FormExplode.serialize_scalar('name', 'value') == [('name', 'value')] 78 | 79 | 80 | def test_form_explode_int(): 81 | assert FormExplode.serialize_scalar('name', 1) == [('name', '1')] 82 | 83 | 84 | def test_form_explode_list(): 85 | assert list(FormExplode.serialize_array('name', ['value', 1])) == [('name', 'value'), ('name', '1')] 86 | 87 | 88 | def test_form_explode_object(): 89 | assert list(FormExplode.serialize_object('name', {'key1': 'value', 'key2': 1})) == [('key1', 'value'), ('key2', '1')] 90 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from collections.abc import Awaitable 3 | from unittest.mock import AsyncMock, Mock 4 | 5 | import httpx 6 | import pydantic 7 | import pytest 8 | import pytest_asyncio 9 | import typing_extensions as typing 10 | 11 | from lapidary.runtime import Body, Query, Responses, SimpleMultimap, UnexpectedResponse, get 12 | from lapidary.runtime.http_consts import CONTENT_TYPE 13 | from tests.client import ClientTestBase 14 | 15 | 16 | class MyRequestBodyModel(pydantic.BaseModel): 17 | a: str 18 | 19 | 20 | class MyRequestBodyList(pydantic.RootModel): 21 | root: list[MyRequestBodyModel] 22 | 23 | 24 | @pytest_asyncio.fixture(scope='function') 25 | async def mock_http_client(): 26 | client = httpx.AsyncClient(base_url='http://example.com') 27 | client.build_request = Mock(wraps=client.build_request) 28 | response = httpx.Response(543, request=httpx.Request('get', '')) 29 | 30 | client.send = AsyncMock(return_value=response) 31 | 32 | yield client 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_build_request_from_list(mock_http_client) -> None: 37 | class Client(ClientTestBase): 38 | @get('/body_list') 39 | async def body_list( 40 | self: typing.Self, 41 | body: typing.Annotated[MyRequestBodyList, Body({'application/json': MyRequestBodyList})], 42 | ) -> typing.Annotated[Awaitable[None], Responses({})]: 43 | pass 44 | 45 | async with Client(client=mock_http_client) as client: 46 | with pytest.raises(UnexpectedResponse): 47 | await client.body_list(body=MyRequestBodyList(root=[MyRequestBodyModel(a='a')])) 48 | 49 | mock_http_client.build_request.assert_called_with( 50 | 'GET', 51 | '/body_list', 52 | content=b'[{"a":"a"}]', 53 | params=httpx.QueryParams(), 54 | headers=httpx.Headers( 55 | { 56 | CONTENT_TYPE: 'application/json', 57 | } 58 | ), 59 | cookies=httpx.Cookies(), 60 | ) 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_request_param_list_simple(mock_http_client): 65 | class Client(ClientTestBase): 66 | @get('/param_list_simple') 67 | async def param_list_simple( 68 | self: typing.Self, 69 | q_a: typing.Annotated[list[str], Query('a', style=SimpleMultimap)], 70 | ) -> typing.Annotated[Awaitable[None], Responses({})]: 71 | pass 72 | 73 | async with Client(client=mock_http_client) as client: 74 | with pytest.raises(UnexpectedResponse): 75 | await client.param_list_simple(q_a=['hello', 'world']) 76 | 77 | mock_http_client.build_request.assert_called_with( 78 | 'GET', 79 | '/param_list_simple', 80 | content=None, 81 | params=httpx.QueryParams(a='hello,world'), 82 | headers=httpx.Headers(), 83 | cookies=httpx.Cookies(), 84 | ) 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_build_request_none(mock_http_client): 89 | class Client(ClientTestBase): 90 | @get('/request_none') 91 | async def request_none( 92 | self: typing.Self, 93 | ) -> typing.Annotated[Awaitable[None], Responses({})]: 94 | pass 95 | 96 | async with Client(client=mock_http_client) as client: 97 | with pytest.raises(UnexpectedResponse): 98 | await client.request_none() 99 | 100 | mock_http_client.build_request.assert_called_with( 101 | 'GET', 102 | '/request_none', 103 | content=None, 104 | params=httpx.QueryParams(), 105 | headers=httpx.Headers(), 106 | cookies=httpx.Cookies(), 107 | ) 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_request_param_list_exploded(mock_http_client): 112 | class Client(ClientTestBase): 113 | @get('/param_list_exploded') 114 | async def param_list_exploded( 115 | self: typing.Self, 116 | q_a: typing.Annotated[list[str], Query('a', style=SimpleMultimap)], 117 | ) -> typing.Annotated[Awaitable[None], Responses({})]: 118 | pass 119 | 120 | async with Client(client=mock_http_client) as client: 121 | with pytest.raises(UnexpectedResponse): 122 | await client.param_list_exploded(q_a=['hello', 'world']) 123 | 124 | mock_http_client.build_request.assert_called_with( 125 | 'GET', 126 | '/param_list_exploded', 127 | content=None, 128 | params=httpx.QueryParams(a='hello,world'), 129 | headers=httpx.Headers(), 130 | cookies=httpx.Cookies(), 131 | ) 132 | 133 | 134 | @pytest.mark.asyncio 135 | async def test_missing_required_param(mock_http_client): 136 | class Client(ClientTestBase): 137 | @get('/param_list_exploded') 138 | async def op( 139 | self: typing.Self, 140 | q_a: typing.Annotated[list[str], Query], 141 | ) -> typing.Annotated[Awaitable[None], Responses({})]: 142 | pass 143 | 144 | async with Client(client=mock_http_client) as client: 145 | with pytest.raises(TypeError): 146 | await client.op() 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_build_request_param_date(mock_http_client): 151 | class Client(ClientTestBase): 152 | @get('/request_date') 153 | async def request_date( 154 | self: typing.Self, 155 | date: typing.Annotated[dt.date, Query()], 156 | ) -> typing.Annotated[tuple[dt.date, None], Responses({})]: 157 | pass 158 | 159 | today = dt.date.today() 160 | 161 | async with Client(client=mock_http_client) as client: 162 | with pytest.raises(UnexpectedResponse): 163 | await client.request_date(date=today) 164 | 165 | mock_http_client.build_request.assert_called_with( 166 | 'GET', 167 | '/request_date', 168 | content=None, 169 | params=httpx.QueryParams({'date': today.isoformat()}), 170 | headers=httpx.Headers(), 171 | cookies=httpx.Cookies(), 172 | ) 173 | --------------------------------------------------------------------------------