diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1dcd6d8..e80daa01 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,12 +5,9 @@ on: tags: - v* branches: - - master + - main paths: - "src/**" - pull_request: - branches: - - master 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 }} 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/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 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/hatch.toml b/hatch.toml index b8c43655..fcfec678 100644 --- a/hatch.toml +++ b/hatch.toml @@ -11,8 +11,17 @@ packages = ["src/view"] extra-args = ["-vv"] extra-dependencies = [ "pytest-asyncio", - "requests" + "requests", + "uvicorn", + "hypercorn", + "daphne", + "gunicorn", + "werkzeug", ] +randomize = true +retries = 3 +retries-delay = 1 +parallel = false [[envs.hatch-test.matrix]] python = ["3.14", "3.13", "3.12", "3.11", "3.10"] diff --git a/logos/logo_theme_dark.png b/logos/logo_theme_dark.png new file mode 100644 index 00000000..d2aa1fe7 Binary files /dev/null and b/logos/logo_theme_dark.png differ diff --git a/logos/logo_theme_light.png b/logos/logo_theme_light.png new file mode 100644 index 00000000..3189113c Binary files /dev/null and b/logos/logo_theme_light.png differ diff --git a/pyproject.toml b/pyproject.toml index 2b055e33..d18f7e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,23 @@ 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 + "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] +"__init__.py" = ["PLC0414"] +"status_codes.py" = ["N818"] +"primitives.py" = ["A001", "A002", "B008"] +"servers.py" = ["PLC0415", "RET503"] 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/cache.py b/src/view/cache.py index 09198d64..f3a3ac2d 100644 --- a/src/view/cache.py +++ b/src/view/cache.py @@ -3,13 +3,20 @@ 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 view.core.response import Response, TextResponse, ViewResult, wrap_view_result + from multidict import CIMultiDict + +from view.core.response import ( + Response, + TextResponse, + ViewResult, + wrap_view_result, +) __all__ = ("in_memory_cache",) @@ -32,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) @@ -73,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) @@ -104,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 c6463980..efe0653a 100644 --- a/src/view/core/app.py +++ b/src/view/core/app.py @@ -20,7 +20,13 @@ wrap_view_result, ) from view.core.router import FoundRoute, Route, Router, RouteView -from view.core.status_codes import Forbidden, HTTPError, InternalServerError, NotFound +from view.core.status_codes import ( + Forbidden, + HTTPError, + InternalServerError, + NotFound, +) +from view.exceptions import InvalidTypeError from view.utils import reraise if TYPE_CHECKING: @@ -127,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}") @@ -136,7 +143,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") @@ -177,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 @@ -191,10 +200,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] @@ -223,13 +233,15 @@ 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) RouteDecorator: TypeAlias = Callable[[RouteView], Route] -SubRouterView: TypeAlias = Callable[[str], ResponseLike | Awaitable[ResponseLike]] +SubRouterView: TypeAlias = Callable[ + [str], ResponseLike | Awaitable[ResponseLike] +] SubRouterViewT = TypeVar("SubRouterViewT", bound=SubRouterView) @@ -251,7 +263,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 @@ -274,14 +286,13 @@ 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) - return route + return self.router.push_route(view, path, method) return decorator @@ -352,13 +363,15 @@ 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 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]: @@ -373,7 +386,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) @@ -381,10 +394,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) diff --git a/src/view/core/body.py b/src/view/core/body.py index c4b31a05..3a39c1e2 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. @@ -21,8 +21,11 @@ class BodyAlreadyUsed(ViewError): times. """ + def __init__(self) -> None: + super().__init__("Body has already been consumed") -class InvalidJSON(ViewError): + +class InvalidJSONError(ViewError): """ The body is not valid JSON data or something went wrong when parsing it. @@ -45,14 +48,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 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 +71,14 @@ 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 +86,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 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..a144dbd6 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 @@ -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: @@ -35,16 +37,16 @@ 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]() 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 +64,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 9384da88..dfd35f08 100644 --- a/src/view/core/request.py +++ b/src/view/core/request.py @@ -1,19 +1,21 @@ 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 typing import TYPE_CHECKING, Any + 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" @@ -29,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] + name: str, + *_: Any, ) -> str: return name.upper() @@ -100,7 +103,7 @@ class Request(BodyMixin): Dataclass representing an HTTP request. """ - app: "BaseApp" + app: BaseApp """ The app associated with the HTTP request. """ @@ -127,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 cd022d02..4059d66e 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 @@ -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" @@ -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 @@ -61,11 +63,11 @@ 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: - return mimetypes.guess_type(path)[0] or "text/plain" + + return mimetypes.guess_type(path)[0] or "text/plain" @dataclass(slots=True) @@ -91,7 +93,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: @@ -115,8 +117,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) @@ -141,7 +143,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 +179,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,33 +188,40 @@ 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( 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]) content = response[0] if __debug__ and isinstance(content, Response): - raise InvalidResponse( - f"Response() objects cannot be used with response" + raise InvalidResponseError( + "Response() objects cannot be used with response" " tuples. Instead, use the status_code and/or headers parameter(s)." ) 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: - raise InvalidResponse(f"Got excess data in response tuple {response[3:]!r}") + if __debug__ and len(response) > 3: # noqa + raise InvalidResponseError( + 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: @@ -222,26 +231,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/router.py b/src/view/core/router.py index 874385f0..6e23b190 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", ) @@ -141,9 +141,11 @@ 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 InvalidType(path, str) + raise InvalidTypeError(path, str) path = normalize_route(path) parent_node = self.parent_node @@ -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) @@ -165,11 +169,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,15 +188,19 @@ 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 - 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. """ @@ -202,7 +210,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 @@ -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 d7234163..3ace02f0 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 @@ -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]: @@ -218,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 @@ -534,7 +537,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/dom/components.py b/src/view/dom/components.py index 09890716..17db58b7 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" @@ -27,7 +31,7 @@ def as_html(self) -> str: ) -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class Component: """ A node with an "injectable" body. @@ -90,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 91e4fc2e..c85e7ee8 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 InvalidType +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"] @@ -40,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 """ @@ -168,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] @@ -197,7 +203,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) @@ -208,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 1a7cdfc6..bf1c613e 100644 --- a/src/view/dom/primitives.py +++ b/src/view/dom/primitives.py @@ -1,11 +1,14 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict + from typing_extensions import NotRequired, Unpack from view.dom.core import HTMLNode -from view.exceptions import InvalidType +from view.exceptions import InvalidTypeError + +if TYPE_CHECKING: + from collections.abc import Callable class ImplicitDefault(str): @@ -14,6 +17,8 @@ class ImplicitDefault(str): thus does not need to be included in the rendered output. """ + __slots__ = () + def _construct_node( name: str, @@ -23,8 +28,10 @@ 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)): - raise InvalidType(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(): if value in {None, False}: @@ -35,7 +42,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 @@ -522,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 {}, ) @@ -709,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, @@ -796,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, @@ -827,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 {}, ) @@ -929,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: @@ -1056,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 {}, ) @@ -1069,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, @@ -1079,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, @@ -1418,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 {}, ) @@ -1501,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, @@ -1690,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 {}, ) @@ -1759,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""" @@ -1795,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", @@ -1909,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, @@ -2176,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 f0a26446..fe610a49 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 @@ -27,5 +27,7 @@ class InvalidType(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 c4f43df7..4594f615 100644 --- a/src/view/javascript.py +++ b/src/view/javascript.py @@ -1,10 +1,18 @@ -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 +from view.exceptions import InvalidTypeError -__all__ = "SupportsJavaScript", "as_javascript_expression", "javascript_compiler" +__all__ = ( + "SupportsJavaScript", + "as_javascript_expression", + "javascript_compiler", +) P = ParamSpec("P") @@ -36,9 +44,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() @@ -53,12 +61,16 @@ 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") + 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. @@ -69,7 +81,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/asgi.py b/src/view/run/asgi.py index 351d79ce..530adf72 100644 --- a/src/view/run/asgi.py +++ b/src/view/run/asgi.py @@ -1,13 +1,16 @@ from __future__ import annotations from collections.abc import AsyncIterator, Awaitable, Callable, Iterable -from typing import Any, Literal, TypeAlias, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict + 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",) @@ -86,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( @@ -97,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 3c73bba8..9dc4d97c 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. @@ -41,7 +41,7 @@ class ServerSettings: "wsgiref", ] - app: "BaseApp" + app: BaseApp port: int host: str hint: str | None = None @@ -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: @@ -143,13 +145,18 @@ 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(): + # 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 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 42f958e8..5d8e47fb 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,15 +1,16 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Awaitable 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 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 @@ -43,7 +44,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: @@ -87,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, @@ -96,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, @@ -105,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, @@ -169,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 0e9c8f64..1fb1c10d 100644 --- a/src/view/utils.py +++ b/src/view/utils.py @@ -1,14 +1,19 @@ -from collections.abc import Callable, Iterator +from __future__ import annotations + 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" @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. @@ -16,9 +21,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 @@ -27,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 @@ -36,13 +44,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 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..bf1ca7d2 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,18 @@ +import pytest +from view.core.app import App, as_app +from view.exceptions import InvalidTypeError + + +def test_as_app_invalid(): + with pytest.raises(InvalidTypeError): + as_app(object()) # type: ignore + + +def test_invalid_type_route(): + app = App() + + with pytest.raises(InvalidTypeError): + app.get(object()) # type: ignore + + with pytest.raises(InvalidTypeError): + app.get("/")(object()) # type: ignore diff --git a/tests/test_requests.py b/tests/test_requests.py index 42b511ee..8991806f 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -3,13 +3,12 @@ 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 @@ -297,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"] @@ -348,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): 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..2eaa24f9 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -1,10 +1,10 @@ import subprocess import sys import time +import platform import pytest import requests - from view.core.app import as_app from view.core.request import Request from view.core.response import ResponseLike @@ -13,6 +13,7 @@ @pytest.mark.parametrize("server_name", ServerSettings.AVAILABLE_SERVERS) +@pytest.mark.skipif(platform.system() != "Linux", reason="this has issues on non-Linux") def test_run_server(server_name: str): try: __import__(server_name) @@ -31,7 +32,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() @@ -53,7 +54,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 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():