diff --git a/.github/workflows/cibuildwheel.yaml b/.github/workflows/cibuildwheel.yaml index b3ec82cfa..c62b840af 100644 --- a/.github/workflows/cibuildwheel.yaml +++ b/.github/workflows/cibuildwheel.yaml @@ -1,5 +1,5 @@ # Build wheels using cibuildwheel (https://cibuildwheel.pypa.io/) -name: Build wheels +name: build-wheels on: # Run when a release has been created @@ -11,6 +11,7 @@ on: jobs: build-sdist: + # NOTE(vytas): We actually build sdist and pure-Python wheel. name: sdist runs-on: ubuntu-latest @@ -25,16 +26,31 @@ jobs: with: python-version: "3.12" - - name: Build sdist + - name: Build sdist and pure-Python wheel + env: + FALCON_DISABLE_CYTHON: "Y" + run: | + pip install --upgrade pip + pip install --upgrade build + python -m build + + - name: Check built artifacts + run: | + tools/check_dist.py ${{ github.event_name == 'release' && format('-r {0}', github.ref) || '' }} + + - name: Test sdist + run: | + tools/test_dist.py dist/*.tar.gz + + - name: Test pure-Python wheel run: | - pip install build - python -m build --sdist + tools/test_dist.py dist/*.whl - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: cibw-sdist - path: dist/*.tar.gz + path: dist/falcon-* build-wheels: name: ${{ matrix.python }}-${{ matrix.platform.name }} @@ -105,37 +121,96 @@ jobs: uses: actions/upload-artifact@v4 with: name: cibw-wheel-${{ matrix.python }}-${{ matrix.platform.name }} - path: wheelhouse/*.whl + path: wheelhouse/falcon-*.whl + + publish-sdist: + name: publish-sdist + needs: + - build-sdist + - build-wheels + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: cibw-sdist + path: dist + merge-multiple: true + + - name: Check collected artifacts + run: | + tools/check_dist.py ${{ github.event_name == 'release' && format('-r {0}', github.ref) || '' }} + + - name: Upload sdist to release + uses: AButler/upload-release-assets@v2.0 + if: github.event_name == 'release' + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + files: 'dist/*.tar.gz' + + - name: Publish sdist and pure-Python wheel to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'workflow_dispatch' + with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + + - name: Publish sdist and pure-Python wheel to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'release' + with: + password: ${{ secrets.PYPI_TOKEN }} publish-wheels: - name: publish + name: publish-wheels needs: - build-sdist - build-wheels + - publish-sdist runs-on: ubuntu-latest steps: - - name: Download packages + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download artifacts uses: actions/download-artifact@v4 with: - pattern: cibw-* + pattern: cibw-wheel-* path: dist merge-multiple: true - name: Check collected artifacts - # TODO(vytas): Run a script to perform version sanity checks instead. - run: ls -l dist/ + run: | + tools/check_dist.py ${{ github.event_name == 'release' && format('-r {0}', github.ref) || '' }} - - name: Publish artifacts to TestPyPI + - name: Publish binary wheels to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 if: github.event_name == 'workflow_dispatch' with: password: ${{ secrets.TEST_PYPI_TOKEN }} repository-url: https://test.pypi.org/legacy/ - # TODO(vytas): Enable this nuclear option once happy with other tests. - # - name: Publish artifacts to PyPI - # uses: pypa/gh-action-pypi-publish@release/v1 - # if: github.event_name == 'release' - # with: - # password: ${{ secrets.PYPI_TOKEN }} + - name: Publish binary wheels to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'release' + with: + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/create-wheels.yaml b/.github/workflows/create-wheels.yaml deleted file mode 100644 index 6caf8c4df..000000000 --- a/.github/workflows/create-wheels.yaml +++ /dev/null @@ -1,320 +0,0 @@ -name: Create wheel - -on: - # run when a release has been created - release: - types: [created] - -env: - # set this so the falcon test uses the installed version and not the local one - PYTHONNOUSERSITE: 1 - # comment TWINE_REPOSITORY_URL to use the real pypi. NOTE: change also the secret used in TWINE_PASSWORD - # TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ - -jobs: - # four jobs are defined make-wheel-win-osx, make-wheel-linux, make-source-dist and make-emulated-wheels - # the wheels jobs do the the same steps, but linux wheels need to be build to target manylinux - make-wheel-win-osx: - needs: make-source-dist - name: ${{ matrix.python-version }}-${{ matrix.architecture }}-${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: - - "windows-latest" - - "macos-latest" - python-version: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" - architecture: - - x64 - - fail-fast: false - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - - name: Create wheel - # `--no-deps` is used to only generate the wheel for the current library. - # Redundant in falcon since it has no dependencies - run: | - python -m pip install --upgrade pip - pip --version - pip install 'setuptools>=47' 'wheel>=0.34' - pip wheel -w dist -v --no-deps . - - - name: Check created wheel - # - install the created wheel without using the pypi index - # - check the cython extension - # - runs the tests - run: | - pip install tox - tox -e wheel_check -- ${{ matrix.pytest-extra }} - - - name: Upload wheels to release - # upload the generated wheels to the github release - uses: AButler/upload-release-assets@v2.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - files: 'dist/*.whl' - - - name: Publish wheel - # the action https://github.com/marketplace/actions/pypi-publish runs only on linux and we cannot specify - # additional options - env: - TWINE_USERNAME: __token__ - # replace TWINE_PASSWORD with token for real pypi - # TWINE_PASSWORD: ${{ secrets.test_pypi_token }} - TWINE_PASSWORD: ${{ secrets.pypi_token }} - run: | - pip install -U twine - twine upload --skip-existing dist/* - - make-wheel-linux: - needs: make-source-dist - # see also comments in the make-wheel-win-osx job for details on the steps - name: ${{ matrix.python-version }}-${{ matrix.architecture }}-${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: - - "ubuntu-latest" - python-version: - # the versions are - as specified in PEP 425. - - cp38-cp38 - - cp39-cp39 - - cp310-cp310 - - cp311-cp311 - - cp312-cp312 - architecture: - - x64 - - fail-fast: false - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Get python version - id: linux-py-version - env: - py_tag: ${{ matrix.python-version }} - run: | - platform=${py_tag%%-*} - version=${platform:2:1}.${platform:3:3} - echo $version - echo "::set-output name=python-version::$version" - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ steps.linux-py-version.outputs.python-version }} - architecture: ${{ matrix.architecture }} - - - name: Create wheel for manylinux 2014 - uses: RalfG/python-wheels-manylinux-build@v0.6.0-manylinux2014_x86_64 - # this action generates 2 wheels in dist/. linux, manylinux2014 - with: - # Remove previous original wheel just to be sure it is recreated. Should not be needed - pre-build-command: "rm -f ./dist/*-linux*.whl" - python-versions: ${{ matrix.python-version }} - build-requirements: "setuptools>=47 wheel>=0.34" - pip-wheel-args: "-w ./dist -v --no-deps" - - - name: Check created wheel - # - install the created wheel without using the pypi index - # - check the cython extension - # - runs the tests - run: | - pip install tox - tox -e wheel_check -- ${{ matrix.pytest-extra }} - - - name: Upload wheels to release - # upload the generated wheels to the github release - uses: AButler/upload-release-assets@v2.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - files: 'dist/*manylinux*' - - - name: Publish wheel - # We upload all manylinux wheels. pip will download the appropriate one according to the system. - env: - TWINE_USERNAME: __token__ - # replace TWINE_PASSWORD with token for real pypi - # TWINE_PASSWORD: ${{ secrets.test_pypi_token }} - TWINE_PASSWORD: ${{ secrets.pypi_token }} - run: | - pip install -U twine - twine upload --skip-existing dist/*manylinux* - - make-source-dist: - # see also comments in the make-wheel-win-osx job for details on the steps - name: sdist-${{ matrix.python-version }}-${{ matrix.architecture }}-${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: - - "ubuntu-latest" - python-version: - - "3.10" - architecture: - - x64 - - fail-fast: false - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.architecture }} - - - name: Create sdist - run: | - python -m pip install --upgrade pip - pip --version - pip install setuptools>=47 wheel>=0.34 - python setup.py sdist --dist-dir dist - python .github/workflows/scripts/verify_tag.py dist - - - name: Upload wheels to release - # upload the generated wheels to the github release - uses: AButler/upload-release-assets@v2.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - files: 'dist/*.tar.gz' - - - name: Publish sdist - env: - TWINE_USERNAME: __token__ - # replace TWINE_PASSWORD with token for real pypi - # TWINE_PASSWORD: ${{ secrets.test_pypi_token }} - TWINE_PASSWORD: ${{ secrets.pypi_token }} - run: | - pip install -U twine - twine upload --skip-existing dist/* - - make-emulated-wheels: - needs: make-source-dist - # see also comments in the make-wheel-linux job for details on the steps - name: ${{ matrix.python-version }}-${{ matrix.architecture }} - runs-on: ubuntu-latest - strategy: - matrix: - os: - - "ubuntu-latest" - python-version: - # the versions are - as specified in PEP 425. - - cp38-cp38 - - cp39-cp39 - - cp310-cp310 - - cp311-cp311 - - cp312-cp312 - architecture: - - aarch64 - - s390x - - fail-fast: false - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Cache wheels - uses: actions/cache@v3 - with: - path: .pip - key: ${{ matrix.python-version }}-${{ matrix.architecture }}-pip-${{ hashFiles('requirements/tests') }} - - - name: Set up emulation - run: | - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - - - name: Create wheel for manylinux 2014 for arm - if: ${{ matrix.architecture == 'aarch64' }} - uses: RalfG/python-wheels-manylinux-build@v0.6.0-manylinux2014_aarch64 - # this action generates 2 wheels in dist/. linux, manylinux2014 - with: - python-versions: ${{ matrix.python-version }} - build-requirements: "setuptools>=47 wheel>=0.34" - pip-wheel-args: "-w ./dist -v --no-deps" - - - name: Check created wheel for arm - if: ${{ matrix.architecture == 'aarch64' }} - uses: docker://quay.io/pypa/manylinux2014_aarch64 - env: - PIP_CACHE_DIR: /github/workspace/.pip/ - PYTHON_VERSION: ${{ matrix.python-version }} - with: - args: | - bash -c " - export PATH=/opt/python/${{ matrix.python-version }}/bin:$PATH && - pip install tox 'pip>=20' && - tox -e wheel_check -- ${{ matrix.pytest-extra }} - " - - - name: Create wheel for manylinux 2014 for s390x - if: ${{ matrix.architecture == 's390x' }} - uses: RalfG/python-wheels-manylinux-build@v0.6.0-manylinux2014_s390x - # this action generates 2 wheels in dist/. linux, manylinux2014 - with: - python-versions: ${{ matrix.python-version }} - build-requirements: "setuptools>=47 wheel>=0.34" - pip-wheel-args: "-w ./dist -v --no-deps" - - - name: Check created wheel for s390x - if: ${{ matrix.architecture == 's390x' }} - uses: docker://quay.io/pypa/manylinux2014_s390x - env: - PIP_CACHE_DIR: /github/workspace/.pip/ - with: - args: | - bash -c " - export PATH=/opt/python/${{ matrix.python-version }}/bin:$PATH && - pip install tox 'pip>=20' && - tox -e wheel_check -- ${{ matrix.pytest-extra }} - " - - - name: Upload wheels to release - # upload the generated wheels to the github release - uses: AButler/upload-release-assets@v2.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - files: 'dist/*manylinux*' - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - architecture: "x64" - - - name: Publish wheel - env: - TWINE_USERNAME: __token__ - # replace TWINE_PASSWORD with token for real pypi - # TWINE_PASSWORD: ${{ secrets.test_pypi_token }} - TWINE_PASSWORD: ${{ secrets.pypi_token }} - run: | - pip install -U twine - twine upload --skip-existing dist/*manylinux* diff --git a/.github/workflows/scripts/verify_tag.py b/.github/workflows/scripts/verify_tag.py deleted file mode 100644 index 7be254e77..000000000 --- a/.github/workflows/scripts/verify_tag.py +++ /dev/null @@ -1,50 +0,0 @@ -import argparse -from os import environ -from pathlib import Path - -# See https://docs.github.com/en/actions/reference/environment-variables -ENV_VARIABLE = 'GITHUB_REF' - - -def go(): - parser = argparse.ArgumentParser() - parser.add_argument( - 'folder', help='Directory where to look for wheels and source dist' - ) - - args = parser.parse_args() - - if ENV_VARIABLE not in environ: - raise RuntimeError('Expected to find %r in environ' % ENV_VARIABLE) - - directory = Path(args.folder) - candidates = list(directory.glob('*.whl')) + list(directory.glob('*.tar.gz')) - if not candidates: - raise RuntimeError('No wheel or source dist found in folder ' + args.folder) - raw_value = environ[ENV_VARIABLE] - tag_value = raw_value.split('/')[-1] - - errors = [] - for candidate in candidates: - name = candidate.stem - if name.endswith('.tar'): - name = name[:-4] - parts = name.split('-') - if len(parts) < 2: - errors.append(str(candidate)) - continue - version = parts[1] - if version.lower() != tag_value.lower(): - errors.append(str(candidate)) - - if errors: - raise RuntimeError( - 'Expected to find only wheels or or source dist with tag %r' - '(from env variable value %r). Found instead %s' - % (tag_value, raw_value, errors) - ) - print('Found %s wheels or source dist with tag %r' % (len(candidates), tag_value)) - - -if __name__ == '__main__': - go() diff --git a/AUTHORS b/AUTHORS index 52ef92933..3ec67da81 100644 --- a/AUTHORS +++ b/AUTHORS @@ -139,6 +139,10 @@ listed below by date of first contribution: * Derk Weijers (derkweijers) * bssyousefi * Pavel 宝尔米 (e-io) +* wendy5667 +* Dave Tapley (davetapley) +* Agustin Arce (aarcex3) +* Christian Grossmüller (chgad) (et al.) diff --git a/docs/_newsfragments/2182.breakingchange.rst b/docs/_newsfragments/2182.breakingchange.rst new file mode 100644 index 000000000..aeeb2e308 --- /dev/null +++ b/docs/_newsfragments/2182.breakingchange.rst @@ -0,0 +1,2 @@ +The function :func:`falcon.http_date_to_dt` now validates http-dates to have the correct +timezone set. It now also returns timezone aware datetime objects. diff --git a/docs/api/media.rst b/docs/api/media.rst index e48d8ac73..dbff05383 100644 --- a/docs/api/media.rst +++ b/docs/api/media.rst @@ -115,16 +115,20 @@ middleware. Here is an example of how this can be done: .. code:: python + from falcon import Request, Response + class NegotiationMiddleware: - def process_request(self, req, resp): + def process_request(self, req: Request, resp: Response) -> None: resp.content_type = req.accept .. tab:: ASGI .. code:: python + from falcon.asgi import Request, Response + class NegotiationMiddleware: - async def process_request(self, req, resp): + async def process_request(self, req: Request, resp: Response) -> None: resp.content_type = req.accept diff --git a/docs/api/middleware.rst b/docs/api/middleware.rst index ea6db856f..3cfbb18cd 100644 --- a/docs/api/middleware.rst +++ b/docs/api/middleware.rst @@ -26,8 +26,11 @@ defined below. .. code:: python + from typing import Any + from falcon import Request, Response + class ExampleMiddleware: - def process_request(self, req, resp): + def process_request(self, req: Request, resp: Response) -> None: """Process the request before routing it. Note: @@ -42,7 +45,13 @@ defined below. the on_* responder. """ - def process_resource(self, req, resp, resource, params): + def process_resource( + self, + req: Request, + resp: Response, + resource: object, + params: dict[str, Any], + ) -> None: """Process the request after routing. Note: @@ -62,7 +71,13 @@ defined below. method as keyword arguments. """ - def process_response(self, req, resp, resource, req_succeeded): + def process_response( + self, + req: Request, + resp: Response, + resource: object, + req_succeeded: bool + ) -> None: """Post-processing of the response (after routing). Args: @@ -90,8 +105,13 @@ defined below. .. code:: python + from typing import Any + from falcon.asgi import Request, Response, WebSocket + class ExampleMiddleware: - async def process_startup(self, scope, event): + async def process_startup( + self, scope: dict[str, Any], event: dict[str, Any] + ) -> None: """Process the ASGI lifespan startup event. Invoked when the server is ready to start up and @@ -111,7 +131,9 @@ defined below. startup event. """ - async def process_shutdown(self, scope, event): + async def process_shutdown( + self, scope: dict[str, Any], event: dict[str, Any] + ) -> None: """Process the ASGI lifespan shutdown event. Invoked when the server has stopped accepting @@ -130,7 +152,7 @@ defined below. shutdown event. """ - async def process_request(self, req, resp): + async def process_request(self, req: Request, resp: Response) -> None: """Process the request before routing it. Note: @@ -145,7 +167,13 @@ defined below. the on_* responder. """ - async def process_resource(self, req, resp, resource, params): + async def process_resource( + self, + req: Request, + resp: Response, + resource: object, + params: dict[str, Any], + ) -> None: """Process the request after routing. Note: @@ -165,7 +193,13 @@ defined below. method as keyword arguments. """ - async def process_response(self, req, resp, resource, req_succeeded): + async def process_response( + self, + req: Request, + resp: Response, + resource: object, + req_succeeded: bool + ) -> None: """Post-processing of the response (after routing). Args: @@ -179,7 +213,7 @@ defined below. otherwise False. """ - async def process_request_ws(self, req, ws): + async def process_request_ws(self, req: Request, ws: WebSocket) -> None: """Process a WebSocket handshake request before routing it. Note: @@ -194,7 +228,13 @@ defined below. on_websocket() after routing. """ - async def process_resource_ws(self, req, ws, resource, params): + async def process_resource_ws( + self, + req: Request, + ws: WebSocket, + resource: object, + params: dict[str, Any], + ) -> None: """Process a WebSocket handshake request after routing. Note: @@ -226,15 +266,18 @@ the following example: .. code:: python + import falcon as wsgi + from falcon import asgi + class ExampleMiddleware: - def process_request(self, req, resp): + def process_request(self, req: wsgi.Request, resp: wsgi.Response) -> None: """Process WSGI request using synchronous logic. Note that req and resp are instances of falcon.Request and falcon.Response, respectively. """ - async def process_request_async(self, req, resp): + async def process_request_async(self, req: asgi.Request, resp: asgi.Response) -> None: """Process ASGI request using asynchronous logic. Note that req and resp are instances of falcon.asgi.Request and diff --git a/docs/api/websocket.rst b/docs/api/websocket.rst index 66b457722..38878f964 100644 --- a/docs/api/websocket.rst +++ b/docs/api/websocket.rst @@ -35,8 +35,12 @@ middleware objects configured for the app: .. code:: python + from typing import Any + from falcon.asgi import Request, WebSocket + + class SomeMiddleware: - async def process_request_ws(self, req, ws): + async def process_request_ws(self, req: Request, ws: WebSocket) -> None: """Process a WebSocket handshake request before routing it. Note: @@ -51,7 +55,13 @@ middleware objects configured for the app: on_websocket() after routing. """ - async def process_resource_ws(self, req, ws, resource, params): + async def process_resource_ws( + self, + req: Request, + ws: WebSocket, + resource: object, + params: dict[str, Any], + ) -> None: """Process a WebSocket handshake request after routing. Note: diff --git a/docs/changes/4.0.0.rst b/docs/changes/4.0.0.rst index 5df54f447..bd15d6a1e 100644 --- a/docs/changes/4.0.0.rst +++ b/docs/changes/4.0.0.rst @@ -32,7 +32,7 @@ now typed, further type annotations may be added throughout the 4.x release cycl To improve them, we may introduce changes to the typing that do not affect runtime behavior, but may surface new or different errors with type checkers. -.. note:: +.. note:: All type aliases in falcon are considered private, and if used should be imported inside ``if TYPE_CHECKING:`` blocks to avoid possible import errors @@ -45,11 +45,14 @@ Contributors to this Release Many thanks to all of our talented and stylish contributors for this release! +- `aarcex3 `__ - `aryaniyaps `__ - `bssyousefi `__ - `CaselIT `__ - `cclauss `__ +- `chgad `__ - `copalco `__ +- `davetapley `__ - `derkweijers `__ - `e-io `__ - `euj1n0ng `__ @@ -69,3 +72,4 @@ Many thanks to all of our talented and stylish contributors for this release! - `TigreModerata `__ - `vgerak `__ - `vytas7 `__ +- `wendy5667 `__ diff --git a/docs/conf.py b/docs/conf.py index 2a3098c25..e2390c14a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,6 +61,7 @@ 'sphinx_tabs.tabs', 'sphinx_tabs.tabs', # Falcon-specific extensions + 'ext.cibuildwheel', 'ext.doorway', 'ext.private_args', 'ext.rfc', diff --git a/docs/ext/cibuildwheel.py b/docs/ext/cibuildwheel.py new file mode 100644 index 000000000..3afea5d11 --- /dev/null +++ b/docs/ext/cibuildwheel.py @@ -0,0 +1,124 @@ +# Copyright 2024 by Vytautas Liuolia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +""" +Binary wheels table extension for Sphinx. + +This extension parses a GitHub Actions workflow for building binary wheels, and +summarizes the build onfiguration in a stylish table. +""" + +import itertools +import pathlib + +import sphinx.util.docutils +import yaml + +FALCON_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +DOT_GITHUB = FALCON_ROOT / '.github' + +_CHECKBOX = '\u2705' +_CPYTHON_PLATFORMS = { + 'manylinux_x86_64': '**Linux Intel** ``manylinux`` 64-bit', + 'musllinux_x86_64': '**Linux Intel** ``musllinux`` 64-bit', + 'manylinux_i686': '**Linux Intel** ``manylinux`` 32-bit', + 'musllinux_i686': '**Linux Intel** ``musllinux`` 32-bit', + 'manylinux_aarch64': '**Linux ARM** ``manylinux`` 64-bit', + 'musllinux_aarch64': '**Linux ARM** ``musllinux`` 64-bit', + 'manylinux_ppc64le': '**Linux PowerPC** ``manylinux`` 64-bit', + 'musllinux_ppc64le': '**Linux PowerPC** ``musllinux`` 64-bit', + 'manylinux_s390x': '**Linux IBM Z** ``manylinux``', + 'musllinux_s390x': '**Linux IBM Z** ``musllinux``', + 'macosx_x86_64': '**macOS Intel**', + 'macosx_arm64': '**macOS Apple Silicon**', + 'win32': '**Windows** 32-bit', + 'win_amd64': '**Windows** 64-bit', + 'win_arm64': '**Windows ARM** 64-bit', +} + + +class WheelsDirective(sphinx.util.docutils.SphinxDirective): + """Directive to tabulate build info from a YAML workflow.""" + + required_arguments = 1 + has_content = True + + @classmethod + def _emit_table(cls, data): + columns = len(data[0]) + assert all( + len(row) == columns for row in data + ), 'All rows must have the same number of columns' + # NOTE(vytas): +2 is padding inside cell borders. + width = max(len(cell) for cell in itertools.chain(*data)) + 2 + hline = ('+' + '-' * width) * columns + '+\n' + output = [hline] + + for row in data: + for cell in row: + # NOTE(vytas): Emojis take two spaces... + padded_width = width - 1 if cell == _CHECKBOX else width + output.append('|' + cell.center(padded_width)) + output.append('|\n') + + header_line = row == data[0] + output.append(hline.replace('-', '=') if header_line else hline) + + return ''.join(output) + + def run(self): + workflow_path = pathlib.Path(self.arguments[0]) + if not workflow_path.is_absolute(): + workflow_path = FALCON_ROOT / workflow_path + + # TODO(vytas): Should we package cibuildwheel.yaml into sdist too? + # For now, if .github is missing, we simply hide the table. + if not workflow_path.is_file() and not DOT_GITHUB.exists(): + return [] + + with open(workflow_path) as fp: + workflow = yaml.safe_load(fp) + + matrix = workflow['jobs']['build-wheels']['strategy']['matrix'] + platforms = matrix['platform'] + include = matrix['include'] + assert not matrix.get('exclude'), 'TODO: exclude is not supported yet' + supported = set( + itertools.product( + [platform['name'] for platform in platforms], matrix['python'] + ) + ) + supported.update((item['platform']['name'], item['python']) for item in include) + cpythons = sorted({cp for _, cp in supported}, key=lambda val: (len(val), val)) + + header = ['Platform / CPython version'] + table = [header + [cp.replace('cp3', '3.') for cp in cpythons]] + table.extend( + [description] + + [(_CHECKBOX if (name, cp) in supported else '') for cp in cpythons] + for name, description in _CPYTHON_PLATFORMS.items() + ) + + content = '\n'.join(self.content) + '\n\n' + self._emit_table(table) + return self.parse_text_to_nodes(content) + + +def setup(app): + app.add_directive('wheels', WheelsDirective) + + return { + 'version': '0.1', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/docs/user/install.rst b/docs/user/install.rst index ef4986802..bec085e61 100644 --- a/docs/user/install.rst +++ b/docs/user/install.rst @@ -39,9 +39,13 @@ Or, to install the latest beta or release candidate, if any: In order to provide an extra speed boost, Falcon can compile itself with Cython. Wheels containing pre-compiled binaries are available from PyPI for -several common platforms. However, if a wheel for your platform of choice is not -available, you can choose to stick with the source distribution, or use the -instructions below to cythonize Falcon for your environment. +several common platforms (see :ref:`binary_wheels` below for the complete list +of the platforms that we target, or simply check +`Falcon files on PyPI `__). + +However, even if a wheel for your platform of choice is not available, you can +choose to stick with the source distribution, or use the instructions below to +cythonize Falcon for your environment. The following commands tell pip to install Cython, and then to invoke Falcon's ``setup.py``, which will in turn detect the presence of Cython @@ -64,7 +68,8 @@ pass `-v` to pip in order to echo the compilation commands: $ pip install -v --no-build-isolation --no-binary :all: falcon -**Installing on OS X** +Installing on OS X +^^^^^^^^^^^^^^^^^^ Xcode Command Line Tools are required to compile Cython. Install them with this command: @@ -87,6 +92,28 @@ these issues by setting additional Clang C compiler flags as follows: $ export CFLAGS="-Qunused-arguments -Wno-unused-function" +.. _binary_wheels: + +Binary Wheels +^^^^^^^^^^^^^ + +Binary Falcon wheels for are automatically built for many CPython platforms, +courtesy of `cibuildwheel `__. + +.. wheels:: .github/workflows/cibuildwheel.yaml + + The following table summarizes the wheel availability on different + combinations of CPython versions vs CPython platforms: + +.. note:: + The `free-threaded build + `__ + mode is not enabled for our wheels at this time. + +While we believe that our build configuration covers the most common +development and deployment scenarios, :ref:`let us known ` if you are +interested in any builds that are currently missing from our selection! + Dependencies ------------ diff --git a/e2e-tests/server/app.py b/e2e-tests/server/app.py index 46f52a90c..be9558985 100644 --- a/e2e-tests/server/app.py +++ b/e2e-tests/server/app.py @@ -13,8 +13,7 @@ def create_app() -> falcon.asgi.App: - # TODO(vytas): Type to App's constructor. - app = falcon.asgi.App() # type: ignore + app = falcon.asgi.App() hub = Hub() app.add_route('/ping', Pong()) diff --git a/e2e-tests/server/ping.py b/e2e-tests/server/ping.py index 447db6658..cc7537464 100644 --- a/e2e-tests/server/ping.py +++ b/e2e-tests/server/ping.py @@ -10,4 +10,4 @@ async def on_get(self, req: Request, resp: Response) -> None: resp.content_type = falcon.MEDIA_TEXT resp.text = 'PONG\n' # TODO(vytas): Properly type Response.status. - resp.status = HTTPStatus.OK # type: ignore + resp.status = HTTPStatus.OK diff --git a/falcon/app.py b/falcon/app.py index a71c6530d..88ab0e554 100644 --- a/falcon/app.py +++ b/falcon/app.py @@ -19,7 +19,25 @@ import pathlib import re import traceback -from typing import Callable, Iterable, Optional, Tuple, Type, Union +from typing import ( + Any, + Callable, + cast, + ClassVar, + Dict, + FrozenSet, + IO, + Iterable, + List, + Literal, + Optional, + overload, + Pattern, + Tuple, + Type, + TypeVar, + Union, +) import warnings from falcon import app_helpers as helpers @@ -37,9 +55,18 @@ from falcon.response import Response from falcon.response import ResponseOptions import falcon.status_codes as status +from falcon.typing import AsgiResponderCallable +from falcon.typing import AsgiResponderWsCallable +from falcon.typing import AsgiSinkCallable from falcon.typing import ErrorHandler from falcon.typing import ErrorSerializer +from falcon.typing import FindMethod +from falcon.typing import ProcessResponseMethod +from falcon.typing import ResponderCallable +from falcon.typing import SinkCallable from falcon.typing import SinkPrefix +from falcon.typing import StartResponse +from falcon.typing import WSGIEnvironment from falcon.util import deprecation from falcon.util import misc from falcon.util.misc import code_to_http_status @@ -61,10 +88,11 @@ status.HTTP_304, ] ) +_BE = TypeVar('_BE', bound=BaseException) class App: - """The main entry point into a Falcon-based WSGI app. + '''The main entry point into a Falcon-based WSGI app. Each App instance provides a callable `WSGI `_ interface @@ -90,9 +118,9 @@ class App: to implement the methods for the events you would like to handle; Falcon simply skips over any missing middleware methods:: - class ExampleComponent: - def process_request(self, req, resp): - \"\"\"Process the request before routing it. + class ExampleMiddleware: + def process_request(self, req: Request, resp: Response) -> None: + """Process the request before routing it. Note: Because Falcon routes each request based on @@ -105,10 +133,16 @@ def process_request(self, req, resp): routed to an on_* responder method. resp: Response object that will be routed to the on_* responder. - \"\"\" + """ - def process_resource(self, req, resp, resource, params): - \"\"\"Process the request and resource *after* routing. + def process_resource( + self, + req: Request, + resp: Response, + resource: object, + params: dict[str, Any], + ) -> None: + """Process the request and resource *after* routing. Note: This method is only called when the request matches @@ -127,10 +161,16 @@ def process_resource(self, req, resp, resource, params): template fields, that will be passed to the resource's responder method as keyword arguments. - \"\"\" + """ - def process_response(self, req, resp, resource, req_succeeded) - \"\"\"Post-processing of the response (after routing). + def process_response( + self, + req: Request, + resp: Response, + resource: object, + req_succeeded: bool + ) -> None: + """Post-processing of the response (after routing). Args: req: Request object. @@ -141,7 +181,7 @@ def process_response(self, req, resp, resource, req_succeeded) req_succeeded: True if no exceptions were raised while the framework processed and routed the request; otherwise False. - \"\"\" + """ (See also: :ref:`Middleware `) @@ -177,46 +217,33 @@ def process_response(self, req, resp, resource, req_succeeded) sink_before_static_route (bool): Indicates if the sinks should be processed before (when ``True``) or after (when ``False``) the static routes. This has an effect only if no route was matched. (default ``True``) + ''' - Attributes: - req_options: A set of behavioral options related to incoming - requests. (See also: :class:`~.RequestOptions`) - resp_options: A set of behavioral options related to outgoing - responses. (See also: :class:`~.ResponseOptions`) - router_options: Configuration options for the router. If a - custom router is in use, and it does not expose any - configurable options, referencing this attribute will raise - an instance of ``AttributeError``. - - (See also: :ref:`CompiledRouterOptions `) - """ - - _META_METHODS = frozenset(constants._META_METHODS) + _META_METHODS: ClassVar[FrozenSet[str]] = frozenset(constants._META_METHODS) - _STREAM_BLOCK_SIZE = 8 * 1024 # 8 KiB + _STREAM_BLOCK_SIZE: ClassVar[int] = 8 * 1024 # 8 KiB - _STATIC_ROUTE_TYPE = routing.StaticRoute + _STATIC_ROUTE_TYPE: ClassVar[Type[routing.StaticRoute]] = routing.StaticRoute # NOTE(kgriffs): This makes it easier to tell what we are dealing with # without having to import falcon.asgi. - _ASGI = False + _ASGI: ClassVar[bool] = False # NOTE(kgriffs): We do it like this rather than just implementing the # methods directly on the class, so that we keep all the default # responders colocated in the same module. This will make it more # likely that the implementations of the async and non-async versions # of the methods are kept in sync (pun intended). - _default_responder_bad_request = responders.bad_request - _default_responder_path_not_found = responders.path_not_found + _default_responder_bad_request: ClassVar[ResponderCallable] = responders.bad_request + _default_responder_path_not_found: ClassVar[ResponderCallable] = ( + responders.path_not_found + ) __slots__ = ( '_cors_enable', '_error_handlers', '_independent_middleware', '_middleware', - # NOTE(kgriffs): WebSocket is currently only supported for - # ASGI apps, but we may add support for WSGI at some point. - '_middleware_ws', '_request_type', '_response_type', '_router_search', @@ -231,20 +258,57 @@ def process_response(self, req, resp, resource, req_succeeded) 'resp_options', ) + _cors_enable: bool + _error_handlers: Dict[Type[BaseException], ErrorHandler] + _independent_middleware: bool + _middleware: helpers.PreparedMiddlewareResult + _request_type: Type[Request] + _response_type: Type[Response] + _router_search: FindMethod + # NOTE(caselit): this should actually be a protocol of the methods required + # by a router, hardcoded to CompiledRouter for convenience for now. + _router: routing.CompiledRouter + _serialize_error: ErrorSerializer + _sink_and_static_routes: Tuple[ + Tuple[ + Union[Pattern[str], routing.StaticRoute], + Union[SinkCallable, AsgiSinkCallable, routing.StaticRoute], + bool, + ], + ..., + ] + _sink_before_static_route: bool + _sinks: List[ + Tuple[Pattern[str], Union[SinkCallable, AsgiSinkCallable], Literal[True]] + ] + _static_routes: List[ + Tuple[routing.StaticRoute, routing.StaticRoute, Literal[False]] + ] + _unprepared_middleware: List[object] + + # Attributes req_options: RequestOptions + """A set of behavioral options related to incoming requests. + + See also: :class:`~.RequestOptions` + """ resp_options: ResponseOptions + """A set of behavioral options related to outgoing responses. + + See also: :class:`~.ResponseOptions` + """ def __init__( self, - media_type=constants.DEFAULT_MEDIA_TYPE, - request_type=Request, - response_type=Response, - middleware=None, - router=None, - independent_middleware=True, - cors_enable=False, - sink_before_static_route=True, - ): + media_type: str = constants.DEFAULT_MEDIA_TYPE, + request_type: Type[Request] = Request, + response_type: Type[Response] = Response, + middleware: Union[object, Iterable[object]] = None, + router: Optional[routing.CompiledRouter] = None, + independent_middleware: bool = True, + cors_enable: bool = False, + sink_before_static_route: bool = True, + ) -> None: self._cors_enable = cors_enable self._sink_before_static_route = sink_before_static_route self._sinks = [] @@ -261,9 +325,8 @@ def __init__( # NOTE(kgriffs): Check to see if middleware is an # iterable, and if so, append the CORSMiddleware # instance. - iter(middleware) - middleware = list(middleware) - middleware.append(cm) + middleware = list(middleware) # type: ignore[arg-type] + middleware.append(cm) # type: ignore[arg-type] except TypeError: # NOTE(kgriffs): Assume the middleware kwarg references # a single middleware component. @@ -295,7 +358,7 @@ def __init__( self.add_error_handler(HTTPStatus, self._http_status_handler) def __call__( # noqa: C901 - self, env: dict, start_response: Callable + self, env: WSGIEnvironment, start_response: StartResponse ) -> Iterable[bytes]: """WSGI `app` method. @@ -314,10 +377,9 @@ def __call__( # noqa: C901 req = self._request_type(env, options=self.req_options) resp = self._response_type(options=self.resp_options) resource: Optional[object] = None - responder: Optional[Callable] = None - params: dict = {} + params: Dict[str, Any] = {} - dependent_mw_resp_stack: list = [] + dependent_mw_resp_stack: List[ProcessResponseMethod] = [] mw_req_stack, mw_rsrc_stack, mw_resp_stack = self._middleware req_succeeded = False @@ -334,15 +396,15 @@ def __call__( # noqa: C901 # response middleware after request middleware succeeds. if self._independent_middleware: for process_request in mw_req_stack: - process_request(req, resp) + process_request(req, resp) # type: ignore[operator] if resp.complete: break else: - for process_request, process_response in mw_req_stack: + for process_request, process_response in mw_req_stack: # type: ignore[assignment,misc] if process_request and not resp.complete: - process_request(req, resp) + process_request(req, resp) # type: ignore[operator] if process_response: - dependent_mw_resp_stack.insert(0, process_response) + dependent_mw_resp_stack.insert(0, process_response) # type: ignore[arg-type] if not resp.complete: # NOTE(warsaw): Moved this to inside the try except @@ -352,7 +414,8 @@ def __call__( # noqa: C901 # next-hop child resource. In that case, the object # being asked to dispatch to its child will raise an # HTTP exception signalling the problem, e.g. a 404. - responder, params, resource, req.uri_template = self._get_responder(req) + responder: ResponderCallable + responder, params, resource, req.uri_template = self._get_responder(req) # type: ignore[assignment] except Exception as ex: if not self._handle_exception(req, resp, ex, params): raise @@ -372,7 +435,7 @@ def __call__( # noqa: C901 break if not resp.complete: - responder(req, resp, **params) # type: ignore + responder(req, resp, **params) req_succeeded = True except Exception as ex: @@ -389,8 +452,8 @@ def __call__( # noqa: C901 req_succeeded = False - body = [] - length = 0 + body: Iterable[bytes] = [] + length: Optional[int] = 0 try: body, length = self._get_body(resp, env.get('wsgi.file_wrapper')) @@ -400,8 +463,8 @@ def __call__( # noqa: C901 req_succeeded = False - resp_status = code_to_http_status(resp.status) - default_media_type = self.resp_options.default_media_type + resp_status: str = code_to_http_status(resp.status) + default_media_type: Optional[str] = self.resp_options.default_media_type if req.method == 'HEAD' or resp_status in _BODILESS_STATUS_CODES: body = [] @@ -439,17 +502,27 @@ def __call__( # noqa: C901 if length is not None: resp._headers['content-length'] = str(length) - headers = resp._wsgi_headers(default_media_type) + headers: List[Tuple[str, str]] = resp._wsgi_headers(default_media_type) # Return the response per the WSGI spec. start_response(resp_status, headers) return body + # NOTE(caselit): the return type depends on the router, hardcoded to + # CompiledRouterOptions for convenience. @property - def router_options(self): + def router_options(self) -> routing.CompiledRouterOptions: + """Configuration options for the router. + + If a custom router is in use, and it does not expose any + configurable options, referencing this attribute will raise + an instance of ``AttributeError``. + + See also: :ref:`CompiledRouterOptions `. + """ return self._router.options - def add_middleware(self, middleware: Union[object, Iterable]) -> None: + def add_middleware(self, middleware: Union[object, Iterable[object]]) -> None: """Add one or more additional middleware components. Arguments: @@ -463,7 +536,7 @@ def add_middleware(self, middleware: Union[object, Iterable]) -> None: # the chance that middleware may be None. if middleware: try: - middleware = list(middleware) # type: ignore + middleware = list(middleware) # type: ignore[call-overload] except TypeError: # middleware is not iterable; assume it is just one bare component middleware = [middleware] @@ -473,7 +546,7 @@ def add_middleware(self, middleware: Union[object, Iterable]) -> None: and len( [ mc - for mc in self._unprepared_middleware + middleware + for mc in self._unprepared_middleware + middleware # type: ignore[operator] if isinstance(mc, CORSMiddleware) ] ) @@ -484,7 +557,7 @@ def add_middleware(self, middleware: Union[object, Iterable]) -> None: 'cors_enable (which already constructs one instance)' ) - self._unprepared_middleware += middleware + self._unprepared_middleware += middleware # type: ignore[arg-type] # NOTE(kgriffs): Even if middleware is None or an empty list, we still # need to make sure self._middleware is initialized if this is the @@ -494,7 +567,7 @@ def add_middleware(self, middleware: Union[object, Iterable]) -> None: independent_middleware=self._independent_middleware, ) - def add_route(self, uri_template: str, resource: object, **kwargs): + def add_route(self, uri_template: str, resource: object, **kwargs: Any) -> None: """Associate a templatized URI path with a resource. Falcon routes incoming requests to resources based on a set of @@ -606,7 +679,7 @@ def add_static_route( directory: Union[str, pathlib.Path], downloadable: bool = False, fallback_filename: Optional[str] = None, - ): + ) -> None: """Add a route to a directory of static files. Static routes provide a way to serve files directly. This @@ -674,7 +747,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'/') -> None: + def add_sink(self, sink: SinkCallable, prefix: SinkPrefix = r'/') -> None: """Register a sink method for the App. If no route matches a request, but the path in the requested URI @@ -720,6 +793,8 @@ def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/') -> None: if not hasattr(prefix, 'match'): # Assume it is a string prefix = re.compile(prefix) + else: + prefix = cast(Pattern[str], prefix) # NOTE(kgriffs): Insert at the head of the list such that # in the case of a duplicate prefix, the last one added @@ -727,11 +802,25 @@ def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/') -> None: self._sinks.insert(0, (prefix, sink, True)) self._update_sink_and_static_routes() + @overload + def add_error_handler( + self, + exception: Type[_BE], + handler: Callable[[Request, Response, _BE, Dict[str, Any]], None], + ) -> None: ... + + @overload def add_error_handler( self, exception: Union[Type[BaseException], Iterable[Type[BaseException]]], handler: Optional[ErrorHandler] = None, - ): + ) -> None: ... + + def add_error_handler( # type: ignore[misc] + self, + exception: Union[Type[BaseException], Iterable[Type[BaseException]]], + handler: Optional[ErrorHandler] = None, + ) -> None: """Register a handler for one or more exception types. Error handlers may be registered for any exception type, including @@ -810,34 +899,22 @@ def handle(req, resp, ex, params): """ - def wrap_old_handler(old_handler): - # NOTE(kgriffs): This branch *is* actually tested by - # test_error_handlers.test_handler_signature_shim_asgi() (as - # verified manually via pdb), but for some reason coverage - # tracking isn't picking it up. - if iscoroutinefunction(old_handler): # pragma: no cover - - @wraps(old_handler) - async def handler_async(req, resp, ex, params): - await old_handler(ex, req, resp, params) - - return handler_async - + def wrap_old_handler(old_handler: Callable[..., Any]) -> ErrorHandler: @wraps(old_handler) - def handler(req, resp, ex, params): + def handler( + req: Request, resp: Response, ex: BaseException, params: Dict[str, Any] + ) -> None: old_handler(ex, req, resp, params) return handler if handler is None: - try: - handler = exception.handle # type: ignore - except AttributeError: + handler = getattr(exception, 'handle', None) + if handler is None: raise AttributeError( - 'handler must either be specified ' - 'explicitly or defined as a static' - 'method named "handle" that is a ' - 'member of the given exception class.' + 'handler must either be specified explicitly or defined as a ' + 'static method named "handle" that is a member of the given ' + 'exception class.' ) # TODO(vytas): Remove this shimming in a future Falcon version. @@ -857,11 +934,11 @@ def handler(req, resp, ex, params): ) handler = wrap_old_handler(handler) - exception_tuple: tuple + exception_tuple: Tuple[type[BaseException], ...] try: - exception_tuple = tuple(exception) # type: ignore + exception_tuple = tuple(exception) # type: ignore[arg-type] except TypeError: - exception_tuple = (exception,) + exception_tuple = (exception,) # type: ignore[assignment] for exc in exception_tuple: if not issubclass(exc, BaseException): @@ -869,7 +946,7 @@ def handler(req, resp, ex, params): self._error_handlers[exc] = handler - def set_error_serializer(self, serializer: ErrorSerializer): + def set_error_serializer(self, serializer: ErrorSerializer) -> None: """Override the default serializer for instances of :class:`~.HTTPError`. When a responder raises an instance of :class:`~.HTTPError`, @@ -892,7 +969,9 @@ def set_error_serializer(self, serializer: ErrorSerializer): such as `to_json()` and `to_dict()`, that can be used from within custom serializers. For example:: - def my_serializer(req, resp, exception): + def my_serializer( + req: Request, resp: Response, exception: HTTPError + ) -> None: representation = None preferred = req.client_prefers((falcon.MEDIA_YAML, falcon.MEDIA_JSON)) @@ -921,14 +1000,21 @@ def my_serializer(req, resp, exception): # Helpers that require self # ------------------------------------------------------------------------ - def _prepare_middleware(self, middleware=None, independent_middleware=False): + def _prepare_middleware( + self, middleware: List[object], independent_middleware: bool = False + ) -> helpers.PreparedMiddlewareResult: return helpers.prepare_middleware( middleware=middleware, independent_middleware=independent_middleware ) def _get_responder( self, req: Request - ) -> Tuple[Callable, dict, object, Optional[str]]: + ) -> Tuple[ + Union[ResponderCallable, AsgiResponderCallable, AsgiResponderWsCallable], + Dict[str, Any], + object, + Optional[str], + ]: """Search routes for a matching responder. Args: @@ -964,7 +1050,7 @@ def _get_responder( # NOTE(kgriffs): Older routers may not return the # template. But for performance reasons they should at # least return None if they don't support it. - resource, method_map, params = route + resource, method_map, params = route # type: ignore[misc] else: # NOTE(kgriffs): Older routers may indicate that no route # was found by returning (None, None, None). Therefore, we @@ -990,7 +1076,7 @@ def _get_responder( m = matcher.match(path) if m: if is_sink: - params = m.groupdict() + params = m.groupdict() # type: ignore[union-attr] responder = obj break @@ -1028,17 +1114,23 @@ def _compose_error_response( self._serialize_error(req, resp, error) - def _http_status_handler(self, req, resp, status, params): + def _http_status_handler( + self, req: Request, resp: Response, status: HTTPStatus, params: Dict[str, Any] + ) -> None: self._compose_status_response(req, resp, status) - def _http_error_handler(self, req, resp, error, params): + def _http_error_handler( + self, req: Request, resp: Response, error: HTTPError, params: Dict[str, Any] + ) -> None: self._compose_error_response(req, resp, error) - def _python_error_handler(self, req, resp, error, params): + def _python_error_handler( + self, req: Request, resp: Response, error: BaseException, params: Dict[str, Any] + ) -> None: req.log_error(traceback.format_exc()) self._compose_error_response(req, resp, HTTPInternalServerError()) - def _find_error_handler(self, ex): + def _find_error_handler(self, ex: BaseException) -> Optional[ErrorHandler]: # NOTE(csojinb): The `__mro__` class attribute returns the method # resolution order tuple, i.e. the complete linear inheritance chain # ``(type(ex), ..., object)``. For a valid exception class, the last @@ -1053,8 +1145,11 @@ def _find_error_handler(self, ex): if handler is not None: return handler + return None - def _handle_exception(self, req, resp, ex, params): + def _handle_exception( + self, req: Request, resp: Response, ex: BaseException, params: Dict[str, Any] + ) -> bool: """Handle an exception raised from mw or a responder. Args: @@ -1093,7 +1188,11 @@ def _handle_exception(self, req, resp, ex, params): # PERF(kgriffs): Moved from api_helpers since it is slightly faster # to call using self, and this function is called for most # requests. - def _get_body(self, resp, wsgi_file_wrapper=None): + def _get_body( + self, + resp: Response, + wsgi_file_wrapper: Optional[Callable[[IO[bytes], int], Iterable[bytes]]] = None, + ) -> Tuple[Iterable[bytes], Optional[int]]: """Convert resp content into an iterable as required by PEP 333. Args: @@ -1116,7 +1215,7 @@ def _get_body(self, resp, wsgi_file_wrapper=None): """ - data = resp.render_body() + data: Optional[bytes] = resp.render_body() if data is not None: return [data], len(data) @@ -1143,11 +1242,11 @@ def _get_body(self, resp, wsgi_file_wrapper=None): return [], 0 - def _update_sink_and_static_routes(self): + def _update_sink_and_static_routes(self) -> None: if self._sink_before_static_route: - self._sink_and_static_routes = tuple(self._sinks + self._static_routes) + self._sink_and_static_routes = tuple(self._sinks + self._static_routes) # type: ignore[operator] else: - self._sink_and_static_routes = tuple(self._static_routes + self._sinks) + self._sink_and_static_routes = tuple(self._static_routes + self._sinks) # type: ignore[operator] # TODO(myusko): This class is a compatibility alias, and should be removed @@ -1166,5 +1265,5 @@ class API(App): @deprecation.deprecated( 'API class may be removed in a future release, use falcon.App instead.' ) - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index db6d7cc24..bca38a3bc 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -17,7 +17,7 @@ from __future__ import annotations from inspect import iscoroutinefunction -from typing import IO, Iterable, List, Tuple +from typing import IO, Iterable, List, Literal, Optional, overload, Tuple, Union from falcon import util from falcon.constants import MEDIA_JSON @@ -26,6 +26,14 @@ from falcon.errors import HTTPError from falcon.request import Request from falcon.response import Response +from falcon.typing import AsgiProcessRequestMethod as APRequest +from falcon.typing import AsgiProcessRequestWsMethod +from falcon.typing import AsgiProcessResourceMethod as APResource +from falcon.typing import AsgiProcessResourceWsMethod +from falcon.typing import AsgiProcessResponseMethod as APResponse +from falcon.typing import ProcessRequestMethod as PRequest +from falcon.typing import ProcessResourceMethod as PResource +from falcon.typing import ProcessResponseMethod as PResponse from falcon.util.sync import _wrap_non_coroutine_unsafe __all__ = ( @@ -35,10 +43,46 @@ 'CloseableStreamIterator', ) +PreparedMiddlewareResult = Tuple[ + Union[ + Tuple[PRequest, ...], Tuple[Tuple[Optional[PRequest], Optional[PResource]], ...] + ], + Tuple[PResource, ...], + Tuple[PResponse, ...], +] +AsyncPreparedMiddlewareResult = Tuple[ + Union[ + Tuple[APRequest, ...], + Tuple[Tuple[Optional[APRequest], Optional[APResource]], ...], + ], + Tuple[APResource, ...], + Tuple[APResponse, ...], +] + + +@overload +def prepare_middleware( + middleware: Iterable, independent_middleware: bool = ..., asgi: Literal[False] = ... +) -> PreparedMiddlewareResult: ... + + +@overload +def prepare_middleware( + middleware: Iterable, independent_middleware: bool = ..., *, asgi: Literal[True] +) -> AsyncPreparedMiddlewareResult: ... + +@overload def prepare_middleware( - middleware: Iterable, independent_middleware: bool = False, asgi: bool = False -) -> Tuple[tuple, tuple, tuple]: + middleware: Iterable, independent_middleware: bool = ..., asgi: bool = ... +) -> Union[PreparedMiddlewareResult, AsyncPreparedMiddlewareResult]: ... + + +def prepare_middleware( + middleware: Iterable[object], + independent_middleware: bool = False, + asgi: bool = False, +) -> Union[PreparedMiddlewareResult, AsyncPreparedMiddlewareResult]: """Check middleware interfaces and prepare the methods for request handling. Note: @@ -60,9 +104,14 @@ def prepare_middleware( # PERF(kgriffs): do getattr calls once, in advance, so we don't # have to do them every time in the request path. - request_mw: List = [] - resource_mw: List = [] - response_mw: List = [] + request_mw: Union[ + List[PRequest], + List[Tuple[Optional[PRequest], Optional[PResource]]], + List[APRequest], + List[Tuple[Optional[APRequest], Optional[APResource]]], + ] = [] + resource_mw: Union[List[APResource], List[PResource]] = [] + response_mw: Union[List[APResponse], List[PResponse]] = [] for component in middleware: # NOTE(kgriffs): Middleware that supports both WSGI and ASGI can @@ -70,22 +119,25 @@ def prepare_middleware( # to distinguish the two. Otherwise, the prefix is unnecessary. if asgi: - process_request = util.get_bound_method( - component, 'process_request_async' - ) or _wrap_non_coroutine_unsafe( - util.get_bound_method(component, 'process_request') + process_request: Union[Optional[APRequest], Optional[PRequest]] = ( + util.get_bound_method(component, 'process_request_async') + or _wrap_non_coroutine_unsafe( + util.get_bound_method(component, 'process_request') + ) ) - process_resource = util.get_bound_method( - component, 'process_resource_async' - ) or _wrap_non_coroutine_unsafe( - util.get_bound_method(component, 'process_resource') + process_resource: Union[Optional[APResource], Optional[PResource]] = ( + util.get_bound_method(component, 'process_resource_async') + or _wrap_non_coroutine_unsafe( + util.get_bound_method(component, 'process_resource') + ) ) - process_response = util.get_bound_method( - component, 'process_response_async' - ) or _wrap_non_coroutine_unsafe( - util.get_bound_method(component, 'process_response') + process_response: Union[Optional[APResponse], Optional[PResponse]] = ( + util.get_bound_method(component, 'process_response_async') + or _wrap_non_coroutine_unsafe( + util.get_bound_method(component, 'process_response') + ) ) for m in (process_request, process_resource, process_response): @@ -143,20 +195,27 @@ def prepare_middleware( # together or separately. if independent_middleware: if process_request: - request_mw.append(process_request) + request_mw.append(process_request) # type: ignore[arg-type] if process_response: - response_mw.insert(0, process_response) + response_mw.insert(0, process_response) # type: ignore[arg-type] else: if process_request or process_response: - request_mw.append((process_request, process_response)) + request_mw.append((process_request, process_response)) # type: ignore[arg-type] if process_resource: - resource_mw.append(process_resource) + resource_mw.append(process_resource) # type: ignore[arg-type] + + return tuple(request_mw), tuple(resource_mw), tuple(response_mw) # type: ignore[return-value] - return (tuple(request_mw), tuple(resource_mw), tuple(response_mw)) +AsyncPreparedMiddlewareWsResult = Tuple[ + Tuple[AsgiProcessRequestWsMethod, ...], Tuple[AsgiProcessResourceWsMethod, ...] +] -def prepare_middleware_ws(middleware: Iterable) -> Tuple[list, list]: + +def prepare_middleware_ws( + middleware: Iterable[object], +) -> AsyncPreparedMiddlewareWsResult: """Check middleware interfaces and prepare WebSocket methods for request handling. Note: @@ -174,8 +233,11 @@ def prepare_middleware_ws(middleware: Iterable) -> Tuple[list, list]: # PERF(kgriffs): do getattr calls once, in advance, so we don't # have to do them every time in the request path. - request_mw = [] - resource_mw = [] + request_mw: List[AsgiProcessRequestWsMethod] = [] + resource_mw: List[AsgiProcessResourceWsMethod] = [] + + process_request_ws: Optional[AsgiProcessRequestWsMethod] + process_resource_ws: Optional[AsgiProcessResourceWsMethod] for component in middleware: process_request_ws = util.get_bound_method(component, 'process_request_ws') @@ -201,7 +263,7 @@ def prepare_middleware_ws(middleware: Iterable) -> Tuple[list, list]: if process_resource_ws: resource_mw.append(process_resource_ws) - return request_mw, resource_mw + return tuple(request_mw), tuple(resource_mw) def default_serialize_error(req: Request, resp: Response, exception: HTTPError) -> None: @@ -283,7 +345,7 @@ class CloseableStreamIterator: block_size (int): Number of bytes to read per iteration. """ - def __init__(self, stream: IO, block_size: int) -> None: + def __init__(self, stream: IO[bytes], block_size: int) -> None: self._stream = stream self._block_size = block_size diff --git a/falcon/asgi/_asgi_helpers.py b/falcon/asgi/_asgi_helpers.py index ce298abf2..9bbd12e88 100644 --- a/falcon/asgi/_asgi_helpers.py +++ b/falcon/asgi/_asgi_helpers.py @@ -12,15 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import functools import inspect +from typing import Any, Callable, Optional, TypeVar from falcon.errors import UnsupportedError from falcon.errors import UnsupportedScopeError @functools.lru_cache(maxsize=16) -def _validate_asgi_scope(scope_type, spec_version, http_version): +def _validate_asgi_scope( + scope_type: str, spec_version: Optional[str], http_version: str +) -> str: if scope_type == 'http': spec_version = spec_version or '2.0' if not spec_version.startswith('2.'): @@ -60,7 +65,10 @@ def _validate_asgi_scope(scope_type, spec_version, http_version): raise UnsupportedScopeError(f'The ASGI "{scope_type}" scope type is not supported.') -def _wrap_asgi_coroutine_func(asgi_impl): +_C = TypeVar('_C', bound=Callable[..., Any]) + + +def _wrap_asgi_coroutine_func(asgi_impl: _C) -> _C: """Wrap an ASGI application in another coroutine. This utility is used to wrap the cythonized ``App.__call__`` in order to @@ -84,10 +92,10 @@ def _wrap_asgi_coroutine_func(asgi_impl): # "self" parameter. # NOTE(vytas): Intentionally not using functools.wraps as it erroneously # inherits the cythonized method's traits. - async def __call__(self, scope, receive, send): + async def __call__(self: Any, scope: Any, receive: Any, send: Any) -> None: await asgi_impl(self, scope, receive, send) if inspect.iscoroutinefunction(asgi_impl): return asgi_impl - return __call__ + return __call__ # type: ignore[return-value] diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 472ded751..4fe7b7db3 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -18,11 +18,32 @@ from inspect import isasyncgenfunction from inspect import iscoroutinefunction import traceback -from typing import Awaitable, Callable, Iterable, Optional, Type, Union - +from typing import ( + Any, + Awaitable, + Callable, + ClassVar, + Dict, + Iterable, + List, + Optional, + overload, + Tuple, + Type, + TYPE_CHECKING, + TypeVar, + Union, +) + +from falcon import constants +from falcon import responders +from falcon import routing import falcon.app +from falcon.app_helpers import AsyncPreparedMiddlewareResult +from falcon.app_helpers import AsyncPreparedMiddlewareWsResult from falcon.app_helpers import prepare_middleware from falcon.app_helpers import prepare_middleware_ws +from falcon.asgi_spec import AsgiSendMsg from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode from falcon.constants import _UNSET @@ -33,9 +54,14 @@ from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus from falcon.media.multipart import MultipartFormHandler -import falcon.routing -from falcon.typing import ErrorHandler +from falcon.typing import AsgiErrorHandler +from falcon.typing import AsgiReceive +from falcon.typing import AsgiResponderCallable +from falcon.typing import AsgiResponderWsCallable +from falcon.typing import AsgiSend +from falcon.typing import AsgiSinkCallable from falcon.typing import SinkPrefix +from falcon.util import get_argnames from falcon.util.misc import is_python_func from falcon.util.sync import _should_wrap_non_coroutines from falcon.util.sync import _wrap_non_coroutine_unsafe @@ -56,19 +82,20 @@ # TODO(vytas): Clean up these foul workarounds before the 4.0 release. -MultipartFormHandler._ASGI_MULTIPART_FORM = MultipartForm # type: ignore +MultipartFormHandler._ASGI_MULTIPART_FORM = MultipartForm -_EVT_RESP_EOF = {'type': EventType.HTTP_RESPONSE_BODY} +_EVT_RESP_EOF: AsgiSendMsg = {'type': EventType.HTTP_RESPONSE_BODY} _BODILESS_STATUS_CODES = frozenset([100, 101, 204, 304]) _TYPELESS_STATUS_CODES = frozenset([204, 304]) _FALLBACK_WS_ERROR_CODE = 3011 +_BE = TypeVar('_BE', bound=BaseException) class App(falcon.app.App): - """The main entry point into a Falcon-based ASGI app. + '''The main entry point into a Falcon-based ASGI app. Each App instance provides a callable `ASGI `_ interface @@ -113,9 +140,11 @@ class App(falcon.app.App): would like to handle; Falcon simply skips over any missing middleware methods:: - class ExampleComponent: - async def process_startup(self, scope, event): - \"\"\"Process the ASGI lifespan startup event. + class ExampleMiddleware: + async def process_startup( + self, scope: dict[str, Any], event: dict[str, Any] + ) -> None: + """Process the ASGI lifespan startup event. Invoked when the server is ready to start up and receive connections, but before it has started to @@ -132,10 +161,12 @@ async def process_startup(self, scope, event): for the duration of the event loop. event (dict): The ASGI event dictionary for the startup event. - \"\"\" + """ - async def process_shutdown(self, scope, event): - \"\"\"Process the ASGI lifespan shutdown event. + async def process_shutdown( + self, scope: dict[str, Any], event: dict[str, Any] + ) -> None: + """Process the ASGI lifespan shutdown event. Invoked when the server has stopped accepting connections and closed all active connections. @@ -151,10 +182,12 @@ async def process_shutdown(self, scope, event): for the duration of the event loop. event (dict): The ASGI event dictionary for the shutdown event. - \"\"\" + """ - async def process_request(self, req, resp): - \"\"\"Process the request before routing it. + async def process_request( + self, req: Request, resp: Response + ) -> None: + """Process the request before routing it. Note: Because Falcon routes each request based on @@ -167,10 +200,16 @@ async def process_request(self, req, resp): routed to an on_* responder method. resp: Response object that will be routed to the on_* responder. - \"\"\" + """ - async def process_resource(self, req, resp, resource, params): - \"\"\"Process the request and resource *after* routing. + async def process_resource( + self, + req: Request, + resp: Response, + resource: object, + params: dict[str, Any], + ) -> None: + """Process the request and resource *after* routing. Note: This method is only called when the request matches @@ -189,10 +228,16 @@ async def process_resource(self, req, resp, resource, params): template fields, that will be passed to the resource's responder method as keyword arguments. - \"\"\" + """ - async def process_response(self, req, resp, resource, req_succeeded) - \"\"\"Post-processing of the response (after routing). + async def process_response( + self, + req: Request, + resp: Response, + resource: object, + req_succeeded: bool + ) -> None: + """Post-processing of the response (after routing). Args: req: Request object. @@ -203,7 +248,51 @@ async def process_response(self, req, resp, resource, req_succeeded) req_succeeded: True if no exceptions were raised while the framework processed and routed the request; otherwise False. - \"\"\" + """ + + # WebSocket methods + async def process_request_ws( + self, req: Request, ws: WebSocket + ) -> None: + """Process a WebSocket handshake request before routing it. + + Note: + Because Falcon routes each request based on req.path, a + request can be effectively re-routed by setting that + attribute to a new value from within process_request(). + + Args: + req: Request object that will eventually be + passed into an on_websocket() responder method. + ws: The WebSocket object that will be passed into + on_websocket() after routing. + """ + + async def process_resource_ws( + self, + req: Request, + ws: WebSocket, + resource: object, + params: dict[str, Any], + ) -> None: + """Process a WebSocket handshake request after routing. + + Note: + This method is only called when the request matches + a route to a resource. + + Args: + req: Request object that will be passed to the + routed responder. + ws: WebSocket object that will be passed to the + routed responder. + resource: Resource object to which the request was + routed. + params: A dict-like object representing any additional + params derived from the route's URI template fields, + that will be passed to the resource's responder + method as keyword arguments. + """ (See also: :ref:`Middleware `) @@ -239,39 +328,59 @@ async def process_response(self, req, resp, resource, req_succeeded) sink_before_static_route (bool): Indicates if the sinks should be processed before (when ``True``) or after (when ``False``) the static routes. This has an effect only if no route was matched. (default ``True``) + ''' - Attributes: - req_options: A set of behavioral options related to incoming - requests. (See also: :class:`~.RequestOptions`) - resp_options: A set of behavioral options related to outgoing - responses. (See also: :class:`~.ResponseOptions`) - ws_options: A set of behavioral options related to WebSocket - connections. (See also: :class:`~.WebSocketOptions`) - router_options: Configuration options for the router. If a - custom router is in use, and it does not expose any - configurable options, referencing this attribute will raise - an instance of ``AttributeError``. - - (See also: :ref:`CompiledRouterOptions `) - """ - - _STATIC_ROUTE_TYPE = falcon.routing.StaticRouteAsync + _STATIC_ROUTE_TYPE = routing.StaticRouteAsync # NOTE(kgriffs): This makes it easier to tell what we are dealing with # without having to import falcon.asgi. - _ASGI = True + _ASGI: ClassVar[bool] = True - _default_responder_bad_request = falcon.responders.bad_request_async - _default_responder_path_not_found = falcon.responders.path_not_found_async + _default_responder_bad_request: ClassVar[AsgiResponderCallable] = ( + responders.bad_request_async # type: ignore[assignment] + ) + _default_responder_path_not_found: ClassVar[AsgiResponderCallable] = ( + responders.path_not_found_async # type: ignore[assignment] + ) __slots__ = ( '_standard_response_type', + '_middleware_ws', 'ws_options', ) - def __init__(self, *args, request_type=Request, response_type=Response, **kwargs): + _error_handlers: Dict[Type[BaseException], AsgiErrorHandler] # type: ignore[assignment] + _middleware: AsyncPreparedMiddlewareResult # type: ignore[assignment] + _middleware_ws: AsyncPreparedMiddlewareWsResult + _request_type: Type[Request] + _response_type: Type[Response] + + ws_options: WebSocketOptions + """A set of behavioral options related to WebSocket connections. + + See also: :class:`~.WebSocketOptions`. + """ + + def __init__( + self, + media_type: str = constants.DEFAULT_MEDIA_TYPE, + request_type: Type[Request] = Request, + response_type: Type[Response] = Response, + middleware: Union[object, Iterable[object]] = None, + router: Optional[routing.CompiledRouter] = None, + independent_middleware: bool = True, + cors_enable: bool = False, + sink_before_static_route: bool = True, + ) -> None: super().__init__( - *args, request_type=request_type, response_type=response_type, **kwargs + media_type, + request_type, + response_type, + middleware, + router, + independent_middleware, + cors_enable, + sink_before_static_route, ) self.ws_options = WebSocketOptions() @@ -282,31 +391,31 @@ def __init__(self, *args, request_type=Request, response_type=Response, **kwargs ) @_wrap_asgi_coroutine_func - async def __call__( # noqa: C901 + async def __call__( # type: ignore[override] # noqa: C901 self, - scope: dict, - receive: Callable[[], Awaitable[dict]], - send: Callable[[dict], Awaitable[None]], + scope: Dict[str, Any], + receive: AsgiReceive, + send: AsgiSend, ) -> None: # NOTE(kgriffs): The ASGI spec requires the 'type' key to be present. - scope_type = scope['type'] + scope_type: str = scope['type'] # PERF(kgriffs): This should usually be present, so use a # try..except try: - asgi_info = scope['asgi'] + asgi_info: Dict[str, str] = scope['asgi'] except KeyError: # NOTE(kgriffs): According to the ASGI spec, "2.0" is # the default version. asgi_info = scope['asgi'] = {'version': '2.0'} try: - spec_version = asgi_info['spec_version'] + spec_version: Optional[str] = asgi_info['spec_version'] except KeyError: spec_version = None try: - http_version = scope['http_version'] + http_version: str = scope['http_version'] except KeyError: http_version = '1.1' @@ -346,9 +455,8 @@ async def __call__( # noqa: C901 ) resp = self._response_type(options=self.resp_options) - resource = None - responder: Optional[Callable] = None - params: dict = {} + resource: Optional[object] = None + params: Dict[str, Any] = {} dependent_mw_resp_stack: list = [] mw_req_stack, mw_rsrc_stack, mw_resp_stack = self._middleware @@ -367,14 +475,14 @@ async def __call__( # noqa: C901 # response middleware after request middleware succeeds. if self._independent_middleware: for process_request in mw_req_stack: - await process_request(req, resp) + await process_request(req, resp) # type: ignore[operator] if resp.complete: break else: - for process_request, process_response in mw_req_stack: + for process_request, process_response in mw_req_stack: # type: ignore[misc, assignment] if process_request and not resp.complete: - await process_request(req, resp) + await process_request(req, resp) # type: ignore[operator] if process_response: dependent_mw_resp_stack.insert(0, process_response) @@ -387,7 +495,8 @@ async def __call__( # noqa: C901 # next-hop child resource. In that case, the object # being asked to dispatch to its child will raise an # HTTP exception signaling the problem, e.g. a 404. - responder, params, resource, req.uri_template = self._get_responder(req) + responder: AsgiResponderCallable + responder, params, resource, req.uri_template = self._get_responder(req) # type: ignore[assignment] except Exception as ex: if not await self._handle_exception(req, resp, ex, params): @@ -410,7 +519,7 @@ async def __call__( # noqa: C901 break if not resp.complete: - await responder(req, resp, **params) # type: ignore + await responder(req, resp, **params) req_succeeded = True @@ -429,7 +538,7 @@ async def __call__( # noqa: C901 req_succeeded = False - data = b'' + data: Optional[bytes] = b'' try: # NOTE(vytas): It is only safe to inline Response.render_body() @@ -480,8 +589,8 @@ async def __call__( # noqa: C901 req_succeeded = False - resp_status = resp.status_code - default_media_type = self.resp_options.default_media_type + resp_status: int = resp.status_code + default_media_type: Optional[str] = self.resp_options.default_media_type if req.method == 'HEAD' or resp_status in _BODILESS_STATUS_CODES: # @@ -546,7 +655,7 @@ async def __call__( # noqa: C901 # NOTE(kgriffs): This must be done in a separate task because # receive() can block for some time (until the connection is # actually closed). - async def watch_disconnect(): + async def watch_disconnect() -> None: while True: received_event = await receive() if received_event['type'] == EventType.HTTP_DISCONNECT: @@ -724,16 +833,16 @@ async def watch_disconnect(): if resp._registered_callbacks: self._schedule_callbacks(resp) - def add_route(self, uri_template: str, resource: object, **kwargs): + def add_route(self, uri_template: str, resource: object, **kwargs: Any) -> None: # NOTE(kgriffs): Inject an extra kwarg so that the compiled router # will know to validate the responder methods to make sure they # are async coroutines. kwargs['_asgi'] = True super().add_route(uri_template, resource, **kwargs) - add_route.__doc__ = falcon.app.App.add_route.__doc__ + add_route.__doc__ = falcon.app.App.add_route.__doc__ # NOTE: not really required - def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/'): + def add_sink(self, sink: AsgiSinkCallable, prefix: SinkPrefix = r'/') -> None: # type: ignore[override] if not iscoroutinefunction(sink) and is_python_func(sink): if _should_wrap_non_coroutines(): sink = wrap_sync_to_async(sink) @@ -743,15 +852,29 @@ def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/'): 'in order to be used safely with an ASGI app.' ) - super().add_sink(sink, prefix=prefix) + super().add_sink(sink, prefix=prefix) # type: ignore[arg-type] + + add_sink.__doc__ = falcon.app.App.add_sink.__doc__ # NOTE: not really required - add_sink.__doc__ = falcon.app.App.add_sink.__doc__ + @overload # type: ignore[override] + def add_error_handler( + self, + exception: Type[_BE], + handler: Callable[[Request, Response, _BE, Dict[str, Any]], Awaitable[None]], + ) -> None: ... + @overload def add_error_handler( self, exception: Union[Type[BaseException], Iterable[Type[BaseException]]], - handler: Optional[ErrorHandler] = None, - ): + handler: Optional[AsgiErrorHandler] = None, + ) -> None: ... + + def add_error_handler( # type: ignore[misc] + self, + exception: Union[Type[BaseException], Iterable[Type[BaseException]]], + handler: Optional[AsgiErrorHandler] = None, + ) -> None: """Register a handler for one or more exception types. Error handlers may be registered for any exception type, including @@ -818,7 +941,6 @@ def add_error_handler( type(s), the associated handler will be called. Either a single type or an iterable of types may be specified. - Keyword Args: handler (callable): A coroutine function taking the form:: @@ -851,14 +973,12 @@ async def handle(req, resp, ex, params): """ if handler is None: - try: - handler = exception.handle # type: ignore - except AttributeError: + handler = getattr(exception, 'handle', None) + if handler is None: raise AttributeError( - 'handler must either be specified ' - 'explicitly or defined as a static' - 'method named "handle" that is a ' - 'member of the given exception class.' + 'handler must either be specified explicitly or defined as a ' + 'static method named "handle" that is a member of the given ' + 'exception class.' ) # NOTE(vytas): Do not shoot ourselves in the foot in case error @@ -868,7 +988,7 @@ async def handle(req, resp, ex, params): self._http_error_handler, self._python_error_handler, ): - handler = _wrap_non_coroutine_unsafe(handler) + handler = _wrap_non_coroutine_unsafe(handler) # type: ignore[assignment] # NOTE(kgriffs): iscoroutinefunction() always returns False # for cythonized functions. @@ -881,24 +1001,25 @@ async def handle(req, resp, ex, params): 'The handler must be an awaitable coroutine function in order ' 'to be used safely with an ASGI app.' ) + handler_callable: AsgiErrorHandler = handler - exception_tuple: tuple + exception_tuple: Tuple[type[BaseException], ...] try: - exception_tuple = tuple(exception) # type: ignore + exception_tuple = tuple(exception) # type: ignore[arg-type] except TypeError: - exception_tuple = (exception,) + exception_tuple = (exception,) # type: ignore[assignment] for exc in exception_tuple: if not issubclass(exc, BaseException): raise TypeError('"exception" must be an exception type.') - self._error_handlers[exc] = handler + self._error_handlers[exc] = handler_callable # ------------------------------------------------------------------------ # Helper methods # ------------------------------------------------------------------------ - def _schedule_callbacks(self, resp): + def _schedule_callbacks(self, resp: Response) -> None: callbacks = resp._registered_callbacks # PERF(vytas): resp._registered_callbacks is already checked directly # to shave off a function call since this is a hot/critical code path. @@ -907,13 +1028,15 @@ def _schedule_callbacks(self, resp): loop = asyncio.get_running_loop() - for cb, is_async in callbacks: + for cb, is_async in callbacks: # type: ignore[attr-defined] if is_async: loop.create_task(cb()) else: loop.run_in_executor(None, cb) - async def _call_lifespan_handlers(self, ver, scope, receive, send): + async def _call_lifespan_handlers( + self, ver: str, scope: Dict[str, Any], receive: AsgiReceive, send: AsgiSend + ) -> None: while True: event = await receive() if event['type'] == 'lifespan.startup': @@ -921,7 +1044,7 @@ async def _call_lifespan_handlers(self, ver, scope, receive, send): # startup, as opposed to repeating them every request. # NOTE(vytas): If missing, 'asgi' is populated in __call__. - asgi_info = scope['asgi'] + asgi_info: Dict[str, str] = scope['asgi'] version = asgi_info.get('version', '2.0 (implicit)') if not version.startswith('3.'): await send( @@ -981,7 +1104,9 @@ async def _call_lifespan_handlers(self, ver, scope, receive, send): await send({'type': EventType.LIFESPAN_SHUTDOWN_COMPLETE}) return - async def _handle_websocket(self, ver, scope, receive, send): + async def _handle_websocket( + self, ver: str, scope: Dict[str, Any], receive: AsgiReceive, send: AsgiSend + ) -> None: first_event = await receive() if first_event['type'] != EventType.WS_CONNECT: # NOTE(kgriffs): The handshake was abandoned or this is a message @@ -1007,8 +1132,7 @@ async def _handle_websocket(self, ver, scope, receive, send): self.ws_options.default_close_reasons, ) - on_websocket = None - params = {} + params: Dict[str, Any] = {} request_mw, resource_mw = self._middleware_ws @@ -1016,7 +1140,8 @@ async def _handle_websocket(self, ver, scope, receive, send): for process_request_ws in request_mw: await process_request_ws(req, web_socket) - on_websocket, params, resource, req.uri_template = self._get_responder(req) + on_websocket: AsgiResponderWsCallable + on_websocket, params, resource, req.uri_template = self._get_responder(req) # type: ignore[assignment] # NOTE(kgriffs): If the request did not match any # route, a default responder is returned and the @@ -1035,7 +1160,9 @@ async def _handle_websocket(self, ver, scope, receive, send): if not await self._handle_exception(req, None, ex, params, ws=web_socket): raise - def _prepare_middleware(self, middleware=None, independent_middleware=False): + def _prepare_middleware( # type: ignore[override] + self, middleware: List[object], independent_middleware: bool = False + ) -> AsyncPreparedMiddlewareResult: self._middleware_ws = prepare_middleware_ws(middleware) return prepare_middleware( @@ -1044,11 +1171,18 @@ def _prepare_middleware(self, middleware=None, independent_middleware=False): asgi=True, ) - async def _http_status_handler(self, req, resp, status, params, ws=None): + async def _http_status_handler( # type: ignore[override] + self, + req: Request, + resp: Optional[Response], + status: HTTPStatus, + params: Dict[str, Any], + ws: Optional[WebSocket] = None, + ) -> None: if resp: self._compose_status_response(req, resp, status) elif ws: - code = http_status_to_ws_code(status.status) + code = http_status_to_ws_code(status.status_code) falcon._logger.error( '[FALCON] HTTPStatus %s raised while handling WebSocket. ' 'Closing with code %s', @@ -1059,7 +1193,14 @@ async def _http_status_handler(self, req, resp, status, params, ws=None): else: raise NotImplementedError('resp or ws expected') - async def _http_error_handler(self, req, resp, error, params, ws=None): + async def _http_error_handler( # type: ignore[override] + self, + req: Request, + resp: Optional[Response], + error: HTTPError, + params: Dict[str, Any], + ws: Optional[WebSocket] = None, + ) -> None: if resp: self._compose_error_response(req, resp, error) elif ws: @@ -1074,7 +1215,14 @@ async def _http_error_handler(self, req, resp, error, params, ws=None): else: raise NotImplementedError('resp or ws expected') - async def _python_error_handler(self, req, resp, error, params, ws=None): + async def _python_error_handler( # type: ignore[override] + self, + req: Request, + resp: Optional[Response], + error: BaseException, + params: Dict[str, Any], + ws: Optional[WebSocket] = None, + ) -> None: falcon._logger.error('[FALCON] Unhandled exception in ASGI app', exc_info=error) if resp: @@ -1084,13 +1232,35 @@ async def _python_error_handler(self, req, resp, error, params, ws=None): else: raise NotImplementedError('resp or ws expected') - async def _ws_disconnected_error_handler(self, req, resp, error, params, ws): + async def _ws_disconnected_error_handler( + self, + req: Request, + resp: Optional[Response], + error: WebSocketDisconnected, + params: Dict[str, Any], + ws: Optional[WebSocket] = None, + ) -> None: + assert resp is None + assert ws is not None falcon._logger.debug( '[FALCON] WebSocket client disconnected with code %i', error.code ) await self._ws_cleanup_on_error(ws) - async def _handle_exception(self, req, resp, ex, params, ws=None): + if TYPE_CHECKING: + + def _find_error_handler( # type: ignore[override] + self, ex: BaseException + ) -> Optional[AsgiErrorHandler]: ... + + async def _handle_exception( # type: ignore[override] + self, + req: Request, + resp: Optional[Response], + ex: BaseException, + params: Dict[str, Any], + ws: Optional[WebSocket] = None, + ) -> bool: """Handle an exception raised from mw or a responder. Args: @@ -1121,7 +1291,7 @@ async def _handle_exception(self, req, resp, ex, params, ws=None): try: kwargs = {} - if ws and 'ws' in falcon.util.get_argnames(err_handler): + if ws and 'ws' in get_argnames(err_handler): kwargs['ws'] = ws await err_handler(req, resp, ex, params, **kwargs) @@ -1139,7 +1309,7 @@ async def _handle_exception(self, req, resp, ex, params, ws=None): # handlers. return False - async def _ws_cleanup_on_error(self, ws): + async def _ws_cleanup_on_error(self, ws: WebSocket) -> None: # NOTE(kgriffs): Attempt to close cleanly on our end try: await ws.close(self.ws_options.error_close_code) diff --git a/falcon/asgi/reader.py b/falcon/asgi/reader.py index 426668809..281e607c4 100644 --- a/falcon/asgi/reader.py +++ b/falcon/asgi/reader.py @@ -14,7 +14,10 @@ """Buffered ASGI stream reader.""" +from __future__ import annotations + import io +from typing import AsyncIterator, List, NoReturn, Optional, Protocol from falcon.errors import DelimiterError from falcon.errors import OperationNotAllowed @@ -45,7 +48,17 @@ class BufferedReader: '_source', ] - def __init__(self, source, chunk_size=None): + _buffer: bytes + _buffer_len: int + _buffer_pos: int + _chunk_size: int + _consumed: int + _exhausted: bool + _iteration_started: bool + _max_join_size: int + _source: AsyncIterator[bytes] + + def __init__(self, source: AsyncIterator[bytes], chunk_size: Optional[int] = None): self._source = self._iter_normalized(source) self._chunk_size = chunk_size or DEFAULT_CHUNK_SIZE self._max_join_size = self._chunk_size * _MAX_JOIN_CHUNKS @@ -57,7 +70,9 @@ def __init__(self, source, chunk_size=None): self._exhausted = False self._iteration_started = False - async def _iter_normalized(self, source): + async def _iter_normalized( + self, source: AsyncIterator[bytes] + ) -> AsyncIterator[bytes]: chunk = b'' chunk_size = self._chunk_size @@ -77,7 +92,7 @@ async def _iter_normalized(self, source): self._exhausted = True - async def _iter_with_buffer(self, size_hint=0): + async def _iter_with_buffer(self, size_hint: int = 0) -> AsyncIterator[bytes]: if self._buffer_len > self._buffer_pos: if 0 < size_hint < self._buffer_len - self._buffer_pos: buffer_pos = self._buffer_pos @@ -91,7 +106,9 @@ async def _iter_with_buffer(self, size_hint=0): async for chunk in self._source: yield chunk - async def _iter_delimited(self, delimiter, size_hint=0): + async def _iter_delimited( + self, delimiter: bytes, size_hint: int = 0 + ) -> AsyncIterator[bytes]: delimiter_len_1 = len(delimiter) - 1 if not 0 <= delimiter_len_1 < self._chunk_size: raise ValueError('delimiter length must be within [1, chunk_size]') @@ -152,13 +169,13 @@ async def _iter_delimited(self, delimiter, size_hint=0): yield self._buffer - async def _consume_delimiter(self, delimiter): + async def _consume_delimiter(self, delimiter: bytes) -> None: delimiter_len = len(delimiter) if await self.peek(delimiter_len) != delimiter: raise DelimiterError('expected delimiter missing') self._buffer_pos += delimiter_len - def _prepend_buffer(self, chunk): + def _prepend_buffer(self, chunk: bytes) -> None: if self._buffer_len > self._buffer_pos: self._buffer = chunk + self._buffer[self._buffer_pos :] self._buffer_len = len(self._buffer) @@ -168,17 +185,17 @@ def _prepend_buffer(self, chunk): self._buffer_pos = 0 - def _trim_buffer(self): + def _trim_buffer(self) -> None: self._buffer = self._buffer[self._buffer_pos :] self._buffer_len -= self._buffer_pos self._buffer_pos = 0 - async def _read_from(self, source, size=-1): + async def _read_from(self, source: AsyncIterator[bytes], size: int = -1) -> bytes: if size == -1 or size is None: - result = io.BytesIO() + result_bytes = io.BytesIO() async for chunk in source: - result.write(chunk) - return result.getvalue() + result_bytes.write(chunk) + return result_bytes.getvalue() if size <= 0: return b'' @@ -186,7 +203,7 @@ async def _read_from(self, source, size=-1): remaining = size if size <= self._max_join_size: - result = [] + result: List[bytes] = [] async for chunk in source: chunk_len = len(chunk) if remaining < chunk_len: @@ -203,29 +220,29 @@ async def _read_from(self, source, size=-1): return result[0] if len(result) == 1 else b''.join(result) # NOTE(vytas): size > self._max_join_size - result = io.BytesIO() + result_bytes = io.BytesIO() async for chunk in source: chunk_len = len(chunk) if remaining < chunk_len: - result.write(chunk[:remaining]) + result_bytes.write(chunk[:remaining]) self._prepend_buffer(chunk[remaining:]) break - result.write(chunk) + result_bytes.write(chunk) remaining -= chunk_len if remaining == 0: # pragma: no py39,py310 cover break - return result.getvalue() + return result_bytes.getvalue() - def delimit(self, delimiter): + def delimit(self, delimiter: bytes) -> BufferedReader: # TODO: should se self return type(self)(self._iter_delimited(delimiter), chunk_size=self._chunk_size) # ------------------------------------------------------------------------- # Asynchronous IO interface. # ------------------------------------------------------------------------- - def __aiter__(self): + def __aiter__(self) -> AsyncIterator[bytes]: if self._iteration_started: raise OperationNotAllowed('This stream is already being iterated over.') @@ -236,10 +253,10 @@ def __aiter__(self): return self._iter_with_buffer() return self._source - async def exhaust(self): + async def exhaust(self) -> None: await self.pipe() - async def peek(self, size=-1): + async def peek(self, size: int = -1) -> bytes: if size < 0 or size > self._chunk_size: size = self._chunk_size @@ -255,12 +272,17 @@ async def peek(self, size=-1): return self._buffer[:size] - async def pipe(self, destination=None): + async def pipe(self, destination: Optional[AsyncWritableIO] = None) -> None: async for chunk in self._iter_with_buffer(): if destination is not None: await destination.write(chunk) - async def pipe_until(self, delimiter, destination=None, consume_delimiter=False): + async def pipe_until( + self, + delimiter: bytes, + destination: Optional[AsyncWritableIO] = None, + consume_delimiter: bool = False, + ) -> None: async for chunk in self._iter_delimited(delimiter): if destination is not None: await destination.write(chunk) @@ -268,10 +290,10 @@ async def pipe_until(self, delimiter, destination=None, consume_delimiter=False) if consume_delimiter: await self._consume_delimiter(delimiter) - async def read(self, size=-1): + async def read(self, size: int = -1) -> bytes: return await self._read_from(self._iter_with_buffer(size_hint=size or 0), size) - async def readall(self): + async def readall(self) -> bytes: """Read and return all remaining data in the request body. Warning: @@ -286,7 +308,9 @@ async def readall(self): """ return await self._read_from(self._iter_with_buffer()) - async def read_until(self, delimiter, size=-1, consume_delimiter=False): + async def read_until( + self, delimiter: bytes, size: int = -1, consume_delimiter: bool = False + ) -> bytes: result = await self._read_from( self._iter_delimited(delimiter, size_hint=size or 0), size ) @@ -306,30 +330,34 @@ async def read_until(self, delimiter, size=-1, consume_delimiter=False): # pass @property - def eof(self): + def eof(self) -> bool: """Whether the stream is at EOF.""" return self._exhausted and self._buffer_len == self._buffer_pos - def fileno(self): + def fileno(self) -> NoReturn: """Raise an instance of OSError since a file descriptor is not used.""" raise OSError('This IO object does not use a file descriptor') - def isatty(self): + def isatty(self) -> bool: """Return ``False`` always.""" return False - def readable(self): + def readable(self) -> bool: """Return ``True`` always.""" return True - def seekable(self): + def seekable(self) -> bool: """Return ``False`` always.""" return False - def writable(self): + def writable(self) -> bool: """Return ``False`` always.""" return False - def tell(self): + def tell(self) -> int: """Return the number of bytes read from the stream so far.""" return self._consumed - (self._buffer_len - self._buffer_pos) + + +class AsyncWritableIO(Protocol): + async def write(self, data: bytes, /) -> None: ... diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py index bd532feab..6213b1da1 100644 --- a/falcon/asgi/stream.py +++ b/falcon/asgi/stream.py @@ -14,7 +14,13 @@ """ASGI BoundedStream class.""" +from __future__ import annotations + +from typing import AsyncIterator, NoReturn, Optional + +from falcon.asgi_spec import AsgiEvent from falcon.errors import OperationNotAllowed +from falcon.typing import AsgiReceive __all__ = ('BoundedStream',) @@ -94,16 +100,28 @@ class BoundedStream: from the Content-Length header in the request (if available). """ - __slots__ = [ + __slots__ = ( '_buffer', '_bytes_remaining', '_closed', '_iteration_started', '_pos', '_receive', - ] - - def __init__(self, receive, first_event=None, content_length=None): + ) + + _buffer: bytes + _bytes_remaining: int + _closed: bool + _iteration_started: bool + _pos: int + _receive: AsgiReceive + + def __init__( + self, + receive: AsgiReceive, + first_event: Optional[AsgiEvent] = None, + content_length: Optional[int] = None, + ) -> None: self._closed = False self._iteration_started = False @@ -115,7 +133,7 @@ def __init__(self, receive, first_event=None, content_length=None): # object is created in other cases, use "in" here rather than # EAFP. if first_event and 'body' in first_event: - first_chunk = first_event['body'] + first_chunk: bytes = first_event['body'] else: first_chunk = b'' @@ -144,7 +162,7 @@ def __init__(self, receive, first_event=None, content_length=None): if not ('more_body' in first_event and first_event['more_body']): self._bytes_remaining = 0 - def __aiter__(self): + def __aiter__(self) -> AsyncIterator[bytes]: # NOTE(kgriffs): This returns an async generator, but that's OK because # it also implements the iterator protocol defined in PEP 492, albeit # in a more efficient way than a regular async iterator. @@ -161,41 +179,41 @@ def __aiter__(self): # readlines(), __iter__(), __next__(), flush(), seek(), # truncate(), __del__(). - def fileno(self): + def fileno(self) -> NoReturn: """Raise an instance of OSError since a file descriptor is not used.""" raise OSError('This IO object does not use a file descriptor') - def isatty(self): + def isatty(self) -> bool: """Return ``False`` always.""" return False - def readable(self): + def readable(self) -> bool: """Return ``True`` always.""" return True - def seekable(self): + def seekable(self) -> bool: """Return ``False`` always.""" return False - def writable(self): + def writable(self) -> bool: """Return ``False`` always.""" return False - def tell(self): + def tell(self) -> int: """Return the number of bytes read from the stream so far.""" return self._pos @property - def closed(self): + def closed(self) -> bool: return self._closed # ------------------------------------------------------------------------- @property - def eof(self): + def eof(self) -> bool: return not self._buffer and self._bytes_remaining == 0 - def close(self): + def close(self) -> None: """Clear any buffered data and close this stream. Once the stream is closed, any operation on it will @@ -211,7 +229,7 @@ def close(self): self._closed = True - async def exhaust(self): + async def exhaust(self) -> None: """Consume and immediately discard any remaining data in the stream.""" if self._closed: @@ -240,13 +258,13 @@ async def exhaust(self): self._bytes_remaining = 0 # Immediately dereference the data so it can be discarded ASAP - event = None + event = None # type: ignore[assignment] # NOTE(kgriffs): Ensure that if we read more than expected, this # value is normalized to zero. self._bytes_remaining = 0 - async def readall(self): + async def readall(self) -> bytes: """Read and return all remaining data in the request body. Warning: @@ -308,7 +326,7 @@ async def readall(self): return data - async def read(self, size=None): + async def read(self, size: Optional[int] = None) -> bytes: """Read some or all of the remaining bytes in the request body. Warning: @@ -401,7 +419,7 @@ async def read(self, size=None): return data - async def _iter_content(self): + async def _iter_content(self) -> AsyncIterator[bytes]: if self._closed: raise OperationNotAllowed( 'This stream is closed; no further operations on it are permitted.' diff --git a/falcon/asgi/structures.py b/falcon/asgi/structures.py index 22ebc1b7a..7e66c310b 100644 --- a/falcon/asgi/structures.py +++ b/falcon/asgi/structures.py @@ -38,31 +38,8 @@ class SSEvent: in any event that would otherwise be blank (i.e., one that does not specify any fields when initializing the `SSEvent` instance.) - Attributes: - data (bytes): Raw byte string to use as the ``data`` field for the - event message. Takes precedence over both `text` and `json`. - text (str): String to use for the ``data`` field in the message. Will - be encoded as UTF-8 in the event. Takes precedence over `json`. - json (object): JSON-serializable object to be converted to JSON and - used as the ``data`` field in the event message. - event (str): A string identifying the event type (AKA event name). - event_id (str): The event ID that the User Agent should use for - the `EventSource` object's last event ID value. - retry (int): The reconnection time to use when attempting to send the - event. This must be an integer, specifying the reconnection time - in milliseconds. - comment (str): Comment to include in the event message; this is - normally ignored by the user agent, but is useful when composing - a periodic "ping" message to keep the connection alive. Since this - is a common use case, a default "ping" comment will be included - in any event that would otherwise be blank (i.e., one that does - not specify any of these fields when initializing the - `SSEvent` instance.) - - .. _Server-Sent Events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events - """ __slots__ = [ @@ -75,6 +52,39 @@ class SSEvent: 'comment', ] + data: Optional[bytes] + """Raw byte string to use as the ``data`` field for the event message. + Takes precedence over both `text` and `json`. + """ + text: Optional[str] + """String to use for the ``data`` field in the message. + Will be encoded as UTF-8 in the event. Takes precedence over `json`. + """ + json: JSONSerializable + """JSON-serializable object to be converted to JSON and used as the ``data`` + field in the event message. + """ + event: Optional[str] + """A string identifying the event type (AKA event name).""" + event_id: Optional[str] + """The event ID that the User Agent should use for the `EventSource` object's + last event ID value. + """ + retry: Optional[int] + """The reconnection time to use when attempting to send the event. + + This must be an integer, specifying the reconnection time in milliseconds. + """ + comment: Optional[str] + """Comment to include in the event message. + + This is normally ignored by the user agent, but is useful when composing a periodic + "ping" message to keep the connection alive. Since this is a common use case, a + default "ping" comment will be included in any event that would otherwise be blank + (i.e., one that does not specify any of the fields when initializing the + :class:`SSEvent` instance.) + """ + def __init__( self, data: Optional[bytes] = None, diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py index 0599a25f3..4cd9a6a76 100644 --- a/falcon/asgi/ws.py +++ b/falcon/asgi/ws.py @@ -1,50 +1,35 @@ +from __future__ import annotations + import asyncio import collections +from enum import auto from enum import Enum -from typing import ( - Any, - Awaitable, - Callable, - Deque, - Dict, - Iterable, - Mapping, - Optional, - Union, -) +from typing import Any, Deque, Dict, Iterable, Mapping, Optional, Tuple, Union from falcon import errors from falcon import media from falcon import status_codes +from falcon.asgi_spec import AsgiEvent +from falcon.asgi_spec import AsgiSendMsg from falcon.asgi_spec import EventType from falcon.asgi_spec import WSCloseCode from falcon.constants import WebSocketPayloadType +from falcon.typing import AsgiReceive +from falcon.typing import AsgiSend +from falcon.typing import HeaderList from falcon.util import misc -_WebSocketState = Enum('_WebSocketState', 'HANDSHAKE ACCEPTED CLOSED') +__all__ = ('WebSocket',) -__all__ = ('WebSocket',) +class _WebSocketState(Enum): + HANDSHAKE = auto() + ACCEPTED = auto() + CLOSED = auto() class WebSocket: - """Represents a single WebSocket connection with a client. - - Attributes: - ready (bool): ``True`` if the WebSocket connection has been - accepted and the client is still connected, ``False`` otherwise. - unaccepted (bool)): ``True`` if the WebSocket connection has not yet - been accepted, ``False`` otherwise. - closed (bool): ``True`` if the WebSocket connection has been closed - by the server or the client has disconnected. - subprotocols (tuple[str]): The list of subprotocol strings advertised - by the client, or an empty tuple if no subprotocols were - specified. - supports_accept_headers (bool): ``True`` if the ASGI server hosting - the app supports sending headers when accepting the WebSocket - connection, ``False`` otherwise. - - """ + """Represents a single WebSocket connection with a client.""" __slots__ = ( '_asgi_receive', @@ -62,18 +47,25 @@ class WebSocket: 'subprotocols', ) + _state: _WebSocketState + _close_code: Optional[int] + subprotocols: Tuple[str, ...] + """The list of subprotocol strings advertised by the client, or an empty tuple if + no subprotocols were specified. + """ + def __init__( self, ver: str, - scope: dict, - receive: Callable[[], Awaitable[dict]], - send: Callable[[dict], Awaitable], + scope: Dict[str, Any], + receive: AsgiReceive, + send: AsgiSend, media_handlers: Mapping[ WebSocketPayloadType, Union[media.BinaryBaseHandlerWS, media.TextBaseHandlerWS], ], max_receive_queue: int, - default_close_reasons: Dict[Optional[int], str], + default_close_reasons: Dict[int, str], ): self._supports_accept_headers = ver != '2.0' self._supports_reason = _supports_reason(ver) @@ -102,14 +94,20 @@ def __init__( self._close_reasons = default_close_reasons self._state = _WebSocketState.HANDSHAKE - self._close_code = None # type: Optional[int] + self._close_code = None @property def unaccepted(self) -> bool: + """``True`` if the WebSocket connection has not yet been accepted, + ``False`` otherwise. + """ # noqa: D205 return self._state == _WebSocketState.HANDSHAKE @property def closed(self) -> bool: + """``True`` if the WebSocket connection has been closed by the server or the + client has disconnected. + """ # noqa: D205 return ( self._state == _WebSocketState.CLOSED or self._buffered_receiver.client_disconnected @@ -117,6 +115,9 @@ def closed(self) -> bool: @property def ready(self) -> bool: + """``True`` if the WebSocket connection has been accepted and the client is + still connected, ``False`` otherwise. + """ # noqa: D205 return ( self._state == _WebSocketState.ACCEPTED and not self._buffered_receiver.client_disconnected @@ -124,13 +125,16 @@ def ready(self) -> bool: @property def supports_accept_headers(self) -> bool: + """``True`` if the ASGI server hosting the app supports sending headers when + accepting the WebSocket connection, ``False`` otherwise. + """ # noqa: D205 return self._supports_accept_headers async def accept( self, subprotocol: Optional[str] = None, - headers: Optional[Union[Iterable[Iterable[str]], Mapping[str, str]]] = None, - ): + headers: Optional[HeaderList] = None, + ) -> None: """Accept the incoming WebSocket connection. If, after examining the connection's attributes (headers, advertised @@ -151,7 +155,7 @@ async def accept( client may choose to abandon the connection in this case, if it does not receive an explicit protocol selection. - headers (Iterable[[str, str]]): An iterable of ``[name: str, value: str]`` + headers (HeaderList): An iterable of ``(name: str, value: str)`` two-item iterables, representing a collection of HTTP headers to include in the handshake response. Both *name* and *value* must be of type ``str`` and contain only US-ASCII characters. @@ -196,13 +200,14 @@ async def accept( ) header_items = getattr(headers, 'items', None) - if callable(header_items): - headers = header_items() + headers_iterable: Iterable[tuple[str, str]] = header_items() + else: + headers_iterable = headers # type: ignore[assignment] event['headers'] = parsed_headers = [ (name.lower().encode('ascii'), value.encode('ascii')) - for name, value in headers # type: ignore + for name, value in headers_iterable ] for name, __ in parsed_headers: @@ -345,7 +350,6 @@ async def send_text(self, payload: str) -> None: """ self._require_accepted() - # NOTE(kgriffs): We have to check ourselves because some ASGI # servers are not very strict which can lead to hard-to-debug # errors. @@ -366,14 +370,13 @@ async def send_data(self, payload: Union[bytes, bytearray, memoryview]) -> None: payload (Union[bytes, bytearray, memoryview]): The binary data to send. """ + self._require_accepted() # NOTE(kgriffs): We have to check ourselves because some ASGI # servers are not very strict which can lead to hard-to-debug # errors. if not isinstance(payload, (bytes, bytearray, memoryview)): raise TypeError('payload must be a byte string') - self._require_accepted() - await self._send( { 'type': EventType.WS_SEND, @@ -461,7 +464,7 @@ async def receive_media(self) -> object: return self._mh_bin_deserialize(data) - async def _send(self, msg: dict): + async def _send(self, msg: AsgiSendMsg) -> None: if self._buffered_receiver.client_disconnected: self._state = _WebSocketState.CLOSED self._close_code = self._buffered_receiver.client_disconnected_code @@ -486,7 +489,7 @@ async def _send(self, msg: dict): # obscure the traceback. raise - async def _receive(self) -> dict: + async def _receive(self) -> AsgiEvent: event = await self._asgi_receive() event_type = event['type'] @@ -503,7 +506,7 @@ async def _receive(self) -> dict: return event - def _require_accepted(self): + def _require_accepted(self) -> None: if self._state == _WebSocketState.HANDSHAKE: raise errors.OperationNotAllowed( 'WebSocket connection has not yet been accepted' @@ -511,7 +514,7 @@ def _require_accepted(self): elif self._state == _WebSocketState.CLOSED: raise errors.WebSocketDisconnected(self._close_code) - def _translate_webserver_error(self, ex): + def _translate_webserver_error(self, ex: Exception) -> Optional[Exception]: s = str(ex) # NOTE(kgriffs): uvicorn or any other server using the "websockets" @@ -653,13 +656,20 @@ class _BufferedReceiver: 'client_disconnected_code', ] - def __init__(self, asgi_receive: Callable[[], Awaitable[dict]], max_queue: int): + _pop_message_waiter: Optional[asyncio.Future[None]] + _put_message_waiter: Optional[asyncio.Future[None]] + _pump_task: Optional[asyncio.Task[None]] + _messages: Deque[AsgiEvent] + client_disconnected: bool + client_disconnected_code: Optional[int] + + def __init__(self, asgi_receive: AsgiReceive, max_queue: int) -> None: self._asgi_receive = asgi_receive self._max_queue = max_queue self._loop = asyncio.get_running_loop() - self._messages: Deque[dict] = collections.deque() + self._messages = collections.deque() self._pop_message_waiter = None self._put_message_waiter = None @@ -668,12 +678,12 @@ def __init__(self, asgi_receive: Callable[[], Awaitable[dict]], max_queue: int): self.client_disconnected = False self.client_disconnected_code = None - def start(self): - if not self._pump_task: + def start(self) -> None: + if self._pump_task is None: self._pump_task = asyncio.create_task(self._pump()) - async def stop(self): - if not self._pump_task: + async def stop(self) -> None: + if self._pump_task is None: return self._pump_task.cancel() @@ -684,13 +694,14 @@ async def stop(self): self._pump_task = None - async def receive(self): + async def receive(self) -> AsgiEvent: # NOTE(kgriffs): Since this class is only used internally, we # use an assertion to mitigate against framework bugs. # # receive() may not be called again while another coroutine # is already waiting for the next message. - assert not self._pop_message_waiter + assert self._pop_message_waiter is None + assert self._pump_task is not None # NOTE(kgriffs): Wait for a message if none are available. This pattern # was borrowed from the websockets.protocol module. @@ -734,7 +745,7 @@ async def receive(self): return message - async def _pump(self): + async def _pump(self) -> None: while not self.client_disconnected: received_event = await self._asgi_receive() if received_event['type'] == EventType.WS_DISCONNECT: diff --git a/falcon/asgi_spec.py b/falcon/asgi_spec.py index 9fe12dbb9..3aedf9eda 100644 --- a/falcon/asgi_spec.py +++ b/falcon/asgi_spec.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import Any, Mapping +from typing import Any, Dict, Mapping class EventType: @@ -65,3 +65,5 @@ class WSCloseCode: # TODO: use a typed dict for event dicts AsgiEvent = Mapping[str, Any] +# TODO: use a typed dict for send msg dicts +AsgiSendMsg = Dict[str, Any] diff --git a/falcon/constants.py b/falcon/constants.py index 9576f0630..dbbb94934 100644 --- a/falcon/constants.py +++ b/falcon/constants.py @@ -1,3 +1,4 @@ +from enum import auto from enum import Enum import os import sys @@ -187,5 +188,8 @@ _UNSET = object() # TODO: remove once replaced with missing -WebSocketPayloadType = Enum('WebSocketPayloadType', 'TEXT BINARY') -"""Enum representing the two possible WebSocket payload types.""" +class WebSocketPayloadType(Enum): + """Enum representing the two possible WebSocket payload types.""" + + TEXT = auto() + BINARY = auto() diff --git a/falcon/http_status.py b/falcon/http_status.py index df7e0d455..1a591fcf4 100644 --- a/falcon/http_status.py +++ b/falcon/http_status.py @@ -40,19 +40,21 @@ class HTTPStatus(Exception): headers (dict): Extra headers to add to the response. text (str): String representing response content. Falcon will encode this value as UTF-8 in the response. - - Attributes: - status (Union[str,int]): The HTTP status line or integer code for - the status that this exception represents. - status_code (int): HTTP status code normalized from :attr:`status`. - headers (dict): Extra headers to add to the response. - text (str): String representing response content. Falcon will encode - this value as UTF-8 in the response. - """ __slots__ = ('status', 'headers', 'text') + status: ResponseStatus + """The HTTP status line or integer code for the status that this exception + represents. + """ + headers: Optional[HeaderList] + """Extra headers to add to the response.""" + text: Optional[str] + """String representing response content. + Falcon will encode this value as UTF-8 in the response. + """ + def __init__( self, status: ResponseStatus, @@ -65,10 +67,11 @@ def __init__( @property def status_code(self) -> int: + """HTTP status code normalized from :attr:`status`.""" return http_status_to_code(self.status) - @property # type: ignore - def body(self): + @property + def body(self) -> None: raise AttributeRemovedError( 'The body attribute is no longer supported. ' 'Please use the text attribute instead.' diff --git a/falcon/inspect.py b/falcon/inspect.py index 9aac44cb0..6d221f713 100644 --- a/falcon/inspect.py +++ b/falcon/inspect.py @@ -189,7 +189,7 @@ def inspect_middleware(app: App) -> 'MiddlewareInfo': current = [] for method in stack: _, name = _get_source_info_and_name(method) - cls = type(method.__self__) + cls = type(method.__self__) # type: ignore[union-attr] _, cls_name = _get_source_info_and_name(cls) current.append(MiddlewareTreeItemInfo(name, cls_name)) type_infos.append(current) @@ -201,12 +201,12 @@ def inspect_middleware(app: App) -> 'MiddlewareInfo': fns = app_helpers.prepare_middleware([m], True, app._ASGI) class_source_info, cls_name = _get_source_info_and_name(type(m)) methods = [] - for method, name in zip(fns, names): + for method, name in zip(fns, names): # type: ignore[assignment] if method: - real_func = method[0] + real_func = method[0] # type: ignore[index] source_info = _get_source_info(real_func) assert source_info - methods.append(MiddlewareMethodInfo(real_func.__name__, source_info)) + methods.append(MiddlewareMethodInfo(real_func.__name__, source_info)) # type: ignore[union-attr] assert class_source_info m_info = MiddlewareClassInfo(cls_name, class_source_info, methods) middlewareClasses.append(m_info) diff --git a/falcon/media/base.py b/falcon/media/base.py index 320b92bd0..70ceea776 100644 --- a/falcon/media/base.py +++ b/falcon/media/base.py @@ -6,7 +6,9 @@ from falcon.constants import MEDIA_JSON from falcon.typing import AsyncReadableIO +from falcon.typing import DeserializeSync from falcon.typing import ReadableIO +from falcon.typing import SerializeSync class BaseHandler(metaclass=abc.ABCMeta): @@ -19,10 +21,10 @@ class BaseHandler(metaclass=abc.ABCMeta): # might make it part of the public interface for use by custom # media type handlers. - _serialize_sync = None + _serialize_sync: Optional[SerializeSync] = None """Override to provide a synchronous serialization method that takes an object.""" - _deserialize_sync = None + _deserialize_sync: Optional[DeserializeSync] = None """Override to provide a synchronous deserialization method that takes a byte string.""" diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py index 7b368202d..e37d5e3b8 100644 --- a/falcon/media/handlers.py +++ b/falcon/media/handlers.py @@ -4,7 +4,6 @@ import functools from typing import ( Any, - Callable, cast, Dict, Literal, @@ -29,6 +28,8 @@ from falcon.media.multipart import MultipartFormHandler from falcon.media.multipart import MultipartParseOptions from falcon.media.urlencoded import URLEncodedFormHandler +from falcon.typing import DeserializeSync +from falcon.typing import SerializeSync from falcon.util import deprecation from falcon.util import misc from falcon.vendor import mimeparse @@ -54,9 +55,7 @@ def _raise(self, *args: Any, **kwargs: Any) -> NoReturn: _ResolverMethodReturnTuple = Tuple[ - BaseHandler, - Optional[Callable[[Any, Optional[str]], bytes]], - Optional[Callable[[bytes], Any]], + BaseHandler, Optional[SerializeSync], Optional[DeserializeSync] ] diff --git a/falcon/media/json.py b/falcon/media/json.py index f3f2cee1d..502be0126 100644 --- a/falcon/media/json.py +++ b/falcon/media/json.py @@ -1,10 +1,15 @@ +from __future__ import annotations + from functools import partial import json +from typing import Any, Callable, Optional, Union from falcon import errors from falcon import http_error from falcon.media.base import BaseHandler from falcon.media.base import TextBaseHandlerWS +from falcon.typing import AsyncReadableIO +from falcon.typing import ReadableIO class JSONHandler(BaseHandler): @@ -148,7 +153,11 @@ def default(self, obj): loads (func): Function to use when deserializing JSON requests. """ - def __init__(self, dumps=None, loads=None): + def __init__( + self, + dumps: Optional[Callable[[Any], Union[str, bytes]]] = None, + loads: Optional[Callable[[str], Any]] = None, + ) -> None: self._dumps = dumps or partial(json.dumps, ensure_ascii=False) self._loads = loads or json.loads @@ -156,11 +165,11 @@ def __init__(self, dumps=None, loads=None): # proper serialize implementation. result = self._dumps({'message': 'Hello World'}) if isinstance(result, str): - self.serialize = self._serialize_s - self.serialize_async = self._serialize_async_s + self.serialize = self._serialize_s # type: ignore[method-assign] + self.serialize_async = self._serialize_async_s # type: ignore[method-assign] else: - self.serialize = self._serialize_b - self.serialize_async = self._serialize_async_b + self.serialize = self._serialize_b # type: ignore[method-assign] + self.serialize_async = self._serialize_async_b # type: ignore[method-assign] # NOTE(kgriffs): To be safe, only enable the optimized protocol when # not subclassed. @@ -168,7 +177,7 @@ def __init__(self, dumps=None, loads=None): self._serialize_sync = self.serialize self._deserialize_sync = self._deserialize - def _deserialize(self, data): + def _deserialize(self, data: bytes) -> Any: if not data: raise errors.MediaNotFoundError('JSON') try: @@ -176,27 +185,41 @@ def _deserialize(self, data): except ValueError as err: raise errors.MediaMalformedError('JSON') from err - def deserialize(self, stream, content_type, content_length): + def deserialize( + self, + stream: ReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> Any: return self._deserialize(stream.read()) - async def deserialize_async(self, stream, content_type, content_length): + async def deserialize_async( + self, + stream: AsyncReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> Any: return self._deserialize(await stream.read()) # NOTE(kgriffs): Make content_type a kwarg to support the # Request.render_body() shortcut optimization. - def _serialize_s(self, media, content_type=None) -> bytes: - return self._dumps(media).encode() + def _serialize_s(self, media: Any, content_type: Optional[str] = None) -> bytes: + return self._dumps(media).encode() # type: ignore[union-attr] - async def _serialize_async_s(self, media, content_type) -> bytes: - return self._dumps(media).encode() + async def _serialize_async_s( + self, media: Any, content_type: Optional[str] + ) -> bytes: + return self._dumps(media).encode() # type: ignore[union-attr] # NOTE(kgriffs): Make content_type a kwarg to support the # Request.render_body() shortcut optimization. - def _serialize_b(self, media, content_type=None) -> bytes: - return self._dumps(media) + def _serialize_b(self, media: Any, content_type: Optional[str] = None) -> bytes: + return self._dumps(media) # type: ignore[return-value] - async def _serialize_async_b(self, media, content_type) -> bytes: - return self._dumps(media) + async def _serialize_async_b( + self, media: Any, content_type: Optional[str] + ) -> bytes: + return self._dumps(media) # type: ignore[return-value] class JSONHandlerWS(TextBaseHandlerWS): @@ -257,7 +280,11 @@ class JSONHandlerWS(TextBaseHandlerWS): __slots__ = ['dumps', 'loads'] - def __init__(self, dumps=None, loads=None): + def __init__( + self, + dumps: Optional[Callable[[Any], str]] = None, + loads: Optional[Callable[[str], Any]] = None, + ) -> None: self._dumps = dumps or partial(json.dumps, ensure_ascii=False) self._loads = loads or json.loads @@ -268,4 +295,4 @@ def deserialize(self, payload: str) -> object: return self._loads(payload) -http_error._DEFAULT_JSON_HANDLER = _DEFAULT_JSON_HANDLER = JSONHandler() # type: ignore +http_error._DEFAULT_JSON_HANDLER = _DEFAULT_JSON_HANDLER = JSONHandler() diff --git a/falcon/media/msgpack.py b/falcon/media/msgpack.py index 0267e2511..5b8c587c9 100644 --- a/falcon/media/msgpack.py +++ b/falcon/media/msgpack.py @@ -1,10 +1,12 @@ -from __future__ import absolute_import # NOTE(kgriffs): Work around a Cython bug +from __future__ import annotations -from typing import Union +from typing import Any, Callable, Optional, Protocol from falcon import errors from falcon.media.base import BaseHandler from falcon.media.base import BinaryBaseHandlerWS +from falcon.typing import AsyncReadableIO +from falcon.typing import ReadableIO class MessagePackHandler(BaseHandler): @@ -28,7 +30,10 @@ class MessagePackHandler(BaseHandler): $ pip install msgpack """ - def __init__(self): + _pack: Callable[[Any], bytes] + _unpackb: UnpackMethod + + def __init__(self) -> None: import msgpack packer = msgpack.Packer(autoreset=True, use_bin_type=True) @@ -38,10 +43,10 @@ def __init__(self): # NOTE(kgriffs): To be safe, only enable the optimized protocol when # not subclassed. if type(self) is MessagePackHandler: - self._serialize_sync = self._pack + self._serialize_sync = self._pack # type: ignore[assignment] self._deserialize_sync = self._deserialize - def _deserialize(self, data): + def _deserialize(self, data: bytes) -> Any: if not data: raise errors.MediaNotFoundError('MessagePack') try: @@ -51,16 +56,26 @@ def _deserialize(self, data): except ValueError as err: raise errors.MediaMalformedError('MessagePack') from err - def deserialize(self, stream, content_type, content_length): + def deserialize( + self, + stream: ReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> Any: return self._deserialize(stream.read()) - async def deserialize_async(self, stream, content_type, content_length): + async def deserialize_async( + self, + stream: AsyncReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> Any: return self._deserialize(await stream.read()) - def serialize(self, media, content_type) -> bytes: + def serialize(self, media: Any, content_type: Optional[str]) -> bytes: return self._pack(media) - async def serialize_async(self, media, content_type) -> bytes: + async def serialize_async(self, media: Any, content_type: Optional[str]) -> bytes: return self._pack(media) @@ -81,19 +96,26 @@ class MessagePackHandlerWS(BinaryBaseHandlerWS): $ pip install msgpack """ - __slots__ = ['msgpack', 'packer'] + __slots__ = ('msgpack', 'packer') + + _pack: Callable[[Any], bytes] + _unpackb: UnpackMethod - def __init__(self): + def __init__(self) -> None: import msgpack packer = msgpack.Packer(autoreset=True, use_bin_type=True) self._pack = packer.pack self._unpackb = msgpack.unpackb - def serialize(self, media: object) -> Union[bytes, bytearray, memoryview]: + def serialize(self, media: object) -> bytes: return self._pack(media) - def deserialize(self, payload: bytes) -> object: + def deserialize(self, payload: bytes) -> Any: # NOTE(jmvrbanac): Using unpackb since we would need to manage # a buffer for Unpacker() which wouldn't gain us much. return self._unpackb(payload, raw=False) + + +class UnpackMethod(Protocol): + def __call__(self, data: bytes, raw: bool = ...) -> Any: ... diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py index 901cbe67e..4e08b5306 100644 --- a/falcon/media/multipart.py +++ b/falcon/media/multipart.py @@ -17,7 +17,7 @@ from __future__ import annotations import re -from typing import ClassVar, TYPE_CHECKING +from typing import Any, ClassVar, Dict, Optional, Tuple, Type, TYPE_CHECKING from urllib.parse import unquote_to_bytes from falcon import errors @@ -29,6 +29,7 @@ from falcon.util.mediatypes import parse_header if TYPE_CHECKING: + from falcon.asgi.multipart import MultipartForm as AsgiMultipartForm from falcon.media import Handlers # TODO(vytas): @@ -189,11 +190,11 @@ class BodyPart: decoded_text = await part.text """ - _content_disposition = None - _data = None - _filename = None - _media = None - _name = None + _content_disposition: Optional[Tuple[str, Dict[str, str]]] = None + _data: Optional[bytes] = None + _filename: Optional[str] = None + _media: Optional[Any] = None + _name: Optional[str] = None def __init__(self, stream, headers, parse_options): self.stream = stream @@ -488,7 +489,7 @@ class MultipartFormHandler(BaseHandler): See also: :ref:`multipart_parser_conf`. """ - _ASGI_MULTIPART_FORM = None + _ASGI_MULTIPART_FORM: ClassVar[Type[AsgiMultipartForm]] def __init__(self, parse_options=None): self.parse_options = parse_options or MultipartParseOptions() diff --git a/falcon/media/urlencoded.py b/falcon/media/urlencoded.py index 17f73dd65..1d7f6cb04 100644 --- a/falcon/media/urlencoded.py +++ b/falcon/media/urlencoded.py @@ -1,7 +1,12 @@ +from __future__ import annotations + +from typing import Any, Optional from urllib.parse import urlencode from falcon import errors from falcon.media.base import BaseHandler +from falcon.typing import AsyncReadableIO +from falcon.typing import ReadableIO from falcon.util.uri import parse_query_string @@ -28,7 +33,7 @@ class URLEncodedFormHandler(BaseHandler): when deserializing. """ - def __init__(self, keep_blank=True, csv=False): + def __init__(self, keep_blank: bool = True, csv: bool = False) -> None: self._keep_blank = keep_blank self._csv = csv @@ -40,23 +45,35 @@ def __init__(self, keep_blank=True, csv=False): # NOTE(kgriffs): Make content_type a kwarg to support the # Request.render_body() shortcut optimization. - def serialize(self, media, content_type=None) -> bytes: + def serialize(self, media: Any, content_type: Optional[str] = None) -> bytes: # NOTE(vytas): Setting doseq to True to mirror the parse_query_string # behaviour. return urlencode(media, doseq=True).encode() - def _deserialize(self, body): + def _deserialize(self, body: bytes) -> Any: try: # NOTE(kgriffs): According to http://goo.gl/6rlcux the # body should be US-ASCII. Enforcing this also helps # catch malicious input. - body = body.decode('ascii') - return parse_query_string(body, keep_blank=self._keep_blank, csv=self._csv) + body_str = body.decode('ascii') + return parse_query_string( + body_str, keep_blank=self._keep_blank, csv=self._csv + ) except Exception as err: raise errors.MediaMalformedError('URL-encoded') from err - def deserialize(self, stream, content_type, content_length): + def deserialize( + self, + stream: ReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> Any: return self._deserialize(stream.read()) - async def deserialize_async(self, stream, content_type, content_length): + async def deserialize_async( + self, + stream: AsyncReadableIO, + content_type: Optional[str], + content_length: Optional[int], + ) -> Any: return self._deserialize(await stream.read()) diff --git a/falcon/response.py b/falcon/response.py index 2a0a3e834..bc676c31a 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -19,7 +19,7 @@ from datetime import timezone import functools import mimetypes -from typing import Dict, Optional +from typing import Dict from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES from falcon.constants import _UNSET @@ -208,14 +208,14 @@ def status_code(self) -> int: def status_code(self, value): self.status = value - @property # type: ignore + @property def body(self): raise AttributeRemovedError( 'The body attribute is no longer supported. ' 'Please use the text attribute instead.' ) - @body.setter # type: ignore + @body.setter def body(self, value): raise AttributeRemovedError( 'The body attribute is no longer supported. ' @@ -1233,7 +1233,7 @@ class ResponseOptions: This can make testing easier by not requiring HTTPS. Note, however, that this setting can be overridden via :meth:`~.Response.set_cookie()`'s ``secure`` kwarg. """ - default_media_type: Optional[str] + default_media_type: str """The default Internet media type (RFC 2046) to use when rendering a response, when the Content-Type header is not set explicitly. diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 961af5a1a..443d0d4f3 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -38,6 +38,7 @@ from falcon.routing import converters from falcon.routing.util import map_http_methods from falcon.routing.util import set_default_responders +from falcon.typing import MethodDict from falcon.util.misc import is_python_func from falcon.util.sync import _should_wrap_non_coroutines from falcon.util.sync import wrap_sync_to_async @@ -46,7 +47,6 @@ from falcon import Request _CxElement = Union['_CxParent', '_CxChild'] - _MethodDict = Dict[str, Callable] _TAB_STR = ' ' * 4 _FIELD_PATTERN = re.compile( @@ -135,7 +135,7 @@ def finder_src(self) -> str: self.find('/') return self._finder_src - def map_http_methods(self, resource: object, **kwargs: Any) -> _MethodDict: + def map_http_methods(self, resource: object, **kwargs: Any) -> MethodDict: """Map HTTP methods (e.g., GET, POST) to methods of a resource object. This method is called from :meth:`~.add_route` and may be overridden to @@ -309,7 +309,7 @@ def insert(nodes: List[CompiledRouterNode], path_index: int = 0): # to multiple classes, since the symbol is imported only for type check. def find( self, uri: str, req: Optional['Request'] = None - ) -> Optional[Tuple[object, Optional[_MethodDict], Dict[str, Any], Optional[str]]]: + ) -> Optional[Tuple[object, MethodDict, Dict[str, Any], Optional[str]]]: """Search for a route that matches the given partial URI. Args: @@ -334,7 +334,7 @@ def find( ) if node is not None: - return node.resource, node.method_map, params, node.uri_template + return node.resource, node.method_map or {}, params, node.uri_template else: return None @@ -342,7 +342,7 @@ def find( # Private # ----------------------------------------------------------------- - def _require_coroutine_responders(self, method_map: _MethodDict) -> None: + def _require_coroutine_responders(self, method_map: MethodDict) -> None: for method, responder in method_map.items(): # NOTE(kgriffs): We don't simply wrap non-async functions # since they likely perform relatively long blocking @@ -366,7 +366,7 @@ def let(responder=responder): msg = msg.format(responder) raise TypeError(msg) - def _require_non_coroutine_responders(self, method_map: _MethodDict) -> None: + def _require_non_coroutine_responders(self, method_map: MethodDict) -> None: for method, responder in method_map.items(): # NOTE(kgriffs): We don't simply wrap non-async functions # since they likely perform relatively long blocking @@ -682,7 +682,7 @@ def _compile(self) -> Callable: self._finder_src = '\n'.join(src_lines) - scope: _MethodDict = {} + scope: MethodDict = {} exec(compile(self._finder_src, '', 'exec'), scope) return scope['find'] @@ -742,7 +742,7 @@ class CompiledRouterNode: def __init__( self, raw_segment: str, - method_map: Optional[_MethodDict] = None, + method_map: Optional[MethodDict] = None, resource: Optional[object] = None, uri_template: Optional[str] = None, ): diff --git a/falcon/routing/static.py b/falcon/routing/static.py index 93a076a52..d07af4211 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -1,13 +1,25 @@ +from __future__ import annotations + import asyncio from functools import partial import io import os +from pathlib import Path import re +from typing import Any, ClassVar, IO, Optional, Pattern, Tuple, TYPE_CHECKING, Union import falcon +if TYPE_CHECKING: + from falcon import asgi + from falcon import Request + from falcon import Response +from falcon.typing import ReadableIO + -def _open_range(file_path, req_range): +def _open_range( + file_path: Union[str, Path], req_range: Optional[Tuple[int, int]] +) -> Tuple[ReadableIO, int, Optional[Tuple[int, int, int]]]: """Open a file for a ranged request. Args: @@ -68,14 +80,14 @@ class _BoundedFile: length (int): Number of bytes that may be read. """ - def __init__(self, fh, length): + def __init__(self, fh: IO[bytes], length: int) -> None: self.fh = fh self.close = fh.close self.remaining = length - def read(self, size=-1): + def read(self, size: Optional[int] = -1) -> bytes: """Read the underlying file object, within the specified bounds.""" - if size < 0: + if size is None or size < 0: size = self.remaining else: size = min(size, self.remaining) @@ -116,16 +128,27 @@ class StaticRoute: """ # NOTE(kgriffs): Don't allow control characters and reserved chars - _DISALLOWED_CHARS_PATTERN = re.compile('[\x00-\x1f\x80-\x9f\ufffd~?<>:*|\'"]') + _DISALLOWED_CHARS_PATTERN: ClassVar[Pattern[str]] = re.compile( + '[\x00-\x1f\x80-\x9f\ufffd~?<>:*|\'"]' + ) # NOTE(vytas): Match the behavior of the underlying os.path.normpath. - _DISALLOWED_NORMALIZED_PREFIXES = ('..' + os.path.sep, os.path.sep) + _DISALLOWED_NORMALIZED_PREFIXES: ClassVar[Tuple[str, ...]] = ( + '..' + os.path.sep, + os.path.sep, + ) # NOTE(kgriffs): If somehow an executable code exploit is triggerable, this # minimizes how much can be included in the payload. - _MAX_NON_PREFIXED_LEN = 512 - - def __init__(self, prefix, directory, downloadable=False, fallback_filename=None): + _MAX_NON_PREFIXED_LEN: ClassVar[int] = 512 + + def __init__( + self, + prefix: str, + directory: Union[str, Path], + downloadable: bool = False, + fallback_filename: Optional[str] = None, + ) -> None: if not prefix.startswith('/'): raise ValueError("prefix must start with '/'") @@ -151,15 +174,15 @@ def __init__(self, prefix, directory, downloadable=False, fallback_filename=None self._prefix = prefix self._downloadable = downloadable - def match(self, path): + def match(self, path: str) -> bool: """Check whether the given path matches this route.""" if self._fallback_filename is None: return path.startswith(self._prefix) return path.startswith(self._prefix) or path == self._prefix[:-1] - def __call__(self, req, resp): + def __call__(self, req: Request, resp: Response, **kw: Any) -> None: """Resource responder for this route.""" - + assert not kw without_prefix = req.path[len(self._prefix) :] # NOTE(kgriffs): Check surrounding whitespace and strip trailing @@ -222,8 +245,8 @@ def __call__(self, req, resp): class StaticRouteAsync(StaticRoute): """Subclass of StaticRoute with modifications to support ASGI apps.""" - async def __call__(self, req, resp): - super().__call__(req, resp) + async def __call__(self, req: asgi.Request, resp: asgi.Response, **kw: Any) -> None: # type: ignore[override] + super().__call__(req, resp, **kw) # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking resp.stream = _AsyncFileReader(resp.stream) @@ -232,7 +255,7 @@ async def __call__(self, req, resp): class _AsyncFileReader: """Adapts a standard file I/O object so that reads are non-blocking.""" - def __init__(self, file): + def __init__(self, file: IO[bytes]) -> None: self._file = file self._loop = asyncio.get_running_loop() diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index f2cbcb45b..e21961125 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -26,6 +26,7 @@ from collections import defaultdict from collections import deque import contextlib +from enum import auto from enum import Enum import io import itertools @@ -365,7 +366,12 @@ async def collect(self, event: Dict[str, Any]): __call__ = collect -_WebSocketState = Enum('_WebSocketState', 'CONNECT HANDSHAKE ACCEPTED DENIED CLOSED') +class _WebSocketState(Enum): + CONNECT = auto() + HANDSHAKE = auto() + ACCEPTED = auto() + DENIED = auto() + CLOSED = auto() class ASGIWebSocketSimulator: diff --git a/falcon/testing/test_case.py b/falcon/testing/test_case.py index 1b07b97f4..1cb95328c 100644 --- a/falcon/testing/test_case.py +++ b/falcon/testing/test_case.py @@ -21,7 +21,7 @@ try: import testtools as unittest except ImportError: # pragma: nocover - import unittest # type: ignore + import unittest import falcon import falcon.request diff --git a/falcon/typing.py b/falcon/typing.py index 4ce772602..6a7fe3813 100644 --- a/falcon/typing.py +++ b/falcon/typing.py @@ -18,6 +18,7 @@ from enum import auto from enum import Enum import http +import sys from typing import ( Any, Awaitable, @@ -34,9 +35,22 @@ Union, ) +# NOTE(vytas): Mypy still struggles to handle a conditional import in the EAFP +# fashion, so we branch on Py version instead (which it does understand). +if sys.version_info >= (3, 11): + from wsgiref.types import StartResponse as StartResponse + from wsgiref.types import WSGIEnvironment as WSGIEnvironment +else: + WSGIEnvironment = Dict[str, Any] + StartResponse = Callable[[str, List[Tuple[str, str]]], Callable[[bytes], None]] + if TYPE_CHECKING: - from falcon import asgi + from falcon.asgi import Request as AsgiRequest + from falcon.asgi import Response as AsgiResponse + from falcon.asgi import WebSocket from falcon.asgi_spec import AsgiEvent + from falcon.asgi_spec import AsgiSendMsg + from falcon.http_error import HTTPError from falcon.request import Request from falcon.response import Response @@ -52,10 +66,23 @@ class _Missing(Enum): Link = Dict[str, str] # Error handlers -ErrorHandler = Callable[['Request', 'Response', BaseException, dict], Any] +ErrorHandler = Callable[['Request', 'Response', BaseException, Dict[str, Any]], None] + + +class AsgiErrorHandler(Protocol): + async def __call__( + self, + req: AsgiRequest, + resp: Optional[AsgiResponse], + error: BaseException, + params: Dict[str, Any], + *, + ws: Optional[WebSocket] = ..., + ) -> None: ... + # Error serializers -ErrorSerializer = Callable[['Request', 'Response', BaseException], Any] +ErrorSerializer = Callable[['Request', 'Response', 'HTTPError'], None] JSONSerializable = Union[ Dict[str, 'JSONSerializable'], @@ -69,7 +96,18 @@ class _Missing(Enum): ] # Sinks -SinkPrefix = Union[str, Pattern] +SinkPrefix = Union[str, Pattern[str]] + + +class SinkCallable(Protocol): + def __call__(self, req: Request, resp: Response, **kwargs: str) -> None: ... + + +class AsgiSinkCallable(Protocol): + async def __call__( + self, req: AsgiRequest, resp: AsgiResponse, **kwargs: str + ) -> None: ... + # TODO(vytas): Is it possible to specify a Callable or a Protocol that defines # type hints for the two first parameters, but accepts any number of keyword @@ -93,10 +131,22 @@ def __call__( ) -> None: ... +# WSGI class ReadableIO(Protocol): def read(self, n: Optional[int] = ..., /) -> bytes: ... +ProcessRequestMethod = Callable[['Request', 'Response'], None] +ProcessResourceMethod = Callable[ + ['Request', 'Response', Resource, Dict[str, Any]], None +] +ProcessResponseMethod = Callable[['Request', 'Response', Resource, bool], None] + + +class ResponderCallable(Protocol): + def __call__(self, req: Request, resp: Response, **kwargs: Any) -> None: ... + + # ASGI class AsyncReadableIO(Protocol): async def read(self, n: Optional[int] = ..., /) -> bytes: ... @@ -106,12 +156,58 @@ class AsgiResponderMethod(Protocol): async def __call__( self, resource: Resource, - req: asgi.Request, - resp: asgi.Response, + req: AsgiRequest, + resp: AsgiResponse, **kwargs: Any, ) -> None: ... AsgiReceive = Callable[[], Awaitable['AsgiEvent']] +AsgiSend = Callable[['AsgiSendMsg'], Awaitable[None]] +AsgiProcessRequestMethod = Callable[['AsgiRequest', 'AsgiResponse'], Awaitable[None]] +AsgiProcessResourceMethod = Callable[ + ['AsgiRequest', 'AsgiResponse', Resource, Dict[str, Any]], Awaitable[None] +] +AsgiProcessResponseMethod = Callable[ + ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None] +] +AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]] +AsgiProcessResourceWsMethod = Callable[ + ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None] +] + + +class AsgiResponderCallable(Protocol): + async def __call__( + self, req: AsgiRequest, resp: AsgiResponse, **kwargs: Any + ) -> None: ... + + +class AsgiResponderWsCallable(Protocol): + async def __call__( + self, req: AsgiRequest, ws: WebSocket, **kwargs: Any + ) -> None: ... + + +# Routing + +MethodDict = Union[ + Dict[str, ResponderCallable], + Dict[str, Union[AsgiResponderCallable, AsgiResponderWsCallable]], +] + + +class FindMethod(Protocol): + def __call__( + self, uri: str, req: Optional[Request] + ) -> Optional[Tuple[object, MethodDict, Dict[str, Any], Optional[str]]]: ... + + +# Media +class SerializeSync(Protocol): + def __call__(self, media: Any, content_type: Optional[str] = ...) -> bytes: ... + + +DeserializeSync = Callable[[bytes], Any] Responder = Union[ResponderMethod, AsgiResponderMethod] diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index dfb239ce1..03d810e9a 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -59,7 +59,7 @@ # subclass of Morsel. _reserved_cookie_attrs = http_cookies.Morsel._reserved # type: ignore if 'samesite' not in _reserved_cookie_attrs: # pragma: no cover - _reserved_cookie_attrs['samesite'] = 'SameSite' # type: ignore + _reserved_cookie_attrs['samesite'] = 'SameSite' # NOTE(m-mueller): Same for the 'partitioned' attribute that will # probably be added in Python 3.13. if 'partitioned' not in _reserved_cookie_attrs: # pragma: no cover diff --git a/falcon/util/mediatypes.py b/falcon/util/mediatypes.py index c7812bbeb..eebed0446 100644 --- a/falcon/util/mediatypes.py +++ b/falcon/util/mediatypes.py @@ -14,12 +14,14 @@ """Media (aka MIME) type parsing and matching utilities.""" -import typing +from __future__ import annotations + +from typing import Dict, Iterator, Tuple __all__ = ('parse_header',) -def _parse_param_old_stdlib(s): # type: ignore +def _parse_param_old_stdlib(s: str) -> Iterator[str]: while s[:1] == ';': s = s[1:] end = s.find(';') @@ -32,7 +34,7 @@ def _parse_param_old_stdlib(s): # type: ignore s = s[end:] -def _parse_header_old_stdlib(line): # type: ignore +def _parse_header_old_stdlib(line: str) -> Tuple[str, Dict[str, str]]: """Parse a Content-type like header. Return the main content-type and a dictionary of options. @@ -43,7 +45,7 @@ def _parse_header_old_stdlib(line): # type: ignore """ parts = _parse_param_old_stdlib(';' + line) key = parts.__next__() - pdict = {} + pdict: Dict[str, str] = {} for p in parts: i = p.find('=') if i >= 0: @@ -56,7 +58,7 @@ def _parse_header_old_stdlib(line): # type: ignore return key, pdict -def parse_header(line: str) -> typing.Tuple[str, dict]: +def parse_header(line: str) -> Tuple[str, Dict[str, str]]: """Parse a Content-type like header. Return the main content-type and a dictionary of options. diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 835dcbd29..18a27b95e 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -63,6 +63,8 @@ _UNSAFE_CHARS = re.compile(r'[^a-zA-Z0-9.-]') +_UTC_TIMEZONE = datetime.timezone.utc + # PERF(kgriffs): Avoid superfluous namespace lookups _strptime: Callable[[str, str], datetime.datetime] = datetime.datetime.strptime _utcnow: Callable[[], datetime.datetime] = functools.partial( @@ -99,7 +101,7 @@ def decorator(func: Callable) -> Callable: if PYPY: _lru_cache_for_simple_logic = _lru_cache_nop # pragma: nocover else: - _lru_cache_for_simple_logic = functools.lru_cache # type: ignore + _lru_cache_for_simple_logic = functools.lru_cache def is_python_func(func: Union[Callable, Any]) -> bool: @@ -170,15 +172,20 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime Raises: ValueError: http_date doesn't match any of the available time formats + ValueError: http_date doesn't match allowed timezones """ - if not obs_date: # PERF(kgriffs): This violates DRY, but we do it anyway # to avoid the overhead of setting up a tuple, looping # over it, and setting up exception handling blocks each # time around the loop, in the case that we don't actually # need to check for multiple formats. - return _strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z') + # NOTE(vytas): According to RFC 9110, Section 5.6.7, the only allowed + # value for the TIMEZONE field [of IMF-fixdate] is %s"GMT", so we + # simply hardcode GMT in the strptime expression. + return _strptime(http_date, '%a, %d %b %Y %H:%M:%S GMT').replace( + tzinfo=_UTC_TIMEZONE + ) time_formats = ( '%a, %d %b %Y %H:%M:%S %Z', @@ -190,7 +197,15 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime # Loop through the formats and return the first that matches for time_format in time_formats: try: - return _strptime(http_date, time_format) + # NOTE(chgad,vytas): As per now-obsolete RFC 850, Section 2.1.4 + # (and later references in newer RFCs) the TIMEZONE field may be + # be one of many abbreviations such as EST, MDT, etc; which are + # not equivalent to UTC. + # However, Python seems unable to parse any such abbreviations + # except GMT and UTC due to a bug/lacking implementation + # (see https://github.com/python/cpython/issues/66571); so we can + # indiscriminately assume UTC after all. + return _strptime(http_date, time_format).replace(tzinfo=_UTC_TIMEZONE) except ValueError: continue @@ -285,7 +300,7 @@ def get_bound_method(obj: object, method_name: str) -> Union[None, Callable[..., return method -def get_argnames(func: Callable) -> List[str]: +def get_argnames(func: Callable[..., Any]) -> List[str]: """Introspect the arguments of a callable. Args: diff --git a/pyproject.toml b/pyproject.toml index 5d1cf5841..214ba7bde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ "falcon/vendor", ] disallow_untyped_defs = true + warn_unused_ignores = true [[tool.mypy.overrides]] module = [ @@ -41,18 +42,10 @@ [[tool.mypy.overrides]] module = [ - "falcon.app", - "falcon.asgi._asgi_helpers", - "falcon.asgi.app", "falcon.asgi.multipart", - "falcon.asgi.reader", "falcon.asgi.response", "falcon.asgi.stream", - "falcon.asgi.ws", - "falcon.media.json", - "falcon.media.msgpack", "falcon.media.multipart", - "falcon.media.urlencoded", "falcon.media.validators.*", "falcon.responders", "falcon.response_helpers", diff --git a/requirements/docs b/requirements/docs index e59d178cf..888447d50 100644 --- a/requirements/docs +++ b/requirements/docs @@ -4,6 +4,7 @@ jinja2 markupsafe pygments pygments-style-github +PyYAML sphinx sphinx_rtd_theme sphinx-tabs diff --git a/setup.cfg b/setup.cfg index 785692f7e..830760163 100644 --- a/setup.cfg +++ b/setup.cfg @@ -77,7 +77,7 @@ console_scripts = [egg_info] # TODO replace -tag_build = dev1 +tag_build = dev2 [aliases] test=pytest diff --git a/tests/asgi/test_hello_asgi.py b/tests/asgi/test_hello_asgi.py index dc1527a34..2ac20d959 100644 --- a/tests/asgi/test_hello_asgi.py +++ b/tests/asgi/test_hello_asgi.py @@ -9,9 +9,9 @@ import falcon.asgi try: - import aiofiles # type: ignore + import aiofiles except ImportError: - aiofiles = None # type: ignore + aiofiles = None # type: ignore[assignment] SIZE_1_KB = 1024 diff --git a/tests/asgi/test_response_media_asgi.py b/tests/asgi/test_response_media_asgi.py index 01236de2e..a0a64f9d9 100644 --- a/tests/asgi/test_response_media_asgi.py +++ b/tests/asgi/test_response_media_asgi.py @@ -10,9 +10,9 @@ from falcon.util.deprecation import DeprecatedWarning try: - import msgpack # type: ignore + import msgpack except ImportError: - msgpack = None # type: ignore + msgpack = None def create_client(resource, handlers=None): diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index 1f432ddb0..c4d2a7d1e 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -14,21 +14,21 @@ from falcon.testing.helpers import _WebSocketState as ClientWebSocketState try: - import cbor2 # type: ignore + import cbor2 except ImportError: - cbor2 = None # type: ignore + cbor2 = None # type: ignore[assignment] try: - import msgpack # type: ignore + import msgpack except ImportError: - msgpack = None # type: ignore + msgpack = None try: - import rapidjson # type: ignore + import rapidjson except ImportError: - rapidjson = None # type: ignore + rapidjson = None # type: ignore[assignment] # NOTE(kgriffs): We do not use codes defined in the framework because we @@ -1346,12 +1346,12 @@ async def process_resource_ws(self, req, ws, res, params): if handler_has_ws: - async def handle_foobar(req, resp, ex, param, ws=None): # type: ignore + async def handle_foobar(req, resp, ex, param, ws=None): raise thing(status) else: - async def handle_foobar(req, resp, ex, param): # type: ignore + async def handle_foobar(req, resp, ex, param): # type: ignore[misc] raise thing(status) conductor.app.add_route('/', Resource()) diff --git a/tests/bare_bones.py b/tests/bare_bones.py deleted file mode 100644 index 1741de5ee..000000000 --- a/tests/bare_bones.py +++ /dev/null @@ -1,21 +0,0 @@ -import falcon - - -class Things: - def on_get(self, req, resp): - pass - - -api = application = falcon.App() -api.add_route('/', Things()) - - -if __name__ == '__main__': - # import eventlet.wsgi - # import eventlet - # eventlet.wsgi.server(eventlet.listen(('localhost', 8000)), application) - - from wsgiref.simple_server import make_server - - server = make_server('localhost', 8000, application) - server.serve_forever() diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 84e09d33d..46ae0328e 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -9,6 +9,7 @@ import falcon import falcon.testing as testing from falcon.util import http_date_to_dt +from falcon.util.misc import _utcnow UNICODE_TEST_STRING = 'Unicode_\xc3\xa6\xc3\xb8' @@ -172,7 +173,7 @@ def test_response_complex_case(client): assert cookie.domain is None assert cookie.same_site == 'Lax' - assert cookie.expires < utcnow_naive() + assert cookie.expires < _utcnow() # NOTE(kgriffs): I know accessing a private attr like this is # naughty of me, but we just need to sanity-check that the @@ -194,7 +195,7 @@ def test(cookie, path, domain, samesite='Lax'): assert cookie.domain == domain assert cookie.path == path assert cookie.same_site == samesite - assert cookie.expires < utcnow_naive() + assert cookie.expires < _utcnow() test(result.cookies['foo'], path=None, domain=None) test(result.cookies['bar'], path='/bar', domain=None) @@ -232,7 +233,7 @@ def test_set(cookie, value, samesite=None): def test_unset(cookie, samesite='Lax'): assert cookie.value == '' # An unset cookie has an empty value assert cookie.same_site == samesite - assert cookie.expires < utcnow_naive() + assert cookie.expires < _utcnow() test_unset(result_unset.cookies['foo'], samesite='Strict') # default: bar is unset with no samesite param, so should go to Lax @@ -255,7 +256,7 @@ def test_cookie_expires_naive(client): cookie = result.cookies['foo'] assert cookie.value == 'bar' assert cookie.domain is None - assert cookie.expires == datetime(year=2050, month=1, day=1) + assert cookie.expires == datetime(year=2050, month=1, day=1, tzinfo=timezone.utc) assert not cookie.http_only assert cookie.max_age is None assert cookie.path is None @@ -268,7 +269,9 @@ def test_cookie_expires_aware(client): cookie = result.cookies['foo'] assert cookie.value == 'bar' assert cookie.domain is None - assert cookie.expires == datetime(year=2049, month=12, day=31, hour=23) + assert cookie.expires == datetime( + year=2049, month=12, day=31, hour=23, tzinfo=timezone.utc + ) assert not cookie.http_only assert cookie.max_age is None assert cookie.path is None @@ -326,7 +329,7 @@ def test_response_unset_cookie(): assert match expiration = http_date_to_dt(match.group(1), obs_date=True) - assert expiration < utcnow_naive() + assert expiration < _utcnow() # ===================================================================== diff --git a/tests/test_error_handlers.py b/tests/test_error_handlers.py index 751323c20..5bac0842a 100644 --- a/tests/test_error_handlers.py +++ b/tests/test_error_handlers.py @@ -224,9 +224,6 @@ def legacy_handler3(err, rq, rs, prms): client.simulate_head() def test_handler_must_be_coroutine_for_asgi(self, util): - async def legacy_handler(err, rq, rs, prms): - pass - app = util.create_app(True) with util.disable_asgi_non_coroutine_wrapping(): diff --git a/tests/test_hello.py b/tests/test_hello.py index 709b844b0..bb624e7d2 100644 --- a/tests/test_hello.py +++ b/tests/test_hello.py @@ -83,7 +83,7 @@ def close(self): # 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 + close = False def __init__(self, data=b''): self._stream = io.BytesIO(data) diff --git a/tests/test_httperror.py b/tests/test_httperror.py index d05636773..a3d68724f 100644 --- a/tests/test_httperror.py +++ b/tests/test_httperror.py @@ -11,9 +11,9 @@ from falcon.util.deprecation import DeprecatedWarning try: - import yaml # type: ignore + import yaml except ImportError: - yaml = None # type: ignore + yaml = None # type: ignore[assignment] @pytest.fixture diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py index c600008a9..277c0a567 100644 --- a/tests/test_media_multipart.py +++ b/tests/test_media_multipart.py @@ -11,7 +11,7 @@ from falcon.util import BufferedReader try: - import msgpack # type: ignore + import msgpack except ImportError: msgpack = None diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py index 81c8becbf..67869951b 100644 --- a/tests/test_request_attrs.py +++ b/tests/test_request_attrs.py @@ -702,7 +702,7 @@ def test_bogus_content_length_neg(self, asgi): ], ) def test_date(self, asgi, header, attr): - date = datetime.datetime(2013, 4, 4, 5, 19, 18) + date = datetime.datetime(2013, 4, 4, 5, 19, 18, tzinfo=datetime.timezone.utc) date_str = 'Thu, 04 Apr 2013 05:19:18 GMT' headers = {header: date_str} diff --git a/tests/test_request_media.py b/tests/test_request_media.py index f262caeff..0edb8e4eb 100644 --- a/tests/test_request_media.py +++ b/tests/test_request_media.py @@ -10,7 +10,7 @@ import falcon.asgi try: - import msgpack # type: ignore + import msgpack except ImportError: msgpack = None diff --git a/tests/test_response_media.py b/tests/test_response_media.py index 6bf71ab92..bef786922 100644 --- a/tests/test_response_media.py +++ b/tests/test_response_media.py @@ -8,7 +8,7 @@ from falcon import testing try: - import msgpack # type: ignore + import msgpack except ImportError: msgpack = None diff --git a/tests/test_utils.py b/tests/test_utils.py index ec98da01b..159fdd9a7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -26,7 +26,7 @@ from falcon.util.time import TimezoneGMT try: - import msgpack # type: ignore + import msgpack except ImportError: msgpack = None @@ -129,21 +129,22 @@ def test_http_now(self): def test_dt_to_http(self): assert ( - falcon.dt_to_http(datetime(2013, 4, 4)) == 'Thu, 04 Apr 2013 00:00:00 GMT' + falcon.dt_to_http(datetime(2013, 4, 4, tzinfo=timezone.utc)) + == 'Thu, 04 Apr 2013 00:00:00 GMT' ) assert ( - falcon.dt_to_http(datetime(2013, 4, 4, 10, 28, 54)) + falcon.dt_to_http(datetime(2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc)) == 'Thu, 04 Apr 2013 10:28:54 GMT' ) def test_http_date_to_dt(self): assert falcon.http_date_to_dt('Thu, 04 Apr 2013 00:00:00 GMT') == datetime( - 2013, 4, 4 + 2013, 4, 4, tzinfo=timezone.utc ) assert falcon.http_date_to_dt('Thu, 04 Apr 2013 10:28:54 GMT') == datetime( - 2013, 4, 4, 10, 28, 54 + 2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc ) with pytest.raises(ValueError): @@ -151,7 +152,7 @@ def test_http_date_to_dt(self): assert falcon.http_date_to_dt( 'Thu, 04-Apr-2013 10:28:54 GMT', obs_date=True - ) == datetime(2013, 4, 4, 10, 28, 54) + ) == datetime(2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc) with pytest.raises(ValueError): falcon.http_date_to_dt('Sun Nov 6 08:49:37 1994') @@ -161,11 +162,14 @@ def test_http_date_to_dt(self): assert falcon.http_date_to_dt( 'Sun Nov 6 08:49:37 1994', obs_date=True - ) == datetime(1994, 11, 6, 8, 49, 37) + ) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc) assert falcon.http_date_to_dt( 'Sunday, 06-Nov-94 08:49:37 GMT', obs_date=True - ) == datetime(1994, 11, 6, 8, 49, 37) + ) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc) + + with pytest.raises(ValueError): + falcon.http_date_to_dt('Thu, 04 Apr 2013 10:28:54 EST') def test_pack_query_params_none(self): assert falcon.to_query_str({}) == '' diff --git a/tools/build.sh b/tools/build.sh deleted file mode 100755 index 6c5a54850..000000000 --- a/tools/build.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash - -# Copyright 2016 by Rackspace Hosting, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# 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. - -set -e - -VENV_NAME=tmp-falcon-build -BUILD_DIR=./build -DIST_DIR=./dist -PY3_VERSION=3.8.7 - -#---------------------------------------------------------------------- -# Helpers -#---------------------------------------------------------------------- - -# Args: (python_version) -_open_env() { - local PY_VERSION=$1 - - pyenv install -s $PY_VERSION - pyenv virtualenv $PY_VERSION $VENV_NAME - pyenv shell $VENV_NAME - - pip install -q --upgrade pip - pip install -q --upgrade wheel twine - - echo -} - -# Args: () -_close_env() { - rm -rf $DIST_PATH - pyenv shell system - pyenv uninstall -f $VENV_NAME -} - -# Args: (message) -_echo_task() { - echo - echo "# ----------------------------------------------------------" - echo "# $1" - echo "# ----------------------------------------------------------" - -} - -#---------------------------------------------------------------------- -# Prerequisites -#---------------------------------------------------------------------- - -# Setup pyenv -eval "$(pyenv init -)" -eval "$(pyenv virtualenv-init -)" - -#---------------------------------------------------------------------- -# Start with a clean slate -#---------------------------------------------------------------------- - -_echo_task "Cleaning up old artifacts" - -tools/clean.py . - -rm -rf $BUILD_DIR -rm -rf $DIST_DIR - -pyenv shell system -pyenv uninstall -f $VENV_NAME - -#---------------------------------------------------------------------- -# Source distribution -#---------------------------------------------------------------------- - -_echo_task "Building source distribution" -_open_env $PY3_VERSION - -python setup.py sdist -d $DIST_DIR - -_close_env - -#---------------------------------------------------------------------- -# Universal wheel - do not include Cython, note in README -#---------------------------------------------------------------------- - -_echo_task "Building universal wheel" -_open_env $PY3_VERSION - -python setup.py bdist_wheel -d $DIST_DIR - -_close_env - -#---------------------------------------------------------------------- -# README validation -#---------------------------------------------------------------------- - -_echo_task "Checking that README will render on PyPI" -_open_env $PY3_VERSION - -twine check $DIST_DIR/* - -_close_env diff --git a/tools/check-vendored.sh b/tools/check-vendored.sh deleted file mode 100755 index ae57ce7b3..000000000 --- a/tools/check-vendored.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -# Fail on errors -set -e - -_EXPECTED_VERSION_MIMEPARSE="1.6.0" - -pip install --upgrade python-mimeparse - -_VERSION_OUTPUT=$(pip show python-mimeparse | grep ^Version: ) -if [[ $_VERSION_OUTPUT == "Version: $_EXPECTED_VERSION_MIMEPARSE" ]]; then - echo "Latest version of python-mimeparse has not changed ($_EXPECTED_VERSION_MIMEPARSE)" - exit 0 -fi - -echo "Latest version of python-mimeparse is newer than expected." -exit 1 diff --git a/tools/check_dist.py b/tools/check_dist.py new file mode 100755 index 000000000..b4a78ffc2 --- /dev/null +++ b/tools/check_dist.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python + +import argparse +import pathlib +import sys + +HERE = pathlib.Path(__file__).resolve().parent +DIST = HERE.parent / 'dist' + + +def check_dist(dist, git_ref): + sdist = None + versions = set() + wheels = [] + + if git_ref: + git_ref = git_ref.split('/')[-1].lower() + + for path in dist.iterdir(): + if not path.is_file(): + continue + + if path.name.endswith('.tar.gz'): + assert sdist is None, f'sdist already exists: {sdist}' + sdist = path.name + + elif path.name.endswith('.whl'): + wheels.append(path.name) + + else: + sys.stderr.write(f'Unexpected file found in dist: {path.name}\n') + sys.exit(1) + + package, _, _ = path.stem.partition('.tar') + falcon, version, *_ = package.split('-') + assert falcon == 'falcon', 'Unexpected package name: {path.name}' + versions.add(version) + + if git_ref and version != git_ref: + sys.stderr.write( + f'Unexpected version: {path.name} ({version} != {git_ref})\n' + ) + sys.exit(1) + + if not versions: + sys.stderr.write('No artifacts collected!\n') + sys.exit(1) + if len(versions) > 1: + sys.stderr.write(f'Multiple versions found: {tuple(versions)}!\n') + sys.exit(1) + version = versions.pop() + + wheel_list = ' None\n' + if wheels: + wheel_list = ''.join(f' {wheel}\n' for wheel in sorted(wheels)) + + print(f'[{dist}]\n') + print(f'sdist found:\n {sdist}\n') + print(f'wheels found:\n{wheel_list}') + print(f'version identified:\n {version}\n') + + +def main(): + description = 'Check artifacts (sdist, wheels) inside dist dir.' + + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + '-d', + '--dist-dir', + default=str(DIST), + help='dist directory to check (default: %(default)s)', + ) + parser.add_argument( + '-r', + '--git-ref', + help='check version against git branch/tag ref (e.g. $GITHUB_REF)', + ) + + args = parser.parse_args() + check_dist(pathlib.Path(args.dist_dir).resolve(), args.git_ref) + + +if __name__ == '__main__': + main() diff --git a/tools/publish-website.sh b/tools/publish-website.sh deleted file mode 100755 index 39d5841af..000000000 --- a/tools/publish-website.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -if git checkout gh-pages -then - rm -rf css - rm -rf img - rm -rf js - cp -r ../falconframework.org/* . - - git add --all * - git commit -m 'doc: Publish website' - git push origin gh-pages - git checkout master -fi diff --git a/tools/publish.sh b/tools/publish.sh deleted file mode 100755 index 690caa8d0..000000000 --- a/tools/publish.sh +++ /dev/null @@ -1,8 +0,0 @@ -DIST_DIR=./dist - -read -p "Sign and upload $DIST_DIR/* to PyPI? [y/N]: " CONTINUE - -if [[ $CONTINUE =~ ^[Yy]$ ]]; then - pip install -U twine - twine upload -s --skip-existing $DIST_DIR/* -fi diff --git a/tools/test_dist.py b/tools/test_dist.py new file mode 100755 index 000000000..a0dc967b5 --- /dev/null +++ b/tools/test_dist.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +import argparse +import logging +import pathlib +import subprocess +import sys +import tempfile + +logging.basicConfig( + format='[test_dist.py] %(asctime)s [%(levelname)s] %(message)s', level=logging.INFO +) + +FALCON_ROOT = pathlib.Path(__file__).resolve().parent.parent +REQUIREMENTS = FALCON_ROOT / 'requirements' / 'cibwtest' +TESTS = FALCON_ROOT / 'tests' + + +def test_package(package): + with tempfile.TemporaryDirectory() as tmpdir: + venv = pathlib.Path(tmpdir) / 'venv' + subprocess.check_call((sys.executable, '-m', 'venv', venv)) + logging.info(f'Created a temporary venv in {venv}.') + + subprocess.check_call((venv / 'bin' / 'pip', 'install', '--upgrade', 'pip')) + subprocess.check_call((venv / 'bin' / 'pip', 'install', '-r', REQUIREMENTS)) + logging.info(f'Installed test requirements in {venv}.') + subprocess.check_call( + (venv / 'bin' / 'pip', 'install', package), + ) + logging.info(f'Installed {package} into {venv}.') + + subprocess.check_call((venv / 'bin' / 'pytest', TESTS), cwd=venv) + logging.info(f'{package} passes tests.') + + +def main(): + description = 'Test Falcon packages (sdist or generic wheel).' + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + 'package', metavar='PACKAGE', nargs='+', help='sdist/wheel(s) to test' + ) + args = parser.parse_args() + + for package in args.package: + test_package(package) + + +if __name__ == '__main__': + main() diff --git a/tools/testing/fetch_mailman.sh b/tools/testing/fetch_mailman.sh index 6fe642c8c..517973fb4 100755 --- a/tools/testing/fetch_mailman.sh +++ b/tools/testing/fetch_mailman.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +set -e + FALCON_ROOT=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/../.." &> /dev/null && pwd ) MAILMAN_PATH=$FALCON_ROOT/.ecosystem/mailman