From 5e78c4169b8eb66b91ead3e62d44721b9e1644ee Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 25 Oct 2024 11:32:20 -0700 Subject: [PATCH 01/22] apply max_form_memory_size another level up in the parser --- CHANGES.rst | 6 ++++++ pyproject.toml | 2 +- src/quart/formparser.py | 10 ++++++++++ tests/test_formparser.py | 21 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/test_formparser.py diff --git a/CHANGES.rst b/CHANGES.rst index c8ac40f..282b360 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,9 @@ +0.19.7 +------ + +* 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/pyproject.toml b/pyproject.toml index 1e22852..9d71aca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.6" +version = "0.19.7.dev" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ diff --git a/src/quart/formparser.py b/src/quart/formparser.py index eab878a..b8b0e52 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 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/tests/test_formparser.py b/tests/test_formparser.py new file mode 100644 index 0000000..c5e85f2 --- /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) From 0c91e8e5cd35eb0b8629672627decd528f1f2bbf Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 25 Oct 2024 11:46:32 -0700 Subject: [PATCH 02/22] release version 0.19.7 --- CHANGES.rst | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 282b360..eda53f7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,5 @@ -0.19.7 ------- +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 diff --git a/pyproject.toml b/pyproject.toml index 9d71aca..4bb1471 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.7.dev" +version = "0.19.7" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ From 972e5ed8fde09ce2a194643d2c75de0d6a46fcd0 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 25 Oct 2024 15:20:31 -0700 Subject: [PATCH 03/22] start version 0.19.8 --- CHANGES.rst | 3 +++ pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index eda53f7..4d1a1de 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,6 @@ +0.19.8 +------ + 0.19.7 2024-10-25 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 4bb1471..2cedbf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.7" +version = "0.19.8.dev" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ From 3a96a11f3a1f2092f2b450965082396b4f5b549a Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 25 Oct 2024 15:22:04 -0700 Subject: [PATCH 04/22] check max_form_memory_size is set before comparing --- CHANGES.rst | 2 ++ src/quart/formparser.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4d1a1de..2ad511b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,8 @@ 0.19.8 ------ +* Bugfix Fix missing check that caused the previous fix to raise an error. #366 + 0.19.7 2024-10-25 ----------------- diff --git a/src/quart/formparser.py b/src/quart/formparser.py index b8b0e52..8b0c0ab 100644 --- a/src/quart/formparser.py +++ b/src/quart/formparser.py @@ -190,7 +190,7 @@ async def parse( container = self.start_file_streaming(event, content_length) _write = container.write elif isinstance(event, Data): - if field_size is not None: + 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: From 6d7d8df807313224f92b22e3266c3b0d897e4954 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 25 Oct 2024 15:33:21 -0700 Subject: [PATCH 05/22] release version 0.19.8 --- CHANGES.rst | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2ad511b..3b98134 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,5 @@ -0.19.8 ------- +0.19.8 2024-10-25 +----------------- * Bugfix Fix missing check that caused the previous fix to raise an error. #366 diff --git a/pyproject.toml b/pyproject.toml index 2cedbf2..0e06583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.8.dev" +version = "0.19.8" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ From 66f0917f9e15f8c98265c6b2b7701fd923cbdd1e Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Wed, 17 Jul 2024 15:31:40 -0700 Subject: [PATCH 06/22] Fix cerfile typo --- docs/how_to_guides/developing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how_to_guides/developing.rst b/docs/how_to_guides/developing.rst index 008012d..69daa1c 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() From f38f629881d2f65087263d62b1ca48cc7fc369d1 Mon Sep 17 00:00:00 2001 From: Illia Volochii Date: Thu, 14 Nov 2024 10:28:43 +0200 Subject: [PATCH 07/22] Fix a deprecation warning in Python 3.14 --- src/quart/app.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/quart/app.py b/src/quart/app.py index da23382..bcf13b7 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, @@ -127,6 +127,15 @@ except ImportError: from typing_extensions import ParamSpec # type: ignore +# 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) T_after_websocket = TypeVar("T_after_websocket", bound=AfterWebsocketCallable) @@ -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)) From 24a72b9b6e19ca30c985f41bfb8b295377e87c12 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:08:58 +0000 Subject: [PATCH 08/22] Drop support for Python3.8 and support Python 3.13 The former is EoL and the latter was released a few months ago. --- .github/workflows/tests.yaml | 16 ++++++++-------- README.rst | 2 +- docs/discussion/python_versions.rst | 8 ++------ docs/tutorials/installation.rst | 6 +++--- pyproject.toml | 6 +++--- setup.cfg | 2 +- tox.ini | 12 ++++++------ 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 007a9a7..5c62c04 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/README.rst b/README.rst index 7174398..80d86ea 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/discussion/python_versions.rst b/docs/discussion/python_versions.rst index cdf5e4c..65fd626 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/tutorials/installation.rst b/docs/tutorials/installation.rst index 6677461..83568bb 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 0e06583..91c791a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/setup.cfg b/setup.cfg index a2e13d5..1cec8c4 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/tox.ini b/tox.ini index bc71c21..333b62d 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 From e2e56422c750cc20d662950237402eb81af3690c Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:19:09 +0000 Subject: [PATCH 09/22] Fix a minor sphinx error --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index fa5647a..ee34c93 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. From a07890153e51b7f9d201895918087cc1bc82a186 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:44:05 +0000 Subject: [PATCH 10/22] Fix typing issues Following updates to various libraries used by Quart and mypy. --- src/quart/app.py | 9 ++++---- src/quart/blueprints.py | 3 +-- src/quart/helpers.py | 6 +++--- src/quart/testing/app.py | 2 +- src/quart/wrappers/request.py | 10 ++++----- src/quart/wrappers/response.py | 19 ++++++++--------- tests/conftest.py | 2 ++ tests/test_asgi.py | 8 +++++++ tests/test_basic.py | 18 ++++++++-------- tests/test_blueprints.py | 38 ++++++++++++++++----------------- tests/test_debug.py | 2 +- tests/test_sync.py | 6 +++--- tests/test_testing.py | 6 +++--- tests/wrappers/test_response.py | 14 ++++++------ 14 files changed, 75 insertions(+), 68 deletions(-) diff --git a/src/quart/app.py b/src/quart/app.py index bcf13b7..595437d 100644 --- a/src/quart/app.py +++ b/src/quart/app.py @@ -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,7 @@ 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 @@ -384,7 +383,7 @@ async def open_resource( self, path: FilePath, mode: str = "rb", - ) -> AiofilesContextManager[None, None, AsyncBufferedReader]: + ) -> AiofilesContextManager: """Open a file for reading. Use as @@ -401,7 +400,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 @@ -1402,7 +1401,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 acaa047..c40d3f5 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/helpers.py b/src/quart/helpers.py index 8dd1146..f3df700 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 7875590..2ed04e2 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/wrappers/request.py b/src/quart/wrappers/request.py index a80fa81..8b4ed8d 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 065eabf..ad2e6c5 100644 --- a/src/quart/wrappers/response.py +++ b/src/quart/wrappers/response.py @@ -8,7 +8,6 @@ from types import TracebackType from typing import ( Any, - AnyStr, AsyncGenerator, AsyncIterable, AsyncIterator, @@ -19,7 +18,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 @@ -148,7 +147,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 +261,7 @@ class Response(SansIOResponse): def __init__( self, - response: ResponseBody | AnyStr | Iterable | None = None, + response: ResponseBody | str | bytes | Iterable | None = None, status: int | None = None, headers: dict | Headers | None = None, mimetype: str | None = None, @@ -296,7 +295,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 +313,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 +326,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 +343,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 8ab92a2..e14c22c 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 13f0da4..91f4dc0 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 339e16e..cc2208e 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 759f118..53caa91 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 5cba6a1..6115931 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_sync.py b/tests/test_sync.py index 5a8a57c..64173fa 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_testing.py b/tests/test_testing.py index a77c563..d20a121 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 99a77a3..bda590a 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( From c53ccd802485c1d461da8de047ff6e776158cb6d Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:46:40 +0000 Subject: [PATCH 11/22] Remove the ASGI server comparison table As it is outdated. --- docs/tutorials/deployment.rst | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/deployment.rst b/docs/tutorials/deployment.rst index 9344c79..b63a428 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 --------------------- From e8d158159dc558bde1586dfa2b0d2a90cdae5d63 Mon Sep 17 00:00:00 2001 From: Chris R Date: Sat, 14 Sep 2024 20:22:58 -0400 Subject: [PATCH 12/22] Added Quart-LibreTranslate. --- docs/how_to_guides/quart_extensions.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/how_to_guides/quart_extensions.rst b/docs/how_to_guides/quart_extensions.rst index 2148a83..7fa807a 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 From 9ca6bcb13fa7b30b96292f6ede0e9273bf59377e Mon Sep 17 00:00:00 2001 From: Austin <30563249+patriacaelum@users.noreply.github.com> Date: Tue, 18 Jun 2024 02:42:17 -0600 Subject: [PATCH 13/22] Update blog tutorial to use pathlib --- docs/tutorials/blog_tutorial.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/blog_tutorial.rst b/docs/tutorials/blog_tutorial.rst index 5f16ea6..667f7e9 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() From 2473a04d4cac8b73f9ace5f7e87692dc781a5ae5 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:56:00 +0000 Subject: [PATCH 14/22] Add missing config key This sets the default so that automatic options are provided. --- src/quart/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/quart/app.py b/src/quart/app.py index 595437d..c14d30b 100644 --- a/src/quart/app.py +++ b/src/quart/app.py @@ -249,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), From a22fd3dd2a6d13ac8d10eab59c19cda703cae56d Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:56:23 +0000 Subject: [PATCH 15/22] Set the asyncio_default_fixture_loop_scope pytest-asyncio option I think the fixtures should be within the same scope throughout the tests, hence session. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 91c791a..415afa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From f10ce1c7ddc288b0bb6a69ef4d3490224a474a42 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 14 Nov 2024 08:49:26 -0800 Subject: [PATCH 16/22] remove test pypi --- .github/workflows/publish.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 09b1196..c12a463 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/ From 21e4e5274e33e496fe5354224869a4dc4eef5c60 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 08:56:00 +0000 Subject: [PATCH 17/22] Add missing config key This sets the default so that automatic options are provided. (cherry picked from commit 2473a04d4cac8b73f9ace5f7e87692dc781a5ae5) --- src/quart/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/quart/app.py b/src/quart/app.py index da23382..db1ed1f 100644 --- a/src/quart/app.py +++ b/src/quart/app.py @@ -241,6 +241,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), From f43abb0aaa1c239759a9233d0ed0640011de9cc8 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 14 Nov 2024 08:39:55 -0800 Subject: [PATCH 18/22] release version 0.19.9 --- CHANGES.rst | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3b98134..8c3313f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,9 @@ +0.19.9 2024-11-14 +----------------- + +- Fix missing ``PROVIDE_AUTOMATIC_OPTIONS`` config for compatibility + with Flask 3.1. + 0.19.8 2024-10-25 ----------------- diff --git a/pyproject.toml b/pyproject.toml index 0e06583..2cbe19c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.8" +version = "0.19.9" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ From 8edae3efa201e7530646002bbadcad76be1ba04f Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 17:06:09 +0000 Subject: [PATCH 19/22] Add missing changelog entry --- CHANGES.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8c3313f..ecf15a7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,8 +1,9 @@ 0.19.9 2024-11-14 ----------------- -- Fix missing ``PROVIDE_AUTOMATIC_OPTIONS`` config for compatibility - with Flask 3.1. +* Fix missing ``PROVIDE_AUTOMATIC_OPTIONS`` config for compatibility + with Flask 3.1. +* Drop support for Python3.8 and support Python 3.13. 0.19.8 2024-10-25 ----------------- From 87f4580d620445a377fd06f676be20bf5c8e083a Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 14 Nov 2024 17:11:49 +0000 Subject: [PATCH 20/22] Revert 8edae3efa201e7530646002bbadcad76be1ba04f The released version did not include this change. --- CHANGES.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ecf15a7..df5c5bf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,7 +3,6 @@ * Fix missing ``PROVIDE_AUTOMATIC_OPTIONS`` config for compatibility with Flask 3.1. -* Drop support for Python3.8 and support Python 3.13. 0.19.8 2024-10-25 ----------------- From de9d9ef64d3770f2d8502bc52ab65beaf39a0463 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 14 Nov 2024 09:24:40 -0800 Subject: [PATCH 21/22] start version 0.20.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86e33cb..73ef1a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.9" +version = "0.20.0.dev" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones "] classifiers = [ From a80358bbe27a10f5ec709760e62ac3930bbedee7 Mon Sep 17 00:00:00 2001 From: Marco Sirabella Date: Mon, 20 May 2024 09:31:34 -0400 Subject: [PATCH 22/22] Accept AsyncIterables being passed to Response Fixes pallets/flask#5322 --- src/quart/utils.py | 14 +++++++++----- src/quart/wrappers/response.py | 25 +++++++++---------------- tests/test_templating.py | 9 +++++++++ 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/quart/utils.py b/src/quart/utils.py index 7e48ace..a92fc03 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/response.py b/src/quart/wrappers/response.py index ad2e6c5..01bf4c0 100644 --- a/src/quart/wrappers/response.py +++ b/src/quart/wrappers/response.py @@ -2,7 +2,6 @@ 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 @@ -101,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 @@ -261,7 +254,7 @@ class Response(SansIOResponse): def __init__( self, - response: ResponseBody | str | bytes | Iterable | None = None, + response: ResponseBody | str | bytes | Iterable | AsyncIterable | None = None, status: int | None = None, headers: dict | Headers | None = None, mimetype: str | None = None, diff --git a/tests/test_templating.py b/tests/test_templating.py index f745020..5df5403 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"