├── tests ├── __init__.py ├── routers │ ├── __init__.py │ └── item.py ├── middleware.py ├── app.py └── test.py ├── .gitignore ├── simpleapi ├── router.py ├── custom_types.py ├── __init__.py ├── README.md ├── request.py ├── core.py ├── simpleapi.py ├── utils.py ├── response.py └── handler.py ├── docs ├── assets │ ├── logo.png │ ├── banner.png │ ├── favicon.png │ ├── autocomplete.png │ ├── django_kofta.png │ ├── query-error.png │ ├── variable_type.png │ ├── query-response-1.png │ ├── query-response-2.png │ ├── query-response-3.png │ ├── dependency_injection_error.png │ ├── dependency_injection_error2.png │ ├── dependency_injection_post.png │ ├── dependency_injection_pydantic.png │ └── dependency_injection_pydantic_error.png ├── request.md ├── routers.md ├── dynamic_routing.md ├── first_steps.md ├── index.md ├── features.md ├── response.md ├── middleware.md └── automatic_validation.md ├── .github └── workflows │ ├── docs.yaml │ ├── mypy-typecheck.yml │ └── run-tests.yml ├── LICENSE ├── README.md ├── mkdocs.yml ├── pyproject.toml └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .mypy_cache 3 | .pytest_cache 4 | dist 5 | site -------------------------------------------------------------------------------- /simpleapi/router.py: -------------------------------------------------------------------------------- 1 | from .core import API 2 | 3 | 4 | class Router(API): 5 | pass 6 | -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/banner.png -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/autocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/autocomplete.png -------------------------------------------------------------------------------- /docs/assets/django_kofta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/django_kofta.png -------------------------------------------------------------------------------- /docs/assets/query-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/query-error.png -------------------------------------------------------------------------------- /docs/assets/variable_type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/variable_type.png -------------------------------------------------------------------------------- /docs/assets/query-response-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/query-response-1.png -------------------------------------------------------------------------------- /docs/assets/query-response-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/query-response-2.png -------------------------------------------------------------------------------- /docs/assets/query-response-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/query-response-3.png -------------------------------------------------------------------------------- /docs/assets/dependency_injection_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/dependency_injection_error.png -------------------------------------------------------------------------------- /docs/assets/dependency_injection_error2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/dependency_injection_error2.png -------------------------------------------------------------------------------- /docs/assets/dependency_injection_post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/dependency_injection_post.png -------------------------------------------------------------------------------- /docs/assets/dependency_injection_pydantic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/dependency_injection_pydantic.png -------------------------------------------------------------------------------- /docs/assets/dependency_injection_pydantic_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adhamsalama/simpleapi/HEAD/docs/assets/dependency_injection_pydantic_error.png -------------------------------------------------------------------------------- /tests/middleware.py: -------------------------------------------------------------------------------- 1 | # Examples of fucntions as middleware 2 | from simpleapi import Request 3 | 4 | 5 | def current_user(request: Request): 6 | """Middleware that adds user data to the request""" 7 | request.extra["user"] = { 8 | "username": "adhom", 9 | "email": "adhom@adhom.com", 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v2 12 | with: 13 | python-version: 3.x 14 | - run: pip install mkdocs-material 15 | - run: mkdocs gh-deploy --force 16 | -------------------------------------------------------------------------------- /simpleapi/custom_types.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypedDict 2 | 3 | from simpleapi.request import Request 4 | 5 | from .response import GenericResponse, Response 6 | 7 | ViewFunction = Callable[..., GenericResponse] 8 | Middleware = Callable[[Request], Response | None] 9 | ComponentMiddleware = dict[int, list[Middleware]] 10 | 11 | 12 | class RouteHandler(TypedDict): 13 | path: str 14 | method: str 15 | handler: ViewFunction 16 | middleware: list[Middleware] 17 | router_id: int 18 | 19 | 20 | Query = str | list[str] 21 | -------------------------------------------------------------------------------- /docs/request.md: -------------------------------------------------------------------------------- 1 | # Request 2 | 3 | The Request class contains severl useful properties. 4 | 5 | Properties: 6 | 7 | - method (The request HTTP method) 8 | - path (The request path). 9 | - params (The route's dynamic parameters). 10 | - form (The request body's form data). 11 | - body (The request's JSON body). 12 | - query (The request's query parameters). 13 | - headers (The request's headers). 14 | - cookies (The requests's cookies). 15 | - extra (A dict to which middleware can add fields). 16 | 17 | Source Code: [https://github.com/adhamsalama/simpleapi/blob/main/simpleapi/request.py](https://github.com/adhamsalama/simpleapi/blob/main/simpleapi/request.py) 18 | -------------------------------------------------------------------------------- /simpleapi/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.5" 2 | 3 | from .custom_types import RouteHandler, ViewFunction, Middleware, Query 4 | from .request import Request 5 | from .response import ( 6 | ErrorResponse, 7 | JSONResponse, 8 | NotFoundErrorResponse, 9 | Response, 10 | ValidationErrorResponse, 11 | ) 12 | from .router import Router 13 | from .simpleapi import SimpleAPI 14 | 15 | # ? Resources 16 | 17 | # https://wsgi.readthedocs.io/en/latest/definitions.html 18 | 19 | # https://wsgi.readthedocs.io/en/latest/specifications/handling_post_forms.html 20 | 21 | # https://www.toptal.com/python/pythons-wsgi-server-application-interface 22 | 23 | # https://peps.python.org/pep-3333/ 24 | 25 | # https://docs.gunicorn.org/en/stable/ 26 | -------------------------------------------------------------------------------- /docs/routers.md: -------------------------------------------------------------------------------- 1 | # Routers 2 | 3 | When your app gets bigger, it's a good idea to write different parts of it in separate files. 4 | 5 | You can do this using **Routers**. 6 | 7 | Create another file called `routers.py` and add the following code to it: 8 | 9 | ```python 10 | from simpleapi import Router 11 | 12 | router = Router() 13 | 14 | @router.get("/hello") 15 | def hello(): 16 | return "Hello, router!" 17 | ``` 18 | 19 | And in your `main.py` file: 20 | 21 | ```python 22 | from simpleapi import SimpleAPI 23 | from .routers import router 24 | 25 | app = SimpleAPI() 26 | 27 | app.add_router(prefix="/router", router=router) 28 | ``` 29 | 30 | Open your browser at [http://localhost:8000/router/hello](http://localhost:8000/router/hello). 31 | 32 | You will see a response that looks like this: 33 | 34 | `Hello, router!` 35 | -------------------------------------------------------------------------------- /tests/routers/item.py: -------------------------------------------------------------------------------- 1 | from simpleapi import Router, Request 2 | 3 | 4 | def item_middleware(request: Request): 5 | request.extra["item_middleware"] = True 6 | 7 | 8 | router = Router(middleware=[item_middleware]) 9 | 10 | 11 | @router.get("/test") 12 | def test_router_get(): 13 | """Tests that router get works""" 14 | return "test" 15 | 16 | 17 | @router.post("/test") 18 | def test_router_post(): 19 | """Tests that router post works""" 20 | return "test" 21 | 22 | 23 | @router.get("/{additional}/test") 24 | def dynamic_router_test(request: Request): 25 | """Tests that dynamic routing works for a router""" 26 | return request.params["additional"] 27 | 28 | 29 | @router.get("/router_middleware") 30 | def router_middleware(request: Request): 31 | return { 32 | "global": request.extra["global_middleware"], 33 | "router": request.extra["item_middleware"], 34 | } 35 | -------------------------------------------------------------------------------- /simpleapi/README.md: -------------------------------------------------------------------------------- 1 | # SimpleAPI 2 | 3 | SimpleAPI is a minimalistic, unopinionated web framework for Python, inspired by FastAPI & Flask. 4 | 5 | This is a hobby project made for educational purposes because I want to try learning writing a web server framework. 6 | 7 | So, this is obviously not meant for production environments. 8 | 9 | Development of SimpleAPI is tracked at [this](https://github.com/users/adhamsalama/projects/1) GitHub project. 10 | 11 | How to install: 12 | 13 | `pip install simplestapi` 14 | 15 | An example of using SimpleAPI: 16 | 17 | ```python 18 | from simpleapi import SimpleAPI, Request, JSONResponse 19 | 20 | app = SimpleAPI() 21 | 22 | @app.get("/hello") 23 | def hello(request: Request): 24 | """Returns hello world in JSON format""" 25 | return JSONResponse(message={"hello": "world"}) 26 | 27 | app.run(port=8000) 28 | 29 | ``` 30 | 31 | More examples can be found in /examples 32 | -------------------------------------------------------------------------------- /.github/workflows/mypy-typecheck.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Mypy Type Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install poetry 30 | poetry install 31 | - name: Test types with mypy 32 | run: | 33 | poetry run yasta run typecheck 34 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Web Server 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install poetry poethepoet 30 | poetry install 31 | - name: Test with pytest 32 | run: | 33 | poetry run yasta run test 34 | -------------------------------------------------------------------------------- /docs/dynamic_routing.md: -------------------------------------------------------------------------------- 1 | # Dynamic Routing 2 | 3 | A framework wouldn't be useful if it didn't support dynamic routing. 4 | 5 | SimpleAPI supports dynamic routing using curly brackets. 6 | 7 | An example of dynamic routing: 8 | 9 | ```python 10 | from simpleapi import SimpleAPI, Request 11 | 12 | app = SimpleAPI() 13 | 14 | @app.get("/hello/{name}") 15 | def hello_person(request: Request): 16 | name = request.params["name"] 17 | return f"Hello, {name}!" 18 | ``` 19 | 20 | Run it with `gunicorn main:app` 21 | 22 | Open your browser at [http://localhost:8000/hello/David](http://localhost:8000/David). 23 | 24 | You will see the response: 25 | `Hello, David!` 26 | 27 | If you're wondering who is David, it's [Professor David J. Mallan](https://cs.harvard.edu/malan/) of Harvard University, I owe him making me fall in love with Programming when I took his [CS50](https://www.edx.org/course/introduction-computer-science-harvardx-cs50x) course! :heart: :heart: :heart: 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleAPI 2 | 3 | ![banner](https://i.imgur.com/Q3kFiKf.png) 4 | SimpleAPI is a minimalistic, unopinionated web framework for Python, inspired by FastAPI & Flask. 5 | 6 | SimpleAPI is a WSGI compliant framework. 7 | 8 | This is a hobby project made for educational purposes because I want to try learning writing a web server framework. 9 | 10 | So, this is obviously not meant for production environments. 11 | 12 | Development of SimpleAPI is tracked at [this](https://github.com/users/adhamsalama/projects/1) GitHub project. 13 | 14 | ## Installation 15 | 16 | `pip install simplestapi` 17 | 18 | ## Usage 19 | 20 | An example of using SimpleAPI: 21 | 22 | Copy the following code to a file called `app.py` 23 | 24 | ```python 25 | from simpleapi import SimpleAPI 26 | 27 | app = SimpleAPI() 28 | 29 | @app.get("/hello") 30 | def hello(): 31 | return "Hello, world!" 32 | ``` 33 | 34 | Run it with `gunicorn app:app` 35 | 36 | More examples can be found in [tests](./tests) 37 | 38 | ## Documentation 39 | 40 | [https://adhamsalama.github.io/simpleapi](https://adhamsalama.github.io/simpleapi) 41 | 42 | --- 43 | 44 | ![django_kofta](./docs/assets/django_kofta.png) 45 | -------------------------------------------------------------------------------- /docs/first_steps.md: -------------------------------------------------------------------------------- 1 | # First Steps 2 | 3 | This tutorial shows you how to use SimpleAPI with most of its features, step by step. 4 | 5 | You can copy the code blocks to a file named `main.py` and run it with `gunicorn main:app`. 6 | 7 | The simplest SimpleAPI file looks like this: 8 | 9 | Create a file `main.py` with: 10 | 11 | ```python 12 | 13 | from simpleapi import SimpleAPI 14 | 15 | app = SimpleAPI() 16 | 17 | @app.get("/hello") 18 | def hello(): 19 | return "Hello, world!" 20 | ``` 21 | 22 | Run it with `gunicorn main:app` 23 | 24 | Open your browser at [http://localhost:8000/hello](http://localhost:8000/hello). 25 | 26 | You will see the response: 27 | `Hello, world!` 28 | 29 | You can also specify other HTTP methods, for example: 30 | 31 | ```python 32 | @app.post("/hello") 33 | def hello_post(): 34 | return "Hello, world!" 35 | 36 | @app.put("/hello") 37 | def hello_put(): 38 | return "Hello, world!" 39 | 40 | @app.patch("/hello") 41 | def hello_patch(): 42 | return "Hello, world!" 43 | 44 | @app.delete("/hello") 45 | def hello_delete(): 46 | return "Hello, world!" 47 | ``` 48 | 49 | **Notice** that if your function doesn't need the request object, you can just not specify it as a parameter. 50 | -------------------------------------------------------------------------------- /simpleapi/request.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from . import utils 4 | 5 | 6 | class Request: 7 | """ 8 | HTTP Request 9 | 10 | Properties: 11 | environ (The WSGI environ). 12 | method (The request HTTP method) 13 | path (The request path). 14 | params (The route's dynamic parameters). 15 | form (The request body's form data). 16 | body (The request's JSON body). 17 | query (The request's query parameters). 18 | headers (The request's headers). 19 | cookies (The requests's cookies). 20 | extra (A dict to which middleware can add fields). 21 | """ 22 | 23 | def __init__(self, environ: utils.Environ) -> None: 24 | self.method: str = environ["REQUEST_METHOD"] 25 | self.path: str = environ["PATH_INFO"] 26 | self.extra: dict[str, Any] = {} 27 | self.params: dict[str, str] = {} 28 | self.form: dict[str, bytes] 29 | self.body: dict[str, str | int | float | bool | dict] 30 | self.body, self.form = utils.parse_body(environ) 31 | self.query = utils.parse_query_string(environ) 32 | self.headers: dict[str, str] = utils.parse_headers(environ) 33 | self.cookies: dict[str, str] = utils.parse_cookies(environ) 34 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SimpleAPI 2 | repo_url: https://github.com/adhamsalama/simpleapi 3 | repo_name: adhamsalama/simpleapi 4 | site_description: SimpleAPI is a minimalistic, unopinionated, WSGI-compliant, microframework web framework for Python, inspired by FastAPI & Flask. 5 | use_directory_urls: false 6 | docs_dir: docs 7 | theme: 8 | name: material 9 | favicon: assets/favicon.png 10 | logo: assets/logo.png 11 | palette: 12 | # primary: blue 13 | - scheme: default 14 | toggle: 15 | icon: material/brightness-7 16 | name: Switch to dark mode 17 | 18 | # Palette toggle for dark mode 19 | - scheme: slate 20 | toggle: 21 | icon: material/brightness-4 22 | name: Switch to light mode 23 | markdown_extensions: 24 | - attr_list 25 | - pymdownx.emoji: 26 | emoji_index: !!python/name:materialx.emoji.twemoji 27 | emoji_generator: !!python/name:materialx.emoji.to_svg 28 | - pymdownx.highlight: 29 | use_pygments: true 30 | - pymdownx.superfences 31 | 32 | nav: 33 | - index.md 34 | - features.md 35 | - first_steps.md 36 | - dynamic_routing.md 37 | - automatic_validation.md 38 | - routers.md 39 | - middleware.md 40 | - request.md 41 | - response.md -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # SimpleAPI 2 | 3 | ![banner](assets/banner.png) 4 | SimpleAPI is a minimalistic, unopinionated, WSGI-compliant, microframework for Python, inspired by FastAPI & Flask. 5 | 6 | Source Code: [https://github.com/adhamsalama/simpleapi](https://github.com/adhamsalama/simpleapi) 7 | 8 | --- 9 | 10 | The features: 11 | 12 | 1. Simple and easy to understand. 13 | 1. Fully Typed. 14 | 1. Automatic Validation. 15 | 1. Tested. 16 | 17 | The drawbacks: 18 | 19 | 1. Not battle-tested. 20 | 1. No websockets support (yet!). 21 | 1. Doesn't support async/await like FastAPI. 22 | 23 | --- 24 | 25 | ## Installation 26 | 27 | `pip install simplestapi` 28 | 29 | Note that here it's "simplestapi" instead of "simpleapi". This is because the name "simpleapi" is already taken. 30 | 31 | You will also need to install [gunicorn](https://gunicorn.org) to run the application. 32 | 33 | `pip install gunicorn` 34 | 35 | --- 36 | 37 | ## Note 38 | 39 | SimpleAPI is made only for educational purposes, I have been using Python frameworks like Flask and Django for years, and most recently, FastAPI. I was inspired by FastAPI and decided to create a framework by myself to deepen my knowledge of how web frameworks work. 40 | 41 | That being said... 42 | 43 | ![django_kofta](assets/django_kofta.png) 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "simplestapi" 3 | version = "0.1.5" 4 | description = "SimpleAPI is a minimalistic, unopinionated web framework for Python, inspired by FastAPI & Flask" 5 | authors = ["Adham Salama "] 6 | license = "MIT" 7 | classifiers = [ 8 | "Programming Language :: Python :: 3", 9 | "License :: OSI Approved :: MIT License", 10 | "Operating System :: OS Independent", 11 | ] 12 | packages = [ 13 | {include = "simpleapi"} 14 | ] 15 | homepage = "https://adhamsalama.github.io/simpleapi" 16 | repository = "https://github.com/adhamsalama/simpleapi" 17 | readme = "README.md" 18 | 19 | [tool.poetry.urls] 20 | "Bug Tracker" = "https://github.com/adhamsalama/simpleapi/issues" 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.10" 24 | pydantic = "^1.9.2" 25 | 26 | [tool.poetry.dev-dependencies] 27 | pytest = "^7.1.2" 28 | requests = "^2.28.1" 29 | mypy = "^0.971" 30 | types-requests = "^2.28.8" 31 | gunicorn = "^20.1.0" 32 | mkdocs-material = "^8.4.1" 33 | yasta = "^0.1.3" 34 | 35 | [build-system] 36 | requires = ["poetry-core>=1.0.0"] 37 | build-backend = "poetry.core.masonry.api" 38 | 39 | [yasta-tasks] 40 | typecheck = "mypy ." 41 | test = "gunicorn tests.app:app & pytest tests/test.py && kill -9 $(lsof -t -i:8000)" 42 | fulltest = ["typecheck", "test"] -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | ### Simple and easy. 4 | 5 | SimpleAPI is a small framework with a small codebase, because of its simplicity, it's easy to use, and easy to understand. 6 | 7 | You can take a look at the source code to have a deeper look at what's going on 8 | 9 | ### Fully Typed. 10 | 11 | I was hugely inspired by FastAPI's use of [Python's type annotations](https://docs.python.org/3/library/typing.html), it simply makes the developing experience so much easier because of the help from the IDE/Code Editor. 12 | ![Autocomplete](./assets/autocomplete.png) 13 | ![Variable_type](./assets/variable_type.png) 14 | 15 | ### Automatic Validation 16 | 17 | If you specify the required fields of the incoming request body and their types, SimpleAPI automatically validates the incoming request body and supplies your functions with the required arguments. 18 | 19 | If the request body doesn't contain the arguments you specified, or doesn't match the types you specified, SimpleAPI returns an error response with a message highlighting where the error occured and what is the cause. 20 | 21 | You can have more fine-grained control over your arguments if you use [Pydantic](https://pydantic-docs.helpmanual.io). SimpleAPI will also validate the request body and return an error if it doesn't match your specified Pydantic models. 22 | 23 | ### Tested 24 | 25 | There are over 27 tests for several parts of functionalities that SimpleAPI provides. 26 | -------------------------------------------------------------------------------- /docs/response.md: -------------------------------------------------------------------------------- 1 | # Responses 2 | 3 | You can return strings, dicts, integers, floats, Pydantic models right in your view function and SimpleAPI will automatically handle returning them as HTTP responses. 4 | 5 | But what if you want more control? 6 | 7 | ## Response 8 | 9 | The Base Response class contains severl useful properties and methods. 10 | All response classes inherit this class. 11 | 12 | Properties: 13 | 14 | - code (The response status code, default = 200) 15 | - body (The response body) 16 | - headers (The response headers). 17 | - content_type (The response content-type, default = text/html; charset=UTF-8) 18 | 19 | Methods: 20 | 21 | - set_header (Sets a response header). 22 | - set_cookie (Sets a response cookie). 23 | 24 | ## JSONResponse 25 | 26 | The JSON Response class inherits everything from the Response class, except it sets the response content-type to application/json by default. 27 | 28 | ## Example 29 | 30 | To return a response with a 404 status code and a body of "Can't find this resource", and the content-type as text/html: 31 | 32 | ```python 33 | from simpleapi import SimpleAPI, Response 34 | 35 | app = SimpleAPI() 36 | 37 | @app.get("/not-found") 38 | def not_found(): 39 | response = Response( 40 | code=404, 41 | body="Can't Find this resource", 42 | content_type="text/html; charset=UTF-8" 43 | ) 44 | return response 45 | ``` 46 | 47 | Or you could return the built-in **NotFoundErrorResponse**! 48 | 49 | ```python 50 | from simpleapi import SimpleAPI, NotFoundErrorResponse 51 | 52 | app = SimpleAPI() 53 | 54 | @app.get("/not-found") 55 | def not_found(): 56 | return NotFoundErrorResponse() 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | SimpleAPI offers fine-grained control over middleware. 4 | 5 | There are 3 types of middleware: 6 | 7 | 1. Global middleware. 8 | 1. Router middleware. 9 | 1. Function middleware. 10 | 11 | ### Global Middleware 12 | 13 | Global middleware is applied globally on all route handlers. 14 | 15 | It's defined as a normal function that takes a request as a parameter. 16 | 17 | Example: 18 | 19 | ```python 20 | from simpleapi import SimpleAPI, Request 21 | 22 | def global_middleware(request: Request): 23 | """Logger middleware""" 24 | print(f"Path: {request.path}") 25 | 26 | app = SimpleAPI(middleware=[global_middleware]) 27 | 28 | @app.get("/hello") 29 | def hello(): 30 | return "Hello, world!" 31 | ``` 32 | 33 | If you check the terminal, you will see this: 34 | `Path: /hello` 35 | 36 | You can add as many middleware as you want to the middleware array. They will be executed in order. 37 | 38 | ### Router Middleware 39 | 40 | Router middleware is applied to all of the router's route handlers. 41 | 42 | Example: 43 | 44 | ```python 45 | from simpleapi import Router, Request 46 | 47 | def router_middleware(request: Request): 48 | """Logger middleware""" 49 | print(f"Path: {request.path}" 50 | 51 | router = Router(middleware=[router_middleware]) 52 | 53 | @router.get("/hello") 54 | def hello(): 55 | return "Hello, router!" 56 | ``` 57 | 58 | This will print the path of all router handlers under this specific router, it won't execute for any other route handlers that aren't under this specific router. 59 | 60 | ### Function Middleware 61 | 62 | Function middleware is applied to its specific function only. 63 | 64 | Example: 65 | 66 | ```python 67 | from simpleapi import SimpleAPI, Request 68 | 69 | def function_middleware(request: Request): 70 | """Logger middleware""" 71 | print("This is the /hello route") 72 | 73 | app = SimpleAPI() 74 | 75 | @app.get("/hello", middleware=[function_middleware]) 76 | def hello(): 77 | return "Hello, world!" 78 | ``` 79 | -------------------------------------------------------------------------------- /simpleapi/core.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from .custom_types import Middleware, RouteHandler, ViewFunction 4 | 5 | 6 | class API: 7 | """ 8 | Core API 9 | 10 | Exposes HTTP methods to add routing and a method for running the app. 11 | """ 12 | 13 | def __init__(self, middleware: list[Middleware] | None = None) -> None: 14 | self.handlers: list[RouteHandler] = [] 15 | self.middleware: list[Middleware] = middleware if middleware else [] 16 | 17 | def handle_request_decorator( 18 | self, path: str, method: str, middleware: list[Middleware] | None 19 | ): 20 | def decorator(handler: ViewFunction): 21 | 22 | handler_dict: RouteHandler = { 23 | "path": path, 24 | "method": method, 25 | "handler": handler, 26 | "middleware": middleware if middleware else [], 27 | "router_id": id(self), 28 | } 29 | self.handlers.append(handler_dict) 30 | return handler 31 | 32 | return decorator 33 | 34 | def get(self, path: str, middleware: list[Middleware] | None = None): 35 | return self.handle_request_decorator(path, "GET", middleware) 36 | 37 | def post(self, path: str, middleware: list[Middleware] | None = None): 38 | return self.handle_request_decorator(path, "POST", middleware) 39 | 40 | def put(self, path: str, middleware: list[Middleware] | None = None): 41 | return self.handle_request_decorator(path, "PUT", middleware) 42 | 43 | def patch(self, path: str, middleware: list[Middleware] | None = None): 44 | return self.handle_request_decorator(path, "PATCH", middleware) 45 | 46 | def delete(self, path: str, middleware: list[Middleware] | None = None): 47 | return self.handle_request_decorator(path, "DELETE", middleware) 48 | 49 | def head(self, path: str, middleware: list[Middleware] | None = None): 50 | return self.handle_request_decorator(path, "HEAD", middleware) 51 | 52 | def options(self, path: str, middleware: list[Middleware] | None = None): 53 | return self.handle_request_decorator(path, "OPTIONS", middleware) 54 | -------------------------------------------------------------------------------- /simpleapi/simpleapi.py: -------------------------------------------------------------------------------- 1 | # Author: Adham Salama 2 | 3 | import json 4 | from typing import Callable 5 | 6 | from .core import API 7 | from .custom_types import ComponentMiddleware, Middleware, RouteHandler, ViewFunction 8 | from .handler import handle_request 9 | from .request import Request 10 | from .response import ParsingErrorResponse, WSGIResponse 11 | from .router import Router 12 | from .utils import Environ 13 | 14 | 15 | class SimpleAPI(API): 16 | """ 17 | SimpleAPI Class. 18 | """ 19 | 20 | def __init__(self, middleware: list[Middleware] | None = None) -> None: 21 | self.component_middleware: ComponentMiddleware = { 22 | 1: middleware if middleware else [] 23 | } 24 | 25 | super().__init__(middleware if middleware else []) 26 | 27 | # ? Override handle_request_decorator to make the component_id = 1 for global app middleware 28 | def handle_request_decorator( 29 | self, path: str, method: str, middleware: list[Middleware] | None 30 | ): 31 | def decorator(handler: ViewFunction): 32 | 33 | handler_dict: RouteHandler = { 34 | "path": path, 35 | "method": method, 36 | "handler": handler, 37 | "middleware": middleware if middleware else [], 38 | "router_id": 1, 39 | } 40 | self.handlers.append(handler_dict) 41 | return handler 42 | 43 | return decorator 44 | 45 | def add_router(self, prefix: str, router: Router): 46 | # Add component middleware 47 | self.component_middleware[id(router)] = router.middleware 48 | # Add handlers middleware 49 | for handler in router.handlers: 50 | handler["path"] = prefix + handler["path"] 51 | self.handlers.append(handler) 52 | 53 | def __call__(self, environ: Environ, start_response: Callable): 54 | try: 55 | request = Request(environ=environ) 56 | response = handle_request( 57 | request=request, 58 | handlers=self.handlers, 59 | app_middleware=self.component_middleware, 60 | ) 61 | except json.decoder.JSONDecodeError: 62 | response = ParsingErrorResponse() 63 | wsgi_response = WSGIResponse.simple_response(start_response, response) 64 | return wsgi_response.send() 65 | -------------------------------------------------------------------------------- /simpleapi/utils.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import json 3 | from typing import BinaryIO, TypedDict 4 | 5 | Environ = TypedDict( 6 | "Environ", 7 | { 8 | "REQUEST_METHOD": str, 9 | "SCRIPT_NAME": str, 10 | "PATH_INFO": str, 11 | "QUERY_STRING": str, 12 | "CONTENT_TYPE": str, 13 | "CONTENT_LENGTH": str, 14 | "SERVER_NAME": str, 15 | "SERVER_PORT": int, 16 | "SERVER_PROTOCOL": str, 17 | "HTTP_COOKIE": str, 18 | "wsgi.version": tuple[int, int], 19 | "wsgi.url_scheme": str, 20 | "wsgi.input": BinaryIO, 21 | "wsgi.errors": BinaryIO, 22 | "wsgi.multithread": bool, 23 | "wsgi.multiprocess": bool, 24 | "wsgi.run_once": bool, 25 | }, 26 | ) 27 | 28 | 29 | def is_form_data_request(environ: Environ) -> bool: 30 | """Returns True of the request method is POST, False otherwise""" 31 | if environ["REQUEST_METHOD"].upper() != "POST": 32 | return False 33 | content_type = environ.get("CONTENT_TYPE", "application/x-www-form-urlencoded") 34 | return content_type.startswith( 35 | "application/x-www-form-urlencoded" 36 | ) or content_type.startswith("multipart/form-data") 37 | 38 | 39 | def parse_body( 40 | environ: Environ, 41 | ) -> tuple[dict[str, str | int | float | bool | dict], dict[str, bytes]]: 42 | """Parses request body and form data""" 43 | raw_body = environ["wsgi.input"] 44 | body: dict[str, str | int | float | bool | dict] = {} 45 | form: dict[str, bytes] = {} 46 | if is_form_data_request(environ): 47 | storage = cgi.FieldStorage(fp=raw_body, environ=environ) # type: ignore 48 | body = {} 49 | for k in storage.keys(): 50 | form[k] = storage[k].value 51 | else: 52 | read_body = raw_body.read() 53 | body = json.loads(read_body) if read_body else {} 54 | return body, form 55 | 56 | 57 | def parse_query_string(environ: Environ) -> dict[str, str | list[str]]: 58 | """Parses query parameters from a query string and returns a dict""" 59 | if not environ["QUERY_STRING"]: 60 | return {} 61 | splitted = [query.split("=") for query in environ["QUERY_STRING"].split("&")] 62 | queries: dict[str, str | list[str]] = {} 63 | for [k, v] in splitted: 64 | if k in queries: 65 | existing_query = queries[k] 66 | if isinstance(existing_query, list): 67 | existing_query.append(v) 68 | else: 69 | queries[k] = [existing_query, v] 70 | else: 71 | queries[k] = v 72 | return queries 73 | 74 | 75 | def parse_cookies(environ: Environ) -> dict[str, str]: 76 | """Parses cookies from a cookie string and returns a dict of cookies, with keys as lowercase""" 77 | # God, I love list/dict/set comprehension! 78 | return ( 79 | { 80 | k: v 81 | for [k, v] in [ 82 | [ 83 | cookie[: cookie.index("=")], 84 | cookie[cookie.index("=") + 1 :], 85 | ] # Fix bug where cookie value contains "=" 86 | for cookie in environ["HTTP_COOKIE"].replace(" ", "").split(";") 87 | ] 88 | } 89 | if "HTTP_COOKIE" in environ.keys() 90 | else {} 91 | ) 92 | 93 | 94 | def parse_headers(environ: Environ) -> dict[str, str]: 95 | """Parses headers from a request and returns a dict of headers""" 96 | headers: dict[str, str] = {} 97 | for k, v in environ.items(): 98 | if k.startswith("HTTP_"): 99 | headers[k.strip("HTTP_").lower()] = v # type: ignore 100 | return headers 101 | -------------------------------------------------------------------------------- /docs/automatic_validation.md: -------------------------------------------------------------------------------- 1 | # Automatic Validation 2 | 3 | ### A simple example 4 | 5 | ```python 6 | from simpleapi import SimpleAPI 7 | 8 | app = SimpleAPI() 9 | 10 | @app.post("/create-item") 11 | def create_item(name: str, price: int): 12 | return {"name": name, "price": price} 13 | ``` 14 | 15 | Here we specified that the request body will have a field called "name" and its type will be a string, and a field called "price" and its type will be an integer. 16 | 17 | Open Postman or Insomnia (or any HTTP client you like) and send a post request to `http://localhost:8000/create-item` with the required porperties: 18 | 19 | ![dependency_injection_post](assets/dependency_injection_post.png) 20 | 21 | ### Not Sending Required Properties 22 | 23 | So, that worked, but what happens if the request body doesn't match the arguments you specified? 24 | 25 | Lets remove the price field from the request body and send the request again and see what will happen: 26 | 27 | ![dependency_injection_error](assets/dependency_injection_error.png) 28 | 29 | SimpleAPI automatically validated the request body and returned an error response specifying that the request body didn't match the required arguments. In particular, the "price" field is missing. 30 | 31 | ### Sending Wrong Type of Required Fields 32 | 33 | If you add the "price" field back, and make its value anything but an integer, SimpleAPI will return an error response specifying that the "price" field's value isn't of the required type. 34 | 35 | An example: 36 | 37 | ![dependency_injection_error2](assets/dependency_injection_error2.png) 38 | 39 | Note: this only supports int, float, str, and bool. 40 | For more complex types, check and following section about Pydantic models. 41 | 42 | ### Pydantic Models 43 | 44 | You can have more fine-grained control and specify objects using Pydantic BaseModels. 45 | 46 | An example: 47 | 48 | ```python 49 | from simpleapi import SimpleAPI 50 | from pydantic import BaseModel 51 | 52 | class Person(BaseModel): 53 | name: str 54 | age: int 55 | job: str 56 | is_alive: bool 57 | 58 | app = SimpleAPI() 59 | 60 | @app.post("/add-person") 61 | def add_person(person: Person): 62 | person.age += 1 63 | return person 64 | ``` 65 | 66 | ![dependency_injection_pydantic](assets/dependency_injection_pydantic.png) 67 | 68 | If we for example removed the "is_alive" field, the automatic validation will handle it for us. 69 | 70 | ![dependency_injection_pydantic_error](assets/dependency_injection_pydantic_error.png) 71 | 72 | ### Query validation 73 | 74 | You can also specify query parmeters. 75 | 76 | ```python 77 | from simpleapi import Query 78 | 79 | @app.get("/query-type-hint") 80 | def query( 81 | age: Query, 82 | name: Query = "adhom", 83 | ): 84 | return JSONResponse(body={"name": name, "age": age}) 85 | 86 | ``` 87 | 88 | In this example, we specified that we require a query parameter named age and another query parameter named name, which has a default value and return the queries . 89 | 90 | If name wasn't provided by the client, then the default value will be used. 91 | 92 | But if age wasn't provided, an error would be automatically returned. 93 | 94 | ![query_error](assets/query-error.png) 95 | 96 | If we provide the age query, we will get this response. 97 | 98 | ![query_response](assets/query-response-1.png) 99 | 100 | If we provide name and query, we will get this response. 101 | 102 | ![query_response](assets/query-response-2.png) 103 | 104 | Notice that because we provied a value for name, this value was passed to the function and was used instead of the default value. 105 | 106 | The Query type is a union of str and list[str]. 107 | 108 | If we provide multiple values for the same query parameter, it will be passed to the function as an array. 109 | ![query_response](assets/query-response-3.png) 110 | -------------------------------------------------------------------------------- /simpleapi/response.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Callable, TypedDict 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class Error(TypedDict): 8 | loc: list[str] 9 | msg: str 10 | 11 | 12 | class ErrorMessage(TypedDict): 13 | """ 14 | Error Message Dict 15 | 16 | message: str 17 | field: str | None 18 | """ 19 | 20 | errors: list[Error] 21 | 22 | 23 | class Response: 24 | """ 25 | HTTP Response. 26 | 27 | Properties: 28 | code: Response status code, default = 200 29 | message: Response body 30 | headers: Response headers 31 | content_type: Response content-type, default = text/html; charset=UTF-8" 32 | """ 33 | 34 | def __init__( 35 | self, 36 | body: str | dict[str, Any] | list | bytes | ErrorMessage, 37 | content_type: str = "text/html; charset=UTF-8", 38 | headers: list[tuple[str, str]] | None = None, 39 | code: int = 200, 40 | ) -> None: 41 | # ? If the developer should set the content-type using the content_type argument and not supply it again to the headers argument 42 | self.body: Any = body 43 | self.content_type: str = content_type 44 | self.headers: list[tuple[str, str]] = headers if headers else [] 45 | self.headers.append(("content-type", content_type)) 46 | self.code = code 47 | 48 | def set_header(self, header: str, value: str) -> None: 49 | self.headers.append((header, value)) 50 | 51 | def set_cookie(self, key: str, value: str): 52 | self.set_header("Set-Cookie", f"{key}={value}") 53 | 54 | 55 | class JSONResponse(Response): 56 | # content_type: str = "application/json" 57 | def __init__( 58 | self, 59 | body: Any, 60 | headers: list[tuple[str, str]] | None = None, 61 | code: int = 200, 62 | ) -> None: 63 | super().__init__( 64 | code=code, 65 | body=body, 66 | content_type="application/json", 67 | headers=headers if headers else [], 68 | ) 69 | 70 | 71 | class ErrorResponse(Response): 72 | """ 73 | Base Class for Errors 74 | 75 | # Status Code 76 | code: int 77 | # Error Messages 78 | messages: list[ErrorMessage] 79 | """ 80 | 81 | def __init__(self, messages: ErrorMessage, code: int = 500) -> None: 82 | self.code: int = code 83 | self.messages: ErrorMessage = messages 84 | super().__init__( 85 | code=self.code, 86 | body=self.messages, 87 | content_type="application/json", 88 | headers=[], 89 | ) 90 | 91 | 92 | class ValidationErrorResponse(ErrorResponse): 93 | """Error Response for Validation""" 94 | 95 | def __init__(self, messages: ErrorMessage) -> None: 96 | self.code = 403 97 | self.messages: ErrorMessage = messages 98 | super().__init__(code=self.code, messages=self.messages) 99 | 100 | 101 | class NotFoundErrorResponse(ErrorResponse): 102 | """Error Response for Not Found""" 103 | 104 | def __init__( 105 | self, 106 | messages: ErrorMessage = ErrorMessage( 107 | errors=[{"loc": ["request"], "msg": "404 Not Found"}] 108 | ), 109 | ) -> None: 110 | self.code: int = 404 111 | self.messages: ErrorMessage = messages 112 | super().__init__(messages, code=self.code) 113 | 114 | 115 | class ParsingErrorResponse(ErrorResponse): 116 | """Error Response for errors that happen when parsingt he request body""" 117 | 118 | def __init__( 119 | self, 120 | ) -> None: 121 | super().__init__( 122 | code=403, 123 | messages={ 124 | "errors": [{"loc": ["body"], "msg": "Error while parsing the requst"}] 125 | }, 126 | ) 127 | 128 | 129 | start_response_function = Callable[[str, list[tuple[str, str]]], Any] 130 | 131 | 132 | class WSGIResponse: 133 | """ 134 | WSGI Response Class 135 | 136 | It has the required properties to return a response 137 | """ 138 | 139 | def __init__( 140 | self, 141 | start_response: start_response_function, 142 | status: str, 143 | headers: list[tuple[str, str]], 144 | body: bytes | dict[str, Any] | str, 145 | ) -> None: 146 | self.body = body 147 | start_response(status, headers) 148 | 149 | @classmethod 150 | def simple_response( 151 | cls, start_response: start_response_function, response: Response 152 | ): 153 | return WSGIResponse( 154 | start_response, 155 | status=str(response.code), 156 | headers=response.headers, 157 | body=response.body, 158 | ) 159 | 160 | def send(self): 161 | if isinstance(self.body, bytes): 162 | return [self.body] 163 | elif isinstance(self.body, dict): 164 | return [bytes(json.dumps(self.body).encode("utf-8"))] 165 | 166 | elif isinstance(self.body, list): 167 | return [bytes(json.dumps(self.body).encode("utf-8"))] 168 | 169 | else: 170 | return [self.body.encode("utf-8")] 171 | 172 | 173 | GenericResponse = Response | str | int | float | BaseModel | dict 174 | -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from simpleapi import JSONResponse, Request, Response, SimpleAPI, Query 3 | from simpleapi.response import ErrorResponse 4 | 5 | from .routers import item 6 | 7 | 8 | def current_user(request: Request): 9 | """Dummy Middleware that adds user data to the request""" 10 | request.extra["user"] = request.body 11 | 12 | 13 | def global_middleware(request: Request): 14 | request.extra["global_middleware"] = True 15 | 16 | 17 | def always_reject_middleware(request: Request): 18 | return ErrorResponse( 19 | code=401, messages={"errors": [{"loc": ["body"], "msg": "Not Authorized"}]} 20 | ) 21 | 22 | 23 | app = SimpleAPI(middleware=[global_middleware]) 24 | 25 | 26 | @app.get("/set-cookie") 27 | # Don't remove the return type hint 28 | # this catches the case where handler function 29 | # has return type hint which throw a validation error 30 | def set_cookies(request: Request) -> JSONResponse: 31 | """Sets cookies""" 32 | res = JSONResponse(body=request.cookies, headers=[("header1", "value1")]) 33 | res.set_cookie("key1", "value1") 34 | res.set_cookie("key2", "value2") 35 | res.set_cookie("key3", "value3") 36 | return res 37 | 38 | 39 | @app.get("/set-headers") 40 | def set_headers(request: Request): 41 | """Sets headers""" 42 | return JSONResponse( 43 | body=request.headers, 44 | headers=[("header1", "value1"), ("header2", "value2"), ("header3", "value3")], 45 | ) 46 | 47 | 48 | @app.get("/test1/{test1}") 49 | def aa(request: Request): 50 | return request.params 51 | 52 | 53 | @app.get("/test2/{test2}") 54 | def ba(request: Request): 55 | return request.params 56 | 57 | 58 | @app.get("/test1/{test1}/test1") 59 | def a(request: Request): 60 | return request.params 61 | 62 | 63 | @app.get("/test1/{test2}/test2") 64 | def b(request: Request): 65 | return request.params 66 | 67 | 68 | @app.get("/unauthorized", middleware=[always_reject_middleware]) 69 | def reject(): 70 | return "This shouldn't be returned" 71 | 72 | 73 | app.add_router(prefix="/router", router=item.router) 74 | 75 | 76 | class Item(BaseModel): 77 | name: str 78 | price: float 79 | 80 | 81 | items: list[Item] = [] 82 | 83 | 84 | @app.get("/hello") 85 | def hello(request: Request): 86 | """Test hello world""" 87 | return "Hello, world!" 88 | 89 | 90 | @app.get("/global_middleware") 91 | def global_middleware_router(request: Request): 92 | return request.extra["global_middleware"] 93 | 94 | 95 | @app.get("/greet/{name}") 96 | def greet(request: Request): 97 | """Test dynamic routing""" 98 | return f"Greetings, {request.params['name']}" 99 | 100 | 101 | @app.post("/items") 102 | def post_item(request: Request): 103 | """Test post request returning body as JSON""" 104 | return JSONResponse(body=request.body) 105 | 106 | 107 | @app.get("/400") 108 | def status_400(): 109 | return Response(code=400, body="", content_type="string") 110 | 111 | 112 | @app.put("/put") 113 | def put(request: Request): 114 | return JSONResponse(body=request.body) 115 | 116 | 117 | @app.patch("/patch") 118 | def patch(request: Request): 119 | return JSONResponse(body=request.body) 120 | 121 | 122 | @app.delete("/delete") 123 | def delete(): 124 | return "" 125 | 126 | 127 | @app.head("/head") 128 | def head(): 129 | return "" 130 | 131 | 132 | @app.options("/options") 133 | def options(): 134 | return "" 135 | 136 | 137 | @app.post("/middleware", middleware=[current_user]) 138 | def middleware(request: Request): 139 | """Tests middleware""" 140 | return JSONResponse(body=request.extra["user"]) 141 | 142 | 143 | @app.get("/query") 144 | def query(request: Request): 145 | """Test request parameters""" 146 | return JSONResponse(body=request.query) 147 | 148 | 149 | @app.post("/dependency-injection") 150 | def dependency_injection(name: str, price: float): 151 | """Test dependency injection""" 152 | return {"name": name, "price": price} 153 | 154 | 155 | @app.post("/dependency-injection-error") 156 | def dependency_injection_error(name: str, price: float, active: bool): 157 | """Test dependency injection""" 158 | # ? SimpleAPI should automatically return an error with this ever executing 159 | return {"name": name, "price": price} 160 | 161 | 162 | @app.post("/dependency-injection") 163 | def dep_injection(name: str, price: float): 164 | """View function that uses depedency injection""" 165 | return {"name": name, "price": price} 166 | 167 | 168 | @app.get("/1") 169 | def index(): 170 | """View function that takes no parameter and returns JSONResponse""" 171 | return JSONResponse(body={"hello": "world"}) 172 | 173 | 174 | @app.get("/2") 175 | def index2(): 176 | """View function that takes no parameter and returns a string""" 177 | return "Hello, World!" 178 | 179 | 180 | @app.get("/3") 181 | def index3(): 182 | """View function that takes no parameter and returns an int""" 183 | return 12 184 | 185 | 186 | @app.get("/4") 187 | def index4(): 188 | """View function that takes no parameter and returns a float""" 189 | return 20.56 190 | 191 | 192 | @app.get("/5") 193 | def index5(): 194 | """View function that takes no parameter and returns a dict""" 195 | person = {"name": "adhom", "age": 23} 196 | return person 197 | 198 | 199 | @app.get("/6") 200 | def index6(): 201 | """View function that takes no parameter and returns a Pydantic BaseModel""" 202 | item = Item(name="some item", price=2000) 203 | return item 204 | 205 | 206 | @app.post("/pydantic") 207 | def index7(item: Item): 208 | """View function that takes Pydantic model and returns a Pydantic BaseModel""" 209 | return {"item": item.dict()} 210 | 211 | 212 | @app.get("/greet/{first_name}/{last_name}") 213 | def greet_fullname(request: Request): 214 | """Multiple dynamic route that greets users""" 215 | fullname = request.params["first_name"] + " " + request.params["last_name"] 216 | return JSONResponse(body={"fullname": fullname}) 217 | 218 | 219 | @app.post("/pydantic-item") 220 | def post_pydantic_item(item: Item): 221 | return item 222 | 223 | 224 | @app.get("/items") 225 | def get_item(request: Request): 226 | q = request.query["q"][0] 227 | results: list[dict] = [] 228 | for item in items: 229 | if q in item.name: 230 | results.append(item.dict()) 231 | return JSONResponse(body=results) 232 | 233 | 234 | @app.post("/image") 235 | def save_image(request: Request): 236 | """ 237 | Receives an image and stores it on disk. 238 | Assumes request content-type is multipart. 239 | """ 240 | try: 241 | image: bytes = request.form["image"] 242 | with open("image", "wb") as file: 243 | file.write(image) 244 | except Exception as e: 245 | print("Error while trying to save image") 246 | print(str(e)) 247 | return JSONResponse(code=400, body={"body": "Invalid image"}) 248 | return Response( 249 | body=image, content_type="image/*" 250 | ) # Return the image as a response 251 | 252 | 253 | @app.get("/html") 254 | def html(): 255 | """Returns an HTML string""" 256 | return Response( 257 | code=200, 258 | body="

