diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..435632c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish to PyPI + +on: + release: + types: [ published ] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..817762a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,55 @@ +name: tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + strategy: + matrix: + include: + - python: "3.6" + env: py36-fastapi68 + os: ubuntu-20.04 # 3.6 is not available on ubuntu-20.04 + - python: "3.8" + env: py38-fastapi68 + - python: "3.10" + env: py310-fastapi68 + - python: "3.11" + env: py311-fastapi68 + + - python: "3.7" + env: py37-fastapi84 + - python: "3.9" + env: py39-fastapi84 + - python: "3.10" + env: py310-fastapi84 + - python: "3.11" + env: py311-fastapi84 + + - python: "3.7" + env: py37-fastapi99 + - python: "3.9" + env: py39-fastapi99 + - python: "3.10" + env: py310-fastapi99 + - python: "3.11" + env: py311-fastapi99 + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + pip install --upgrade pip + sh build.sh + pip install tox tox-gh-actions + - name: Run tests using tox + run: tox -e ${{ matrix.env }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c008936 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# caches +.idea +.tox +.pytest_cache +*.egg-info +__pycache__ + +# docs +docs/node_modules +docs/package-lock.json +docs/.vitepress/cache +docs/.vitepress/dist + +# build +build +dist \ No newline at end of file diff --git a/README.md b/README.md index 2d06d4e..f2a7e53 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,107 @@ -# fastapi-oauth2 +# fastapi-oauth2 -Easy to setup OAuth2 social authentication mechanism with support for several auth providers. +[![PyPI](https://img.shields.io/pypi/v/fastapi-oauth2.svg)](https://pypi.org/project/fastapi-oauth2/) +[![Python](https://img.shields.io/pypi/pyversions/fastapi-oauth2.svg?logoColor=white)](https://pypi.org/project/fastapi-oauth2/) +[![FastAPI](https://img.shields.io/badge/fastapi-%E2%89%A50.68.1-009486)](https://pypi.org/project/fastapi-oauth2/) +[![Tests](https://github.com/pysnippet/fastapi-oauth2/actions/workflows/tests.yml/badge.svg)](https://github.com/pysnippet/fastapi-oauth2/actions/workflows/tests.yml) +[![License](https://img.shields.io/pypi/l/fastapi-oauth2.svg)](https://github.com/pysnippet/fastapi-oauth2/blob/master/LICENSE) -## Demo +FastAPI OAuth2 is a middleware-based social authentication mechanism supporting several auth providers. It depends on +the [social-core](https://github.com/python-social-auth/social-core) authentication backends. -This sample application is made to demonstrate the use of the [**fastapi-oauth2**](./fastapi_oauth2) package. +## Features to be implemented -## Running the application +- Use multiple OAuth2 providers at the same time + * There need to be provided a way to configure the OAuth2 for multiple providers +- Provide `fastapi.security.*` implementations that use cookies +- Token -> user data, user data -> token easy conversion +- Customizable OAuth2 routes +- Registration support + +## Installation -```bash -uvicorn main:app --reload +```shell +python -m pip install fastapi-oauth2 ``` -## TODO +## Configuration -- Make the [**fastapi-oauth2**](./fastapi_oauth2) depend - on (overuse) the [**social-core**](https://github.com/python-social-auth/social-core) +Configuration requires you to provide the JWT requisites and define the clients of the particular providers. The +middleware configuration is declared with the `OAuth2Config` and `OAuth2Client` classes. -## Features +### OAuth2Config -- Integrate with any existing FastAPI project (no dependencies of the project should stop the work of - the `fastapi-oauth2`) - * Implementation must allow to provide a context for configurations (also, see how it is done in another projects) -- Use multiple OAuth2 providers at the same time - * There need to be provided a way to configure the OAuth2 for multiple providers -- Token -> user data, user data -> token easy conversion -- Customize OAuth2 routes +- `allow_http` - Allow insecure HTTP requests. Defaults to `False`. +- `jwt_secret` - The secret key used to sign the JWT. Defaults to `None`. +- `jwt_expires` - The expiration time of the JWT in seconds. Defaults to `900`. +- `jwt_algorithm` - The algorithm used to sign the JWT. Defaults to `HS256`. +- `clients` - The list of the OAuth2 clients. Defaults to `[]`. + +### OAuth2Client + +- `backend` - The [social-core](https://github.com/python-social-auth/social-core) authentication backend classname. +- `client_id` - The OAuth2 client ID for the particular provider. +- `client_secret` - The OAuth2 client secret for the particular provider. +- `redirect_uri` - The OAuth2 redirect URI to redirect to after success. Defaults to the base URL. +- `scope` - The OAuth2 scope for the particular provider. Defaults to `[]`. + +It is also important to mention that for the configured clients of the auth providers, the authorization URLs are +accessible by the `/oauth2/{provider}/auth` path where the `provider` variable represents the exact value of the auth +provider backend `name` attribute. + +```python +from fastapi_oauth2.client import OAuth2Client +from fastapi_oauth2.config import OAuth2Config +from social_core.backends.github import GithubOAuth2 + +oauth2_config = OAuth2Config( + allow_http=False, + jwt_secret=os.getenv("JWT_SECRET"), + jwt_expires=os.getenv("JWT_EXPIRES"), + jwt_algorithm=os.getenv("JWT_ALGORITHM"), + clients=[ + OAuth2Client( + backend=GithubOAuth2, + client_id=os.getenv("OAUTH2_CLIENT_ID"), + client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), + redirect_uri="https://pysnippet.org/", + scope=["user:email"], + ), + ] +) +``` + +## Integration + +To integrate the package into your FastAPI application, you need to add the `OAuth2Middleware` with particular configs +in the above-represented format and include the router to the main router of the application. + +```python +from fastapi import FastAPI +from fastapi_oauth2.middleware import OAuth2Middleware +from fastapi_oauth2.router import router as oauth2_router + +app = FastAPI() +app.include_router(oauth2_router) +app.add_middleware(OAuth2Middleware, config=oauth2_config) +``` + +After adding the middleware, the `user` attribute will be available in the request context. It will contain the user +data provided by the OAuth2 provider. + +```jinja2 +{% if request.user.is_authenticated %} + Sign out +{% else %} + Sign in +{% endif %} +``` + +## Contribute + +Any contribution is welcome. If you have any ideas or suggestions, feel free to open an issue or a pull request. And +don't forget to add tests for your changes. + +## License + +Copyright (C) 2023 Artyom Vancyan. [MIT](https://github.com/pysnippet/fastapi-oauth2/blob/master/LICENSE) diff --git a/demo/dependencies.py b/demo/dependencies.py deleted file mode 100644 index 52594b6..0000000 --- a/demo/dependencies.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Optional - -from fastapi import HTTPException -from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel -from fastapi.security import OAuth2 -from fastapi.security.utils import get_authorization_scheme_param -from starlette.requests import Request -from starlette.status import HTTP_403_FORBIDDEN - - -class OAuth2PasswordBearerCookie(OAuth2): - def __init__( - self, - tokenUrl: str, - scheme_name: str = None, - scopes: dict = None, - auto_error: bool = True, - ): - flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes or {}}) - super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) - - async def __call__(self, request: Request) -> Optional[str]: - scheme, param = get_authorization_scheme_param(request.headers.get("Authorization")) - authorization = scheme.lower() == "bearer" - if not authorization: - scheme, param = get_authorization_scheme_param(request.cookies.get("Authorization")) - authorization = scheme.lower() == "bearer" - - if not authorization: - if self.auto_error: - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Not authenticated" - ) - else: - return None - return param - - -oauth2_scheme = OAuth2PasswordBearerCookie(tokenUrl="/token") diff --git a/demo/router.py b/demo/router.py deleted file mode 100644 index 822dab1..0000000 --- a/demo/router.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from starlette.requests import Request - -from .dependencies import oauth2_scheme - -router = APIRouter() - - -@router.get("/user") -def user(request: Request, _: str = Depends(oauth2_scheme)): - return request.user - - -@router.post("/token") -def token(request: Request): - return request.cookies.get("Authorization") diff --git a/.env b/examples/demonstration/.env similarity index 73% rename from .env rename to examples/demonstration/.env index a1f6457..a1c0106 100644 --- a/.env +++ b/examples/demonstration/.env @@ -1,6 +1,5 @@ OAUTH2_CLIENT_ID=eccd08d6736b7999a32a OAUTH2_CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31 -OAUTH2_CALLBACK_URL=http://127.0.0.1:8000/oauth2/token JWT_SECRET=secret JWT_ALGORITHM=HS256 diff --git a/examples/demonstration/README.md b/examples/demonstration/README.md new file mode 100644 index 0000000..6c75893 --- /dev/null +++ b/examples/demonstration/README.md @@ -0,0 +1,27 @@ +## Demonstration + +This sample application is made to demonstrate the use of +the [**fastapi-oauth2**](https://github.com/pysnippet/fastapi-oauth2) package. + +## Installation + +You got to have `fastapi-oauth2` installed in your environment. To do so, run the following command in +the `pyproject.toml` file's directory. + +### Regular install + +```bash +pip install fastapi-oauth2 +``` + +### Install in editable mode + +```bash +pip install -e . +``` + +## Running the application + +```bash +uvicorn main:app --reload +``` diff --git a/examples/demonstration/config.py b/examples/demonstration/config.py new file mode 100644 index 0000000..c63b136 --- /dev/null +++ b/examples/demonstration/config.py @@ -0,0 +1,25 @@ +import os + +from dotenv import load_dotenv +from social_core.backends.github import GithubOAuth2 + +from fastapi_oauth2.client import OAuth2Client +from fastapi_oauth2.config import OAuth2Config + +load_dotenv() + +oauth2_config = OAuth2Config( + allow_http=True, + jwt_secret=os.getenv("JWT_SECRET"), + jwt_expires=os.getenv("JWT_EXPIRES"), + jwt_algorithm=os.getenv("JWT_ALGORITHM"), + clients=[ + OAuth2Client( + backend=GithubOAuth2, + client_id=os.getenv("OAUTH2_CLIENT_ID"), + client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), + # redirect_uri="http://127.0.0.1:8000/", + scope=["user:email"], + ), + ] +) diff --git a/examples/demonstration/main.py b/examples/demonstration/main.py new file mode 100644 index 0000000..1fd1291 --- /dev/null +++ b/examples/demonstration/main.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from fastapi import FastAPI + +from config import oauth2_config +from fastapi_oauth2.middleware import OAuth2Middleware +from fastapi_oauth2.router import router as oauth2_router +from router import router as app_router + +router = APIRouter() + +app = FastAPI() +app.include_router(app_router) +app.include_router(oauth2_router) +app.add_middleware(OAuth2Middleware, config=oauth2_config) diff --git a/examples/demonstration/router.py b/examples/demonstration/router.py new file mode 100644 index 0000000..d592744 --- /dev/null +++ b/examples/demonstration/router.py @@ -0,0 +1,21 @@ +import json + +from fastapi import Depends +from fastapi import Request, APIRouter +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2 +from fastapi.templating import Jinja2Templates + +oauth2 = OAuth2() +router = APIRouter() +templates = Jinja2Templates(directory="templates") + + +@router.get("/", response_class=HTMLResponse) +async def root(request: Request): + return templates.TemplateResponse("index.html", {"request": request, "user": request.user, "json": json}) + + +@router.get("/user") +def user(request: Request, _: str = Depends(oauth2)): + return request.user diff --git a/examples/demonstration/templates/index.html b/examples/demonstration/templates/index.html new file mode 100644 index 0000000..c42adf1 --- /dev/null +++ b/examples/demonstration/templates/index.html @@ -0,0 +1,37 @@ + + + + + + + OAuth2 Demo + + +
+
+ {% if request.user.is_authenticated %} + Sign out + Pic + {% else %} + + + + + + {% endif %} +
+
+
+ {% if request.user.is_authenticated %} +

