From ff0d99ef16647cadf727d794ef321e14460f2a3b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 11:50:04 -0500 Subject: [PATCH 01/19] Roll our own MultiMap implementation. --- src/view/multi_map.py | 136 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/view/multi_map.py diff --git a/src/view/multi_map.py b/src/view/multi_map.py new file mode 100644 index 00000000..72d9dc9b --- /dev/null +++ b/src/view/multi_map.py @@ -0,0 +1,136 @@ +from collections.abc import ( + Iterable, + Iterator, + Mapping, + Sequence, + KeysView, + ValuesView, + ItemsView, +) +from typing import Any, TypeVar +from view.exceptions import ViewError + +KeyT = TypeVar("KeyT") +ValueT = TypeVar("ValueT") +T = TypeVar("T") + + +class HasMultipleValuesError(ViewError): + """ + Multiple values were found when they were explicitly disallowed. + """ + + def __init__(self, key: Any) -> None: + super().__init__(f"{key!r} has multiple values") + + +class MultiMap(Mapping[KeyT, ValueT]): + """ + Mapping of individual keys to one or many values. + """ + + __slots__ = "_values" + + def __init__(self, items: Iterable[tuple[KeyT, ValueT]] = ()) -> None: + self._values: dict[KeyT, list[ValueT]] = {} + + for key, value in items: + values = self._values.setdefault(key, []) + values.append(value) + + def __getitem__(self, key: KeyT, /) -> ValueT: + """ + Get the first value if it exists, or else raise a `KeyError`. + """ + + return self._values[key][0] + + def __len__(self) -> int: + return len(self._values) + + def __iter__(self) -> Iterator[KeyT]: + return iter(self._values) + + def __contains__(self, key: object, /) -> bool: + return key in self._values + + def __eq__(self, other: object, /) -> bool: + if isinstance(other, MultiMap): + return other._values == self._values + + return NotImplemented + + def __ne__(self, other: object, /) -> bool: + if isinstance(other, MultiMap): + return other._values != self._values + + return NotImplemented + + def __repr__(self) -> str: + return f"MultiMap({self.as_sequence()})" + + def _as_flat(self) -> dict[KeyT, ValueT]: + """ + Turn this into a "flat" representation of the mapping in which all + keys have exactly one value. + """ + return {key: value[0] for key, value in self._values.items()} + + def keys(self) -> KeysView[KeyT]: + """ + Return a view of all the keys in this map. + """ + return self._values.keys() + + def values(self) -> ValuesView[ValueT]: + """ + Return a view of the first value for each key in the mapping. + """ + return self._as_flat().values() + + def many_values(self) -> ValuesView[Sequence[ValueT]]: + """ + Return a view of all values in the mapping. + """ + return self._values.values() + + def items(self) -> ItemsView[KeyT, ValueT]: + """ + Return a view of all items in the mapping, using the first value + for each key. + """ + return self._as_flat().items() + + def many_items(self) -> ItemsView[KeyT, Sequence[ValueT]]: + """ + Return a view of all items in the mapping. + """ + return self._values.items() + + def get_one_or_many(self, key: KeyT) -> Sequence[ValueT]: + """ + Get one or many values for a given key. + """ + return self._values[key] + + def get_exactly_one(self, key: KeyT) -> ValueT: + """ + Get precisely one value for a key. If more than one value is present, + then this raises a `HasMultipleValuesError`. + """ + value = self._values[key] + if len(value) != 1: + raise HasMultipleValuesError(key) + + return value[0] + + def as_sequence(self) -> Sequence[tuple[KeyT, ValueT]]: + """ + Return all the keys and values in a sequence of (key, value) tuples. + """ + result: list[tuple[KeyT, ValueT]] = [] + for key, values in self._values.items(): + for value in values: + result.append((key, value)) + + return result From 818d7c44fa7989d9673e3e082729ff2baeb073bd Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 11:51:26 -0500 Subject: [PATCH 02/19] More multi_map to view.core --- src/view/{ => core}/multi_map.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/view/{ => core}/multi_map.py (100%) diff --git a/src/view/multi_map.py b/src/view/core/multi_map.py similarity index 100% rename from src/view/multi_map.py rename to src/view/core/multi_map.py From 2fdc939ce853eb1005e08d26ec3e8e55a77029ce Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:17:45 -0500 Subject: [PATCH 03/19] Use MultiMap instead of CIMultiDict for headers. This fails a lot of tests at the moment. --- src/view/cache.py | 5 +-- src/view/core/headers.py | 83 +++++++++++++++++++++++++++------------ src/view/core/response.py | 22 +++++------ src/view/dom/core.py | 4 +- src/view/run/asgi.py | 6 +-- src/view/run/wsgi.py | 4 +- src/view/testing.py | 9 ++--- tests/test_requests.py | 4 +- tests/test_responses.py | 4 +- 9 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/view/cache.py b/src/view/cache.py index f3a3ac2d..94c02d83 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -9,14 +9,13 @@ if TYPE_CHECKING: from collections.abc import Callable - from multidict import CIMultiDict - from view.core.response import ( Response, TextResponse, ViewResult, wrap_view_result, ) +from view.core.headers import RequestHeaders __all__ = ("in_memory_cache",) @@ -47,7 +46,7 @@ async def __call__( @dataclass(slots=True, frozen=True) class _CachedResponse: body: bytes - headers: CIMultiDict[str] + headers: RequestHeaders[str] status: int last_reset: float diff --git a/src/view/core/headers.py b/src/view/core/headers.py index a144dbd6..b9fbf6f3 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -1,11 +1,11 @@ from __future__ import annotations from collections.abc import Mapping +from collections import UserString from typing import TYPE_CHECKING, Any, TypeAlias -from multidict import CIMultiDict - from view.exceptions import InvalidTypeError +from view.core.multi_map import MultiMap if TYPE_CHECKING: from view.run.asgi import ASGIHeaders @@ -13,34 +13,64 @@ __all__ = ( "RequestHeaders", "HeadersLike", - "as_multidict", - "asgi_as_multidict", - "multidict_as_asgi", - "wsgi_as_multidict", + "as_real_headers", + "asgi_to_headers", + "headers_to_asgi", + "wsgi_to_headers", ) -RequestHeaders: TypeAlias = CIMultiDict[str] + +class LowerStr(UserString): + """ + A string that always acts in lowercase. This is useful for case-insensitive + comparisons. + """ + + def __init__(self, data: object) -> None: + super().__init__(self._to_lower(data)) + + def _to_lower(self, data: object) -> object: + if isinstance(data, str): + data = data.lower() + + return data + + def __contains__(self, char: object) -> bool: + return super().__contains__(self._to_lower(char)) + + def __eq__(self, string: object) -> bool: + return super().__eq__(self._to_lower(string)) + + def __ne__(self, value: object, /) -> bool: + return super().__ne__(self._to_lower(value)) + + def __hash__(self) -> int: + return hash(self.data) + + +RequestHeaders: TypeAlias = MultiMap[LowerStr, str] HeadersLike: TypeAlias = ( RequestHeaders | Mapping[str, str] | Mapping[bytes, bytes] ) -def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: +def as_real_headers(headers: HeadersLike | None, /) -> RequestHeaders: """ Convenience function for casting a "header-like object" (or `None`) - to a `CIMultiDict`. + to a `MultiMap`. """ if headers is None: - return CIMultiDict[str]() + return MultiMap[LowerStr, str]() - if isinstance(headers, CIMultiDict): + if isinstance(headers, MultiMap): return headers if __debug__ and not isinstance(headers, Mapping): raise InvalidTypeError(Mapping, headers) assert isinstance(headers, dict) - multidict = CIMultiDict[str]() + all_values: list[tuple[LowerStr, str]] = [] + for key, value in headers.items(): if isinstance(key, bytes): key = key.decode("utf-8") # noqa @@ -48,16 +78,16 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: if isinstance(value, bytes): value = value.decode("utf-8") # noqa - multidict[key] = value + all_values.append((LowerStr(key), value)) - return multidict + return MultiMap(all_values) -def wsgi_as_multidict(environ: Mapping[str, Any]) -> RequestHeaders: +def wsgi_to_headers(environ: Mapping[str, Any]) -> RequestHeaders: """ - Convert WSGI headers (from the `environ`) to a case-insensitive multidict. + Convert WSGI headers (from the `environ`) to a case-insensitive multi-map. """ - headers = CIMultiDict[str]() + values: list[tuple[LowerStr, str]] = [] for key, value in environ.items(): if not key.startswith("HTTP_"): @@ -65,26 +95,27 @@ def wsgi_as_multidict(environ: Mapping[str, Any]) -> RequestHeaders: assert isinstance(value, str) key = key.removeprefix("HTTP_").replace("_", "-").lower() # noqa - headers[key] = value + values.append((LowerStr(key), value)) - return headers + return MultiMap(values) -def asgi_as_multidict(headers: ASGIHeaders, /) -> RequestHeaders: +def asgi_to_headers(headers: ASGIHeaders, /) -> RequestHeaders: """ - Convert ASGI headers to a case-insensitive multidict. + Convert ASGI headers to a case-insensitive multi-map. """ - multidict = CIMultiDict[str]() + values: list[tuple[LowerStr, str]] = [] for key, value in headers: - multidict[key.decode("utf-8")] = value.decode("utf-8") + lower_str = LowerStr(key.decode("utf-8")) + values.append((lower_str, value.decode("utf-8"))) - return multidict + return MultiMap(values) -def multidict_as_asgi(headers: RequestHeaders, /) -> ASGIHeaders: +def headers_to_asgi(headers: RequestHeaders, /) -> ASGIHeaders: """ - Convert a case-insensitive multidict to an ASGI header iterable. + Convert a case-insensitive multi-map to an ASGI header iterable. """ asgi_headers: ASGIHeaders = [] diff --git a/src/view/core/response.py b/src/view/core/response.py index 4059d66e..e00e8831 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -11,11 +11,11 @@ import aiofiles from loguru import logger -from multidict import CIMultiDict from view.core.body import BodyMixin -from view.core.headers import HeadersLike, RequestHeaders, as_multidict +from view.core.headers import HeadersLike, RequestHeaders, as_real_headers from view.exceptions import InvalidTypeError, ViewError +from view.core.multi_map import MultiMap __all__ = "Response", "ViewResult", "ResponseLike" @@ -27,7 +27,7 @@ class Response(BodyMixin): """ status_code: int - headers: CIMultiDict[str] + headers: RequestHeaders[str] def __post_init__(self) -> None: if __debug__: @@ -103,12 +103,12 @@ async def stream(): length = len(data) yield data - multidict = as_multidict(headers) - if "content-type" not in multidict: + multi_map = as_real_headers(headers) + if "content-type" not in multi_map: content_type = content_type or _guess_file_type(path) - multidict["content-type"] = content_type + multi_map["content-type"] = content_type - return cls(stream, status_code, multidict, path) + return cls(stream, status_code, multi_map, path) def _as_bytes(data: str | bytes) -> bytes: @@ -148,7 +148,7 @@ def from_content( async def stream() -> AsyncGenerator[bytes]: yield _as_bytes(content) - return cls(stream, status_code, as_multidict(headers), content) + return cls(stream, status_code, as_real_headers(headers), content) @dataclass(slots=True) @@ -173,7 +173,7 @@ async def stream() -> AsyncGenerator[bytes]: return cls( content=content, parsed_data=data, - headers=as_multidict(headers), + headers=as_real_headers(headers), status_code=status_code, receive_data=stream, ) @@ -244,7 +244,7 @@ async def stream() -> AsyncGenerator[bytes]: async for data in response: yield _as_bytes(data) - return Response(stream, status_code=200, headers=CIMultiDict()) + return Response(stream, status_code=200, headers=MultiMap()) if isinstance(response, Generator): @@ -252,7 +252,7 @@ async def stream() -> AsyncGenerator[bytes]: for data in response: yield _as_bytes(data) - return Response(stream, status_code=200, headers=CIMultiDict()) + return Response(stream, status_code=200, headers=MultiMap()) raise TypeError(f"Invalid response: {response!r}") diff --git a/src/view/dom/core.py b/src/view/dom/core.py index c85e7ee8..3b97eaf5 100644 --- a/src/view/dom/core.py +++ b/src/view/dom/core.py @@ -15,7 +15,7 @@ from queue import LifoQueue from typing import TYPE_CHECKING, ClassVar, ParamSpec, TypeAlias -from view.core.headers import as_multidict +from view.core.headers import as_real_headers from view.core.response import Response from view.exceptions import InvalidTypeError from view.javascript import SupportsJavaScript @@ -216,7 +216,7 @@ async def stream() -> AsyncIterator[bytes]: return Response( stream, status_code or 200, - as_multidict({"content-type": "text/html"}), + as_real_headers({"content-type": "text/html"}), ) return wrapper diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index 530adf72..734c6fd3 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -5,7 +5,7 @@ from typing_extensions import NotRequired -from view.core.headers import asgi_as_multidict, multidict_as_asgi +from view.core.headers import asgi_to_headers, headers_to_asgi from view.core.request import Method, Request, extract_query_parameters if TYPE_CHECKING: @@ -78,7 +78,7 @@ async def asgi( ) -> None: assert scope["type"] == "http" method = Method(scope["method"]) - headers = asgi_as_multidict(scope["headers"]) + headers = asgi_to_headers(scope["headers"]) async def receive_data() -> AsyncIterator[bytes]: more_body = True @@ -98,7 +98,7 @@ async def receive_data() -> AsyncIterator[bytes]: { "type": "http.response.start", "status": response.status_code, - "headers": multidict_as_asgi(response.headers), + "headers": headers_to_asgi(response.headers), } ) async for data in response.stream_body(): diff --git a/src/view/run/wsgi.py b/src/view/run/wsgi.py index 7e3e8dcf..e236e18a 100644 --- a/src/view/run/wsgi.py +++ b/src/view/run/wsgi.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable from typing import IO, TYPE_CHECKING, Any, TypeAlias -from view.core.headers import wsgi_as_multidict +from view.core.headers import wsgi_to_headers from view.core.request import Method, Request, extract_query_parameters from view.core.status_codes import STATUS_STRINGS @@ -52,7 +52,7 @@ async def stream(): path = environ["PATH_INFO"] assert isinstance(path, str) - headers = wsgi_as_multidict(environ) + headers = wsgi_to_headers(environ) parameters = extract_query_parameters(environ["QUERY_STRING"]) request = Request(stream, app, path, method, headers, parameters) response = loop.run_until_complete(app.process_request(request)) diff --git a/src/view/testing.py b/src/view/testing.py index 5d8e47fb..fc650397 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -2,24 +2,23 @@ from typing import TYPE_CHECKING -from view.core.headers import HeadersLike, as_multidict +from view.core.headers import HeadersLike, as_real_headers from view.core.request import Method, Request, extract_query_parameters from view.core.status_codes import STATUS_STRINGS if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable - from multidict import CIMultiDict - from view.core.app import BaseApp from view.core.response import Response + from view.core.headers import RequestHeaders __all__ = ("AppTestClient",) async def into_tuple( response_coro: Awaitable[Response], / -) -> tuple[bytes, int, CIMultiDict]: +) -> tuple[bytes, int, RequestHeaders]: """ Convenience function for transferring a test client call into a tuple through a single ``await``. @@ -76,7 +75,7 @@ async def stream() -> AsyncGenerator[bytes]: app=self.app, path=path, method=method, - headers=as_multidict(headers), + headers=as_real_headers(headers), query_parameters=extract_query_parameters(query_string), ) return await self.app.process_request(request_data) diff --git a/tests/test_requests.py b/tests/test_requests.py index 8991806f..7d343026 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -5,7 +5,7 @@ from multidict import MultiDict from view.core.app import App, as_app from view.core.body import InvalidJSONError -from view.core.headers import as_multidict +from view.core.headers import as_real_headers from view.core.request import Method, Request from view.core.response import ResponseLike from view.core.router import DuplicateRouteError @@ -61,7 +61,7 @@ async def stream_none() -> AsyncIterator[bytes]: app=app, path="/", method=Method.POST, - headers=as_multidict({"test": "42"}), + headers=as_real_headers({"test": "42"}), query_parameters=MultiDict(), ) response = await app.process_request(manual_request) diff --git a/tests/test_responses.py b/tests/test_responses.py index a49e0fcd..120b0ba4 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -4,7 +4,7 @@ import pytest from view.core.app import App, as_app -from view.core.headers import as_multidict +from view.core.headers import as_real_headers from view.core.request import Request from view.core.response import FileResponse, JSONResponse, Response, ResponseLike from view.core.status_codes import ( @@ -49,7 +49,7 @@ async def stream(): return Response( receive_data=stream, status_code=Success.CREATED, - headers=as_multidict({"hello": "world"}), + headers=as_real_headers({"hello": "world"}), ) client = AppTestClient(app) From 5e38949babc5e25def6a70c08bbf4194cc953186 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:22:42 -0500 Subject: [PATCH 04/19] Fix some problems the new multi-map. --- src/view/core/headers.py | 13 +++++++++++++ src/view/core/multi_map.py | 3 +++ src/view/run/wsgi.py | 9 +++------ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/view/core/headers.py b/src/view/core/headers.py index b9fbf6f3..720ac442 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from view.run.asgi import ASGIHeaders + from view.run.wsgi import WSGIHeaders __all__ = ( "RequestHeaders", @@ -100,6 +101,18 @@ def wsgi_to_headers(environ: Mapping[str, Any]) -> RequestHeaders: return MultiMap(values) +def headers_to_wsgi(headers: RequestHeaders) -> WSGIHeaders: + """ + Convert a case-insensitive multi-map to a WSGI header iterable. + """ + + wsgi_headers: WSGIHeaders = [] + for key, value in headers.items(): + wsgi_headers.append((str(key), value)) + + return wsgi_headers + + def asgi_to_headers(headers: ASGIHeaders, /) -> RequestHeaders: """ Convert ASGI headers to a case-insensitive multi-map. diff --git a/src/view/core/multi_map.py b/src/view/core/multi_map.py index 72d9dc9b..e732d9f8 100644 --- a/src/view/core/multi_map.py +++ b/src/view/core/multi_map.py @@ -58,6 +58,9 @@ def __eq__(self, other: object, /) -> bool: if isinstance(other, MultiMap): return other._values == self._values + if isinstance(other, dict): + return self._values == other + return NotImplemented def __ne__(self, other: object, /) -> bool: diff --git a/src/view/run/wsgi.py b/src/view/run/wsgi.py index e236e18a..c25813ce 100644 --- a/src/view/run/wsgi.py +++ b/src/view/run/wsgi.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable from typing import IO, TYPE_CHECKING, Any, TypeAlias -from view.core.headers import wsgi_to_headers +from view.core.headers import wsgi_to_headers, headers_to_wsgi from view.core.request import Method, Request, extract_query_parameters from view.core.status_codes import STATUS_STRINGS @@ -13,7 +13,7 @@ __all__ = ("wsgi_for_app",) -WSGIHeaders: TypeAlias = list[tuple[str, str]] +WSGIHeaders: TypeAlias = Iterable[tuple[str, str]] # We can't use a TypedDict for the environment because it has arbitrary keys # for the headers. WSGIEnvironment: TypeAlias = dict[str, Any] @@ -57,10 +57,7 @@ async def stream(): request = Request(stream, app, path, method, headers, parameters) response = loop.run_until_complete(app.process_request(request)) - wsgi_headers: WSGIHeaders = [] - for key, value in response.headers.items(): - # Multidict has a weird string subclass as the key for some reason - wsgi_headers.append((str(key), value)) + wsgi_headers: WSGIHeaders = headers_to_wsgi(response.headers) # WSGI is such a weird spec status_str = ( From 615b007712899792d0057d8b3af962da18ced27a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:31:19 -0500 Subject: [PATCH 05/19] Fix most failing tests. --- src/view/core/multi_map.py | 12 +++++++++++- src/view/core/response.py | 11 +++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/view/core/multi_map.py b/src/view/core/multi_map.py index e732d9f8..31d1dfee 100644 --- a/src/view/core/multi_map.py +++ b/src/view/core/multi_map.py @@ -1,3 +1,4 @@ +from __future__ import annotations from collections.abc import ( Iterable, Iterator, @@ -59,7 +60,7 @@ def __eq__(self, other: object, /) -> bool: return other._values == self._values if isinstance(other, dict): - return self._values == other + return self._as_flat() == other return NotImplemented @@ -137,3 +138,12 @@ def as_sequence(self) -> Sequence[tuple[KeyT, ValueT]]: result.append((key, value)) return result + + def with_new_value( + self, key: KeyT, value: ValueT + ) -> MultiMap[KeyT, ValueT]: + """ + Create a copy of this map with a new key and value included. + """ + new_sequence = list(self.as_sequence()) + [(key, value)] + return type(self)(new_sequence) diff --git a/src/view/core/response.py b/src/view/core/response.py index e00e8831..6f61fa8c 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -13,7 +13,12 @@ from loguru import logger from view.core.body import BodyMixin -from view.core.headers import HeadersLike, RequestHeaders, as_real_headers +from view.core.headers import ( + HeadersLike, + LowerStr, + RequestHeaders, + as_real_headers, +) from view.exceptions import InvalidTypeError, ViewError from view.core.multi_map import MultiMap @@ -106,7 +111,9 @@ async def stream(): multi_map = as_real_headers(headers) if "content-type" not in multi_map: content_type = content_type or _guess_file_type(path) - multi_map["content-type"] = content_type + multi_map = multi_map.with_new_value( + LowerStr("content-type"), content_type + ) return cls(stream, status_code, multi_map, path) From 84da52fed96261e98e5fb93bc5afe8a9b5eae920 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:32:20 -0500 Subject: [PATCH 06/19] Wrap test_run_server() with a try/finally. --- tests/test_servers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_servers.py b/tests/test_servers.py index 2eaa24f9..a7d9d055 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -32,10 +32,12 @@ async def index(): app.run(server_hint={server_name!r}) """ process = subprocess.Popen([sys.executable, "-c", code]) - time.sleep(2) - response = requests.get("http://localhost:5000") - assert response.text == "ok" - process.kill() + try: + time.sleep(2) + response = requests.get("http://localhost:5000") + assert response.text == "ok" + finally: + process.kill() @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) From 8e0aaa8bf8df12401f476c8c1469c7c6a3fcbc09 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:42:04 -0500 Subject: [PATCH 07/19] Fix type checking for LowerStr objects. --- src/view/core/headers.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 720ac442..7ddd8fda 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Mapping -from collections import UserString from typing import TYPE_CHECKING, Any, TypeAlias from view.exceptions import InvalidTypeError @@ -21,23 +20,24 @@ ) -class LowerStr(UserString): +class LowerStr(str): """ A string that always acts in lowercase. This is useful for case-insensitive comparisons. """ - def __init__(self, data: object) -> None: - super().__init__(self._to_lower(data)) + def __new__(cls, data: object) -> LowerStr: + return super().__new__(cls, cls._to_lower(data)) - def _to_lower(self, data: object) -> object: + @staticmethod + def _to_lower(data: object) -> object: if isinstance(data, str): data = data.lower() return data - def __contains__(self, char: object) -> bool: - return super().__contains__(self._to_lower(char)) + def __contains__(self, key: str, /) -> bool: + return super().__contains__(key.lower()) def __eq__(self, string: object) -> bool: return super().__eq__(self._to_lower(string)) @@ -46,10 +46,10 @@ def __ne__(self, value: object, /) -> bool: return super().__ne__(self._to_lower(value)) def __hash__(self) -> int: - return hash(self.data) + return hash(str(self)) -RequestHeaders: TypeAlias = MultiMap[LowerStr, str] +RequestHeaders: TypeAlias = MultiMap[str, str] HeadersLike: TypeAlias = ( RequestHeaders | Mapping[str, str] | Mapping[bytes, bytes] ) @@ -61,7 +61,7 @@ def as_real_headers(headers: HeadersLike | None, /) -> RequestHeaders: to a `MultiMap`. """ if headers is None: - return MultiMap[LowerStr, str]() + return MultiMap[str, str]() if isinstance(headers, MultiMap): return headers From 68e768aebc8f7821798ebac1da73b35eb39927b6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:45:42 -0500 Subject: [PATCH 08/19] Remove type parameters for RequestHeaders. --- src/view/cache.py | 2 +- src/view/core/response.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/cache.py b/src/view/cache.py index 94c02d83..3bec324c 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -46,7 +46,7 @@ async def __call__( @dataclass(slots=True, frozen=True) class _CachedResponse: body: bytes - headers: RequestHeaders[str] + headers: RequestHeaders status: int last_reset: float diff --git a/src/view/core/response.py b/src/view/core/response.py index 6f61fa8c..6b4c6dd5 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -32,7 +32,7 @@ class Response(BodyMixin): """ status_code: int - headers: RequestHeaders[str] + headers: RequestHeaders def __post_init__(self) -> None: if __debug__: From 0124ab6ca4ba90ef89daf4415c481bd5f422d043 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 12:46:18 -0500 Subject: [PATCH 09/19] Rename RequestHeaders to HTTPHeaders. They're used in responses as well, so "Request" in the name isn't a good idea. --- src/view/cache.py | 4 ++-- src/view/core/headers.py | 16 ++++++++-------- src/view/core/request.py | 4 ++-- src/view/core/response.py | 6 +++--- src/view/testing.py | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/view/cache.py b/src/view/cache.py index 3bec324c..ffaa20c1 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -15,7 +15,7 @@ ViewResult, wrap_view_result, ) -from view.core.headers import RequestHeaders +from view.core.headers import HTTPHeaders __all__ = ("in_memory_cache",) @@ -46,7 +46,7 @@ async def __call__( @dataclass(slots=True, frozen=True) class _CachedResponse: body: bytes - headers: RequestHeaders + headers: HTTPHeaders status: int last_reset: float diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 7ddd8fda..482b0e9a 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -11,7 +11,7 @@ from view.run.wsgi import WSGIHeaders __all__ = ( - "RequestHeaders", + "HTTPHeaders", "HeadersLike", "as_real_headers", "asgi_to_headers", @@ -49,13 +49,13 @@ def __hash__(self) -> int: return hash(str(self)) -RequestHeaders: TypeAlias = MultiMap[str, str] +HTTPHeaders: TypeAlias = MultiMap[str, str] HeadersLike: TypeAlias = ( - RequestHeaders | Mapping[str, str] | Mapping[bytes, bytes] + HTTPHeaders | Mapping[str, str] | Mapping[bytes, bytes] ) -def as_real_headers(headers: HeadersLike | None, /) -> RequestHeaders: +def as_real_headers(headers: HeadersLike | None, /) -> HTTPHeaders: """ Convenience function for casting a "header-like object" (or `None`) to a `MultiMap`. @@ -84,7 +84,7 @@ def as_real_headers(headers: HeadersLike | None, /) -> RequestHeaders: return MultiMap(all_values) -def wsgi_to_headers(environ: Mapping[str, Any]) -> RequestHeaders: +def wsgi_to_headers(environ: Mapping[str, Any]) -> HTTPHeaders: """ Convert WSGI headers (from the `environ`) to a case-insensitive multi-map. """ @@ -101,7 +101,7 @@ def wsgi_to_headers(environ: Mapping[str, Any]) -> RequestHeaders: return MultiMap(values) -def headers_to_wsgi(headers: RequestHeaders) -> WSGIHeaders: +def headers_to_wsgi(headers: HTTPHeaders) -> WSGIHeaders: """ Convert a case-insensitive multi-map to a WSGI header iterable. """ @@ -113,7 +113,7 @@ def headers_to_wsgi(headers: RequestHeaders) -> WSGIHeaders: return wsgi_headers -def asgi_to_headers(headers: ASGIHeaders, /) -> RequestHeaders: +def asgi_to_headers(headers: ASGIHeaders, /) -> HTTPHeaders: """ Convert ASGI headers to a case-insensitive multi-map. """ @@ -126,7 +126,7 @@ def asgi_to_headers(headers: ASGIHeaders, /) -> RequestHeaders: return MultiMap(values) -def headers_to_asgi(headers: RequestHeaders, /) -> ASGIHeaders: +def headers_to_asgi(headers: HTTPHeaders, /) -> ASGIHeaders: """ Convert a case-insensitive multi-map to an ASGI header iterable. """ diff --git a/src/view/core/request.py b/src/view/core/request.py index dfd35f08..acdebf0f 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -15,7 +15,7 @@ from collections.abc import Mapping from view.core.app import BaseApp - from view.core.headers import RequestHeaders + from view.core.headers import HTTPHeaders __all__ = "Method", "Request" @@ -119,7 +119,7 @@ class Request(BodyMixin): The HTTP method of the request. See `Method`. """ - headers: RequestHeaders + headers: HTTPHeaders """ A "multi-dictionary" containing the request headers. This is `dict`-like, but if a header has multiple values, it is represented by a list. diff --git a/src/view/core/response.py b/src/view/core/response.py index 6b4c6dd5..08ef4f93 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -16,7 +16,7 @@ from view.core.headers import ( HeadersLike, LowerStr, - RequestHeaders, + HTTPHeaders, as_real_headers, ) from view.exceptions import InvalidTypeError, ViewError @@ -32,7 +32,7 @@ class Response(BodyMixin): """ status_code: int - headers: RequestHeaders + headers: HTTPHeaders def __post_init__(self) -> None: if __debug__: @@ -44,7 +44,7 @@ def __post_init__(self) -> None: f"{self.status_code!r} is not a valid HTTP status code" ) - async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: + async def as_tuple(self) -> tuple[bytes, int, HTTPHeaders]: """ Process the response as a tuple. This is mainly useful for assertions in testing. diff --git a/src/view/testing.py b/src/view/testing.py index fc650397..c60e2dd7 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -11,14 +11,14 @@ from view.core.app import BaseApp from view.core.response import Response - from view.core.headers import RequestHeaders + from view.core.headers import HTTPHeaders __all__ = ("AppTestClient",) async def into_tuple( response_coro: Awaitable[Response], / -) -> tuple[bytes, int, RequestHeaders]: +) -> tuple[bytes, int, HTTPHeaders]: """ Convenience function for transferring a test client call into a tuple through a single ``await``. From 6fa691926a37fa2b08e87dc106ab4ea8599326a4 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:06:54 -0500 Subject: [PATCH 10/19] Add a dedicated class for HTTP headers. --- src/view/core/headers.py | 31 ++++++++++++++++++++++++++----- src/view/core/response.py | 5 ++--- tests/test_requests.py | 6 +++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 482b0e9a..02c19e9e 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -49,7 +49,28 @@ def __hash__(self) -> int: return hash(str(self)) -HTTPHeaders: TypeAlias = MultiMap[str, str] +class HTTPHeaders(MultiMap[str, str]): + """ + Case-insensitive multi-map of HTTP headers. + """ + + def __getitem__(self, key: str, /) -> str: + return super().__getitem__(LowerStr(key)) + + def __contains__(self, key: object, /) -> bool: + return super().__contains__(LowerStr(key)) + + def __repr__(self) -> str: + return f"HTTPHeaders({self.as_sequence()})" + + def get_exactly_one(self, key: str) -> str: + return super().get_exactly_one(LowerStr(key)) + + def with_new_value(self, key: str, value: str) -> HTTPHeaders: + new_sequence = list(self.as_sequence()) + [(LowerStr(key), value)] + return type(self)(new_sequence) + + HeadersLike: TypeAlias = ( HTTPHeaders | Mapping[str, str] | Mapping[bytes, bytes] ) @@ -61,9 +82,9 @@ def as_real_headers(headers: HeadersLike | None, /) -> HTTPHeaders: to a `MultiMap`. """ if headers is None: - return MultiMap[str, str]() + return HTTPHeaders() - if isinstance(headers, MultiMap): + if isinstance(headers, HTTPHeaders): return headers if __debug__ and not isinstance(headers, Mapping): @@ -81,7 +102,7 @@ def as_real_headers(headers: HeadersLike | None, /) -> HTTPHeaders: all_values.append((LowerStr(key), value)) - return MultiMap(all_values) + return HTTPHeaders(all_values) def wsgi_to_headers(environ: Mapping[str, Any]) -> HTTPHeaders: @@ -98,7 +119,7 @@ def wsgi_to_headers(environ: Mapping[str, Any]) -> HTTPHeaders: key = key.removeprefix("HTTP_").replace("_", "-").lower() # noqa values.append((LowerStr(key), value)) - return MultiMap(values) + return HTTPHeaders(values) def headers_to_wsgi(headers: HTTPHeaders) -> WSGIHeaders: diff --git a/src/view/core/response.py b/src/view/core/response.py index 08ef4f93..2a5caa11 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -20,7 +20,6 @@ as_real_headers, ) from view.exceptions import InvalidTypeError, ViewError -from view.core.multi_map import MultiMap __all__ = "Response", "ViewResult", "ResponseLike" @@ -251,7 +250,7 @@ async def stream() -> AsyncGenerator[bytes]: async for data in response: yield _as_bytes(data) - return Response(stream, status_code=200, headers=MultiMap()) + return Response(stream, status_code=200, headers=HTTPHeaders()) if isinstance(response, Generator): @@ -259,7 +258,7 @@ async def stream() -> AsyncGenerator[bytes]: for data in response: yield _as_bytes(data) - return Response(stream, status_code=200, headers=MultiMap()) + return Response(stream, status_code=200, headers=HTTPHeaders()) raise TypeError(f"Invalid response: {response!r}") diff --git a/tests/test_requests.py b/tests/test_requests.py index 7d343026..8c6dd35b 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -95,9 +95,9 @@ async def app(request: Request) -> ResponseLike: assert request.headers["foo"] == "42" return "1" elif request.path == "/many": - assert request.headers["Bar"] == "42" - assert request.headers["bar"] == "42" - assert request.headers["baR"] == "42" + assert request.headers["Bar"] == "24" + assert request.headers["bar"] == "24" + assert request.headers["baR"] == "24" assert request.headers["test"] == "123" return "2" else: From 46ec3fdcd85c3dd5cd45214da1e509cb5babd160 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:09:43 -0500 Subject: [PATCH 11/19] Use MultiMap for query parameters. --- src/view/core/multi_map.py | 2 +- src/view/core/request.py | 17 +++++------------ tests/test_requests.py | 8 ++++---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/view/core/multi_map.py b/src/view/core/multi_map.py index 31d1dfee..87285573 100644 --- a/src/view/core/multi_map.py +++ b/src/view/core/multi_map.py @@ -111,7 +111,7 @@ def many_items(self) -> ItemsView[KeyT, Sequence[ValueT]]: """ return self._values.items() - def get_one_or_many(self, key: KeyT) -> Sequence[ValueT]: + def get_many(self, key: KeyT) -> Sequence[ValueT]: """ Get one or many values for a given key. """ diff --git a/src/view/core/request.py b/src/view/core/request.py index acdebf0f..24a815aa 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -6,10 +6,9 @@ from enum import auto from typing import TYPE_CHECKING, Any -from multidict import MultiDict - from view.core.body import BodyMixin from view.core.router import normalize_route +from view.core.multi_map import MultiMap if TYPE_CHECKING: from collections.abc import Mapping @@ -125,7 +124,7 @@ class Request(BodyMixin): but if a header has multiple values, it is represented by a list. """ - query_parameters: MultiDict[str] + query_parameters: MultiMap[str, str] """ The query string parameters of the HTTP request. """ @@ -141,18 +140,12 @@ def __post_init__(self) -> None: self.path = normalize_route(self.path) -def extract_query_parameters(query_string: str | bytes) -> MultiDict[str]: +def extract_query_parameters(query_string: str | bytes) -> MultiMap[str, str]: """ - Extract a query string from a URL and return it as a multidict. + Extract a query string from a URL and return it as a multi-map. """ if isinstance(query_string, bytes): query_string = query_string.decode("utf-8") assert isinstance(query_string, str), query_string - parsed = urllib.parse.parse_qsl(query_string) - result = MultiDict() - - for key, value in parsed: - result[key] = value - - return result + return MultiMap(urllib.parse.parse_qsl(query_string)) diff --git a/tests/test_requests.py b/tests/test_requests.py index 8c6dd35b..7ebc4565 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -2,7 +2,6 @@ from collections.abc import AsyncIterator import pytest -from multidict import MultiDict from view.core.app import App, as_app from view.core.body import InvalidJSONError from view.core.headers import as_real_headers @@ -10,6 +9,7 @@ from view.core.response import ResponseLike from view.core.router import DuplicateRouteError from view.core.status_codes import BadRequest +from view.core.multi_map import MultiMap from view.testing import AppTestClient, bad, into_tuple, ok @@ -62,7 +62,7 @@ async def stream_none() -> AsyncIterator[bytes]: path="/", method=Method.POST, headers=as_real_headers({"test": "42"}), - query_parameters=MultiDict(), + query_parameters=MultiMap(), ) response = await app.process_request(manual_request) assert (await response.body()) == b"1" @@ -318,8 +318,8 @@ async def test_request_query_parameters(): async def main(): request = app.current_request() assert request.query_parameters["foo"] == "bar" - # FIXME: Why doesn't multidict work? - # assert request.query_parameters["test"] == ["1", "2", "3"] + assert request.query_parameters["test"] == "1" + assert request.query_parameters.get_many("test") == ["1", "2", "3"] assert "noexist" not in request.query_parameters return "ok" From 8ea64cc2df78e1148a1abc99e899a61b0f35ca6c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:09:58 -0500 Subject: [PATCH 12/19] Remove multidict from dependencies. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bf1c30f3..ba895565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = ["multidict~=6.5", "loguru~=0.7", "aiofiles~=24.1", "typing_extensions>=4"] +dependencies = ["loguru~=0.7", "aiofiles~=24.1", "typing_extensions>=4"] dynamic = ["version", "license"] [project.optional-dependencies] From 7c061d4d88416545c9e521eeede9fa67a752188c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:11:20 -0500 Subject: [PATCH 13/19] Add missing __all__. --- src/view/core/multi_map.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view/core/multi_map.py b/src/view/core/multi_map.py index 87285573..18320a15 100644 --- a/src/view/core/multi_map.py +++ b/src/view/core/multi_map.py @@ -11,6 +11,8 @@ from typing import Any, TypeVar from view.exceptions import ViewError +__all__ = "HasMultipleValuesError", "MultiMap" + KeyT = TypeVar("KeyT") ValueT = TypeVar("ValueT") T = TypeVar("T") From cef49bb2e34a9aa3a2229b1a9b83ae159418a595 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:16:13 -0500 Subject: [PATCH 14/19] Fix linter errors. Also begin adding a test for multi-maps. --- src/view/cache.py | 3 ++- src/view/core/app.py | 2 +- src/view/core/headers.py | 10 +++++++--- src/view/core/multi_map.py | 15 ++++++++++----- src/view/core/request.py | 2 +- src/view/core/response.py | 4 ++-- src/view/run/servers.py | 4 ++-- src/view/run/wsgi.py | 2 +- src/view/testing.py | 2 +- tests/test_misc.py | 5 +++++ 10 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/view/cache.py b/src/view/cache.py index ffaa20c1..d530ad21 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -9,13 +9,14 @@ if TYPE_CHECKING: from collections.abc import Callable + from view.core.headers import HTTPHeaders + from view.core.response import ( Response, TextResponse, ViewResult, wrap_view_result, ) -from view.core.headers import HTTPHeaders __all__ = ("in_memory_cache",) diff --git a/src/view/core/app.py b/src/view/core/app.py index efe0653a..01bce88a 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -33,7 +33,7 @@ from view.run.asgi import ASGIProtocol from view.run.wsgi import WSGIProtocol -__all__ = "BaseApp", "as_app", "App" +__all__ = "App", "BaseApp", "as_app" T = TypeVar("T") P = ParamSpec("P") diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 02c19e9e..99878037 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -3,8 +3,10 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, TypeAlias -from view.exceptions import InvalidTypeError +from typing_extensions import Self + from view.core.multi_map import MultiMap +from view.exceptions import InvalidTypeError if TYPE_CHECKING: from view.run.asgi import ASGIHeaders @@ -26,7 +28,9 @@ class LowerStr(str): comparisons. """ - def __new__(cls, data: object) -> LowerStr: + __slots__ = () + + def __new__(cls, data: object) -> Self: return super().__new__(cls, cls._to_lower(data)) @staticmethod @@ -67,7 +71,7 @@ def get_exactly_one(self, key: str) -> str: return super().get_exactly_one(LowerStr(key)) def with_new_value(self, key: str, value: str) -> HTTPHeaders: - new_sequence = list(self.as_sequence()) + [(LowerStr(key), value)] + new_sequence = [*list(self.as_sequence()), (LowerStr(key), value)] return type(self)(new_sequence) diff --git a/src/view/core/multi_map.py b/src/view/core/multi_map.py index 18320a15..6fbee710 100644 --- a/src/view/core/multi_map.py +++ b/src/view/core/multi_map.py @@ -1,14 +1,16 @@ from __future__ import annotations + from collections.abc import ( + ItemsView, Iterable, Iterator, + KeysView, Mapping, Sequence, - KeysView, ValuesView, - ItemsView, ) from typing import Any, TypeVar + from view.exceptions import ViewError __all__ = "HasMultipleValuesError", "MultiMap" @@ -32,7 +34,7 @@ class MultiMap(Mapping[KeyT, ValueT]): Mapping of individual keys to one or many values. """ - __slots__ = "_values" + __slots__ = ("_values",) def __init__(self, items: Iterable[tuple[KeyT, ValueT]] = ()) -> None: self._values: dict[KeyT, list[ValueT]] = {} @@ -75,6 +77,9 @@ def __ne__(self, other: object, /) -> bool: def __repr__(self) -> str: return f"MultiMap({self.as_sequence()})" + def __hash__(self) -> int: + return hash(self._values) + def _as_flat(self) -> dict[KeyT, ValueT]: """ Turn this into a "flat" representation of the mapping in which all @@ -137,7 +142,7 @@ def as_sequence(self) -> Sequence[tuple[KeyT, ValueT]]: result: list[tuple[KeyT, ValueT]] = [] for key, values in self._values.items(): for value in values: - result.append((key, value)) + result.append((key, value)) # noqa: PERF401 return result @@ -147,5 +152,5 @@ def with_new_value( """ Create a copy of this map with a new key and value included. """ - new_sequence = list(self.as_sequence()) + [(key, value)] + new_sequence = [*list(self.as_sequence()), (key, value)] return type(self)(new_sequence) diff --git a/src/view/core/request.py b/src/view/core/request.py index 24a815aa..e9b3dc2e 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -7,8 +7,8 @@ from typing import TYPE_CHECKING, Any from view.core.body import BodyMixin -from view.core.router import normalize_route from view.core.multi_map import MultiMap +from view.core.router import normalize_route if TYPE_CHECKING: from collections.abc import Mapping diff --git a/src/view/core/response.py b/src/view/core/response.py index 2a5caa11..ecffddbe 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -15,13 +15,13 @@ from view.core.body import BodyMixin from view.core.headers import ( HeadersLike, - LowerStr, HTTPHeaders, + LowerStr, as_real_headers, ) from view.exceptions import InvalidTypeError, ViewError -__all__ = "Response", "ViewResult", "ResponseLike" +__all__ = "Response", "ResponseLike", "ViewResult" @dataclass(slots=True) diff --git a/src/view/run/servers.py b/src/view/run/servers.py index 9dc4d97c..bba566d1 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -157,6 +157,6 @@ def run_app_on_any_server(self) -> None: ) from error # I'm not sure what Ruff is complaining about here - for start_server in servers.values(): # noqa: RET503 + for start_server in servers.values(): with suppress(ImportError): - return start_server() # noqa: RET503 + return start_server() diff --git a/src/view/run/wsgi.py b/src/view/run/wsgi.py index c25813ce..c3e9a1c1 100644 --- a/src/view/run/wsgi.py +++ b/src/view/run/wsgi.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable from typing import IO, TYPE_CHECKING, Any, TypeAlias -from view.core.headers import wsgi_to_headers, headers_to_wsgi +from view.core.headers import headers_to_wsgi, wsgi_to_headers from view.core.request import Method, Request, extract_query_parameters from view.core.status_codes import STATUS_STRINGS diff --git a/src/view/testing.py b/src/view/testing.py index c60e2dd7..4d986e19 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -10,8 +10,8 @@ from collections.abc import AsyncGenerator, Awaitable from view.core.app import BaseApp - from view.core.response import Response from view.core.headers import HTTPHeaders + from view.core.response import Response __all__ = ("AppTestClient",) diff --git a/tests/test_misc.py b/tests/test_misc.py index bf1ca7d2..581d44f9 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,6 +1,7 @@ import pytest from view.core.app import App, as_app from view.exceptions import InvalidTypeError +from view.core.multi_map import MultiMap def test_as_app_invalid(): @@ -16,3 +17,7 @@ def test_invalid_type_route(): with pytest.raises(InvalidTypeError): app.get("/")(object()) # type: ignore + + +def test_multi_map(): + pass From 9a2a25933c0115e7faa1da2443140c3abdb4b24d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:18:26 -0500 Subject: [PATCH 15/19] Ensure all noqa comments have a specific rule. --- src/view/core/app.py | 2 +- src/view/core/headers.py | 6 +++--- src/view/core/response.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 01bce88a..dcbd5ecd 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -143,7 +143,7 @@ def run( settings.run_app_on_any_server() except KeyboardInterrupt: logger.info("CTRL^C received, shutting down") - except Exception: # noqa + except Exception: # noqa: BLE001 logger.exception("Error in server lifecycle") finally: logger.info("Server finished") diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 99878037..35e3cdc4 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -99,10 +99,10 @@ def as_real_headers(headers: HeadersLike | None, /) -> HTTPHeaders: for key, value in headers.items(): if isinstance(key, bytes): - key = key.decode("utf-8") # noqa + key = key.decode("utf-8") # noqa: PLW2901 if isinstance(value, bytes): - value = value.decode("utf-8") # noqa + value = value.decode("utf-8") # noqa: PLW2901 all_values.append((LowerStr(key), value)) @@ -120,7 +120,7 @@ def wsgi_to_headers(environ: Mapping[str, Any]) -> HTTPHeaders: continue assert isinstance(value, str) - key = key.removeprefix("HTTP_").replace("_", "-").lower() # noqa + key = key.removeprefix("HTTP_").replace("_", "-").lower() # noqa: PLW2901 values.append((LowerStr(key), value)) return HTTPHeaders(values) diff --git a/src/view/core/response.py b/src/view/core/response.py index ecffddbe..b2e3e4f4 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -217,10 +217,10 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: # Ruff wants me to use a constant here, but I think this is clear enough # for lengths. - if len(response) > 2: # noqa + if len(response) > 2: # noqa: PLR2004 headers = response[2] - if __debug__ and len(response) > 3: # noqa + if __debug__ and len(response) > 3: # noqa: PLR2004 raise InvalidResponseError( f"Got excess data in response tuple {response[3:]!r}" ) From 909e94a04b2e29c3731c62d17ee042765c45fe9c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:32:20 -0500 Subject: [PATCH 16/19] Add tests for multi maps. --- tests/test_misc.py | 92 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/tests/test_misc.py b/tests/test_misc.py index 581d44f9..e0226518 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,7 +1,7 @@ import pytest from view.core.app import App, as_app from view.exceptions import InvalidTypeError -from view.core.multi_map import MultiMap +from view.core.multi_map import HasMultipleValuesError, MultiMap def test_as_app_invalid(): @@ -19,5 +19,91 @@ def test_invalid_type_route(): app.get("/")(object()) # type: ignore -def test_multi_map(): - pass +def test_empty_multi_map(): + multi_map = MultiMap() + assert multi_map == {} + + with pytest.raises(KeyError): + multi_map["a"] + + with pytest.raises(KeyError): + multi_map[object()] + + with pytest.raises(KeyError): + multi_map[None] + + assert len(multi_map) == 0 + assert multi_map.as_sequence() == [] + + called = False + for _ in multi_map.keys(): + called = True + + assert called is False + + for _ in multi_map.values(): + called = True + + assert called is False + + for _ in multi_map.items(): + called = True + + assert called is False + + +def test_multi_map_no_duplicates(): + data = [('a', 1), ('b', 2), ('c', 3)] + multi_map = MultiMap(data) + + assert multi_map == {"a": 1, "b": 2, "c": 3} + assert len(multi_map) == 3 + assert multi_map.as_sequence() == data + + for key, value in data: + assert multi_map[key] == value + assert multi_map.get_many(key) == [value] + assert multi_map.get(key) == value + assert multi_map.get_exactly_one(key) == value + assert key in multi_map.keys() + assert value in multi_map.values() + + +def test_multi_map_with_duplicates(): + data = [('a', 1), ('a', 2), ('a', 3), ('b', 4)] + multi_map = MultiMap(data) + assert len(multi_map) == 2 + assert multi_map.as_sequence() == data + + assert multi_map == {"a": 1, "b": 4} + assert multi_map["a"] == 1 + assert multi_map.get_many("a") == [1, 2, 3] + + assert list(multi_map.keys()) == ['a', 'b'] + assert list(multi_map.values()) == [1, 4] + assert list(multi_map.items()) == [('a', 1), ('b', 4)] + assert list(multi_map.many_values()) == [[1, 2, 3], [4]] + assert list(multi_map.many_items()) == [('a', [1, 2, 3]), ('b', [4])] + + with pytest.raises(HasMultipleValuesError): + multi_map.get_exactly_one('a') + + assert multi_map.get_exactly_one("b") == 4 + + +def test_multi_map_with_new_value(): + data = [('a', 1), ('b', 2), ('b', 3)] + multi_map = MultiMap(data) + assert len(multi_map) == 2 + + new_map = multi_map.with_new_value('b', 4) + assert len(new_map) == 2 + assert multi_map != new_map + assert new_map.get_many("b") == [2, 3, 4] + + new_map = new_map.with_new_value("c", 4) + assert len(new_map) == 3 + assert new_map != multi_map + assert new_map["c"] == 4 + assert new_map.get_exactly_one("c") == 4 + assert new_map.get_many("b") == [2, 3, 4] From 6a674dd00d6f0d0222a73284360005c6c23c83ef Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:35:38 -0500 Subject: [PATCH 17/19] Increase test coverage for multi-map. --- tests/test_misc.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_misc.py b/tests/test_misc.py index e0226518..b7093f5f 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -51,6 +51,11 @@ def test_empty_multi_map(): assert called is False + for _ in multi_map: + called = True + + assert called is False + def test_multi_map_no_duplicates(): data = [('a', 1), ('b', 2), ('c', 3)] @@ -61,6 +66,7 @@ def test_multi_map_no_duplicates(): assert multi_map.as_sequence() == data for key, value in data: + assert key in multi_map assert multi_map[key] == value assert multi_map.get_many(key) == [value] assert multi_map.get(key) == value @@ -68,6 +74,14 @@ def test_multi_map_no_duplicates(): assert key in multi_map.keys() assert value in multi_map.values() + called = 0 + for key in multi_map: + called += 1 + assert key in ("a", "b", "c") + + assert called == 3 + + def test_multi_map_with_duplicates(): data = [('a', 1), ('a', 2), ('a', 3), ('b', 4)] @@ -79,6 +93,8 @@ def test_multi_map_with_duplicates(): assert multi_map["a"] == 1 assert multi_map.get_many("a") == [1, 2, 3] + assert "a" in multi_map + assert "b" in multi_map assert list(multi_map.keys()) == ['a', 'b'] assert list(multi_map.values()) == [1, 4] assert list(multi_map.items()) == [('a', 1), ('b', 4)] @@ -90,6 +106,13 @@ def test_multi_map_with_duplicates(): assert multi_map.get_exactly_one("b") == 4 + called = 0 + for key in multi_map: + called += 1 + assert key in ("a", "b") + + assert called == 2 + def test_multi_map_with_new_value(): data = [('a', 1), ('b', 2), ('b', 3)] @@ -98,11 +121,13 @@ def test_multi_map_with_new_value(): new_map = multi_map.with_new_value('b', 4) assert len(new_map) == 2 + assert "b" in new_map assert multi_map != new_map assert new_map.get_many("b") == [2, 3, 4] new_map = new_map.with_new_value("c", 4) assert len(new_map) == 3 + assert "c" in new_map assert new_map != multi_map assert new_map["c"] == 4 assert new_map.get_exactly_one("c") == 4 From 7f4eed2e7428bd2c9cf5bbe6840367cec86c2310 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:38:01 -0500 Subject: [PATCH 18/19] Add a job for PR triage. --- .github/workflows/triage.yml | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/triage.yml diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 00000000..1fdc7b63 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,67 @@ +name: Triage +on: + pull_request: + types: + - "opened" + - "reopened" + - "synchronize" + - "labeled" + - "unlabeled" + +jobs: + changelog_check: + runs-on: ubuntu-latest + name: Check for changelog updates + steps: + - name: "Check if the source directory was changed" + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + src: + - 'src/**' + + - name: "Check for changelog updates" + if: steps.changes.outputs.src == 'true' + uses: brettcannon/check-for-changed-files@v1 + with: + file-pattern: | + CHANGELOG.md + skip-label: "skip changelog" + failure-message: "Missing a CHANGELOG.md update; please add one or apply the ${skip-label} label to the pull request" + + tests_check: + runs-on: ubuntu-latest + name: Check for updated tests + steps: + - name: "Check if the source directory was changed" + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + src: + - 'src/**' + + - name: "Check for test updates" + if: steps.changes.outputs.src == 'true' + uses: brettcannon/check-for-changed-files@v1 + with: + file-pattern: | + tests/* + skip-label: "skip tests" + failure-message: "Missing unit tests; please add some or apply the ${skip-label} label to the pull request" + + all_green: + runs-on: ubuntu-latest + name: PR has no missing information + if: always() + + needs: + - changelog_check + - tests_check + + steps: + - name: Check whether jobs passed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} From 192f27e334dd51ef2c3e4fba6cfebdc8ebbe38c6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 1 Jan 2026 13:43:54 -0500 Subject: [PATCH 19/19] Improve job for running the tests. --- .github/workflows/tests.yml | 47 ++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8c1d9d6a..7afc1870 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,29 +16,60 @@ env: PYTHONUNBUFFERED: "1" FORCE_COLOR: "1" PYTHONIOENCODING: "utf8" - PYTHONDEVMODE: "1" - HATCH_VERBOSE: "1" jobs: - run: + changes: + name: Check for changed files + runs-on: ubuntu-latest + outputs: + source: ${{ steps.filter.outputs.source }} + tests: ${{ steps.filter.outputs.tests }} + steps: + - uses: actions/checkout@v2 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + source: + - 'src/**' + tests: + - 'tests/**' + + run-tests: + needs: changes + if: ${{ needs.changes.outputs.source == 'true' || needs.changes.outputs.tests == 'true' }} name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} runs-on: ${{ matrix.os }} strategy: - fail-fast: false + fail-fast: true matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Hatch - run: pip install hatch + uses: pypa/hatch@install - name: Run tests run: hatch test + + tests-pass: + runs-on: ubuntu-latest + name: All tests passed + if: always() + + needs: + - run-tests + + steps: + - name: Check whether all tests passed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + allowed-skips: ${{ toJSON(needs) }}