Hi

", 259 | content_type="text/html; charset=UTF-8", 260 | ) 261 | 262 | 263 | @app.get("/empty") 264 | def empty(): 265 | """Returns an empty response""" 266 | return "" 267 | 268 | 269 | @app.get("/query-type-hint") 270 | def q( 271 | age: Query, 272 | name: Query = "adhom", 273 | ): 274 | return JSONResponse(body={"name": name, "age": age}) 275 | -------------------------------------------------------------------------------- /simpleapi/handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, get_type_hints, cast 3 | import inspect 4 | 5 | from pydantic import BaseModel, ValidationError 6 | 7 | from .custom_types import ComponentMiddleware, Middleware, RouteHandler, Query 8 | from .request import Request 9 | from .response import ( 10 | JSONResponse, 11 | NotFoundErrorResponse, 12 | Response, 13 | ValidationErrorResponse, 14 | ) 15 | 16 | 17 | def handle_request( 18 | request: Request, handlers: list[RouteHandler], app_middleware: ComponentMiddleware 19 | ) -> Response: 20 | """ 21 | A method that tries to find the matching route handler. 22 | """ 23 | 24 | for handler in handlers: 25 | if handler["method"] == request.method and ( 26 | handler["path"] == request.path or match_dynamic_path(request, handler) 27 | ): 28 | # Apply global app middleware 29 | # ? Global app middleware that runs for all route handlers is number 1 30 | middleware_response: Response | None = apply_middleware( 31 | request, app_middleware[1] 32 | ) 33 | if middleware_response: 34 | return middleware_response 35 | 36 | # Add component middleware 37 | # Get component middleware from app_middleware by using the component_id in the handler 38 | # ? Skip it if the component_id = 1 (Global app middleware, was already applied) 39 | component_middleware: list[Middleware] = ( 40 | app_middleware[handler["router_id"]] 41 | if handler["router_id"] != 1 42 | else [] 43 | ) 44 | middleware_response = apply_middleware(request, component_middleware) 45 | if middleware_response: 46 | return middleware_response 47 | 48 | # Apply handlers middleware 49 | middleware_response = apply_middleware(request, handler["middleware"]) 50 | if middleware_response: 51 | return middleware_response 52 | 53 | handler_type_hints = get_type_hints(handler["handler"]) 54 | # Remove return type hint as we don't need it because it had caused 55 | # a validation error for any handler function that had a return type hint 56 | if "return" in handler_type_hints: 57 | del handler_type_hints["return"] 58 | dependency_injection: dict[str, Any] = {} 59 | for k, v in handler_type_hints.items(): 60 | if v == Request: 61 | dependency_injection[k] = request 62 | elif k in request.body.keys(): 63 | if isinstance(request.body[k], v): 64 | dependency_injection[k] = request.body[k] 65 | elif type(v) == type(BaseModel): 66 | try: 67 | dependency_injection[k] = v.parse_obj(request.body[k]) 68 | except ValidationError as e: 69 | return ValidationErrorResponse( 70 | messages={"errors": e.errors()} # type: ignore 71 | ) 72 | else: 73 | return ValidationErrorResponse( 74 | messages={ 75 | "errors": [ 76 | { 77 | "loc": [k], 78 | "msg": f"Property {k} is required to be of type {v.__name__}", 79 | } 80 | ] 81 | } 82 | ) 83 | elif v is Query: 84 | signature = inspect.signature(handler["handler"]) 85 | parameter = signature.parameters[k] 86 | if k in request.query: 87 | dependency_injection[k] = request.query[k] 88 | elif isinstance(parameter.default, (str, list)): 89 | dependency_injection[k] = parameter.default 90 | else: 91 | return ValidationErrorResponse( 92 | messages={ 93 | "errors": [ 94 | { 95 | "loc": [k], 96 | "msg": f"Query paramater {k} is required", 97 | } 98 | ] 99 | } 100 | ) 101 | else: 102 | return ValidationErrorResponse( 103 | messages={ 104 | "errors": [ 105 | { 106 | "loc": [k], 107 | "msg": f"Body field {k} is required to be of type {v.__name__} but it's missing", 108 | } 109 | ] 110 | } 111 | ) 112 | if handler_type_hints and not dependency_injection: 113 | return ValidationErrorResponse( 114 | messages={ 115 | "errors": [ 116 | {"loc": ["body"], "msg": "Required fields are not supplied"} 117 | ] 118 | } 119 | ) 120 | response = handler["handler"](**dependency_injection) 121 | if isinstance(response, str): 122 | constructed_response = Response(code=200, body=response) 123 | elif isinstance(response, (int, float)): 124 | constructed_response = Response(code=200, body=str(response)) 125 | elif isinstance(response, JSONResponse): 126 | constructed_response = response 127 | elif isinstance(response, Response): 128 | constructed_response = response 129 | elif isinstance(response, BaseModel): 130 | # Pydantic BaseModel 131 | constructed_response = Response( 132 | code=200, 133 | body=response.json(), 134 | content_type="application/json", 135 | ) 136 | elif isinstance(response, dict): 137 | constructed_response = Response( 138 | code=200, 139 | body=json.dumps(response), 140 | content_type="application/json", 141 | ) 142 | else: 143 | raise Exception("Unsupported Return Type from View Function") 144 | return constructed_response 145 | return NotFoundErrorResponse() 146 | 147 | 148 | def match_dynamic_path(request: Request, handler: RouteHandler): 149 | """Returns True if it matches a dynamic path and populates the request params""" 150 | # ? Check dynamic routes 151 | matched_dynamic_path = False 152 | if "{" in handler["path"] and "}" in handler["path"]: # ['/greet/{name}'] 153 | # ? Ignore first slash 154 | # ! This wont work for trailing backslash 155 | handler_path = handler["path"].split("/")[1:] # path = ['greet', '{name}'] 156 | request_path = request.path.split("/")[1:] 157 | if len(handler_path) == len(request_path): 158 | for handler_part, request_part in zip(handler_path, request_path): 159 | if ( 160 | handler_part[0] == "{" and handler_part[-1] == "}" 161 | ): # handler_part = '{name}' 162 | request.params[handler_part[1:-1]] = request_part 163 | elif handler_part == request_part: 164 | matched_dynamic_path = True 165 | else: 166 | matched_dynamic_path = False 167 | request.params = {} 168 | break 169 | return matched_dynamic_path 170 | 171 | 172 | def apply_middleware(request: Request, middleware: list[Middleware]) -> Response | None: 173 | for m in middleware: 174 | middlware_response = m(request) 175 | if middlware_response: 176 | return middlware_response 177 | return None 178 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | 5 | 6 | def test_hello_world(): 7 | response = requests.get("http://localhost:8000/hello") 8 | assert response.ok 9 | assert response.content.decode("utf-8") == "Hello, world!" 10 | 11 | 12 | def test_global_middleware(): 13 | response = requests.get("http://localhost:8000/global_middleware") 14 | assert response.ok 15 | assert response.content.decode("utf-8") == "True" 16 | 17 | 18 | def test_global_and_router_middleware(): 19 | response = requests.get("http://localhost:8000/router/router_middleware") 20 | assert response.ok 21 | assert response.json() == {"global": True, "router": True} 22 | 23 | 24 | def test_rejecting_middleware(): 25 | name = "adhom" 26 | response = requests.get(f"http://localhost:8000/unauthorized") 27 | assert not response.ok 28 | assert response.status_code == 401 29 | 30 | 31 | def test_dynamic_routing(): 32 | name = "adhom" 33 | response = requests.get(f"http://localhost:8000/greet/{name}") 34 | assert response.ok 35 | assert response.content.decode("utf-8") == f"Greetings, {name}" 36 | 37 | 38 | def test_dynamic_route_doesnt_save_other_params(): 39 | response = requests.get(f"http://localhost:8000/test1/ayo/test1") 40 | assert response.ok 41 | assert response.json() == {"test1": "ayo"} 42 | response = requests.get(f"http://localhost:8000/test1/aloha/test1") 43 | assert response.ok 44 | assert response.json() == {"test1": "aloha"} 45 | response = requests.get(f"http://localhost:8000/test1/oya/test2") 46 | assert response.ok 47 | assert response.json() == {"test2": "oya"} 48 | response = requests.get(f"http://localhost:8000/test1/ayo") 49 | assert response.ok 50 | assert response.json() == {"test1": "ayo"} 51 | response = requests.get(f"http://localhost:8000/test2/oya") 52 | assert response.ok 53 | assert response.json() == {"test2": "oya"} 54 | 55 | 56 | def test_router(): 57 | response = requests.get("http://localhost:8000/router/test") 58 | assert response.ok 59 | assert response.content.decode("utf-8") == "test" 60 | 61 | 62 | def test_dynamic_routing_for_router(): 63 | response = requests.get("http://localhost:8000/router/dynamic/test") 64 | assert response.ok 65 | assert response.content.decode("utf-8") == "dynamic" 66 | 67 | 68 | def test_post_request_json(): 69 | item = {"name": "RTX 3090", "price": "4000"} 70 | response = requests.post(f"http://localhost:8000/items", json=item) 71 | assert response.ok 72 | assert json.loads(response.content.decode("utf-8")) == item 73 | 74 | 75 | def test_html_response(): 76 | response = requests.get("http://localhost:8000/html") 77 | assert response.ok 78 | assert response.headers["content-type"] == "text/html; charset=UTF-8" 79 | 80 | 81 | def test_status_code_400(): 82 | response = requests.get("http://localhost:8000/400") 83 | assert response.status_code == 400 84 | 85 | 86 | def test_put_request(): 87 | item = {"hello": "world"} 88 | response = requests.put("http://localhost:8000/put", json=item) 89 | assert response.ok 90 | assert json.loads(response.content.decode("utf-8")) == item 91 | 92 | 93 | def test_patch_request(): 94 | item = {"hello": "world"} 95 | response = requests.patch("http://localhost:8000/patch", json=item) 96 | assert response.ok 97 | assert json.loads(response.content.decode("utf-8")) == item 98 | 99 | 100 | def test_delete_request(): 101 | response = requests.delete("http://localhost:8000/delete") 102 | assert response.ok 103 | 104 | 105 | def test_head_request(): 106 | response = requests.head("http://localhost:8000/head") 107 | assert response.ok 108 | 109 | 110 | def test_options_request(): 111 | response = requests.options("http://localhost:8000/options") 112 | assert response.ok 113 | 114 | 115 | def test_middleware(): 116 | user = {"username": "adhom", "email": "adhom@adhom.com"} 117 | response = requests.post("http://localhost:8000/middleware", json=user) 118 | assert response.ok 119 | assert json.loads(response.content.decode("utf-8")) == user 120 | 121 | 122 | def test_get_query_request(): 123 | response = requests.get(f"http://localhost:8000/query?q=cats&p=dogs&tags=a&tags=b") 124 | assert response.ok 125 | assert json.loads(response.content.decode("utf-8")) == { 126 | "q": "cats", 127 | "p": "dogs", 128 | "tags": ["a", "b"], 129 | } 130 | 131 | 132 | def test_dependency_injection(): 133 | item = {"name": "RTX 3090", "price": 3000.50} 134 | response = requests.post("http://localhost:8000/dependency-injection", json=item) 135 | assert response.ok 136 | assert json.loads(response.content.decode("utf-8")) == item 137 | 138 | 139 | def test_dependency_injection_error(): 140 | item = {"name": "RTX 3090", "price": 3000.50} 141 | response = requests.post( 142 | "http://localhost:8000/dependency-injection-error", json=item 143 | ) 144 | assert not response.ok 145 | assert response.status_code == 403 146 | assert response.json() == { 147 | "errors": [ 148 | { 149 | "loc": ["active"], 150 | "msg": "Body field active is required to be of type bool but it's missing", 151 | } 152 | ] 153 | } 154 | 155 | 156 | def test_router_get(): 157 | response = requests.get("http://localhost:8000/router/test") 158 | assert response.ok 159 | assert response.text == "test" 160 | 161 | 162 | def test_router_post(): 163 | response = requests.post("http://localhost:8000/router/test") 164 | assert response.ok 165 | assert response.text == "test" 166 | 167 | 168 | def test_not_found(): 169 | response = requests.get("http://localhost:8000/non-exisiting-route") 170 | assert not response.ok 171 | assert response.status_code == 404 172 | assert response.json() == {"errors": [{"loc": ["request"], "msg": "404 Not Found"}]} 173 | 174 | 175 | def test_set_cookies(): 176 | response = requests.get("http://localhost:8000/set-cookie") 177 | assert response.ok 178 | assert response.cookies.items() == [ 179 | ("key1", "value1"), 180 | ("key2", "value2"), 181 | ("key3", "value3"), 182 | ] 183 | 184 | 185 | def test_reading_cookies(): 186 | cookies = {"name": "adhom", "age": "23"} 187 | response = requests.get("http://localhost:8000/set-cookie", cookies=cookies) 188 | assert response.ok 189 | assert response.json() == cookies 190 | # ! Test cookies whose value contain a "=" 191 | cookies = {"jwt": "sdasdasdasd=="} 192 | response = requests.get("http://localhost:8000/set-cookie", cookies=cookies) 193 | assert response.ok 194 | assert response.json() == cookies 195 | 196 | 197 | def test_set_headers(): 198 | response = requests.get("http://localhost:8000/set-headers") 199 | assert response.ok 200 | headers = [ 201 | ("header1", "value1"), 202 | ("header2", "value2"), 203 | ("header3", "value3"), 204 | ] 205 | for k, v in headers: 206 | assert k in response.headers.keys() 207 | assert v in response.headers.values() 208 | assert response.headers[k] == v 209 | 210 | 211 | def test_reading_headers(): 212 | headers = {"name": "adhom", "age": "23"} 213 | response = requests.get("http://localhost:8000/set-headers", headers=headers) 214 | assert response.ok 215 | json_response = response.json() 216 | for k, v in headers.items(): 217 | assert k in json_response.keys() 218 | assert v in json_response.values() 219 | assert json_response[k] == v 220 | 221 | 222 | def test_query_type_hints(): 223 | response = requests.get("http://localhost:8000/query-type-hint?") 224 | assert not response.ok 225 | assert response.status_code == 403 226 | json_response = response.json() 227 | assert json_response == { 228 | "errors": [ 229 | { 230 | "loc": ["age"], 231 | "msg": f"Query paramater age is required", 232 | } 233 | ] 234 | } 235 | response = requests.get("http://localhost:8000/query-type-hint?age=69") 236 | assert response.ok 237 | json_response = response.json() 238 | assert json_response == {"name": "adhom", "age": "69"} 239 | 240 | response = requests.get( 241 | "http://localhost:8000/query-type-hint?name=sasa&age=69&age=420" 242 | ) 243 | assert response.ok 244 | json_response = response.json() 245 | assert json_response == {"name": "sasa", "age": ["69", "420"]} 246 | 247 | 248 | def test_pydantic_validation(): 249 | # bad body 250 | body = {"item": {"name": "adhom", "price": "not a float"}} 251 | response = requests.post("http://localhost:8000/pydantic", json=body) 252 | print(response.content) 253 | assert not response.ok 254 | json_response = response.json() 255 | assert json_response == { 256 | "errors": [ 257 | { 258 | "loc": ["price"], 259 | "msg": "value is not a valid float", 260 | "type": "type_error.float", 261 | } 262 | ] 263 | } 264 | body["item"]["price"] = 22.5 # type: ignore 265 | response = requests.post("http://localhost:8000/pydantic", json=body) 266 | print(response.content) 267 | assert response.ok 268 | json_response = response.json() 269 | assert json_response == body 270 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "atomicwrites" 5 | version = "1.4.1" 6 | description = "Atomic file writes." 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 10 | files = [ 11 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 12 | ] 13 | 14 | [[package]] 15 | name = "attrs" 16 | version = "22.1.0" 17 | description = "Classes Without Boilerplate" 18 | category = "dev" 19 | optional = false 20 | python-versions = ">=3.5" 21 | files = [ 22 | {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, 23 | {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, 24 | ] 25 | 26 | [package.extras] 27 | dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] 28 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 29 | tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] 30 | tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] 31 | 32 | [[package]] 33 | name = "certifi" 34 | version = "2022.6.15" 35 | description = "Python package for providing Mozilla's CA Bundle." 36 | category = "dev" 37 | optional = false 38 | python-versions = ">=3.6" 39 | files = [ 40 | {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, 41 | {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, 42 | ] 43 | 44 | [[package]] 45 | name = "charset-normalizer" 46 | version = "2.1.0" 47 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 48 | category = "dev" 49 | optional = false 50 | python-versions = ">=3.6.0" 51 | files = [ 52 | {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, 53 | {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, 54 | ] 55 | 56 | [package.extras] 57 | unicode-backport = ["unicodedata2"] 58 | 59 | [[package]] 60 | name = "click" 61 | version = "8.1.3" 62 | description = "Composable command line interface toolkit" 63 | category = "dev" 64 | optional = false 65 | python-versions = ">=3.7" 66 | files = [ 67 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 68 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 69 | ] 70 | 71 | [package.dependencies] 72 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 73 | 74 | [[package]] 75 | name = "colorama" 76 | version = "0.4.5" 77 | description = "Cross-platform colored terminal text." 78 | category = "dev" 79 | optional = false 80 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 81 | files = [ 82 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 83 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 84 | ] 85 | 86 | [[package]] 87 | name = "commonmark" 88 | version = "0.9.1" 89 | description = "Python parser for the CommonMark Markdown spec" 90 | category = "dev" 91 | optional = false 92 | python-versions = "*" 93 | files = [ 94 | {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, 95 | {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, 96 | ] 97 | 98 | [package.extras] 99 | test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] 100 | 101 | [[package]] 102 | name = "ghp-import" 103 | version = "2.1.0" 104 | description = "Copy your docs directly to the gh-pages branch." 105 | category = "dev" 106 | optional = false 107 | python-versions = "*" 108 | files = [ 109 | {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, 110 | {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, 111 | ] 112 | 113 | [package.dependencies] 114 | python-dateutil = ">=2.8.1" 115 | 116 | [package.extras] 117 | dev = ["flake8", "markdown", "twine", "wheel"] 118 | 119 | [[package]] 120 | name = "gunicorn" 121 | version = "20.1.0" 122 | description = "WSGI HTTP Server for UNIX" 123 | category = "dev" 124 | optional = false 125 | python-versions = ">=3.5" 126 | files = [ 127 | {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, 128 | {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, 129 | ] 130 | 131 | [package.dependencies] 132 | setuptools = ">=3.0" 133 | 134 | [package.extras] 135 | eventlet = ["eventlet (>=0.24.1)"] 136 | gevent = ["gevent (>=1.4.0)"] 137 | setproctitle = ["setproctitle"] 138 | tornado = ["tornado (>=0.2)"] 139 | 140 | [[package]] 141 | name = "idna" 142 | version = "3.3" 143 | description = "Internationalized Domain Names in Applications (IDNA)" 144 | category = "dev" 145 | optional = false 146 | python-versions = ">=3.5" 147 | files = [ 148 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 149 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 150 | ] 151 | 152 | [[package]] 153 | name = "importlib-metadata" 154 | version = "4.12.0" 155 | description = "Read metadata from Python packages" 156 | category = "dev" 157 | optional = false 158 | python-versions = ">=3.7" 159 | files = [ 160 | {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"}, 161 | {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"}, 162 | ] 163 | 164 | [package.dependencies] 165 | zipp = ">=0.5" 166 | 167 | [package.extras] 168 | docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"] 169 | perf = ["ipython"] 170 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] 171 | 172 | [[package]] 173 | name = "iniconfig" 174 | version = "1.1.1" 175 | description = "iniconfig: brain-dead simple config-ini parsing" 176 | category = "dev" 177 | optional = false 178 | python-versions = "*" 179 | files = [ 180 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 181 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 182 | ] 183 | 184 | [[package]] 185 | name = "jinja2" 186 | version = "3.1.2" 187 | description = "A very fast and expressive template engine." 188 | category = "dev" 189 | optional = false 190 | python-versions = ">=3.7" 191 | files = [ 192 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 193 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 194 | ] 195 | 196 | [package.dependencies] 197 | MarkupSafe = ">=2.0" 198 | 199 | [package.extras] 200 | i18n = ["Babel (>=2.7)"] 201 | 202 | [[package]] 203 | name = "markdown" 204 | version = "3.3.7" 205 | description = "Python implementation of Markdown." 206 | category = "dev" 207 | optional = false 208 | python-versions = ">=3.6" 209 | files = [ 210 | {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, 211 | {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, 212 | ] 213 | 214 | [package.extras] 215 | testing = ["coverage", "pyyaml"] 216 | 217 | [[package]] 218 | name = "markupsafe" 219 | version = "2.1.1" 220 | description = "Safely add untrusted strings to HTML/XML markup." 221 | category = "dev" 222 | optional = false 223 | python-versions = ">=3.7" 224 | files = [ 225 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, 226 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, 227 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, 228 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, 229 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, 230 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, 231 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, 232 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, 233 | {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, 234 | {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, 235 | {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, 236 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, 237 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, 238 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, 239 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, 240 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, 241 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, 242 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, 243 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, 244 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, 245 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, 246 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, 247 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, 248 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, 249 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, 250 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, 251 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, 252 | {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, 253 | {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, 254 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, 255 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, 256 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, 257 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, 258 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, 259 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, 260 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, 261 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, 262 | {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, 263 | {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, 264 | {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, 265 | ] 266 | 267 | [[package]] 268 | name = "mergedeep" 269 | version = "1.3.4" 270 | description = "A deep merge function for 🐍." 271 | category = "dev" 272 | optional = false 273 | python-versions = ">=3.6" 274 | files = [ 275 | {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, 276 | {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, 277 | ] 278 | 279 | [[package]] 280 | name = "mkdocs" 281 | version = "1.3.1" 282 | description = "Project documentation with Markdown." 283 | category = "dev" 284 | optional = false 285 | python-versions = ">=3.6" 286 | files = [ 287 | {file = "mkdocs-1.3.1-py3-none-any.whl", hash = "sha256:fda92466393127d2da830bc6edc3a625a14b436316d1caf347690648e774c4f0"}, 288 | {file = "mkdocs-1.3.1.tar.gz", hash = "sha256:a41a2ff25ce3bbacc953f9844ba07d106233cd76c88bac1f59cb1564ac0d87ed"}, 289 | ] 290 | 291 | [package.dependencies] 292 | click = ">=3.3" 293 | ghp-import = ">=1.0" 294 | importlib-metadata = ">=4.3" 295 | Jinja2 = ">=2.10.2" 296 | Markdown = ">=3.2.1,<3.4" 297 | mergedeep = ">=1.3.4" 298 | packaging = ">=20.5" 299 | PyYAML = ">=3.10" 300 | pyyaml-env-tag = ">=0.1" 301 | watchdog = ">=2.0" 302 | 303 | [package.extras] 304 | i18n = ["babel (>=2.9.0)"] 305 | 306 | [[package]] 307 | name = "mkdocs-material" 308 | version = "8.4.1" 309 | description = "Documentation that simply works" 310 | category = "dev" 311 | optional = false 312 | python-versions = ">=3.7" 313 | files = [ 314 | {file = "mkdocs-material-8.4.1.tar.gz", hash = "sha256:92c70f94b2e1f8a05d9e05eec1c7af9dffc516802d69222329db89503c97b4f3"}, 315 | {file = "mkdocs_material-8.4.1-py2.py3-none-any.whl", hash = "sha256:319a6254819ce9d864ff79de48c43842fccfdebb43e4e6820eef75216f8cfb0a"}, 316 | ] 317 | 318 | [package.dependencies] 319 | jinja2 = ">=3.0.2" 320 | markdown = ">=3.2" 321 | mkdocs = ">=1.3.0" 322 | mkdocs-material-extensions = ">=1.0.3" 323 | pygments = ">=2.12" 324 | pymdown-extensions = ">=9.4" 325 | 326 | [[package]] 327 | name = "mkdocs-material-extensions" 328 | version = "1.0.3" 329 | description = "Extension pack for Python Markdown." 330 | category = "dev" 331 | optional = false 332 | python-versions = ">=3.6" 333 | files = [ 334 | {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, 335 | {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, 336 | ] 337 | 338 | [[package]] 339 | name = "mypy" 340 | version = "0.971" 341 | description = "Optional static typing for Python" 342 | category = "dev" 343 | optional = false 344 | python-versions = ">=3.6" 345 | files = [ 346 | {file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"}, 347 | {file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"}, 348 | {file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"}, 349 | {file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"}, 350 | {file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"}, 351 | {file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"}, 352 | {file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"}, 353 | {file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"}, 354 | {file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"}, 355 | {file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"}, 356 | {file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"}, 357 | {file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"}, 358 | {file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"}, 359 | {file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"}, 360 | {file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"}, 361 | {file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"}, 362 | {file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"}, 363 | {file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"}, 364 | {file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"}, 365 | {file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"}, 366 | {file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"}, 367 | {file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"}, 368 | {file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"}, 369 | ] 370 | 371 | [package.dependencies] 372 | mypy-extensions = ">=0.4.3" 373 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 374 | typing-extensions = ">=3.10" 375 | 376 | [package.extras] 377 | dmypy = ["psutil (>=4.0)"] 378 | python2 = ["typed-ast (>=1.4.0,<2)"] 379 | reports = ["lxml"] 380 | 381 | [[package]] 382 | name = "mypy-extensions" 383 | version = "0.4.3" 384 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 385 | category = "dev" 386 | optional = false 387 | python-versions = "*" 388 | files = [ 389 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 390 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 391 | ] 392 | 393 | [[package]] 394 | name = "packaging" 395 | version = "21.3" 396 | description = "Core utilities for Python packages" 397 | category = "dev" 398 | optional = false 399 | python-versions = ">=3.6" 400 | files = [ 401 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 402 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 403 | ] 404 | 405 | [package.dependencies] 406 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 407 | 408 | [[package]] 409 | name = "pluggy" 410 | version = "1.0.0" 411 | description = "plugin and hook calling mechanisms for python" 412 | category = "dev" 413 | optional = false 414 | python-versions = ">=3.6" 415 | files = [ 416 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 417 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 418 | ] 419 | 420 | [package.extras] 421 | dev = ["pre-commit", "tox"] 422 | testing = ["pytest", "pytest-benchmark"] 423 | 424 | [[package]] 425 | name = "py" 426 | version = "1.11.0" 427 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 428 | category = "dev" 429 | optional = false 430 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 431 | files = [ 432 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 433 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 434 | ] 435 | 436 | [[package]] 437 | name = "pydantic" 438 | version = "1.9.2" 439 | description = "Data validation and settings management using python type hints" 440 | category = "main" 441 | optional = false 442 | python-versions = ">=3.6.1" 443 | files = [ 444 | {file = "pydantic-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c9e04a6cdb7a363d7cb3ccf0efea51e0abb48e180c0d31dca8d247967d85c6e"}, 445 | {file = "pydantic-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafe841be1103f340a24977f61dee76172e4ae5f647ab9e7fd1e1fca51524f08"}, 446 | {file = "pydantic-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afacf6d2a41ed91fc631bade88b1d319c51ab5418870802cedb590b709c5ae3c"}, 447 | {file = "pydantic-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ee0d69b2a5b341fc7927e92cae7ddcfd95e624dfc4870b32a85568bd65e6131"}, 448 | {file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ff68fc85355532ea77559ede81f35fff79a6a5543477e168ab3a381887caea76"}, 449 | {file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c0f5e142ef8217019e3eef6ae1b6b55f09a7a15972958d44fbd228214cede567"}, 450 | {file = "pydantic-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:615661bfc37e82ac677543704437ff737418e4ea04bef9cf11c6d27346606044"}, 451 | {file = "pydantic-1.9.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:328558c9f2eed77bd8fffad3cef39dbbe3edc7044517f4625a769d45d4cf7555"}, 452 | {file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd446bdb7755c3a94e56d7bdfd3ee92396070efa8ef3a34fab9579fe6aa1d84"}, 453 | {file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0b214e57623a535936005797567231a12d0da0c29711eb3514bc2b3cd008d0f"}, 454 | {file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d8ce3fb0841763a89322ea0432f1f59a2d3feae07a63ea2c958b2315e1ae8adb"}, 455 | {file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b34ba24f3e2d0b39b43f0ca62008f7ba962cff51efa56e64ee25c4af6eed987b"}, 456 | {file = "pydantic-1.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:84d76ecc908d917f4684b354a39fd885d69dd0491be175f3465fe4b59811c001"}, 457 | {file = "pydantic-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4de71c718c9756d679420c69f216776c2e977459f77e8f679a4a961dc7304a56"}, 458 | {file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5803ad846cdd1ed0d97eb00292b870c29c1f03732a010e66908ff48a762f20e4"}, 459 | {file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8c5360a0297a713b4123608a7909e6869e1b56d0e96eb0d792c27585d40757f"}, 460 | {file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:cdb4272678db803ddf94caa4f94f8672e9a46bae4a44f167095e4d06fec12979"}, 461 | {file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19b5686387ea0d1ea52ecc4cffb71abb21702c5e5b2ac626fd4dbaa0834aa49d"}, 462 | {file = "pydantic-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e0b4fb13ad4db4058a7c3c80e2569adbd810c25e6ca3bbd8b2a9cc2cc871d7"}, 463 | {file = "pydantic-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91089b2e281713f3893cd01d8e576771cd5bfdfbff5d0ed95969f47ef6d676c3"}, 464 | {file = "pydantic-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e631c70c9280e3129f071635b81207cad85e6c08e253539467e4ead0e5b219aa"}, 465 | {file = "pydantic-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b3946f87e5cef3ba2e7bd3a4eb5a20385fe36521d6cc1ebf3c08a6697c6cfb3"}, 466 | {file = "pydantic-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5565a49effe38d51882cb7bac18bda013cdb34d80ac336428e8908f0b72499b0"}, 467 | {file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bd67cb2c2d9602ad159389c29e4ca964b86fa2f35c2faef54c3eb28b4efd36c8"}, 468 | {file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4aafd4e55e8ad5bd1b19572ea2df546ccace7945853832bb99422a79c70ce9b8"}, 469 | {file = "pydantic-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:d70916235d478404a3fa8c997b003b5f33aeac4686ac1baa767234a0f8ac2326"}, 470 | {file = "pydantic-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ca86b525264daa5f6b192f216a0d1e860b7383e3da1c65a1908f9c02f42801"}, 471 | {file = "pydantic-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1061c6ee6204f4f5a27133126854948e3b3d51fcc16ead2e5d04378c199b2f44"}, 472 | {file = "pydantic-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78578f0c7481c850d1c969aca9a65405887003484d24f6110458fb02cca7747"}, 473 | {file = "pydantic-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da164119602212a3fe7e3bc08911a89db4710ae51444b4224c2382fd09ad453"}, 474 | {file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ead3cd020d526f75b4188e0a8d71c0dbbe1b4b6b5dc0ea775a93aca16256aeb"}, 475 | {file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7d0f183b305629765910eaad707800d2f47c6ac5bcfb8c6397abdc30b69eeb15"}, 476 | {file = "pydantic-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1a68f4f65a9ee64b6ccccb5bf7e17db07caebd2730109cb8a95863cfa9c4e55"}, 477 | {file = "pydantic-1.9.2-py3-none-any.whl", hash = "sha256:78a4d6bdfd116a559aeec9a4cfe77dda62acc6233f8b56a716edad2651023e5e"}, 478 | {file = "pydantic-1.9.2.tar.gz", hash = "sha256:8cb0bc509bfb71305d7a59d00163d5f9fc4530f0881ea32c74ff4f74c85f3d3d"}, 479 | ] 480 | 481 | [package.dependencies] 482 | typing-extensions = ">=3.7.4.3" 483 | 484 | [package.extras] 485 | dotenv = ["python-dotenv (>=0.10.4)"] 486 | email = ["email-validator (>=1.0.3)"] 487 | 488 | [[package]] 489 | name = "pygments" 490 | version = "2.13.0" 491 | description = "Pygments is a syntax highlighting package written in Python." 492 | category = "dev" 493 | optional = false 494 | python-versions = ">=3.6" 495 | files = [ 496 | {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, 497 | {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, 498 | ] 499 | 500 | [package.extras] 501 | plugins = ["importlib-metadata"] 502 | 503 | [[package]] 504 | name = "pymdown-extensions" 505 | version = "9.5" 506 | description = "Extension pack for Python Markdown." 507 | category = "dev" 508 | optional = false 509 | python-versions = ">=3.7" 510 | files = [ 511 | {file = "pymdown_extensions-9.5-py3-none-any.whl", hash = "sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4"}, 512 | {file = "pymdown_extensions-9.5.tar.gz", hash = "sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0"}, 513 | ] 514 | 515 | [package.dependencies] 516 | markdown = ">=3.2" 517 | 518 | [[package]] 519 | name = "pyparsing" 520 | version = "3.0.9" 521 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 522 | category = "dev" 523 | optional = false 524 | python-versions = ">=3.6.8" 525 | files = [ 526 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 527 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 528 | ] 529 | 530 | [package.extras] 531 | diagrams = ["jinja2", "railroad-diagrams"] 532 | 533 | [[package]] 534 | name = "pytest" 535 | version = "7.1.2" 536 | description = "pytest: simple powerful testing with Python" 537 | category = "dev" 538 | optional = false 539 | python-versions = ">=3.7" 540 | files = [ 541 | {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, 542 | {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, 543 | ] 544 | 545 | [package.dependencies] 546 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 547 | attrs = ">=19.2.0" 548 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 549 | iniconfig = "*" 550 | packaging = "*" 551 | pluggy = ">=0.12,<2.0" 552 | py = ">=1.8.2" 553 | tomli = ">=1.0.0" 554 | 555 | [package.extras] 556 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 557 | 558 | [[package]] 559 | name = "python-dateutil" 560 | version = "2.8.2" 561 | description = "Extensions to the standard Python datetime module" 562 | category = "dev" 563 | optional = false 564 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 565 | files = [ 566 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 567 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 568 | ] 569 | 570 | [package.dependencies] 571 | six = ">=1.5" 572 | 573 | [[package]] 574 | name = "pyyaml" 575 | version = "6.0" 576 | description = "YAML parser and emitter for Python" 577 | category = "dev" 578 | optional = false 579 | python-versions = ">=3.6" 580 | files = [ 581 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 582 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 583 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 584 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 585 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 586 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 587 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 588 | {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, 589 | {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, 590 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, 591 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, 592 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, 593 | {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, 594 | {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, 595 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 596 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 597 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 598 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 599 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 600 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 601 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 602 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 603 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 604 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 605 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 606 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 607 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 608 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 609 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 610 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 611 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 612 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 613 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 614 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 615 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 616 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 617 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 618 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 619 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 620 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 621 | ] 622 | 623 | [[package]] 624 | name = "pyyaml-env-tag" 625 | version = "0.1" 626 | description = "A custom YAML tag for referencing environment variables in YAML files. " 627 | category = "dev" 628 | optional = false 629 | python-versions = ">=3.6" 630 | files = [ 631 | {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, 632 | {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, 633 | ] 634 | 635 | [package.dependencies] 636 | pyyaml = "*" 637 | 638 | [[package]] 639 | name = "requests" 640 | version = "2.28.1" 641 | description = "Python HTTP for Humans." 642 | category = "dev" 643 | optional = false 644 | python-versions = ">=3.7, <4" 645 | files = [ 646 | {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, 647 | {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, 648 | ] 649 | 650 | [package.dependencies] 651 | certifi = ">=2017.4.17" 652 | charset-normalizer = ">=2,<3" 653 | idna = ">=2.5,<4" 654 | urllib3 = ">=1.21.1,<1.27" 655 | 656 | [package.extras] 657 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 658 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 659 | 660 | [[package]] 661 | name = "rich" 662 | version = "12.6.0" 663 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 664 | category = "dev" 665 | optional = false 666 | python-versions = ">=3.6.3,<4.0.0" 667 | files = [ 668 | {file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"}, 669 | {file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"}, 670 | ] 671 | 672 | [package.dependencies] 673 | commonmark = ">=0.9.0,<0.10.0" 674 | pygments = ">=2.6.0,<3.0.0" 675 | 676 | [package.extras] 677 | jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] 678 | 679 | [[package]] 680 | name = "setuptools" 681 | version = "67.6.1" 682 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 683 | category = "dev" 684 | optional = false 685 | python-versions = ">=3.7" 686 | files = [ 687 | {file = "setuptools-67.6.1-py3-none-any.whl", hash = "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078"}, 688 | {file = "setuptools-67.6.1.tar.gz", hash = "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a"}, 689 | ] 690 | 691 | [package.extras] 692 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 693 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 694 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 695 | 696 | [[package]] 697 | name = "shellingham" 698 | version = "1.5.0.post1" 699 | description = "Tool to Detect Surrounding Shell" 700 | category = "dev" 701 | optional = false 702 | python-versions = ">=3.7" 703 | files = [ 704 | {file = "shellingham-1.5.0.post1-py2.py3-none-any.whl", hash = "sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744"}, 705 | {file = "shellingham-1.5.0.post1.tar.gz", hash = "sha256:823bc5fb5c34d60f285b624e7264f4dda254bc803a3774a147bf99c0e3004a28"}, 706 | ] 707 | 708 | [[package]] 709 | name = "six" 710 | version = "1.16.0" 711 | description = "Python 2 and 3 compatibility utilities" 712 | category = "dev" 713 | optional = false 714 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 715 | files = [ 716 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 717 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 718 | ] 719 | 720 | [[package]] 721 | name = "toml" 722 | version = "0.10.2" 723 | description = "Python Library for Tom's Obvious, Minimal Language" 724 | category = "dev" 725 | optional = false 726 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 727 | files = [ 728 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 729 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 730 | ] 731 | 732 | [[package]] 733 | name = "tomli" 734 | version = "2.0.1" 735 | description = "A lil' TOML parser" 736 | category = "dev" 737 | optional = false 738 | python-versions = ">=3.7" 739 | files = [ 740 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 741 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 742 | ] 743 | 744 | [[package]] 745 | name = "typer" 746 | version = "0.6.1" 747 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 748 | category = "dev" 749 | optional = false 750 | python-versions = ">=3.6" 751 | files = [ 752 | {file = "typer-0.6.1-py3-none-any.whl", hash = "sha256:54b19e5df18654070a82f8c2aa1da456a4ac16a2a83e6dcd9f170e291c56338e"}, 753 | {file = "typer-0.6.1.tar.gz", hash = "sha256:2d5720a5e63f73eaf31edaa15f6ab87f35f0690f8ca233017d7d23d743a91d73"}, 754 | ] 755 | 756 | [package.dependencies] 757 | click = ">=7.1.1,<9.0.0" 758 | colorama = {version = ">=0.4.3,<0.5.0", optional = true, markers = "extra == \"all\""} 759 | rich = {version = ">=10.11.0,<13.0.0", optional = true, markers = "extra == \"all\""} 760 | shellingham = {version = ">=1.3.0,<2.0.0", optional = true, markers = "extra == \"all\""} 761 | 762 | [package.extras] 763 | all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] 764 | dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] 765 | doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)"] 766 | test = ["black (>=22.3.0,<23.0.0)", "coverage (>=5.2,<6.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] 767 | 768 | [[package]] 769 | name = "types-requests" 770 | version = "2.28.8" 771 | description = "Typing stubs for requests" 772 | category = "dev" 773 | optional = false 774 | python-versions = "*" 775 | files = [ 776 | {file = "types-requests-2.28.8.tar.gz", hash = "sha256:7a9f7b152d594a1c18dd4932cdd2596b8efbeedfd73caa4e4abb3755805b4685"}, 777 | {file = "types_requests-2.28.8-py3-none-any.whl", hash = "sha256:b0421f9f2d0dd0f8df2c75f974686517ca67473f05b466232d4c6384d765ad7a"}, 778 | ] 779 | 780 | [package.dependencies] 781 | types-urllib3 = "<1.27" 782 | 783 | [[package]] 784 | name = "types-urllib3" 785 | version = "1.26.22" 786 | description = "Typing stubs for urllib3" 787 | category = "dev" 788 | optional = false 789 | python-versions = "*" 790 | files = [ 791 | {file = "types-urllib3-1.26.22.tar.gz", hash = "sha256:b05af90e73889e688094008a97ca95788db8bf3736e2776fd43fb6b171485d94"}, 792 | {file = "types_urllib3-1.26.22-py3-none-any.whl", hash = "sha256:09a8783e1002472e8d1e1f3792d4c5cca1fffebb9b48ee1512aae6d16fe186bc"}, 793 | ] 794 | 795 | [[package]] 796 | name = "typing-extensions" 797 | version = "4.3.0" 798 | description = "Backported and Experimental Type Hints for Python 3.7+" 799 | category = "main" 800 | optional = false 801 | python-versions = ">=3.7" 802 | files = [ 803 | {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, 804 | {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, 805 | ] 806 | 807 | [[package]] 808 | name = "urllib3" 809 | version = "1.26.11" 810 | description = "HTTP library with thread-safe connection pooling, file post, and more." 811 | category = "dev" 812 | optional = false 813 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 814 | files = [ 815 | {file = "urllib3-1.26.11-py2.py3-none-any.whl", hash = "sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc"}, 816 | {file = "urllib3-1.26.11.tar.gz", hash = "sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"}, 817 | ] 818 | 819 | [package.extras] 820 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 821 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] 822 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 823 | 824 | [[package]] 825 | name = "watchdog" 826 | version = "2.1.9" 827 | description = "Filesystem events monitoring" 828 | category = "dev" 829 | optional = false 830 | python-versions = ">=3.6" 831 | files = [ 832 | {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, 833 | {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"}, 834 | {file = "watchdog-2.1.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658"}, 835 | {file = "watchdog-2.1.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591"}, 836 | {file = "watchdog-2.1.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33"}, 837 | {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846"}, 838 | {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3"}, 839 | {file = "watchdog-2.1.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654"}, 840 | {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39"}, 841 | {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7"}, 842 | {file = "watchdog-2.1.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd"}, 843 | {file = "watchdog-2.1.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3"}, 844 | {file = "watchdog-2.1.9-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d"}, 845 | {file = "watchdog-2.1.9-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9"}, 846 | {file = "watchdog-2.1.9-py3-none-manylinux2014_aarch64.whl", hash = "sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213"}, 847 | {file = "watchdog-2.1.9-py3-none-manylinux2014_armv7l.whl", hash = "sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892"}, 848 | {file = "watchdog-2.1.9-py3-none-manylinux2014_i686.whl", hash = "sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153"}, 849 | {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64.whl", hash = "sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306"}, 850 | {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412"}, 851 | {file = "watchdog-2.1.9-py3-none-manylinux2014_s390x.whl", hash = "sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1"}, 852 | {file = "watchdog-2.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6"}, 853 | {file = "watchdog-2.1.9-py3-none-win32.whl", hash = "sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1"}, 854 | {file = "watchdog-2.1.9-py3-none-win_amd64.whl", hash = "sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c"}, 855 | {file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"}, 856 | {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, 857 | ] 858 | 859 | [package.extras] 860 | watchmedo = ["PyYAML (>=3.10)"] 861 | 862 | [[package]] 863 | name = "yasta" 864 | version = "0.1.3" 865 | description = "A modern task runner." 866 | category = "dev" 867 | optional = false 868 | python-versions = ">=3.10,<4.0" 869 | files = [ 870 | {file = "yasta-0.1.3-py3-none-any.whl", hash = "sha256:ca32f368196d90c068bc784dba61dd360adb144f7b76e8a767276daa6faaffe9"}, 871 | {file = "yasta-0.1.3.tar.gz", hash = "sha256:a8ed16c421e20d03b1759fb95a1077f86b229a331542d19eb1de277062eb0bef"}, 872 | ] 873 | 874 | [package.dependencies] 875 | toml = ">=0.10.2,<0.11.0" 876 | typer = {version = ">=0.6.1,<0.7.0", extras = ["all"]} 877 | 878 | [[package]] 879 | name = "zipp" 880 | version = "3.8.1" 881 | description = "Backport of pathlib-compatible object wrapper for zip files" 882 | category = "dev" 883 | optional = false 884 | python-versions = ">=3.7" 885 | files = [ 886 | {file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"}, 887 | {file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"}, 888 | ] 889 | 890 | [package.extras] 891 | docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] 892 | testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 893 | 894 | [metadata] 895 | lock-version = "2.0" 896 | python-versions = "^3.10" 897 | content-hash = "09cac44568fca060eaf11683f00c0e6777c9f3778e6126159757394ef30c8a97" 898 | --------------------------------------------------------------------------------