From ce294fe04836418c4ad705c194063f2801ad011a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 18:27:07 -0500 Subject: [PATCH 01/20] Remove dependency on aiofiles. --- pyproject.toml | 2 +- src/view/core/body.py | 43 +++++++++++++++++------------------- src/view/core/response.py | 46 +++++++++++++++++++++++++-------------- src/view/dom/core.py | 2 +- src/view/run/asgi.py | 2 +- src/view/run/wsgi.py | 2 +- src/view/testing.py | 2 +- tests/test_responses.py | 2 +- 8 files changed, 56 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ba895565..4c49953b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = ["loguru~=0.7", "aiofiles~=24.1", "typing_extensions>=4"] +dependencies = ["loguru~=0.7", "typing_extensions>=4"] dynamic = ["version", "license"] [project.optional-dependencies] diff --git a/src/view/core/body.py b/src/view/core/body.py index 3a39c1e2..f2c8ef78 100644 --- a/src/view/core/body.py +++ b/src/view/core/body.py @@ -10,7 +10,7 @@ __all__ = ("BodyMixin",) -BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] +BodyStream: TypeAlias = AsyncIterator[bytes] class BodyAlreadyUsedError(ViewError): @@ -21,8 +21,8 @@ class BodyAlreadyUsedError(ViewError): times. """ - def __init__(self) -> None: - super().__init__("Body has already been consumed") + def __init__(self, receive_data: BodyStream) -> None: + super().__init__(f"Body {receive_data!r} has already been consumed") class InvalidJSONError(ViewError): @@ -43,19 +43,31 @@ class BodyMixin: receive_data: BodyStream consumed: bool = field(init=False, default=False) - async def body(self) -> bytes: + async def stream_body(self) -> AsyncIterator[bytes]: """ - Read the full body from the stream. + Incrementally stream the body without keeping the whole thing + in-memory at a given time. """ + if __debug__ and not isinstance(self.receive_data, AsyncIterator): + raise InvalidTypeError(self.receive_data, AsyncIterator) + if self.consumed: - raise BodyAlreadyUsedError + raise BodyAlreadyUsedError(self.receive_data) self.consumed = True - buffer = BytesIO() - async for data in self.receive_data(): + async for data in self.receive_data: if __debug__ and not isinstance(data, bytes): raise InvalidTypeError(data, bytes) + yield data + + async def body(self) -> bytes: + """ + Read the full body from the stream. + """ + + buffer = BytesIO() + async for data in self.stream_body(): buffer.write(data) return buffer.getvalue() @@ -79,18 +91,3 @@ async def json( return parse_function(text) except Exception as error: raise InvalidJSONError("Failed to parse JSON") from error - - async def stream_body(self) -> AsyncIterator[bytes]: - """ - Incrementally stream the body, not keeping the whole thing - in-memory at a given time. - """ - if self.consumed: - raise BodyAlreadyUsedError - - self.consumed = True - - async for data in self.receive_data(): - if __debug__ and not isinstance(data, bytes): - raise InvalidTypeError(data, bytes) - yield data diff --git a/src/view/core/response.py b/src/view/core/response.py index 3d0bbf2e..a20d9942 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -4,13 +4,19 @@ import mimetypes import sys import warnings -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Awaitable, + Callable, + Generator, +) from dataclasses import dataclass from os import PathLike from typing import Any, AnyStr, Generic, TypeAlias -import aiofiles from loguru import logger +import asyncio from view.core.body import BodyMixin from view.core.headers import ( @@ -74,6 +80,17 @@ def _guess_file_type(path: StrPath, /) -> str: return mimetypes.guess_type(path)[0] or "text/plain" +async def _read_stream( + path: StrPath, *, chunk_size: int +) -> AsyncIterator[bytes]: + file = await asyncio.to_thread(open, path, "rb") + length = chunk_size + while length == chunk_size: + data = await asyncio.to_thread(file.read, chunk_size) + length = len(data) + yield data + + @dataclass(slots=True) class FileResponse(Response): """ @@ -90,7 +107,7 @@ def from_file( *, status_code: int = 200, headers: HeadersLike | None = None, - chunk_size: int = 512, + chunk_size: int = 512, # This probably needs tuning content_type: str | None = None, ) -> FileResponse: """ @@ -99,14 +116,6 @@ def from_file( if __debug__ and not isinstance(chunk_size, int): raise InvalidTypeError(chunk_size, int) - async def stream(): - async with aiofiles.open(path, "rb") as file: - length = chunk_size - while length == chunk_size: - data = await file.read(chunk_size) - length = len(data) - yield data - multi_map = as_real_headers(headers) if "content-type" not in multi_map: content_type = content_type or _guess_file_type(path) @@ -114,7 +123,12 @@ async def stream(): LowerStr("content-type"), content_type ) - return cls(stream, status_code, multi_map, path) + return cls( + _read_stream(path, chunk_size=chunk_size), + status_code, + multi_map, + path, + ) def _as_bytes(data: str | bytes) -> bytes: @@ -155,7 +169,7 @@ def from_content( async def stream() -> AsyncGenerator[bytes]: yield _as_bytes(content) - return cls(stream, status_code, as_real_headers(headers), content) + return cls(stream(), status_code, as_real_headers(headers), content) @dataclass(slots=True) @@ -182,7 +196,7 @@ async def stream() -> AsyncGenerator[bytes]: parsed_data=data, headers=as_real_headers(headers), status_code=status_code, - receive_data=stream, + receive_data=stream(), ) @@ -251,7 +265,7 @@ async def stream() -> AsyncGenerator[bytes]: async for data in response: yield _as_bytes(data) - return Response(stream, status_code=200, headers=HTTPHeaders()) + return Response(stream(), status_code=200, headers=HTTPHeaders()) if isinstance(response, Generator): @@ -259,7 +273,7 @@ async def stream() -> AsyncGenerator[bytes]: for data in response: yield _as_bytes(data) - return Response(stream, status_code=200, headers=HTTPHeaders()) + return Response(stream(), status_code=200, headers=HTTPHeaders()) raise TypeError(f"Invalid response: {response!r}") diff --git a/src/view/dom/core.py b/src/view/dom/core.py index f4b2f437..f201b457 100644 --- a/src/view/dom/core.py +++ b/src/view/dom/core.py @@ -215,7 +215,7 @@ async def stream() -> AsyncIterator[bytes]: yield line.encode("utf-8") + b"\n" return Response( - stream, + stream(), status_code or 200, as_real_headers({"content-type": "text/html"}), ) diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index 012419da..da9ac6ee 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -93,7 +93,7 @@ async def receive_data() -> AsyncIterator[bytes]: parameters = extract_query_parameters(scope["query_string"]) request = Request( - receive_data, app, scope["path"], method, headers, parameters + receive_data(), app, scope["path"], method, headers, parameters ) response = await app.process_request(request) diff --git a/src/view/run/wsgi.py b/src/view/run/wsgi.py index 5ccfaa1c..1b581f65 100644 --- a/src/view/run/wsgi.py +++ b/src/view/run/wsgi.py @@ -57,7 +57,7 @@ async def stream(): assert isinstance(path, str) headers = wsgi_to_headers(environ) parameters = extract_query_parameters(environ["QUERY_STRING"]) - request = Request(stream, app, path, method, headers, parameters) + request = Request(stream(), app, path, method, headers, parameters) response = loop.run_until_complete(app.process_request(request)) wsgi_headers: WSGIHeaders = headers_to_wsgi(response.headers) diff --git a/src/view/testing.py b/src/view/testing.py index 24345690..5410b8ea 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -71,7 +71,7 @@ async def stream() -> AsyncGenerator[bytes]: path, _, query_string = route.partition("?") request_data = Request( - receive_data=stream, + receive_data=stream(), app=self.app, path=path, method=method, diff --git a/tests/test_responses.py b/tests/test_responses.py index 120b0ba4..d9337fbf 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -47,7 +47,7 @@ async def stream(): yield b"Test" return Response( - receive_data=stream, + receive_data=stream(), status_code=Success.CREATED, headers=as_real_headers({"hello": "world"}), ) From bb02a83ccd65c06e8e59d5655d398a1693411282 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 18:28:59 -0500 Subject: [PATCH 02/20] Run formatter. --- 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 a20d9942..83050cb5 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import mimetypes import sys @@ -16,7 +17,6 @@ from typing import Any, AnyStr, Generic, TypeAlias from loguru import logger -import asyncio from view.core.body import BodyMixin from view.core.headers import ( From 679a013f9ec5ad8c011b75d3ef728938296c470f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 19:10:47 -0500 Subject: [PATCH 03/20] Initial implementation of our own logger. --- src/view/logs.py | 104 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/view/logs.py diff --git a/src/view/logs.py b/src/view/logs.py new file mode 100644 index 00000000..f44f8209 --- /dev/null +++ b/src/view/logs.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Final, ClassVar, Self +from contextvars import ContextVar, Token + + +@dataclass(slots=True, frozen=True) +class LogLevel: + """ + An arbitrary log level. + """ + + name: str + level: int + + def __gt__(self, other: object) -> bool: + if not isinstance(other, LogLevel): + return NotImplemented + + return self.level > other.level + + def __lt__(self, other: object) -> bool: + if not isinstance(other, LogLevel): + return NotImplemented + + return self.level < other.level + + +DEBUG: Final[LogLevel] = LogLevel("debug", 0) +INFO: Final[LogLevel] = LogLevel("info", 100) +WARNING: Final[LogLevel] = LogLevel("info", 1000) +CRITICAL: Final[LogLevel] = LogLevel("critical", 10_000) + + +@dataclass(slots=True) +class Logger: + """ + An independent logger for the current context. + """ + + current_logger: ClassVar[ContextVar[Logger]] = ContextVar("current_logger") + + current_level: LogLevel = field(default=INFO) + reset_token: Token[Logger] | None = field(default=None) + + def shut_up(self) -> None: + pass + + def message( + self, level: LogLevel, *objects: object, **data: object + ) -> None: + """ + Output a log message with an arbitrary log level. + """ + if level > self.current_level: + return + + objects_list = [str(item) for item in objects] + for name, value in data.items(): + objects_list.append(f"{name}={value}") + + message = " ".join(objects_list) + print(f"{level.name}: {message}") + + def debug(self, *message: object, **data: object) -> None: + """ + Output a debug message. + """ + self.message(DEBUG, *message, **data) + + def info(self, *message: object, **data: object) -> None: + """ + Output an informative message. + """ + self.message(INFO, *message, **data) + + def warning(self, *message: object, **data: object) -> None: + """ + Output an "unfixable" warning (a warning that wasn't the fault of the + user). + """ + self.message(WARNING, *message, **data) + + def critical(self, *message: object, **data: object) -> None: + """ + Output a critical message. + """ + self.message(CRITICAL, *message, **data) + + @classmethod + def current(cls) -> Logger: + """ + Get the logger for the current context. This raises an exception if + no logger is set. + """ + return cls.current_logger.get() + + def __enter__(self) -> Self: + self.reset_token = self.current_logger.set(self) + return self + + def __exit__(self, *_: Any) -> None: + assert self.reset_token is not None + self.current_logger.reset(self.reset_token) From 630c68f06f43e083a9cf7c7d06db53a8537a359f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 19:15:12 -0500 Subject: [PATCH 04/20] Rename "logs.py" to "logger.py" --- src/view/{logs.py => logger.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/view/{logs.py => logger.py} (100%) diff --git a/src/view/logs.py b/src/view/logger.py similarity index 100% rename from src/view/logs.py rename to src/view/logger.py From 13b234ad051834a5a3e5c1169b6761c92f3ac47f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 19:21:44 -0500 Subject: [PATCH 05/20] Add a dedicated responses module. --- src/view/core/app.py | 2 +- src/view/core/response.py | 97 +-------------------------------- src/view/logger.py | 7 ++- src/view/responses.py | 112 ++++++++++++++++++++++++++++++++++++++ tests/test_responses.py | 3 +- 5 files changed, 120 insertions(+), 101 deletions(-) create mode 100644 src/view/responses.py diff --git a/src/view/core/app.py b/src/view/core/app.py index 5962808b..62170b83 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -13,7 +13,6 @@ from view.core.request import Method, Request from view.core.response import ( - FileResponse, Response, ResponseLike, ViewResult, @@ -27,6 +26,7 @@ NotFound, ) from view.exceptions import InvalidTypeError +from view.responses import FileResponse from view.utils import reraise if TYPE_CHECKING: diff --git a/src/view/core/response.py b/src/view/core/response.py index 83050cb5..3d5c43af 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -1,20 +1,13 @@ from __future__ import annotations -import asyncio -import json -import mimetypes -import sys import warnings from collections.abc import ( AsyncGenerator, - AsyncIterator, Awaitable, - Callable, Generator, ) from dataclasses import dataclass -from os import PathLike -from typing import Any, AnyStr, Generic, TypeAlias +from typing import AnyStr, Generic, TypeAlias from loguru import logger @@ -22,7 +15,6 @@ from view.core.headers import ( HeadersLike, HTTPHeaders, - LowerStr, as_real_headers, ) from view.exceptions import InvalidTypeError, ViewError @@ -70,65 +62,6 @@ async def as_tuple(self) -> tuple[bytes, int, HTTPHeaders]: | _ResponseTuple ) ViewResult = ResponseLike | Awaitable[ResponseLike] -StrPath: TypeAlias = str | PathLike[str] - - -def _guess_file_type(path: StrPath, /) -> str: - if sys.version_info >= (3, 13): - return mimetypes.guess_file_type(path)[0] or "text/plain" - - return mimetypes.guess_type(path)[0] or "text/plain" - - -async def _read_stream( - path: StrPath, *, chunk_size: int -) -> AsyncIterator[bytes]: - file = await asyncio.to_thread(open, path, "rb") - length = chunk_size - while length == chunk_size: - data = await asyncio.to_thread(file.read, chunk_size) - length = len(data) - yield data - - -@dataclass(slots=True) -class FileResponse(Response): - """ - Response containing a file, streamed asynchronously. - """ - - path: StrPath - - @classmethod - def from_file( - cls, - path: StrPath, - /, - *, - status_code: int = 200, - headers: HeadersLike | None = None, - chunk_size: int = 512, # This probably needs tuning - content_type: str | None = None, - ) -> FileResponse: - """ - Generate a :class:`FileResponse` from a file path. - """ - if __debug__ and not isinstance(chunk_size, int): - raise InvalidTypeError(chunk_size, int) - - multi_map = as_real_headers(headers) - if "content-type" not in multi_map: - content_type = content_type or _guess_file_type(path) - multi_map = multi_map.with_new_value( - LowerStr("content-type"), content_type - ) - - return cls( - _read_stream(path, chunk_size=chunk_size), - status_code, - multi_map, - path, - ) def _as_bytes(data: str | bytes) -> bytes: @@ -172,34 +105,6 @@ async def stream() -> AsyncGenerator[bytes]: return cls(stream(), status_code, as_real_headers(headers), content) -@dataclass(slots=True) -class JSONResponse(Response): - content: dict[str, Any] - parsed_data: str - - @classmethod - def from_content( - cls, - content: dict[str, Any], - *, - parse_function: Callable[[dict[str, Any]], str] = json.dumps, - status_code: int = 200, - headers: HeadersLike | None = None, - ) -> JSONResponse: - data = parse_function(content) - - async def stream() -> AsyncGenerator[bytes]: - yield data.encode("utf-8") - - return cls( - content=content, - parsed_data=data, - headers=as_real_headers(headers), - status_code=status_code, - receive_data=stream(), - ) - - class InvalidResponseError(ViewError): """ A view returned an object that view.py doesn't know how to convert into a diff --git a/src/view/logger.py b/src/view/logger.py index f44f8209..41e2c8c8 100644 --- a/src/view/logger.py +++ b/src/view/logger.py @@ -1,7 +1,8 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Final, ClassVar, Self + from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from typing import ClassVar, Final, Self @dataclass(slots=True, frozen=True) @@ -99,6 +100,6 @@ def __enter__(self) -> Self: self.reset_token = self.current_logger.set(self) return self - def __exit__(self, *_: Any) -> None: + def __exit__(self, *_: object) -> None: assert self.reset_token is not None self.current_logger.reset(self.reset_token) diff --git a/src/view/responses.py b/src/view/responses.py new file mode 100644 index 00000000..c9bf8eba --- /dev/null +++ b/src/view/responses.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import mimetypes +import sys +from os import PathLike +from typing import TYPE_CHECKING, Any, TypeAlias + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Callable + +import asyncio +import json +from dataclasses import dataclass + +from view.core.headers import HeadersLike, LowerStr, as_real_headers +from view.core.response import Response +from view.core.response import TextResponse as TextResponse # noqa: PLC0414 +from view.exceptions import InvalidTypeError + +__all__ = "FileResponse", "JSONResponse", "TextResponse" + +StrPath: TypeAlias = str | PathLike[str] + + +def _guess_file_type(path: StrPath, /) -> str: + if sys.version_info >= (3, 13): + return mimetypes.guess_file_type(path)[0] or "text/plain" + + return mimetypes.guess_type(path)[0] or "text/plain" + + +async def _read_stream( + path: StrPath, *, chunk_size: int +) -> AsyncIterator[bytes]: + file = await asyncio.to_thread(open, path, "rb") + length = chunk_size + while length == chunk_size: + data = await asyncio.to_thread(file.read, chunk_size) + length = len(data) + yield data + + +@dataclass(slots=True) +class FileResponse(Response): + """ + Response containing a file, streamed asynchronously. + """ + + path: StrPath + + @classmethod + def from_file( + cls, + path: StrPath, + /, + *, + status_code: int = 200, + headers: HeadersLike | None = None, + chunk_size: int = 512, # This probably needs tuning + content_type: str | None = None, + ) -> FileResponse: + """ + Generate a :class:`FileResponse` from a file path. + """ + if __debug__ and not isinstance(chunk_size, int): + raise InvalidTypeError(chunk_size, int) + + multi_map = as_real_headers(headers) + if "content-type" not in multi_map: + content_type = content_type or _guess_file_type(path) + multi_map = multi_map.with_new_value( + LowerStr("content-type"), content_type + ) + + return cls( + _read_stream(path, chunk_size=chunk_size), + status_code, + multi_map, + path, + ) + + +@dataclass(slots=True) +class JSONResponse(Response): + """ + Response containing JSON data. + """ + + content: dict[str, Any] + parsed_data: str + + @classmethod + def from_content( + cls, + content: dict[str, Any], + *, + parse_function: Callable[[dict[str, Any]], str] = json.dumps, + status_code: int = 200, + headers: HeadersLike | None = None, + ) -> JSONResponse: + data = parse_function(content) + + async def stream() -> AsyncGenerator[bytes]: + yield data.encode("utf-8") + + return cls( + content=content, + parsed_data=data, + headers=as_real_headers(headers), + status_code=status_code, + receive_data=stream(), + ) diff --git a/tests/test_responses.py b/tests/test_responses.py index d9337fbf..8afc0ad2 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -6,7 +6,7 @@ from view.core.app import App, as_app 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.response import Response, ResponseLike from view.core.status_codes import ( STATUS_EXCEPTIONS, STATUS_STRINGS, @@ -14,6 +14,7 @@ HTTPError, Success, ) +from view.responses import JSONResponse, FileResponse from view.testing import AppTestClient, bad, into_tuple, ok From 41dfed456705c4704355c39a75dec4082e862579 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 19:38:07 -0500 Subject: [PATCH 06/20] Improve message processing system in the logger. --- src/view/logger.py | 60 +++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/view/logger.py b/src/view/logger.py index 41e2c8c8..dcd639b1 100644 --- a/src/view/logger.py +++ b/src/view/logger.py @@ -2,7 +2,11 @@ from contextvars import ContextVar, Token from dataclasses import dataclass, field -from typing import ClassVar, Final, Self +from typing import ClassVar, Final, Self, TYPE_CHECKING +import time + +if TYPE_CHECKING: + from collections.abc import Sequence, Mapping @dataclass(slots=True, frozen=True) @@ -33,6 +37,22 @@ def __lt__(self, other: object) -> bool: CRITICAL: Final[LogLevel] = LogLevel("critical", 10_000) +@dataclass(slots=True) +class Message: + level: LogLevel + objects: Sequence[object] + named_objects: Mapping[str, object] + timestamp: float = field(default_factory=time.time) + + def as_string(self) -> str: + objects_list = [str(item) for item in self.objects] + for name, value in self.named_objects.items(): + objects_list.append(f"{name}={value}") + + message = " ".join(objects_list) + return message + + @dataclass(slots=True) class Logger: """ @@ -41,52 +61,48 @@ class Logger: current_logger: ClassVar[ContextVar[Logger]] = ContextVar("current_logger") - current_level: LogLevel = field(default=INFO) - reset_token: Token[Logger] | None = field(default=None) + current_level: LogLevel = INFO + quiet: bool = field(default=False, init=False) + reset_token: Token[Logger] | None = field( + default=None, repr=False, init=False + ) def shut_up(self) -> None: - pass + self.quiet = True - def message( - self, level: LogLevel, *objects: object, **data: object - ) -> None: + def process_message(self, message: Message) -> None: """ Output a log message with an arbitrary log level. """ - if level > self.current_level: + if self.quiet or (message.level > self.current_level): return - objects_list = [str(item) for item in objects] - for name, value in data.items(): - objects_list.append(f"{name}={value}") - - message = " ".join(objects_list) - print(f"{level.name}: {message}") + print(message) - def debug(self, *message: object, **data: object) -> None: + def debug(self, *objects: object, **named_objects: object) -> None: """ Output a debug message. """ - self.message(DEBUG, *message, **data) + self.process_message(Message(DEBUG, objects, named_objects)) - def info(self, *message: object, **data: object) -> None: + def info(self, *objects: object, **named_objects: object) -> None: """ Output an informative message. """ - self.message(INFO, *message, **data) + self.process_message(Message(INFO, objects, named_objects)) - def warning(self, *message: object, **data: object) -> None: + def warning(self, *objects: object, **named_objects: object) -> None: """ Output an "unfixable" warning (a warning that wasn't the fault of the user). """ - self.message(WARNING, *message, **data) + self.process_message(Message(WARNING, objects, named_objects)) - def critical(self, *message: object, **data: object) -> None: + def critical(self, *objects: object, **named_objects: object) -> None: """ Output a critical message. """ - self.message(CRITICAL, *message, **data) + self.process_message(Message(CRITICAL, objects, named_objects)) @classmethod def current(cls) -> Logger: From 02bf19bfc5610c6017decb6c1e09050dd854616d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 3 Jan 2026 21:52:23 -0500 Subject: [PATCH 07/20] More logger stuff. --- src/view/logger.py | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/view/logger.py b/src/view/logger.py index dcd639b1..d98241f9 100644 --- a/src/view/logger.py +++ b/src/view/logger.py @@ -4,6 +4,9 @@ from dataclasses import dataclass, field from typing import ClassVar, Final, Self, TYPE_CHECKING import time +import asyncio +from datetime import datetime +import calendar if TYPE_CHECKING: from collections.abc import Sequence, Mapping @@ -67,42 +70,62 @@ class Logger: default=None, repr=False, init=False ) + # TODO: Factor this out into its own class + write_task: asyncio.Task[None] | None = field( + default=None, repr=False, init=False + ) + write_queue: asyncio.Queue[Message] = field( + default_factory=asyncio.Queue, repr=False, init=False + ) + writer_done: bool = field(default=False, repr=False, init=False) + def shut_up(self) -> None: self.quiet = True - def process_message(self, message: Message) -> None: + async def _writer(self) -> None: + while not self.writer_done: + message = await self.write_queue.get() + when = datetime.fromtimestamp(message.timestamp) + month = calendar.month_abbr[when.month] + # TODO: Add a dedicated file stream + print( + f"{month} {when.day}, {when.hour}:{when.minute}:{when.second} [{message.level.name}] {message.as_string()}" + ) + self.write_queue.task_done() + + def dispatch_message(self, message: Message) -> None: """ Output a log message with an arbitrary log level. """ if self.quiet or (message.level > self.current_level): return - print(message) + self.write_queue.put_nowait(message) def debug(self, *objects: object, **named_objects: object) -> None: """ Output a debug message. """ - self.process_message(Message(DEBUG, objects, named_objects)) + self.dispatch_message(Message(DEBUG, objects, named_objects)) def info(self, *objects: object, **named_objects: object) -> None: """ Output an informative message. """ - self.process_message(Message(INFO, objects, named_objects)) + self.dispatch_message(Message(INFO, objects, named_objects)) def warning(self, *objects: object, **named_objects: object) -> None: """ Output an "unfixable" warning (a warning that wasn't the fault of the user). """ - self.process_message(Message(WARNING, objects, named_objects)) + self.dispatch_message(Message(WARNING, objects, named_objects)) def critical(self, *objects: object, **named_objects: object) -> None: """ Output a critical message. """ - self.process_message(Message(CRITICAL, objects, named_objects)) + self.dispatch_message(Message(CRITICAL, objects, named_objects)) @classmethod def current(cls) -> Logger: @@ -112,10 +135,15 @@ def current(cls) -> Logger: """ return cls.current_logger.get() - def __enter__(self) -> Self: + async def __aenter__(self) -> Self: self.reset_token = self.current_logger.set(self) + assert self.write_task is None + self.write_task = asyncio.create_task(self._writer()) return self - def __exit__(self, *_: object) -> None: + async def __aexit__(self, *_: object) -> None: assert self.reset_token is not None + assert self.write_task is not None + await self.write_queue.join() + self.writer_done = True self.current_logger.reset(self.reset_token) From 5cde84b7a9d2ff00f580b69b2186e2444eb00e21 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 5 Jan 2026 23:49:05 -0500 Subject: [PATCH 08/20] Try using stdlib logging instead of loguru. --- src/view/core/app.py | 105 +++++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 62170b83..72343d0b 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -9,8 +9,8 @@ from pathlib import Path from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar -from loguru import logger - +import sys +import logging from view.core.request import Method, Request from view.core.response import ( Response, @@ -49,6 +49,19 @@ def __init__(self): "The current request being handled." ) self._production: bool | None = None + logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.DEBUG) + + formatter = logging.Formatter( + "view: %(asctime)s -- [%(levelname)s]: %(message)s" + ) + handler.setFormatter(formatter) + + logger.addHandler(handler) + self.logger = logger + self.logger.info("hello") @property def debug(self) -> bool: @@ -69,14 +82,13 @@ def request_context(self, request: Request) -> Iterator[None]: """ Enter a context for the given request. """ - with logger.contextualize(request=request): - app_token = self._CURRENT_APP.set(self) - request_token = self._request.set(request) - try: - yield - finally: - self._request.reset(request_token) - self._CURRENT_APP.reset(app_token) + app_token = self._CURRENT_APP.set(self) + request_token = self._request.set(request) + try: + yield + finally: + self._request.reset(request_token) + self._CURRENT_APP.reset(app_token) @classmethod def current_app(cls) -> BaseApp: @@ -136,17 +148,17 @@ def run( stacklevel=2, ) - logger.info(f"Serving app on http://localhost:{port}") + self.logger.info(f"Serving app on http://localhost:{port}") self._production = production settings = ServerSettings(self, host=host, port=port, hint=server_hint) try: settings.run_app_on_any_server() except KeyboardInterrupt: - logger.info("CTRL^C received, shutting down") + self.logger.info("CTRL^C received, shutting down") except Exception: # noqa: BLE001 - logger.exception("Error in server lifecycle") + self.logger.exception("Error in server lifecycle") finally: - logger.info("Server finished") + self.logger.info("Server finished") def run_detached( self, @@ -173,38 +185,35 @@ def run_detached( process.start() return process - -async def _execute_view_internal( - view: Callable[P, ViewResult], - *args: P.args, - **kwargs: P.kwargs, -) -> Response: - logger.debug(f"Executing view: {view}") - try: - 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}" - ) - raise - - -async def execute_view( - view: Callable[P, ViewResult], *args: P.args, **kwargs: P.kwargs -) -> Response: - try: - return await _execute_view_internal(view, *args, **kwargs) - except BaseException as exception: - # Let HTTP errors pass through, so the caller can deal with it - if isinstance(exception, HTTPError): + async def _execute_view_internal( + self, + view: Callable[P, ViewResult], + *args: P.args, + **kwargs: P.kwargs, + ) -> Response: + self.logger.debug(f"Executing view: {view}") + try: + result = view(*args, **kwargs) + return await wrap_view_result(result) + except HTTPError as error: + self.logger.error(f"HTTP Error {error.status_code}") raise - logger.exception(exception) - if __debug__: - raise InternalServerError.from_current_exception() from exception + async def execute_view( + self, view: Callable[P, ViewResult], *args: P.args, **kwargs: P.kwargs + ) -> Response: + try: + return await self._execute_view_internal(view, *args, **kwargs) + except BaseException as exception: + # Let HTTP errors pass through, so the caller can deal with it + if isinstance(exception, HTTPError): + raise + self.logger.exception(exception) + + if __debug__: + raise InternalServerError.from_current_exception() from exception - raise InternalServerError from exception + raise InternalServerError from exception SingleView = Callable[["Request"], ViewResult] @@ -223,7 +232,7 @@ def __init__(self, view: SingleView) -> None: async def process_request(self, request: Request) -> Response: with self.request_context(request): try: - return await execute_view(self.view, request) + return await self.execute_view(self.view, request) except HTTPError as error: return error.as_response() @@ -256,9 +265,7 @@ def __init__(self, *, router: Router | None = None) -> None: self.router = router or Router() async def _process_request_internal(self, request: Request) -> Response: - logger.opt(colors=True).info( - f"{request.method} {request.path}" - ) + self.logger.info(f"{request.method} {request.path}") found_route: FoundRoute | None = self.router.lookup_route( request.path, request.method ) @@ -267,7 +274,7 @@ async def _process_request_internal(self, request: Request) -> Response: # Extend instead of replacing? request.path_parameters = found_route.path_parameters - return await execute_view(found_route.route.view) + return await self.execute_view(found_route.route.view) async def process_request(self, request: Request) -> Response: with self.request_context(request): @@ -276,7 +283,7 @@ async def process_request(self, request: Request) -> Response: except HTTPError as error: error_view = self.router.lookup_error(type(error)) if error_view is not None: - return await execute_view(error_view) + return await self.execute_view(error_view) return error.as_response() From 412a12725b9e41185898f9b489afb6f49f0b0a2f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 5 Jan 2026 23:49:57 -0500 Subject: [PATCH 09/20] Remove loguru and our homebrewed logger implementation. --- pyproject.toml | 2 +- src/view/core/response.py | 3 - src/view/logger.py | 149 -------------------------------------- 3 files changed, 1 insertion(+), 153 deletions(-) delete mode 100644 src/view/logger.py diff --git a/pyproject.toml b/pyproject.toml index 4c49953b..64135cf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = ["loguru~=0.7", "typing_extensions>=4"] +dependencies = ["typing_extensions>=4"] dynamic = ["version", "license"] [project.optional-dependencies] diff --git a/src/view/core/response.py b/src/view/core/response.py index 3d5c43af..a462c92a 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -9,8 +9,6 @@ from dataclasses import dataclass from typing import AnyStr, Generic, TypeAlias -from loguru import logger - from view.core.body import BodyMixin from view.core.headers import ( HeadersLike, @@ -154,7 +152,6 @@ def _wrap_response(response: ResponseLike, /) -> Response: """ Wrap a response from a view into a :class:`Response` object. """ - logger.debug(f"Got response: {response!r}") if isinstance(response, Response): return response diff --git a/src/view/logger.py b/src/view/logger.py deleted file mode 100644 index d98241f9..00000000 --- a/src/view/logger.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -from contextvars import ContextVar, Token -from dataclasses import dataclass, field -from typing import ClassVar, Final, Self, TYPE_CHECKING -import time -import asyncio -from datetime import datetime -import calendar - -if TYPE_CHECKING: - from collections.abc import Sequence, Mapping - - -@dataclass(slots=True, frozen=True) -class LogLevel: - """ - An arbitrary log level. - """ - - name: str - level: int - - def __gt__(self, other: object) -> bool: - if not isinstance(other, LogLevel): - return NotImplemented - - return self.level > other.level - - def __lt__(self, other: object) -> bool: - if not isinstance(other, LogLevel): - return NotImplemented - - return self.level < other.level - - -DEBUG: Final[LogLevel] = LogLevel("debug", 0) -INFO: Final[LogLevel] = LogLevel("info", 100) -WARNING: Final[LogLevel] = LogLevel("info", 1000) -CRITICAL: Final[LogLevel] = LogLevel("critical", 10_000) - - -@dataclass(slots=True) -class Message: - level: LogLevel - objects: Sequence[object] - named_objects: Mapping[str, object] - timestamp: float = field(default_factory=time.time) - - def as_string(self) -> str: - objects_list = [str(item) for item in self.objects] - for name, value in self.named_objects.items(): - objects_list.append(f"{name}={value}") - - message = " ".join(objects_list) - return message - - -@dataclass(slots=True) -class Logger: - """ - An independent logger for the current context. - """ - - current_logger: ClassVar[ContextVar[Logger]] = ContextVar("current_logger") - - current_level: LogLevel = INFO - quiet: bool = field(default=False, init=False) - reset_token: Token[Logger] | None = field( - default=None, repr=False, init=False - ) - - # TODO: Factor this out into its own class - write_task: asyncio.Task[None] | None = field( - default=None, repr=False, init=False - ) - write_queue: asyncio.Queue[Message] = field( - default_factory=asyncio.Queue, repr=False, init=False - ) - writer_done: bool = field(default=False, repr=False, init=False) - - def shut_up(self) -> None: - self.quiet = True - - async def _writer(self) -> None: - while not self.writer_done: - message = await self.write_queue.get() - when = datetime.fromtimestamp(message.timestamp) - month = calendar.month_abbr[when.month] - # TODO: Add a dedicated file stream - print( - f"{month} {when.day}, {when.hour}:{when.minute}:{when.second} [{message.level.name}] {message.as_string()}" - ) - self.write_queue.task_done() - - def dispatch_message(self, message: Message) -> None: - """ - Output a log message with an arbitrary log level. - """ - if self.quiet or (message.level > self.current_level): - return - - self.write_queue.put_nowait(message) - - def debug(self, *objects: object, **named_objects: object) -> None: - """ - Output a debug message. - """ - self.dispatch_message(Message(DEBUG, objects, named_objects)) - - def info(self, *objects: object, **named_objects: object) -> None: - """ - Output an informative message. - """ - self.dispatch_message(Message(INFO, objects, named_objects)) - - def warning(self, *objects: object, **named_objects: object) -> None: - """ - Output an "unfixable" warning (a warning that wasn't the fault of the - user). - """ - self.dispatch_message(Message(WARNING, objects, named_objects)) - - def critical(self, *objects: object, **named_objects: object) -> None: - """ - Output a critical message. - """ - self.dispatch_message(Message(CRITICAL, objects, named_objects)) - - @classmethod - def current(cls) -> Logger: - """ - Get the logger for the current context. This raises an exception if - no logger is set. - """ - return cls.current_logger.get() - - async def __aenter__(self) -> Self: - self.reset_token = self.current_logger.set(self) - assert self.write_task is None - self.write_task = asyncio.create_task(self._writer()) - return self - - async def __aexit__(self, *_: object) -> None: - assert self.reset_token is not None - assert self.write_task is not None - await self.write_queue.join() - self.writer_done = True - self.current_logger.reset(self.reset_token) From 0d2a5ceac3f0e9fa5cd7016d5811d622ee03aa1b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 5 Jan 2026 23:52:22 -0500 Subject: [PATCH 10/20] Add a shut_up() method. --- src/view/core/app.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 72343d0b..0d6a04f9 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -61,7 +61,13 @@ def __init__(self): logger.addHandler(handler) self.logger = logger - self.logger.info("hello") + + def shut_up(self) -> None: + """ + Stop the logger. + """ + + self.logger.disabled = True @property def debug(self) -> bool: From b1b2707b9e8335df52af97e5de16ac50d6d53dcb Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 6 Jan 2026 19:13:24 -0500 Subject: [PATCH 11/20] Add colors to log output. --- src/view/core/_colors.py | 136 +++++++++++++++++++++++++++++++++++++++ src/view/core/app.py | 24 +++++-- 2 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/view/core/_colors.py diff --git a/src/view/core/_colors.py b/src/view/core/_colors.py new file mode 100644 index 00000000..2c556c4e --- /dev/null +++ b/src/view/core/_colors.py @@ -0,0 +1,136 @@ +""" +This is mostly stolen from CPython's _colorize module. If that becomes part of +the standard library someday, we can hopefully remove this. +""" + +import sys +from typing import IO +import os +import logging + + +class ANSIColors: + """ + Namespace of ANSI color codes. + """ + + RESET = "\x1b[0m" + + BLACK = "\x1b[30m" + BLUE = "\x1b[34m" + CYAN = "\x1b[36m" + GREEN = "\x1b[32m" + GREY = "\x1b[90m" + MAGENTA = "\x1b[35m" + RED = "\x1b[31m" + WHITE = "\x1b[37m" # more like LIGHT GRAY + YELLOW = "\x1b[33m" + + BOLD = "\x1b[1m" + BOLD_BLACK = "\x1b[1;30m" # DARK GRAY + BOLD_BLUE = "\x1b[1;34m" + BOLD_CYAN = "\x1b[1;36m" + BOLD_GREEN = "\x1b[1;32m" + BOLD_MAGENTA = "\x1b[1;35m" + BOLD_RED = "\x1b[1;31m" + BOLD_WHITE = "\x1b[1;37m" # actual WHITE + BOLD_YELLOW = "\x1b[1;33m" + + # intense = like bold but without being bold + INTENSE_BLACK = "\x1b[90m" + INTENSE_BLUE = "\x1b[94m" + INTENSE_CYAN = "\x1b[96m" + INTENSE_GREEN = "\x1b[92m" + INTENSE_MAGENTA = "\x1b[95m" + INTENSE_RED = "\x1b[91m" + INTENSE_WHITE = "\x1b[97m" + INTENSE_YELLOW = "\x1b[93m" + + BACKGROUND_BLACK = "\x1b[40m" + BACKGROUND_BLUE = "\x1b[44m" + BACKGROUND_CYAN = "\x1b[46m" + BACKGROUND_GREEN = "\x1b[42m" + BACKGROUND_MAGENTA = "\x1b[45m" + BACKGROUND_RED = "\x1b[41m" + BACKGROUND_WHITE = "\x1b[47m" + BACKGROUND_YELLOW = "\x1b[43m" + + INTENSE_BACKGROUND_BLACK = "\x1b[100m" + INTENSE_BACKGROUND_BLUE = "\x1b[104m" + INTENSE_BACKGROUND_CYAN = "\x1b[106m" + INTENSE_BACKGROUND_GREEN = "\x1b[102m" + INTENSE_BACKGROUND_MAGENTA = "\x1b[105m" + INTENSE_BACKGROUND_RED = "\x1b[101m" + INTENSE_BACKGROUND_WHITE = "\x1b[107m" + INTENSE_BACKGROUND_YELLOW = "\x1b[103m" + + +NoColors = ANSIColors() + +for attr, code in ANSIColors.__dict__.items(): + if not attr.startswith("__"): + setattr(NoColors, attr, "") + + +def _supports_colors(*, file: IO[str] | IO[bytes] | None = None) -> bool: + """ + Does the current environment support ANSI color codes? + """ + + if file is None: + file = sys.stdout + + assert file is not None + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + if os.environ.get("TERM") == "dumb": + return False + + if not hasattr(file, "fileno"): + return False + + if sys.platform == "win32": + try: + import nt + + if not nt._supports_virtual_terminal(): + return False + except (ImportError, AttributeError): + return False + + try: + return os.isatty(file.fileno()) + except OSError: + return hasattr(file, "isatty") and file.isatty() + + +def get_colors(*, file: IO[str] | IO[bytes] | None = None) -> ANSIColors: + """ + Get a namespace containing color names as attributes. If colors are + enabled, these attributes will contain ANSI color codes. Otherwise, they'll + be empty string. + + """ + if _supports_colors(file=file): + return ANSIColors() + else: + return NoColors + + +class ColorfulFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + colors = get_colors() + mapping = { + logging.DEBUG: colors.BOLD_BLUE, + logging.INFO: colors.BOLD_GREEN, + logging.WARNING: colors.BOLD_YELLOW, + logging.ERROR: colors.BOLD_RED, + logging.CRITICAL: colors.INTENSE_BACKGROUND_RED, + } + color_code = mapping.get(record.levelno) + if color_code is not None: + record.levelname = f"{color_code}{record.levelname}{colors.RESET}" + + return super().format(record) diff --git a/src/view/core/app.py b/src/view/core/app.py index 0d6a04f9..ee64cb98 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -11,6 +11,7 @@ import sys import logging +from view.core._colors import ColorfulFormatter from view.core.request import Method, Request from view.core.response import ( Response, @@ -44,23 +45,38 @@ class BaseApp(ABC): _CURRENT_APP = contextvars.ContextVar["BaseApp"]("Current app being used.") - def __init__(self): + def __init__(self) -> None: self._request = contextvars.ContextVar[Request]( "The current request being handled." ) self._production: bool | None = None - logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}") + self.logger = self._new_logger() + + def _new_logger(self) -> logging.Logger: + """ + Create a new logger for this app. + """ + # TODO: This should be configurable + + # In the future, we might want to add a use-case for multiple apps in + # the same process. To support this, we use the ID of this instance in + # the logger name to keep it unique. + + # XXX: Should this create a new logger for each, or for each instance? + logger = logging.getLogger( + f"{__name__}.{self.__class__.__name__}-{id(self)}" + ) logger.setLevel(logging.DEBUG) handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.DEBUG) - formatter = logging.Formatter( + formatter = ColorfulFormatter( "view: %(asctime)s -- [%(levelname)s]: %(message)s" ) handler.setFormatter(formatter) logger.addHandler(handler) - self.logger = logger + return logger def shut_up(self) -> None: """ From 0064dd7a14121bc97ecc7a20265c679907102d2a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 6 Jan 2026 19:24:03 -0500 Subject: [PATCH 12/20] Add docstrings to module. --- src/view/cache.py | 4 ++++ src/view/core/__init__.py | 4 ++++ src/view/core/app.py | 5 ++++- src/view/core/body.py | 4 ++++ src/view/core/headers.py | 4 ++++ src/view/core/multi_map.py | 4 ++++ src/view/core/request.py | 10 +++++++--- src/view/core/response.py | 4 ++++ src/view/core/router.py | 4 ++++ src/view/core/status_codes.py | 4 ++++ src/view/dom/__init__.py | 5 +++++ src/view/dom/components.py | 4 ++++ src/view/dom/core.py | 4 ++++ src/view/dom/primitives.py | 4 ++++ src/view/exceptions.py | 4 ++++ src/view/javascript.py | 4 ++++ src/view/responses.py | 4 ++++ src/view/run/__init__.py | 4 ++++ src/view/run/asgi.py | 4 ++++ src/view/run/servers.py | 4 ++++ src/view/run/wsgi.py | 4 ++++ src/view/testing.py | 4 ++++ src/view/utils.py | 4 ++++ 23 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/view/cache.py b/src/view/cache.py index d530ad21..78cd02fd 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -1,3 +1,7 @@ +""" +Utilities for caching responses from views. +""" + from __future__ import annotations import math diff --git a/src/view/core/__init__.py b/src/view/core/__init__.py index 7dd1b91b..ae272a47 100644 --- a/src/view/core/__init__.py +++ b/src/view/core/__init__.py @@ -1,3 +1,7 @@ +""" +The parts absolutely necessary for web applications using view.py. +""" + from view.core import app as app from view.core import headers as headers from view.core import request as request diff --git a/src/view/core/app.py b/src/view/core/app.py index ee64cb98..9452ccfc 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -1,3 +1,7 @@ +""" +Primary app implementation. +""" + from __future__ import annotations import contextlib @@ -8,7 +12,6 @@ from multiprocessing import Process from pathlib import Path from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar - import sys import logging from view.core._colors import ColorfulFormatter diff --git a/src/view/core/body.py b/src/view/core/body.py index f2c8ef78..68c8c24b 100644 --- a/src/view/core/body.py +++ b/src/view/core/body.py @@ -1,3 +1,7 @@ +""" +The implementation of request and response bodies. +""" + from __future__ import annotations import json diff --git a/src/view/core/headers.py b/src/view/core/headers.py index 3d1fc3ff..8f94c28b 100644 --- a/src/view/core/headers.py +++ b/src/view/core/headers.py @@ -1,3 +1,7 @@ +""" +Utilities and implementation for HTTP request/response headers. +""" + from __future__ import annotations from collections.abc import Mapping diff --git a/src/view/core/multi_map.py b/src/view/core/multi_map.py index 8aa143cd..92519fa0 100644 --- a/src/view/core/multi_map.py +++ b/src/view/core/multi_map.py @@ -1,3 +1,7 @@ +""" +A "multi-map" implementation intended for use in HTTP headers and query strings. +""" + from __future__ import annotations from collections.abc import ( diff --git a/src/view/core/request.py b/src/view/core/request.py index ab6a7354..55e0276e 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -1,3 +1,7 @@ +""" +Implementation and utilities for HTTP requests. +""" + from __future__ import annotations import sys @@ -19,15 +23,15 @@ __all__ = "Method", "Request" if sys.version_info >= (3, 11): - from enum import StrEnum + from enum import StrEnum as _StrEnum else: from enum import Enum - class StrEnum(str, Enum): + class _StrEnum(str, Enum): pass -class _UpperStrEnum(StrEnum): +class _UpperStrEnum(_StrEnum): @staticmethod def _generate_next_value_( name: str, diff --git a/src/view/core/response.py b/src/view/core/response.py index a462c92a..ad72f3cd 100644 --- a/src/view/core/response.py +++ b/src/view/core/response.py @@ -1,3 +1,7 @@ +""" +Implementation and utilities for HTTP responses. +""" + from __future__ import annotations import warnings diff --git a/src/view/core/router.py b/src/view/core/router.py index 6e23b190..223085f1 100644 --- a/src/view/core/router.py +++ b/src/view/core/router.py @@ -1,3 +1,7 @@ +""" +The router implementation. +""" + from __future__ import annotations from collections.abc import Awaitable, Callable, MutableMapping diff --git a/src/view/core/status_codes.py b/src/view/core/status_codes.py index 3ace02f0..d42ad8c5 100644 --- a/src/view/core/status_codes.py +++ b/src/view/core/status_codes.py @@ -1,3 +1,7 @@ +""" +Utilities and data regarding all HTTP status codes. +""" + from __future__ import annotations import sys diff --git a/src/view/dom/__init__.py b/src/view/dom/__init__.py index b2e3e632..2f341a73 100644 --- a/src/view/dom/__init__.py +++ b/src/view/dom/__init__.py @@ -1,3 +1,8 @@ +""" +A Document Object Model (DOM) API for Python, allowing users to write HTML in +their Python code. +""" + from view.dom import components as components from view.dom import core as core from view.dom import primitives as primitives diff --git a/src/view/dom/components.py b/src/view/dom/components.py index 17db58b7..5c52dccc 100644 --- a/src/view/dom/components.py +++ b/src/view/dom/components.py @@ -1,3 +1,7 @@ +""" +Implementation of "components" -- DOM nodes defined by the user. +""" + from __future__ import annotations from dataclasses import dataclass diff --git a/src/view/dom/core.py b/src/view/dom/core.py index f201b457..beeb1932 100644 --- a/src/view/dom/core.py +++ b/src/view/dom/core.py @@ -1,3 +1,7 @@ +""" +The implementation of the DOM API. +""" + from __future__ import annotations import uuid diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py index bf1c613e..fea4e818 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -1,3 +1,7 @@ +""" +Constructor functions for all HTTP elements. +""" + from __future__ import annotations from typing import TYPE_CHECKING, Any, Literal, TypedDict diff --git a/src/view/exceptions.py b/src/view/exceptions.py index 928ffe73..70cc0038 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -1,3 +1,7 @@ +""" +Common exceptions used throughout view.py. +""" + from __future__ import annotations from typing import Any diff --git a/src/view/javascript.py b/src/view/javascript.py index 8fff59a7..4cc9ca4c 100644 --- a/src/view/javascript.py +++ b/src/view/javascript.py @@ -1,3 +1,7 @@ +""" +Utilities for using JavaScript in view.py applications. +""" + from __future__ import annotations from io import StringIO diff --git a/src/view/responses.py b/src/view/responses.py index c9bf8eba..de72d026 100644 --- a/src/view/responses.py +++ b/src/view/responses.py @@ -1,3 +1,7 @@ +""" +Common response types. +""" + from __future__ import annotations import mimetypes diff --git a/src/view/run/__init__.py b/src/view/run/__init__.py index 52ae092d..4ae6c7e8 100644 --- a/src/view/run/__init__.py +++ b/src/view/run/__init__.py @@ -1,3 +1,7 @@ +""" +Utilities for running view.py web applications. +""" + from view.run import asgi as asgi from view.run import servers as servers from view.run import wsgi as wsgi diff --git a/src/view/run/asgi.py b/src/view/run/asgi.py index da9ac6ee..782f4457 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -1,3 +1,7 @@ +""" +Implementation and utilities for running view.py applications on an ASGI server. +""" + from __future__ import annotations from collections.abc import AsyncIterator, Awaitable, Callable, Iterable diff --git a/src/view/run/servers.py b/src/view/run/servers.py index b32db0d5..6ab7ffdb 100644 --- a/src/view/run/servers.py +++ b/src/view/run/servers.py @@ -1,3 +1,7 @@ +""" +Magically run applications on some common servers. +""" + from __future__ import annotations from collections.abc import Callable, Sequence diff --git a/src/view/run/wsgi.py b/src/view/run/wsgi.py index 1b581f65..6f556808 100644 --- a/src/view/run/wsgi.py +++ b/src/view/run/wsgi.py @@ -1,3 +1,7 @@ +""" +Implementation and utilities for running view.py applications on an ASGI server. +""" + from __future__ import annotations import asyncio diff --git a/src/view/testing.py b/src/view/testing.py index 5410b8ea..1d0a64c7 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,3 +1,7 @@ +""" +Utilities for testing a view.py application without the use of I/O. +""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/src/view/utils.py b/src/view/utils.py index 84fdde14..5c6d3f86 100644 --- a/src/view/utils.py +++ b/src/view/utils.py @@ -1,3 +1,7 @@ +""" +General utilities for view.py users. +""" + from __future__ import annotations from contextlib import contextmanager From 57ed7b04411994eaf14aae3b7039cd7a31715a78 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 6 Jan 2026 19:28:30 -0500 Subject: [PATCH 13/20] Include objects not in a module __all__ in autogenerated reference. --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 4cffdad0..6a16281b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,6 +26,8 @@ 'members': True, 'undoc-members': True, 'show-inheritance': True, + "inherited-members": True, + "ignore-module-all": True, } exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] From 9b852daaa04b1baecbf484c6db1ee167382d8055 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 6 Jan 2026 19:31:28 -0500 Subject: [PATCH 14/20] Remove _cached_response from InMemoryCache.__init__ --- src/view/cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/view/cache.py b/src/view/cache.py index 78cd02fd..2fa1d436 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -74,7 +74,9 @@ class InMemoryCache(BaseCache[P, T]): callable: Callable[P, T] reset_frequency: float - _cached_response: _CachedResponse | None = field(repr=False, default=None) + _cached_response: _CachedResponse | None = field( + init=False, repr=False, default=None + ) def invalidate(self) -> None: self._cached_response = None From 98e393730c482ac02cafc092f61e8f8379557e55 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 7 Jan 2026 09:19:24 -0500 Subject: [PATCH 15/20] Add "development mode". --- src/view/core/app.py | 50 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 9452ccfc..088193b0 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar import sys import logging +import json +import os from view.core._colors import ColorfulFormatter from view.core.request import Method, Request from view.core.response import ( @@ -33,6 +35,8 @@ from view.responses import FileResponse from view.utils import reraise +from importlib.metadata import Distribution, PackageNotFoundError + if TYPE_CHECKING: from view.run.asgi import ASGIProtocol from view.run.wsgi import WSGIProtocol @@ -43,6 +47,32 @@ P = ParamSpec("P") +def _is_development_mode() -> bool: + devmode_variable = os.environ.get("VIEW_DEVMODE") + if devmode_variable is not None: + if not devmode_variable.isdigit(): + raise RuntimeError( + f"Invalid value for VIEW_DEVMODE: {devmode_variable!r}" + ) + + return bool(int(devmode_variable)) + + try: + view_distribution = Distribution.from_name("view.py") + except PackageNotFoundError: + # view.py isn't even installed -- we're definitely in some sort of + # local copy. + return True + json_data = view_distribution.read_text("direct_url.json") + if json_data is None: + return False + + is_editable = ( + json.loads(json_data).get("dir_info", {}).get("editable", False) + ) + return is_editable + + class BaseApp(ABC): """Base view.py application.""" @@ -53,6 +83,7 @@ def __init__(self) -> None: "The current request being handled." ) self._production: bool | None = None + self.development_mode: bool = _is_development_mode() self.logger = self._new_logger() def _new_logger(self) -> logging.Logger: @@ -61,17 +92,21 @@ def _new_logger(self) -> logging.Logger: """ # TODO: This should be configurable + log_level = logging.INFO + if self.development_mode: + log_level = logging.DEBUG + # In the future, we might want to add a use-case for multiple apps in # the same process. To support this, we use the ID of this instance in # the logger name to keep it unique. - # XXX: Should this create a new logger for each, or for each instance? + # XXX: Should this create a new logger for each class, or for each instance? logger = logging.getLogger( f"{__name__}.{self.__class__.__name__}-{id(self)}" ) - logger.setLevel(logging.DEBUG) + logger.setLevel(log_level) handler = logging.StreamHandler(sys.stdout) - handler.setLevel(logging.DEBUG) + handler.setLevel(log_level) formatter = ColorfulFormatter( "view: %(asctime)s -- [%(levelname)s]: %(message)s" @@ -173,6 +208,15 @@ def run( stacklevel=2, ) + if self.development_mode: + self.logger.info("You're in development mode!") + self.logger.info( + "Development mode implies that you're working on view.py itself and plan on contributing to the library." + ) + self.logger.info( + "If that doesn't sound correct, set VIEW_DEVMODE to 0." + ) + self.logger.info(f"Serving app on http://localhost:{port}") self._production = production settings = ServerSettings(self, host=host, port=port, hint=server_hint) From 4459fbd108e6a3ed2fd4e1f44cb8710bc585b2e3 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 7 Jan 2026 09:23:29 -0500 Subject: [PATCH 16/20] Touchups to development mode. --- src/view/core/app.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 088193b0..371495f9 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -79,12 +79,28 @@ class BaseApp(ABC): _CURRENT_APP = contextvars.ContextVar["BaseApp"]("Current app being used.") def __init__(self) -> None: - self._request = contextvars.ContextVar[Request]( - "The current request being handled." - ) + self._request = contextvars.ContextVar[Request]("request") self._production: bool | None = None - self.development_mode: bool = _is_development_mode() + + # We use a private variable for this to artificially disallow people + # from writing to development_mode. + self._development_mode: bool = _is_development_mode() + self.logger = self._new_logger() + """ + The logger used by the app. + """ + + @property + def development_mode(self) -> bool: + """ + Whether view.py is in "development mode". If this is ``True``, then + that means you're working on contributing to the library itself. + + This cannot be set from Python. If you'd like to control this behavior, + set the ``VIEW_DEVMODE`` environment variable to ``1`` or ``0``. + """ + return self._development_mode def _new_logger(self) -> logging.Logger: """ From 80d9445b609afcad97b274b9d19a97f76b21a7dc Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 7 Jan 2026 16:07:23 -0500 Subject: [PATCH 17/20] Run formatter. --- src/view/core/_colors.py | 7 +++---- src/view/core/app.py | 14 +++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/view/core/_colors.py b/src/view/core/_colors.py index 2c556c4e..2770031d 100644 --- a/src/view/core/_colors.py +++ b/src/view/core/_colors.py @@ -3,10 +3,10 @@ the standard library someday, we can hopefully remove this. """ +import logging +import os import sys from typing import IO -import os -import logging class ANSIColors: @@ -115,8 +115,7 @@ def get_colors(*, file: IO[str] | IO[bytes] | None = None) -> ANSIColors: """ if _supports_colors(file=file): return ANSIColors() - else: - return NoColors + return NoColors class ColorfulFormatter(logging.Formatter): diff --git a/src/view/core/app.py b/src/view/core/app.py index 371495f9..00fb74fb 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -6,16 +6,18 @@ import contextlib import contextvars +import json +import logging +import os +import sys import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Iterator +from importlib.metadata import Distribution, PackageNotFoundError from multiprocessing import Process from pathlib import Path from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar -import sys -import logging -import json -import os + from view.core._colors import ColorfulFormatter from view.core.request import Method, Request from view.core.response import ( @@ -35,8 +37,6 @@ from view.responses import FileResponse from view.utils import reraise -from importlib.metadata import Distribution, PackageNotFoundError - if TYPE_CHECKING: from view.run.asgi import ASGIProtocol from view.run.wsgi import WSGIProtocol @@ -240,7 +240,7 @@ def run( settings.run_app_on_any_server() except KeyboardInterrupt: self.logger.info("CTRL^C received, shutting down") - except Exception: # noqa: BLE001 + except Exception: self.logger.exception("Error in server lifecycle") finally: self.logger.info("Server finished") From b3377f0843ebe8a0821fec6621e1b861ea49ab39 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 7 Jan 2026 16:12:06 -0500 Subject: [PATCH 18/20] Fix a bunch of linter errors. --- src/view/core/_colors.py | 10 ++++++---- src/view/core/app.py | 7 ++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/view/core/_colors.py b/src/view/core/_colors.py index 2770031d..9231d5c7 100644 --- a/src/view/core/_colors.py +++ b/src/view/core/_colors.py @@ -3,6 +3,8 @@ the standard library someday, we can hopefully remove this. """ +from __future__ import annotations + import logging import os import sys @@ -67,9 +69,9 @@ class ANSIColors: NoColors = ANSIColors() -for attr, code in ANSIColors.__dict__.items(): - if not attr.startswith("__"): - setattr(NoColors, attr, "") +for attribute in ANSIColors.__dict__: + if not attribute.startswith("__"): + setattr(NoColors, attribute, "") def _supports_colors(*, file: IO[str] | IO[bytes] | None = None) -> bool: @@ -95,7 +97,7 @@ def _supports_colors(*, file: IO[str] | IO[bytes] | None = None) -> bool: try: import nt - if not nt._supports_virtual_terminal(): + if not nt._supports_virtual_terminal(): # noqa: SLF001 return False except (ImportError, AttributeError): return False diff --git a/src/view/core/app.py b/src/view/core/app.py index 00fb74fb..3f3ca8b6 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -67,10 +67,7 @@ def _is_development_mode() -> bool: if json_data is None: return False - is_editable = ( - json.loads(json_data).get("dir_info", {}).get("editable", False) - ) - return is_editable + return json.loads(json_data).get("dir_info", {}).get("editable", False) class BaseApp(ABC): @@ -293,7 +290,7 @@ async def execute_view( # Let HTTP errors pass through, so the caller can deal with it if isinstance(exception, HTTPError): raise - self.logger.exception(exception) + self.logger.exception("Error while processing response") if __debug__: raise InternalServerError.from_current_exception() from exception From 6251ca3523cb2984b4bead21f9714773c96b835c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 7 Jan 2026 16:14:09 -0500 Subject: [PATCH 19/20] Make HTTP errors a warning in the logs. --- src/view/core/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 3f3ca8b6..5c672597 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -278,7 +278,7 @@ async def _execute_view_internal( result = view(*args, **kwargs) return await wrap_view_result(result) except HTTPError as error: - self.logger.error(f"HTTP Error {error.status_code}") + self.logger.warning(f"HTTP Error {error.status_code}") raise async def execute_view( From 627801754650dd498f3d12811a883c2951b57a76 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 7 Jan 2026 16:20:19 -0500 Subject: [PATCH 20/20] Fix remaining linter problems. --- src/view/core/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/view/core/app.py b/src/view/core/app.py index 5c672597..5836b474 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -230,7 +230,7 @@ def run( "If that doesn't sound correct, set VIEW_DEVMODE to 0." ) - self.logger.info(f"Serving app on http://localhost:{port}") + self.logger.info("Serving app on http://localhost:%d", port) self._production = production settings = ServerSettings(self, host=host, port=port, hint=server_hint) try: @@ -273,12 +273,12 @@ async def _execute_view_internal( *args: P.args, **kwargs: P.kwargs, ) -> Response: - self.logger.debug(f"Executing view: {view}") + self.logger.debug("Executing view: %s", view) try: result = view(*args, **kwargs) return await wrap_view_result(result) except HTTPError as error: - self.logger.warning(f"HTTP Error {error.status_code}") + self.logger.warning("HTTP Error %d", error.status_code) raise async def execute_view( @@ -347,7 +347,7 @@ def __init__(self, *, router: Router | None = None) -> None: self.router = router or Router() async def _process_request_internal(self, request: Request) -> Response: - self.logger.info(f"{request.method} {request.path}") + self.logger.info("%s on route %s", request.method, request.path) found_route: FoundRoute | None = self.router.lookup_route( request.path, request.method )