Hi, {{ request.user.name }}

+

This is what your JWT contains currently

+
{{ json.dumps(request.user, indent=4) }}
+ {% else %} +

You are not authenticated

+

You should sign in by clicking the GitHub's icon

+ {% endif %} +
+ + \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 5512df3..0000000 --- a/main.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -import os - -from dotenv import load_dotenv -from fastapi import FastAPI, Request, APIRouter -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from social_core.backends.github import GithubOAuth2 - -from demo.router import router as demo_router -from fastapi_oauth2.client import OAuth2Client -from fastapi_oauth2.middleware import OAuth2Middleware -from fastapi_oauth2.router import router as oauth2_router - -load_dotenv() -router = APIRouter() -templates = Jinja2Templates(directory="templates") - - -@router.get("/", response_class=HTMLResponse) -async def root(request: Request): - return templates.TemplateResponse("index.html", {"request": request, "user": request.user, "json": json}) - - -app = FastAPI() -app.include_router(router) -app.include_router(demo_router) -app.include_router(oauth2_router) -app.add_middleware(OAuth2Middleware, config={ - "allow_http": True, - "jwt_secret": os.getenv("JWT_SECRET"), - "jwt_expires": os.getenv("JWT_EXPIRES"), - "jwt_algorithm": os.getenv("JWT_ALGORITHM"), - "clients": [ - OAuth2Client( - backend=GithubOAuth2, - client_id=os.getenv("OAUTH2_CLIENT_ID"), - client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), - # redirect_uri="http://127.0.0.1:8000/", - scope=["user:email"], - ), - ] -}) diff --git a/pyproject.toml b/pyproject.toml index 6063eaa..cfa5084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] testpaths = ["tests"] -filterwarnings = ["ignore::DeprecationWarning"] \ No newline at end of file +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::trio.TrioDeprecationWarning", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd5f611 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.68.1 +httpx>=0.22.0 +oauthlib>=3.2.2 +python-jose>=3.3.0 +social-auth-core>=4.4.2 +starlette>=0.19.1 diff --git a/setup.cfg b/setup.cfg index 22ec0f7..ac5fc78 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ name = fastapi-oauth2 version = attr: fastapi_oauth2.__version__ author = Artyom Vancyan author_email = artyom@pysnippet.org -description = Easy to setup OAuth2 social authentication mechanism with support for several auth providers. +description = Easy to setup OAuth2 authentication with support for several auth providers. long_description = file: README.md long_description_content_type = text/markdown project_urls = @@ -11,18 +11,23 @@ project_urls = Source Code=https://github.com/pysnippet/fastapi-oauth2/ keywords = python + sso auth login - social + oauth oauth2 + social fastapi + allauth + security + middleware authentication license = MIT license_files = LICENSE platforms = unix, linux, osx, win32 classifiers = Operating System :: OS Independent - Development Status :: 1 - Planning + Development Status :: 2 - Pre-Alpha Framework :: FastAPI Programming Language :: Python Programming Language :: Python :: 3 @@ -37,7 +42,12 @@ classifiers = packages = fastapi_oauth2 install_requires = - fastapi>=0.85.2 + fastapi>=0.68.1 + httpx>=0.22.0 + oauthlib>=3.2.2 + python-jose>=3.3.0 + social-auth-core>=4.4.2 + starlette>=0.19.1 include_package_data = yes python_requires = >=3.6 package_dir = diff --git a/src/fastapi_oauth2/__init__.py b/src/fastapi_oauth2/__init__.py index 6c8e6b9..5f1e750 100644 --- a/src/fastapi_oauth2/__init__.py +++ b/src/fastapi_oauth2/__init__.py @@ -1 +1 @@ -__version__ = "0.0.0" +__version__ = "1.0.0-alpha" diff --git a/src/fastapi_oauth2/config.py b/src/fastapi_oauth2/config.py index 8ce77e1..707ad66 100644 --- a/src/fastapi_oauth2/config.py +++ b/src/fastapi_oauth2/config.py @@ -1,5 +1,6 @@ import os from typing import List +from typing import Union from .client import OAuth2Client @@ -16,7 +17,7 @@ def __init__( *, allow_http: bool = False, jwt_secret: str = "", - jwt_expires: int = 900, + jwt_expires: Union[int, str] = 900, jwt_algorithm: str = "HS256", clients: List[OAuth2Client] = None, ): diff --git a/src/fastapi_oauth2/core.py b/src/fastapi_oauth2/core.py index 8191eb4..e8676de 100644 --- a/src/fastapi_oauth2/core.py +++ b/src/fastapi_oauth2/core.py @@ -9,7 +9,6 @@ from urllib.parse import urljoin import httpx -import requests from oauthlib.oauth2 import WebApplicationClient from social_core.backends.oauth import BaseOAuth2 from social_core.strategy import BaseStrategy @@ -38,7 +37,7 @@ def get_setting(self, name): @staticmethod def get_json(url, method='GET', *args, **kwargs): - return requests.request(method, url, *args, **kwargs) + return httpx.request(method, url, *args, **kwargs) class OAuth2Core: diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 5ffda19..f7622a5 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -63,7 +63,7 @@ def jwt_decode(cls, token: str) -> dict: @classmethod def jwt_create(cls, token_data: dict) -> str: - expire = datetime.utcnow() + timedelta(minutes=cls.expires) + expire = datetime.utcnow() + timedelta(seconds=cls.expires) return cls.jwt_encode({**token_data, "exp": expire}) @@ -85,7 +85,10 @@ def __init__(self, config: OAuth2Config) -> None: Auth.register_client(client) async def authenticate(self, request: Request) -> Optional[Tuple["Auth", "User"]]: - authorization = request.cookies.get("Authorization") + authorization = request.headers.get( + "Authorization", + request.cookies.get("Authorization"), + ) scheme, param = get_authorization_scheme_param(authorization) if not scheme or not param: diff --git a/src/fastapi_oauth2/router.py b/src/fastapi_oauth2/router.py index 773bc09..5d1a2ef 100644 --- a/src/fastapi_oauth2/router.py +++ b/src/fastapi_oauth2/router.py @@ -16,7 +16,7 @@ async def token(request: Request, provider: str): @router.get("/logout") -async def logout(request: Request): +def logout(request: Request): response = RedirectResponse(request.base_url) response.delete_cookie("Authorization") return response diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index f8a7d82..0000000 --- a/templates/index.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - OAuth2 Demo - - -
-
- {% if request.user.is_authenticated %} - Sign out - Pic - {% else %} - - GH - - {% endif %} -
-
-
- {% if request.user.is_authenticated %} -

Hi, {{ request.user.name }}

-

This is what your JWT contains currently

-
{{ json.dumps(request.user, indent=4) }}
- {% else %} -

You are not authenticated

-

You should sign in by clicking the GitHub's icon

- {% endif %} -
- - \ No newline at end of file diff --git a/demo/__init__.py b/tests/__init__.py similarity index 100% rename from demo/__init__.py rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aedb52a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +import importlib +import os + +import pytest +import social_core.backends as backends +from social_core.backends.oauth import BaseOAuth2 + +package_path = backends.__path__[0] + + +@pytest.fixture +def backends(): + backend_instances = [] + for module in os.listdir(package_path): + try: + module_instance = importlib.import_module("social_core.backends.%s" % module[:-3]) + backend_instances.extend([ + attr for attr in module_instance.__dict__.values() + if type(attr) is type and all([ + issubclass(attr, BaseOAuth2), + attr is not BaseOAuth2, + ]) + ]) + except ImportError: + continue + return backend_instances diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..b687b13 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ +tox==3.24.3 +trio>=0.19.0 +pytest==6.2.5 +httpx==0.22.0 +appengine-python-standard # for loading the gae backend diff --git a/tests/test_oauth2_middleware.py b/tests/test_oauth2_middleware.py new file mode 100644 index 0000000..4617b74 --- /dev/null +++ b/tests/test_oauth2_middleware.py @@ -0,0 +1,43 @@ +import pytest +from fastapi import FastAPI +from httpx import AsyncClient +from social_core.backends.github import GithubOAuth2 + +from fastapi_oauth2.client import OAuth2Client +from fastapi_oauth2.core import OAuth2Core +from fastapi_oauth2.middleware import OAuth2Middleware +from fastapi_oauth2.router import router as oauth2_router + +app = FastAPI() + +app.include_router(oauth2_router) +app.add_middleware(OAuth2Middleware, config={ + "allow_http": True, + "clients": [ + OAuth2Client( + backend=GithubOAuth2, + client_id="test_id", + client_secret="test_secret", + ), + ], +}) + + +@pytest.mark.anyio +async def test_auth_redirect(): + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get("/oauth2/github/auth") + assert response.status_code == 303 # Redirect + + +@pytest.mark.anyio +async def test_core_init(backends): + for backend in backends: + try: + OAuth2Core(OAuth2Client( + backend=backend, + client_id="test_client_id", + client_secret="test_client_secret", + )) + except (NotImplementedError, Exception): + assert False diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9ec924f --- /dev/null +++ b/tox.ini @@ -0,0 +1,15 @@ +[tox] +envlist = + py{36,38,310,311}-fastapi68 + py{37,39,310,311}-fastapi{84,99} + +[testenv] +deps = + fastapi99: fastapi>=0.99.0 + fastapi84: fastapi<=0.84.0 + fastapi68: fastapi<=0.68.1 + -r{toxinidir}/tests/requirements.txt +allowlist_externals = sh +commands = + sh build.sh + pytest