From f460291ca0e1ca77913bb6152d0bc1e6c9271cbb Mon Sep 17 00:00:00 2001 From: kos Date: Tue, 5 Nov 2024 13:40:41 +0100 Subject: [PATCH] fix(router): support PEP 604 unions (pipe operator) --- .github/workflows/build.yml | 2 +- .pre-commit-config.yaml | 20 ++++---- flask_ninja/models.py | 2 +- flask_ninja/operation.py | 4 +- pyproject.toml | 2 +- .../test_api/test_get_schema/api_schema | 2 +- tests/unit/test_router.py | 48 +++++++++++++++++ tests/unit/test_router_py3_10.py | 51 +++++++++++++++++++ tox.ini | 9 +++- 9 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_router_py3_10.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 863c27c..657b2bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-python@v1 with: - python-version: 3.9 + python-version: 3.11 - run: pip install pre-commit - run: pre-commit run --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9d1c3b..3145477 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ default_language_version: - python: python3.9 + python: python3.11 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v5.0.0 hooks: - id: trailing-whitespace exclude: ^.*\.md$ @@ -19,23 +19,23 @@ repos: - id: check-merge-conflict - repo: https://github.com/jorisroovers/gitlint - rev: v0.17.0 + rev: v0.19.1 hooks: - id: gitlint - repo: https://github.com/adrienverge/yamllint - rev: v1.26.3 + rev: v1.35.1 hooks: - id: yamllint - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.30.0 + rev: v0.42.0 hooks: - id: markdownlint language_version: system - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort # extra dependencies for config in pyproject.toml @@ -43,13 +43,13 @@ repos: args: [ "--profile", "black" ] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 24.10.0 hooks: - id: black args: [--fast, --target-version=py39] - repo: https://github.com/PyCQA/pylint - rev: v2.12.2 + rev: v3.3.1 hooks: - id: pylint exclude: ^.*(docs/).*$ @@ -58,14 +58,14 @@ repos: additional_dependencies: ['toml', 'pyproject'] - repo: https://github.com/PyCQA/pydocstyle - rev: 6.1.1 + rev: 6.3.0 hooks: - id: pydocstyle exclude: ^.*(migrations/).*$ additional_dependencies: [ 'toml', 'pyproject' ] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.1.1 + rev: v1.13.0 hooks: - id: mypy args: [] diff --git a/flask_ninja/models.py b/flask_ninja/models.py index fa9c2e6..31eb5f4 100644 --- a/flask_ninja/models.py +++ b/flask_ninja/models.py @@ -152,7 +152,7 @@ class MediaType(BaseModel): class ParameterBase(BaseModel): description: Optional[str] = None required: Optional[bool] = None - deprecated: Optional[bool] = None + deprecated: Union[Any, str, bool, None] = None # Serialization rules for simple scenarios style: Optional[str] = None explode: Optional[bool] = None diff --git a/flask_ninja/operation.py b/flask_ninja/operation.py index 555b877..b946d1b 100644 --- a/flask_ninja/operation.py +++ b/flask_ninja/operation.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict, ValidationError from .constants import NOT_SET, ApiConfigError, ParamType -from .model_field import FieldMapping, ModelField, Undefined +from .model_field import FieldMapping, ModelField, Undefined, UnionType from .models import MediaType from .models import Operation as OAPIOperation from .models import ( @@ -158,7 +158,7 @@ def _sanitize_responses( # Check if for each returned type there is implicitly or explicitly defined response model and code if func_return_type: - if get_origin(func_return_type) == Union: + if get_origin(func_return_type) in [Union, UnionType]: for ret_type in func_return_type.__args__: if not any(resp.type_ != ret_type for resp in responses.values()): raise ApiConfigError( diff --git a/pyproject.toml b/pyproject.toml index 3295b0c..1633f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "flask-ninja" -version = "1.3.0" +version = "1.3.1" description = "Flask Ninja is a web framework for building APIs with Flask and Python 3.9+ type hints." readme = "README.md" authors = ["Michal Korbela "] diff --git a/tests/unit/snapshots/test_api/test_get_schema/api_schema b/tests/unit/snapshots/test_api/test_get_schema/api_schema index 8e4d83c..6dac989 100644 --- a/tests/unit/snapshots/test_api/test_get_schema/api_schema +++ b/tests/unit/snapshots/test_api/test_get_schema/api_schema @@ -1 +1 @@ -{"openapi": "3.1.0", "info": {"title": "", "description": "", "version": "1.0.0"}, "paths": {"/some_endpoint/{param}": {"get": {"summary": "", "description": "", "parameters": [{"required": true, "schema": {"type": "integer"}, "name": "param", "in": "path"}], "requestBody": {"description": "", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Server"}}}, "required": true}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "integer"}}}}}, "security": [{"bearerTokenAuth": []}]}}}, "components": {"schemas": {"Server": {"additionalProperties": true, "properties": {"url": {"anyOf": [{"format": "uri", "minLength": 1, "type": "string"}, {"type": "string"}], "title": "Url"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Description"}, "variables": {"anyOf": [{"additionalProperties": {"$ref": "#/components/schemas/ServerVariable"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Variables"}}, "required": ["url"], "title": "Server", "type": "object"}, "ServerVariable": {"additionalProperties": true, "properties": {"enum": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "title": "Enum"}, "default": {"title": "Default", "type": "string"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Description"}}, "required": ["default"], "title": "ServerVariable", "type": "object"}}, "securitySchemes": {"bearerTokenAuth": {"type": "http", "scheme": "bearer"}}}} \ No newline at end of file +{"openapi": "3.1.0", "info": {"title": "", "description": "", "version": "1.0.0"}, "paths": {"/some_endpoint/{param}": {"get": {"summary": "", "description": "", "parameters": [{"required": true, "schema": {"type": "integer"}, "name": "param", "in": "path"}], "requestBody": {"description": "", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Server"}}}, "required": true}, "responses": {"200": {"description": "", "content": {"application/json": {"schema": {"type": "integer"}}}}}, "security": [{"bearerTokenAuth": []}]}}}, "components": {"schemas": {"Server": {"additionalProperties": true, "properties": {"url": {"anyOf": [{"format": "uri", "minLength": 1, "type": "string"}, {"type": "string"}], "title": "Url"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Description"}, "variables": {"anyOf": [{"additionalProperties": {"$ref": "#/components/schemas/ServerVariable"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Variables"}}, "required": ["url"], "title": "Server", "type": "object"}, "ServerVariable": {"additionalProperties": true, "properties": {"enum": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "title": "Enum"}, "default": {"type": "string", "title": "Default"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Description"}}, "required": ["default"], "title": "ServerVariable", "type": "object"}}, "securitySchemes": {"bearerTokenAuth": {"type": "http", "scheme": "bearer"}}}} \ No newline at end of file diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index f4c2e77..1bf3f36 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -1,4 +1,7 @@ +from typing import Union + import pytest +from pydantic import BaseModel from flask_ninja import Query from flask_ninja.operation import Callback, Operation @@ -43,6 +46,51 @@ def sample_method(): assert router.operations[0].params == [param] +def test_add_route_union(): + router = Router() + callback = Callback( + name="some_name", + url="some_url", + method="some_callback_method", + response_codes={}, + ) + + param = create_model_field(name="some_param", type_=int, field_info=Query()) + + class Response200(BaseModel): + status: str + + class Response400(BaseModel): + error: str + + @router.add_route( + "GET", + "/foo", + responses={200: Response200, 400: Response400}, + auth="some_auth", + summary="some_summary", + description="some_description", + params=[param], + callbacks=[callback], + ) + def sample_method() -> Union[Response200, Response400]: + return Response200(status="foo") + + assert len(router.operations) == 1 + assert router.operations[0].path == "/foo" + assert router.operations[0].method == "GET" + assert str(router.operations[0].responses) == str( + { + 200: create_model_field(name="Response 200", type_=Response200), + 400: create_model_field(name="Response 400", type_=Response400), + } + ) + assert router.operations[0].callbacks == [callback] + assert router.operations[0].summary == "some_summary" + assert router.operations[0].description == "some_description" + assert router.operations[0].params == [param] + + def test_add_route_no_params(): router = Router() diff --git a/tests/unit/test_router_py3_10.py b/tests/unit/test_router_py3_10.py new file mode 100644 index 0000000..5d5c2cc --- /dev/null +++ b/tests/unit/test_router_py3_10.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel + +from flask_ninja import Query +from flask_ninja.operation import Callback +from flask_ninja.router import Router +from flask_ninja.utils import create_model_field + + +def test_add_route_union(): + router = Router() + callback = Callback( + name="some_name", + url="some_url", + method="some_callback_method", + response_codes={}, + ) + + param = create_model_field(name="some_param", type_=int, field_info=Query()) + + class Response200(BaseModel): + status: str + + class Response400(BaseModel): + error: str + + @router.add_route( + "GET", + "/foo", + responses={200: Response200, 400: Response400}, + auth="some_auth", + summary="some_summary", + description="some_description", + params=[param], + callbacks=[callback], + ) + def sample_method() -> Response200 | Response400: + return Response200(status="foo") + + assert len(router.operations) == 1 + assert router.operations[0].path == "/foo" + assert router.operations[0].method == "GET" + assert str(router.operations[0].responses) == str( + { + 200: create_model_field(name="Response 200", type_=Response200), + 400: create_model_field(name="Response 400", type_=Response400), + } + ) + assert router.operations[0].callbacks == [callback] + assert router.operations[0].summary == "some_summary" + assert router.operations[0].description == "some_description" + assert router.operations[0].params == [param] diff --git a/tox.ini b/tox.ini index 5e6333a..1199460 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3.9, py3.10, py3.11 +envlist = py39, py310, py311 isolated_build = True [testenv] @@ -9,3 +9,10 @@ deps = pytest commands = # NOTE: you can run any command line tool here - not just tests pytest + +[testenv:py39] +deps = pytest + pytest-snapshot +commands = + # NOTE: test modules with _py3_10 suffix contain py3.9-incompatible syntax + pytest --ignore-glob='*_py3_10.py'