diff --git a/.github/workflows/create-wheels.yaml b/.github/workflows/create-wheels.yaml index 2a469dee4..6caf8c4df 100644 --- a/.github/workflows/create-wheels.yaml +++ b/.github/workflows/create-wheels.yaml @@ -24,8 +24,6 @@ jobs: - "windows-latest" - "macos-latest" python-version: - - "3.6" - - "3.7" - "3.8" - "3.9" - "3.10" @@ -38,12 +36,12 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} @@ -95,7 +93,6 @@ jobs: - "ubuntu-latest" python-version: # the versions are - as specified in PEP 425. - - cp37-cp37m - cp38-cp38 - cp39-cp39 - cp310-cp310 @@ -108,7 +105,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 @@ -123,7 +120,7 @@ jobs: echo "::set-output name=python-version::$version" - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ steps.linux-py-version.outputs.python-version }} architecture: ${{ matrix.architecture }} @@ -181,12 +178,12 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} @@ -227,7 +224,6 @@ jobs: - "ubuntu-latest" python-version: # the versions are - as specified in PEP 425. - - cp37-cp37m - cp38-cp38 - cp39-cp39 - cp310-cp310 @@ -241,7 +237,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 @@ -308,7 +304,7 @@ jobs: files: 'dist/*manylinux*' - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" architecture: "x64" diff --git a/.github/workflows/mintest.yaml b/.github/workflows/mintest.yaml index cf2ff0d78..480fc0cd3 100644 --- a/.github/workflows/mintest.yaml +++ b/.github/workflows/mintest.yaml @@ -15,20 +15,27 @@ jobs: fail-fast: false matrix: python-version: - - "3.8" - "3.10" - "3.11" - "3.12" + - "3.13" steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 + if: ${{ matrix.python-version != '3.13' }} with: python-version: ${{ matrix.python-version }} + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + if: ${{ matrix.python-version == '3.13' }} + with: + python-version: "3.13.0-rc.1 - 3.13" + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests-emulated.yaml b/.github/workflows/tests-emulated.yaml index 5ed32f76c..ba00d83fb 100644 --- a/.github/workflows/tests-emulated.yaml +++ b/.github/workflows/tests-emulated.yaml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 diff --git a/.github/workflows/tests-mailman.yaml b/.github/workflows/tests-mailman.yaml index 60fd7691a..ccb6a00ec 100644 --- a/.github/workflows/tests-mailman.yaml +++ b/.github/workflows/tests-mailman.yaml @@ -3,12 +3,9 @@ name: Run tests (GNU Mailman 3) on: # Trigger the workflow on master but also allow it to run manually. workflow_dispatch: - - # NOTE(vytas): Disabled as it is failing as of 2023-09. - # Maybe @maxking just needs to update the Docker image (?) - # push: - # branches: - # - master + push: + branches: + - master jobs: run_tox: @@ -17,7 +14,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 135beda0b..42206d05d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,6 +19,8 @@ jobs: matrix: python-version: - "3.12" + python-dev-version: + - "" os: - "ubuntu-latest" toxenv: @@ -50,15 +52,9 @@ jobs: - python-version: pypy3.9 os: ubuntu-latest toxenv: pypy3 - - python-version: "3.7" - os: ubuntu-latest - toxenv: py37 - python-version: "3.8" os: ubuntu-latest toxenv: py38 - - python-version: "3.8" - os: ubuntu-latest - toxenv: py38_cython - python-version: "3.9" os: ubuntu-latest toxenv: py39 @@ -89,6 +85,14 @@ jobs: - python-version: "3.12" os: windows-latest toxenv: py312_nocover + - python-version: "3.13" + python-dev-version: "3.13.0-rc.1 - 3.13" + os: ubuntu-latest + toxenv: py313 + - python-version: "3.13" + python-dev-version: "3.13.0-rc.1 - 3.13" + os: ubuntu-latest + toxenv: py313_cython # These env require 3.8 and 20.04, see tox.ini - python-version: "3.8" os: ubuntu-20.04 @@ -104,17 +108,24 @@ jobs: # Some are GitHub actions, others run shell commands. steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 # NOTE(vytas): Work around # https://github.com/codecov/codecov-action/issues/190 with: fetch-depth: 2 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 + if: ${{ !matrix.python-dev-version }} with: python-version: ${{ matrix.python-version }} + - name: Set up Python (pre-release) + uses: actions/setup-python@v5 + if: ${{ matrix.python-dev-version }} + with: + python-version: ${{ matrix.python-dev-version }} + - name: Install smoke test dependencies if: ${{ matrix.toxenv == 'py38_smoke' || matrix.toxenv == 'py38_smoke_cython' }} run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4748acef0..935c8aacb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,8 @@ $ pip install -U ruff $ ruff format ``` -You can check all this by running ``tox`` from within the Falcon project directory. Your environment must be based on CPython 3.8, 3.10, 3.11 or 3.12: +You can check all this by running ``tox`` from within the Falcon project directory. +Your environment must be based on CPython 3.10, 3.11, 3.12 or 3.13: ```bash $ pip install -U tox diff --git a/README.rst b/README.rst index 82c93d832..cb1105237 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,7 @@ clean design that embraces HTTP and the REST architectural style. Falcon apps work with any `WSGI `_ or `ASGI `_ server, and run like a -champ under CPython 3.7+ and PyPy 3.7+. +champ under CPython 3.8+ and PyPy 3.8+. Quick Links ----------- @@ -79,7 +79,7 @@ Falcon tries to do as little as possible while remaining highly effective. - Idiomatic HTTP error responses - Straightforward exception handling - Snappy testing with WSGI/ASGI helpers and mocks -- CPython 3.7+ and PyPy 3.7+ support +- CPython 3.8+ and PyPy 3.8+ support .. Patron list starts here. For Python package, we substitute this section with: Support Falcon Development @@ -210,7 +210,7 @@ PyPy ^^^^ `PyPy `__ is the fastest way to run your Falcon app. -PyPy3.7+ is supported as of PyPy v7.3.4+. +PyPy3.8+ is supported as of PyPy v7.3.7+. .. code:: bash @@ -226,7 +226,7 @@ CPython ^^^^^^^ Falcon also fully supports -`CPython `__ 3.7+. +`CPython `__ 3.8+. The latest stable version of Falcon can be installed directly from PyPI: diff --git a/docs/changes/4.0.0.rst b/docs/changes/4.0.0.rst index 361c17619..db76ba733 100644 --- a/docs/changes/4.0.0.rst +++ b/docs/changes/4.0.0.rst @@ -14,12 +14,15 @@ Changes to Supported Platforms - CPython 3.11 is now fully supported. (`#2072 `__) - CPython 3.12 is now fully supported. (`#2196 `__) -- End-of-life Python 3.5 & 3.6 are no longer supported. (`#2074 `__) -- Python 3.7 is no longer actively supported, but the framework should still - continue to install from source. We may remove the support for 3.7 altogether - later in the 4.x series if we are faced with incompatible ecosystem changes - in typing, Cython, etc. - +- CPython 3.13 is now fully supported. (`#2258 `__) +- End-of-life Python 3.5, 3.6 & 3.7 are no longer supported. (`#2074 `__, `#2273 `__) +- Soon end-of-life Python 3.8 is no longer actively supported, but + the framework should still continue to install from source and function. +- The Falcon 4.x series is guaranteed to support CPython 3.10 and + PyPy3.10 (v7.3.16). + This means that we may drop the support for Python 3.8 & 3.9 altogether in a + later 4.x release, especially if we are faced with incompatible ecosystem + changes in typing, Cython, etc. .. towncrier release notes start diff --git a/docs/index.rst b/docs/index.rst index 6827981c5..8e3ac1f0d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,7 +94,7 @@ Falcon tries to do as little as possible while remaining highly effective. - Idiomatic :ref:`HTTP error ` responses - Straightforward exception handling - Snappy :ref:`testing ` with WSGI/ASGI helpers and mocks -- CPython 3.7+ and PyPy 3.7+ support +- CPython 3.8+ and PyPy 3.8+ support Who's Using Falcon? ------------------- diff --git a/docs/user/install.rst b/docs/user/install.rst index 435649463..ef4986802 100644 --- a/docs/user/install.rst +++ b/docs/user/install.rst @@ -7,7 +7,7 @@ PyPy ---- `PyPy `__ is the fastest way to run your Falcon app. -PyPy3.7+ is supported as of PyPy v7.3.4. +PyPy3.8+ is supported as of PyPy v7.3.7. .. code:: bash @@ -23,7 +23,7 @@ CPython ------- Falcon fully supports -`CPython `__ 3.7+. +`CPython `__ 3.8+. The latest stable version of Falcon can be installed directly from PyPI: diff --git a/docs/user/intro.rst b/docs/user/intro.rst index a86809a8e..c6c22f228 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -15,7 +15,7 @@ architectural style, and tries to do as little as possible while remaining highly effective. Falcon apps work with any WSGI server, and run like a champ under -CPython 3.7+ and PyPy 3.7+. +CPython 3.8+ and PyPy 3.8+. Features -------- @@ -35,7 +35,7 @@ Falcon tries to do as little as possible while remaining highly effective. - Idiomatic :ref:`HTTP error ` responses - Straightforward exception handling - Snappy :ref:`testing ` with WSGI/ASGI helpers and mocks -- CPython 3.7+ and PyPy 3.7+ support +- CPython 3.8+ and PyPy 3.8+ support How is Falcon different? ------------------------ diff --git a/docs/user/recipes/request-id.rst b/docs/user/recipes/request-id.rst index 688d4fbb4..9274a5ad8 100644 --- a/docs/user/recipes/request-id.rst +++ b/docs/user/recipes/request-id.rst @@ -48,4 +48,4 @@ In a pinch, you can also output the request ID directly: .. literalinclude:: ../../../examples/recipes/request_id_log.py :language: python -.. _thread-local: https://docs.python.org/3.7/library/threading.html#thread-local-data +.. _thread-local: https://docs.python.org/3/library/threading.html#thread-local-data diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index bbfd1ab2c..a58c96c23 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -32,7 +32,7 @@ WSGI tutorial:: └── app.py We'll create a *virtualenv* using the ``venv`` module from the standard library -(Falcon requires Python 3.7+):: +(Falcon requires Python 3.8+):: $ mkdir asgilook $ python3 -m venv asgilook/.venv diff --git a/e2e-tests/server/app.py b/e2e-tests/server/app.py index be9558985..46f52a90c 100644 --- a/e2e-tests/server/app.py +++ b/e2e-tests/server/app.py @@ -13,7 +13,8 @@ def create_app() -> falcon.asgi.App: - app = falcon.asgi.App() + # TODO(vytas): Type to App's constructor. + app = falcon.asgi.App() # type: ignore hub = Hub() app.add_route('/ping', Pong()) diff --git a/e2e-tests/server/hub.py b/e2e-tests/server/hub.py index e4e729b59..80213ecf6 100644 --- a/e2e-tests/server/hub.py +++ b/e2e-tests/server/hub.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import typing import uuid @@ -11,9 +13,9 @@ class Emitter: POLL_TIMEOUT = 3.0 - def __init__(self): - self._done = False - self._queue = asyncio.Queue() + def __init__(self) -> None: + self._done: bool = False + self._queue: asyncio.Queue[SSEvent] = asyncio.Queue() async def events(self) -> typing.AsyncGenerator[typing.Optional[SSEvent], None]: try: @@ -37,16 +39,16 @@ async def enqueue(self, message: str) -> None: await self._queue.put(event) @property - def done(self): + def done(self) -> bool: return self._done class Hub: - def __init__(self): - self._emitters = set() - self._users = {} + def __init__(self) -> None: + self._emitters: set[Emitter] = set() + self._users: dict[str, WebSocket] = {} - def _update_emitters(self) -> set: + def _update_emitters(self) -> set[Emitter]: done = {emitter for emitter in self._emitters if emitter.done} self._emitters.difference_update(done) return self._emitters.copy() diff --git a/e2e-tests/server/ping.py b/e2e-tests/server/ping.py index 7deb5f077..447db6658 100644 --- a/e2e-tests/server/ping.py +++ b/e2e-tests/server/ping.py @@ -9,4 +9,5 @@ class Pong: async def on_get(self, req: Request, resp: Response) -> None: resp.content_type = falcon.MEDIA_TEXT resp.text = 'PONG\n' - resp.status = HTTPStatus.OK + # TODO(vytas): Properly type Response.status. + resp.status = HTTPStatus.OK # type: ignore diff --git a/falcon/__init__.py b/falcon/__init__.py index 169f8bfc4..b9976b643 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -25,13 +25,370 @@ import logging as _logging -# NOTE(kgriffs): Hoist classes and functions into the falcon namespace. -# Please explicitly list all exports, unless there are many of the -# same type (e.g., example status codes, constants, and errors). +__all__ = ( + # API interface + 'API', + 'App', + 'after', + 'before', + 'BoundedStream', + 'CORSMiddleware', + 'HTTPError', + 'HTTPStatus', + 'HTTPFound', + 'HTTPMovedPermanently', + 'HTTPPermanentRedirect', + 'HTTPSeeOther', + 'HTTPTemporaryRedirect', + 'Forwarded', + 'Request', + 'RequestOptions', + 'Response', + 'ResponseOptions', + # Public constants + 'HTTP_METHODS', + 'WEBDAV_METHODS', + 'COMBINED_METHODS', + 'DEFAULT_MEDIA_TYPE', + 'MEDIA_BMP', + 'MEDIA_GIF', + 'MEDIA_HTML', + 'MEDIA_JPEG', + 'MEDIA_JS', + 'MEDIA_JSON', + 'MEDIA_MSGPACK', + 'MEDIA_MULTIPART', + 'MEDIA_PNG', + 'MEDIA_TEXT', + 'MEDIA_URLENCODED', + 'MEDIA_XML', + 'MEDIA_YAML', + 'SINGLETON_HEADERS', + 'WebSocketPayloadType', + # Utilities + 'async_to_sync', + 'BufferedReader', + 'CaseInsensitiveDict', + 'code_to_http_status', + 'Context', + 'create_task', + 'deprecated', + 'dt_to_http', + 'ETag', + 'get_argnames', + 'get_bound_method', + 'get_http_status', + 'get_running_loop', + 'http_cookies', + 'http_date_to_dt', + 'http_now', + 'http_status_to_code', + 'IS_64_BITS', + 'is_python_func', + 'misc', + 'parse_header', + 'reader', + 'runs_sync', + 'secure_filename', + 'structures', + 'sync', + 'sync_to_async', + 'time', + 'TimezoneGMT', + 'to_query_str', + 'uri', + 'wrap_sync_to_async', + 'wrap_sync_to_async_unsafe', + # Error classes + 'CompatibilityError', + 'DelimiterError', + 'HeaderNotSupported', + 'HTTPBadGateway', + 'HTTPBadRequest', + 'HTTPConflict', + 'HTTPFailedDependency', + 'HTTPForbidden', + 'HTTPGatewayTimeout', + 'HTTPGone', + 'HTTPInsufficientStorage', + 'HTTPInternalServerError', + 'HTTPInvalidHeader', + 'HTTPInvalidParam', + 'HTTPLengthRequired', + 'HTTPLocked', + 'HTTPLoopDetected', + 'HTTPMethodNotAllowed', + 'HTTPMissingHeader', + 'HTTPMissingParam', + 'HTTPNetworkAuthenticationRequired', + 'HTTPNotAcceptable', + 'HTTPNotFound', + 'HTTPNotImplemented', + 'HTTPPayloadTooLarge', + 'HTTPPreconditionFailed', + 'HTTPPreconditionRequired', + 'HTTPRangeNotSatisfiable', + 'HTTPRequestHeaderFieldsTooLarge', + 'HTTPRouteNotFound', + 'HTTPServiceUnavailable', + 'HTTPTooManyRequests', + 'HTTPUnauthorized', + 'HTTPUnavailableForLegalReasons', + 'HTTPUnprocessableEntity', + 'HTTPUnsupportedMediaType', + 'HTTPUriTooLong', + 'HTTPVersionNotSupported', + 'MediaMalformedError', + 'MediaNotFoundError', + 'MediaValidationError', + 'MultipartParseError', + 'OperationNotAllowed', + 'PayloadTypeError', + 'UnsupportedError', + 'UnsupportedScopeError', + 'WebSocketDisconnected', + 'WebSocketHandlerNotFound', + 'WebSocketPathNotFound', + 'WebSocketServerError', + # HTTP status codes + 'HTTP_100', + 'HTTP_101', + 'HTTP_102', + 'HTTP_200', + 'HTTP_201', + 'HTTP_202', + 'HTTP_203', + 'HTTP_204', + 'HTTP_205', + 'HTTP_206', + 'HTTP_207', + 'HTTP_208', + 'HTTP_226', + 'HTTP_300', + 'HTTP_301', + 'HTTP_302', + 'HTTP_303', + 'HTTP_304', + 'HTTP_305', + 'HTTP_307', + 'HTTP_308', + 'HTTP_400', + 'HTTP_401', + 'HTTP_402', + 'HTTP_403', + 'HTTP_404', + 'HTTP_405', + 'HTTP_406', + 'HTTP_407', + 'HTTP_408', + 'HTTP_409', + 'HTTP_410', + 'HTTP_411', + 'HTTP_412', + 'HTTP_413', + 'HTTP_414', + 'HTTP_415', + 'HTTP_416', + 'HTTP_417', + 'HTTP_418', + 'HTTP_422', + 'HTTP_423', + 'HTTP_424', + 'HTTP_426', + 'HTTP_428', + 'HTTP_429', + 'HTTP_431', + 'HTTP_451', + 'HTTP_500', + 'HTTP_501', + 'HTTP_502', + 'HTTP_503', + 'HTTP_504', + 'HTTP_505', + 'HTTP_507', + 'HTTP_508', + 'HTTP_511', + 'HTTP_701', + 'HTTP_702', + 'HTTP_703', + 'HTTP_710', + 'HTTP_711', + 'HTTP_712', + 'HTTP_719', + 'HTTP_720', + 'HTTP_721', + 'HTTP_722', + 'HTTP_723', + 'HTTP_724', + 'HTTP_725', + 'HTTP_726', + 'HTTP_727', + 'HTTP_740', + 'HTTP_741', + 'HTTP_742', + 'HTTP_743', + 'HTTP_744', + 'HTTP_745', + 'HTTP_748', + 'HTTP_749', + 'HTTP_750', + 'HTTP_753', + 'HTTP_754', + 'HTTP_755', + 'HTTP_759', + 'HTTP_771', + 'HTTP_772', + 'HTTP_773', + 'HTTP_774', + 'HTTP_776', + 'HTTP_777', + 'HTTP_778', + 'HTTP_779', + 'HTTP_780', + 'HTTP_781', + 'HTTP_782', + 'HTTP_783', + 'HTTP_784', + 'HTTP_785', + 'HTTP_786', + 'HTTP_791', + 'HTTP_792', + 'HTTP_797', + 'HTTP_799', + 'HTTP_ACCEPTED', + 'HTTP_ALREADY_REPORTED', + 'HTTP_BAD_GATEWAY', + 'HTTP_BAD_REQUEST', + 'HTTP_CONFLICT', + 'HTTP_CONTINUE', + 'HTTP_CREATED', + 'HTTP_EXPECTATION_FAILED', + 'HTTP_FAILED_DEPENDENCY', + 'HTTP_FORBIDDEN', + 'HTTP_FOUND', + 'HTTP_GATEWAY_TIMEOUT', + 'HTTP_GONE', + 'HTTP_HTTP_VERSION_NOT_SUPPORTED', + 'HTTP_IM_A_TEAPOT', + 'HTTP_IM_USED', + 'HTTP_INSUFFICIENT_STORAGE', + 'HTTP_INTERNAL_SERVER_ERROR', + 'HTTP_LENGTH_REQUIRED', + 'HTTP_LOCKED', + 'HTTP_LOOP_DETECTED', + 'HTTP_METHOD_NOT_ALLOWED', + 'HTTP_MOVED_PERMANENTLY', + 'HTTP_MULTIPLE_CHOICES', + 'HTTP_MULTI_STATUS', + 'HTTP_NETWORK_AUTHENTICATION_REQUIRED', + 'HTTP_NON_AUTHORITATIVE_INFORMATION', + 'HTTP_NOT_ACCEPTABLE', + 'HTTP_NOT_FOUND', + 'HTTP_NOT_IMPLEMENTED', + 'HTTP_NOT_MODIFIED', + 'HTTP_NO_CONTENT', + 'HTTP_OK', + 'HTTP_PARTIAL_CONTENT', + 'HTTP_PAYMENT_REQUIRED', + 'HTTP_PERMANENT_REDIRECT', + 'HTTP_PRECONDITION_FAILED', + 'HTTP_PRECONDITION_REQUIRED', + 'HTTP_PROCESSING', + 'HTTP_PROXY_AUTHENTICATION_REQUIRED', + 'HTTP_REQUESTED_RANGE_NOT_SATISFIABLE', + 'HTTP_REQUEST_ENTITY_TOO_LARGE', + 'HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE', + 'HTTP_REQUEST_TIMEOUT', + 'HTTP_REQUEST_URI_TOO_LONG', + 'HTTP_RESET_CONTENT', + 'HTTP_SEE_OTHER', + 'HTTP_SERVICE_UNAVAILABLE', + 'HTTP_SWITCHING_PROTOCOLS', + 'HTTP_TEMPORARY_REDIRECT', + 'HTTP_TOO_MANY_REQUESTS', + 'HTTP_UNAUTHORIZED', + 'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS', + 'HTTP_UNPROCESSABLE_ENTITY', + 'HTTP_UNSUPPORTED_MEDIA_TYPE', + 'HTTP_UPGRADE_REQUIRED', + 'HTTP_USE_PROXY', +) + +# NOTE(kgriffs,vytas): Hoist classes and functions into the falcon namespace. +# Please explicitly list ALL exports. from falcon.app import API from falcon.app import App -from falcon.constants import * -from falcon.errors import * +from falcon.constants import ASGI_SUPPORTED # NOQA: F401 +from falcon.constants import COMBINED_METHODS +from falcon.constants import DEFAULT_MEDIA_TYPE +from falcon.constants import HTTP_METHODS +from falcon.constants import MEDIA_BMP +from falcon.constants import MEDIA_GIF +from falcon.constants import MEDIA_HTML +from falcon.constants import MEDIA_JPEG +from falcon.constants import MEDIA_JS +from falcon.constants import MEDIA_JSON +from falcon.constants import MEDIA_MSGPACK +from falcon.constants import MEDIA_MULTIPART +from falcon.constants import MEDIA_PNG +from falcon.constants import MEDIA_TEXT +from falcon.constants import MEDIA_URLENCODED +from falcon.constants import MEDIA_XML +from falcon.constants import MEDIA_YAML +from falcon.constants import PYTHON_VERSION # NOQA: F401 +from falcon.constants import SINGLETON_HEADERS +from falcon.constants import WEBDAV_METHODS +from falcon.constants import WebSocketPayloadType +from falcon.errors import CompatibilityError +from falcon.errors import DelimiterError +from falcon.errors import HeaderNotSupported +from falcon.errors import HTTPBadGateway +from falcon.errors import HTTPBadRequest +from falcon.errors import HTTPConflict +from falcon.errors import HTTPFailedDependency +from falcon.errors import HTTPForbidden +from falcon.errors import HTTPGatewayTimeout +from falcon.errors import HTTPGone +from falcon.errors import HTTPInsufficientStorage +from falcon.errors import HTTPInternalServerError +from falcon.errors import HTTPInvalidHeader +from falcon.errors import HTTPInvalidParam +from falcon.errors import HTTPLengthRequired +from falcon.errors import HTTPLocked +from falcon.errors import HTTPLoopDetected +from falcon.errors import HTTPMethodNotAllowed +from falcon.errors import HTTPMissingHeader +from falcon.errors import HTTPMissingParam +from falcon.errors import HTTPNetworkAuthenticationRequired +from falcon.errors import HTTPNotAcceptable +from falcon.errors import HTTPNotFound +from falcon.errors import HTTPNotImplemented +from falcon.errors import HTTPPayloadTooLarge +from falcon.errors import HTTPPreconditionFailed +from falcon.errors import HTTPPreconditionRequired +from falcon.errors import HTTPRangeNotSatisfiable +from falcon.errors import HTTPRequestHeaderFieldsTooLarge +from falcon.errors import HTTPRouteNotFound +from falcon.errors import HTTPServiceUnavailable +from falcon.errors import HTTPTooManyRequests +from falcon.errors import HTTPUnauthorized +from falcon.errors import HTTPUnavailableForLegalReasons +from falcon.errors import HTTPUnprocessableEntity +from falcon.errors import HTTPUnsupportedMediaType +from falcon.errors import HTTPUriTooLong +from falcon.errors import HTTPVersionNotSupported +from falcon.errors import MediaMalformedError +from falcon.errors import MediaNotFoundError +from falcon.errors import MediaValidationError +from falcon.errors import MultipartParseError +from falcon.errors import OperationNotAllowed +from falcon.errors import PayloadTypeError +from falcon.errors import UnsupportedError +from falcon.errors import UnsupportedScopeError +from falcon.errors import WebSocketDisconnected +from falcon.errors import WebSocketHandlerNotFound +from falcon.errors import WebSocketPathNotFound +from falcon.errors import WebSocketServerError from falcon.hooks import after from falcon.hooks import before from falcon.http_error import HTTPError @@ -47,13 +404,177 @@ from falcon.request import RequestOptions from falcon.response import Response from falcon.response import ResponseOptions -from falcon.status_codes import * + +# Hoist HTTP status codes. +from falcon.status_codes import HTTP_100 +from falcon.status_codes import HTTP_101 +from falcon.status_codes import HTTP_102 +from falcon.status_codes import HTTP_200 +from falcon.status_codes import HTTP_201 +from falcon.status_codes import HTTP_202 +from falcon.status_codes import HTTP_203 +from falcon.status_codes import HTTP_204 +from falcon.status_codes import HTTP_205 +from falcon.status_codes import HTTP_206 +from falcon.status_codes import HTTP_207 +from falcon.status_codes import HTTP_208 +from falcon.status_codes import HTTP_226 +from falcon.status_codes import HTTP_300 +from falcon.status_codes import HTTP_301 +from falcon.status_codes import HTTP_302 +from falcon.status_codes import HTTP_303 +from falcon.status_codes import HTTP_304 +from falcon.status_codes import HTTP_305 +from falcon.status_codes import HTTP_307 +from falcon.status_codes import HTTP_308 +from falcon.status_codes import HTTP_400 +from falcon.status_codes import HTTP_401 +from falcon.status_codes import HTTP_402 +from falcon.status_codes import HTTP_403 +from falcon.status_codes import HTTP_404 +from falcon.status_codes import HTTP_405 +from falcon.status_codes import HTTP_406 +from falcon.status_codes import HTTP_407 +from falcon.status_codes import HTTP_408 +from falcon.status_codes import HTTP_409 +from falcon.status_codes import HTTP_410 +from falcon.status_codes import HTTP_411 +from falcon.status_codes import HTTP_412 +from falcon.status_codes import HTTP_413 +from falcon.status_codes import HTTP_414 +from falcon.status_codes import HTTP_415 +from falcon.status_codes import HTTP_416 +from falcon.status_codes import HTTP_417 +from falcon.status_codes import HTTP_418 +from falcon.status_codes import HTTP_422 +from falcon.status_codes import HTTP_423 +from falcon.status_codes import HTTP_424 +from falcon.status_codes import HTTP_426 +from falcon.status_codes import HTTP_428 +from falcon.status_codes import HTTP_429 +from falcon.status_codes import HTTP_431 +from falcon.status_codes import HTTP_451 +from falcon.status_codes import HTTP_500 +from falcon.status_codes import HTTP_501 +from falcon.status_codes import HTTP_502 +from falcon.status_codes import HTTP_503 +from falcon.status_codes import HTTP_504 +from falcon.status_codes import HTTP_505 +from falcon.status_codes import HTTP_507 +from falcon.status_codes import HTTP_508 +from falcon.status_codes import HTTP_511 +from falcon.status_codes import HTTP_701 +from falcon.status_codes import HTTP_702 +from falcon.status_codes import HTTP_703 +from falcon.status_codes import HTTP_710 +from falcon.status_codes import HTTP_711 +from falcon.status_codes import HTTP_712 +from falcon.status_codes import HTTP_719 +from falcon.status_codes import HTTP_720 +from falcon.status_codes import HTTP_721 +from falcon.status_codes import HTTP_722 +from falcon.status_codes import HTTP_723 +from falcon.status_codes import HTTP_724 +from falcon.status_codes import HTTP_725 +from falcon.status_codes import HTTP_726 +from falcon.status_codes import HTTP_727 +from falcon.status_codes import HTTP_740 +from falcon.status_codes import HTTP_741 +from falcon.status_codes import HTTP_742 +from falcon.status_codes import HTTP_743 +from falcon.status_codes import HTTP_744 +from falcon.status_codes import HTTP_745 +from falcon.status_codes import HTTP_748 +from falcon.status_codes import HTTP_749 +from falcon.status_codes import HTTP_750 +from falcon.status_codes import HTTP_753 +from falcon.status_codes import HTTP_754 +from falcon.status_codes import HTTP_755 +from falcon.status_codes import HTTP_759 +from falcon.status_codes import HTTP_771 +from falcon.status_codes import HTTP_772 +from falcon.status_codes import HTTP_773 +from falcon.status_codes import HTTP_774 +from falcon.status_codes import HTTP_776 +from falcon.status_codes import HTTP_777 +from falcon.status_codes import HTTP_778 +from falcon.status_codes import HTTP_779 +from falcon.status_codes import HTTP_780 +from falcon.status_codes import HTTP_781 +from falcon.status_codes import HTTP_782 +from falcon.status_codes import HTTP_783 +from falcon.status_codes import HTTP_784 +from falcon.status_codes import HTTP_785 +from falcon.status_codes import HTTP_786 +from falcon.status_codes import HTTP_791 +from falcon.status_codes import HTTP_792 +from falcon.status_codes import HTTP_797 +from falcon.status_codes import HTTP_799 +from falcon.status_codes import HTTP_ACCEPTED +from falcon.status_codes import HTTP_ALREADY_REPORTED +from falcon.status_codes import HTTP_BAD_GATEWAY +from falcon.status_codes import HTTP_BAD_REQUEST +from falcon.status_codes import HTTP_CONFLICT +from falcon.status_codes import HTTP_CONTINUE +from falcon.status_codes import HTTP_CREATED +from falcon.status_codes import HTTP_EXPECTATION_FAILED +from falcon.status_codes import HTTP_FAILED_DEPENDENCY +from falcon.status_codes import HTTP_FORBIDDEN +from falcon.status_codes import HTTP_FOUND +from falcon.status_codes import HTTP_GATEWAY_TIMEOUT +from falcon.status_codes import HTTP_GONE +from falcon.status_codes import HTTP_HTTP_VERSION_NOT_SUPPORTED +from falcon.status_codes import HTTP_IM_A_TEAPOT +from falcon.status_codes import HTTP_IM_USED +from falcon.status_codes import HTTP_INSUFFICIENT_STORAGE +from falcon.status_codes import HTTP_INTERNAL_SERVER_ERROR +from falcon.status_codes import HTTP_LENGTH_REQUIRED +from falcon.status_codes import HTTP_LOCKED +from falcon.status_codes import HTTP_LOOP_DETECTED +from falcon.status_codes import HTTP_METHOD_NOT_ALLOWED +from falcon.status_codes import HTTP_MOVED_PERMANENTLY +from falcon.status_codes import HTTP_MULTI_STATUS +from falcon.status_codes import HTTP_MULTIPLE_CHOICES +from falcon.status_codes import HTTP_NETWORK_AUTHENTICATION_REQUIRED +from falcon.status_codes import HTTP_NO_CONTENT +from falcon.status_codes import HTTP_NON_AUTHORITATIVE_INFORMATION +from falcon.status_codes import HTTP_NOT_ACCEPTABLE +from falcon.status_codes import HTTP_NOT_FOUND +from falcon.status_codes import HTTP_NOT_IMPLEMENTED +from falcon.status_codes import HTTP_NOT_MODIFIED +from falcon.status_codes import HTTP_OK +from falcon.status_codes import HTTP_PARTIAL_CONTENT +from falcon.status_codes import HTTP_PAYMENT_REQUIRED +from falcon.status_codes import HTTP_PERMANENT_REDIRECT +from falcon.status_codes import HTTP_PRECONDITION_FAILED +from falcon.status_codes import HTTP_PRECONDITION_REQUIRED +from falcon.status_codes import HTTP_PROCESSING +from falcon.status_codes import HTTP_PROXY_AUTHENTICATION_REQUIRED +from falcon.status_codes import HTTP_REQUEST_ENTITY_TOO_LARGE +from falcon.status_codes import HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE +from falcon.status_codes import HTTP_REQUEST_TIMEOUT +from falcon.status_codes import HTTP_REQUEST_URI_TOO_LONG +from falcon.status_codes import HTTP_REQUESTED_RANGE_NOT_SATISFIABLE +from falcon.status_codes import HTTP_RESET_CONTENT +from falcon.status_codes import HTTP_SEE_OTHER +from falcon.status_codes import HTTP_SERVICE_UNAVAILABLE +from falcon.status_codes import HTTP_SWITCHING_PROTOCOLS +from falcon.status_codes import HTTP_TEMPORARY_REDIRECT +from falcon.status_codes import HTTP_TOO_MANY_REQUESTS +from falcon.status_codes import HTTP_UNAUTHORIZED +from falcon.status_codes import HTTP_UNAVAILABLE_FOR_LEGAL_REASONS +from falcon.status_codes import HTTP_UNPROCESSABLE_ENTITY +from falcon.status_codes import HTTP_UNSUPPORTED_MEDIA_TYPE +from falcon.status_codes import HTTP_UPGRADE_REQUIRED +from falcon.status_codes import HTTP_USE_PROXY from falcon.stream import BoundedStream # NOTE(kgriffs): Ensure that "from falcon import uri" will import # the same front-door module as "import falcon.uri". This works by # priming the import cache with the one we want. -import falcon.uri +import falcon.uri # NOQA: F401 + +# Hoist utilities. from falcon.util import async_to_sync from falcon.util import BufferedReader from falcon.util import CaseInsensitiveDict @@ -84,14 +605,16 @@ from falcon.util import structures from falcon.util import sync from falcon.util import sync_to_async -from falcon.util import sys +from falcon.util import sys # NOQA: F401 from falcon.util import time from falcon.util import TimezoneGMT from falcon.util import to_query_str from falcon.util import uri from falcon.util import wrap_sync_to_async from falcon.util import wrap_sync_to_async_unsafe -from falcon.version import __version__ + +# Package version +from falcon.version import __version__ # NOQA: F401 # NOTE(kgriffs): Only to be used internally on the rare occasion that we # need to log something that we can't communicate any other way. diff --git a/falcon/app.py b/falcon/app.py index f71a291f6..53cffe397 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -674,7 +674,7 @@ def add_static_route( self._static_routes.insert(0, (sr, sr, False)) self._update_sink_and_static_routes() - def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/'): + def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/') -> None: """Register a sink method for the App. If no route matches a request, but the path in the requested URI diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index 1f0730f86..db6d7cc24 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -14,6 +14,8 @@ """Utilities for the App class.""" +from __future__ import annotations + from inspect import iscoroutinefunction from typing import IO, Iterable, List, Tuple @@ -202,7 +204,7 @@ def prepare_middleware_ws(middleware: Iterable) -> Tuple[list, list]: return request_mw, resource_mw -def default_serialize_error(req: Request, resp: Response, exception: HTTPError): +def default_serialize_error(req: Request, resp: Response, exception: HTTPError) -> None: """Serialize the given instance of HTTPError. This function determines which of the supported media types, if @@ -281,14 +283,14 @@ class CloseableStreamIterator: block_size (int): Number of bytes to read per iteration. """ - def __init__(self, stream: IO, block_size: int): + def __init__(self, stream: IO, block_size: int) -> None: self._stream = stream self._block_size = block_size - def __iter__(self): + def __iter__(self) -> CloseableStreamIterator: return self - def __next__(self): + def __next__(self) -> bytes: data = self._stream.read(self._block_size) if data == b'': @@ -296,7 +298,7 @@ def __next__(self): else: return data - def close(self): + def close(self) -> None: try: self._stream.close() except (AttributeError, TypeError): diff --git a/falcon/asgi/__init__.py b/falcon/asgi/__init__.py index 09c9ce2bd..f77f73f3b 100644 --- a/falcon/asgi/__init__.py +++ b/falcon/asgi/__init__.py @@ -33,3 +33,13 @@ from .structures import SSEvent from .ws import WebSocket from .ws import WebSocketOptions + +__all__ = ( + 'App', + 'BoundedStream', + 'Request', + 'Response', + 'SSEvent', + 'WebSocket', + 'WebSocketOptions', +) diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 328d3a0d6..2483bd1e1 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -51,7 +51,7 @@ from .ws import WebSocket from .ws import WebSocketOptions -__all__ = ['App'] +__all__ = ('App',) # TODO(vytas): Clean up these foul workarounds before the 4.0 release. diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py index 4301fc4e6..d77e5b3b1 100644 --- a/falcon/asgi/request.py +++ b/falcon/asgi/request.py @@ -25,7 +25,7 @@ from . import _request_helpers as asgi_helpers from .stream import BoundedStream -__all__ = ['Request'] +__all__ = ('Request',) _SINGLETON_HEADERS_BYTESTR = frozenset([h.encode() for h in SINGLETON_HEADERS]) diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py index 2ec33e95a..44e52d85a 100644 --- a/falcon/asgi/response.py +++ b/falcon/asgi/response.py @@ -22,7 +22,7 @@ from falcon.util.misc import _encode_items_to_latin1 from falcon.util.misc import is_python_func -__all__ = ['Response'] +__all__ = ('Response',) class Response(response.Response): diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py index e46179dd0..bd532feab 100644 --- a/falcon/asgi/stream.py +++ b/falcon/asgi/stream.py @@ -16,7 +16,7 @@ from falcon.errors import OperationNotAllowed -__all__ = ['BoundedStream'] +__all__ = ('BoundedStream',) class BoundedStream: diff --git a/falcon/asgi/structures.py b/falcon/asgi/structures.py index a1e9dda45..a1b6828db 100644 --- a/falcon/asgi/structures.py +++ b/falcon/asgi/structures.py @@ -1,7 +1,10 @@ +from typing import Optional + from falcon.constants import MEDIA_JSON from falcon.media.json import _DEFAULT_JSON_HANDLER +from falcon.typing import JSONSerializable -__all__ = ['SSEvent'] +__all__ = ('SSEvent',) class SSEvent: @@ -71,14 +74,14 @@ class SSEvent: def __init__( self, - data=None, - text=None, - json=None, - event=None, - event_id=None, - retry=None, - comment=None, - ): + data: Optional[bytes] = None, + text: Optional[str] = None, + json: JSONSerializable = None, + event: Optional[str] = None, + event_id: Optional[str] = None, + retry: Optional[int] = None, + comment: Optional[str] = None, + ) -> None: # NOTE(kgriffs): Check up front since this makes it a lot easier # to debug the source of the problem in the app vs. waiting for # an error to be raised from the framework when it calls serialize() @@ -111,7 +114,7 @@ def __init__( self.comment = comment - def serialize(self, handler=None): + def serialize(self, handler=None) -> bytes: """Serialize this event to string. Args: diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 63ff3d318..bb7db11cc 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -22,7 +22,7 @@ _WebSocketState = Enum('_WebSocketState', 'HANDSHAKE ACCEPTED CLOSED') -__all__ = ['WebSocket'] +__all__ = ('WebSocket',) class WebSocket: diff --git a/falcon/asgi_spec.py b/falcon/asgi_spec.py index 5e80a7241..bca282b37 100644 --- a/falcon/asgi_spec.py +++ b/falcon/asgi_spec.py @@ -14,6 +14,8 @@ """Constants, etc. defined by the ASGI specification.""" +from __future__ import annotations + class EventType: """Standard ASGI event type strings.""" diff --git a/falcon/bench/nuts/nuts/tests/__init__.py b/falcon/bench/nuts/nuts/tests/__init__.py index 7d74cffd7..e9d73c571 100644 --- a/falcon/bench/nuts/nuts/tests/__init__.py +++ b/falcon/bench/nuts/nuts/tests/__init__.py @@ -3,7 +3,7 @@ from pecan import set_config from pecan.testing import load_test_app -__all__ = ['FunctionalTest'] +__all__ = ('FunctionalTest',) class FunctionalTest(TestCase): diff --git a/falcon/constants.py b/falcon/constants.py index 2bf1252a4..0f706af14 100644 --- a/falcon/constants.py +++ b/falcon/constants.py @@ -2,18 +2,40 @@ import os import sys +__all__ = ( + 'HTTP_METHODS', + 'WEBDAV_METHODS', + 'COMBINED_METHODS', + 'DEFAULT_MEDIA_TYPE', + 'MEDIA_BMP', + 'MEDIA_GIF', + 'MEDIA_HTML', + 'MEDIA_JPEG', + 'MEDIA_JS', + 'MEDIA_JSON', + 'MEDIA_MSGPACK', + 'MEDIA_MULTIPART', + 'MEDIA_PNG', + 'MEDIA_TEXT', + 'MEDIA_URLENCODED', + 'MEDIA_XML', + 'MEDIA_YAML', + 'SINGLETON_HEADERS', + 'WebSocketPayloadType', +) + PYPY = sys.implementation.name == 'pypy' """Evaluates to ``True`` when the current Python implementation is PyPy.""" PYTHON_VERSION = tuple(sys.version_info[:3]) """Python version information triplet: (major, minor, micro).""" -FALCON_SUPPORTED = PYTHON_VERSION >= (3, 7, 0) +FALCON_SUPPORTED = PYTHON_VERSION >= (3, 8, 0) """Whether this version of Falcon supports the current Python version.""" if not FALCON_SUPPORTED: # pragma: nocover raise ImportError( - 'Falcon requires Python 3.7+. ' + 'Falcon requires Python 3.8+. ' '(Recent Pip should automatically pick a suitable Falcon version.)' ) diff --git a/falcon/cyutil/misc.pyx b/falcon/cyutil/misc.pyx index f4e2b1229..b7962aa62 100644 --- a/falcon/cyutil/misc.pyx +++ b/falcon/cyutil/misc.pyx @@ -13,33 +13,6 @@ # limitations under the License. -def isascii(unicode string not None): - """Return ``True`` if all characters in the string are ASCII. - - ASCII characters have code points in the range U+0000-U+007F. - - Note: - On Python 3.7+, this function is just aliased to ``str.isascii``. - - This is a Cython fallback for older CPython versions. For longer strings, - it is slightly less performant than the built-in ``str.isascii``. - - Args: - string (str): A string to test. - - Returns: - ``True`` if all characters are ASCII, ``False`` otherwise. - """ - - cdef Py_UCS4 ch - - for ch in string: - if ch > 0x007F: - return False - - return True - - def encode_items_to_latin1(dict data not None): cdef list result = [] cdef unicode key diff --git a/falcon/errors.py b/falcon/errors.py index 74a50c69c..afc1faa8a 100644 --- a/falcon/errors.py +++ b/falcon/errors.py @@ -34,14 +34,21 @@ def on_get(self, req, resp): # -- snip -- """ +from __future__ import annotations + from datetime import datetime -from typing import Optional +from typing import Iterable, Optional, TYPE_CHECKING, Union from falcon.http_error import HTTPError import falcon.status_codes as status from falcon.util.deprecation import deprecated_args from falcon.util.misc import dt_to_http +if TYPE_CHECKING: + from falcon.typing import HeaderList + from falcon.typing import Headers + + __all__ = ( 'CompatibilityError', 'DelimiterError', @@ -142,7 +149,7 @@ class WebSocketDisconnected(ConnectionError): code (int): The WebSocket close code, as per the WebSocket spec. """ - def __init__(self, code: Optional[int] = None): + def __init__(self, code: Optional[int] = None) -> None: self.code = code or 1000 # Default to "Normal Closure" @@ -168,6 +175,10 @@ class WebSocketServerError(WebSocketDisconnected): pass +HTTPErrorKeywordArguments = Union[str, int, None] +RetryAfter = Union[int, datetime, None] + + class HTTPBadRequest(HTTPError): """400 Bad Request. @@ -214,7 +225,13 @@ class HTTPBadRequest(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ) -> None: super().__init__( status.HTTP_400, title=title, @@ -292,7 +309,12 @@ class HTTPUnauthorized(HTTPError): @deprecated_args(allowed_positional=0) def __init__( - self, title=None, description=None, headers=None, challenges=None, **kwargs + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + challenges: Optional[Iterable[str]] = None, + **kwargs: HTTPErrorKeywordArguments, ): if challenges: headers = _load_headers(headers) @@ -364,7 +386,13 @@ class HTTPForbidden(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_403, title=title, @@ -429,7 +457,13 @@ class HTTPNotFound(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_404, title=title, @@ -550,7 +584,12 @@ class HTTPMethodNotAllowed(HTTPError): @deprecated_args(allowed_positional=1) def __init__( - self, allowed_methods, title=None, description=None, headers=None, **kwargs + self, + allowed_methods: Iterable[str], + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, ): headers = _load_headers(headers) headers['Allow'] = ', '.join(allowed_methods) @@ -616,7 +655,13 @@ class HTTPNotAcceptable(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_406, title=title, @@ -683,7 +728,13 @@ class HTTPConflict(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_409, title=title, @@ -756,7 +807,13 @@ class HTTPGone(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_410, title=title, @@ -814,7 +871,13 @@ class HTTPLengthRequired(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_411, title=title, @@ -873,7 +936,13 @@ class HTTPPreconditionFailed(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_412, title=title, @@ -943,8 +1012,13 @@ class HTTPPayloadTooLarge(HTTPError): @deprecated_args(allowed_positional=0) def __init__( - self, title=None, description=None, retry_after=None, headers=None, **kwargs - ): + self, + title: Optional[str] = None, + description: Optional[str] = None, + retry_after: RetryAfter = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ) -> None: super().__init__( status.HTTP_413, title=title, @@ -1008,7 +1082,13 @@ class HTTPUriTooLong(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_414, title=title, @@ -1067,7 +1147,13 @@ class HTTPUnsupportedMediaType(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_415, title=title, @@ -1140,7 +1226,12 @@ class HTTPRangeNotSatisfiable(HTTPError): @deprecated_args(allowed_positional=1) def __init__( - self, resource_length, title=None, description=None, headers=None, **kwargs + self, + resource_length: int, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, ): headers = _load_headers(headers) headers['Content-Range'] = 'bytes */' + str(resource_length) @@ -1205,7 +1296,13 @@ class HTTPUnprocessableEntity(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_422, title=title, @@ -1261,7 +1358,13 @@ class HTTPLocked(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_423, title=title, @@ -1316,7 +1419,13 @@ class HTTPFailedDependency(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_424, title=title, @@ -1379,7 +1488,13 @@ class HTTPPreconditionRequired(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_428, title=title, @@ -1448,7 +1563,12 @@ class HTTPTooManyRequests(HTTPError): @deprecated_args(allowed_positional=0) def __init__( - self, title=None, description=None, headers=None, retry_after=None, **kwargs + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + retry_after: RetryAfter = None, + **kwargs: HTTPErrorKeywordArguments, ): super().__init__( status.HTTP_429, @@ -1511,7 +1631,13 @@ class HTTPRequestHeaderFieldsTooLarge(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_431, title=title, @@ -1580,7 +1706,13 @@ class HTTPUnavailableForLegalReasons(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_451, title=title, @@ -1635,7 +1767,13 @@ class HTTPInternalServerError(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_500, title=title, @@ -1697,7 +1835,13 @@ class HTTPNotImplemented(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_501, title=title, @@ -1752,7 +1896,13 @@ class HTTPBadGateway(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_502, title=title, @@ -1824,7 +1974,12 @@ class HTTPServiceUnavailable(HTTPError): @deprecated_args(allowed_positional=0) def __init__( - self, title=None, description=None, headers=None, retry_after=None, **kwargs + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + retry_after: RetryAfter = None, + **kwargs: HTTPErrorKeywordArguments, ): super().__init__( status.HTTP_503, @@ -1881,7 +2036,13 @@ class HTTPGatewayTimeout(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_504, title=title, @@ -1942,7 +2103,13 @@ class HTTPVersionNotSupported(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_505, title=title, @@ -2001,7 +2168,13 @@ class HTTPInsufficientStorage(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_507, title=title, @@ -2057,7 +2230,13 @@ class HTTPLoopDetected(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_508, title=title, @@ -2125,7 +2304,13 @@ class HTTPNetworkAuthenticationRequired(HTTPError): """ @deprecated_args(allowed_positional=0) - def __init__(self, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): super().__init__( status.HTTP_511, title=title, @@ -2178,7 +2363,13 @@ class HTTPInvalidHeader(HTTPBadRequest): """ @deprecated_args(allowed_positional=2) - def __init__(self, msg, header_name, headers=None, **kwargs): + def __init__( + self, + msg: str, + header_name: str, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): description = 'The value provided for the "{0}" header is invalid. {1}' description = description.format(header_name, msg) @@ -2232,7 +2423,12 @@ class HTTPMissingHeader(HTTPBadRequest): """ @deprecated_args(allowed_positional=1) - def __init__(self, header_name, headers=None, **kwargs): + def __init__( + self, + header_name: str, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ): description = 'The "{0}" header is required.' description = description.format(header_name) @@ -2289,7 +2485,13 @@ class HTTPInvalidParam(HTTPBadRequest): """ @deprecated_args(allowed_positional=2) - def __init__(self, msg, param_name, headers=None, **kwargs): + def __init__( + self, + msg: str, + param_name: str, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ) -> None: description = 'The "{0}" parameter is invalid. {1}' description = description.format(param_name, msg) @@ -2345,7 +2547,12 @@ class HTTPMissingParam(HTTPBadRequest): """ @deprecated_args(allowed_positional=1) - def __init__(self, param_name, headers=None, **kwargs): + def __init__( + self, + param_name: str, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ) -> None: description = 'The "{0}" parameter is required.' description = description.format(param_name) @@ -2396,7 +2603,7 @@ class MediaNotFoundError(HTTPBadRequest): base articles related to this error (default ``None``). """ - def __init__(self, media_type, **kwargs): + def __init__(self, media_type: str, **kwargs: HTTPErrorKeywordArguments) -> None: super().__init__( title='Invalid {0}'.format(media_type), description='Could not parse an empty {0} body'.format(media_type), @@ -2441,21 +2648,23 @@ class MediaMalformedError(HTTPBadRequest): base articles related to this error (default ``None``). """ - def __init__(self, media_type, **kwargs): + def __init__( + self, media_type: str, **kwargs: Union[HeaderList, HTTPErrorKeywordArguments] + ): super().__init__( title='Invalid {0}'.format(media_type), description=None, **kwargs ) self._media_type = media_type @property - def description(self): + def description(self) -> Optional[str]: msg = 'Could not parse {} body'.format(self._media_type) if self.__cause__ is not None: msg += ' - {}'.format(self.__cause__) return msg @description.setter - def description(self, value): + def description(self, value: str) -> None: pass @@ -2504,7 +2713,14 @@ class MediaValidationError(HTTPBadRequest): base articles related to this error (default ``None``). """ - def __init__(self, *, title=None, description=None, headers=None, **kwargs): + def __init__( + self, + *, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + **kwargs: HTTPErrorKeywordArguments, + ) -> None: super().__init__( title=title, description=description, @@ -2534,7 +2750,11 @@ class MultipartParseError(MediaMalformedError): description = None @deprecated_args(allowed_positional=0) - def __init__(self, description=None, **kwargs): + def __init__( + self, + description: Optional[str] = None, + **kwargs: Union[HeaderList, HTTPErrorKeywordArguments], + ) -> None: HTTPBadRequest.__init__( self, title='Malformed multipart/form-data request media', @@ -2548,7 +2768,7 @@ def __init__(self, description=None, **kwargs): # ----------------------------------------------------------------------------- -def _load_headers(headers): +def _load_headers(headers: Optional[HeaderList]) -> Headers: """Transform the headers to dict.""" if headers is None: return {} @@ -2557,7 +2777,10 @@ def _load_headers(headers): return dict(headers) -def _parse_retry_after(headers, retry_after): +def _parse_retry_after( + headers: Optional[HeaderList], + retry_after: RetryAfter, +) -> Optional[HeaderList]: """Set the Retry-After to the headers when required.""" if retry_after is None: return headers diff --git a/falcon/forwarded.py b/falcon/forwarded.py index 9fe3c03f8..f855b3661 100644 --- a/falcon/forwarded.py +++ b/falcon/forwarded.py @@ -16,9 +16,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations import re import string +from typing import List, Optional from falcon.util.uri import unquote_string @@ -75,14 +77,14 @@ class Forwarded: # falcon.Request interface. __slots__ = ('src', 'dest', 'host', 'scheme') - def __init__(self): - self.src = None - self.dest = None - self.host = None - self.scheme = None + def __init__(self) -> None: + self.src: Optional[str] = None + self.dest: Optional[str] = None + self.host: Optional[str] = None + self.scheme: Optional[str] = None -def _parse_forwarded_header(forwarded): +def _parse_forwarded_header(forwarded: str) -> List[Forwarded]: """Parse the value of a Forwarded header. Makes an effort to parse Forwarded headers as specified by RFC 7239: diff --git a/falcon/hooks.py b/falcon/hooks.py index f4bb7afae..5ca50aefb 100644 --- a/falcon/hooks.py +++ b/falcon/hooks.py @@ -14,21 +14,35 @@ """Hook decorators.""" +from __future__ import annotations + from functools import wraps from inspect import getmembers from inspect import iscoroutinefunction import re +import typing as t from falcon.constants import COMBINED_METHODS from falcon.util.misc import get_argnames from falcon.util.sync import _wrap_non_coroutine_unsafe +if t.TYPE_CHECKING: # pragma: no cover + import falcon as wsgi + from falcon import asgi + _DECORABLE_METHOD_NAME = re.compile( r'^on_({})(_\w+)?$'.format('|'.join(method.lower() for method in COMBINED_METHODS)) ) +Resource = object +Responder = t.Callable +ResponderOrResource = t.Union[Responder, Resource] +Action = t.Callable -def before(action, *args, is_async=False, **kwargs): + +def before( + action: Action, *args: t.Any, is_async: bool = False, **kwargs: t.Any +) -> t.Callable[[ResponderOrResource], ResponderOrResource]: """Execute the given action function *before* the responder. The `params` argument that is passed to the hook @@ -78,7 +92,7 @@ def do_something(req, resp, resource, params): *action*. """ - def _before(responder_or_resource): + def _before(responder_or_resource: ResponderOrResource) -> ResponderOrResource: if isinstance(responder_or_resource, type): resource = responder_or_resource @@ -88,7 +102,9 @@ def _before(responder_or_resource): # responder in the do_before_all closure; otherwise, they # will capture the same responder variable that is shared # between iterations of the for loop, above. - def let(responder=responder): + responder = t.cast(Responder, responder) + + def let(responder: Responder = responder) -> None: do_before_all = _wrap_with_before( responder, action, args, kwargs, is_async ) @@ -100,7 +116,7 @@ def let(responder=responder): return resource else: - responder = responder_or_resource + responder = t.cast(Responder, responder_or_resource) do_before_one = _wrap_with_before(responder, action, args, kwargs, is_async) return do_before_one @@ -108,7 +124,9 @@ def let(responder=responder): return _before -def after(action, *args, is_async=False, **kwargs): +def after( + action: Action, *args: t.Any, is_async: bool = False, **kwargs: t.Any +) -> t.Callable[[ResponderOrResource], ResponderOrResource]: """Execute the given action function *after* the responder. Args: @@ -141,14 +159,15 @@ def after(action, *args, is_async=False, **kwargs): *action*. """ - def _after(responder_or_resource): + def _after(responder_or_resource: ResponderOrResource) -> ResponderOrResource: if isinstance(responder_or_resource, type): - resource = responder_or_resource + resource = t.cast(Resource, responder_or_resource) for responder_name, responder in getmembers(resource, callable): if _DECORABLE_METHOD_NAME.match(responder_name): + responder = t.cast(Responder, responder) - def let(responder=responder): + def let(responder: Responder = responder) -> None: do_after_all = _wrap_with_after( responder, action, args, kwargs, is_async ) @@ -160,7 +179,7 @@ def let(responder=responder): return resource else: - responder = responder_or_resource + responder = t.cast(Responder, responder_or_resource) do_after_one = _wrap_with_after(responder, action, args, kwargs, is_async) return do_after_one @@ -173,7 +192,13 @@ def let(responder=responder): # ----------------------------------------------------------------------------- -def _wrap_with_after(responder, action, action_args, action_kwargs, is_async): +def _wrap_with_after( + responder: Responder, + action: Action, + action_args: t.Any, + action_kwargs: t.Any, + is_async: bool, +) -> Responder: """Execute the given action function after a responder method. Args: @@ -196,20 +221,35 @@ def _wrap_with_after(responder, action, action_args, action_kwargs, is_async): # is actually covered, but coverage isn't tracking it for # some reason. if not is_async: # pragma: nocover - action = _wrap_non_coroutine_unsafe(action) + async_action = _wrap_non_coroutine_unsafe(action) + else: + async_action = action @wraps(responder) - async def do_after(self, req, resp, *args, **kwargs): + async def do_after( + self: ResponderOrResource, + req: asgi.Request, + resp: asgi.Response, + *args: t.Any, + **kwargs: t.Any, + ) -> None: if args: _merge_responder_args(args, kwargs, extra_argnames) await responder(self, req, resp, **kwargs) - await action(req, resp, self, *action_args, **action_kwargs) + assert async_action + await async_action(req, resp, self, *action_args, **action_kwargs) else: @wraps(responder) - def do_after(self, req, resp, *args, **kwargs): + def do_after( + self: ResponderOrResource, + req: wsgi.Request, + resp: wsgi.Response, + *args: t.Any, + **kwargs: t.Any, + ) -> None: if args: _merge_responder_args(args, kwargs, extra_argnames) @@ -219,7 +259,13 @@ def do_after(self, req, resp, *args, **kwargs): return do_after -def _wrap_with_before(responder, action, action_args, action_kwargs, is_async): +def _wrap_with_before( + responder: Responder, + action: Action, + action_args: t.Tuple[t.Any, ...], + action_kwargs: t.Dict[str, t.Any], + is_async: bool, +) -> t.Union[t.Callable[..., t.Awaitable[None]], t.Callable[..., None]]: """Execute the given action function before a responder method. Args: @@ -242,20 +288,35 @@ def _wrap_with_before(responder, action, action_args, action_kwargs, is_async): # is actually covered, but coverage isn't tracking it for # some reason. if not is_async: # pragma: nocover - action = _wrap_non_coroutine_unsafe(action) + async_action = _wrap_non_coroutine_unsafe(action) + else: + async_action = action @wraps(responder) - async def do_before(self, req, resp, *args, **kwargs): + async def do_before( + self: ResponderOrResource, + req: asgi.Request, + resp: asgi.Response, + *args: t.Any, + **kwargs: t.Any, + ) -> None: if args: _merge_responder_args(args, kwargs, extra_argnames) - await action(req, resp, self, kwargs, *action_args, **action_kwargs) + assert async_action + await async_action(req, resp, self, kwargs, *action_args, **action_kwargs) await responder(self, req, resp, **kwargs) else: @wraps(responder) - def do_before(self, req, resp, *args, **kwargs): + def do_before( + self: ResponderOrResource, + req: wsgi.Request, + resp: wsgi.Response, + *args: t.Any, + **kwargs: t.Any, + ) -> None: if args: _merge_responder_args(args, kwargs, extra_argnames) @@ -265,7 +326,9 @@ def do_before(self, req, resp, *args, **kwargs): return do_before -def _merge_responder_args(args, kwargs, argnames): +def _merge_responder_args( + args: t.Tuple[t.Any, ...], kwargs: t.Dict[str, t.Any], argnames: t.List[str] +) -> None: """Merge responder args into kwargs. The framework always passes extra args as keyword arguments. diff --git a/falcon/http_error.py b/falcon/http_error.py index 20c221fe3..3f4af635c 100644 --- a/falcon/http_error.py +++ b/falcon/http_error.py @@ -11,10 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - """HTTPError exception class.""" +from __future__ import annotations + from collections import OrderedDict +from typing import MutableMapping, Optional, Type, TYPE_CHECKING, Union import xml.etree.ElementTree as et from falcon.constants import MEDIA_JSON @@ -23,6 +25,12 @@ from falcon.util import uri from falcon.util.deprecation import deprecated_args +if TYPE_CHECKING: + from falcon.media import BaseHandler + from falcon.typing import HeaderList + from falcon.typing import Link + from falcon.typing import ResponseStatus + class HTTPError(Exception): """Represents a generic HTTP error. @@ -112,13 +120,13 @@ class HTTPError(Exception): @deprecated_args(allowed_positional=1) def __init__( self, - status, - title=None, - description=None, - headers=None, - href=None, - href_text=None, - code=None, + status: ResponseStatus, + title: Optional[str] = None, + description: Optional[str] = None, + headers: Optional[HeaderList] = None, + href: Optional[str] = None, + href_text: Optional[str] = None, + code: Optional[int] = None, ): self.status = status @@ -131,6 +139,7 @@ def __init__( self.description = description self.headers = headers self.code = code + self.link: Optional[Link] if href: link = self.link = OrderedDict() @@ -140,7 +149,7 @@ def __init__( else: self.link = None - def __repr__(self): + def __repr__(self) -> str: return '<%s: %s>' % (self.__class__.__name__, self.status) __str__ = __repr__ @@ -149,7 +158,9 @@ def __repr__(self): def status_code(self) -> int: return http_status_to_code(self.status) - def to_dict(self, obj_type=dict): + def to_dict( + self, obj_type: Type[MutableMapping[str, Union[str, int, None, Link]]] = dict + ) -> MutableMapping[str, Union[str, int, None, Link]]: """Return a basic dictionary representing the error. This method can be useful when serializing the error to hash-like @@ -180,7 +191,7 @@ def to_dict(self, obj_type=dict): return obj - def to_json(self, handler=None): + def to_json(self, handler: Optional[BaseHandler] = None) -> bytes: """Return a JSON representation of the error. Args: @@ -198,7 +209,7 @@ def to_json(self, handler=None): handler = _DEFAULT_JSON_HANDLER return handler.serialize(obj, MEDIA_JSON) - def to_xml(self): + def to_xml(self) -> bytes: """Return an XML-encoded representation of the error. Returns: @@ -229,4 +240,7 @@ def to_xml(self): # NOTE: initialized in falcon.media.json, that is always imported since Request/Response # are imported by falcon init. -_DEFAULT_JSON_HANDLER = None +if TYPE_CHECKING: + _DEFAULT_JSON_HANDLER: BaseHandler +else: + _DEFAULT_JSON_HANDLER = None diff --git a/falcon/http_status.py b/falcon/http_status.py index d4411391e..df7e0d455 100644 --- a/falcon/http_status.py +++ b/falcon/http_status.py @@ -11,12 +11,19 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - """HTTPStatus exception class.""" +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + from falcon.util import http_status_to_code from falcon.util.deprecation import AttributeRemovedError +if TYPE_CHECKING: + from falcon.typing import HeaderList + from falcon.typing import ResponseStatus + class HTTPStatus(Exception): """Represents a generic HTTP status. @@ -46,7 +53,12 @@ class HTTPStatus(Exception): __slots__ = ('status', 'headers', 'text') - def __init__(self, status, headers=None, text=None): + def __init__( + self, + status: ResponseStatus, + headers: Optional[HeaderList] = None, + text: Optional[str] = None, + ) -> None: self.status = status self.headers = headers self.text = text diff --git a/falcon/inspect.py b/falcon/inspect.py index 919165687..9aac44cb0 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -14,16 +14,30 @@ """Inspect utilities for falcon applications.""" +from __future__ import annotations + from functools import partial import inspect -from typing import Callable, Dict, List, Optional, Type +from typing import ( + Any, + Callable, + cast, + Dict, + Iterable, + List, + Optional, + Tuple, + Type, + Union, +) from falcon import app_helpers from falcon.app import App from falcon.routing import CompiledRouter +from falcon.routing.compiled import CompiledRouterNode -def inspect_app(app: App) -> 'AppInfo': +def inspect_app(app: App) -> AppInfo: """Inspects an application. Args: @@ -43,7 +57,7 @@ def inspect_app(app: App) -> 'AppInfo': return AppInfo(routes, middleware, static, sinks, error_handlers, app._ASGI) -def inspect_routes(app: App) -> 'List[RouteInfo]': +def inspect_routes(app: App) -> List[RouteInfo]: """Inspects the routes of an application. Args: @@ -65,7 +79,9 @@ def inspect_routes(app: App) -> 'List[RouteInfo]': return inspect_function(router) -def register_router(router_class): +def register_router( + router_class: Type, +) -> Callable[..., Callable[..., List[RouteInfo]]]: """Register a function to inspect a particular router. This decorator registers a new function for a custom router @@ -83,7 +99,7 @@ def inspect_my_router(router): already registered an error will be raised. """ - def wraps(fn): + def wraps(fn: Callable[..., List[RouteInfo]]) -> Callable[..., List[RouteInfo]]: if router_class in _supported_routers: raise ValueError( 'Another function is already registered for the router {}'.format( @@ -96,8 +112,7 @@ def wraps(fn): return wraps -# router inspection registry -_supported_routers: Dict[Type, Callable] = {} +_supported_routers: Dict[Type, Callable[..., Any]] = {} def inspect_static_routes(app: App) -> 'List[StaticRouteInfo]': @@ -131,6 +146,7 @@ def inspect_sinks(app: App) -> 'List[SinkInfo]': sinks = [] for prefix, sink, _ in app._sinks: source_info, name = _get_source_info_and_name(sink) + assert source_info info = SinkInfo(prefix.pattern, name, source_info) sinks.append(info) return sinks @@ -150,6 +166,7 @@ def inspect_error_handlers(app: App) -> 'List[ErrorHandlerInfo]': errors = [] for exc, fn in app._error_handlers.items(): source_info, name = _get_source_info_and_name(fn) + assert source_info info = ErrorHandlerInfo(exc.__name__, name, source_info, _is_internal(fn)) errors.append(info) return errors @@ -188,7 +205,9 @@ def inspect_middleware(app: App) -> 'MiddlewareInfo': if method: real_func = method[0] source_info = _get_source_info(real_func) + assert source_info methods.append(MiddlewareMethodInfo(real_func.__name__, source_info)) + assert class_source_info m_info = MiddlewareClassInfo(cls_name, class_source_info, methods) middlewareClasses.append(m_info) @@ -210,7 +229,7 @@ def inspect_compiled_router(router: CompiledRouter) -> 'List[RouteInfo]': List[RouteInfo]: A list of :class:`~.RouteInfo`. """ - def _traverse(roots, parent): + def _traverse(roots: List[CompiledRouterNode], parent: str) -> None: for root in roots: path = parent + '/' + root.raw_segment if root.resource is not None: @@ -224,13 +243,16 @@ def _traverse(roots, parent): source_info = _get_source_info(real_func) internal = _is_internal(real_func) - + assert source_info, ( + 'This is for type checking only, as here source ' + 'info will always be a string' + ) method_info = RouteMethodInfo( method, source_info, real_func.__name__, internal ) methods.append(method_info) source_info, class_name = _get_source_info_and_name(root.resource) - + assert source_info route_info = RouteInfo(path, class_name, source_info, methods) routes.append(route_info) @@ -250,7 +272,7 @@ def _traverse(roots, parent): class _Traversable: __visit_name__ = 'N/A' - def to_string(self, verbose=False, internal=False) -> str: + def to_string(self, verbose: bool = False, internal: bool = False) -> str: """Return a string representation of this class. Args: @@ -264,7 +286,7 @@ def to_string(self, verbose=False, internal=False) -> str: """ return StringVisitor(verbose, internal).process(self) - def __repr__(self): + def __repr__(self) -> str: return self.to_string() @@ -520,7 +542,9 @@ def __init__( self.error_handlers = error_handlers self.asgi = asgi - def to_string(self, verbose=False, internal=False, name='') -> str: + def to_string( + self, verbose: bool = False, internal: bool = False, name: str = '' + ) -> str: """Return a string representation of this class. Args: @@ -546,7 +570,7 @@ class InspectVisitor: Subclasses must implement ``visit_`` methods for each supported class. """ - def process(self, instance: _Traversable): + def process(self, instance: _Traversable) -> str: """Process the instance, by calling the appropriate visit method. Uses the `__visit_name__` attribute of the `instance` to obtain the method @@ -577,14 +601,16 @@ class StringVisitor(InspectVisitor): beginning of the text. Defaults to ``'Falcon App'``. """ - def __init__(self, verbose=False, internal=False, name=''): + def __init__( + self, verbose: bool = False, internal: bool = False, name: str = '' + ) -> None: self.verbose = verbose self.internal = internal self.name = name self.indent = 0 @property - def tab(self): + def tab(self) -> str: """Get the current tabulation.""" return ' ' * self.indent @@ -595,13 +621,15 @@ def visit_route_method(self, route_method: RouteMethodInfo) -> str: text += ' ({0.source_info})'.format(route_method) return text - def _methods_to_string(self, methods: List): + def _methods_to_string( + self, methods: Union[List[RouteMethodInfo], List[MiddlewareMethodInfo]] + ) -> str: """Return a string from the list of methods.""" tab = self.tab + ' ' * 3 - methods = _filter_internal(methods, self.internal) - if not methods: + filtered_methods = _filter_internal(methods, self.internal) + if not filtered_methods: return '' - text_list = [self.process(m) for m in methods] + text_list = [self.process(m) for m in filtered_methods] method_text = ['{}├── {}'.format(tab, m) for m in text_list[:-1]] method_text += ['{}└── {}'.format(tab, m) for m in text_list[-1:]] return '\n'.join(method_text) @@ -751,7 +779,9 @@ def visit_app(self, app: AppInfo) -> str: # ------------------------------------------------------------------------ -def _get_source_info(obj, default='[unknown file]'): +def _get_source_info( + obj: Any, default: Optional[str] = '[unknown file]' +) -> Optional[str]: """Try to get the definition file and line of obj. Return default on error. @@ -765,11 +795,11 @@ def _get_source_info(obj, default='[unknown file]'): # responders coming from cythonized modules will # appear as built-in functions, and raise a # TypeError when trying to locate the source file. - source_info = default + return default return source_info -def _get_source_info_and_name(obj): +def _get_source_info_and_name(obj: Any) -> Tuple[Optional[str], str]: """Attempt to get the definition file and line of obj and its name.""" source_info = _get_source_info(obj, None) if source_info is None: @@ -778,10 +808,11 @@ def _get_source_info_and_name(obj): name = getattr(obj, '__name__', None) if name is None: name = getattr(type(obj), '__name__', '[unknown]') + name = cast(str, name) return source_info, name -def _is_internal(obj): +def _is_internal(obj: Any) -> bool: """Check if the module of the object is a falcon module.""" module = inspect.getmodule(obj) if module: @@ -789,7 +820,14 @@ def _is_internal(obj): return False -def _filter_internal(iterable, return_internal): +def _filter_internal( + iterable: Union[ + Iterable[RouteMethodInfo], + Iterable[ErrorHandlerInfo], + Iterable[MiddlewareMethodInfo], + ], + return_internal: bool, +) -> Union[Iterable[_Traversable], List[_Traversable]]: """Filter the internal elements of an iterable.""" if return_internal: return iterable diff --git a/falcon/media/__init__.py b/falcon/media/__init__.py index a90c0e384..f85547b16 100644 --- a/falcon/media/__init__.py +++ b/falcon/media/__init__.py @@ -10,7 +10,7 @@ from .multipart import MultipartFormHandler from .urlencoded import URLEncodedFormHandler -__all__ = [ +__all__ = ( 'BaseHandler', 'BinaryBaseHandlerWS', 'TextBaseHandlerWS', @@ -22,4 +22,4 @@ 'MissingDependencyHandler', 'MultipartFormHandler', 'URLEncodedFormHandler', -] +) diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index 0186e0aee..68dbbe88b 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -1,11 +1,13 @@ from collections import UserDict import functools +from typing import Mapping from falcon import errors from falcon.constants import MEDIA_JSON from falcon.constants import MEDIA_MULTIPART from falcon.constants import MEDIA_URLENCODED from falcon.constants import PYPY +from falcon.media.base import BaseHandler from falcon.media.base import BinaryBaseHandlerWS from falcon.media.json import JSONHandler from falcon.media.multipart import MultipartFormHandler @@ -38,10 +40,10 @@ def _raise(self, *args, **kwargs): class Handlers(UserDict): """A :class:`dict`-like object that manages Internet media type handlers.""" - def __init__(self, initial=None): + def __init__(self, initial=None) -> None: self._resolve = self._create_resolver() - handlers = initial or { + handlers: Mapping[str, BaseHandler] = initial or { MEDIA_JSON: JSONHandler(), MEDIA_MULTIPART: MultipartFormHandler(), MEDIA_URLENCODED: URLEncodedFormHandler(), diff --git a/falcon/middleware.py b/falcon/middleware.py index a799fe2f8..5772e16c7 100644 --- a/falcon/middleware.py +++ b/falcon/middleware.py @@ -1,4 +1,6 @@ -from typing import Iterable, Optional, Union +from __future__ import annotations + +from typing import Any, Iterable, Optional, Union from .request import Request from .response import Response @@ -73,7 +75,9 @@ def __init__( ) self.allow_credentials = allow_credentials - def process_response(self, req: Request, resp: Response, resource, req_succeeded): + def process_response( + self, req: Request, resp: Response, resource: object, req_succeeded: bool + ) -> None: """Implement the CORS policy for all routes. This middleware provides a simple out-of-the box CORS policy, @@ -120,5 +124,5 @@ def process_response(self, req: Request, resp: Response, resource, req_succeeded resp.set_header('Access-Control-Allow-Headers', allow_headers) resp.set_header('Access-Control-Max-Age', '86400') # 24 hours - async def process_response_async(self, *args): + async def process_response_async(self, *args: Any) -> None: self.process_response(*args) diff --git a/falcon/redirects.py b/falcon/redirects.py index e308a5064..7d2381d47 100644 --- a/falcon/redirects.py +++ b/falcon/redirects.py @@ -11,12 +11,18 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - """HTTPStatus specializations for 3xx redirects.""" +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + import falcon from falcon.http_status import HTTPStatus +if TYPE_CHECKING: + from falcon.typing import Headers + class HTTPMovedPermanently(HTTPStatus): """301 Moved Permanently. @@ -37,7 +43,7 @@ class HTTPMovedPermanently(HTTPStatus): response. """ - def __init__(self, location, headers=None): + def __init__(self, location: str, headers: Optional[Headers] = None) -> None: if headers is None: headers = {} headers.setdefault('location', location) @@ -66,7 +72,7 @@ class HTTPFound(HTTPStatus): response. """ - def __init__(self, location, headers=None): + def __init__(self, location: str, headers: Optional[Headers] = None) -> None: if headers is None: headers = {} headers.setdefault('location', location) @@ -100,7 +106,7 @@ class HTTPSeeOther(HTTPStatus): response. """ - def __init__(self, location, headers=None): + def __init__(self, location: str, headers: Optional[Headers] = None) -> None: if headers is None: headers = {} headers.setdefault('location', location) @@ -129,7 +135,7 @@ class HTTPTemporaryRedirect(HTTPStatus): response. """ - def __init__(self, location, headers=None): + def __init__(self, location: str, headers: Optional[Headers] = None) -> None: if headers is None: headers = {} headers.setdefault('location', location) @@ -155,7 +161,7 @@ class HTTPPermanentRedirect(HTTPStatus): response. """ - def __init__(self, location, headers=None): + def __init__(self, location: str, headers: Optional[Headers] = None) -> None: if headers is None: headers = {} headers.setdefault('location', location) diff --git a/falcon/request.py b/falcon/request.py index cb037369d..cdf0a3ec9 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -12,6 +12,8 @@ """Request class.""" +from __future__ import annotations + from datetime import datetime from io import BytesIO from uuid import UUID @@ -30,7 +32,6 @@ from falcon.media.json import _DEFAULT_JSON_HANDLER from falcon.stream import BoundedStream from falcon.util import structures -from falcon.util.misc import isascii from falcon.util.uri import parse_host from falcon.util.uri import parse_query_string from falcon.vendor import mimeparse @@ -487,7 +488,7 @@ def __init__(self, env, options=None): # perf(vytas): Only decode the tunnelled path in case it is not ASCII. # For ASCII-strings, the below decoding chain is a no-op. - if not isascii(path): + if not path.isascii(): path = path.encode('iso-8859-1').decode('utf-8', 'replace') if ( diff --git a/falcon/response.py b/falcon/response.py index 05f6c2e10..e96f2ba2f 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -14,9 +14,11 @@ """Response class.""" +from __future__ import annotations + import functools import mimetypes -from typing import Optional +from typing import Dict, Optional from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES from falcon.constants import _UNSET @@ -1252,7 +1254,7 @@ class ResponseOptions: secure_cookies_by_default: bool default_media_type: Optional[str] media_handlers: Handlers - static_media_types: dict + static_media_types: Dict[str, str] __slots__ = ( 'secure_cookies_by_default', diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 2570a8ff4..2e59ba78f 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -15,7 +15,6 @@ """Utilities for the Response class.""" from falcon.util import uri -from falcon.util.misc import isascii from falcon.util.misc import secure_filename @@ -91,7 +90,7 @@ def format_content_disposition(value, disposition_type='attachment'): # NOTE(vytas): RFC 6266, Appendix D. # Include a "filename" parameter when US-ASCII ([US-ASCII]) is # sufficiently expressive. - if isascii(value): + if value.isascii(): return '%s; filename="%s"' % (disposition_type, value) # NOTE(vytas): RFC 6266, Appendix D. diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index cf65cdcc5..d5329f90f 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -978,7 +978,7 @@ def __setattr__(self, name, value) -> None: class _CxParent: - def __init__(self): + def __init__(self) -> None: self._children: List[_CxElement] = [] def append_child(self, construct: _CxElement): diff --git a/falcon/status_codes.py b/falcon/status_codes.py index 3944fe674..b098fc43a 100644 --- a/falcon/status_codes.py +++ b/falcon/status_codes.py @@ -143,3 +143,167 @@ HTTP_792 = '792 Climate change driven catastrophic weather event' HTTP_797 = '797 This is the last page of the Internet. Go back' HTTP_799 = '799 End of the world' + +__all__ = ( + 'HTTP_100', + 'HTTP_101', + 'HTTP_102', + 'HTTP_200', + 'HTTP_201', + 'HTTP_202', + 'HTTP_203', + 'HTTP_204', + 'HTTP_205', + 'HTTP_206', + 'HTTP_207', + 'HTTP_208', + 'HTTP_226', + 'HTTP_300', + 'HTTP_301', + 'HTTP_302', + 'HTTP_303', + 'HTTP_304', + 'HTTP_305', + 'HTTP_307', + 'HTTP_308', + 'HTTP_400', + 'HTTP_401', + 'HTTP_402', + 'HTTP_403', + 'HTTP_404', + 'HTTP_405', + 'HTTP_406', + 'HTTP_407', + 'HTTP_408', + 'HTTP_409', + 'HTTP_410', + 'HTTP_411', + 'HTTP_412', + 'HTTP_413', + 'HTTP_414', + 'HTTP_415', + 'HTTP_416', + 'HTTP_417', + 'HTTP_418', + 'HTTP_422', + 'HTTP_423', + 'HTTP_424', + 'HTTP_426', + 'HTTP_428', + 'HTTP_429', + 'HTTP_431', + 'HTTP_451', + 'HTTP_500', + 'HTTP_501', + 'HTTP_502', + 'HTTP_503', + 'HTTP_504', + 'HTTP_505', + 'HTTP_507', + 'HTTP_508', + 'HTTP_511', + 'HTTP_701', + 'HTTP_702', + 'HTTP_703', + 'HTTP_710', + 'HTTP_711', + 'HTTP_712', + 'HTTP_719', + 'HTTP_720', + 'HTTP_721', + 'HTTP_722', + 'HTTP_723', + 'HTTP_724', + 'HTTP_725', + 'HTTP_726', + 'HTTP_727', + 'HTTP_740', + 'HTTP_741', + 'HTTP_742', + 'HTTP_743', + 'HTTP_744', + 'HTTP_745', + 'HTTP_748', + 'HTTP_749', + 'HTTP_750', + 'HTTP_753', + 'HTTP_754', + 'HTTP_755', + 'HTTP_759', + 'HTTP_771', + 'HTTP_772', + 'HTTP_773', + 'HTTP_774', + 'HTTP_776', + 'HTTP_777', + 'HTTP_778', + 'HTTP_779', + 'HTTP_780', + 'HTTP_781', + 'HTTP_782', + 'HTTP_783', + 'HTTP_784', + 'HTTP_785', + 'HTTP_786', + 'HTTP_791', + 'HTTP_792', + 'HTTP_797', + 'HTTP_799', + 'HTTP_ACCEPTED', + 'HTTP_ALREADY_REPORTED', + 'HTTP_BAD_GATEWAY', + 'HTTP_BAD_REQUEST', + 'HTTP_CONFLICT', + 'HTTP_CONTINUE', + 'HTTP_CREATED', + 'HTTP_EXPECTATION_FAILED', + 'HTTP_FAILED_DEPENDENCY', + 'HTTP_FORBIDDEN', + 'HTTP_FOUND', + 'HTTP_GATEWAY_TIMEOUT', + 'HTTP_GONE', + 'HTTP_HTTP_VERSION_NOT_SUPPORTED', + 'HTTP_IM_A_TEAPOT', + 'HTTP_IM_USED', + 'HTTP_INSUFFICIENT_STORAGE', + 'HTTP_INTERNAL_SERVER_ERROR', + 'HTTP_LENGTH_REQUIRED', + 'HTTP_LOCKED', + 'HTTP_LOOP_DETECTED', + 'HTTP_METHOD_NOT_ALLOWED', + 'HTTP_MOVED_PERMANENTLY', + 'HTTP_MULTIPLE_CHOICES', + 'HTTP_MULTI_STATUS', + 'HTTP_NETWORK_AUTHENTICATION_REQUIRED', + 'HTTP_NON_AUTHORITATIVE_INFORMATION', + 'HTTP_NOT_ACCEPTABLE', + 'HTTP_NOT_FOUND', + 'HTTP_NOT_IMPLEMENTED', + 'HTTP_NOT_MODIFIED', + 'HTTP_NO_CONTENT', + 'HTTP_OK', + 'HTTP_PARTIAL_CONTENT', + 'HTTP_PAYMENT_REQUIRED', + 'HTTP_PERMANENT_REDIRECT', + 'HTTP_PRECONDITION_FAILED', + 'HTTP_PRECONDITION_REQUIRED', + 'HTTP_PROCESSING', + 'HTTP_PROXY_AUTHENTICATION_REQUIRED', + 'HTTP_REQUESTED_RANGE_NOT_SATISFIABLE', + 'HTTP_REQUEST_ENTITY_TOO_LARGE', + 'HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE', + 'HTTP_REQUEST_TIMEOUT', + 'HTTP_REQUEST_URI_TOO_LONG', + 'HTTP_RESET_CONTENT', + 'HTTP_SEE_OTHER', + 'HTTP_SERVICE_UNAVAILABLE', + 'HTTP_SWITCHING_PROTOCOLS', + 'HTTP_TEMPORARY_REDIRECT', + 'HTTP_TOO_MANY_REQUESTS', + 'HTTP_UNAUTHORIZED', + 'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS', + 'HTTP_UNPROCESSABLE_ENTITY', + 'HTTP_UNSUPPORTED_MEDIA_TYPE', + 'HTTP_UPGRADE_REQUIRED', + 'HTTP_USE_PROXY', +) diff --git a/falcon/stream.py b/falcon/stream.py index c300ee98a..f5bbe463d 100644 --- a/falcon/stream.py +++ b/falcon/stream.py @@ -19,7 +19,7 @@ import io from typing import BinaryIO, Callable, List, Optional, TypeVar, Union -__all__ = ['BoundedStream'] +__all__ = ('BoundedStream',) Result = TypeVar('Result', bound=Union[bytes, List[bytes]]) diff --git a/falcon/typing.py b/falcon/typing.py index bc4137027..4049d9ab8 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -11,19 +11,45 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - """Shorthand definitions for more complex types.""" -from typing import Any, Callable, Pattern, Union +from __future__ import annotations + +import http +from typing import ( + Any, + Callable, + Dict, + List, + Pattern, + Tuple, + TYPE_CHECKING, + Union, +) + +if TYPE_CHECKING: + from falcon.request import Request + from falcon.response import Response -from falcon.request import Request -from falcon.response import Response + +Link = Dict[str, str] # Error handlers -ErrorHandler = Callable[[Request, Response, BaseException, dict], Any] +ErrorHandler = Callable[['Request', 'Response', BaseException, dict], Any] # Error serializers -ErrorSerializer = Callable[[Request, Response, BaseException], Any] +ErrorSerializer = Callable[['Request', 'Response', BaseException], Any] + +JSONSerializable = Union[ + Dict[str, 'JSONSerializable'], + List['JSONSerializable'], + Tuple['JSONSerializable', ...], + bool, + float, + int, + str, + None, +] # Sinks SinkPrefix = Union[str, Pattern] @@ -33,3 +59,6 @@ # arguments afterwords? # class SinkCallable(Protocol): # def __call__(sef, req: Request, resp: Response, ): ... +Headers = Dict[str, str] +HeaderList = Union[Headers, List[Tuple[str, str]]] +ResponseStatus = Union[http.HTTPStatus, str, int] diff --git a/falcon/util/mediatypes.py b/falcon/util/mediatypes.py index c0dca5121..c7812bbeb 100644 --- a/falcon/util/mediatypes.py +++ b/falcon/util/mediatypes.py @@ -16,6 +16,8 @@ import typing +__all__ = ('parse_header',) + def _parse_param_old_stdlib(s): # type: ignore while s[:1] == ';': @@ -84,6 +86,3 @@ def parse_header(line: str) -> typing.Tuple[str, dict]: return (key.strip(), pdict) return _parse_header_old_stdlib(line) - - -__all__ = ['parse_header'] diff --git a/falcon/util/misc.py b/falcon/util/misc.py index a6a60a0dc..835dcbd29 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -44,12 +44,6 @@ except ImportError: _cy_encode_items_to_latin1 = None -try: - from falcon.cyutil.misc import isascii as _cy_isascii -except ImportError: - _cy_isascii = None - - __all__ = ( 'is_python_func', 'deprecated', @@ -506,30 +500,8 @@ def _encode_items_to_latin1(data: Dict[str, str]) -> List[Tuple[bytes, bytes]]: return result -def _isascii(string: str) -> bool: - """Return ``True`` if all characters in the string are ASCII. - - ASCII characters have code points in the range U+0000-U+007F. - - Note: - On Python 3.7+, this function is just aliased to ``str.isascii``. - - This is a pure-Python fallback for older CPython (where Cython is - unavailable) and PyPy versions. - - Args: - string (str): A string to test. - - Returns: - ``True`` if all characters are ASCII, ``False`` otherwise. - """ - - try: - string.encode('ascii') - return True - except ValueError: - return False - - _encode_items_to_latin1 = _cy_encode_items_to_latin1 or _encode_items_to_latin1 -isascii = getattr(str, 'isascii', _cy_isascii or _isascii) + +isascii = deprecated('This will be removed in V5. Please use `str.isascii`')( + str.isascii +) diff --git a/falcon/util/sync.py b/falcon/util/sync.py index 56e6719f1..0bfc24021 100644 --- a/falcon/util/sync.py +++ b/falcon/util/sync.py @@ -8,7 +8,7 @@ from falcon.util import deprecated -__all__ = [ +__all__ = ( 'async_to_sync', 'create_task', 'get_running_loop', @@ -16,7 +16,7 @@ 'sync_to_async', 'wrap_sync_to_async', 'wrap_sync_to_async_unsafe', -] +) Result = TypeVar('Result') diff --git a/falcon/util/time.py b/falcon/util/time.py index d475a166e..e6e13c38d 100644 --- a/falcon/util/time.py +++ b/falcon/util/time.py @@ -12,7 +12,7 @@ import datetime from typing import Optional -__all__ = ['TimezoneGMT'] +__all__ = ('TimezoneGMT',) class TimezoneGMT(datetime.tzinfo): diff --git a/falcon/util/uri.py b/falcon/util/uri.py index a9acec6d4..f9a772785 100644 --- a/falcon/util/uri.py +++ b/falcon/util/uri.py @@ -33,6 +33,17 @@ _cy_uri = None +__all__ = ( + 'decode', + 'encode', + 'encode_value', + 'encode_check_escaped', + 'encode_value_check_escaped', + 'parse_host', + 'parse_query_string', + 'unquote_string', +) + # NOTE(kgriffs): See also RFC 3986 _UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' @@ -547,15 +558,3 @@ def unquote_string(quoted: str) -> str: if _cy_uri is not None: decode = _cy_uri.decode # NOQA parse_query_string = _cy_uri.parse_query_string # NOQA - - -__all__ = [ - 'decode', - 'encode', - 'encode_value', - 'encode_check_escaped', - 'encode_value_check_escaped', - 'parse_host', - 'parse_query_string', - 'unquote_string', -] diff --git a/pyproject.toml b/pyproject.toml index c56da1c31..be08709b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,10 @@ ] [tool.mypy] - exclude = "falcon/bench/|falcon/cmd/" + exclude = [ + "falcon/bench/|falcon/cmd/", + "falcon/vendor" + ] [[tool.mypy.overrides]] module = [ "cbor2", @@ -35,8 +38,20 @@ [[tool.mypy.overrides]] module = [ + "falcon.util.*", + "falcon.app_helpers", + "falcon.asgi_spec", + "falcon.constants", + "falcon.errors", + "falcon.forwarded", + "falcon.hooks", + "falcon.http_error", + "falcon.http_status", + "falcon.http_status", + "falcon.inspect", + "falcon.middleware", + "falcon.redirects", "falcon.stream", - "falcon.util.*" ] disallow_untyped_defs = true @@ -76,7 +91,7 @@ [tool.black] # this is kept to avoid reformatting all the code if one were to # inadvertently run black on the project - target-version = ["py37"] + target-version = ["py38"] skip-string-normalization = true line-length = 88 extend-exclude = "falcon/vendor" @@ -85,12 +100,12 @@ # NOTE(vytas): Before switching to Ruff, Falcon used the Blue formatter. # With the below settings, accidentally running blue should yield # only minor cosmetic changes in a handful of files. - target-version = ["py37"] + target-version = ["py38"] line-length = 88 extend-exclude = "falcon/vendor" [tool.ruff] - target-version = "py37" + target-version = "py38" format.quote-style = "single" line-length = 88 extend-exclude = ["falcon/vendor"] diff --git a/requirements/tests b/requirements/tests index e3623da8d..dd1fa0451 100644 --- a/requirements/tests +++ b/requirements/tests @@ -28,6 +28,3 @@ python-rapidjson; platform_machine != 's390x' and platform_machine != 'aarch64' # wheels are missing some EoL interpreters and non-x86 platforms; build would fail unless rust is available orjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64' - -# Images for 3.7 on emulated architectures seem to only have OpenSSL 1.0.2 -urllib3 < 2.0; python_version <= '3.7' diff --git a/setup.cfg b/setup.cfg index 04059572e..04a693b0f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,12 +24,12 @@ classifiers = Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Programming Language :: Cython keywords = asgi @@ -52,7 +52,7 @@ project_urls = zip_safe = False include_package_data = True packages = find: -python_requires = >=3.7 +python_requires = >=3.8 install_requires = tests_require = testtools diff --git a/tests/test_hello.py b/tests/test_hello.py index 1bef8d773..709b844b0 100644 --- a/tests/test_hello.py +++ b/tests/test_hello.py @@ -78,10 +78,17 @@ def close(self): self.close_called = True -class NonClosingBytesIO(io.BytesIO): +# NOTE(vytas): Do not inherit from BytesIO but encapsulate instead, as on +# CPython 3.13 garbage-collecting a BytesIO instance with an invalid .close() +# sometimes bubbles up a warning about exception when trying to call it. +class NonClosingBytesIO: # Not callable; test that CloseableStreamIterator ignores it close = False # type: ignore + def __init__(self, data=b''): + self._stream = io.BytesIO(data) + self.read = self._stream.read + class ClosingFilelikeHelloResource: sample_status = '200 OK' diff --git a/tests/test_utils.py b/tests/test_utils.py index b4d51a78e..46afc2c9a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -645,25 +645,9 @@ def test_secure_filename_empty_value(self): with pytest.raises(ValueError): misc.secure_filename('') - @pytest.mark.parametrize( - 'string,expected_ascii', - [ - ('', True), - ('/', True), - ('/api', True), - ('/data/items/something?query=apples%20and%20oranges', True), - ('/food?item=ð\x9f\x8d\x94', False), - ('\x00\x00\x7f\x00\x00\x7f\x00', True), - ('\x00\x00\x7f\x00\x00\x80\x00', False), - ], - ) - @pytest.mark.parametrize('method', ['isascii', '_isascii']) - def test_misc_isascii(self, string, expected_ascii, method): - isascii = getattr(misc, method) - if expected_ascii: - assert isascii(string) - else: - assert not isascii(string) + def test_misc_isascii(self): + with pytest.warns(deprecation.DeprecatedWarning): + assert misc.isascii('foobar') @pytest.mark.parametrize( @@ -1431,9 +1415,6 @@ def a_function(a=1, b=2): assert 'a_function(...)' in str(recwarn[0].message) -@pytest.mark.skipif( - falcon.PYTHON_VERSION < (3, 7), reason='module __getattr__ requires python 3.7' -) def test_json_deprecation(): with pytest.warns(deprecation.DeprecatedWarning, match='json'): util.json diff --git a/tools/testing/fetch_mailman.sh b/tools/testing/fetch_mailman.sh index 18135e786..6fe642c8c 100755 --- a/tools/testing/fetch_mailman.sh +++ b/tools/testing/fetch_mailman.sh @@ -19,14 +19,11 @@ cd $MAILMAN_PATH # git checkout tags/$MAILMAN_VERSION # NOTE(vytas): Patch tox.ini to introduce a new Falcon environment. -# TODO(vytas): Remove the shim pinning importlib-resources once -# https://gitlab.com/mailman/mailman/-/merge_requests/1130 is merged upstream. cat <> tox.ini [testenv:falcon-nocov] -basepython = python3.8 +basepython = python3.12 commands_pre = - pip install "importlib-resources < 6.0" pip uninstall -y falcon pip install $FALCON_ROOT EOT diff --git a/tox.ini b/tox.ini index 329e2b9d3..301827f6c 100644 --- a/tox.ini +++ b/tox.ini @@ -182,12 +182,6 @@ setenv = PYTHONASYNCIODEBUG=1 commands = pytest tests [] -[testenv:py37_cython] -basepython = python3.7 -deps = {[with-cython]deps} -setenv = {[with-cython]setenv} -commands = {[with-cython]commands} - [testenv:py38_cython] basepython = python3.8 deps = {[with-cython]deps} @@ -220,6 +214,14 @@ deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} +[testenv:py313_cython] +basepython = python3.13 +# NOTE(vytas): pyximport relies on distutils.extension +deps = {[with-cython]deps} + setuptools +setenv = {[with-cython]setenv} +commands = {[with-cython]commands} + # -------------------------------------------------------------------- # WSGI servers (Cythonized Falcon) # -------------------------------------------------------------------- @@ -463,7 +465,7 @@ commands = # -------------------------------------------------------------------- [testenv:hug] -basepython = python3.7 +basepython = python3.8 deps = virtualenv commands = {toxinidir}/tools/testing/install_hug.sh