diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 09b11966..c12a4638 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -60,11 +60,6 @@ jobs: id-token: write steps: - uses: actions/download-artifact@v4 - # Try uploading to Test PyPI first, in case something fails. - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - packages-dir: artifact/ - uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: artifact/ diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 007a9a70..5c62c046 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,17 +23,17 @@ jobs: fail-fast: false matrix: include: - - {name: Linux, python: '3.12', os: ubuntu-latest, tox: py312} - - {name: Windows, python: '3.12', os: windows-latest, tox: py312} - - {name: Mac, python: '3.12', os: macos-latest, tox: py312} + - {name: Linux, python: '3.13', os: ubuntu-latest, tox: py313} + - {name: Windows, python: '3.13', os: windows-latest, tox: py313} + - {name: Mac, python: '3.13', os: macos-latest, tox: py313} + - {name: '3.13', python: '3.13', os: ubuntu-latest, tox: py313} - {name: '3.12', python: '3.12', os: ubuntu-latest, tox: py312} - {name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311} - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39} - - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - - {name: Typing, python: '3.12', os: ubuntu-latest, tox: mypy} - - {name: Package, python: '3.12', os: ubuntu-latest, tox: package} - - {name: Lint, python: '3.12', os: ubuntu-latest, tox: pep8} - - {name: Format, python: '3.12', os: ubuntu-latest, tox: format} + - {name: Typing, python: '3.13', os: ubuntu-latest, tox: mypy} + - {name: Package, python: '3.13', os: ubuntu-latest, tox: package} + - {name: Lint, python: '3.13', os: ubuntu-latest, tox: pep8} + - {name: Format, python: '3.13', os: ubuntu-latest, tox: format} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/CHANGES.rst b/CHANGES.rst index c8ac40f2..df5c5bf8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,20 @@ +0.19.9 2024-11-14 +----------------- + +* Fix missing ``PROVIDE_AUTOMATIC_OPTIONS`` config for compatibility + with Flask 3.1. + +0.19.8 2024-10-25 +----------------- + +* Bugfix Fix missing check that caused the previous fix to raise an error. #366 + +0.19.7 2024-10-25 +----------------- + +* Security Fix how ``max_form_memory_size`` is applied when parsing large + non-file fields. https://github.com/advisories/GHSA-q34m-jh98-gwm2 + 0.19.6 2024-05-19 ----------------- diff --git a/README.rst b/README.rst index 71743989..80d86ea0 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,7 @@ Quart can be installed via `pip $ pip install quart -and requires Python 3.8.0 or higher (see `python version support +and requires Python 3.9.0 or higher (see `python version support `_ for reasoning). diff --git a/docs/conf.py b/docs/conf.py index fa5647aa..ee34c93f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,7 +65,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/docs/discussion/python_versions.rst b/docs/discussion/python_versions.rst index cdf5e4c9..65fd6269 100644 --- a/docs/discussion/python_versions.rst +++ b/docs/discussion/python_versions.rst @@ -3,9 +3,5 @@ Python version support ====================== -The main branch and releases >= 0.19.0 onwards only support Python -3.8.0 or greater. - -The 0.6-maintenance branch supported Python3.6. The final 0.6.X -release, 0.6.15, was released in October 2019 after the release of -Python3.8. +The main branch and releases >= 0.20.0 onwards only support Python +3.9.0 or greater. diff --git a/docs/how_to_guides/developing.rst b/docs/how_to_guides/developing.rst index 008012d4..69daa1c6 100644 --- a/docs/how_to_guides/developing.rst +++ b/docs/how_to_guides/developing.rst @@ -31,7 +31,7 @@ environment variable. For example, $ QUART_APP=run:app quart run The ``quart run`` command comes with ``--host``, and ``--port`` to -specify where the app is served, and ``--cerfile`` and ``--keyfile`` +specify where the app is served, and ``--certfile`` and ``--keyfile`` to specify the SSL certificates to use. app.run() diff --git a/docs/how_to_guides/quart_extensions.rst b/docs/how_to_guides/quart_extensions.rst index 2148a83f..7fa807a3 100644 --- a/docs/how_to_guides/quart_extensions.rst +++ b/docs/how_to_guides/quart_extensions.rst @@ -48,6 +48,8 @@ here, validation and auto-generated API documentation. - `Quart-session `_ server side session support. +- `Quart-LibreTranslate `_ Simple integration to + use LibreTranslate with your Quart app. - `Quart-Uploads `_ File upload handling for Quart. Supporting sync code in a Quart Extension diff --git a/docs/tutorials/blog_tutorial.rst b/docs/tutorials/blog_tutorial.rst index 5f16ea6e..667f7e95 100644 --- a/docs/tutorials/blog_tutorial.rst +++ b/docs/tutorials/blog_tutorial.rst @@ -110,7 +110,7 @@ we can do by adding the command code to *src/blog/__init__.py*: from sqlite3 import dbapi2 as sqlite3 app.config.update({ - "DATABASE": app.root_path / "blog.db", + "DATABASE": Path(app.root_path) / "blog.db", }) def _connect_db(): @@ -120,7 +120,7 @@ we can do by adding the command code to *src/blog/__init__.py*: def init_db(): db = _connect_db() - with open(app.root_path / "schema.sql", mode="r") as file_: + with open(Path(app.root_path) / "schema.sql", mode="r") as file_: db.cursor().executescript(file_.read()) db.commit() diff --git a/docs/tutorials/deployment.rst b/docs/tutorials/deployment.rst index 9344c793..b63a4288 100644 --- a/docs/tutorials/deployment.rst +++ b/docs/tutorials/deployment.rst @@ -38,13 +38,10 @@ See the `Hypercorn docs `_. Alternative ASGI Servers ------------------------ -==================================================== ====== ====== =========== ================== -Server name HTTP/2 HTTP/3 Server Push Websocket Response -==================================================== ====== ====== =========== ================== -`Hypercorn `_ ✓ ✓ ✓ ✓ -`Daphne `_ ✓ ✗ ✗ ✗ -`Uvicorn `_ ✗ ✗ ✗ ✗ -==================================================== ====== ====== =========== ================== +Alongside `Hypercorn `_, `Daphne +`_, and `Uvicorn +`_ are available ASGI servers that +work with Quart. Serverless deployment --------------------- diff --git a/docs/tutorials/installation.rst b/docs/tutorials/installation.rst index 66774612..83568bb3 100644 --- a/docs/tutorials/installation.rst +++ b/docs/tutorials/installation.rst @@ -3,7 +3,7 @@ Installation ============ -Quart is only compatible with Python 3.8 or higher and can be installed +Quart is only compatible with Python 3.9 or higher and can be installed using pip or your favorite python package manager: .. code-block:: console @@ -20,11 +20,11 @@ be installed with Quart: - blinker, to manager signals, - click, to manage command line arguments - hypercorn, an ASGI server for development, -- importlib_metadata only for Python 3.8, +- importlib_metadata only for Python 3.9, - itsdangerous, for signing secure cookies, - jinja2, for template rendering, - markupsafe, for markup rendering, -- typing_extensions only for Python 3.8, +- typing_extensions only for Python 3.9, - werkzeug, as the basis of many Quart classes. You can choose to install with the dotenv extra: diff --git a/pyproject.toml b/pyproject.toml index 1e228527..73ef1a2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.6" +version = "0.20.0.dev" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ @@ -11,11 +11,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", ] @@ -26,7 +26,7 @@ repository = "https://github.com/pallets/quart/" documentation = "https://quart.palletsprojects.com" [tool.poetry.dependencies] -python = ">=3.8" +python = ">=3.9" aiofiles = "*" blinker = ">=1.6" click = ">=8.0.0" @@ -55,7 +55,7 @@ dotenv = ["python-dotenv"] [tool.black] line-length = 100 -target-version = ["py38"] +target-version = ["py39"] [tool.isort] combine_as_imports = true @@ -87,6 +87,7 @@ warn_unused_ignores = true [tool.pytest.ini_options] addopts = "--no-cov-on-fail --showlocals --strict-markers" +asyncio_default_fixture_loop_scope = "session" asyncio_mode = "auto" testpaths = ["tests"] diff --git a/setup.cfg b/setup.cfg index a2e13d5d..1cec8c49 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [flake8] ignore = E203, E252, E704, FI58, W503, W504 max_line_length = 100 -min_version = 3.8 +min_version = 3.9 per-file-ignores = src/quart/__init__.py:F401 require_code = True diff --git a/src/quart/app.py b/src/quart/app.py index da23382a..c14d30b8 100644 --- a/src/quart/app.py +++ b/src/quart/app.py @@ -7,7 +7,7 @@ import warnings from collections import defaultdict from datetime import timedelta -from inspect import isasyncgen, isgenerator +from inspect import isasyncgen, iscoroutinefunction as _inspect_iscoroutinefunction, isgenerator from types import TracebackType from typing import ( Any, @@ -28,7 +28,6 @@ from aiofiles import open as async_open from aiofiles.base import AiofilesContextManager -from aiofiles.threadpool.binary import AsyncBufferedReader from flask.sansio.app import App from flask.sansio.scaffold import setupmethod from hypercorn.asyncio import serve @@ -125,7 +124,16 @@ try: from typing import ParamSpec except ImportError: - from typing_extensions import ParamSpec # type: ignore + from typing_extensions import ParamSpec + +# Python 3.14 deprecated asyncio.iscoroutinefunction, but suggested +# inspect.iscoroutinefunction does not work correctly in some Python +# versions before 3.12. +# See https://github.com/python/cpython/issues/122858#issuecomment-2466239748 +if sys.version_info >= (3, 12): + iscoroutinefunction = _inspect_iscoroutinefunction +else: + iscoroutinefunction = asyncio.iscoroutinefunction AppOrBlueprintKey = Optional[str] # The App key is None, whereas blueprints are named T_after_serving = TypeVar("T_after_serving", bound=AfterServingCallable) @@ -241,6 +249,7 @@ class Quart(App): "PREFER_SECURE_URLS": False, "PRESERVE_CONTEXT_ON_EXCEPTION": None, "PROPAGATE_EXCEPTIONS": None, + "PROVIDE_AUTOMATIC_OPTIONS": True, "RESPONSE_TIMEOUT": 60, # Second "SECRET_KEY": None, "SEND_FILE_MAX_AGE_DEFAULT": timedelta(hours=12), @@ -375,7 +384,7 @@ async def open_resource( self, path: FilePath, mode: str = "rb", - ) -> AiofilesContextManager[None, None, AsyncBufferedReader]: + ) -> AiofilesContextManager: """Open a file for reading. Use as @@ -392,7 +401,7 @@ async def open_resource( async def open_instance_resource( self, path: FilePath, mode: str = "rb" - ) -> AiofilesContextManager[None, None, AsyncBufferedReader]: + ) -> AiofilesContextManager: """Open a file for reading. Use as @@ -1130,7 +1139,7 @@ def ensure_async( run. Before Quart 0.11 this did not run the synchronous code in an executor. """ - if asyncio.iscoroutinefunction(func): + if iscoroutinefunction(func): return func else: return self.sync_to_async(cast(Callable[P, T], func)) @@ -1393,7 +1402,7 @@ async def make_response(self, result: ResponseReturnValue | HTTPException) -> Re response.status_code = int(status) if headers is not None: - response.headers.update(headers) # type: ignore[arg-type] + response.headers.update(headers) return response diff --git a/src/quart/blueprints.py b/src/quart/blueprints.py index acaa0474..c40d3f57 100644 --- a/src/quart/blueprints.py +++ b/src/quart/blueprints.py @@ -7,7 +7,6 @@ from aiofiles import open as async_open from aiofiles.base import AiofilesContextManager -from aiofiles.threadpool.binary import AsyncBufferedReader from flask.sansio.app import App from flask.sansio.blueprints import ( # noqa Blueprint as SansioBlueprint, @@ -101,7 +100,7 @@ async def open_resource( self, path: FilePath, mode: str = "rb", - ) -> AiofilesContextManager[None, None, AsyncBufferedReader]: + ) -> AiofilesContextManager: """Open a file for reading. Use as diff --git a/src/quart/formparser.py b/src/quart/formparser.py index eab878a6..8b0c0abc 100644 --- a/src/quart/formparser.py +++ b/src/quart/formparser.py @@ -15,6 +15,7 @@ from urllib.parse import parse_qsl from werkzeug.datastructures import Headers, MultiDict +from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.formparser import default_stream_factory from werkzeug.http import parse_options_header from werkzeug.sansio.multipart import Data, Epilogue, Field, File, MultipartDecoder, NeedData @@ -173,19 +174,28 @@ async def parse( files = [] current_part: Field | File + field_size: int | None = None async for data in body: parser.receive_data(data) event = parser.next_event() while not isinstance(event, (Epilogue, NeedData)): if isinstance(event, Field): current_part = event + field_size = 0 container = [] _write = container.append elif isinstance(event, File): current_part = event + field_size = None container = self.start_file_streaming(event, content_length) _write = container.write elif isinstance(event, Data): + if self.max_form_memory_size is not None and field_size is not None: + field_size += len(event.data) + + if field_size > self.max_form_memory_size: + raise RequestEntityTooLarge() + _write(event.data) if not event.more_data: if isinstance(current_part, Field): diff --git a/src/quart/helpers.py b/src/quart/helpers.py index 8dd1146d..f3df7002 100644 --- a/src/quart/helpers.py +++ b/src/quart/helpers.py @@ -129,10 +129,10 @@ def get_flashed_messages( all messages will be popped, but only those matching the filter returned. See :func:`~quart.helpers.flash` for message creation. """ - flashes = request_ctx.flashes + flashes: list[str] = request_ctx.flashes if flashes is None: - flashes = session.pop("_flashes") if "_flashes" in session else [] - request_ctx.flashes = flashes + flashes = session.pop("_flashes", []) + request_ctx.flashes = flashes # type: ignore[assignment] if category_filter: flashes = [flash for flash in flashes if flash[0] in category_filter] if not with_categories: diff --git a/src/quart/testing/app.py b/src/quart/testing/app.py index 7875590a..2ed04e23 100644 --- a/src/quart/testing/app.py +++ b/src/quart/testing/app.py @@ -37,7 +37,7 @@ def test_client(self) -> TestClientProtocol: return self.app.test_client() async def startup(self) -> None: - scope: LifespanScope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}} + scope: LifespanScope = {"type": "lifespan", "asgi": {"spec_version": "2.0"}, "state": {}} self._task = asyncio.ensure_future(self.app(scope, self._asgi_receive, self._asgi_send)) await self._app_queue.put({"type": "lifespan.startup"}) await asyncio.wait_for(self._startup.wait(), timeout=self.startup_timeout) diff --git a/src/quart/utils.py b/src/quart/utils.py index 7e48acee..a92fc032 100644 --- a/src/quart/utils.py +++ b/src/quart/utils.py @@ -10,13 +10,14 @@ from pathlib import Path from typing import ( Any, - AsyncGenerator, + AsyncIterator, Awaitable, Callable, Coroutine, - Generator, Iterable, + Iterator, TYPE_CHECKING, + TypeVar, ) from werkzeug.datastructures import Headers @@ -66,12 +67,15 @@ async def _wrapper(*args: Any, **kwargs: Any) -> Any: return _wrapper -def run_sync_iterable(iterable: Generator[Any, None, None]) -> AsyncGenerator[Any, None]: - async def _gen_wrapper() -> AsyncGenerator[Any, None]: +T = TypeVar("T") + + +def run_sync_iterable(iterable: Iterator[T]) -> AsyncIterator[T]: + async def _gen_wrapper() -> AsyncIterator[T]: # Wrap the generator such that each iteration runs # in the executor. Then rationalise the raised # errors so that it ends. - def _inner() -> Any: + def _inner() -> T: # https://bugs.python.org/issue26221 # StopIteration errors are swallowed by the # run_in_exector method diff --git a/src/quart/wrappers/request.py b/src/quart/wrappers/request.py index a80fa81d..8b4ed8de 100644 --- a/src/quart/wrappers/request.py +++ b/src/quart/wrappers/request.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import Any, AnyStr, Awaitable, Callable, Generator, NoReturn, overload +from typing import Any, Awaitable, Callable, Generator, NoReturn, overload from hypercorn.typing import HTTPScope from werkzeug.datastructures import CombinedMultiDict, Headers, iter_multi_items, MultiDict @@ -184,7 +184,7 @@ async def stream(self) -> NoReturn: @property async def data(self) -> bytes: - return await self.get_data(as_text=False, parse_form_data=True) + return await self.get_data(as_text=False, parse_form_data=True) # type: ignore @overload async def get_data( @@ -197,16 +197,16 @@ async def get_data(self, cache: bool, as_text: Literal[True], parse_form_data: b @overload async def get_data( self, cache: bool = True, as_text: bool = False, parse_form_data: bool = False - ) -> AnyStr: ... + ) -> str | bytes: ... async def get_data( self, cache: bool = True, as_text: bool = False, parse_form_data: bool = False - ) -> AnyStr: + ) -> str | bytes: """Get the request body data. Arguments: cache: If False the body data will be cleared, resulting in any - subsequent calls returning an empty AnyStr and reducing + subsequent calls returning an empty str | bytes and reducing memory usage. as_text: If True the data is returned as a decoded string, otherwise raw bytes are returned. diff --git a/src/quart/wrappers/response.py b/src/quart/wrappers/response.py index 065eabf4..01bf4c02 100644 --- a/src/quart/wrappers/response.py +++ b/src/quart/wrappers/response.py @@ -2,13 +2,11 @@ from abc import ABC, abstractmethod from hashlib import md5 -from inspect import isasyncgen, isgenerator from io import BytesIO from os import PathLike from types import TracebackType from typing import ( Any, - AnyStr, AsyncGenerator, AsyncIterable, AsyncIterator, @@ -19,7 +17,7 @@ from aiofiles import open as async_open from aiofiles.base import AiofilesContextManager -from aiofiles.threadpool.binary import AsyncBufferedIOBase, AsyncBufferedReader +from aiofiles.threadpool.binary import AsyncBufferedIOBase from werkzeug.datastructures import ContentRange, Headers from werkzeug.exceptions import RequestedRangeNotSatisfiable from werkzeug.http import parse_etags @@ -102,27 +100,21 @@ async def __anext__(self) -> bytes: class IterableBody(ResponseBody): - def __init__(self, iterable: AsyncGenerator[bytes, None] | Iterable) -> None: - self.iter: AsyncGenerator[bytes, None] - if isasyncgen(iterable): - self.iter = iterable - elif isgenerator(iterable): - self.iter = run_sync_iterable(iterable) + def __init__(self, iterable: AsyncIterable[Any] | Iterable[Any]) -> None: + self.iter: AsyncIterator[Any] + if isinstance(iterable, Iterable): + self.iter = run_sync_iterable(iter(iterable)) else: - - async def _aiter() -> AsyncGenerator[bytes, None]: - for data in iterable: # type: ignore - yield data - - self.iter = _aiter() + self.iter = iterable.__aiter__() # Can't use aiter() until 3.10 async def __aenter__(self) -> IterableBody: return self async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: - await self.iter.aclose() + if hasattr(self.iter, "aclose"): + await self.iter.aclose() - def __aiter__(self) -> AsyncIterator: + def __aiter__(self) -> AsyncIterator[Any]: return self.iter @@ -148,7 +140,7 @@ def __init__(self, file_path: str | PathLike, *, buffer_size: int | None = None) if buffer_size is not None: self.buffer_size = buffer_size self.file: AsyncBufferedIOBase | None = None - self.file_manager: AiofilesContextManager[None, None, AsyncBufferedReader] = None + self.file_manager: AiofilesContextManager = None async def __aenter__(self) -> FileBody: self.file_manager = async_open(self.file_path, mode="rb") @@ -262,7 +254,7 @@ class Response(SansIOResponse): def __init__( self, - response: ResponseBody | AnyStr | Iterable | None = None, + response: ResponseBody | str | bytes | Iterable | AsyncIterable | None = None, status: int | None = None, headers: dict | Headers | None = None, mimetype: str | None = None, @@ -296,7 +288,7 @@ def __init__( elif isinstance(response, ResponseBody): self.response = response elif isinstance(response, (str, bytes)): - self.set_data(response) # type: ignore + self.set_data(response) else: self.response = self.iterable_body_class(response) @@ -314,9 +306,9 @@ async def get_data(self, as_text: Literal[True]) -> str: ... async def get_data(self, as_text: Literal[False]) -> bytes: ... @overload - async def get_data(self, as_text: bool = True) -> AnyStr: ... + async def get_data(self, as_text: bool = True) -> str | bytes: ... - async def get_data(self, as_text: bool = False) -> AnyStr: + async def get_data(self, as_text: bool = False) -> str | bytes: """Return the body data.""" if self.implicit_sequence_conversion: await self.make_sequence() @@ -327,9 +319,9 @@ async def get_data(self, as_text: bool = False) -> AnyStr: result += data.decode() else: result += data - return result # type: ignore + return result - def set_data(self, data: AnyStr) -> None: + def set_data(self, data: str | bytes) -> None: """Set the response data. This will encode using the :attr:`charset`. @@ -344,7 +336,7 @@ def set_data(self, data: AnyStr) -> None: @property async def data(self) -> bytes: - return await self.get_data() + return await self.get_data(as_text=False) @data.setter def data(self, value: bytes) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 8ab92a27..e14c22c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,7 @@ def _http_scope() -> HTTPScope: ], "client": ("127.0.0.1", 80), "server": None, + "state": {}, # type: ignore[typeddict-item] "extensions": {}, } @@ -46,5 +47,6 @@ def _websocket_scope() -> WebsocketScope: "client": ("127.0.0.1", 80), "server": None, "subprotocols": [], + "state": {}, # type: ignore[typeddict-item] "extensions": {}, } diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 13f0da44..91f4dc09 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -34,6 +34,7 @@ async def test_http_1_0_host_header(headers: list, expected: str) -> None: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIHTTPConnection(app, scope) request = connection._create_request_from_scope(lambda: None) # type: ignore @@ -57,6 +58,7 @@ async def test_http_completion() -> None: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIHTTPConnection(app, scope) @@ -98,6 +100,7 @@ async def test_http_request_without_body(request_message: dict) -> None: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIHTTPConnection(app, scope) request = connection._create_request_from_scope(lambda: None) # type: ignore @@ -135,6 +138,7 @@ async def test_websocket_completion() -> None: "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIWebsocketConnection(app, scope) @@ -168,6 +172,7 @@ def test_http_path_from_absolute_target() -> None: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIHTTPConnection(app, scope) request = connection._create_request_from_scope(lambda: None) # type: ignore @@ -194,6 +199,7 @@ def test_http_path_with_root_path(path: str, expected: str) -> None: "client": ("127.0.0.1", 80), "server": None, "extensions": {}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIHTTPConnection(app, scope) request = connection._create_request_from_scope(lambda: None) # type: ignore @@ -216,6 +222,7 @@ def test_websocket_path_from_absolute_target() -> None: "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIWebsocketConnection(app, scope) websocket = connection._create_websocket_from_scope(lambda: None) # type: ignore @@ -242,6 +249,7 @@ def test_websocket_path_with_root_path(path: str, expected: str) -> None: "server": None, "subprotocols": [], "extensions": {"websocket.http.response": {}}, + "state": {}, # type: ignore[typeddict-item] } connection = ASGIWebsocketConnection(app, scope) websocket = connection._create_websocket_from_scope(lambda: None) # type: ignore diff --git a/tests/test_basic.py b/tests/test_basic.py index 339e16ee..cc2208e4 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -83,13 +83,13 @@ async def test_index(path: str, app: Quart) -> None: test_client = app.test_client() response = await test_client.get(path) assert response.status_code == 200 - assert b"index" in (await response.get_data()) # type: ignore + assert b"index" in (await response.get_data()) async def test_iri(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/❤️") - assert "💔".encode() in (await response.get_data()) # type: ignore + assert "💔".encode() in (await response.get_data()) async def test_options(app: Quart) -> None: @@ -107,35 +107,35 @@ async def test_json(app: Quart) -> None: test_client = app.test_client() response = await test_client.post("/json/", json={"value": "json"}) assert response.status_code == 200 - assert b'{"value":"json"}\n' == (await response.get_data()) # type: ignore + assert b'{"value":"json"}\n' == (await response.get_data()) async def test_implicit_json(app: Quart) -> None: test_client = app.test_client() response = await test_client.post("/implicit_json/", json={"value": "json"}) assert response.status_code == 200 - assert b'{"value":"json"}\n' == (await response.get_data()) # type: ignore + assert b'{"value":"json"}\n' == (await response.get_data()) async def test_implicit_json_list(app: Quart) -> None: test_client = app.test_client() response = await test_client.post("/implicit_json/", json=["a", 2]) assert response.status_code == 200 - assert b'["a",2]\n' == (await response.get_data()) # type: ignore + assert b'["a",2]\n' == (await response.get_data()) async def test_werkzeug(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/werkzeug/") assert response.status_code == 200 - assert b"Hello" == (await response.get_data()) # type: ignore + assert b"Hello" == (await response.get_data()) async def test_generic_error(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/error/") assert response.status_code == 409 - assert b"Something Unique" in (await response.get_data()) # type: ignore + assert b"Something Unique" in (await response.get_data()) async def test_url_defaults(app: Quart) -> None: @@ -151,7 +151,7 @@ async def test_not_found_error(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/not_found/") assert response.status_code == 404 - assert b"Not Found" in (await response.get_data()) # type: ignore + assert b"Not Found" in (await response.get_data()) async def test_make_response_str(app: Quart) -> None: @@ -225,4 +225,4 @@ async def test_root_path(app: Quart) -> None: async def test_stream(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/stream") - assert (await response.get_data()) == b"Hello World" # type: ignore + assert (await response.get_data()) == b"Hello World" diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 759f1186..53caa91f 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -86,7 +86,7 @@ async def empty_path_route() -> ResponseReturnValue: test_client = app.test_client() response = await test_client.get("/prefix") assert response.status_code == 200 - assert await response.get_data() == b"OK" # type: ignore + assert await response.get_data() == b"OK" async def test_blueprint_template_filter() -> None: @@ -104,7 +104,7 @@ async def route() -> ResponseReturnValue: app.register_blueprint(blueprint) response = await app.test_client().get("/") - assert b"olleh" in (await response.get_data()) # type: ignore + assert b"olleh" in (await response.get_data()) async def test_blueprint_error_handler() -> None: @@ -124,7 +124,7 @@ async def handler(_: Exception) -> ResponseReturnValue: response = await app.test_client().get("/error/") assert response.status_code == 409 - assert b"Something Unique" in (await response.get_data()) # type: ignore + assert b"Something Unique" in (await response.get_data()) async def test_blueprint_method_view() -> None: @@ -328,14 +328,14 @@ async def sibling_index() -> ResponseReturnValue: client = app.test_client() - assert (await (await client.get("/parent/")).get_data()) == b"Parent yes" # type: ignore - assert (await (await client.get("/parent/child/")).get_data()) == b"Child yes" # type: ignore - assert (await (await client.get("/parent/sibling")).get_data()) == b"Sibling yes" # type: ignore # noqa: E501 - assert (await (await client.get("/alt/sibling")).get_data()) == b"Sibling yes" # type: ignore - assert (await (await client.get("/parent/child/grandchild/")).get_data()) == b"Grandchild yes" # type: ignore # noqa: E501 - assert (await (await client.get("/parent/no")).get_data()) == b"Parent no" # type: ignore - assert (await (await client.get("/parent/child/no")).get_data()) == b"Parent no" # type: ignore - assert (await (await client.get("/parent/child/grandchild/no")).get_data()) == b"Grandchild no" # type: ignore # noqa: E501 + assert (await (await client.get("/parent/")).get_data()) == b"Parent yes" + assert (await (await client.get("/parent/child/")).get_data()) == b"Child yes" + assert (await (await client.get("/parent/sibling")).get_data()) == b"Sibling yes" + assert (await (await client.get("/alt/sibling")).get_data()) == b"Sibling yes" + assert (await (await client.get("/parent/child/grandchild/")).get_data()) == b"Grandchild yes" + assert (await (await client.get("/parent/no")).get_data()) == b"Parent no" + assert (await (await client.get("/parent/child/no")).get_data()) == b"Parent no" + assert (await (await client.get("/parent/child/grandchild/no")).get_data()) == b"Grandchild no" async def test_blueprint_renaming() -> None: @@ -366,12 +366,12 @@ async def index2() -> str: client = app.test_client() - assert (await (await client.get("/a/")).get_data()) == b"bp.index" # type: ignore - assert (await (await client.get("/b/")).get_data()) == b"alt.index" # type: ignore - assert (await (await client.get("/a/a/")).get_data()) == b"bp.sub.index2" # type: ignore - assert (await (await client.get("/b/a/")).get_data()) == b"alt.sub.index2" # type: ignore - assert (await (await client.get("/a/error")).get_data()) == b"Error" # type: ignore - assert (await (await client.get("/b/error")).get_data()) == b"Error" # type: ignore + assert (await (await client.get("/a/")).get_data()) == b"bp.index" + assert (await (await client.get("/b/")).get_data()) == b"alt.index" + assert (await (await client.get("/a/a/")).get_data()) == b"bp.sub.index2" + assert (await (await client.get("/b/a/")).get_data()) == b"alt.sub.index2" + assert (await (await client.get("/a/error")).get_data()) == b"Error" + assert (await (await client.get("/b/error")).get_data()) == b"Error" def test_self_registration() -> None: @@ -461,5 +461,5 @@ async def b() -> str: client = app.test_client() assert ( await (await client.get("/a")).get_data() - ) == b"app_1, app_2, parent_1, parent_2, child_1, child_2" # type: ignore - assert (await (await client.get("/b")).get_data()) == b"child" # type: ignore + ) == b"app_1, app_2, parent_1, parent_2, child_1, child_2" + assert (await (await client.get("/b")).get_data()) == b"child" diff --git a/tests/test_debug.py b/tests/test_debug.py index 5cba6a11..61159310 100644 --- a/tests/test_debug.py +++ b/tests/test_debug.py @@ -10,4 +10,4 @@ async def test_debug() -> None: response = await traceback_response(Exception("Unique error")) assert response.status_code == 500 - assert b"Unique error" in (await response.get_data()) # type: ignore + assert b"Unique error" in (await response.get_data()) diff --git a/tests/test_formparser.py b/tests/test_formparser.py new file mode 100644 index 00000000..c5e85f22 --- /dev/null +++ b/tests/test_formparser.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest +from werkzeug.exceptions import RequestEntityTooLarge + +from quart.formparser import MultiPartParser +from quart.wrappers.request import Body + + +async def test_multipart_max_form_memory_size() -> None: + """max_form_memory_size is tracked across multiple data events.""" + data = b"--bound\r\nContent-Disposition: form-field; name=a\r\n\r\n" + data += b"a" * 15 + b"\r\n--bound--" + body = Body(None, None) + body.set_result(data) + # The buffer size is less than the max size, so multiple data events will be + # returned. The field size is greater than the max. + parser = MultiPartParser(max_form_memory_size=10, buffer_size=5) + + with pytest.raises(RequestEntityTooLarge): + await parser.parse(body, b"bound", 0) diff --git a/tests/test_sync.py b/tests/test_sync.py index 5a8a57ca..64173fa3 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -31,14 +31,14 @@ def _gen() -> Generator[bytes, None, None]: async def test_sync_request_context(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/") - assert b"GET" in (await response.get_data()) # type: ignore + assert b"GET" in (await response.get_data()) response = await test_client.post("/") - assert b"POST" in (await response.get_data()) # type: ignore + assert b"POST" in (await response.get_data()) async def test_sync_generator(app: Quart) -> None: test_client = app.test_client() response = await test_client.get("/gen") result = await response.get_data() - assert result[-2:] == b"bb" # type: ignore + assert result[-2:] == b"bb" assert int(result[:-2]) != threading.current_thread().ident diff --git a/tests/test_templating.py b/tests/test_templating.py index f7450202..5df54033 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -9,6 +9,7 @@ g, Quart, render_template_string, + Response, ResponseReturnValue, session, stream_template_string, @@ -148,3 +149,11 @@ async def index() -> ResponseReturnValue: test_client = app.test_client() response = await test_client.get("/") assert (await response.data) == b"42" + + @app.get("/2") + async def index2() -> ResponseReturnValue: + return Response(await stream_template_string("{{ config }}", config=43)) + + test_client = app.test_client() + response = await test_client.get("/2") + assert (await response.data) == b"43" diff --git a/tests/test_testing.py b/tests/test_testing.py index a77c5637..d20a121b 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -233,8 +233,8 @@ async def test_data() -> None: @app.route("/", methods=["POST"]) async def echo() -> str: - data = await request.get_data(True) - return data + data = await request.get_data(as_text=True) + return data # type: ignore client = Client(app) headers = {"Content-Type": "application/octet-stream"} @@ -365,7 +365,7 @@ async def index() -> str: local_session["foo"] = [42] assert len(local_session) == 1 response = await test_client.get("/") - assert (await response.get_data()) == b"[42]" # type: ignore + assert (await response.get_data()) == b"[42]" async with test_client.session_transaction() as local_session: assert len(local_session) == 1 assert local_session["foo"] == [42] diff --git a/tests/wrappers/test_response.py b/tests/wrappers/test_response.py index 99a77a32..bda590a2 100644 --- a/tests/wrappers/test_response.py +++ b/tests/wrappers/test_response.py @@ -73,9 +73,9 @@ def test_response_status(status: Any, expected: int) -> None: async def test_response_body() -> None: response = Response(b"Body") - assert b"Body" == (await response.get_data()) # type: ignore + assert b"Body" == (await response.get_data()) # Fetch again to ensure it isn't exhausted - assert b"Body" == (await response.get_data()) # type: ignore + assert b"Body" == (await response.get_data()) async def test_response_make_conditional(http_scope: HTTPScope) -> None: @@ -92,7 +92,7 @@ async def test_response_make_conditional(http_scope: HTTPScope) -> None: ) response = Response(b"abcdef") await response.make_conditional(request, accept_ranges=True, complete_length=6) - assert b"abcd" == (await response.get_data()) # type: ignore + assert b"abcd" == (await response.get_data()) assert response.status_code == 206 assert response.accept_ranges == "bytes" assert response.content_range.units == "bytes" @@ -107,7 +107,7 @@ async def test_response_make_conditional_no_condition(http_scope: HTTPScope) -> ) response = Response(b"abcdef") await response.make_conditional(request, complete_length=6) - assert b"abcdef" == (await response.get_data()) # type: ignore + assert b"abcdef" == (await response.get_data()) assert response.status_code == 200 @@ -125,7 +125,7 @@ async def test_response_make_conditional_out_of_bound(http_scope: HTTPScope) -> ) response = Response(b"abcdef") await response.make_conditional(request, complete_length=6) - assert b"abcdef" == (await response.get_data()) # type: ignore + assert b"abcdef" == (await response.get_data()) assert response.status_code == 206 @@ -145,7 +145,7 @@ async def test_response_make_conditional_not_modified(http_scope: HTTPScope) -> ) await response.make_conditional(request) assert response.status_code == 304 - assert b"" == (await response.get_data()) # type: ignore + assert b"" == (await response.get_data()) assert "content-length" not in response.headers @@ -182,7 +182,7 @@ def test_response_cache_control() -> None: async def test_empty_response() -> None: response = Response() - assert b"" == (await response.get_data()) # type: ignore + assert b"" == (await response.get_data()) @given( diff --git a/tox.ini b/tox.ini index bc71c210..333b62d9 100644 --- a/tox.ini +++ b/tox.ini @@ -5,11 +5,11 @@ envlist = mypy package pep8 - py38 py39 py310 py311 py312 + py313 minversion = 3.3 isolated_build = true @@ -24,7 +24,7 @@ deps = commands = pytest --cov=quart {posargs} [testenv:docs] -basepython = python3.12 +basepython = python3.13 deps = pydata-sphinx-theme sphinx @@ -32,7 +32,7 @@ commands = sphinx-build -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/ [testenv:format] -basepython = python3.12 +basepython = python3.13 deps = black isort @@ -42,7 +42,7 @@ commands = skip_install = true [testenv:pep8] -basepython = python3.12 +basepython = python3.13 deps = flake8 pep8-naming @@ -52,7 +52,7 @@ commands = flake8 src/quart/ tests/ skip_install = true [testenv:mypy] -basepython = python3.12 +basepython = python3.13 deps = flask mypy @@ -67,7 +67,7 @@ commands = mypy src/quart/ tests/ [testenv:package] -basepython = python3.12 +basepython = python3.13 deps = poetry twine