diff --git a/.github/workflows/cibuildwheel.yaml b/.github/workflows/cibuildwheel.yaml new file mode 100644 index 000000000..b3ec82cfa --- /dev/null +++ b/.github/workflows/cibuildwheel.yaml @@ -0,0 +1,141 @@ +# Build wheels using cibuildwheel (https://cibuildwheel.pypa.io/) +name: Build wheels + +on: + # Run when a release has been created + release: + types: [created] + + # NOTE(vytas): Also allow to release to Test PyPi manually. + workflow_dispatch: + +jobs: + build-sdist: + name: sdist + 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: Build sdist + run: | + pip install build + python -m build --sdist + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: cibw-sdist + path: dist/*.tar.gz + + build-wheels: + name: ${{ matrix.python }}-${{ matrix.platform.name }} + needs: build-sdist + runs-on: ${{ matrix.platform.os }} + strategy: + fail-fast: false + matrix: + platform: + - name: manylinux_x86_64 + os: ubuntu-latest + - name: musllinux_x86_64 + os: ubuntu-latest + - name: manylinux_aarch64 + os: ubuntu-latest + emulation: true + - name: musllinux_aarch64 + os: ubuntu-latest + emulation: true + - name: manylinux_s390x + os: ubuntu-latest + emulation: true + - name: macosx_x86_64 + os: macos-13 + - name: macosx_arm64 + os: macos-14 + - name: win_amd64 + os: windows-latest + python: + - cp39 + - cp310 + - cp311 + - cp312 + - cp313 + include: + - platform: + name: manylinux_x86_64 + os: ubuntu-latest + python: cp38 + - platform: + name: musllinux_x86_64 + os: ubuntu-latest + python: cp38 + + defaults: + run: + shell: bash + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + if: ${{ matrix.platform.emulation }} + with: + platforms: all + + - name: Build wheels + uses: pypa/cibuildwheel@v2.20.0 + env: + CIBW_ARCHS_LINUX: all + CIBW_BUILD: ${{ matrix.python }}-${{ matrix.platform.name }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: cibw-wheel-${{ matrix.python }}-${{ matrix.platform.name }} + path: wheelhouse/*.whl + + publish-wheels: + name: publish + needs: + - build-sdist + - build-wheels + runs-on: ubuntu-latest + + steps: + - name: Download packages + uses: actions/download-artifact@v4 + with: + pattern: cibw-* + path: dist + merge-multiple: true + + - name: Check collected artifacts + # TODO(vytas): Run a script to perform version sanity checks instead. + run: ls -l dist/ + + - name: Publish artifacts 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 }} diff --git a/.gitignore b/.gitignore index 20f6ac3c7..e8b6d5f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ parts sdist var pip-wheel-metadata +wheelhouse # Installer logs pip-log.txt diff --git a/docs/conf.py b/docs/conf.py index 1b4b4ecb9..4ef269e33 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Falcon documentation build configuration file, created by # sphinx-quickstart on Wed Mar 12 14:14:02 2014. # diff --git a/pyproject.toml b/pyproject.toml index be08709b3..4a58ef88c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = [ "setuptools>=47", "wheel>=0.34", - "cython>=0.29.21; python_implementation == 'CPython'", # Skip cython when using pypy + "cython>=3.0.8; python_implementation == 'CPython'", # Skip cython when using pypy ] [tool.mypy] @@ -168,6 +168,14 @@ filterwarnings = [ "ignore:path is deprecated\\. Use files\\(\\) instead:DeprecationWarning", "ignore:This process \\(.+\\) is multi-threaded", ] +markers = [ + "slow: mark Falcon tests as slower (potentially taking more than ~500ms).", +] testpaths = [ "tests" ] + +[tool.cibuildwheel] +build-frontend = "build" +test-requires = ["-r requirements/cibwtest"] +test-command = "pytest {project}/tests" diff --git a/requirements/cibwtest b/requirements/cibwtest new file mode 100644 index 000000000..33dd318b6 --- /dev/null +++ b/requirements/cibwtest @@ -0,0 +1,5 @@ +msgpack +pytest +pytest-asyncio<0.22.0 +pyyaml +requests diff --git a/requirements/mintest b/requirements/mintest index 8fce419e3..65e9332a6 100644 --- a/requirements/mintest +++ b/requirements/mintest @@ -1,7 +1,4 @@ coverage>=4.1 -msgpack -mujson pytest -pyyaml +pytest-asyncio<0.22.0 requests -ujson diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py index 6e790e0fd..5fdd9acde 100644 --- a/tests/asgi/test_asgi_servers.py +++ b/tests/asgi/test_asgi_servers.py @@ -9,12 +9,21 @@ import sys import time -import httpx import pytest import requests import requests.exceptions -import websockets -import websockets.exceptions + +try: + import httpx +except ImportError: + httpx = None # type: ignore + +try: + import websockets + import websockets.exceptions +except ImportError: + websockets = None # type: ignore + from falcon import testing @@ -166,6 +175,7 @@ def test_sse_client_disconnects_early(self, server_base_url): ) @pytest.mark.asyncio + @pytest.mark.skipif(httpx is None, reason='httpx is required for this test') async def test_stream_chunked_request(self, server_base_url): """Regression test for https://github.com/falconry/falcon/issues/2024""" @@ -183,6 +193,9 @@ async def emitter(): assert resp.json().get('drops') >= 1 +@pytest.mark.skipif( + websockets is None, reason='websockets is required for this test class' +) class TestWebSocket: @pytest.mark.asyncio @pytest.mark.parametrize('explicit_close', [True, False]) diff --git a/tests/asgi/test_boundedstream_asgi.py b/tests/asgi/test_boundedstream_asgi.py index db79b7f86..acb81215d 100644 --- a/tests/asgi/test_boundedstream_asgi.py +++ b/tests/asgi/test_boundedstream_asgi.py @@ -22,6 +22,7 @@ ) @pytest.mark.parametrize('extra_body', [True, False]) @pytest.mark.parametrize('set_content_length', [True, False]) +@pytest.mark.slow def test_read_all(body, extra_body, set_content_length): if extra_body and not set_content_length: pytest.skip( diff --git a/tests/asgi/test_buffered_reader.py b/tests/asgi/test_buffered_reader.py index f97744893..01cab8b1c 100644 --- a/tests/asgi/test_buffered_reader.py +++ b/tests/asgi/test_buffered_reader.py @@ -212,6 +212,7 @@ async def test_read(reader1, sizes, expected): @pytest.mark.parametrize('start_size', [1, 16777216]) +@pytest.mark.slow @falcon.runs_sync async def test_varying_read_size(reader2, start_size): size = start_size @@ -318,6 +319,7 @@ async def test_invalid_delimiter_length(reader1): (13372477, 51637898), ], ) +@pytest.mark.slow @falcon.runs_sync async def test_irregular_large_read_until(reader2, size1, size2): delimiter = b'--boundary1234567890--' @@ -376,6 +378,7 @@ async def test_small_reads(reader3): assert last.endswith(b'4') +@pytest.mark.slow @falcon.runs_sync async def test_small_reads_with_delimiter(reader3): ops = 0 diff --git a/tests/asgi/test_example_asgi.py b/tests/asgi/test_example_asgi.py deleted file mode 100644 index f67ee3af6..000000000 --- a/tests/asgi/test_example_asgi.py +++ /dev/null @@ -1,223 +0,0 @@ -# examples/things_advanced_asgi.py - -import json -import logging -import uuid - -import httpx - -import falcon -import falcon.asgi - - -class StorageEngine: - async def get_things(self, marker, limit): - return [{'id': str(uuid.uuid4()), 'color': 'green'}] - - async def add_thing(self, thing): - thing['id'] = str(uuid.uuid4()) - return thing - - -class StorageError(Exception): - @staticmethod - async def handle(ex, req, resp, params): - # TODO: Log the error, clean up, etc. before raising - raise falcon.HTTPInternalServerError() - - -class SinkAdapter: - engines = { - 'ddg': 'https://duckduckgo.com', - 'y': 'https://search.yahoo.com/search', - } - - async def __call__(self, req, resp, engine): - url = self.engines[engine] - params = {'q': req.get_param('q', True)} - - async with httpx.AsyncClient() as client: - result = await client.get(url, params=params) - - resp.status = result.status_code - resp.content_type = result.headers['content-type'] - resp.text = result.text - - -class AuthMiddleware: - async def process_request(self, req, resp): - token = req.get_header('Authorization') - account_id = req.get_header('Account-ID') - - challenges = ['Token type="Fernet"'] - - if token is None: - description = 'Please provide an auth token as part of the request.' - - raise falcon.HTTPUnauthorized( - title='Auth token required', - description=description, - challenges=challenges, - href='http://docs.example.com/auth', - ) - - if not self._token_is_valid(token, account_id): - description = ( - 'The provided auth token is not valid. ' - 'Please request a new token and try again.' - ) - - raise falcon.HTTPUnauthorized( - title='Authentication required', - description=description, - challenges=challenges, - href='http://docs.example.com/auth', - ) - - def _token_is_valid(self, token, account_id): - return True # Suuuuuure it's valid... - - -class RequireJSON: - async def process_request(self, req, resp): - if not req.client_accepts_json: - raise falcon.HTTPNotAcceptable( - description='This API only supports responses encoded as JSON.', - href='http://docs.examples.com/api/json', - ) - - if req.method in ('POST', 'PUT'): - if 'application/json' not in req.content_type: - raise falcon.HTTPUnsupportedMediaType( - description='This API only supports requests encoded as JSON.', - href='http://docs.examples.com/api/json', - ) - - -class JSONTranslator: - # NOTE: Normally you would simply use req.get_media() and resp.media for - # this particular use case; this example serves only to illustrate - # what is possible. - - async def process_request(self, req, resp): - # NOTE: Test explicitly for 0, since this property could be None in - # the case that the Content-Length header is missing (in which case we - # can't know if there is a body without actually attempting to read - # it from the request stream.) - if req.content_length == 0: - # Nothing to do - return - - body = await req.stream.read() - if not body: - raise falcon.HTTPBadRequest( - title='Empty request body', - description='A valid JSON document is required.', - ) - - try: - req.context.doc = json.loads(body.decode('utf-8')) - - except (ValueError, UnicodeDecodeError): - description = ( - 'Could not decode the request body. The ' - 'JSON was incorrect or not encoded as ' - 'UTF-8.' - ) - - raise falcon.HTTPBadRequest(title='Malformed JSON', description=description) - - async def process_response(self, req, resp, resource, req_succeeded): - if not hasattr(resp.context, 'result'): - return - - resp.text = json.dumps(resp.context.result) - - -def max_body(limit): - async def hook(req, resp, resource, params): - length = req.content_length - if length is not None and length > limit: - msg = ( - 'The size of the request is too large. The body must not ' - 'exceed ' + str(limit) + ' bytes in length.' - ) - - raise falcon.HTTPPayloadTooLarge( - title='Request body is too large', description=msg - ) - - return hook - - -class ThingsResource: - def __init__(self, db): - self.db = db - self.logger = logging.getLogger('thingsapp.' + __name__) - - async def on_get(self, req, resp, user_id): - marker = req.get_param('marker') or '' - limit = req.get_param_as_int('limit') or 50 - - try: - result = await self.db.get_things(marker, limit) - except Exception as ex: - self.logger.error(ex) - - description = ( - 'Aliens have attacked our base! We will ' - 'be back as soon as we fight them off. ' - 'We appreciate your patience.' - ) - - raise falcon.HTTPServiceUnavailable( - title='Service Outage', description=description, retry_after=30 - ) - - # NOTE: Normally you would use resp.media for this sort of thing; - # this example serves only to demonstrate how the context can be - # used to pass arbitrary values between middleware components, - # hooks, and resources. - resp.context.result = result - - resp.set_header('Powered-By', 'Falcon') - resp.status = falcon.HTTP_200 - - @falcon.before(max_body(64 * 1024)) - async def on_post(self, req, resp, user_id): - try: - doc = req.context.doc - except AttributeError: - raise falcon.HTTPBadRequest( - title='Missing thing', - description='A thing must be submitted in the request body.', - ) - - proper_thing = await self.db.add_thing(doc) - - resp.status = falcon.HTTP_201 - resp.location = '/%s/things/%s' % (user_id, proper_thing['id']) - - -# The app instance is an ASGI callable -app = falcon.asgi.App( - middleware=[ - # AuthMiddleware(), - RequireJSON(), - JSONTranslator(), - ] -) - -db = StorageEngine() -things = ThingsResource(db) -app.add_route('/{user_id}/things', things) - -# If a responder ever raises an instance of StorageError, pass control to -# the given handler. -app.add_error_handler(StorageError, StorageError.handle) - -# Proxy some things to another service; this example shows how you might -# send parts of an API off to a legacy system that hasn't been upgraded -# yet, or perhaps is a single cluster that all data centers have to share. -sink = SinkAdapter() -app.add_sink(sink, r'/search/(?Pddg|y)\Z') diff --git a/tests/asgi/test_hello_asgi.py b/tests/asgi/test_hello_asgi.py index cbc5d3dc3..fb19e3c61 100644 --- a/tests/asgi/test_hello_asgi.py +++ b/tests/asgi/test_hello_asgi.py @@ -3,13 +3,17 @@ import tempfile from _util import disable_asgi_non_coroutine_wrapping # NOQA -import aiofiles import pytest import falcon from falcon import testing import falcon.asgi +try: + import aiofiles # type: ignore +except ImportError: + aiofiles = None # type: ignore + SIZE_1_KB = 1024 @@ -308,6 +312,7 @@ def test_filelike_closing(self, client, stream_factory, assert_closed): if assert_closed: assert resource.stream.close_called + @pytest.mark.skipif(aiofiles is None, reason='aiofiles is required for this test') def test_filelike_closing_aiofiles(self, client): resource = AIOFilesHelloResource() try: diff --git a/tests/asgi/test_response_media_asgi.py b/tests/asgi/test_response_media_asgi.py index b911c1486..01236de2e 100644 --- a/tests/asgi/test_response_media_asgi.py +++ b/tests/asgi/test_response_media_asgi.py @@ -9,6 +9,11 @@ import falcon.asgi from falcon.util.deprecation import DeprecatedWarning +try: + import msgpack # type: ignore +except ImportError: + msgpack = None # type: ignore + def create_client(resource, handlers=None): app = falcon.asgi.App() @@ -89,6 +94,7 @@ def test_non_ascii_json_serialization(document): ('application/x-msgpack'), ], ) +@pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') def test_msgpack(media_type): class TestResource: async def on_get(self, req, resp): diff --git a/tests/asgi/test_scheduled_callbacks.py b/tests/asgi/test_scheduled_callbacks.py index aa47e2ad4..36d4f4e5b 100644 --- a/tests/asgi/test_scheduled_callbacks.py +++ b/tests/asgi/test_scheduled_callbacks.py @@ -9,6 +9,7 @@ from falcon.asgi import App +@pytest.mark.slow def test_multiple(): class SomeResource: def __init__(self): diff --git a/tests/asgi/test_sync.py b/tests/asgi/test_sync.py index 7b40faed3..6baab8c75 100644 --- a/tests/asgi/test_sync.py +++ b/tests/asgi/test_sync.py @@ -8,6 +8,7 @@ import falcon.util +@pytest.mark.slow def test_sync_helpers(): safely_values = [] unsafely_values = [] diff --git a/tests/asgi/test_testing_asgi.py b/tests/asgi/test_testing_asgi.py index f2de736cd..915fec4de 100644 --- a/tests/asgi/test_testing_asgi.py +++ b/tests/asgi/test_testing_asgi.py @@ -9,6 +9,7 @@ @pytest.mark.asyncio +@pytest.mark.slow async def test_asgi_request_event_emitter_hang(): # NOTE(kgriffs): This tests the ASGI server behavior that # ASGIRequestEventEmitter simulates when emit() is called diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py index 48dae09ab..6fb7b7667 100644 --- a/tests/asgi/test_ws.py +++ b/tests/asgi/test_ws.py @@ -2,7 +2,6 @@ from collections import deque import os -import cbor2 import pytest import falcon @@ -15,9 +14,9 @@ from falcon.testing.helpers import _WebSocketState as ClientWebSocketState try: - import rapidjson # type: ignore + import cbor2 # type: ignore except ImportError: - rapidjson = None # type: ignore + cbor2 = None # type: ignore try: @@ -26,6 +25,12 @@ msgpack = None # type: ignore +try: + import rapidjson # type: ignore +except ImportError: + rapidjson = None # type: ignore + + # NOTE(kgriffs): We do not use codes defined in the framework because we # want to verify that the correct value is being used. class CloseCode: @@ -109,6 +114,7 @@ async def on_websocket(self, req, ws, explicit): @pytest.mark.asyncio +@pytest.mark.slow async def test_echo(): # noqa: C901 consumer_sleep = 0.01 producer_loop = 10 @@ -407,6 +413,7 @@ async def on_websocket(self, req, ws): @pytest.mark.asyncio @pytest.mark.parametrize('custom_text', [True, False]) @pytest.mark.parametrize('custom_data', [True, False]) +@pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') async def test_media(custom_text, custom_data, conductor): # NOQA: C901 # TODO(kgriffs): Refactor to reduce McCabe score @@ -471,6 +478,8 @@ def deserialize(self, payload: str) -> object: ) if custom_data: + if cbor2 is None: + pytest.skip('cbor2 is required for this test') class CBORHandler(media.BinaryBaseHandlerWS): def serialize(self, media: object) -> bytes: @@ -1017,6 +1026,7 @@ def test_ws_base_not_implemented(): @pytest.mark.asyncio +@pytest.mark.slow async def test_ws_context_timeout(conductor): class Resource: async def on_websocket(self, req, ws): @@ -1089,6 +1099,7 @@ class Resource: @pytest.mark.asyncio +@pytest.mark.slow async def test_ws_responder_never_ready(conductor, monkeypatch): async def noop_close(obj, code=None): pass diff --git a/tests/conftest.py b/tests/conftest.py index b021132cd..e26f0cefe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,12 +87,14 @@ def as_params(*values, prefix=None): @staticmethod def load_module(filename, parent_dir=None, suffix=None): - root = FALCON_ROOT - root = root / parent_dir if parent_dir is not None else root - path = root / filename + if parent_dir: + filename = pathlib.Path(parent_dir) / filename + else: + filename = pathlib.Path(filename) + path = FALCON_ROOT / filename if suffix is not None: path = path.with_name(f'{path.stem}_{suffix}.py') - prefix = '.'.join(path.parent.parts) + prefix = '.'.join(filename.parent.parts) module_name = f'{prefix}.{path.stem}' spec = importlib.util.spec_from_file_location(module_name, path) diff --git a/tests/test_buffered_reader.py b/tests/test_buffered_reader.py index b20e5aa07..247676cb8 100644 --- a/tests/test_buffered_reader.py +++ b/tests/test_buffered_reader.py @@ -169,6 +169,7 @@ def test_read_until_with_size(buffered_reader, size): assert stream.read_until(b'--boundary1234567890--', size) == (TEST_DATA[:size]) +@pytest.mark.slow def test_read_until(buffered_reader): stream = buffered_reader() @@ -186,6 +187,7 @@ def test_read_until(buffered_reader): (13372477, 51637898), ], ) +@pytest.mark.slow def test_irregular_large_read_until(buffered_reader, size1, size2): stream = buffered_reader() delimiter = b'--boundary1234567890--' @@ -345,6 +347,7 @@ def test_duck_compatibility_with_io_base(shorter_stream): assert not shorter_stream.writeable() +@pytest.mark.slow def test_fragmented_reads(fragmented_stream): b = io.BytesIO() fragmented_stream.pipe_until(b'--boundary1234567890--', b) diff --git a/tests/test_cmd_inspect_app.py b/tests/test_cmd_inspect_app.py index 7a6866f74..f2b4e895f 100644 --- a/tests/test_cmd_inspect_app.py +++ b/tests/test_cmd_inspect_app.py @@ -11,7 +11,11 @@ from falcon.testing import redirected _WIN32 = sys.platform.startswith('win') -_MODULE = 'tests.test_cmd_inspect_app' + +# NOTE(vytas): This is not the cleanest way to import as we lack __init__.py, +# but it works as pytest (when operating in the default "prepend" import mode) +# inserts the directory of every test file into sys.path. +_MODULE = 'test_cmd_inspect_app' class DummyResource: diff --git a/tests/test_httperror.py b/tests/test_httperror.py index 2de1972ee..313fb00e8 100644 --- a/tests/test_httperror.py +++ b/tests/test_httperror.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 - import datetime import http import json @@ -8,12 +6,16 @@ from _util import create_app # NOQA import pytest -import yaml import falcon import falcon.testing as testing from falcon.util.deprecation import DeprecatedWarning +try: + import yaml # type: ignore +except ImportError: + yaml = None # type: ignore + @pytest.fixture def client(asgi): @@ -331,6 +333,7 @@ def test_client_does_not_accept_json_or_xml(self, client): assert response.headers['Vary'] == 'Accept' assert not response.content + @pytest.mark.skipif(yaml is None, reason='PyYAML is required for this test') def test_custom_error_serializer(self, client): headers = { 'X-Error-Title': 'Storage service down', diff --git a/tests/test_httpstatus.py b/tests/test_httpstatus.py index e7ff51c17..3a031ffc6 100644 --- a/tests/test_httpstatus.py +++ b/tests/test_httpstatus.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 - import http from _util import create_app # NOQA diff --git a/tests/test_inspect.py b/tests/test_inspect.py index 5da970ed2..5273fbde4 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -1,5 +1,6 @@ from functools import partial import os +import pathlib import sys import _inspect_fixture as i_f @@ -10,6 +11,8 @@ from falcon import routing import falcon.asgi +HERE = pathlib.Path(__file__).resolve().parent + def get_app(asgi, cors=True, **kw): if asgi: @@ -33,9 +36,7 @@ def make_app(): app.add_route('/bar', i_f.OtherResponder(), suffix='id') app.add_static_route('/fal', os.path.abspath('falcon')) - app.add_static_route( - '/tes', os.path.abspath('tests'), fallback_filename='conftest.py' - ) + app.add_static_route('/tes', HERE, fallback_filename='conftest.py') return app @@ -54,9 +55,7 @@ def make_app_async(): app.add_route('/bar', i_f.OtherResponderAsync(), suffix='id') app.add_static_route('/fal', os.path.abspath('falcon')) - app.add_static_route( - '/tes', os.path.abspath('tests'), fallback_filename='conftest.py' - ) + app.add_static_route('/tes', HERE, fallback_filename='conftest.py') return app @@ -154,7 +153,7 @@ def test_static_routes(self, asgi): assert routes[-1].directory == os.path.abspath('falcon') assert routes[-1].fallback_filename is None assert routes[-2].prefix == '/tes/' - assert routes[-2].directory == os.path.abspath('tests') + assert routes[-2].directory == str(HERE) assert routes[-2].fallback_filename.endswith('conftest.py') def test_sink(self, asgi): diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py index e0442751b..f2dbb96c8 100644 --- a/tests/test_media_handlers.py +++ b/tests/test_media_handlers.py @@ -4,9 +4,7 @@ import platform from _util import create_app # NOQA -import mujson import pytest -import ujson import falcon from falcon import media @@ -14,13 +12,26 @@ from falcon.asgi.stream import BoundedStream from falcon.util.deprecation import DeprecatedWarning +mujson = None orjson = None rapidjson = None +ujson = None + +try: + import mujson # type: ignore +except ImportError: + pass + try: import rapidjson # type: ignore except ImportError: pass +try: + import ujson # type: ignore +except ImportError: + pass + if platform.python_implementation() == 'CPython': try: import orjson # type: ignore @@ -32,8 +43,6 @@ SERIALIZATION_PARAM_LIST = [ # Default json.dumps, with only ascii (None, {'test': 'value'}, b'{"test":"value"}'), - (partial(mujson.dumps, ensure_ascii=True), {'test': 'value'}, b'{"test":"value"}'), - (ujson.dumps, {'test': 'value'}, b'{"test":"value"}'), ( partial(lambda media, **kwargs: json.dumps([media, kwargs]), ensure_ascii=True), {'test': 'value'}, @@ -52,15 +61,25 @@ b'{"key": "value"}', {'key': 'VALUE'}, ), - (mujson.loads, b'{"test": "value"}', {'test': 'value'}), - (ujson.loads, b'{"test": "value"}', {'test': 'value'}), -] -ALL_JSON_IMPL = [ - (json.dumps, json.loads), - (partial(mujson.dumps, ensure_ascii=True), mujson.loads), - (ujson.dumps, ujson.loads), ] +ALL_JSON_IMPL = [(json.dumps, json.loads)] +ALL_JSON_IMPL_IDS = ['stdlib'] + + +if mujson: + SERIALIZATION_PARAM_LIST += [ + ( + partial(mujson.dumps, ensure_ascii=True), + {'test': 'value'}, + b'{"test":"value"}', + ), + ] + DESERIALIZATION_PARAM_LIST += [ + (mujson.loads, b'{"test": "value"}', {'test': 'value'}), + ] + ALL_JSON_IMPL += [(partial(mujson.dumps, ensure_ascii=True), mujson.loads)] + ALL_JSON_IMPL_IDS += ['mujson'] if orjson: SERIALIZATION_PARAM_LIST += [ @@ -70,6 +89,7 @@ (orjson.loads, b'{"test": "value"}', {'test': 'value'}), ] ALL_JSON_IMPL += [(orjson.dumps, orjson.loads)] + ALL_JSON_IMPL_IDS += ['orjson'] if rapidjson: SERIALIZATION_PARAM_LIST += [ @@ -79,6 +99,36 @@ (rapidjson.loads, b'{"test": "value"}', {'test': 'value'}), ] ALL_JSON_IMPL += [(rapidjson.dumps, rapidjson.loads)] + ALL_JSON_IMPL_IDS += ['rapidjson'] + +if ujson: + SERIALIZATION_PARAM_LIST += [ + (ujson.dumps, {'test': 'value'}, b'{"test":"value"}'), + ] + DESERIALIZATION_PARAM_LIST += [ + (ujson.loads, b'{"test": "value"}', {'test': 'value'}), + ] + ALL_JSON_IMPL += [(ujson.dumps, ujson.loads)] + ALL_JSON_IMPL_IDS += ['ujson'] + + +@pytest.mark.parametrize( + 'library, name', + [ + (mujson, 'mujson'), + (orjson, 'orjson'), + (rapidjson, 'rapidjson'), + (ujson, 'ujson'), + ], + ids=['mujson', 'orjson', 'rapidjson', 'ujson'], +) +def test_check_json_library(library, name): + # NOTE(vytas): A synthetic test just to visualize which JSON libraries + # are absent and skipped. + if library is None: + pytest.skip(f'{name} is not installed') + assert hasattr(library, 'dumps') + assert hasattr(library, 'loads') @pytest.mark.parametrize('func, body, expected', SERIALIZATION_PARAM_LIST) @@ -115,7 +165,7 @@ def test_deserialization(asgi, func, body, expected): assert result == expected -@pytest.mark.parametrize('dumps, loads', ALL_JSON_IMPL) +@pytest.mark.parametrize('dumps, loads', ALL_JSON_IMPL, ids=ALL_JSON_IMPL_IDS) @pytest.mark.parametrize('subclass', (True, False)) def test_full_app(asgi, dumps, loads, subclass): if subclass: diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py index 1f91fe3e2..16b6b27e0 100644 --- a/tests/test_media_multipart.py +++ b/tests/test_media_multipart.py @@ -406,21 +406,19 @@ def _factory(options): multipart_handler = media.MultipartFormHandler() for key, value in options.items(): setattr(multipart_handler.parse_options, key, value) - req_handlers = media.Handlers( - { - falcon.MEDIA_JSON: media.JSONHandler(), - falcon.MEDIA_MULTIPART: multipart_handler, - } - ) + req_handlers = { + falcon.MEDIA_JSON: media.JSONHandler(), + falcon.MEDIA_MULTIPART: multipart_handler, + } + resp_handlers = { + falcon.MEDIA_JSON: media.JSONHandler(), + } + if msgpack: + resp_handlers[falcon.MEDIA_MSGPACK] = media.MessagePackHandler() app = create_app(asgi) - app.req_options.media_handlers = req_handlers - app.resp_options.media_handlers = media.Handlers( - { - falcon.MEDIA_JSON: media.JSONHandler(), - falcon.MEDIA_MSGPACK: media.MessagePackHandler(), - } - ) + app.req_options.media_handlers = media.Handlers(req_handlers) + app.resp_options.media_handlers = media.Handlers(resp_handlers) resource = AsyncMultipartAnalyzer() if asgi else MultipartAnalyzer() app.add_route('/submit', resource) diff --git a/tests/test_request_media.py b/tests/test_request_media.py index 4f3d8febc..79d5ba620 100644 --- a/tests/test_request_media.py +++ b/tests/test_request_media.py @@ -9,6 +9,11 @@ from falcon import testing from falcon import util +try: + import msgpack # type: ignore +except ImportError: + msgpack = None + def create_client(asgi, handlers=None, resource=None): if not resource: @@ -98,6 +103,7 @@ def test_json(client, media_type): ('application/x-msgpack'), ], ) +@pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') def test_msgpack(asgi, media_type): client = create_client( asgi, @@ -150,6 +156,7 @@ def test_unknown_media_type(asgi, media_type): @pytest.mark.parametrize('media_type', ['application/json', 'application/msgpack']) +@pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') def test_empty_body(asgi, media_type): client = _create_client_invalid_media( asgi, @@ -190,9 +197,8 @@ def test_invalid_json(asgi): assert str(client.resource.captured_error.value.__cause__) == str(e) +@pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') def test_invalid_msgpack(asgi): - import msgpack - handlers = {'application/msgpack': media.MessagePackHandler()} client = _create_client_invalid_media( asgi, errors.HTTPBadRequest, handlers=handlers diff --git a/tests/test_response_media.py b/tests/test_response_media.py index 4c72ce374..6bf71ab92 100644 --- a/tests/test_response_media.py +++ b/tests/test_response_media.py @@ -7,6 +7,11 @@ from falcon import media from falcon import testing +try: + import msgpack # type: ignore +except ImportError: + msgpack = None + @pytest.fixture def client(): @@ -94,6 +99,7 @@ def test_non_ascii_json_serialization(document): ('application/x-msgpack'), ], ) +@pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') def test_msgpack(media_type): client = create_client( { diff --git a/tests/test_static.py b/tests/test_static.py index 2b9907adb..a6546e2a5 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -2,6 +2,7 @@ import io import os import pathlib +import posixpath import _util # NOQA import pytest @@ -13,17 +14,40 @@ import falcon.testing as testing +def normalize_path(path): + # NOTE(vytas): On CPython 3.13, ntpath.isabs() no longer returns True for + # Unix-like absolute paths that start with a single \. + # We work around this in tests by prepending a fake drive D:\ on Windows. + # See also: https://github.com/python/cpython/issues/117352 + is_pathlib_path = isinstance(path, pathlib.Path) + if not is_pathlib_path and not posixpath.isabs(path): + return path + + path = os.path.normpath(path) + if path.startswith('\\'): + path = 'D:' + path + return pathlib.Path(path) if is_pathlib_path else path + + @pytest.fixture() -def client(asgi): +def client(asgi, monkeypatch): + def add_static_route_normalized(obj, prefix, directory, **kwargs): + add_static_route_orig(obj, prefix, normalize_path(directory), **kwargs) + app = _util.create_app(asgi=asgi) + + app_cls = type(app) + add_static_route_orig = app_cls.add_static_route + monkeypatch.setattr(app_cls, 'add_static_route', add_static_route_normalized) + client = testing.TestClient(app) client.asgi = asgi return client -def create_sr(asgi, *args, **kwargs): +def create_sr(asgi, prefix, directory, **kwargs): sr_type = StaticRouteAsync if asgi else StaticRoute - return sr_type(*args, **kwargs) + return sr_type(prefix, normalize_path(directory), **kwargs) @pytest.fixture @@ -114,8 +138,7 @@ def __init__(self, size): def test_bad_path(asgi, uri, patch_open): patch_open(b'') - sr_type = StaticRouteAsync if asgi else StaticRoute - sr = sr_type('/static', '/var/www/statics') + sr = create_sr(asgi, '/static', '/var/www/statics') req = _util.create_req(asgi, host='test.com', path=uri, root_path='statics') @@ -229,7 +252,7 @@ async def run(): body = resp.stream.read() assert resp.content_type in _MIME_ALTERNATIVE.get(mtype, (mtype,)) - assert body.decode() == os.path.normpath('/var/www/statics' + expected_path) + assert body.decode() == normalize_path('/var/www/statics' + expected_path) assert resp.headers.get('accept-ranges') == 'bytes' @@ -360,7 +383,7 @@ async def run(): sr(req, resp) body = resp.stream.read() - assert body.decode() == os.path.normpath('/var/www/statics/css/test.css') + assert body.decode() == normalize_path('/var/www/statics/css/test.css') def test_lifo(client, patch_open): @@ -371,11 +394,11 @@ def test_lifo(client, patch_open): response = client.simulate_request(path='/downloads/thing.zip') assert response.status == falcon.HTTP_200 - assert response.text == os.path.normpath('/opt/somesite/downloads/thing.zip') + assert response.text == normalize_path('/opt/somesite/downloads/thing.zip') response = client.simulate_request(path='/downloads/archive/thingtoo.zip') assert response.status == falcon.HTTP_200 - assert response.text == os.path.normpath('/opt/somesite/x/thingtoo.zip') + assert response.text == normalize_path('/opt/somesite/x/thingtoo.zip') def test_lifo_negative(client, patch_open): @@ -386,11 +409,11 @@ def test_lifo_negative(client, patch_open): response = client.simulate_request(path='/downloads/thing.zip') assert response.status == falcon.HTTP_200 - assert response.text == os.path.normpath('/opt/somesite/downloads/thing.zip') + assert response.text == normalize_path('/opt/somesite/downloads/thing.zip') response = client.simulate_request(path='/downloads/archive/thingtoo.zip') assert response.status == falcon.HTTP_200 - assert response.text == os.path.normpath( + assert response.text == normalize_path( '/opt/somesite/downloads/archive/thingtoo.zip' ) @@ -450,14 +473,12 @@ def test_fallback_filename( asgi, uri, default, expected, content_type, downloadable, patch_open, monkeypatch ): def validate(path): - if os.path.normpath(default) not in path: + if normalize_path(default) not in path: raise IOError() patch_open(validate=validate) - monkeypatch.setattr( - 'os.path.isfile', lambda file: os.path.normpath(default) in file - ) + monkeypatch.setattr('os.path.isfile', lambda file: normalize_path(default) in file) sr = create_sr( asgi, @@ -484,7 +505,7 @@ async def run(): body = resp.stream.read() assert sr.match(req.path) - expected_content = os.path.normpath(os.path.join('/var/www/statics', expected)) + expected_content = normalize_path(os.path.join('/var/www/statics', expected)) assert body.decode() == expected_content assert resp.content_type in _MIME_ALTERNATIVE.get(content_type, (content_type,)) assert resp.headers.get('accept-ranges') == 'bytes' @@ -529,7 +550,7 @@ def test(prefix, directory, expected): assert response.status == falcon.HTTP_404 else: assert response.status == falcon.HTTP_200 - assert response.text == os.path.normpath(directory + expected) + assert response.text == normalize_path(directory + expected) assert int(response.headers['Content-Length']) == len(response.text) test('/static', '/opt/somesite/static/', static_exp) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2cca7d490..1f267bff6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from datetime import datetime from datetime import timezone import functools @@ -27,6 +25,11 @@ from falcon.util import structures from falcon.util import uri +try: + import msgpack # type: ignore +except ImportError: + msgpack = None + @pytest.fixture def app(asgi): @@ -1096,6 +1099,7 @@ def on_post(self, req, resp): MEDIA_URLENCODED, ], ) + @pytest.mark.skipif(msgpack is None, reason='msgpack is required for this test') def test_simulate_content_type_extra_handler(self, asgi, content_type): class TestResourceAsync(testing.SimpleTestResourceAsync): def __init__(self): diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 65be90d74..b8f029df5 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -1,5 +1,6 @@ import multiprocessing import os +import os.path import time from wsgiref.simple_server import make_server @@ -9,6 +10,7 @@ import falcon import falcon.testing as testing +_HERE = os.path.abspath(os.path.dirname(__file__)) _SERVER_HOST = 'localhost' _SERVER_PORT = 9800 + os.getpid() % 100 # Facilitates parallel test execution _SERVER_BASE_URL = 'http://{}:{}/'.format(_SERVER_HOST, _SERVER_PORT) @@ -22,6 +24,13 @@ def test_get(self): assert resp.status_code == 200 assert resp.text == '127.0.0.1' + def test_get_file(self): + # NOTE(vytas): There was a breaking change in the behaviour of + # ntpath.isabs() in CPython 3.13, let us verify basic file serving. + resp = requests.get(_SERVER_BASE_URL + 'tests/test_wsgi.py') + assert resp.status_code == 200 + assert 'class TestWSGIServer:' in resp.text + def test_put(self): body = '{}' resp = requests.put(_SERVER_BASE_URL, data=body) @@ -91,6 +100,7 @@ def on_post(self, req, resp): api = application = falcon.App() api.add_route('/', Things()) api.add_route('/bucket', Bucket()) + api.add_static_route('/tests', _HERE) server = make_server(host, port, application) diff --git a/tox.ini b/tox.ini index 301827f6c..cd5444fb6 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,6 @@ envlist = cleanup, mypy_tests, mintest, pytest, - pytest_sans_msgpack, coverage, towncrier @@ -83,10 +82,10 @@ commands = python "{toxinidir}/tools/clean.py" "{toxinidir}/falcon" [testenv:mintest] setenv = PIP_CONFIG_FILE={toxinidir}/pip.conf - PYTHONASYNCIODEBUG=1 + PYTHONASYNCIODEBUG=0 FALCON_DISABLE_CYTHON=Y deps = -r{toxinidir}/requirements/mintest -commands = coverage run -m pytest tests --ignore=tests/asgi [] +commands = coverage run -m pytest tests -k 'not slow' [] [testenv:pytest] deps = {[testenv]deps}