From 3498b83f8d08593ad1c0320ba97b173d421df19a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 12:48:36 -0500 Subject: [PATCH 01/32] Change my email in the license. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 09efdf01..fd928a85 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Peter Bierma +Copyright (c) 2025-present Peter Bierma Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 64fd2de7fd9681f532d0cc378819995955851ea7 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 12:53:45 -0500 Subject: [PATCH 02/32] Fix the logo in the README. --- README.md | 4 ++-- logos/logo_theme_dark.png | Bin 0 -> 29956 bytes logos/logo_theme_light.png | Bin 0 -> 30447 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 logos/logo_theme_dark.png create mode 100644 logos/logo_theme_light.png diff --git a/README.md b/README.md index 2a7d0d25..e321fd1c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- - + + view.py logo
diff --git a/logos/logo_theme_dark.png b/logos/logo_theme_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d2aa1fe71a4911c4fd8ead72dce33811443eec43 GIT binary patch literal 29956 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 literal 0 HcmV?d00001 diff --git a/logos/logo_theme_light.png b/logos/logo_theme_light.png new file mode 100644 index 0000000000000000000000000000000000000000..3189113cd137e5ec43baa2d7f88eaea6e5dcca4b GIT binary patch literal 30447 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 C{e+?b literal 0 HcmV?d00001 From 4a4fd041eff82a49849e423f00ecada5312d4d6c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:21:25 -0500 Subject: [PATCH 03/32] Fix type hints in _guess_file_type. --- src/view/core/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/core/response.py b/src/view/core/response.py index cd022d02..323db480 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -61,7 +61,7 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: StrPath: TypeAlias = str | PathLike[str] -def _guess_file_type(path: Path, /) -> list[str]: +def _guess_file_type(path: StrPath, /) -> str: if sys.version_info >= (3, 13): return mimetypes.guess_file_type(path)[0] or "text/plain" else: From 3ac9c68689f9dd85f7c43b57e6b326a783a55af9 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:24:09 -0500 Subject: [PATCH 04/32] Add missing slots=True --- src/view/dom/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/dom/components.py b/src/view/dom/components.py index 09890716..56c633f5 100644 --- a/src/view/dom/components.py +++ b/src/view/dom/components.py @@ -27,7 +27,7 @@ def as_html(self) -> str: ) -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class Component: """ A node with an "injectable" body. From 02caadda963803b5223979efb33f0f5d392204e1 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:28:12 -0500 Subject: [PATCH 05/32] Update Hatch test configuration. --- hatch.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hatch.toml b/hatch.toml index b8c43655..ee1c620a 100644 --- a/hatch.toml +++ b/hatch.toml @@ -11,8 +11,16 @@ packages = ["src/view"] extra-args = ["-vv"] extra-dependencies = [ "pytest-asyncio", - "requests" + "requests", + "uvicorn", + "hypercorn", + "daphne", + "gunicorn", + "werkzeug", ] +randomize = true +retries = 3 +retries-delay = 1 [[envs.hatch-test.matrix]] python = ["3.14", "3.13", "3.12", "3.11", "3.10"] From 1400733c93cf2b2c0655f762ec59e39d7cdf2c72 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:33:50 -0500 Subject: [PATCH 06/32] Run formatter and linter. --- src/view/core/app.py | 1 + src/view/core/request.py | 5 +++-- src/view/core/response.py | 4 ++-- src/view/core/status_codes.py | 2 +- src/view/dom/primitives.py | 1 + src/view/run/asgi.py | 1 + src/view/run/servers.py | 2 +- src/view/testing.py | 2 +- tests/test_cache.py | 1 - tests/test_dom.py | 9 ++++----- tests/test_misc.py | 19 +++++++++++++++++++ tests/test_requests.py | 1 - tests/test_responses.py | 3 +-- tests/test_servers.py | 1 - tests/test_utils.py | 5 ++--- 15 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 tests/test_misc.py diff --git a/src/view/core/app.py b/src/view/core/app.py index c6463980..1909e09f 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -22,6 +22,7 @@ from view.core.router import FoundRoute, Route, Router, RouteView from view.core.status_codes import Forbidden, HTTPError, InternalServerError, NotFound from view.utils import reraise +from view.exceptions import InvalidType if TYPE_CHECKING: from view.run.asgi import ASGIProtocol diff --git a/src/view/core/request.py b/src/view/core/request.py index 9384da88..7eee7837 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -1,11 +1,12 @@ from __future__ import annotations +import sys import urllib.parse from collections.abc import Mapping from dataclasses import dataclass, field from enum import auto from typing import TYPE_CHECKING -import sys + from multidict import MultiDict from view.core.body import BodyMixin @@ -100,7 +101,7 @@ class Request(BodyMixin): Dataclass representing an HTTP request. """ - app: "BaseApp" + app: BaseApp """ The app associated with the HTTP request. """ diff --git a/src/view/core/response.py b/src/view/core/response.py index 323db480..7c0987bd 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -2,8 +2,8 @@ import json import mimetypes -import warnings import sys +import warnings from collections.abc import AsyncGenerator, Awaitable, Callable, Generator from dataclasses import dataclass from os import PathLike @@ -199,7 +199,7 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: content = response[0] if __debug__ and isinstance(content, Response): raise InvalidResponse( - f"Response() objects cannot be used with response" + "Response() objects cannot be used with response" " tuples. Instead, use the status_code and/or headers parameter(s)." ) diff --git a/src/view/core/status_codes.py b/src/view/core/status_codes.py index d7234163..47a3e1de 100644 --- a/src/view/core/status_codes.py +++ b/src/view/core/status_codes.py @@ -1,9 +1,9 @@ from __future__ import annotations +import sys import traceback from enum import IntEnum from typing import ClassVar -import sys from view.core.response import TextResponse diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index 1a7cdfc6..af3a1110 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -2,6 +2,7 @@ from collections.abc import Callable from typing import Any, Literal, TypedDict + from typing_extensions import NotRequired, Unpack from view.dom.core import HTMLNode diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index 351d79ce..39dc3e43 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from typing import Any, Literal, TypeAlias, TypedDict + from typing_extensions import NotRequired from view.core.app import BaseApp diff --git a/src/view/run/servers.py b/src/view/run/servers.py index 3c73bba8..24bce31b 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -41,7 +41,7 @@ class ServerSettings: "wsgiref", ] - app: "BaseApp" + app: BaseApp port: int host: str hint: str | None = None diff --git a/src/view/testing.py b/src/view/testing.py index 42f958e8..b0a1f908 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -43,7 +43,7 @@ def bad(status_code: int) -> tuple[bytes, int, dict[str, str]]: Utility function for an error response from `into_tuple()`. """ body = STATUS_STRINGS[status_code] - return (f"{status_code} {body}".encode("utf-8"), status_code, {}) + return (f"{status_code} {body}".encode(), status_code, {}) class AppTestClient: diff --git a/tests/test_cache.py b/tests/test_cache.py index 1df2427b..b59edff8 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,7 +2,6 @@ from unittest.mock import patch import pytest - from view.cache import InMemoryCache, in_memory_cache, minutes from view.core.app import App from view.core.response import ResponseLike diff --git a/tests/test_dom.py b/tests/test_dom.py index 461748a1..5c123ec1 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -2,7 +2,6 @@ from collections.abc import AsyncIterator, Callable, Iterator import pytest - from view.core.app import App from view.dom.components import Children, component from view.dom.core import HTMLNode, html_context, html_response @@ -45,7 +44,7 @@ def test_dom_primitives(dom_node: Callable[..., HTMLNode]): return iterator = parent.as_html_stream() - assert "" in next(iterator) assert "" in next(iterator) if has_body: - assert f"gotcha" in next(iterator) + assert "gotcha" in next(iterator) assert f"" in next(iterator) - assert f"" in next(iterator) - assert f"" == next(iterator) + assert "" in next(iterator) + assert next(iterator) == "" with pytest.raises(StopIteration): next(iterator) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 00000000..6d321549 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,19 @@ +import pytest + +from view.exceptions import InvalidType +from view.core.app import as_app, App + + +def test_as_app_invalid(): + with pytest.raises(InvalidType): + as_app(object()) # type: ignore + + +def test_invalid_type_route(): + app = App() + + with pytest.raises(InvalidType): + app.get(object()) # type: ignore + + with pytest.raises(InvalidType): + app.get("/")(object()) # type: ignore diff --git a/tests/test_requests.py b/tests/test_requests.py index 42b511ee..a756e9db 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -3,7 +3,6 @@ import pytest from multidict import MultiDict - from view.core.app import App, as_app from view.core.body import InvalidJSON from view.core.headers import as_multidict diff --git a/tests/test_responses.py b/tests/test_responses.py index 03a8af45..a49e0fcd 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest - from view.core.app import App, as_app from view.core.headers import as_multidict from view.core.request import Request @@ -146,7 +145,7 @@ def app(request: Request) -> ResponseLike: elif request.path == "/message": raise BadRequest("Test") else: - raise RuntimeError() + raise RuntimeError client = AppTestClient(app) assert (await into_tuple(client.get("/"))) == bad(400) diff --git a/tests/test_servers.py b/tests/test_servers.py index d01cb8e4..bc31da78 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -4,7 +4,6 @@ import pytest import requests - from view.core.app import as_app from view.core.request import Request from view.core.response import ResponseLike diff --git a/tests/test_utils.py b/tests/test_utils.py index 3f42c510..8d0de0bc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,4 @@ import pytest - from view.utils import reraise, reraises @@ -42,11 +41,11 @@ def test_reraise_multiple(): with pytest.raises(RuntimeError): with reraise(RuntimeError, TypeError, ValueError): - raise ValueError() + raise ValueError with pytest.raises(RuntimeError): with reraise(RuntimeError, TypeError, ValueError): - raise TypeError() + raise TypeError def test_do_not_reraise_base_exceptions(): From 526c3940d9af39cbe4372520a7afe78739471faa Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:43:26 -0500 Subject: [PATCH 07/32] Add some ruff things. --- pyproject.toml | 13 +++++++++++++ src/view/cache.py | 7 ++++++- src/view/core/app.py | 9 +++++++-- src/view/utils.py | 1 + tests/test_misc.py | 3 +-- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2b055e33..e4b22aa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,16 @@ Funding = "https://github.com/sponsors/ZeroIntensity" [project.scripts] view = "view.__main__:main" view-py = "view.__main__:main" + +[tool.ruff] +exclude = ["tests/"] +line-length = 79 +indent-width = 4 + +[tool.ruff.lint] +ignore = ["S101"] # We intentionally want assertions to be debug-only + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["PLC0414"] +"status_codes.py" = ["N818"] +"primitives.py" = ["A001", "A002", "B008"] diff --git a/src/view/cache.py b/src/view/cache.py index 09198d64..57e16534 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -9,7 +9,12 @@ from multidict import CIMultiDict -from view.core.response import Response, TextResponse, ViewResult, wrap_view_result +from view.core.response import ( + Response, + TextResponse, + ViewResult, + wrap_view_result, +) __all__ = ("in_memory_cache",) diff --git a/src/view/core/app.py b/src/view/core/app.py index 1909e09f..ec69cb20 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -20,9 +20,14 @@ wrap_view_result, ) from view.core.router import FoundRoute, Route, Router, RouteView -from view.core.status_codes import Forbidden, HTTPError, InternalServerError, NotFound -from view.utils import reraise +from view.core.status_codes import ( + Forbidden, + HTTPError, + InternalServerError, + NotFound, +) from view.exceptions import InvalidType +from view.utils import reraise if TYPE_CHECKING: from view.run.asgi import ASGIProtocol diff --git a/src/view/utils.py b/src/view/utils.py index 0e9c8f64..90931343 100644 --- a/src/view/utils.py +++ b/src/view/utils.py @@ -1,3 +1,4 @@ +from __future__ import annotations from collections.abc import Callable, Iterator from contextlib import contextmanager from functools import wraps diff --git a/tests/test_misc.py b/tests/test_misc.py index 6d321549..8c44e7d4 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,7 +1,6 @@ import pytest - +from view.core.app import App, as_app from view.exceptions import InvalidType -from view.core.app import as_app, App def test_as_app_invalid(): From df3131337b1b31030baa66218671b8c9e6be80c1 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:45:20 -0500 Subject: [PATCH 08/32] Fix linter complaints in utils. --- src/view/utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/view/utils.py b/src/view/utils.py index 90931343..d82c4079 100644 --- a/src/view/utils.py +++ b/src/view/utils.py @@ -1,4 +1,5 @@ from __future__ import annotations + from collections.abc import Callable, Iterator from contextlib import contextmanager from functools import wraps @@ -17,9 +18,11 @@ def reraise( This is primarily useful for reraising exceptions into HTTP errors, such as an error 400 (Bad Request). """ + target = exceptions or Exception + try: yield - except exceptions or Exception as error: + except target as error: raise new_exception from error @@ -37,13 +40,14 @@ def reraises( This is primarily useful for reraising exceptions into HTTP errors, such as an error 400 (Bad Request). """ + target = exceptions or Exception def factory(function: Callable[P, T], /) -> Callable[P, T]: @wraps(function) def decorator(*args: P.args, **kwargs: P.kwargs) -> T: try: return function(*args, **kwargs) - except exceptions or Exception as error: + except target as error: raise new_exception from error return decorator From b14024cc4121727d00849cbe725128fd4469c757 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:48:37 -0500 Subject: [PATCH 09/32] Fix more linter problems. --- src/view/cache.py | 8 +++++--- src/view/javascript.py | 8 ++++++-- src/view/testing.py | 3 ++- src/view/utils.py | 6 ++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/view/cache.py b/src/view/cache.py index 57e16534..216a10b0 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -3,11 +3,13 @@ import math import time from abc import ABC, abstractmethod -from collections.abc import Callable from dataclasses import dataclass, field -from typing import Generic, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Generic, ParamSpec, TypeVar -from multidict import CIMultiDict +if TYPE_CHECKING: + from collections.abc import Callable + + from multidict import CIMultiDict from view.core.response import ( Response, diff --git a/src/view/javascript.py b/src/view/javascript.py index c4f43df7..69528a87 100644 --- a/src/view/javascript.py +++ b/src/view/javascript.py @@ -1,6 +1,10 @@ -from collections.abc import Callable, Iterator +from __future__ import annotations + from io import StringIO -from typing import ParamSpec, Protocol, runtime_checkable +from typing import TYPE_CHECKING, ParamSpec, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator from view.exceptions import InvalidType diff --git a/src/view/testing.py b/src/view/testing.py index b0a1f908..02a99a6a 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,6 +1,5 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Awaitable from typing import TYPE_CHECKING from multidict import CIMultiDict @@ -10,6 +9,8 @@ from view.core.status_codes import STATUS_STRINGS if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Awaitable + from view.core.app import BaseApp from view.core.response import Response diff --git a/src/view/utils.py b/src/view/utils.py index d82c4079..c91993e5 100644 --- a/src/view/utils.py +++ b/src/view/utils.py @@ -1,9 +1,11 @@ from __future__ import annotations -from collections.abc import Callable, Iterator from contextlib import contextmanager from functools import wraps -from typing import ParamSpec, TypeVar +from typing import TYPE_CHECKING, ParamSpec, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator __all__ = "reraise", "reraises" From ab9a5fb0a4469e361d0b014467a19a3294fafa9c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:51:18 -0500 Subject: [PATCH 10/32] Fix some exception linter things. --- src/view/core/app.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index ec69cb20..352a207d 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -142,7 +142,7 @@ def run( settings.run_app_on_any_server() except KeyboardInterrupt: logger.info("CTRL^C received, shutting down") - except Exception: + except Exception: # noqa logger.exception("Error in server lifecycle") finally: logger.info("Server finished") @@ -200,7 +200,7 @@ async def execute_view( if __debug__: raise InternalServerError.from_current_exception() else: - raise InternalServerError() + raise InternalServerError SingleView = Callable[["Request"], ViewResult] @@ -257,7 +257,7 @@ async def _process_request_internal(self, request: Request) -> Response: request.path, request.method ) if found_route is None: - raise NotFound() + raise NotFound # Extend instead of replacing? request.path_parameters = found_route.path_parameters @@ -387,10 +387,10 @@ def static_files(self, path: str, directory: str | Path) -> None: def serve_static_file(path_from_url: str) -> ResponseLike: file = directory / path_from_url if not file.is_file(): - raise NotFound() + raise NotFound if not file.is_relative_to(directory): - raise Forbidden() + raise Forbidden with reraise(Forbidden, OSError): return FileResponse.from_file(file) From 1bd3890a9b0c88a29da2b8a86bad4aa4aec30a51 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:53:22 -0500 Subject: [PATCH 11/32] Suffix errors with "Error". --- src/view/core/app.py | 14 +++++++------- src/view/core/body.py | 18 +++++++++--------- src/view/core/headers.py | 4 ++-- src/view/core/response.py | 16 +++++++++------- src/view/core/router.py | 18 +++++++++--------- src/view/dom/core.py | 4 ++-- src/view/dom/primitives.py | 6 +++--- src/view/exceptions.py | 2 +- src/view/javascript.py | 6 +++--- src/view/run/servers.py | 8 +++++--- tests/test_misc.py | 8 ++++---- tests/test_requests.py | 10 +++++----- 12 files changed, 59 insertions(+), 55 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 352a207d..089717ae 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -26,7 +26,7 @@ InternalServerError, NotFound, ) -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError from view.utils import reraise if TYPE_CHECKING: @@ -229,7 +229,7 @@ def as_app(view: SingleView, /) -> SingleViewApp: Decorator for using a single function as an app. """ if __debug__ and not callable(view): - raise InvalidType(view, Callable) + raise InvalidTypeError(view, Callable) return SingleViewApp(view) @@ -280,10 +280,10 @@ def route(self, path: str, /, *, method: Method) -> RouteDecorator: """ if __debug__ and not isinstance(path, str): - raise InvalidType(path, str) + raise InvalidTypeError(path, str) if __debug__ and not isinstance(method, Method): - raise InvalidType(method, Method) + raise InvalidTypeError(method, Method) def decorator(view: RouteView, /) -> Route: route = self.router.push_route(view, path, method) @@ -360,11 +360,11 @@ def decorator(view: RouteView, /) -> RouteView: def subrouter(self, path: str) -> Callable[[SubRouterViewT], SubRouterViewT]: if __debug__ and not isinstance(path, str): - raise InvalidType(path, str) + raise InvalidTypeError(path, str) def decorator(function: SubRouterViewT, /) -> SubRouterViewT: if __debug__ and not callable(function): - raise InvalidType(Callable, function) + raise InvalidTypeError(Callable, function) def router_function(path_from_url: str) -> Route: def route() -> ResponseLike | Awaitable[ResponseLike]: @@ -379,7 +379,7 @@ def route() -> ResponseLike | Awaitable[ResponseLike]: def static_files(self, path: str, directory: str | Path) -> None: if __debug__ and not isinstance(directory, (str, Path)): - raise InvalidType(directory, str, Path) + raise InvalidTypeError(directory, str, Path) directory = Path(directory) diff --git a/src/view/core/body.py b/src/view/core/body.py index c4b31a05..c6db43e1 100644 --- a/src/view/core/body.py +++ b/src/view/core/body.py @@ -6,14 +6,14 @@ from io import BytesIO from typing import Any, TypeAlias -from view.exceptions import InvalidType, ViewError +from view.exceptions import InvalidTypeError, ViewError __all__ = ("BodyMixin",) BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] -class BodyAlreadyUsed(ViewError): +class BodyAlreadyUsedError(ViewError): """ The body was already used on this response. @@ -22,7 +22,7 @@ class BodyAlreadyUsed(ViewError): """ -class InvalidJSON(ViewError): +class InvalidJSONError(ViewError): """ The body is not valid JSON data or something went wrong when parsing it. @@ -45,14 +45,14 @@ async def body(self) -> bytes: Read the full body from the stream. """ if self.consumed: - raise BodyAlreadyUsed("Body has already been consumed") + raise BodyAlreadyUsedError("Body has already been consumed") self.consumed = True buffer = BytesIO() async for data in self.receive_data(): if __debug__ and not isinstance(data, bytes): - raise InvalidType(data, bytes) + raise InvalidTypeError(data, bytes) buffer.write(data) return buffer.getvalue() @@ -68,12 +68,12 @@ async def json( try: text = data.decode("utf-8") except UnicodeDecodeError as error: - raise InvalidJSON("Body does not contain valid UTF-8 data") from error + raise InvalidJSONError("Body does not contain valid UTF-8 data") from error try: return parse_function(text) except Exception as error: - raise InvalidJSON("Failed to parse JSON") from error + raise InvalidJSONError("Failed to parse JSON") from error async def stream_body(self) -> AsyncIterator[bytes]: """ @@ -81,11 +81,11 @@ async def stream_body(self) -> AsyncIterator[bytes]: in-memory at a given time. """ if self.consumed: - raise BodyAlreadyUsed("Body has already been consumed") + raise BodyAlreadyUsedError("Body has already been consumed") self.consumed = True async for data in self.receive_data(): if __debug__ and not isinstance(data, bytes): - raise InvalidType(data, bytes) + raise InvalidTypeError(data, bytes) yield data diff --git a/src/view/core/headers.py b/src/view/core/headers.py index f7b1db93..a98d8be8 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -5,7 +5,7 @@ from multidict import CIMultiDict -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError if TYPE_CHECKING: from view.run.asgi import ASGIHeaders @@ -35,7 +35,7 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: return headers if __debug__ and not isinstance(headers, Mapping): - raise InvalidType(Mapping, headers) + raise InvalidTypeError(Mapping, headers) assert isinstance(headers, dict) multidict = CIMultiDict[str]() diff --git a/src/view/core/response.py b/src/view/core/response.py index 7c0987bd..37ff657b 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -15,7 +15,7 @@ from view.core.body import BodyMixin from view.core.headers import HeadersLike, RequestHeaders, as_multidict -from view.exceptions import InvalidType, ViewError +from view.exceptions import InvalidTypeError, ViewError __all__ = "Response", "ViewResult", "ResponseLike" @@ -91,7 +91,7 @@ def from_file( Generate a `FileResponse` from a file path. """ if __debug__ and not isinstance(chunk_size, int): - raise InvalidType(chunk_size, int) + raise InvalidTypeError(chunk_size, int) async def stream(): async with aiofiles.open(path, "rb") as file: @@ -141,7 +141,7 @@ def from_content( """ if __debug__ and not isinstance(content, (str, bytes)): - raise InvalidType(content, str, bytes) + raise InvalidTypeError(content, str, bytes) async def stream() -> AsyncGenerator[bytes]: yield _as_bytes(content) @@ -177,7 +177,7 @@ async def stream() -> AsyncGenerator[bytes]: ) -class InvalidResponse(ViewError): +class InvalidResponseError(ViewError): """ A view returned an object that view.py doesn't know how to convert into a response object. @@ -186,7 +186,7 @@ class InvalidResponse(ViewError): def _wrap_response_tuple(response: _ResponseTuple) -> Response: if __debug__ and response == (): - raise InvalidResponse("Response cannot be an empty tuple") + raise InvalidResponseError("Response cannot be an empty tuple") if __debug__ and len(response) == 1: warnings.warn( @@ -198,7 +198,7 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: content = response[0] if __debug__ and isinstance(content, Response): - raise InvalidResponse( + raise InvalidResponseError( "Response() objects cannot be used with response" " tuples. Instead, use the status_code and/or headers parameter(s)." ) @@ -210,7 +210,9 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: headers = response[2] if __debug__ and len(response) > 3: - raise InvalidResponse(f"Got excess data in response tuple {response[3:]!r}") + raise InvalidResponseError( + f"Got excess data in response tuple {response[3:]!r}" + ) return TextResponse.from_content(content, status_code=status, headers=headers) diff --git a/src/view/core/router.py b/src/view/core/router.py index 874385f0..a097fdaf 100644 --- a/src/view/core/router.py +++ b/src/view/core/router.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, TypeAlias from view.core.status_codes import HTTPError, status_exception -from view.exceptions import InvalidType, ViewError +from view.exceptions import InvalidTypeError, ViewError if TYPE_CHECKING: from view.core.request import Method @@ -49,7 +49,7 @@ def normalize_route(route: str, /) -> str: return route -class DuplicateRoute(ViewError): +class DuplicateRouteError(ViewError): """ The router found multiple views for the same route. @@ -84,7 +84,7 @@ def parameter(self, name: str) -> PathNode: self.path_parameter = next_node return next_node if __debug__ and name != self.path_parameter.name: - raise DuplicateRoute( + raise DuplicateRouteError( f"Path parameter {name} is in the same place as" f" {self.path_parameter.name}, but with a different name", ) @@ -143,7 +143,7 @@ class Router: def _get_node_for_path(self, path: str, *, allow_path_parameters: bool) -> PathNode: if __debug__ and not isinstance(path, str): - raise InvalidType(path, str) + raise InvalidTypeError(path, str) path = normalize_route(path) parent_node = self.parent_node @@ -165,11 +165,11 @@ def push_route(self, view: RouteView, path: str, method: Method) -> Route: """ if __debug__ and not callable(view): - raise InvalidType(view, Callable) + raise InvalidTypeError(view, Callable) node = self._get_node_for_path(path, allow_path_parameters=True) if node.routes.get(method) is not None: - raise DuplicateRoute( + raise DuplicateRouteError( f"The route {path!r} was already used for method {method.value}" ) @@ -184,11 +184,11 @@ def push_subrouter(self, subrouter: SubRouter, path: str) -> None: """ if __debug__ and not callable(subrouter): - raise InvalidType(subrouter, Callable) + raise InvalidTypeError(subrouter, Callable) node = self._get_node_for_path(path, allow_path_parameters=False) if node.subrouter is not None: - raise DuplicateRoute(f"The route {path!r} already has a subrouter") + raise DuplicateRouteError(f"The route {path!r} already has a subrouter") node.subrouter = subrouter @@ -202,7 +202,7 @@ def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: elif issubclass(error, HTTPError): error_type = error else: - raise InvalidType(error, int, type) + raise InvalidTypeError(error, int, type) self.error_views[error_type] = view diff --git a/src/view/dom/core.py b/src/view/dom/core.py index 91e4fc2e..012dcf3f 100644 --- a/src/view/dom/core.py +++ b/src/view/dom/core.py @@ -18,7 +18,7 @@ from view.core.headers import as_multidict from view.core.response import Response from view.core.router import RouteView -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError from view.javascript import SupportsJavaScript __all__ = ("HTMLNode", "html_response") @@ -197,7 +197,7 @@ def try_item(item: HTMLViewResponseItem) -> None: try_item(item) else: if __debug__ and not isinstance(iterator, Iterator): - raise InvalidType(iterator, AsyncIterator, Iterator) + raise InvalidTypeError(iterator, AsyncIterator, Iterator) for item in iterator: try_item(item) diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index af3a1110..b901284a 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -6,7 +6,7 @@ from typing_extensions import NotRequired, Unpack from view.dom.core import HTMLNode -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError class ImplicitDefault(str): @@ -25,7 +25,7 @@ def _construct_node( data: dict[str, str], ) -> HTMLNode: if __debug__ and ((child_text is not None) and not isinstance(child_text, str)): - raise InvalidType(child_text, str) + raise InvalidTypeError(child_text, str) for attribute_name, value in attributes.copy().items(): if value in {None, False}: @@ -36,7 +36,7 @@ def _construct_node( attributes = {**attributes, **global_attributes} for data_name, value in data.items(): if __debug__ and not isinstance(value, str): - raise InvalidType(value, str) + raise InvalidTypeError(value, str) attributes[f"data-{data_name}"] = value diff --git a/src/view/exceptions.py b/src/view/exceptions.py index f0a26446..ab42b59d 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -14,7 +14,7 @@ def __init__(self, *msg: str) -> None: super().__init__(*msg) -class InvalidType(ViewError, TypeError): +class InvalidTypeError(ViewError, TypeError): """ Something got a type that it didn't expect. For example, passing a `str` object in a place where a `bytes` object was expected would raise diff --git a/src/view/javascript.py b/src/view/javascript.py index 69528a87..fc6dd303 100644 --- a/src/view/javascript.py +++ b/src/view/javascript.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError __all__ = "SupportsJavaScript", "as_javascript_expression", "javascript_compiler" @@ -57,7 +57,7 @@ def as_javascript_expression(data: object) -> str: if isinstance(data, SupportsJavaScript): result = data.as_javascript() if __debug__ and not isinstance(result, str): - raise InvalidType(result, str) + raise InvalidTypeError(result, str) raise TypeError(f"Don't know how to convert {data!r} to a JavaScript expression") @@ -73,7 +73,7 @@ def decorator(*args: P.args, **kwargs: P.kwargs) -> str: for line in function(*args, **kwargs): if __debug__ and not isinstance(line, str): - raise InvalidType(line, str) + raise InvalidTypeError(line, str) buffer.write(f"{line};\n") return buffer.getvalue() diff --git a/src/view/run/servers.py b/src/view/run/servers.py index 24bce31b..97204835 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -16,7 +16,7 @@ StartServer: TypeAlias = Callable[[], None] -class BadServer(ViewError): +class BadServerError(ViewError): """ Something is wrong with the selected server. @@ -143,12 +143,14 @@ def run_app_on_any_server(self) -> None: try: start_server = servers[self.hint] except KeyError as key_error: - raise BadServer(f"{self.hint!r} is not a known server") from key_error + raise BadServerError( + f"{self.hint!r} is not a known server" + ) from key_error try: return start_server() except ImportError as error: - raise BadServer(f"{self.hint} is not installed") from error + raise BadServerError(f"{self.hint} is not installed") from error for start_server in servers.values(): with suppress(ImportError): diff --git a/tests/test_misc.py b/tests/test_misc.py index 8c44e7d4..bf1ca7d2 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,18 +1,18 @@ import pytest from view.core.app import App, as_app -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError def test_as_app_invalid(): - with pytest.raises(InvalidType): + with pytest.raises(InvalidTypeError): as_app(object()) # type: ignore def test_invalid_type_route(): app = App() - with pytest.raises(InvalidType): + with pytest.raises(InvalidTypeError): app.get(object()) # type: ignore - with pytest.raises(InvalidType): + with pytest.raises(InvalidTypeError): app.get("/")(object()) # type: ignore diff --git a/tests/test_requests.py b/tests/test_requests.py index a756e9db..8991806f 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -4,11 +4,11 @@ import pytest from multidict import MultiDict from view.core.app import App, as_app -from view.core.body import InvalidJSON +from view.core.body import InvalidJSONError from view.core.headers import as_multidict from view.core.request import Method, Request from view.core.response import ResponseLike -from view.core.router import DuplicateRoute +from view.core.router import DuplicateRouteError from view.core.status_codes import BadRequest from view.testing import AppTestClient, bad, into_tuple, ok @@ -296,7 +296,7 @@ async def main(): request = app.current_request() try: data = await request.json() - except InvalidJSON as error: + except InvalidJSONError as error: raise BadRequest() from error return data["test"] @@ -347,10 +347,10 @@ async def conflict() -> ResponseLike: assert (await into_tuple(client.get("/foo/bar/"))) == ok("test") assert (await into_tuple(client.get("/foo/"))) == bad(404) - with pytest.raises(DuplicateRoute): + with pytest.raises(DuplicateRouteError): app.subrouter("/foo/bar")(main) - with pytest.raises(DuplicateRoute): + with pytest.raises(DuplicateRouteError): app.get("/foo/bar")(conflict.view) with pytest.raises(RuntimeError): From 504e7860ada8a2550e53eda44228e42acf826209 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 13:56:11 -0500 Subject: [PATCH 12/32] Fix more type checking things with the linter. --- src/view/core/request.py | 2 +- src/view/dom/core.py | 6 ++++-- src/view/dom/primitives.py | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/view/core/request.py b/src/view/core/request.py index 7eee7837..77f05814 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -2,7 +2,6 @@ import sys import urllib.parse -from collections.abc import Mapping from dataclasses import dataclass, field from enum import auto from typing import TYPE_CHECKING @@ -15,6 +14,7 @@ if TYPE_CHECKING: from view.core.app import BaseApp + from collections.abc import Mapping __all__ = "Method", "Request" diff --git a/src/view/dom/core.py b/src/view/dom/core.py index 012dcf3f..841cf8ef 100644 --- a/src/view/dom/core.py +++ b/src/view/dom/core.py @@ -13,14 +13,16 @@ from dataclasses import dataclass, field from io import StringIO from queue import LifoQueue -from typing import ClassVar, ParamSpec, TypeAlias +from typing import TYPE_CHECKING, ClassVar, ParamSpec, TypeAlias from view.core.headers import as_multidict from view.core.response import Response -from view.core.router import RouteView from view.exceptions import InvalidTypeError from view.javascript import SupportsJavaScript +if TYPE_CHECKING: + from view.core.router import RouteView + __all__ = ("HTMLNode", "html_response") HTMLTree: TypeAlias = Iterator["HTMLNode"] diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index b901284a..c1afcf60 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -1,13 +1,15 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, TYPE_CHECKING from typing_extensions import NotRequired, Unpack from view.dom.core import HTMLNode from view.exceptions import InvalidTypeError +if TYPE_CHECKING: + from collections.abc import Callable + class ImplicitDefault(str): """ From 0104716ba0822828564d7ade1aa91dc5d1685da7 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:00:14 -0500 Subject: [PATCH 13/32] Add missing __future__ import. --- src/view/core/request.py | 3 ++- src/view/dom/components.py | 8 ++++++-- src/view/dom/primitives.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/view/core/request.py b/src/view/core/request.py index 77f05814..a9c8f9ef 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -13,9 +13,10 @@ from view.core.router import normalize_route if TYPE_CHECKING: - from view.core.app import BaseApp from collections.abc import Mapping + from view.core.app import BaseApp + __all__ = "Method", "Request" if sys.version_info >= (3, 11): diff --git a/src/view/dom/components.py b/src/view/dom/components.py index 56c633f5..9d602a95 100644 --- a/src/view/dom/components.py +++ b/src/view/dom/components.py @@ -1,12 +1,16 @@ -from collections.abc import Callable, Iterable +from __future__ import annotations + from dataclasses import dataclass from functools import wraps -from typing import NoReturn, ParamSpec +from typing import TYPE_CHECKING, NoReturn, ParamSpec from view.dom.core import HTMLNode, HTMLTree from view.dom.primitives import base, body, html, link, meta, script from view.dom.primitives import title as title_node +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + __all__ = "Children", "Component", "component" diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index c1afcf60..4ddeea2a 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Literal, TypedDict, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal, TypedDict from typing_extensions import NotRequired, Unpack From 1f37a0fa541c32b7ba8c4718b7facbbf0fa6af27 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:01:19 -0500 Subject: [PATCH 14/32] Add missing __slots__. --- src/view/dom/primitives.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index 4ddeea2a..f218d1ca 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -17,6 +17,8 @@ class ImplicitDefault(str): thus does not need to be included in the rendered output. """ + __slots__ = () + def _construct_node( name: str, From 77edabb9c9743e384599f628f92143f7b25466a7 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:02:33 -0500 Subject: [PATCH 15/32] Type checking shenanigans. --- src/view/run/asgi.py | 6 ++++-- src/view/testing.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index 39dc3e43..0b1344e3 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -1,14 +1,16 @@ from __future__ import annotations from collections.abc import AsyncIterator, Awaitable, Callable, Iterable -from typing import Any, Literal, TypeAlias, TypedDict +from typing import Any, Literal, TypeAlias, TypedDict, TYPE_CHECKING from typing_extensions import NotRequired -from view.core.app import BaseApp from view.core.headers import asgi_as_multidict, multidict_as_asgi from view.core.request import Method, Request, extract_query_parameters +if TYPE_CHECKING: + from view.core.app import BaseApp + __all__ = ("asgi_for_app",) diff --git a/src/view/testing.py b/src/view/testing.py index 02a99a6a..8590b3e3 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from multidict import CIMultiDict - from view.core.headers import HeadersLike, as_multidict from view.core.request import Method, Request, extract_query_parameters from view.core.status_codes import STATUS_STRINGS @@ -11,6 +9,8 @@ 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 c15b9e632aea4cd210fa7fc15cc893e04e2b21b4 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:04:27 -0500 Subject: [PATCH 16/32] More linter fixes. --- src/view/core/headers.py | 6 +++--- src/view/core/request.py | 2 +- src/view/run/asgi.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/view/core/headers.py b/src/view/core/headers.py index a98d8be8..8ecde655 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -41,10 +41,10 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: multidict = CIMultiDict[str]() for key, value in headers.items(): if isinstance(key, bytes): - key = key.decode("utf-8") + key = key.decode("utf-8") # noqa if isinstance(value, bytes): - value = value.decode("utf-8") + value = value.decode("utf-8") # noqa multidict[key] = value @@ -62,7 +62,7 @@ def wsgi_as_multidict(environ: Mapping[str, Any]) -> RequestHeaders: continue assert isinstance(value, str) - key = key.removeprefix("HTTP_").replace("_", "-").lower() + key = key.removeprefix("HTTP_").replace("_", "-").lower() # noqa headers[key] = value return headers diff --git a/src/view/core/request.py b/src/view/core/request.py index a9c8f9ef..99929f38 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -31,7 +31,7 @@ class StrEnum(str, Enum): class _UpperStrEnum(StrEnum): @staticmethod def _generate_next_value_( - name: str, start: int, count: int, last_values: list[str] + name: str, start: int, count: int, last_values: list[str] # noqa ) -> str: return name.upper() diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index 0b1344e3..e7876905 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Awaitable, Callable, Iterable -from typing import Any, Literal, TypeAlias, TypedDict, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict from typing_extensions import NotRequired From 00e241ae6762b68c534c08ccc3da9bfb84428b38 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:15:50 -0500 Subject: [PATCH 17/32] More linter stuff. --- pyproject.toml | 7 ++++++- src/view/core/body.py | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e4b22aa5..ec0bd6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,12 @@ line-length = 79 indent-width = 4 [tool.ruff.lint] -ignore = ["S101"] # We intentionally want assertions to be debug-only +ignore = [ + "S101", # We intentionally want assertions to be debug-only + "EM101", # Improves traceback readability(?), but damages code readability + "EM102", # Same as above + "TRY003", # Moves relevant messages away from where they are raised. +] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["PLC0414"] diff --git a/src/view/core/body.py b/src/view/core/body.py index c6db43e1..55008629 100644 --- a/src/view/core/body.py +++ b/src/view/core/body.py @@ -21,6 +21,9 @@ class BodyAlreadyUsedError(ViewError): times. """ + def __init__(self) -> None: + super().__init__("Body has already been consumed") + class InvalidJSONError(ViewError): """ @@ -45,7 +48,7 @@ async def body(self) -> bytes: Read the full body from the stream. """ if self.consumed: - raise BodyAlreadyUsedError("Body has already been consumed") + raise BodyAlreadyUsedError self.consumed = True @@ -81,7 +84,7 @@ async def stream_body(self) -> AsyncIterator[bytes]: in-memory at a given time. """ if self.consumed: - raise BodyAlreadyUsedError("Body has already been consumed") + raise BodyAlreadyUsedError self.consumed = True From 6982ce026829660e19f9de082b830e8024737b76 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:18:39 -0500 Subject: [PATCH 18/32] Almost there with linter fixes. --- src/view/core/app.py | 10 +++++----- src/view/core/response.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 089717ae..ecac28e1 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -197,10 +197,11 @@ async def execute_view( if isinstance(exception, HTTPError): raise logger.exception(exception) + if __debug__: - raise InternalServerError.from_current_exception() - else: - raise InternalServerError + raise InternalServerError.from_current_exception() from exception + + raise InternalServerError from exception SingleView = Callable[["Request"], ViewResult] @@ -286,8 +287,7 @@ def route(self, path: str, /, *, method: Method) -> RouteDecorator: raise InvalidTypeError(method, Method) def decorator(view: RouteView, /) -> Route: - route = self.router.push_route(view, path, method) - return route + return self.router.push_route(view, path, method) return decorator diff --git a/src/view/core/response.py b/src/view/core/response.py index 37ff657b..a2861784 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -64,8 +64,8 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: def _guess_file_type(path: StrPath, /) -> str: if sys.version_info >= (3, 13): return mimetypes.guess_file_type(path)[0] or "text/plain" - else: - return mimetypes.guess_type(path)[0] or "text/plain" + + return mimetypes.guess_type(path)[0] or "text/plain" @dataclass(slots=True) @@ -115,8 +115,8 @@ def _as_bytes(data: str | bytes) -> bytes: """ if isinstance(data, str): return data.encode("utf-8") - else: - return data + + return data @dataclass(slots=True) @@ -206,10 +206,12 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: status = response[1] headers: HeadersLike | None = None - if len(response) > 2: + # Ruff wants me to use a constant here, but I think this is clear enough + # for lengths. + if len(response) > 2: # noqa headers = response[2] - if __debug__ and len(response) > 3: + if __debug__ and len(response) > 3: # noqa raise InvalidResponseError( f"Got excess data in response tuple {response[3:]!r}" ) From 265e453033c8b84266c9186a1be692b048c7cba1 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:26:04 -0500 Subject: [PATCH 19/32] Fix remaining linter problems. --- src/view/__init__.py | 2 +- src/view/__main__.py | 2 +- src/view/core/app.py | 1 + src/view/core/request.py | 2 +- src/view/core/response.py | 17 +++++++++++------ src/view/core/status_codes.py | 7 ++++--- src/view/javascript.py | 6 +++--- src/view/run/servers.py | 3 ++- 8 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/view/__init__.py b/src/view/__init__.py index cf1822a3..c51bbb13 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -9,4 +9,4 @@ from view import run as run from view import testing as testing from view import utils as utils -from view.__about__ import * +from view.__about__ import * # noqa: F403 diff --git a/src/view/__main__.py b/src/view/__main__.py index bc2fdfce..cd9ac480 100644 --- a/src/view/__main__.py +++ b/src/view/__main__.py @@ -1,5 +1,5 @@ def main(): - print() + pass if __name__ == "__main__": diff --git a/src/view/core/app.py b/src/view/core/app.py index ecac28e1..04bc4b97 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -133,6 +133,7 @@ def run( warnings.warn( f"The app was run with {production=}, but Python's {__debug__=}", RuntimeWarning, + stacklevel=2, ) logger.info(f"Serving app on http://localhost:{port}") diff --git a/src/view/core/request.py b/src/view/core/request.py index 99929f38..1105c816 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -9,13 +9,13 @@ from multidict import MultiDict from view.core.body import BodyMixin -from view.core.headers import RequestHeaders from view.core.router import normalize_route if TYPE_CHECKING: from collections.abc import Mapping from view.core.app import BaseApp + from view.core.headers import RequestHeaders __all__ = "Method", "Request" diff --git a/src/view/core/response.py b/src/view/core/response.py index a2861784..46705e82 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -193,6 +193,7 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: f"Returned tuple {response!r} with a single item," " which is useless. Return the item directly.", RuntimeWarning, + stacklevel=2, ) return TextResponse.from_content(response[0]) @@ -226,26 +227,30 @@ def _wrap_response(response: ResponseLike, /) -> Response: logger.debug(f"Got response: {response!r}") if isinstance(response, Response): return response - elif isinstance(response, (str, bytes)): + + if isinstance(response, (str, bytes)): return TextResponse.from_content(response) - elif isinstance(response, tuple): + + if isinstance(response, tuple): return _wrap_response_tuple(response) - elif isinstance(response, AsyncGenerator): + + if isinstance(response, AsyncGenerator): async def stream() -> AsyncGenerator[bytes]: async for data in response: yield _as_bytes(data) return Response(stream, status_code=200, headers=CIMultiDict()) - elif isinstance(response, Generator): + + if isinstance(response, Generator): async def stream() -> AsyncGenerator[bytes]: for data in response: yield _as_bytes(data) return Response(stream, status_code=200, headers=CIMultiDict()) - else: - raise TypeError(f"Invalid response: {response!r}") + + raise TypeError(f"Invalid response: {response!r}") async def wrap_view_result(result: ViewResult, /) -> Response: diff --git a/src/view/core/status_codes.py b/src/view/core/status_codes.py index 47a3e1de..1f7ccf00 100644 --- a/src/view/core/status_codes.py +++ b/src/view/core/status_codes.py @@ -189,13 +189,14 @@ def __init__(self, *msg: object) -> None: super().__init__(*msg) super().add_note(HTTP_ERROR_TRACEBACK_NOTE) - def __init_subclass__(cls, ignore: bool = False) -> None: + def __init_subclass__(cls, *, ignore: bool = False) -> None: if not ignore: assert cls.status_code != 0, cls STATUS_EXCEPTIONS[cls.status_code] = cls cls.description = STATUS_STRINGS[cls.status_code] - global __all__ + # It's too much of a hassle to add an explicit __all__ with every status code. + global __all__ # noqa: PLW0603 __all__ += (cls.__name__,) def as_response(self) -> TextResponse[str]: @@ -534,7 +535,7 @@ def from_current_exception(cls) -> InternalServerError: return cls(message) -class NotImplemented(ServerSideError): +class NotImplemented(ServerSideError): # noqa: A001 """ The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that diff --git a/src/view/javascript.py b/src/view/javascript.py index fc6dd303..9634d169 100644 --- a/src/view/javascript.py +++ b/src/view/javascript.py @@ -40,9 +40,9 @@ def as_javascript_expression(data: object) -> str: if isinstance(data, bool): if data is True: return "true" - else: - assert data is False - return "false" + + assert data is False + return "false" if isinstance(data, dict): result = StringIO() diff --git a/src/view/run/servers.py b/src/view/run/servers.py index 97204835..1f01cd30 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -152,6 +152,7 @@ def run_app_on_any_server(self) -> None: except ImportError as error: raise BadServerError(f"{self.hint} is not installed") from error - for start_server in servers.values(): + # I'm not sure what Ruff is complaining about here + for start_server in servers.values(): # noqa: RET503 with suppress(ImportError): return start_server() From 2d2d7d711d645a82e5205e9884bb2d203dc9df91 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:28:58 -0500 Subject: [PATCH 20/32] Run hatch fmt. --- .pre-commit-config.yaml | 12 ++-- src/view/cache.py | 12 +++- src/view/core/app.py | 12 +++- src/view/core/body.py | 4 +- src/view/core/headers.py | 4 +- src/view/core/request.py | 9 ++- src/view/core/response.py | 8 ++- src/view/core/router.py | 20 +++++-- src/view/core/status_codes.py | 4 +- src/view/dom/components.py | 4 +- src/view/dom/core.py | 12 +++- src/view/dom/primitives.py | 100 ++++++++++++++++++++++++---------- src/view/exceptions.py | 4 +- src/view/javascript.py | 14 ++++- src/view/run/asgi.py | 12 +++- src/view/run/servers.py | 8 ++- src/view/run/wsgi.py | 4 +- src/view/testing.py | 16 ++++-- src/view/utils.py | 6 +- 19 files changed, 193 insertions(+), 72 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c4258f6..67624c5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,8 +8,12 @@ repos: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.14.10 hooks: - - id: black - language_version: python3.13 + # Run the linter. + - id: ruff-check + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/src/view/cache.py b/src/view/cache.py index 216a10b0..f3a3ac2d 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -39,7 +39,9 @@ def invalidate(self) -> None: """ @abstractmethod - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response: ... + async def __call__( + self, *args: P.args, **kwargs: P.kwargs + ) -> Response: ... @dataclass(slots=True, frozen=True) @@ -80,7 +82,9 @@ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response: self._cached_response = cached return cached.as_response() - if (time.time() - self._cached_response.last_reset) > self.reset_frequency: + if ( + time.time() - self._cached_response.last_reset + ) > self.reset_frequency: self.invalidate() return await self(*args, **kwargs) @@ -111,6 +115,8 @@ def in_memory_cache( """ def decorator_factory(function: Callable[P, T], /) -> InMemoryCache[P, T]: - return InMemoryCache(function, reset_frequency=reset_frequency or math.inf) + return InMemoryCache( + function, reset_frequency=reset_frequency or math.inf + ) return decorator_factory diff --git a/src/view/core/app.py b/src/view/core/app.py index 04bc4b97..efe0653a 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -184,7 +184,9 @@ async def _execute_view_internal( result = view(*args, **kwargs) return await wrap_view_result(result) except HTTPError as error: - logger.opt(colors=True).info(f"HTTP Error {error.status_code}") + logger.opt(colors=True).info( + f"HTTP Error {error.status_code}" + ) raise @@ -237,7 +239,9 @@ def as_app(view: SingleView, /) -> SingleViewApp: RouteDecorator: TypeAlias = Callable[[RouteView], Route] -SubRouterView: TypeAlias = Callable[[str], ResponseLike | Awaitable[ResponseLike]] +SubRouterView: TypeAlias = Callable[ + [str], ResponseLike | Awaitable[ResponseLike] +] SubRouterViewT = TypeVar("SubRouterViewT", bound=SubRouterView) @@ -359,7 +363,9 @@ def decorator(view: RouteView, /) -> RouteView: return decorator - def subrouter(self, path: str) -> Callable[[SubRouterViewT], SubRouterViewT]: + def subrouter( + self, path: str + ) -> Callable[[SubRouterViewT], SubRouterViewT]: if __debug__ and not isinstance(path, str): raise InvalidTypeError(path, str) diff --git a/src/view/core/body.py b/src/view/core/body.py index 55008629..3a39c1e2 100644 --- a/src/view/core/body.py +++ b/src/view/core/body.py @@ -71,7 +71,9 @@ async def json( try: text = data.decode("utf-8") except UnicodeDecodeError as error: - raise InvalidJSONError("Body does not contain valid UTF-8 data") from error + raise InvalidJSONError( + "Body does not contain valid UTF-8 data" + ) from error try: return parse_function(text) diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 8ecde655..a144dbd6 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -20,7 +20,9 @@ ) RequestHeaders: TypeAlias = CIMultiDict[str] -HeadersLike: TypeAlias = RequestHeaders | Mapping[str, str] | Mapping[bytes, bytes] +HeadersLike: TypeAlias = ( + RequestHeaders | Mapping[str, str] | Mapping[bytes, bytes] +) def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: diff --git a/src/view/core/request.py b/src/view/core/request.py index 1105c816..dfd35f08 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -4,7 +4,7 @@ import urllib.parse from dataclasses import dataclass, field from enum import auto -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from multidict import MultiDict @@ -31,7 +31,8 @@ class StrEnum(str, Enum): class _UpperStrEnum(StrEnum): @staticmethod def _generate_next_value_( - name: str, start: int, count: int, last_values: list[str] # noqa + name: str, + *_: Any, ) -> str: return name.upper() @@ -129,7 +130,9 @@ class Request(BodyMixin): The query string parameters of the HTTP request. """ - path_parameters: Mapping[str, str] = field(default_factory=dict, init=False) + path_parameters: Mapping[str, str] = field( + default_factory=dict, init=False + ) """ The path parameters of this request. """ diff --git a/src/view/core/response.py b/src/view/core/response.py index 46705e82..4059d66e 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -49,7 +49,9 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: # AnyStr isn't working with the type checker, probably because it's a TypeVar StrOrBytes: TypeAlias = str | bytes -_ResponseTuple: TypeAlias = tuple[StrOrBytes, int] | tuple[StrOrBytes, int, HeadersLike] +_ResponseTuple: TypeAlias = ( + tuple[StrOrBytes, int] | tuple[StrOrBytes, int, HeadersLike] +) ResponseLike: TypeAlias = ( Response | StrOrBytes @@ -217,7 +219,9 @@ def _wrap_response_tuple(response: _ResponseTuple) -> Response: f"Got excess data in response tuple {response[3:]!r}" ) - return TextResponse.from_content(content, status_code=status, headers=headers) + return TextResponse.from_content( + content, status_code=status, headers=headers + ) def _wrap_response(response: ResponseLike, /) -> Response: diff --git a/src/view/core/router.py b/src/view/core/router.py index a097fdaf..6e23b190 100644 --- a/src/view/core/router.py +++ b/src/view/core/router.py @@ -141,7 +141,9 @@ class Router: ) parent_node: PathNode = field(default_factory=lambda: PathNode(name="")) - def _get_node_for_path(self, path: str, *, allow_path_parameters: bool) -> PathNode: + def _get_node_for_path( + self, path: str, *, allow_path_parameters: bool + ) -> PathNode: if __debug__ and not isinstance(path, str): raise InvalidTypeError(path, str) @@ -153,7 +155,9 @@ def _get_node_for_path(self, path: str, *, allow_path_parameters: bool) -> PathN if is_path_parameter(part): if not allow_path_parameters: raise RuntimeError("Path parameters are not allowed here") - parent_node = parent_node.parameter(extract_path_parameter(part)) + parent_node = parent_node.parameter( + extract_path_parameter(part) + ) else: parent_node = parent_node.next(part) @@ -188,11 +192,15 @@ def push_subrouter(self, subrouter: SubRouter, path: str) -> None: node = self._get_node_for_path(path, allow_path_parameters=False) if node.subrouter is not None: - raise DuplicateRouteError(f"The route {path!r} already has a subrouter") + raise DuplicateRouteError( + f"The route {path!r} already has a subrouter" + ) node.subrouter = subrouter - def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: + def push_error( + self, error: int | type[HTTPError], view: RouteView + ) -> None: """ Register an error view with the router. """ @@ -211,7 +219,9 @@ def lookup_route(self, path: str, method: Method, /) -> FoundRoute | None: Look up the view for the route. """ path_parameters: dict[str, str] = {} - assert normalize_route(path) == path, "Request() should've normalized the route" + assert normalize_route(path) == path, ( + "Request() should've normalized the route" + ) parent_node = self.parent_node parts = path.split("/") diff --git a/src/view/core/status_codes.py b/src/view/core/status_codes.py index 1f7ccf00..3ace02f0 100644 --- a/src/view/core/status_codes.py +++ b/src/view/core/status_codes.py @@ -219,7 +219,9 @@ def status_exception(status: int) -> type[HTTPError]: try: status_type: type[HTTPError] = STATUS_EXCEPTIONS[status] except KeyError as error: - raise ValueError(f"{status} is not a valid HTTP error status code") from error + raise ValueError( + f"{status} is not a valid HTTP error status code" + ) from error return status_type diff --git a/src/view/dom/components.py b/src/view/dom/components.py index 9d602a95..17db58b7 100644 --- a/src/view/dom/components.py +++ b/src/view/dom/components.py @@ -94,7 +94,9 @@ def page( """ with html(lang=language): yield meta(charset="utf-8") - yield meta(name="viewport", content="width=device-width, initial-scale=1.0") + yield meta( + name="viewport", content="width=device-width, initial-scale=1.0" + ) if description is not None: yield meta(name="description", content=description) diff --git a/src/view/dom/core.py b/src/view/dom/core.py index 841cf8ef..c85e7ee8 100644 --- a/src/view/dom/core.py +++ b/src/view/dom/core.py @@ -42,7 +42,9 @@ class HTMLNode(SupportsJavaScript): Data class representing an HTML node in the tree. """ - node_stack: ClassVar[ContextVar[LifoQueue[HTMLNode]]] = ContextVar("node_stack") + node_stack: ClassVar[ContextVar[LifoQueue[HTMLNode]]] = ContextVar( + "node_stack" + ) node_name: str """ @@ -170,7 +172,9 @@ def html_context() -> HTMLTree: P = ParamSpec("P") HTMLViewResponseItem: TypeAlias = HTMLNode | int -HTMLViewResult = AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem] +HTMLViewResult = ( + AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem] +) HTMLView: TypeAlias = Callable[P, HTMLViewResult] @@ -210,7 +214,9 @@ async def stream() -> AsyncIterator[bytes]: yield line.encode("utf-8") + b"\n" return Response( - stream, status_code or 200, as_multidict({"content-type": "text/html"}) + stream, + status_code or 200, + as_multidict({"content-type": "text/html"}), ) return wrapper diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index f218d1ca..bf1c613e 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -28,7 +28,9 @@ def _construct_node( global_attributes: GlobalAttributes, data: dict[str, str], ) -> HTMLNode: - if __debug__ and ((child_text is not None) and not isinstance(child_text, str)): + if __debug__ and ( + (child_text is not None) and not isinstance(child_text, str) + ): raise InvalidTypeError(child_text, str) for attribute_name, value in attributes.copy().items(): @@ -527,7 +529,11 @@ def td( return _construct_node( "td", child_text=child_text, - attributes={"colspan": colspan, "rowspan": rowspan, "headers": headers}, + attributes={ + "colspan": colspan, + "rowspan": rowspan, + "headers": headers, + }, global_attributes=global_attributes, data=data or {}, ) @@ -714,7 +720,9 @@ def track( *, data: dict[str, str] | None = None, kind: ( - Literal["subtitles", "captions", "descriptions", "chapters", "metadata"] + Literal[ + "subtitles", "captions", "descriptions", "chapters", "metadata" + ] | ImplicitDefault ) = ImplicitDefault("subtitles"), src: str | None, @@ -801,9 +809,8 @@ def video( autoplay: bool = False, loop: bool = False, muted: bool = False, - preload: Literal["auto", "metadata", "none"] | ImplicitDefault = ImplicitDefault( - "auto" - ), + preload: Literal["auto", "metadata", "none"] + | ImplicitDefault = ImplicitDefault("auto"), poster: str | None = None, playsinline: bool = False, crossorigin: Literal["anonymous", "use-credentials"] | None = None, @@ -832,11 +839,16 @@ def video( def wbr( - *, data: dict[str, str] | None = None, **global_attributes: Unpack[GlobalAttributes] + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], ) -> HTMLNode: """Defines a possible line-break opportunity in text""" return _construct_node( - "wbr", attributes={}, global_attributes=global_attributes, data=data or {} + "wbr", + attributes={}, + global_attributes=global_attributes, + data=data or {}, ) @@ -934,9 +946,8 @@ def audio( autoplay: bool = False, loop: bool = False, muted: bool = False, - preload: Literal["auto", "metadata", "none"] | ImplicitDefault = ImplicitDefault( - "auto" - ), + preload: Literal["auto", "metadata", "none"] + | ImplicitDefault = ImplicitDefault("auto"), crossorigin: Literal["anonymous", "use-credentials"] | None = None, **global_attributes: Unpack[GlobalAttributes], ) -> HTMLNode: @@ -1061,11 +1072,16 @@ def body( def br( - *, data: dict[str, str] | None = None, **global_attributes: Unpack[GlobalAttributes] + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], ) -> HTMLNode: """Inserts a single line break""" return _construct_node( - "br", attributes={}, global_attributes=global_attributes, data=data or {} + "br", + attributes={}, + global_attributes=global_attributes, + data=data or {}, ) @@ -1074,9 +1090,8 @@ def button( /, *, data: dict[str, str] | None = None, - type: Literal["button", "submit", "reset"] | ImplicitDefault = ImplicitDefault( - "submit" - ), + type: Literal["button", "submit", "reset"] + | ImplicitDefault = ImplicitDefault("submit"), name: str | None = None, value: str | None = None, disabled: bool = False, @@ -1084,7 +1099,9 @@ def button( formaction: str | None = None, formenctype: ( Literal[ - "application/x-www-form-urlencoded", "multipart/form-data", "text/plain" + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", ] | None ) = None, @@ -1423,7 +1440,12 @@ def embed( """Embeds external content at the specified point in the document""" return _construct_node( "embed", - attributes={"src": src, "type": type, "width": width, "height": height}, + attributes={ + "src": src, + "type": type, + "width": width, + "height": height, + }, global_attributes=global_attributes, data=data or {}, ) @@ -1506,16 +1528,21 @@ def form( *, data: dict[str, str] | None = None, action: str | None = None, - method: Literal["get", "post", "dialog"] | ImplicitDefault = ImplicitDefault("get"), + method: Literal["get", "post", "dialog"] + | ImplicitDefault = ImplicitDefault("get"), enctype: ( Literal[ - "application/x-www-form-urlencoded", "multipart/form-data", "text/plain" + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", ] | ImplicitDefault ) = ImplicitDefault("application/x-www-form-urlencoded"), name: str | None = None, target: Literal["_blank", "_self", "_parent", "_top"] | None = None, - autocomplete: Literal["on", "off"] | ImplicitDefault = ImplicitDefault("on"), + autocomplete: Literal["on", "off"] | ImplicitDefault = ImplicitDefault( + "on" + ), novalidate: bool = False, accept_charset: str | None = None, rel: str | None = None, @@ -1695,11 +1722,16 @@ def hgroup( def hr( - *, data: dict[str, str] | None = None, **global_attributes: Unpack[GlobalAttributes] + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], ) -> HTMLNode: """Defines a thematic break or horizontal rule in content""" return _construct_node( - "hr", attributes={}, global_attributes=global_attributes, data=data or {} + "hr", + attributes={}, + global_attributes=global_attributes, + data=data or {}, ) @@ -1764,7 +1796,9 @@ def iframe( ] | None ) = None, - loading: Literal["eager", "lazy"] | ImplicitDefault = ImplicitDefault("eager"), + loading: Literal["eager", "lazy"] | ImplicitDefault = ImplicitDefault( + "eager" + ), **global_attributes: Unpack[GlobalAttributes], ) -> HTMLNode: """Embeds another HTML page within the current page""" @@ -1800,10 +1834,11 @@ def img( crossorigin: Literal["anonymous", "use-credentials"] | None = None, usemap: str | None = None, ismap: bool = False, - loading: Literal["eager", "lazy"] | ImplicitDefault = ImplicitDefault("eager"), - decoding: Literal["sync", "async", "auto"] | ImplicitDefault = ImplicitDefault( - "auto" + loading: Literal["eager", "lazy"] | ImplicitDefault = ImplicitDefault( + "eager" ), + decoding: Literal["sync", "async", "auto"] + | ImplicitDefault = ImplicitDefault("auto"), referrerpolicy: ( Literal[ "no-referrer", @@ -1914,7 +1949,9 @@ def input( formaction: str | None = None, formenctype: ( Literal[ - "application/x-www-form-urlencoded", "multipart/form-data", "text/plain" + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", ] | None ) = None, @@ -2181,7 +2218,12 @@ def meta( content: str | None = None, charset: str | None = None, http_equiv: ( - Literal["content-security-policy", "content-type", "default-style", "refresh"] + Literal[ + "content-security-policy", + "content-type", + "default-style", + "refresh", + ] | None ) = None, property: str | None = None, diff --git a/src/view/exceptions.py b/src/view/exceptions.py index ab42b59d..fe610a49 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -27,5 +27,7 @@ class InvalidTypeError(ViewError, TypeError): """ def __init__(self, got: Any, *expected: type) -> None: - expected_string = ", ".join([exception.__name__ for exception in expected]) + expected_string = ", ".join( + [exception.__name__ for exception in expected] + ) super().__init__(f"Expected {expected_string}, but got {got!r}") diff --git a/src/view/javascript.py b/src/view/javascript.py index 9634d169..4594f615 100644 --- a/src/view/javascript.py +++ b/src/view/javascript.py @@ -8,7 +8,11 @@ from view.exceptions import InvalidTypeError -__all__ = "SupportsJavaScript", "as_javascript_expression", "javascript_compiler" +__all__ = ( + "SupportsJavaScript", + "as_javascript_expression", + "javascript_compiler", +) P = ParamSpec("P") @@ -59,10 +63,14 @@ def as_javascript_expression(data: object) -> str: if __debug__ and not isinstance(result, str): raise InvalidTypeError(result, str) - raise TypeError(f"Don't know how to convert {data!r} to a JavaScript expression") + raise TypeError( + f"Don't know how to convert {data!r} to a JavaScript expression" + ) -def javascript_compiler(function: Callable[P, Iterator[str]]) -> Callable[P, str]: +def javascript_compiler( + function: Callable[P, Iterator[str]], +) -> Callable[P, str]: """ Decorator that converts a function yielding lines of JavaScript code into a function that returns the entire source code. diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index e7876905..530adf72 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -89,7 +89,9 @@ async def receive_data() -> AsyncIterator[bytes]: more_body = data.get("more_body", False) parameters = extract_query_parameters(scope["query_string"]) - request = Request(receive_data, app, scope["path"], method, headers, parameters) + request = Request( + receive_data, app, scope["path"], method, headers, parameters + ) response = await app.process_request(request) await send( @@ -100,8 +102,12 @@ async def receive_data() -> AsyncIterator[bytes]: } ) async for data in response.stream_body(): - await send({"type": "http.response.body", "body": data, "more_body": True}) + await send( + {"type": "http.response.body", "body": data, "more_body": True} + ) - await send({"type": "http.response.body", "body": b"", "more_body": False}) + await send( + {"type": "http.response.body", "body": b"", "more_body": False} + ) return asgi diff --git a/src/view/run/servers.py b/src/view/run/servers.py index 1f01cd30..22b569c5 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -104,7 +104,9 @@ def load_config(self): def load(self): return self.application - runner = GunicornRunner(self.app.wsgi(), {"bind": f"{self.host}:{self.port}"}) + runner = GunicornRunner( + self.app.wsgi(), {"bind": f"{self.host}:{self.port}"} + ) runner.run() def run_werkzeug(self) -> None: @@ -150,7 +152,9 @@ def run_app_on_any_server(self) -> None: try: return start_server() except ImportError as error: - raise BadServerError(f"{self.hint} is not installed") from error + raise BadServerError( + f"{self.hint} is not installed" + ) from error # I'm not sure what Ruff is complaining about here for start_server in servers.values(): # noqa: RET503 diff --git a/src/view/run/wsgi.py b/src/view/run/wsgi.py index d3431572..7e3e8dcf 100644 --- a/src/view/run/wsgi.py +++ b/src/view/run/wsgi.py @@ -63,7 +63,9 @@ async def stream(): wsgi_headers.append((str(key), value)) # WSGI is such a weird spec - status_str = f"{response.status_code} {STATUS_STRINGS[response.status_code]}" + status_str = ( + f"{response.status_code} {STATUS_STRINGS[response.status_code]}" + ) start_response(status_str, wsgi_headers) return [loop.run_until_complete(response.body())] diff --git a/src/view/testing.py b/src/view/testing.py index 8590b3e3..5d8e47fb 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -88,7 +88,9 @@ async def get( headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.GET, headers=headers, body=body) + return await self.request( + route, method=Method.GET, headers=headers, body=body + ) async def post( self, @@ -97,7 +99,9 @@ async def post( headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.POST, headers=headers, body=body) + return await self.request( + route, method=Method.POST, headers=headers, body=body + ) async def put( self, @@ -106,7 +110,9 @@ async def put( headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.PUT, headers=headers, body=body) + return await self.request( + route, method=Method.PUT, headers=headers, body=body + ) async def patch( self, @@ -170,4 +176,6 @@ async def head( headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.HEAD, headers=headers, body=body) + return await self.request( + route, method=Method.HEAD, headers=headers, body=body + ) diff --git a/src/view/utils.py b/src/view/utils.py index c91993e5..1fb1c10d 100644 --- a/src/view/utils.py +++ b/src/view/utils.py @@ -12,7 +12,8 @@ @contextmanager def reraise( - new_exception: type[BaseException] | BaseException, *exceptions: type[BaseException] + new_exception: type[BaseException] | BaseException, + *exceptions: type[BaseException], ) -> Iterator[None]: """ Context manager to reraise one or many exceptions as a single exception. @@ -33,7 +34,8 @@ def reraise( def reraises( - new_exception: type[BaseException] | BaseException, *exceptions: type[BaseException] + new_exception: type[BaseException] | BaseException, + *exceptions: type[BaseException], ) -> Callable[[Callable[P, T]], Callable[P, T]]: """ Decorator to reraise one or many exceptions as a single exception for an From b062e1904bf57db224a027c955a5252555bb7612 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:33:35 -0500 Subject: [PATCH 21/32] Add a CI check for linting. --- .github/workflows/build.yml | 4 ++-- .github/workflows/lint.yml | 26 ++++++++++++++++++++++++++ .github/workflows/tests.yml | 4 ++-- 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1dcd6d8..cf8f16e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,12 +5,12 @@ on: tags: - v* branches: - - master + - main paths: - "src/**" pull_request: branches: - - master + - main concurrency: group: build-${{ github.head_ref }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..c27c464a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + pull_request: + branches: + - main + +concurrency: + group: build-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint source code + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Hatch + run: pip install hatch + + - name: Run linter + run: hatch fmt -l diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 939b7667..8c1d9d6a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,10 +3,10 @@ name: Tests on: push: branches: - - master + - main pull_request: branches: - - master + - main concurrency: group: test-${{ github.head_ref }} From f4f52d56ef2bf9d81c91e1420fd2e6286c9222ca Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:36:11 -0500 Subject: [PATCH 22/32] Fix lint I think. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ec0bd6dc..6ffa5434 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,3 +52,4 @@ ignore = [ "__init__.py" = ["PLC0414"] "status_codes.py" = ["N818"] "primitives.py" = ["A001", "A002", "B008"] +"servers.py" = ["PLC0415"] From 60050743ab0a6a3fe80993564b7a0dad10fbc6ef Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:38:23 -0500 Subject: [PATCH 23/32] Don't run tests in parallel. --- hatch.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/hatch.toml b/hatch.toml index ee1c620a..fcfec678 100644 --- a/hatch.toml +++ b/hatch.toml @@ -21,6 +21,7 @@ extra-dependencies = [ randomize = true retries = 3 retries-delay = 1 +parallel = false [[envs.hatch-test.matrix]] python = ["3.14", "3.13", "3.12", "3.11", "3.10"] From 174fe55db75af5447c13c5a8a14306406854ae5c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:40:26 -0500 Subject: [PATCH 24/32] Really fix lint I think. --- pyproject.toml | 1 + src/view/run/servers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6ffa5434..e10bcefc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ ignore = [ "EM101", # Improves traceback readability(?), but damages code readability "EM102", # Same as above "TRY003", # Moves relevant messages away from where they are raised. + "PLC0415", # This is generally done to avoid circular imports. ] [tool.ruff.lint.per-file-ignores] diff --git a/src/view/run/servers.py b/src/view/run/servers.py index 22b569c5..9dc4d97c 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -159,4 +159,4 @@ def run_app_on_any_server(self) -> None: # I'm not sure what Ruff is complaining about here for start_server in servers.values(): # noqa: RET503 with suppress(ImportError): - return start_server() + return start_server() # noqa: RET503 From 5cb03caebda055635a4ff98bfa8af1c082f66b92 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:48:20 -0500 Subject: [PATCH 25/32] Increase wait time for detached server tests. --- tests/test_servers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_servers.py b/tests/test_servers.py index bc31da78..2126ea34 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -30,7 +30,7 @@ async def index(): app.run(server_hint={server_name!r}) """ process = subprocess.Popen([sys.executable, "-c", code]) - time.sleep(0.5) + time.sleep(2) response = requests.get("http://localhost:5000") assert response.text == "ok" process.kill() @@ -52,7 +52,7 @@ def app(request: Request) -> ResponseLike: process = app.run_detached(server_hint=server_name) try: - time.sleep(0.5) + time.sleep(2) response = requests.get("http://localhost:5000", headers={"test": "silly"}) assert response.text == "test" assert response.status_code == 201 From 2bff0d6dd90df9fff4052f3c2bdd8c51c71a748f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:51:46 -0500 Subject: [PATCH 26/32] Stupid Ruff. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e10bcefc..d18f7e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,4 +53,4 @@ ignore = [ "__init__.py" = ["PLC0414"] "status_codes.py" = ["N818"] "primitives.py" = ["A001", "A002", "B008"] -"servers.py" = ["PLC0415"] +"servers.py" = ["PLC0415", "RET503"] From be4b2bb3720cbf4a4a0d9bd6b5f7b99da015b12c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 14:56:24 -0500 Subject: [PATCH 27/32] Skip Gunicorn on non-Linux. --- tests/test_servers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_servers.py b/tests/test_servers.py index 2126ea34..95c74a8b 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -1,6 +1,7 @@ import subprocess import sys import time +import os import pytest import requests @@ -13,6 +14,9 @@ @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) def test_run_server(server_name: str): + if (server_name == "gunicorn") and (os.name != "posix"): + pytest.skip("gunicorn seems to have issues on non-linux") + try: __import__(server_name) except ImportError: From 131ee2858316f8de244c1eeee3e5a44897418e90 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 15:00:08 -0500 Subject: [PATCH 28/32] Skip the whole test on non-Linux. --- tests/test_servers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_servers.py b/tests/test_servers.py index 95c74a8b..22eddc73 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -13,10 +13,8 @@ @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) +@pytest.mark.skip(os.name != "posix", "this has issues on non-Linux") def test_run_server(server_name: str): - if (server_name == "gunicorn") and (os.name != "posix"): - pytest.skip("gunicorn seems to have issues on non-linux") - try: __import__(server_name) except ImportError: From 80fc58e7ccc891bb3587ffcac81dc1296f2e24c4 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 15:02:42 -0500 Subject: [PATCH 29/32] :( --- tests/test_servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_servers.py b/tests/test_servers.py index 22eddc73..c496bb70 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -13,7 +13,7 @@ @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) -@pytest.mark.skip(os.name != "posix", "this has issues on non-Linux") +@pytest.mark.skipif(os.name != "posix", "this has issues on non-Linux") def test_run_server(server_name: str): try: __import__(server_name) From c517e4f3ef05bbcdf463c3130de108f2eb86ef0e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 15:03:06 -0500 Subject: [PATCH 30/32] :((( --- tests/test_servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_servers.py b/tests/test_servers.py index c496bb70..35d120ee 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -13,7 +13,7 @@ @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) -@pytest.mark.skipif(os.name != "posix", "this has issues on non-Linux") +@pytest.mark.skipif(os.name != "posix", reason="this has issues on non-Linux") def test_run_server(server_name: str): try: __import__(server_name) From aad3442d410060bc8b75ead05b8430c8f043bdf9 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 15:06:03 -0500 Subject: [PATCH 31/32] I love asyncio errors so much. --- tests/test_servers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_servers.py b/tests/test_servers.py index 35d120ee..2eaa24f9 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -1,7 +1,7 @@ import subprocess import sys import time -import os +import platform import pytest import requests @@ -13,7 +13,7 @@ @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) -@pytest.mark.skipif(os.name != "posix", reason="this has issues on non-Linux") +@pytest.mark.skipif(platform.system() != "Linux", reason="this has issues on non-Linux") def test_run_server(server_name: str): try: __import__(server_name) From ea0b1a7d7dc0d96f1d0f1175b59413bbb1d4768c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 31 Dec 2025 16:45:25 -0500 Subject: [PATCH 32/32] Remove PRs from build CI. --- .github/workflows/build.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf8f16e5..e80daa01 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,9 +8,6 @@ on: - main paths: - "src/**" - pull_request: - branches: - - main concurrency: group: build-${{ github.head_ref }}