From 0cc482b20544c6a083fe119cd79fa636baf1656a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 18:43:51 -0400 Subject: [PATCH 001/188] A fresh start. Since I last released a version of view.py, I've gone from zero contributions to open source to a CPython core developer. I've learned so much, and it's time to apply that here. --- CMakeLists.txt | 3 - README.md | 82 - _view.pyi | 174 -- assets/logo_theme_dark.png | Bin 29956 -> 0 bytes assets/logo_theme_light.png | Bin 30447 -> 0 bytes client/index.html | 10 - client/package.json | 21 - client/src/reactpy.tsx | 247 --- client/src/vite-env.d.ts | 1 - client/tsconfig.json | 25 - client/vite.config.js | 6 - docs/building-projects/app_basics.md | 117 -- docs/building-projects/build_steps.md | 221 -- docs/building-projects/documenting.md | 116 -- docs/building-projects/parameters.md | 358 ---- docs/building-projects/request_data.md | 184 -- docs/building-projects/responses.md | 303 --- docs/building-projects/routing.md | 260 --- docs/building-projects/templating.md | 184 -- docs/building-projects/websockets.md | 174 -- docs/contributing.md | 6 - docs/getting-started/configuration.md | 163 -- docs/getting-started/creating_a_project.md | 73 - docs/getting-started/installation.md | 49 - docs/index.md | 111 - docs/next-env.d.ts | 5 - docs/reference/app.md | 3 - docs/reference/build.md | 3 - docs/reference/config.md | 3 - docs/reference/exceptions.md | 3 - docs/reference/responses.md | 3 - docs/reference/routing.md | 8 - docs/reference/templates.md | 3 - docs/reference/types.md | 3 - docs/reference/utils.md | 4 - docs/reference/websockets.md | 3 - include/view/app.h | 42 - include/view/backport.h | 68 - include/view/context.h | 9 - include/view/errors.h | 44 - include/view/handling.h | 30 - include/view/headerdict.h | 9 - include/view/inputs.h | 55 - include/view/map.h | 30 - include/view/parts.h | 23 - include/view/results.h | 17 - include/view/route.h | 40 - include/view/typecodes.h | 45 - include/view/view.h | 39 - include/view/ws.h | 10 - pyproject.toml | 46 +- runtime.txt | 2 +- src/_view/app.c | 1199 ----------- src/_view/backport.c | 73 - src/_view/context.c | 356 ---- src/_view/errors.c | 990 --------- src/_view/handling.c | 743 ------- src/_view/headerdict.c | 450 ----- src/_view/inputs.c | 603 ------ src/_view/main.c | 270 --- src/_view/map.c | 345 ---- src/_view/parts.c | 347 ---- src/_view/results.c | 367 ---- src/_view/route.c | 149 -- src/_view/typecodes.c | 1288 ------------ src/_view/ws.c | 674 ------ src/view/__about__.py | 2 - src/view/__init__.py | 45 - src/view/__main__.py | 483 ----- src/view/_codec.py | 151 -- src/view/_docs.py | 122 -- src/view/_loader.py | 677 ------- src/view/_logging.py | 1081 ---------- src/view/_parsers.py | 28 - src/view/_util.py | 177 -- src/view/app.py | 1534 -------------- src/view/build.py | 448 ---- src/view/components.py | 2136 -------------------- src/view/config.py | 225 --- src/view/databases.py | 327 --- src/view/default_page.py | 8 - src/view/exceptions.py | 165 -- src/view/integrations.py | 21 - src/view/patterns.py | 133 -- src/view/py.typed | 0 src/view/response.py | 359 ---- src/view/routing.py | 887 -------- src/view/templates.py | 411 ---- src/view/typecodes.py | 136 -- src/view/typing.py | 208 -- src/view/util.py | 378 ---- src/view/ws.py | 310 --- tests/buildscripts/failing_build_script.py | 2 - tests/buildscripts/failing_req.py | 8 - tests/buildscripts/my_build_script.py | 6 - tests/buildscripts/req.py | 7 - tests/configs/build_commands.toml | 13 - tests/configs/build_platform.toml | 12 - tests/configs/build_reqs.toml | 14 - tests/configs/build_scripts.toml | 8 - tests/configs/fs.toml | 3 - tests/configs/simple.toml | 3 - tests/configs/subtemplates.toml | 2 - tests/configs/templates.toml | 3 - tests/configs/urls.toml | 3 - tests/conftest.py | 42 - tests/fs_routing/delete.py | 6 - tests/fs_routing/get.py | 6 - tests/fs_routing/options/index.py | 6 - tests/fs_routing/patch.py | 6 - tests/fs_routing/post.py | 6 - tests/fs_routing/put.py | 6 - tests/other_templates/something.html | 1 - tests/patterns_routes/_routes.py | 40 - tests/patterns_routes/urls.py | 14 - tests/simple_routes/first.py | 11 - tests/simple_routes/second.py | 11 - tests/simple_routes/third/fourth.py | 11 - tests/subtemplates/sub.html | 2 - tests/subtemplates/world.html | 1 - tests/templates/index.html | 1 - tests/templates/test.md | 5 - tests/test_app.py | 873 -------- tests/test_build.py | 88 - tests/test_functions.py | 216 -- tests/test_loaders.py | 130 -- tests/test_status.py | 56 - tests/test_templates.py | 109 - tests/test_websocket.py | 161 -- uncrustify.cfg | 3 +- 130 files changed, 6 insertions(+), 23633 deletions(-) delete mode 100644 _view.pyi delete mode 100644 assets/logo_theme_dark.png delete mode 100644 assets/logo_theme_light.png delete mode 100644 client/index.html delete mode 100644 client/package.json delete mode 100644 client/src/reactpy.tsx delete mode 100644 client/src/vite-env.d.ts delete mode 100644 client/tsconfig.json delete mode 100644 client/vite.config.js delete mode 100644 docs/building-projects/app_basics.md delete mode 100644 docs/building-projects/build_steps.md delete mode 100644 docs/building-projects/documenting.md delete mode 100644 docs/building-projects/parameters.md delete mode 100644 docs/building-projects/request_data.md delete mode 100644 docs/building-projects/responses.md delete mode 100644 docs/building-projects/routing.md delete mode 100644 docs/building-projects/templating.md delete mode 100644 docs/building-projects/websockets.md delete mode 100644 docs/contributing.md delete mode 100644 docs/getting-started/configuration.md delete mode 100644 docs/getting-started/creating_a_project.md delete mode 100644 docs/getting-started/installation.md delete mode 100644 docs/index.md delete mode 100644 docs/next-env.d.ts delete mode 100644 docs/reference/app.md delete mode 100644 docs/reference/build.md delete mode 100644 docs/reference/config.md delete mode 100644 docs/reference/exceptions.md delete mode 100644 docs/reference/responses.md delete mode 100644 docs/reference/routing.md delete mode 100644 docs/reference/templates.md delete mode 100644 docs/reference/types.md delete mode 100644 docs/reference/utils.md delete mode 100644 docs/reference/websockets.md delete mode 100644 include/view/app.h delete mode 100644 include/view/backport.h delete mode 100644 include/view/context.h delete mode 100644 include/view/errors.h delete mode 100644 include/view/handling.h delete mode 100644 include/view/headerdict.h delete mode 100644 include/view/inputs.h delete mode 100644 include/view/map.h delete mode 100644 include/view/parts.h delete mode 100644 include/view/results.h delete mode 100644 include/view/route.h delete mode 100644 include/view/typecodes.h delete mode 100644 include/view/view.h delete mode 100644 include/view/ws.h delete mode 100644 src/_view/app.c delete mode 100644 src/_view/backport.c delete mode 100644 src/_view/context.c delete mode 100644 src/_view/errors.c delete mode 100644 src/_view/handling.c delete mode 100644 src/_view/headerdict.c delete mode 100644 src/_view/inputs.c delete mode 100644 src/_view/main.c delete mode 100644 src/_view/map.c delete mode 100644 src/_view/parts.c delete mode 100644 src/_view/results.c delete mode 100644 src/_view/route.c delete mode 100644 src/_view/typecodes.c delete mode 100644 src/_view/ws.c delete mode 100644 src/view/__about__.py delete mode 100644 src/view/__init__.py delete mode 100644 src/view/__main__.py delete mode 100644 src/view/_codec.py delete mode 100644 src/view/_docs.py delete mode 100644 src/view/_loader.py delete mode 100644 src/view/_logging.py delete mode 100644 src/view/_parsers.py delete mode 100644 src/view/_util.py delete mode 100644 src/view/app.py delete mode 100644 src/view/build.py delete mode 100644 src/view/components.py delete mode 100644 src/view/config.py delete mode 100644 src/view/databases.py delete mode 100644 src/view/default_page.py delete mode 100644 src/view/exceptions.py delete mode 100644 src/view/integrations.py delete mode 100644 src/view/patterns.py delete mode 100644 src/view/py.typed delete mode 100644 src/view/response.py delete mode 100644 src/view/routing.py delete mode 100644 src/view/templates.py delete mode 100644 src/view/typecodes.py delete mode 100644 src/view/typing.py delete mode 100644 src/view/util.py delete mode 100644 src/view/ws.py delete mode 100644 tests/buildscripts/failing_build_script.py delete mode 100644 tests/buildscripts/failing_req.py delete mode 100644 tests/buildscripts/my_build_script.py delete mode 100644 tests/buildscripts/req.py delete mode 100644 tests/configs/build_commands.toml delete mode 100644 tests/configs/build_platform.toml delete mode 100644 tests/configs/build_reqs.toml delete mode 100644 tests/configs/build_scripts.toml delete mode 100644 tests/configs/fs.toml delete mode 100644 tests/configs/simple.toml delete mode 100644 tests/configs/subtemplates.toml delete mode 100644 tests/configs/templates.toml delete mode 100644 tests/configs/urls.toml delete mode 100644 tests/conftest.py delete mode 100644 tests/fs_routing/delete.py delete mode 100644 tests/fs_routing/get.py delete mode 100644 tests/fs_routing/options/index.py delete mode 100644 tests/fs_routing/patch.py delete mode 100644 tests/fs_routing/post.py delete mode 100644 tests/fs_routing/put.py delete mode 100644 tests/other_templates/something.html delete mode 100644 tests/patterns_routes/_routes.py delete mode 100644 tests/patterns_routes/urls.py delete mode 100644 tests/simple_routes/first.py delete mode 100644 tests/simple_routes/second.py delete mode 100644 tests/simple_routes/third/fourth.py delete mode 100644 tests/subtemplates/sub.html delete mode 100644 tests/subtemplates/world.html delete mode 100644 tests/templates/index.html delete mode 100644 tests/templates/test.md delete mode 100644 tests/test_app.py delete mode 100644 tests/test_build.py delete mode 100644 tests/test_functions.py delete mode 100644 tests/test_loaders.py delete mode 100644 tests/test_status.py delete mode 100644 tests/test_templates.py delete mode 100644 tests/test_websocket.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 226c9871..cc6c705c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,9 +18,6 @@ find_package( # Link Python python_add_library(_view MODULE ${_view_SRC} WITH_SOABI) -# Settings -add_compile_definitions(PYAWAITABLE_PYAPI) - # Add include directories target_include_directories(_view PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include/) target_include_directories(_view PUBLIC $ENV{PYAWAITABLE_INCLUDE_DIR}) diff --git a/README.md b/README.md index 472fb7c7..afc9f72a 100644 --- a/README.md +++ b/README.md @@ -14,89 +14,7 @@ Build -> [!Warning] -> view.py is currently in alpha, and may be lacking some features. -> If you would like to follow development progress, be sure to join [the discord](https://discord.gg/tZAfuWAbm2). - -- [Docs](https://view.zintensity.dev) -- [Source](https://github.com/ZeroIntensity/view.py) -- [PyPI](https://pypi.org/project/view.py) -- [Discord](https://discord.gg/tZAfuWAbm2) - -## Features - -- Batteries Detachable: Don't like our approach to something? No problem! We aim to provide native support for all your favorite libraries, as well as provide APIs to let you reinvent the wheel as you wish. -- Lightning Fast: Powered by [pyawaitable](https://github.com/ZeroIntensity/pyawaitable), view.py is the first web framework to implement ASGI in pure C, without the use of external transpilers. -- Developer Oriented: view.py is developed with ease of use in mind, providing a rich documentation, docstrings, and type hints. - -See [why I wrote it](https://view.zintensity.dev/#why-did-i-build-it) on the docs. - -## Examples - -```py -from view import new_app - -app = new_app() - -@app.get("/") -async def index(): - return await app.template("index.html", engine="jinja") - -app.run() -``` - -```py -# routes/index.py -from view import get, HTML - -# Build TypeScript Frontend -@get(steps=["typescript"], cache_rate=1000) -async def index(): - return await HTML.from_file("dist/index.html") -``` - -```py -from view import JSON, body, post - -@post("/create") -@body("name", str) -@body("books", dict[str, str]) -def create(name: str, books: dict[str, str]): - # ... - return JSON({"message": "Successfully created user!"}), 201 -``` - -## There's C code in here, how do I know it's safe? - -view.py is put through [rigorous testing](https://github.com/ZeroIntensity/view.py/tree/master/tests), checked with [Valgrind](https://valgrind.org/), and checks for memory leaks, thanks to [Memray](https://github.com/bloomberg/memray). See the testing badges at the top. - -## Installation - -**Python 3.8+ is required.** - -### Development - -```console -$ pip install git+https://github.com/ZeroIntensity/view.py -``` - -### PyPI - -```console -$ pip install view.py -``` - -### Pipx - -```console -$ pipx install view.py -``` ## Copyright `view.py` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. - -
- -

view.py is affiliated with Space Hosting

-
diff --git a/_view.pyi b/_view.pyi deleted file mode 100644 index c18d25c4..00000000 --- a/_view.pyi +++ /dev/null @@ -1,174 +0,0 @@ -# flake8: noqa - -""" -_view - Type stubs for the view.py extension module. - -Anything in this file that is defined solely for typing purposes should be -prefixed with __ to tell the developer that its not an actual symbol defined by -the extension module. -""" - -from ipaddress import IPv4Address as __IPv4Address -from ipaddress import IPv6Address as __IPv6Address -from typing import Any as __Any -from typing import Awaitable as __Awaitable -from typing import Callable as __Callable -from typing import Coroutine as __Coroutine -from typing import Iterable as __Iterable -from typing import Literal as __Literal -from typing import NoReturn as __NoReturn -from typing import TypeVar as __TypeVar - -from view.app import App -from view.routing import RouteData as __RouteData -from view.typing import AsgiDict as __AsgiDict -from view.typing import AsgiReceive as __AsgiReceive -from view.typing import AsgiSend as __AsgiSend -from view.typing import Middleware as __Middleware -from view.typing import Parser as __Parser -from view.typing import Part as __Part -from view.typing import RouteInputDict as __RouteInput -from view.typing import StrMethodASGI as __StrMethodASGI -from view.typing import TypeInfo as __TypeInfo -from view.typing import ViewRoute as __ViewRoute - -__T = __TypeVar("__T") - -class ViewApp: - def __init__(self) -> __NoReturn: ... - async def asgi_app_entry( - self, - scope: __AsgiDict, - receive: __AsgiReceive, - send: __AsgiSend, - /, - ) -> None: ... - def _get( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _post( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _put( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _patch( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _delete( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _options( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _websocket( - self, - path: str, - callable: __ViewRoute, - cache_rate: int, - inputs: list[__RouteInput[__Any] | __RouteData], - errors: dict[int, __ViewRoute], - parts: list[__Part | str], - middleware: list[__Middleware], - /, - ) -> None: ... - def _set_dev_state(self, value: bool, /) -> None: ... - def _exc(self, status_code: int, handler: __ViewRoute, /) -> None: ... - def _supply_parsers(self, query: __Parser, json: __Parser, /) -> None: ... - def _register_error(self, error: type, /) -> None: ... - -def test_awaitable( - coro: __Coroutine[__Any, __Any, __T], / -) -> __Awaitable[__T]: ... - -class Context: - def __init__(self) -> __NoReturn: ... - - app: App - cookies: dict[str, str] - headers: HeaderDict - client: __IPv4Address | __IPv6Address | None - server: __IPv4Address | __IPv6Address | None - method: __StrMethodASGI - path: str - scheme: __Literal["http", "https"] - http_version: __Literal["1.0", "1.1", "2.0", "view_test"] - -class TCPublic: - def _compile( - self, - iterable: __Iterable[__TypeInfo], - json_parser: __Callable[[str], dict], - /, - ) -> None: ... - def _cast(self, obj: object, allow_cast: bool, /) -> __Any: ... - -class ViewWebSocket: - def __init__(self) -> __NoReturn: ... - async def accept(self) -> None: ... - async def send(self, text: str | bytes, /) -> None: ... - async def close(self) -> None: ... - async def receive(self) -> str: ... - -class InvalidStatusError(RuntimeError): ... -class WebSocketHandshakeError(RuntimeError): ... - -def setup_route_log(func: __Callable[[int | str, str, str], None], warn: __Callable[[str], None], /) -> None: ... -def register_ws_cls( - tp: type[__Any], ws_handshake_err: type[__Any], ws_err: type[__Any], /, -) -> None: ... - -class HeaderDict: - def __init__(self) -> __NoReturn: ... - def __setitem__(self, key: str, value: str, /) -> None: ... - def __getitem__(self, key: str, /) -> str | list[str]: ... - -def dummy_context(app: ViewApp | None) -> Context: ... diff --git a/assets/logo_theme_dark.png b/assets/logo_theme_dark.png deleted file mode 100644 index d2aa1fe71a4911c4fd8ead72dce33811443eec43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29956 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 diff --git a/assets/logo_theme_light.png b/assets/logo_theme_light.png deleted file mode 100644 index 3189113cd137e5ec43baa2d7f88eaea6e5dcca4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30447 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 C{e+?b diff --git a/client/index.html b/client/index.html deleted file mode 100644 index a18c0184..00000000 --- a/client/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/client/package.json b/client/package.json deleted file mode 100644 index 87a6a47e..00000000 --- a/client/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview" - }, - "devDependencies": { - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "typescript": "^5.4.5", - "vite": "^5.2.0", - "vite-plugin-singlefile": "^2.0.1" - }, - "dependencies": { - "@reactpy/client": "^0.3.1", - "react": "^18.3.1", - "react-dom": "^18.3.1" - } -} diff --git a/client/src/reactpy.tsx b/client/src/reactpy.tsx deleted file mode 100644 index fabba91f..00000000 --- a/client/src/reactpy.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { - BaseReactPyClient, - ReactPyClient, - ReactPyModule, -} from "@reactpy/client"; -import React from "react"; -import ReactDOM from "react-dom/client"; -import { Layout } from "@reactpy/client/src/components"; - -export function createReconnectingWebSocket(props: { - url: URL; - readyPromise: Promise; - onOpen?: () => void; - onMessage: (message: MessageEvent) => void; - onClose?: () => void; - startInterval: number; - maxInterval: number; - maxRetries: number; - backoffMultiplier: number; -}) { - const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; - let retries = 0; - let interval = startInterval; - let everConnected = false; - const closed = false; - const socket: { current?: WebSocket } = {}; - - const connect = () => { - if (closed) { - return; - } - socket.current = new WebSocket(props.url); - socket.current.onopen = () => { - everConnected = true; - console.info("ReactPy connected!"); - interval = startInterval; - retries = 0; - if (props.onOpen) { - props.onOpen(); - } - }; - socket.current.onmessage = props.onMessage; - socket.current.onclose = () => { - if (props.onClose) { - props.onClose(); - } - if (!everConnected) { - console.info("ReactPy failed to connect!"); - return; - } - console.info("ReactPy disconnected!"); - if (retries >= maxRetries) { - console.info("ReactPy connection max retries exhausted!"); - return; - } - console.info( - `ReactPy reconnecting in ${(interval / 1000).toPrecision( - 4, - )} seconds...`, - ); - setTimeout(connect, interval); - interval = nextInterval(interval, backoffMultiplier, maxInterval); - retries++; - }; - }; - - props.readyPromise - .then(() => console.info("Starting ReactPy client...")) - .then(connect); - - return socket; -} - -export function nextInterval( - currentInterval: number, - backoffMultiplier: number, - maxInterval: number, -): number { - return Math.min( - // increase interval by backoff multiplier - currentInterval * backoffMultiplier, - // don't exceed max interval - maxInterval, - ); -} - -export type ReconnectOptions = { - startInterval: number; - maxInterval: number; - maxRetries: number; - backoffMultiplier: number; -}; - -export type ReactPyUrls = { - componentUrl: URL; - query: string; - jsModules: string; -}; - -export type ReactPyDjangoClientProps = { - urls: ReactPyUrls; - reconnectOptions: ReconnectOptions; - mountElement: HTMLElement; - prerenderElement: HTMLElement | null; - offlineElement: HTMLElement | null; -}; - -export class ReactPyDjangoClient - extends BaseReactPyClient - implements ReactPyClient -{ - urls: ReactPyUrls; - socket: { current?: WebSocket }; - mountElement: HTMLElement; - prerenderElement: HTMLElement | null = null; - offlineElement: HTMLElement | null = null; - - constructor(props: ReactPyDjangoClientProps) { - super(); - this.urls = props.urls; - this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, - url: this.urls.componentUrl, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), - ...props.reconnectOptions, - onClose: () => { - // If offlineElement exists, show it and hide the mountElement/prerenderElement - if (this.prerenderElement) { - this.prerenderElement.remove(); - this.prerenderElement = null; - } - if (this.offlineElement) { - this.mountElement.hidden = true; - this.offlineElement.hidden = false; - } - }, - onOpen: () => { - // If offlineElement exists, hide it and show the mountElement - if (this.offlineElement) { - this.offlineElement.hidden = true; - this.mountElement.hidden = false; - } - }, - }); - this.mountElement = props.mountElement; - this.prerenderElement = props.prerenderElement; - this.offlineElement = props.offlineElement; - } - - sendMessage(message: any): void { - this.socket.current?.send(JSON.stringify(message)); - } - - loadModule(moduleName: string): Promise { - return import(`${this.urls.jsModules}/${moduleName}`); - } -} - -export function mountComponent( - mountElement: HTMLElement, - host: string, - urlPrefix: string, - routeId: string, - resolvedJsModulesPath: string, - reconnectStartInterval: number, - reconnectMaxInterval: number, - reconnectMaxRetries: number, - reconnectBackoffMultiplier: number, -) { - // Protocols - let httpProtocol = window.location.protocol; - let wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`; - - // WebSocket route (for Python components) - let wsOrigin: string; - if (host) { - wsOrigin = `${wsProtocol}//${host}`; - } else { - wsOrigin = `${wsProtocol}//${window.location.host}`; - } - - // HTTP route (for JavaScript modules) - let httpOrigin: string; - let jsModulesPath: string; - if (host) { - httpOrigin = `${httpProtocol}//${host}`; - jsModulesPath = `${urlPrefix}/web_module`; - } else { - httpOrigin = `${httpProtocol}//${window.location.host}`; - if (resolvedJsModulesPath) { - jsModulesPath = resolvedJsModulesPath; - } else { - jsModulesPath = `${urlPrefix}/web_module`; - } - } - - // Embed the initial HTTP path into the WebSocket URL - let componentUrl = new URL(`${wsOrigin}/${urlPrefix}`); - componentUrl.searchParams.append("route", routeId); - if (window.location.search) { - componentUrl.searchParams.append("http_search", window.location.search); - } - - // Configure a new ReactPy client - const client = new ReactPyDjangoClient({ - urls: { - componentUrl: componentUrl, - query: document.location.search, - jsModules: `${httpOrigin}/${jsModulesPath}`, - }, - reconnectOptions: { - startInterval: reconnectStartInterval, - maxInterval: reconnectMaxInterval, - backoffMultiplier: reconnectBackoffMultiplier, - maxRetries: reconnectMaxRetries, - }, - mountElement: mountElement, - prerenderElement: document.getElementById(mountElement.id + "-prerender"), - offlineElement: document.getElementById(mountElement.id + "-offline"), - }); - - // Replace the prerender element with the real element on the first layout update - if (client.prerenderElement) { - client.onMessage("layout-update", () => { - if (client.prerenderElement) { - client.prerenderElement.replaceWith(client.mountElement); - client.prerenderElement = null; - } - }); - } - - // Start rendering the component - const root = ReactDOM.createRoot(client.mountElement); - root.render(); -} - -mountComponent( - document.documentElement, - window.location.host, - "_view/reactpy-stream", - document.getElementById("_view-route-hook")!.innerText, - "", - 750, - 60000, - 150, - 1.25, -); diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/client/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/client/tsconfig.json b/client/tsconfig.json deleted file mode 100644 index 3f0914be..00000000 --- a/client/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - - "jsx": "react" - }, - "include": ["src"] -} diff --git a/client/vite.config.js b/client/vite.config.js deleted file mode 100644 index 4b4db39a..00000000 --- a/client/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -export default defineConfig({ - plugins: [viteSingleFile()], -}); diff --git a/docs/building-projects/app_basics.md b/docs/building-projects/app_basics.md deleted file mode 100644 index 6a5ef9de..00000000 --- a/docs/building-projects/app_basics.md +++ /dev/null @@ -1,117 +0,0 @@ -# App Basics - -## New Applications - -Every view project will have a `new_app` call. The simplest app looks like this: - -```py -from view import new_app - -app = new_app() -app.run() # You'll learn about this later -``` - -`new_app` does a few important things: - -- Loads the configuration, regardless of whether a config file exists. -- Sets the `App` address for use by `get_app` (more on that later). -- Loads finalization code for when the app closes. - -While it's not required for every app, naming your app variable `app` is the proper convention for view, as that's the default variable searched for when using the `view serve` command, but more on that in a moment. - -For now, just try to stick with naming your app file `app.py` and your `view.App` instance `app`. - -## Launching Apps - -Python libraries generally have two ways to run a web server: - -- Running via the command line. -- Launching from Python itself (e.g. a `server.start(...)` function). - -Both have their benefits and downsides, so view.py supports both out of the box. `App` comes with its `run()` method, and the view CLI has the `view serve` command. - -Generally, you're going to want to add an `app.run()` to every view.py project, like so: - -```py -from view import new_app - -app = new_app() -app.run() -``` - -This way, if you (or someone else) want to run your code programmatically, they can run it via something like `python3 app.py`. It's also more semantically clear that an app is going to start when you run that file. - -If you prefer the CLI method, you can just run `view serve` and view.py will extract the app from the file itself, ignoring the `run()` call. - -Note that this behavior is a double-edged sword, so be careful. When calling with `run()`, the Python script will never get past that line because the server will run indefinitely, but when using `view serve` it proceeds past it just fine since all it's doing is extracting the `app`, skipping `run()`. For example, take a look at this code: - -```py -from view import new_app - -app = new_app() -app.run() -print("You called the app with `view serve`!") # This only runs when `view serve` is used -``` - -### Fancy Mode - -View comes with something called "fancy mode", which is a fancy UI that shows when you run the app. If you would like to disable this, you can do one of two things: - -- Disable the `fancy` setting in configuration. -- Pass `fancy=False` to `run()`. - -You should disable it in the configuration if you completely despise fancy mode and don't want to use it at all, but if you only want to temporarily turn it off (for example, if you're a view.py developer and need to see proper output) then pass `fancy=False`. - -## Getting the App - -### Circular Imports - -If you've worked with big Python projects before, there's a good chance you've run into a circular import error. A circular import error occurs when two modules try to import each other. A view.py example of this problem would most likely be the main app file trying to import a route, but then that route tries to import the app. - -!!! note - - The below example uses routing, which if you're reading this for the first time you don't know how to use yet. Focus on the use of the `app` variable and not the routing itself. - -```py -# app.py -from view import new_app -from routes import my_route - -app = new_app() -app.load([my_route]) -app.run() -``` - -```py -# routes.py -from view import get -from app import app - -@app.get("/something") -def something(): - return "something" - -@get("/") -def index(): - return "Hello, view.py" -``` - -View gives you a solution to this problem: `get_app`. `get_app` uses some magic internally to get you your `App` instance right then and there, no import required. It works similar to how you would use `new_app`: - -```py -from view import get_app - -app = get_app() - -@app.get("/") -def index(): - return "..." -``` - -## Review - -Every view.py project should contain a call to `new_app`. `new_app` does important things like loading your configuration, set's up finalization code, and letting the `App` instance be used by `get_app`. - -Running an app can be done in two ways: programmatically via the `App.run` or through `view serve` command. However, every view.py app should contain an `App.run` to give the choice for running programmatically. By default, view.py has a fancy UI when running your app, which may be disabled via editing the config or passing `fancy=False` to `run()`. - -Finally, circular imports occur when two Python modules try to import each other, which can happen a lot in view when getting the app from the app file (especially in manual routing). To fix it, View provides a `get_app` function to get you your `App` instance without an import. diff --git a/docs/building-projects/build_steps.md b/docs/building-projects/build_steps.md deleted file mode 100644 index af4cc7b7..00000000 --- a/docs/building-projects/build_steps.md +++ /dev/null @@ -1,221 +0,0 @@ -# Runtime Builds - -## Static Exports - -In some cases, you might want to export your application as [static HTML](https://en.wikipedia.org/wiki/Static_web_page). This makes it much easier to serve your app somewhere, at the limit of being able to perform actions server-side. You can export your app in view.py via the `view build` command, or by running the `build_app` function: - -``` -$ view build -* Starting build process! -* Starting build steps -* Getting routes -* Calling GET /... -* Created ... -* Created index.html -* Successfully built app -``` - -This will export your app into a static folder called `build`, which can then be served via something like [http.server](https://docs.python.org/3/library/http.server.html). An exported route cannot contain: - -- Route Inputs -- Path Parameters -- A method other than `GET` - -As stated above, you can also build your app programatically via `build_app`: - -```py -from view import new_app -from view.build import build_app - -app = new_app() -app.load() # Call the loader manually, since we aren't calling run() - -build_app(app) -``` - -## Build Steps - -Instead of exporting static HTML, you might just want to call some build script at runtime for your app to use. For example, this could be something like a [Next.js](https://nextjs.org) app, which you want to use as the UI for your website. Each different build is called a **build step** in View. View's build system does not aim to be a full fledged build system, but instead a bridge to use other package managers or tools to build requirements for your app. It tries to be _extendable_, instead of batteries-included. - -To specify a build step, add it under `build.steps` in your configuration. A build step should contain a list of requirements under `requires` and a `command`: - -```toml -# view.toml -[build.steps.nextjs] -requires = ["npm"] -command = "npm run build" -``` - -By default, this will only be run once the app is started. If you would like to run it every time a certain route is called, add the `steps` parameter to a router function. Note that this will make your route much slower (as a build process needs to be started for every request), so it's highly recommended that you [cache](https://view.zintensity.dev/building-projects/responses/#caching) the route. - -For example: - -```py -from view import new_app - -app = new_app() - -@app.get("/", steps=["nextjs"], cache_rate=10000) # Reloads app every 10,000 requests -async def index(): - return await app.template("out/index.html") - -app.run() -``` - -## Executing Build Scripts - -Instead of running a command, you can also run a Python script. To do this, simply specify a `script` value as a path to a file instead of a `command`: - -```toml -# view.toml -[build.steps.foo] -requires = [] -script = "foo.py" -``` - -!!! note - - `__name__` is set to `__view_build__` when using a build script. If you want to use the file for other things, you can simply check `if __name__ == "__view_build__"` - -You can also specify a list of files or commands for both, to run multiple of either: - -```toml -# view.toml -[build.steps.foo] -requires = ["gcc"] -script = ["foo.py", "bar.py"] -command = ["gcc -c -Wall -Werror -fpic foo.c", "gcc -shared -o libfoo.so foo.o"] -``` - -If the script needs to run asynchronous code, export a `__view_build__` from the script: - -```py -# build.py -import aiofiles - -# This function will be run by the view.py build system -async def __view_build__(): - async with aiofiles.open("something.txt", "w") as f: - await f.write("...") -``` - -## Default Steps - -As said earlier, the default build steps are always run right before the app is started, and then never ran again (unless explicitly needed by a route). If you would like only certain steps to run, specify them with the `build.default_steps` value: - -```toml -# view.toml -[build] -default_steps = ["nextjs"] -# Only NextJS will be built on startup - -[build.steps.nextjs] -requires = ["npm"] -command = "npm run build" - -[build.steps.php] -requires = ["php"] -command = "php -f payment.php" -``` - -## Platform-Dependent Steps - -Many commands are different based on the platform used. For example, to read from a file on the Windows shell would be `type`, while on Linux and Mac it would be `cat`. If you add multiple step entries (in the form of an [array of tables](https://toml.io/en/v1.0.0-rc.2#array-of-tables)) with `platform` values, view.py will run the entry based on the platform the app was run on. - -For example, using the file reading example from above: - -Notice the double brackets next to `[[build.steps.read_from_file]]`, specifying an array of tables. - -```toml -# view.toml - -[[build.steps.read_from_file]] -platform = ["mac", "linux"] -command = "cat whatever.txt" - -[[build.steps.read_from_file]] -platform = "windows" -command = "type whatever.txt" -``` - -The `platform` value can be one of three things per entry: - -- A list of platforms. -- A string containing a single platform. -- `None`, meaning to use this entry if no other platforms match. - -For example, with a `None` platform set (on multiple entries), the above could be rewritten as: - -```toml -# view.toml - -[[build.steps.read_from_file]] -# Windows ONLY runs this step -platform = "windows" -command = "type whatever.txt" - -[[build.steps.read_from_file]] -# All other platforms run this! -command = "cat whatever.txt" -``` - -Note that only one step entry can have a `None` platform value, otherwise view.py will throw an error. - -!!! note - - The only recognized operating systems for `platform` are the big three: Windows, Mac, and any Linux based system. If you want more fine-grained control (for example, using `pacman` or `apt` depending on the Linux distro), use a custom build script that knows how to read the Linux distribution. - -## Build Requirements - -As you've seen above, build requirements are specified via the `requires` value. Out of the box, view.py supports a number of different build tools, compilers, and interpreters. To specify a requirement for one, simply add the name of their executable (_i.e._, how you access their CLI). For example, since `pip` is accessed via using the `pip` command in your terminal, `pip` is the name of the requirement. - -However, view.py might not support checking for a command by default (this is the case if you get a `Unknown build requirement` error). If so, you need a custom requirement. If you would like to, you can make an [issue](https://github.com/ZeroIntensity/view.py/issues) requesting support for it as well. - -### Custom Requirements - -There are four types of custom requirements, which are specified by adding a prefix to the requirement name: - -- Importing a Python module (`mod+`) -- Executing a Python script (`script+`) -- Checking if a path exists (`path+`) -- Checking if a command exists (`command+`) - -For example, the `command+gcc` would make sure that `gcc --version` return `0`: - -```toml -# view.toml -[build.steps.c] -requires = ["command+gcc"] -command = "gcc *.c -o out" -``` - -### The Requirement Protocol - -In a custom requirement specifying a module or script, view.py will attempt to call an asynchronous `__view_requirement__` function (similar to `__view_build__`). This function should return a `bool` value, with `True` indicating that the requirement exists, and `False` otherwise. - -!!! note - - If no `__view_requirement__` function exists, then all view.py does it check that execution or import was successful, and marks the requirement as passing. - -For example, if you were to write a requirement script that checks if the Python version is at least `3.10`, it could look like: - -```py -# check_310.py -import sys - -async def __view_requirement__() -> bool: - # Make sure we're running on at least Python 3.10 - return sys.version_info >= (3, 10) -``` - -The above could actually be used via both `script+check_310.py` and `mod+check_310`. - -!!! note - - Don't use the view.py build system to check the Python version or if a Python package is installed. Instead, use the `dependencies` section of a `pyproject.toml` file, or [PEP 723](https://peps.python.org/pep-0723/) script metadata. - -## Review - -View can build static HTML with the `view build` command, or via `view.build.build_app`. Build steps in view.py are used to call external build systems, which can then in turn be used to build things your app needs at runtime (such as static HTML generated by [Next.js](https://nextjs.org)). Builds can run commands, Python scripts, or both. - -Each build step contains a list of build requirements. View provides several known requirements to specify out of the box, but you may also specify custom requirements, either via a Python script or module, checking a file path, or executing an arbitrary command. diff --git a/docs/building-projects/documenting.md b/docs/building-projects/documenting.md deleted file mode 100644 index 0dd563d6..00000000 --- a/docs/building-projects/documenting.md +++ /dev/null @@ -1,116 +0,0 @@ -# Documenting Applications - -## What is documenting? - -Writing documentation (or "documenting", as view.py calls it) can be an important task when it comes to writing API's, but it can be extremely tedious to do manually. Other frameworks, such as [FastAPI](https://fastapi.tiangolo.com), have their own approaches to generating API documentation, a common method is by using [OpenAPI](https://www.openapis.org/). - -OpenAPI is a good choice when it comes to this topic, but View does not support it. However, [support is planned](https://github.com/ZeroIntensity/view.py/issues/103). - -For now, View has it's own system internally that does not use OpenAPI. This means that **client generation is not yet supported.** If you would like to track this issue, see it [here](https://github.com/ZeroIntensity/view.py/issues/74). - -## Writing Documentation - -On a route, you may define a route's documentation in one of two ways: - -- Passing `doc` to the router function (e.g. `@get("/", doc="Homepage")`) -- More versatile, adding a docstring to the route (e.g. `"""Homepage"""`) - -Here's an example using both: - -```py -from view import new_app - -app = new_app() - -@app.get("/", doc="The homepage") -async def index(): - ... - -@app.get("/hello") -async def hello(): - """A greeting to the user.""" - -app.docs("docs.md") # more on this function later -app.run() -``` - -## Documenting Inputs - -For route inputs, it's almost idential, except that **you cannot** use a docstring, and instead must use the `doc` parameter. This syntax is the same across both `query` and `body` (including standard and direct). - -```py -from view import new_app - -@app.get('/') -@app.query("greeting", str, doc="The greeting to be used by the server", default="hello") -async def index(greeting: str): - """The homepage that returns a greeting to the user.""" - return f"{greeting}, world!" -``` - -However, you may want to define documentation for certain object keys when using object types (i.e. they support `__view_body__` or are handled internally). In this case, you can use `typing.Annotated` and a docstring again - -- The docstring defines a description for the overall class. -- `Annotated` can provide a description for a certain key. - -```py -from view import new_app -from typing import Annotated, NamedTuple - -app = new_app() - -class Person(NamedTuple): - """A person in the world.""" - first: Annotated[str, "Their first name."] - last: Annotated[str, "Their last name."] - -@app.get("/") -@app.query("person", Person) -async def index(person: Person): - ... - -app.run() -``` - -**Note:** If you are on Python 3.8, you will get an error complaining about `Annotated` not being a part of `typing`. In this case, you can import `Annotated` from `typing_extensions` instead. - -## Autogeneration - -View will generate your API documentation into a markdown document that you could render in something like [MkDocs](https://mkdocs.org). This can be done via `App.docs()`, which will generate the markdown and write it to a file for you. - -There are, roughly speaking, two ways to write to a file via `App.docs()`: - -- Passing it a `str` or `Path`. -- Passing it a `TextIO[str]` file wrapper. - -```py -from view import new_app -from pathlib import Path - -app = new_app() - -app.docs("docs.md") -app.docs(Path("docs.md")) - -with open("docs.md", "w") as f: - app.docs(f) -``` - -Alternatively, you can also use the `view docs` command to generate your documentation: - -``` -$ view docs -- Created `docs.md` -``` - -## Review - -"Documenting" in terms of View, is the act of writing documentation. Other frameworks use [OpenAPI](https://www.openapis.org/) as a versatile solution to doing this, but [view.py does not yet support this](https://github.com/ZeroIntensity/view.py/issues/103). - -To write a description for a route, you may pass a `doc` parameter to the router call, or instead add a docstring to the route function itself. In a route input, it's quite similar, where you pass `doc` to the input function, but **using a docstring is not allowed**. However, this rule is broken in the case of using an object as the type. When using an object, you must provide a docstring to define the class's description, and use `typing.Annotated` (or `typing_extensions.Annotated`) to set descriptions for object attributes. - -Finally, you can actually generate the markdown content via the `docs()` method on your `App`, or by the `view docs` command. `docs()` can take three types of parameters: - -- A `str`, in which it opens the file path and attempts to write to it. -- A `Path`, in which the same thing happens. -- A `TextIO[str]` (file wrapper), where the file is **not opened** by View, and is instead just written to via the wrapper. diff --git a/docs/building-projects/parameters.md b/docs/building-projects/parameters.md deleted file mode 100644 index f7d07935..00000000 --- a/docs/building-projects/parameters.md +++ /dev/null @@ -1,358 +0,0 @@ -# Taking Parameters - -## Query Strings and Bodies - -If you're familiar with HTTP semantics, then you likely already know about query parameters and body parameters. If you have no idea what that is, that's okay. - -In HTTP, the query string is at the end of the URL and is prefixed with a `?`. For example, in a YouTube link (e.g. `youtube.com/watch?v=...`), the query string is `?v=...`. The query string contains key-value pairs in the form of strings. In Python, it's similiar to a `dict` that only contains strings. - -Bodies are a little bit more complicated. An HTTP body can be a number of different formats, but in a nutshell they are again, key-value pairs, but they can be a number of types. For now, JSON will be the main focus, which can have `str` keys, and any of the following as a value (in terms of Python types): - -- `str` -- `int` -- `bool` -- `dict[str, ]` - -The main similiarity here is that they are both key value pairs, which will make more sense in a moment. - -## Route Inputs - -In view.py, a route input is anything that feeds a parameter (or "input") to the route. This can be either a parameter received through the HTTP body, or something taken from the query string. View treats these two essentially the same on the user's end. Route inputs are similar to routes in the sense that there are standard and direct versions of the same thing: - -- `query` or `App.query` -- `body` or `App.body` - -There is little to no difference between the standard and direct variations, **including loading**. The direct versions are only to be used when the app is already available to **prevent extra imports**. - -## Defining Inputs - -For documentation purposes, only `query` variations will be used. However, **`body` works the exact same way**. A route input function (`query` in this case) takes one or more parameters: - -- The name of the parameter, should be a `str`. -- The type that it expects (optional). Note that this can be passed as many times as you want, and each type is just treated as a union. - -The below code would expect a parameter in the query string named `hello` of type `int`: - -```py -from view import new_app - -app = new_app() - -@app.get("/") -@app.query("hello", int) -async def index(hello: int): - print(hello) - return "hello" -``` - -The `query()` call can actually come before `get` due to the nature of the routing system. In fact, anything you decorate a route with does not have a specific order needed. For example, the following is completely valid: - -```py -@app.query("hello", int) # query comes before get() -@app.get("/") -async def index(hello: int): - ... -``` - -!!! note - - Route inputs are based on their order, and not the name of the input. For example, the following is valid: - - ```py - from view import new_app - - app = new_app() - - @app.get("/") - @app.query("hello", str) - @app.query("world", str) - async def index(world: str, hello: str): # the world parameter will get the "hello input", and vice versa - ... - ``` - -### Automatically - -If you've used a library like [FastAPI](https://fastapi.tiangolo.com), then you're probably already familiar with the concept of automatic inputs. Automatic inputs in terms of view.py are when you define route inputs without using a `query` or `body` decorator, and instead, just get input definitions through the function signature. This is the most basic example: - -```py -from view import new_app - -app = new_app() - -@app.get("/") -async def index(hello: str): # no @query needed - return f"{hello}, world" - -app.run() -``` - -Note that automatic inputs create inputs for **query parameters only**. - -!!! note - - When mixing automatic route inputs with decorators (e.g. `query` and `body`), view.py assumes that decorator inputs have the same name as the parameter. For example, the following will not work: - - ```py - from view import new_app - - app = new_app() - - @app.get("/") - @app.query("hello", str) - async def index(hello_param: str, test: str): - ... - - app.run() - ``` - -## Cast Semantics - -In query strings, only a string can be sent, but these strings can represent other data types. This idea is called **casting**, and it's not at all specific to Python. If you're still confused, think of it as calling `int()` on the string `"1"` to convert it into an integer `1`. - -View has this exact same behavior when it comes to route inputs. If you tell your route to take an `int`, view.py will do all the necessary computing internally to make sure that you get an integer in your route. If a proper integer was not sent, then the server will automatically return an error `400` (Bad Request). There are a few things that should be noted for this behavior: - -- Every type can be casted to `str`. -- Every type can be casted to `Any`. -- `bool` expects `true` and `false` (instead of Python's `True` and `False`) to fit with JSON's types. -- `dict` expects valid JSON, **not** a valid Python dictionary. - -## Typing Inputs - -Typing route inputs is very simple if you're already familiar with Python's type annotation system. Again, unions can be formed via passing multiple types instead of one. However, direct union types provided by Python are supported too. This includes both `typing.Union` and the newer `|` syntax. - -```py -from view import new_app -from typing import Union - -app = new_app() - -@app.get("/") -@app.query("name", str, int) -async def index(name: str | int): - ... - -@app.get("/hello") -@app.query("name", Union[str, int]) -async def hello(name: str | int): - ... - -@app.get("/world") -@app.query("name", str | int) -async def world(name: str | int): - ... - -app.run() -``` - -The types supported are (all of which can be mixed and matched to your needs): - -- `str` -- `int` -- `bool` -- `list` (or `typing.List`) -- `dict` (or `typing.Dict`) -- `Any` (as in `typing.Any`) -- `None` -- `dataclasses.dataclass` -- `pydantic.BaseModel` -- Classes decorated with `attrs.define` -- `typing.NamedTuple` -- `typing.TypedDict` -- Any object supporting the `__view_body__` protocol. - -### Lists and Dictionaries - -You can use lists and dictionaries in a few ways, the most simple being just passing the raw type (`list` and `dict`). In typing terms, view.py will assume that these mean `dict[str, Any]` (as all JSON keys have to be strings) and `list[Any]`. If you would like to enforce a type, simply replace `Any` with an available type. The typing variations of these types (`typing.Dict` and `typing.List`) are supported as well. - -```py -from view import new_app -from typing import Dict - -app = new_app() - -@app.get("/") -@app.query("name", Dict[str, str | int]) -async def index(name: Dict[str, str | int]): - ... - -@app.get("/hello") -@app.query("name", dict) -async def hello(name: dict): - ... - -app.run() -``` - -Note that backport is **not possible** if you're using new typing features (such as the `dict[...]` or `list[...]`) as `from __future__ import annotations` does not affect parameters, meaning that the second value sent to the route input function (again, `query` or `body`) is not changed. - -## Using Objects - -As listed about earlier, view.py supports a few different objects to be used as types. All of these objects are meant for holding data to a specific model, which can be incredibly useful in developing web apps. Some things should be noted when using these types: - -- Any annotated value types must an available type already (i.e. `str | int` is supported, but `set | str` is not). Other objects are indeed supported. -- Respected modifiers are supported (such as `dataclasses.field` on `dataclass`). -- Methods are unrelated to the parsing, and may return any type and take any parameters. Methods are not accessible to the user (as JSON doesn't have methods). - -Here's an example using `dataclasses`: - -```py -from view import new_app -from dataclasses import dataclass, field -from typing import List - -app = new_app() - -@dataclass -class Person: - first: str - last: str - favorite_foods: List[str] = field(default_factory=list) - -@app.get("/") -@app.query("me", Person) -async def index(me: Person): - return f"Hello, {me.first} {me.last}" -``` - -If you would prefer to not use an object, View supports using a `TypedDict` to enforce parameters. It's subject to the same rules as normal objects, but is allowed to use `typing.NotRequired` to omit keys. Note that `TypedDict` **cannot** have default values. - -```py -from view import new_app -from typing import TypedDict, NotRequired, List - -app = new_app() - -class Person(TypedDict): - first: str - last: str - favorite_foods: NotRequired[List[str]] - -@app.get("/") -@app.query("me", Person) -async def index(me: Person): - return f"Hello, {me['first']} {me['last']}" -``` - -## Type Validation API - -You can use view.py's type validator on your own to do whatever you want. To create a validator for a type, use `compile_type`: - -```py -from view import compile_type - -validator = compile_type(str | int) -``` - -!!! danger - - The above code uses the `|` syntax, which is only available to Python 3.9+ - -With a validator, you can do three things: - -- Cast an object to the type. -- Check if an object is compatible with the type. -- Check if an object is compatible, without the use of casting. - -`cast` will raise a `TypeValidationError` if the type is not compatible: - -```py -from view import compile_type - -tp = compile_type(dict) -tp.cast("{}") -tp.cast("123") # TypeValidationError -``` - -The difference between `check_type` and `is_compatible`, is that `check_type` is a [type guard](https://mypy.readthedocs.io/en/latest/type_narrowing.html), which `is_compatible` is not. - -This means that `check_type` will ensure that the object is _an instance_ of the type, while `is_compatible` checks whether it can be casted. For example: - -```py -from view import compile_type - -tp = compile_type(dict) - -x: Any = {} -y: Any = {} # you could also use the string "{}" here - -if tp.check_type(x): - reveal_type(x) # dict - # to a type checker, x is now a dict - -if tp.is_compatible(y): - reveal_type(y) # Any - # a type checker doesn't know that y is a dict -``` - -## Body Protocol - -If any of the above types do not support your needs, you may design your own type with the `__view_body__` protocol. On a type, `__view_body__` can be held in one of two things: - -- An attribute (e.g. `cls.__view_body__ = ...`) -- A property -- A static (or class) method. - -Whichever way you choose, the `__view_body__` data must be accessed statically, **not in an instance**. The data should be a dictionary (containing only `str` keys, once again), but the values should be types, not instances. These types outline how view.py should parse it at runtime. For example, a `__view_body__` to create an object that has a key called `a`, which a `str` value would look like so: - -```py -class MyObject: - __view_body__ = {"a": str} -``` - -View **does not** handle the initialization, so you must define a proper `__init__` for it. If you are already using the `__init__` for something else, you can define a `__view_construct__` class or static method and view.py will choose it over `__init__`. - -```py -class MyObject: - __view_body__ = {"a": str} - - @classmethod - def __view_construct__(cls, **kwargs): - self = cls() - self.a: str = kwargs["a"] -``` - -### Default Types and Unions - -`__view_body__` works the same as standard object types would work in the sense that types like `typing.Union` or the `|` syntax are supported, but you may also use a special value called `BodyParam`. `BodyParam` will allow you to pass union types in a tuple and set a default value. If you only want one type when using `BodyParam`, simply set `types` to a single value instead of a tuple. Here's an example of how it works, with the original object from above: - -```py -class MyObject: - __view_body__ = { - "a": view.BodyParam(types=(str, int), default="hello"), - "b": view.BodyParam(types=str, default="world"), - } - - @classmethod - def __view_construct__(cls, **kwargs): - self = cls() - self.a: str | int = kwargs["a"] - self.a: str = kwargs["b"] -``` - -## Client Semantics - -On the client side, sending data to view.py might be a bit unintuitive. For this part of the documentation, a JSON body will be used for simplicity. In the case of JSON, strings will be casted to a proper type if the route supports it. For example, if a route takes `a: str | int`, the following would be set to the integer `1`, not `"1"`. - -```json -{ - "a": "1" -} -``` - -Objects are simply formatted in JSON as well. If you had an object under the parameter name `test` and that object had the key `a: str`, it would be sent to the server like so: - -```py -{ - "test": { - "a": "..." - } -} -``` - -## Review - -View treats queries and bodies more or less equivalent, as they are both key value pairs. Strings can be casted to every other type assuming that it is in the proper format, and that's what makes it work. - -Any body or query parameter to a route is called a route input. There are standard and direct inputs (`body` and `query`, `App.body` and `App.query`), but they are not same in the way standard and direct routers work (direct inputs only exist to prevent extra imports). - -A route input function takes two parameters, the name (which is always a `str`), and the (optional) type(s). All the supported types are JSON types with the exception of some object structures (which are translated to a `dict`/JSON internally). `__view_body__` and `__view_construct__` can be used to implement special types that will be parsed by view. diff --git a/docs/building-projects/request_data.md b/docs/building-projects/request_data.md deleted file mode 100644 index 7c2cf1eb..00000000 --- a/docs/building-projects/request_data.md +++ /dev/null @@ -1,184 +0,0 @@ -# Request Data - -## The Context - -If you've used a framework like [Django](https://djangoproject.com) or [FastAPI](https://fastapi.tiangolo.com), you've likely used a `request` parameter (or a `Request` type). View has something similiar, called `Context`. - -The `Context` instance contains information about the incoming request, including: - -- The headers. -- The cookies. -- The HTTP version. -- The request method. -- The URL path. -- The client and server address. - -!!! info - - `Context` is an [extension type](https://docs.python.org/3/extending/newtypes_tutorial.html), and is defined in the `_view` module. It's Python signatures are defined in the `_view` type stub. - -## Context Input - -The context can be added to a route via a route input, which is done through the `context` decorator. Note that `context` has a standard and direct variation (`App.context` is available to prevent imports). - -For example: - -```py -from view import new_app, context, Context - -app = new_app() - -@app.get("/") -@context -async def index(ctx: Context): - print(ctx.headers["user-agent"]) - return "..." - -app.run() -``` - -Since `context` is a route input, it can be used alongside other route inputs: - -```py -from view import new_app, Context - -app = new_app() - -@app.get("/") -@app.query("greeting", str) -@app.context # direct variation -async def index(greeting: str, ctx: Context): - return f"{greeting}, {ctx.headers['place']}" - -app.run() -``` - -### Automatic Input - -`Context` works well with the automatic input API (similar to how you would do it in [FastAPI](https://fastapi.tiangolo.com)), like so: - -```py -from view import new_app, Context - -app = new_app() - -@app.get("/") -async def index(ctx: Context): # this is allowed - ... - -app.run() -``` - -## Detecting Tests - -`Context` can also be used to detect whether the route is being used via `App.test`, through the `http_version` attribute. - -!!! info - - `App.test` is a more internal detail, but is available for public use. It looks like this: - - ```py - from view import new_app - import asyncio - - app = new_app() - - @app.get("/") - async def index(): - return "hello, view.py" - - async def main(): - async with app.test() as test: - res = await test.get("/") - assert res.message == "hello, view.py" - - if __name__ == "__main__": - asyncio.run(main()) - ``` - -When a route is being used via `App.test`, `http_version` is set to `view_test`. For example: - -```py -from view import new_app, Context -import asyncio - -app = new_app() - -@app.get("/") -@app.context -async def index(context: Context): - if context.http_version == "view_test": - return "this is a test!" - - return "hello, view.py" - -async def main(): - async with app.test() as test: - res = await test.get("/") - assert res.message == "this is a test!" - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Cookies - -Technically speaking, cookies in HTTP are done via [headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie), but typically cookies in Python frameworks are done in a `dict` instead. View is no exception to this. - -Cookies can be viewed from the `cookies` attribute. However, since the `Context` is **not related** to the response, you must use `cookie` on a `Response` object to mutate a cookie. For example: - -```py -from view import new_app, Context, Response - -app = new_app() - -@app.get("/") -async def index(ctx: Context): # automatic route input - count = int(ctx.cookies.get("count") or 0) - count += 1 - res = Response(f"you have been to this page {count} time(s)") - res.set_cookie("count", str(count)) - return res - -app.run() -``` - -## Server and Client Address - -The `Context` also provides information about the servers binding address, as well as the address of the client. This information is provided in the form of an `ipaddress.IPv4Address` or `ipaddress.IPv6Address` (see the [ipaddress module](https://docs.python.org/3/library/ipaddress.html)), and then the port is in a seperate attribute. - -Both `Context.client` and `Context.client_port` may be `None`, but note that **if either of them is not `None`, the other one will be non `None`**. For example: - -```py -from view import new_app, Context - -app = new_app() - -@app.get('/') -@app.context -async def index(ctx: Context): - if ctx.client: - port = ctx.client_port # this will always be an int in this case - ... - -app.run() -``` - -!!! danger - - The above is **not** type safe. The type checker will still believe the `port` is `int | None`. - -## Review - -`Context` is similiar to `Request` in other web frameworks, and is considered to be a route input in View, meaning you can add it to a route via the `context` decorator (or `App.context`, to prevent an extra import), or by the automatic route input system (i.e. adding a parameter annotated with type `Context`). - -`Context` contains eight attributes: - -- `headers`, of type `dict[str, str]`. -- `cookies`, of type `dict[str, str]`. -- `client`, of type `ipaddress.IPv4Address`, `ipaddress.IPv6Address`, or `None`. -- `server`, of type `ipaddress.IPv4Address`, `ipaddress.IPv6Address`, or `None`. -- `method`, of type `StrMethodASGI` (uppercase string containing the method, such as `"GET"`). -- `path`, of type `str`. -- `scheme`, which can be the string `"http"`, `"https"`. -- `http_version`, which can be the string `"1.0"`, `"1.1"`, `"2.0"`, `"view_test"`. diff --git a/docs/building-projects/responses.md b/docs/building-projects/responses.md deleted file mode 100644 index eb28206d..00000000 --- a/docs/building-projects/responses.md +++ /dev/null @@ -1,303 +0,0 @@ -# Returning Responses - -## Basic Responses - -In any web framework, returning a response can be as simple as returning a string of text or quite complex with all sorts of things like server-side rendering. Right out of the box, View supports returning status codes, headers, and a response without any fancy tooling. A response **must** contain a body (this is a `str` or `bytes`), but may also contain a status (`int`) or headers (`dict[str, str]`). These may be in any order. - -```py -from view import new_app - -app = new_app() - -@app.get("/") -async def index(): - return "Hello, view.py", 201, {"x-my-header": "my_header"} -``` - -## HTTP Errors - -Generally when returning a [client error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses) or [server error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#server_error_responses), you want to skip future execution. For example: - -```py -from view import new_app - -app = new_app() - -@app.get("/") -async def index(number: int): - if number == 1: - return "number cannot be one", 400 - - return f"your number is {number}" - -app.run() -``` - -However, manually returning can be messy. For this, view.py provides you the `Error` class, which behaves like an `Exception`. It takes two parameters: - -- The status code, which is `400` by default. -- The message to send back to the user. If this is `None`, it uses the default error message (e.g. `Bad Request` for error `400`). - -Since `Error` works like a Python exception, you can `raise` it just fine: - -```py -from view import new_app, Error - -app = new_app() - -@app.get("/") -async def index(number: int): - if number == 1: - raise Error(400) - - return f"your number is {number}" - -app.run() -``` - -!!! warning - - `Error` can only be used to send back *error* responses. It can **not** be used to return status codes such as `200`. - -## Caching - -Sometimes, computing the response for a route can be expensive or unnecessary. For this, view.py, along with many other web frameworks, provide the ability to cache responses. - -View lets you do this by using the `cache_rate` parameter on a router. - -For example: - -```py -from view import new_app - -app = new_app() - -@app.get("/", cache_rate=10) # reload this route every 10 requests -async def index(): - return "..." - -app.run() -``` - -You can see this in more detail by using a route that changes it's responses: - -```py -from view import new_app - -app = new_app() -count = 1 - -@app.get("/", cache_rate=10) -async def index(): - global count - count += 1 - return str(count) - -app.run() -``` - -In the above example, `index` is only called every 10 requests, so after 20 calls, `count` would be `2`. - -## Response Protocol - -If you have some sort of object that you want to wrap a response around, view.py gives you the `__view_result__` protocol. The only requirements are: - -- `__view_result__` is available on the returned object (doesn't matter if it's static or instance) -- `__view_result__` returns data that corresponds to the allowed return values. - -For example, a type `MyObject` defining `__view_result__` could look like: - -```py -from view import new_app - -app = new_app() - -class MyObject: - def __view_result__(self): - return "Hello from MyObject!", 201, {"x-www-myobject": "foo"} - -@app.get("/") -async def index(): - return MyObject() # this is ok - -app.run() -``` - -Note that in the above scenario, you wouldn't actually need a whole object. Instead, you could also just define a utility function: - -```py -def _response(): - return "Hello, view.py!", 201, {"foo": "bar"} - -@app.get("/") -async def index(): - return _response() -``` - -## Response Objects - -View comes with two built in response objects: `Response` and `HTML`. - -- `Response` is simply a wrapper around other responses. -- `HTML` is for returning HTML content. -- `JSON` is for returning JSON content. - -A common use case for `Response` is wrapping an object that has a `__view_result__` and changing one of the values. For example: - -```py -from view import new_app, Response - -app = new_app() - -class Test: - def __view_result__(self): - return "test", 201 - -@app.get("/") -async def index(): - return Response(Test(), status=200) # 200 is returned, not 201 - -app.run() -``` - -Another common case for `Response` is using cookies. You can add a cookie to the response via the `cookie` method: - -```py -@app.get("/") -async def index(): - res = Response(...) - res.cookie("hello", "world") - return res -``` - -Note that **all response classes inherit from `Response`**, meaning you can use this functionality anywhere. - -!!! note - - A `Response` must be *returned* for things like `cookie` to take effect. For example: - - ```py - from view import new_app, Response - - app = new_app() - - @app.get("/") - async def index(): - res = Response(...) - return "..." # res is not returned! - - app.run() - ``` - -### Body Translate Strategy - -The body translate strategy in the `__view_result__` protocol refers to how the `Response` class will translate the body into a `str`. There are four available strategies: - -- `str`, which uses the object's `__str__` method. -- `repr`, which uses the object's `__repr__` method. -- `result`, which calls the `__view_result__` protocol implemented on the object (assuming it exists). -- `custom`, uses the `Response` instance's `_custom` attribute (this only works on subclasses of `Response` that implement it). - -For example, the route below would return the string `"'hi'"`: - -```py -from view import new_app, Response - -app = new_app() - -@app.get("/") -async def index(): - res = Response('hi', body_translate="repr") - return res - -app.run() -``` - -### Implementing Responses - -`Response` is a [generic type](https://mypy.readthedocs.io/en/stable/generics.html), meaning you should supply it a type argument when writing a class that inherits from it. - -For example, if you wanted to write a type that takes a `str`: - -```py -class MyResponse(Response[str]): - def __init__(self, body: str) -> None: - super().__init__(body) -``` - -Generally, you'll want to use the `custom` translation strategy when writing custom `Response` objects. - -You must implement the `translate_body` method (which takes in the `T` passed to `Response`, and returns a `str`) to use the `custom` strategy. For example, the code below would be for a `Response` type that formats a list: - -```py -from view import Response - -class ListResponse(Response[list]): - def __init__(self, body: list) -> None: - super().__init__(body, body_translate="custom") - - def translate_body(self, body: list) -> str: - return " ".join(body) -``` - -## Middleware - -### The Middleware API - -`Route.middleware` is used to define a middleware function for a route. Like other web frameworks, middleware functions are given a `call_next`. Note that `call_next` is always asynchronous regardless of whether the route is asynchronous. - -```py -from view import new_app, CallNext - -app = new_app() - -@app.get("/") -def index(): - return "my response!" - -@index.middleware -async def index_middleware(call_next: CallNext): - print("this is called before index()!") - res = await call_next() - print("this is called after index()!") - return res - -app.run() -``` - -### Response Parsing - -As shown above, `call_next` returns the result of the route. However, dealing with the raw response tuple might be a bit of a hassle. Instead, you can convert the response to a `Response` object using the `to_response` function: - -```py -from view import new_app, CallNext, to_response -from time import perf_counter - -app = new_app() - -@app.get("/") -def index(): - return "my response!" - -@index.middleware -async def took_time_middleware(call_next: CallNext): - a = perf_counter() - res = to_response(await call_next()) - b = perf_counter() - res.headers["X-Time-Elapsed"] = str(b - a) - return res - -app.run() -``` - -## Review - -Responses can be returned with a string, integer, and/or dictionary in any order. - -- The string represents the body of the response (e.g. HTML or JSON) -- The integer represents the status code (200 by default) -- The dictionary represents the headers (e.g. `{"x-www-my-header": "some value"}`) - -`Response` objects can also be returned, which implement the `__view_result__` protocol. All response classes inherit from `Response`, which supports operations like setting cookies. - -Finally, the `middleware` method on a `Route` can be used to implement middleware. diff --git a/docs/building-projects/routing.md b/docs/building-projects/routing.md deleted file mode 100644 index e783eab8..00000000 --- a/docs/building-projects/routing.md +++ /dev/null @@ -1,260 +0,0 @@ -# Routing - -## Loaders - -Routing is a big part of any web library, and there are many ways to do it. View does it's best to support as many methods as possible to give you a well-rounded approach to routing. In view, your choice of routing is called the loader/loader strategy, and there are five of them: - -- `manual` -- `simple` -- `filesystem` -- `patterns` -- `custom` - -## Manually Routing - -If you're used to Python libraries like [Flask](https://flask.palletsprojects.com/en/3.0.x/) or [FastAPI](https://fastapi.tiangolo.com), then you're probably already familiar with manual routing. Manual routing is considered to be letting the user do all of the loading themself, and not do any automatic import or load mechanics. There are two ways to do manual routing, directly calling on your `App` being the most robust. Here's an example: - -```py -from view import new_app - -app = new_app() - -@app.get("/") -def index(): - return "Hello, view.py!" - -app.run() -``` - -This type of function is called a **direct router**, and is what's recommended for small view.py projects. However, if you're more accustomed to JavaScript libraries, using the **standard routers** may be a good fit. When using manual routing, a standard router must be registered via a call to `App.load`. - -!!! question "What should I annotate as the return value?" - - view.py is aimed at people who love type hints, so what type should a route return? Well, all router functions will automatically tell the type checker what a route should return (specifically, [ViewResult](https://view.zintensity.dev/reference/types/#view.typing.ViewResult)), but if you *really* want to specify the return value, you have two options: - - - Use something route-specific, so something like `tuple[str, int]`, or just `str` - - More robust, using `ViewResult`, as mentioned above. - - The recommended way is to annotate the return value as `ViewResult`, but again, this is already known by the type checker: - - ```py - from view import new_app, ViewResult - - @app.get("/") - def index() -> ViewResult: - return "Hello, view.py!" - - app.run() - ``` - -### Standard and Direct Routers - -Standard routers and direct routers have **the exact same** API (i.e. they are called the same way). The only difference is that direct routers automatically register a route onto the app, while standard routes do not. Direct routers tend to be used in small projects under manual loading, but standard routers are used in larger applications with one of the other loaders. - -Here are all the routers (standard on left, direct on right): - -- `view.get` and `App.get` -- `view.post` and `App.post` -- `view.put` and `App.put` -- `view.patch` and `App.patch` -- `view.delete` and `App.delete` -- `view.options` and `App.options` - -```py -from view import new_app, get - -app = new_app() - -@get("/") -def index(): - return "Hello, view.py!" - -app.load([get]) -app.run() -``` - -This method may be a bit more versatile if you plan on writing a larger project using manual routing, as you can import your routes from other files, but if that's the case it's recommended that you use one of the other loaders. - -!!! tip - - Use the direct variation if the `App` is already available, and use the standard version otherwise. - -### Methodless Routing - -So far, only routers that allow a single method are allowed. If you're familiar with the [Flask](https://flask.palletsprojects.com) framework, you've likely tried the `route` method that lets any a route be accessed with any method. View supports the same thing, via the `route` router function, and the `App.route` direct variation. - -For example: - -```py -from view import new_app, route - -app = new_app() - -@route("/") -async def index(): - return "this can be accessed with any method!" - -app.load([index]) -app.run() -``` - -You can specify certain methods via the `methods` parameter: - -```py -from view import new_app - -app = new_app() - -@app.route("/", methods=("GET", "POST")) # using the direct variation -async def index(): - return "this can be accessed with only GET and POST" - -app.run() -``` - -## Simple Routing - -Simple routing is similar to manual routing, but you tend to not use direct routers and don't have any call to `load()`. In your routes directory (`routes/` by default, `loader_path` setting), your routes will be held in any number of files. Simple loading is recursive, so you may also use folders. View will automatically extract any route objects created in these files. - -```py -# routes/foo.py -from view import get - -@get("/foo") -def index(): - return "foo" - -@get("/bar") -def bar(): - return "bar" -``` - -`/foo` and `/bar` will be loaded properly, no extra call to `App.load` is required. In fact, you don't even have to import these in your app file. **This is the recommended loader for larger view.py projects.** - -## URL Pattern Routing - -If you have ever used [Django](https://djangoproject.com), you already know how URL pattern routing works. Instead of defining your routes all over the place, all routes are defined and imported into one central file. Traditionally, this file is called `urls.py`, but you can play around with the name via the `loader_path` configuration option. - -Pattern loading looks like this in view.py: - -```py -# something.py - -def my_route(hello: str): - return f"{hello}, world!" -``` - -```py -from view import path, query -from something import my_route - -patterns = ( - path("/", my_route, query("hello")), # this is a route input, you'll learn about this later - path("/another/thing", "/this/can/be/a/path/to/file.py") -) -``` - -In the above example, we defined two routes via exporting a `tuple` of `Route` objects (generated by `path`). The name `patterns` was used as the variable name, but it may be any of the following: - -- `PATTERNS` -- `patterns` -- `URLPATTERNS` -- `URL_PATTERNS` -- `urlpatterns` -- `url_patterns` - -!!! tip - - Traditionally, Python constants are denoted via using the `SCREAMING_SNAKE_CASE` naming convention. - To follow Python convention, use `PATTERNS` or `URL_PATTERNS` when using the `patterns` loader. - -## Filesystem Routing - -Finally, if you're familiar with JavaScript frameworks like [NextJS](https://nextjs.org), you're likely already familiar with filesystem routing. If that's the case, this may be the proper loader for you. The filesystem loader works by recursively searching your `loader_path` (again, `routes/` by default) and assigning each found file to a route. You do not have to pass an argument for the path when using filesystem routing. - -Filesystem routing comes with a few quirks. - -- There should only be one route per file. -- The upper directory structure is ignored, so `/home/user/app/routes/foo.py`, the assigned route would be `/foo`. -- If a file is named `index.py`, the route is not named `index`, but instead the parent (e.g. `foo/hello/index.py` would be assigned to `foo/hello`). -- If a file is prefixed with `_` (e.g. `_hello.py`), then it will be skipped entirely and not loaded. Files like this should be used for utilities and such. - -Here's an example of this in action: - -```py -# routes/_util.py - -def do_something(): - ... -``` - -```py -# routes/index.py -from view import get -from _util import do_something - -@get() -def index(): - do_something() - return "Hello, view.py!" -``` - -## Custom Routing - -The `custom` loader is, you guessed it, a user-defined loader. To start, decorate a function with `custom_loader`: - -```py -from pathlib import Path -from typing import Iterable -from view import Route, new_app - -app = new_app() - -@app.custom_loader -def my_loader(app: App, path: Path) -> Iterable[Route]: - return [...] - -app.run() -``` - -As shown above, there are two parameters to the `custom_loader` callback: - -- The `App` instance. -- The `Path` set by the `loader_path` config setting. - -The `custom_loader` callback is expected to return a list (or any iterable) of collected routes. - -!!! tip "Don't reimplement router functions!" - - You might be confused about the `Route` constructor. That's because it's undocumented, and still technically a private API (meaning it can change at any time, for no reason). Don't try and instantiate a route yourself! Instead, let router functions do it (e.g. `get` or `query`), and collect the functions (or really, `Route` instances) - -For example, if you wanted to implement a loader that added one route: - -```py -from pathlib import Path -from typing import Iterable -from view import Route, new_app, get - -app = new_app() - -@app.custom_loader -def my_loader(app: App, path: Path) -> Iterable[Route]: - # Disregarding the app and path here! Don't do that! - @get("/my_route") - def my_route(): - return "Hello from my loader!" - - return [my_route] - -app.run() -``` - -## Review - -In view, a loader is defined as the method of routing used. There are three loaders in view.py: `manual`, `simple`, and `filesystem`. - -- `manual` is good for small projects that are similar to Python libraries like [Flask](https://flask.palletsprojects.com/en/3.0.x/) or [FastAPI](https://fastapi.tiangolo.com). -- `simple` routing is the recommended loader for full-scale view.py applications. -- `filesystem` routing is similar to how JavaScript frameworks like [NextJS](https://nextjs.org) handle routing. -- `patterns` is similar to [Django](https://djangoproject.com/) routing. -- `custom` let's you decide - you can make your own loader and figure it out as you please. diff --git a/docs/building-projects/templating.md b/docs/building-projects/templating.md deleted file mode 100644 index a20ca586..00000000 --- a/docs/building-projects/templating.md +++ /dev/null @@ -1,184 +0,0 @@ -# HTML Templating - -## What is templating? - -If you're building any sort of website, you likely don't want to write HTML from Python strings. Instead, you would rather just render HTML files and keep your Python code seperate. - -However, this has a drawback: **you can't put variables into your HTML.** Nearly all Python web frameworks use templating as a solution. - -Templating is the use of a template engine to put Python code in your HTML. For a more in-depth explanation, see the [Python Wiki](https://wiki.python.org/moin/Templating). - -## Templating API - -In View, the main template API entry point is the `template` function. Because this function performs I/O, it is asynchronous. - -The only required argument to `template` is the name or path of the template to be used. For example: - -```py -from view import new_app, template - -app = new_app() - -@app.get("/") -async def index(): - return await template("index") # this refers to index.html - -@app.get("/other") -async def other(): - return await template("index.html") # works the same way - -app.run() -``` - -The most notable difference about view.py's templating API is that parameters are automatically included from your scope (i.e. you don't have to pass them into the call to `template`). If you're against this behavior, you may disable it in the configuration via the `globals` and `locals` settings. - -You can override the template engine and settings via the `engine` and `directory` parameters. For example, if the engine was `view`, the below would use `mako`: - -```py -from view import new_app, template - -app = new_app() - -@app.get('/') -async def index(): - return await template("index", engine="mako") - -app.run() -``` - -There's also a direct variation of `template` on `App`. - -```py -from view import new_app - -app = new_app() - -@app.get("/") -async def index(): - return await app.template("index") - -app.run() -``` - -The following template engines are supported: - -- View's built-in engine -- [Jinja](https://jinja.palletsprojects.com/en/3.1.x/) -- [Django Templates](https://docs.djangoproject.com/en/5.0/intro/tutorial03/) -- [Mako](https://www.makotemplates.org/) -- [Chameleon](https://chameleon.readthedocs.io/en/latest/) - -## The View Engine - -View has it's own built in template engine that is used by default. It's based around the usage of a `` tag, which is more limited, yet pretty to look at. - -A `` element can have any of the following attributes: - -- `ref`: Can be any Python expression (including variable references). -- `template`: Loads another template in place. -- `if`: Shows the element if the expression is truthy. -- `elif`: Shows the element if the expression is truthy and if the previous `if` or `elif` was falsy. -- `else`: Shows the element if all the previous `if` and `elif`'s were falsy. -- `iter`: May be any iterable expression. An `item` attribute must be present if this attribute is set. -- `item`: Specifies the name for the item in each iteration. Always present when `iter` is set. - -### Examples - -`ref` can be used to take variables, but may also be used to display any Python expression. For example, if you had defined `hello = "world"`: - -```html -

Hello,

-

The length of hello is

-``` - -If you had declared `my_list = [1, 2, 3]`, you could iterate through it like so: - -```html - - - -``` - -The above would result in `123` - -`if`, `elif`, and `else` are only shown if their cases are met. So, for example: - -```html - - - - - - - -

You must be an admin to use the admin panel!

-
-``` - -## Using Other Engines - -If you would like to use an unsupported engine (or use extra features of a supported engine), you can do one of two things: - -- Make a feature request on [GitHub](https://github.com/ZeroIntensity/view.py) requesting for support. -- Manually use it's API to return a response from a route. - -For example, if you wanted to customize [Jinja](https://jinja.palletsprojects.com/en/3.1.x/), you shouldn't use View's `template`, but instead just use it manually: - -```py -from view import new_app -from jinja2 import Environment - -app = new_app() -env = Environment() - -@app.get('/') -async def index(): - return env.get_template("mytemplate.html").render() - -app.run() -``` - -However, if you would like to access the engine instance (such as Jinja's `Environment`), you can get it from `app.templaters`, or set the value yourself. For example: - -```py -from view import new_app -from jinja2 import Environment - -app = new_app() -env = Environment() -app.templaters["jinja"] = env - -@app.get('/') -async def index(): - return await app.template("index.html") - -app.run() -``` - -## Markdown Rendering - -Many find writing raw HTML to be a hassle in many cases. For this, view.py provides the `markdown` function, which can turn markdown content into HTML, similar to how [MkDocs](https://mkdocs.org) does: - -```py -from view import new_app, markdown - -app = new_app() - -@app.get("/blog") -async def index(): - return await markdown("blog.md") - -app.run() -``` - -## Review - -Template engines are used to mix your Python code and HTML. You can use View's `template` or (`App.template`, if the `App` is available already) function to render a template with one of the supported engines, which are: - -- view.py's built-in engine -- [Jinja](https://jinja.palletsprojects.com/en/3.1.x/) -- [Django Templates](https://docs.djangoproject.com/en/5.0/intro/tutorial03/) -- [Mako](https://www.makotemplates.org/) -- [Chameleon](https://chameleon.readthedocs.io/en/latest/) - -If you would like to use an unsupported engine, you can make a feature request on [GitHub](https://github.com/ZeroIntensity/view.py/issues), or use it's API manually. diff --git a/docs/building-projects/websockets.md b/docs/building-projects/websockets.md deleted file mode 100644 index fa688202..00000000 --- a/docs/building-projects/websockets.md +++ /dev/null @@ -1,174 +0,0 @@ -# WebSockets - -!!! question "What is a WebSocket?" - - In web development, a WebSocket is a two-way communication channel used on websites. Read more about them [here](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API). - -## WebSocket Routers - -Like other routers, the `websocket` router has both a standard and direct variation, with the same API. Unlike other routers, a WebSocket comes with one input out of the box, that being the actual WebSocket object. - -!!! danger "Other Inputs" - - You can add `query` inputs and path parameters to a `websocket` route, but not a `body` input. - -A WebSocket route does also not care what you return. In fact, a type checker expects that routes decorated with `websocket` return `None`. - -For example, a WebSocket that does nothing is as follows: - -```py -from view import new_app, WebSocket - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - ... - -app.run() -``` - -!! warning - - If you installed `uvicorn` manually, make sure to install `websockets` or `wsproto` if you plan on using WebSockets: - - ``` - $ pip install websockets - ``` - -## Handshakes - -If you used the above code, it wouldn't actually work as a WebSocket from the client, since we don't accept the connection. - -Like other libraries, view.py does not automatically decide the lifecycle of your WebSocket handshake, meaning you have to manually `accept` and `close` it. For example, adding on to our above example that does nothing: - -```py -from view import new_app, WebSocket - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - await ws.accept() - await ws.close() - -app.run() -``` - -Now, we could actually use a WebSocket client to access this route. However, you should use a context manager instead of manually calling lifecycle methods: - -```py -from view import new_app, WebSocket - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - async with ws: - ... - -app.run() -``` - -!!! note Client Disconnect - - A `WebSocketHandshakeError` is raised if the client disconnects before the server calls `close`. - -## Sending and Receiving - -Now, let's make our WebSocket do something! We can use `send` and `receive` to send and receive data. - -The best way to understand these methods is visually, so a simple chat application could look like: - -```py -from view import new_app, WebSocket -import aiofiles # For asynchronous input() - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - await ws.accept() # We shouldn't ever need to exit, so no need for a context manager - while True: - await ws.send(await aiofiles.stdin.readline()) - print("Them:", await ws.receive()) - -app.run() -``` - -### Receiving Types - -Using view.py's type-casting system, you can specify a type to receive from the client by passing `tp` to `receive`. The supported types are: - -- `str` -- `int` -- `bool` -- `bytes` -- `dict` - -For example, if you wanted to receive JSON data: - -```py -from view import new_app, WebSocket - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - async with ws: - json = await ws.receive(tp=dict) - -app.run() -``` - -## Message Pairs - -In many cases, such as with our chat app from above, we want a 1:1 ratio of messages from the server to the client. view.py gives you the `pair` method, to remove some boilerplate. It simply sends a message, then returns a received message. For example, with our chat app from above: - -```py -from view import new_app, WebSocket -import aiofiles - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - await ws.accept() - while True: - print("Them:", await ws.pair(await aiofiles.stdin.readline())) - -app.run() -``` - -As stated above, `pair` sends the message before receiving, but you can reverse this by passing `recv_first=True`: - -```py -print("Them:", await ws.pair(await aiofiles.stdin.readline(), recv_first=True)) -``` - -This would receive from the client, _then_ send a message, and then return that received message. - -## Expecting Messages - -In some cases, you might just want the client to send some data to ensure compliance with a protocol, or perhaps for a [ping-pong](https://en.wikipedia.org/wiki/Ping-pong_scheme). You can use the `expect` method, which ensures that the client send some data, and then discards the message. For example: - -```py -from view import new_app, WebSocket - -app = new_app() - -@app.websocket("/") -async def websocket_route(ws: WebSocket): - async with ws: - await ws.expect("MYPROTOCOL V1.1") - await ws.send("ACK") - # ... - -app.run() -``` - -## Review - -WebSocket routes always have at least one route input, that being a `WebSocket` object representing the connection. view.py does not handle the connection lifetime, so calling `accept`, `close`, or using the context manager is up to the user. - -Data can be sent and received via `send` and `receive` (who would have guessed!), and certain types can be expected from the client via the `tp` parameter. You can also use the `pair` method to eliminate some boilerplate when it comes to 1:1 message correspondence, as well as use the `expect` method to expect that the client sends some data. diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index e8528842..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -hide: - - navigation ---- - ---8<-- "CONTRIBUTING.md" diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md deleted file mode 100644 index 2736a5e7..00000000 --- a/docs/getting-started/configuration.md +++ /dev/null @@ -1,163 +0,0 @@ -# Configuration - -## Introduction - -Before you can make any projects with view.py, you should learn about how it handles configuration. Configuration is handled by the [configzen](https://github.com/bswck/configzen) library under the hood, so most questions about configuration will be answered there. - -## The Config File - -When creating your app, view will search for one of the following configuration files: - -- `view.toml` -- `view.json` -- `view.ini` -- `view_config.py` - -Note that while all of these are different formats, they can all evaluate to the same thing internally. If you have any questions on these semantics, once again see [configzen](https://github.com/bswck/configzen). - -## Programatically - -Many Python users aren't fond of the configuration file strategy, and that's okay. View supports editing the config at runtime just fine through the `config` property. The `config` property stores a `Config` object, which holds more subcategories. - -```py -app = new_app() -app.config.foo.bar = "..." -``` - -Configurations are loaded at runtime by the `load_config` function. If you would like to use View's configuration file without creating an `App`, you may use it like so: - -```py -from view import load_config - -config = load_config() -``` - -## Settings - -View has several different configuration settings. For documentation purposes, values will be talked about in terms of Python (i.e. `null` values will be regarded as `None`). - -At the top level, there's one real setting: `dev`. - -`dev` is `True` by default, and is what tells view.py whether you're running in a production server setting or just running on your local machine. - -### Environment Variables - -If you would like to set a configuration setting via an [environment variable](https://en.wikipedia.org/wiki/Environment_variable), you must account for the setting's environment prefix. - -All environment prefixes look like `view__`. For example, the `loader` setting is under the `app` section, so to set `loader` you would use the following command: - -```bash -$ export view_app_loader=filesystem -``` - -Environment variables can also be set via the `env` config setting, or by adding a `.env` file to the project: - -```toml -[env] -TEST = "hello" -``` - -```.env -TEST=hello -``` - -You can access environment variables via the `view.env` utility: - -```py -from view import env - -test = env("TEST", tp=int) -# test will be an integer. if environment variable "TEST" does not exist, an exception is thrown. -# if environment variable "TEST" is not an integer, an exception is thrown. -``` - -### App Settings - -**Environment Prefix: `view_app_`** - -| Key | Description | Default | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------ | -| `loader` | This is the strategy that will be used to load routes. Can be `manual`, `simple`, or `filesystem`. | `manual` | -| `app_path` | A string defining the location of the app, as well as the variable name. Should be in the format of `file_path:variable_name`. | `app.py:app` | -| `uvloop` | Whether or not to use `uvloop` as a means of event loop. Can be `decide` or a `bool` value. | `decide` | -| `loader_path` | When the loader is `simple` or `filesystem`, this is the path that it searches for routes. | `routes/` | - -Example with TOML: - -```toml -[app] -loader = "filesystem" -loader_path = "./app" -``` - -## Server Settings - -**Environment Prefix:** `view_server_` - -| Key | Description | Default | -| ------------ | -------------------------------------------------------------------------------------------------------------------- | --------- | -| `host` | IPv4 address specifying what address to bind the server to. `0.0.0.0` by default. | `0.0.0.0` | -| `port` | Integer defining what port to bind the server to. | `5000` | -| `backend` | ASGI backend to use. Can be `uvicorn`, `daphne`, or `hypercorn`. | `uvicorn` | -| `extra_args` | Dictionary containing extra parameters for the ASGI backend. This parameter is specific to the backend and not View. | `{}` | - -Example with TOML: - -```toml -[server] -host = "localhost" -port = 8080 -``` - -## Log Settings - -**Environment Prefix:** `view_log_` - -| Key | Description | Default | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | -| `level` | Log level. May be `debug`, `info`, `warning`, `error`, `critical`, or an `int`. This is based on Python's built-in [logging module](https://docs.python.org/3/library/logging.html). | `info` | -| `server_logger` | This is a `bool` determining whether the ASGI backend's logger should be displayed. | `False` | -| `fancy` | Whether to use View's fancy output mode. | `True` | -| `pretty_tracebacks` | Whether to use [Rich Exceptions](https://rich.readthedocs.io/en/stable/logging.html?highlight=exceptions#handle-exceptions). | `True` | -| `startup_message` | Whether to show the view.py welcome message on server startup. | `True` | - -### User Logging Settings - -_Environment Prefix:_ `view_user_log_` - -- `urgency`: The log level for user logging. `info` by default. -- `log_file`: The target file for outputting log messages. `None` by default. -- `show_time`: Whether to show the time in each message. `True` by default. -- `show_caller`: Whether to show the caller function in each message. `True` by default. -- `show_color`: Whether to enable colorization for messages. `True` by default. -- `show_urgency`: Whether to show the urgency for messages. `True` by default. -- `file_write`: The preference for writing to an output file, if set. May be `both`, to write to both the terminal and the output file, `only`, to write to just the output file, or `never`, to not write anything. -- `strftime`: The time format used if `show_time` is set to `True`. `%H:%M:%S` by default. - -Example with TOML: - -```toml -[log] -level = "warning" -fancy = false - -[log.user] -log_file = "app.log" -``` - -## Template Settings - -_Environment Prefix:_ `view_templates_` - -- `directory`: The path to search for templates. `./templates` by default. -- `locals`: Whether to include local variables in the rendering parameters (i.e. local variables can be used inside templates). `True` by default -- `globals`: The same as `locals`, but for global variables instead. `True` by default. -- `engine`: The default template engine to use for rendering. Can be `view`, `jinja`, `django`, `mako`, or `chameleon`. `view` by default. - -Example with TOML: - -```toml -[templates] -directory = "./pages" -engine = "jinja" -``` diff --git a/docs/getting-started/creating_a_project.md b/docs/getting-started/creating_a_project.md deleted file mode 100644 index 6fe43746..00000000 --- a/docs/getting-started/creating_a_project.md +++ /dev/null @@ -1,73 +0,0 @@ -# Creating a Project - -## Automatic - -The View CLI supports automatically creating a project via the `view init` command. - -``` -$ view init -``` - -Alternatively, you can run `view init` with `pipx`: - -```py -$ pipx run view-py init -``` - -## Manually - -view.py doesn't actually need any big project structure. In fact, you can run an app in just a single Python file, but larger structures like this might be more convenient for big projects. The only real requirement for something to be a view app is that it calls `new_app`, but again, more on that later. - -Some "hello world" code for manually starting a view.py project would look like this: - -```py -from view import new_app - -app = new_app() - -@app.get("/") -def index(): - return "..." - -app.run() -``` - -## Structure - -First, in any view project, you need a file to contain your app. By default, view expects it to be in `app.py` under a variable called `app`. Again, you can change this via the `app_path` setting. You're also going to want an `app.run()` (assuming you named your `App` instance `app`), but more on that later. - -```py -from view import new_app - -app = new_app() -app.run() -``` - -::: view.app.new_app - -Generally, you're going to want one of the configuration files talked about earlier, but if you're against configuration files that's OK, view.py will work just fine without it. If you choose to use something other than manual routing, you want a `routes` directory (unless you changed the `loader_path` setting). - -```toml -# view.toml -dev = true - -[app] -loader_path = "./my_custom_loader_path" -``` - -For mobility purposes, you may want to add a `pyproject.toml` that contains the dependencies for your project, in case you need to run your project on a different system. - -```toml -# pyproject.toml -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" - -[project] -name = "your_view_app" -requires-python = ">=3.8" -authors = [ - { name = "Your Name", email = "your@email.com" }, -] -dependencies = ["view.py"] -``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md deleted file mode 100644 index 3dc8ef5d..00000000 --- a/docs/getting-started/installation.md +++ /dev/null @@ -1,49 +0,0 @@ -# Installation - -## System Requirements - -view.py requires [CPython](https://python.org/downloads/) 3.8 or above. - -!!! question "What is CPython?" - - CPython is the reference/official implementation of Python. If you downloaded Python through [python.org](https://python.org) or some sort of system package manager (e.g. `apt`, `pacman`, `brew`), it's probably CPython. - -## Installing with Pipx (Recommended) - -[pipx](https://pipx.pypa.io/stable/) can install CLIs into isolated environments. view.py recommends using `pipx` for installation, and then using `view init` to initialize a virtual environment in projects. For example: - -``` -$ pipx install view.py -... pipx output -$ view init -``` - -## Installing via Pip - -``` -$ pip install view.py -``` - -## Development Version - -``` -$ pip install git+https://github.com/ZeroIntensity/view.py -``` - -## Finalizing - -To ensure you've installed view.py correctly, run the `view` command: - -``` -$ view -``` - -!!! note Problem on Linux - - On Linux, `view` is already a command! Read about it [here](https://www.ibm.com/docs/zh/aix/7.2?topic=v-view-command), but in short, it opens `vi` in read only mode. You can either shadow this command with view.py's CLI, or use the `view-py` command instead, which is an alias. This documentation will assume you use `view` instead of `view-py`, but note that they do the exact same thing. - -If this doesn't work properly, try executing via Python: - -``` -$ python3 -m view -``` diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 45ed1c65..00000000 --- a/docs/index.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -hide: - - navigation ---- - -# Welcome to view.py's documentation! - -Here, you can learn how to use view.py and its various features. - -- [Source](https://github.com/ZeroIntensity/view.py) -- [PyPI](https://pypi.org/project/view.py) -- [Discord](https://discord.gg/tZAfuWAbm2) - -## Showcase - -```py -from view import new_app - -app = new_app() - -@app.get("/") -async def index(): - return await app.template("index.html", engine="jinja") - -app.run() -``` - -```py -# routes/index.py -from view import get, HTML - -# Build TypeScript Frontend -@get(steps=["typescript"], cache_rate=1000) -async def index(): - return await HTML.from_file("dist/index.html") -``` - -```py -from dataclasses import dataclass -from view import body, post - -@dataclass -class User: - name: str - password: str - -@post("/signup") -@body("data", User) -def create(data: User): - # Use database of your choice... - return JSON({"message": "Successfully created your account."}), 201 -``` - -```py -from view import new_app, Context, Error, JSON - -app = new_app() - -@app.get("/") -@app.context -async def index(ctx: Context): - auth = ctx.headers.get("Authorization") - if not auth: - raise Error(400) - - return JSON({"data": "..."}) - -app.run() -``` - -```py -from view import new_app - -app = new_app() - -@app.post("/login") -@app.query("username", doc="Username for your account.") -@app.query("password", doc="Password for your account.") -async def index(): - """Log in to your account.""" - ... - -app.run() -``` - -```html - - - - - - - -

You must be logged in.

-
-``` - -```toml -# view.toml -[build] -default_steps = ["nextjs"] -# Only NextJS will be built on startup - -[build.steps.nextjs] -requires = ["npm"] -command = "npm run build" - -[build.steps.php] -requires = ["php"] -command = "php -f payment.php" -``` diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts deleted file mode 100644 index 4f11a03d..00000000 --- a/docs/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/docs/reference/app.md b/docs/reference/app.md deleted file mode 100644 index 3a616d2a..00000000 --- a/docs/reference/app.md +++ /dev/null @@ -1,3 +0,0 @@ -# App Reference - -::: view.app diff --git a/docs/reference/build.md b/docs/reference/build.md deleted file mode 100644 index 527d3e68..00000000 --- a/docs/reference/build.md +++ /dev/null @@ -1,3 +0,0 @@ -# Build Reference - -::: view.build diff --git a/docs/reference/config.md b/docs/reference/config.md deleted file mode 100644 index 3769d154..00000000 --- a/docs/reference/config.md +++ /dev/null @@ -1,3 +0,0 @@ -# Configuration Reference - -::: view.config diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md deleted file mode 100644 index bbf90b80..00000000 --- a/docs/reference/exceptions.md +++ /dev/null @@ -1,3 +0,0 @@ -# Exceptions Reference - -::: view.exceptions diff --git a/docs/reference/responses.md b/docs/reference/responses.md deleted file mode 100644 index d4970701..00000000 --- a/docs/reference/responses.md +++ /dev/null @@ -1,3 +0,0 @@ -# Responses Reference - -::: view.response diff --git a/docs/reference/routing.md b/docs/reference/routing.md deleted file mode 100644 index 245ee8c1..00000000 --- a/docs/reference/routing.md +++ /dev/null @@ -1,8 +0,0 @@ -# Routing Reference - -::: view.routing - - -::: view._loader - -::: view.patterns diff --git a/docs/reference/templates.md b/docs/reference/templates.md deleted file mode 100644 index 209591cc..00000000 --- a/docs/reference/templates.md +++ /dev/null @@ -1,3 +0,0 @@ -# Templating Reference - -::: view.templates diff --git a/docs/reference/types.md b/docs/reference/types.md deleted file mode 100644 index 64e8d587..00000000 --- a/docs/reference/types.md +++ /dev/null @@ -1,3 +0,0 @@ -# Types Reference - -::: view.typing diff --git a/docs/reference/utils.md b/docs/reference/utils.md deleted file mode 100644 index b802b630..00000000 --- a/docs/reference/utils.md +++ /dev/null @@ -1,4 +0,0 @@ -# Utilities Reference - -::: view.util -::: view.typecodes diff --git a/docs/reference/websockets.md b/docs/reference/websockets.md deleted file mode 100644 index 4ebe02e6..00000000 --- a/docs/reference/websockets.md +++ /dev/null @@ -1,3 +0,0 @@ -# WebSockets Reference - -::: view.ws diff --git a/include/view/app.h b/include/view/app.h deleted file mode 100644 index a66e3c6a..00000000 --- a/include/view/app.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifndef VIEW_APP_H -#define VIEW_APP_H - -#include // PyObject, PyTypeObject -#include // bool - -#include // app_parsers -#include // map - -extern PyTypeObject ViewAppType; - -#if defined(__LINE__) && defined(__FILE__) -#define PyErr_BadASGI() view_PyErr_BadASGI(__FILE__, __LINE__) -#else -#define PyErr_BadASGI() view_PyErr_BadASGI(".c", 0) -#endif - -int view_PyErr_BadASGI(char *file, int lineno); - -typedef struct _ViewApp -{ - PyObject_HEAD - PyObject *startup; - PyObject *cleanup; - map *get; - map *post; - map *put; - map *patch; - map *delete; - map *options; - map *websocket; - map *all_routes; - PyObject *client_errors[28]; - PyObject *server_errors[11]; - bool dev; - PyObject *exceptions; - app_parsers parsers; - bool has_path_params; - PyObject *error_type; -} ViewApp; - -#endif diff --git a/include/view/backport.h b/include/view/backport.h deleted file mode 100644 index 5a9bd0a8..00000000 --- a/include/view/backport.h +++ /dev/null @@ -1,68 +0,0 @@ -#ifndef VIEW_BACKPORT_H -#define VIEW_BACKPORT_H - -#include - -#if PY_MAJOR_VERSION != 3 -#error "this file assumes python 3" -#endif - -#ifndef _PyObject_Vectorcall -#define VIEW_NEEDS_VECTORCALL -PyObject * _PyObject_VectorcallBackport( - PyObject *obj, - PyObject **args, - size_t nargsf, - PyObject *kwargs -); - -#define PyObject_CallNoArgs(o) PyObject_CallObject(o, NULL) -#define PyObject_Vectorcall _PyObject_VectorcallBackport -#define PyObject_VectorcallDict _PyObject_FastCallDict -#endif - -#if PY_VERSION_HEX < 0x030c0000 -PyObject * PyErr_GetRaisedException(void); -void PyErr_SetRaisedException(PyObject *err); -#endif - -#ifndef Py_NewRef -#define VIEW_NEEDS_NEWREF -PyObject * Py_NewRef_Backport(PyObject *o); -#define Py_NewRef Py_NewRef_Backport -#endif - -#ifndef Py_XNewRef -#define VIEW_NEEDS_XNEWREF -PyObject * Py_XNewRef_Backport(PyObject *o); -#define Py_XNewRef Py_XNewRef_Backport -#endif - -#ifndef Py_IS_TYPE -#define Py_IS_TYPE(o, type) (Py_TYPE(o) == type) -#endif - -#if PY_MINOR_VERSION == 8 -#define PyObject_CallOneArg(func, val) \ - PyObject_Vectorcall(func, (PyObject *[]) { val }, 1, NULL) -#endif - -#if PY_MINOR_VERSION < 13 -static int -PyModule_Add(PyObject *module, const char *name, PyObject *value) -{ - if (value == NULL) - { - return -1; - } - if (PyModule_AddObject(module, name, value) < 0) - { - Py_DECREF(value); - return -1; - } - return 0; -} - -#endif - -#endif diff --git a/include/view/context.h b/include/view/context.h deleted file mode 100644 index 8e31dabb..00000000 --- a/include/view/context.h +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef VIEW_CONTEXT_H -#define VIEW_CONTEXT_H - -#include // PyObject, PyTypeObject - -extern PyTypeObject ContextType; -PyObject * context_from_data(PyObject *app, PyObject *scope); - -#endif diff --git a/include/view/errors.h b/include/view/errors.h deleted file mode 100644 index d7660a33..00000000 --- a/include/view/errors.h +++ /dev/null @@ -1,44 +0,0 @@ -#ifndef VIEW_ERRORS_H -#define VIEW_ERRORS_H - -#include // PyObject -#include // bool -#include // uint16_t - -#include // ViewApp -#include // route - -int route_error( - PyObject *awaitable, - PyObject *err -); - -int fire_error( - ViewApp *self, - PyObject *awaitable, - int status, - route *r, - bool *called, - const char *message, - const char *method_str, - bool is_http -); - -int server_err( - ViewApp *self, - PyObject *awaitable, - uint16_t status, - route *r, - bool *handler_was_called, - const char *method_str -); - -int load_errors(route *r, PyObject *dict); - -uint16_t hash_server_error(int status); -uint16_t hash_client_error(int status); - -void -show_error(bool dev); - -#endif diff --git a/include/view/handling.h b/include/view/handling.h deleted file mode 100644 index cb3bdb8d..00000000 --- a/include/view/handling.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef VIEW_HANDLING_H -#define VIEW_HANDLING_H - -#include -#include // bool -#include // route - -int handle_route_callback( - PyObject* awaitable, - PyObject* result -); -int handle_route(PyObject* awaitable, char* query); -int handle_route_impl( - PyObject* awaitable, - char* body, - char* query -); -int handle_route_query(PyObject* awaitable, char* query); -void route_free(route* r); -int send_raw_text( - PyObject* awaitable, - PyObject* send, - int status, - const char* res_str, - PyObject* headers, /* may be NULL */ - bool is_http -); -int handle_route_websocket(PyObject* awaitable, PyObject* result); - -#endif diff --git a/include/view/headerdict.h b/include/view/headerdict.h deleted file mode 100644 index a94ad6a0..00000000 --- a/include/view/headerdict.h +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef VIEW_HEADERDICT_H -#define VIEW_HEADERDICT_H - -#include // PyObject, PyTypeObject - -extern PyTypeObject HeaderDictType; -PyObject* headerdict_from_list(PyObject* list, PyObject* cookies); - -#endif diff --git a/include/view/inputs.h b/include/view/inputs.h deleted file mode 100644 index d41c7def..00000000 --- a/include/view/inputs.h +++ /dev/null @@ -1,55 +0,0 @@ -#ifndef VIEW_INPUTS_H -#define VIEW_INPUTS_H - -#include // PyObject, Py_ssize_t - -#include // type_info - -typedef struct _app_parsers -{ - PyObject *query; - PyObject *json; -} app_parsers; - -int body_inc_buf(PyObject *awaitable, PyObject *result); - -PyObject * query_parser( - app_parsers *parsers, - const char *data -); - -typedef struct _route_input -{ - int route_data; // If this is above 0, assume all other items are undefined. - type_info **types; - Py_ssize_t types_size; - PyObject *df; - PyObject **validators; - Py_ssize_t validators_size; - char *name; - bool is_body; -} route_input; - -PyObject * build_data_input( - int num, - PyObject *app, - PyObject *scope, - PyObject *receive, - PyObject *send -); - -typedef struct _ViewApp ViewApp; // Including "app.h" is a circular dependency - -PyObject ** generate_params( - ViewApp *app, - app_parsers *parsers, - const char *data, - PyObject *query, - route_input **inputs, - Py_ssize_t inputs_size, - PyObject *scope, - PyObject *receive, - PyObject *send -); - -#endif diff --git a/include/view/map.h b/include/view/map.h deleted file mode 100644 index b9e015d9..00000000 --- a/include/view/map.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef VIEW_MAP_H -#define VIEW_MAP_H - -#include // Py_ssize_t - -typedef void (* map_free_func)(void *); -typedef void (* map_print_func)(void *); - -typedef struct STRUCT_MAP_PAIR -{ - char *key; - void *value; -} pair; - -typedef struct STRUCT_MAP -{ - Py_ssize_t len; - Py_ssize_t capacity; - pair **items; - map_free_func dealloc; -} map; - -void * map_get(map *m, const char *key); -map * map_new(Py_ssize_t inital_capacity, map_free_func dealloc); -void map_set(map *m, const char *key, void *value); -void map_free(map *m); -map * map_copy(map *m); -void print_map(map *m, map_print_func pr); - -#endif diff --git a/include/view/parts.h b/include/view/parts.h deleted file mode 100644 index 42196ec8..00000000 --- a/include/view/parts.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef VIEW_PARTS_H -#define VIEW_PARTS_H - -#include // PyObject, Py_ssize_t - -#include // ViewApp -#include // map -#include // route - -int extract_parts( - ViewApp* self, - PyObject* awaitable, - map* target, - char* path, - const char* method_str, - Py_ssize_t* size, - route** out_r, - PyObject*** out_params -); - -int load_parts(ViewApp* app, map* routes, PyObject* parts, route* r); - -#endif diff --git a/include/view/results.h b/include/view/results.h deleted file mode 100644 index 9afb953a..00000000 --- a/include/view/results.h +++ /dev/null @@ -1,17 +0,0 @@ -#ifndef VIEW_RESULTS_H -#define VIEW_RESULTS_H - -#include // PyObject - -int handle_result( - PyObject *raw_result, - char **res_target, - int *status_target, - PyObject **headers_target, - PyObject *raw_path, - const char *method -); -char * pymem_strdup(const char *c, Py_ssize_t size); -PyObject * build_default_headers(); - -#endif diff --git a/include/view/route.h b/include/view/route.h deleted file mode 100644 index ba8dfd43..00000000 --- a/include/view/route.h +++ /dev/null @@ -1,40 +0,0 @@ -#ifndef VIEW_ROUTE_H -#define VIEW_ROUTE_H - -#include // uint16_t - -#include // map -#include // route_input - -typedef struct Route route; - -struct Route { - PyObject* callable; - char* cache; - PyObject* cache_headers; - uint16_t cache_status; - Py_ssize_t cache_index; - Py_ssize_t cache_rate; - route_input** inputs; - Py_ssize_t inputs_size; - PyObject* client_errors[28]; - PyObject* server_errors[11]; - PyObject* exceptions; - bool has_body; - bool is_http; - - // transport attributes - map* routes; - route* r; -}; - -void route_free(route* r); -route* route_new( - PyObject* callable, - Py_ssize_t inputs_size, - Py_ssize_t cache_rate, - bool has_body -); -route* route_transport_new(route* r); - -#endif diff --git a/include/view/typecodes.h b/include/view/typecodes.h deleted file mode 100644 index b32ea8d3..00000000 --- a/include/view/typecodes.h +++ /dev/null @@ -1,45 +0,0 @@ -#ifndef VIEW_TYPECODES_H -#define VIEW_TYPECODES_H - -#include // PyObject, PyTypeObject -#include // bool - -typedef struct Route route; // route.h depends on this file -extern PyTypeObject TCPublicType; - -typedef enum -{ - STRING_ALLOWED = 1 << 0, - NULL_ALLOWED = 2 << 0 -} typecode_flag; - -typedef struct _type_info type_info; - -struct _type_info -{ - uint8_t typecode; - PyObject *ob; - type_info **children; - Py_ssize_t children_size; - PyObject *df; -}; - -bool figure_has_body(PyObject *inputs); - -int load_typecodes( - route *r, - PyObject *target -); - -PyObject * -cast_from_typecodes( - type_info **codes, - Py_ssize_t len, - PyObject *item, - PyObject *json_parser, - bool allow_casting -); -type_info ** build_type_codes(PyObject *type_codes, Py_ssize_t len); -void free_type_codes(type_info **codes, Py_ssize_t len); - -#endif diff --git a/include/view/view.h b/include/view/view.h deleted file mode 100644 index f2df9053..00000000 --- a/include/view/view.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef VIEW_H -#define VIEW_H - -#include // PyObject - -void view_fatal( - const char *message, - const char *where, - const char *func, - int lineno -); - -#if defined(__LINE__) && defined(__FILE__) -#define VIEW_FATAL(msg) view_fatal(msg, __FILE__, __func__, __LINE__) -#else -#define VIEW_FATAL(msg) fail(msg, ".c", __func__, 0) -#endif - -#ifdef __GNUC__ -#define NORETURN __attribute__((noreturn)) -#else -#define NORETURN __declspec(noreturn) -#endif - - -// Optimization hints, only supported on GCC -#ifdef __GNUC__ -#define HOT __attribute__((hot)) // Called often -#define PURE __attribute__((pure)) // Depends only on input and memory state (i.e. makes no memory allocations) -#define CONST __attribute__((const)) // Depends only on inputs -#define COLD __attribute__((cold)) // Called rarely -#else -#define PURE -#define HOT -#define CONST -#define COLD -#endif - -#endif diff --git a/include/view/ws.h b/include/view/ws.h deleted file mode 100644 index 20bea91c..00000000 --- a/include/view/ws.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef VIEW_WS_H -#define VIEW_WS_H - -#include -#include - -extern PyTypeObject WebSocketType; -PyObject * ws_from_data(PyObject *scope, PyObject *send, PyObject *receive); - -#endif diff --git a/pyproject.toml b/pyproject.toml index c05ad45c..804ef399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["scikit-build-core>=0.9.0", "hatchling>=1", "pyawaitable>=1.3.0"] +requires = ["scikit-build-core>=0.9.0", "hatchling>=1", "pyawaitable~=2.0"] build-backend = "hatchling.build" [project] @@ -9,7 +9,7 @@ readme = "README.md" requires-python = ">=3.9" keywords = [] authors = [ - { name = "ZeroIntensity", email = "zintensitydev@gmail.com" }, + { name = "Peter Bierma", email = "zintensitydev@gmail.com" }, ] classifiers = [ "Programming Language :: Python", @@ -20,49 +20,11 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = [ - "rich>=13", - "click>=8", - "typing_extensions", - "ujson>=5", - "pydantic_settings>=2", - "toml~=0.10", - "aiofiles>=24", - "prompts.py~=0.1", - "pyawaitable>=1.3.0" -] +dependencies = [] dynamic = ["version", "license"] [project.optional-dependencies] -databases = [ - "psycopg2-binary", - "mysql-connector-python", - "pymongo", - "aiosqlite" -] -templates = ["beautifulsoup4", "jinja2", "mako", "django", "chameleon", "markdown"] -fancy = ["psutil", "plotext"] -servers = ["uvicorn[websockets]", "hypercorn", "daphne", "watchfiles"] -full = [ - "psutil", - "plotext", - "beautifulsoup4", - "jinja2", - "mako", - "django", - "chameleon", - "attrs", - "psycopg2-binary", - "mysql-connector-python", - "pymongo", - "aiosqlite", - "markdown", - "uvicorn", - "hypercorn", - "daphne", - "reactpy", - "watchfiles" -] +full = [] [project.urls] Documentation = "https://view.zintensity.dev" diff --git a/runtime.txt b/runtime.txt index cc1923a4..bd28b9c5 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -3.8 +3.9 diff --git a/src/_view/app.c b/src/_view/app.c deleted file mode 100644 index 67ba19a3..00000000 --- a/src/_view/app.c +++ /dev/null @@ -1,1199 +0,0 @@ -/* - * view.py ASGI app implementation - * - * This file contains the ViewApp class, which is the base class for the App class. - * All the actual ASGI calls are here. The ASGI app location is under the asgi_app_entry() method. - * - * The view.py ASGI app should *never* raise an exception (in a perfect world, at least). All errors - * should be handled accordingly, and a proper HTTP response should be sent back in all cases, regardless of what happened. - * - * The lifecycle of a request is as follows: - * - * - Receive ASGI values (scope, receive(), and send()) - * - If it's a lifespan call, start the lifespan protocol. - * - If not, extract the path and method from the scope. - * - If it's an HTTP request: - * * Search the corresponding method map with the route. - * * If it's not found, check if the app has path parameters. - * > If not, return a 404. - * > If it does, defer to the path parts API (very unstable and buggy). - * * If it is found, check if the route has inputs (data inputs, query parameters, and body parameters). - * > If it does, defer to the proper handler function. - * > If not, we can just call it right now, and send the result to the results API. - * - If it's a WebSocket connection: - * * Search the WebSocket map with the route. - * * If it's not found, check if the app has path parameters. - * > If not, return a 404, but explicitly mark it as a WebSocket rejection (websocket.http.response) - * > If it does, defer to the path parts API (very unstable and buggy). This is not implemented yet! - * * Defer to the proper handler function. A WebSocket route always has at least one input. - */ -#include - -#include -#include - -#include -#include -#include -#include // extract_parts, load_parts -#include // pymem_strdup -#include // route_free, route_new, handle_route, handle_route_query -#include -#include // VIEW_FATAL - -#include - -#define LOAD_ROUTE(target) \ - char *path; \ - PyObject *callable; \ - PyObject *inputs; \ - Py_ssize_t cache_rate; \ - PyObject *errors; \ - PyObject *parts = NULL; \ - if (!PyArg_ParseTuple( \ - args, \ - "zOnOOO", \ - &path, \ - &callable, \ - &cache_rate, \ - &inputs, \ - &errors, \ - &parts \ - )) return NULL; \ - route *r = route_new( \ - callable, \ - PySequence_Size(inputs), \ - cache_rate, \ - figure_has_body(inputs) \ - ); \ - if (!r) return NULL; \ - if (load_typecodes( \ - r, \ - inputs \ - ) < 0) { \ - route_free(r); \ - return NULL; \ - } \ - if (load_errors(r, errors) < 0) { \ - route_free(r); \ - return NULL; \ - } \ - if (!map_get(self->all_routes, path)) { \ - int *num = PyMem_Malloc(sizeof(int)); \ - if (!num) { \ - PyErr_NoMemory(); \ - route_free(r); \ - return NULL; \ - } \ - *num = 1; \ - map_set(self->all_routes, path, num); \ - } \ - if (!PySequence_Size(parts)) \ - map_set(self->target, path, r); \ - else if (load_parts(self, self->target, parts, r) < 0) return NULL; \ - -#define ROUTE(target) \ - static PyObject *target ( \ - ViewApp * self, \ - PyObject * args \ - ) { \ - LOAD_ROUTE(target); \ - Py_RETURN_NONE; \ - } - -/* - * Something unexpected happened with the received ASGI data (e.g. the scope is missing a key). - * Don't call this manually, use the PyErr_BadASGI macro, which passes the file and lineno. - */ -COLD int -view_PyErr_BadASGI(char *file, int lineno) -{ - PyErr_Format( - PyExc_RuntimeError, - "(%s:%d) problem with view.py's ASGI server (this is a bug!)", - file, - lineno - ); - return -1; -} - -/* - * Allocate and initialize a new ViewApp object. - * This builds all the route tables, and any other field on the ViewApp struct. - */ -static PyObject * -new(PyTypeObject *tp, PyObject *args, PyObject *kwds) -{ - ViewApp *self = (ViewApp *) tp->tp_alloc( - tp, - 0 - ); - if (!self) return NULL; - self->startup = NULL; - self->cleanup = NULL; - self->get = map_new( - 4, - (map_free_func) route_free - ); - self->put = map_new( - 4, - (map_free_func) route_free - ); - self->post = map_new( - 4, - (map_free_func) route_free - ); - self->delete = map_new( - 4, - (map_free_func) route_free - ); - self->patch = map_new( - 4, - (map_free_func) route_free - ); - self->options = map_new( - 4, - (map_free_func) route_free - ); - self->websocket = map_new( - 4, - (map_free_func) route_free - ); - self->all_routes = map_new( - 4, - free - ); - - if ( - !self->options || !self->patch || !self->delete || - !self->post || - !self->put || !self->put || !self->get - ) - { - // TODO: Fix these leaks! - // However, this is an unlikely case that will only happen - // if the interpreter is out of memory. - return NULL; - } - ; - - for (int i = 0; i < 28; i++) - self->client_errors[i] = NULL; - - for (int i = 0; i < 11; i++) - self->server_errors[i] = NULL; - - self->has_path_params = false; - self->error_type = NULL; - - return (PyObject *) self; -} - -/* - * Dummy function to stop manual construction of a ViewApp from Python. - * In a perfect world, this will never get called. - */ -static int -init(PyObject *self, PyObject *args, PyObject *kwds) -{ - PyErr_SetString( - PyExc_TypeError, - "ViewApp is not constructable" - ); - return -1; -} - -/* - * ASGI lifespan implementation. - */ -static int -lifespan(PyObject *awaitable, PyObject *result) -{ - // This needs to be here, or else the server will complain about lifespan not being supported. - // Most of this is undocumented and unavailable for use from the user for now. - ViewApp *self; - PyObject *send; - PyObject *receive; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &self, - NULL, - &receive, - &send - ) < 0 - ) - return -1; - - // Borrowed reference - do not DECREF - PyObject *tp = PyDict_GetItemString( - result, - "type" - ); - if (tp == NULL) - return PyErr_BadASGI(); - const char *type = PyUnicode_AsUTF8(tp); - - bool is_startup = !strcmp( - type, - "lifespan.startup" - ); - PyObject *target_obj = is_startup ? self->startup : self->cleanup; - if (target_obj) - { - if (!PyObject_CallNoArgs(target_obj)) - return -1; - } - - PyObject *send_dict = Py_BuildValue( - "{s:s}", - "type", - is_startup ? "lifespan.startup.complete" : - "lifespan.shutdown.complete" - ); - - if (!send_dict) - return -1; - - PyObject *send_coro = PyObject_Vectorcall( - send, - (PyObject *[]) { send_dict }, - 1, - NULL - ); - - if (!send_coro) - return -1; - - Py_DECREF(send_dict); - - if ( - PyAwaitable_AddAwait( - awaitable, - send_coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(send_coro); - return -1; - } - Py_DECREF(send_coro); - if (!is_startup) return 0; - - PyObject *aw = PyAwaitable_New(); - if (!aw) - return -1; - - PyObject *recv_coro = PyObject_CallNoArgs(receive); - if (!recv_coro) - { - Py_DECREF(aw); - return -1; - } - - if ( - PyAwaitable_AddAwait( - aw, - recv_coro, - lifespan, - NULL - ) < 0 - ) - { - Py_DECREF(aw); - Py_DECREF(recv_coro); - return -1; - } - ; - - return 0; -} - -/* The ViewApp deallocator. */ -static void -dealloc(ViewApp *self) -{ - Py_XDECREF(self->cleanup); - Py_XDECREF(self->startup); - map_free(self->get); - map_free(self->post); - map_free(self->put); - map_free(self->patch); - map_free(self->delete); - map_free(self->options); - map_free(self->websocket); - Py_XDECREF(self->exceptions); - - for (int i = 0; i < 11; i++) - Py_XDECREF(self->server_errors[i]); - - for (int i = 0; i < 28; i++) - Py_XDECREF(self->client_errors[i]); - - Py_XDECREF(self->error_type); - Py_TYPE(self)->tp_free(self); -} - -/* - * Utility function for getting a key from the ASGI scope. - * If the key is missing, an error is thown via PyErr_BadASGI(). - */ -static const char * -dict_get_str(PyObject *dict, const char *str) -{ - Py_INCREF(dict); - PyObject *ob = PyDict_GetItemString( - dict, - str - ); - Py_DECREF(dict); - if (!ob) - { - PyErr_BadASGI(); - return NULL; - } - - const char *result = PyUnicode_AsUTF8(ob); - return result; -} - -/* - * view.py ASGI implementation. This is where the magic happens! - * - * This is accessible via asgi_app_entry() in Python. - * - */ -HOT static PyObject * -app( - ViewApp *self, - PyObject * const *args, - Py_ssize_t nargs -) -{ - /* - * All HTTP and WebSocket connections start here. This function is responsible for - * looking up loaded routes, calling PyAwaitable, and so on. - * - * Note that a lot of things aren't actually implemented here, such as route handling, but - * it's all sort of stitched together in this function. - * - * As mentioned in the top comment, this should always send some sort - * of response back to the user, regardless of how badly things went. - * - * For example, if an error occurred somewhere, this should sent - * back a 500 (assuming that an exception handler doesn't exist). - * - * We don't want to let the ASGI server do it, because then we're - * missing out on the chance to call an error handler or log what happened. - */ - - // We can assume that there will be three arguments. - // If there aren't, then something is seriously wrong! - assert(nargs == 3); - PyObject *scope = args[0]; - PyObject *receive = args[1]; - PyObject *send = args[2]; - - // Borrowed reference - PyObject *tp = PyDict_GetItemString( - scope, - "type" - ); - - if (!tp) - { - PyErr_BadASGI(); - return NULL; - } - - const char *type = PyUnicode_AsUTF8(tp); - - PyObject *awaitable = PyAwaitable_New(); - if (!awaitable) - return NULL; - - if ( - !strcmp( - type, - "lifespan" - ) - ) - { - // We are in the lifespan protocol! - PyObject *recv_coro = PyObject_CallNoArgs(receive); - if (!recv_coro) - { - Py_DECREF(awaitable); - return NULL; - } - - if ( - PyAwaitable_SaveValues( - awaitable, - 4, - self, - scope, - receive, - send - ) < 0 - ) - { - Py_DECREF(awaitable); - return NULL; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - recv_coro, - lifespan, - NULL - ) < 0 - ) - { - Py_DECREF(awaitable); - Py_DECREF(recv_coro); - return NULL; - } - ; - Py_DECREF(recv_coro); - return awaitable; - } - - PyObject *raw_path_obj = PyDict_GetItemString( - scope, - "path" - ); - - if (!raw_path_obj) - { - Py_DECREF(awaitable); - PyErr_BadASGI(); - return NULL; - } - - const char *raw_path = PyUnicode_AsUTF8(raw_path_obj); - if (!raw_path) - { - Py_DECREF(awaitable); - return NULL; - } - - if ( - PyAwaitable_SaveValues( - awaitable, - 5, - self, - scope, - receive, - send, - raw_path_obj - ) < 0 - ) - { - Py_DECREF(awaitable); - return NULL; - } - - bool is_http = !strcmp( - type, - "http" - ); - - size_t len = strlen(raw_path); - char *path; - if (raw_path[len - 1] == '/' && len != 1) - { - path = PyMem_Malloc(len + 1); - if (!path) - { - Py_DECREF(awaitable); - return PyErr_NoMemory(); - } - - memcpy(path, raw_path, len); - path[len - 1] = '\0'; - } else - { - path = pymem_strdup(raw_path, len); - if (!path) - { - Py_DECREF(awaitable); - return PyErr_NoMemory(); - } - } - const char *method = NULL; - - if (is_http) - { - method = dict_get_str( - scope, - "method" - ); - } - - PyObject *query_obj = PyDict_GetItemString( - scope, - "query_string" - ); - - if (!query_obj) - { - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - - Py_ssize_t query_size; - char *query_str; - - if (PyBytes_AsStringAndSize(query_obj, &query_str, &query_size) < 0) - { - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - char *query = pymem_strdup(query_str, query_size); - map *ptr = self->websocket; // ws by default - const char *method_str = "websocket"; - - if (is_http) - { - if ( - !strcmp( - method, - "GET" - ) - ) - { - ptr = self->get; - method_str = "GET"; - } else if ( - !strcmp( - method, - "POST" - ) - ) - { - ptr = self->post; - method_str = "POST"; - } else if ( - !strcmp( - method, - "PATCH" - ) - ) - { - ptr = self->patch; - method_str = "PATCH"; - } else if ( - !strcmp( - method, - "PUT" - ) - ) - { - ptr = self->put; - method_str = "PUT"; - } else if ( - !strcmp( - method, - "DELETE" - ) - ) - { - ptr = self->delete; - method_str = "DELETE"; - } else if ( - !strcmp( - method, - "OPTIONS" - ) - ) - { - ptr = self->options; - method_str = "OPTIONS"; - } - if (ptr == self->websocket) - { - ptr = self->get; - } - } - - route *r = map_get( - ptr, - path - ); - PyObject **params = NULL; - Py_ssize_t *size = NULL; - - if (!r || r->r) - { - if (!self->has_path_params) - { - if ( - map_get( - self->all_routes, - path - ) - ) - { - if ( - fire_error( - self, - awaitable, - 405, - NULL, - NULL, - NULL, - method_str, - is_http - ) < 0 - ) - { - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - PyMem_Free(path); - return awaitable; - } - if ( - fire_error( - self, - awaitable, - 404, - NULL, - NULL, - NULL, - method_str, - is_http - ) < 0 - ) - { - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - PyMem_Free(path); - return awaitable; - } - - // path parameter extraction - int res = extract_parts( - self, - awaitable, - ptr, - path, - method_str, - size, - &r, - ¶ms - ); - if (res < 0) - { - PyMem_Free(path); - PyMem_Free(size); - - if (res == -1) - { - // -1 denotes that an exception occurred, raise it - Py_DECREF(awaitable); - return NULL; - } - - // -2 denotes that an error can be sent to the client, return - // the awaitable for execution of send() - return awaitable; - } - } - - if ( - is_http && (r->cache_rate != -1) && (r->cache_index++ < - r->cache_rate) && - r->cache - ) - { - // We have a cached response that we can use! - // Let's start the ASGI response process - PyObject *dct = Py_BuildValue( - "{s:s,s:i,s:O}", - "type", - "http.response.start", - "status", - r->cache_status, - "headers", - r->cache_headers - ); - - if (!dct) - { - if (size) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - PyMem_Free(size); - } - PyMem_Free(path); - Py_DECREF(awaitable); - return NULL; - } - - PyObject *coro = PyObject_Vectorcall( - send, - (PyObject *[]) { dct }, - 1, - NULL - ); - - Py_DECREF(dct); - - if (!coro) - { - if (size) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - PyMem_Free(size); - } - PyMem_Free(path); - Py_DECREF(awaitable); - return NULL; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - if (size) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - PyMem_Free(size); - } - Py_DECREF(awaitable); - Py_DECREF(coro); - PyMem_Free(path); - return NULL; - } - - Py_DECREF(coro); - - PyObject *dc = Py_BuildValue( - "{s:s,s:y}", - "type", - "http.response.body", - "body", - r->cache - ); - - if (!dc) - { - if (size) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - PyMem_Free(size); - } - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - - coro = PyObject_Vectorcall( - send, - (PyObject *[]) { dc }, - 1, - NULL - ); - - Py_DECREF(dc); - - if (!coro) - { - if (size) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - PyMem_Free(size); - } - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - if (size) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - PyMem_Free(size); - } - Py_DECREF(awaitable); - Py_DECREF(coro); - PyMem_Free(path); - return NULL; - } - - Py_DECREF(coro); - PyMem_Free(path); - return awaitable; - } - - if ( - PyAwaitable_SaveArbValues( - awaitable, - 4, - r, - params, - size, - method_str - ) < 0 - ) - { - Py_DECREF(awaitable); - return NULL; - } - - if (PyAwaitable_SaveIntValues(awaitable, 1, is_http) < 0) - { - Py_DECREF(awaitable); - return NULL; - } - - if (r->inputs_size != 0) - { - if (!r->has_body) - { - if ( - handle_route_query( - awaitable, - query - ) < 0 - ) - { - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - ; - - return awaitable; - } - - if ( - handle_route( - awaitable, - query - ) < 0 - ) - { - Py_DECREF(awaitable); - return NULL; - } - - return awaitable; - } else - { - // If there are no inputs, we can skip parsing! - if (!is_http) VIEW_FATAL("got a websocket without an input!"); - - PyObject *res_coro; - if (size) - { - res_coro = PyObject_Vectorcall( - r->callable, - params, - *size, - NULL - ); - - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(path); - PyMem_Free(params); - PyMem_Free(size); - } else res_coro = PyObject_CallNoArgs(r->callable); - - if (!res_coro) - { - Py_DECREF(awaitable); - PyMem_Free(path); - return NULL; - } - - if (!res_coro) - { - if ( - server_err( - self, - awaitable, - 500, - r, - NULL, - method_str - ) < 0 - ) - return NULL; - return awaitable; - } - if ( - PyAwaitable_AddAwait( - awaitable, - res_coro, - handle_route_callback, - route_error - ) < 0 - ) - { - Py_DECREF(res_coro); - PyMem_Free(path); - Py_DECREF(awaitable); - return NULL; - } - } - - return awaitable; -} - -/* - * These are all loader functions that allocate a route structure and store - * it on the corresponding route table. - */ -ROUTE(get); -ROUTE(post); -ROUTE(patch); -ROUTE(put); -ROUTE(delete); -ROUTE(options); - -/* - * Loader function for WebSockets. - * We have a special case for WebSocket routes - the `is_http` field is set to false. - */ -static PyObject * -websocket(ViewApp *self, PyObject *args) -{ - LOAD_ROUTE(websocket); - r->is_http = false; - Py_RETURN_NONE; -} - -/* - * Adds a global error handler to the app. - * - * Note that this is for *status* codes only, not exceptions! - * For example, if a route returned 400 without raising an exception, - * then the handler for error 400 would be called. - * - * This is more or less undocumented, and subject to change. - */ -static PyObject * -err_handler(ViewApp *self, PyObject *args) -{ - PyObject *handler; - int status_code; - - if ( - !PyArg_ParseTuple( - args, - "iO", - &status_code, - &handler - ) - ) return NULL; - - if (status_code < 400 || status_code > 511) - { - PyErr_Format( - PyExc_ValueError, - "%d is not a valid status code", - status_code - ); - return NULL; - } - - if (status_code >= 500) - { - self->server_errors[status_code - 500] = Py_NewRef(handler); - } else - { - uint16_t index = hash_client_error(status_code); - if (index == 600) - { - PyErr_Format( - PyExc_ValueError, - "%d is not a valid status code", - status_code - ); - return NULL; - } - self->client_errors[index] = Py_NewRef(handler); - } - - Py_RETURN_NONE; -} - -/* - * Adds a global exception handler to the app. - * - * This is similar to err_handler(), but this - * catches exceptions instead of error response codes. - */ -static PyObject * -exc_handler(ViewApp *self, PyObject *args) -{ - PyObject *dict; - if ( - !PyArg_ParseTuple( - args, - "O!", - &PyDict_Type, - &dict - ) - ) return NULL; - if (self->exceptions) - { - PyDict_Merge( - self->exceptions, - dict, - 1 - ); - } else - { - self->exceptions = Py_NewRef(dict); - } - - Py_RETURN_NONE; -} - -/* - * Simple function that defers a - * segmentation fault to the VIEW_FATAL macro. - * - * This is only active as a signal handler - * when development mode is enabled. - */ -static void -sigsegv_handler(int signum) -{ - signal( - SIGSEGV, - SIG_DFL - ); - VIEW_FATAL("segmentation fault"); -} - -/* - * Set whether the app is in development mode. - * - * If it is, then the SIGSEGV handler is enabled. - */ -static PyObject * -set_dev_state(ViewApp *self, PyObject *args) -{ - int value; - if ( - !PyArg_ParseTuple( - args, - "p", - &value - ) - ) return NULL; - self->dev = (bool) value; - - if (value) - signal( - SIGSEGV, - sigsegv_handler - ); - - Py_RETURN_NONE; -} - -/* - * Supply Python parser functions to C code. - * - * As of now, this only takes a query string parser and a JSON parser, but - * that is pretty much gaurunteed to change. - */ -static PyObject * -supply_parsers(ViewApp *self, PyObject *args) -{ - PyObject *query; - PyObject *json; - - if ( - !PyArg_ParseTuple( - args, - "OO", - &query, - &json - ) - ) - return NULL; - - self->parsers.query = query; - self->parsers.json = json; - Py_RETURN_NONE; -} - -static PyMethodDef methods[] = -{ - {"asgi_app_entry", (PyCFunction) app, METH_FASTCALL, NULL}, - {"_get", (PyCFunction) get, METH_VARARGS, NULL}, - {"_post", (PyCFunction) post, METH_VARARGS, NULL}, - {"_put", (PyCFunction) put, METH_VARARGS, NULL}, - {"_patch", (PyCFunction) patch, METH_VARARGS, NULL}, - {"_delete", (PyCFunction) delete, METH_VARARGS, NULL}, - {"_options", (PyCFunction) options, METH_VARARGS, NULL}, - {"_websocket", (PyCFunction) websocket, METH_VARARGS, NULL}, - {"_set_dev_state", (PyCFunction) set_dev_state, METH_VARARGS, NULL}, - {"_err", (PyCFunction) err_handler, METH_VARARGS, NULL}, - {"_supply_parsers", (PyCFunction) supply_parsers, METH_VARARGS, NULL}, - {NULL, NULL, 0, NULL} -}; - -PyTypeObject ViewAppType = -{ - PyVarObject_HEAD_INIT( - NULL, - 0 - ) - .tp_name = "_view.ViewApp", - .tp_basicsize = sizeof(ViewApp), - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, - .tp_init = (initproc) init, - .tp_methods = methods, - .tp_new = new, - .tp_dealloc = (destructor) dealloc -}; diff --git a/src/_view/backport.c b/src/_view/backport.c deleted file mode 100644 index b0e89e94..00000000 --- a/src/_view/backport.c +++ /dev/null @@ -1,73 +0,0 @@ -/* - * CPython ABI Backports - * - * This lets view.py use things like vectorcall, Py_NewRef, or PyErr_GetRaisedException on older versions. - */ -#include -#include - -#ifdef VIEW_NEEDS_VECTORCALL -PyObject * -_PyObject_VectorcallBackport( - PyObject *obj, - PyObject **args, - size_t nargsf, - PyObject *kwargs -) -{ - PyObject *tuple = PyTuple_New(nargsf); - if (!tuple) - return NULL; - for (size_t i = 0; i < nargsf; i++) - { - Py_INCREF(args[i]); - PyTuple_SET_ITEM(tuple, i, args[i]); - } - PyObject *o = PyObject_Call(obj, tuple, kwargs); - Py_DECREF(tuple); - return o; -} - -#endif - -#if PY_VERSION_HEX < 0x030c0000 -PyObject * -PyErr_GetRaisedException(void) -{ - PyObject *type, *val, *tb; - PyErr_Fetch(&type, &val, &tb); - PyErr_NormalizeException(&type, &val, &tb); - Py_XDECREF(type); - Py_XDECREF(tb); - // technically some entry in the traceback might be lost; ignore that - assert(val != NULL); - return val; -} - -void -PyErr_SetRaisedException(PyObject *err) -{ - PyErr_Restore((PyObject *) Py_TYPE(err), err, NULL); -} - -#endif - -#ifdef VIEW_NEEDS_NEWREF -PyObject * -Py_NewRef_Backport(PyObject *o) -{ - Py_INCREF(o); - return o; -} - -#endif - -#ifdef VIEW_NEEDS_XNEWREF -PyObject * -Py_XNewRef_Backport(PyObject *o) -{ - Py_XINCREF(o); - return o; -} - -#endif diff --git a/src/_view/context.c b/src/_view/context.c deleted file mode 100644 index 910effee..00000000 --- a/src/_view/context.c +++ /dev/null @@ -1,356 +0,0 @@ -/* - * view.py route context implementation - * - * This file provides the definition and logic of the Context() class. A context - * essentially wraps the ASGI scope, and contains a reference to the app instance. - * - * It contains information that someone might find useful, such as the headers, - * cookies, method, route, and so on. Use of the context's attributes should be - * avoided from C, since you have the ASGI scope in C. This is, more or less, a - * transport for passing those values to something like a route. - * - * Note that this also does some header parsing through the HeaderDict() class. - * - * The implementation of Context() is pretty simple. It's a simple extension type that - * uses PyMemberDef with T_OBJECT or T_OBJECT_EX for all the fields. - * - * The object is constructed at runtime by the exported context_from_data() function, - * which is called during route input generation. context_from_data() is responsible - * for unpacking all the values given the ASGI scope. For convenience, the app - * instance is stored on the object as well. - * - * Note that while this is part of the private _view module, fields of Context() are - * considered to be a public API. Make changes to those with caution! They have much - * less lenience than the rest of the C API. - */ -#include -#include // PyMemberDef - -#include // offsetof - -#include // PyErr_BadASGI -#include -#include -#include // headerdict_from_list -#include // ip_address - -#define STR_TO_OBJECT(str) PyUnicode_FromStringAndSize(str, sizeof(str) - 1) - -typedef struct -{ - PyObject_HEAD - PyObject *app; - PyObject *scheme; - PyObject *headers; - PyObject *cookies; - PyObject *http_version; - PyObject *client; - PyObject *client_port; - PyObject *server; - PyObject *server_port; - PyObject *method; - PyObject *path; -} Context; - -static PyMemberDef members[] = -{ - {"app", T_OBJECT_EX, offsetof(Context, app), 0, NULL}, - {"scheme", T_OBJECT_EX, offsetof(Context, scheme), 0, NULL}, - {"headers", T_OBJECT_EX, offsetof(Context, headers), 0, NULL}, - {"cookies", T_OBJECT_EX, offsetof(Context, cookies), 0, NULL}, - {"http_version", T_OBJECT_EX, offsetof(Context, http_version), 0, NULL}, - {"client", T_OBJECT, offsetof(Context, client), 0, NULL}, - {"client_port", T_OBJECT, offsetof(Context, client_port), 0, NULL}, - {"server", T_OBJECT, offsetof(Context, server), 0, NULL}, - {"server_port", T_OBJECT, offsetof(Context, server_port), 0, NULL}, - {"method", T_OBJECT, offsetof(Context, method), 0, NULL}, - {"path", T_OBJECT, offsetof(Context, path), 0, NULL}, - {NULL} // Sentinel -}; - -/* - * Python __repr__ for Context() - * As of now, this is just a really long format string. - */ -static PyObject * -repr(PyObject *self) -{ - Context *ctx = (Context *) self; - return PyUnicode_FromFormat( - "Context(app=..., scheme=%R, headers=%R, cookies=%R, http_version=%R, client=%R, client_port=%R, server=%R, server_port=%R, method=%R, path=%R)", - ctx->scheme, - ctx->headers, - ctx->cookies, - ctx->http_version, - ctx->client, - ctx->client_port, - ctx->server, - ctx->server_port, - ctx->method, - ctx->path - ); -} - -/* The Context Deallocator */ -static void -dealloc(Context *self) -{ - Py_XDECREF(self->app); - Py_XDECREF(self->scheme); - Py_XDECREF(self->headers); - Py_XDECREF(self->cookies); - Py_XDECREF(self->http_version); - Py_XDECREF(self->client); - Py_XDECREF(self->client_port); - Py_XDECREF(self->server); - Py_XDECREF(self->server_port); - Py_XDECREF(self->method); - Py_XDECREF(self->path); - Py_TYPE(self)->tp_free((PyObject *) self); -} - -/* - * Initializer for the Context() class. - * - * This shouldn't be called outside of this file, as the app - * generates Context() inputs through the exported context_from_data() - * - * Again, only the *attributes* for Context() are considered public. - * This can change at any time! - */ -static PyObject * -Context_new( - PyTypeObject *type, - PyObject *args, - PyObject *kwargs -) -{ - Context *self = (Context *) type->tp_alloc( - type, - 0 - ); - if (!self) - return NULL; - - return (PyObject *) self; -} - -/* - * The actual interface for generating a Context() instance at runtime. - * - * This doesn't really do much other than unpack values from - * the ASGI scope and store them in the proper attributes, with - * the exception of calling headerdict_from_list() on the headers. - * - * Private API - no access from Python and unstable. - */ -PyObject * -context_from_data(PyObject *app, PyObject *scope) -{ - Context *context = (Context *) Context_new( - &ContextType, - NULL, - NULL - ); - PyObject *scheme; - PyObject *http_version; - PyObject *method; - PyObject *path; - PyObject *header_list; - PyObject *client; - PyObject *server; - - if (scope != NULL) - { - scheme = Py_XNewRef( - PyDict_GetItemString( - scope, - "scheme" - ) - ); - http_version = Py_XNewRef( - PyDict_GetItemString( - scope, - "http_version" - ) - ); - method = Py_XNewRef( - PyDict_GetItemString( - scope, - "method" - ) - ); - path = Py_XNewRef( - PyDict_GetItemString( - scope, - "path" - ) - ); - header_list = Py_XNewRef( - PyDict_GetItemString( - scope, - "headers" - ) - ); - client = Py_XNewRef( - PyDict_GetItemString( - scope, - "client" - ) - ); // [host, port] - server = Py_XNewRef( - PyDict_GetItemString( - scope, - "server" - ) - ); // [host, port/None] - } else - { - // Default values for a dummy context - scheme = STR_TO_OBJECT("http"); - http_version = STR_TO_OBJECT("view_test"); - method = STR_TO_OBJECT("GET"); - path = STR_TO_OBJECT("/???"); - header_list = Py_NewRef(default_headers); - // TODO: When Python 3.11 is EOL, remove the - // call to Py_NewRef() here, since Py_None is - // immortal on those versions. - client = Py_NewRef(Py_None); - server = Py_NewRef(Py_None); - } - - if ( - !scheme || !header_list || !http_version || !client || !server || - !path || !method - ) - { - Py_XDECREF(scheme); - Py_XDECREF(http_version); - Py_XDECREF(path); - Py_XDECREF(client); - Py_XDECREF(method); - Py_DECREF(context); - if (!PyErr_Occurred()) - PyErr_BadASGI(); - return NULL; - } - - context->http_version = http_version; - context->scheme = scheme; - context->method = method; - context->path = path; - - if (client != Py_None) - { - if (PyTuple_Size(client) != 2) - { - Py_DECREF(context); - Py_DECREF(client); - Py_DECREF(server); - PyErr_BadASGI(); - return NULL; - } - - context->client_port = Py_NewRef( - PyTuple_GET_ITEM( - client, - 1 - ) - ); - if (PyErr_Occurred()) - { - Py_DECREF(context); - Py_DECREF(client); - Py_DECREF(server); - return NULL; - } - - PyObject *address = PyObject_Vectorcall( - ip_address, - (PyObject *[]) { - PyTuple_GET_ITEM(client, 0) - }, - 1, - NULL - ); - Py_DECREF(client); - - if (!address) - { - Py_DECREF(context); - Py_DECREF(server); - return NULL; - } - - context->client = address; - } else context->client = NULL; - if (server != Py_None) - { - if (PyTuple_Size(server) != 2) - { - Py_DECREF(context); - Py_DECREF(server); - PyErr_BadASGI(); - return NULL; - } - - context->server_port = Py_NewRef( - PyTuple_GET_ITEM( - server, - 1 - ) - ); - PyObject *address = PyObject_Vectorcall( - ip_address, - (PyObject *[]) { - PyTuple_GET_ITEM( - server, - 0 - ) - }, - 1, - NULL - ); - Py_DECREF(server); - - if (!address) - { - Py_DECREF(context); - return NULL; - } - context->server = address; - } else context->server = NULL; - PyObject *cookies = PyDict_New(); - - if (!cookies) - { - Py_DECREF(context); - return NULL; - } - - context->cookies = cookies; - context->headers = headerdict_from_list(header_list, context->cookies); - if (!context->headers) - { - Py_DECREF(context); - return NULL; - } - context->app = Py_NewRef(app); - return (PyObject *) context; -} - -PyTypeObject ContextType = -{ - PyVarObject_HEAD_INIT( - NULL, - 0 - ) - .tp_name = "_view.Context", - .tp_basicsize = sizeof(Context), - .tp_itemsize = 0, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, - .tp_new = Context_new, - .tp_dealloc = (destructor) dealloc, - .tp_members = members, - .tp_repr = repr -}; diff --git a/src/_view/errors.c b/src/_view/errors.c deleted file mode 100644 index 556da48d..00000000 --- a/src/_view/errors.c +++ /dev/null @@ -1,990 +0,0 @@ -#include - -#include // uint16_t -#include // bool - -#include // ViewApp -#include -#include -#include // send_raw_text -#include // handle_result -#include // route -#include // invalid_status_error - -#include - -#define ER(code, str) \ - case code: \ - return str -#define ERR(code, msg) \ - case code: \ - return send_raw_text( \ - awaitable, \ - send, \ - code, \ - msg, \ - true \ - ); - -/* - * Print an error without clearing it. - */ -void -show_error(bool dev) -{ - if (dev) - { - PyObject *err = PyErr_GetRaisedException(); - Py_INCREF(err); // Save a reference to it - PyErr_SetRaisedException(err); - PyErr_Print(); - // PyErr_Print() clears the error indicator, so - // we need to reset it. - PyErr_SetRaisedException(err); - } else PyErr_Clear(); -} - -/* - * Mappings between error codes and their index. - * 400 - 0 - * 401 - 1 - * 402 - 2 - * 403 - 3 - * 404 - 4 - * 405 - 5 - * 406 - 6 - * 407 - 7 - * 408 - 8 - * 409 - 9 - * 410 - 10 - * 411 - 11 - * 412 - 12 - * 413 - 13 - * 414 - 14 - * 415 - 15 - * 416 - 16 - * 417 - 17 - * 418 - 18 - * NOTICE: status codes start to skip around now! - * 421 - 19 - * 422 - 20 - * 423 - 21 - * 424 - 22 - * 425 - 23 - * 426 - 24 - * 428 - 25 - * 429 - 26 - * 431 - 27 - * 451 - 28 - */ - -/* - * Translate the error code into an index for the error table. - * See above for the mappings between status codes and indicies. - */ -uint16_t -hash_client_error(int status) -{ - if (status < 419) - { - return status - 400; - } - - if (status < 427) - { - return status - 402; - } - - if (status < 430) - { - return status - 406; - } - - if (status == 431) - { - return 27; - } - - if (status == 451) - { - return 28; - } - - PyErr_Format( - PyExc_RuntimeError, - "%d is not a valid status code", - status - ); - return 600; -} - -/* - * Translate a server error into an index for the error table. - */ -uint16_t -hash_server_error(int status) -{ - uint16_t index = status - (status < 509 ? 500 : 501); - if ((index < 0) || (index > 10)) - { - PyErr_Format( - PyExc_RuntimeError, - "%d is not a valid status code", - status - ); - return 600; - } - return index; -} - -/* - * Get the stringified version of an error (e.g. 400 -> "Bad Request"). - * These strings are static, and do not need to be freed by the caller. - */ -static const char * -get_err_str(int status) -{ - switch (status) - { - ER( - 400, - "Bad Request" - ); - ER( - 401, - "Unauthorized" - ); - ER( - 402, - "Payment Required" - ); - ER( - 403, - "Forbidden" - ); - ER( - 404, - "Not Found" - ); - ER( - 405, - "Method Not Allowed" - ); - ER( - 406, - "Not Acceptable" - ); - ER( - 407, - "Proxy Authentication Required" - ); - ER( - 408, - "Request Timeout" - ); - ER( - 409, - "Conflict" - ); - ER( - 410, - "Gone" - ); - ER( - 411, - "Length Required" - ); - ER( - 412, - "Precondition Failed" - ); - ER( - 413, - "Payload Too Large" - ); - ER( - 414, - "URI Too Long" - ); - ER( - 415, - "Unsupported Media Type" - ); - ER( - 416, - "Range Not Satisfiable" - ); - ER( - 417, - "Expectation Failed" - ); - ER( - 418, - "I'm a teapot" - ); - ER( - 421, - "Misdirected Request" - ); - ER( - 422, - "Unprocessable Content" - ); - ER( - 423, - "Locked" - ); - ER( - 424, - "Failed Dependency" - ); - ER( - 425, - "Too Early" - ); - ER( - 426, - "Upgrade Required" - ); - ER( - 428, - "Precondition Required" - ); - ER( - 429, - "Too Many Requests" - ); - ER( - 431, - "Request Header Fields Too Large" - ); - ER( - 451, - "Unavailable for Legal Reasons" - ); - ER( - 500, - "Internal Server Error" - ); - ER( - 501, - "Not Implemented" - ); - ER( - 502, - "Bad Gateway" - ); - ER( - 503, - "Service Unavailable" - ); - ER( - 504, - "Gateway Timeout" - ); - ER( - 505, - "HTTP Version Not Supported" - ); - ER( - 506, - "Variant Also Negotiates" - ); - ER( - 507, - "Insufficent Storage" - ); - ER( - 508, - "Loop Detected" - ); - ER( - 510, - "Not Extended" - ); - ER( - 511, - "Network Authentication Required" - ); - } - - PyErr_Format( - PyExc_RuntimeError, - "invalid status code: %d", - status - ); - return NULL; -} - -/* - * PyAwaitable callback for an error handler. - * - * This function takes the result of an error callback, which is - * the same as any route callback, so the result is deferred to handle_result() - */ -static int -finalize_err_cb(PyObject *awaitable, PyObject *result) -{ - PyObject *send; - PyObject *raw_path; - const char *method_str; - route *r; - long is_http; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &send, - &raw_path - ) < 0 - ) - return -1; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - &method_str - ) < 0 - ) - return -1; - - if (PyAwaitable_UnpackIntValues(awaitable, &is_http) < 0) - return -1; - - char *res_str; - int status_code; - PyObject *headers; - - if ( - handle_result( - result, - &res_str, - &status_code, - &headers, - raw_path, - method_str - ) < 0 - ) - { - Py_DECREF(result); - return -1; - } - - if ( - send_raw_text( - awaitable, - send, - status_code, - res_str, - headers, - is_http - ) < 0 - ) - { - Py_DECREF(result); - PyMem_Free(res_str); - return -1; - } - - PyMem_Free(res_str); - return 0; -} - -static int -run_err_cb( - PyObject *awaitable, - PyObject *handler, - PyObject *send, - int status, - bool *called, - const char *message, - route *r, - PyObject *raw_path, - const char *method, - bool is_http -) -{ - if (!handler) - { - if (called) - *called = false; - const char *msg; - if (!message) - { - msg = get_err_str(status); - if (!msg) - return -1; - } else - msg = message; - - PyObject *args = Py_BuildValue( - "(iOs)", - status, - raw_path, - method - ); - - if ( - !PyObject_Call( - route_log, - args, - NULL - ) - ) - { - Py_DECREF(args); - return -1; - } - - Py_DECREF(args); - if ( - send_raw_text( - awaitable, - send, - status, - msg, - NULL, - is_http - ) < 0 - ) - return -1; - - return 0; - } - if (called) - *called = true; - - PyObject *coro = PyObject_CallNoArgs(handler); - - if (!coro) - return -1; - - PyObject *new_awaitable = PyAwaitable_New(); - - if (!new_awaitable) - { - Py_DECREF(coro); - return -1; - } - - if ( - PyAwaitable_SaveValues( - new_awaitable, - 2, - send, - raw_path - ) < 0 - ) - { - Py_DECREF(new_awaitable); - Py_DECREF(coro); - return -1; - } - - if ( - PyAwaitable_SaveArbValues( - new_awaitable, - 1, - r - ) < 0 - ) - { - Py_DECREF(new_awaitable); - Py_DECREF(coro); - return -1; - } - - if (PyAwaitable_SaveIntValues(new_awaitable, 1, is_http) < 0) - { - Py_DECREF(new_awaitable); - Py_DECREF(coro); - return -1; - } - - if ( - PyAwaitable_AddAwait( - new_awaitable, - coro, - finalize_err_cb, - NULL - ) < 0 - ) - { - Py_DECREF(new_awaitable); - Py_DECREF(coro); - return -1; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - new_awaitable, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(new_awaitable); - Py_DECREF(coro); - return -1; - } - - return 0; -} - -int -fire_error( - ViewApp *self, - PyObject *awaitable, - int status, - route *r, - bool *called, - const char *message, - const char *method_str, - bool is_http -) -{ - PyObject *send; - PyObject *raw_path; - - if ( - PyAwaitable_UnpackValues( - awaitable, - NULL, - NULL, - NULL, - &send, - &raw_path - ) < 0 - ) - return -1; - - uint16_t index = 0; - PyObject *handler = NULL; - - if (status >= 500) - { - index = hash_server_error(status); - if (index == 600) - { - return -1; - } - if (r) - handler = r->server_errors[index]; - if (!handler) - handler = self->server_errors[index]; - } else - { - index = hash_client_error(status); - if (index == 600) - { - return -1; - } - if (r) - handler = r->client_errors[index]; - if (!handler) - handler = self->client_errors[index]; - } - - if ( - run_err_cb( - awaitable, - handler, - send, - status, - called, - message, - r, - raw_path, - method_str, - is_http - ) < 0 - ) - { - if ( - send_raw_text( - awaitable, - send, - 500, - "failed to dispatch error handler", - NULL, - is_http - ) < 0 - ) - { - return -1; - } - } - - return 0; -} - -static int -server_err_exc( - ViewApp *self, - PyObject *awaitable, - uint16_t status, - route *r, - bool *handler_was_called, - PyObject *msg, - const char *method_str -) -{ - const char *message = NULL; - PyObject *msg_str = NULL; - PyErr_Clear(); - - if (self->dev) - { - assert(msg != NULL); - msg_str = PyObject_Str(msg); - if (!msg_str) - { - return -1; - } - - message = PyUnicode_AsUTF8(msg_str); - if (!message) - { - Py_DECREF(msg_str); - return -1; - } - } - - if ( - fire_error( - self, - awaitable, - status, - r, - handler_was_called, - message, - method_str, - true - ) < 0 - ) - { - Py_XDECREF(msg_str); - return -1; - } - - Py_XDECREF(msg_str); - return 0; -} - -int -server_err( - ViewApp *self, - PyObject *awaitable, - uint16_t status, - route *r, - bool *handler_was_called, - const char *method_str -) -{ - int res = server_err_exc( - self, - awaitable, - status, - r, - handler_was_called, - PyErr_GetRaisedException(), - method_str - ); - return res; -} - -int -route_error( - PyObject *awaitable, - PyObject *err -) -{ - /* - if (PyErr_GivenExceptionMatches(err, ws_disconnect_err)) - { - // the socket prematurely disconnected, let's complain about it - #if PY_MINOR_VERSION < 9 - PyObject *args = Py_BuildValue( - "(s)", - "Unhandled WebSocket disconnect" - ); - if (!args) - return -2; - - if (!PyObject_Call(route_warn, args, NULL)) - { - Py_DECREF(args); - return -2; - } - #else - PyObject *message = PyUnicode_FromStringAndSize( - "Unhandled WebSocket disconnect", - sizeof( - "Unhandled WebSocket disconnect") - 1 - ); - if (!message) - return -2; - - if (!PyObject_CallOneArg(route_warn, message)) - { - Py_DECREF(message); - return -2; - } - #endif - - return 0; - } - */ - - ViewApp *self; - route *r; - PyObject *scope; - PyObject *send; - bool handler_was_called; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &self, - NULL, - NULL, - &send, - NULL - ) < 0 - ) - return -1; - - const char *method_str; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - NULL, - NULL, - &method_str - ) < 0 - ) - return -1; - - long is_http; - - if (PyAwaitable_UnpackIntValues(awaitable, &is_http) < 0) - return -1; - - if (self->error_type != NULL) - // Under general cirumstances, error_type should never - // be NULL. But, we might as well support it. - if (PyErr_GivenExceptionMatches(err, self->error_type)) - { - PyObject *status_obj = PyObject_GetAttrString( - err, - "status" - ); - if (!status_obj) - return -2; - - PyObject *msg_obj = PyObject_GetAttrString( - err, - "message" - ); - - if (!msg_obj) - { - Py_DECREF(status_obj); - return -2; - } - - int status = PyLong_AsLong(status_obj); - if ((status == -1) && PyErr_Occurred()) - { - Py_DECREF(status_obj); - Py_DECREF(msg_obj); - return -2; - } - - const char *message = NULL; - - if (msg_obj != Py_None) - { - message = PyUnicode_AsUTF8(msg_obj); - if (!message) - { - Py_DECREF(status_obj); - Py_DECREF(msg_obj); - return -2; - } - } - - if ( - fire_error( - self, - awaitable, - status, - r, - NULL, - message, - method_str, - is_http - ) < 0 - ) - { - Py_DECREF(status_obj); - Py_DECREF(msg_obj); - return -2; - } - - Py_DECREF(status_obj); - Py_DECREF(msg_obj); - return 0; - } - - if (!is_http) - { - // send a websocket error code - PyObject *send_dict; - if (self->dev) - { - PyObject *str = PyObject_Str(err); - if (!str) - return -1; - - send_dict = Py_BuildValue( - "{s:s,s:i,s:S}", - "type", - "websocket.close", - "code", - 1006, - "reason", - str - ); - Py_DECREF(str); - } else - send_dict = Py_BuildValue( - "{s:s,s:i}", - "type", - "websocket.close", - "code", - 1006 - ); - - if (!send_dict) - return -1; - - PyObject *coro = PyObject_Vectorcall( - send, - (PyObject *[]){send_dict}, - 1, - NULL - ); - Py_DECREF(send_dict); - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - Py_DECREF(coro); - - PyErr_SetRaisedException(err); - PyErr_Print(); - - return 0; - } - - if ( - server_err_exc( - self, - awaitable, - 500, - r, - &handler_was_called, - err, - method_str - ) < 0 - ) - { - return -1; - } - - if (!handler_was_called) - { - // err is a borrowed reference, and PyErr_SetRaisedException steals it! - PyErr_SetRaisedException(Py_NewRef(err)); - PyErr_Print(); - } - - return 0; -} - -int -load_errors(route *r, PyObject *dict) -{ - PyObject *iter = PyObject_GetIter(dict); - PyObject *key; - PyObject *value; - - while ((key = PyIter_Next(iter))) - { - value = PyDict_GetItem( - dict, - key - ); - if (!value) - { - Py_DECREF(iter); - return -1; - } - - int status_code = PyLong_AsLong(key); - if (status_code == -1) - { - Py_DECREF(iter); - return -1; - } - - if (status_code < 400 || status_code > 511) - { - PyErr_Format( - PyExc_ValueError, - "%d is not a valid status code", - status_code - ); - Py_DECREF(iter); - return -1; - } - - if (status_code >= 500) - { - r->server_errors[status_code - 500] = Py_NewRef(value); - } else - { - uint16_t index = hash_client_error(status_code); - if (index == 600) - { - PyErr_Format( - PyExc_RuntimeError, - "%d is not a valid status code", - status_code - ); - return -1; - } - r->client_errors[index] = Py_NewRef(value); - } - } - - Py_DECREF(iter); - - if (PyErr_Occurred()) - return -1; - return 0; -} diff --git a/src/_view/handling.c b/src/_view/handling.c deleted file mode 100644 index faf74614..00000000 --- a/src/_view/handling.c +++ /dev/null @@ -1,743 +0,0 @@ -/* - * view.py C route handling implementation - * - * This file contains the the general logic for calling - * a route object, and where to send their results. - * - * This is responsible for determining inputs, dealing with return - * values, and dispatching the HTTP response to the ASGI server. - * - * Raw results returned from a route are one of three things: - * - * - A tuple containing a string and integer, and optionally a dictionary. - * - A string, solely denoting a body. - * - An object with a __view_result__(), which returns one of the two above. - * - * The handling implementation is only responsible for dealing with a __view_result__(), and - * the rest is sent to the route result implementation. - */ -#include -#include // bool - -#include // ViewApp -#include -#include // context_from_data -#include // route_error -#include -#include // route_input, body_inc_buf -#include // handle_result -#include // route_log - -#include - -// NOTE: This should be below 512 for PyMalloc to be effective -// on the first call. -#define INITIAL_BUF_SIZE 256 - -/* - * Call a route object with both query and body parameters. - */ -int -handle_route_impl( - PyObject *awaitable, - char *body, - char *query -) -{ - route *r; - ViewApp *self; - Py_ssize_t *size; - PyObject **path_params; - PyObject *scope; - PyObject *receive; - PyObject *send; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &self, - &scope, - &receive, - &send, - NULL - ) < 0 - ) - { - return -1; - } - - const char *method_str; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - &path_params, - &size, - &method_str - ) < 0 - ) - { - return -1; - } - - PyObject *query_obj = query_parser( - &self->parsers, - query - ); - - if (!query_obj) - { - show_error(self->dev); - return server_err( - self, - awaitable, - 400, - r, - NULL, - method_str - ); - } - - PyObject **params = generate_params( - self, - &self->parsers, - body, - query_obj, - r->inputs, - r->inputs_size, - scope, - receive, - send - ); - - Py_DECREF(query_obj); - - if (!params) - { - show_error(self->dev); - return server_err( - self, - awaitable, - 400, - r, - NULL, - method_str - ); - } - - PyObject *coro; - - if (size) - { - PyObject **merged = PyMem_Calloc( - r->inputs_size + (*size), - sizeof(PyObject *) - ); - - if (!merged) - return -1; - - for (int i = 0; i < (*size); i++) - merged[i] = path_params[i]; - - for (int i = *size; i < r->inputs_size + *size; i++) - merged[i] = params[i]; - - coro = PyObject_Vectorcall( - r->callable, - merged, - r->inputs_size + (*size), - NULL - ); - - for (int i = 0; i < r->inputs_size + *size; i++) - Py_DECREF(merged[i]); - - free(path_params); - free(size); - free(merged); - if ( - server_err( - self, - awaitable, - 500, - r, - NULL, - method_str - ) < 0 - ) - return -1; - } else coro = PyObject_Vectorcall( - r->callable, - params, - r->inputs_size, - NULL - ); - - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - handle_route_callback, - route_error - ) < 0 - ) - { - return -1; - } - - return 0; -} - -int -handle_route(PyObject *awaitable, char *query) -{ - PyObject *receive; - route *r; - - if ( - PyAwaitable_UnpackValues( - awaitable, - NULL, - NULL, - &receive, - NULL, - NULL - ) < 0 - ) - return -1; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - NULL, - NULL, - NULL - ) < 0 - ) - return -1; - - char *buf = PyMem_Malloc(INITIAL_BUF_SIZE); - - if (!buf) - { - PyErr_NoMemory(); - return -1; - } - - Py_ssize_t *size = PyMem_Malloc(sizeof(Py_ssize_t)); - - if (!size) - { - PyMem_Free(buf); - PyErr_NoMemory(); - return -1; - } - - Py_ssize_t *used = PyMem_Malloc(sizeof(Py_ssize_t)); - - if (!used) - { - PyMem_Free(buf); - PyMem_Free(used); - PyErr_NoMemory(); - return -1; - } - - *used = 0; - *size = INITIAL_BUF_SIZE; - strcpy(buf, ""); - - PyObject *aw = PyAwaitable_New(); - if (!aw) - return -1; - - if ( - PyAwaitable_SaveValues( - aw, - 2, - awaitable, - receive - ) < 0 - ) - { - Py_DECREF(aw); - PyMem_Free(buf); - return -1; - } - - - if ( - PyAwaitable_SaveArbValues( - aw, - 4, - buf, - size, - used, - query - ) < 0 - ) - { - Py_DECREF(aw); - PyMem_Free(buf); - return -1; - } - - PyObject *receive_coro = PyObject_CallNoArgs(receive); - - if (!receive_coro) - { - Py_DECREF(aw); - return -1; - } - - if ( - PyAwaitable_AddAwait( - aw, - receive_coro, - body_inc_buf, - NULL - ) < 0 - ) - { - Py_DECREF(aw); - PyMem_Free(buf); - return -1; - } - - Py_DECREF(receive_coro); - - if ( - PyAwaitable_AddAwait( - awaitable, - aw, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(aw); - PyMem_Free(buf); - return -1; - } - - return 0; -} - -int -handle_route_callback( - PyObject *awaitable, - PyObject *result -) -{ - ViewApp *self; - PyObject *send; - PyObject *scope; - PyObject *receive; - PyObject *raw_path; - route *r; - const char *method_str; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &self, - &scope, - &receive, - &send, - &raw_path - ) < 0 - ) - return -1; - - PyObject *view_result = PyObject_GetAttrString( - result, - "__view_result__" - ); - if (view_result) - { - PyObject *context = context_from_data((PyObject *) self, scope); - if (!context) - { - Py_DECREF(view_result); - return -1; - } - - result = PyObject_CallOneArg(view_result, context); - Py_DECREF(view_result); - if (!result) - return -1; - - if ( - Py_TYPE(result)->tp_as_async && Py_TYPE(result)->tp_as_async-> - am_await - ) - { - // object is awaitable - if ( - PyAwaitable_AddAwait( - awaitable, - result, - handle_route_callback, - route_error - ) < 0 - ) - { - Py_DECREF(result); - return -1; - } - - return 0; - } - } else Py_INCREF(result); - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - NULL, - NULL, - &method_str - ) < 0 - ) - { - Py_DECREF(result); - return -1; - } - - char *res_str; - int status; - PyObject *headers; - - if ( - handle_result( - result, - &res_str, - &status, - &headers, - raw_path, - method_str - ) < 0 - ) - { - Py_DECREF(result); - return -1; - } - - Py_DECREF(result); - - if (r->cache_rate > 0) - { - r->cache = res_str; - r->cache_status = status; - r->cache_headers = Py_NewRef(headers); - r->cache_index = 0; - } - - PyObject *dc = Py_BuildValue( - "{s:s,s:i,s:O}", - "type", - "http.response.start", - "status", - status, - "headers", - headers - ); - - if (!dc) - return -1; - - PyObject *coro = PyObject_Vectorcall( - send, - (PyObject *[]) { dc }, - 1, - NULL - ); - Py_DECREF(dc); - - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - ; - - Py_DECREF(coro); - PyObject *dct = Py_BuildValue( - "{s:s,s:y}", - "type", - "http.response.body", - "body", - res_str - ); - - if (!dct) - return -1; - - coro = PyObject_Vectorcall( - send, - (PyObject *[]) { dct }, - 1, - NULL - ); - - Py_DECREF(dct); - if (r->cache_rate <= 0) - PyMem_Free(res_str); - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - - Py_DECREF(coro); - return 0; -} - -int -send_raw_text( - PyObject *awaitable, - PyObject *send, - int status, - const char *res_str, - PyObject *headers, /* may be NULL */ - bool is_http -) -{ - PyObject *coro; - PyObject *send_dict; - - if (!headers) - { - send_dict = Py_BuildValue( - "{s:s,s:i,s:[[y,y]]}", - "type", - is_http ? "http.response.start" : "websocket.http.response.start", - "status", - status, - "headers", - "content-type", - "text/plain" - ); - - if (!send_dict) - return -1; - - coro = PyObject_Vectorcall( - send, - (PyObject *[]) { send_dict }, - 1, - NULL - ); - } else - { - send_dict = Py_BuildValue( - "{s:s,s:i,s:O}", - "type", - is_http ? "http.response.start" : "websocket.http.response.start", - "status", - status, - "headers", - headers - ); - - if (!send_dict) - return -1; - - coro = PyObject_Vectorcall( - send, - (PyObject *[]){send_dict}, - 1, - NULL - ); - } - Py_DECREF(send_dict); - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - ; - - Py_DECREF(coro); - PyObject *dict = Py_BuildValue( - "{s:s,s:y}", - "type", - is_http ? "http.response.body" : "websocket.http.response.body", - "body", - res_str - ); - - if (!dict) - return -1; - - coro = PyObject_Vectorcall( - send, - (PyObject *[]){dict}, - 1, - NULL - ); - - Py_DECREF(dict); - - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - - Py_DECREF(coro); - return 0; -} - -int -handle_route_websocket(PyObject *awaitable, PyObject *result) -{ - char *res; - int status = 1005; - PyObject *headers; - - PyObject *send; - PyObject *receive; - PyObject *raw_path; - route *r; - const char *method_str; - - if ( - PyAwaitable_UnpackValues( - awaitable, - NULL, - NULL, - NULL, - &send, - &raw_path - ) < 0 - ) return -1; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - NULL, - NULL, - NULL - ) < 0 - ) return -1; - - if (result == Py_None) - { - PyObject *args = Py_BuildValue( - "(iOs)", - 1000, - raw_path, - "websocket_closed" - ); - - if (!args) - return -1; - - if ( - !PyObject_Call( - route_log, - args, - NULL - ) - ) - { - Py_DECREF(args); - return -1; - } - Py_DECREF(args); - return 0; - } - - - if ( - handle_result( - result, - &res, - &status, - &headers, - raw_path, - "websocket_closed" - ) < 0 - ) - return -1; - - PyObject *send_dict = Py_BuildValue( - "{s:s,s:s}", - "type", - "websocket.send", - "text", - res - ); - free(res); - - if (!send_dict) - return -1; - - PyObject *coro = PyObject_Vectorcall( - send, - (PyObject *[]) { send_dict }, - 1, - NULL - ); - if (!coro) - { - Py_DECREF(send_dict); - return -1; - } - - Py_DECREF(send_dict); - if (PyAwaitable_AddAwait(awaitable, coro, NULL, NULL) < 0) - { - Py_DECREF(coro); - return -1; - } - return 0; -} diff --git a/src/_view/headerdict.c b/src/_view/headerdict.c deleted file mode 100644 index 36f62472..00000000 --- a/src/_view/headerdict.c +++ /dev/null @@ -1,450 +0,0 @@ -/* - * view.py headerdict implementation - * - * A headerdict represents HTTP headers from the client. - * It's similar to a normal Python dictionary, but keys are always strings, and values - * can be either a string or a list. - * - * This implementation uses view.py's map, not a Python dictionary. - */ -#include -#include // PyMemberDef - -#include -#include // offsetof - -#include -#include -#include -#include // pymem_strdup -#include // COLD -#define MAX_COOKIE_LENGTH 256 - -typedef struct -{ - bool is_list; - PyObject *value; -} header_item; - -void -header_item_free(header_item *item) -{ - Py_DECREF(item->value); - PyMem_Free(item); -} - -header_item * -header_item_new(PyObject *value) -{ - header_item *item = PyMem_Malloc(sizeof(header_item)); - if (!item) - return NULL; - - item->is_list = false; - item->value = Py_NewRef(value); - return item; -} - -typedef struct -{ - PyObject_HEAD - map *headers; -} HeaderDict; - -/* - * This creates a copy of the object as a dictionary, and uses it as a __repr__() - * - * It's not perfect, but this function won't really get called in production, - * so we can cheat a little bit here. - */ -static PyObject * -repr(HeaderDict *self) -{ - PyObject *dict_repr = PyDict_New(); - if (!dict_repr) - return NULL; - - for (Py_ssize_t i = 0; i < self->headers->capacity; ++i) - { - pair *p = self->headers->items[i]; - if (!p) - continue; - - header_item *it = p->value; - if (PyDict_SetItemString(dict_repr, p->key, it->value) < 0) - { - Py_DECREF(dict_repr); - return NULL; - } - ; - } - - return PyUnicode_FromFormat( - "HeaderDict(%R)", - dict_repr - ); -} - -static void -dealloc(HeaderDict *self) -{ - if (self->headers) - map_free(self->headers); - Py_TYPE(self)->tp_free((PyObject *) self); -} - -static PyObject * -HeaderDict_new( - PyTypeObject *type, - PyObject *args, - PyObject *kwargs -) -{ - HeaderDict *self = (HeaderDict *) type->tp_alloc( - type, - 0 - ); - if (!self) - return NULL; - - self->headers = map_new(4, (map_free_func) header_item_free); - if (!self->headers) - { - Py_DECREF(self); - return NULL; - } - return (PyObject *) self; -} - -// For debugging -COLD static void -print_header_item(header_item *item) -{ - PyObject_Print(item->value, stdout, Py_PRINT_RAW); -} - -static PyObject * -get_item(HeaderDict *self, PyObject *value) -{ - if (!PyUnicode_CheckExact(value)) - { - PyErr_Format( - PyExc_TypeError, - "expected header dict index to be a string, not %R", - value - ); - return NULL; - } - - Py_ssize_t key_size; - const char *const_key_str = PyUnicode_AsUTF8AndSize(value, &key_size); - if (!const_key_str) - return NULL; - - char *key_str = pymem_strdup(const_key_str, key_size); - if (!key_str) - return NULL; - - // make it lower case - for (Py_ssize_t i = 0; key_str[i]; ++i) - { - key_str[i] = tolower(key_str[i]); - } - - header_item *item = map_get(self->headers, key_str); - PyMem_Free(key_str); - if (item == NULL) - { - PyErr_SetObject(PyExc_KeyError, value); - return NULL; - } - - return Py_NewRef(item->value); -} - -static int -set_item(HeaderDict *self, PyObject *key, PyObject *value) -{ - if (!PyUnicode_CheckExact(value)) - { - PyErr_Format( - PyExc_TypeError, - "expected header dict index to be a string, not %R", - value - ); - return -1; - } - - Py_ssize_t key_size; - const char *const_key_str = PyUnicode_AsUTF8AndSize(key, &key_size); - if (!const_key_str) - return -1; - - char *key_str = pymem_strdup(const_key_str, key_size); - if (!key_str) - return -1; - - // make it lower case - for (Py_ssize_t i = 0; key_str[i]; ++i) - { - key_str[i] = tolower(key_str[i]); - } - - header_item *item = map_get(self->headers, key_str); - if (!item) - { - // item is not set, set it - item = header_item_new(value); - if (!item) - { - PyMem_Free(key_str); - return -1; - } - - map_set(self->headers, key_str, item); - PyMem_Free(key_str); - return 0; - } - PyMem_Free(key_str); - - if (item->is_list) - { - if (PyList_Append(item->value, value) < 0) - return -1; - return 0; - } - - PyObject *list = PyList_New(2); - if (!list) - return -1; - - PyList_SET_ITEM(list, 0, item->value); - PyList_SET_ITEM(list, 1, Py_NewRef(value)); - item->value = list; - item->is_list = 1; - - return 0; -} - -static Py_ssize_t -get_length(HeaderDict *self) -{ - return self->headers->len; -} - -PyObject * -headerdict_from_list(PyObject *list, PyObject *cookies) -{ - HeaderDict *hd = (HeaderDict *) HeaderDict_new( - &HeaderDictType, - NULL, - NULL - ); - if (!hd) - return NULL; - - Py_ssize_t size = PyList_GET_SIZE(list); - for (Py_ssize_t i = 0; i < size; ++i) - { - PyObject *tup = PyList_GET_ITEM(list, i); - PyObject *key = PyTuple_GET_ITEM(tup, 0); - PyObject *value = PyTuple_GET_ITEM(tup, 1); - - Py_ssize_t key_size; - char *const_key_str; - if (PyBytes_AsStringAndSize(key, &const_key_str, &key_size) < 0) - { - Py_DECREF(hd); - return NULL; - } - - char *key_str = pymem_strdup(const_key_str, key_size); - if (!key_str) - { - Py_DECREF(hd); - return NULL; - } - - // make it lower case - for (Py_ssize_t i = 0; key_str[i]; ++i) - { - key_str[i] = tolower(key_str[i]); - } - - PyObject *value_str = PyUnicode_FromEncodedObject( - value, - "utf8", - "strict" - ); - if (!value_str) - { - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - - if (cookies && !strcmp(key_str, "cookie")) - { - // It's a cookie - // Value Format: key=value; key=value; ... - char *value_const_str; - Py_ssize_t value_size; - - if ( - PyBytes_AsStringAndSize( - value, - &value_const_str, - &value_size - ) < 0 - ) - { - - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - - char cookie_key_buf[MAX_COOKIE_LENGTH] = ""; - char cookie_value_buf[MAX_COOKIE_LENGTH] = ""; - char *buf = cookie_key_buf; - - Py_ssize_t loc = 0; - - // Using value_size + 1 as we want to include the NULL terminator - for (Py_ssize_t i = 0; i < (value_size + 1); ++i) - { - char c = value_const_str[i]; - buf[loc++] = c; - if (loc == MAX_COOKIE_LENGTH) - { - PyErr_SetString( - PyExc_SystemError, - "client cookie is too long" - ); - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - - if ((c == '=') && (buf == cookie_key_buf)) - { - buf[loc - 1] = '\0'; - buf = cookie_value_buf; - loc = 0; - continue; - } - - if ((c == ';' || c == '\0') && (buf == cookie_value_buf)) - { - ++i; // Skip the trailing space - buf[loc - 1] = '\0'; - PyObject *cookie_value_obj = - PyUnicode_FromStringAndSize(cookie_value_buf, loc); - - if (!cookie_value_obj) - { - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - - if ( - PyDict_SetItemString( - cookies, - cookie_key_buf, - cookie_value_obj - ) < 0 - ) - { - Py_DECREF(cookie_value_obj); - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - - Py_DECREF(cookie_value_obj); - - buf = cookie_key_buf; - loc = 0; - continue; - } - } - - if (loc != 0) - { - PyErr_Format( - PyExc_SystemError, - "problem in cookie parsing: expected loc to be 0, got %ld", - loc - ); - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - } - - header_item *item = header_item_new(value_str); - if (!item) - { - PyMem_Free(key_str); - Py_DECREF(hd); - return NULL; - } - - map_set(hd->headers, key_str, item); - PyMem_Free(key_str); - } - - return (PyObject *) hd; -} - -static PyMappingMethods mapping_methods = -{ - .mp_subscript = (binaryfunc) get_item, - .mp_ass_subscript = (objobjargproc) set_item, - .mp_length = (lenfunc) get_length -}; - -static PyObject * -HeaderDict_get(HeaderDict *self, PyObject *args) -{ - PyObject *key; - PyObject *df = Py_None; - - if (!PyArg_ParseTuple(args, "O!|O", &PyUnicode_Type, &key, &df)) - return NULL; - - PyObject *val = get_item(self, key); - if (!val) - { - if (!PyErr_ExceptionMatches(PyExc_KeyError)) - return NULL; - PyErr_Clear(); - return Py_NewRef(df); - } - - return val; -} - -static PyMethodDef methods[] = -{ - {"get", (PyCFunction) HeaderDict_get, METH_VARARGS, NULL}, - {NULL, NULL, 0, NULL} -}; - -PyTypeObject HeaderDictType = -{ - PyVarObject_HEAD_INIT( - NULL, - 0 - ) - .tp_name = "_view.HeaderDict", - .tp_basicsize = sizeof(HeaderDict), - .tp_itemsize = 0, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_new = HeaderDict_new, - .tp_dealloc = (destructor) dealloc, - .tp_repr = (reprfunc) repr, - .tp_as_mapping = &mapping_methods, - .tp_methods = methods -}; diff --git a/src/_view/inputs.c b/src/_view/inputs.c deleted file mode 100644 index dd78a11d..00000000 --- a/src/_view/inputs.c +++ /dev/null @@ -1,603 +0,0 @@ -/* - * view.py route inputs implementation - * - * This file is responsible for parsing route inputs through query - * strings and body parameters. - * - * If a route has no inputs, then the parsing - * step is skipped for optimization purposes. - * - * If a route has only query inputs, then we don't need to go through the - * body parsing step, and only parse the query string (handle_route_query()). - * - * If a route has body inputs, then we start by parsing that, and if it has any - * query string parameters, that's handled later. ASGI does not send the body - * in a single receive() call, so we have a buffer that increases over time. - * - * This implementation is also in charge of building data inputs (such as Context() or WebSocket()) - * and appending them to routes. This is indicated by a special integer determined by the loader. - * - */ -#include -#include // true - -#include // ViewApp -#include // context_from_data -#include -#include // app_parsers -#include // handle_route_impl -#include -#include // ws_from_data -#include // VIEW_FATAL - -#include - -typedef struct _app_parsers app_parsers; -typedef PyObject **(* parserfunc)( - app_parsers *, - const char *, - PyObject *, - route_input **, - Py_ssize_t -); - -/* - * PyAwaitable callback - do not call manually! - * - * This appends the internal buffer with the received body, and - * calls itself as a coroutine until the entire body has been - * received. - * - * After the body has been received, the route is sent to - * the handler. - */ -int -body_inc_buf(PyObject *awaitable, PyObject *result) -{ - PyObject *body = PyDict_GetItemString( - result, - "body" - ); - - if (!body) - { - return PyErr_BadASGI(); - } - - PyObject *more_body = PyDict_GetItemString( - result, - "more_body" - ); - if (!more_body) - { - return PyErr_BadASGI(); - } - - char *buf_inc; - Py_ssize_t buf_inc_size; - - if ( - PyBytes_AsStringAndSize( - body, - &buf_inc, - &buf_inc_size - ) < 0 - ) - { - return -1; - } - - char *buf; - Py_ssize_t *size; - Py_ssize_t *used; - char *query; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &buf, - &size, - &used, - &query - ) < 0 - ) - { - return -1; - } - - char *nbuf = buf; - bool needs_realloc = false; - - while (((*used) + buf_inc_size) > (*size)) - { - // The buffer would overflow, we need to reallocate it - *size *= 2; - needs_realloc = true; - } - - if (needs_realloc) - { - nbuf = PyMem_Realloc( - buf, - (*size) - ); - - if (!nbuf) - { - PyErr_NoMemory(); - return -1; - } - } - - strncat( - nbuf, - buf_inc, - buf_inc_size - ); - *used += buf_inc_size; - PyAwaitable_SetArbValue( - awaitable, - 0, - nbuf - ); - - PyObject *aw; - PyObject *receive; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &aw, - &receive - ) < 0 - ) - { - return -1; - } - - if (PyObject_IsTrue(more_body)) - { - PyObject *receive_coro = PyObject_CallNoArgs(receive); - - if ( - PyAwaitable_AddAwait( - awaitable, - receive_coro, - body_inc_buf, - NULL - ) < 0 - ) - { - Py_DECREF(receive_coro); - PyMem_Free(query); - PyMem_Free(nbuf); - return -1; - } - - Py_DECREF(receive_coro); - } else - { - if ( - handle_route_impl( - aw, - nbuf, - query - ) < 0 - ) - { - PyMem_Free(nbuf); - return -1; - } - - PyMem_Free(nbuf); - } - - return 0; -} - -/* - * Call a route without parsing the body. - */ -int -handle_route_query(PyObject *awaitable, char *query) -{ - ViewApp *self; - route *r; - PyObject **path_params; - Py_ssize_t *size; - PyObject *scope; - PyObject *receive; - PyObject *send; - - if ( - PyAwaitable_UnpackValues( - awaitable, - &self, - &scope, - &receive, - &send, - NULL - ) < 0 - ) - return -1; - - const char *method_str; - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - NULL, - NULL, - NULL, - &method_str - ) < - 0 - ) - return -1; - - PyObject *query_obj = query_parser( - &self->parsers, - query - ); - - if (!query_obj) - { - PyErr_Clear(); - return server_err( - self, - awaitable, - 400, - r, - NULL, - method_str - ); - } - - if ( - PyAwaitable_UnpackArbValues( - awaitable, - &r, - &path_params, - &size, - NULL - ) < 0 - ) - { - Py_DECREF(query_obj); - return -1; - } - - Py_ssize_t fake_size = 0; - - if (size == NULL) - size = &fake_size; - PyObject **params = PyMem_Calloc( - r->inputs_size, - sizeof(PyObject *) - ); - if (!params) - { - Py_DECREF(query_obj); - return -1; - } - Py_ssize_t final_size = 0; - - for (int i = 0; i < r->inputs_size; i++) - { - if (r->inputs[i]->route_data) - { - PyObject *data = build_data_input( - r->inputs[i]->route_data, - (PyObject *) self, - scope, - receive, - send - ); - if (!data) - { - for (int i = 0; i < r->inputs_size; i++) - Py_XDECREF(params[i]); - - PyMem_Free(params); - Py_DECREF(query_obj); - return -1; - } - - params[i] = data; - ++final_size; - continue; - } - - PyObject *item = PyDict_GetItemString( - query_obj, - r->inputs[i]->name - ); - - if (!item) - { - if (r->inputs[i]->df) - { - params[i] = r->inputs[i]->df; - ++final_size; - continue; - } - - for (int i = 0; i < r->inputs_size; i++) - Py_XDECREF(params[i]); - - PyMem_Free(params); - Py_DECREF(query_obj); - return fire_error( - self, - awaitable, - 400, - r, - NULL, - NULL, - method_str, - r->is_http - ); - } else ++final_size; - - if (item) - { - PyObject *parsed_item = cast_from_typecodes( - r->inputs[i]->types, - r->inputs[i]->types_size, - item, - self->parsers.json, - true - ); - if (!parsed_item) - { - PyErr_Clear(); - for (int i = 0; i < r->inputs_size; i++) - Py_XDECREF(params[i]); - - PyMem_Free(params); - Py_DECREF(query_obj); - return fire_error( - self, - awaitable, - 400, - r, - NULL, - NULL, - method_str, - r->is_http - ); - } - params[i] = parsed_item; - } - } - - PyObject **merged = PyMem_Calloc( - final_size + (*size), - sizeof(PyObject *) - ); - - if (!merged) - { - PyErr_NoMemory(); - return -1; - } - - for (int i = 0; i < (*size); i++) - merged[i] = path_params[i]; - - for (int i = 0; i < final_size; i++) - merged[*size + i] = params[i]; - - PyObject *coro = PyObject_Vectorcall( - r->callable, - merged, - *size + final_size, - NULL - ); - - for (int i = 0; i < final_size + *size; i++) - Py_XDECREF(merged[i]); - - PyMem_Free(merged); - PyMem_Free(params); - Py_DECREF(query_obj); - - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - r->is_http ? handle_route_callback : handle_route_websocket, - route_error - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - - Py_DECREF(coro); - return 0; -} - -/* - * Parse a query string into a Python dictionary. - */ -PyObject * -query_parser( - app_parsers *parsers, - const char *data -) -{ - PyObject *py_str = PyUnicode_FromString(data); - - if (!py_str) - return NULL; - - PyObject *obj = PyObject_Vectorcall( - parsers->query, - (PyObject *[]) { py_str }, - 1, - NULL - ); - - Py_DECREF(py_str); - return obj; // no need for null check -} - -/* - * Build a route data object based on the given data ID (determined by the loader). - * - * As of now: - * - 1: Context() - * - 2: WebSocket(), only supported on WebSocket routes - */ -PyObject * -build_data_input( - int num, - PyObject *app, - PyObject *scope, - PyObject *receive, - PyObject *send -) -{ - switch (num) - { - case 1: - return context_from_data(app, scope); - case 2: - return ws_from_data( - scope, - send, - receive - ); - - default: - VIEW_FATAL("got invalid route data number"); - } - return NULL; // to make editor happy -} - -static PyObject * -parse_body( - const char *data, - app_parsers *parsers, - PyObject *scope -) -{ - PyObject *py_str = PyUnicode_FromString(data); - if (!py_str) - return NULL; - - PyObject *obj = PyObject_Vectorcall( - parsers->json, - (PyObject *[]) { py_str }, - 1, - NULL - ); - Py_DECREF(py_str); - - return obj; -} - -PyObject ** -generate_params( - ViewApp *app, - app_parsers *parsers, - const char *data, - PyObject *query, - route_input **inputs, - Py_ssize_t inputs_size, - PyObject *scope, - PyObject *receive, - PyObject *send -) -{ - PyObject *obj = parse_body(data, parsers, scope); - if (!obj) - { - return NULL; - } - - PyObject **ob = PyMem_Calloc( - inputs_size, - sizeof(PyObject *) - ); - - if (!ob) - { - PyErr_NoMemory(); - Py_DECREF(obj); - return NULL; - } - - for (int i = 0; i < inputs_size; i++) - { - route_input *inp = inputs[i]; - if (inp->route_data) - { - PyObject *data = build_data_input( - inp->route_data, - (PyObject *) app, - scope, - receive, - send - ); - if (!data) - { - Py_DECREF(obj); - PyMem_Free(ob); - return NULL; - } - - ob[i] = data; - continue; - } - - // Borrowed reference - PyObject *raw_item = PyDict_GetItemString( - inp->is_body ? obj : query, - inp->name - ); - PyObject *item = cast_from_typecodes( - inp->types, - inp->types_size, - raw_item, - parsers->json, - true - ); - - if (!item) - { - assert(PyErr_Occurred()); - Py_DECREF(obj); - PyMem_Free(ob); - return NULL; - } - - for (int x = 0; x < inp->validators_size; x++) - { - PyObject *o = PyObject_Vectorcall( - inp->validators[x], - (PyObject *[]) { item }, - 1, - NULL - ); - if (!PyObject_IsTrue(o)) - { - Py_DECREF(o); - PyMem_Free(ob); - Py_DECREF(obj); - Py_DECREF(item); - return NULL; - } - } - - ob[i] = item; - } - - Py_DECREF(obj); - return ob; -} diff --git a/src/_view/main.c b/src/_view/main.c deleted file mode 100644 index 3fe5e3b2..00000000 --- a/src/_view/main.c +++ /dev/null @@ -1,270 +0,0 @@ -/* - * The _view extension module definition - * - * This is where all attributes of the extension module are initialized. - * Type stubs for the module are defined in the _view.pyi file. - * - * Most things the view.py C API are private APIs - they can make - * breaking changes without a deprecation period. - * - * Python objects stored at global scope are initialized by the module - * initialization function (PyInit__view). Generally, Python objects at - * global scope are one of two things: - * - * - A Python object that needs to be used from C, such as an exception class. - * - A Python API that needs to be called from the C API, such as `ipaddress.ip_address`. - * - * Some APIs are at global scope, but stored on the module to allow Python to manage - * it's reference count, such as the default headers. If they were only stored - * at global scope, then there would be no way for view.py to know when to decrement - * their reference count and deallocate it, causing a memory leak when the module - * is deallocated. - */ -#include -#include - -#include // ViewAppType -#include // ContextType -#include // HeaderDictType -#include // build_default_headers -#include // WebSocketType -#include - -#define PYAWAITABLE_THIS_FILE_INIT -#include - -PyObject *route_log = NULL; -PyObject *route_warn = NULL; -PyObject *ip_address = NULL; -PyObject *invalid_status_error = NULL; -PyObject *default_headers = NULL; - -/* - * Register route logging functions. - * - * As of now, this stores only the route logger, and the - * service warning function. - */ -static PyObject * -setup_route_log(PyObject *self, PyObject *args) -{ - PyObject *func; - PyObject *warn; - - if ( - !PyArg_ParseTuple( - args, - "OO", - &func, - &warn - ) - ) - return NULL; - - if (!PyCallable_Check(func)) - { - PyErr_Format( - PyExc_RuntimeError, - "setup_route_log got non-function object: %R", - func - ); - return NULL; - } - - if (!PyCallable_Check(warn)) - { - PyErr_Format( - PyExc_RuntimeError, - "setup_route_log got non-function object: %R", - warn - ); - return NULL; - } - - route_log = Py_NewRef(func); - route_warn = Py_NewRef(warn); - - if (PyModule_AddObject(self, "route_log", route_log) < 0) - { - Py_DECREF(route_log); - Py_DECREF(route_warn); - return NULL; - } - - if (PyModule_AddObject(self, "route_warn", route_warn) < 0) - { - Py_DECREF(route_warn); - return NULL; - } - - Py_RETURN_NONE; -} - -static PyObject * -dummy_context(PyObject *self, PyObject *app) -{ - return context_from_data(app, NULL); -} - -static PyObject * -test_awaitable(PyObject *self, PyObject *args) -{ - PyObject *func; - if (!PyArg_ParseTuple(args, "O", &func)) - return NULL; - - PyObject *res = PyObject_CallNoArgs(func); - if (!res) - return NULL; - - PyObject *awaitable = PyAwaitable_New(); - if (!awaitable) - { - Py_DECREF(res); - return NULL; - } - - if (PyAwaitable_AddAwait(awaitable, res, NULL, NULL) < 0) - { - Py_DECREF(awaitable); - Py_DECREF(res); - return NULL; - } - - return awaitable; -} - -static PyMethodDef methods[] = -{ - {"test_awaitable", test_awaitable, METH_VARARGS, NULL}, - {"setup_route_log", setup_route_log, METH_VARARGS, NULL}, - {"dummy_context", dummy_context, METH_O, NULL}, - {NULL, NULL, 0, NULL} -}; - -static struct PyModuleDef module = -{ - PyModuleDef_HEAD_INIT, - "_view", - NULL, - -1, - methods, -}; - -/* - * Crash Python and view.py with a fatal error. - * - * Don't use this directly! Use the VIEW_FATAL macro instead. - */ -NORETURN void -view_fatal( - const char *message, - const char *where, - const char *func, - int lineno -) -{ - fprintf( - stderr, - "_view FATAL ERROR at [%s:%d] in %s: %s\n", - where, - lineno, - func, - message - ); - fputs( - "Please report this at https://github.com/ZeroIntensity/view.py/issues\n", - stderr - ); - Py_FatalError("view.py core died"); -}; - -int -exec_module(PyObject *mod) -{ - if (PyModule_AddType(mod, &ViewAppType) < 0) - { - return -1; - } - - if (PyModule_AddType(mod, &ContextType) < 0) - { - return -1; - } - - if (PyModule_AddType(mod, &TCPublicType) < 0) - { - return -1; - } - - if (PyModule_AddType(mod, &WebSocketType) < 0) - { - return -1; - } - - if (PyModule_AddType(mod, &HeaderDictType) < 0) - { - return -1; - } - - if ( - PyModule_Add( - mod, - "InvalidStatusError", - PyErr_NewException( - "_view.InvalidStatusError", - PyExc_RuntimeError, - NULL - ) - ) < 0 - ) - { - return -1; - } - - default_headers = build_default_headers(); - if (default_headers == NULL) - { - return -1; - } - - if (PyModule_AddObject(mod, "default_headers", default_headers) < 0) - { - Py_DECREF(default_headers); - return -1; - } - - if (PyAwaitable_Init() < 0) - { - return -1; - } - - return 0; -} - -static struct PyModuleDef_Slot slots[] = -{ - {Py_mod_exec, exec_module}, -#if PY_MINOR_VERSION >= 12 - {Py_mod_multiple_interpreters, Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED}, -#endif -#if PY_MINOR_VERSION >= 13 - {Py_mod_gil, Py_MOD_GIL_USED}, -#endif - {0, NULL}, -}; - -PyModuleDef module_def = -{ - PyModuleDef_HEAD_INIT, - .m_name = "_view", - .m_size = 0, // TODO: Support subinterpreters - .m_methods = methods, - .m_slots = slots -}; - -PyMODINIT_FUNC -PyInit__view(void) -{ - return PyModuleDef_Init(&module_def); -} diff --git a/src/_view/map.c b/src/_view/map.c deleted file mode 100644 index 53d7cfa1..00000000 --- a/src/_view/map.c +++ /dev/null @@ -1,345 +0,0 @@ -/* - * view.py hash map implementation - * - * This is a simple and fast hash map that view.py uses instead - * of Python dictionaries. - * - * Maps store an array of pair pointers, which hold two things: - * - The key as a string, in case there's hash collisions. - * - The value. This is a void pointer. - * - * Maps expand by doubling their capacity every time the limit is reached. - * The initial capacity is passed to map_new(), and when that is hit, the next - * call to map_set() will expand the map by a factor of two. - * - * For example, if you pass 1 as the initial capacity, the map can hold - * one item total, and then the next time you call map_set() to add something - * new, it expands to 2. - * - * Now, if you call map_set() a third time, it expands to 4, then 8, then 16, and so on. - * - * A map expects that all of the values are the same type, and defers deallocation to - * the user by letting them pass a deallocator function for each value. This is - * called on each value upon calling map_free() - - * Upon calling map_get(), the key name is hashed and turned into an index, which - * is then used to get a value from the pairs array. If the index is NULL, map_get() returns NULL. - * - * If it isn't, then we proceed. As one final check, the - * key passed to the function and the key stored on the pair are checked with strcmp() - * - * If they match, great! The value on the pair is returned. If they don't, then there - * is a hash collision - we need to search the rest of the table. We do this and compare - * each key on the pair with the one passed. If we find a match, we return it. Otherwise, we - * return NULL. - * - * This implementation uses a Fowler-Noll-Vo hash function to hash strings into integers. - * Spoiler-alert: this was not home-brewed, nor was most of this implementation! - * Most of this map implementation is based on other works. - * - */ -#include -#include // uint64_t -#include -#include // strdup - -#include -#include // pymem_strdup -#include // PURE, COLD - -#define FNV_OFFSET 14695981039346656037UL -#define FNV_PRIME 1099511628211UL - -/* - * Fowler-Noll-Vo Hash Function. - * Read about it here: https://en.wikipedia.org/wiki/Fowler–Noll–Vo_hash_function - */ -PURE static uint64_t -hash_key(const char *key) -{ - /* - * This function is marked as "pure," meaning it makes no memory allocations, and - * only depends on the passed parameters and state of memory. - */ - uint64_t hash = FNV_OFFSET; - for (const char *p = key; *p; p++) - { - hash ^= (uint64_t) (unsigned char) (*p); - hash *= FNV_PRIME; - } - return hash; -} - -/* - * Get an item out of the map. - * - * If no value is found, this function returns NULL. - * Note that this does not raise a Python exception. - */ -PURE void * -map_get(map *m, const char *key) -{ - /* - * This hashes the key using an FNV hash function. - * - * If the key stored at the index does not matched what was passed to the function, - * then there is a hash collision, and this function uses an O(n) search to find - * the value. - * - * Best case: O(1) - * Worst case: O(n) - * - */ - uint64_t hash = hash_key(key); - Py_ssize_t index = (Py_ssize_t) (hash & (uint64_t)(m->capacity - 1)); - - while (m->items[index] != NULL) - { - if ( - !strcmp( - key, - m->items[index]->key - ) - ) - return m->items[index]->value; - index++; - if (index == m->capacity) - { - index = 0; - // need to wrap around the table - } - } - return NULL; -} - -/* - * The map initializer and allocator. - * - * This allocates an array of size initial_capacity, and - * stores a function for deallocating values. - * - * Note that the deallocator can run before map_free() has - * been called, as an item will be deallocated if map_set() - * is called on the same key. For example, if you stored the - * key "foo" with map_set(), and then stored the key "foo" - * again later, the original would be deallocated upon setting - * it again. - * - * If this fails, NULL is returned, and a MemoryError - * is raised. - */ -map * -map_new(Py_ssize_t inital_capacity, map_free_func dealloc) -{ - map *m = PyMem_Malloc(sizeof(map)); - if (!m) - return (map *) PyErr_NoMemory(); - - m->len = 0; - m->capacity = inital_capacity; - m->items = PyMem_Calloc( - inital_capacity, - sizeof(pair) - ); - if (!m->items) - return (map *) PyErr_NoMemory(); - m->dealloc = dealloc; - return m; -} - -/* - * Set a pair on a pair array of size `capacity`. - * - * If the key is already stored, the value is deallocated - * with the passed dealloc() function pointer. - * - * The `len` parameter is a pointer to a Py_ssize_t, which is incremented - * if a new entry is created in the pair array. - * - * If this function fails, a MemoryError is raised. - * - */ -static int -set_entry( - pair **items, - Py_ssize_t capacity, - const char *key, - void *value, - Py_ssize_t *len, - map_free_func dealloc -) -{ - uint64_t hash = hash_key(key); - Py_ssize_t index = (Py_ssize_t) (hash & (uint64_t)(capacity - 1)); - - while (items[index] != NULL) - { - if ( - !strcmp( - key, - items[index]->key - ) - ) - { - dealloc(items[index]->value); - items[index]->value = value; - return 0; - } - - index++; - if (index == capacity) - index = 0; - } - - if (len != NULL) - (*len)++; - - if (!items[index]) - { - items[index] = PyMem_Malloc(sizeof(pair)); - if (!items[index]) - { - PyErr_NoMemory(); - return -1; - } - } - - char *new_key = pymem_strdup(key, strlen(key)); - - if (!new_key) - { - PyMem_Free(items[index]); - return -1; - } - - items[index]->key = new_key; - items[index]->value = value; - return 0; -} - -/* - * Expand the map's pair array by a factor of two. - * - * For example, if the capacity is 4, it will become 8. - * If it's 8, it will become 16. If it's 16, it will become 32, and so on. - */ -static int -expand(map *m) -{ - Py_ssize_t new_capacity = m->capacity * 2; - if (new_capacity < m->capacity) - { - PyErr_SetString( - PyExc_RuntimeError, - "integer limit reached on _view map capacity" - ); - return -1; - } - pair **items = PyMem_Calloc( - new_capacity, - sizeof(pair) - ); - if (!items) - { - PyErr_NoMemory(); - return -1; - } - - for (Py_ssize_t i = 0; i < m->capacity; i++) - { - pair *item = m->items[i]; - if (item) - { - if ( - set_entry( - items, - new_capacity, - item->key, - item->value, - NULL, - m->dealloc - ) < 0 - ) - { - return -1; - } - ; - PyMem_Free(item); - } - } - - PyMem_Free(m->items); - m->items = items; - m->capacity = new_capacity; - return 0; -} - -/* - * Deallocate the map. - * - * This will call the deallocator passed to map_new() on - * each of the stored values. - */ -void -map_free(map *m) -{ - for (Py_ssize_t i = 0; i < m->capacity; i++) - { - pair *item = m->items[i]; - if (item) - { - m->dealloc(item->value); - PyMem_Free(item->key); - PyMem_Free(item); - } - } - - PyMem_Free(m->items); - PyMem_Free(m); -} - -/* - * Set a key and value on the map. - * - */ -void -map_set(map *m, const char *key, void *value) -{ - /* - * If the map is at maximum capacity (e.g. 2 items set on a capacity of 2), - * then, it is expanded by a factor of two. - * - * The key is hashed using an FNV hash function. - */ - if (m->len >= m->capacity / 2) - expand(m); - set_entry( - m->items, - m->capacity, - key, - value, - &m->len, - m->dealloc - ); -} - -// For debugging purposes -COLD void -print_map(map *m, map_print_func pr) -{ - puts("map {"); - for (int i = 0; i < m->capacity; i++) - { - pair *p = m->items[i]; - if (p) - { - printf( - "\"%s\": ", - p->key - ); - pr(p->value); - puts(""); - } - } - puts("}"); -} diff --git a/src/_view/parts.c b/src/_view/parts.c deleted file mode 100644 index 7c3d7c11..00000000 --- a/src/_view/parts.c +++ /dev/null @@ -1,347 +0,0 @@ -/* - * Path parts implementation (aka path parameters). - * - * This is unfinished, undocumented, and quite buggy. - * Nearly all of this will be changed or rewritten. - * - * The underlying implementation is quite complicated, so let's try and go through - * it with an example. - * - * Let's say the requested route is GET /app/12345/index and 12345 is a path parameter. - * - * We would first call map_get(app->get, "/app"). Of this returns NULL, it is a 404. - * Then, we map_get(route->routes, "/12345"). If NULL, then we check if a route->r is available. - * - * If so, this is a path parameter, we save the value and move on to the next. Otherwise, 404. - * We repeat this process until we reach the end of the URL. So, next we do map_get(route->r->routes, "/index"). - * - * Once again, we check if map_get(route->r->routes, "/index") is NULL. If it isn't, then we check - * if route->r->r is non-NULL. If it is, then it's a 404. Otherwise, once again save the value as a path parameter - * and repeat the process. - * - * This will go until the end of the path is reached. - * - * In the above example, then order of operations would look like so: - * - * - app_routes["/app/12345/index"] -> NULL, check for path parameters. - * - app_routes["/app"] -> non-NULL, proceed with path parameter extraction. - * - app_routes["/app"].routes["/12345"] -> NULL, check if transport is available. - * - app_routes["/app"].r -> non-NULL, this is a path parameter! If not, a 404 would be sent back. - * - path_params = ["12345"] - * - app_routes["/app"].r.routes["/index"] -> non-NULL, proceed. - * - Reached end of path! Location of our route object is app_routes["/app"].r.routes["/index"], - * with ["12345"] as the initial inputs. - * - * A visual representation of the route structure could look like such: - * - * This is our initial route, which - * is only accessed if, in this case, - * /app/12345/index returns NULL. - * +-- /app --+ - * | | - * | ... | This is the object we want! - * | | +-- /index --+ - * | routes -------> NULL | | - * | r ------------> +----------+ | ... | - * | | | | | | - * +----------+ | NULL | | routes ------> NULL - * | | | r -----------> NULL - * | | | | - * | routes -------> +------------+ - * | r ------------> NULL - * | | - * +----------+ - * This is our transport - * route, it represents - * a path parameter. - */ -#include -#include // true - -#include // ViewApp -#include -#include // map -#include // route, route_free -#include // VIEW_FATAL - -#define TRANSPORT_MAP() map_new(2, (map_free_func) route_free) - -// Port of strsep for use on windows -char * -v_strsep(char **stringp, const char *delim) -{ - char *rv = *stringp; - if (rv) - { - *stringp += strcspn( - *stringp, - delim - ); - if (**stringp) - *(*stringp)++ = '\0'; - else - *stringp = 0; - } - return rv; -} - -/* - * The implementation of runtime path parameter extraction. - * - * This is extremely buggy and will likely be rewritten - do not use this function. - */ -int -extract_parts( - ViewApp *self, - PyObject *awaitable, - map *target, - char *path, - const char *method_str, - Py_ssize_t *size, - route **out_r, - PyObject ***out_params -) -{ - char *token; - route *rt = NULL; - bool did_save = false; - PyObject **params = calloc( - 1, - sizeof(PyObject *) - ); - if (!params) - { - PyErr_NoMemory(); - return -1; - } - - bool skip = true; // skip leading / - route *last_r = NULL; - - while ((token = v_strsep(&path, "/"))) - { - if (skip) - { - skip = false; - continue; - } - // TODO: optimize this - char *s = PyMem_Malloc(strlen(token) + 2); - sprintf( - s, - "/%s", - token - ); - assert(target); - - if ((!did_save && rt && rt->r) || last_r) - { - printf( - "last_r: %p\n", - last_r - ); - route *this_r = (last_r ? last_r : rt)->r; - last_r = this_r; - - PyObject *unicode = PyUnicode_FromString(token); - if (!unicode) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - return -1; - } - - params = realloc( - params, - (++(*size)) * sizeof(PyObject *) - ); - params[*size - 1] = unicode; - if (this_r->routes) target = this_r->routes; - if (!this_r->r) last_r = NULL; - did_save = true; // prevent this code from looping, but also preserve rt in case the iteration ends - continue; - } else if (did_save) did_save = false; - - rt = map_get( - target, - s - ); - PyMem_Free(s); - - if (!rt) - { - // the route doesnt exist! - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - if ( - fire_error( - self, - awaitable, - 404, - NULL, - NULL, - NULL, - method_str, - true - ) < 0 - ) - { - Py_DECREF(awaitable); - return -1; - } - - return -2; - } - - target = rt->routes; - } - - bool failed = false; - route *r = rt->r; - if (r && !r->callable) - { - r = r->r; // edge case - if (!r) failed = true; - } else if (!r) failed = true; - - if (failed) - { - for (int i = 0; i < *size; i++) - Py_DECREF(params[i]); - - PyMem_Free(params); - if ( - fire_error( - self, - awaitable, - 404, - NULL, - NULL, - NULL, - method_str, - true - ) < 0 - ) - { - return -1; - } - - return -2; - } -} - -/* - * Generate route tables on routes, and add transport routes. - * - * Private API - subject to change. - */ -int -load_parts(ViewApp *app, map *routes, PyObject *parts, route *r) -{ - /* - * This is a one-time cost, so performance is not super important - * in this function. - */ - PyObject *iter = PyObject_GetIter(parts); - if (!iter) return -1; - - PyObject *item; - map *target = routes; - route *rt = NULL; - Py_ssize_t size = PySequence_Size(parts); - if (size == -1) - { - Py_DECREF(iter); - return -1; - } - - Py_ssize_t index = 0; - bool set_r = false; - - while ((item = PyIter_Next(iter))) - { - ++index; - - if ( - PyUnicode_CheckExact( - item - ) - ) - { - // path part - const char *str = PyUnicode_AsUTF8(item); - if (!str) - { - Py_DECREF(iter); - return -1; - } - ; - route *found = map_get( - target, - str - ); - route *transport = route_transport_new(NULL); - if (!transport) - { - Py_DECREF(iter); - return -1; - } - ; - if (!found) - { - map_set( - target, - str, - transport - ); - transport->routes = TRANSPORT_MAP(); - target = transport->routes; - if (!target) - { - Py_DECREF(iter); - return -1; - } - ; - } else - { - if (!found->routes) found->routes = TRANSPORT_MAP(); - if (!found->routes) - { - Py_DECREF(iter); - return -1; - } - ; - target = found->routes; - map_set( - target, - str, - transport - ); - } - rt = transport; - } else - { - app->has_path_params = true; - if (!rt) VIEW_FATAL("first path param was part"); - if (index == size) - { - rt->r = r; - set_r = true; - } else - { - rt->r = route_transport_new(NULL); - rt = rt->r; - } - } - if (!set_r) rt->r = r; - } - - Py_DECREF(iter); - if (PyErr_Occurred()) return -1; - - return 0; -} diff --git a/src/_view/results.c b/src/_view/results.c deleted file mode 100644 index 60c7bed4..00000000 --- a/src/_view/results.c +++ /dev/null @@ -1,367 +0,0 @@ -/* - * view.py route results implementation - * - * This file is responsible for parsing route responses. - * - * Note that this implementation does not actually send - * ASGI responses. Instead, it generates the necessary - * components to send a response (which is done by the handler). - * - * This is also not responsible for calling __view_result__(), since - * that would require generating a Context() - * - * All this does, is given a flattened result (i.e. __view_result__() was already called), extract - * the three needed components for an ASGI response. If some components are missing, then default - * ones are used. - * - * For example, given b"hello world" as result, this would be in charge of turning that - * into a "hello world" C string on the heap, as well as setting the status code to 200 and - * using the default headers. - * - * If it was given a tuple, such as (b"hello world", 201), then it would once again get the - * C string, then set the status to 201, and then use the default headers. - * - * Historically, view.py used to support doing this in any order (e.g. returning the - * tuple `(b"hello world", 201)` would be equivalent to `(201, b"hello world")`) - * - * For performance and versatility reasons, this was removed. - */ -#include - -#include -#include -#include // route_log - -/* - * Implementation of strdup() using PyMem_Malloc() - * - * Unlike strdup(), this takes a size parameter. Try - * to avoid using strlen(), and use a function that includes - * the string size, such as PyUnicode_AsUTF8AndSize() - * - * Strings that are returned by this function should - * be freed using PyMem_Free(), not free() - * - * Technically speaking, this is more or less a copy - * of CPython's private _PyMem_Strdup function. - */ -char * -pymem_strdup(const char *c, Py_ssize_t size) -{ - char *buf = PyMem_Malloc(size + 1); // Length with null terminator - if (!buf) - return (char *) PyErr_NoMemory(); - memcpy(buf, c, size + 1); - return buf; -} - -/* - * Get a duplicated string of a Python string or bytes object. - * - * If the object is not a string or bytes, this throws a TypeError - * and returns NULL. - * - * This uses pymem_strdup(), so strings returned by this function - * should be deallocated via PyMem_Free() - */ -static char * -handle_response_body(PyObject *target) -{ - if (PyUnicode_CheckExact(target)) - { - Py_ssize_t size; - const char *tmp = PyUnicode_AsUTF8AndSize(target, &size); - if (!tmp) return NULL; - return pymem_strdup(tmp, size); - } else if (PyBytes_CheckExact(target)) - { - Py_ssize_t size; - char *tmp; - if (PyBytes_AsStringAndSize(target, &tmp, &size) < 0) - return NULL; - return pymem_strdup(tmp, size); - } else - { - PyErr_Format( - PyExc_TypeError, - "expected a str or bytes response body, got %R", - target - ); - return NULL; - } -} - -/* - * Generate the "default response headers" (i.e. headers that - * are sent when no headers are explicitly set by the user.) - * - * This returns a new strong reference. However, this should - * only be called once per program, by the module initialization - * function. The result is stored globally as `default_headers`. - */ -PyObject * -build_default_headers() -{ - // [("content-type", "text/plain")] - return Py_BuildValue("[(y, y)]", "content-type", "text/plain"); -} - -/* - * The raw implementation of handling results. - * - * Unlike the exported handle_result(), this does not write to - * the route log. - */ -static int -handle_result_impl( - PyObject *result, - char **res_target, - int *status_target, - PyObject **headers_target -) -{ - char *res_str = NULL; - int status = 200; - PyErr_Clear(); - - res_str = handle_response_body(result); - if (!res_str) - { - if (!PyTuple_CheckExact(result)) - return -1; - - PyErr_Clear(); - if (PySequence_Size(result) > 3) - { - PyErr_SetString( - PyExc_TypeError, - "returned tuple should not exceed 3 elements" - ); - return -1; - } - - PyObject *first = PyTuple_GetItem( - result, - 0 - ); - PyObject *second = PyTuple_GetItem( - result, - 1 - ); - PyObject *third = PyTuple_GetItem( - result, - 2 - ); - - PyErr_Clear(); - res_str = handle_response_body(first); - if (!res_str) - return -1; - - if (!second) - { - // exit early - *res_target = res_str; - *status_target = 200; - *headers_target = Py_NewRef(default_headers); - return 0; - } - - if (!PyLong_CheckExact(second)) - { - PyErr_Format( - PyExc_TypeError, - "expected second value of response to be an int, got %R", - second - ); - return -1; - } - - status = PyLong_AsLong(second); - if (status == -1) - { - PyMem_Free(res_str); - return -1; - } - - if (!third) - { - // exit early - *res_target = res_str; - *status_target = status; - *headers_target = Py_NewRef(default_headers); - return 0; - } - - if (PyList_CheckExact(third) || PyTuple_CheckExact(third)) - { - /* - * Undocumented because I don't want the user to touch it! - * This is a way for a route to return a raw ASGI header list, which allows - * for faster and multi-headers. - */ - *res_target = res_str; - *status_target = status; - *headers_target = Py_NewRef(third); - return 0; - } - - if (!PyDict_CheckExact(third)) - { - PyErr_Format( - PyExc_TypeError, - "expected third value of response to be a dict, got %R", - third - ); - return -1; - } - - PyObject *header_tup = PyTuple_New(PyDict_GET_SIZE(third)); - if (!header_tup) - { - PyMem_Free(res_str); - return -1; - } - - PyObject *key; - PyObject *value; - Py_ssize_t pos = 0; - - while (PyDict_Next(third, &pos, &key, &value)) - { - PyObject *key_bytes = PyUnicode_AsEncodedString( - key, - "utf-8", - "strict" - ); - - if (!key_bytes) - { - PyMem_Free(res_str); - return -1; - } - - PyObject *value_bytes = PyUnicode_AsEncodedString( - value, - "utf-8", - "strict" - ); - if (!value_bytes) - { - PyMem_Free(res_str); - Py_DECREF(key_bytes); - return -1; - } - - PyObject *header = PyTuple_New(2); - if (!header) - { - Py_DECREF(key_bytes); - Py_DECREF(value_bytes); - PyMem_Free(res_str); - Py_DECREF(header_tup); - return -1; - } - // PyTuple_SET_ITEM steals the reference, no need to Py_DECREF - PyTuple_SET_ITEM(header, 0, key_bytes); - PyTuple_SET_ITEM(header, 1, value_bytes); - - // pos does not start at 0, it starts at 1 - PyTuple_SET_ITEM(header_tup, pos - 1, header); - } - - - *res_target = res_str; - *status_target = status; - *headers_target = header_tup; - return 0; - } - - *res_target = res_str; - *status_target = status; - *headers_target = Py_NewRef(default_headers); - return 0; -} - -/* - * Generate HTTP response components (i.e. the body, status, and headers) from - * a route return value. - * - * The result passed should be a tuple, or body string. This function - * does not call __view_result__(), as that is up to the caller. - * - * The body output parameter will be a string on the heap, - * and is responsible for deallocating it with PyMem_Free() - * - * The status output parameter can be *any* integer (including non-HTTP - * status codes). Validation is up to the caller. - * - * The headers will always be an ASGI headers iterable [(bytes_key, bytes_value), ...] - * - * If this function fails, the caller is not responsible for - * deallocating or managing references of any of the parameters. - */ -int -handle_result( - PyObject *raw_result, - char **res_target, - int *status_target, - PyObject **headers_target, - PyObject *raw_path, - const char *method -) -{ - /* - * This calls handle_result_impl() internally, but - * this function is the actual interface for handling a return value. - * - * The only extra thing that this does is write to the route log. - */ - int res = handle_result_impl( - raw_result, - res_target, - status_target, - headers_target - ); - - return res; - // Calling route_log is extremely slow - if (res < 0) - return -1; - - if (!route_log) return res; - - PyObject *args = Py_BuildValue( - "(iOs)", - *status_target, - raw_path, - method - ); - - if (!args) - return -1; - - /* - * A lot of errors related to memory corruption are traced - * to here by debuggers. - * - * This is, more or less, a false positive! It's quite - * unlikely that the actual cause of the issue is here. - */ - PyObject *result = PyObject_Call( - route_log, - args, - NULL - ); - - if (!result) - { - Py_DECREF(args); - return -1; - } - - Py_DECREF(result); - Py_DECREF(args); - - return res; -} diff --git a/src/_view/route.c b/src/_view/route.c deleted file mode 100644 index 20e5f7b5..00000000 --- a/src/_view/route.c +++ /dev/null @@ -1,149 +0,0 @@ -/* - * view.py internal route implementation - * - * This file contains the allocators and deallocator for route structures. - * - * Note that technically speaking, there are two types of routes: standard and transport. - * In the current state, every route is a standard route. However, the unstable and buggy - * path parameter API uses transport routes to represent path parameters. Read the comment - * at the top of parts.c for how that works. - * - * Standard routes are initialized with route_new(), while transports are - * initialized with route_transport_new() - * - * Essentially, standard route instances hold all proper route fields, except the - * "routes" and "r" fields. Both of these are NULL in a standard route. - * - * In a transport, it's the opposite - everything is NULL except "routes" and "r". - * - * Standard routes are responsible for managing the memory of all of their fields. - * However, the inputs array is not allocated by route_new() - that's done by the loader. - * The route_free() deallocator expects that the inputs array has been allocated. - * - * With that being said, expect everything from typecodes to reference counts to be - * managed by a route pointer. - */ -#include -#include -#include - -/* - * Allocator for routes. - * - * This function does not allocate the inputs array, regardless - * of the `inputs_size` parameter. - * - * If this fails, a MemoryError is raised. - */ -route * -route_new( - PyObject *callable, - Py_ssize_t inputs_size, - Py_ssize_t cache_rate, - bool has_body -) -{ - route *r = PyMem_Malloc(sizeof(route)); - if (!r) - return (route *) PyErr_NoMemory(); - - r->cache = NULL; - r->callable = Py_NewRef(callable); - r->cache_rate = cache_rate; - r->cache_index = 0; - r->cache_headers = NULL; - r->cache_status = 0; - r->inputs = NULL; - r->inputs_size = inputs_size; - r->has_body = has_body; - r->is_http = true; - - // Transport attributes - r->routes = NULL; - r->r = NULL; - - for (int i = 0; i < 28; i++) - r->client_errors[i] = NULL; - - for (int i = 0; i < 11; i++) - r->server_errors[i] = NULL; - - return r; -} - -/* - * Deallocator for routes. - * - * This function assumes that the inputs array has been allocated, and - * is responsible for deallocating it with PyMem_Free() - */ -void -route_free(route *r) -{ - for (int i = 0; i < r->inputs_size; i++) - { - if (r->inputs[i]->route_data) - { - continue; - } - Py_XDECREF(r->inputs[i]->df); - free_type_codes( - r->inputs[i]->types, - r->inputs[i]->types_size - ); - - for (int i = 0; i < r->inputs[i]->validators_size; i++) - { - Py_DECREF(r->inputs[i]->validators[i]); - } - } - - PyMem_Free(r->inputs); - - Py_XDECREF(r->cache_headers); - Py_DECREF(r->callable); - - for (int i = 0; i < 11; i++) - Py_XDECREF(r->server_errors[i]); - - for (int i = 0; i < 28; i++) - Py_XDECREF(r->client_errors[i]); - - if (r->cache) - PyMem_Free(r->cache); - PyMem_Free(r); -} - -/* - * Allocator for a "route transport," per the path parts API. - * - * Along with the rest of the path parts API, this function - * should be considered very buggy and subject to change. - */ -route * -route_transport_new(route *r) -{ - route *rt = PyMem_Malloc(sizeof(route)); - if (!rt) - return (route *)PyErr_NoMemory(); - rt->cache = NULL; - rt->callable = NULL; - rt->cache_rate = 0; - rt->cache_index = 0; - rt->cache_headers = NULL; - rt->cache_status = 0; - rt->inputs = NULL; - rt->inputs_size = 0; - rt->has_body = false; - rt->is_http = false; - - for (int i = 0; i < 28; i++) - rt->client_errors[i] = NULL; - - for (int i = 0; i < 11; i++) - rt->server_errors[i] = NULL; - - rt->routes = NULL; - rt->r = r; - return rt; -} diff --git a/src/_view/typecodes.c b/src/_view/typecodes.c deleted file mode 100644 index 7f2fc78a..00000000 --- a/src/_view/typecodes.c +++ /dev/null @@ -1,1288 +0,0 @@ -/* - * view.py typecode implementation - * - * Typecodes are a view.py invention. In short, they are a fast - * way to do runtime type checking. - * - * The simplest way to do runtime type checking would just - * be to take a type, and run isinstance() (or in this case, PyObject_IsInstance). - * - * You could cheat a little by using something like Py_IS_TYPE to avoid the call, - * but that's still not great. There's also no good way to do unions, generics, - * or other typing shenanigans. - * - * Typecodes are view.py's solution. It starts in the _build_type_codes function, - * which is in Python (since it's a one time cost, we don't have to worry about performance there). - * - * See _loader.py for the _build_type_codes implementation - in short, it takes - * a type (including things like `typing._GenericAlias`, for generics), and converts - * it into a list that the C API loader can understand. - * - * We'll stay away from the internals of that here - let's just focus on - * the C implementation. - * - * A type code is stored in a type_info structure. - * Technically speaking, the actual "type code" is just an integer on the structure, - * and the whole structure is called "type information." However, view.py calls - * type information typecodes for convenience and historical purposes. - * - * In most cases, you'll see type information being passed as an array, instead of - * a single structure. An array of type information just means unions - a single type - * is represented by *one* type_info structure, and then unions are just a bunch - * of those chained together. We'll refer to a single structure as "type information," - * and an array of them as "typecodes." - * - * A typecode (or really, type_info structure) has three parts: - * - * - An 8-bit integer indicating the type. This is the actual "type code." - * - An array of type information (typecodes) acting as the "children." This will be empty in many cases. - * This is generally for generic types. For example, if the type was `list[str]`, then the overall - * type code integer would be for lists, and then the children would contain the typecodes of the - * generics, which in this case would be that of `str`. - * - A default value to use in case the value was missing when checked. - * - * The basic types are: - * - Any (which if this exists anywhere on the typecode, the rest of it is skipped). - * - String - * - Integer - * - Boolean - * - Float - * - None/null - * - * These types don't have any children, and are simply checked with CheckExact. - * - * view.py does what it can to cast the object to the given type at runtime. For example, if - * the object was the string `"1"`, but the typecode is for an `int`, then it will - * cast it (unless casting was disabled, which only happens when using the public typecode API). - - * If a string is anywhere on the typecode, then it means that every value can - * be assigned to it. However, casting on strings is done lazily. So, if the typecode is - * for `str | int`, then it will only try and cast it to a string if casting to an integer fails. - * - * The types that can have children are: - * - * - Dictionary/JSON - * - List/array - * - Classes - * - * Dictionaries and lists both use the children as generics, so if the typecode was - * for a list, then it would expect all of it's items to be compliant with the - * children typecodes. - * - * Note that dictionaries can only have string keys, so the children only apply - * to the values. - * - * Classes are a bit special, since the only children they can have are of `TYPECODE_CLASSTYPES`. - * `TYPECODE_CLASSTYPES` are only supported here, and must not be used anywhere else. - * - * A `TYPECODE_CLASSTYPES` represents an attribute of an object. - * The children are the type of the attribute, and the default is stored like any other typecode. - * - * However, the name is stored in a sneaky way - there's actually a fourth field on the - * type_info structure, which contains an extra Python object. This slot is only - * present with a `TYPECODE_CLASSTYPES`, and contains a Python string containing - * the name of the attribute. - * - * The only thing that can be casted to a class is a dictionary or a string that represents JSON. - */ -#include -#include // bool - -#include -#include // route_input -#include // pymem_strdup -#include // route -#include -#include // VIEW_FATAL - -#define CHECK(flags) ((typecode_flags & (flags)) == (flags)) -#define TYPECODE_ANY 0 -#define TYPECODE_STR 1 -#define TYPECODE_INT 2 -#define TYPECODE_BOOL 3 -#define TYPECODE_FLOAT 4 -#define TYPECODE_DICT 5 -#define TYPECODE_NONE 6 -#define TYPECODE_CLASS 7 -#define TYPECODE_CLASSTYPES 8 -#define TYPECODE_LIST 9 -#define TC_VERIFY(typeobj) \ - if (typeobj( \ - value \ - )) { \ - verified = true; \ - } \ - break; - -/* Deallocator for type info */ -static void -free_type_info(type_info *ti) -{ - Py_XDECREF(ti->ob); - if ((intptr_t) ti->df > 0) Py_DECREF(ti->df); - for (int i = 0; i < ti->children_size; i++) - { - free_type_info(ti->children[i]); - } -} - -/* Deallocator for an array of type information. */ -void -free_type_codes(type_info **codes, Py_ssize_t len) -{ - for (Py_ssize_t i = 0; i < len; i++) - free_type_info(codes[i]); -} - -/* - * Utility function for raising an error when the loader - * passes some wrong input. This is semantically - * similar to PyErr_BadASGI() - * - * In a perfect world, this will never be called. - */ -COLD static inline int -bad_input(const char *name) -{ - PyErr_Format( - PyExc_SystemError, - "missing key in loader dict: %s", - name - ); - return -1; -} - -/* - * Verify a dictionary object given typecodes. - * This will update the dictionary with casted values. - */ -static int -verify_dict_typecodes( - type_info **codes, - Py_ssize_t len, - PyObject *dict, - PyObject *json_parser -) -{ - Py_ssize_t pos = 0; - PyObject *key; - PyObject *value; - - while (PyDict_Next(dict, &pos, &key, &value)) - { - PyObject *casted_value = cast_from_typecodes( - codes, - len, - value, - json_parser, - true - ); - if (!casted_value) return -1; - - if ( - PyDict_SetItem( - dict, - key, - casted_value - ) < 0 - ) - return -1; - } - - if (PyErr_Occurred()) - return -1; - - return 0; -} - -/* - * Verify a list with the given typecodes. - * This will cast items in the list. - */ -static int -verify_list_typecodes( - type_info **codes, - Py_ssize_t len, - PyObject *list, - PyObject *json_parser -) -{ - Py_ssize_t list_len = PySequence_Size(list); - if (list_len == -1) return -1; - if (list_len == 0) return 0; - - for (int i = 0; i < list_len; i++) - { - PyObject *item = PyList_GET_ITEM( - list, - i - ); - - item = cast_from_typecodes( - codes, - len, - item, - json_parser, - true - ); - - // This is intentional, do not make this -1 - if (!item) return 1; - if ( - PyList_SetItem( - list, - i, - item - ) < 0 - ) - { - Py_DECREF(item); - return -1; - } - } - - return 0; -} - -/* - * Cast an object using the given typecodes. - * This is essentially the "main" function of typecodes. - * - * The allow_casting parameter is whether to allow an object to not - * be the actual type. For example, if casting is enabled, the string `"1"` can - * be casted to the integer `1`, if the typecode supports it. If this is disabled, - * then it will ensure that the `item` parameter is directly an instance of the type. - */ -PyObject * -cast_from_typecodes( - type_info **codes, - Py_ssize_t len, - PyObject *item, - PyObject *json_parser, - bool allow_casting -) -{ - if (!codes) - { - // type is Any - if (!item) Py_RETURN_NONE; - return item; - } - ; - - typecode_flag typecode_flags = 0; - - for (Py_ssize_t i = 0; i < len; i++) - { - PyErr_Clear(); - type_info *ti = codes[i]; - - switch (ti->typecode) - { - case TYPECODE_ANY: - { - return item; - } - case TYPECODE_STR: - { - if (!allow_casting) - { - if (PyUnicode_CheckExact(item)) - { - return Py_NewRef(item); - } - PyErr_SetString( - PyExc_ValueError, - "Got non-string without casting enabled" - ); - return NULL; - } - typecode_flags |= STRING_ALLOWED; - break; - } - case TYPECODE_NONE: - { - if (!allow_casting) - { - if (item == Py_None) - { - return Py_NewRef(item); - } - PyErr_SetString( - PyExc_ValueError, - "Got non-None without casting enabled" - ); - return NULL; - } - typecode_flags |= NULL_ALLOWED; - break; - } - case TYPECODE_INT: - { - if ( - PyLong_CheckExact( - item - ) - ) - { - return Py_NewRef(item); - } else if (!allow_casting) - { - PyErr_SetString( - PyExc_ValueError, - "Got non-int without casting enabled" - ); - return NULL; - } - - PyObject *py_int = PyLong_FromUnicodeObject( - item, - 10 - ); - if (!py_int) - { - break; - } - return py_int; - } - case TYPECODE_BOOL: - { - if ( - PyBool_Check( - item - ) - ) return Py_NewRef(item); - else if (!allow_casting) - { - PyErr_SetString( - PyExc_ValueError, - "Got non-bool without casting enabled" - ); - return NULL; - } else if (PyLong_CheckExact(item)) - { - long val = PyLong_AsLong(item); - if (val == -1 && PyErr_Occurred()) - return NULL; - return PyBool_FromLong(val); - } else if (PyUnicode_CheckExact(item)) - { - const char *str = PyUnicode_AsUTF8(item); - PyObject *py_bool = NULL; - if (!str) - return NULL; - if ( - strcmp( - str, - "true" - ) == 0 - ) - { - py_bool = Py_NewRef(Py_True); - } else if ( - strcmp( - str, - "false" - ) == 0 - ) - { - py_bool = Py_NewRef(Py_False); - } - - if (py_bool != NULL) - return py_bool; - } - PyErr_Format(PyExc_ValueError, "Not bool-like: %R", item); - break; - } - case TYPECODE_FLOAT: - { - if ( - PyFloat_CheckExact( - item - ) - ) return Py_NewRef(item); - else if (!allow_casting) - { - PyErr_SetString( - PyExc_ValueError, - "Got non-float without casting enabled" - ); - return NULL; - } - PyObject *flt = PyFloat_FromString(item); - if (!flt) - { - break; - } - return flt; - } - case TYPECODE_DICT: - { - PyObject *obj; - if ( - PyDict_Check( - item - ) - ) - { - obj = Py_NewRef(item); - } else if (!allow_casting) - { - PyErr_SetString( - PyExc_ValueError, - "Got non-dict without casting enabled" - ); - return NULL; - } else - { - obj = PyObject_Vectorcall( - json_parser, - (PyObject *[]) { item }, - 1, - NULL - ); - } - if (!obj) - { - break; - } - int res = verify_dict_typecodes( - ti->children, - ti->children_size, - obj, - json_parser - ); - if (res == -1) - { - Py_DECREF(obj); - return NULL; - } - - if (res == 1) - { - Py_DECREF(obj); - break; - } - - return obj; - } - case TYPECODE_CLASS: - { - if (!allow_casting) - { - if ( - !Py_IS_TYPE( - item, - Py_TYPE(ti->ob) - ) - ) - { - PyErr_Format( - PyExc_ValueError, - "Got non-%R instance without casting enabled", - Py_TYPE(ti->ob) - ); - return NULL; - } - - return Py_NewRef(item); - } - PyObject *kwargs = PyDict_New(); - if (!kwargs) - return NULL; - PyObject *obj; - if ( - PyDict_CheckExact(item) || Py_IS_TYPE( - item, - Py_TYPE(ti->ob) - ) - ) - { - obj = Py_NewRef(item); - } else - { - obj = PyObject_Vectorcall( - json_parser, - (PyObject *[]) { item }, - 1, - NULL - ); - } - - if (!obj) - { - Py_DECREF(kwargs); - break; - } - - bool ok = true; - for (Py_ssize_t i = 0; i < ti->children_size; i++) - { - type_info *info = ti->children[i]; - PyObject *got_item = PyDict_GetItem( - obj, - info->ob - ); - - if (!got_item) - { - if ((intptr_t) info->df != -1) - { - if (info->df) - { - got_item = info->df; - if (PyCallable_Check(got_item)) - { - got_item = PyObject_CallNoArgs(got_item); // It's a factory - if (!got_item) - { - Py_DECREF(kwargs); - Py_DECREF(obj); - ok = false; - break; - } - } - } else - { - PyErr_Format( - PyExc_ValueError, - "Missing key: %S", - info->ob - ); - ok = false; - Py_DECREF(kwargs); - Py_DECREF(obj); - break; - } - } else - { - continue; - } - } - - PyObject *parsed_item = cast_from_typecodes( - info->children, - info->children_size, - got_item, - json_parser, - allow_casting - ); - - if (!parsed_item) - { - Py_DECREF(kwargs); - Py_DECREF(obj); - ok = false; - break; - } - - if ( - PyDict_SetItem( - kwargs, - info->ob, - parsed_item - ) < 0 - ) - { - Py_DECREF(kwargs); - Py_DECREF(obj); - Py_DECREF(parsed_item); - return NULL; - } - Py_DECREF(parsed_item); - } - - if (!ok) break; - - PyObject *caller; - caller = PyObject_GetAttrString( - ti->ob, - "__view_construct__" - ); - if (!caller) - { - PyErr_Clear(); - caller = ti->ob; - } - - PyObject *built = PyObject_VectorcallDict( - caller, - NULL, - 0, - kwargs - ); - - Py_DECREF(kwargs); - if (!built) - { - return NULL; - } - - return built; - } - case TYPECODE_LIST: - { - PyObject *list; - if ( - Py_IS_TYPE( - item, - &PyList_Type - ) - ) - { - Py_INCREF(item); - list = item; - } else - { - list = PyObject_Vectorcall( - json_parser, - (PyObject *[]) { item }, - 1, - NULL - ); - - if (!list) - { - break; - } - - if ( - !Py_IS_TYPE( - list, - &PyList_Type - ) - ) - { - PyErr_Format( - PyExc_TypeError, - "Expected array, got %R", - list - ); - break; - } - } - - int res = verify_list_typecodes( - ti->children, - ti->children_size, - list, - json_parser - ); - if (res == -1) - { - Py_DECREF(list); - return NULL; - } - - if (res == 1) - { - Py_DECREF(list); - break; - } - - return list; - } - case TYPECODE_CLASSTYPES: - default: - { - fprintf( - stderr, - "got bad typecode in cast_from_typecodes: %d\n", - ti->typecode - ); - VIEW_FATAL("invalid typecode"); - } - } - } - PyObject *final_err = PyErr_GetRaisedException(); - - if ( - (CHECK(NULL_ALLOWED)) && - (item == NULL || item == - Py_None) - ) - { - Py_XDECREF(final_err); - Py_RETURN_NONE; - } - if (CHECK(STRING_ALLOWED)) - { - if ( - !PyObject_IsInstance( - item, - (PyObject *) &PyUnicode_Type - ) - ) - { - if (!final_err) - PyErr_SetString( - PyExc_ValueError, - "Expected string" - ); - else - PyErr_SetRaisedException(final_err); - return NULL; - } - - Py_XDECREF(final_err); - return Py_NewRef(item); - } - - assert(final_err != NULL); - PyErr_SetRaisedException(final_err); - return NULL; -} - -/* - * Convert Python typecodes generated by the loader into C typecodes. - * - * This is essentially the bridge for typecodes between C and Python. - */ -type_info ** -build_type_codes(PyObject *type_codes, Py_ssize_t len) -{ - type_info **tps = PyMem_Calloc( - sizeof(type_info), - len - ); - - for (Py_ssize_t i = 0; i < len; i++) - { - PyObject *info = PyList_GetItem( - type_codes, - i - ); - type_info *ti = PyMem_Malloc(sizeof(type_info)); - - if (!info && ti) - { - for (int x = 0; x < i; x++) - free_type_info(tps[x]); - - PyMem_Free(tps); - if (ti) PyMem_Free(ti); - return NULL; - } - - PyObject *type_code = PyTuple_GetItem( - info, - 0 - ); - PyObject *obj = PyTuple_GetItem( - info, - 1 - ); - PyObject *children = PyTuple_GetItem( - info, - 2 - ); - - PyObject *df = PyTuple_GetItem( - info, - 3 - ); - - if (df) - { - if ( - PyObject_HasAttrString( - df, - "__VIEW_NODEFAULT__" - ) - ) df = NULL; - else if ( - PyObject_HasAttrString( - df, - "__VIEW_NOREQ__" - ) - ) - df = (PyObject *) -1; - } - - if (!type_code || !obj || !children) - { - for (int x = 0; x < i; x++) - free_type_info(tps[x]); - - PyMem_Free(tps); - return NULL; - } - - if (!df) PyErr_Clear(); - - Py_ssize_t code = PyLong_AsLong(type_code); - - Py_XINCREF(obj); - ti->ob = obj; - ti->typecode = code; - // we cant use Py_XINCREF or Py_XDECREF because it could be -1 - if ((intptr_t) df > 0) Py_INCREF(df); - ti->df = df; - - Py_ssize_t children_len = PySequence_Size(children); - if (children_len == -1) - { - for (int x = 0; x < i; x++) - free_type_info(tps[x]); - - PyMem_Free(tps); - Py_XDECREF(obj); - if ((intptr_t) df > 0) Py_DECREF(df); - return NULL; - } - - ti->children_size = children_len; - type_info **children_info = build_type_codes( - children, - children_len - ); - - if (!children_info) - { - for (int x = 0; x < i; i++) - free_type_info(tps[x]); - - PyMem_Free(tps); - Py_XDECREF(obj); - if ((intptr_t) df) Py_DECREF(df); - return NULL; - } - - ti->children = children_info; - tps[i] = ti; - } - - return tps; -} - -int -load_typecodes( - route *r, - PyObject *target -) -{ - PyObject *iter = PyObject_GetIter(target); - PyObject *item; - Py_ssize_t index = 0; - - Py_ssize_t len = PySequence_Size(target); - if (len == -1) - { - return -1; - } - - r->inputs = PyMem_Calloc( - len, - sizeof(route_input *) - ); - if (!r->inputs) return -1; - - while ((item = PyIter_Next(iter))) - { - route_input *inp = PyMem_Malloc(sizeof(route_input)); - r->inputs[index++] = inp; - - if (!inp) - { - Py_DECREF(iter); - return -1; - } - - if ( - Py_IS_TYPE( - item, - &PyLong_Type - ) - ) - { - int data = PyLong_AsLong(item); - - if (PyErr_Occurred()) - { - Py_DECREF(iter); - return -1; - } - - inp->route_data = data; - continue; - } else - { - inp->route_data = 0; - } - - PyObject *is_body = Py_XNewRef( - PyDict_GetItemString( - item, - "is_body" - ) - ); - - if (!is_body) - { - Py_DECREF(iter); - PyMem_Free(r->inputs); - return bad_input("is_body"); - } - inp->is_body = PyObject_IsTrue(is_body); - Py_DECREF(is_body); - - PyObject *name = Py_XNewRef( - PyDict_GetItemString( - item, - "name" - ) - ); - - if (!name) - { - Py_DECREF(iter); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return bad_input("name"); - } - - Py_ssize_t name_size; - const char *cname = PyUnicode_AsUTF8AndSize(name, &name_size); - if (!cname) - { - Py_DECREF(iter); - Py_DECREF(name); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return -1; - } - inp->name = pymem_strdup(cname, name_size); - - Py_DECREF(name); - - PyObject *has_default = PyDict_GetItemString( - item, - "has_default" - ); - if (!has_default) - { - Py_DECREF(iter); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return bad_input("has_default"); - } - - if (PyObject_IsTrue(has_default)) - { - inp->df = Py_XNewRef( - PyDict_GetItemString( - item, - "default" - ) - ); - if (!inp->df) - { - Py_DECREF(iter); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return bad_input("default"); - } - } else - { - inp->df = NULL; - } - - Py_DECREF(has_default); - - PyObject *codes = PyDict_GetItemString( - item, - "type_codes" - ); - - if (!codes) - { - Py_DECREF(iter); - Py_XDECREF(inp->df); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return bad_input("type_codes"); - } - - Py_ssize_t len = PySequence_Size(codes); - if (len == -1) - { - Py_DECREF(iter); - Py_XDECREF(inp->df); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return -1; - } - inp->types_size = len; - if (!len) inp->types = NULL; - else - { - inp->types = build_type_codes( - codes, - len - ); - if (!inp->types) - { - Py_DECREF(iter); - Py_XDECREF(inp->df); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return -1; - } - } - - PyObject *validators = PyDict_GetItemString( - item, - "validators" - ); - - if (!validators) - { - Py_DECREF(iter); - Py_XDECREF(inp->df); - free_type_codes( - inp->types, - inp->types_size - ); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return bad_input("validators"); - } - - Py_ssize_t size = PySequence_Size(validators); - inp->validators = PyMem_Calloc( - size, - sizeof(PyObject *) - ); - inp->validators_size = size; - - if (!inp->validators) - { - Py_DECREF(iter); - free_type_codes( - inp->types, - inp->types_size - ); - Py_XDECREF(inp->df); - PyMem_Free(r->inputs); - PyMem_Free(inp); - return -1; - } - - for (int i = 0; i < size; i++) - { - inp->validators[i] = Py_NewRef( - PySequence_GetItem( - validators, - i - ) - ); - } - } - ; - - Py_DECREF(iter); - if (PyErr_Occurred()) return -1; - return 0; -} - -/* - * Figure out whether there's a body input in the list of route inputs. - * - * This is for optimization - if a route doesn't have a body input, - * then receiving and parsing the body can be skipped at runtime. - */ -bool -figure_has_body(PyObject *inputs) -{ - PyObject *iter = PyObject_GetIter(inputs); - PyObject *item; - bool res = false; - - if (!iter) - { - return false; - } - - while ((item = PyIter_Next(iter))) - { - if ( - Py_IS_TYPE( - item, - &PyLong_Type - ) - ) - continue; - PyObject *is_body = PyDict_GetItemString( - item, - "is_body" - ); - - if (!is_body) - { - Py_DECREF(iter); - return false; - } - - if (PyObject_IsTrue(is_body)) - { - res = true; - } - Py_DECREF(is_body); - } - - Py_DECREF(iter); - - if (PyErr_Occurred()) - { - return false; - } - - return res; -} - -/* - * TCPublic is just the base type for the Python wrapper. - * Breaking changes are allowed on the API. - */ - -typedef struct -{ - PyObject_HEAD - type_info **codes; - Py_ssize_t codes_len; - PyObject *json_parser; -} TCPublic; - -/* Deallocator for a public type validation object. */ -static void -dealloc(TCPublic *self) -{ - free_type_codes( - self->codes, - self->codes_len - ); - Py_DECREF(self->json_parser); - Py_TYPE(self)->tp_free((PyObject *) self); -} - -/* - * Allocator function for the TCPublic object. - * This is considered private - breaking changes are allowed. - */ -static PyObject * -new(PyTypeObject *type, PyObject *args, PyObject *kwargs) -{ - TCPublic *self = (TCPublic *) type->tp_alloc( - type, - 0 - ); - if (!self) - return NULL; - - return (PyObject *) self; -} - -/* - * Python wrapper around cast_from_typecodes() - * Also considered private - breaking changes are possible. - * - * This is known as _cast() in Python - */ -static PyObject * -cast_from_typecodes_public(PyObject *self, PyObject *args) -{ - TCPublic *tc = (TCPublic *) self; - PyObject *obj; - int allow_cast; - - if ( - !PyArg_ParseTuple( - args, - "Op", - &obj, - &allow_cast - ) - ) - return NULL; - - PyObject *res = cast_from_typecodes( - tc->codes, - tc->codes_len, - obj, - tc->json_parser, - allow_cast - ); - if (!res) - { - return NULL; - } - - return res; -} - -/* - * Load Python typecodes into the object as C type codes. - */ -static PyObject * -compile(PyObject *self, PyObject *args) -{ - TCPublic *tc = (TCPublic *) self; - PyObject *list; - PyObject *json_parser; - - if ( - !PyArg_ParseTuple( - args, - "OO", - &list, - &json_parser - ) - ) - return NULL; - - if (!PySequence_Check(list)) - { - PyErr_SetString( - PyExc_TypeError, - "expected a sequence" - ); - return NULL; - } - - Py_ssize_t size = PySequence_Size(list); - if (size < 0) - return NULL; - - type_info **info = build_type_codes( - list, - size - ); - tc->codes = info; - tc->codes_len = size; - tc->json_parser = Py_NewRef(json_parser); - Py_RETURN_NONE; -} - -static PyMethodDef methods[] = -{ - {"_compile", (PyCFunction) compile, METH_VARARGS, NULL}, - {"_cast", (PyCFunction) cast_from_typecodes_public, METH_VARARGS, NULL}, - {NULL, NULL, 0, NULL} -}; - - -PyTypeObject TCPublicType = -{ - PyVarObject_HEAD_INIT( - NULL, - 0 - ) - .tp_name = "_view.TCPublic", - .tp_basicsize = sizeof(TCPublic), - .tp_itemsize = 0, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, - .tp_new = new, - .tp_dealloc = (destructor) dealloc, - .tp_methods = methods, -}; diff --git a/src/_view/ws.c b/src/_view/ws.c deleted file mode 100644 index bc85661f..00000000 --- a/src/_view/ws.c +++ /dev/null @@ -1,674 +0,0 @@ -/* - * view.py ASGI WebSocket implementation - * - * This file contains the internal _WebSocket object, as well - * as all the logic for dealing with WebSockets. - * - * While the WebSocket API is public, it is wrapped by a Python class, - * hence why the object name here is _WebSocket, meaning that - * breaking API changes can be made here. - * - * The _WebSocket class is fairly simple, if you're familiar with ASGI. The - * object wraps both the ASGI send() and receive() function, which are passed through - * the data input constructor: ws_from_data() - * - * In fact, similar to Context, that's really the only way to construct a WebSocket - * object at runtime, as the WebSocket __new__() doesn't do any argument parsing. All - * fields of WebSocket are set by ws_from_data() - * - * The underlying WebSocket methods are just simple PyAwaitable calls: - * - * - accept() is implemented by calling send() with a "websocket.accept" - * - receive() is implemented by calling the ASGI receive() - * - close() is implemented by sending a "websocket.close". Note that this - * function sets the closing field to true, which prevents any further - * calls. closing can be set without the underlying connection actually - * being finalized yet. - */ -#include -#include // offsetof - -#include // PyErr_BadASGI -#include -#include // handle_result -#include // route -#include // WebSocketType -#include - -#include - -typedef struct -{ - PyObject_HEAD - PyObject *send; // ASGI send() - PyObject *receive; // ASGI receive() - PyObject *raw_path; // Path from the ASGI scope - bool closing; // This is set upon calling close(), regardless of whether the connection has actually finalized -} WebSocket; - -/* Deallocator for the WebSocket object. */ -static void -dealloc(WebSocket *self) -{ - Py_XDECREF(self->send); - Py_XDECREF(self->receive); - Py_TYPE(self)->tp_free((PyObject *) self); -} - -/* - * WebSocket object allocator. - * - * Note that this does not set any fields, it only allocates the - * object. Generally, you don't want to call this manually. Use - * the ws_from_data() function instead. - */ -static PyObject * -WebSocket_new( - PyTypeObject *type, - PyObject *args, - PyObject *kwargs -) -{ - WebSocket *self = (WebSocket *) type->tp_alloc( - type, - 0 - ); - if (!self) - return NULL; - - return (PyObject *) self; -} - -/* - * The main WebSocket initializer. - * - * Note that this does not actually return a _WebSocket() instance, but - * instead an instance of the Python WebSocket() class. - */ -PyObject * -ws_from_data(PyObject *scope, PyObject *send, PyObject *receive) -{ - WebSocket *ws = (WebSocket *) WebSocket_new( - &WebSocketType, - NULL, - NULL - ); - - if (!ws) - return NULL; - - ws->send = Py_NewRef(send); - ws->receive = Py_NewRef(receive); - ws->raw_path = Py_XNewRef(PyDict_GetItemString(scope, "path")); - - if (!ws->raw_path) - { - PyErr_BadASGI(); - return NULL; - } - - PyObject *py_ws = PyObject_Vectorcall( - PyLong_FromLong(1), - (PyObject *[]) { (PyObject *) ws }, - 1, - NULL - ); - - return py_ws; -} - -/* - * Actual implementation of accept(). Do not call this manually! - * - * This is a PyAwaitable callback set by recv_awaitable(), it is - * given the result of the call to the ASGI receive() function. - * - * This expects that the "type" key in the result is "websocket.receive." If not, - * a RuntimeError is thrown. - * - * If the WebSocket disconnected between calls (i.e. the "type" key is "websocket.disconnect"), - * then this returns None back to the user through PyAwaitable. - * - * It is up to the Python caller to handle the result of this function. - */ -static int -run_ws_accept(PyObject *awaitable, PyObject *result) -{ - PyObject *tp = PyDict_GetItemString( - result, - "type" - ); - if (!tp) - { - PyErr_BadASGI(); - return -1; - } - - const char *type = PyUnicode_AsUTF8(tp); - if (!type) - return -1; - - if ( - !strcmp( - type, - "websocket.disconnect" - ) - ) - { - return 0; - } - - if ( - strcmp( - type, - "websocket.connect" - ) - ) - { - // type is probably websocket.receive, so accept() was already called - PyErr_SetString( - PyExc_RuntimeError, - "received message was not websocket.connect (was accept() already called?)" - ); - return -1; - } - - WebSocket *ws; - if ( - PyAwaitable_UnpackValues( - awaitable, - &ws - ) < 0 - ) - return -1; - - PyObject *send_dict = Py_BuildValue( - "{s:s}", - "type", - "websocket.accept" - ); - if (!send_dict) - return -1; - - PyObject *coro = PyObject_Vectorcall( - ws->send, - (PyObject *[]) { send_dict }, - 1, - NULL - ); - Py_DECREF(send_dict); - - if (!coro) - return -1; - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(coro); - return -1; - } - PyObject *args = Py_BuildValue( - "(zOz)", - "N/A", - ws->raw_path, - "websocket" - ); - - if (!PyObject_Call(route_log, args, NULL)) - { - Py_DECREF(args); - Py_DECREF(awaitable); - return -1; - } - Py_DECREF(args); - - return 0; -} - -/* - * Actual implementation of receive(). Do not call this manually! - * - * This behaves nearly exactly the same as accept(), with the - * exception of the return value. - */ -static int -run_ws_recv(PyObject *awaitable, PyObject *result) -{ - PyObject *tp = PyDict_GetItemString( - result, - "type" - ); - if (!tp) - return -1; - - const char *type = PyUnicode_AsUTF8(tp); - if (!type) - return -1; - - if ( - !strcmp( - type, - "websocket.disconnect" - ) - ) - { - return 0; - } - - if ( - strcmp( - type, - "websocket.receive" - ) - ) - { - // type is probably websocket.connect, so accept() was not called - PyErr_SetString( - PyExc_RuntimeError, - "received message was not websocket.receive (did you forget to call accept()?)" - ); - return -1; - } - - PyObject *text = PyDict_GetItemString( - result, - "text" - ); - - if (!text || (text == Py_None)) - { - text = PyDict_GetItemString( - result, - "bytes" - ); - - if (!text || (text == Py_None)) - { - PyErr_BadASGI(); - return -1; - } - } - ; - - if ( - PyAwaitable_SetResult( - awaitable, - Py_NewRef(text) - ) < 0 - ) - { - Py_DECREF(text); - return -1; - } - - return 0; -} - -/* - * Simple wrapper around exceptions that occur during - * asynchronous calls in WebSocket connections. - */ -static int -ws_err( - PyObject *awaitable, - PyObject *err -) -{ - /* - * This needs to be here for the error to propagate at runtime. - * - * All this does is print the error and clear the error indicator, to - * prevent the ASGI server from handling it weirdly. - */ - PyErr_SetRaisedException(err); - PyErr_Print(); - PyErr_Clear(); - PyAwaitable_Cancel(awaitable); - return -2; -} - -/* - * Utility function for calling receive() with a PyAwaitable callback. - * - * Most of the _WebSocket() methods call this function, and keep - * their logic in a method-specific callback. For example, accept() is - * implemented by calling this function with run_ws_accept() as the callback. - */ -static PyObject * -recv_awaitable(WebSocket *self, awaitcallback cb) -{ - PyObject *recv_coro = PyObject_CallNoArgs(self->receive); - if (!recv_coro) - return NULL; - - PyObject *awaitable = PyAwaitable_New(); - if (!awaitable) - { - Py_DECREF(recv_coro); - return NULL; - } - - if ( - PyAwaitable_SaveValues( - awaitable, - 1, - self - ) < 0 - ) - { - Py_DECREF(awaitable); - Py_DECREF(recv_coro); - return NULL; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - recv_coro, - cb, - ws_err - ) < 0 - ) - { - Py_DECREF(recv_coro); - return NULL; - } - ; - - Py_DECREF(recv_coro); - return awaitable; -} - -/* - * Actual Python method for accept() - * - * This defers to PyAwaitable, which calls run_ws_accept(), which - * is the actual implementation function. - */ -static PyObject * -WebSocket_accept(WebSocket *self) -{ - if (self->closing) - { - PyErr_SetString(PyExc_RuntimeError, "websocket has been closed"); - return NULL; - } - return recv_awaitable( - self, - run_ws_accept - ); -} - -/* - * Actual Python method for receive() - * - * This is an asynchronous function. - */ -static PyObject * -WebSocket_receive(WebSocket *self) -{ - /* - * This defers to PyAwaitable, which calls run_ws_recv(), which - * is the actual implementation function. - */ - if (self->closing) - { - PyErr_SetString(PyExc_RuntimeError, "websocket has been closed"); - return NULL; - } - return recv_awaitable( - self, - run_ws_recv - ); -} - -/* - * Python method for closing the connection. - * - * This takes two keyword arguments at the Python level: code and reason. - * Code is the WebSocket close code, and reason is a string containing the reason why. - * - * Validating these are up to the Python caller, not C. - */ -static PyObject * -WebSocket_close( - WebSocket *self, - PyObject *args, - PyObject *kwargs -) -{ - /* - * This still counts as a private API - the WebSocket() class that - * wraps it is what's public. - */ - static char *kwlist[] = {"code", "reason", NULL}; - PyObject *code = NULL; - PyObject *reason = NULL; - - if ( - !PyArg_ParseTupleAndKeywords( - args, - kwargs, - "|O!O!", - kwlist, - &PyLong_Type, - &code, - &PyUnicode_Type, - &reason - ) - ) - return NULL; - - if (self->closing) - { - PyErr_SetString( - PyExc_RuntimeError, - "websocket is already closed or closing" - ); - return NULL; - } - - PyObject *awaitable = PyAwaitable_New(); - if (!awaitable) - return NULL; - - PyObject *send_dict = Py_BuildValue( - "{s:s}", - "type", - "websocket.close" - ); - if (!send_dict) - { - Py_DECREF(awaitable); - return NULL; - } - - if (code) - { - if ( - PyDict_SetItemString( - send_dict, - "code", - code - ) < 0 - ) - { - Py_DECREF(awaitable); - Py_DECREF(send_dict); - return NULL; - } - } - - if (reason) - { - if ( - PyDict_SetItemString( - send_dict, - "reason", - reason - ) < 0 - ) - { - Py_DECREF(awaitable); - Py_DECREF(send_dict); - return NULL; - } - } - - PyObject *coro = PyObject_Vectorcall( - self->send, - (PyObject *[]) { send_dict }, - 1, - NULL - ); - Py_DECREF(send_dict); - - if (!coro) - { - Py_DECREF(awaitable); - return NULL; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(awaitable); - return NULL; - } - self->closing = true; - - Py_DECREF(coro); - return awaitable; -} - -/* - * Send data to the client. - * - * This is a Python method that accepts a string or bytes. - */ -static PyObject * -WebSocket_send(WebSocket *self, PyObject *args) -{ - /* - * Note that this is still a private API - the Python send() - * function in WebSocket() is responsible for wrapping it. - * Breaking changes are allowed! - */ - PyObject *data; - - if ( - !PyArg_ParseTuple( - args, - "O", - &data - ) - ) - return NULL; - - PyObject *awaitable = PyAwaitable_New(); - if (!awaitable) - return NULL; - - PyObject *send_dict; - if (PyUnicode_Check(data)) - { - send_dict = Py_BuildValue( - "{s:s,s:S}", - "type", - "websocket.send", - "text", - data - ); - } else if (PyBytes_Check(data)) - { - send_dict = Py_BuildValue( - "{s:s,s:S}", - "type", - "websocket.send", - "bytes", - data - ); - } else - { - PyErr_Format( - PyExc_TypeError, - "expected string or bytes, got %R", - Py_TYPE(data) - ); - return NULL; - } - - if (!send_dict) - { - Py_DECREF(awaitable); - return NULL; - } - - PyObject *coro = PyObject_Vectorcall( - self->send, - (PyObject *[]) { send_dict }, - 1, - NULL - ); - Py_DECREF(send_dict); - - if (!coro) - { - Py_DECREF(awaitable); - return NULL; - } - - if ( - PyAwaitable_AddAwait( - awaitable, - coro, - NULL, - NULL - ) < 0 - ) - { - Py_DECREF(awaitable); - Py_DECREF(coro); - return NULL; - } - - Py_DECREF(coro); - return awaitable; -} - -static PyMethodDef methods[] = -{ - {"accept", (PyCFunction) WebSocket_accept, METH_NOARGS, NULL}, - {"receive", (PyCFunction) WebSocket_receive, METH_NOARGS, NULL}, - {"close", (PyCFunction) WebSocket_close, METH_VARARGS | METH_KEYWORDS, - NULL}, - {"send", (PyCFunction) WebSocket_send, METH_VARARGS, NULL}, - {NULL} -}; - -PyTypeObject WebSocketType = -{ - PyVarObject_HEAD_INIT( - NULL, - 0 - ) - .tp_name = "_view.ViewWebSocket", - .tp_basicsize = sizeof(WebSocket), - .tp_itemsize = 0, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, - .tp_new = WebSocket_new, - .tp_dealloc = (destructor) dealloc, - .tp_methods = methods -}; diff --git a/src/view/__about__.py b/src/view/__about__.py deleted file mode 100644 index 656205a5..00000000 --- a/src/view/__about__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "1.0.0-alpha11" -__license__ = "MIT" diff --git a/src/view/__init__.py b/src/view/__init__.py deleted file mode 100644 index 1f23f851..00000000 --- a/src/view/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -# flake8: noqa -""" -view.py - The Batteries-Detachable Web Framework - -Docs: https://view.zintensity.dev -GitHub: https://github.com/zerointensity/view.py -Support: https://github.com/sponsors/ZeroIntensity - -Quickstart: - -```py -from view import new_app - -app = new_app() - -@app.get("/") -def index(): - return "Hello, view.py!" - -app.run() -``` -""" -try: - import _view -except ModuleNotFoundError as e: - raise ImportError( - "the _view extension module is missing! view.py cannot be used with pure python" - ) from e - -# these are re-exports -from _view import Context, HeaderDict, InvalidStatusError - -from .__about__ import * -from .app import * -from .build import * -from .default_page import * -from .exceptions import * -from .integrations import * -from .patterns import * -from .response import * -from .routing import * -from .templates import * -from .typecodes import * -from .util import * -from .ws import * diff --git a/src/view/__main__.py b/src/view/__main__.py deleted file mode 100644 index 6e1a5e24..00000000 --- a/src/view/__main__.py +++ /dev/null @@ -1,483 +0,0 @@ -from __future__ import annotations - -import asyncio -import getpass -import os -import random -import re -import subprocess -import venv as _venv -from pathlib import Path -from typing import NoReturn - -import click -from prompts.integration import PrettyOption - -from .__about__ import __version__ -from ._logging import VIEW_TEXT -from .exceptions import AppNotFoundError, BuildError - -B_OPEN = "{" -B_CLOSE = "}" - - -def _get_email(): - home = Path.home() - git_config = home / ".gitconfig" - if not git_config.exists(): - return "your@email.com" - - try: - text = git_config.read_text(encoding="utf-8") - except PermissionError: - return "your@email.com" - - for i in text.split("\n"): - # don't use re.compile to keep the import lazy - match = re.match(r' *email = "(.+)"', i) - if match: - return match.group(1) - - return "your@email.com" - - -PYPROJECT_BASE = ( - lambda name: f"""[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" - -[project] -name = "{name}" -authors = [ - {B_OPEN}name = "{getpass.getuser()}", email = "{_get_email()}"{B_CLOSE} -] -requires-python = ">=3.8" -license = "MIT" -dependencies = ["view.py"] -version = "1.0.0" -""" -) - - -def success(msg: str) -> None: - click.secho(f" - {msg}", fg="green", bold=True) - - -def warn(msg: str) -> None: - click.secho(f" ! {msg}", fg="yellow", bold=True) - - -def error(msg: str) -> NoReturn: - click.secho(f" ! {msg}", fg="red", bold=True) - exit(1) - - -def info(msg: str) -> None: - click.secho(f" * {msg}", fg="bright_magenta", bold=True) - - -def ver() -> None: - click.echo(f"view.py {__version__}") - - -def welcome() -> None: - click.secho(random.choice(VIEW_TEXT) + "\n", fg="blue", bold=True) - ver() - click.echo("Docs: ", nl=False) - click.secho("https://view.zintensity.dev", fg="blue", bold=True) - click.echo("GitHub: ", nl=False) - click.secho( - "https://github.com/ZeroIntensity/view.py", - fg="green", - bold=True, - ) - click.echo("Support: ", nl=False) - click.secho( - "https://github.com/sponsors/ZeroIntensity", - fg="bright_magenta", - bold=True, - ) - - -@click.group(invoke_without_command=True) -@click.option("--debug", "-d", is_flag=True) -@click.option("--version", "-v", is_flag=True) -@click.pass_context -def main(ctx: click.Context, debug: bool, version: bool) -> None: - if debug: - from .util import enable_debug - - enable_debug() - if version: - ver() - elif not ctx.invoked_subcommand: - welcome() - - -@main.group() -def logs(): ... - - -@logs.command() -@click.option( - "--path", - type=click.Path( - exists=True, - file_okay=False, - resolve_path=True, - path_type=Path, - writable=True, - ), - default="./", -) -def show(path: Path): - from rich import print - from rich.panel import Panel - - internal = path / "view_internal.log" - - if not internal.exists(): - error(f"`{internal}` does not exist") - - service = path / "view_service.log" - - if not service.exists(): - error(f"`{service}` does not exist") - - print(Panel(internal.read_text(encoding="utf-8"), title=str(internal))) - click.pause() - print(Panel(service.read_text(encoding="utf-8"), title=str(service))) - - -@logs.command() -@click.option( - "--path", - type=click.Path( - exists=True, - file_okay=False, - resolve_path=True, - path_type=Path, - writable=True, - ), - default="./", -) -def clear(path: Path): - internal = path / "view_internal.log" - - if not internal.exists(): - error(f"`{internal}` does not exist") - - service = path / "view_service.log" - - if not service.exists(): - error(f"`{service}` does not exist") - - os.remove(internal) - os.remove(service) - - -def _run(*, force_prod: bool = False) -> None: - from .config import load_config - from .util import run as run_path - - os.environ["_VIEW_RUN"] = "1" - - conf = load_config() - if force_prod: - conf.dev = True - - try: - run_path(conf.app.app_path) - except AppNotFoundError as e: - error(str(e).replace('"', "`")) - - -@main.command() -def serve(): - _run() - - -@main.command() -def prod(): - _run(force_prod=True) - - -@main.command() -@click.option( - "--path", - "-p", - type=click.Path( - exists=True, - file_okay=False, - resolve_path=True, - path_type=Path, - readable=True, - ), - default="./", -) -def dev(path: Path): - try: - from watchfiles import run_process - except ImportError: - error("Module `watchfiles` is not installed!") - - run_process(path, target=_run) - - -@main.command() -@click.option( - "--path", - "-p", - type=click.Path( - exists=False, - file_okay=False, - resolve_path=True, - path_type=Path, - writable=True, - ), - default=Path.cwd() / "build", -) -def build(path: Path): - from ._logging import Internal - from .config import load_config - from .util import extract_path - - conf = load_config() - app = extract_path(conf.app.app_path) - app.load() - - def info_hook(*msg: object, **kwargs): - info(" ".join([str(i) for i in msg])) - - Internal.info = info_hook # type: ignore - - from .build import build_app - - try: - asyncio.run(build_app(app, path=path)) - except BuildError as e: - error(str(e)) - - -@main.command() -@click.option( - "--name", - "-n", - help="Project name.", - type=str, - default="my_app", - prompt="Project name", - cls=PrettyOption, -) -@click.option( - "--load", - "-l", - help="Preset for route loading.", - default="simple", - type=click.Choice(("manual", "filesystem", "simple", "patterns")), - prompt="Loader strategy", - cls=PrettyOption, -) -@click.option( - "--repo", - "-r", - help="Whether a Git repository should be created.", - default=True, - is_flag=True, - prompt="Create repository?", - cls=PrettyOption, -) -@click.option( - "--venv", - help="Whether a virtual environment should be created.", - default=True, - is_flag=True, - prompt="Create virtual environment?", - cls=PrettyOption, -) -@click.option( - "--path", - "-p", - type=click.Path( - exists=False, - file_okay=False, - resolve_path=True, - path_type=Path, - writable=True, - ), - default=None, -) -@click.option( - "--type", - "-t", - help="Configuration type to initalize.", - default="toml", - type=click.Choice(("toml", "json", "ini", "yml", "py")), -) -@click.option( - "--no-project", - help="Disable creation of a pyproject.toml file.", - is_flag=True, -) -def init( - name: str, - repo: bool, - venv: bool, - path: Path | None, - type: str, - load: str, - no_project: bool, -): - from .config import make_preset - - path = path or Path(f"./{name}") - - fname = f"view.{type}" - if not path.exists(): - success(f"Created `{path.relative_to('.')}`") - path.mkdir() - - if repo: - info("Initializing repository...") - res = subprocess.call(["git", "init", str(path)]) - if res != 0: - warn("failed to initalize git repository") - else: - gitignore = path / ".gitignore" - - with open(gitignore, "w") as f: - f.write("__pycache__/") - - success("Created `.gitignore`") - - if venv: - info("Creating venv...") - venv_path = path / ".venv" - _venv.create(venv_path, with_pip=True) - success(f"Created virtual environment in {venv_path}") - info("Installing view.py with all dependencies...") - res = subprocess.call( - [ - (venv_path / "bin" / "pip").absolute(), - "install", - "view.py[full]", - ] - ) - - if res != 0: - error("failed to install view.py") - - conf_path = path / fname - with open(conf_path, "w") as f: - f.write(make_preset(type, load)) - - success(f"Created `{fname}`") - - app_path = path / "app.py" - - from .__about__ import __version__ - - with open(app_path, "w") as f: - if load in {"filesystem", "simple", "patterns"}: - f.write( - f"# view.py {__version__}\n" - """from view import new_app - -app = new_app() -app.run() -""" - ) - - if load == "manual": - f.write( - f"# view.py {__version__}\n" - """from view import new_app, default_page - -app = new_app() - -@app.get("/") -async def index(): - return default_page() - -app.run() -""" - ) - - success("Created `app.py`") - - if not no_project: - pyproject = path / "pyproject.toml" - - with pyproject.open("w", encoding="utf-8") as f: - f.write(PYPROJECT_BASE(name)) - - success("Created `pyproject.toml`") - - if load == "patterns": - urls = path / "urls.py" - with open(urls, "w") as f: - f.write( - """from view import path -from routes.index import index - -PATTERNS = ( - path("/", index), -) -""" - ) - - if load != "manual": - routes = path / "routes" - routes.mkdir() - success("Created `routes`") - - index = routes / "index.py" - - pathstr = "" if load == "filesystem" else "'/'" - with open(index, "w") as f: - f.write( - f"""from view import get, default_page - -@get({pathstr}) -async def index(): - return default_page() -""" - ) - - success("Created `routes/index.py`") - - welcome() - success(f"Successfully initalized app in `{path}`") - return - - -@main.command() -@click.option( - "--file", - "-f", - type=click.Path( - exists=False, - dir_okay=False, - resolve_path=True, - path_type=Path, - writable=True, - ), - default=Path.cwd() / "docs.md", -) -@click.option("--app", "-a", type=str, default=None) -def docs(file: Path, app: str | None): - from .config import load_config - from .util import extract_path - - if not app: - conf = load_config() - target = extract_path(conf.app.app_path) - else: - target = extract_path(app) - - target.docs(file) - success(f"Created `{file}`") - - -if __name__ == "__main__": - main() diff --git a/src/view/_codec.py b/src/view/_codec.py deleted file mode 100644 index c7489a54..00000000 --- a/src/view/_codec.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Union - -if TYPE_CHECKING: - from _typeshed import ReadableBuffer - -import codecs -import encodings -import re -from dataclasses import dataclass -from encodings.utf_8 import StreamReader as UTF8StreamReader -from html.parser import HTMLParser -from io import StringIO - -__all__ = ("codec_info",) - -Input = Union[bytes, bytearray, memoryview] - -UTF8 = encodings.search_function("utf-8") -assert UTF8 -TAG = re.compile(r"< *([A-z]+) *(.*) *>(.*)< *\/([A-z]+) *>") - - -@dataclass() -class _Tag: - name: str - attrs: dict[str, str | None] - content: list[str | _Tag] - - -@dataclass() -class _Item: - tag: _Tag | None - source: str | None - - -class _Parser(HTMLParser): - def __init__(self): - super().__init__() - self._tags: list[_Tag] = [] - self.source: list[_Item] = [] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]): - dict_attrs = {tup[0]: (repr(tup[1]) if tup[1] else None) for tup in attrs} - if not self._tags: - self._tags.append(_Tag(tag, dict_attrs, [])) - else: - tg = _Tag(tag, dict_attrs, []) - self._tags[-1].content.append(tg) - self._tags.append(tg) - - def handle_endtag(self, tag: str): - if not self._tags: - raise SyntaxError(f'unexpected end tag "{tag}"') - if self._tags[-1].name != tag: - raise SyntaxError( - f"expected end tag for {self._tags[-1].name!r}, got {tag!r}" - ) - if len(self._tags) == 1: - self.source.append(_Item(self._tags.pop(), None)) - else: - self._tags.pop() - - def handle_data(self, data: str): - if not self._tags: - self.source.append(_Item(None, data)) - else: - self._tags[-1].content.append(data) - - -def _transform_recursive(tag: _Tag) -> str: - attrs = [f"{a}={b}" for a, b in tag.attrs.items()] - items = [] - - for i in tag.content: - if isinstance(i, _Tag): - items.append(_transform_recursive(i)) - elif isinstance(i, str): - items.append(i) - else: - items.append("''") - - content = StringIO() - - if items: - content.write(", ") - - for index, value in enumerate(items): - content.write(f"{value}{', ' if (index + 1) != len(items) else ''}") - - if attrs: - content.write(", ") - - return f"_vpy_newnode({repr(tag.name)}{content.getvalue()}" f"{','.join(attrs)})" - - -def _transform(code: str) -> str: - p = _Parser() - p.feed(code) - source = StringIO() - - for tag in p.source: - if tag.tag: - source.write(_transform_recursive(tag.tag)) - else: - assert tag.source - source.write(tag.source) - return "from view.nodes import new_node as _vpy_newnode\n" + source.getvalue() - - -def decode(source: Input) -> str: - return _transform(bytes(source).decode()) - - -def view_decode(input: bytes, errors: str = "strict") -> tuple[str, int]: - code, length = UTF8.decode(input, errors) - - return _transform(code), length - - -def transform_stream(stream: Any) -> StringIO: - return StringIO(_transform(stream.read())) - - -class IncrementalDecoder(codecs.BufferedIncrementalDecoder): - def _buffer_decode( - self, input: ReadableBuffer, errors: str, final: bool - ) -> tuple[str, int]: - if final: - return view_decode(input, errors) # type: ignore - else: - return "", 0 - - -class StreamReader(UTF8StreamReader): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - print("abc") - self.stream: StringIO = transform_stream(self.stream) - - -codec_info = codecs.CodecInfo( - UTF8.encode, - view_decode, - name="view", - streamreader=StreamReader, - streamwriter=UTF8.streamwriter, - incrementalencoder=UTF8.incrementalencoder, - incrementaldecoder=IncrementalDecoder, -) diff --git a/src/view/_docs.py b/src/view/_docs.py deleted file mode 100644 index f22f9f05..00000000 --- a/src/view/_docs.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, get_args - -from typing_extensions import get_origin - -from .typing import DocsType - -if TYPE_CHECKING: - from ._loader import LoaderDoc - from .app import InputDoc - from .routing import _NoDefaultType - -_PRIMITIVES = { - str: "string", - int: "integer", - dict: "object", - Any: "any", - bool: "boolean", - float: "double", - None: "null", -} - - -def _tp_name(tp: Any, types: list[Any]) -> str: - prim = _PRIMITIVES.get(tp) - if prim: - return f"`{prim}`" - else: - if tp not in types: - doc: dict[str, LoaderDoc] | None = getattr(tp, "_view_doc", None) - if not doc: - if hasattr(tp, "__origin__"): - origin = get_origin(tp) - args = get_args(tp) - tp_name = _PRIMITIVES.get(origin) or getattr( - origin, "__name__", str(origin) - ) - parsed_args = [(_PRIMITIVES.get(i) or i.__name__) for i in args] - return f"`{tp_name}<{', '.join(parsed_args)}>`" - - return f"`{doc}`" - - types.append(tp) - - for v in doc.values(): - _tp_name(v.tp, types) - - return f"`{tp.__name__}`" - - -def _format_type(tp: tuple[type[Any], ...], types: list[Any]) -> str: - if len(tp) == 1: - return _tp_name(tp[0], types) - - final = "" - - for index, i in enumerate(tp): - if (index + 1) == len(tp): - final += _tp_name(i, types) - else: - final += f"{_tp_name(i, types)} | " - - return final - - -def _format_default(default: Any | _NoDefaultType) -> str: - if hasattr(default, "__VIEW_NODEFAULT__"): - return "**Required**" - - return f"`{default!r}`" - - -def _make_table( - final: list[str], - table_name: str, - inputs: dict[str, InputDoc], - types: list[Any], -) -> None: - if not inputs: - return - - final.append(f"#### {table_name}") - final.append("| Name | Description | Type | Default |") - final.append("| - | - | - | - |") - - for name, body in inputs.items(): - final.append( - f"| {name} | {body.desc} | {_format_type(body.type, types)} | {_format_default(body.default)} |" # noqa - ) - - -def markdown_docs(docs: DocsType) -> str: - final: list[str] = [] - types: list[Any] = [] - if docs: - final.append(f"\n## Routes") - else: - final.append("\n*This app is empty...*") - - for k, v in docs.items(): - name = k[0] if isinstance(k[0], str) else ", ".join(k[0]) - final.append(f"### {name} `{k[1]}`") - final.append(f"*{v.desc}*") - - _make_table(final, "Query Parameters", v.query, types) - _make_table(final, "Body Parameters", v.body, types) - - part = ["\n## Types"] if types else [""] - - for i in types: - doc: dict[str, LoaderDoc] = getattr(i, "_view_doc") - part.append(f"### `{i.__name__}`") - part.append("| Key | Description | Type | Default |") - part.append("| - | - | - | - |") - - for name, loader_doc in doc.items(): - part.append( - f"| {name} | {loader_doc.desc} | {_format_type((loader_doc.tp,), types)} | {_format_default(loader_doc.default)} |" # noqa - ) - - return "# Docs" + "\n".join(part) + "\n".join(final) diff --git a/src/view/_loader.py b/src/view/_loader.py deleted file mode 100644 index f599b150..00000000 --- a/src/view/_loader.py +++ /dev/null @@ -1,677 +0,0 @@ -from __future__ import annotations - -import os -import sys -import warnings -from dataclasses import _MISSING_TYPE, Field, dataclass -from pathlib import Path -from typing import ( - TYPE_CHECKING, - ForwardRef, - Iterable, - NamedTuple, - TypedDict, - get_args, - get_type_hints, -) - -from _view import Context - -from ._util import needs_dep, run_path - -if not TYPE_CHECKING: - from typing import _eval_type -else: - - def _eval_type(*args) -> Any: ... - - -import inspect - -from typing_extensions import get_origin - -from ._logging import Internal -from ._util import docs_hint, is_annotated, is_union, set_load -from .exceptions import ( - DuplicateRouteError, - InvalidBodyError, - InvalidRouteError, - LoaderWarning, - UnknownBuildStepError, - ViewInternalError, -) -from .routing import BodyParam, Method, Route, RouteData, RouteInput, _NoDefault -from .typing import Any, RouteInputDict, TypeInfo, ValueType - -ExtNotRequired: Any = None -try: - from typing import NotRequired # type: ignore -except ImportError: - NotRequired = None - from typing_extensions import NotRequired as ExtNotRequired - - -_NOT_REQUIRED_TYPES: list[Any] = [] - -if ExtNotRequired: - _NOT_REQUIRED_TYPES.append(ExtNotRequired) - -if NotRequired: - _NOT_REQUIRED_TYPES.append(NotRequired) - -if TYPE_CHECKING: - from attrs import Attribute - from pydantic.fields import ModelField - - from .app import App as ViewApp - - _TypedDictMeta = None -else: - from typing import _TypedDictMeta - -__all__ = "load_fs", "load_simple", "finalize" - - -TYPECODE_ANY = 0 -TYPECODE_STR = 1 -TYPECODE_INT = 2 -TYPECODE_BOOL = 3 -TYPECODE_FLOAT = 4 -TYPECODE_DICT = 5 -TYPECODE_NONE = 6 -TYPECODE_CLASS = 7 -TYPECODE_CLASSTYPES = 8 -TYPECODE_LIST = 9 - - -_BASIC_CODES = { - str: TYPECODE_STR, - int: TYPECODE_INT, - bool: TYPECODE_BOOL, - float: TYPECODE_FLOAT, - dict: TYPECODE_DICT, - None: TYPECODE_NONE, - Any: TYPECODE_ANY, - list: TYPECODE_LIST, -} - -""" -Type info should contain up to four things: - - Type Code - - Type Object (only set when using a __view_body__ object) - - Children (i.e. the `int` part of dict[str, int]) - - Default (only set when typecode is TYPECODE_CLASSTYPES) - -This can be formatted as so: - [(union1_tc, None, []), (union2_tc, None, [(type_tc, obj, [])])] -""" - -""" --- Route Data Information -- -1 - Context -2 - WebSocket -""" - - -class _ViewNotRequired: - __VIEW_NOREQ__ = 1 - - -def _format_body( - vbody_types: dict, - doc: dict[Any, LoaderDoc], - origin: type[Any], - *, - not_required: set[str] | None = None, -) -> list[TypeInfo]: - """Generate a type info list from view body types.""" - not_required = not_required or set() - if not isinstance(vbody_types, dict): - raise InvalidBodyError( - f"__view_body__ should return a dict, not {type(vbody_types)}", # noqa - ) - - vbody_final: dict[str, list[Any]] = {} - vbody_defaults: dict[str, Any] = {} - - for k, raw_v in vbody_types.items(): - if not isinstance(k, str): - raise InvalidBodyError( - f"all keys returned by __view_body__ should be strings, not {type(k)}" # noqa - ) - - default: type[Any] = _NoDefault - v = raw_v.types if isinstance(raw_v, BodyParam) else raw_v - - if isinstance(v, str): - scope = getattr(origin, "_view_scope", globals()) - v = _eval_type(ForwardRef(v), scope, scope) - - if isinstance(raw_v, BodyParam): - default = raw_v.default - - if (getattr(raw_v, "__origin__", None) in _NOT_REQUIRED_TYPES) or ( - k in not_required - ): - v = get_args(raw_v) - default = _ViewNotRequired - iter_v = v if isinstance(v, (tuple, list)) else (v,) - vbody_final[k] = _build_type_codes( - iter_v, - doc, - key_name=k, - default=default, - ) - vbody_defaults[k] = default - - return [ - (TYPECODE_CLASSTYPES, k, v, vbody_defaults[k]) # type: ignore - for k, v in vbody_final.items() - ] - - -@dataclass -class LoaderDoc: - desc: str - tp: Any - default: Any - - -class _NotSet: - """Sentinel value for default being not set in _build_type_codes.""" - - -def _build_type_codes( - inp: Iterable[type[ValueType]], - doc: dict[Any, LoaderDoc] | None = None, - *, - key_name: str | None = None, - default: Any | _NoDefault = _NotSet, -) -> list[TypeInfo]: - """Generate types from a list of types. - - Args: - inp: Iterable containing each type. - doc: Auto-doc dictionary when a docstring is extracted. - key_name: Name of the current key. Only needed for auto-doc purposes. - default: Default value. Only needed for auto-doc purposes.""" - if not inp: - return [] - - codes: list[TypeInfo] = [] - - for tp in inp: - tps: dict[str, type[Any] | BodyParam] - - if is_annotated(tp): - if doc is None: - raise InvalidBodyError(f"Annotated is not valid here ({tp})") - - if not key_name: - raise ViewInternalError("key_name is None") - - if default is _NotSet: - raise ViewInternalError("default is _NotSet") - - tmp = tp.__origin__ - doc[key_name] = LoaderDoc(tp.__metadata__[0], tmp, default) - tp = tmp - elif doc is not None: - if not key_name: - raise ViewInternalError("key_name is None") - - if default is _NotSet: - raise ViewInternalError("internal error: default is _NotSet") - - doc[key_name] = LoaderDoc("No description provided.", tp, default) - - type_code = _BASIC_CODES.get(tp) - - if type_code: - codes.append((type_code, None, [])) - continue - - if (TypedDict in getattr(tp, "__orig_bases__", [])) or ( # type: ignore - type(tp) == _TypedDictMeta - ): - try: - body = get_type_hints(tp) - except KeyError: - body = tp.__annotations__ - - opt = getattr(tp, "__optional_keys__", None) - - class _Transport: - @staticmethod - def __view_construct__(**kwargs): - return kwargs - - doc = {} - codes.append( - ( - TYPECODE_CLASS, - _Transport, - _format_body(body, doc, tp, not_required=opt), - ), - ) - setattr(tp, "_view_doc", doc) - continue - - if (NamedTuple in getattr(tp, "__orig_bases__", [])) or ( - hasattr(tp, "_field_defaults") - ): - defaults = tp._field_defaults # type: ignore - tps = {} - try: - hints = get_type_hints(tp) - except KeyError: - hints = getattr(tp, "_field_types", tp.__annotations__) - - for k, v in hints.items(): - if k in defaults: - tps[k] = BodyParam(v, defaults[k]) - else: - tps[k] = v - - doc = {} - codes.append((TYPECODE_CLASS, tp, _format_body(tps, doc, tp))) - setattr(tp, "_view_doc", doc) - continue - - dataclass_fields: dict[str, Field] | None = getattr( - tp, "__dataclass_fields__", None - ) - - if dataclass_fields: - tps = {} - for k, v in dataclass_fields.items(): - if isinstance(v.default, _MISSING_TYPE) and ( - isinstance(v.default_factory, _MISSING_TYPE) - ): - tps[k] = v.type - else: - default = ( - v.default - if not isinstance(v.default, _MISSING_TYPE) - else v.default_factory - ) - tps[k] = BodyParam(v.type, default) - - doc = {} - codes.append((TYPECODE_CLASS, tp, _format_body(tps, doc, tp))) - setattr(tp, "_view_doc", doc) - continue - - pydantic_fields: dict[str, ModelField] | None = getattr( - tp, "__fields__", None - ) or getattr(tp, "model_fields", None) - if pydantic_fields: - tps = {} - try: - from pydantic_core import PydanticUndefined - except ImportError: - PydanticUndefined = None - - for k, v in pydantic_fields.items(): - outer_type = getattr(v, "outer_type_", None) - if not outer_type: - outer_type = v.annotation - default_not_set = v.default in (None, PydanticUndefined) - if default_not_set and (not v.default_factory): - tps[k] = outer_type - else: - tps[k] = BodyParam( - outer_type, - v.default_factory if default_not_set else v.default, - ) - doc = {} - codes.append((TYPECODE_CLASS, tp, _format_body(tps, doc, tp))) - setattr(tp, "_view_doc", doc) - continue - - attrs_fields: tuple[Attribute, ...] | None = getattr( - tp, "__attrs_attrs__", None - ) - if attrs_fields: - try: - from attrs import Factory - except ModuleNotFoundError as e: - needs_dep("attrs", e) - - tps = {} - - for i in attrs_fields: - default = i.default - if not default: - tps[i.name] = i.type # type: ignore - else: - tps[i.name] = BodyParam( - i.type, # type: ignore - default.factory if isinstance(default, Factory) else default, # type: ignore - ) - - doc = {} - codes.append((TYPECODE_CLASS, tp, _format_body(tps, doc, tp))) - setattr(tp, "_view_doc", doc) - continue - - vbody = getattr(tp, "__view_body__", None) - if vbody: - if callable(vbody): - vbody_types = vbody() - else: - vbody_types = vbody - - doc = {} - codes.append((TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp))) - setattr(tp, "_view_doc", doc) - continue - - origin = get_origin(tp) - if is_union(type(tp)) and (origin not in {dict, list}): - new_codes = _build_type_codes(get_args(tp)) - codes.extend(new_codes) - continue - - if origin is dict: - key, value = get_args(tp) - - if key is not str: - raise InvalidBodyError(f"dictionary keys must be strings, not {key}") - - tp_codes = _build_type_codes((value,)) - codes.append((TYPECODE_DICT, None, tp_codes)) - elif origin is list: - tps = get_args(tp) # type: ignore - codes.append((TYPECODE_LIST, None, _build_type_codes(tps))) # type: ignore - else: - raise InvalidBodyError(f"{tp} is not a valid type for routes") - - return codes - - -def _format_inputs( - inputs: list[RouteInput | RouteData], -) -> list[RouteInputDict | RouteData]: - """ - Convert a list of route inputs to a proper dictionary that the C loader can handle. - This function also will generate the typecodes for the input. - """ - result: list[RouteInputDict | RouteData] = [] - - for i in inputs: - if not isinstance(i, RouteInput): - result.append(i) - continue - type_codes = _build_type_codes(i.tp) - Internal.info("built type codes:", type_codes) - result.append( - { - "name": i.name, - "type_codes": type_codes, - "default": i.default, # type: ignore - "validators": i.validators, - "is_body": i.is_body, - "has_default": i.default is not _NoDefault, - } - ) - - return result - - -def finalize(routes: Iterable[Route], app: ViewApp): - """ - Attach list of routes to an app and validate all parameters. - - Args: - routes: List of routes. - app: App to attach to. - """ - virtual_routes: dict[str, list[Route]] = {} - - targets = { - Method.GET: app._get, - Method.POST: app._post, - Method.PUT: app._put, - Method.PATCH: app._patch, - Method.DELETE: app._delete, - Method.OPTIONS: app._options, - Method.WEBSOCKET: app._websocket, - } - - for route in routes: - set_load(route) - route.app = app - - if route.parallel_build is None: - route.parallel_build = app.config.build.parallel - - for step in route.steps or []: - if step not in app.config.build.steps: - raise UnknownBuildStepError(f"build step {step!r} is not defined") - - if route.method: - target = targets[route.method] - - if route.method is Method.WEBSOCKET: - for i in route.inputs: - if isinstance(i, RouteInput): - if i.is_body: - raise InvalidRouteError( - f"websocket routes cannot have body inputs" - ) - else: - target = None - - if (not route.path) and (not route.parts): - raise InvalidRouteError(f"{route} did not specify a path") - - lst = virtual_routes.get(route.path or "") - - if lst: - if route.method in [i.method for i in lst]: - assert route.method - raise DuplicateRouteError( - f"duplicate route: {route.method.name} for {route.path}", - ) - lst.append(route) - else: - virtual_routes[route.path or ""] = [route] - - sig = inspect.signature(route.func) - route.inputs = [i for i in reversed(route.inputs)] - - if len(sig.parameters) != len(route.inputs): - names = [i.name for i in route.inputs if isinstance(i, RouteInput)] - index = 0 - - for k, v in sig.parameters.items(): - if k in names: - index += 1 - continue - - tp = v.annotation if v.annotation is not inspect._empty else Any - - if tp is Context: - route.inputs.insert(index, 1) - continue - - default = v.default if v.default is not inspect._empty else _NoDefault - - route.inputs.insert( - index, - RouteInput( - k, - False, - (tp,), - default, - None, - [], - ), - ) - index += 1 - - if len(route.inputs) != len(sig.parameters): - raise InvalidRouteError( - "mismatch in parameter names with automatic route inputs", - hint=docs_hint( - "https://view.zintensity.dev/building-projects/parameters/#automatically" - ), - ) - - app.loaded_routes.append(route) - if target: - target( - route.path, # type: ignore - route, - route.cache_rate, - _format_inputs(route.inputs), - route.errors or {}, - route.parts, # type: ignore - ) - else: - for i in (route.method_list) or targets.keys(): - target = targets[i] - target( - route.path, # type: ignore - route, - route.cache_rate, - _format_inputs(route.inputs), - route.errors or {}, - route.parts, # type: ignore - ) - - -def load_fs(app: ViewApp, target_dir: Path) -> None: - """ - Filesystem loading implementation, similiar to NextJS's "pages" routing system. - - You take `target_dir` and search it, if a file is found and not prefixed with _, then convert - the directory structure to a path. For example, target_dir/hello/world/index.py would be converted - to a route for /hello/world - - Args: - app: App to attach routes to. - target_dir: Directory to search for routes. - """ - Internal.info("loading using filesystem") - Internal.debug(f"loading {app}") - - routes: list[Route] = [] - - if not target_dir.exists(): - raise FileNotFoundError(f"{target_dir.absolute()} does not exist") - - sys.path.append(str(target_dir.absolute())) - for root, _, files in os.walk(target_dir): - for f in files: - if f.startswith("_"): - continue - - path = os.path.join(root, f) - mod = run_path(path) - current_routes: list[Route] = [] - - for i in mod.values(): - if isinstance(i, Route): - if i.method in [x.method for x in current_routes]: - warnings.warn( - "same method used twice during filesystem loading", - LoaderWarning, - ) - current_routes.append(i) - - if not current_routes: - raise InvalidRouteError(f"{path} has no set routes") - - for x in current_routes: - if x.path: - warnings.warn( - f"path was passed for {x} when filesystem loading is enabled" # noqa - ) - else: - path_obj = Path(path) - stripped = list(path_obj.parts[len(target_dir.parts) :]) # noqa - if stripped[-1] == "index.py": - stripped.pop(len(stripped) - 1) - - stripped_obj = Path(*stripped) - stripped_path = str(stripped_obj).rsplit( - ".", - maxsplit=1, - )[0] - x.path = "/" + stripped_path - - for x in current_routes: - routes.append(x) - - finalize(routes, app) - - -def load_simple(app: ViewApp, target_dir: Path) -> None: - """ - Simple loading implementation. - - Simple loading is essentially searching a directory recursively - for files, and then extracting Route instances from each file. - - If a file is prefixed with _, it will not be loaded. - - Args: - app: App to attach routes to. - target_dir: Directory to search for routes. - - """ - Internal.info("loading using simple strategy") - routes: list[Route] = [] - - if not target_dir.exists(): - raise FileNotFoundError(f"{target_dir.absolute()} does not exist") - - sys.path.append(str(target_dir.absolute())) - - for root, _, files in os.walk(target_dir): - for f in files: - if f.startswith("_"): - continue - - path = os.path.join(root, f) - mod = run_path(path) - mini_routes: list[Route] = [] - - for i in mod.values(): - if isinstance(i, Route): - mini_routes.append(i) - - for route in mini_routes: - if not route.path: - raise InvalidRouteError( - "omitting path is only supported on filesystem loading", - ) - - routes.append(route) - - finalize(routes, app) - - -def load_patterns(app: ViewApp, target_path: Path) -> None: - Internal.info("loading using patterns strategy") - mod = run_path(str(target_path)) - patterns = ( - mod.get("PATTERNS") - or mod.get("URL_PATTERNS") - or mod.get("URLPATTERNS") - or mod.get("urlpatterns") - or mod.get("patterns") - or mod.get("url_patterns") - ) - - if not patterns: - raise InvalidRouteError( - f"{target_path} did not define a PATTERNS variable", - hint=docs_hint( - "https://view.zintensity.dev/building-projects/routing/#url-pattern-routing" - ), - ) - - finalize(patterns, app) diff --git a/src/view/_logging.py b/src/view/_logging.py deleted file mode 100644 index e69685fd..00000000 --- a/src/view/_logging.py +++ /dev/null @@ -1,1081 +0,0 @@ -from __future__ import annotations - -import logging -import os -import queue -import random -import sys -import time -import warnings -from abc import ABC -from threading import Event, Thread -from typing import IO, Callable, Iterable, NamedTuple, TextIO - -from rich import box -from rich.align import Align -from rich.console import Console, ConsoleOptions, RenderResult -from rich.file_proxy import FileProxy -from rich.layout import Layout -from rich.live import Live -from rich.logging import RichHandler -from rich.panel import Panel -from rich.progress import BarColumn, Progress, Task, TaskProgressColumn, TextColumn -from rich.progress_bar import ProgressBar -from rich.table import Table -from rich.text import Text - -from _view import setup_route_log - -from ._util import shell_hint -from .exceptions import ViewInternalError -from .typing import LogLevel - - -# See https://github.com/Textualize/rich/issues/433 -def _showwarning( - message: Warning | str, - category: type[Warning], - filename: str, - lineno: int, - file: TextIO | None = None, - line: str | None = None, -) -> None: - msg = warnings.WarningMessage( - message, - category, - filename, - lineno, - file, - line, - ) - - if file is None: - file = sys.stderr - if file is None: - # sys.stderr is None when run with pythonw.exe: - # warnings get lost - return - text = warnings._formatwarnmsg(msg) # type: ignore - if file.isatty(): - Console(file=file, stderr=True).print( - Panel( - text, - title=f"[bold red]{category.__name__}", - subtitle=f"[bold green]\n{filename}, line {lineno}", - highlight=True, - expand=False, - ) - ) - else: - try: - file.write(f"{category.__name__}: {text}") - except OSError: - # the file (probably stderr) is invalid - this warning gets lost. - pass - - -def _warning_no_src_line( - message: Warning | str, - category: type[Warning], - filename: str, - lineno: int, - file: TextIO | None = None, - line: str | None = None, -) -> str: - if (file is None and sys.stderr is not None) or file is sys.stderr: - return str(message) + "\n" - else: - return f"{filename}:{lineno} {category.__name__}: {message}\n" - - -def format_warnings(): - warnings.showwarning = _showwarning - warnings.formatwarning = _warning_no_src_line # type: ignore - - -LCOLORS = { - logging.DEBUG: "blue", - logging.INFO: "green", - logging.WARNING: "dim yellow", - logging.ERROR: "red", - logging.CRITICAL: "dim red", -} - - -class ViewFormatter(logging.Formatter): - def formatMessage(self, record: logging.LogRecord): - return ( - f"[bold white][[/][bold {LCOLORS[record.levelno]}]{record.levelname.lower()}[/][bold white]][/]:" - f" {record.getMessage()}" - ) - - -svc = logging.getLogger("view.service") -internal = logging.getLogger("view.internal") -for lg in (svc, internal): - lg.setLevel("INFO") - handler = RichHandler( - show_level=False, - show_path=False, - show_time=False, - rich_tracebacks=True, - markup=True, - ) - handler.setFormatter(ViewFormatter()) - lg.addHandler(handler) - -internal.setLevel(10000) - - -class RouteInfo(NamedTuple): - status: int | str # str for websocket states - route: str - method: str - closed: bool = False - - -class QueueItem(NamedTuple): - service: bool - is_route: bool - level: LogLevel - message: str - route: RouteInfo | None = None - is_stdout: bool = False - is_stderr: bool = False - - -_LIVE: bool = False -_QUEUE: queue.Queue[QueueItem] = queue.Queue() -_CLOSE = Event() - - -class _FileProxyWrapper(FileProxy): - def __init__( - self, - console: Console, - file: IO[str], - qu: queue.Queue[QueueItem], - ) -> None: - super().__init__(console, file) - self._queue = qu - - -class _StandardOutProxy(_FileProxyWrapper): - """Wrap standard out to fancy logging.""" - - def write(self, text: str) -> int: - Internal.debug(f"stole from stdout: {text}") - self._queue.put(QueueItem(False, False, "info", text, is_stdout=True)) - return super().write(text) - - -class _StandardErrProxy(_FileProxyWrapper): - """Wrap standard error to fancy logging.""" - - def write(self, text: str) -> int: - Internal.debug(f"stole from stderr: {text}") - self._queue.put(QueueItem(False, False, "info", text, is_stderr=True)) - return super().write(text) - - -def _sep(target: tuple[object, ...]): - return " ".join([str(i) for i in target]) - - -def _defer(msg: str, level: LogLevel, is_service: bool) -> None: - _QUEUE.put_nowait( - QueueItem( - service=is_service, - is_route=False, - level=level, - message=msg, - ) - ) - - -LOGS: dict[int, LogLevel] = { - logging.DEBUG: "debug", - logging.INFO: "info", - logging.WARNING: "warning", - logging.ERROR: "error", - logging.CRITICAL: "critical", -} - - -class ServiceIntercept(logging.Filter): - def filter(self, record: logging.LogRecord): - if _LIVE: - Internal.info(f"deferring service logger: {record}") - _defer(record.getMessage(), LOGS[record.levelno], True) - return os.environ.get("VIEW_DEBUG") == "1" - return True - - -class _Logger(ABC): - """Wrapper around the built in logger.""" - - log: logging.Logger - - @staticmethod - def _log( - attr: Callable[..., None], - *msg: object, - highlight: bool = True, - **kwargs, - ): - attr( - _sep(msg), - extra={ - "markup": True, - **({} if highlight else {"highlighter": None}), - }, - **kwargs, - ) - - @classmethod - def debug(cls, *msg: object, **kwargs): - """Write debug message.""" - cls._log(cls.log.debug, *msg, **kwargs) - - @classmethod - def info(cls, *msg: object, **kwargs): - """Write info message.""" - cls._log(cls.log.info, *msg, **kwargs) - - @classmethod - def warning(cls, *msg: object, **kwargs): - """Write warning message.""" - cls._log(cls.log.warning, *msg, **kwargs) - - @classmethod - def error(cls, *msg: object, **kwargs): - """Write error message.""" - cls._log(cls.log.error, *msg, **kwargs) - - @classmethod - def critical(cls, *msg: object, **kwargs): - """Write critical message.""" - cls._log(cls.log.critical, *msg, **kwargs) - - @classmethod - def exception(cls, *msg: object, **kwargs): - """Write exception.""" - cls._log(cls.log.exception, *msg, **kwargs) - - -class Service(_Logger): - """Logger to be seen by the user when the app is running.""" - - log = svc - - -class Internal(_Logger): - """Logger to be seen by view.py developers for debugging purposes.""" - - log = internal - - -svc.addFilter(ServiceIntercept()) - - -def _status_color(status: int | str) -> str: - if isinstance(status, str): - return "bold green" - - if status >= 500: - return "bold red" - if status >= 400: - return "bold purple" - if status >= 300: - return "bold yellow" - if status >= 200: - return "bold dim green" - if status >= 100: - return "bold blue" - - raise ViewInternalError(f"got bad status: {status}") - - -_METHOD_COLORS: dict[str, str] = { - "websocket": "bold dim magenta", - "HEAD": "bold green", - "GET": "bold dim green", - "POST": "bold blue", - "PUT": "bold dim blue", - "PATCH": "bold cyan", - "DELETE": "bold red", - "CONNECT": "bold magenta", - "OPTIONS": "bold yellow", - "TRACE": "bold dim yellow", -} - - -def route(path: str, status: int, method: str): - if _LIVE: - return _QUEUE.put_nowait( - QueueItem( - True, - True, - "info", - "", - route=RouteInfo(status, path, method), - ) - ) - Service.info( - f"[bold {_METHOD_COLORS[method]}]{method.lower()}" - f"[/] [bold white]{path}[/]" - f" [bold {_status_color(status)}]{status}", - highlight=False, - ) - - -VIEW_TEXT = ( - r""" _ - (_) - __ ___ _____ ___ __ _ _ - \ \ / / |/ _ \ \ /\ / / '_ \| | | | - \ V /| | __/\ V V /| |_) | |_| | - \_/ |_|\___| \_/\_(_) .__/ \__, | - | | __/ | - |_| |___/ """, - r""" - _________ _______ _______ -|\ /|\__ __/( ____ \|\ /| ( ____ )|\ /| -| ) ( | ) ( | ( \/| ) ( | | ( )|( \ / ) -| | | | | | | (__ | | _ | | | (____)| \ (_) / -( ( ) ) | | | __) | |( )| | | _____) \ / - \ \_/ / | | | ( | || || | | ( ) ( - \ / ___) (___| (____/\| () () | _ | ) | | - \_/ \_______/(_______/(_______)(_)|/ \_/ - -""", - r""" - _ _ __ ____ _ _ ____ _ _ -/ )( \( )( __)/ )( \ ( _ \( \/ ) -\ \/ / )( ) _) \ /\ / _ ) __/ ) / - \__/ (__)(____)(_/\_)(_)(__) (__/ -""", - r""" - ___ ___ __ _______ __ __ ___ _______ ___ ___ -|" \ /" ||" \ /" "||" |/ \| "| | __ "\|" \/" | - \ \ // / || | (: ______)|' / \: | (. |__) :)\ \ / - \\ \/. ./ |: | \/ | |: /' | |: ____/ \\ \/ - \. // |. | // ___)_ \// /\' | _____ (| / / / - \\ / /\ |\(: "| / / \\ | ))_ ")/|__/ \ / / - \__/ (__\_|_)\_______)|___/ \___|(_____((_______) |___/ - -""", - r""" - - _ - _ _|_|___ _ _ _ ___ _ _ -| | | | -_| | | |_| . | | | - \_/|_|___|_____|_| _|_ | - |_| |___| -""", - r""" - _ - _ __(_)__ _ __ ___ __ __ -| |/ / / -_) |/|/ / / _ \/ // / -|___/_/\__/|__,__(_) .__/\_, / - /_/ /___/ -""", - r""" -____ ____ __ ___________ __ ____ .______ ____ ____ -\ \ / / | | | ____\ \ / \ / / | _ \ \ \ / / - \ \/ / | | | |__ \ \/ \/ / | |_) | \ \/ / - \ / | | | __| \ / | ___/ \_ _/ - \ / | | | |____ \ /\ / __ | | | | - \__/ |__| |_______| \__/ \__/ (__)| _| |__| - -""", - r""" - __ __ __ ______ __ __ ______ __ __ -/\ \ / / /\ \ /\ ___\ /\ \ _ \ \ /\ == \ /\ \_\ \ -\ \ \'/ \ \ \ \ \ __\ \ \ \/ ".\ \ \ \ _-/ \ \____ \ - \ \__| \ \_\ \ \_____\ \ \__/".~\_\ \ \_\ \/\_____\ - \/_/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ - -""", - r""" - __ __ ________ ______ __ __ __ ______ __ __ -/_/\ /_/\ /_______/\/_____/\ /_//_//_/\ /_____/\ /_/\/_/\ -\:\ \\ \ \\__.::._\/\::::_\/_\:\\:\\:\ \ \:::_ \ \\ \ \ \ \ - \:\ \\ \ \ \::\ \ \:\/___/\\:\\:\\:\ \ ___\:(_) \ \\:\_\ \ \ - \:\_/.:\ \ _\::\ \__\::___\/_\:\\:\\:\ \ /__/\\: ___\/ \::::_\/ - \ ..::/ //__\::\__/\\:\____/\\:\\:\\:\ \\::\ \\ \ \ \::\ \ - \___/_( \________\/ \_____\/ \_______\/ \:_\/ \_\/ \__\/ - -""", - r""" - - .-. - ___ ___ ( __) .--. ___ ___ ___ .-.. ___ ___ -( )( ) (''") / \ ( )( )( ) / \ ( )( ) - | | | | | | | .-. ; | | | | | | ' .-, ; | | | | - | | | | | | | | | | | | | | | | | | . | | | | | - | | | | | | | |/ | | | | | | | | | | | | ' | | - | | | | | | | ' _.' | | | | | | | | | | ' `-' | - ' ' ; ' | | | .'.-. | | ; ' | | .-. | | ' | `.__. | - \ `' / | | ' `-' / ' `-' `-' ' ( ) | `-' ' ___ | | - '_.' (___) `.__.' '.__.'.__.' `-' | \__.' ( )' | - | | ; `-' ' - (___) .__.' -""", - r''' - _ _ __ _ _ - __ __ (_) ___ __ __ __ | '_ \ | || | - \ V / | | / -_) \ V V / _ | .__/ \_, | - _\_/_ _|_|_ \___| \_/\_/ _(_)_ |_|__ _|__/ -_|"""""|_|"""""|_|"""""|_|"""""|_|"""""|_|"""""|_| """"| -"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-' -''', - r""" - _ _ __ _____ _ _ __ __ __ __ - /_/\ /\_\ /\_\ /\_____\/_/\ /\_\ /_/\__/\ /\ /\ /\ - ) ) ) ( ( \/_/( (_____/) ) )( ( ( ) ) ) ) )\ \ \/ / / -/_/ / \ \_\ /\_\\ \__\ /_/ //\\ \_\ /_/ /_/ / \ \__/ / -\ \ \_/ / // / // /__/_\ \ / \ / /_ \ \ \_\/ \__/ / - \ \ / /( (_(( (_____\)_) /\ (_(/_/\)_) ) / / / - \_\_/_/ \/_/ \/_____/\_\/ \/_/\_\/\_\/ \/_/ - -""", - r""" - _ - (_) - _ __ __ .---. _ _ __ _ .--. _ __ -[ \ [ ][ |/ /__\\[ \ [ \ [ ][ '/'`\ \[ \ [ ] - \ \/ / | || \__., \ \/\ \/ /_ | \__/ | \ '/ / - \__/ [___]'.__.' \__/\__/(_)| ;.__/[\_: / - [__| \__.' -""", - r""" - ___ ___ ___ _______ ___ __ ________ ___ ___ -|\ \ / /|\ \|\ ___ \ |\ \ |\ \ |\ __ \|\ \ / /| -\ \ \ / / | \ \ \ __/|\ \ \ \ \ \ \ \ \|\ \ \ \/ / / - \ \ \/ / / \ \ \ \ \_|/_\ \ \ __\ \ \ \ \ ____\ \ / / - \ \ / / \ \ \ \ \_|\ \ \ \|\__\_\ \ __\ \ \___|\/ / / - \ \__/ / \ \__\ \_______\ \____________\\__\ \__\ __/ / / - \|__|/ \|__|\|_______|\|____________\|__|\|__||\___/ / - \|___|/ - - -""", - r""" - __ -.--.--.|__|.-----.--.--.--. .-----.--.--. -| | || || -__| | | |__| _ | | | - \___/ |__||_____|________|__| __|___ | - |__| |_____| -""", - r""" - _ - _ _ <_> ___ _ _ _ ___ _ _ -| | || |/ ._>| | | |_ | . \| | | -|__/ |_|\___.|__/_/<_>| _/`_. | - |_| <___' - """, - r""" _ - :_; -.-..-..-. .--. .-..-..-. .---. .-..-. -: `; :: :' '_.': `; `; : _ : .; `: :; : -`.__.':_;`.__.'`.__.__.':_;: ._.'`._. ; - : : .-. : - :_; `._.'""", - r""" - .-. .'( )\.---. .'( /`-. )\ /( - ,' / ) \ ) ( ,-._( ,') \ ) ,' _ \ \ (_.' / -( ) | ( ) ( \ '-, ( /(/ / ( '-' ( ) _.' - ) './ / \ ) ) ,-` ) ( ,_ ) ,._.' / / -( , ( ) \ ( ``-. ( .'\ \ ( \ ( ' ( \ - )/..' )/ )..-.( )/ )/ ).' )/ ).' - -""", - r""" -_ _ ____ ____ _ _ ____ _ -|| |\|___\| __\||| \ | . \||_/\ -||/ /| / | ]_||\ / ,-. | __/| __/ -|__/ |/ |___/|/\/ '-' |/ |/ -""", - r""" - __ __ _____ _____ ___ ___ _____ __ __ - ) ) ( ( (_ _) / ___/ ( ( ) ) ( __ \ ) \ / ( -( ( ) ) | | ( (__ \ \ _ / / ) )_) ) \ \ / / - \ \ / / | | ) __) \ \/ \/ / ( ___/ \ \/ / - \ \/ / | | ( ( ) _ ( ) ) \ / - \ / _| |__ \ \___ \ ( ) / __ ( ( )( - \/ /_____( \____\ \_/ \_/ (__) /__\ /__\ - -""", - r""" - - o - -o o o8 .oPYo. o o o .oPYo. o o -Y. .P 8 8oooo8 Y. .P. .P 8 8 8 8 -`b..d' 8 8. `b.d'b.d' 8 8 8 8 - `YP' 8 `Yooo' `Y' `Y' 88 8YooP' `YooP8 -::...:::..:.....:::..::..::..:8 ....::....8 -::::::::::::::::::::::::::::::8 :::::::ooP'. -::::::::::::::::::::::::::::::..:::::::...:: -""", - r""" - || . -.... ... ... .... ... ... ... ... ... .... ... - '|. | || .|...|| || || | ||' || '|. | - '|.| || || ||| ||| || | '|.| - '| .||. '|...' | | ||...' '| - || .. | - '''' '' -""", - r""" - - __ - __ __ /\_\ __ __ __ __ _____ __ __ -/\ \/\ \\/\ \ /'__`\/\ \/\ \/\ \ /\ '__`\/\ \/\ \ -\ \ \_/ |\ \ \/\ __/\ \ \_/ \_/ \ __\ \ \L\ \ \ \_\ \ - \ \___/ \ \_\ \____\\ \___x___/'/\_\\ \ ,__/\/`____ \ - \/__/ \/_/\/____/ \/__//__/ \/_/ \ \ \/ `/___/> \ - \ \_\ /\___/ - \/_/ \/__/ -""", - r""" - oo - -dP .dP dP .d8888b. dP dP dP 88d888b. dP dP -88 d8' 88 88ooood8 88 88 88 88' `88 88 88 -88 .88' 88 88. ... 88.88b.88' dP 88. .88 88. .88 -8888P' dP `88888P' 8888P Y8P 88 88Y888P' `8888P88 - 88 .88 - dP d8888P -""", - r""" - - _ - _ _ (_) __ _ _ _ _ _ _ _ -( ) ( )| | /'__`\( ) ( ) ( ) ( '_`\ ( ) ( ) -| \_/ || |( ___/| \_/ \_/ | _ | (_) )| (_) | -`\___/'(_)`\____)`\___x___/'(_)| ,__/'`\__, | - | | ( )_| | - (_) `\___/' -""", - r""" - - __ _ ____ ______ __ __ __ _____ __ _ - \ \ //| || ___|| \/ \| | | |\ \ // - \ \// | || ___|| /\ | _ | _| \ \// - \__/ |____||______||____/ \__||_||___| /__/ - - -""", - r""" - _ - (_) - _ _ _ _____ _ _ _ ____ _ _ -| | | | | ___ | | | || _ \| | | | - \ V /| | ____| | | || |_| | |_| | - \_/ |_|_____)\___(_) __/ \__ | - |_| (____/ -""", - r""" -___ _________________ __ ___ ______________ -7 V 77 77 77 V V 7 7 77 7 7 -| | || || ___!| | | | | - || ! | -| ! || || __|_| ! ! | | ___!!_ _! -| || || 7| |____| 7 7 7 -!_____!!__!!_____!!________!7__7!__! !___! - -""", - r""" - _ _ _ ___ _ _ _____ __ -| \ / || | __|| | | | | _,\ `v' / -`\ V /'| | _| | 'V' |_| v_/`. .' - \_/ |_|___|!_/ \_!\/_| !_! -""", - r""" - ___ __ -\ / | |__ | | |__) \ / - \/ | |___ |/\| .| | - -""", -) - - -COLOR = ( - "red", - "blue", - "pink", - "cyan", - "magenta", - "yellow", - "dim yellow", - "dim red", - "green", - "dim blue", - "dim green", -) - -_LOG_COLORS: dict[LogLevel, str] = { - "debug": "blue", - "info": "green", - "warning": "dim yellow", - "error": "red", - "critical": "dim red", -} - -LMAPPINGS = { - logging.DEBUG: Service.debug, - logging.INFO: Service.info, - logging.WARNING: Service.warning, - logging.ERROR: Service.error, - logging.CRITICAL: Service.critical, -} - - -class Hijack(logging.Filter): - def filter(self, record: logging.LogRecord): - LMAPPINGS[record.levelno](record.getMessage()) - return False - - -class LogPanel(Panel): - """Panel with limit on number of lines relative to the terminal size.""" - - def __init__(self, **kwargs): - self._lines = [""] - self._line_index = 0 - super().__init__("", **kwargs) - - def _inc(self): - self._lines.append("") - self._line_index += 1 - - def write(self, text: str) -> None: - """Write text to the panel.""" - for i in text: - if i == "\n": - self._inc() - else: - self._lines[self._line_index] += i - - def __rich_console__( - self, - console: Console, - options: ConsoleOptions, - ) -> RenderResult: - height = options.height - assert height is not None - - width = options.max_width - 2 # 2 panel characters - - while height < (len(self._lines)): - self._lines.pop(0) - self._line_index -= 1 - - final_lines = [] - - for i in self._lines: - if len(i) < (width - 3): # - 3 because the ellipsis - final_lines.append(i) - else: - final_lines.append(f"{i[:width - 3]}...") - - self.renderable = "\n".join(final_lines) - - return super().__rich_console__(console, options) - - -class LogTable(Table): - """Table with limit on number of columns relative to the terminal height.""" - - def __rich_console__( - self, console: "Console", options: "ConsoleOptions" - ) -> "RenderResult": - height = options.max_height - while len(self.rows) > (height - 4): - # - 4 because the header and footer lines - self.rows.pop(0) - for i in self.columns: - i._cells.pop(0) - - return super().__rich_console__(console, options) - - -class Dataset: - """ - Dataset in a graph. - """ - - def __init__(self, name: str, point_limit: int | None = None) -> None: - """ - Args: - name: Name of the dataset. - point_limit: Amount of points allowed in the dataset at a time. - """ - self.name = name - self.points: dict[float, float] = {} - self.point_limit = point_limit - self.point_order: list[float] = [] - - def add_point(self, x: float, y: float) -> None: - """Add a point to the dataset. - - Args: - x: X value. - y: Y value. - """ - if self.point_limit and (len(self.point_order) >= self.point_limit): - to_del = self.point_order.pop(0) - del self.points[to_del] - - self.point_order.append(x) - self.points[x] = y - - def add_points(self, *args: tuple[float, float]) -> None: - """Add multiple points to the dataset.""" - for i in args: - self.add_point(*i) - - -def _heat_color(amount: float) -> str: - """Generate a color for a percentage.""" - if amount < 20: - return "dim blue" - if amount < 40: - return "cyan" - if amount < 60: - return "dim green" - if amount < 80: - return "yellow" - if amount < 100: - return "red" - - if amount == 100: - return "dim red" - - raise ViewInternalError("invalid percentage") - - -class HeatedProgress(Progress): - """ - Progress that changes color based on how close the bar is to completion. - """ - - def make_tasks_table(self, tasks: Iterable[Task]) -> Table: - result = super().make_tasks_table(tasks) - - for col in result.columns: - for cell in col._cells: - if isinstance(cell, ProgressBar): - cell.complete_style = _heat_color(cell.completed) - elif isinstance(cell, Text): - text = str(cell) - - if "%" not in text: - continue - - cell.stylize(_heat_color(float(text[:-1]))) - return result - - -def convert_kb(value: float): - return value / 1024 - - -def _server_logger(): - """Fancy logger implementation.""" - global _LIVE - _LIVE = True - table = LogTable(box=box.ROUNDED, expand=True) - - for i in ("Method", "Route", "Status"): - table.add_column(i) - - feed = LogPanel(title="Feed") - errors = LogPanel(title="Exceptions") - stdout = LogPanel(title="Standard Output") - layout = Layout() - layout.split_row( - Layout(name="left"), - Layout(name="right"), - ) - layout["left"].split_column( - Align.center( - Text( - random.choice(VIEW_TEXT), - style=f"bold {random.choice(COLOR)}", - ), - vertical="middle", - ), - errors, - stdout, - ) - layout["right"].split_column( - feed, - Layout(name="corner"), - ) - system = HeatedProgress( - TextColumn("[progress.description]{task.description}"), - BarColumn(finished_style="dim red"), - TaskProgressColumn(), - ) - cpu = system.add_task("CPU") - mem = system.add_task("Memory (Virtual)") - smem = system.add_task("Memory (Swap)") - disk = system.add_task("Disk Usage") - - try: - import plotext as plt - except ModuleNotFoundError: - plt = None - - class Plot: - """Plot renderable for rich.""" - - def __init__(self, name: str, x: str, y: str) -> None: - """Args: - name: Title of the graph. - x: X label of the graph. - y: Y label of the graph.""" - if plt: - plt.xscale("linear") - plt.yscale("linear") - - self.title = name - self.x_label = x - self.y_label = y - self.datasets: dict[str, Dataset] = {} - - def dataset(self, name: str, *, point_limit: int | None = None) -> Dataset: - """Generate or create a new dataset. - - Args: - name: Name of the dataset. - point_limit: Limit on the number of points to be allowed on the graph at a time. If not set, terminal size divided by 3 is used. - """ - found = self.datasets.get(name) - if found: - return found - - size = os.get_terminal_size().lines // 3 - - ds = Dataset(name, point_limit=point_limit or size) - self.datasets[name] = ds - return ds - - def _render(self, width: int, height: int) -> None: - if not plt: - return - - plt.clf() - plt.plotsize(width, height) - - for ds in self.datasets.values(): - if ds.points: - plt.plot( - [x for x in ds.points.keys()], - [y for y in ds.points.values()], - label=ds.name, - ) - - plt.title(self.title) - plt.xlabel(self.x_label) - plt.ylabel(self.y_label) - plt.theme("pro") - - def __rich_console__( - self, - console: Console, - options: ConsoleOptions, - ) -> RenderResult: - if not plt: - return Panel( - shell_hint("pip install plotext", "pip install view.py[fancy]"), - title="This widget needs an external library!", - ) - self._render(options.max_width, options.max_height) - yield Text.from_ansi(plt.build()) - - layout["corner"].split_row( - Layout(name="left_corner"), - Layout(name="very_corner"), - ) - network = Plot("Network", "Seconds", "Usage (KbPS)") - - try: - import psutil - except ModuleNotFoundError: - psutil = None - - if psutil: - layout["very_corner"].split_column(Panel(system, title="System"), network) - else: - layout["very_corner"].split_column( - Panel( - shell_hint("pip install plotext", "pip install view.py[fancy]"), - title="This widget needs an external library!", - ), - network, - ) - - io = Plot("IO", "Seconds", "Usage (Per Second)") - layout["left_corner"].split_column(table, io) - - console = Console() - - preserved = sys.stdout - preserved_2 = sys.stderr - sys.stdout = _StandardOutProxy(console, sys.stdout, _QUEUE) - sys.stderr = _StandardErrProxy(console, sys.stderr, _QUEUE) - - def inner(): - if not psutil: - return - - while not _CLOSE.wait(0.3): - system.update(cpu, completed=psutil.cpu_percent()) - system.update(mem, completed=psutil.virtual_memory().percent) - system.update(smem, completed=psutil.swap_memory().percent) - system.update(disk, completed=psutil.disk_usage("/").percent) - - network.dataset("Upload").add_point(0, 0) - network.dataset("Download").add_point(0, 0) - - def net(): - if not psutil: - return - - base = time.time() - net_io = psutil.net_io_counters() - - while not _CLOSE.wait(0.5): - net_io2 = psutil.net_io_counters() - ua = net_io2.bytes_sent - net_io.bytes_sent - da = net_io2.bytes_recv - net_io.bytes_recv - us = convert_kb(ua) - ds = convert_kb(da) - - network.dataset("Upload").add_point(time.time() - base, us) - network.dataset("Download").add_point(time.time() - base, ds) - - net_io = net_io2 - - def io_count(): - if not psutil: - return - - base = time.time() - p = psutil.Process() - pio_base = p.io_counters() - - while not _CLOSE.wait(1): - p = psutil.Process() - pio = p.io_counters() - io.dataset("Read").add_point( - time.time() - base, - pio.read_count - pio_base.read_count, - ) - io.dataset("Write").add_point( - time.time() - base, - pio.write_count - pio_base.write_count, - ) - - pio_base = pio - - for thread in (inner, net, io_count): - Thread(target=thread, daemon=True).start() - - with Live( - Align.center(layout), - screen=True, - transient=True, - redirect_stdout=False, - redirect_stderr=False, - console=console, - ) as live: - while True: - if _CLOSE.is_set(): - sys.stdout = preserved - sys.stderr = preserved_2 - return - - result = _QUEUE.get() - - if result.is_stdout: - stdout.write(result.message) - continue - - if result.is_stderr: - errors.write(result.message) - continue - - if not result.is_route: - if result.service: - feed.write( - f"[bold {_LOG_COLORS[result.level]}]" - f"{result.level}[/]: {result.message}\n" - ) - else: - info = result.route - assert info, "result has no route" - - if info.method == "websocket": - table.add_row( - f"[bold {_METHOD_COLORS['websocket']}]websocket[/]", - info.route, - f"[bold green]opened[/]", - ) - elif info.method == "websocket_closed": - table.add_row( - f"[bold {_METHOD_COLORS['websocket']}]websocket[/]", - info.route, - f"[bold red]closed[/]", - ) - else: - table.add_row( - f"[bold {_METHOD_COLORS[info.method]}]{info.method}[/]", - info.route, - f"[bold {_status_color(info.status)}]{info.status}[/]", - ) - - live.update(Align.center(layout)) - - -def _write_route(status: int | str, route: str, method_raw: str) -> None: - method = method_raw or "websocket" - info = RouteInfo(status, route, method) - - if _LIVE: - _QUEUE.put_nowait(QueueItem(True, True, "info", "", info)) - else: - if method == "websocket_closed": - Service.info( - f"[{_METHOD_COLORS['websocket']}]websocket[/] [white]{route}[/] [bold red]closed[/]", - highlight=False, - ) - elif method == "websocket": - Service.info( - f"[{_METHOD_COLORS['websocket']}]websocket[/] [white]{route}[/] [bold green]open[/]", - highlight=False, - ) - else: - Service.info( - f"[{_METHOD_COLORS[method]}]{method.lower()}[/] [white]{route}[/] [{_status_color(status)}]{status}[/]", - highlight=False, - ) - - -setup_route_log(_write_route, Service.warning) - - -def enter_server(): - """Start fancy mode.""" - if _CLOSE.is_set(): - _CLOSE.clear() - - Thread(target=_server_logger, daemon=True).start() - - -def exit_server(): - """End fancy mode.""" - _CLOSE.set() diff --git a/src/view/_parsers.py b/src/view/_parsers.py deleted file mode 100644 index 25d6deec..00000000 --- a/src/view/_parsers.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING -from urllib.parse import parse_qs - -import ujson - -from .typing import ViewBody - -if TYPE_CHECKING: - from .app import App - - -def query_parser(data: str) -> ViewBody: - parsed: dict[str, list[str]] = parse_qs(data) - - final: ViewBody = {} - for k, v in parsed.items(): - if len(v) == 1: - final[k] = v[0] - else: - final[k] = v - - return final - - -def supply_parsers(app: App) -> None: - app._supply_parsers(query_parser, ujson.loads) diff --git a/src/view/_util.py b/src/view/_util.py deleted file mode 100644 index ce621cd9..00000000 --- a/src/view/_util.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -import getpass -import inspect -import os -import pathlib -import runpy -import socket -import sys -import warnings -import weakref -from collections.abc import Iterable -from pathlib import Path -from types import CodeType as Code -from types import FrameType as Frame -from types import FunctionType as Function -from typing import Any, NoReturn, Union - -from rich.markup import escape -from rich.panel import Panel -from rich.syntax import Syntax -from typing_extensions import Annotated, TypeGuard - -from .exceptions import NeedsDependencyError, NotLoadedWarning - -try: - from types import UnionType # type: ignore -except ImportError: - UnionType = None - -TypingUnionType = type(Union[str, int]) - -__all__ = ( - "is_union", - "LoadChecker", - "set_load", - "shell_hint", - "make_hint", - "is_annotated", - "run_path", - "needs_dep", -) - - -def is_union(tp: type[Any]) -> bool: - return tp in {UnionType, TypingUnionType} - - -AnnotatedType: type[Annotated] = type(Annotated[str, ""]) # type: ignore - - -def is_annotated(hint: Any) -> TypeGuard[Any]: - return (type(hint) is AnnotatedType) and hasattr(hint, "__metadata__") - - -class LoadChecker: - _view_loaded: bool - - def _view_load_check(self) -> None: - if (not self._view_loaded) and (not os.environ.get("_VIEW_CANCEL_FINALIZERS")): - warnings.warn(f"{self} was never loaded", NotLoadedWarning) - - def __post_init__(self) -> None: - self._view_loaded = False - weakref.finalize(self, self._view_load_check) - - -def set_load(cl: LoadChecker): - """Let the developer feel that they aren't touching private members.""" - cl._view_loaded = True - - -def shell_hint(*commands: str) -> Panel: - if os.name == "nt": - shell_prefix = f"{os.getcwd()}>" - else: - shell_prefix = f"{getpass.getuser()}@{socket.gethostname()}[bold green]$[/]" - - formatted = [f"{shell_prefix} {escape(command)}" for command in commands] - return Panel.fit( - "\n[gray46]// OR[/]\n".join(formatted), - title="[bold green]Terminal[/]", - ) - - -def docs_hint(url: str) -> str: - return f"[bold green]for more information, see [/][bold blue]{url}[/]" - - -def make_hint( - comment: str | None = None, - caller: Function | None | Iterable[Code] | str = None, - *, - line: int | None = None, - prepend: str = "", - back_lines: int = 1, -) -> Syntax | str: - if not isinstance(caller, str): - frame: Frame | None = inspect.currentframe() - - assert frame, "failed to get frame" - - back: Frame | None = frame.f_back - assert back, "failed to get f_back" - - code_list: list[Code] = [] - - if caller: - if isinstance(caller, Iterable): - code_list.extend(caller) # type: ignore - else: - code_list.append(caller.__code__) - else: - code_list.append(back.f_code) - - while back.f_back: - back = back.f_back - - if back.f_code in code_list: - break - - txt = pathlib.Path(back.f_code.co_filename).read_text( - encoding="utf-8", - ) - line = line or (back.f_lineno - back_lines) - else: - caller_path = pathlib.Path(caller) - - if not caller_path.exists(): - return "" - - txt = caller_path.read_text(encoding="utf-8") - line = line or 0 - - split = txt.split("\n") - - assert line is not None - if comment: - split[line] += f"{prepend} # {comment}" - - return Syntax( - "\n".join(split), - "python", - line_numbers=True, - line_range=(line - 10, line + 20), - highlight_lines={(line + 1) if not line < 0 else len(txt) - line}, - ) - - -def run_path(path: str | Path) -> dict[str, Any]: - from ._logging import Internal - - sys.path.append(str(Path(path).parent.absolute())) - path = str(Path(path).absolute()) - Internal.info(f"running: {path}") - mod = runpy.run_path(path, run_name="__view__") - sys.path.pop() - return mod - - -def needs_dep( - name: str, - err: ModuleNotFoundError | ImportError | None = None, - section: str | None = None, -) -> NoReturn: - if section: - hint = shell_hint( - f"pip install {name}", - f"pip install view.py[{section}]", - ) - else: - hint = shell_hint(f"pip install {name}") - - raise NeedsDependencyError( - f"view.py needs the module {name}, but you don't have it installed!", - hint=hint, - ) from err diff --git a/src/view/app.py b/src/view/app.py deleted file mode 100644 index 8a1a6bc2..00000000 --- a/src/view/app.py +++ /dev/null @@ -1,1534 +0,0 @@ -""" -view.py app implementation - -This module contains the `App` class, `new_app`, and `get_app`. - -Note that the actual ASGI functionality is stored under the `ViewApp` -extension type, which `App` inherits from. -""" - -from __future__ import annotations - -import asyncio -import ctypes -import faulthandler -import inspect -import logging -import os -import sys -import warnings -import weakref -from collections.abc import Iterable as CollectionsIterable -from contextlib import asynccontextmanager, suppress -from dataclasses import dataclass -from functools import lru_cache, partial -from io import UnsupportedOperation -from pathlib import Path -from queue import Queue -from threading import Thread -from types import FrameType as Frame -from types import TracebackType as Traceback -from typing import ( - Any, - AsyncIterator, - Callable, - Coroutine, - Generic, - Iterable, - TextIO, - TypeVar, - get_type_hints, - overload, -) -from urllib.parse import urlencode - -import ujson -from rich import print -from rich.traceback import install -from typing_extensions import ParamSpec, TypeAlias - -from _view import InvalidStatusError, ViewApp - -from .__main__ import welcome -from ._docs import markdown_docs -from ._loader import finalize, load_fs, load_patterns, load_simple -from ._logging import ( - LOGS, - Hijack, - Internal, - Service, - enter_server, - exit_server, - format_warnings, -) -from ._parsers import supply_parsers -from ._util import needs_dep -from .config import Config, load_config -from .exceptions import ( - BadEnvironmentError, - InvalidCustomLoaderError, - ViewError, - ViewInternalError, -) -from .response import HTML -from .routing import Path as _RouteDeco -from .routing import ( - Route, - RouteInput, - RouteOrCallable, - RouteOrWebsocket, - V, - _NoDefault, - _NoDefaultType, -) -from .routing import body as body_impl -from .routing import context as context_impl -from .routing import delete, get, options, patch, post, put -from .routing import query as query_impl -from .routing import route as route_impl -from .routing import websocket -from .templates import _CurrentFrame, _CurrentFrameType, markdown, template -from .typing import Callback, DocsType, ErrorStatusCode, StrMethod, TemplateEngine -from .util import enable_debug -from .ws import WebSocket - -ReactPyComponent: TypeAlias = Any - -get_type_hints = lru_cache(get_type_hints) # type: ignore - -__all__ = "App", "new_app", "get_app", "HTTPError", "ERROR_CODES" - -S = TypeVar("S", int, str, dict, bool) -A = TypeVar("A") -T = TypeVar("T") -P = ParamSpec("P") - -_ROUTES_WARN_MSG = "routes argument should only be passed when load strategy is manual" -_ConfigSpecified = None - -B = TypeVar("B", bound=BaseException) -CustomLoader: TypeAlias = Callable[["App", Path], Iterable[Route]] - -ERROR_CODES: tuple[ErrorStatusCode, ...] = ( - 400, - 401, - 402, - 403, - 404, - 405, - 406, - 407, - 408, - 409, - 410, - 411, - 412, - 413, - 414, - 415, - 416, - 417, - 418, - 421, - 422, - 423, - 424, - 425, - 426, - 428, - 429, - 431, - 451, - 500, - 501, - 502, - 503, - 504, - 505, - 506, - 507, - 508, - 510, - 511, -) - - -class TestingResponse: - def __init__( - self, - message: str | None, - headers: dict[str, str], - status: int, - content: bytes, - ) -> None: - self._message = message - self.headers = headers - self.status = status - self.content = content - - @property - def message(self) -> str: - if self._message is None: - raise RuntimeError("cannot decode content into string") - - return self._message - - -def _format_qs(query: dict[str, Any]) -> dict[str, Any]: - query_str = {} - - for k, v in query.items(): - if isinstance(v, (dict, list)): - if isinstance(v, dict): - query_str[k] = ujson.dumps(_format_qs(v)) - else: - query_str[k] = ujson.dumps(v) - else: - query_str[k] = v - - return query_str - - -async def _to_thread(func: Callable[[], T]) -> T: - if hasattr(asyncio, "to_thread"): - return await asyncio.to_thread(func) - else: - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, func) - - -class VirtualWebSocket: - def __init__(self) -> None: - self.recv_queue = Queue() - self.send_queue = Queue() - self.recv_queue.put_nowait({"type": "websocket.connect"}) - - async def close(self): - self.recv_queue.put_nowait({"type": "websocket.disconnect"}) - - async def _server_receive(self): - return await _to_thread(self.recv_queue.get) - - async def _server_send(self, data: dict): - self.send_queue.put_nowait(data) - - async def send(self, message: str) -> None: - self.recv_queue.put_nowait({"type": "websocket.receive", "text": message}) - - async def receive(self) -> str: - data = await _to_thread(self.send_queue.get) - msg = data.get("text") or data.get("bytes") - - if not msg: - reason = data.get("reason") - if reason: - raise RuntimeError(reason) - raise ViewInternalError(f"{data!r} has no text or bytes key") - - return msg - - async def handshake(self) -> None: - assert (await _to_thread(self.send_queue.get))["type"] == "websocket.accept" - - -class TestingContext: - def __init__( - self, - app: Callable[[Any, Any, Any], Any], - ) -> None: - self.app = app - self._lifespan: asyncio.Queue[str] = asyncio.Queue() - self._lifespan.put_nowait("lifespan.startup") - - async def start(self) -> None: - async def receive(): - return await self._lifespan.get() - - async def send(_: dict[str, Any]): - pass - - await self.app({"type": "lifespan"}, receive, send) - - async def stop(self) -> None: - await self._lifespan.put("lifespan.shutdown") - - def _gen_headers(self, headers: dict[str, str]) -> list[tuple[bytes, bytes]]: - return [ - (key.encode(), value.encode()) for key, value in (headers or {}).items() - ] - - def _truncate(self, route: str) -> str: - return route[: route.find("?")] if "?" in route else route - - async def _request( - self, - method: str, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - body_q: asyncio.Queue[bytes] = asyncio.Queue() - start: asyncio.Queue[tuple[dict[str, str], int]] = asyncio.Queue() - - async def receive(): - return { - "body": ujson.dumps(body).encode(), - "more_body": False, - "type": "http.request", - } - - async def send(obj: dict[str, Any]): - if obj["type"] == "http.response.start": - await start.put( - ( - {k.decode(): v.decode() for k, v in obj["headers"]}, - obj["status"], - ) - ) - elif obj["type"] == "http.response.body": - assert isinstance(obj["body"], bytes) - await body_q.put(obj["body"]) - else: - raise ViewInternalError(f"bad type: {obj['type']}") - - truncated_route = self._truncate(route) - query_str = _format_qs(query or {}) - headers_list = self._gen_headers(headers or {}) - - await self.app( - { - "type": "http", - "path": truncated_route, - "query_string": ( - urlencode(query_str).encode() if query else b"" - ), # noqa - "headers": headers_list, - "method": method, - "http_version": "view_test", - "scheme": "http", - "client": None, - "server": None, - }, - receive, - send, - ) - - res_headers, status = await start.get() - body_b = await body_q.get() - - try: - body_s: str | None = body_b.decode() - except UnicodeError: - body_s = None - - return TestingResponse(body_s, res_headers, status, body_b) - - async def get( - self, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - return await self._request( - "GET", - route, - body=body, - query=query, - headers=headers, - ) - - async def post( - self, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - return await self._request( - "POST", - route, - body=body, - query=query, - headers=headers, - ) - - async def put( - self, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - return await self._request( - "PUT", - route, - body=body, - query=query, - headers=headers, - ) - - async def patch( - self, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - return await self._request( - "PATCH", - route, - body=body, - query=query, - headers=headers, - ) - - async def delete( - self, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - return await self._request( - "DELETE", - route, - body=body, - query=query, - headers=headers, - ) - - async def options( - self, - route: str, - *, - body: dict[str, Any] | None = None, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> TestingResponse: - return await self._request( - "OPTIONS", - route, - body=body, - query=query, - headers=headers, - ) - - @asynccontextmanager - async def websocket( - self, - route: str, - *, - query: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> AsyncIterator[VirtualWebSocket]: - query_str = _format_qs(query or {}) - headers_list = self._gen_headers(headers or {}) - truncated_route = self._truncate(route) - - socket = VirtualWebSocket() - - def _wrapper(): - loop = asyncio.new_event_loop() - loop.run_until_complete( - self.app( - { - "type": "websocket", - "path": truncated_route, - "query_string": ( - urlencode(query_str).encode() if query else b"" - ), # noqa - "headers": headers_list, - }, - socket._server_receive, - socket._server_send, - ) - ) - - Thread(target=_wrapper).start() - - await socket.handshake() - try: - yield socket - finally: - await socket.close() - - -@dataclass -class InputDoc(Generic[T]): - desc: str - type: tuple[type[T], ...] - default: T | _NoDefaultType - - -@dataclass -class RouteDoc: - desc: str - body: dict[str, InputDoc] - query: dict[str, InputDoc] - - -_LEVELS = dict((v, k) for k, v in LOGS.items()) - - -class HTTPError(BaseException): - """ - Base class to act as a transport for raising HTTP errors. - """ - - def __init__( - self, status: ErrorStatusCode = 400, message: str | None = None - ) -> None: - """ - Args: - status: The status code for the resulting response. - message: The (optional) message to send back to the client. If none, uses the default error message (e.g. `Bad Request` for status `400`). - """ - if status not in ERROR_CODES: - raise InvalidStatusError("status code can only be a client or server error") - - self.status = status - self.message = message - - -WS_CODES = (1000,) - - -class WSError(BaseException): - """ - Base class to act as a transport for raising WebSocket errors. - """ - - def __init__(self, status: int = 1000, message: str | None = None) -> None: - """ - Args: - status: The status code for the resulting response. - message: The (optional) message to send back to the client. If none, uses the default error message. - """ - if status not in WS_CODES: - raise InvalidStatusError(f"invalid websocket close code: {status}") - - self.status = status - self.message = message - - -_DefinedByConfig = None - - -class App(ViewApp): - """ - Main view.py app object. - - You likely don't want to instantiate this class yourself, and should call `new_app()` instead. - The constructor of this class should be considered unstable - although, it will probably not change all that much. - """ - - def __init__(self, config: Config) -> None: - supply_parsers(self) - self.config = config - """Configuration object.""" - - self._set_dev_state(config.dev) - self._manual_routes: list[Route] = [] - self.loaded: bool = False - """Whether load() has been called at least once.""" - self.running = False - """Whether the app is running.""" - self._docs: DocsType = {} - self.loaded_routes: list[Route] = [] - """Routes loaded into the app.""" - self.templaters: dict[str, Any] = {} - """Dictionary containing template engine instances.""" - self._reactive_sessions: dict[str, ReactPyComponent] = {} - self._user_loader: CustomLoader | None = None - self._run_called = False - - os.environ.update({k: str(v) for k, v in config.env.items()}) - - Service.log.setLevel( - config.log.level - if not isinstance(config.log.level, str) - else config.log.level.upper() - ) - - if config.dev: - if os.environ.get("VIEW_PROD") is not None: - Service.warning("VIEW_PROD is set but dev is set to true") - - format_warnings() - weakref.finalize(self, self._finalize) - - if config.log.pretty_tracebacks and (not config.log.fancy): - install(show_locals=True) - - rich_handler = sys.excepthook - - def _hook(tp: type[B], value: B, traceback: Traceback) -> None: - rich_handler(tp, value, traceback) - os.environ["_VIEW_CANCEL_FINALIZERS"] = "1" - - if isinstance(value, ViewError): - if value.hint: - print(value.hint) - - if isinstance(value, ViewInternalError): - print("[bold dim red]This is an internal error, not your fault![/]") - print( - "[bold dim red]Please report this at https://github.com/ZeroIntensity/view.py/issues[/]" - ) - - sys.excepthook = _hook # type: ignore - with suppress(UnsupportedOperation): - faulthandler.enable() - else: - os.environ["VIEW_PROD"] = "1" - - if config.log.level == "debug": - enable_debug() - - self.running = False - - def _finalize(self) -> None: - if os.environ.get("_VIEW_CANCEL_FINALIZERS"): - return - - if self.loaded: - return - - if not self._run_called: - warnings.warn("load() was never called (did you forget to start the app?)") - split = self.config.app.app_path.split(":", maxsplit=1) - - if len(split) != 2: - return - else: - warnings.warn( - "run() was called, but the app never started. pass force=True to run() to fix this" - ) - - def _push_route(self, route: Route) -> None: - if route in self._manual_routes: - return - - self._manual_routes.append(route) - - def route( - self, - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - methods: Iterable[StrMethod] | None = None, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add a route that can be called with any method (or only specific methods). - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - methods: Methods that can be used to access this route. If this is `None`, then all methods are allowed. - - Example: - ```py - from view import route - - @route("/", methods=("GET", "POST")) - async def index(): - return "Hello, view.py!" - ``` - """ - - def inner(r: RouteOrCallable[P]) -> Route[P]: - new_r = route_impl( - path_or_route, - doc, - cache_rate=cache_rate, - methods=methods, - steps=steps, - parallel_build=parallel_build, - )(r) - self._push_route(new_r) - return new_r - - return inner - - def custom_loader(self, loader: CustomLoader): - self._user_loader = loader - - def _method_wrapper( - self, - path: str, - doc: str | None, - cache_rate: int, - target: Callable[..., Any], - steps: Iterable[str] | None, - parallel_build: bool | None, - # i dont really feel like typing this properly - ) -> _RouteDeco[P]: - def inner(route: RouteOrCallable[P]) -> Route[P]: - new_route = target( - path, - doc, - cache_rate=cache_rate, - steps=steps, - parallel_build=parallel_build, - )(route) - self._push_route(new_route) - return new_route - - return inner - - def websocket( - self, - path: str, - doc: str | None = None, - ) -> Callable[[RouteOrWebsocket[P]], Route[P]]: - def inner(route: RouteOrWebsocket[P]) -> Route[P]: - new_route = websocket(path, doc)(route) - self._push_route(new_route) - return new_route - - return inner - - def get( - self, - path: str, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add a GET route. - - Args: - path: The path to this route. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - - Example: - ```py - from view import new_app - - app = new_app() - - @app.get("/") - async def index(): - return "Hello, view.py!" - - app.run() - ``` - """ - return self._method_wrapper(path, doc, cache_rate, get, steps, parallel_build) - - def post( - self, - path: str, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add a POST route. - - Args: - path: The path to this route. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - - Example: - ```py - from view import new_app - - app = new_app() - - @app.post("/") - async def index(): - return "Hello, view.py!" - - app.run() - ``` - """ - return self._method_wrapper( - path, - doc, - cache_rate, - post, - steps, - parallel_build, - ) - - def delete( - self, - path: str, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add a DELETE route. - - Args: - path: The path to this route. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - - Example: - ```py - from view import new_app - - app = new_app() - - @app.delete("/") - async def index(): - return "Hello, view.py!" - - app.run() - ``` - """ - return self._method_wrapper( - path, - doc, - cache_rate, - delete, - steps, - parallel_build, - ) - - def patch( - self, - path: str, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add a PATCH route. - - Args: - path: The path to this route. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - - Example: - ```py - from view import new_app - - app = new_app() - - @app.patch("/") - async def index(): - return "Hello, view.py!" - - app.run() - ``` - """ - return self._method_wrapper( - path, - doc, - cache_rate, - patch, - steps, - parallel_build, - ) - - def put( - self, - path: str, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add a PUT route. - - Args: - path: The path to this route. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - - Example: - ```py - from view import new_app - - app = new_app() - - @app.put("/") - async def index(): - return "Hello, view.py!" - - app.run() - ``` - """ - return self._method_wrapper(path, doc, cache_rate, put, steps, parallel_build) - - def options( - self, - path: str, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> _RouteDeco[P]: - """ - Add an OPTIONS route. - - Args: - path: The path to this route. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - - Example: - ```py - from view import new_app - - app = new_app() - - @app.options("/") - async def index(): - return "Hello, view.py!" - - app.run() - ``` - """ - return self._method_wrapper( - path, doc, cache_rate, options, steps, parallel_build - ) - - def query( - self, - name: str, - *tps: type[V], - doc: str | None = None, - default: V | None | _NoDefaultType = _NoDefault, - ) -> Callable[[RouteOrCallable[P]], Route[P]]: - """ - Set a query parameter. - - Args: - name: Name of the parameter. - tps: Types that can be passed to the server. If empty, any is used. - doc: Description of this query parameter. - default: Default value to be used if not supplied. - """ - - def inner(func: RouteOrCallable[P]) -> Route[P]: - route: Route[P] = query_impl(name, *tps, doc=doc, default=default)(func) - self._push_route(route) - return route - - return inner - - def body( - self, - name: str, - *tps: type[V], - doc: str | None = None, - default: V | None | _NoDefaultType = _NoDefault, - ) -> Callable[[RouteOrCallable[P]], Route[P]]: - """ - Set a body parameter. - - Args: - name: Name of the parameter. - tps: Types that can be passed to the server. If empty, any is used. - doc: Description of this body parameter. - default: Default value to be used if not supplied. - """ - - def inner(func: RouteOrCallable[P]) -> Route[P]: - route: Route[P] = body_impl(name, *tps, doc=doc, default=default)(func) - self._push_route(route) - return route - - return inner - - async def template( - self, - name: str | Path, - directory: str | Path | None = _ConfigSpecified, - engine: TemplateEngine | None = _ConfigSpecified, - frame: Frame | None | _CurrentFrameType = _CurrentFrame, - **parameters: Any, - ) -> HTML: - """ - Render a template with the specified engine. - - This is the direct variation of `template()` to prevent an import. - """ - f: Frame | None - if frame is _CurrentFrame: - f = inspect.currentframe() - assert f - f = f.f_back - assert f - else: - f = frame # type: ignore - - return await template(name, directory, engine, f, app=self, **parameters) - - async def markdown( - self, - name: str | Path, - *, - directory: str | Path | None = _ConfigSpecified, - ) -> HTML: - """ - Convert a markdown file into HTML. - - This is the direct variation of `markdown()` to prevent an import. - """ - return await markdown(name, directory=directory, app=self) - - @overload - def context( - self, - r_or_none: RouteOrCallable[P], - ) -> Route[P]: ... - - @overload - def context( - self, - r_or_none: None = None, - ) -> Callable[[RouteOrCallable[P]], Route[P]]: ... - - def context( - self, - r_or_none: RouteOrCallable[P] | None = None, - ) -> Callable[[RouteOrCallable[P]], Route[P]] | Route[P]: - return context_impl(r_or_none) - - async def _app(self, scope, receive, send) -> None: - return await self.asgi_app_entry(scope, receive, send) - - def load(self, *routes: Route) -> None: - """ - Load the app. - This is automatically called most of the time, and should only be called manually during manual loading. - - Args: - routes: Routes to load into the app. - """ - if self.loaded: - if routes: - finalize(routes, self) - Internal.warning("load called again") - return - - if routes and (self.config.app.loader != "manual"): - warnings.warn(_ROUTES_WARN_MSG) - - for index, i in enumerate(routes): - if not isinstance(i, Route): - raise TypeError(f"(index {index}) expected Route object, got {i}") - - with suppress(ImportError): - import exceptiongroup - from reactpy.backend.hooks import ConnectionContext - from reactpy.core.layout import Layout - from reactpy.core.serve import serve_layout - - @self.websocket("/_view/reactpy-stream") - @self.query("route", str) - async def reactpy_stream(ws: WebSocket, route: str): - try: - page = self._reactive_sessions[route.strip("\n")] - except KeyError: - return "Invalid route stream ID" - - await ws.accept() - with suppress(exceptiongroup.ExceptionGroup): - await serve_layout( - Layout(ConnectionContext(page)), # type: ignore - ws.send, # type: ignore - partial(ws.receive, tp=dict), # type: ignore - ) - - if self.config.app.loader == "filesystem": - load_fs(self, self.config.app.loader_path) - elif self.config.app.loader == "simple": - load_simple(self, self.config.app.loader_path) - elif self.config.app.loader == "patterns": - load_patterns(self, self.config.app.loader_path) - elif self.config.app.loader == "custom": - if not self._user_loader: - raise InvalidCustomLoaderError("custom loader was not set") - - collected = self._user_loader(self, self.config.app.loader_path) - if not isinstance(collected, CollectionsIterable): - raise InvalidCustomLoaderError( - f"expected custom loader to return a list of routes, got {collected!r}" - ) - finalize(collected, self) - else: - finalize([*(routes or ()), *self._manual_routes], self) - - self.loaded = True - - for r in self.loaded_routes: - if not r.path: - continue - - if r.path.startswith("/_view"): - continue - - body: dict[str, InputDoc] = {} - query: dict[str, InputDoc] = {} - - for i in r.inputs: - if not isinstance(i, RouteInput): - continue - - target = body if i.is_body else query - target[i.name] = InputDoc( - i.doc or "No description provided.", i.tp, i.default - ) - - if r.method: - self._docs[(r.method.name, r.path)] = RouteDoc( - r.doc or "No description provided.", body, query - ) - else: - self._docs[ - ( - ( - tuple([i.name for i in r.method_list]) - if r.method_list - else ( - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "OPTIONS", - ) - ), - r.path, - ) - ] = RouteDoc(r.doc or "No description provided.", body, query) - - async def _spawn(self, coro: Coroutine[Any, Any, Any]): - loop = asyncio.get_event_loop() - Internal.info(f"using event loop: {loop}") - await self.build() - Internal.info(f"spawning {coro}") - - task = loop.create_task(coro) - - if self.config.log.fancy: - enter_server() - - self.running = True - Internal.debug("here we go!") - - if (self.config.log.startup_message) and (not self.config.log.fancy): - welcome() - - if self.config.dev: - Service.warning( - "Development mode is enabled, do not expect high performance." - ) - - async def subcoro(): - Service.info( - f"Server running at http://{self.config.server.host}:{self.config.server.port} with backend [bold green]{self.config.server.backend}[/]" # noqa - ) - - await asyncio.gather(task, subcoro()) - self.running = False - - if self.config.log.fancy: - exit_server() - - Internal.info("server closed") - - def _run(self, start_target: Callable[..., Any] | None = None) -> Any: - self.load() - Internal.info("starting server!") - server = self.config.server.backend - uvloop_enabled = False - - if self.config.app.uvloop is True: - try: - import uvloop - except ModuleNotFoundError as e: - needs_dep("uvloop", e) - uvloop.install() - uvloop_enabled = True - elif self.config.app.uvloop == "decide": - with suppress(ModuleNotFoundError): - import uvloop - - uvloop.install() - uvloop_enabled = True - - def start(coro: Coroutine[Any, Any, Any]) -> None: - try: - (start_target or asyncio.run)(coro) - except KeyboardInterrupt: - Service.info("CTRL+C received, closing server.") - exit() - - if server == "uvicorn": - try: - import uvicorn - except ModuleNotFoundError as e: - needs_dep("uvicorn", e, "servers") - - config = uvicorn.Config( - self._app, - port=self.config.server.port, - host=str(self.config.server.host), - log_level="debug" if self.config.dev else "info", - lifespan="on", - factory=False, - interface="asgi3", - loop="uvloop" if uvloop_enabled else "asyncio", - **self.config.server.extra_args, - ) - - for log in ( - logging.getLogger("uvicorn"), - logging.getLogger("uvicorn.error"), - logging.getLogger("uvicorn.access"), - logging.getLogger("uvicorn.asgi"), - ): - if self.config.log.server_logger and self.config.log.fancy: - log.addFilter(Hijack()) - log.disabled = not self.config.log.server_logger - - uvicorn_server = uvicorn.Server(config) - return start(self._spawn(uvicorn_server.serve())) - - elif server == "hypercorn": - try: - import hypercorn - except ModuleNotFoundError as e: - needs_dep("hypercorn", e, "servers") - - for log in ( - logging.getLogger("hypercorn.error"), - logging.getLogger("hypercorn.access"), - ): - if self.config.log.server_logger and self.config.log.fancy: - log.addFilter(Hijack()) - log.disabled = not self.config.log.server_logger - - from hypercorn.asyncio import serve - - conf = hypercorn.Config() - conf.loglevel = "debug" if self.config.dev else "info" - conf.bind = [ - f"{self.config.server.host}:{self.config.server.port}", - ] - - for k, v in self.config.server.extra_args.items(): - setattr(conf, k, v) - - return start(self._spawn(serve(self._app, conf))) - elif server == "daphne": - try: - import daphne as _ - except ModuleNotFoundError as e: - needs_dep("daphne", e, "servers") - - from daphne.endpoints import build_endpoint_description_strings - from daphne.server import Server - - endpoints = build_endpoint_description_strings( - host=str(self.config.server.host), - port=self.config.server.port, - ) - - for logger in ( - logging.getLogger("daphne.cli"), - logging.getLogger("daphne.server"), - logging.getLogger("daphne.http_protocol"), - ): - logger.disabled = not self.config.log.server_logger - # default daphne log configuration - level = ( - _LEVELS[self.config.log.level] - if not isinstance(self.config.log.level, int) - else self.config.log.level - ) - logger.setLevel(level) - - if self.config.log.server_logger: - logging.basicConfig( - level=level, - format="%(asctime)-15s %(levelname)-8s %(message)s", - ) - - daphne_server = Server( - self._app, server_name="view.py", endpoints=endpoints - ) - if self.config.log.server_logger and self.config.log.fancy: - logger.addFilter(Hijack()) - # mypy thinks asyncio.to_thread doesn't exist for some reason - return start( - self._spawn(asyncio.to_thread(daphne_server.run)) # type: ignore - ) - else: - raise NotImplementedError("viewserver is not implemented yet") - - def run(self, *, fancy: bool | None = None, force: bool = False) -> None: - """ - Run the app. - - args: - fancy: Override for the `fancy` parameter in configuration. It's useful to pass this parameter instead of modifying the configuration when debugging. - force: Force the app to run, regardless of environment. - """ - self._run_called = True - if fancy is not None: - self.config.log.fancy = fancy - - frame = inspect.currentframe() - assert frame, "failed to get frame" - assert frame.f_back, "frame has no f_back" - - back = frame.f_back - base = os.path.basename(back.f_code.co_filename) - app_path = self.config.app.app_path - fname = app_path.split(":", maxsplit=1)[0] - if base != fname: - warnings.warn( - f"ran app from {base}, but app path is {fname} in config", - ) - - if force: - Internal.info("forcing app start") - - if force or ( - (not os.environ.get("_VIEW_RUN")) - and (back.f_globals.get("__name__") == "__main__") - ): - self._run() - else: - Internal.info("called run, but env or scope prevented startup") - - async def build(self) -> None: - """ - Run the default build steps for the app. - """ - from .build import build_steps - - await build_steps(self) - - async def export(self, path: str | Path | None = None) -> None: - """ - Export the app as static HTML. - - Args: - path: Path to export files to. This is passed to `build_app`. - """ - from .build import build_app - - await build_app(self, path=Path(path) if path else None) - - def run_threaded(self, *, daemon: bool = True) -> Thread: - """ - Run the app in a thread. - - Args: - daemon: Equivalent to the `daemon` parameter on `threading.Thread` - """ - thread = Thread(target=self._run, daemon=daemon) - thread.start() - return thread - - def run_async( - self, - loop: asyncio.AbstractEventLoop | None = None, - ) -> None: - """ - Run the app in an event loop. - - Args: - loop: `asyncio` event loop to use. If `None`, `asyncio.get_event_loop()` is called. - """ - self._run((loop or asyncio.get_event_loop()).run_until_complete) - - def run_task( - self, - loop: asyncio.AbstractEventLoop | None = None, - ) -> asyncio.Task[None]: - """ - Run the app as an `asyncio` task. - - Args: - loop: `asyncio` event loop to use. If `None`, `asyncio.get_event_loop()` is called. - """ - return self._run((loop or asyncio.get_event_loop()).create_task) - - start = run - - def __repr__(self) -> str: - return f"App(config={self.config!r})" - - @asynccontextmanager - async def test(self): - """ - Open the testing context. - """ - self.load() - await self.build() - ctx = TestingContext(self.asgi_app_entry) - try: - yield ctx - finally: - await ctx.stop() - - @overload - def docs(self, file: None = None) -> str: ... - - @overload - def docs(self, file: TextIO) -> None: ... - - @overload - def docs( - self, - file: Path, - *, - encoding: str = "utf-8", - overwrite: bool = True, - ) -> None: ... - - @overload - def docs( - self, - file: str, - *, - encoding: str = "utf-8", - overwrite: bool = True, - ) -> None: ... - - def docs( - self, - file: str | TextIO | Path | None = None, - *, - encoding: str = "utf-8", - overwrite: bool = True, - ) -> str | None: - """Generate documentation for the app.""" - self.load() - md = markdown_docs(self._docs) - - if not file: - return md - - if isinstance(file, str): - if not overwrite: - Path(file).write_text(md, encoding=encoding) - else: - with open(file, "w", encoding=encoding) as f: - f.write(md) - elif isinstance(file, Path): - if overwrite: - with open(file, "w", encoding=encoding) as f: - f.write(md) - else: - file.write_text(md) - else: - file.write(md) - - return None - - -def new_app( - *, - start: bool = False, - config_path: Path | str | None = None, - config_directory: Path | str | None = None, - app_dealloc: Callback | None = None, - store: bool = True, - config: Config | None = None, -) -> App: - """ - Create a new view app. - - Args: - start: Should the app be started automatically? (In a new thread) - config_path: Path of the target configuration file - config_directory: Directory path to search for a configuration - app_dealloc: Callback to run when the App instance is freed from memory - store: Whether to store the app, to allow use from get_app() - config: Raw `Config` object to use instead of loading the config. - - Example: - ```py - from view import new_app - - app = new_app() - - # ... - - app.run() - ``` - """ - config = config or load_config( - path=Path(config_path) if config_path else None, - directory=Path(config_directory) if config_directory else None, - ) - - app = App(config) - - if start: - app.run_threaded() - - def finalizer(): - if "_VIEW_APP_ADDRESS" in os.environ: - del os.environ["_VIEW_APP_ADDRESS"] - - if app_dealloc: - app_dealloc() - - weakref.finalize(app, finalizer) - - if store: - os.environ["_VIEW_APP_ADDRESS"] = str(id(app)) - # id() on CPython returns the address, but it is - # implementation dependent. - # However, view.py only supports CPython anyway - - return app - - -# This is forbidden pointers.py technology -# If anyone has a better way to do it, let me know - -ctypes.pythonapi.Py_IncRef.argtypes = (ctypes.py_object,) - - -def get_app(*, address: int | None = None) -> App: - """Get the last app created by `new_app`.""" - env = os.environ.get("_VIEW_APP_ADDRESS") - addr = address or env - - if (not addr) and (not env): - raise BadEnvironmentError("no view app registered") - - app: App = ctypes.cast(int(addr), ctypes.py_object).value # type: ignore - ctypes.pythonapi.Py_IncRef(app) - return app diff --git a/src/view/build.py b/src/view/build.py deleted file mode 100644 index 310f9969..00000000 --- a/src/view/build.py +++ /dev/null @@ -1,448 +0,0 @@ -""" -view.py build APIs - -While this module is considered public, you likely don't need the functions in here. -Instead, you should just let view.py do most of the work, such as through calling `build_app` upon startup. -""" - -from __future__ import annotations - -import asyncio -import importlib -import re -import runpy -import warnings -from asyncio import subprocess -from collections.abc import Coroutine -from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn - -import aiofiles -import aiofiles.os - -from ._logging import Internal -from .app import App -from .typing import ViewResult - -if TYPE_CHECKING: - from .config import Config - -import platform - -from .config import BuildStep, Platform -from .exceptions import ( - BuildError, - BuildWarning, - MissingRequirementError, - PlatformNotSupportedError, - UnknownBuildStepError, - ViewInternalError, -) -from .util import to_response - -__all__ = "build_steps", "build_app" - - -class _BuildStepWithName(NamedTuple): - name: str - step: BuildStep - cache: list[str] - - -_SPECIAL_REQ = re.compile(r"(\w+)\+(.+)") - - -async def _call_command(command: str) -> None: - Internal.info(f"Running `{command}`") - proc = await subprocess.create_subprocess_shell(command) - await proc.wait() - - if proc.returncode != 0: - raise BuildError(f"{command} returned non-zero exit code") - - -async def _call_script(path: Path, *, call_func: str | None = None) -> Any: - Internal.info(f"Executing Python script at `{path}`") - globls = runpy.run_path(str(path), run_name="__view_build__") - - if call_func: - func = globls.get(call_func) - if func: - try: - return await func() - except Exception as e: - raise BuildError(f"Script at {path} raised exception!") from e - - -_COMMAND_REQS = [ - # C - "gcc", - "cl", - "clang", - # C++ - "g++", - "clang++", - "cmake", - # Python - "pip", - "uv", - "poetry", - "pipx", - # JavaScript - "node", - "npm", - "yarn", - "pnpm", - "bun", - # Java - "java", - "javac", - "mvn", - "gradle", - "gradlew", - # Rust - "rustup", - "rustc", - "cargo", - # Ruby - "gem", - "ruby", - # C# - "dotnet", - "nuget", - # PHP - "php", - "composer", - # Go - "go", - # Kotlin - "kotlinc", - # Lua - "lua", - "luarocks", - # Dart - "dart", -] - -# use -v -_USE_V_FLAG = ["lua", "php"] -# use -version -_USE_SINGLE_DASH = ["kotlinc", "java", "javac"] - - -async def _check_version_command(name: str) -> bool: - command = "--version" - - if name in _USE_V_FLAG: - command = "-v" - - if name in _USE_SINGLE_DASH: - command = "-version" - - proc = await subprocess.create_subprocess_shell( - f"{name} {command}", - stdout=subprocess.PIPE, - ) - await proc.wait() - return proc.returncode == 0 - - -async def _check_requirement(req: str) -> None: - Internal.info(f"Ensuring dependency {req!r}") - special = _SPECIAL_REQ.match(req) - - if not special: - if req not in _COMMAND_REQS: - raise BuildError(f"Unknown build requirement: {req!r}") - - if not await _check_version_command(req): - raise MissingRequirementError(f"{req} is not installed") - return - - prefix = special.group(1) - target = special.group(2) - - if prefix == "mod": - Internal.info(f"Importing `{target}`") - try: - mod = importlib.import_module(target) - except ModuleNotFoundError as e: - raise MissingRequirementError(f"Could not import {target}") from e - - reqfunc = getattr(mod, "__view_requirement__", None) - if reqfunc: - res = await reqfunc() - if res is False: - raise MissingRequirementError( - f"Requirement script in module {target} returned non-True" - ) - elif prefix == "script": - path = Path(target) - if (not path.exists()) or (not path.is_file()): - raise MissingRequirementError( - f"Python script at {target} does not exist or is not a file" - ) - - res = await _call_script(path, call_func="__view_requirement__") - if res is False: - raise MissingRequirementError( - f"Requirement script at {path} returned non-True" - ) - elif prefix == "path": - if not Path(target).exists(): - raise MissingRequirementError(f"{target} does not exist") - elif prefix == "command": - if not await _check_version_command(target): - raise MissingRequirementError(f"{target} is not installed") - else: - raise BuildError(f"Invalid requirement prefix: {prefix}") - - -_PLATFORMS: dict[str, list[Platform]] = { - "Linux": ["linux", "Linux"], - "Darwin": ["mac", "macOS", "Mac", "MacOS"], - "Windows": ["windows", "Windows"], -} - - -def _is_platform_compatible(plat: Platform | list[Platform] | None) -> bool: - system = platform.system() - - try: - names = _PLATFORMS[system] - except KeyError as e: - raise ViewInternalError( - f"platform.system() returned unknown os: {system}" - ) from e - - if isinstance(plat, list): - for supported_platform in plat: - if supported_platform in names: - return True - - return False - - return plat in names - - -def _invalid_platform(name: str) -> NoReturn: - system = platform.system() - raise PlatformNotSupportedError( - f"build step {name!r} does not support {system.lower()}" - ) - - -async def _build_step(step: _BuildStepWithName) -> None: - if step.step.platform: - if not _is_platform_compatible(step.step.platform): - _invalid_platform(step.name) - - Internal.info(f"Building step {step.name!r}") - data = step.step - - for req in data.requires: - if req in step.cache: - Internal.info(f"{req} was already checked, skipping it") - continue - - await _check_requirement(req) - step.cache.append(req) - - if data.command: - if isinstance(data.command, list): - for command in data.command: - await _call_command(command) - else: - await _call_command(data.command) - - if data.script: - if isinstance(data.script, list): - for script in data.script: - await _call_script(script, call_func="__view_build__") - else: - await _call_script(data.script, call_func="__view_build__") - - -def _find_step(name: str, steps: list[BuildStep]) -> _BuildStepWithName: - platform_step: _BuildStepWithName | None = None - null_platform: bool = False - - for i in steps: - if (not i.platform) and (not platform_step): - if null_platform: - raise BuildError( - f"step {name!r} has multiple entries without a platform" - ) - platform_step = _BuildStepWithName(name, i, []) - null_platform = True - else: - if _is_platform_compatible(i.platform): - platform_step = _BuildStepWithName(name, i, []) - - if not platform_step: - _invalid_platform(name) - - return platform_step - - -async def run_step(app_or_config: App | Config, name: str) -> None: - """ - Run an individual build step. - - Args: - app: App object or configuration to load build steps from. - name: Name of the build step. - - Raises: - UnknownBuildStepError: The step does not exist. - """ - if isinstance(app_or_config, App): - step_conf = app_or_config.config.build.steps.get(name) - else: - step_conf = app_or_config.build.steps.get(name) - - if not step_conf: - raise UnknownBuildStepError(f"no step named {name!r}") - - if isinstance(step_conf, list): - step = _find_step(name, step_conf) - else: - step = _BuildStepWithName(name, step_conf, []) - - await _build_step(step) - - -async def build_steps(app_or_config: App | Config) -> None: - """ - Run the default build steps for a given application or configuration. This is called upon starting a server. - - Args: - app_or_config: App or configuration object to read build steps from. - """ - if isinstance(app_or_config, App): - build = app_or_config.config.build - else: - build = app_or_config.build - - cache: list[str] = [] - steps: list[_BuildStepWithName] = [] - - for name, step in build.steps.items(): - if build.default_steps and (name not in build.default_steps): - continue - - if isinstance(step, list): - steps.append(_find_step(name, step)) - else: - steps.append(_BuildStepWithName(name, step, cache)) - - Internal.info("Starting build steps") - - if build.parallel: - coros = [_build_step(step) for step in steps] - await asyncio.gather(*coros) - else: - for step in steps: - await _build_step(step) - - -async def _handle_result(res: ViewResult) -> str | bytes: - response = await to_response(res) - return response.body - - -async def _compile_routes( - app: App, - *, - should_await: bool = False, -) -> dict[str, str | bytes]: - from .routing import Method - - results: dict[str, str | bytes] = {} - coros: list[Coroutine] = [] - - for i in app.loaded_routes: - if (not i.method) or (i.method != Method.GET): - warnings.warn(f"{i} is not a GET route, skipping it", BuildWarning) - continue - - if not i.path: - warnings.warn(f"{i} needs path parameters, skipping it", BuildWarning) - continue - - Internal.info(f"Calling GET {i.path}") - - if i.inputs: - warnings.warn(f"{i.path} needs a route input, skipping it", BuildWarning) - continue - - res = i.func() # type: ignore - - # I'm unsure if I'm doing this right. - # Reviewers, correct this if I'm wrong! - if isinstance(res, Coroutine): - if should_await: - results[i.path[1:]] = await _handle_result(await res) - else: - task = asyncio.create_task(res) - - def cb(fut: asyncio.Task[ViewResult]): - text = fut.result() - - def cb_2(fut2: asyncio.Task[str | bytes]): - assert i.path is not None - results[i.path[1:]] = fut2.result() - - task2 = asyncio.create_task(_handle_result(text)) - task2.add_done_callback(cb_2) - - task.add_done_callback(cb) - coros.append(res) - - if not should_await: - await asyncio.gather(*coros) - - return results - - -async def build_app(app: App, *, path: Path | None = None) -> None: - """ - Compile an app into static HTML, including running all of it's build steps. - - Args: - app: App object to build. - path: Output path for files. - """ - Internal.info("Starting build process!") - await build_steps(app) - - Internal.info("Getting routes") - results = await _compile_routes( - app, - should_await=not app.config.build.parallel, - ) - path = path or app.config.build.path - - if path.exists(): - await aiofiles.os.removedirs(path) - await aiofiles.os.mkdir(path) - elif not path.exists(): - await aiofiles.os.mkdir(path) - - for file_path, content in results.items(): - directory = path / file_path - file = directory / "index.html" - - if not (await aiofiles.os.path.exists(directory)): - await aiofiles.os.mkdir(directory) - Internal.info(f"Created {directory}") - - if isinstance(content, str): - async with aiofiles.open(file, "w", encoding="utf-8") as f: - await f.write(content) - else: - async with aiofiles.open(file, "wb") as f: - await f.write(content) - Internal.info(f"Created {file}") - - Internal.info("Successfully built app") diff --git a/src/view/components.py b/src/view/components.py deleted file mode 100644 index d36c8105..00000000 --- a/src/view/components.py +++ /dev/null @@ -1,2136 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, Literal - -from typing_extensions import NotRequired, TypedDict, Unpack - - -class DOMNode: - def __init__(self, data: str | DOMNode) -> None: - self.data = str(data) - self.compiler_ready = False - - def __str__(self) -> str: - return self.data - - def __repr__(self) -> str: - return f"DOMNode({self.data!r})" - - __view_result__ = __str__ - - -AutoCapitalizeType = Literal["off", "none", "on", "sentences", "words", "characters"] -DirType = Literal["ltr", "rtl", "auto"] - - -class GlobalAttributes(TypedDict): - accesskey: NotRequired[str] - autocapitalize: NotRequired[AutoCapitalizeType] - autofocus: NotRequired[bool] - cls: NotRequired[str] - contenteditable: NotRequired[bool] - contextmenu: NotRequired[str] - data: NotRequired[Dict[str, Any]] - dir: NotRequired[DirType] - draggable: NotRequired[bool] - enterkeyhint: NotRequired[str] - exportparts: NotRequired[str] - - -NEWLINE = "\n" - - -def _node( - name: str, - text: tuple[str | DOMNode], - attrs: dict[str, Any], - kwargs: GlobalAttributes, -) -> DOMNode: - attributes: dict[str, str | None] = {**kwargs, **attrs} - - cls = kwargs.get("cls") - if cls: - attributes["class"] = cls - kwargs.pop("cls") - attributes.pop("cls") - for k, v in kwargs.items(): - if isinstance(v, bool): - attributes[k] = "true" if v else "false" - - for k, v in (kwargs.get("data") or {}).items(): - attributes[f"data-{k}"] = v - - attr_str = "" - - for k, v in attributes.items(): - if v is None: - continue - - k = k.replace("_", "-") - if v: - attr_str += f" {k}={v!r}" - else: - attr_str += f" {k}" - return DOMNode( - f"<{name}{attr_str}>{NEWLINE.join([str(i) for i in text])}", - ) - - -def a( - *__content: str | DOMNode, - download: str | None = None, - href: str | None = None, - hreflang: str | None = None, - ping: str | None = None, - referrerpolicy: str | None = None, - rel: str | None = None, - target: str | None = None, - type: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "a", - __content, - { - "download": download, - "href": href, - "hreflang": hreflang, - "ping": ping, - "referrerpolicy": referrerpolicy, - "rel": rel, - "target": target, - "type": type, - }, - kwargs, - ) - - -def abbr( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("abbr", __content, {}, kwargs) - - -def acronym( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("acronym", __content, {}, kwargs) - - -def address( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("address", __content, {}, kwargs) - - -def area( - *__content: str | DOMNode, - alt: str | None = None, - coords: str | None = None, - download: str | None = None, - href: str | None = None, - hreflang: str | None = None, - ping: str | None = None, - referrerpolicy: str | None = None, - rel: str | None = None, - shape: str | None = None, - target: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "area", - __content, - { - "alt": alt, - "coords": coords, - "download": download, - "href": href, - "hreflang": hreflang, - "ping": ping, - "referrerpolicy": referrerpolicy, - "rel": rel, - "shape": shape, - "target": target, - }, - kwargs, - ) - - -def article( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("article", __content, {}, kwargs) - - -def aside( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("aside", __content, {}, kwargs) - - -def audio( - *__content: str | DOMNode, - autoplay: str | None = None, - controls: str | None = None, - controlslist: str | None = None, - crossorigin: str | None = None, - disableremoteplayback: str | None = None, - loop: str | None = None, - muted: str | None = None, - preload: str | None = None, - src: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "audio", - __content, - { - "autoplay": autoplay, - "controls": controls, - "controlslist": controlslist, - "crossorigin": crossorigin, - "disableremoteplayback": disableremoteplayback, - "loop": loop, - "muted": muted, - "preload": preload, - "src": src, - }, - kwargs, - ) - - -def b( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("b", __content, {}, kwargs) - - -def base( - *__content: str | DOMNode, - href: str | None = None, - target: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "base", - __content, - { - "href": href, - "target": target, - }, - kwargs, - ) - - -def bdi( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("bdi", __content, {}, kwargs) - - -def bdo( - *__content: str | DOMNode, - dir: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "bdo", - __content, - { - "dir": dir, - }, - kwargs, - ) - - -def big( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("big", __content, {}, kwargs) - - -def blockquote( - *__content: str | DOMNode, - cite: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "blockquote", - __content, - { - "cite": cite, - }, - kwargs, - ) - - -def body( - *__content: str | DOMNode, - alink: str | None = None, - background: str | None = None, - bgcolor: str | None = None, - bottommargin: str | None = None, - leftmargin: str | None = None, - link: str | None = None, - onafterprint: str | None = None, - onbeforeprint: str | None = None, - onbeforeunload: str | None = None, - onblur: str | None = None, - onerror: str | None = None, - onfocus: str | None = None, - onhashchange: str | None = None, - onlanguagechange: str | None = None, - onload: str | None = None, - onmessage: str | None = None, - onoffline: str | None = None, - ononline: str | None = None, - onpopstate: str | None = None, - onredo: str | None = None, - onresize: str | None = None, - onstorage: str | None = None, - onundo: str | None = None, - onunload: str | None = None, - rightmargin: str | None = None, - text: str | None = None, - topmargin: str | None = None, - vlink: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "body", - __content, - { - "alink": alink, - "background": background, - "bgcolor": bgcolor, - "bottommargin": bottommargin, - "leftmargin": leftmargin, - "link": link, - "onafterprint": onafterprint, - "onbeforeprint": onbeforeprint, - "onbeforeunload": onbeforeunload, - "onblur": onblur, - "onerror": onerror, - "onfocus": onfocus, - "onhashchange": onhashchange, - "onlanguagechange": onlanguagechange, - "onload": onload, - "onmessage": onmessage, - "onoffline": onoffline, - "ononline": ononline, - "onpopstate": onpopstate, - "onredo": onredo, - "onresize": onresize, - "onstorage": onstorage, - "onundo": onundo, - "onunload": onunload, - "rightmargin": rightmargin, - "text": text, - "topmargin": topmargin, - "vlink": vlink, - }, - kwargs, - ) - - -def br( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("br", __content, {}, kwargs) - - -def button( - *__content: str | DOMNode, - autofocus: str | None = None, - autocomplete: str | None = None, - disabled: str | None = None, - form: str | None = None, - formaction: str | None = None, - formenctype: str | None = None, - formmethod: str | None = None, - formnovalidate: str | None = None, - formtarget: str | None = None, - name: str | None = None, - popovertarget: str | None = None, - popovertargetaction: str | None = None, - type: str | None = None, - value: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "button", - __content, - { - "autofocus": autofocus, - "autocomplete": autocomplete, - "disabled": disabled, - "form": form, - "formaction": formaction, - "formenctype": formenctype, - "formmethod": formmethod, - "formnovalidate": formnovalidate, - "formtarget": formtarget, - "name": name, - "popovertarget": popovertarget, - "popovertargetaction": popovertargetaction, - "type": type, - "value": value, - }, - kwargs, - ) - - -def canvas( - *__content: str | DOMNode, - height: str | None = None, - moz_opaque: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "canvas", - __content, - { - "height": height, - "moz_opaque": moz_opaque, - "width": width, - }, - kwargs, - ) - - -def caption( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("caption", __content, {}, kwargs) - - -def center( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("center", __content, {}, kwargs) - - -def cite( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("cite", __content, {}, kwargs) - - -def code( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("code", __content, {}, kwargs) - - -def col( - *__content: str | DOMNode, - span: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "col", - __content, - { - "span": span, - }, - kwargs, - ) - - -def colgroup( - *__content: str | DOMNode, - span: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "colgroup", - __content, - { - "span": span, - }, - kwargs, - ) - - -def data( - *__content: str | DOMNode, - value: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "data", - __content, - { - "value": value, - }, - kwargs, - ) - - -def datalist( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("datalist", __content, {}, kwargs) - - -def dd( - *__content: str | DOMNode, - nowrap: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "dd", - __content, - { - "nowrap": nowrap, - }, - kwargs, - ) - - -def html_del( - *__content: str | DOMNode, - cite: str | None = None, - datetime: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "html_del", - __content, - { - "cite": cite, - "datetime": datetime, - }, - kwargs, - ) - - -def details( - *__content: str | DOMNode, - open: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "details", - __content, - { - "open": open, - }, - kwargs, - ) - - -def dfn( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("dfn", __content, {}, kwargs) - - -def dialog( - *__content: str | DOMNode, - open: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "dialog", - __content, - { - "open": open, - }, - kwargs, - ) - - -def dir( - *__content: str | DOMNode, - compact: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "dir", - __content, - { - "compact": compact, - }, - kwargs, - ) - - -def div( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("div", __content, {}, kwargs) - - -def dl( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("dl", __content, {}, kwargs) - - -def dt( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("dt", __content, {}, kwargs) - - -def em( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("em", __content, {}, kwargs) - - -def embed( - *__content: str | DOMNode, - height: str | None = None, - src: str | None = None, - type: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "embed", - __content, - { - "height": height, - "src": src, - "type": type, - "width": width, - }, - kwargs, - ) - - -def fieldset( - *__content: str | DOMNode, - disabled: str | None = None, - form: str | None = None, - name: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "fieldset", - __content, - { - "disabled": disabled, - "form": form, - "name": name, - }, - kwargs, - ) - - -def figcaption( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("figcaption", __content, {}, kwargs) - - -def figure( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("figure", __content, {}, kwargs) - - -def font( - *__content: str | DOMNode, - color: str | None = None, - face: str | None = None, - size: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "font", - __content, - { - "color": color, - "face": face, - "size": size, - }, - kwargs, - ) - - -def footer( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("footer", __content, {}, kwargs) - - -def form( - *__content: str | DOMNode, - accept: str | None = None, - accept_charset: str | None = None, - autocapitalize: str | None = None, - autocomplete: str | None = None, - name: str | None = None, - rel: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "form", - __content, - { - "accept": accept, - "accept_charset": accept_charset, - "autocapitalize": autocapitalize, - "autocomplete": autocomplete, - "name": name, - "rel": rel, - }, - kwargs, - ) - - -def frame( - *__content: str | DOMNode, - src: str | None = None, - name: str | None = None, - noresize: str | None = None, - scrolling: str | None = None, - marginheight: str | None = None, - marginwidth: str | None = None, - frameborder: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "frame", - __content, - { - "src": src, - "name": name, - "noresize": noresize, - "scrolling": scrolling, - "marginheight": marginheight, - "marginwidth": marginwidth, - "frameborder": frameborder, - }, - kwargs, - ) - - -def frameset( - *__content: str | DOMNode, - cols: str | None = None, - rows: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "frameset", - __content, - { - "cols": cols, - "rows": rows, - }, - kwargs, - ) - - -def h1( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("h1", __content, {}, kwargs) - - -def h2( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("h2", __content, {}, kwargs) - - -def h3( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("h3", __content, {}, kwargs) - - -def h4( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("h4", __content, {}, kwargs) - - -def h5( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("h5", __content, {}, kwargs) - - -def h6( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("h6", __content, {}, kwargs) - - -def head( - *__content: str | DOMNode, - profile: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "head", - __content, - { - "profile": profile, - }, - kwargs, - ) - - -def header( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("header", __content, {}, kwargs) - - -def hgroup( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("hgroup", __content, {}, kwargs) - - -def hr( - *__content: str | DOMNode, - align: str | None = None, - color: str | None = None, - noshade: str | None = None, - size: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "hr", - __content, - { - "align": align, - "color": color, - "noshade": noshade, - "size": size, - "width": width, - }, - kwargs, - ) - - -def html( - *__content: str | DOMNode, - manifest: str | None = None, - version: str | None = None, - xmlns: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "html", - __content, - { - "manifest": manifest, - "version": version, - "xmlns": xmlns, - }, - kwargs, - ) - - -def i( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("i", __content, {}, kwargs) - - -def iframe( - *__content: str | DOMNode, - allow: str | None = None, - allowfullscreen: str | None = None, - allowpaymentrequest: str | None = None, - credentialless: str | None = None, - csp: str | None = None, - height: str | None = None, - loading: str | None = None, - name: str | None = None, - referrerpolicy: str | None = None, - sandbox: str | None = None, - src: str | None = None, - srcdoc: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "iframe", - __content, - { - "allow": allow, - "allowfullscreen": allowfullscreen, - "allowpaymentrequest": allowpaymentrequest, - "credentialless": credentialless, - "csp": csp, - "height": height, - "loading": loading, - "name": name, - "referrerpolicy": referrerpolicy, - "sandbox": sandbox, - "src": src, - "srcdoc": srcdoc, - "width": width, - }, - kwargs, - ) - - -def image( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("image", __content, {}, kwargs) - - -def img( - *__content: str | DOMNode, - alt: str | None = None, - crossorigin: str | None = None, - decoding: str | None = None, - elementtiming: str | None = None, - fetchpriority: str | None = None, - height: str | None = None, - ismap: str | None = None, - loading: str | None = None, - referrerpolicy: str | None = None, - sizes: str | None = None, - src: str | None = None, - srcset: str | None = None, - width: str | None = None, - usemap: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "img", - __content, - { - "alt": alt, - "crossorigin": crossorigin, - "decoding": decoding, - "elementtiming": elementtiming, - "fetchpriority": fetchpriority, - "height": height, - "ismap": ismap, - "loading": loading, - "referrerpolicy": referrerpolicy, - "sizes": sizes, - "src": src, - "srcset": srcset, - "width": width, - "usemap": usemap, - }, - kwargs, - ) - - -def input( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("input", __content, {}, kwargs) - - -def ins( - *__content: str | DOMNode, - cite: str | None = None, - datetime: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "ins", - __content, - { - "cite": cite, - "datetime": datetime, - }, - kwargs, - ) - - -def kbd( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("kbd", __content, {}, kwargs) - - -def label( - *__content: str | DOMNode, - html_for: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "label", - __content, - { - "html_for": html_for, - }, - kwargs, - ) - - -def legend( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("legend", __content, {}, kwargs) - - -def li( - *__content: str | DOMNode, - value: str | None = None, - type: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "li", - __content, - { - "value": value, - "type": type, - }, - kwargs, - ) - - -def link( - *__content: str | DOMNode, - html_as: str | None = None, - crossorigin: str | None = None, - disabled: str | None = None, - fetchpriority: str | None = None, - href: str | None = None, - hreflang: str | None = None, - imagesizes: str | None = None, - imagesrcset: str | None = None, - integrity: str | None = None, - media: str | None = None, - prefetch: str | None = None, - referrerpolicy: str | None = None, - rel: str | None = None, - sizes: str | None = None, - title: str | None = None, - type: str | None = None, - blocking: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "link", - __content, - { - "html_as": html_as, - "crossorigin": crossorigin, - "disabled": disabled, - "fetchpriority": fetchpriority, - "href": href, - "hreflang": hreflang, - "imagesizes": imagesizes, - "imagesrcset": imagesrcset, - "integrity": integrity, - "media": media, - "prefetch": prefetch, - "referrerpolicy": referrerpolicy, - "rel": rel, - "sizes": sizes, - "title": title, - "type": type, - "blocking": blocking, - }, - kwargs, - ) - - -def main( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("main", __content, {}, kwargs) - - -def map( - *__content: str | DOMNode, - name: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "map", - __content, - { - "name": name, - }, - kwargs, - ) - - -def mark( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("mark", __content, {}, kwargs) - - -def marquee( - *__content: str | DOMNode, - behavior: str | None = None, - bgcolor: str | None = None, - direction: str | None = None, - height: str | None = None, - hspace: str | None = None, - loop: str | None = None, - scrollamount: str | None = None, - scrolldelay: str | None = None, - truespeed: str | None = None, - vspace: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "marquee", - __content, - { - "behavior": behavior, - "bgcolor": bgcolor, - "direction": direction, - "height": height, - "hspace": hspace, - "loop": loop, - "scrollamount": scrollamount, - "scrolldelay": scrolldelay, - "truespeed": truespeed, - "vspace": vspace, - "width": width, - }, - kwargs, - ) - - -def menu( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("menu", __content, {}, kwargs) - - -def menuitem( - *__content: str | DOMNode, - checked: str | None = None, - command: str | None = None, - default: str | None = None, - disabled: str | None = None, - icon: str | None = None, - label: str | None = None, - radiogroup: str | None = None, - type: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "menuitem", - __content, - { - "checked": checked, - "command": command, - "default": default, - "disabled": disabled, - "icon": icon, - "label": label, - "radiogroup": radiogroup, - "type": type, - }, - kwargs, - ) - - -def meta( - *__content: str | DOMNode, - charset: str | None = None, - content: str | None = None, - http_equiv: str | None = None, - name: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "meta", - __content, - { - "charset": charset, - "content": content, - "http_equiv": http_equiv, - "name": name, - }, - kwargs, - ) - - -def meter( - *__content: str | DOMNode, - value: str | None = None, - min: str | None = None, - max: str | None = None, - low: str | None = None, - high: str | None = None, - optimum: str | None = None, - form: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "meter", - __content, - { - "value": value, - "min": min, - "max": max, - "low": low, - "high": high, - "optimum": optimum, - "form": form, - }, - kwargs, - ) - - -def nav( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("nav", __content, {}, kwargs) - - -def nobr( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("nobr", __content, {}, kwargs) - - -def noembed( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("noembed", __content, {}, kwargs) - - -def noframes( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("noframes", __content, {}, kwargs) - - -def noscript( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("noscript", __content, {}, kwargs) - - -def object( - *__content: str | DOMNode, - archive: str | None = None, - border: str | None = None, - classid: str | None = None, - codebase: str | None = None, - codetype: str | None = None, - data: str | None = None, - declare: str | None = None, - form: str | None = None, - height: str | None = None, - name: str | None = None, - standby: str | None = None, - type: str | None = None, - usemap: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "object", - __content, - { - "archive": archive, - "border": border, - "classid": classid, - "codebase": codebase, - "codetype": codetype, - "data": data, - "declare": declare, - "form": form, - "height": height, - "name": name, - "standby": standby, - "type": type, - "usemap": usemap, - "width": width, - }, - kwargs, - ) - - -def ol( - *__content: str | DOMNode, - reversed: str | None = None, - start: str | None = None, - type: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "ol", - __content, - { - "reversed": reversed, - "start": start, - "type": type, - }, - kwargs, - ) - - -def optgroup( - *__content: str | DOMNode, - disabled: str | None = None, - label: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "optgroup", - __content, - { - "disabled": disabled, - "label": label, - }, - kwargs, - ) - - -def option( - *__content: str | DOMNode, - disabled: str | None = None, - label: str | None = None, - selected: str | None = None, - value: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "option", - __content, - { - "disabled": disabled, - "label": label, - "selected": selected, - "value": value, - }, - kwargs, - ) - - -def output( - *__content: str | DOMNode, - html_for: str | None = None, - form: str | None = None, - name: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "output", - __content, - { - "html_for": html_for, - "form": form, - "name": name, - }, - kwargs, - ) - - -def p( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("p", __content, {}, kwargs) - - -def param( - *__content: str | DOMNode, - name: str | None = None, - value: str | None = None, - type: str | None = None, - valuetype: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "param", - __content, - { - "name": name, - "value": value, - "type": type, - "valuetype": valuetype, - }, - kwargs, - ) - - -def picture( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("picture", __content, {}, kwargs) - - -def plaintext( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("plaintext", __content, {}, kwargs) - - -def portal( - *__content: str | DOMNode, - referrerpolicy: str | None = None, - src: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "portal", - __content, - { - "referrerpolicy": referrerpolicy, - "src": src, - }, - kwargs, - ) - - -def pre( - *__content: str | DOMNode, - cols: str | None = None, - width: str | None = None, - wrap: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "pre", - __content, - { - "cols": cols, - "width": width, - "wrap": wrap, - }, - kwargs, - ) - - -def progress( - *__content: str | DOMNode, - max: str | None = None, - value: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "progress", - __content, - { - "max": max, - "value": value, - }, - kwargs, - ) - - -def q( - *__content: str | DOMNode, - cite: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "q", - __content, - { - "cite": cite, - }, - kwargs, - ) - - -def rb( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("rb", __content, {}, kwargs) - - -def rp( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("rp", __content, {}, kwargs) - - -def rt( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("rt", __content, {}, kwargs) - - -def rtc( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("rtc", __content, {}, kwargs) - - -def ruby( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("ruby", __content, {}, kwargs) - - -def s( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("s", __content, {}, kwargs) - - -def samp( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("samp", __content, {}, kwargs) - - -def script( - *__content: str | DOMNode, - html_async: str | None = None, - crossorigin: str | None = None, - defer: str | None = None, - fetchpriority: str | None = None, - integrity: str | None = None, - nomodule: str | None = None, - nonce: str | None = None, - referrerpolicy: str | None = None, - src: str | None = None, - type: str | None = None, - blocking: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "script", - __content, - { - "html_async": html_async, - "crossorigin": crossorigin, - "defer": defer, - "fetchpriority": fetchpriority, - "integrity": integrity, - "nomodule": nomodule, - "nonce": nonce, - "referrerpolicy": referrerpolicy, - "src": src, - "type": type, - "blocking": blocking, - }, - kwargs, - ) - - -def search( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("search", __content, {}, kwargs) - - -def section( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("section", __content, {}, kwargs) - - -def select( - *__content: str | DOMNode, - autocomplete: str | None = None, - autofocus: str | None = None, - disabled: str | None = None, - form: str | None = None, - multiple: str | None = None, - name: str | None = None, - required: str | None = None, - size: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "select", - __content, - { - "autocomplete": autocomplete, - "autofocus": autofocus, - "disabled": disabled, - "form": form, - "multiple": multiple, - "name": name, - "required": required, - "size": size, - }, - kwargs, - ) - - -def slot( - *__content: str | DOMNode, - name: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "slot", - __content, - { - "name": name, - }, - kwargs, - ) - - -def small( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("small", __content, {}, kwargs) - - -def source( - *__content: str | DOMNode, - type: str | None = None, - src: str | None = None, - srcset: str | None = None, - sizes: str | None = None, - media: str | None = None, - height: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "source", - __content, - { - "type": type, - "src": src, - "srcset": srcset, - "sizes": sizes, - "media": media, - "height": height, - "width": width, - }, - kwargs, - ) - - -def span( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("span", __content, {}, kwargs) - - -def strike( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("strike", __content, {}, kwargs) - - -def strong( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("strong", __content, {}, kwargs) - - -def style( - *__content: str | DOMNode, - media: str | None = None, - nonce: str | None = None, - title: str | None = None, - blocking: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "style", - __content, - { - "media": media, - "nonce": nonce, - "title": title, - "blocking": blocking, - }, - kwargs, - ) - - -def sub( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("sub", __content, {}, kwargs) - - -def summary( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("summary", __content, {}, kwargs) - - -def sup( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("sup", __content, {}, kwargs) - - -def table( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("table", __content, {}, kwargs) - - -def tbody( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("tbody", __content, {}, kwargs) - - -def td( - *__content: str | DOMNode, - colspan: str | None = None, - headers: str | None = None, - rowspan: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "td", - __content, - { - "colspan": colspan, - "headers": headers, - "rowspan": rowspan, - }, - kwargs, - ) - - -def template( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("template", __content, {}, kwargs) - - -def textarea( - *__content: str | DOMNode, - autocomplete: str | None = None, - autocorrect: str | None = None, - autofocus: str | None = None, - cols: str | None = None, - dirname: str | None = None, - disabled: str | None = None, - form: str | None = None, - maxlength: str | None = None, - minlength: str | None = None, - name: str | None = None, - placeholder: str | None = None, - readonly: str | None = None, - required: str | None = None, - rows: str | None = None, - spellcheck: str | None = None, - wrap: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "textarea", - __content, - { - "autocomplete": autocomplete, - "autocorrect": autocorrect, - "autofocus": autofocus, - "cols": cols, - "dirname": dirname, - "disabled": disabled, - "form": form, - "maxlength": maxlength, - "minlength": minlength, - "name": name, - "placeholder": placeholder, - "readonly": readonly, - "required": required, - "rows": rows, - "spellcheck": spellcheck, - "wrap": wrap, - }, - kwargs, - ) - - -def tfoot( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("tfoot", __content, {}, kwargs) - - -def th( - *__content: str | DOMNode, - abbr: str | None = None, - colspan: str | None = None, - headers: str | None = None, - rowspan: str | None = None, - scope: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "th", - __content, - { - "abbr": abbr, - "colspan": colspan, - "headers": headers, - "rowspan": rowspan, - "scope": scope, - }, - kwargs, - ) - - -def thead( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("thead", __content, {}, kwargs) - - -def time( - *__content: str | DOMNode, - datetime: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "time", - __content, - { - "datetime": datetime, - }, - kwargs, - ) - - -def title( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("title", __content, {}, kwargs) - - -def tr( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("tr", __content, {}, kwargs) - - -def track( - *__content: str | DOMNode, - default: str | None = None, - kind: str | None = None, - label: str | None = None, - src: str | None = None, - srclang: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "track", - __content, - { - "default": default, - "kind": kind, - "label": label, - "src": src, - "srclang": srclang, - }, - kwargs, - ) - - -def tt( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("tt", __content, {}, kwargs) - - -def u( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("u", __content, {}, kwargs) - - -def ul( - *__content: str | DOMNode, - compact: str | None = None, - type: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "ul", - __content, - { - "compact": compact, - "type": type, - }, - kwargs, - ) - - -def var( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("var", __content, {}, kwargs) - - -def video( - *__content: str | DOMNode, - autoplay: str | None = None, - controls: str | None = None, - controlslist: str | None = None, - crossorigin: str | None = None, - disablepictureinpicture: str | None = None, - disableremoteplayback: str | None = None, - height: str | None = None, - loop: str | None = None, - muted: str | None = None, - playsinline: str | None = None, - poster: str | None = None, - preload: str | None = None, - src: str | None = None, - width: str | None = None, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node( - "video", - __content, - { - "autoplay": autoplay, - "controls": controls, - "controlslist": controlslist, - "crossorigin": crossorigin, - "disablepictureinpicture": disablepictureinpicture, - "disableremoteplayback": disableremoteplayback, - "height": height, - "loop": loop, - "muted": muted, - "playsinline": playsinline, - "poster": poster, - "preload": preload, - "src": src, - "width": width, - }, - kwargs, - ) - - -def wbr( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("wbr", __content, {}, kwargs) - - -def xmp( - *__content: str | DOMNode, - **kwargs: Unpack[GlobalAttributes], -) -> DOMNode: - return _node("xmp", __content, {}, kwargs) - - -def stylesheet(url: str) -> DOMNode: - return link(rel="stylesheet", href=url) - - -def js(url: str) -> DOMNode: - return script(src=url) - - -__all__ = ( - "a", - "abbr", - "acronym", - "address", - "area", - "article", - "aside", - "audio", - "b", - "base", - "bdi", - "bdo", - "big", - "blockquote", - "body", - "br", - "button", - "canvas", - "caption", - "center", - "cite", - "code", - "col", - "colgroup", - "data", - "datalist", - "dd", - "html_del", - "details", - "dfn", - "dialog", - "dir", - "div", - "dl", - "dt", - "em", - "embed", - "fieldset", - "figcaption", - "figure", - "font", - "footer", - "form", - "frame", - "frameset", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "head", - "header", - "hgroup", - "hr", - "html", - "i", - "iframe", - "image", - "img", - "input", - "ins", - "kbd", - "label", - "legend", - "li", - "link", - "main", - "map", - "mark", - "marquee", - "menu", - "menuitem", - "meta", - "meter", - "nav", - "nobr", - "noembed", - "noframes", - "noscript", - "object", - "ol", - "optgroup", - "option", - "output", - "p", - "param", - "picture", - "plaintext", - "portal", - "pre", - "progress", - "q", - "rb", - "rp", - "rt", - "rtc", - "ruby", - "s", - "samp", - "script", - "search", - "section", - "select", - "slot", - "small", - "source", - "span", - "strike", - "strong", - "style", - "sub", - "summary", - "sup", - "table", - "tbody", - "td", - "template", - "textarea", - "tfoot", - "th", - "thead", - "time", - "title", - "tr", - "track", - "tt", - "u", - "ul", - "var", - "video", - "wbr", - "xmp", - "stylesheet", - "js", -) diff --git a/src/view/config.py b/src/view/config.py deleted file mode 100644 index a98ed722..00000000 --- a/src/view/config.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -view.py configuration APIs - -This module contains `load_config`, `Config`, and all subcategories of `Config`. -""" - -from __future__ import annotations -import runpy -from ipaddress import IPv4Address -from pathlib import Path -from typing import Any, Dict, List, Literal, Union -from pydantic import Field -from pydantic_settings import BaseSettings -from typing_extensions import TypeAlias -import toml -from .exceptions import ViewInternalError -from .typing import TemplateEngine - -__all__ = ( - "AppConfig", - "ServerConfig", - "LogConfig", - "MongoConfig", - "PostgresConfig", - "SQLiteConfig", - "DatabaseConfig", - "TemplatesConfig", - "BuildStep", - "BuildConfig", - "Config", - "make_preset", - "load_config", -) - - -class AppConfig(BaseSettings): - loader: Literal["manual", "simple", "filesystem", "patterns", "custom"] = "manual" - app_path: str = "app.py:app" - uvloop: Union[Literal["decide"], bool] = "decide" - loader_path: Path = Path("./routes") - - -class ServerConfig(BaseSettings): - host: IPv4Address = IPv4Address("0.0.0.0") - port: int = 5000 - backend: Literal["uvicorn", "hypercorn", "daphne"] = "uvicorn" - extra_args: Dict[str, Any] = Field(default_factory=dict) - - -class LogConfig(BaseSettings): - level: Union[Literal["debug", "info", "warning", "error", "critical"], int] = "info" - fancy: bool = True - server_logger: bool = False - pretty_tracebacks: bool = True - startup_message: bool = True - - -class MongoConfig(BaseSettings): - host: IPv4Address - port: int - username: str - password: str - database: str - - -class PostgresConfig(BaseSettings): - database: str - user: str - password: str - host: IPv4Address - port: int - - -class SQLiteConfig(BaseSettings): - file: Path - - -class MySQLConfig(BaseSettings): - host: IPv4Address - user: str - password: str - database: str - - -class DatabaseConfig(BaseSettings): - type: Literal["sqlite", "mysql", "postgres", "mongo"] = "sqlite" - mongo: Union[MongoConfig, None] = None - postgres: Union[PostgresConfig, None] = None - sqlite: Union[SQLiteConfig, None] = SQLiteConfig(file=Path("view.db")) - mysql: Union[MySQLConfig, None] = None - - -class TemplatesConfig(BaseSettings): - directory: Path = Path("./templates") - locals: bool = True - globals: bool = True - engine: TemplateEngine = "view" - - -Platform: TypeAlias = Literal[ - "windows", "mac", "linux", "macOS", "Windows", "Linux", "Mac", "MacOS" -] - - -class BuildStep(BaseSettings): - platform: Union[List[Platform], Platform, None] = None - requires: List[str] = Field(default_factory=list) - command: Union[str, None, List[str]] = None - script: Union[Path, None, List[Path]] = None - - -class BuildConfig(BaseSettings): - path: Path = Path("./build") - default_steps: Union[List[str], None] = None - steps: Dict[str, Union[BuildStep, List[BuildStep]]] = Field(default_factory=dict) - parallel: bool = False - - -class Config(BaseSettings): - dev: bool = True - env: Dict[str, Any] = Field(default_factory=dict) - app: AppConfig = Field(default_factory=AppConfig) - server: ServerConfig = Field(default_factory=ServerConfig) - log: LogConfig = Field(default_factory=LogConfig) - templates: TemplatesConfig = Field(default_factory=TemplatesConfig) - build: BuildConfig = Field(default_factory=BuildConfig) - - -B_OPEN = "{" -B_CLOSE = "}" -B_OC = "{}" - - -def make_preset(tp: str, loader: str) -> str: - if tp == "toml": - return f"""# See https://view.zintensity.dev/getting-started/configuration/ -dev = true # Development mode - -[app] -loader = "{loader}" # Loader strategy -app_path = "app.py:app" # Location and name of the app instance -uvloop = "decide" # Use uvloop for the event loop -loader_path = "routes/" # Loader-specific path - -[server] -host = "0.0.0.0" # Address to bind -port = 5000 # Port to bind -backend = "uvicorn" # ASGI server - -[server.extra_args] -# ASGI backend specific arguments -# workers = 4 - -[log] -level = "info" # Log level -server_logger = false # Show ASGI servers raw logs -fancy = true # Enable fancy output -pretty_tracebacks = true # Use Rich exceptions -startup_message = true # Show view.py welcome message - -[templates] -directory = "./templates" # Template search directory -locals = true # Allow templates to access local variables when rendered -globals = true # Same as above, but with global variables -engine = "view" # Default template engine -""" - if tp == "json": - return f"""{B_OPEN} - "dev": true, - "app": {B_OPEN} - "loader": "{loader}" - {B_CLOSE} - "server": {B_OC}, - "log": {B_OC} -{B_CLOSE}""" - - if tp == "py": - return """from view import Config - -CONFIG = Config()""" - - raise ViewInternalError("bad file type") - - -def load_config( - path: Path | None = None, - *, - directory: Path | None = None, -) -> Config: - """ - Load the configuration file. If there is no existing configuration file, a virtual configuration is generated with default values. - - Args: - path: Path to get the configuration from. - directory: Where to look for the configuration. - """ - paths = ( - "view.toml", - "view.json", - "view_config.py", - "config.py", - ) - - if path: - if directory: - return Config.model_validate(toml.load(directory / path)) - # Not sure why someone would do this, but it's good to support it - return Config.model_validate(toml.load(path)) - - for i in paths: - p = Path(i) if not directory else directory / i - - if not p.exists(): - continue - - if p.suffix == ".py": - glbls = runpy.run_path(str(p)) - config = glbls.get("CONFIG") - if not isinstance(config, Config): - raise TypeError(f"{config!r} is not an instance of Config") - return config - - return Config.model_validate(toml.load(p)) - - return Config() diff --git a/src/view/databases.py b/src/view/databases.py deleted file mode 100644 index 7d951467..00000000 --- a/src/view/databases.py +++ /dev/null @@ -1,327 +0,0 @@ -from __future__ import annotations - -import asyncio -from abc import ABC, abstractmethod -from datetime import datetime -from enum import Enum -from typing import Any, ClassVar, Set, TypeVar, Union, get_origin, get_type_hints - -from typing_extensions import Annotated, Self, dataclass_transform, get_args - -from ._util import is_annotated, is_union, needs_dep -from .exceptions import InvalidDatabaseSchemaError -from .routing import BodyParam -from .typing import ViewBody - -try: - import aiosqlite -except ModuleNotFoundError as e: - needs_dep("aiosqlite", e, "databases") - -try: - import mysql.connector -except ModuleNotFoundError as e: - needs_dep("mysql-connector-python", e, "databases") - -try: - import psycopg2 -except ModuleNotFoundError as e: - needs_dep("psycopg2-binary", e, "databases") - -try: - import pymongo -except ModuleNotFoundError as e: - needs_dep("pymongo", e, "databases") - -__all__ = ("Model",) -NoneType = type(None) - - -class _Connection(ABC): - @abstractmethod - async def connect(self) -> None: ... - - @abstractmethod - async def close(self) -> None: ... - - @abstractmethod - async def insert(self, table: str, json: dict) -> None: ... - - @abstractmethod - async def find(self, table: str, json: dict) -> None: ... - - @abstractmethod - async def migrate(self, table: str, vbody: dict) -> None: ... - - -_SQL_TYPES: dict[type, str] = { - str: "TEXT", - float: "FLOAT", - int: "INT", - bytes: "BLOB", - datetime: "DATETIME", -} - - -def _sql_translate(vbody: dict) -> str: - items: list[str] = [] - - for k, v in vbody.items(): - tp = _SQL_TYPES.get(v) - - if tp: - items.append(f"{k} {tp} NOT NULL") - continue - - flags = ["NOT NULL"] - origin = get_origin(v) - - if is_union(type(v)): - args = get_args(v) - if (len(args) != 2) or (NoneType not in args): - raise InvalidDatabaseSchemaError( - "union types are not allowed in databases, other than None", - ) - - flags.pop(0) - v = args[0] if args[0] is not None else args[1] - - if is_annotated(v): - print(get_args(v)) - - tp = _SQL_TYPES.get(v) - - if tp: - items.append(f"{k} {tp}{' '.join(flags)}") - continue - - raise InvalidDatabaseSchemaError(f"{v} is not a supported type") - - return ", ".join(items) - - -class _PostgresConnection(_Connection): - def __init__( - self, - database: str | None = None, - user: str | None = None, - password: str | None = None, - host: str | None = None, - port: int | None = None, - ) -> None: - self.database = database - self.user = user - self.password = password - self.host = host - self.port = port - self.connection = None - self.cursor = None - - def create_database_connection(self): - return psycopg2.connect( - database=self.database, - user=self.user, - password=self.password, - host=self.host, - port=self.port, - ) - - async def connect(self) -> None: - try: - self.connection = await asyncio.to_thread(self.create_database_connection) - self.cursor = await asyncio.to_thread(self.connection.cursor) # type: ignore - except psycopg2.Error as e: - raise ValueError( - "Unable to connect to the database - invalid credentials" - ) from e - - async def close(self) -> None: - if self.connection is not None: - await asyncio.to_thread(self.connection.close) - self.connection = None - self.cursor = None - - -class _SQLiteConnection(_Connection): - def __init__(self, database_file: str) -> None: - self.database_file = database_file - self.connection: aiosqlite.Connection | None = None - self.cursor: aiosqlite.Cursor | None = None - - async def connect(self) -> None: - self.connection = await aiosqlite.connect(self.database_file) - self.cursor = await self.connection.cursor() - - async def close(self) -> None: - if self.connection is not None: - assert self.cursor is not None - await self.cursor.close() - await self.connection.close() - self.connection = None - self.cursor = None - - async def insert(self, table: str, json: dict) -> None: ... - - async def find(self, table: str, json: dict) -> None: ... - - async def migrate(self, table: str, vbody: dict): - assert self.cursor is not None - sql = _sql_translate(vbody) - print(sql) - await self.cursor.execute(f"CREATE TABLE IF NOT EXISTS {table} ({sql})") - - -class _MySQLConnection: - def __init__( - self, - host: str, - user: str, - password: str, - database: str, - ) -> None: - self.host = host - self.user = user - self.password = password - self.database = database - self.connection = None - self.cursor = None - - async def connect(self) -> None: - try: - self.connection = await asyncio.to_thread( - mysql.connector.connect, - host=self.host, - user=self.user, - password=self.password, - database=self.database, - ) - - self.cursor = await asyncio.to_thread(self.connection.cursor) - except mysql.connector.Error as e: - raise ValueError( - "Unable to connect to the database - invalid credentials" - ) from e - - async def close(self): - if self.connection is not None: - assert self.cursor is not None - await asyncio.to_thread(self.cursor.close) - await asyncio.to_thread(self.connection.close) - self.connection = None - self.cursor = None - - -class _MongoDBConnection: - def __init__( - self, - host: str, - port: int, - username: str, - password: str, - database: str, - ): - self.host = host - self.port = port - self.username = username - self.password = password - self.database = database - self.client = None - self.db = None - - async def connect(self): - self.client = await asyncio.to_thread( - pymongo.MongoClient, - host=self.host, - port=self.port, - username=self.username, - password=self.password, - authSource=self.database, - ) - self.db = self.client[self.database] - - async def close(self): - if self.client is not None: - await asyncio.to_thread(self.client.close) - self.client = None - self.db = None - - -class _Meta(Enum): - HASH = 0 - ID = 1 - EXCLUDE = 2 - - -class _ModelMeta: - def __init__(self, tp: _Meta): - self.tp = tp - - -T = TypeVar("T") -Hashed = Annotated[T, _ModelMeta(_Meta.HASH)] -Id = Annotated[T, _ModelMeta(_Meta.ID)] -Exclude = Annotated[T, _ModelMeta(_Meta.EXCLUDE)] - - -@dataclass_transform() -class Model: - view_initialized: ClassVar[bool] = False - conn: ClassVar[Union[_Connection, None]] = None - exclude: ClassVar[Set[str]] - __view_body__: ClassVar[ViewBody] = {} - __view_table__: ClassVar[str] - - def __init__(self, *args: Any, **kwargs: Any): - for index, k in enumerate(self.__view_body__): - if index >= len(args): - setattr(self, k, kwargs[k]) - else: - setattr(self, k, args[index]) - - def __init_subclass__(cls, **kwargs: Any): - cls.__view_table__ = kwargs.get("table") or ("vpy_" + cls.__name__.lower()) - model_hints = get_type_hints(Model) - actual_hints = get_type_hints(cls) - params = { - k: actual_hints[k] for k in (model_hints.keys() ^ actual_hints.keys()) - } - - for k, v in params.items(): - df = cls.__dict__.get(k) - if df: - cls.__view_body__[k] = BodyParam(types=v, default=df) - else: - cls.__view_body__[k] = v - - def __repr__(self) -> str: - body = [f"{k}={repr(getattr(self, k))}" for k in self.__view_body__] - return f"{type(self).__name__}({', '.join(body)})" - - __str__ = __repr__ - - @classmethod - def find(cls) -> list[Self]: ... - - @classmethod - def unique(cls) -> Self: ... - - def exists(self) -> bool: ... - - def save(self) -> None: - conn = self._assert_conn() - - conn.insert(self.__view_table__, self._json()) - - def _json(self) -> dict[str, Any]: ... - - def json(self) -> dict[str, Any]: ... - - @classmethod - def from_json(cls, json: dict[str, Any]) -> Self: ... - - @classmethod - def _assert_conn(cls) -> _Connection: - if not cls.conn: - raise Exception - - return cls.conn diff --git a/src/view/default_page.py b/src/view/default_page.py deleted file mode 100644 index 344f0141..00000000 --- a/src/view/default_page.py +++ /dev/null @@ -1,8 +0,0 @@ -from .response import HTML - -__all__ = ("default_page",) - - -def default_page() -> HTML: - """Return the view.py default page.""" - return HTML("") diff --git a/src/view/exceptions.py b/src/view/exceptions.py deleted file mode 100644 index 8d6a47dd..00000000 --- a/src/view/exceptions.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -All view.py exceptions - -Everything in this module inherits from `ViewError` or `ViewWarning`. -""" - -from __future__ import annotations - -from rich.console import RenderableType - -__all__ = ( - "ViewWarning", - "NotLoadedWarning", - "ViewError", - "BadEnvironmentError", - "InvalidBodyError", - "MistakeError", - "LoaderWarning", - "AppNotFoundError", - "DatabaseError", - "InvalidDatabaseSchemaError", - "DuplicateRouteError", - "InvalidRouteError", - "ViewInternalError", - "ConfigurationError", - "NeedsDependencyError", - "InvalidTemplateError", - "TypeValidationError", - "BuildWarning", - "BuildError", - "MissingRequirementError", - "InvalidResultError", - "UnknownBuildStepError", - "PlatformNotSupportedError", - "WebSocketError", - "WebSocketExpectError", - "WebSocketHandshakeError", - "InvalidCustomLoaderError", - "WebSocketDisconnectError", - "MissingAppError", -) - - -class ViewWarning(UserWarning): - """Base class for all warnings in view.py""" - - -class NotLoadedWarning(ViewWarning): - """load() was never called""" - - -class LoaderWarning(ViewWarning): - """A warning from the loader.""" - - -class ViewError(Exception): - """Base class for exceptions in view.py""" - - def __init__( - self, - *args: object, - hint: RenderableType | None = None, - ) -> None: - self.hint = hint - super().__init__(*args) - - -class BadEnvironmentError(ViewError): - """An environment variable is missing.""" - - -class InvalidBodyError(ViewError): - """The specified type cannot be used as a view body.""" - - -class MistakeError(ViewError): - """The user made a mistake.""" - - -class AppNotFoundError(ViewError, FileNotFoundError): - """Couldn't find the app from the given path.""" - - -class DatabaseError(ViewError): - """Database error.""" - - -class InvalidDatabaseSchemaError(DatabaseError): - """Database schema is invalid.""" - - -class DuplicateRouteError(ViewError): - """Duplicate routes in loader.""" - - -class InvalidRouteError(ViewError): - """Something is wrong with a route.""" - - -class ViewInternalError(ViewError): - """Something was wrong internally.""" - - -class ConfigurationError(ViewError): - """Something is wrong with the configuration.""" - - -class NeedsDependencyError(ViewError): - """View needs a dependency that wasn't installed.""" - - -class InvalidTemplateError(ViewError): - """Something is wrong with a template.""" - - -class TypeValidationError(TypeError, ViewError): - """Could not assign the object to the target type.""" - - -class BuildWarning(ViewWarning): - """Warning issued during building.""" - - -class BuildError(ViewError): - """Build failed.""" - - -class MissingRequirementError(BuildError): - """Build requirement is missing.""" - - -class InvalidResultError(ViewError, TypeError): - """Invalid route result.""" - - -class UnknownBuildStepError(BuildError): - """Undefined build step was used.""" - - -class PlatformNotSupportedError(BuildError): - """Build step does not support the platform.""" - - -class WebSocketError(ViewError): - """Something related to a WebSocket failed.""" - - -class WebSocketHandshakeError(WebSocketError): - """WebSocket handshake went wrong somehow.""" - - -class WebSocketDisconnectError(WebSocketHandshakeError): - """WebSocket client disconnected unexpectedly.""" - - -class WebSocketExpectError(WebSocketError, AssertionError, TypeError): - """WebSocket received unexpected message.""" - - -class InvalidCustomLoaderError(ViewError): - """Custom loader is invalid.""" - - -class MissingAppError(ViewError): - """No view.py app was found.""" diff --git a/src/view/integrations.py b/src/view/integrations.py deleted file mode 100644 index f548376f..00000000 --- a/src/view/integrations.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Any - -from ._util import needs_dep - -__all__ = ("page",) - -try: - from reactpy.core.types import VdomDict - from reactpy.core.vdom import make_vdom_constructor - - _html = make_vdom_constructor("html") - _body = make_vdom_constructor("body") - - def page(head: VdomDict, *body: VdomDict, lang: str = "en") -> VdomDict: # type: ignore - return _html({"lang": lang}, head, _body(*body)) - -except ImportError: - VdomDict = dict[str, Any] - - def page(head: VdomDict, *body: VdomDict, lang: str = "en") -> VdomDict: - needs_dep("reactpy") diff --git a/src/view/patterns.py b/src/view/patterns.py deleted file mode 100644 index 402a7ba5..00000000 --- a/src/view/patterns.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -view.py patterns API - -This contains the `path()` function, which acts as the logic for the Django-like `patterns` loader. -""" - -from __future__ import annotations - -from ._util import run_path -from .exceptions import DuplicateRouteError, InvalidRouteError -from .routing import ( - Callable, - Method, - Route, - RouteOrCallable, - delete, - get, - options, - patch, - post, - put, -) -from .routing import route as route_impl -from .typing import StrMethod, ViewRoute - -__all__ = "RouteInput", "path" -_Get = None - -_FUNC_MAPPINGS = { - Method.GET: get, - Method.POST: post, - Method.PUT: put, - Method.PATCH: patch, - Method.DELETE: delete, - Method.OPTIONS: options, -} - -_STR_MAPPINGS = { - "get": Method.GET, - "post": Method.POST, - "put": Method.PUT, - "patch": Method.PATCH, - "delete": Method.DELETE, - "options": Method.OPTIONS, -} - -RouteInput = Callable[[RouteOrCallable], Route] - - -def _get_method_enum(method: StrMethod | None | Method) -> Method: - if isinstance(method, str): - method = method.lower() # type: ignore - - if method in _STR_MAPPINGS: - method_enum: Method = _STR_MAPPINGS[method] # type: ignore - else: - method_enum: Method = method or Method.GET # type: ignore - - return method_enum - - -def path( - target: str, - path_or_function: str | ViewRoute | Route, - *inputs: RouteInput, - method: Method | StrMethod | None = _Get, -) -> Route: - """ - Function to generate a `Route` object using Django-like routing. - - Args: - target: URL path of the route. - path_or_function: Path to a route, a function representing a route, or a Route object (meaning a function was decorated with `get()` or some other router function). - inputs: All route inputs, e.g. `query()` or `body()`. Note that route inputs decorated on the function itself are automatically transferred. - method: Method of the route. `GET` by default. - - Raises: - DuplicateRouteError: Parameter `target` is a string specifying a module path, and it contains multiple `Route` objects. - InvalidRouteError: `target` is a string specifying a module that has no `Route` objects. - - Example: - ```py - # urls.py - from view import path, body - from .routes import index, create_account - - patterns = [ - path("/", index), - path( - "/create", - create_account, - body("username", str), - body("password", str), - method="POST" - ) - ] - ``` - """ - if isinstance(path_or_function, str): - mod = run_path(path_or_function) - route: Route | ViewRoute | None = None - - for v in mod.values(): - if isinstance(v, Route) or callable(v): - if route: - raise DuplicateRouteError( - f"multiple routes found in {path_or_function}" - ) - - route = v - - if not route: - raise InvalidRouteError(f"no route in {path_or_function}") - else: - route = path_or_function - - if not isinstance(route, Route): - method_enum = _get_method_enum(method) - func = _FUNC_MAPPINGS[method_enum] - route_obj: Route = func(target)(route) - else: - if not route.method: - route_obj = route_impl(target)(route) - route_obj.method_list = route.method_list - else: - method_enum = _get_method_enum(method or route.method) - func = _FUNC_MAPPINGS[method_enum] - route_obj = func(target)(route) - - for i in inputs: - i(route_obj) - - return route_obj diff --git a/src/view/py.typed b/src/view/py.typed deleted file mode 100644 index e69de29b..00000000 diff --git a/src/view/response.py b/src/view/response.py deleted file mode 100644 index 936fcfd0..00000000 --- a/src/view/response.py +++ /dev/null @@ -1,359 +0,0 @@ -""" -view.py public response APIs - -This module contains the `Response` class, which is conventionally used as the base response in view.py -All other classes that inherit from it are contained in this module. -""" - -from __future__ import annotations - -import secrets -from collections.abc import Awaitable -from contextlib import suppress -from datetime import datetime as DateTime -from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Generic, TextIO, TypeVar, Union - -import aiofiles -import ujson -from typing_extensions import final - -from .exceptions import InvalidResultError -from .typing import ( - BodyTranslateStrategy, - MaybeAwaitable, - SameSite, - SupportsViewResult, - ViewResult, -) -from .util import call_result, timestamp - -if TYPE_CHECKING: - from reactpy.types import VdomDict, VdomJson - - from _view import Context - -with suppress(ImportError): - from reactpy import vdom_to_html - from reactpy.backend.hooks import ConnectionContext - from reactpy.backend.types import Connection, Location - from reactpy.core.layout import Layout - -T = TypeVar("T") - -__all__ = ( - "Response", - "HTML", - "JSON", -) - -_Find = None -HTMLContent = Union[TextIO, str, Path] - - -class Response(Generic[T]): - """ - Base view.py response class. - - Technically speaking, it's not required for an object to inherit from this class if it wants to be used as a response object - to do that, all an object has to do is implement `__view_result__`. - However, it's good convention to use this as the base class for objects that are solely used as a response. - - It's probably not a good idea to reuse responses (i.e. return the same `Response` object from multiple calls), but it's not prevented. - """ - - def __init__( - self, - body: T, - status: int = 200, - headers: dict[str, str] | None = None, - *, - body_translate: BodyTranslateStrategy | None = _Find, - content_type: str = "text/plain", - ) -> None: - """ - Args: - body: Response body. This can be any type, and the `body_translate` parameter determines how it's converted into a string that view.py can read. - status: Status code to send to the user. - headers: Dictionary containing the response headers. - body_translate: This determines how the underlying `__view_result__` translates the body object into a string. By default, if the body object has a `__view_result__` attribute, this is set to `"result"`, and `"str"` otherwise. If this is `"custom"`, the `translate_body` method is called which can define any logic to translate the object. Note that if you're directly instantiating `Response`, you probably don't want to set this parameter - it's more for use from subclasses. - content_type: MIME type of the body. `"text/plain"` by default. - """ - self.body = body - """Implementation-specific body object. This will always be what's passed to the constructor of `Response`""" - self.status = status - """Status code to be sent alongside the response.""" - self.headers = headers or {} - """Headers to be sent along with the response.""" - self.content_type: str = content_type - """MIME type of the body. This is added to the headers last-minute in the `__view_result__` call, so you can update this value after it has been set.""" - self._raw_headers: list[tuple[bytes, bytes]] = [] - - if body_translate: - self.translate = body_translate - else: - self.translate = "str" if not hasattr(body, "__view_result__") else "result" - - def translate_body(self, body: T) -> MaybeAwaitable[str | bytes]: - """ - Translate the body via the `"custom"` body translate strategy. On classes that don't implement this, a `NotImplementedError` is raised. - - Args: - body: Body object to translate to a string. This is dependent on the class. - - Returns: - A `str` or `bytes` containing the translated body. - """ - raise NotImplementedError( - 'the "custom" translate strategy can only be used in subclasses that implement it' - ) # noqa - - @final - def cookie( - self, - key: str, - value: str = "", - *, - max_age: int | None = None, - expires: int | DateTime | None = None, - path: str | None = None, - domain: str | None = None, - http_only: bool = False, - same_site: SameSite = "lax", - partitioned: bool = False, - secure: bool = False, - ) -> None: - """ - Set a cookie. - - Args: - key: Cookie name. - value: Cookie value. - max_age: Max age of the cookies. - expires: When the cookie expires. - domain: Domain the cookie is valid at. - http_only: Whether the cookie should be HTTP only. - same_site: SameSite setting for the cookie. - partitioned: Whether to tie it to the top level site. - secure: Whether the cookie should enforce HTTPS. - """ - cookie_str = f"{key}={value}; SameSite={same_site}".encode() - - if expires: - dt = ( - expires - if isinstance(expires, DateTime) - else DateTime.fromtimestamp(expires) - ) - ts = timestamp(dt) - cookie_str += f"; Expires={ts}".encode() - - if http_only: - cookie_str += b"; HttpOnly" - - if domain: - cookie_str += f"; Domain={domain}".encode() - - if max_age: - cookie_str += f"; Max-Age={max_age}".encode() - - if partitioned: - cookie_str += b"; Partitioned" - - if secure: - cookie_str += b"; Secure" - - if path: - cookie_str += f"; Path={path}".encode() - - self._raw_headers.append((b"Set-Cookie", cookie_str)) - - def _build_headers(self) -> tuple[tuple[bytes, bytes], ...]: - headers: list[tuple[bytes, bytes]] = [ - *self._raw_headers, - (b"content-type", self.content_type.encode()), - ] - - for k, v in self.headers.items(): - headers.append((k.encode(), v.encode())) - - return tuple(headers) - - @final - async def __view_result__(self, ctx: Context) -> ViewResult: - """ - view.py response function. - This should not be called manually, and it's implementation should be considered unstable, but this will always be here. - """ - body: str | bytes = "" - if self.translate == "str": - if isinstance(self.body, bytes): - body = self.body.decode() - else: - body = str(self.body) - elif self.translate == "repr": - body = repr(self.body) - elif self.translate == "custom": - coro_or_body = self.translate_body(self.body) - if isinstance(coro_or_body, Awaitable): - body = await coro_or_body - else: - body = coro_or_body - else: - if not isinstance(self.body, SupportsViewResult): - raise InvalidResultError( - f"{self.body} does not support __view_result__" - ) - - body_res = await call_result(self.body, ctx=ctx) - if isinstance(body_res, (str, bytes)): - body = body_res - elif isinstance(body_res, tuple): - body = body_res[0] # type: ignore - if not isinstance(body, (str, bytes)): - raise InvalidResultError( - f"expected str or bytes object, got {body}" - ) - else: - raise InvalidResultError( - f"unexpected result from __view_result__: {body_res}" - ) - - return body, self.status, self._build_headers() - - -class HTML(Response[HTMLContent]): - """ - HTML response wrapper. - """ - - def __init__( - self, - body: HTMLContent, - status: int = 200, - headers: dict[str, str] | None = None, - ) -> None: - super().__init__( - body, - status, - headers, - body_translate="custom", - content_type="text/html", - ) - - def translate_body(self, body: HTMLContent) -> str: - parsed_body = "" - - if isinstance(body, Path): - parsed_body = body.read_text() - elif isinstance(body, str): - parsed_body = body - else: - try: - parsed_body = body.read() - except AttributeError: - raise TypeError( - f"expected TextIO, str, Path, not {type(body)}", # noqa - ) from None - - return parsed_body - - @classmethod - async def from_file(cls, path: str | Path) -> HTML: - """ - Read an HTML file and load it as a response. - Roughly speaking, this shouldn't be used over `template()`, but it's a better option if you have a pure HTML file that doesn't need to get put through an extra template step. - - Args: - path: `str` or `Path` object containing the path to the HTML file. - - Example: - ```py - from view import HTML, get - - @get("/") - async def index(): - return await HTML.from_file("content/index.html") - ``` - """ - async with aiofiles.open(path) as f: - return cls(await f.read()) - - -class JSON(Response[Dict[str, Any]]): - """ - JSON response wrapper. - - Dictionaries passed to this object are serialized by `ujson.dumps`. - """ - - def __init__( - self, - body: dict[str, Any], - status: int = 200, - headers: dict[str, str] | None = None, - ) -> None: - super().__init__( - body, - status, - headers, - body_translate="custom", - content_type="application/json", - ) - - def translate_body(self, body: dict[str, Any]) -> str: - return ujson.dumps(body) - - -async def _reactpy_bootstrap(self: Component, ctx: Context) -> ViewResult: - from .app import get_app - - app = get_app() - hook = secrets.token_hex(64) - app._reactive_sessions[hook] = self - - async with Layout( - ConnectionContext( - self, - value=Connection( - {"reactpy": {"id": hook}}, - Location(ctx.path, ""), - carrier=None, - ), - ) - ) as layout: - # this is especially ugly, but reactpy renders - # the first few nodes as nothing for whatever reason. - vdom: VdomJson = (await layout.render())["model"]["children"][0]["children"][0][ - "children" - ][0] - - if vdom["tagName"] != "html": - raise RuntimeError("root react component must be html (see view.page())") - - children = vdom.get("children") - if not children: - raise RuntimeError("root react component has no children") - - head: VdomDict = children[0] - if head["tagName"] != "head": - raise RuntimeError(f"expected a element, got <{head['tagName']}>") - - body: VdomDict = children[1] - if body["tagName"] != "body": - raise RuntimeError(f"expected a element, got <{body['tagName']}>") - - prerender_head = vdom_to_html(head) - prerender_body = vdom_to_html(body) - return await app.template( - "./client/dist/index.html", - directory=Path("./"), - engine="view", - prerender_head=prerender_head, - prerender_body=prerender_body, - ) - - -with suppress(ImportError): - from reactpy.core.component import Component - - Component.__view_result__ = _reactpy_bootstrap # type: ignore diff --git a/src/view/routing.py b/src/view/routing.py deleted file mode 100644 index 7347fbf7..00000000 --- a/src/view/routing.py +++ /dev/null @@ -1,887 +0,0 @@ -""" -view.py public router APIs - -This module contains all the router functions (e.g. `get()`, `post()`, etc.). -""" - -from __future__ import annotations - -import asyncio -import builtins -import inspect -import re -from collections.abc import Awaitable, Sequence -from contextlib import suppress -from dataclasses import dataclass, field -from enum import Enum -from typing import ( - Any, - Callable, - Generic, - Iterable, - Literal, - Type, - TypeVar, - Union, - overload, -) - -from typing_extensions import ParamSpec, TypeAlias - -from ._logging import Service -from ._util import LoadChecker, make_hint -from .exceptions import InvalidRouteError, MissingAppError, MistakeError -from .typing import ( - TYPE_CHECKING, - Middleware, - StrMethod, - Validator, - ValueType, - ViewResult, - ViewRoute, - WebSocketRoute, -) - -if TYPE_CHECKING: - from .app import App - -__all__ = ( - "get", - "post", - "put", - "patch", - "delete", - "options", - "query", - "body", - "route_types", - "BodyParam", - "context", - "route", - "websocket", - "Route", - "Router", -) - -PART = re.compile(r"{(((\w+)(: *(\w+)))|(\w+))}") - - -class Method(Enum): - GET = 1 - POST = 2 - DELETE = 3 - PATCH = 4 - PUT = 5 - OPTIONS = 6 - WEBSOCKET = 7 - - -V = TypeVar("V", bound="ValueType") -P = ParamSpec("P") - - -@dataclass -class BodyParam(Generic[V]): - types: type[V] | Sequence[type[V]] | tuple[type[V], ...] - default: V - - -@dataclass -class RouteInput(Generic[V]): - name: str - is_body: bool - tp: tuple[type[V], ...] - default: V | None | _NoDefaultType - doc: str | None - validators: Sequence[Validator[V]] - - -@dataclass -class Part(Generic[V]): - name: str - type: type[V] | None - - -RouteData = Literal[1, 2] - - -async def wrap_step(app: App, step: str) -> None: - from .build import run_step - - try: - await run_step(app, step) - except Exception as e: - Service.exception(e) - raise e - - -@dataclass -class Route(Generic[P], LoadChecker): - """ - Standard view.py route object. - - It's highly unlikely to need to instantiate this class manually, use a router function or some other API. - The internals of this API are considered unstable, do not except stability! Use this only for type hinting. - """ - - func: ViewRoute[P] - path: str | None - method: Method | None - inputs: list[RouteInput | RouteData] - doc: str | None = None - cache_rate: int = -1 - errors: dict[int, ViewRoute] | None = None - extra_types: dict[str, Any] = field(default_factory=dict) - parts: list[str | Part[Any]] = field(default_factory=list) - middleware_funcs: list[Middleware[P]] = field(default_factory=list) - method_list: list[Method] | None = None - steps: Iterable[str] | None = None - parallel_build: bool | None = None - app: App | None = ( - None # This will only be non-None after the loader has been called. - ) - - def error(self, status_code: int): - def wrapper(handler: ViewRoute): - if not self.errors: - self.errors = {} - - self.errors[status_code] = handler - return handler - - return wrapper - - def middleware(self, func_or_none: Middleware[P] | None = None): - """Define a middleware function for the route.""" - - def inner(func: Middleware[P]) -> None: - self.middleware_funcs.append(func) - - if func_or_none: - return inner(func_or_none) - - return inner - - def __repr__(self): - return f"Route({self.method.name if self.method else 'ANY_METHOD'}(\"{self.path or self.func.__name__}\"))" # noqa - - __str__ = __repr__ - - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ViewResult: - coros: list[Awaitable] = [] - assert self.app, "app is None" - - for step in self.steps or (): - coros.append(wrap_step(self.app, step)) - - if self.parallel_build: - await asyncio.gather(*coros) - else: - for coro in coros: - await coro - - if self.middleware_funcs: - index = len(self.middleware_funcs) - 1 - mw = self.middleware_funcs[index] - - async def call_next() -> ViewResult: - nonlocal mw - nonlocal index - index -= 1 - - if index < 0: - func = self.func(*args, **kwargs) - - if isinstance(func, Awaitable): - return await func - - return func # type: ignore - - mw = self.middleware_funcs[index] - return await mw(call_next, *args, **kwargs) - - return await mw(call_next, *args, **kwargs) - - result = self.func(*args, **kwargs) - if isinstance(result, Awaitable): - return await result - - # The type checker still thinks it's asynchronous, for some reason - return result # type: ignore - - -RouteOrWebsocket: TypeAlias = Union[Route[P], WebSocketRoute[P]] -RouteOrCallable: TypeAlias = Union[Route[P], ViewRoute[P]] - - -def _ensure_route(r: RouteOrCallable[P]) -> Route[P]: - if isinstance(r, Route): - return r - - return Route(r, None, Method.GET, []) - - -def route_types( - r: RouteOrCallable[P], - data: type[Any] | tuple[type[Any]] | dict[str, Any], -) -> Route: - route = _ensure_route(r) - if isinstance(data, tuple): - for i in data: - route.extra_types[i.__name__] = i - elif isinstance(data, dict): - route.extra_types.update(data) - elif isinstance(data, type): - route.extra_types[data.__name__] = data - else: - raise InvalidRouteError( - "expected type, tuple of tuples," f" or a dict, got {type(data).__name__}" - ) - - return route - - -_DefinedByConfig = None - - -def _method( - r: RouteOrCallable[P], - raw_path: str | None, - doc: str | None, - method: Method | None, - cache_rate: int, - *, - method_list: list[Method] | None = None, - inputs: list[RouteInput | RouteData] | None = None, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Route[P]: - route = _ensure_route(r) - route.method = method - route.cache_rate = cache_rate - route.method_list = method_list - route.steps = steps - route.parallel_build = parallel_build - util_path = raw_path or "/" - - if not util_path.startswith("/"): - raise MistakeError( - "paths must started with a slash", - hint=make_hint( - f'This should be "/{util_path}" instead', - back_lines=2, - ), - ) - - if util_path.endswith("/") and (len(util_path) != 1): - raise MistakeError( - "paths must not end with a slash", - hint=make_hint(f'This should be "{util_path[:-1]}" instead', back_lines=2), - ) - - if "{" in util_path: - assert raw_path - parts: list[str | Part] = [] - - for index, i in enumerate(util_path[1:].split("/")): - match = PART.match(i) - - if not match: - parts.append("/" + i) - continue - - if index == 0: - raise MistakeError( - "first part must not be a path parameter", - hint=make_hint("Not allowed!", back_lines=2), - ) - - if match.group(6): - parts.append(Part("/" + match.group(6), None)) - else: - parts.append( - Part( - match.group(3), - { - **globals(), - **route.extra_types, - **{i: getattr(builtins, i) for i in dir(builtins)}, - }[match.group(5)], - ) - ) - - route.parts = parts - route.path = None - else: - route.path = raw_path - - if doc: - route.doc = doc - else: - route.doc = route.func.__doc__ - - if inputs: - route.inputs.extend(inputs) - - return route - - -Path: TypeAlias = Callable[[RouteOrCallable[P]], Route[P]] - - -def _method_wrapper( - path_or_route: str | None | RouteOrCallable[P], - doc: str | None, - method: Method | None, - cache_rate: int, - *, - method_list: list[Method] | None = None, - inputs: list[RouteInput | RouteData] | None = None, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - def inner(r: RouteOrCallable[P]) -> Route[P]: - if (not isinstance(path_or_route, str)) and path_or_route: - raise TypeError(f"{path_or_route!r} is not a string") - - return _method( - r, - path_or_route, # type: ignore - doc, - method, - cache_rate, - method_list=method_list, - inputs=inputs, - steps=steps, - parallel_build=parallel_build, - ) - - if not path_or_route: - return inner - - if isinstance(path_or_route, str): - return inner - - return inner - - -def get( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add a GET route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import get - - @get("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - Method.GET, - cache_rate, - steps=steps, - parallel_build=parallel_build, - ) - - -def post( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add a POST route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import post - - @post("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - Method.POST, - cache_rate, - steps=steps, - parallel_build=parallel_build, - ) - - -def patch( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add a PATCH route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import patch - - @patch("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - Method.PATCH, - cache_rate, - steps=steps, - parallel_build=parallel_build, - ) - - -def put( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add a PUT route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import put - - @put("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - Method.PUT, - cache_rate, - steps=steps, - parallel_build=parallel_build, - ) - - -def delete( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add a DELETE route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import delete - - @delete("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - Method.DELETE, - cache_rate, - steps=steps, - parallel_build=parallel_build, - ) - - -def options( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add an OPTIONS route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import options - - @options("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - Method.OPTIONS, - cache_rate, - steps=steps, - parallel_build=parallel_build, - ) - - -def websocket( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Callable[[RouteOrWebsocket[P]], Route[P]]: - """ - Add a websocket route. - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import websocket, WebSocket - - @websocket("/") - async def index_ws(ws: WebSocket): - async with ws: - await ws.send("Hello world") - ``` - """ - return _method_wrapper( # type: ignore - path_or_route, - doc, - Method.WEBSOCKET, - -1, - inputs=[2], - steps=steps, - parallel_build=parallel_build, - ) - - -_STR_METHOD_MAPPING: dict[StrMethod, Method] = { - "GET": Method.GET, - "POST": Method.POST, - "PUT": Method.PUT, - "PATCH": Method.PATCH, - "DELETE": Method.DELETE, - "OPTIONS": Method.OPTIONS, - "WEBSOCKET": Method.WEBSOCKET, -} - - -def route( - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - methods: Iterable[StrMethod] | None = None, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, -) -> Path[P]: - """ - Add a route that can be called with any method (or only specific methods). - - Args: - path_or_route: The path to this route, or the route itself. - doc: The description of the route to be used in documentation. - cache_rate: Reload the cache for this route every x number of requests. `-1` means to never cache. - methods: Methods that can be used to access this route. If this is `None`, then all methods are allowed. - steps: Build steps to run before this route is executed. - parallel_build: Whether to run the build steps in parallel. If this is not specified, this is defined by the app configuration. - - Example: - ```py - from view import route - - @route("/", methods=("GET", "POST")) - async def index(): - return "Hello, view.py!" - ``` - """ - return _method_wrapper( - path_or_route, - doc, - None, - cache_rate, - method_list=([_STR_METHOD_MAPPING[i] for i in methods] if methods else None), - steps=steps, - parallel_build=parallel_build, - ) - - -class _NoDefault: - __VIEW_NODEFAULT__ = 1 - - -_NoDefaultType = Type[_NoDefault] - - -def query( - name: str, - *tps: type[V], - doc: str | None = None, - default: V | None | _NoDefaultType = _NoDefault, -) -> Path[P]: - """ - Add a route input for a query parameter. - - Args: - name: The name of the parameter to read when the query string is received. - tps: All the possible types that are allowed to be used. If none are specified, the type is `Any`. - doc: Description of this parameter. - default: The default value to use if the key was not received. - - Example: - ```py - from view import new_app, query - - app = new_app() - - @app.get("/") - @query("greeting", str, doc="The greeting to use.", default="hello") - def index(greeting: str): - return f"{greeting}, world!" - - app.run() - ``` - """ - - frame = inspect.currentframe() - assert frame, "currentframe() returned None" - - assert frame.f_back, "frame has no f_back" - assert frame.f_back.f_back, "frame 2 has no f_back" - - target = frame.f_back.f_back - - for i in tps: - with suppress(TypeError): - setattr(i, "_view_scope", {**target.f_locals, **target.f_globals}) - - def inner(r: RouteOrCallable[P]) -> Route[P]: - route = _ensure_route(r) - route.inputs.append(RouteInput(name, False, tps, default, doc, [])) - return route - - return inner - - -def body( - name: str, - *tps: type[V], - doc: str | None = None, - default: V | None | _NoDefaultType = _NoDefault, -) -> Path[P]: - """ - Add a route input for a body parameter. - - Args: - name: The name of the parameter to read when the body is received. - tps: All the possible types that are allowed to be used. If none are specified, the type is `Any`. - doc: Description of this parameter. - default: The default value to use if the key was not received. - - Example: - ```py - from view import new_app, body - - app = new_app() - - @app.get("/") - @body("greeting", str, doc="The greeting to use.", default="hello") - def index(greeting: str): - return f"{greeting}, world!" - - app.run() - ``` - """ - - def inner(r: RouteOrCallable[P]) -> Route[P]: - route = _ensure_route(r) - route.inputs.append(RouteInput(name, True, tps, default, doc, [])) - return route - - return inner - - -@overload -def context( - r_or_none: RouteOrCallable[P], -) -> Route[P]: ... - - -@overload -def context( - r_or_none: None = None, -) -> Callable[[RouteOrCallable[P]], Route[P]]: ... - - -def context( - r_or_none: RouteOrCallable[P] | None = None, -) -> Callable[[RouteOrCallable[P]], Route[P]] | Route[P]: - """ - Add a context input to the route. This is a decorator. - - Args: - r_or_none: Route object, or none if calling the decorator with `()` - - Example: - ```py - from view import context, Context - - @context - def index(ctx: Context): - print(ctx.headers) - return "hello, world" - ``` - """ - - def inner(r: RouteOrCallable[P]) -> Route[P]: - route = _ensure_route(r) - route.inputs.append(1) - return route - - if r_or_none: - return inner(r_or_none) - - return inner - - -class Router: - """ - Object that stores and loads routes. - """ - - def __init__(self, app: App | None = None, url_prefix: str | None = None) -> None: - self.app = app - self.routes: list[Route] = [] - self.url_prefix = url_prefix - - def load(self, app: App | None = None, *, url_prefix: str | None = None) -> None: - """ - Load the stored routes onto an app. This clears the stored sequence of routes. - - Args: - app: App to load routes on to. If this is `None`, uses the stored `app` attribute. - url_prefix: Prefix to append to all routes before loading. If this is `None`, uses the stored `url_prefix` attribute. - - Raises: - ValueError: Both the `app` parameter and stored `app` attribute are `None`. - """ - - target = app or self.app - - if not target: - raise MissingAppError("no app passed to loader") - - url_prefix = url_prefix or self.url_prefix - if url_prefix: - if url_prefix.endswith("/"): - raise MistakeError("url paths cannot end with /") - - for route in self.routes: - if route.parts: - route.parts.insert(0, url_prefix) - else: - assert route.path - route.path = url_prefix + route.path - - target.load(*self.routes) - self.routes = [] - - def get( - self, - path_or_route: str | None | RouteOrCallable[P] = None, - doc: str | None = None, - *, - cache_rate: int = -1, - steps: Iterable[str] | None = None, - parallel_build: bool | None = _DefinedByConfig, - ) -> Path[P]: - """ - Add a GET route to the router. - - Example: - ```py - from view import Router - - router = Router(url_prefix="/foo") - - @router.get("/") - async def index(): - return "Hello, view.py!" - ``` - """ - return get( - path_or_route, - doc, - cache_rate=cache_rate, - steps=steps, - parallel_build=parallel_build, - ) diff --git a/src/view/templates.py b/src/view/templates.py deleted file mode 100644 index 3f9de112..00000000 --- a/src/view/templates.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -view.py templating APIs - -This module contains all APIs related to rendering HTML through a template engine. As of this docstring being written, view.py supports the following engines: - - view.py's own template engine - - Jinja2 - - Django's template engine - - Mako - - Chameleon -""" - -from __future__ import annotations - -import inspect -import os -import sys -from pathlib import Path -from types import FrameType as Frame -from types import ModuleType -from typing import TYPE_CHECKING, Any, Iterator, Type - -import aiofiles - -from ._util import needs_dep -from .config import TemplatesConfig -from .exceptions import BadEnvironmentError, InvalidTemplateError -from .response import HTML -from .typing import TemplateEngine - -if TYPE_CHECKING: - from bs4 import Tag - - from .app import App - -_ConfigSpecified = None -_DEFAULT_CONF = TemplatesConfig() - -__all__ = ("template", "render", "markdown") - - -class _CurrentFrame: # sentinel - pass - - -_CurrentFrameType = Type[_CurrentFrame] - -_DJANGO_HOOK = ModuleType("_view_django") -sys.modules["_view_django"] = _DJANGO_HOOK - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "_view_django") - - -class _ViewRenderer: - def __init__(self, parameters: dict[str, Any]): - self.parameters = parameters - self._last_if: bool | None = None - - async def _render_children( - self, view: Tag, result: list[Any], *, defer: bool = False - ) -> None: - from bs4 import Tag - - for child in view.children: - if isinstance(child, Tag): - if child.name == "view": - ele = await self._tag(child, defer=defer) - if ele: - result.append(ele) - else: - result.append(child) - - async def _tag( - self, - view: Tag, - *, - defer: bool = False, - ) -> Tag | str | None: - from bs4 import BeautifulSoup - - if not view.attrs: - raise InvalidTemplateError(" tags must have at least one attribute") - - iterator_obj: Iterator[Any] | None = None - iterator_name: str | None = None - - def _iter_render(itera: str, item: str) -> None: - if not itera: - raise InvalidTemplateError('"iter" attribute cannot be empty') - - if not item: - raise InvalidTemplateError('"item" attribute cannot be empty') - - nonlocal iterator_obj - iterator_obj = iter(eval(itera, self.parameters)) - nonlocal iterator_name - iterator_name = item - - result: list[str | Tag] = [] - - for key, value in view.attrs.items(): - if key == "ref": - func = repr if "repr" in view.attrs else str - ref: str = func(eval(value, self.parameters)) - if "nosanitize" in view.attrs: - result.append(BeautifulSoup(ref, "html.parser")) - else: - result.append(ref) - elif key in {"nosanitize", "repr"}: - if "ref" not in view.attrs: - raise InvalidTemplateError( - "f{key} can only be used with a ref attribute in tag" - ) - elif key == "template": - html = await template(value) - body = html.translate_body(html.body) - result.append(body) - elif key == "if": - self._last_if = bool(eval(value, self.parameters)) - if not self._last_if: - if not defer: - view.replace_with("") - return None - elif (key in {"else", "elif"}) and (self._last_if is None): - raise InvalidTemplateError( - f'{key} can only be used if an "if" attribute was used prior' - ) # noqa - elif key == "else": - if self._last_if is True: - if not defer: - view.replace_with("") - return None - elif key == "elif": - if self._last_if is False: - self._last_if = bool(eval(value, self.parameters)) - if not self._last_if: - if not defer: - view.replace_with("") - return None - else: - if not defer: - view.replace_with("") - return None - - elif key == "iter": - item = view.attrs.get("item") - if not item: - raise InvalidTemplateError( - ' tags with an "iter" attribute must have an "item" attribute' - ) # noqa - - _iter_render(value, item) - elif key == "item": - iter_name = view.attrs.get("iter") - if not iter_name: - raise InvalidTemplateError( - ' tags with an "item" attribute must have an "iter" attribute' - ) # noqa - - _iter_render(iter_name, value) - else: - raise InvalidTemplateError(f"unknown attribute {key!r} in tag") - - if iterator_obj: - assert iterator_name, "iterator_name is None (this is a bug!)" - - for i in iterator_obj: # type: ignore - self.parameters[iterator_name] = i - await self._render_children(view, result, defer=True) - - iterator_obj = None - else: - await self._render_children(view, result) - - return ( - view.replace_with(*result) - if not defer - else "\n".join([str(i) for i in result]) - ) - - async def render(self, content: str) -> str: - try: - from bs4 import BeautifulSoup, Tag - except ModuleNotFoundError as e: - needs_dep("beautifulsoup4", e, "templates") - - soup = BeautifulSoup(content, features="html.parser") - - for view in soup.find_all("view"): - assert isinstance(view, Tag), "found non-tag somehow (this is a bug!)" - await self._tag(view) - - return str(soup) - - -async def render( - source: str, - engine: TemplateEngine = "view", - parameters: dict[str, Any] | None | _CurrentFrameType = _CurrentFrame, - *, - app: App | None = None, -) -> str: - """ - Render a template from the source instead of a filename. - Generally should be used internally, but is considered stable. - - This function does not require that an app has been created, but will attempt to get an app with `get_app()` regardless. - If `get_app()` fails, template engine instances are not stored. - - Args: - source: Source code to pass to the template engine. - engine: Template engine to use. Unlike `template()`, this does not try and load the default template engine from the config. - parameters: Variables to pass to the template engine for use in templates. By default, this will attempt to steal locals from the callers frame. - app: App to store engines on. If `None`, will attempt to call `get_app()`. - - Example: - ```py - import aiofiles - from view import render - - - async def main(): - async with aiofiles.open("template.html") as f: - html = await render(await f.read(), engine="jinja") - ``` - """ - from .app import get_app - - if parameters is _CurrentFrame: - parameters = {} - frame = inspect.currentframe() - assert frame, "failed to get frame" - while frame.f_code.co_filename == __file__: - frame = frame.f_back - assert frame, "frame has no f_back" - - assert isinstance(frame, Frame) - - parameters.update(frame.f_globals) - parameters.update(frame.f_locals) - - try: - templaters = (app or get_app()).templaters - except BadEnvironmentError: - templaters = {} - - if engine == "view": - view = _ViewRenderer(parameters or {}) # type: ignore - return await view.render(source) - elif engine == "jinja": - try: - from jinja2 import Environment - except ModuleNotFoundError as e: - needs_dep("jinja2", e, "templates") - - env: Environment | None = templaters.get("jinja") - if not env: - templaters["jinja"] = Environment() - env = templaters["jinja"] - env.is_async = True - - return await env.from_string(source).render_async(**parameters) # type: ignore - elif engine == "mako": - try: - from mako.template import Template - except ModuleNotFoundError as e: - needs_dep("mako", e, "templates") - - return Template(source).render_unicode(**parameters) # type: ignore - elif engine == "chameleon": - try: - from chameleon.zpt.template import PageTemplate - except ModuleNotFoundError as e: - needs_dep("chameleon", e, "templates") - - return PageTemplate(source)(**parameters) # type: ignore - elif engine == "django": - try: - from django import setup - from django.conf import settings - from django.template import Context, Template - except ModuleNotFoundError as e: - needs_dep("django", e, "templates") - - TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": False, - "OPTIONS": {}, - } - ] - settings.configure(TEMPLATES=TEMPLATES) - setup() - - return Template(source).render(Context(parameters)) - else: - raise InvalidTemplateError(f"{engine!r} is not a supported template engine") - - -async def template( - name: str | Path, - directory: str | Path | None = _ConfigSpecified, - engine: TemplateEngine | None = _ConfigSpecified, - frame: Frame | None | _CurrentFrameType = _CurrentFrame, - app: App | None = None, - **parameters: Any, -) -> HTML: - """ - Render a template with the specified engine. - - Args: - name: A string, which is appended the `.html` suffix (if it doesn't have it already), or a `Path` object, representing a file containing an HTML template. - directory: Directory to search for templates. If this is `None`, the directory specified in the configuration is used. - engine: Template engine to choose. If this is `None`, the default engine specified in the configuration is used. - frame: Frame to steal locals from for parameters. This will use the callers frame by default. If this is `None`, then no locals are sent to the template engine. - app: App instance to store template engine instances on. If this is `None`, `get_app` is called. - parameters: Extra values to send to the template engine as variables. - - Example: - ```py - from view import get, template - - @get("/") - async def index(): - return await template("test") - ``` - """ - from .app import get_app - - try: - conf = app.config.templates if app else get_app().config.templates - except BadEnvironmentError: - conf = _DEFAULT_CONF - - directory = Path(directory or conf.directory) - engine = engine or conf.engine - - if isinstance(name, str): - if not name.endswith(".html"): - name += ".html" - - name = Path(name) - - path = directory / name - params: dict[str, Any] = {} - if frame: - if frame is _CurrentFrame: - frame = inspect.currentframe() - assert frame, "failed to get frame" - while frame.f_code.co_filename == __file__: - frame = frame.f_back - assert frame, "frame has no f_back" - - assert isinstance(frame, Frame) - - if conf.globals: - params.update(frame.f_globals) - - if conf.locals: - params.update(frame.f_locals) - - params.update(parameters) - - async with aiofiles.open(path) as f: - source = await f.read() - - return HTML(await render(source, engine, params, app=app)) - - -async def markdown( - name: str | Path, - *, - directory: str | Path | None = _ConfigSpecified, - app: App | None = None, -) -> HTML: - """ - Convert a markdown file into HTML. - - Args: - name: Equivalent to `name` in `template()`, with the exception of using the `.md` suffix instead of `.html`. - directory: Equivalent to `directory` in `template()`. - app: Equivalent to `app` in `template()`. - - Raises: - NeedsDependencyError: `markdown` module is not installed. - """ - from .app import get_app - - try: - from markdown import markdown as md_to_html - except ModuleNotFoundError as e: - needs_dep("markdown", e, "templates") - - try: - conf = app.config.templates if app else get_app().config.templates - except BadEnvironmentError: - conf = _DEFAULT_CONF - - directory = Path(directory or conf.directory) - - if isinstance(name, str): - if not name.endswith(".md"): - name += ".md" - - name = Path(name) - - path = directory / name - async with aiofiles.open(path) as f: - source = await f.read() - - return HTML(f"{md_to_html(source)}") diff --git a/src/view/typecodes.py b/src/view/typecodes.py deleted file mode 100644 index 28021f32..00000000 --- a/src/view/typecodes.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -view.py public typecode APIs. - -A "typecode" is a view.py term - it's a representation of a type for fast runtime type checking. -The details of typecode internals are subject to change. - -Typecodes are used internally for quickly validating received query and body route inputs. -""" - -from __future__ import annotations - -from typing import Generic, Iterable, TypeVar - -import ujson -from typing_extensions import TypeGuard - -from _view import TCPublic - -from ._loader import _build_type_codes -from .exceptions import TypeValidationError -from .typing import TypeInfo - -__all__ = "TCValidator", "compile_type" -T = TypeVar("T") - - -class TCValidator(TCPublic, Generic[T]): - """ - Class for holding a typecode to be validated against. - - A "typecode" is a view.py term - it's a representation of a type for fast runtime type checking. - The details of typecode internals are subject to change. - - It's highly unlikely that you would need to instantiate this class yourself - it should only be referenced directly for type hinting purposes. - - The constructor of this class is considered unstable - do not expect backwards compatibility! - Use the `compile_type` function instead. - """ - - def __init__(self, tp: type[T], codes: Iterable[TypeInfo]) -> None: - self.tp: type[T] = tp - """Original, unmodified type passed to `compile_type()`.""" - self.codes: Iterable[TypeInfo] = codes - """Iterable containing `TypeInfo` objects. Note that `TypeInfo` objects themself are considered unstable.""" - self._compile(codes, ujson.loads) - - def check_type(self, obj: object) -> TypeGuard[T]: - """ - Check if an object *is* the type. This will not cast parameters, so it acts as a `TypeGuard`. - - Args: - obj: Object to check against. - - Example: - ```py - from view import compile_type - - tc = compile_type(int) - val = 1 - - if tc.check_type(val): - assert type(val) == int - else: - assert type(val) != int - ``` - """ - try: - self._cast(obj, False) - return True - except (ValueError, TypeError): - return False - - def is_compatible(self, obj: object) -> bool: - """ - Check if an object is compatible with the type (including with casting). - - Args: - obj: Object to check against. - - Example: - ```py - from view import compile_type - - tc = compile_type(int) - - assert tc.is_compatible('1') is True # '1' can be casted to an integer - assert tc.is_compatible('hello') is False # 'hello' cannot be casted to an integer - ``` - """ - try: - self._cast(obj, True) - return True - except (ValueError, TypeError): - return False - - def cast(self, obj: object) -> T: - """ - Attempt to turn `obj` into the underlying type. - - Args: - obj: Object to cast from. - - Raises: - TypeValidationError: The object is not compatible with the type. - - Example: - ```py - from view import compile_type - - tc = compile_type(int) - obj = tc.cast("1") - assert obj == 1 - ``` - """ - try: - return self._cast(obj, True) - except (ValueError, TypeError): - raise TypeValidationError(f"{obj} is not assignable to {self.tp}") from None - - -def compile_type(tp: type[T]) -> TCValidator[T]: - """ - Compile a type to a type validation object. - - Args: - tp: Type to compile. Note that this can't be just *any* type, it has to be something that view.py supports (such as `str`, `int`, or something that implements `__view_body__`). - - Example: - ```py - from view import compile_type - - validator = compile_type(str | int) - assert validator.is_compatible('1') # True - ``` - """ - return TCValidator(tp, _build_type_codes([tp])) diff --git a/src/view/typing.py b/src/view/typing.py deleted file mode 100644 index 57ed74aa..00000000 --- a/src/view/typing.py +++ /dev/null @@ -1,208 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from typing import ( - TYPE_CHECKING, - Any, - Awaitable, - Callable, - Dict, - Generic, - List, - Literal, - Tuple, - Type, - TypeVar, - Union, -) - -from typing_extensions import ( - Concatenate, - ParamSpec, - Protocol, - TypedDict, - runtime_checkable, -) - -if TYPE_CHECKING: - from _view import Context - - from .app import RouteDoc - from .ws import WebSocket - -AsgiSerial = Union[ - bytes, - str, - int, - float, - list, - tuple, - Dict[str, "AsgiSerial"], - bool, - None, -] - -AsgiDict = Dict[str, AsgiSerial] - -AsgiReceive = Callable[[], Awaitable[AsgiDict]] -AsgiSend = Callable[[AsgiDict], Awaitable[None]] - -RawResponseHeader = Tuple[bytes, bytes] -ResponseHeaders = Union[ - Dict[str, str], - List[RawResponseHeader], - Tuple[RawResponseHeader, ...], -] -StrResponseBody = Union[str, bytes] - -T = TypeVar("T") -MaybeAwaitable = Union[T, Awaitable[T]] - - -@runtime_checkable -class SupportsViewResult(Protocol): - def __view_result__(self, ctx: Context) -> MaybeAwaitable[ViewResult]: ... - - -ResponseBody = Union[StrResponseBody, SupportsViewResult] - -ViewResult = Union[ - ResponseBody, - None, - Tuple[ResponseBody, int], - Tuple[ResponseBody, int, dict[str, str]], - Tuple[ResponseBody, int, Sequence[Tuple[bytes, bytes]]], -] -P = ParamSpec("P") -V = TypeVar("V", bound="ValueType") - - -ViewResponse = Awaitable[ViewResult] -R = TypeVar("R", bound="ViewResponse") -WebSocketRoute = Callable[Concatenate["WebSocket", P], Awaitable[None]] -ViewRoute = Union[Callable[P, Union[ViewResponse, ViewResult]], WebSocketRoute] - - -ValidatorResult = Union[bool, Tuple[bool, str]] -Validator = Callable[[V], ValidatorResult] - -TypeObject = Union[Type[Any], None] -TypeInfo = Union[ - Tuple[int, TypeObject, List["TypeInfo"]], - Tuple[int, TypeObject, List["TypeInfo"], Any], -] - - -class RouteInputDict(TypedDict, Generic[V]): - name: str - type_codes: list[TypeInfo] - default: V | None - validators: list[Validator[V]] - is_body: bool - has_default: bool - - -ViewBody = Dict[str, "ValueType"] - - -class _SupportsViewBodyCV(Protocol): - __view_body__: ViewBody - - -class _SupportsViewBodyF(Protocol): - @staticmethod - def __view_body__() -> ViewBody: ... - - -ViewBodyLike = Union[_SupportsViewBodyCV, _SupportsViewBodyF] -ValueType = Union[ - ViewBodyLike, - str, - int, - Dict[str, "ValueType"], - bool, - float, - Any, -] -Parser = Callable[[str], ViewBody] - - -class Part(Protocol[V]): - name: str - tp: type[V] | None - - -Callback = Callable[[], Any] -SameSite = Literal["strict", "lax", "none"] -BodyTranslateStrategy = Literal["str", "repr", "result", "custom"] - -DocsType = Dict[Tuple[Union[str, Tuple[str, ...]], str], "RouteDoc"] -LogLevel = Literal["debug", "info", "warning", "error", "critical"] -StrMethod = Literal[ - "get", - "post", - "put", - "patch", - "delete", - "options", - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "OPTIONS", - "WEBSOCKET", -] -TemplateEngine = Literal["view", "jinja", "django", "mako", "chameleon"] -StrMethodASGI = Literal[ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "OPTIONS", -] -CallNext = Callable[[], ViewResponse] -Middleware = Callable[Concatenate[CallNext, P], ViewResponse] -ErrorStatusCode = Literal[ - 400, - 401, - 402, - 403, - 404, - 405, - 406, - 407, - 408, - 409, - 410, - 411, - 412, - 413, - 414, - 415, - 416, - 417, - 418, - 421, - 422, - 423, - 424, - 425, - 426, - 428, - 429, - 431, - 451, - 500, - 501, - 502, - 503, - 504, - 505, - 506, - 507, - 508, - 510, - 511, -] diff --git a/src/view/util.py b/src/view/util.py deleted file mode 100644 index 0062baab..00000000 --- a/src/view/util.py +++ /dev/null @@ -1,378 +0,0 @@ -""" -view.py public utility APIs - -This module contains general utility functions. -Most of these exist because they are used somewhere else in view.py, it's just nice to provide the functionality publicly. - -For example, `timestamp()` is used by `cookie()` in `Response`, but it could be useful to generate timestamps for other cases. -""" - -from __future__ import annotations - -import json -import logging -import os -from collections.abc import Awaitable -from datetime import datetime as DateTime -from email.utils import formatdate -from typing import TYPE_CHECKING, Callable, TypeVar, Union, overload - -from typing_extensions import ParamSpec, deprecated - -from _view import Context, dummy_context - -from ._logging import Internal, Service -from ._util import run_path, shell_hint -from .exceptions import AppNotFoundError, BadEnvironmentError, InvalidResultError -from .typing import ErrorStatusCode, StrResponseBody, SupportsViewResult, ViewResult - -if TYPE_CHECKING: - from .app import App - from .response import Response - -__all__ = ( - "run", - "env", - "enable_debug", - "timestamp", - "extract_path", - "expect_errors", - "call_result", - "to_response", -) - -T = TypeVar("T") -P = ParamSpec("P") - - -def extract_path(path: str) -> App: - """ - Extract an `App` instance from a path. - - Args: - path: Path to the file and the app name in the format of `/path/to/app.py:app_name`. - - Raises: - AppNotFoundError: File path does not exist. - AttributeError: File was found and loaded, but is missing the attribute name specified by `path`. - TypeError: The object found is not a `view.App` object. - - Example: - ```py - from view import extract_path - - app = extract_path("app.py:app") - app.run() - ``` - """ - from .app import App - - split = path.split(":", maxsplit=1) - - if len(split) != 2: - raise ValueError( - "module string should be in the format of `/path/to/app.py:app_name`", - ) - - file_path = os.path.abspath(split[0]) - - try: - mod = run_path(file_path) - except FileNotFoundError as e: - raise AppNotFoundError(f'"{split[0]}" in {path} does not exist') from e - - try: - target = mod[split[1]] - except KeyError: - raise AttributeError(f'"{split[1]}" in {path} does not exist') from None - - if not isinstance(target, App): - raise TypeError(f"{target!r} is not an instance of view.App") - - return target - - -@deprecated( - "Use run() on `App` instead. If you have an app path, use `extract_path()`, followed by run()" -) -def run(app_or_path: str | App) -> None: - """ - Run a view app. - - Args: - app_or_path: App object or path to run. - """ - from .app import App - - if isinstance(app_or_path, App): - app_or_path.run() - return - - target = extract_path(app_or_path) - target._run() - - -def enable_debug(): - """ - Enable debug mode. The exact details of what this does should be considered unstable. - - Generally, this will enable debug logging. - """ - internal = Internal.log - internal.disabled = False - internal.setLevel(logging.DEBUG) - internal.addHandler( - logging.StreamHandler(open("view_internal.log", "w", encoding="utf-8")) - ) - Service.log.addHandler( - logging.StreamHandler(open("view_service.log", "w", encoding="utf-8")) - ) - - Internal.info("debug mode enabled") - os.environ["VIEW_DEBUG"] = "1" - - -EnvConv = Union[str, int, bool, dict] - -# good god why does mypy suck at the very thing it's designed to do - - -@overload -def env(key: str, *, tp: type[str] = str) -> str: # type: ignore - ... - - -@overload -def env(key: str, *, tp: type[int] = int) -> int: # type: ignore - ... - - -@overload -def env(key: str, *, tp: type[bool] = bool) -> bool: # type: ignore - ... - - -@overload -def env(key: str, *, tp: type[dict] = dict) -> dict: # type: ignore - ... - - -def env(key: str, *, tp: type[EnvConv] = str) -> EnvConv: - """ - Get and parse an environment variable. - - Args: - key: Environment variable to access. - tp: Type to convert to. - - Example: - ```py - from view import new_app, env - - app = new_app() - - @app.get("/") - def index(): - return env("FOO") - - app.run() - ``` - - Raises: - BadEnvironmentError: Environment variable is not set or does not match the type. - TypeError: `tp` parameter is not a valid type. - """ - value = os.environ.get(key) - - if not value: - raise BadEnvironmentError( - f'environment variable "{key}" not set', - hint=shell_hint( - f"set {key}=..." if os.name == "nt" else f"export {key}=..." - ), - ) - - if tp is str: - return value - - if tp is int: - try: - return int(value) - except ValueError: - raise BadEnvironmentError( - f"{value!r} (key {key!r}) is not int-like" - ) from None - - if tp is dict: - try: - return json.loads(value) - except ValueError: - raise BadEnvironmentError(f"{value!r} ({key!r}) is not dict-like") - - if tp is bool: - value = value.lower() - if value not in {"true", "false"}: - raise BadEnvironmentError(f"{value!r} ({key!r}) is not bool-like") - - return value == "true" - - raise TypeError(f"invalid type in env(): {tp}") - - -_Now = None - - -def timestamp(tm: DateTime | None = _Now) -> str: - """ - RFC 1123 Compliant Timestamp. This is used by `Response` internally. - - Args: - tm: Date object to create a timestamp for. Now by default. - """ - stamp: float = DateTime.now().timestamp() if not tm else tm.timestamp() - return formatdate(stamp, usegmt=True) - - -_UseErrMessage = None - - -def expect_errors( - *errs: BaseException, - message: str | None = _UseErrMessage, - status: ErrorStatusCode = 400, -) -> Callable[[Callable[P, T]], Callable[P, T]]: - """ - Raise an HTTP error if any of `errs` occurs during execution. - This function is a decorator. - - Args: - errs: All errors to recognize. - message: Message to pass to `HTTPError`. Uses the message with the raised exception if `None`. - status: Status code to return. `400` by default. - - Example: - ```py - from view import get, expect_errors, context, Context - - @get("/") - @context - @expect_errors(KeyError, message="Missing header.") - def index(ctx: Context): - my_header = ctx.headers["www-token"] - return ... - ``` - """ - - def inner(func: Callable[P, T]) -> Callable[P, T]: - def deco(*args: P.args, **kwargs: P.kwargs) -> T: - try: - return func(*args, **kwargs) - except BaseException as e: - if e not in errs: - raise - - from .app import HTTPError - - raise HTTPError(message=message or str(e), status=status) - - return deco - - return inner - - -async def call_result( - result: SupportsViewResult, *, ctx: Context | None = None -) -> ViewResult: - """ - Call the `__view_result__` on an object. - - Args: - result: An object containing a `__view_result__` method. - ctx: The `Context` object to pass. If this is `None`, then a dummy context with incorrect values is generated. Only pass `None` here when you're sure that the `__view_result__` does not need the context. - """ - from .app import get_app - - app: App | None = None - - try: - app = get_app() - except BadEnvironmentError: - app = None - - ctx = ctx or dummy_context(app) - coro_or_res = result.__view_result__(ctx) - if isinstance(coro_or_res, Awaitable): - return await coro_or_res - - return coro_or_res - - -async def to_response( - result: ViewResult | Awaitable[ViewResult], - *, - ctx: Context | None = None, -) -> Response[StrResponseBody]: - """ - Cast a result from a route function to a `Response` object. - - Args: - result: Result to cast. This can be any valid view.py response, such as a string, a tuple, or some object that implements `__view_result__`. - ctx: `Context` object to pass to `call_result`, if the `result` parameter supports `__view_result__`. - - Example: - ```py - from view import new_app, to_response - - app = new_app() - - @app.get("/") - def index(): - return "Hello, world!" - - @app.get("/test") - async def test(): - response = await to_response(index()) - assert response.body == "Hello, world!" - return ... - - app.run() - ``` - """ - from .response import Response - - if isinstance(result, Awaitable): - result = await result - - if isinstance(result, SupportsViewResult): - res = await call_result(result, ctx=ctx) - return await to_response(res) - - if isinstance(result, tuple): - status: int = 200 - headers: dict[str, str] = {} - raw_headers: list[tuple[bytes, bytes]] = [] - body: StrResponseBody | None = None - - for value in result: - if isinstance(value, int): - status = value - elif isinstance(value, (str, bytes)): - body = value - elif isinstance(value, dict): - headers = value - elif isinstance(value, list): - raw_headers = value - else: - raise InvalidResultError( - f"{value!r} is not a valid response tuple item" - ) - - if not body: - raise InvalidResultError("result has no body") - - res = Response(body, status, headers) - res._raw_headers = raw_headers - return res - - assert isinstance(result, (str, bytes)) - return Response(result) diff --git a/src/view/ws.py b/src/view/ws.py deleted file mode 100644 index 5a457a37..00000000 --- a/src/view/ws.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -view.py WebSocket APIs - -It's unlikely that you need something from this module for anything other than typing purposes. -The bulk of the WebSocket implementation is C code, and considered unstable. -""" - -from __future__ import annotations - -from types import TracebackType -from typing import Any, Union, overload - -import ujson -from typing_extensions import Self - -from _view import ViewWebSocket - -from ._logging import Internal, Service -from .exceptions import ( - WebSocketDisconnectError, - WebSocketExpectError, - WebSocketHandshakeError, -) - -__all__ = "WebSocketSendable", "WebSocketReceivable", "WebSocket" - -WebSocketSendable = Union[str, bytes, dict, int, bool] -WebSocketReceivable = Union[str, bytes, dict, int, bool] - - -class WebSocket: - """ - Object representing a WebSocket connection. - - It's highly unlikely that you need to create an instance of this class yourself! - The constructor of this class is considered unstable, do not expect API stability! - - This class should only be directly referenced when using type hints. - """ - - def __init__(self, socket: ViewWebSocket) -> None: - self._socket: ViewWebSocket = socket - self.open: bool = False - """Whether the connection is currently open.""" - self.done: bool = False - """Whether the connection was accepted, and then closed.""" - - @overload - async def receive(self, tp: type[str] = str) -> str: ... - - @overload - async def receive(self, tp: type[bytes] = bytes) -> bytes: ... - - @overload - async def receive(self, tp: type[dict] = dict) -> dict: ... - - @overload - async def receive(self, tp: type[int] = int) -> int: ... - - @overload - async def receive(self, tp: type[bool] = bool) -> bool: ... - - async def receive(self, tp: type[WebSocketReceivable] = str) -> WebSocketReceivable: - """ - Receive a message from the client. - - Args: - tp: Python type to cast the received message to. Supported types are `str`, `bytes`, `dict` (as JSON), `int`, and `bool`. - - Raises: - WebSocketHandshakeError: The connection has already been closed. - WebSocketDisconnectError: The connection closed while receiving. - WebSocketExpectError: The received data could not be casted to the requested type. - - Example: - ```py - from view import websocket, WebSocket - - @websocket("/") - async def index(ws: WebSocket): - await ws.accept() - message = await ws.receive() - ``` - """ - if not self.open: - raise WebSocketHandshakeError( - "cannot receive from connection that is not open" - ) - res: str = await self._socket.receive() - Internal.debug(f"received from socket: {res}") - - if res is None: - raise WebSocketDisconnectError("socket disconnected") - - if tp is str: - return res - - if tp is int: - return int(res) - - if tp is dict: - return ujson.loads(res) - - if tp is bytes: - return res.encode() - - if tp is bool: - if (res not in {"True", "true", "False", "false"}) and (not res.isdigit()): - raise WebSocketExpectError( - f"expected boolean-like message, got {res!r}" - ) - - if res.isdigit(): - return bool(int(res)) - - return res in {"True", "true"} - - raise TypeError(f"expected type str, bytes, dict, int, or bool, but got {tp!r}") - - async def send(self, message: WebSocketSendable) -> None: - """ - Send a message to the client. - - Args: - message: Message to send to the client. - - Raises: - WebSocketHandshakeError: The connection has already been closed. - TypeError: Type of `message` cannot be sent to the client. - - Example: - ```py - from view import websocket, WebSocket - - @websocket("/") - async def index(ws: WebSocket): - await ws.accept() - await ws.send("Hello from the other side") - ``` - """ - Internal.debug(f"sending to websocket: {message}") - if not self.open: - raise WebSocketHandshakeError("cannot send to connection that is not open") - if isinstance(message, (str, bytes)): - await self._socket.send(message) - elif isinstance(message, dict): - await self._socket.send(ujson.dumps(message)) - elif isinstance(message, bool): - await self._socket.send("true" if message else "false") - elif isinstance(message, int): - await self._socket.send(str(message)) - else: - raise TypeError( - f"expected object of type str, bytes, dict, int, or bool, but got {message!r}" - ) - - @overload - async def pair( - self, - message: WebSocketSendable, - *, - tp: type[str] = str, - recv_first: bool = False, - ) -> str: ... - - @overload - async def pair( - self, - message: WebSocketSendable, - *, - tp: type[bytes] = bytes, - recv_first: bool = False, - ) -> bytes: ... - - @overload - async def pair( - self, - message: WebSocketSendable, - *, - tp: type[int] = int, - recv_first: bool = False, - ) -> int: ... - - @overload - async def pair( - self, - message: WebSocketSendable, - *, - tp: type[dict] = dict, - recv_first: bool = False, - ) -> dict: ... - - @overload - async def pair( - self, - message: WebSocketSendable, - *, - tp: type[bool] = bool, - recv_first: bool = False, - ) -> bool: ... - - async def pair( - self, - message: WebSocketSendable, - *, - tp: type[WebSocketReceivable] = str, - recv_first: bool = False, - ) -> WebSocketReceivable: - """ - Receive a message and send a message. - - Args: - message: Message to send. Equivalent to `message` in `send()` - tp: Type to cast the result to. Equivalent to `tp` in `receive()` - recv_first: Whether to receive the message before sending. If this is `False`, a message is sent first, then a message is received. - - Example: - ```py - from view import websocket, WebSocket - - @websocket("/") - async def index(ws: WebSocket): - await ws.accept() - response = await ws.pair("syn") - assert response == "ack" - ``` - """ - if not recv_first: - await self.send(message) - return await self.receive(tp) - else: - res = await self.receive(tp) - await self.send(message) - return res - - async def close(self) -> None: - """ - Close the connection. - - Raises: - WebSocketHandshakeError: The connection has already been closed. - - Example: - ```py - from view import websocket, WebSocket - - @websocket("/") - async def index(ws: WebSocket): - # ... - await ws.close() # Close the connection - ``` - """ - if not self.open: - raise WebSocketHandshakeError("cannot close connection that isn't open") - - self.open = False - self.done = True - await self._socket.close() - - async def accept(self) -> None: - """ - Open the connection. - - Raises: - WebSocketHandshakeError: `accept()` has already been called on this object. - - - Example: - ```py - from view import websocket, WebSocket - - @websocket("/") - async def index(ws: WebSocket): - await ws.accept() # Open the connection - # ... - ``` - """ - if self.done or self.open: - raise WebSocketHandshakeError("connection was already opened") - - self.open = True - await self._socket.accept() - - async def expect(self, message: WebSocketSendable) -> None: - msg = await self.receive(tp=type(message)) - if msg != message: - raise WebSocketExpectError(f"websocket expected {message!r}, got {msg!r}") - - recv = receive - connect = accept - - async def __aenter__(self) -> Self: - await self.accept() - return self - - async def __aexit__( - self, - tp: type[BaseException] | BaseException | None, - val: Any, - tb: TracebackType | None, - ) -> None: - if tp == WebSocketDisconnectError: - self.open = False - Service.warning("Unhandled WebSocket disconnect") - elif tp: - self.open = False - # exception occurred, raise it so view.py can handle it - raise - elif self.open: - await self.close() diff --git a/tests/buildscripts/failing_build_script.py b/tests/buildscripts/failing_build_script.py deleted file mode 100644 index ff8dee8c..00000000 --- a/tests/buildscripts/failing_build_script.py +++ /dev/null @@ -1,2 +0,0 @@ -async def __view_build__() -> None: - raise RuntimeError("test") diff --git a/tests/buildscripts/failing_req.py b/tests/buildscripts/failing_req.py deleted file mode 100644 index e319ec92..00000000 --- a/tests/buildscripts/failing_req.py +++ /dev/null @@ -1,8 +0,0 @@ -import aiofiles - - -async def __view_requirement__() -> bool: - async with aiofiles.open("failingreq.test", "w"): - pass - - return False diff --git a/tests/buildscripts/my_build_script.py b/tests/buildscripts/my_build_script.py deleted file mode 100644 index 6d6c7afa..00000000 --- a/tests/buildscripts/my_build_script.py +++ /dev/null @@ -1,6 +0,0 @@ -import os - - -async def __view_build__() -> None: - os.environ["_VIEW_TEST_BUILD_SCRIPT"] = "1" - assert __name__ == "__view_build__" diff --git a/tests/buildscripts/req.py b/tests/buildscripts/req.py deleted file mode 100644 index 25ba626b..00000000 --- a/tests/buildscripts/req.py +++ /dev/null @@ -1,7 +0,0 @@ -import aiofiles - - -async def __view_requirement__() -> bool: - async with aiofiles.open("customreq.test", "w"): - pass - return True diff --git a/tests/configs/build_commands.toml b/tests/configs/build_commands.toml deleted file mode 100644 index 6570e2c1..00000000 --- a/tests/configs/build_commands.toml +++ /dev/null @@ -1,13 +0,0 @@ -[build] -default_steps = ["test"] - -[[build.steps.test]] -command = "touch build.test" - -[[build.steps.test]] -platform = "windows" -command = "type NUL > build.test" - -[build.steps.fail] -requires = ["command+fdsafasdjfkhas"] -command = "exit -1" \ No newline at end of file diff --git a/tests/configs/build_platform.toml b/tests/configs/build_platform.toml deleted file mode 100644 index fe094f07..00000000 --- a/tests/configs/build_platform.toml +++ /dev/null @@ -1,12 +0,0 @@ -[build] -default_steps = ["foo"] - -[[build.steps.foo]] -platform = ["linux", "mac"] -command = "touch linux_build.test" - -[[build.steps.foo]] -command = "type NUL > windows_build.test" - -[build.steps.windowsonly] -platform = "windows" \ No newline at end of file diff --git a/tests/configs/build_reqs.toml b/tests/configs/build_reqs.toml deleted file mode 100644 index 1753028e..00000000 --- a/tests/configs/build_reqs.toml +++ /dev/null @@ -1,14 +0,0 @@ -[build] -default_steps = ["test"] - -[build.steps.test] -requires = ["pip"] - -[build.steps.foo] -requires = ["dart"] - -[build.steps.customreq] -requires = ["script+./tests/buildscripts/req.py"] - -[build.steps.failingreq] -requires = ["mod+buildscripts.failing_req"] \ No newline at end of file diff --git a/tests/configs/build_scripts.toml b/tests/configs/build_scripts.toml deleted file mode 100644 index e827e2bd..00000000 --- a/tests/configs/build_scripts.toml +++ /dev/null @@ -1,8 +0,0 @@ -[build] -default_steps = ["myscript"] - -[build.steps.myscript] -script = ["./tests/buildscripts/my_build_script.py"] - -[build.steps.fail] -script = "./tests/buildscripts/failing_build_script.py" \ No newline at end of file diff --git a/tests/configs/fs.toml b/tests/configs/fs.toml deleted file mode 100644 index fa556506..00000000 --- a/tests/configs/fs.toml +++ /dev/null @@ -1,3 +0,0 @@ -[app] -loader = "filesystem" -loader_path = "tests/fs_routing" diff --git a/tests/configs/simple.toml b/tests/configs/simple.toml deleted file mode 100644 index 0f8fcc43..00000000 --- a/tests/configs/simple.toml +++ /dev/null @@ -1,3 +0,0 @@ -[app] -loader = "simple" -loader_path = "tests/simple_routes" diff --git a/tests/configs/subtemplates.toml b/tests/configs/subtemplates.toml deleted file mode 100644 index d754c8d8..00000000 --- a/tests/configs/subtemplates.toml +++ /dev/null @@ -1,2 +0,0 @@ -[templates] -directory = "tests/subtemplates/" diff --git a/tests/configs/templates.toml b/tests/configs/templates.toml deleted file mode 100644 index 8b8bc20a..00000000 --- a/tests/configs/templates.toml +++ /dev/null @@ -1,3 +0,0 @@ -[templates] -directory = "tests/other_templates/" -engine = "mako" diff --git a/tests/configs/urls.toml b/tests/configs/urls.toml deleted file mode 100644 index 2d703e9f..00000000 --- a/tests/configs/urls.toml +++ /dev/null @@ -1,3 +0,0 @@ -[app] -loader = "patterns" -loader_path = "tests/patterns_routes/urls.py" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index fb3421e8..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,42 +0,0 @@ -import functools -import inspect -import platform -import sys -from typing import Any, Callable - -import pytest - -ITERATIONS: int = 100 - - -def pytest_addoption(parser: Any) -> None: - parser.addoption("--enable-leak-tracking", action="store_true") - - -def limit_leaks(memstring: str): - def decorator(func: Callable): - if "--enable-leak-tracking" not in sys.argv: - return func - - if platform.system() != "Windows": - if not inspect.iscoroutinefunction(func): - - @functools.wraps(func) - def wrapper(*args, **kwargs): # type: ignore - for _ in range(ITERATIONS): - func(*args, **kwargs) - - else: - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - for _ in range(ITERATIONS): - await func(*args, **kwargs) - - wrapper = pytest.mark.asyncio(wrapper) - - return pytest.mark.limit_leaks(memstring)(wrapper) - else: - return func - - return decorator diff --git a/tests/fs_routing/delete.py b/tests/fs_routing/delete.py deleted file mode 100644 index 9a0837ec..00000000 --- a/tests/fs_routing/delete.py +++ /dev/null @@ -1,6 +0,0 @@ -from view import delete - - -@delete() -async def p(): - return "delete" diff --git a/tests/fs_routing/get.py b/tests/fs_routing/get.py deleted file mode 100644 index d7dde076..00000000 --- a/tests/fs_routing/get.py +++ /dev/null @@ -1,6 +0,0 @@ -from view import get - - -@get() -async def g(): - return "get" diff --git a/tests/fs_routing/options/index.py b/tests/fs_routing/options/index.py deleted file mode 100644 index b8989ca9..00000000 --- a/tests/fs_routing/options/index.py +++ /dev/null @@ -1,6 +0,0 @@ -from view import options - - -@options() -async def o(): - return "options" diff --git a/tests/fs_routing/patch.py b/tests/fs_routing/patch.py deleted file mode 100644 index d738b1c7..00000000 --- a/tests/fs_routing/patch.py +++ /dev/null @@ -1,6 +0,0 @@ -from view import patch - - -@patch() -async def p(): - return "patch" diff --git a/tests/fs_routing/post.py b/tests/fs_routing/post.py deleted file mode 100644 index d2e73b00..00000000 --- a/tests/fs_routing/post.py +++ /dev/null @@ -1,6 +0,0 @@ -from view import post - - -@post() -async def p(): - return "post" diff --git a/tests/fs_routing/put.py b/tests/fs_routing/put.py deleted file mode 100644 index a9bd83c2..00000000 --- a/tests/fs_routing/put.py +++ /dev/null @@ -1,6 +0,0 @@ -from view import put - - -@put() -async def p(): - return "put" diff --git a/tests/other_templates/something.html b/tests/other_templates/something.html deleted file mode 100644 index 4193abdc..00000000 --- a/tests/other_templates/something.html +++ /dev/null @@ -1 +0,0 @@ -${x} diff --git a/tests/patterns_routes/_routes.py b/tests/patterns_routes/_routes.py deleted file mode 100644 index 3c6d48f3..00000000 --- a/tests/patterns_routes/_routes.py +++ /dev/null @@ -1,40 +0,0 @@ -from view import delete, get, options, patch, post, route - - -@get("/bad") -async def g(): - return "get" - - -@post() -async def p(): - return "post" - - -async def pu(): - return "put" - - -@patch() -async def pa(): - return "patch" - - -@delete() -async def d(): - return "delete" - - -@options() -async def o(): - return "options" - - -@route() -async def r(): - return "any" - - -@post() -async def inputs(a: str): - return a diff --git a/tests/patterns_routes/urls.py b/tests/patterns_routes/urls.py deleted file mode 100644 index 6f2bb1f2..00000000 --- a/tests/patterns_routes/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -from _routes import d, g, inputs, o, p, pa, pu, r - -from view import path, query - -PATTERNS = ( - path("/get", g), - path("/post", p), - path("/put", pu, method="put"), - path("/patch", pa), - path("/delete", d), - path("/options", o), - path("/any", r), - path("/inputs", inputs, query("a", str)), -) diff --git a/tests/simple_routes/first.py b/tests/simple_routes/first.py deleted file mode 100644 index 94a88ad3..00000000 --- a/tests/simple_routes/first.py +++ /dev/null @@ -1,11 +0,0 @@ -from view import get, post - - -@get("/get") -async def g(): - return "get" - - -@post("/post") -async def p(): - return "post" diff --git a/tests/simple_routes/second.py b/tests/simple_routes/second.py deleted file mode 100644 index 1e27e31f..00000000 --- a/tests/simple_routes/second.py +++ /dev/null @@ -1,11 +0,0 @@ -from view import patch, put - - -@put("/put") -async def pu(): - return "put" - - -@patch("/patch") -async def pa(): - return "patch" diff --git a/tests/simple_routes/third/fourth.py b/tests/simple_routes/third/fourth.py deleted file mode 100644 index a2ccce56..00000000 --- a/tests/simple_routes/third/fourth.py +++ /dev/null @@ -1,11 +0,0 @@ -from view import delete, options - - -@delete("/delete") -async def d(): - return "delete" - - -@options("/options") -async def o(): - return "options" diff --git a/tests/subtemplates/sub.html b/tests/subtemplates/sub.html deleted file mode 100644 index d2c45463..00000000 --- a/tests/subtemplates/sub.html +++ /dev/null @@ -1,2 +0,0 @@ -hello - diff --git a/tests/subtemplates/world.html b/tests/subtemplates/world.html deleted file mode 100644 index cc628ccd..00000000 --- a/tests/subtemplates/world.html +++ /dev/null @@ -1 +0,0 @@ -world diff --git a/tests/templates/index.html b/tests/templates/index.html deleted file mode 100644 index 18bdb0bb..00000000 --- a/tests/templates/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/templates/test.md b/tests/templates/test.md deleted file mode 100644 index cc6fba2c..00000000 --- a/tests/templates/test.md +++ /dev/null @@ -1,5 +0,0 @@ -# A - -## B - -### C diff --git a/tests/test_app.py b/tests/test_app.py deleted file mode 100644 index 2a81762e..00000000 --- a/tests/test_app.py +++ /dev/null @@ -1,873 +0,0 @@ -import asyncio -from dataclasses import dataclass, field -from typing import Dict, List, NamedTuple, TypedDict, Union - -import attrs -import pytest -from conftest import limit_leaks -from pydantic import BaseModel, Field -from typing_extensions import NotRequired - -from view import ( - JSON, - BodyParam, - Context, - Response, - WebSocket, - body, - context, - get, - new_app, - query, -) -from view import route as route_impl -from view.typing import CallNext - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_reponses(): - app = new_app() - - @app.get("/") - async def index(): - return "hello" - - async with app.test() as test: - assert (await test.get("/")).message == "hello" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_status_codes(): - app = new_app() - - @app.get("/") - async def index(): - return "error", 400 - - async with app.test() as test: - res = await test.get("/") - assert res.status == 400 - assert res.message == "error" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_headers(): - app = new_app() - - @app.get("/") - async def index(): - return "hello", 200, {"a": "b"} - - async with app.test() as test: - res = await test.get("/") - assert res.headers["a"] == "b" - assert res.message == "hello" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_result_protocol(): - app = new_app() - - class MyObject: - def __view_result__(self, ctx: Context): - return "hello", 200 - - @app.get("/") - async def index(): - return MyObject() - - @app.get("/multi") - async def multi(): - return Response(MyObject(), 201) - - async with app.test() as test: - assert (await test.get("/")).message == "hello" - res = await test.get("/multi") - assert res.message == "hello" - assert res.status == 201 - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_body_type_validation(): - app = new_app() - - @app.get("/") - @body("name", str) - async def index(name: str): - return name - - @app.get("/status") - @body("status", int) - async def stat(status: int): - return "hello", status - - @app.get("/union") - @body("test", bool, int) - async def union(test: Union[bool, int]): - if type(test) is bool: - return "1" - elif type(test) is int: - return "2" - else: - raise Exception - - @app.get("/multi") - @body("status", int) - @body("name", str) - async def multi(status: int, name: str): - return name, status - - async with app.test() as test: - assert (await test.get("/", body={"name": "hi"})).message == "hi" - assert (await test.get("/status", body={"status": 404})).status == 404 - assert (await test.get("/status", body={"status": "hi"})).status == 400 # noqa - assert (await test.get("/union", body={"test": "a"})).status == 400 - assert (await test.get("/union", body={"test": "true"})).message == "1" # noqa - assert (await test.get("/union", body={"test": "2"})).message == "2" - res = await test.get("/multi", body={"status": 404, "name": "test"}) - assert res.status == 404 - assert res.message == "test" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_query_type_validation(): - app = new_app() - - @app.get("/") - @query("name", str) - async def index(name: str): - return name - - @app.get("/status") - @query("status", int) - async def stat(status: int): - return "hello", status - - @app.get("/union") - @query("test", bool, int) - async def union(test: Union[bool, int]): - if type(test) is bool: - return "1" - elif type(test) is int: - return "2" - else: - raise Exception - - @app.get("/multi") - @query("status", int) - @query("name", str) - async def multi(status: int, name: str): - return name, status - - async with app.test() as test: - assert (await test.get("/", query={"name": "hi"})).message == "hi" - assert (await test.get("/status", query={"status": 404})).status == 404 - assert (await test.get("/status", query={"status": "hi"})).status == 400 # noqa - assert (await test.get("/union", query={"test": "a"})).status == 400 - assert (await test.get("/union", query={"test": "true"})).message == "1" # noqa - assert (await test.get("/union", query={"test": "2"})).message == "2" - res = await test.get("/multi", query={"status": 404, "name": "test"}) - assert res.status == 404 - assert res.message == "test" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_queries_directly_from_app_and_body(): - app = new_app() - - @app.query("name", str) - @app.get("/") - async def query_route(name: str): - return name - - @app.get("/body") - @app.body("name", str) - async def body_route(name: str): - return name - - async with app.test() as test: - assert (await test.get("/", query={"name": "test"})).message == "test" - assert (await test.get("/body", body={"name": "test"})).message == "test" - assert (await test.get("/body", body={"name": "test"})).message == "test" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_response_type(): - app = new_app() - - @app.get("/") - async def index(): - return Response("hello world", 201, {"hello": "world"}) - - async with app.test() as test: - res = await test.get("/") - - assert res.message == "hello world" - assert res.status == 201 - assert res.headers["hello"] == "world" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_object_validation(): - app = new_app() - - @dataclass - class Dataclass: - a: str - b: Union[str, int] - c: Dict[str, int] - d: dict = field(default_factory=dict) - - class Pydantic(BaseModel): - a: str - b: Union[str, int] - c: Dict[str, int] - d: dict = Field(default_factory=dict) - - class ND(NamedTuple): - a: str - b: Union[str, int] - c: Dict[str, int] - - class VB: - __view_body__ = { - "hello": str, - "world": BodyParam((str, int), default="hello"), - } - - @staticmethod - def __view_construct__(hello: str, world: Union[str, int]): - assert isinstance(hello, str) - assert world == "hello" - - class TypedD(TypedDict): - a: str - b: Union[str, int] - c: Dict[str, int] - d: NotRequired[str] - - @app.get("/td") - @app.query("data", TypedD) - async def td(data: TypedD): - assert data["a"] == "1" - assert data["b"] == 2 - assert data["c"]["3"] == 4 - assert "d" not in data - return "hello" - - @app.get("/dc") - @app.query("data", Dataclass) - async def dc(data: Dataclass): - assert data.a == "1" - assert data.b == 2 - assert data.c["3"] == 4 - assert data.d == {} - return "hello" - - @app.get("/pd") - @app.query("data", Pydantic) - async def pd(data: Pydantic): - assert data.a == "1" - assert data.c["3"] == 4 - assert data.d == {} - return "world" - - @app.get("/nd") - @app.query("data", ND) - async def nd(data: ND): - assert data.a == "1" - assert data.b == 2 - assert data.c["3"] == 4 - return "foo" - - @app.get("/vb") - @app.query("data", VB) - async def vb(data: VB): - return "yay" - - class NestedC(NamedTuple): - c: Union[str, int] - - class NestedB(NamedTuple): - b: NestedC - - class NestedA(NamedTuple): - a: NestedB - - @app.get("/nested") - @app.query("data", NestedA) - async def nested(data: NestedA): - assert data.a.b.c in {"hello", 1} - return "hello" - - async with app.test() as test: - assert ( - await test.get("/td", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) - ).message == "hello" - assert ( - await test.get("/dc", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) - ).message == "hello" - assert ( - await test.get("/pd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) - ).message == "world" - assert ( - await test.get("/nd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) - ).message == "foo" - assert ( - await test.get("/pd", query={"data": {"a": "1", "b": 2, "c": {"3": "4"}}}) - ).status == 200 - assert ( - await test.get("/vb", query={"data": {"hello": "world"}}) - ).message == "yay" - assert (await test.get("/vb", query={"data": {"hello": 2}})).status == 400 - assert ( - await test.get("/vb", query={"data": {"hello": "world", "world": {}}}) - ).status == 400 - assert ( - await test.get("/nested", query={"data": {"a": {"b": {"c": "hello"}}}}) - ).message == "hello" - assert ( - await test.get("/nested", query={"data": {"a": {"b": {"c": 1}}}}) - ).message == "hello" - assert ( - await test.get("/dc", query={"data": {"a": "1", "b": True, "c": {"3": 4}}}) - ).status == 400 - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_dict_validation(): - app = new_app() - - class Object(NamedTuple): - a: str - b: Union[str, int] - - @app.get("/") - @app.query("data", Dict[str, Object]) - async def index(data: Dict[str, Object]): - assert data["a"].a == "a" - assert data["b"].b in {"a", 1} - return "hello" - - async with app.test() as test: - assert ( - await test.get( - "/", - query={"a": {"a": "a", "b": "b"}, "b": {"a": "a", "b": "a"}}, - ) - ).message - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_non_async_routes(): - app = new_app() - - @app.get("/") - def index(): - return "hello world", 201, {"a": "b"} - - async with app.test() as test: - res = await test.get("/") - - assert res.message == "hello world" - assert res.status == 201 - assert res.headers["a"] == "b" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_list_validation(): - app = new_app() - - @app.get("/") - @app.query("test", List[int]) - async def index(test: List[int]): - return str(test[0]) - - @app.get("/union") - @app.query("test", List[Union[int, str]]) - async def union(test: List[Union[int, str]]): - return str(test[0]) - - @app.get("/dict") - @app.query("test", Dict[str, List[str]]) - async def d(test: Dict[str, List[str]]): - return test["a"][0] - - @dataclass - class Body: - l: List[int] - d: Dict[str, List[str]] - - @app.get("/body") - @app.body("test", Body) - async def bod(test: Body): - return test.d["a"][0], test.l[0] - - @dataclass - class B: - test: str - - @dataclass - class A: - l: List[B] - - @app.get("/nested") - @app.body("test", A) - async def nested(test: A): - return test.l[0].test - - async with app.test() as test: - assert (await test.get("/", query={"test": [1, 2, 3]})).message == "1" - assert (await test.get("/union", query={"test": [1, "2", 3]})).message == "1" - assert (await test.get("/", query={"test": [1, "2", True]})).status == 400 - assert ( - await test.get("/dict", query={"test": {"a": ["1", "2", "3"]}}) - ).message == "1" - assert ( - await test.get("/dict", query={"test": {"a": ["1", "2", 3]}}) - ).status == 400 - assert ( - await test.get( - "/body", - body={"test": {"l": [200], "d": {"a": ["1", "2", "3"]}}}, - ) - ).message == "1" - assert ( - await test.get( - "/body", - body={"test": {"l": [200], "d": {"a": ["1", "2", 3]}}}, - ) - ).status == 400 - assert ( - await test.get( - "/nested", - body={"test": {"l": [{"test": "1"}]}}, - ) - ).message == "1" - assert ( - await test.get( - "/nested", - body={"test": {"l": [{"test": 2}]}}, - ) - ).status == 400 - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_auto_route_inputs(): - @dataclass() - class Data: - a: str - b: int - - app = new_app() - - @app.get("/") - async def index(name: str, status: int): - return name, status - - @app.get("/merged") - @query("status", int) - async def merged(name: str, status: int): - return name, status - - @app.get("/data") - async def test_data(data: Data): - return data.a, data.b - - async with app.test() as test: - res = await test.get("/", query={"name": "hi", "status": 201}) - assert res.message == "hi" - assert res.status == 201 - - res2 = await test.get("/merged", query={"name": "hi", "status": 201}) - assert res2.message == "hi" - assert res2.status == 201 - - res3 = await test.get("/data", query={"data": {"a": "hi", "b": 201}}) - assert res3.message == "hi" - assert res3.status == 201 - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_attrs_validation(): - app = new_app() - - @attrs.define - class Test: - a: str - b: int - c: List[str] - d: Dict[str, int] = attrs.Factory(dict) - - @app.get("/") - @app.query("test", Test) - async def index(test: Test): - return test.a - - async with app.test() as test: - assert ( - await test.get("/", query={"test": {"a": "b", "b": 0, "c": []}}) - ).message == "b" - assert ( - await test.get("/", query={"test": {"a": "b", "b": "hi", "c": []}}) - ).status == 400 - assert ( - await test.get("/", query={"test": {"a": "b", "b": 0, "c": ["a"]}}) - ).message == "b" - assert ( - await test.get("/", query={"test": {"a": "b", "b": 0, "c": [1]}}) - ).status == 400 - assert ( - await test.get( - "/", - query={"test": {"a": "b", "b": 0, "c": [], "d": {"a": "b"}}}, - ) - ).status == 400 - assert ( - await test.get( - "/", query={"test": {"a": "b", "b": 0, "c": [], "d": {"a": 0}}} - ) - ).message == "b" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_caching(): - app = new_app() - count = 0 - - @app.get("/param", cache_rate=10) - async def param(): - nonlocal count - count += 1 - return str(count) - - @get("/param_std", cache_rate=10) - async def param_std(): - nonlocal count - count += 1 - return str(count) - - async with app.test() as test: - results = [(await test.get("/param")).message for _ in range(10)] - assert all(i == results[0] for i in results) - - results = [(await test.get("/param_std")).message for _ in range(10)] - assert all(i == results[0] for i in results) - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_synchronous_route_inputs(): - app = new_app() - - @app.get("/") - @app.query("test", str) - def index(test: str): - return test - - @app.get("/body") - @app.body("test", str) - def bd(test: str): - return test - - @app.get("/both") - @app.body("a", str) - @app.query("b", str) - def both(a: str, b: str): - return a + b - - async with app.test() as test: - assert (await test.get("/", query={"test": "a"})).message == "a" - assert (await test.get("/body", body={"test": "b"})).message == "b" - assert ( - await test.get("/both", body={"a": "a"}, query={"b": "b"}) - ).message == "ab" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_request_data(): - app = new_app() - - @app.get("/") - @context() - async def index(ctx: Context): - header = ctx.headers["hello"] - assert isinstance(header, str) - return header - - @app.get("/scheme") - @app.context - async def scheme(ctx: Context): - return ctx.scheme - - @app.get("/method") - @app.context() - async def method(ctx: Context): - return ctx.method - - @app.post("/method") - @context - async def method_post(ctx: Context): - return ctx.method - - @app.get("/version") - @context - async def http_version(ctx: Context): - return ctx.http_version - - @app.get("/cookies") - async def cookies(ctx: Context): - return ctx.cookies["hello"] - - async with app.test() as test: - assert (await test.get("/", headers={"hello": "world"})).message == "world" - assert (await test.get("/scheme")).message == "http" - assert (await test.get("/method")).message == "GET" - assert (await test.post("/method")).message == "POST" - assert (await test.get("/version")).message == "view_test" - assert ( - await test.get("/cookies", headers={"cookie": "hello=world"}) - ).message == "world" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_context_alongside_other_inputs(): - app = new_app() - - @app.get("/") - @app.query("a", str) - @app.context - @app.body("c", str) - async def index(a: str, ctx: Context, c: str): - b = ctx.headers["b"] - assert isinstance(b, str) - return a + b + c - - async with app.test() as test: - assert ( - await test.get("/", query={"a": "a"}, headers={"b": "b"}, body={"c": "c"}) - ).message == "abc" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_middleware(): - app = new_app() - value: bool = False - - @app.get("/") - async def index(): - return str(value) - - @index.middleware - async def index_middleware(call_next: CallNext): - nonlocal value - value = True - return await call_next() - - async with app.test() as test: - assert (await test.get("/")).message == "True" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_middleware_with_parameters(): - app = new_app() - - @app.get("/") - @app.query("a", str) - async def index(a: str): - return "hello" - - @index.middleware - async def index_middleware(call_next: CallNext, a: str): - assert a == "a" - return await call_next() - - @app.get("/both") - @app.query("a", str) - @app.context - @app.body("b", str) - async def both(a: str, ctx: Context, b: str): - return "hello" - - @both.middleware - async def both_middleware(call_next: CallNext, a: str, ctx: Context, b: str): - assert a + b == "ab" - assert ctx.http_version == "view_test" - return await call_next() - - async with app.test() as test: - await test.get("/", query={"a": "a"}) - await test.get("/both", query={"a": "a"}, body={"b": "b"}) - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_methodless_routes(): - app = new_app() - - @app.route("/") - def methodless(): - return "a" - - @app.route("/ctx") - @app.context - def methodless_ctx(context: Context): - return context.method - - @route_impl("/methods", methods=("GET", "POST")) - @app.context - async def m(context: Context): - return context.method - - app.load(m) - - async with app.test() as test: - assert (await test.get("/")).message == "a" - assert (await test.post("/")).message == "a" - assert (await test.put("/")).message == "a" - assert (await test.patch("/")).message == "a" - assert (await test.delete("/")).message == "a" - assert (await test.options("/")).message == "a" - assert (await test.options("/ctx")).message == "OPTIONS" - assert (await test.post("/ctx")).message == "POST" - assert (await test.post("/ctx")).message == "POST" - assert (await test.get("/methods")).message == "GET" - assert (await test.post("/methods")).message == "POST" - assert (await test.put("/methods")).status == 405 - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_method_not_allowed_errors(): - app = new_app() - - @app.get("/") - async def index(): - return "a" - - async with app.test() as test: - assert (await test.get("/")).message == "a" - res = await test.post("/") - assert res.status == 405 - assert res.message == "Method Not Allowed" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_json_response_class(): - app = new_app() - - @app.get("/") - async def index(): - return JSON({"hello": "world"}) - - async with app.test() as test: - assert (await test.get("/")).message == '{"hello":"world"}' - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_body_translate_strategies(): - app = new_app() - - @app.get("/") - async def index(): - return Response("a", body_translate="repr") - - @app.get("/result") - async def result(): - return Response(JSON({}), body_translate="result") - - class CustomResponse(Response[list]): - def __init__(self, body: list) -> None: - super().__init__(body, body_translate="custom") - - def translate_body(self, body: list) -> str: - return " ".join(body) - - @app.get("/custom") - async def custom(): - return CustomResponse(["1", "2", "3"]) - - async with app.test() as test: - assert (await test.get("/")).message == repr("a") - assert (await test.get("/result")).message == "{}" - assert (await test.get("/custom")).message == "1 2 3" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_bytes_response(): - app = new_app() - - @app.get("/") - async def index(): - return b"\x09 \x09" - - @app.get("/hi") - async def hi(): - return b"hi", 201, {"test": "test"} - - async with app.test() as test: - assert (await test.get("/")).content == b"\x09 \x09" - assert (await test.get("/hi")).content == b"hi" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_stress(): - app = new_app() - - @app.get("/") - def index(): - return "test" - - @app.get("/async") - async def async_route(): - return "async" - - @app.get("/inputs") - async def inputs(x: str): - return x - - @app.websocket("/ws") - async def ws_route(ws: WebSocket): - async with ws: - await ws.send("test") - - async with app.test() as test: - - async def run(): - async def routes(): - assert (await test.get("/")).message == "test" - assert (await test.get("/async")).message == "async" - for i in range(5): - assert ( - await test.get("/inputs", query={"x": str(i)}) - ).message == str(i) - - await asyncio.gather(*[routes() for i in range(10)]) - - async with test.websocket("/ws") as sock: - assert (await sock.receive()) == "test" - - await asyncio.gather(*[run() for _ in range(100)]) diff --git a/tests/test_build.py b/tests/test_build.py deleted file mode 100644 index 107a2402..00000000 --- a/tests/test_build.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -from pathlib import Path - -import pytest - -from view import new_app - - -@pytest.mark.asyncio -async def test_build_requirements(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_reqs.toml") - - @app.get("/") - async def index(): - import pip - - return pip.__file__ - - @app.get("/foo", steps=("foo",)) - async def wont_work(): - return "shouldn't be here" - - @app.get("/customreq", steps=("customreq",)) - async def should_work(): - assert os.path.exists("customreq.test") - return "test" - - @app.get("/failingreq", steps=("failingreq",)) - async def fail(): - return "shouldn't be here" - - async with app.test() as test: - assert (await test.get("/")).message != "" - assert (await test.get("/foo")).status == 500 - assert (await test.get("/customreq")).message == "test" - assert (await test.get("/failingreq")).status == 500 - assert os.path.exists("failingreq.test") - - -@pytest.mark.asyncio -async def test_build_scripts(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_scripts.toml") - - called = False - - @app.get("/", steps=("fail",)) - async def index(): - nonlocal called - called = True - return "..." - - async with app.test() as test: - assert "_VIEW_TEST_BUILD_SCRIPT" in os.environ - assert (await test.get("/")).status == 500 - assert not called - - -@pytest.mark.asyncio -async def test_build_commands(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_commands.toml") - - @app.get("/", steps=["fail"]) - async def fail(): - return "." - - async with app.test() as test: - assert (await test.get("/")).status == 500 - - assert os.path.exists("build.test") - - -@pytest.mark.asyncio -async def test_build_platform(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_platform.toml") - - @app.get("/", steps=["windowsonly"]) - async def index(): - return "hello world" - - async with app.test() as test: - if os.name == "nt": - assert (await test.get("/")).message == "hello world" - else: - assert (await test.get("/")).status == 500 - - assert os.path.exists( - "linux_build.test" if os.name != "nt" else "windows_build.test" - ) diff --git a/tests/test_functions.py b/tests/test_functions.py deleted file mode 100644 index 052684e4..00000000 --- a/tests/test_functions.py +++ /dev/null @@ -1,216 +0,0 @@ -import os -from dataclasses import dataclass -from typing import Dict - -import pytest -from conftest import limit_leaks -from typing_extensions import Annotated - -from view import ( - App, - BadEnvironmentError, - Context, - TypeValidationError, - call_result, - compile_type, - env, - get_app, - new_app, - to_response, -) -from view.typing import CallNext, MaybeAwaitable, SupportsViewResult, ViewResult - - -@limit_leaks("1 MB") -def test_app_creation(): - app = new_app() - assert isinstance(app, App) - app.load() - - -@limit_leaks("1 MB") -def test_app_fetching(): - app = new_app() - assert isinstance(get_app(), App) - app.load() - assert app is get_app() - - -def documentation_generation(): - app = new_app() - - @dataclass - class Test: - id: int - - @dataclass - class Person: - """A person.""" - - first: Annotated[str, "Your first name."] - last: Annotated[str, "Your last name."] - parent: Test - - @app.get("/") - @app.query("person", Person, doc="Your info.") - @app.body("friend", Person, doc="Your friend's info.") - @app.query("favorite_color", str, doc="Your favorite color.") - @app.query("contacts", Dict[str, int], doc="Your phone contacts.") - async def index( - person: Person, - friend: Person, - favorite_color: str, - contacts: Dict[str, int], - ): - """Homepage.""" - return "hello world" - - assert ( - app.docs() - == """# Docs -## Types -### `Person` -| Key | Description | Type | Default | -| - | - | - | - | -| first | Your first name. | `string` | **Required** | -| last | Your last name. | `string` | **Required** | -| parent | No description provided. | `Test` | **Required** | -### `Test` -| Key | Description | Type | Default | -| - | - | - | - | -| id | No description provided. | `integer` | **Required** | -## Routes -### GET `/` -*Homepage.* -#### Query Parameters -| Name | Description | Type | Default | -| - | - | - | - | -| person | Your info. | `Person` | **Required** | -| favorite_color | Your favorite color. | `string` | **Required** | -| contacts | Your phone contacts. | `object` | **Required** | -#### Body Parameters -| Name | Description | Type | Default | -| - | - | - | - | -| friend | Your friend's info. | `Person` | **Required** |""" - ) - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_public_typecode_interface(): - @dataclass - class Test: - a: str - b: str - - tp = compile_type(Test) - assert isinstance(tp.cast('{"a": "a", "b": "b"}'), Test) - - x = True - - if tp.check_type('{"a": "a", "b": "b"}'): - x = False - - assert x - if tp.is_compatible('{"a": "a", "b": "b"}'): - x = False - - assert not x - - with pytest.raises(TypeValidationError): - tp.cast("{}") - - -@pytest.mark.asyncio -async def test_environment_variables(): - with pytest.raises(BadEnvironmentError): - env("_TEST") - - os.environ["_TEST"] = "1" - - assert env("_TEST") == "1" - assert env("_TEST", tp=int) == 1 - os.environ["_TEST2"] = '{"hello": "world"}' - - test2 = env("_TEST2", tp=dict) - assert isinstance(test2, dict) - assert test2["hello"] == "world" - - os.environ["_TEST3"] = "false" - assert env("_TEST3", tp=bool) is False - - -@pytest.mark.asyncio -async def test_to_response(): - app = new_app() - - @app.get("/") - async def index(): - return "hello", 201, {"a": "b"} - - @app.get("/bytes") - async def other(): - return b"test", 200, {"hello": "world"} - - @index.middleware - async def middleware(call_next: CallNext): - res = await to_response(await call_next()) - assert res.body == "hello" - assert res.status == 201 - assert res.headers == {"a": "b"} - res.body = "goodbye" - return res - - @other.middleware - async def other_middleware(call_next: CallNext): - res = await to_response(await call_next()) - assert res.body == b"test" - assert res.headers == {"hello": "world"} - return res - - async with app.test() as test: - assert (await test.get("/")).message == "goodbye" - assert (await test.get("/bytes")).message == "test" - - -async def test_supports_result_isinstance(): - called = 0 - - class MyObject(SupportsViewResult): - async def __view_result__(self, ctx: Context) -> MaybeAwaitable[ViewResult]: - nonlocal called - called += 1 - return "hello" - - class MyObjectNoInherit: - async def __view_result__(self, ctx: Context) -> MaybeAwaitable[ViewResult]: - nonlocal called - called += 1 - return "hello" - - assert isinstance(MyObject(), SupportsViewResult) - assert issubclass(MyObject, SupportsViewResult) - assert isinstance(MyObjectNoInherit(), SupportsViewResult) - assert called == 2 - - -@pytest.mark.asyncio -async def test_call_result(): - class MyObject(SupportsViewResult): - async def __view_result__(self, ctx: Context) -> MaybeAwaitable[ViewResult]: - return "hello" - - class MyObjectUseCtx(SupportsViewResult): - async def __view_result__(self, ctx: Context) -> MaybeAwaitable[ViewResult]: - return ctx.path - - app = new_app() - assert (await call_result(MyObject())) == "hello" - - @app.get("/") - async def index(ctx: Context): - res = await call_result(MyObjectUseCtx(), ctx=ctx) - return res - - async with app.test() as test: - assert (await test.get("/")).message == "/" diff --git a/tests/test_loaders.py b/tests/test_loaders.py deleted file mode 100644 index 9e2cf30b..00000000 --- a/tests/test_loaders.py +++ /dev/null @@ -1,130 +0,0 @@ -from pathlib import Path -from typing import List - -import pytest - -from view import ( - App, - InvalidCustomLoaderError, - Route, - delete, - get, - new_app, - options, - patch, - post, - put, -) - - -@pytest.mark.asyncio -async def test_manual_loader(): - app = new_app() - assert app.config.app.loader == "manual" - - @get("/get") - async def g(): - return "get" - - @post("/post") - async def p(): - return "post" - - @put("/put") - async def pu(): - return "put" - - @patch("/patch") - async def pa(): - return "patch" - - @delete("/delete") - async def d(): - return "delete" - - @options("/options") - async def o(): - return "options" - - app.load(g, p, pu, pa, d, o) - - async with app.test() as test: - assert (await test.get("/get")).message == "get" - assert (await test.post("/post")).message == "post" - assert (await test.put("/put")).message == "put" - assert (await test.patch("/patch")).message == "patch" - assert (await test.delete("/delete")).message == "delete" - assert (await test.options("/options")).message == "options" - - -@pytest.mark.asyncio -async def test_simple_loader(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "simple.toml") - - async with app.test() as test: - assert (await test.get("/get")).message == "get" - assert (await test.post("/post")).message == "post" - assert (await test.put("/put")).message == "put" - assert (await test.patch("/patch")).message == "patch" - assert (await test.delete("/delete")).message == "delete" - assert (await test.options("/options")).message == "options" - - -@pytest.mark.asyncio -async def test_filesystem_loader(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "fs.toml") - - async with app.test() as test: - assert (await test.get("/get")).message == "get" - assert (await test.post("/post")).message == "post" - assert (await test.put("/put")).message == "put" - assert (await test.patch("/patch")).message == "patch" - assert (await test.delete("/delete")).message == "delete" - assert (await test.options("/options")).message == "options" - - -@pytest.mark.asyncio -async def test_patterns_loader(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "urls.toml") - - async with app.test() as test: - assert (await test.get("/get")).message == "get" - assert (await test.post("/post")).message == "post" - assert (await test.put("/put")).message == "put" - assert (await test.patch("/patch")).message == "patch" - assert (await test.delete("/delete")).message == "delete" - assert (await test.options("/options")).message == "options" - assert (await test.options("/any")).message == "any" - assert (await test.post("/inputs", query={"a": "a"})).message == "a" - - -@pytest.mark.asyncio -async def test_custom_loader(): - app = new_app() - app.config.app.loader = "custom" - - @app.custom_loader - def my_loader(app: App, path: Path) -> List[Route]: - @get("/") - async def index(): - return "test" - - return [index] - - async with app.test() as test: - assert (await test.get("/")).message == "test" - - -def test_custom_loader_errors(): - app = new_app() - app.config.app.loader = "custom" - - with pytest.raises(InvalidCustomLoaderError): - app.load() - - @app.custom_loader - def my_loader(app: App, path: Path) -> List[Route]: - return 123 # type: ignore - - with pytest.raises(InvalidCustomLoaderError): - app.load() diff --git a/tests/test_status.py b/tests/test_status.py deleted file mode 100644 index 59ce1dc1..00000000 --- a/tests/test_status.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -from conftest import limit_leaks - -from view import ERROR_CODES, HTTPError, InvalidStatusError, new_app - -STATUS_CODES = ( - 200, - 201, - 202, - 203, - 204, - 205, - 206, - 207, - 208, - 226, - 300, - 301, - 302, - 303, - 304, - 305, - 307, - 308, -) - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_returning_the_proper_status_code(): - app = new_app() - - @app.get("/") - async def index(status: int): - return "hello", status - - @app.get("/error") - async def err(status: int): - raise HTTPError(status) # type: ignore - - @app.get("/fail") - async def fail(): - with pytest.raises(InvalidStatusError): - raise HTTPError(200) # type: ignore - - with pytest.raises(InvalidStatusError): - raise HTTPError(600) # type: ignore - - return "" - - async with app.test() as test: - for status in [*STATUS_CODES, *ERROR_CODES]: - assert (await test.get("/", query={"status": status})).status == status - - for status in ERROR_CODES: - assert (await test.get("/error", query={"status": status})).status == status diff --git a/tests/test_templates.py b/tests/test_templates.py deleted file mode 100644 index c49f5429..00000000 --- a/tests/test_templates.py +++ /dev/null @@ -1,109 +0,0 @@ -from pathlib import Path - -import pytest - -from view import markdown, new_app, render, template - - -@pytest.mark.asyncio -async def test_view_rendering(): - x = 2 - - class Test: - def __init__(self): - self.foo = "bar" - - test = Test() - d = {"hello": "world"} - assert (await render('')) == "1" - assert (await render('')) == "2" - assert (await render('')) == "bar" - assert (await render("")) == "world" - assert (await render('2')) == "22" - - l = [1, 2, 3] - assert (await render('')) == "3" - assert (await render('')) == "123" - assert (await render('hi')) == "hi" - assert (await render('hi')) == "" - assert ( - await render( - 'hi2bye' - ) - ) == "2" - assert ( - await render( - 'hi24bye' - ) - ) == "2" - assert ( - await render( - 'hi24bye' - ) - ) == "bye" - - -@pytest.mark.asyncio -async def test_other_engines(): - x = "world" - assert (await render("hello {{ x }}", engine="jinja")) == "hello world" - assert (await render("hello {{ x }}", engine="django")) == "hello world" - assert (await render("hello ${x}", engine="mako")) == "hello world" - assert (await render("hello ${x}", engine="chameleon")) == "hello world" - - -@pytest.mark.asyncio -async def test_templating(): - app = new_app() - - @app.get("/") - async def index(): - hi = "hello" - return await app.template( - "index.html", directory=Path.cwd() / "tests" / "templates" - ) - - @app.get("/other") - async def other(): - x = 1 - return await template( - "something", - directory=Path.cwd() / "tests" / "other_templates", - engine="mako", - ) - - @app.get("/markdown") - async def md(): - return await markdown("test.md", directory=Path.cwd() / "tests" / "templates") - - async with app.test() as test: - assert (await test.get("/")).message.replace("\n", "") == "hello" - assert (await test.get("/other")).message.replace("\n", "") == "1" - assert (await test.get("/markdown")).message.replace( - "\n", "" - ) == "

A

B

C

" - - -@pytest.mark.asyncio -async def test_template_configuration_settings(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "templates.toml") - - @app.get("/") - async def index(): - x = 1 - return await template("something") - - async with app.test() as test: - assert (await test.get("/")).message.replace("\n", "") == "1" - - -@pytest.mark.asyncio -async def test_view_renderer_subtemplates(): - app = new_app(config_path=Path.cwd() / "tests" / "configs" / "subtemplates.toml") - - @app.get("/") - async def index(): - return await template("sub") - - async with app.test() as test: - assert (await test.get("/")).message.replace("\n", "") == "helloworld" diff --git a/tests/test_websocket.py b/tests/test_websocket.py deleted file mode 100644 index 9da55153..00000000 --- a/tests/test_websocket.py +++ /dev/null @@ -1,161 +0,0 @@ -import pytest -from conftest import limit_leaks - -from view import ( - InvalidRouteError, - WebSocket, - WebSocketExpectError, - WebSocketHandshakeError, - new_app, - websocket, -) -''' - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_websocket_echo_server(): - app = new_app() - - @app.websocket("/") - async def echo(ws: WebSocket): - await ws.accept() - - while True: - message = await ws.receive() - await ws.send(message) - - async with app.test() as test: - async with test.websocket("/") as ws: - await ws.send("hello") - assert (await ws.receive()) == "hello" - await ws.send("world") - assert (await ws.receive()) == "world" - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_websocket_message_pairs(): - app = new_app() - - @websocket("/") - async def back_and_forth(ws: WebSocket): - await ws.accept() - count = 0 - - while True: - message = await ws.pair(count, tp=int) - assert message == count - count += 1 - - app.load(back_and_forth) - - async with app.test() as test: - async with test.websocket("/") as ws: - for i in range(5): - num = await ws.receive() - assert num == str(i) - await ws.send(num) - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_websocket_message_pairs_receiving_first(): - app = new_app() - - @app.websocket("/") - async def forth_and_back(ws: WebSocket): - await ws.accept() - - for i in range(5): - assert i == await ws.pair(i, recv_first=True, tp=int) - - async with app.test() as test: - async with test.websocket("/") as ws: - count = 0 - await ws.send(str(count)) - assert (await ws.receive()) == str(count) - count += 1 - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_websocket_receiving_casts(): - app = new_app() - - @app.websocket("/") - async def casts(ws: WebSocket): - await ws.accept() - - assert (await ws.receive(tp=int)) == 42 - assert (await ws.receive(tp=dict)) == {"hello": 42} - assert (await ws.receive(tp=bool)) is True - assert (await ws.receive(tp=bool)) is False - assert (await ws.receive(tp=bytes)) == b"test" - - await ws.send("hi") - await ws.expect("foo") - await ws.expect(b"foo") - - with pytest.raises(WebSocketExpectError): - await ws.expect(1) - - async with app.test() as test: - async with test.websocket("/") as ws: - await ws.send("42") - await ws.send('{"hello": 42}') - await ws.send("1") - await ws.send("false") - await ws.send("test") - - assert (await ws.receive()) == "hi" - await ws.send("foo") - await ws.send("bar") - await ws.send("2") - - -@pytest.mark.asyncio -@limit_leaks("1 MB") -async def test_websocket_handshake_errors(): - app = new_app() - - @app.websocket("/") - async def casts(ws: WebSocket): - with pytest.raises(WebSocketHandshakeError): - await ws.send("hi") - - with pytest.raises(WebSocketHandshakeError): - await ws.receive() - - async with ws: - with pytest.raises(WebSocketHandshakeError): - await ws.accept() - await ws.send("test") - - with pytest.raises(WebSocketHandshakeError): - await ws.accept() - - with pytest.raises(WebSocketHandshakeError): - await ws.close() - - with pytest.raises(WebSocketHandshakeError): - await ws.send("hi") - - with pytest.raises(WebSocketHandshakeError): - await ws.receive() - - async with app.test() as test: - async with test.websocket("/") as ws: - assert (await ws.receive()) == "test" - - -def test_disallow_body_inputs(): - app = new_app() - - @app.websocket("/") - @app.body("foo", str) - async def whatever(ws: WebSocket, foo: str): ... - - with pytest.raises(InvalidRouteError): - app.load() - -''' diff --git a/uncrustify.cfg b/uncrustify.cfg index 63315975..6fe5f9ad 100644 --- a/uncrustify.cfg +++ b/uncrustify.cfg @@ -10,7 +10,7 @@ indent_with_tabs = 0 # Ugly Newlines nl_struct_brace = remove nl_else_brace = remove -nl_brace_else = remove +nl_brace_else = force nl_if_brace = remove nl_else_if = remove nl_for_brace = remove @@ -59,7 +59,6 @@ sp_paren_comma = remove # Braces sp_inside_braces_empty = remove sp_sparen_brace = force -nl_before_brace_open = true nl_assign_brace = remove sp_brace_else = force sp_else_brace = force From 4351bf0dec6cddc5a243d96c50572821d43d7428 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 19:12:49 -0400 Subject: [PATCH 002/188] Some further resetting. --- CHANGELOG.md | 43 +-------------- hatch_build.py | 7 --- mkdocs.yml | 125 ------------------------------------------- netlify.toml | 9 ---- requirements.txt | 1 - runtime.txt | 1 - src/_view/module.c | 29 ++++++++++ src/view/__init__.py | 0 src/view/app.py | 2 + 9 files changed, 32 insertions(+), 185 deletions(-) delete mode 100644 hatch_build.py delete mode 100644 mkdocs.yml delete mode 100644 netlify.toml delete mode 100644 requirements.txt delete mode 100644 runtime.txt create mode 100644 src/_view/module.c create mode 100644 src/view/__init__.py create mode 100644 src/view/app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9d5a40..8fc9e12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,48 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Added the `default_page` function -- Made `default_page` the example response in `view init` -- Added `.gitignore` generation to `view init` -- Added support for coroutines in `PyAwaitable` (vendored) -- Finished websocket implementation -- Added the `custom` loader -- Added support for returning `bytes` objects in the body -- Added `nosanitize` and `repr` to the `ref` attribute of `` tags in view template rendering -- `WebSocketDisconnect` is now raised instead of `WebSocketHandshakeError` in an unexpected WebSocket disconnect -- Added many, _many_, more docstrings -- Added the `app` attribute to `Context` -- Switched to PyMalloc under the hood -- Deprecated the `run()` utility -- Added support for asynchronous `__view_result__` functions -- Removed unstable `components` functions from top-level `view` module -- Added native support for `ReactPy` component routes -- Added the `expect_errors` utility -- Added the `HeaderDict` class -- Changed the `headers` attribute on `Context` to a `HeaderDict` instance of a `dict` -- Added the `call_result` utility -- Added the `ctx` parameter to `to_response` -- Removed broken hint when forgetting to call `load()` -- Added support for `isinstance` to `SupportsViewResult` -- Moved `to_response` to the `view.utils` module -- Added the `force` parameter to `run` -- Added the `view dev` command (live reload) -- Fixed redirection and disabling of HTTP server logging -- C API is now compliant with [PEP 7](https://peps.python.org/pep-0007/) -- Added `-g3` and `-O3` flag to the `_view` extension module (debugging information and optimizations) -- Removed use of Rich `escape()` in the message shown when a dependency is needed -- Query string client errors are now displayed during development mode -- `KeyboardInterrupt` is swallowed by the server coroutine, and a log message is now issued -- Typecode API now raises exceptions indicating a validation error (and now it's sent as a response with a query or body parse failure) -- `hatchling` and `scikit-build-core` are now used for build instead of `setuptools` -- Renamed the `view-admin` command to `view-py` -- **Breaking Change:** Renamed `Error` to `HTTPError` -- **Breaking Change:** `__view_result__` is now given a `Context` parameter -- **Breaking Change:** `to_response` is now asynchronous -- **Breaking Change:** Renamed `Response._custom` to `Response.translate_body` -- **Breaking Change:** Removed the `hijack` configuration setting -- **Breaking Change:** Removed the `post_init` parameter from `new_app`, as well as renamed the `store_address` parameter to `store` -- **Breaking Change:** `load()` now takes routes via variadic arguments, instead of a list of routes. +- Removed everything from prior releases! ## [1.0.0-alpha10] - 2024-5-26 diff --git a/hatch_build.py b/hatch_build.py deleted file mode 100644 index 360d1f1c..00000000 --- a/hatch_build.py +++ /dev/null @@ -1,7 +0,0 @@ -from hatchling.metadata.plugin.interface import MetadataHookInterface -import pyawaitable -import os - -class JSONMetaDataHook(MetadataHookInterface): - def update(self, *_) -> None: - os.environ["PYAWAITABLE_INCLUDE_DIR"] = pyawaitable.include() diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index ed8547b9..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,125 +0,0 @@ -site_name: view.py -site_url: https://view.zintensity.dev -repo_url: https://github.com/ZeroIntensity/view.py -repo_name: ZeroIntensity/view.py - -nav: - - Home: index.md - - Getting Started: - - Installation: getting-started/installation.md - - Configuration: getting-started/configuration.md - - Creating a Project: getting-started/creating_a_project.md - - Building Projects: - - App Basics: building-projects/app_basics.md - - URL Routing: building-projects/routing.md - - Returning Responses: building-projects/responses.md - - Taking Parameters: building-projects/parameters.md - - Getting Request Data: building-projects/request_data.md - - HTML Templating: building-projects/templating.md - - Runtime Builds: building-projects/build_steps.md - - Writing Documentation: building-projects/documenting.md - - Using WebSockets: building-projects/websockets.md - - API Reference: - - Types: reference/types.md - - Utilities: reference/utils.md - - Exceptions: reference/exceptions.md - - Applications: reference/app.md - - Configuration: reference/config.md - - Routing: reference/routing.md - - Responses: reference/responses.md - - Templates: reference/templates.md - - Build: reference/build.md - - WebSockets: reference/websockets.md - - Contributing: contributing.md - -theme: - name: material - palette: - - media: "(prefers-color-scheme)" - primary: blue - accent: blue - toggle: - icon: material/brightness-auto - name: Switch to light mode - - # Palette toggle for light mode - - media: "(prefers-color-scheme: light)" - scheme: default - primary: blue - accent: blue - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - # Palette toggle for dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: blue - accent: blue - toggle: - icon: material/brightness-4 - name: Switch to system preference - features: - - content.tabs.link - - content.code.copy - - content.action.edit - - search.highlight - - search.share - - search.suggest - - navigation.footer - - navigation.indexes - - navigation.sections - - navigation.tabs - - navigation.tabs.sticky - - navigation.top - - toc.follow - - icon: - repo: fontawesome/brands/github - -extra: - social: - - icon: fontawesome/brands/discord - link: https://discord.gg/tZAfuWAbm2 - name: view.py discord - - icon: fontawesome/brands/github - link: https://github.com/ZeroIntensity/view.py - name: view.py repository - - icon: material/heart - link: https://github.com/sponsors/ZeroIntensity/ - name: support view.py - -markdown_extensions: - - toc: - permalink: true - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.inlinehilite - - pymdownx.snippets - - admonition - - pymdownx.details - - pymdownx.tabbed: - alternate_style: true - - pymdownx.superfences - -plugins: - - search - - tags - - git-revision-date-localized: - enable_creation_date: true - - mkdocstrings: - handlers: - python: - paths: [src] - options: - show_root_heading: true - show_object_full_path: false - show_symbol_type_heading: true - show_symbol_type_toc: true - show_signature: true - seperate_signature: true - show_signature_annotations: true - signature_crossrefs: true - show_source: false - show_if_no_docstring: true - show_docstring_examples: false diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 554da9a1..00000000 --- a/netlify.toml +++ /dev/null @@ -1,9 +0,0 @@ -[build] -command = "hatch run docs:build" -publish = "site" - -[[headers]] - # Define which paths this specific [[headers]] block will cover. - for = "/*" - [headers.values] - Access-Control-Allow-Origin = "*" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f06bada8..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -hatch diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index bd28b9c5..00000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -3.9 diff --git a/src/_view/module.c b/src/_view/module.c new file mode 100644 index 00000000..88406014 --- /dev/null +++ b/src/_view/module.c @@ -0,0 +1,29 @@ +#include +#include + +static int _view_exec(PyObject *mod) +{ + return PyAwaitable_Init(mod); +} + +static PyMethodDef _view_methods[] = { + {NULL} +}; + +static PyModuleDef_Slot _view_slots[] = { + {Py_mod_exec, _view_exec}, + {0, NULL} +}; + +static PyModuleDef _view_module = { + .m_base = PyModuleDef_HEAD_INIT, + .m_name = "_view", + .m_methods = _view_methods, + .m_slots = _view_slots +}; + +PyMODINIT_FUNC +PyInit__view(void) +{ + return PyModuleDef_Init(&_view_module); +} diff --git a/src/view/__init__.py b/src/view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/view/app.py b/src/view/app.py new file mode 100644 index 00000000..ed8adc61 --- /dev/null +++ b/src/view/app.py @@ -0,0 +1,2 @@ +class App: + pass From b5a337ecc4b078ee175a394b4a07362f8a4a0a59 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 19:53:15 -0400 Subject: [PATCH 003/188] Outline for the router. --- _view.pyi | 0 hatch.toml | 29 ------------------ morals.md | 8 +++++ sample.py | 12 ++++++++ src/view/__about__.py | 3 ++ src/view/__init__.py | 2 ++ src/view/app.py | 16 ++++++++-- src/view/router.py | 70 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 _view.pyi create mode 100644 morals.md create mode 100644 sample.py create mode 100644 src/view/__about__.py create mode 100644 src/view/router.py diff --git a/_view.pyi b/_view.pyi new file mode 100644 index 00000000..e69de29b diff --git a/hatch.toml b/hatch.toml index ddc50be8..73cdfdd5 100644 --- a/hatch.toml +++ b/hatch.toml @@ -10,10 +10,6 @@ packages = ["src/view"] [build.targets.wheel.force-include] "_view.pyi" = "_view.pyi" -[metadata.hooks.custom] -path = "hatch_build.py" -enable-by-default = true - [build.targets.wheel.hooks.scikit-build] experimental = true @@ -24,28 +20,3 @@ verbose = true [build.targets.wheel.hooks.scikit-build.install] strip = false - -[envs.hatch-test] -features = ["full"] -dev-mode = false -dependencies = [ - "coverage", - "pytest", - "pytest-memray", - "pytest-asyncio", -] -platforms = ["linux", "macos"] - -[envs.test.overrides.platform.windows] -dependencies = [ - "coverage", - "pytest", - "pytest-asyncio", -] - -[envs.docs] -dependencies = ["mkdocs", "mkdocstrings[python]", "mkdocs-material", "mkdocs-git-revision-date-localized-plugin"] - -[envs.docs.scripts] -build = "mkdocs build" -serve = "mkdocs serve" diff --git a/morals.md b/morals.md new file mode 100644 index 00000000..5a068d8f --- /dev/null +++ b/morals.md @@ -0,0 +1,8 @@ +# view.py's morals + +1. Developers shouldn't feel happier in JavaScript. +2. Ease of use is the best way to do it. +3. But resist the temptation to sprinkle in magic. +4. Remember the Zen of Python (PEP 20). +5. Swiss-army knives don't make good APIs. +6. An independent system is a better one. diff --git a/sample.py b/sample.py new file mode 100644 index 00000000..df338ed6 --- /dev/null +++ b/sample.py @@ -0,0 +1,12 @@ +from view.app import new_app +from view.responses import HTML + + +app = new_app() + +@app.get("/") +def index(): + return HTML.from_file("index/test.html") + +if __name__ == "__main__": + app.run() diff --git a/src/view/__about__.py b/src/view/__about__.py new file mode 100644 index 00000000..30139e14 --- /dev/null +++ b/src/view/__about__.py @@ -0,0 +1,3 @@ +__version__: str = "1.0.0-alpha11" +__author__: str = "Peter Bierma " +__license__: str = "MIT" diff --git a/src/view/__init__.py b/src/view/__init__.py index e69de29b..2cdb99ac 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -0,0 +1,2 @@ +from view.__about__ import * +from view.router import * diff --git a/src/view/app.py b/src/view/app.py index ed8adc61..363c38a8 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,2 +1,14 @@ -class App: - pass +from view.router import Router + +__all__ = "App", "new_app" + + +class App(Router): + def process_request(self): + pass + +def new_app() -> App: + """ + High-level function for constructing a view.py application. + """ + ... diff --git a/src/view/router.py b/src/view/router.py new file mode 100644 index 00000000..5e645581 --- /dev/null +++ b/src/view/router.py @@ -0,0 +1,70 @@ +from __future__ import annotations +from enum import Enum, auto +from typing import Awaitable, Callable, TypeAlias +from dataclasses import dataclass + +__all__ = "Method", "Route", "Router" + +class Method(Enum): + GET = auto() + POST = auto() + PUT = auto() + PATCH = auto() + DELETE = auto() + CONNECT = auto() + OPTIONS = auto() + TRACE = auto() + HEAD = auto() + +RouteHandler: TypeAlias = Callable[[], None | Awaitable[None]] + + +@dataclass +class Route: + handler: RouteHandler + path: str + method: Method + + +class Router: + def __init__(self) -> None: + self.routes: dict[str, Route] = {} + + def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: + self.routes[path] = Route(handler=handler, path=path, method=method) + + def route(self, path: str, /, *, method: Method): + """ + Decorator interface for adding a route to the app. + """ + def decorator(function: RouteHandler, /): + self.push_route(function, path, method) + return function + return decorator + + def get(self, path: str, /): + return self.route(path, method=Method.GET) + + def post(self, path: str, /): + return self.route(path, method=Method.POST) + + def put(self, path: str, /): + return self.route(path, method=Method.PUT) + + def patch(self, path: str, /): + return self.route(path, method=Method.PATCH) + + def delete(self, path: str, /): + return self.route(path, method=Method.DELETE) + + def connect(self, path: str, /): + return self.route(path, method=Method.CONNECT) + + def options(self, path: str, /): + return self.route(path, method=Method.OPTIONS) + + def trace(self, path: str, /): + return self.route(path, method=Method.TRACE) + + def head(self, path: str, /): + return self.route(path, method=Method.HEAD) From 7a2c8804789ba84a4a84bb2f047ef63d2c9a9464 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 20:00:36 -0400 Subject: [PATCH 004/188] Some fixups. --- CMakeLists.txt | 2 +- pyproject.toml | 2 +- src/view/__about__.py | 6 +++--- src/view/app.py | 8 +++++++- src/view/router.py | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cc6c705c..9bf48bd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,7 +20,7 @@ python_add_library(_view MODULE ${_view_SRC} WITH_SOABI) # Add include directories target_include_directories(_view PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include/) -target_include_directories(_view PUBLIC $ENV{PYAWAITABLE_INCLUDE_DIR}) +target_include_directories(_view PUBLIC $ENV{PYAWAITABLE_INCLUDE}) MESSAGE(STATUS "Everything looks good, let's install!") # Install extension module diff --git a/pyproject.toml b/pyproject.toml index 804ef399..9a67eb97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = [] +dependencies = ["multidict~=6.5"] dynamic = ["version", "license"] [project.optional-dependencies] diff --git a/src/view/__about__.py b/src/view/__about__.py index 30139e14..eca136d8 100644 --- a/src/view/__about__.py +++ b/src/view/__about__.py @@ -1,3 +1,3 @@ -__version__: str = "1.0.0-alpha11" -__author__: str = "Peter Bierma " -__license__: str = "MIT" +__version__ = "1.0.0-alpha11" +__author__ = "Peter Bierma " +__license__ = "MIT" diff --git a/src/view/app.py b/src/view/app.py index 363c38a8..368d0854 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,10 +1,16 @@ from view.router import Router +from dataclasses import dataclass +from multidict import CIMultiDict __all__ = "App", "new_app" +@dataclass(slots=True, frozen=True) +class Request: + path: str + headers: CIMultiDict class App(Router): - def process_request(self): + def process_request(self, request: Request): pass def new_app() -> App: diff --git a/src/view/router.py b/src/view/router.py index 5e645581..76b9f098 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -19,7 +19,7 @@ class Method(Enum): RouteHandler: TypeAlias = Callable[[], None | Awaitable[None]] -@dataclass +@dataclass(slots=True, frozen=True) class Route: handler: RouteHandler path: str From 87c2c1d8ed25c077589451fdf160ad4be9a5cc36 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 20:37:31 -0400 Subject: [PATCH 005/188] Add status codes. --- src/view/app.py | 17 ++- src/view/router.py | 23 ++-- src/view/status_codes.py | 274 +++++++++++++++++++++++++++++++++++++++ status.py | 26 ++++ status.txt | 119 +++++++++++++++++ 5 files changed, 448 insertions(+), 11 deletions(-) create mode 100644 src/view/status_codes.py create mode 100644 status.py create mode 100644 status.txt diff --git a/src/view/app.py b/src/view/app.py index 368d0854..e9748efc 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,17 +1,28 @@ -from view.router import Router +from view.router import Route, Router from dataclasses import dataclass from multidict import CIMultiDict __all__ = "App", "new_app" + @dataclass(slots=True, frozen=True) class Request: path: str headers: CIMultiDict + +@dataclass(slots=True, frozen=True) +class Response: + content: bytes + status_code: int + headers: CIMultiDict + + class App(Router): - def process_request(self, request: Request): - pass + def process_request(self, request: Request) -> Response: + route: Route | None = self.lookup_route(request.path) + return NotImplemented + def new_app() -> App: """ diff --git a/src/view/router.py b/src/view/router.py index 76b9f098..39f94642 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -5,6 +5,7 @@ __all__ = "Method", "Route", "Router" + class Method(Enum): GET = auto() POST = auto() @@ -16,6 +17,7 @@ class Method(Enum): TRACE = auto() HEAD = auto() + RouteHandler: TypeAlias = Callable[[], None | Awaitable[None]] @@ -33,38 +35,43 @@ def __init__(self) -> None: def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: self.routes[path] = Route(handler=handler, path=path, method=method) + def lookup_route(self, path: str) -> Route | None: + return self.routes.get(path) + def route(self, path: str, /, *, method: Method): """ Decorator interface for adding a route to the app. """ + def decorator(function: RouteHandler, /): self.push_route(function, path, method) return function + return decorator def get(self, path: str, /): return self.route(path, method=Method.GET) - + def post(self, path: str, /): return self.route(path, method=Method.POST) - + def put(self, path: str, /): return self.route(path, method=Method.PUT) - + def patch(self, path: str, /): return self.route(path, method=Method.PATCH) - + def delete(self, path: str, /): return self.route(path, method=Method.DELETE) - + def connect(self, path: str, /): return self.route(path, method=Method.CONNECT) - + def options(self, path: str, /): return self.route(path, method=Method.OPTIONS) - + def trace(self, path: str, /): return self.route(path, method=Method.TRACE) - + def head(self, path: str, /): return self.route(path, method=Method.HEAD) diff --git a/src/view/status_codes.py b/src/view/status_codes.py new file mode 100644 index 00000000..2e07e7a1 --- /dev/null +++ b/src/view/status_codes.py @@ -0,0 +1,274 @@ +from __future__ import annotations +from typing import ClassVar + +STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} + + +class HTTPError(Exception): + status_code: ClassVar[int] + + def __init_subclass__(cls) -> None: + STATUS_EXCEPTIONS[cls.status_code] = cls + + +def status_exception(status: int, *, message: str | None = None) -> HTTPError: + """ + Get an exception for the given status. + """ + 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 + + if message is not None: + return status_type() + + return status_type(message) + + +class ClientSideError(HTTPError): + pass + + +class ServerSideError(HTTPError): + pass + + +class BadRequest(ClientSideError): + """The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).""" + + status_code = 400 + + +class Unauthorized(ClientSideError): + """Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.""" + + status_code = 401 + + +class PaymentRequired(ClientSideError): + """The initial purpose of this code was for digital payment systems, however this status code is rarely used and no standard convention exists.""" + + status_code = 402 + + +class Forbidden(ClientSideError): + """The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.""" + + status_code = 403 + + +class NotFound(ClientSideError): + """The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web.""" + + status_code = 404 + + +class MethodNotAllowed(ClientSideError): + """The request method is known by the server but is not supported by the target resource. For example, an API may not allow DELETE on a resource, or the TRACE method entirely.""" + + status_code = 405 + + +class NotAcceptable(ClientSideError): + """This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent.""" + + status_code = 406 + + +class ProxyAuthenticationRequired(ClientSideError): + """This is similar to 401 Unauthorized but authentication is needed to be done by a proxy.""" + + status_code = 407 + + +class RequestTimeout(ClientSideError): + """This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers use HTTP pre-connection mechanisms to speed up browsing. Some servers may shut down a connection without sending this message.""" + + status_code = 408 + + +class Conflict(ClientSideError): + """This response is sent when a request conflicts with the current state of the server. In WebDAV remote web authoring, 409 responses are errors sent to the client so that a user might be able to resolve a conflict and resubmit the request.""" + + status_code = 409 + + +class Gone(ClientSideError): + """This response is sent when the requested content has been permanently deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code.""" + + status_code = 410 + + +class LengthRequired(ClientSideError): + """Server rejected the request because the Content-Length header field is not defined and the server requires it.""" + + status_code = 411 + + +class PreconditionFailed(ClientSideError): + """In conditional requests, the client has indicated preconditions in its headers which the server does not meet.""" + + status_code = 412 + + +class ContentTooLarge(ClientSideError): + """The request body is larger than limits defined by server. The server might close the connection or return an Retry-After header field.""" + + status_code = 413 + + +class URITooLong(ClientSideError): + """The URI requested by the client is longer than the server is willing to interpret.""" + + status_code = 414 + + +class UnsupportedMediaType(ClientSideError): + """The media format of the requested data is not supported by the server, so the server is rejecting the request.""" + + status_code = 415 + + +class RangeNotSatisfiable(ClientSideError): + """The ranges specified by the Range header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target resource's data.""" + + status_code = 416 + + +class ExpectationFailed(ClientSideError): + """This response code means the expectation indicated by the Expect request header field cannot be met by the server.""" + + status_code = 417 + + +class IAmATeapot(ClientSideError): + """The server refuses the attempt to brew coffee with a teapot.""" + + status_code = 418 + + +class MisdirectedRequest(ClientSideError): + """The request was directed at a server that is not able to produce a response. This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI.""" + + status_code = 421 + + +class UnprocessableContent(ClientSideError): + """The request was well-formed but was unable to be followed due to semantic errors.""" + + status_code = 422 + + +class Locked(ClientSideError): + """The resource that is being accessed is locked.""" + + status_code = 423 + + +class FailedDependency(ClientSideError): + """The request failed due to failure of a previous request.""" + + status_code = 424 + + +class TooEarlyExperimental(ClientSideError): + """Indicates that the server is unwilling to risk processing a request that might be replayed.""" + + status_code = 425 + + +class UpgradeRequired(ClientSideError): + """The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. The server sends an Upgrade header in a 426 response to indicate the required protocol(s).""" + + status_code = 426 + + +class PreconditionRequired(ClientSideError): + """The origin server requires the request to be conditional. This response is intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.""" + + status_code = 428 + + +class TooManyRequests(ClientSideError): + """The user has sent too many requests in a given amount of time (rate limiting).""" + + status_code = 429 + + +class RequestHeaderFieldsTooLarge(ClientSideError): + """The server is unwilling to process the request because its header fields are too large. The request may be resubmitted after reducing the size of the request header fields.""" + + status_code = 431 + + +class UnavailableForLegalReasons(ClientSideError): + """The user agent requested a resource that cannot legally be provided, such as a web page censored by a government.""" + + status_code = 451 + + +class InternalServerError(ServerSideError): + """The server has encountered a situation it does not know how to handle. This error is generic, indicating that the server cannot find a more appropriate 5XX status code to respond with.""" + + status_code = 500 + + +class NotImplemented(ServerSideError): + """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 must not return this code) are GET and HEAD.""" + + status_code = 501 + + +class BadGateway(ServerSideError): + """This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.""" + + status_code = 502 + + +class ServiceUnavailable(ServerSideError): + """The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This response should be used for temporary conditions and the Retry-After HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached.""" + + status_code = 503 + + +class GatewayTimeout(ServerSideError): + """This error response is given when the server is acting as a gateway and cannot get a response in time.""" + + status_code = 504 + + +class HTTPVersionNotSupported(ServerSideError): + """The HTTP version used in the request is not supported by the server.""" + + status_code = 505 + + +class VariantAlsoNegotiates(ServerSideError): + """The server has an internal configuration error: during content negotiation, the chosen variant is configured to engage in content negotiation itself, which results in circular references when creating responses.""" + + status_code = 506 + + +class InsufficientStorage(ServerSideError): + """The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request.""" + + status_code = 507 + + +class LoopDetected(ServerSideError): + """The server detected an infinite loop while processing the request.""" + + status_code = 508 + + +class NotExtended(ServerSideError): + """The client request declares an HTTP Extension (RFC 2774) that should be used to process the request, but the extension is not supported.""" + + status_code = 510 + + +class NetworkAuthenticationRequired(ServerSideError): + """Indicates that the client needs to authenticate to gain network access.""" + + status_code = 511 diff --git a/status.py b/status.py new file mode 100644 index 00000000..6edd353f --- /dev/null +++ b/status.py @@ -0,0 +1,26 @@ +with open("status.txt") as f: + text = f.read().split("\n") + collected = [[]] + index = 0 + count = 0 + for i in text: + if count == 3: + count = 0 + index += 1 + collected.append([]) + count += 1 + collected[index].append(i) + + for collect in collected: + raw_name, desc, _ = collect + status, name = raw_name.split(" ", maxsplit=1) + name = name.replace("(WebDAV)", "") + status = int(status) + cls = "ClientSideError" if status < 500 else "ServerSideError" + output = f''' +class {name.replace(' ', '')}({cls}): + """{desc}""" + + status_code = {status} +''' + print(output) diff --git a/status.txt b/status.txt new file mode 100644 index 00000000..dc6bfa03 --- /dev/null +++ b/status.txt @@ -0,0 +1,119 @@ +400 Bad Request +The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). + +401 Unauthorized +Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response. + +402 Payment Required +The initial purpose of this code was for digital payment systems, however this status code is rarely used and no standard convention exists. + +403 Forbidden +The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server. + +404 Not Found +The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web. + +405 Method Not Allowed +The request method is known by the server but is not supported by the target resource. For example, an API may not allow DELETE on a resource, or the TRACE method entirely. + +406 Not Acceptable +This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent. + +407 Proxy Authentication Required +This is similar to 401 Unauthorized but authentication is needed to be done by a proxy. + +408 Request Timeout +This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers use HTTP pre-connection mechanisms to speed up browsing. Some servers may shut down a connection without sending this message. + +409 Conflict +This response is sent when a request conflicts with the current state of the server. In WebDAV remote web authoring, 409 responses are errors sent to the client so that a user might be able to resolve a conflict and resubmit the request. + +410 Gone +This response is sent when the requested content has been permanently deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code. + +411 Length Required +Server rejected the request because the Content-Length header field is not defined and the server requires it. + +412 Precondition Failed +In conditional requests, the client has indicated preconditions in its headers which the server does not meet. + +413 Content Too Large +The request body is larger than limits defined by server. The server might close the connection or return an Retry-After header field. + +414 URI Too Long +The URI requested by the client is longer than the server is willing to interpret. + +415 Unsupported Media Type +The media format of the requested data is not supported by the server, so the server is rejecting the request. + +416 Range Not Satisfiable +The ranges specified by the Range header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target resource's data. + +417 Expectation Failed +This response code means the expectation indicated by the Expect request header field cannot be met by the server. + +418 I'm a teapot +The server refuses the attempt to brew coffee with a teapot. + +421 Misdirected Request +The request was directed at a server that is not able to produce a response. This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI. + +422 Unprocessable Content (WebDAV) +The request was well-formed but was unable to be followed due to semantic errors. + +423 Locked (WebDAV) +The resource that is being accessed is locked. + +424 Failed Dependency (WebDAV) +The request failed due to failure of a previous request. + +425 Too Early Experimental +Indicates that the server is unwilling to risk processing a request that might be replayed. + +426 Upgrade Required +The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. The server sends an Upgrade header in a 426 response to indicate the required protocol(s). + +428 Precondition Required +The origin server requires the request to be conditional. This response is intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict. + +429 Too Many Requests +The user has sent too many requests in a given amount of time (rate limiting). + +431 Request Header Fields Too Large +The server is unwilling to process the request because its header fields are too large. The request may be resubmitted after reducing the size of the request header fields. + +451 Unavailable For Legal Reasons +The user agent requested a resource that cannot legally be provided, such as a web page censored by a government. + +500 Internal Server Error +The server has encountered a situation it does not know how to handle. This error is generic, indicating that the server cannot find a more appropriate 5XX status code to respond with. + +501 Not Implemented +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 must not return this code) are GET and HEAD. + +502 Bad Gateway +This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response. + +503 Service Unavailable +The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This response should be used for temporary conditions and the Retry-After HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached. + +504 Gateway Timeout +This error response is given when the server is acting as a gateway and cannot get a response in time. + +505 HTTP Version Not Supported +The HTTP version used in the request is not supported by the server. + +506 Variant Also Negotiates +The server has an internal configuration error: during content negotiation, the chosen variant is configured to engage in content negotiation itself, which results in circular references when creating responses. + +507 Insufficient Storage (WebDAV) +The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request. + +508 Loop Detected (WebDAV) +The server detected an infinite loop while processing the request. + +510 Not Extended +The client request declares an HTTP Extension (RFC 2774) that should be used to process the request, but the extension is not supported. + +511 Network Authentication Required +Indicates that the client needs to authenticate to gain network access. From 4901c8b630e335e6b25246db58531c14fe15b17b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 20:37:45 -0400 Subject: [PATCH 006/188] Remove generation artifacts. --- status.py | 26 ------------ status.txt | 119 ----------------------------------------------------- 2 files changed, 145 deletions(-) delete mode 100644 status.py delete mode 100644 status.txt diff --git a/status.py b/status.py deleted file mode 100644 index 6edd353f..00000000 --- a/status.py +++ /dev/null @@ -1,26 +0,0 @@ -with open("status.txt") as f: - text = f.read().split("\n") - collected = [[]] - index = 0 - count = 0 - for i in text: - if count == 3: - count = 0 - index += 1 - collected.append([]) - count += 1 - collected[index].append(i) - - for collect in collected: - raw_name, desc, _ = collect - status, name = raw_name.split(" ", maxsplit=1) - name = name.replace("(WebDAV)", "") - status = int(status) - cls = "ClientSideError" if status < 500 else "ServerSideError" - output = f''' -class {name.replace(' ', '')}({cls}): - """{desc}""" - - status_code = {status} -''' - print(output) diff --git a/status.txt b/status.txt deleted file mode 100644 index dc6bfa03..00000000 --- a/status.txt +++ /dev/null @@ -1,119 +0,0 @@ -400 Bad Request -The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). - -401 Unauthorized -Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response. - -402 Payment Required -The initial purpose of this code was for digital payment systems, however this status code is rarely used and no standard convention exists. - -403 Forbidden -The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server. - -404 Not Found -The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web. - -405 Method Not Allowed -The request method is known by the server but is not supported by the target resource. For example, an API may not allow DELETE on a resource, or the TRACE method entirely. - -406 Not Acceptable -This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent. - -407 Proxy Authentication Required -This is similar to 401 Unauthorized but authentication is needed to be done by a proxy. - -408 Request Timeout -This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers use HTTP pre-connection mechanisms to speed up browsing. Some servers may shut down a connection without sending this message. - -409 Conflict -This response is sent when a request conflicts with the current state of the server. In WebDAV remote web authoring, 409 responses are errors sent to the client so that a user might be able to resolve a conflict and resubmit the request. - -410 Gone -This response is sent when the requested content has been permanently deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code. - -411 Length Required -Server rejected the request because the Content-Length header field is not defined and the server requires it. - -412 Precondition Failed -In conditional requests, the client has indicated preconditions in its headers which the server does not meet. - -413 Content Too Large -The request body is larger than limits defined by server. The server might close the connection or return an Retry-After header field. - -414 URI Too Long -The URI requested by the client is longer than the server is willing to interpret. - -415 Unsupported Media Type -The media format of the requested data is not supported by the server, so the server is rejecting the request. - -416 Range Not Satisfiable -The ranges specified by the Range header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target resource's data. - -417 Expectation Failed -This response code means the expectation indicated by the Expect request header field cannot be met by the server. - -418 I'm a teapot -The server refuses the attempt to brew coffee with a teapot. - -421 Misdirected Request -The request was directed at a server that is not able to produce a response. This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI. - -422 Unprocessable Content (WebDAV) -The request was well-formed but was unable to be followed due to semantic errors. - -423 Locked (WebDAV) -The resource that is being accessed is locked. - -424 Failed Dependency (WebDAV) -The request failed due to failure of a previous request. - -425 Too Early Experimental -Indicates that the server is unwilling to risk processing a request that might be replayed. - -426 Upgrade Required -The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. The server sends an Upgrade header in a 426 response to indicate the required protocol(s). - -428 Precondition Required -The origin server requires the request to be conditional. This response is intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict. - -429 Too Many Requests -The user has sent too many requests in a given amount of time (rate limiting). - -431 Request Header Fields Too Large -The server is unwilling to process the request because its header fields are too large. The request may be resubmitted after reducing the size of the request header fields. - -451 Unavailable For Legal Reasons -The user agent requested a resource that cannot legally be provided, such as a web page censored by a government. - -500 Internal Server Error -The server has encountered a situation it does not know how to handle. This error is generic, indicating that the server cannot find a more appropriate 5XX status code to respond with. - -501 Not Implemented -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 must not return this code) are GET and HEAD. - -502 Bad Gateway -This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response. - -503 Service Unavailable -The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This response should be used for temporary conditions and the Retry-After HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached. - -504 Gateway Timeout -This error response is given when the server is acting as a gateway and cannot get a response in time. - -505 HTTP Version Not Supported -The HTTP version used in the request is not supported by the server. - -506 Variant Also Negotiates -The server has an internal configuration error: during content negotiation, the chosen variant is configured to engage in content negotiation itself, which results in circular references when creating responses. - -507 Insufficient Storage (WebDAV) -The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request. - -508 Loop Detected (WebDAV) -The server detected an infinite loop while processing the request. - -510 Not Extended -The client request declares an HTTP Extension (RFC 2774) that should be used to process the request, but the extension is not supported. - -511 Network Authentication Required -Indicates that the client needs to authenticate to gain network access. From dcf35aa0b83892e6866a1849fce5cb35200460c2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 21:09:46 -0400 Subject: [PATCH 007/188] Outline for responses. --- src/view/app.py | 12 +++------- src/view/response.py | 11 ++++++++++ src/view/router.py | 47 ++++++++++++++++++++++++++++------------ src/view/status_codes.py | 7 ++---- 4 files changed, 49 insertions(+), 28 deletions(-) create mode 100644 src/view/response.py diff --git a/src/view/app.py b/src/view/app.py index e9748efc..bde14571 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,4 +1,4 @@ -from view.router import Route, Router +from view.router import Route, Router, Response from dataclasses import dataclass from multidict import CIMultiDict @@ -10,17 +10,11 @@ class Request: path: str headers: CIMultiDict - -@dataclass(slots=True, frozen=True) -class Response: - content: bytes - status_code: int - headers: CIMultiDict - - class App(Router): def process_request(self, request: Request) -> Response: route: Route | None = self.lookup_route(request.path) + if route is None: + ... return NotImplemented diff --git a/src/view/response.py b/src/view/response.py new file mode 100644 index 00000000..4bb6d0d1 --- /dev/null +++ b/src/view/response.py @@ -0,0 +1,11 @@ +from multidict import CIMultiDict +from dataclasses import dataclass +from typing import TypeAlias, AnyStr + +@dataclass(slots=True, frozen=True) +class Response: + content: bytes + status_code: int + headers: CIMultiDict + +ResponseLike: TypeAlias = Response | AnyStr diff --git a/src/view/router.py b/src/view/router.py index 39f94642..e90907e4 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,7 +1,9 @@ from __future__ import annotations from enum import Enum, auto -from typing import Awaitable, Callable, TypeAlias +from typing import Awaitable, Callable, TypeAlias, TypeVar from dataclasses import dataclass +from status_codes import HTTPError, status_exception +from view.response import ResponseLike __all__ = "Method", "Route", "Router" @@ -18,8 +20,10 @@ class Method(Enum): HEAD = auto() -RouteHandler: TypeAlias = Callable[[], None | Awaitable[None]] +RouteHandler: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] +RouteHandlerVar = TypeVar("RouteHandlerVar", bound=RouteHandler) +RouteDecorator: TypeAlias = Callable[[RouteHandlerVar], RouteHandlerVar] @dataclass(slots=True, frozen=True) class Route: @@ -27,10 +31,10 @@ class Route: path: str method: Method - class Router: def __init__(self) -> None: self.routes: dict[str, Route] = {} + self.error_handlers: dict[type[HTTPError], RouteHandler] = {} def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: self.routes[path] = Route(handler=handler, path=path, method=method) @@ -38,40 +42,55 @@ def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: def lookup_route(self, path: str) -> Route | None: return self.routes.get(path) - def route(self, path: str, /, *, method: Method): + def route(self, path: str, /, *, method: Method) -> RouteDecorator: """ Decorator interface for adding a route to the app. """ - def decorator(function: RouteHandler, /): + def decorator(function: RouteHandlerVar, /) -> RouteHandlerVar: self.push_route(function, path, method) return function return decorator - def get(self, path: str, /): + def get(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.GET) - def post(self, path: str, /): + def post(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.POST) - def put(self, path: str, /): + def put(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.PUT) - def patch(self, path: str, /): + def patch(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.PATCH) - def delete(self, path: str, /): + def delete(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.DELETE) - def connect(self, path: str, /): + def connect(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.CONNECT) - def options(self, path: str, /): + def options(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.OPTIONS) - def trace(self, path: str, /): + def trace(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.TRACE) - def head(self, path: str, /): + def head(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.HEAD) + + def error(self, status: int | type[HTTPError]) -> RouteDecorator: + error_type: type[HTTPError] + if isinstance(status, int): + error_type = status_exception(status) + elif issubclass(status, HTTPError): + error_type = status + else: + raise TypeError(f"expected a status code or type, but got {status!r}") + + def decorator(function: RouteHandlerVar) -> RouteHandlerVar: + self.error_handlers[error_type] = function + return function + + return decorator diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 2e07e7a1..5d81647b 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -11,7 +11,7 @@ def __init_subclass__(cls) -> None: STATUS_EXCEPTIONS[cls.status_code] = cls -def status_exception(status: int, *, message: str | None = None) -> HTTPError: +def status_exception(status: int) -> type[HTTPError]: """ Get an exception for the given status. """ @@ -20,10 +20,7 @@ def status_exception(status: int, *, message: str | None = None) -> HTTPError: except KeyError as error: raise ValueError(f"{status} is not a valid HTTP error status code") from error - if message is not None: - return status_type() - - return status_type(message) + return status_type class ClientSideError(HTTPError): From d3e2126d5d64b6872e6adc3f8621b10d49b17e52 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 21 Jun 2025 21:23:05 -0400 Subject: [PATCH 008/188] Improve docstrings for status codes. --- src/view/status_codes.py | 231 ++++++++++++++++++++++++++++++++------- 1 file changed, 191 insertions(+), 40 deletions(-) diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 5d81647b..d44ae1a2 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -32,240 +32,391 @@ class ServerSideError(HTTPError): class BadRequest(ClientSideError): - """The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).""" + """ + The server cannot or will not process the request due to something + that is perceived to be a client error (e.g., malformed request syntax, + invalid request message framing, or deceptive request routing). + """ status_code = 400 class Unauthorized(ClientSideError): - """Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.""" + """ + Although the HTTP standard specifies "unauthorized", semantically this + response means "unauthenticated". That is, the client must authenticate + itself to get the requested response. + """ status_code = 401 class PaymentRequired(ClientSideError): - """The initial purpose of this code was for digital payment systems, however this status code is rarely used and no standard convention exists.""" + """ + The initial purpose of this code was for digital payment systems, + however this status code is rarely used and no standard convention exists. + """ status_code = 402 class Forbidden(ClientSideError): - """The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.""" + """ + The client does not have access rights to the content; that is, it is + unauthorized, so the server is refusing to give the requested resource. + Unlike 401 Unauthorized, the client's identity is known to the server. + """ status_code = 403 class NotFound(ClientSideError): - """The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web.""" + """ + The server cannot find the requested resource. In the browser, this means + the URL is not recognized. In an API, this can also mean that the endpoint + is valid but the resource itself does not exist. Servers may also send this + response instead of 403 Forbidden to hide the existence of a resource from + an unauthorized client. This response code is probably the most well known + due to its frequent occurrence on the web. + """ status_code = 404 class MethodNotAllowed(ClientSideError): - """The request method is known by the server but is not supported by the target resource. For example, an API may not allow DELETE on a resource, or the TRACE method entirely.""" + """ + The request method is known by the server but is not supported by the + target resource. For example, an API may not allow DELETE on a resource, + or the TRACE method entirely. + """ status_code = 405 class NotAcceptable(ClientSideError): - """This response is sent when the web server, after performing server-driven content negotiation, doesn't find any content that conforms to the criteria given by the user agent.""" + """ + This response is sent when the web server, after performing server-driven + content negotiation, doesn't find any content that conforms to the + criteria given by the user agent. + """ status_code = 406 class ProxyAuthenticationRequired(ClientSideError): - """This is similar to 401 Unauthorized but authentication is needed to be done by a proxy.""" + """ + This is similar to 401 Unauthorized but authentication is needed to be + done by a proxy. + """ status_code = 407 class RequestTimeout(ClientSideError): - """This response is sent on an idle connection by some servers, even without any previous request by the client. It means that the server would like to shut down this unused connection. This response is used much more since some browsers use HTTP pre-connection mechanisms to speed up browsing. Some servers may shut down a connection without sending this message.""" + """ + This response is sent on an idle connection by some servers, even without + any previous request by the client. It means that the server would like to + shut down this unused connection. This response is used much more since + some browsers use HTTP pre-connection mechanisms to speed up browsing. + Some servers may shut down a connection without sending this message. + """ status_code = 408 class Conflict(ClientSideError): - """This response is sent when a request conflicts with the current state of the server. In WebDAV remote web authoring, 409 responses are errors sent to the client so that a user might be able to resolve a conflict and resubmit the request.""" + """ + This response is sent when a request conflicts with the current state of + the server. In WebDAV remote web authoring, 409 responses are errors sent + to the client so that a user might be able to resolve a conflict and + resubmit the request. + """ status_code = 409 class Gone(ClientSideError): - """This response is sent when the requested content has been permanently deleted from server, with no forwarding address. Clients are expected to remove their caches and links to the resource. The HTTP specification intends this status code to be used for "limited-time, promotional services". APIs should not feel compelled to indicate resources that have been deleted with this status code.""" + """ + This response is sent when the requested content has been permanently + deleted from server, with no forwarding address. Clients are expected to + remove their caches and links to the resource. The HTTP specification + intends this status code to be used for "limited-time, promotional + services". APIs should not feel compelled to indicate resources that have + been deleted with this status code. + """ status_code = 410 class LengthRequired(ClientSideError): - """Server rejected the request because the Content-Length header field is not defined and the server requires it.""" + """ + Server rejected the request because the Content-Length header field is not + defined and the server requires it. + """ status_code = 411 class PreconditionFailed(ClientSideError): - """In conditional requests, the client has indicated preconditions in its headers which the server does not meet.""" + """ + In conditional requests, the client has indicated preconditions in its + headers which the server does not meet. + """ status_code = 412 class ContentTooLarge(ClientSideError): - """The request body is larger than limits defined by server. The server might close the connection or return an Retry-After header field.""" + """ + The request body is larger than limits defined by server. The server might + close the connection or return an Retry-After header field. + """ status_code = 413 class URITooLong(ClientSideError): - """The URI requested by the client is longer than the server is willing to interpret.""" + """ + The URI requested by the client is longer than the server is willing to + interpret. + """ status_code = 414 class UnsupportedMediaType(ClientSideError): - """The media format of the requested data is not supported by the server, so the server is rejecting the request.""" + """ + The media format of the requested data is not supported by the server, + so the server is rejecting the request. + """ status_code = 415 class RangeNotSatisfiable(ClientSideError): - """The ranges specified by the Range header field in the request cannot be fulfilled. It's possible that the range is outside the size of the target resource's data.""" + """ + The ranges specified by the Range header field in the request cannot be + fulfilled. It's possible that the range is outside the size of the target + resource's data. + """ status_code = 416 class ExpectationFailed(ClientSideError): - """This response code means the expectation indicated by the Expect request header field cannot be met by the server.""" + """ + This response code means the expectation indicated by the Expect request + header field cannot be met by the server. + """ status_code = 417 class IAmATeapot(ClientSideError): - """The server refuses the attempt to brew coffee with a teapot.""" + """ + The server refuses the attempt to brew coffee with a teapot. + """ status_code = 418 class MisdirectedRequest(ClientSideError): - """The request was directed at a server that is not able to produce a response. This can be sent by a server that is not configured to produce responses for the combination of scheme and authority that are included in the request URI.""" + """ + The request was directed at a server that is not able to produce a + response. This can be sent by a server that is not configured to produce + responses for the combination of scheme and authority that are included + in the request URI. + """ status_code = 421 class UnprocessableContent(ClientSideError): - """The request was well-formed but was unable to be followed due to semantic errors.""" + """ + The request was well-formed but was unable to be followed due to semantic errors. + """ status_code = 422 class Locked(ClientSideError): - """The resource that is being accessed is locked.""" + """ + The resource that is being accessed is locked. + """ status_code = 423 class FailedDependency(ClientSideError): - """The request failed due to failure of a previous request.""" + """ + The request failed due to failure of a previous request. + """ status_code = 424 class TooEarlyExperimental(ClientSideError): - """Indicates that the server is unwilling to risk processing a request that might be replayed.""" + """ + Indicates that the server is unwilling to risk processing a request + that might be replayed. + """ status_code = 425 class UpgradeRequired(ClientSideError): - """The server refuses to perform the request using the current protocol but might be willing to do so after the client upgrades to a different protocol. The server sends an Upgrade header in a 426 response to indicate the required protocol(s).""" + """ + The server refuses to perform the request using the current protocol but + might be willing to do so after the client upgrades to a different + protocol. The server sends an Upgrade header in a 426 response to indicate + the required protocol(s). + """ status_code = 426 class PreconditionRequired(ClientSideError): - """The origin server requires the request to be conditional. This response is intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.""" + """ + The origin server requires the request to be conditional. This response is + intended to prevent the 'lost update' problem, where a client GETs a + resource's state, modifies it and PUTs it back to the server, when + meanwhile a third party has modified the state on the server, leading to + a conflict. + """ status_code = 428 class TooManyRequests(ClientSideError): - """The user has sent too many requests in a given amount of time (rate limiting).""" + """ + The user has sent too many requests in a given amount of + time (rate limiting). + """ status_code = 429 class RequestHeaderFieldsTooLarge(ClientSideError): - """The server is unwilling to process the request because its header fields are too large. The request may be resubmitted after reducing the size of the request header fields.""" + """ + The server is unwilling to process the request because its header fields + are too large. The request may be resubmitted after reducing the size of + the request header fields. + """ status_code = 431 class UnavailableForLegalReasons(ClientSideError): - """The user agent requested a resource that cannot legally be provided, such as a web page censored by a government.""" + """ + The user agent requested a resource that cannot legally be provided, + such as a web page censored by a government. + """ status_code = 451 class InternalServerError(ServerSideError): - """The server has encountered a situation it does not know how to handle. This error is generic, indicating that the server cannot find a more appropriate 5XX status code to respond with.""" + """ + The server has encountered a situation it does not know how to handle. + This error is generic, indicating that the server cannot find a more + appropriate 5XX status code to respond with. + """ status_code = 500 class NotImplemented(ServerSideError): - """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 must not return this code) are GET and HEAD.""" + """ + 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 + must not return this code) are GET and HEAD. + """ status_code = 501 class BadGateway(ServerSideError): - """This error response means that the server, while working as a gateway to get a response needed to handle the request, got an invalid response.""" + """ + This error response means that the server, while working as a gateway to + get a response needed to handle the request, got an invalid response. + """ status_code = 502 class ServiceUnavailable(ServerSideError): - """The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This response should be used for temporary conditions and the Retry-After HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached.""" + """ + The server is not ready to handle the request. Common causes are a server + that is down for maintenance or that is overloaded. Note that together + with this response, a user-friendly page explaining the problem should be + sent. This response should be used for temporary conditions and the + Retry-After HTTP header should, if possible, contain the estimated time + before the recovery of the service. The webmaster must also take care + about the caching-related headers that are sent along with this response, + as these temporary condition responses should usually not be cached. + """ status_code = 503 class GatewayTimeout(ServerSideError): - """This error response is given when the server is acting as a gateway and cannot get a response in time.""" + """ + This error response is given when the server is acting as a gateway and + cannot get a response in time. + """ status_code = 504 class HTTPVersionNotSupported(ServerSideError): - """The HTTP version used in the request is not supported by the server.""" + """ + The HTTP version used in the request is not supported by the server. + """ status_code = 505 class VariantAlsoNegotiates(ServerSideError): - """The server has an internal configuration error: during content negotiation, the chosen variant is configured to engage in content negotiation itself, which results in circular references when creating responses.""" + """ + The server has an internal configuration error: during content + negotiation, the chosen variant is configured to engage in content + negotiation itself, which results in circular references when creating + responses. + """ status_code = 506 class InsufficientStorage(ServerSideError): - """The method could not be performed on the resource because the server is unable to store the representation needed to successfully complete the request.""" + """ + The method could not be performed on the resource because the server is + unable to store the representation needed to successfully complete the + request. + """ status_code = 507 class LoopDetected(ServerSideError): - """The server detected an infinite loop while processing the request.""" + """ + The server detected an infinite loop while processing the request. + """ status_code = 508 class NotExtended(ServerSideError): - """The client request declares an HTTP Extension (RFC 2774) that should be used to process the request, but the extension is not supported.""" + """ + The client request declares an HTTP Extension (RFC 2774) that should be + used to process the request, but the extension is not supported. + """ status_code = 510 class NetworkAuthenticationRequired(ServerSideError): - """Indicates that the client needs to authenticate to gain network access.""" + """ + Indicates that the client needs to authenticate to gain network access. + """ status_code = 511 From 3b66d24834bfc76a18608e5f9bc5b4c1a7c31411 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 10:45:09 -0400 Subject: [PATCH 009/188] Some improvements to the response API. --- morals.md | 2 +- src/view/__init__.py | 1 + src/view/app.py | 91 +++++++++++++++++++++++++++++++++++----- src/view/response.py | 7 +++- src/view/router.py | 21 ++++++---- src/view/status_codes.py | 3 +- 6 files changed, 104 insertions(+), 21 deletions(-) diff --git a/morals.md b/morals.md index 5a068d8f..27d0faa9 100644 --- a/morals.md +++ b/morals.md @@ -5,4 +5,4 @@ 3. But resist the temptation to sprinkle in magic. 4. Remember the Zen of Python (PEP 20). 5. Swiss-army knives don't make good APIs. -6. An independent system is a better one. +6. An independent system is a better one. Global state is evil. diff --git a/src/view/__init__.py b/src/view/__init__.py index 2cdb99ac..c9403bb3 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -1,2 +1,3 @@ from view.__about__ import * +from view.app import * from view.router import * diff --git a/src/view/app.py b/src/view/app.py index bde14571..1a66b57e 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,8 +1,16 @@ -from view.router import Route, Router, Response +import contextlib +import contextvars +from collections.abc import Awaitable from dataclasses import dataclass +from typing import Literal, overload + from multidict import CIMultiDict -__all__ = "App", "new_app" +from view.response import Response, ResponseLike +from view.router import Route, RouteHandler, Router +from view.status_codes import HTTPError, NotFound + +__all__ = "App", "Request" @dataclass(slots=True, frozen=True) @@ -10,16 +18,79 @@ class Request: path: str headers: CIMultiDict + +def wrap_response(response: ResponseLike) -> Response: + if isinstance(response, Response): + return response + + content: bytes + if isinstance(response, str): + content = response.encode() + elif isinstance(response, bytes): + content = response + else: + raise TypeError(f"") + + return Response(content, 200, CIMultiDict()) + + class App(Router): - def process_request(self, request: Request) -> Response: + def __init__(self): + self._request = contextvars.ContextVar[Request]( + "The current request being handled." + ) + + async def execute_handler(self, handler: RouteHandler) -> ResponseLike: + result = handler() + if isinstance(result, Awaitable): + result = await result + + return result + + def default_error(self, error: type[HTTPError]) -> RouteHandler: + """ + Get the default server error handler for a given HTTP error. + """ + + def inner(): + return Response( + b"Error", status_code=error.status_code, headers=CIMultiDict() + ) + + return inner + + @contextlib.contextmanager + def _request_context(self, request: Request): + token = self._request.set(request) + try: + yield + finally: + self._request.reset(token) + + @overload + def current_request(self, *, validate: Literal[False]) -> Request | None: ... + + @overload + def current_request(self, *, validate: Literal[True]) -> Request: ... + + def current_request(self, *, validate: bool = True) -> Request | None: + if validate: + return self._request.get() + + try: + return self._request.get() + except LookupError: + return None + + async def process_request(self, request: Request) -> Response: route: Route | None = self.lookup_route(request.path) + handler: RouteHandler if route is None: - ... - return NotImplemented + handler = self.lookup_error(NotFound) or self.default_error(NotFound) + else: + handler = route.handler + with self._request_context(request): + response = await self.execute_handler(handler) -def new_app() -> App: - """ - High-level function for constructing a view.py application. - """ - ... + return wrap_response(response) diff --git a/src/view/response.py b/src/view/response.py index 4bb6d0d1..c743feb0 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,6 +1,8 @@ -from multidict import CIMultiDict from dataclasses import dataclass -from typing import TypeAlias, AnyStr +from typing import AnyStr, TypeAlias + +from multidict import CIMultiDict + @dataclass(slots=True, frozen=True) class Response: @@ -8,4 +10,5 @@ class Response: status_code: int headers: CIMultiDict + ResponseLike: TypeAlias = Response | AnyStr diff --git a/src/view/router.py b/src/view/router.py index e90907e4..4d3bcfaa 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,8 +1,11 @@ from __future__ import annotations + +from dataclasses import dataclass from enum import Enum, auto from typing import Awaitable, Callable, TypeAlias, TypeVar -from dataclasses import dataclass + from status_codes import HTTPError, status_exception + from view.response import ResponseLike __all__ = "Method", "Route", "Router" @@ -20,27 +23,31 @@ class Method(Enum): HEAD = auto() - RouteHandler: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] RouteHandlerVar = TypeVar("RouteHandlerVar", bound=RouteHandler) RouteDecorator: TypeAlias = Callable[[RouteHandlerVar], RouteHandlerVar] + @dataclass(slots=True, frozen=True) class Route: handler: RouteHandler path: str method: Method + class Router: def __init__(self) -> None: - self.routes: dict[str, Route] = {} + self.route_handlers: dict[str, Route] = {} self.error_handlers: dict[type[HTTPError], RouteHandler] = {} def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: - self.routes[path] = Route(handler=handler, path=path, method=method) + self.route_handlers[path] = Route(handler=handler, path=path, method=method) - def lookup_route(self, path: str) -> Route | None: - return self.routes.get(path) + def lookup_route(self, path: str, /) -> Route | None: + return self.route_handlers.get(path) + + def lookup_error(self, error: type[HTTPError], /) -> RouteHandler | None: + return self.error_handlers.get(error) def route(self, path: str, /, *, method: Method) -> RouteDecorator: """ @@ -88,7 +95,7 @@ def error(self, status: int | type[HTTPError]) -> RouteDecorator: error_type = status else: raise TypeError(f"expected a status code or type, but got {status!r}") - + def decorator(function: RouteHandlerVar) -> RouteHandlerVar: self.error_handlers[error_type] = function return function diff --git a/src/view/status_codes.py b/src/view/status_codes.py index d44ae1a2..451be936 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -1,4 +1,5 @@ from __future__ import annotations + from typing import ClassVar STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} @@ -33,7 +34,7 @@ class ServerSideError(HTTPError): class BadRequest(ClientSideError): """ - The server cannot or will not process the request due to something + The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). """ From ad37e4aec362e2974ea4685cb811a08dd79cf67a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 10:55:03 -0400 Subject: [PATCH 010/188] Outline for server running. --- src/view/app.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 1a66b57e..45ebe8eb 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -20,6 +20,9 @@ class Request: def wrap_response(response: ResponseLike) -> Response: + """ + Wrap a response from a handler into a `Response` object. + """ if isinstance(response, Response): return response @@ -41,11 +44,16 @@ def __init__(self): ) async def execute_handler(self, handler: RouteHandler) -> ResponseLike: - result = handler() - if isinstance(result, Awaitable): - result = await result + try: + result = handler() + if isinstance(result, Awaitable): + result = await result - return result + return result + except HTTPError as error: + http_error = type(error) + handler = self.lookup_error(http_error) or self.default_error(http_error) + return await self.execute_handler(handler) def default_error(self, error: type[HTTPError]) -> RouteHandler: """ @@ -74,6 +82,9 @@ def current_request(self, *, validate: Literal[False]) -> Request | None: ... def current_request(self, *, validate: Literal[True]) -> Request: ... def current_request(self, *, validate: bool = True) -> Request | None: + """ + Get the current request being handled. + """ if validate: return self._request.get() @@ -83,6 +94,9 @@ def current_request(self, *, validate: bool = True) -> Request | None: return None async def process_request(self, request: Request) -> Response: + """ + Get the response from the server for a given request. + """ route: Route | None = self.lookup_route(request.path) handler: RouteHandler if route is None: @@ -94,3 +108,12 @@ async def process_request(self, request: Request) -> Response: response = await self.execute_handler(handler) return wrap_response(response) + + def wsgi(self): + ... + + def asgi(self): + ... + + def run(self): + ... From 47f381bece6478b9aebba7ea4b012b1f034ad387 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 11:21:38 -0400 Subject: [PATCH 011/188] Some refactoring with the router. --- sample.py | 1 - src/view/app.py | 131 +++++++++++++++++++++++++++++++-------------- src/view/router.py | 70 +++++++----------------- 3 files changed, 109 insertions(+), 93 deletions(-) diff --git a/sample.py b/sample.py index df338ed6..8fc4704a 100644 --- a/sample.py +++ b/sample.py @@ -1,7 +1,6 @@ from view.app import new_app from view.responses import HTML - app = new_app() @app.get("/") diff --git a/src/view/app.py b/src/view/app.py index 45ebe8eb..956dda3f 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,16 +1,20 @@ import contextlib import contextvars +from abc import ABC, abstractmethod from collections.abc import Awaitable from dataclasses import dataclass -from typing import Literal, overload +from typing import Callable, Iterator, Literal, TypeAlias, TypeVar, overload from multidict import CIMultiDict from view.response import Response, ResponseLike -from view.router import Route, RouteHandler, Router +from view.router import Method, Route, RouteHandler, Router from view.status_codes import HTTPError, NotFound -__all__ = "App", "Request" +__all__ = "BaseApp", "Request" + +RouteHandlerVar = TypeVar("RouteHandlerVar", bound=RouteHandler) +RouteDecorator: TypeAlias = Callable[[RouteHandlerVar], RouteHandlerVar] @dataclass(slots=True, frozen=True) @@ -32,43 +36,25 @@ def wrap_response(response: ResponseLike) -> Response: elif isinstance(response, bytes): content = response else: - raise TypeError(f"") + raise TypeError(f"Invalid response: {response!r}") return Response(content, 200, CIMultiDict()) -class App(Router): - def __init__(self): +class BaseApp(ABC): + """Base view.py application.""" + + def __init__(self, *, router: Router): self._request = contextvars.ContextVar[Request]( "The current request being handled." ) + self.router = router - async def execute_handler(self, handler: RouteHandler) -> ResponseLike: - try: - result = handler() - if isinstance(result, Awaitable): - result = await result - - return result - except HTTPError as error: - http_error = type(error) - handler = self.lookup_error(http_error) or self.default_error(http_error) - return await self.execute_handler(handler) - - def default_error(self, error: type[HTTPError]) -> RouteHandler: + @contextlib.contextmanager + def request_context(self, request: Request) -> Iterator[None]: """ - Get the default server error handler for a given HTTP error. + Enter a context for the given request. """ - - def inner(): - return Response( - b"Error", status_code=error.status_code, headers=CIMultiDict() - ) - - return inner - - @contextlib.contextmanager - def _request_context(self, request: Request): token = self._request.set(request) try: yield @@ -93,27 +79,92 @@ def current_request(self, *, validate: bool = True) -> Request | None: except LookupError: return None + @abstractmethod + async def process_request(self, request: Request) -> Response: + """ + Get the response from the server for a given request. + """ + + def wsgi(self): ... + + def asgi(self): ... + + def run(self): ... + + +class RoutableApp(BaseApp): + def __init__(self) -> None: + super().__init__(router=Router()) + + async def _execute_handler(self, handler: RouteHandler) -> ResponseLike: + try: + result = handler() + if isinstance(result, Awaitable): + result = await result + + return result + except HTTPError as error: + http_error = type(error) + handler = self.router.lookup_error(http_error) + return await self._execute_handler(handler) + async def process_request(self, request: Request) -> Response: """ Get the response from the server for a given request. """ - route: Route | None = self.lookup_route(request.path) + route: Route | None = self.router.lookup_route(request.path) handler: RouteHandler if route is None: - handler = self.lookup_error(NotFound) or self.default_error(NotFound) + handler = self.router.lookup_error(NotFound) else: handler = route.handler - with self._request_context(request): - response = await self.execute_handler(handler) + with self.request_context(request): + response = await self._execute_handler(handler) return wrap_response(response) - def wsgi(self): - ... + def route(self, path: str, /, *, method: Method) -> RouteDecorator: + """ + Decorator interface for adding a route to the app. + """ + + def decorator(handler: RouteHandlerVar, /) -> RouteHandlerVar: + self.router.push_route(handler, path, method) + return handler + + return decorator + + def get(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.GET) + + def post(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.POST) + + def put(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.PUT) + + def patch(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.PATCH) + + def delete(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.DELETE) + + def connect(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.CONNECT) + + def options(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.OPTIONS) + + def trace(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.TRACE) + + def head(self, path: str, /) -> RouteDecorator: + return self.route(path, method=Method.HEAD) - def asgi(self): - ... + def error(self, status: int | type[HTTPError], /) -> RouteDecorator: + def decorator(handler: RouteHandlerVar, /) -> RouteHandlerVar: + self.router.push_error(status, handler) + return handler - def run(self): - ... + return decorator diff --git a/src/view/router.py b/src/view/router.py index 4d3bcfaa..f1446383 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -24,8 +24,6 @@ class Method(Enum): RouteHandler: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] -RouteHandlerVar = TypeVar("RouteHandlerVar", bound=RouteHandler) -RouteDecorator: TypeAlias = Callable[[RouteHandlerVar], RouteHandlerVar] @dataclass(slots=True, frozen=True) @@ -43,61 +41,29 @@ def __init__(self) -> None: def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: self.route_handlers[path] = Route(handler=handler, path=path, method=method) + def push_error(self, error: int | type[HTTPError], handler: RouteHandler) -> None: + error_type: type[HTTPError] + if isinstance(error, int): + error_type = status_exception(error) + elif issubclass(error, HTTPError): + error_type = error + else: + raise TypeError(f"expected a status code or type, but got {error!r}") + + self.error_handlers[error_type] = handler + def lookup_route(self, path: str, /) -> Route | None: return self.route_handlers.get(path) - def lookup_error(self, error: type[HTTPError], /) -> RouteHandler | None: - return self.error_handlers.get(error) + def lookup_error(self, error: type[HTTPError], /) -> RouteHandler: + return self.error_handlers.get(error) or self.default_error(error) - def route(self, path: str, /, *, method: Method) -> RouteDecorator: + def default_error(self, error: type[HTTPError]) -> RouteHandler: """ - Decorator interface for adding a route to the app. + Get the default error handler for a given HTTP error. """ - def decorator(function: RouteHandlerVar, /) -> RouteHandlerVar: - self.push_route(function, path, method) - return function - - return decorator - - def get(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.GET) - - def post(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.POST) - - def put(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.PUT) - - def patch(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.PATCH) - - def delete(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.DELETE) - - def connect(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.CONNECT) - - def options(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.OPTIONS) - - def trace(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.TRACE) - - def head(self, path: str, /) -> RouteDecorator: - return self.route(path, method=Method.HEAD) - - def error(self, status: int | type[HTTPError]) -> RouteDecorator: - error_type: type[HTTPError] - if isinstance(status, int): - error_type = status_exception(status) - elif issubclass(status, HTTPError): - error_type = status - else: - raise TypeError(f"expected a status code or type, but got {status!r}") - - def decorator(function: RouteHandlerVar) -> RouteHandlerVar: - self.error_handlers[error_type] = function - return function + def inner(): + return f"Error {error.status_code}" - return decorator + return inner From 576164f5af8bb6ad2c39d10d96710b983988e1c1 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 11:32:13 -0400 Subject: [PATCH 012/188] Add a 'single-handler' app. --- src/view/app.py | 28 +++++++++++++++++++++------- src/view/router.py | 11 ++++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 956dda3f..5a377cce 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -44,11 +44,10 @@ def wrap_response(response: ResponseLike) -> Response: class BaseApp(ABC): """Base view.py application.""" - def __init__(self, *, router: Router): + def __init__(self): self._request = contextvars.ContextVar[Request]( "The current request being handled." ) - self.router = router @contextlib.contextmanager def request_context(self, request: Request) -> Iterator[None]: @@ -92,9 +91,27 @@ def asgi(self): ... def run(self): ... +SingleHandler = Callable[[Request], ResponseLike] + + +class SingleHandlerApp(BaseApp): + def __init__(self, handler: SingleHandler) -> None: + super().__init__() + self.handler = handler + + async def process_request(self, request: Request) -> Response: + with self.request_context(request): + return self.handler(request) + + +def as_app(handler: SingleHandler, /) -> SingleHandlerApp: + return SingleHandlerApp(handler) + + class RoutableApp(BaseApp): - def __init__(self) -> None: - super().__init__(router=Router()) + def __init__(self, *, router: Router | None = None) -> None: + super().__init__() + self.router = router or Router() async def _execute_handler(self, handler: RouteHandler) -> ResponseLike: try: @@ -109,9 +126,6 @@ async def _execute_handler(self, handler: RouteHandler) -> ResponseLike: return await self._execute_handler(handler) async def process_request(self, request: Request) -> Response: - """ - Get the response from the server for a given request. - """ route: Route | None = self.router.lookup_route(request.path) handler: RouteHandler if route is None: diff --git a/src/view/router.py b/src/view/router.py index f1446383..5b70b3a7 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,5 +1,6 @@ from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, auto from typing import Awaitable, Callable, TypeAlias, TypeVar @@ -33,7 +34,15 @@ class Route: method: Method -class Router: +class BaseRouter(ABC): + @abstractmethod + def lookup_route(self, path: str, /) -> Route | None: ... + + @abstractmethod + def lookup_error(self, error: type[HTTPError], /) -> RouteHandler: ... + + +class Router(BaseRouter): def __init__(self) -> None: self.route_handlers: dict[str, Route] = {} self.error_handlers: dict[type[HTTPError], RouteHandler] = {} From 926abe185cc631ac322d5185b5411caa0e8c8082 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 11:44:46 -0400 Subject: [PATCH 013/188] 'handler' -> 'view' The framework *is* called view.py, you know. --- .github/ISSUE_TEMPLATE/crash.yml | 2 +- src/view/app.py | 58 ++++++++++++++++++-------------- src/view/router.py | 28 +++++++-------- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml index 29c96ad5..b5901fa9 100644 --- a/.github/ISSUE_TEMPLATE/crash.yml +++ b/.github/ISSUE_TEMPLATE/crash.yml @@ -6,7 +6,7 @@ body: attributes: label: "Crash Information:" description: > - Give a clear description of what happened and how to reproduce it, if possible. If you aren't sure, attempt to run the program through a debugger, such as [Valgrind](https://valgrind.org/) or enabling [faulthandler](https://docs.python.org/3/library/faulthandler.html). + Give a clear description of what happened and how to reproduce it, if possible. If you aren't sure, attempt to run the program through a debugger, such as [Valgrind](https://valgrind.org/) or enabling [faultview](https://docs.python.org/3/library/faultview.html). This should only be for *crashes* not bugs. For that, use the bug template. diff --git a/src/view/app.py b/src/view/app.py index 5a377cce..5a349eda 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -8,13 +8,13 @@ from multidict import CIMultiDict from view.response import Response, ResponseLike -from view.router import Method, Route, RouteHandler, Router +from view.router import Method, Route, RouteView, Router from view.status_codes import HTTPError, NotFound __all__ = "BaseApp", "Request" -RouteHandlerVar = TypeVar("RouteHandlerVar", bound=RouteHandler) -RouteDecorator: TypeAlias = Callable[[RouteHandlerVar], RouteHandlerVar] +RouteViewVar = TypeVar("RouteViewVar", bound=RouteView) +RouteDecorator: TypeAlias = Callable[[RouteViewVar], RouteViewVar] @dataclass(slots=True, frozen=True) @@ -25,7 +25,7 @@ class Request: def wrap_response(response: ResponseLike) -> Response: """ - Wrap a response from a handler into a `Response` object. + Wrap a response from a view into a `Response` object. """ if isinstance(response, Response): return response @@ -40,7 +40,6 @@ def wrap_response(response: ResponseLike) -> Response: return Response(content, 200, CIMultiDict()) - class BaseApp(ABC): """Base view.py application.""" @@ -91,21 +90,28 @@ def asgi(self): ... def run(self): ... -SingleHandler = Callable[[Request], ResponseLike] +SingleView = Callable[[Request], ResponseLike] -class SingleHandlerApp(BaseApp): - def __init__(self, handler: SingleHandler) -> None: +class SingleViewApp(BaseApp): + """ + Application with a single view function that + processes all requests. + """ + def __init__(self, view: SingleView) -> None: super().__init__() - self.handler = handler + self.view = view async def process_request(self, request: Request) -> Response: with self.request_context(request): - return self.handler(request) + return self.view(request) -def as_app(handler: SingleHandler, /) -> SingleHandlerApp: - return SingleHandlerApp(handler) +def as_app(view: SingleView, /) -> SingleViewApp: + """ + Decorator for using a single function as an app. + """ + return SingleViewApp(view) class RoutableApp(BaseApp): @@ -113,28 +119,28 @@ def __init__(self, *, router: Router | None = None) -> None: super().__init__() self.router = router or Router() - async def _execute_handler(self, handler: RouteHandler) -> ResponseLike: + async def _execute_view(self, view: RouteView) -> ResponseLike: try: - result = handler() + result = view() if isinstance(result, Awaitable): result = await result return result except HTTPError as error: http_error = type(error) - handler = self.router.lookup_error(http_error) - return await self._execute_handler(handler) + view = self.router.lookup_error(http_error) + return await self._execute_view(view) async def process_request(self, request: Request) -> Response: route: Route | None = self.router.lookup_route(request.path) - handler: RouteHandler + view: RouteView if route is None: - handler = self.router.lookup_error(NotFound) + view = self.router.lookup_error(NotFound) else: - handler = route.handler + view = route.view with self.request_context(request): - response = await self._execute_handler(handler) + response = await self._execute_view(view) return wrap_response(response) @@ -143,9 +149,9 @@ def route(self, path: str, /, *, method: Method) -> RouteDecorator: Decorator interface for adding a route to the app. """ - def decorator(handler: RouteHandlerVar, /) -> RouteHandlerVar: - self.router.push_route(handler, path, method) - return handler + def decorator(view: RouteViewVar, /) -> RouteViewVar: + self.router.push_route(view, path, method) + return view return decorator @@ -177,8 +183,8 @@ def head(self, path: str, /) -> RouteDecorator: return self.route(path, method=Method.HEAD) def error(self, status: int | type[HTTPError], /) -> RouteDecorator: - def decorator(handler: RouteHandlerVar, /) -> RouteHandlerVar: - self.router.push_error(status, handler) - return handler + def decorator(view: RouteViewVar, /) -> RouteViewVar: + self.router.push_error(status, view) + return view return decorator diff --git a/src/view/router.py b/src/view/router.py index 5b70b3a7..a5fce92f 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -24,12 +24,12 @@ class Method(Enum): HEAD = auto() -RouteHandler: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] +RouteView: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] @dataclass(slots=True, frozen=True) class Route: - handler: RouteHandler + view: RouteView path: str method: Method @@ -39,18 +39,18 @@ class BaseRouter(ABC): def lookup_route(self, path: str, /) -> Route | None: ... @abstractmethod - def lookup_error(self, error: type[HTTPError], /) -> RouteHandler: ... + def lookup_error(self, error: type[HTTPError], /) -> RouteView: ... class Router(BaseRouter): def __init__(self) -> None: - self.route_handlers: dict[str, Route] = {} - self.error_handlers: dict[type[HTTPError], RouteHandler] = {} + self.route_views: dict[str, Route] = {} + self.error_views: dict[type[HTTPError], RouteView] = {} - def push_route(self, handler: RouteHandler, path: str, method: Method) -> None: - self.route_handlers[path] = Route(handler=handler, path=path, method=method) + def push_route(self, view: RouteView, path: str, method: Method) -> None: + self.route_views[path] = Route(view=view, path=path, method=method) - def push_error(self, error: int | type[HTTPError], handler: RouteHandler) -> None: + def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: error_type: type[HTTPError] if isinstance(error, int): error_type = status_exception(error) @@ -59,17 +59,17 @@ def push_error(self, error: int | type[HTTPError], handler: RouteHandler) -> Non else: raise TypeError(f"expected a status code or type, but got {error!r}") - self.error_handlers[error_type] = handler + self.error_views[error_type] = view def lookup_route(self, path: str, /) -> Route | None: - return self.route_handlers.get(path) + return self.route_views.get(path) - def lookup_error(self, error: type[HTTPError], /) -> RouteHandler: - return self.error_handlers.get(error) or self.default_error(error) + def lookup_error(self, error: type[HTTPError], /) -> RouteView: + return self.error_views.get(error) or self.default_error(error) - def default_error(self, error: type[HTTPError]) -> RouteHandler: + def default_error(self, error: type[HTTPError]) -> RouteView: """ - Get the default error handler for a given HTTP error. + Get the default error view for a given HTTP error. """ def inner(): From da7e4a47f8107f155361d14e0c918fc02d9e56ec Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 11:55:06 -0400 Subject: [PATCH 014/188] Add a testing client. --- src/view/app.py | 5 +++- src/view/router.py | 4 +-- src/view/testing.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/view/testing.py diff --git a/src/view/app.py b/src/view/app.py index 5a349eda..a96f4823 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -8,7 +8,7 @@ from multidict import CIMultiDict from view.response import Response, ResponseLike -from view.router import Method, Route, RouteView, Router +from view.router import Method, Route, Router, RouteView from view.status_codes import HTTPError, NotFound __all__ = "BaseApp", "Request" @@ -20,6 +20,7 @@ @dataclass(slots=True, frozen=True) class Request: path: str + method: Method headers: CIMultiDict @@ -40,6 +41,7 @@ def wrap_response(response: ResponseLike) -> Response: return Response(content, 200, CIMultiDict()) + class BaseApp(ABC): """Base view.py application.""" @@ -98,6 +100,7 @@ class SingleViewApp(BaseApp): Application with a single view function that processes all requests. """ + def __init__(self, view: SingleView) -> None: super().__init__() self.view = view diff --git a/src/view/router.py b/src/view/router.py index a5fce92f..72c80211 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from enum import Enum, auto +from enum import StrEnum, auto from typing import Awaitable, Callable, TypeAlias, TypeVar from status_codes import HTTPError, status_exception @@ -12,7 +12,7 @@ __all__ = "Method", "Route", "Router" -class Method(Enum): +class Method(StrEnum): GET = auto() POST = auto() PUT = auto() diff --git a/src/view/testing.py b/src/view/testing.py new file mode 100644 index 00000000..fb403ef9 --- /dev/null +++ b/src/view/testing.py @@ -0,0 +1,68 @@ +from multidict import CIMultiDict + +from view.app import BaseApp, Request +from view.response import Response +from view.router import Method + + +class TestClient: + """ + Client to test an app. + + This makes no actual HTTP requests, and instead should be used to + exercise correctness of responses. + """ + + def __init__(self, app: BaseApp) -> None: + self.app = app + + async def request( + self, route: str, *, method: Method, headers: dict[str, str] | None = None + ) -> Response: + request_data = Request(route, method, headers=CIMultiDict(headers or {})) + return await self.app.process_request(request_data) + + async def get( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.GET, headers=headers) + + async def post( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.POST, headers=headers) + + async def put( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.PUT, headers=headers) + + async def patch( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.PATCH, headers=headers) + + async def delete( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.DELETE, headers=headers) + + async def connect( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.CONNECT, headers=headers) + + async def options( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.OPTIONS, headers=headers) + + async def trace( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.TRACE, headers=headers) + + async def head( + self, route: str, *, headers: dict[str, str] | None = None + ) -> Response: + return await self.request(route, method=Method.HEAD, headers=headers) From 713375a11f2a7d5b47306cdaa2ee0a2d1e415182 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 12:03:14 -0400 Subject: [PATCH 015/188] Fix build to get tests running. --- src/_view/module.c | 2 +- src/view/router.py | 2 +- src/view/status_codes.py | 2 +- src/view/testing.py | 2 +- tests/test_responses.py | 7 +++++++ 5 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 tests/test_responses.py diff --git a/src/_view/module.c b/src/_view/module.c index 88406014..9f2e38b9 100644 --- a/src/_view/module.c +++ b/src/_view/module.c @@ -3,7 +3,7 @@ static int _view_exec(PyObject *mod) { - return PyAwaitable_Init(mod); + return PyAwaitable_Init(); } static PyMethodDef _view_methods[] = { diff --git a/src/view/router.py b/src/view/router.py index 72c80211..48622e7c 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -5,7 +5,7 @@ from enum import StrEnum, auto from typing import Awaitable, Callable, TypeAlias, TypeVar -from status_codes import HTTPError, status_exception +from view.status_codes import HTTPError, status_exception from view.response import ResponseLike diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 451be936..b4f6e8f3 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -6,7 +6,7 @@ class HTTPError(Exception): - status_code: ClassVar[int] + status_code: ClassVar[int] = 0 def __init_subclass__(cls) -> None: STATUS_EXCEPTIONS[cls.status_code] = cls diff --git a/src/view/testing.py b/src/view/testing.py index fb403ef9..70eb53b2 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -5,7 +5,7 @@ from view.router import Method -class TestClient: +class AppTestClient: """ Client to test an app. diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 00000000..890f953e --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,7 @@ +import pytest +from view.testing import AppTestClient +from view.app import as_app + +@pytest.mark.asyncio +async def test_response(): + assert 1 From e74eb748c88c9701cd7ef2fcb876a42eed19d88b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 12:15:25 -0400 Subject: [PATCH 016/188] Add a working test. --- src/view/app.py | 6 ++++-- src/view/response.py | 10 ++++++++++ src/view/testing.py | 2 +- tests/test_responses.py | 23 ++++++++++++++++++++--- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index a96f4823..36965d08 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,3 +1,4 @@ +from __future__ import annotations import contextlib import contextvars from abc import ABC, abstractmethod @@ -19,6 +20,7 @@ @dataclass(slots=True, frozen=True) class Request: + app: BaseApp path: str method: Method headers: CIMultiDict @@ -65,7 +67,7 @@ def request_context(self, request: Request) -> Iterator[None]: def current_request(self, *, validate: Literal[False]) -> Request | None: ... @overload - def current_request(self, *, validate: Literal[True]) -> Request: ... + def current_request(self, *, validate: Literal[True] = True) -> Request: ... def current_request(self, *, validate: bool = True) -> Request | None: """ @@ -107,7 +109,7 @@ def __init__(self, view: SingleView) -> None: async def process_request(self, request: Request) -> Response: with self.request_context(request): - return self.view(request) + return wrap_response(self.view(request)) def as_app(view: SingleView, /) -> SingleViewApp: diff --git a/src/view/response.py b/src/view/response.py index c743feb0..31806036 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -6,9 +6,19 @@ @dataclass(slots=True, frozen=True) class Response: + """ + High-level dataclass representing a response from a view. + """ content: bytes status_code: int headers: CIMultiDict + def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: + """ + Process the response as a tuple. This is mainly useful + for assertions in testing. + """ + return (self.content, self.status_code, self.headers) + ResponseLike: TypeAlias = Response | AnyStr diff --git a/src/view/testing.py b/src/view/testing.py index 70eb53b2..60321bca 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -19,7 +19,7 @@ def __init__(self, app: BaseApp) -> None: async def request( self, route: str, *, method: Method, headers: dict[str, str] | None = None ) -> Response: - request_data = Request(route, method, headers=CIMultiDict(headers or {})) + request_data = Request(self.app, route, method, headers=CIMultiDict(headers or {})) return await self.app.process_request(request_data) async def get( diff --git a/tests/test_responses.py b/tests/test_responses.py index 890f953e..b01aea65 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,7 +1,24 @@ import pytest +from view.response import ResponseLike +from view.router import Method from view.testing import AppTestClient -from view.app import as_app +from view.app import Request, as_app + @pytest.mark.asyncio -async def test_response(): - assert 1 +async def test_str_or_bytes_response(): + @as_app + def app(request: Request) -> ResponseLike: + assert request.app == app + assert request.app.current_request() is request + assert isinstance(request.path, str) + assert request.method is Method.GET + + if request.path == "/": + return "Hello" + else: + return b"World" + + client = AppTestClient(app) + assert (await client.get("/")).as_tuple() == (b"Hello", 200, {}) + assert (await client.get("/f")).as_tuple() == (b"World", 200, {}) From 1e5f88100dc311c1f3a88e4b0b5c4fff2a28aa99 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 12:47:53 -0400 Subject: [PATCH 017/188] Add some simple logging with loguru. --- pyproject.toml | 2 +- src/view/app.py | 56 ++++++++++++++++++++++++++++++++++++----- tests/test_responses.py | 12 +++++++-- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a67eb97..289711a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = ["multidict~=6.5"] +dependencies = ["multidict~=6.5", "loguru~=0.7"] dynamic = ["version", "license"] [project.optional-dependencies] diff --git a/src/view/app.py b/src/view/app.py index 36965d08..82b69b5c 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -10,7 +10,8 @@ from view.response import Response, ResponseLike from view.router import Method, Route, Router, RouteView -from view.status_codes import HTTPError, NotFound +from view.status_codes import HTTPError, InternalServerError, NotFound +from loguru import logger __all__ = "BaseApp", "Request" @@ -30,6 +31,7 @@ def wrap_response(response: ResponseLike) -> Response: """ Wrap a response from a view into a `Response` object. """ + logger.debug(f"Got response: {response}") if isinstance(response, Response): return response @@ -57,11 +59,12 @@ def request_context(self, request: Request) -> Iterator[None]: """ Enter a context for the given request. """ - token = self._request.set(request) - try: - yield - finally: - self._request.reset(token) + with logger.contextualize(request=request): + token = self._request.set(request) + try: + yield + finally: + self._request.reset(token) @overload def current_request(self, *, validate: Literal[False]) -> Request | None: ... @@ -120,11 +123,16 @@ def as_app(view: SingleView, /) -> SingleViewApp: class RoutableApp(BaseApp): + """ + An application containing an automatic routing mechanism + and error handling. + """ def __init__(self, *, router: Router | None = None) -> None: super().__init__() self.router = router or Router() async def _execute_view(self, view: RouteView) -> ResponseLike: + logger.debug(f"Executing view: {view}") try: result = view() if isinstance(result, Awaitable): @@ -132,11 +140,17 @@ async def _execute_view(self, view: RouteView) -> ResponseLike: return result except HTTPError as error: + logger.info(f"HTTP Error {error.status_code}") http_error = type(error) view = self.router.lookup_error(http_error) return await self._execute_view(view) + except BaseException as exception: + logger.exception(exception) + view = self.router.lookup_error(InternalServerError) + return await self._execute_view(view) async def process_request(self, request: Request) -> Response: + logger.info(f"{request.method} {request.path}") route: Route | None = self.router.lookup_route(request.path) view: RouteView if route is None: @@ -161,33 +175,63 @@ def decorator(view: RouteViewVar, /) -> RouteViewVar: return decorator def get(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a GET route. + """ return self.route(path, method=Method.GET) def post(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a POST route. + """ return self.route(path, method=Method.POST) def put(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a PUT route. + """ return self.route(path, method=Method.PUT) def patch(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a PATCH route. + """ return self.route(path, method=Method.PATCH) def delete(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a DELETE route. + """ return self.route(path, method=Method.DELETE) def connect(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a CONNECT route. + """ return self.route(path, method=Method.CONNECT) def options(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding an OPTIONS route. + """ return self.route(path, method=Method.OPTIONS) def trace(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a TRACE route. + """ return self.route(path, method=Method.TRACE) def head(self, path: str, /) -> RouteDecorator: + """ + Decorator interface for adding a HEAD route. + """ return self.route(path, method=Method.HEAD) def error(self, status: int | type[HTTPError], /) -> RouteDecorator: + """ + Decorator interface for adding an error handler to the app. + """ def decorator(view: RouteViewVar, /) -> RouteViewVar: self.router.push_error(status, view) return view diff --git a/tests/test_responses.py b/tests/test_responses.py index b01aea65..2a2287f2 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -7,6 +7,9 @@ @pytest.mark.asyncio async def test_str_or_bytes_response(): + class MyString(str): + pass + @as_app def app(request: Request) -> ResponseLike: assert request.app == app @@ -16,9 +19,14 @@ def app(request: Request) -> ResponseLike: if request.path == "/": return "Hello" - else: + elif request.path == "/bytes": return b"World" + elif request.path == "/my-string": + return MyString("My string") + else: + raise RuntimeError() client = AppTestClient(app) assert (await client.get("/")).as_tuple() == (b"Hello", 200, {}) - assert (await client.get("/f")).as_tuple() == (b"World", 200, {}) + assert (await client.get("/bytes")).as_tuple() == (b"World", 200, {}) + assert (await client.get("/my-string")).as_tuple() == (b"My string", 200, {}) From 778abb009037efe869ad83fd6997761b608765e5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 17:14:24 -0400 Subject: [PATCH 018/188] Improve the module __init__. --- src/view/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/view/__init__.py b/src/view/__init__.py index c9403bb3..7d6e7c3f 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -1,3 +1,16 @@ +""" +view.py - The Batteries-Detachable Web Framework. +""" from view.__about__ import * -from view.app import * -from view.router import * + +from view import app as app +from app import * + +from view import response as response +from response import * + +from view import router as router +from router import * + +from view import status_codes as status_codes +from view import testing as testing From cde578298470e0dbf215787e49396645c5733ff2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 17:19:40 -0400 Subject: [PATCH 019/188] Move Request to its own file. --- src/view/__init__.py | 13 ++++++------- src/view/app.py | 41 ++++++++--------------------------------- src/view/request.py | 28 ++++++++++++++++++++++++++++ src/view/response.py | 23 +++++++++++++++++++++++ src/view/router.py | 21 ++++----------------- src/view/testing.py | 4 +++- 6 files changed, 72 insertions(+), 58 deletions(-) create mode 100644 src/view/request.py diff --git a/src/view/__init__.py b/src/view/__init__.py index 7d6e7c3f..aa03b139 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -1,16 +1,15 @@ """ view.py - The Batteries-Detachable Web Framework. """ -from view.__about__ import * from view import app as app -from app import * - +from view import request as request from view import response as response -from response import * - from view import router as router -from router import * - from view import status_codes as status_codes from view import testing as testing +from view.__about__ import * +from view.app import * +from view.request import * +from view.response import * +from view.router import * diff --git a/src/view/app.py b/src/view/app.py index 82b69b5c..eea906d8 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1,51 +1,24 @@ from __future__ import annotations + import contextlib import contextvars from abc import ABC, abstractmethod from collections.abc import Awaitable -from dataclasses import dataclass from typing import Callable, Iterator, Literal, TypeAlias, TypeVar, overload -from multidict import CIMultiDict +from loguru import logger -from view.response import Response, ResponseLike -from view.router import Method, Route, Router, RouteView +from view.request import Method, Request +from view.response import Response, ResponseLike, wrap_response +from view.router import Route, Router, RouteView from view.status_codes import HTTPError, InternalServerError, NotFound -from loguru import logger -__all__ = "BaseApp", "Request" +__all__ = "BaseApp", "as_app" RouteViewVar = TypeVar("RouteViewVar", bound=RouteView) RouteDecorator: TypeAlias = Callable[[RouteViewVar], RouteViewVar] -@dataclass(slots=True, frozen=True) -class Request: - app: BaseApp - path: str - method: Method - headers: CIMultiDict - - -def wrap_response(response: ResponseLike) -> Response: - """ - Wrap a response from a view into a `Response` object. - """ - logger.debug(f"Got response: {response}") - if isinstance(response, Response): - return response - - content: bytes - if isinstance(response, str): - content = response.encode() - elif isinstance(response, bytes): - content = response - else: - raise TypeError(f"Invalid response: {response!r}") - - return Response(content, 200, CIMultiDict()) - - class BaseApp(ABC): """Base view.py application.""" @@ -127,6 +100,7 @@ class RoutableApp(BaseApp): An application containing an automatic routing mechanism and error handling. """ + def __init__(self, *, router: Router | None = None) -> None: super().__init__() self.router = router or Router() @@ -232,6 +206,7 @@ def error(self, status: int | type[HTTPError], /) -> RouteDecorator: """ Decorator interface for adding an error handler to the app. """ + def decorator(view: RouteViewVar, /) -> RouteViewVar: self.router.push_error(status, view) return view diff --git a/src/view/request.py b/src/view/request.py new file mode 100644 index 00000000..ddb9e010 --- /dev/null +++ b/src/view/request.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from enum import StrEnum, auto + +from multidict import CIMultiDict + +from view.app import BaseApp + +__all__ = "Method", "Request" + + +class Method(StrEnum): + GET = auto() + POST = auto() + PUT = auto() + PATCH = auto() + DELETE = auto() + CONNECT = auto() + OPTIONS = auto() + TRACE = auto() + HEAD = auto() + + +@dataclass(slots=True, frozen=True) +class Request: + app: BaseApp + path: str + method: Method + headers: CIMultiDict diff --git a/src/view/response.py b/src/view/response.py index 31806036..5a86c263 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,14 +1,18 @@ from dataclasses import dataclass from typing import AnyStr, TypeAlias +from loguru import logger from multidict import CIMultiDict +__all__ = "Response", "ResponseLike" + @dataclass(slots=True, frozen=True) class Response: """ High-level dataclass representing a response from a view. """ + content: bytes status_code: int headers: CIMultiDict @@ -22,3 +26,22 @@ def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: ResponseLike: TypeAlias = Response | AnyStr + + +def wrap_response(response: ResponseLike) -> Response: + """ + Wrap a response from a view into a `Response` object. + """ + logger.debug(f"Got response: {response}") + if isinstance(response, Response): + return response + + content: bytes + if isinstance(response, str): + content = response.encode() + elif isinstance(response, bytes): + content = response + else: + raise TypeError(f"Invalid response: {response!r}") + + return Response(content, 200, CIMultiDict()) diff --git a/src/view/router.py b/src/view/router.py index 48622e7c..45580c04 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -2,26 +2,13 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from enum import StrEnum, auto -from typing import Awaitable, Callable, TypeAlias, TypeVar - -from view.status_codes import HTTPError, status_exception +from typing import Awaitable, Callable, TypeAlias +from view.request import Method from view.response import ResponseLike +from view.status_codes import HTTPError, status_exception -__all__ = "Method", "Route", "Router" - - -class Method(StrEnum): - GET = auto() - POST = auto() - PUT = auto() - PATCH = auto() - DELETE = auto() - CONNECT = auto() - OPTIONS = auto() - TRACE = auto() - HEAD = auto() +__all__ = "Route", "Router" RouteView: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] diff --git a/src/view/testing.py b/src/view/testing.py index 60321bca..c8d86aba 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -19,7 +19,9 @@ def __init__(self, app: BaseApp) -> None: async def request( self, route: str, *, method: Method, headers: dict[str, str] | None = None ) -> Response: - request_data = Request(self.app, route, method, headers=CIMultiDict(headers or {})) + request_data = Request( + self.app, route, method, headers=CIMultiDict(headers or {}) + ) return await self.app.process_request(request_data) async def get( From a9694d8983c3fd280dbb5c2f444119001a8c748d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 17:20:18 -0400 Subject: [PATCH 020/188] Fix missing __all__ in the view.testing module. --- src/view/testing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/view/testing.py b/src/view/testing.py index c8d86aba..e711a877 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -4,6 +4,7 @@ from view.response import Response from view.router import Method +__all__ = "AppTestClient", class AppTestClient: """ From a9f987190f96b3059dddd12ca95ec7c90ad5d767 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 17:25:04 -0400 Subject: [PATCH 021/188] Improve and add some missing docstrings. --- src/view/router.py | 31 ++++++++++++++++++++++--------- src/view/status_codes.py | 15 +++++++++++++-- src/view/testing.py | 3 ++- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/view/router.py b/src/view/router.py index 45580c04..b9ab172b 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,6 +1,5 @@ from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Awaitable, Callable, TypeAlias @@ -16,28 +15,34 @@ @dataclass(slots=True, frozen=True) class Route: + """ + Dataclass representing a route in a router. + """ + view: RouteView path: str method: Method -class BaseRouter(ABC): - @abstractmethod - def lookup_route(self, path: str, /) -> Route | None: ... - - @abstractmethod - def lookup_error(self, error: type[HTTPError], /) -> RouteView: ... - +class Router: + """ + Standard router that supports error and route lookups. + """ -class Router(BaseRouter): def __init__(self) -> None: self.route_views: dict[str, Route] = {} self.error_views: dict[type[HTTPError], RouteView] = {} def push_route(self, view: RouteView, path: str, method: Method) -> None: + """ + Register a view with the router. + """ self.route_views[path] = Route(view=view, path=path, method=method) def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: + """ + Register an error view with the router. + """ error_type: type[HTTPError] if isinstance(error, int): error_type = status_exception(error) @@ -49,9 +54,17 @@ def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: self.error_views[error_type] = view def lookup_route(self, path: str, /) -> Route | None: + """ + Look up the view for the route. + """ return self.route_views.get(path) def lookup_error(self, error: type[HTTPError], /) -> RouteView: + """ + Look up the error view for the given HTTP error. + + If no custom handler is set, this returns a default error view. + """ return self.error_views.get(error) or self.default_error(error) def default_error(self, error: type[HTTPError]) -> RouteView: diff --git a/src/view/status_codes.py b/src/view/status_codes.py index b4f6e8f3..3ed3e348 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -6,6 +6,13 @@ class HTTPError(Exception): + """ + Base class for all HTTP errors. + + Raising this type, or a subclass of this type, will be converted + to a status code at runtime. + """ + status_code: ClassVar[int] = 0 def __init_subclass__(cls) -> None: @@ -25,11 +32,15 @@ def status_exception(status: int) -> type[HTTPError]: class ClientSideError(HTTPError): - pass + """ + Base class for all HTTP errors between 400 and 500. + """ class ServerSideError(HTTPError): - pass + """ + Base class for all HTTP errors between 500 and 600. + """ class BadRequest(ClientSideError): diff --git a/src/view/testing.py b/src/view/testing.py index e711a877..5c4bc100 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -4,7 +4,8 @@ from view.response import Response from view.router import Method -__all__ = "AppTestClient", +__all__ = ("AppTestClient",) + class AppTestClient: """ From e7b570d3f83cde0962833fc936cc6deec3c6c32c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 18:04:44 -0400 Subject: [PATCH 022/188] Outline for ASGI servers. --- src/view/app.py | 4 +- src/view/server.py | 100 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/view/server.py diff --git a/src/view/app.py b/src/view/app.py index eea906d8..05267225 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -13,7 +13,7 @@ from view.router import Route, Router, RouteView from view.status_codes import HTTPError, InternalServerError, NotFound -__all__ = "BaseApp", "as_app" +__all__ = "BaseApp", "as_app", "App" RouteViewVar = TypeVar("RouteViewVar", bound=RouteView) RouteDecorator: TypeAlias = Callable[[RouteViewVar], RouteViewVar] @@ -95,7 +95,7 @@ def as_app(view: SingleView, /) -> SingleViewApp: return SingleViewApp(view) -class RoutableApp(BaseApp): +class App(BaseApp): """ An application containing an automatic routing mechanism and error handling. diff --git a/src/view/server.py b/src/view/server.py new file mode 100644 index 00000000..56ea7dd6 --- /dev/null +++ b/src/view/server.py @@ -0,0 +1,100 @@ +from typing import (Any, Awaitable, Callable, Iterable, Literal, NotRequired, + TypeAlias, TypedDict) + +from multidict import CIMultiDict + +from view.app import BaseApp +from view.request import Method, Request + +__all__ = () + + +class ASGIScopeData(TypedDict): + version: str + spec_version: NotRequired[str] + + +ASGIHeaders = Iterable[tuple[bytes, bytes]] + + +class ASGIHttpScope(TypedDict): + type: Literal["http"] + asgi: ASGIScopeData + http_version: str + method: str + scheme: str + path: str + raw_path: bytes + root_path: str + headers: ASGIHeaders + client: Iterable[tuple[str, int]] | None + server: Iterable[tuple[str, int | None]] | None + state: NotRequired[dict[str, Any] | None] + + +class ASGIBodyMixin(TypedDict): + body: NotRequired[bytes] + more_body: NotRequired[bool] + + +class ASGIHttpReceiveResult(ASGIBodyMixin, TypedDict): + type: Literal["http.request"] + + +class ASGIHttpSendStart(TypedDict): + type: Literal["http.response.start"] + status: int + headers: ASGIHeaders + trailers: NotRequired[bool] + + +class ASGIHttpSendBody(ASGIBodyMixin, TypedDict): + type: Literal["http.response.body"] + + +ASGIHttpReceive: TypeAlias = Callable[[], Awaitable[ASGIHttpReceiveResult]] +ASGIHttpSend: TypeAlias = Callable[ + [ASGIHttpSendStart | ASGIHttpSendBody], Awaitable[None] +] +ASGIProtocol: TypeAlias = Callable[ + [ASGIHttpScope, ASGIHttpReceive, ASGIHttpSend], Awaitable[None] +] + + +def headers_as_multidict(headers: ASGIHeaders, /) -> CIMultiDict: + multidict = CIMultiDict() + + for key, value in headers: + multidict[key.decode("utf-8")] = value.decode("utf-8") + + return multidict + + +def multidict_as_headers(headers: CIMultiDict, /) -> ASGIHeaders: + asgi_headers: ASGIHeaders = [] + + for key, value in headers: + asgi_headers.append((key.encode("utf-8"), value.encode("utf-8"))) + + return asgi_headers + + +async def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: + async def asgi( + scope: ASGIHttpScope, receive: ASGIHttpReceive, send: ASGIHttpSend + ) -> None: + assert scope["type"] == "http" + method = Method(scope["method"]) + headers = headers_as_multidict(scope["headers"]) + request = Request(app, scope["path"], method, headers) + + response = await app.process_request(request) + await send( + { + "type": "http.response.start", + "status": response.status_code, + "headers": multidict_as_headers(response.headers), + } + ) + + return asgi From f48ccf94a835dbb8f0ebe698e0cbb9203dbd11c2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 18:25:11 -0400 Subject: [PATCH 023/188] Rough implementation of request bodies. --- sample.py | 1 + src/view/__init__.py | 1 + src/view/app.py | 12 +++++++--- src/view/{server.py => asgi.py} | 21 +++++++++++++---- src/view/request.py | 41 +++++++++++++++++++++++++++++++-- src/view/router.py | 8 +++++-- src/view/status_codes.py | 4 +++- src/view/testing.py | 14 ++++++++--- 8 files changed, 86 insertions(+), 16 deletions(-) rename src/view/{server.py => asgi.py} (78%) diff --git a/sample.py b/sample.py index 8fc4704a..6ec28d98 100644 --- a/sample.py +++ b/sample.py @@ -5,6 +5,7 @@ @app.get("/") def index(): + request = app.current_request() return HTML.from_file("index/test.html") if __name__ == "__main__": diff --git a/src/view/__init__.py b/src/view/__init__.py index aa03b139..f7971bd3 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -3,6 +3,7 @@ """ from view import app as app +from view import asgi as asgi from view import request as request from view import response as response from view import router as router diff --git a/src/view/app.py b/src/view/app.py index 05267225..fed5fb07 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -8,6 +8,7 @@ from loguru import logger +from view.asgi import ASGIProtocol, asgi_for_app from view.request import Method, Request from view.response import Response, ResponseLike, wrap_response from view.router import Route, Router, RouteView @@ -40,10 +41,14 @@ def request_context(self, request: Request) -> Iterator[None]: self._request.reset(token) @overload - def current_request(self, *, validate: Literal[False]) -> Request | None: ... + def current_request( + self, *, validate: Literal[False] + ) -> Request | None: ... @overload - def current_request(self, *, validate: Literal[True] = True) -> Request: ... + def current_request( + self, *, validate: Literal[True] = True + ) -> Request: ... def current_request(self, *, validate: bool = True) -> Request | None: """ @@ -65,7 +70,8 @@ async def process_request(self, request: Request) -> Response: def wsgi(self): ... - def asgi(self): ... + def asgi(self) -> ASGIProtocol: + return asgi_for_app(self) def run(self): ... diff --git a/src/view/server.py b/src/view/asgi.py similarity index 78% rename from src/view/server.py rename to src/view/asgi.py index 56ea7dd6..6bdbd33c 100644 --- a/src/view/server.py +++ b/src/view/asgi.py @@ -1,12 +1,12 @@ -from typing import (Any, Awaitable, Callable, Iterable, Literal, NotRequired, - TypeAlias, TypedDict) +from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, + NotRequired, TypeAlias, TypedDict) from multidict import CIMultiDict from view.app import BaseApp from view.request import Method, Request -__all__ = () +__all__ = ("asgi_for_app",) class ASGIScopeData(TypedDict): @@ -79,14 +79,25 @@ def multidict_as_headers(headers: CIMultiDict, /) -> ASGIHeaders: return asgi_headers -async def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: +def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: async def asgi( scope: ASGIHttpScope, receive: ASGIHttpReceive, send: ASGIHttpSend ) -> None: assert scope["type"] == "http" method = Method(scope["method"]) headers = headers_as_multidict(scope["headers"]) - request = Request(app, scope["path"], method, headers) + + async def receive_data() -> AsyncIterator[bytes]: + more_body = True + while more_body: + data = await receive() + assert data["type"] == "http.request" + yield data.get("body", b"") + more_body = data.get("more_body", False) + + request = Request( + app, scope["path"], method, headers, receive_data=receive_data + ) response = await app.process_request(request) await send( diff --git a/src/view/request.py b/src/view/request.py index ddb9e010..9636536f 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,5 +1,7 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum, auto +from io import BytesIO +from typing import AsyncGenerator, AsyncIterator, Callable, TypeAlias from multidict import CIMultiDict @@ -20,9 +22,44 @@ class Method(StrEnum): HEAD = auto() -@dataclass(slots=True, frozen=True) +BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] + + +@dataclass(slots=True) class Request: + """ + Dataclass representing an HTTP request. + """ + app: BaseApp path: str method: Method headers: CIMultiDict + receive_data: BodyStream | None = None + consumed: bool = field(init=False, default=False) + + async def body(self) -> bytes: + if self.consumed: + raise RuntimeError("body has already been consumed") + + self.consumed = True + if self.receive_data is None: + return b"" + + buffer = BytesIO() + async for data in self.receive_data(): + buffer.write(data) + + return buffer.getvalue() + + async def stream_body(self) -> AsyncGenerator[bytes]: + if self.consumed: + raise RuntimeError("body has already been consumed") + + self.consumed = True + if self.receive_data is None: + yield b"" + return + + async for data in self.receive_data(): + yield data diff --git a/src/view/router.py b/src/view/router.py index b9ab172b..f6070414 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -39,7 +39,9 @@ def push_route(self, view: RouteView, path: str, method: Method) -> None: """ self.route_views[path] = Route(view=view, path=path, method=method) - 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. """ @@ -49,7 +51,9 @@ def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: elif issubclass(error, HTTPError): error_type = error else: - raise TypeError(f"expected a status code or type, but got {error!r}") + raise TypeError( + f"expected a status code or type, but got {error!r}" + ) self.error_views[error_type] = view diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 3ed3e348..d636a60e 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -26,7 +26,9 @@ def status_exception(status: int) -> type[HTTPError]: try: status_type: type[HTTPError] = STATUS_EXCEPTIONS[status] except KeyError as error: - raise ValueError(f"{status} is not a valid HTTP error status code") from error + raise ValueError( + f"{status} is not a valid HTTP error status code" + ) from error return status_type diff --git a/src/view/testing.py b/src/view/testing.py index 5c4bc100..02747a06 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -19,7 +19,11 @@ def __init__(self, app: BaseApp) -> None: self.app = app async def request( - self, route: str, *, method: Method, headers: dict[str, str] | None = None + self, + route: str, + *, + method: Method, + headers: dict[str, str] | None = None, ) -> Response: request_data = Request( self.app, route, method, headers=CIMultiDict(headers or {}) @@ -54,12 +58,16 @@ async def delete( async def connect( self, route: str, *, headers: dict[str, str] | None = None ) -> Response: - return await self.request(route, method=Method.CONNECT, headers=headers) + return await self.request( + route, method=Method.CONNECT, headers=headers + ) async def options( self, route: str, *, headers: dict[str, str] | None = None ) -> Response: - return await self.request(route, method=Method.OPTIONS, headers=headers) + return await self.request( + route, method=Method.OPTIONS, headers=headers + ) async def trace( self, route: str, *, headers: dict[str, str] | None = None From 57371871e1dedabca21a5b3aa9010c48902529ee Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 18:45:08 -0400 Subject: [PATCH 024/188] Outline for streaming responses. --- src/view/app.py | 8 ++------ src/view/body.py | 32 ++++++++++++++++++++++++++++++++ src/view/request.py | 38 +++----------------------------------- src/view/response.py | 36 ++++++++++++++++++++++++++++++------ src/view/router.py | 8 ++------ src/view/status_codes.py | 4 +--- src/view/testing.py | 8 ++------ tests/test_responses.py | 3 ++- 8 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 src/view/body.py diff --git a/src/view/app.py b/src/view/app.py index fed5fb07..a1da399c 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -41,14 +41,10 @@ def request_context(self, request: Request) -> Iterator[None]: self._request.reset(token) @overload - def current_request( - self, *, validate: Literal[False] - ) -> Request | None: ... + def current_request(self, *, validate: Literal[False]) -> Request | None: ... @overload - def current_request( - self, *, validate: Literal[True] = True - ) -> Request: ... + def current_request(self, *, validate: Literal[True] = True) -> Request: ... def current_request(self, *, validate: bool = True) -> Request | None: """ diff --git a/src/view/body.py b/src/view/body.py new file mode 100644 index 00000000..940e531c --- /dev/null +++ b/src/view/body.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field +from io import BytesIO +from typing import AsyncGenerator, AsyncIterator, Callable, TypeAlias + +BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] + + +@dataclass(slots=True) +class BodyMixin: + receive_data: BodyStream + consumed: bool = field(init=False, default=False) + + async def body(self) -> bytes: + if self.consumed: + raise RuntimeError("body has already been consumed") + + self.consumed = True + + buffer = BytesIO() + async for data in self.receive_data(): + buffer.write(data) + + return buffer.getvalue() + + async def stream_body(self) -> AsyncGenerator[bytes]: + if self.consumed: + raise RuntimeError("body has already been consumed") + + self.consumed = True + + async for data in self.receive_data(): + yield data diff --git a/src/view/request.py b/src/view/request.py index 9636536f..e2f372e5 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,11 +1,10 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import StrEnum, auto -from io import BytesIO -from typing import AsyncGenerator, AsyncIterator, Callable, TypeAlias from multidict import CIMultiDict from view.app import BaseApp +from view.body import BodyMixin __all__ = "Method", "Request" @@ -22,11 +21,8 @@ class Method(StrEnum): HEAD = auto() -BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] - - @dataclass(slots=True) -class Request: +class Request(BodyMixin): """ Dataclass representing an HTTP request. """ @@ -35,31 +31,3 @@ class Request: path: str method: Method headers: CIMultiDict - receive_data: BodyStream | None = None - consumed: bool = field(init=False, default=False) - - async def body(self) -> bytes: - if self.consumed: - raise RuntimeError("body has already been consumed") - - self.consumed = True - if self.receive_data is None: - return b"" - - buffer = BytesIO() - async for data in self.receive_data(): - buffer.write(data) - - return buffer.getvalue() - - async def stream_body(self) -> AsyncGenerator[bytes]: - if self.consumed: - raise RuntimeError("body has already been consumed") - - self.consumed = True - if self.receive_data is None: - yield b"" - return - - async for data in self.receive_data(): - yield data diff --git a/src/view/response.py b/src/view/response.py index 5a86c263..e7b95bb9 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,29 +1,53 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import AnyStr, TypeAlias +from typing import AnyStr, AsyncGenerator, TypeAlias from loguru import logger from multidict import CIMultiDict +from view.body import BodyMixin + __all__ = "Response", "ResponseLike" -@dataclass(slots=True, frozen=True) -class Response: +@dataclass(slots=True) +class Response(BodyMixin): """ High-level dataclass representing a response from a view. """ - content: bytes status_code: int headers: CIMultiDict - def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: + async def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: """ Process the response as a tuple. This is mainly useful for assertions in testing. """ + return (await self.body(), self.status_code, self.headers) + + +@dataclass(slots=True) +class BytesResponse(Response): + """ + Simple in-memory response for a byte string. + """ + + content: bytes + + def as_tuple_sync(self) -> tuple[bytes, int, CIMultiDict]: return (self.content, self.status_code, self.headers) + @classmethod + def from_bytes( + cls, content: bytes, status_code: int, headers: CIMultiDict + ) -> BytesResponse: + async def stream() -> AsyncGenerator[bytes]: + yield content + + return cls(stream, status_code, headers, content) + ResponseLike: TypeAlias = Response | AnyStr @@ -44,4 +68,4 @@ def wrap_response(response: ResponseLike) -> Response: else: raise TypeError(f"Invalid response: {response!r}") - return Response(content, 200, CIMultiDict()) + return BytesResponse.from_bytes(content, 200, CIMultiDict()) diff --git a/src/view/router.py b/src/view/router.py index f6070414..b9ab172b 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -39,9 +39,7 @@ def push_route(self, view: RouteView, path: str, method: Method) -> None: """ self.route_views[path] = Route(view=view, path=path, method=method) - 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. """ @@ -51,9 +49,7 @@ def push_error( elif issubclass(error, HTTPError): error_type = error else: - raise TypeError( - f"expected a status code or type, but got {error!r}" - ) + raise TypeError(f"expected a status code or type, but got {error!r}") self.error_views[error_type] = view diff --git a/src/view/status_codes.py b/src/view/status_codes.py index d636a60e..3ed3e348 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -26,9 +26,7 @@ def status_exception(status: int) -> type[HTTPError]: try: status_type: type[HTTPError] = STATUS_EXCEPTIONS[status] except KeyError as error: - raise ValueError( - f"{status} is not a valid HTTP error status code" - ) from error + raise ValueError(f"{status} is not a valid HTTP error status code") from error return status_type diff --git a/src/view/testing.py b/src/view/testing.py index 02747a06..a80098ea 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -58,16 +58,12 @@ async def delete( async def connect( self, route: str, *, headers: dict[str, str] | None = None ) -> Response: - return await self.request( - route, method=Method.CONNECT, headers=headers - ) + return await self.request(route, method=Method.CONNECT, headers=headers) async def options( self, route: str, *, headers: dict[str, str] | None = None ) -> Response: - return await self.request( - route, method=Method.OPTIONS, headers=headers - ) + return await self.request(route, method=Method.OPTIONS, headers=headers) async def trace( self, route: str, *, headers: dict[str, str] | None = None diff --git a/tests/test_responses.py b/tests/test_responses.py index 2a2287f2..4dc063c1 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,8 +1,9 @@ import pytest + +from view.app import Request, as_app from view.response import ResponseLike from view.router import Method from view.testing import AppTestClient -from view.app import Request, as_app @pytest.mark.asyncio From cdd1a1d19ccd8d2f82e12b880da1d8a2a4f1103d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 19:24:10 -0400 Subject: [PATCH 025/188] Outline for file responses. --- pyproject.toml | 2 +- src/view/asgi.py | 35 +++++++++--------------- src/view/response.py | 64 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 289711a3..f9839836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", ] -dependencies = ["multidict~=6.5", "loguru~=0.7"] +dependencies = ["multidict~=6.5", "loguru~=0.7", "aiofiles~=24.1"] dynamic = ["version", "license"] [project.optional-dependencies] diff --git a/src/view/asgi.py b/src/view/asgi.py index 6bdbd33c..a40a224d 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,5 +1,14 @@ -from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, - NotRequired, TypeAlias, TypedDict) +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Iterable, + Literal, + NotRequired, + TypeAlias, + TypedDict, +) from multidict import CIMultiDict @@ -61,24 +70,6 @@ class ASGIHttpSendBody(ASGIBodyMixin, TypedDict): ] -def headers_as_multidict(headers: ASGIHeaders, /) -> CIMultiDict: - multidict = CIMultiDict() - - for key, value in headers: - multidict[key.decode("utf-8")] = value.decode("utf-8") - - return multidict - - -def multidict_as_headers(headers: CIMultiDict, /) -> ASGIHeaders: - asgi_headers: ASGIHeaders = [] - - for key, value in headers: - asgi_headers.append((key.encode("utf-8"), value.encode("utf-8"))) - - return asgi_headers - - def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: async def asgi( scope: ASGIHttpScope, receive: ASGIHttpReceive, send: ASGIHttpSend @@ -95,9 +86,7 @@ async def receive_data() -> AsyncIterator[bytes]: yield data.get("body", b"") more_body = data.get("more_body", False) - request = Request( - app, scope["path"], method, headers, receive_data=receive_data - ) + request = Request(receive_data, app, scope["path"], method, headers) response = await app.process_request(request) await send( diff --git a/src/view/response.py b/src/view/response.py index e7b95bb9..d682f01b 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,8 +1,10 @@ from __future__ import annotations from dataclasses import dataclass +from os import PathLike from typing import AnyStr, AsyncGenerator, TypeAlias +import aiofiles from loguru import logger from multidict import CIMultiDict @@ -28,6 +30,59 @@ async def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: return (await self.body(), self.status_code, self.headers) +HeadersLike = CIMultiDict | dict[str, str] | dict[bytes, bytes] +StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes] + + +def as_multidict(headers: HeadersLike | None, /) -> CIMultiDict: + if headers is None: + return CIMultiDict() + + if isinstance(headers, CIMultiDict): + return headers + + if not isinstance(headers, dict): + raise TypeError(f"Invalid headers: {headers}") + + assert isinstance(headers, dict) + multidict = CIMultiDict() + for key, value in headers.items(): + if isinstance(key, bytes): + key = key.decode("utf-8") + + if isinstance(value, bytes): + value = value.decode("utf-8") + + multidict[key] = value + + return multidict + + +@dataclass(slots=True) +class FileResponse(Response): + path: StrOrBytesPath + + @classmethod + def from_file( + cls, + path: StrOrBytesPath, + /, + *, + status_code: int = 200, + headers: HeadersLike | None = None, + increment: int = 512, + ) -> FileResponse: + async def stream(): + async with aiofiles.open(path, "rb") as file: + length = increment + while length == increment: + data = await file.read(increment) + length = len(data) + yield data + + return cls(stream, status_code, as_multidict(headers), path) + + @dataclass(slots=True) class BytesResponse(Response): """ @@ -41,12 +96,17 @@ def as_tuple_sync(self) -> tuple[bytes, int, CIMultiDict]: @classmethod def from_bytes( - cls, content: bytes, status_code: int, headers: CIMultiDict + cls, + content: bytes, + /, + *, + status_code: int = 200, + headers: HeadersLike | None = None, ) -> BytesResponse: async def stream() -> AsyncGenerator[bytes]: yield content - return cls(stream, status_code, headers, content) + return cls(stream, status_code, as_multidict(headers), content) ResponseLike: TypeAlias = Response | AnyStr From e8ce92519ce57199b01507f87b976950c8dd67b6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 19:25:35 -0400 Subject: [PATCH 026/188] Add some missing formalities, such as __all__ or future imports. --- src/view/asgi.py | 4 ++-- src/view/body.py | 4 ++++ src/view/testing.py | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/view/asgi.py b/src/view/asgi.py index a40a224d..78c63fc8 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import ( Any, AsyncIterator, @@ -10,8 +12,6 @@ TypedDict, ) -from multidict import CIMultiDict - from view.app import BaseApp from view.request import Method, Request diff --git a/src/view/body.py b/src/view/body.py index 940e531c..0d7870ee 100644 --- a/src/view/body.py +++ b/src/view/body.py @@ -1,7 +1,11 @@ +from __future__ import annotations + from dataclasses import dataclass, field from io import BytesIO from typing import AsyncGenerator, AsyncIterator, Callable, TypeAlias +__all__ = ("BodyMixin",) + BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] diff --git a/src/view/testing.py b/src/view/testing.py index a80098ea..88213f7a 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from multidict import CIMultiDict from view.app import BaseApp, Request From 257f75f8e946e047e095ab5c72ecb8678aaced79 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 19:33:19 -0400 Subject: [PATCH 027/188] Stream responses in the ASGI implementation. --- src/view/asgi.py | 36 ++++++++++++++++++++++++++++++++++++ src/view/response.py | 2 +- src/view/testing.py | 6 +++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/view/asgi.py b/src/view/asgi.py index 78c63fc8..124f148a 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -11,6 +11,7 @@ TypeAlias, TypedDict, ) +from multidict import CIMultiDict from view.app import BaseApp from view.request import Method, Request @@ -69,8 +70,35 @@ class ASGIHttpSendBody(ASGIBodyMixin, TypedDict): [ASGIHttpScope, ASGIHttpReceive, ASGIHttpSend], Awaitable[None] ] +def headers_as_multidict(headers: ASGIHeaders, /) -> CIMultiDict: + """ + Convert ASGI headers to a case-insensitive multidict. + """ + multidict = CIMultiDict() + + for key, value in headers: + multidict[key.decode("utf-8")] = value.decode("utf-8") + + return multidict + + +def multidict_as_headers(headers: CIMultiDict, /) -> ASGIHeaders: + """ + Convert a case-insensitive multidict to an ASGI header iterable. + """ + asgi_headers: ASGIHeaders = [] + + for key, value in headers: + asgi_headers.append((key.encode("utf-8"), value.encode("utf-8"))) + + return asgi_headers + def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: + """ + Generate an ASGI-compliant callable for a given app, allowing + it to be executed in an ASGI server. + """ async def asgi( scope: ASGIHttpScope, receive: ASGIHttpReceive, send: ASGIHttpSend ) -> None: @@ -96,5 +124,13 @@ async def receive_data() -> AsyncIterator[bytes]: "headers": multidict_as_headers(response.headers), } ) + 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": b"", "more_body": False} + ) return asgi diff --git a/src/view/response.py b/src/view/response.py index d682f01b..9856da92 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -128,4 +128,4 @@ def wrap_response(response: ResponseLike) -> Response: else: raise TypeError(f"Invalid response: {response!r}") - return BytesResponse.from_bytes(content, 200, CIMultiDict()) + return BytesResponse.from_bytes(content) diff --git a/src/view/testing.py b/src/view/testing.py index 88213f7a..05ba8321 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import AsyncGenerator from multidict import CIMultiDict @@ -27,8 +28,11 @@ async def request( method: Method, headers: dict[str, str] | None = None, ) -> Response: + async def stream_none() -> AsyncGenerator[bytes]: + yield b"" + request_data = Request( - self.app, route, method, headers=CIMultiDict(headers or {}) + receive_data=stream_none, app=self.app, path=route, method=method, headers=CIMultiDict(headers or {}), ) return await self.app.process_request(request_data) From 5e11a3d00a0144911c0776e2418e25e0775adc82 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 19:58:21 -0400 Subject: [PATCH 028/188] Fix circular references. --- src/view/app.py | 15 ++++++++++----- src/view/asgi.py | 24 +++++++----------------- src/view/request.py | 11 +++++++++-- src/view/router.py | 10 ++++++---- src/view/testing.py | 17 ++++++++++++----- 5 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index a1da399c..f6a17215 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -4,16 +4,19 @@ import contextvars from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import Callable, Iterator, Literal, TypeAlias, TypeVar, overload +from typing import (TYPE_CHECKING, Callable, Iterator, Literal, TypeAlias, + TypeVar, overload) from loguru import logger -from view.asgi import ASGIProtocol, asgi_for_app -from view.request import Method, Request from view.response import Response, ResponseLike, wrap_response from view.router import Route, Router, RouteView from view.status_codes import HTTPError, InternalServerError, NotFound +if TYPE_CHECKING: + from view.asgi import ASGIProtocol + from view.request import Method, Request + __all__ = "BaseApp", "as_app", "App" RouteViewVar = TypeVar("RouteViewVar", bound=RouteView) @@ -24,7 +27,7 @@ class BaseApp(ABC): """Base view.py application.""" def __init__(self): - self._request = contextvars.ContextVar[Request]( + self._request = contextvars.ContextVar["Request"]( "The current request being handled." ) @@ -67,12 +70,14 @@ async def process_request(self, request: Request) -> Response: def wsgi(self): ... def asgi(self) -> ASGIProtocol: + from view.asgi import asgi_for_app + return asgi_for_app(self) def run(self): ... -SingleView = Callable[[Request], ResponseLike] +SingleView = Callable[["Request"], "ResponseLike"] class SingleViewApp(BaseApp): diff --git a/src/view/asgi.py b/src/view/asgi.py index 124f148a..8cfea054 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,16 +1,8 @@ from __future__ import annotations -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Iterable, - Literal, - NotRequired, - TypeAlias, - TypedDict, -) +from typing import (TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, + Iterable, Literal, NotRequired, TypeAlias, TypedDict) + from multidict import CIMultiDict from view.app import BaseApp @@ -70,6 +62,7 @@ class ASGIHttpSendBody(ASGIBodyMixin, TypedDict): [ASGIHttpScope, ASGIHttpReceive, ASGIHttpSend], Awaitable[None] ] + def headers_as_multidict(headers: ASGIHeaders, /) -> CIMultiDict: """ Convert ASGI headers to a case-insensitive multidict. @@ -99,6 +92,7 @@ def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: Generate an ASGI-compliant callable for a given app, allowing it to be executed in an ASGI server. """ + async def asgi( scope: ASGIHttpScope, receive: ASGIHttpReceive, send: ASGIHttpSend ) -> None: @@ -125,12 +119,8 @@ 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/request.py b/src/view/request.py index e2f372e5..cddb500a 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,15 +1,22 @@ from dataclasses import dataclass from enum import StrEnum, auto +from typing import TYPE_CHECKING from multidict import CIMultiDict -from view.app import BaseApp from view.body import BodyMixin +if TYPE_CHECKING: + from view.app import BaseApp + __all__ = "Method", "Request" class Method(StrEnum): + """ + The HTTP request method. + """ + GET = auto() POST = auto() PUT = auto() @@ -27,7 +34,7 @@ class Request(BodyMixin): Dataclass representing an HTTP request. """ - app: BaseApp + app: "BaseApp" path: str method: Method headers: CIMultiDict diff --git a/src/view/router.py b/src/view/router.py index b9ab172b..3e2e3968 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,16 +1,18 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Awaitable, Callable, TypeAlias +from typing import TYPE_CHECKING, Awaitable, Callable, TypeAlias -from view.request import Method -from view.response import ResponseLike from view.status_codes import HTTPError, status_exception +if TYPE_CHECKING: + from view.request import Method + from view.response import ResponseLike + __all__ = "Route", "Router" -RouteView: TypeAlias = Callable[[], ResponseLike | Awaitable[ResponseLike]] +RouteView: TypeAlias = Callable[[], "ResponseLike" | Awaitable["ResponseLike"]] @dataclass(slots=True, frozen=True) diff --git a/src/view/testing.py b/src/view/testing.py index 05ba8321..ff612d6c 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,11 +1,14 @@ from __future__ import annotations -from typing import AsyncGenerator + +from typing import TYPE_CHECKING, AsyncGenerator from multidict import CIMultiDict -from view.app import BaseApp, Request -from view.response import Response -from view.router import Method +from view.request import Method, Request + +if TYPE_CHECKING: + from view.app import BaseApp + from view.response import Response __all__ = ("AppTestClient",) @@ -32,7 +35,11 @@ async def stream_none() -> AsyncGenerator[bytes]: yield b"" request_data = Request( - receive_data=stream_none, app=self.app, path=route, method=method, headers=CIMultiDict(headers or {}), + receive_data=stream_none, + app=self.app, + path=route, + method=method, + headers=CIMultiDict(headers or {}), ) return await self.app.process_request(request_data) From ccf5e4e3c70b9b464c486519b5b0bb6fe61ac2f6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 20:01:20 -0400 Subject: [PATCH 029/188] Fix Method enums being lowercase. --- src/view/app.py | 11 +++++++++-- src/view/asgi.py | 14 ++++++++++++-- src/view/request.py | 10 +++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index f6a17215..1a9f711b 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -4,8 +4,15 @@ import contextvars from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import (TYPE_CHECKING, Callable, Iterator, Literal, TypeAlias, - TypeVar, overload) +from typing import ( + TYPE_CHECKING, + Callable, + Iterator, + Literal, + TypeAlias, + TypeVar, + overload, +) from loguru import logger diff --git a/src/view/asgi.py b/src/view/asgi.py index 8cfea054..6fc0b850 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,7 +1,17 @@ from __future__ import annotations -from typing import (TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, - Iterable, Literal, NotRequired, TypeAlias, TypedDict) +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterator, + Awaitable, + Callable, + Iterable, + Literal, + NotRequired, + TypeAlias, + TypedDict, +) from multidict import CIMultiDict diff --git a/src/view/request.py b/src/view/request.py index cddb500a..b8783fbe 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -12,7 +12,15 @@ __all__ = "Method", "Request" -class Method(StrEnum): +class _UpperStrEnum(StrEnum): + @staticmethod + def _generate_next_value_( + name: str, start: int, count: int, last_values: list[str] + ) -> str: + return name.upper() + + +class Method(_UpperStrEnum): """ The HTTP request method. """ From fa1bcd1f29a22ea1ce7877623970343110d743f4 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 20:02:49 -0400 Subject: [PATCH 030/188] Remove unused import. --- src/view/asgi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/view/asgi.py b/src/view/asgi.py index 6fc0b850..149478d7 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import ( - TYPE_CHECKING, Any, AsyncIterator, Awaitable, From ee3946adec6ce54b950024fb8c416bc5684f18bb Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 20:42:25 -0400 Subject: [PATCH 031/188] Make Router a dataclass. --- src/view/app.py | 11 ++--------- src/view/asgi.py | 13 ++----------- src/view/router.py | 8 ++++---- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 1a9f711b..f6a17215 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -4,15 +4,8 @@ import contextvars from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import ( - TYPE_CHECKING, - Callable, - Iterator, - Literal, - TypeAlias, - TypeVar, - overload, -) +from typing import (TYPE_CHECKING, Callable, Iterator, Literal, TypeAlias, + TypeVar, overload) from loguru import logger diff --git a/src/view/asgi.py b/src/view/asgi.py index 149478d7..4fc19d0f 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,16 +1,7 @@ from __future__ import annotations -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Iterable, - Literal, - NotRequired, - TypeAlias, - TypedDict, -) +from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, + NotRequired, TypeAlias, TypedDict) from multidict import CIMultiDict diff --git a/src/view/router.py b/src/view/router.py index 3e2e3968..857d24a5 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Awaitable, Callable, TypeAlias from view.status_codes import HTTPError, status_exception @@ -26,14 +26,14 @@ class Route: method: Method +@dataclass(slots=True, frozen=True) class Router: """ Standard router that supports error and route lookups. """ - def __init__(self) -> None: - self.route_views: dict[str, Route] = {} - self.error_views: dict[type[HTTPError], RouteView] = {} + route_views: dict[str, Route] = field(default_factory=dict) + error_views: dict[type[HTTPError], RouteView] = field(default_factory=dict) def push_route(self, view: RouteView, path: str, method: Method) -> None: """ From bf3f264ca36a70b61d4588fa429c97f2035daa6f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 21:45:43 -0400 Subject: [PATCH 032/188] Rough WSGI implementation. --- src/view/__init__.py | 1 + src/view/app.py | 6 +++- src/view/status_codes.py | 66 +++++++++++++++++++++++++++++++++++++++- src/view/wsgi.py | 55 +++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 src/view/wsgi.py diff --git a/src/view/__init__.py b/src/view/__init__.py index f7971bd3..989a8eca 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -9,6 +9,7 @@ from view import router as router from view import status_codes as status_codes from view import testing as testing +from view import wsgi as wsgi from view.__about__ import * from view.app import * from view.request import * diff --git a/src/view/app.py b/src/view/app.py index f6a17215..a7b13332 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from view.asgi import ASGIProtocol + from view.wsgi import WSGIProtocol from view.request import Method, Request __all__ = "BaseApp", "as_app", "App" @@ -67,7 +68,10 @@ async def process_request(self, request: Request) -> Response: Get the response from the server for a given request. """ - def wsgi(self): ... + def wsgi(self) -> WSGIProtocol: + from view.wsgi import wsgi_for_app + + return wsgi_for_app(self) def asgi(self) -> ASGIProtocol: from view.asgi import asgi_for_app diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 3ed3e348..499a5344 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -3,7 +3,71 @@ from typing import ClassVar STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} - +STATUS_STRINGS: dict[int, str] = { +100: 'Continue', +101: 'Switching protocols', +102: 'Processing', +103: 'Early Hints', +200: 'OK', +201: 'Created', +202: 'Accepted', +203: 'Non-Authoritative Information', +204: 'No Content', +205: 'Reset Content', +206: 'Partial Content', +207: 'Multi-Status', +208: 'Already Reported', +226: 'IM Used', +300: 'Multiple Choices', +301: 'Moved Permanently', +302: 'Found', +303: 'See Other', +304: 'Not Modified', +305: 'Use Proxy', +306: 'Switch Proxy', +307: 'Temporary Redirect', +308: 'Permanent Redirect', +400: 'Bad Request', +401: 'Unauthorized', +402: 'Payment Required', +403: 'Forbidden', +404: 'Not Found', +405: 'Method Not Allowed', +406: 'Not Acceptable', +407: 'Proxy Authentication Required', +408: 'Request Timeout', +409: 'Conflict', +410: 'Gone', +411: 'Length Required', +412: 'Precondition Failed', +413: 'Payload Too Large', +414: 'URI Too Long', +415: 'Unsupported Media Type', +416: 'Range Not Satisfiable', +417: 'Expectation Failed', +418: "I'm a Teapot", +421: 'Misdirected Request', +422: 'Unprocessable Entity', +423: 'Locked', +424: 'Failed Dependency', +425: 'Too Early', +426: 'Upgrade Required', +428: 'Precondition Required', +429: 'Too Many Requests', +431: 'Request Header Fields Too Large', +451: 'Unavailable For Legal Reasons', +500: 'Internal Server Error', +501: 'Not Implemented', +502: 'Bad Gateway', +503: 'Service Unavailable', +504: 'Gateway Timeout', +505: 'HTTP Version Not Supported', +506: 'Variant Also Negotiates', +507: 'Insufficient Storage', +508: 'Loop Detected', +510: 'Not Extended', +511: 'Network Authentication Required', +} class HTTPError(Exception): """ diff --git a/src/view/wsgi.py b/src/view/wsgi.py new file mode 100644 index 00000000..84135c7b --- /dev/null +++ b/src/view/wsgi.py @@ -0,0 +1,55 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Callable, Iterable, TypeAlias, IO +from multidict import CIMultiDict +from view.request import Request, Method +from view.status_codes import STATUS_STRINGS +import asyncio + +if TYPE_CHECKING: + from view.app import BaseApp + +WSGIHeaders: TypeAlias = list[tuple[str, str]] +WSGIEnvironment: TypeAlias = dict[str, str | IO[bytes]] # XXX: Use a TypedDict? +WSGIStartResponse = Callable[[str, WSGIHeaders], Callable[[bytes], object]] +WSGIProtocol: TypeAlias = Callable[[WSGIEnvironment, WSGIStartResponse], Iterable[bytes]] + +def wsgi_for_app(app: BaseApp, /, loop: asyncio.AbstractEventLoop | None = None) -> WSGIProtocol: + loop = loop or asyncio.new_event_loop() + + def wsgi(environ: WSGIEnvironment, start_response: WSGIStartResponse) -> Iterable[bytes]: + method = Method(environ["REQUEST_METHOD"]) + headers = CIMultiDict() + + for key, value in environ.items(): + if not key.startswith("HTTP_"): + continue + + assert isinstance(value, str) + key = key.lstrip("HTTP_") + headers[key.replace("_", "-").lower()] = value + + async def stream(): + request_body: str | IO[bytes] = environ['wsgi.input'] + assert isinstance(request_body, IO) + length = 512 + + while length == 512: + data = await asyncio.to_thread(request_body.read, 512) + length = len(data) + yield data + + path = environ["PATH_INFO"] + assert isinstance(path, str) + request = Request(stream, app, path, method, headers) + response = loop.run_until_complete(app.process_request(request)) + + wsgi_headers: WSGIHeaders = [] + for key, value in response.headers.items(): + wsgi_headers.append((key, value)) + + # WSGI is such a weird spec + status_str = f"{response.status_code} {STATUS_STRINGS[response.status_code]}" + start_response(status_str, wsgi_headers) + return [loop.run_until_complete(response.body())] + + return wsgi From e018d69bd2fa0bb2924ac26cbc00cdd3e503e589 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 21:45:59 -0400 Subject: [PATCH 033/188] Run formatters. --- src/view/app.py | 2 +- src/view/status_codes.py | 127 ++++++++++++++++++++------------------- src/view/wsgi.py | 26 +++++--- 3 files changed, 83 insertions(+), 72 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index a7b13332..2200438d 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -15,8 +15,8 @@ if TYPE_CHECKING: from view.asgi import ASGIProtocol - from view.wsgi import WSGIProtocol from view.request import Method, Request + from view.wsgi import WSGIProtocol __all__ = "BaseApp", "as_app", "App" diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 499a5344..2fecfbca 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -4,71 +4,72 @@ STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} STATUS_STRINGS: dict[int, str] = { -100: 'Continue', -101: 'Switching protocols', -102: 'Processing', -103: 'Early Hints', -200: 'OK', -201: 'Created', -202: 'Accepted', -203: 'Non-Authoritative Information', -204: 'No Content', -205: 'Reset Content', -206: 'Partial Content', -207: 'Multi-Status', -208: 'Already Reported', -226: 'IM Used', -300: 'Multiple Choices', -301: 'Moved Permanently', -302: 'Found', -303: 'See Other', -304: 'Not Modified', -305: 'Use Proxy', -306: 'Switch Proxy', -307: 'Temporary Redirect', -308: 'Permanent Redirect', -400: 'Bad Request', -401: 'Unauthorized', -402: 'Payment Required', -403: 'Forbidden', -404: 'Not Found', -405: 'Method Not Allowed', -406: 'Not Acceptable', -407: 'Proxy Authentication Required', -408: 'Request Timeout', -409: 'Conflict', -410: 'Gone', -411: 'Length Required', -412: 'Precondition Failed', -413: 'Payload Too Large', -414: 'URI Too Long', -415: 'Unsupported Media Type', -416: 'Range Not Satisfiable', -417: 'Expectation Failed', -418: "I'm a Teapot", -421: 'Misdirected Request', -422: 'Unprocessable Entity', -423: 'Locked', -424: 'Failed Dependency', -425: 'Too Early', -426: 'Upgrade Required', -428: 'Precondition Required', -429: 'Too Many Requests', -431: 'Request Header Fields Too Large', -451: 'Unavailable For Legal Reasons', -500: 'Internal Server Error', -501: 'Not Implemented', -502: 'Bad Gateway', -503: 'Service Unavailable', -504: 'Gateway Timeout', -505: 'HTTP Version Not Supported', -506: 'Variant Also Negotiates', -507: 'Insufficient Storage', -508: 'Loop Detected', -510: 'Not Extended', -511: 'Network Authentication Required', + 100: "Continue", + 101: "Switching protocols", + 102: "Processing", + 103: "Early Hints", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 306: "Switch Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a Teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", } + class HTTPError(Exception): """ Base class for all HTTP errors. diff --git a/src/view/wsgi.py b/src/view/wsgi.py index 84135c7b..4235439d 100644 --- a/src/view/wsgi.py +++ b/src/view/wsgi.py @@ -1,9 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Iterable, TypeAlias, IO + +import asyncio +from typing import IO, TYPE_CHECKING, Callable, Iterable, TypeAlias + from multidict import CIMultiDict -from view.request import Request, Method + +from view.request import Method, Request from view.status_codes import STATUS_STRINGS -import asyncio if TYPE_CHECKING: from view.app import BaseApp @@ -11,12 +14,19 @@ WSGIHeaders: TypeAlias = list[tuple[str, str]] WSGIEnvironment: TypeAlias = dict[str, str | IO[bytes]] # XXX: Use a TypedDict? WSGIStartResponse = Callable[[str, WSGIHeaders], Callable[[bytes], object]] -WSGIProtocol: TypeAlias = Callable[[WSGIEnvironment, WSGIStartResponse], Iterable[bytes]] +WSGIProtocol: TypeAlias = Callable[ + [WSGIEnvironment, WSGIStartResponse], Iterable[bytes] +] + -def wsgi_for_app(app: BaseApp, /, loop: asyncio.AbstractEventLoop | None = None) -> WSGIProtocol: +def wsgi_for_app( + app: BaseApp, /, loop: asyncio.AbstractEventLoop | None = None +) -> WSGIProtocol: loop = loop or asyncio.new_event_loop() - def wsgi(environ: WSGIEnvironment, start_response: WSGIStartResponse) -> Iterable[bytes]: + def wsgi( + environ: WSGIEnvironment, start_response: WSGIStartResponse + ) -> Iterable[bytes]: method = Method(environ["REQUEST_METHOD"]) headers = CIMultiDict() @@ -29,7 +39,7 @@ def wsgi(environ: WSGIEnvironment, start_response: WSGIStartResponse) -> Iterabl headers[key.replace("_", "-").lower()] = value async def stream(): - request_body: str | IO[bytes] = environ['wsgi.input'] + request_body: str | IO[bytes] = environ["wsgi.input"] assert isinstance(request_body, IO) length = 512 @@ -42,7 +52,7 @@ async def stream(): assert isinstance(path, str) request = Request(stream, app, path, method, headers) response = loop.run_until_complete(app.process_request(request)) - + wsgi_headers: WSGIHeaders = [] for key, value in response.headers.items(): wsgi_headers.append((key, value)) From 9b452c94a247afa947ffe166f4a08c4071d53968 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 21:47:25 -0400 Subject: [PATCH 034/188] Add descriptions to error messages. --- src/view/router.py | 2 +- src/view/status_codes.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/view/router.py b/src/view/router.py index 857d24a5..847f1bbc 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -75,6 +75,6 @@ def default_error(self, error: type[HTTPError]) -> RouteView: """ def inner(): - return f"Error {error.status_code}" + return f"{error.status_code} {error.description}" return inner diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 2fecfbca..308dbba6 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -79,9 +79,11 @@ class HTTPError(Exception): """ status_code: ClassVar[int] = 0 + description: ClassVar[str] = "" def __init_subclass__(cls) -> None: STATUS_EXCEPTIONS[cls.status_code] = cls + cls.description = STATUS_STRINGS[cls.status_code] def status_exception(status: int) -> type[HTTPError]: From f524fac4fb6dcf3426035e1989f4ae652b03a06a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 22:14:51 -0400 Subject: [PATCH 035/188] Handle errors in single-view apps. --- src/view/app.py | 73 ++++++++++++++++++++++++---------------- src/view/router.py | 16 ++------- src/view/status_codes.py | 23 ++++++++++--- 3 files changed, 64 insertions(+), 48 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 2200438d..5f395753 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -4,8 +4,8 @@ import contextvars from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import (TYPE_CHECKING, Callable, Iterator, Literal, TypeAlias, - TypeVar, overload) +from typing import (TYPE_CHECKING, Callable, Iterator, Literal, ParamSpec, + TypeAlias, TypeVar, Union, overload) from loguru import logger @@ -81,7 +81,30 @@ def asgi(self) -> ASGIProtocol: def run(self): ... -SingleView = Callable[["Request"], "ResponseLike"] +P = ParamSpec("P") + + +async def execute_view( + view: Callable[P, ResponseLike | Awaitable[ResponseLike]], + *args: P.args, + **kwargs: P.kwargs, +) -> ResponseLike: + logger.debug(f"Executing view: {view}") + try: + result = view(*args, **kwargs) + if isinstance(result, Awaitable): + result = await result + + return result + except HTTPError as error: + logger.info(f"HTTP Error {error.status_code}") + raise + except BaseException as exception: + logger.exception(exception) + raise InternalServerError() from exception + + +SingleView = Callable[["Request"], Union["ResponseLike", Awaitable["ResponseLike"]]] class SingleViewApp(BaseApp): @@ -96,7 +119,11 @@ def __init__(self, view: SingleView) -> None: async def process_request(self, request: Request) -> Response: with self.request_context(request): - return wrap_response(self.view(request)) + try: + response = await execute_view(self.view, request) + return wrap_response(response) + except HTTPError as error: + return error.as_response() def as_app(view: SingleView, /) -> SingleViewApp: @@ -116,37 +143,25 @@ def __init__(self, *, router: Router | None = None) -> None: super().__init__() self.router = router or Router() - async def _execute_view(self, view: RouteView) -> ResponseLike: - logger.debug(f"Executing view: {view}") - try: - result = view() - if isinstance(result, Awaitable): - result = await result - - return result - except HTTPError as error: - logger.info(f"HTTP Error {error.status_code}") - http_error = type(error) - view = self.router.lookup_error(http_error) - return await self._execute_view(view) - except BaseException as exception: - logger.exception(exception) - view = self.router.lookup_error(InternalServerError) - return await self._execute_view(view) - - async def process_request(self, request: Request) -> Response: + async def _process_request_internal(self, request: Request) -> Response: logger.info(f"{request.method} {request.path}") route: Route | None = self.router.lookup_route(request.path) - view: RouteView if route is None: - view = self.router.lookup_error(NotFound) - else: - view = route.view + raise NotFound() + + response = await execute_view(route.view) + return wrap_response(response) + async def process_request(self, request: Request) -> Response: with self.request_context(request): - response = await self._execute_view(view) + try: + return await self._process_request_internal(request) + 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 wrap_response(response) + return error.as_response() def route(self, path: str, /, *, method: Method) -> RouteDecorator: """ diff --git a/src/view/router.py b/src/view/router.py index 847f1bbc..36229fcf 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -61,20 +61,8 @@ def lookup_route(self, path: str, /) -> Route | None: """ return self.route_views.get(path) - def lookup_error(self, error: type[HTTPError], /) -> RouteView: + def lookup_error(self, error: type[HTTPError], /) -> RouteView | None: """ Look up the error view for the given HTTP error. - - If no custom handler is set, this returns a default error view. - """ - return self.error_views.get(error) or self.default_error(error) - - def default_error(self, error: type[HTTPError]) -> RouteView: """ - Get the default error view for a given HTTP error. - """ - - def inner(): - return f"{error.status_code} {error.description}" - - return inner + return self.error_views.get(error) diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 308dbba6..60963f2f 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -2,6 +2,8 @@ from typing import ClassVar +from view.response import BytesResponse + STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} STATUS_STRINGS: dict[int, str] = { 100: "Continue", @@ -81,9 +83,20 @@ class HTTPError(Exception): status_code: ClassVar[int] = 0 description: ClassVar[str] = "" - def __init_subclass__(cls) -> None: - STATUS_EXCEPTIONS[cls.status_code] = cls - cls.description = STATUS_STRINGS[cls.status_code] + 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] + + @classmethod + def as_response(cls) -> BytesResponse: + if cls.status_code == 0: + raise TypeError(f"{cls} is not a real response") + message = f"{cls.status_code} {cls.description}" + return BytesResponse.from_bytes( + message.encode("utf-8"), status_code=cls.status_code + ) def status_exception(status: int) -> type[HTTPError]: @@ -98,13 +111,13 @@ def status_exception(status: int) -> type[HTTPError]: return status_type -class ClientSideError(HTTPError): +class ClientSideError(HTTPError, ignore=True): """ Base class for all HTTP errors between 400 and 500. """ -class ServerSideError(HTTPError): +class ServerSideError(HTTPError, ignore=True): """ Base class for all HTTP errors between 500 and 600. """ From e7b3df747d1837283bc51f485002455eaadb89f9 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sun, 22 Jun 2025 22:49:20 -0400 Subject: [PATCH 036/188] Improve some testing APIs. --- src/view/testing.py | 103 +++++++++++++++++++++++++++++++--------- tests/test_requests.py | 31 ++++++++++++ tests/test_responses.py | 14 ++---- 3 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 tests/test_requests.py diff --git a/src/view/testing.py b/src/view/testing.py index ff612d6c..228f461c 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, AsyncGenerator +from typing import TYPE_CHECKING, AsyncGenerator, Awaitable from multidict import CIMultiDict @@ -13,6 +13,18 @@ __all__ = ("AppTestClient",) +async def into_tuple( + response_coro: Awaitable[Response], / +) -> tuple[bytes, int, CIMultiDict]: + """ + Convenience function for transferring a test client call into a tuple + through a single ``await``. + """ + response = await response_coro + body = await response.body() + return (body, response.status_code, response.headers) + + class AppTestClient: """ Client to test an app. @@ -30,12 +42,13 @@ async def request( *, method: Method, headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - async def stream_none() -> AsyncGenerator[bytes]: - yield b"" + async def stream() -> AsyncGenerator[bytes]: + yield body or b"" request_data = Request( - receive_data=stream_none, + receive_data=stream, app=self.app, path=route, method=method, @@ -44,46 +57,92 @@ async def stream_none() -> AsyncGenerator[bytes]: return await self.app.process_request(request_data) async def get( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.GET, headers=headers) + return await self.request(route, method=Method.GET, headers=headers, body=body) async def post( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.POST, headers=headers) + return await self.request(route, method=Method.POST, headers=headers, body=body) async def put( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.PUT, headers=headers) + return await self.request(route, method=Method.PUT, headers=headers, body=body) async def patch( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.PATCH, headers=headers) + return await self.request( + route, method=Method.PATCH, headers=headers, body=body + ) async def delete( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.DELETE, headers=headers) + return await self.request( + route, method=Method.DELETE, headers=headers, body=body + ) async def connect( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.CONNECT, headers=headers) + return await self.request( + route, method=Method.CONNECT, headers=headers, body=body + ) async def options( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.OPTIONS, headers=headers) + return await self.request( + route, method=Method.OPTIONS, headers=headers, body=body + ) async def trace( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.TRACE, headers=headers) + return await self.request( + route, method=Method.TRACE, headers=headers, body=body + ) async def head( - self, route: str, *, headers: dict[str, str] | None = None + self, + route: str, + *, + headers: dict[str, str] | None = None, + body: bytes | None = None, ) -> Response: - return await self.request(route, method=Method.HEAD, headers=headers) + return await self.request(route, method=Method.HEAD, headers=headers, body=body) diff --git a/tests/test_requests.py b/tests/test_requests.py new file mode 100644 index 00000000..6c18a8fb --- /dev/null +++ b/tests/test_requests.py @@ -0,0 +1,31 @@ +import pytest + +from view.app import Request, as_app +from view.response import ResponseLike +from view.router import Method +from view.testing import AppTestClient, into_tuple + + +@pytest.mark.asyncio +async def test_request_data(): + @as_app + def app(request: Request) -> ResponseLike: + assert request.app == app + assert request.app.current_request() is request + assert isinstance(request.path, str) + assert request.method is Method.GET + + if request.path == "/": + assert request.headers == {} + return "Hello" + elif request.path == "/1": + assert request.headers == {"test-something": "42"} + return "World" + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"Hello", 200, {}) + assert (await into_tuple(client.get("/1", headers={"test-something": "42"}))) == ( + b"World", + 200, + {}, + ) diff --git a/tests/test_responses.py b/tests/test_responses.py index 4dc063c1..7f051ec8 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -2,8 +2,7 @@ from view.app import Request, as_app from view.response import ResponseLike -from view.router import Method -from view.testing import AppTestClient +from view.testing import AppTestClient, into_tuple @pytest.mark.asyncio @@ -13,11 +12,6 @@ class MyString(str): @as_app def app(request: Request) -> ResponseLike: - assert request.app == app - assert request.app.current_request() is request - assert isinstance(request.path, str) - assert request.method is Method.GET - if request.path == "/": return "Hello" elif request.path == "/bytes": @@ -28,6 +22,6 @@ def app(request: Request) -> ResponseLike: raise RuntimeError() client = AppTestClient(app) - assert (await client.get("/")).as_tuple() == (b"Hello", 200, {}) - assert (await client.get("/bytes")).as_tuple() == (b"World", 200, {}) - assert (await client.get("/my-string")).as_tuple() == (b"My string", 200, {}) + assert (await into_tuple(client.get("/"))) == (b"Hello", 200, {}) + assert (await into_tuple(client.get("/bytes"))) == (b"World", 200, {}) + assert (await into_tuple(client.get("/my-string"))) == (b"My string", 200, {}) From 16892d83c5e90612691d3700540da48e06b12f5c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 09:11:06 -0400 Subject: [PATCH 037/188] Some general fixups. --- src/view/asgi.py | 6 +++--- src/view/body.py | 10 +++++++++ src/view/request.py | 5 +++-- src/view/response.py | 51 ++++++++++++++++++++++++++++++++------------ src/view/wsgi.py | 12 +++++++---- 5 files changed, 61 insertions(+), 23 deletions(-) diff --git a/src/view/asgi.py b/src/view/asgi.py index 4fc19d0f..225f8712 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -6,7 +6,7 @@ from multidict import CIMultiDict from view.app import BaseApp -from view.request import Method, Request +from view.request import Method, Request, RequestHeaders __all__ = ("asgi_for_app",) @@ -63,7 +63,7 @@ class ASGIHttpSendBody(ASGIBodyMixin, TypedDict): ] -def headers_as_multidict(headers: ASGIHeaders, /) -> CIMultiDict: +def headers_as_multidict(headers: ASGIHeaders, /) -> RequestHeaders: """ Convert ASGI headers to a case-insensitive multidict. """ @@ -75,7 +75,7 @@ def headers_as_multidict(headers: ASGIHeaders, /) -> CIMultiDict: return multidict -def multidict_as_headers(headers: CIMultiDict, /) -> ASGIHeaders: +def multidict_as_headers(headers: RequestHeaders, /) -> ASGIHeaders: """ Convert a case-insensitive multidict to an ASGI header iterable. """ diff --git a/src/view/body.py b/src/view/body.py index 0d7870ee..1cf5a72e 100644 --- a/src/view/body.py +++ b/src/view/body.py @@ -11,10 +11,16 @@ @dataclass(slots=True) class BodyMixin: + """ + Mixin dataclass for common HTTP body operations. + """ receive_data: BodyStream consumed: bool = field(init=False, default=False) async def body(self) -> bytes: + """ + Read the full body from the stream. + """ if self.consumed: raise RuntimeError("body has already been consumed") @@ -27,6 +33,10 @@ async def body(self) -> bytes: return buffer.getvalue() async def stream_body(self) -> AsyncGenerator[bytes]: + """ + Incrementally stream the body, not keeping the whole thing + in-memory at a given time. + """ if self.consumed: raise RuntimeError("body has already been consumed") diff --git a/src/view/request.py b/src/view/request.py index b8783fbe..77459b8d 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import StrEnum, auto -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias from multidict import CIMultiDict @@ -11,6 +11,7 @@ __all__ = "Method", "Request" +RequestHeaders: TypeAlias = CIMultiDict[str] class _UpperStrEnum(StrEnum): @staticmethod @@ -45,4 +46,4 @@ class Request(BodyMixin): app: "BaseApp" path: str method: Method - headers: CIMultiDict + headers: RequestHeaders diff --git a/src/view/response.py b/src/view/response.py index 9856da92..153d31fa 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -7,8 +7,9 @@ import aiofiles from loguru import logger from multidict import CIMultiDict - +import mimetypes from view.body import BodyMixin +from view.request import RequestHeaders __all__ = "Response", "ResponseLike" @@ -20,9 +21,9 @@ class Response(BodyMixin): """ status_code: int - headers: CIMultiDict + headers: CIMultiDict[str] - async def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: + async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: """ Process the response as a tuple. This is mainly useful for assertions in testing. @@ -30,11 +31,15 @@ async def as_tuple(self) -> tuple[bytes, int, CIMultiDict]: return (await self.body(), self.status_code, self.headers) -HeadersLike = CIMultiDict | dict[str, str] | dict[bytes, bytes] -StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes] +HeadersLike = RequestHeaders | dict[str, str] | dict[bytes, bytes] +StrPath: TypeAlias = str | PathLike[str] -def as_multidict(headers: HeadersLike | None, /) -> CIMultiDict: +def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: + """ + Convenience function for casting a "header-like object" (or `None`) + to a `CIMultiDict`. + """ if headers is None: return CIMultiDict() @@ -60,27 +65,39 @@ def as_multidict(headers: HeadersLike | None, /) -> CIMultiDict: @dataclass(slots=True) class FileResponse(Response): - path: StrOrBytesPath + """ + Response containing a file, streamed asynchronously. + """ + path: StrPath @classmethod def from_file( cls, - path: StrOrBytesPath, + path: StrPath, /, *, status_code: int = 200, headers: HeadersLike | None = None, - increment: int = 512, + chunk_size: int = 512, + content_type: str | None = None, ) -> FileResponse: + """ + Generate a `FileResponse` from a file path. + """ async def stream(): async with aiofiles.open(path, "rb") as file: - length = increment - while length == increment: - data = await file.read(increment) + length = chunk_size + while length == chunk_size: + data = await file.read(chunk_size) length = len(data) yield data - return cls(stream, status_code, as_multidict(headers), path) + multidict = as_multidict(headers) + if "content-type" not in multidict: + content_type = content_type or mimetypes.guess_file_type(path)[0] or "text/plain" + multidict["content-type"] = content_type + + return cls(stream, status_code, multidict, path) @dataclass(slots=True) @@ -91,7 +108,10 @@ class BytesResponse(Response): content: bytes - def as_tuple_sync(self) -> tuple[bytes, int, CIMultiDict]: + def as_tuple_sync(self) -> tuple[bytes, int, RequestHeaders]: + """ + Synchronous variation of `as_tuple`, using the cached content. + """ return (self.content, self.status_code, self.headers) @classmethod @@ -103,6 +123,9 @@ def from_bytes( status_code: int = 200, headers: HeadersLike | None = None, ) -> BytesResponse: + """ + Generate a `BytesResponse` from a `bytes` object. + """ async def stream() -> AsyncGenerator[bytes]: yield content diff --git a/src/view/wsgi.py b/src/view/wsgi.py index 4235439d..13b40c92 100644 --- a/src/view/wsgi.py +++ b/src/view/wsgi.py @@ -20,8 +20,12 @@ def wsgi_for_app( - app: BaseApp, /, loop: asyncio.AbstractEventLoop | None = None + app: BaseApp, /, loop: asyncio.AbstractEventLoop | None = None, chunk_size: int = 512 ) -> WSGIProtocol: + """ + Generate a WSGI-compliant callable for a given app, allowing + it to be executed in an ASGI server. + """ loop = loop or asyncio.new_event_loop() def wsgi( @@ -41,10 +45,10 @@ def wsgi( async def stream(): request_body: str | IO[bytes] = environ["wsgi.input"] assert isinstance(request_body, IO) - length = 512 + length = chunk_size - while length == 512: - data = await asyncio.to_thread(request_body.read, 512) + while length == chunk_size: + data = await asyncio.to_thread(request_body.read, chunk_size) length = len(data) yield data From 585d7cb32d4514706355d31a68579d0f339146e6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 09:11:26 -0400 Subject: [PATCH 038/188] Run formatter. --- src/view/body.py | 1 + src/view/request.py | 1 + src/view/response.py | 10 ++++++++-- src/view/wsgi.py | 5 ++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/view/body.py b/src/view/body.py index 1cf5a72e..b8953c7c 100644 --- a/src/view/body.py +++ b/src/view/body.py @@ -14,6 +14,7 @@ class BodyMixin: """ Mixin dataclass for common HTTP body operations. """ + receive_data: BodyStream consumed: bool = field(init=False, default=False) diff --git a/src/view/request.py b/src/view/request.py index 77459b8d..8409cc43 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -13,6 +13,7 @@ RequestHeaders: TypeAlias = CIMultiDict[str] + class _UpperStrEnum(StrEnum): @staticmethod def _generate_next_value_( diff --git a/src/view/response.py b/src/view/response.py index 153d31fa..37b34648 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,5 +1,6 @@ from __future__ import annotations +import mimetypes from dataclasses import dataclass from os import PathLike from typing import AnyStr, AsyncGenerator, TypeAlias @@ -7,7 +8,7 @@ import aiofiles from loguru import logger from multidict import CIMultiDict -import mimetypes + from view.body import BodyMixin from view.request import RequestHeaders @@ -68,6 +69,7 @@ class FileResponse(Response): """ Response containing a file, streamed asynchronously. """ + path: StrPath @classmethod @@ -84,6 +86,7 @@ def from_file( """ Generate a `FileResponse` from a file path. """ + async def stream(): async with aiofiles.open(path, "rb") as file: length = chunk_size @@ -94,7 +97,9 @@ async def stream(): multidict = as_multidict(headers) if "content-type" not in multidict: - content_type = content_type or mimetypes.guess_file_type(path)[0] or "text/plain" + content_type = ( + content_type or mimetypes.guess_file_type(path)[0] or "text/plain" + ) multidict["content-type"] = content_type return cls(stream, status_code, multidict, path) @@ -126,6 +131,7 @@ def from_bytes( """ Generate a `BytesResponse` from a `bytes` object. """ + async def stream() -> AsyncGenerator[bytes]: yield content diff --git a/src/view/wsgi.py b/src/view/wsgi.py index 13b40c92..e86b39e7 100644 --- a/src/view/wsgi.py +++ b/src/view/wsgi.py @@ -20,7 +20,10 @@ def wsgi_for_app( - app: BaseApp, /, loop: asyncio.AbstractEventLoop | None = None, chunk_size: int = 512 + app: BaseApp, + /, + loop: asyncio.AbstractEventLoop | None = None, + chunk_size: int = 512, ) -> WSGIProtocol: """ Generate a WSGI-compliant callable for a given app, allowing From 1f0d13395744cc034af53a1f7dfded28d814d44d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 09:37:14 -0400 Subject: [PATCH 039/188] Add some new tests. --- src/view/request.py | 29 +++++++++++++++++++++++++ src/view/response.py | 31 +-------------------------- src/view/testing.py | 24 ++++++++++----------- tests/test_requests.py | 47 +++++++++++++++++++++++++++++++++++++++-- tests/test_responses.py | 3 ++- 5 files changed, 89 insertions(+), 45 deletions(-) diff --git a/src/view/request.py b/src/view/request.py index 8409cc43..5504ed0f 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -12,6 +12,35 @@ __all__ = "Method", "Request" RequestHeaders: TypeAlias = CIMultiDict[str] +HeadersLike = RequestHeaders | dict[str, str] | dict[bytes, bytes] + + +def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: + """ + Convenience function for casting a "header-like object" (or `None`) + to a `CIMultiDict`. + """ + if headers is None: + return CIMultiDict() + + if isinstance(headers, CIMultiDict): + return headers + + if not isinstance(headers, dict): + raise TypeError(f"Invalid headers: {headers}") + + assert isinstance(headers, dict) + multidict = CIMultiDict() + for key, value in headers.items(): + if isinstance(key, bytes): + key = key.decode("utf-8") + + if isinstance(value, bytes): + value = value.decode("utf-8") + + multidict[key] = value + + return multidict class _UpperStrEnum(StrEnum): diff --git a/src/view/response.py b/src/view/response.py index 37b34648..2f1166fd 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -10,7 +10,7 @@ from multidict import CIMultiDict from view.body import BodyMixin -from view.request import RequestHeaders +from view.request import HeadersLike, RequestHeaders, as_multidict __all__ = "Response", "ResponseLike" @@ -32,38 +32,9 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: return (await self.body(), self.status_code, self.headers) -HeadersLike = RequestHeaders | dict[str, str] | dict[bytes, bytes] StrPath: TypeAlias = str | PathLike[str] -def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: - """ - Convenience function for casting a "header-like object" (or `None`) - to a `CIMultiDict`. - """ - if headers is None: - return CIMultiDict() - - if isinstance(headers, CIMultiDict): - return headers - - if not isinstance(headers, dict): - raise TypeError(f"Invalid headers: {headers}") - - assert isinstance(headers, dict) - multidict = CIMultiDict() - for key, value in headers.items(): - if isinstance(key, bytes): - key = key.decode("utf-8") - - if isinstance(value, bytes): - value = value.decode("utf-8") - - multidict[key] = value - - return multidict - - @dataclass(slots=True) class FileResponse(Response): """ diff --git a/src/view/testing.py b/src/view/testing.py index 228f461c..7d5929e0 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -4,7 +4,7 @@ from multidict import CIMultiDict -from view.request import Method, Request +from view.request import HeadersLike, Method, Request, as_multidict if TYPE_CHECKING: from view.app import BaseApp @@ -41,7 +41,7 @@ async def request( route: str, *, method: Method, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: async def stream() -> AsyncGenerator[bytes]: @@ -52,7 +52,7 @@ async def stream() -> AsyncGenerator[bytes]: app=self.app, path=route, method=method, - headers=CIMultiDict(headers or {}), + headers=as_multidict(headers), ) return await self.app.process_request(request_data) @@ -60,7 +60,7 @@ async def get( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request(route, method=Method.GET, headers=headers, body=body) @@ -69,7 +69,7 @@ async def post( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request(route, method=Method.POST, headers=headers, body=body) @@ -78,7 +78,7 @@ async def put( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request(route, method=Method.PUT, headers=headers, body=body) @@ -87,7 +87,7 @@ async def patch( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request( @@ -98,7 +98,7 @@ async def delete( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request( @@ -109,7 +109,7 @@ async def connect( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request( @@ -120,7 +120,7 @@ async def options( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request( @@ -131,7 +131,7 @@ async def trace( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request( @@ -142,7 +142,7 @@ async def head( self, route: str, *, - headers: dict[str, str] | None = None, + headers: HeadersLike | None = None, body: bytes | None = None, ) -> Response: return await self.request(route, method=Method.HEAD, headers=headers, body=body) diff --git a/tests/test_requests.py b/tests/test_requests.py index 6c18a8fb..528960dc 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,8 +1,9 @@ import pytest -from view.app import Request, as_app +from view.app import as_app from view.response import ResponseLike -from view.router import Method +from view.status_codes import BadRequest +from view.request import Request, Method from view.testing import AppTestClient, into_tuple @@ -21,6 +22,8 @@ def app(request: Request) -> ResponseLike: elif request.path == "/1": assert request.headers == {"test-something": "42"} return "World" + else: + raise BadRequest() client = AppTestClient(app) assert (await into_tuple(client.get("/"))) == (b"Hello", 200, {}) @@ -29,3 +32,43 @@ def app(request: Request) -> ResponseLike: 200, {}, ) + +@pytest.mark.asyncio +async def test_request_body(): + @as_app + async def app(request: Request) -> ResponseLike: + body = await request.body() + if request.path == "/": + assert body == b"test" + return "1" + elif request.path == "/large": + assert body == b"A" * 10000 + return "2" + else: + raise BadRequest() + + client = AppTestClient(app) + assert (await into_tuple(client.get("/", body=b"test"))) == (b"1", 200, {}) + assert (await into_tuple(client.get("/large", body=b"A" * 10000))) == (b"2", 200, {}) + + +@pytest.mark.asyncio +async def test_request_headers(): + @as_app + async def app(request: Request) -> ResponseLike: + if request.path == "/": + assert request.headers["foo"] == "42" + return "1" + elif request.path == "/many": + assert request.headers["Bar"] == "42" + assert request.headers["bar"] == "42" + assert request.headers["baR"] == "42" + assert request.headers["test"] == "123" + return "2" + else: + raise BadRequest() + + client = AppTestClient(app) + assert (await into_tuple(client.get("/", headers={"foo": "42"}))) == (b"1", 200, {}) + assert (await into_tuple(client.get("/many", headers={"Bar": "24", "bAr": "42", "test": "123"}))) == (b"2", 200, {}) + diff --git a/tests/test_responses.py b/tests/test_responses.py index 7f051ec8..2f402ec2 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,8 +1,9 @@ import pytest -from view.app import Request, as_app +from view.app import as_app from view.response import ResponseLike from view.testing import AppTestClient, into_tuple +from view.request import Request @pytest.mark.asyncio From efed32f4ace61e3f711a8088e7b9f27a73beeb8b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 09:46:58 -0400 Subject: [PATCH 040/188] Move headers to their own file. --- src/view/asgi.py | 33 +++---------------- src/view/headers.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ src/view/request.py | 36 ++------------------ src/view/response.py | 2 +- src/view/testing.py | 3 +- src/view/wsgi.py | 13 ++------ 6 files changed, 89 insertions(+), 76 deletions(-) create mode 100644 src/view/headers.py diff --git a/src/view/asgi.py b/src/view/asgi.py index 225f8712..feb0a989 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -3,10 +3,9 @@ from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, NotRequired, TypeAlias, TypedDict) -from multidict import CIMultiDict - from view.app import BaseApp -from view.request import Method, Request, RequestHeaders +from view.headers import asgi_as_multidict, multidict_as_asgi +from view.request import Method, Request __all__ = ("asgi_for_app",) @@ -63,30 +62,6 @@ class ASGIHttpSendBody(ASGIBodyMixin, TypedDict): ] -def headers_as_multidict(headers: ASGIHeaders, /) -> RequestHeaders: - """ - Convert ASGI headers to a case-insensitive multidict. - """ - multidict = CIMultiDict() - - for key, value in headers: - multidict[key.decode("utf-8")] = value.decode("utf-8") - - return multidict - - -def multidict_as_headers(headers: RequestHeaders, /) -> ASGIHeaders: - """ - Convert a case-insensitive multidict to an ASGI header iterable. - """ - asgi_headers: ASGIHeaders = [] - - for key, value in headers: - asgi_headers.append((key.encode("utf-8"), value.encode("utf-8"))) - - return asgi_headers - - def asgi_for_app(app: BaseApp, /) -> ASGIProtocol: """ Generate an ASGI-compliant callable for a given app, allowing @@ -98,7 +73,7 @@ async def asgi( ) -> None: assert scope["type"] == "http" method = Method(scope["method"]) - headers = headers_as_multidict(scope["headers"]) + headers = asgi_as_multidict(scope["headers"]) async def receive_data() -> AsyncIterator[bytes]: more_body = True @@ -115,7 +90,7 @@ async def receive_data() -> AsyncIterator[bytes]: { "type": "http.response.start", "status": response.status_code, - "headers": multidict_as_headers(response.headers), + "headers": multidict_as_asgi(response.headers), } ) async for data in response.stream_body(): diff --git a/src/view/headers.py b/src/view/headers.py new file mode 100644 index 00000000..ad03ac5c --- /dev/null +++ b/src/view/headers.py @@ -0,0 +1,78 @@ +from typing import TYPE_CHECKING, Any, TypeAlias + +from multidict import CIMultiDict + +if TYPE_CHECKING: + from view.asgi import ASGIHeaders + +RequestHeaders: TypeAlias = CIMultiDict[str] +HeadersLike = RequestHeaders | dict[str, str] | dict[bytes, bytes] + + +def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: + """ + Convenience function for casting a "header-like object" (or `None`) + to a `CIMultiDict`. + """ + if headers is None: + return CIMultiDict() + + if isinstance(headers, CIMultiDict): + return headers + + if not isinstance(headers, dict): + raise TypeError(f"Invalid headers: {headers}") + + assert isinstance(headers, dict) + multidict = CIMultiDict() + for key, value in headers.items(): + if isinstance(key, bytes): + key = key.decode("utf-8") + + if isinstance(value, bytes): + value = value.decode("utf-8") + + multidict[key] = value + + return multidict + + +def wsgi_as_multidict(environ: dict[str, Any]) -> RequestHeaders: + """ + Convert WSGI headers (from the `environ`) to a case-insensitive multidict. + """ + headers = CIMultiDict() + + for key, value in environ.items(): + if not key.startswith("HTTP_"): + continue + + assert isinstance(value, str) + key = key.lstrip("HTTP_") + headers[key.replace("_", "-").lower()] = value + + return headers + + +def asgi_as_multidict(headers: ASGIHeaders, /) -> RequestHeaders: + """ + Convert ASGI headers to a case-insensitive multidict. + """ + multidict = CIMultiDict() + + for key, value in headers: + multidict[key.decode("utf-8")] = value.decode("utf-8") + + return multidict + + +def multidict_as_asgi(headers: RequestHeaders, /) -> ASGIHeaders: + """ + Convert a case-insensitive multidict to an ASGI header iterable. + """ + asgi_headers: ASGIHeaders = [] + + for key, value in headers: + asgi_headers.append((key.encode("utf-8"), value.encode("utf-8"))) + + return asgi_headers diff --git a/src/view/request.py b/src/view/request.py index 5504ed0f..9212da2b 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,47 +1,15 @@ from dataclasses import dataclass from enum import StrEnum, auto -from typing import TYPE_CHECKING, TypeAlias - -from multidict import CIMultiDict +from typing import TYPE_CHECKING from view.body import BodyMixin +from view.headers import RequestHeaders if TYPE_CHECKING: from view.app import BaseApp __all__ = "Method", "Request" -RequestHeaders: TypeAlias = CIMultiDict[str] -HeadersLike = RequestHeaders | dict[str, str] | dict[bytes, bytes] - - -def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: - """ - Convenience function for casting a "header-like object" (or `None`) - to a `CIMultiDict`. - """ - if headers is None: - return CIMultiDict() - - if isinstance(headers, CIMultiDict): - return headers - - if not isinstance(headers, dict): - raise TypeError(f"Invalid headers: {headers}") - - assert isinstance(headers, dict) - multidict = CIMultiDict() - for key, value in headers.items(): - if isinstance(key, bytes): - key = key.decode("utf-8") - - if isinstance(value, bytes): - value = value.decode("utf-8") - - multidict[key] = value - - return multidict - class _UpperStrEnum(StrEnum): @staticmethod diff --git a/src/view/response.py b/src/view/response.py index 2f1166fd..a3df2015 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -10,7 +10,7 @@ from multidict import CIMultiDict from view.body import BodyMixin -from view.request import HeadersLike, RequestHeaders, as_multidict +from view.headers import HeadersLike, RequestHeaders, as_multidict __all__ = "Response", "ResponseLike" diff --git a/src/view/testing.py b/src/view/testing.py index 7d5929e0..dbc5e1fa 100644 --- a/src/view/testing.py +++ b/src/view/testing.py @@ -4,7 +4,8 @@ from multidict import CIMultiDict -from view.request import HeadersLike, Method, Request, as_multidict +from view.headers import HeadersLike, as_multidict +from view.request import Method, Request if TYPE_CHECKING: from view.app import BaseApp diff --git a/src/view/wsgi.py b/src/view/wsgi.py index e86b39e7..5f0616b3 100644 --- a/src/view/wsgi.py +++ b/src/view/wsgi.py @@ -3,8 +3,7 @@ import asyncio from typing import IO, TYPE_CHECKING, Callable, Iterable, TypeAlias -from multidict import CIMultiDict - +from view.headers import wsgi_as_multidict from view.request import Method, Request from view.status_codes import STATUS_STRINGS @@ -35,15 +34,6 @@ def wsgi( environ: WSGIEnvironment, start_response: WSGIStartResponse ) -> Iterable[bytes]: method = Method(environ["REQUEST_METHOD"]) - headers = CIMultiDict() - - for key, value in environ.items(): - if not key.startswith("HTTP_"): - continue - - assert isinstance(value, str) - key = key.lstrip("HTTP_") - headers[key.replace("_", "-").lower()] = value async def stream(): request_body: str | IO[bytes] = environ["wsgi.input"] @@ -57,6 +47,7 @@ async def stream(): path = environ["PATH_INFO"] assert isinstance(path, str) + headers = wsgi_as_multidict(environ) request = Request(stream, app, path, method, headers) response = loop.run_until_complete(app.process_request(request)) From 0a44fa2f2685168f99e88e35c9e1af82806976be Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 11:02:08 -0400 Subject: [PATCH 041/188] Rough implementation of 'magic' server running. --- sample.py | 2 +- src/view/app.py | 42 +++++++++++++++- src/view/headers.py | 2 + src/view/run.py | 106 ++++++++++++++++++++++++++++++++++++++++ tests/test_requests.py | 16 ++++-- tests/test_responses.py | 2 +- 6 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 src/view/run.py diff --git a/sample.py b/sample.py index 6ec28d98..26e680d6 100644 --- a/sample.py +++ b/sample.py @@ -9,4 +9,4 @@ def index(): return HTML.from_file("index/test.html") if __name__ == "__main__": - app.run() + app.run(production=True) diff --git a/src/view/app.py b/src/view/app.py index 5f395753..89a75b5c 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -2,6 +2,7 @@ import contextlib import contextvars +import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable from typing import (TYPE_CHECKING, Callable, Iterator, Literal, ParamSpec, @@ -11,6 +12,7 @@ from view.response import Response, ResponseLike, wrap_response from view.router import Route, Router, RouteView +from view.run import ServerSettings from view.status_codes import HTTPError, InternalServerError, NotFound if TYPE_CHECKING: @@ -31,6 +33,14 @@ def __init__(self): self._request = contextvars.ContextVar["Request"]( "The current request being handled." ) + self._production: bool | None = None + + @property + def debug(self) -> bool: + if self._production is None: + return __debug__ + + return self._production @contextlib.contextmanager def request_context(self, request: Request) -> Iterator[None]: @@ -69,16 +79,46 @@ async def process_request(self, request: Request) -> Response: """ def wsgi(self) -> WSGIProtocol: + """ + Get the WSGI callable for the app. + """ from view.wsgi import wsgi_for_app return wsgi_for_app(self) def asgi(self) -> ASGIProtocol: + """ + Get the ASGI callable for the app. + """ from view.asgi import asgi_for_app return asgi_for_app(self) - def run(self): ... + def run( + self, + *, + host: str = "localhost", + port: int = 5000, + production: bool = False, + server_hint: str | None = None, + ) -> None: + """ + Run the app. + + This is a sort of magic function that's supposed to "just work". If + finer control over the server settings is desired, explicitly use the + server's API with the app's `asgi` or `wsgi` method. + """ + + if production is __debug__: + warnings.warn( + f"The app was run with {production=}, but Python's {__debug__=}", + RuntimeWarning, + ) + + self._production = production + settings = ServerSettings(self, host=host, port=port, hint=server_hint) + settings.run_app_on_any_server() P = ParamSpec("P") diff --git a/src/view/headers.py b/src/view/headers.py index ad03ac5c..5fc87762 100644 --- a/src/view/headers.py +++ b/src/view/headers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any, TypeAlias from multidict import CIMultiDict diff --git a/src/view/run.py b/src/view/run.py new file mode 100644 index 00000000..239b85c9 --- /dev/null +++ b/src/view/run.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import warnings +from contextlib import suppress +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, TypeAlias + +if TYPE_CHECKING: + from view.app import BaseApp + from view.wsgi import WSGIProtocol + +__all__ = ("ServerSettings",) + +StartServer: TypeAlias = Callable[[], None] + + +@dataclass(slots=True, frozen=True) +class ServerSettings: + app: "BaseApp" + port: int + host: str + hint: str | None = None + + def run_uvicorn(self) -> None: + import uvicorn + + uvicorn.run(self.app.asgi(), host=self.host, port=self.port) + + def run_hypercorn(self) -> None: + import asyncio + + import hypercorn + from hypercorn.asyncio import serve + + config = hypercorn.Config() + config.bind = [f"{self.host}:{self.port}"] + asyncio.run(serve(self.app.asgi(), config)) # type: ignore + + def run_daphne(self) -> None: + from daphne.endpoints import build_endpoint_description_strings + from daphne.server import Server + + endpoints = build_endpoint_description_strings( + host=self.host, + port=self.port, + ) + server = Server(self.app.asgi(), endpoints=endpoints) + server.run() + + def run_gunicorn(self) -> None: + from gunicorn.app.base import BaseApplication + + class GunicornRunner(BaseApplication): + def __init__( + self, app: WSGIProtocol, options: dict[str, Any] | None = None + ) -> None: + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + assert self.cfg is not None + for key, value in self.options.items(): + if key in self.cfg.settings and value is not None: + self.cfg.set(key, value) + + def load(self): + return self.application + + runner = GunicornRunner(self.app.wsgi(), {"bind": f"{self.host}:{self.port}"}) + runner.run() + + def run_werkzeug(self) -> None: + from werkzeug.serving import run_simple + + run_simple(self.host, self.port, self.app.wsgi()) + + def run_wsgiref(self) -> None: + from wsgiref.simple_server import make_server + + with make_server(self.host, self.port, self.app.wsgi()) as server: + server.serve_forever() + + def run_app_on_any_server(self) -> None: + servers: dict[str, StartServer] = { + "uvicorn": self.run_uvicorn, + "hypercorn": self.run_hypercorn, + "daphne": self.run_daphne, + "gunicorn": self.run_gunicorn, + "werkzeug": self.run_werkzeug, + "wsgiref": self.run_wsgiref, + } + if self.hint is not None: + try: + start_server = servers[self.hint] + except KeyError as key_error: + raise ValueError(f"{self.hint!r} is not a known server") from key_error + + try: + return start_server() + except ImportError: + warnings.warn(f"{self.hint} is not installed") + + for start_server in servers.values(): + with suppress(ImportError): + return start_server() diff --git a/tests/test_requests.py b/tests/test_requests.py index 528960dc..59feb18c 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,9 +1,9 @@ import pytest from view.app import as_app +from view.request import Method, Request from view.response import ResponseLike from view.status_codes import BadRequest -from view.request import Request, Method from view.testing import AppTestClient, into_tuple @@ -33,6 +33,7 @@ def app(request: Request) -> ResponseLike: {}, ) + @pytest.mark.asyncio async def test_request_body(): @as_app @@ -49,7 +50,11 @@ async def app(request: Request) -> ResponseLike: client = AppTestClient(app) assert (await into_tuple(client.get("/", body=b"test"))) == (b"1", 200, {}) - assert (await into_tuple(client.get("/large", body=b"A" * 10000))) == (b"2", 200, {}) + assert (await into_tuple(client.get("/large", body=b"A" * 10000))) == ( + b"2", + 200, + {}, + ) @pytest.mark.asyncio @@ -70,5 +75,8 @@ async def app(request: Request) -> ResponseLike: client = AppTestClient(app) assert (await into_tuple(client.get("/", headers={"foo": "42"}))) == (b"1", 200, {}) - assert (await into_tuple(client.get("/many", headers={"Bar": "24", "bAr": "42", "test": "123"}))) == (b"2", 200, {}) - + assert ( + await into_tuple( + client.get("/many", headers={"Bar": "24", "bAr": "42", "test": "123"}) + ) + ) == (b"2", 200, {}) diff --git a/tests/test_responses.py b/tests/test_responses.py index 2f402ec2..8a9442cc 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,9 +1,9 @@ import pytest from view.app import as_app +from view.request import Request from view.response import ResponseLike from view.testing import AppTestClient, into_tuple -from view.request import Request @pytest.mark.asyncio From 30d66c6ced2780060f2418652f7c50b585737019 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 11:04:22 -0400 Subject: [PATCH 042/188] Add some missing docstrings. --- pyproject.toml | 1 - src/view/run.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f9839836..9ebce88a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ dependencies = ["multidict~=6.5", "loguru~=0.7", "aiofiles~=24.1"] dynamic = ["version", "license"] [project.optional-dependencies] -full = [] [project.urls] Documentation = "https://view.zintensity.dev" diff --git a/src/view/run.py b/src/view/run.py index 239b85c9..619720de 100644 --- a/src/view/run.py +++ b/src/view/run.py @@ -16,6 +16,10 @@ @dataclass(slots=True, frozen=True) class ServerSettings: + """ + Dataclass representing server settings that can be used to start + serving an app. + """ app: "BaseApp" port: int host: str @@ -82,6 +86,12 @@ def run_wsgiref(self) -> None: server.serve_forever() def run_app_on_any_server(self) -> None: + """ + Run the app on the nearest available ASGI or WSGI server. + + This will always succeed, as it will fall back to the standard + `wsgiref` module if no other server is installed. + """ servers: dict[str, StartServer] = { "uvicorn": self.run_uvicorn, "hypercorn": self.run_hypercorn, From c2e082b64a57fa4c52cae213c2b92af3f6131c0f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 12:38:55 -0400 Subject: [PATCH 043/188] Make it possible to directly stream responses in views. --- src/view/response.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/view/response.py b/src/view/response.py index a3df2015..499f3367 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from os import PathLike from typing import AnyStr, AsyncGenerator, TypeAlias +from collections.abc import AsyncIterator import aiofiles from loguru import logger @@ -32,6 +33,8 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: return (await self.body(), self.status_code, self.headers) + +ResponseLike: TypeAlias = Response | AnyStr | AsyncIterator[AnyStr] StrPath: TypeAlias = str | PathLike[str] @@ -109,9 +112,6 @@ async def stream() -> AsyncGenerator[bytes]: return cls(stream, status_code, as_multidict(headers), content) -ResponseLike: TypeAlias = Response | AnyStr - - def wrap_response(response: ResponseLike) -> Response: """ Wrap a response from a view into a `Response` object. @@ -125,6 +125,16 @@ def wrap_response(response: ResponseLike) -> Response: content = response.encode() elif isinstance(response, bytes): content = response + elif isinstance(response, AsyncIterator): + async def stream() -> AsyncIterator[bytes]: + async for data in response: + if isinstance(data, str): + yield data.encode("utf-8") + else: + assert isinstance(data, bytes) + yield data + + return Response(stream, status_code=200, headers=CIMultiDict()) else: raise TypeError(f"Invalid response: {response!r}") From e44bdba26d2d73223b2ccdd66fc4816d3d9206bb Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 12:43:04 -0400 Subject: [PATCH 044/188] Add some debug type checks. --- src/view/app.py | 4 +++- src/view/body.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index 89a75b5c..be7d086e 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -110,6 +110,8 @@ def run( server's API with the app's `asgi` or `wsgi` method. """ + # production=True, __debug__ should be False. + # production=False, __debug__ should be True. if production is __debug__: warnings.warn( f"The app was run with {production=}, but Python's {__debug__=}", @@ -199,7 +201,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 wrap_response(await execute_view(error_view)) return error.as_response() diff --git a/src/view/body.py b/src/view/body.py index b8953c7c..9a4a3fda 100644 --- a/src/view/body.py +++ b/src/view/body.py @@ -29,6 +29,8 @@ async def body(self) -> bytes: buffer = BytesIO() async for data in self.receive_data(): + if __debug__ and not isinstance(data, bytes): + raise TypeError(f"expected bytes, got {data!r}") buffer.write(data) return buffer.getvalue() @@ -44,4 +46,6 @@ async def stream_body(self) -> AsyncGenerator[bytes]: self.consumed = True async for data in self.receive_data(): + if __debug__ and not isinstance(data, bytes): + raise TypeError(f"expected bytes, got {data!r}") yield data From 2366150542b93aec07c8fba9539839f0cd35d58d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 14:04:28 -0400 Subject: [PATCH 045/188] Use a tree for routing. --- src/view/app.py | 4 +- src/view/request.py | 7 +++- src/view/response.py | 4 +- src/view/router.py | 98 ++++++++++++++++++++++++++++++++++++++++++-- src/view/run.py | 1 + 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index be7d086e..4641c92b 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -10,6 +10,7 @@ from loguru import logger +from view.request import Method, Request from view.response import Response, ResponseLike, wrap_response from view.router import Route, Router, RouteView from view.run import ServerSettings @@ -17,7 +18,6 @@ if TYPE_CHECKING: from view.asgi import ASGIProtocol - from view.request import Method, Request from view.wsgi import WSGIProtocol __all__ = "BaseApp", "as_app", "App" @@ -30,7 +30,7 @@ class BaseApp(ABC): """Base view.py application.""" def __init__(self): - self._request = contextvars.ContextVar["Request"]( + self._request = contextvars.ContextVar[Request]( "The current request being handled." ) self._production: bool | None = None diff --git a/src/view/request.py b/src/view/request.py index 9212da2b..0462a3b7 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum, auto from typing import TYPE_CHECKING from view.body import BodyMixin from view.headers import RequestHeaders +from view.router import normalize_route if TYPE_CHECKING: from view.app import BaseApp @@ -45,3 +46,7 @@ class Request(BodyMixin): path: str method: Method headers: RequestHeaders + parameters: dict[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.path = normalize_route(self.path) diff --git a/src/view/response.py b/src/view/response.py index 499f3367..e8db0c81 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,10 +1,10 @@ from __future__ import annotations import mimetypes +from collections.abc import AsyncIterator from dataclasses import dataclass from os import PathLike from typing import AnyStr, AsyncGenerator, TypeAlias -from collections.abc import AsyncIterator import aiofiles from loguru import logger @@ -33,7 +33,6 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: return (await self.body(), self.status_code, self.headers) - ResponseLike: TypeAlias = Response | AnyStr | AsyncIterator[AnyStr] StrPath: TypeAlias = str | PathLike[str] @@ -126,6 +125,7 @@ def wrap_response(response: ResponseLike) -> Response: elif isinstance(response, bytes): content = response elif isinstance(response, AsyncIterator): + async def stream() -> AsyncIterator[bytes]: async for data in response: if isinstance(data, str): diff --git a/src/view/router.py b/src/view/router.py index 36229fcf..1890b301 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from functools import cache from typing import TYPE_CHECKING, Awaitable, Callable, TypeAlias from view.status_codes import HTTPError, status_exception @@ -26,20 +27,93 @@ class Route: method: Method +def normalize_route(route: str, /) -> str: + """ + Format a route (without any leading URL) into a common style. + """ + if route in {"", "/"}: + return "/" + + route = route.rstrip("/") + if not route.startswith("/"): + route = "/" + route + + return route + + +@dataclass(slots=True) +class PathNode: + """ + A node in the "path tree". + """ + + name: str + route: Route | None = field(default=None) + children: dict[str, PathNode] = field(default_factory=dict) + path_parameter: PathNode | None = field(default=None) + + def parameter(self, name: str) -> PathNode: + """ + Mark this node as having a path parameter (if not already), and + return the path parameter node. + """ + if self.path_parameter is None: + next_node = PathNode(name=name) + self.path_parameter = next_node + return next_node + if __debug__ and name != self.path_parameter.name: + raise ValueError( + f"path parameter {name} in the same place as {self.path_parameter.name} but with a different name", + ) + return self.path_parameter + + def next(self, part: str) -> PathNode: + """ + Get the next node for the given path part, creating + it if it doesn't exist. + """ + node = self.children.get(part) + if node is not None: + return node + + new_node = PathNode(name=part) + self.children[part] = new_node + return new_node + + +def is_path_parameter(part: str) -> bool: + return "{" in part + + @dataclass(slots=True, frozen=True) class Router: """ Standard router that supports error and route lookups. """ - route_views: dict[str, Route] = field(default_factory=dict) error_views: dict[type[HTTPError], RouteView] = field(default_factory=dict) + parent_node: PathNode = field(default_factory=lambda: PathNode(name="")) def push_route(self, view: RouteView, path: str, method: Method) -> None: """ Register a view with the router. """ - self.route_views[path] = Route(view=view, path=path, method=method) + path = normalize_route(path) + parent_node = self.parent_node + parts = path.split("/") + route = Route(view=view, path=path, method=method) + + for part in parts: + if is_path_parameter(part): + # TODO: Extract path parameter name + parent_node = parent_node.parameter(part) + else: + parent_node = parent_node.next(part) + + if parent_node.route is not None: + raise RuntimeError(f"the route {path!r} was already used") + + parent_node.route = route def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: """ @@ -59,7 +133,25 @@ def lookup_route(self, path: str, /) -> Route | None: """ Look up the view for the route. """ - return self.route_views.get(path) + path_parameters: dict[str, str] = {} + assert normalize_route(path) == path, "Request() should've normalized the route" + + parent_node = self.parent_node + parts = path.split("/") + + for part in parts: + node = parent_node.children.get(part) + if node is None: + node = parent_node.path_parameter + if node is None: + # This route doesn't exist + return None + + path_parameters[node.name] = part + + parent_node = node + + return parent_node.route def lookup_error(self, error: type[HTTPError], /) -> RouteView | None: """ diff --git a/src/view/run.py b/src/view/run.py index 619720de..654e269e 100644 --- a/src/view/run.py +++ b/src/view/run.py @@ -20,6 +20,7 @@ class ServerSettings: Dataclass representing server settings that can be used to start serving an app. """ + app: "BaseApp" port: int host: str From d1dacf0d2ba82528a602d6b265b9b34be2ed44c0 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 15:20:30 -0400 Subject: [PATCH 046/188] Store path parameters on the request. --- morals.md | 1 + src/view/app.py | 30 ++++++++++-------------------- src/view/request.py | 5 ++++- src/view/router.py | 27 +++++++++++++++++++++------ 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/morals.md b/morals.md index 27d0faa9..73e5f565 100644 --- a/morals.md +++ b/morals.md @@ -6,3 +6,4 @@ 4. Remember the Zen of Python (PEP 20). 5. Swiss-army knives don't make good APIs. 6. An independent system is a better one. Global state is evil. +7. A solution should solve a problem that's practical, not just theoretical. diff --git a/src/view/app.py b/src/view/app.py index 4641c92b..4365968b 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -5,14 +5,14 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import (TYPE_CHECKING, Callable, Iterator, Literal, ParamSpec, - TypeAlias, TypeVar, Union, overload) +from typing import (TYPE_CHECKING, Callable, Iterator, ParamSpec, TypeAlias, + TypeVar, Union) from loguru import logger from view.request import Method, Request from view.response import Response, ResponseLike, wrap_response -from view.router import Route, Router, RouteView +from view.router import FoundRoute, Router, RouteView from view.run import ServerSettings from view.status_codes import HTTPError, InternalServerError, NotFound @@ -54,23 +54,11 @@ def request_context(self, request: Request) -> Iterator[None]: finally: self._request.reset(token) - @overload - def current_request(self, *, validate: Literal[False]) -> Request | None: ... - - @overload - def current_request(self, *, validate: Literal[True] = True) -> Request: ... - - def current_request(self, *, validate: bool = True) -> Request | None: + def current_request(self) -> Request: """ Get the current request being handled. """ - if validate: - return self._request.get() - - try: - return self._request.get() - except LookupError: - return None + return self._request.get() @abstractmethod async def process_request(self, request: Request) -> Response: @@ -187,11 +175,13 @@ def __init__(self, *, router: Router | None = None) -> None: async def _process_request_internal(self, request: Request) -> Response: logger.info(f"{request.method} {request.path}") - route: Route | None = self.router.lookup_route(request.path) - if route is None: + found_route: FoundRoute | None = self.router.lookup_route(request.path) + if found_route is None: raise NotFound() - response = await execute_view(route.view) + # Extend instead of replacing? + request.parameters = found_route.path_parameters + response = await execute_view(found_route.route.view) return wrap_response(response) async def process_request(self, request: Request) -> Response: diff --git a/src/view/request.py b/src/view/request.py index 0462a3b7..ae24d89c 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass, field from enum import StrEnum, auto from typing import TYPE_CHECKING @@ -8,6 +10,7 @@ if TYPE_CHECKING: from view.app import BaseApp + from view.router import Route __all__ = "Method", "Request" @@ -46,7 +49,7 @@ class Request(BodyMixin): path: str method: Method headers: RequestHeaders - parameters: dict[str, str] = field(default_factory=dict) + parameters: dict[str, str] = field(init=False, default_factory=dict) def __post_init__(self) -> None: self.path = normalize_route(self.path) diff --git a/src/view/router.py b/src/view/router.py index 1890b301..4455f643 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from functools import cache from typing import TYPE_CHECKING, Awaitable, Callable, TypeAlias from view.status_codes import HTTPError, status_exception @@ -82,7 +81,20 @@ def next(self, part: str) -> PathNode: def is_path_parameter(part: str) -> bool: - return "{" in part + """ + Is this part a path parameter? + """ + return part.startswith("{") and part.endswith("}") + + +def extract_path_parameter(part: str) -> str: + return part[1 : len(part) - 1] + + +@dataclass(slots=True, frozen=True) +class FoundRoute: + route: Route + path_parameters: dict[str, str] @dataclass(slots=True, frozen=True) @@ -105,8 +117,7 @@ def push_route(self, view: RouteView, path: str, method: Method) -> None: for part in parts: if is_path_parameter(part): - # TODO: Extract path parameter name - parent_node = parent_node.parameter(part) + parent_node = parent_node.parameter(extract_path_parameter(part)) else: parent_node = parent_node.next(part) @@ -129,7 +140,7 @@ def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: self.error_views[error_type] = view - def lookup_route(self, path: str, /) -> Route | None: + def lookup_route(self, path: str, /) -> FoundRoute | None: """ Look up the view for the route. """ @@ -151,7 +162,11 @@ def lookup_route(self, path: str, /) -> Route | None: parent_node = node - return parent_node.route + final_route: Route | None = parent_node.route + if final_route is None: + return None + + return FoundRoute(final_route, path_parameters) def lookup_error(self, error: type[HTTPError], /) -> RouteView | None: """ From 425cfe4ab47ba8d444814d506bb9a5cb31e00f3a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 15:25:43 -0400 Subject: [PATCH 047/188] Clean up the gitignore. --- .gitignore | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/.gitignore b/.gitignore index 64324205..dbb49ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,7 @@ # Python __pycache__/ .venv/ -38venv/ -39venv/ -311venv/ # LSP .vscode/ compile_flags.txt -pyawaitable.h - -# View Configurations -view.toml -view.json -view_config.py - -# Testing Files -*.test -test.py -a.py -.coverage -.pytest-cache/ -.ruff-cache/ -.cache/ - -# Logs -*.log -vgcore.* -valgrind.txt* - -# JavaScript -node_modules/ -*.lock -benchmark.py -package-lock.json -client.js -.next/ - -# Builds -site/ -dist/ -build/ -*.egg-info/ -html/dist.css -*.so -a.md From 2cdf4560bc720d12c3ae457e49d33a6110a853a3 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 17:41:32 -0400 Subject: [PATCH 048/188] Some improvements to status code exceptions. --- src/view/status_codes.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 60963f2f..4e1b5d7d 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -93,7 +93,12 @@ def __init_subclass__(cls, ignore: bool = False) -> None: def as_response(cls) -> BytesResponse: if cls.status_code == 0: raise TypeError(f"{cls} is not a real response") - message = f"{cls.status_code} {cls.description}" + + if cls.args == (): + message = f"{cls.status_code} {cls.description}" + else: + message = str(cls) + return BytesResponse.from_bytes( message.encode("utf-8"), status_code=cls.status_code ) @@ -347,7 +352,7 @@ class FailedDependency(ClientSideError): status_code = 424 -class TooEarlyExperimental(ClientSideError): +class TooEarly(ClientSideError): """ Indicates that the server is unwilling to risk processing a request that might be replayed. From 97360de6467d46847cd55acea4c9db92c00bb0c5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 17:51:06 -0400 Subject: [PATCH 049/188] Some cleanups. --- src/view/request.py | 1 - src/view/router.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/view/request.py b/src/view/request.py index ae24d89c..10695610 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: from view.app import BaseApp - from view.router import Route __all__ = "Method", "Request" diff --git a/src/view/router.py b/src/view/router.py index 4455f643..455b325a 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -68,8 +68,8 @@ def parameter(self, name: str) -> PathNode: def next(self, part: str) -> PathNode: """ - Get the next node for the given path part, creating - it if it doesn't exist. + Get the next node for the given path part, creating it if it doesn't + exist. """ node = self.children.get(part) if node is not None: From 01d60051860ac650feccd3d4c3d08de8f2662eee Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:03:09 -0400 Subject: [PATCH 050/188] Add a test for path parameters. --- tests/test_requests.py | 69 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/test_requests.py b/tests/test_requests.py index 59feb18c..256ef264 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,6 +1,6 @@ import pytest -from view.app import as_app +from view.app import as_app, App from view.request import Method, Request from view.response import ResponseLike from view.status_codes import BadRequest @@ -80,3 +80,70 @@ async def app(request: Request) -> ResponseLike: client.get("/many", headers={"Bar": "24", "bAr": "42", "test": "123"}) ) ) == (b"2", 200, {}) + + +@pytest.mark.asyncio +async def test_request_router(): + app = App() + + @app.get("/") + def index(): + return "Index" + + @app.get("/hello") + def hello(): + return "Hello" + + @app.get("/hello/world") + def world(): + return "World" + + @app.get("/goodbye/world") + def goodbye(): + return "Goodbye" + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"Index", 200, {}) + assert (await into_tuple(client.get("/hello"))) == (b"Hello", 200, {}) + assert (await into_tuple(client.get("/hello/world"))) == (b"World", 200, {}) + assert (await into_tuple(client.get("/"))) == (b"Index", 200, {}) + assert (await into_tuple(client.get("/goodbye/world"))) == (b"Goodbye", 200, {}) + + +@pytest.mark.asyncio +async def test_request_path_parameters(): + app = App() + + @app.get("/") + def index(): + return "Index" + + @app.get("/spanish/{inquisition}") + async def path_param(): + request = app.current_request() + assert request.parameters['inquisition'] == '42' + return "0" + + @app.get("/spanish/inquisition") + def overwrite_path_param(): + return "1" + + @app.get("/spanish/inquisition/{nobody}") + def sub_path_param(): + request = app.current_request() + assert request.parameters["nobody"] == 'gotcha' + return "2" + + @app.get("/spanish/{inquisition}/{nobody}") + def double_path_param(): + request = app.current_request() + assert request.parameters["inquisition"] == '1' + assert request.parameters["nobody"] == '2' + return "3" + + client = AppTestClient(app) + assert (await into_tuple(client.get("/spanish/42"))) == (b"0", 200, {}) + assert (await into_tuple(client.get("/spanish/inquisition"))) == (b"1", 200, {}) + assert (await into_tuple(client.get("/spanish/inquisition/gotcha"))) == (b"2", 200, {}) + assert (await into_tuple(client.get("/spanish/1/2"))) == (b"3", 200, {}) + From 8829ad8ccd355ff652e795a7b3ea4fe7d30c3c19 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:18:14 -0400 Subject: [PATCH 051/188] Add a test for request methods and manual requests. --- tests/test_requests.py | 101 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/tests/test_requests.py b/tests/test_requests.py index 256ef264..d86ff099 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,6 +1,9 @@ +from typing import AsyncIterator + import pytest -from view.app import as_app, App +from view.app import App, as_app +from view.headers import as_multidict from view.request import Method, Request from view.response import ResponseLike from view.status_codes import BadRequest @@ -34,6 +37,35 @@ def app(request: Request) -> ResponseLike: ) +@pytest.mark.asyncio +async def test_manual_request(): + @as_app + def app(request: Request) -> ResponseLike: + assert request.app == app + assert request.app.current_request() is request + assert isinstance(request.path, str) + assert request.method is Method.POST + assert request.headers["test"] == "42" + + return "1" + + async def stream_none() -> AsyncIterator[bytes]: + yield b"" + + with pytest.raises(LookupError): + app.current_request() + + manual_request = Request( + receive_data=stream_none, + app=app, + path="/", + method=Method.POST, + headers=as_multidict({"test": "42"}), + ) + response = await app.process_request(manual_request) + assert (await response.body()) == b"1" + + @pytest.mark.asyncio async def test_request_body(): @as_app @@ -121,29 +153,84 @@ def index(): @app.get("/spanish/{inquisition}") async def path_param(): request = app.current_request() - assert request.parameters['inquisition'] == '42' + assert request.parameters["inquisition"] == "42" return "0" @app.get("/spanish/inquisition") def overwrite_path_param(): return "1" - + @app.get("/spanish/inquisition/{nobody}") def sub_path_param(): request = app.current_request() - assert request.parameters["nobody"] == 'gotcha' + assert request.parameters["nobody"] == "gotcha" return "2" @app.get("/spanish/{inquisition}/{nobody}") def double_path_param(): request = app.current_request() - assert request.parameters["inquisition"] == '1' - assert request.parameters["nobody"] == '2' + assert request.parameters["inquisition"] == "1" + assert request.parameters["nobody"] == "2" return "3" client = AppTestClient(app) assert (await into_tuple(client.get("/spanish/42"))) == (b"0", 200, {}) assert (await into_tuple(client.get("/spanish/inquisition"))) == (b"1", 200, {}) - assert (await into_tuple(client.get("/spanish/inquisition/gotcha"))) == (b"2", 200, {}) + assert (await into_tuple(client.get("/spanish/inquisition/gotcha"))) == ( + b"2", + 200, + {}, + ) assert (await into_tuple(client.get("/spanish/1/2"))) == (b"3", 200, {}) + +@pytest.mark.asyncio +async def test_request_method(): + app = App() + + @app.get("/") + async def index_get(): + return "get" + + @app.post("/") + async def index_post(): + return "post" + + @app.patch("/") + async def index_patch(): + return "patch" + + @app.put("/") + async def index_put(): + return "put" + + @app.delete("/") + async def index_delete(): + return "delete" + + @app.connect("/") + async def index_connect(): + return "connect" + + @app.options("/") + async def index_options(): + return "options" + + @app.trace("/") + async def index_trace(): + return "trace" + + @app.head("/") + async def index_head(): + return "head" + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"get", 200, {}) + assert (await into_tuple(client.post("/"))) == (b"post", 200, {}) + assert (await into_tuple(client.patch("/"))) == (b"patch", 200, {}) + assert (await into_tuple(client.put("/"))) == (b"put", 200, {}) + assert (await into_tuple(client.delete("/"))) == (b"delete", 200, {}) + assert (await into_tuple(client.connect("/"))) == (b"connect", 200, {}) + assert (await into_tuple(client.options("/"))) == (b"options", 200, {}) + assert (await into_tuple(client.trace("/"))) == (b"trace", 200, {}) + assert (await into_tuple(client.head("/"))) == (b"head", 200, {}) From 4af94c2b61bc587a01c6a8ad244a1e4707830cbf Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:22:29 -0400 Subject: [PATCH 052/188] Support different methods for the same route. --- src/view/app.py | 4 +++- src/view/router.py | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 4365968b..9cd224f5 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -175,7 +175,9 @@ def __init__(self, *, router: Router | None = None) -> None: async def _process_request_internal(self, request: Request) -> Response: logger.info(f"{request.method} {request.path}") - found_route: FoundRoute | None = self.router.lookup_route(request.path) + found_route: FoundRoute | None = self.router.lookup_route( + request.path, request.method + ) if found_route is None: raise NotFound() diff --git a/src/view/router.py b/src/view/router.py index 455b325a..380d1a4c 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -47,7 +47,7 @@ class PathNode: """ name: str - route: Route | None = field(default=None) + routes: dict[Method, Route] = field(default_factory=dict) children: dict[str, PathNode] = field(default_factory=dict) path_parameter: PathNode | None = field(default=None) @@ -121,10 +121,10 @@ def push_route(self, view: RouteView, path: str, method: Method) -> None: else: parent_node = parent_node.next(part) - if parent_node.route is not None: + if parent_node.routes.get(method) is not None: raise RuntimeError(f"the route {path!r} was already used") - parent_node.route = route + parent_node.routes[method] = route def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: """ @@ -140,7 +140,7 @@ def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: self.error_views[error_type] = view - def lookup_route(self, path: str, /) -> FoundRoute | None: + def lookup_route(self, path: str, method: Method, /) -> FoundRoute | None: """ Look up the view for the route. """ @@ -162,7 +162,7 @@ def lookup_route(self, path: str, /) -> FoundRoute | None: parent_node = node - final_route: Route | None = parent_node.route + final_route: Route | None = parent_node.routes.get(method) if final_route is None: return None From 35eac7029ce245c945aaed91e38586fcb96dff42 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:28:46 -0400 Subject: [PATCH 053/188] Add docstrings to the request methods. --- src/view/request.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/view/request.py b/src/view/request.py index 10695610..10b16b05 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -28,14 +28,58 @@ class Method(_UpperStrEnum): """ GET = auto() + """ + The GET method requests a representation of the specified resource. + + Requests using GET should only retrieve data and should not contain + a request content. + """ + POST = auto() + """ + The POST method submits an entity to the specified resource, often causing + a change in state or side effects on the server. + """ + PUT = auto() + """ + The PUT method replaces all current representations of the target resource + with the request content. + """ + PATCH = auto() + """ + The PATCH method applies partial modifications to a resource. + """ + DELETE = auto() + """ + The DELETE method deletes the specified resource. + """ + CONNECT = auto() + """ + The CONNECT method establishes a tunnel to the server identified by the + target resource. + """ + OPTIONS = auto() + """ + The OPTIONS method describes the communication options for the target + resource. + """ + TRACE = auto() + """ + The TRACE method performs a message loop-back test along the path to the + target resource. + """ + HEAD = auto() + """ + The HEAD method asks for a response identical to a GET request, but + without a response body. + """ @dataclass(slots=True) From 6735695ef253692f63b16f7ee6fc334cc7b6381b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:35:46 -0400 Subject: [PATCH 054/188] Return the traceback from InternalServerError when debugging is enabled. --- src/view/status_codes.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 4e1b5d7d..0718df31 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import ClassVar +import traceback from view.response import BytesResponse @@ -89,15 +90,15 @@ def __init_subclass__(cls, ignore: bool = False) -> None: STATUS_EXCEPTIONS[cls.status_code] = cls cls.description = STATUS_STRINGS[cls.status_code] - @classmethod - def as_response(cls) -> BytesResponse: + def as_response(self) -> BytesResponse: + cls = type(self) if cls.status_code == 0: raise TypeError(f"{cls} is not a real response") - if cls.args == (): + if self.args == (): message = f"{cls.status_code} {cls.description}" else: - message = str(cls) + message = str(self) return BytesResponse.from_bytes( message.encode("utf-8"), status_code=cls.status_code @@ -421,6 +422,14 @@ class InternalServerError(ServerSideError): status_code = 500 + def as_response(self) -> BytesResponse: + if not __debug__: + return super().as_response() + + cls = type(self) + message = traceback.format_exc() + return BytesResponse.from_bytes(message.encode("utf-8"), status_code=cls.status_code) + class NotImplemented(ServerSideError): """ From 6204e9b6d145a9a0c250890e5c915606cfbfd4f9 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:39:37 -0400 Subject: [PATCH 055/188] Add type hints to CIMultiDict creations. --- src/view/headers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/view/headers.py b/src/view/headers.py index 5fc87762..d6660ffa 100644 --- a/src/view/headers.py +++ b/src/view/headers.py @@ -17,7 +17,7 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: to a `CIMultiDict`. """ if headers is None: - return CIMultiDict() + return CIMultiDict[str]() if isinstance(headers, CIMultiDict): return headers @@ -26,7 +26,7 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: raise TypeError(f"Invalid headers: {headers}") assert isinstance(headers, dict) - multidict = CIMultiDict() + multidict = CIMultiDict[str]() for key, value in headers.items(): if isinstance(key, bytes): key = key.decode("utf-8") @@ -43,7 +43,7 @@ def wsgi_as_multidict(environ: dict[str, Any]) -> RequestHeaders: """ Convert WSGI headers (from the `environ`) to a case-insensitive multidict. """ - headers = CIMultiDict() + headers = CIMultiDict[str]() for key, value in environ.items(): if not key.startswith("HTTP_"): @@ -60,7 +60,7 @@ def asgi_as_multidict(headers: ASGIHeaders, /) -> RequestHeaders: """ Convert ASGI headers to a case-insensitive multidict. """ - multidict = CIMultiDict() + multidict = CIMultiDict[str]() for key, value in headers: multidict[key.decode("utf-8")] = value.decode("utf-8") From a0c5d954e4454c3497e9140e3f822bb417ba3015 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 20:47:32 -0400 Subject: [PATCH 056/188] Add missing docstrings. --- src/view/app.py | 7 +++++++ src/view/router.py | 9 +++++++++ src/view/run.py | 18 ++++++++++++++++++ src/view/status_codes.py | 6 ++++-- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 9cd224f5..8225421f 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -37,6 +37,13 @@ def __init__(self): @property def debug(self) -> bool: + """ + Is the app in debug mode? + + If debug mode is enabled, some extra checks and settings are enabled + to improve the development experience, at the cost of being slower and + less secure. + """ if self._production is None: return __debug__ diff --git a/src/view/router.py b/src/view/router.py index 380d1a4c..60eaf3ea 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -88,11 +88,20 @@ def is_path_parameter(part: str) -> bool: def extract_path_parameter(part: str) -> str: + """ + Extract the name of a path parameter from a string given by the user + in a route string. + """ return part[1 : len(part) - 1] @dataclass(slots=True, frozen=True) class FoundRoute: + """ + Dataclass representing a route that was looked up by the router + for a given path. + """ + route: Route path_parameters: dict[str, str] diff --git a/src/view/run.py b/src/view/run.py index 654e269e..7dc1b000 100644 --- a/src/view/run.py +++ b/src/view/run.py @@ -27,11 +27,17 @@ class ServerSettings: hint: str | None = None def run_uvicorn(self) -> None: + """ + Run the app using the `uvicorn` library. + """ import uvicorn uvicorn.run(self.app.asgi(), host=self.host, port=self.port) def run_hypercorn(self) -> None: + """ + Run the app using the `hypercorn` library. + """ import asyncio import hypercorn @@ -42,6 +48,9 @@ def run_hypercorn(self) -> None: asyncio.run(serve(self.app.asgi(), config)) # type: ignore def run_daphne(self) -> None: + """ + Run the app using the `daphne` library. + """ from daphne.endpoints import build_endpoint_description_strings from daphne.server import Server @@ -53,6 +62,9 @@ def run_daphne(self) -> None: server.run() def run_gunicorn(self) -> None: + """ + Run the app using the `gunicorn` library. + """ from gunicorn.app.base import BaseApplication class GunicornRunner(BaseApplication): @@ -76,11 +88,17 @@ def load(self): runner.run() def run_werkzeug(self) -> None: + """ + Run the app using the `werkzeug` library. + """ from werkzeug.serving import run_simple run_simple(self.host, self.port, self.app.wsgi()) def run_wsgiref(self) -> None: + """ + Run the app using the built-in `wsgiref` module. + """ from wsgiref.simple_server import make_server with make_server(self.host, self.port, self.app.wsgi()) as server: diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 0718df31..a0bdeeca 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -1,7 +1,7 @@ from __future__ import annotations -from typing import ClassVar import traceback +from typing import ClassVar from view.response import BytesResponse @@ -428,7 +428,9 @@ def as_response(self) -> BytesResponse: cls = type(self) message = traceback.format_exc() - return BytesResponse.from_bytes(message.encode("utf-8"), status_code=cls.status_code) + return BytesResponse.from_bytes( + message.encode("utf-8"), status_code=cls.status_code + ) class NotImplemented(ServerSideError): From 9c06fb4dfe26ba121bab5c0aef402fbcf6b447eb Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 21:02:44 -0400 Subject: [PATCH 057/188] Add a test for path normalization. --- tests/test_requests.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_requests.py b/tests/test_requests.py index d86ff099..6a97b839 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -234,3 +234,30 @@ async def index_head(): assert (await into_tuple(client.options("/"))) == (b"options", 200, {}) assert (await into_tuple(client.trace("/"))) == (b"trace", 200, {}) assert (await into_tuple(client.head("/"))) == (b"head", 200, {}) + + +@pytest.mark.asyncio +async def test_normalized_routes(): + app = App() + + @app.get("") + async def index(): + return "1" + + @app.get("hello/") + async def hello(): + return "2" + + @app.get("/test/") + async def test(): + return "3" + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"1", 200, {}) + assert (await into_tuple(client.get(""))) == (b"1", 200, {}) + assert (await into_tuple(client.get("/hello"))) == (b"2", 200, {}) + assert (await into_tuple(client.get("/hello/"))) == (b"2", 200, {}) + assert (await into_tuple(client.get("hello/"))) == (b"2", 200, {}) + assert (await into_tuple(client.get("/test"))) == (b"3", 200, {}) + assert (await into_tuple(client.get("/test/"))) == (b"3", 200, {}) + assert (await into_tuple(client.get("test/"))) == (b"3", 200, {}) From b54b75ac3dd27f5d38c5acb40a72e117b9822de5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 21:14:21 -0400 Subject: [PATCH 058/188] Add a __debug__ check. --- src/view/headers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/headers.py b/src/view/headers.py index d6660ffa..6020119a 100644 --- a/src/view/headers.py +++ b/src/view/headers.py @@ -22,7 +22,7 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: if isinstance(headers, CIMultiDict): return headers - if not isinstance(headers, dict): + if __debug__ and not isinstance(headers, dict): raise TypeError(f"Invalid headers: {headers}") assert isinstance(headers, dict) From bbcfb0bfc9cba7e067befaad9b009b591548a068 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 23 Jun 2025 21:18:42 -0400 Subject: [PATCH 059/188] Remove a comment and change a type. --- src/view/wsgi.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/view/wsgi.py b/src/view/wsgi.py index 5f0616b3..2d1e4e96 100644 --- a/src/view/wsgi.py +++ b/src/view/wsgi.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import IO, TYPE_CHECKING, Callable, Iterable, TypeAlias +from typing import IO, TYPE_CHECKING, Any, Callable, Iterable, TypeAlias from view.headers import wsgi_as_multidict from view.request import Method, Request @@ -11,7 +11,9 @@ from view.app import BaseApp WSGIHeaders: TypeAlias = list[tuple[str, str]] -WSGIEnvironment: TypeAlias = dict[str, str | IO[bytes]] # XXX: Use a TypedDict? +# We can't use a TypedDict for the environment because it has arbitrary keys +# for the headers. +WSGIEnvironment: TypeAlias = dict[str, Any] WSGIStartResponse = Callable[[str, WSGIHeaders], Callable[[bytes], object]] WSGIProtocol: TypeAlias = Callable[ [WSGIEnvironment, WSGIStartResponse], Iterable[bytes] From df7e2bc75bc37a44bab8b98197843eb570750790 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 07:29:58 -0400 Subject: [PATCH 060/188] Eliminate some redundancy in the requests tests. --- tests/test_requests.py | 83 +++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/tests/test_requests.py b/tests/test_requests.py index 6a97b839..4cc65440 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -10,6 +10,12 @@ from view.testing import AppTestClient, into_tuple +def ok(body: str | bytes) -> tuple[bytes, int, dict[str, str]]: + if isinstance(body, str): + body = body.encode("utf-8") + return (body, 200,{}) + + @pytest.mark.asyncio async def test_request_data(): @as_app @@ -29,13 +35,8 @@ def app(request: Request) -> ResponseLike: raise BadRequest() client = AppTestClient(app) - assert (await into_tuple(client.get("/"))) == (b"Hello", 200, {}) - assert (await into_tuple(client.get("/1", headers={"test-something": "42"}))) == ( - b"World", - 200, - {}, - ) - + assert (await into_tuple(client.get("/"))) == ok("Hello") + assert (await into_tuple(client.get("/1", headers={"test-something": "42"}))) == ok("World") @pytest.mark.asyncio async def test_manual_request(): @@ -81,12 +82,8 @@ async def app(request: Request) -> ResponseLike: raise BadRequest() client = AppTestClient(app) - assert (await into_tuple(client.get("/", body=b"test"))) == (b"1", 200, {}) - assert (await into_tuple(client.get("/large", body=b"A" * 10000))) == ( - b"2", - 200, - {}, - ) + assert (await into_tuple(client.get("/", body=b"test"))) == ok("1") + assert (await into_tuple(client.get("/large", body=b"A" * 10000))) == ok("2") @pytest.mark.asyncio @@ -106,12 +103,12 @@ async def app(request: Request) -> ResponseLike: raise BadRequest() client = AppTestClient(app) - assert (await into_tuple(client.get("/", headers={"foo": "42"}))) == (b"1", 200, {}) + assert (await into_tuple(client.get("/", headers={"foo": "42"}))) == ok("1") assert ( await into_tuple( client.get("/many", headers={"Bar": "24", "bAr": "42", "test": "123"}) ) - ) == (b"2", 200, {}) + ) == ok("2") @pytest.mark.asyncio @@ -135,11 +132,11 @@ def goodbye(): return "Goodbye" client = AppTestClient(app) - assert (await into_tuple(client.get("/"))) == (b"Index", 200, {}) - assert (await into_tuple(client.get("/hello"))) == (b"Hello", 200, {}) - assert (await into_tuple(client.get("/hello/world"))) == (b"World", 200, {}) - assert (await into_tuple(client.get("/"))) == (b"Index", 200, {}) - assert (await into_tuple(client.get("/goodbye/world"))) == (b"Goodbye", 200, {}) + assert (await into_tuple(client.get("/"))) == ok("Index") + assert (await into_tuple(client.get("/hello"))) == ok("Hello") + assert (await into_tuple(client.get("/hello/world"))) == ok("World") + assert (await into_tuple(client.get("/"))) == ok("Index") + assert (await into_tuple(client.get("/goodbye/world"))) == ok("Goodbye") @pytest.mark.asyncio @@ -174,14 +171,10 @@ def double_path_param(): return "3" client = AppTestClient(app) - assert (await into_tuple(client.get("/spanish/42"))) == (b"0", 200, {}) - assert (await into_tuple(client.get("/spanish/inquisition"))) == (b"1", 200, {}) - assert (await into_tuple(client.get("/spanish/inquisition/gotcha"))) == ( - b"2", - 200, - {}, - ) - assert (await into_tuple(client.get("/spanish/1/2"))) == (b"3", 200, {}) + assert (await into_tuple(client.get("/spanish/42"))) == ok("0") + assert (await into_tuple(client.get("/spanish/inquisition"))) == ok("1") + assert (await into_tuple(client.get("/spanish/inquisition/gotcha"))) == ok("2") + assert (await into_tuple(client.get("/spanish/1/2"))) == ok("3") @pytest.mark.asyncio @@ -225,15 +218,15 @@ async def index_head(): return "head" client = AppTestClient(app) - assert (await into_tuple(client.get("/"))) == (b"get", 200, {}) - assert (await into_tuple(client.post("/"))) == (b"post", 200, {}) - assert (await into_tuple(client.patch("/"))) == (b"patch", 200, {}) - assert (await into_tuple(client.put("/"))) == (b"put", 200, {}) - assert (await into_tuple(client.delete("/"))) == (b"delete", 200, {}) - assert (await into_tuple(client.connect("/"))) == (b"connect", 200, {}) - assert (await into_tuple(client.options("/"))) == (b"options", 200, {}) - assert (await into_tuple(client.trace("/"))) == (b"trace", 200, {}) - assert (await into_tuple(client.head("/"))) == (b"head", 200, {}) + assert (await into_tuple(client.get("/"))) == ok("get") + assert (await into_tuple(client.post("/"))) == ok("post") + assert (await into_tuple(client.patch("/"))) == ok("patch") + assert (await into_tuple(client.put("/"))) == ok("put") + assert (await into_tuple(client.delete("/"))) == ok("delete") + assert (await into_tuple(client.connect("/"))) == ok("connect") + assert (await into_tuple(client.options("/"))) == ok("options") + assert (await into_tuple(client.trace("/"))) == ok("trace") + assert (await into_tuple(client.head("/"))) == ok("head") @pytest.mark.asyncio @@ -253,11 +246,11 @@ async def test(): return "3" client = AppTestClient(app) - assert (await into_tuple(client.get("/"))) == (b"1", 200, {}) - assert (await into_tuple(client.get(""))) == (b"1", 200, {}) - assert (await into_tuple(client.get("/hello"))) == (b"2", 200, {}) - assert (await into_tuple(client.get("/hello/"))) == (b"2", 200, {}) - assert (await into_tuple(client.get("hello/"))) == (b"2", 200, {}) - assert (await into_tuple(client.get("/test"))) == (b"3", 200, {}) - assert (await into_tuple(client.get("/test/"))) == (b"3", 200, {}) - assert (await into_tuple(client.get("test/"))) == (b"3", 200, {}) + assert (await into_tuple(client.get("/"))) == ok("1") + assert (await into_tuple(client.get(""))) == ok("1") + assert (await into_tuple(client.get("/hello"))) == ok("2") + assert (await into_tuple(client.get("/hello/"))) == ok("2") + assert (await into_tuple(client.get("hello/"))) == ok("2") + assert (await into_tuple(client.get("/test"))) == ok("3") + assert (await into_tuple(client.get("/test/"))) == ok("3") + assert (await into_tuple(client.get("test/"))) == ok("3") From f4a658eb465fba25ec37c1f644a2e280f20e622d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 09:59:35 -0400 Subject: [PATCH 061/188] Support returning tuples from views. --- src/view/app.py | 11 ++++-- src/view/asgi.py | 13 ++++++-- src/view/response.py | 79 +++++++++++++++++++++++--------------------- 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 8225421f..e6c8796a 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -5,8 +5,15 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import (TYPE_CHECKING, Callable, Iterator, ParamSpec, TypeAlias, - TypeVar, Union) +from typing import ( + TYPE_CHECKING, + Callable, + Iterator, + ParamSpec, + TypeAlias, + TypeVar, + Union, +) from loguru import logger diff --git a/src/view/asgi.py b/src/view/asgi.py index feb0a989..52041cb5 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,7 +1,16 @@ from __future__ import annotations -from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, - NotRequired, TypeAlias, TypedDict) +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Iterable, + Literal, + NotRequired, + TypeAlias, + TypedDict, +) from view.app import BaseApp from view.headers import asgi_as_multidict, multidict_as_asgi diff --git a/src/view/response.py b/src/view/response.py index e8db0c81..fafd1d8b 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,10 +1,12 @@ from __future__ import annotations +from contextlib import suppress import mimetypes from collections.abc import AsyncIterator from dataclasses import dataclass from os import PathLike -from typing import AnyStr, AsyncGenerator, TypeAlias +from typing import AnyStr, AsyncGenerator, Generic, TypeAlias +import warnings import aiofiles from loguru import logger @@ -19,7 +21,7 @@ @dataclass(slots=True) class Response(BodyMixin): """ - High-level dataclass representing a response from a view. + Low-level dataclass representing a response from a view. """ status_code: int @@ -79,63 +81,66 @@ async def stream(): @dataclass(slots=True) -class BytesResponse(Response): +class StringOrBytesResponse(Response, Generic[AnyStr]): """ - Simple in-memory response for a byte string. + Simple in-memory response for a UTF-8 encoded string, or a raw ASCII byte string. """ - - content: bytes - - def as_tuple_sync(self) -> tuple[bytes, int, RequestHeaders]: - """ - Synchronous variation of `as_tuple`, using the cached content. - """ - return (self.content, self.status_code, self.headers) + content: AnyStr @classmethod - def from_bytes( - cls, - content: bytes, - /, - *, - status_code: int = 200, - headers: HeadersLike | None = None, - ) -> BytesResponse: + def from_content(cls, content: AnyStr, /, *, status_code: int = 200, headers: HeadersLike | None = None) -> StringOrBytesResponse: """ - Generate a `BytesResponse` from a `bytes` object. + Generate a `StringResponse` from a `string` object. """ + + if __debug__ and not isinstance(content, (str, bytes)): + raise TypeError(f"expected a string or bytes object, got {content!r}") async def stream() -> AsyncGenerator[bytes]: - yield content + if isinstance(content, str): + yield content.encode("utf-8") + else: + yield content return cls(stream, status_code, as_multidict(headers), content) -def wrap_response(response: ResponseLike) -> Response: +def _wrap_response_tuple(response: tuple[str | bytes, int, HeadersLike]) -> Response: + if response == (): + raise ValueError("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) + + content = response[0] + status = response[1] + headers: HeadersLike | None = None + + with suppress(KeyError): + headers = response[2] + + return StringOrBytesResponse.from_content(content, status_code=status, headers=headers) + + +def wrap_response(response: ResponseLike, /) -> Response: """ Wrap a response from a view into a `Response` object. """ - logger.debug(f"Got response: {response}") + logger.debug(f"Got response: {response!r}") if isinstance(response, Response): return response - - content: bytes - if isinstance(response, str): - content = response.encode() - elif isinstance(response, bytes): - content = response + elif isinstance(response, (str, bytes)): + return StringOrBytesResponse.from_content(response) + elif isinstance(response, tuple): + return _wrap_response_tuple(response) elif isinstance(response, AsyncIterator): async def stream() -> AsyncIterator[bytes]: async for data in response: - if isinstance(data, str): - yield data.encode("utf-8") - else: - assert isinstance(data, bytes) - yield data + yield data return Response(stream, status_code=200, headers=CIMultiDict()) else: raise TypeError(f"Invalid response: {response!r}") - - return BytesResponse.from_bytes(content) From 4bea833139966f31a52bd54792997fcce93b55a8 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:23:08 -0400 Subject: [PATCH 062/188] Some nonspecific improvements. --- src/view/app.py | 11 +---- src/view/asgi.py | 13 +----- src/view/response.py | 68 +++++++++++++++++++++--------- src/view/status_codes.py | 89 ++++++++++++++++++++++++++++++++++++---- status.py | 15 +++++++ status.txt | 20 +++++++++ 6 files changed, 170 insertions(+), 46 deletions(-) create mode 100644 status.py create mode 100644 status.txt diff --git a/src/view/app.py b/src/view/app.py index e6c8796a..8225421f 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -5,15 +5,8 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import ( - TYPE_CHECKING, - Callable, - Iterator, - ParamSpec, - TypeAlias, - TypeVar, - Union, -) +from typing import (TYPE_CHECKING, Callable, Iterator, ParamSpec, TypeAlias, + TypeVar, Union) from loguru import logger diff --git a/src/view/asgi.py b/src/view/asgi.py index 52041cb5..feb0a989 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,16 +1,7 @@ from __future__ import annotations -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Iterable, - Literal, - NotRequired, - TypeAlias, - TypedDict, -) +from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, + NotRequired, TypeAlias, TypedDict) from view.app import BaseApp from view.headers import asgi_as_multidict, multidict_as_asgi diff --git a/src/view/response.py b/src/view/response.py index fafd1d8b..34afe7a2 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -1,12 +1,12 @@ from __future__ import annotations -from contextlib import suppress import mimetypes +import warnings from collections.abc import AsyncIterator +from contextlib import suppress from dataclasses import dataclass from os import PathLike from typing import AnyStr, AsyncGenerator, Generic, TypeAlias -import warnings import aiofiles from loguru import logger @@ -35,7 +35,12 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: return (await self.body(), self.status_code, self.headers) -ResponseLike: TypeAlias = Response | AnyStr | AsyncIterator[AnyStr] +# 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] +ResponseLike: TypeAlias = ( + Response | StrOrBytes | AsyncIterator[StrOrBytes] | ResponseTuple +) StrPath: TypeAlias = str | PathLike[str] @@ -61,6 +66,10 @@ def from_file( """ Generate a `FileResponse` from a file path. """ + if __debug__ and not isinstance(chunk_size, int): + raise TypeError( + f"expected an integer for chunk_size, but got {chunk_size!r}" + ) async def stream(): async with aiofiles.open(path, "rb") as file: @@ -80,48 +89,69 @@ async def stream(): return cls(stream, status_code, multidict, path) +def as_bytes(data: str | bytes) -> bytes: + """ + Utility to convert a string to a byte string, or let a byte string pass. + """ + if isinstance(data, str): + return data.encode("utf-8") + else: + return data + + @dataclass(slots=True) -class StringOrBytesResponse(Response, Generic[AnyStr]): +class StrOrBytesResponse(Response, Generic[AnyStr]): """ Simple in-memory response for a UTF-8 encoded string, or a raw ASCII byte string. """ + content: AnyStr @classmethod - def from_content(cls, content: AnyStr, /, *, status_code: int = 200, headers: HeadersLike | None = None) -> StringOrBytesResponse: + def from_content( + cls, + content: AnyStr, + /, + *, + status_code: int = 200, + headers: HeadersLike | None = None, + ) -> StrOrBytesResponse[AnyStr]: """ Generate a `StringResponse` from a `string` object. """ - + if __debug__ and not isinstance(content, (str, bytes)): - raise TypeError(f"expected a string or bytes object, got {content!r}") + raise TypeError(f"Expected a string or bytes object, got {content!r}") async def stream() -> AsyncGenerator[bytes]: - if isinstance(content, str): - yield content.encode("utf-8") - else: - yield content + yield as_bytes(content) return cls(stream, status_code, as_multidict(headers), content) -def _wrap_response_tuple(response: tuple[str | bytes, int, HeadersLike]) -> Response: +def _wrap_response_tuple(response: ResponseTuple) -> Response: if response == (): raise ValueError("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) + warnings.warn( + f"Returned tuple {response!r} with a single item," + " which is useless. Return the item directly.", + RuntimeWarning, + ) + return StrOrBytesResponse.from_content(response[0]) content = response[0] status = response[1] headers: HeadersLike | None = None - with suppress(KeyError): + if len(response) > 2: headers = response[2] - return StringOrBytesResponse.from_content(content, status_code=status, headers=headers) + if __debug__ and len(response) > 3: + raise ValueError(f"Got excess data in response tuple {response[3:]!r}") + + return StrOrBytesResponse.from_content(content, status_code=status, headers=headers) def wrap_response(response: ResponseLike, /) -> Response: @@ -132,14 +162,14 @@ def wrap_response(response: ResponseLike, /) -> Response: if isinstance(response, Response): return response elif isinstance(response, (str, bytes)): - return StringOrBytesResponse.from_content(response) + return StrOrBytesResponse.from_content(response) elif isinstance(response, tuple): return _wrap_response_tuple(response) elif isinstance(response, AsyncIterator): async def stream() -> AsyncIterator[bytes]: async for data in response: - yield data + yield as_bytes(data) return Response(stream, status_code=200, headers=CIMultiDict()) else: diff --git a/src/view/status_codes.py b/src/view/status_codes.py index a0bdeeca..5c5ddda5 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -1,9 +1,10 @@ from __future__ import annotations import traceback +from enum import Enum from typing import ClassVar -from view.response import BytesResponse +from view.response import StrOrBytesResponse STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} STATUS_STRINGS: dict[int, str] = { @@ -73,6 +74,82 @@ } +class Success(Enum): + OK = 200 + """ + The request succeeded. The result and meaning of "success" depends on + the HTTP method: + + GET: The resource has been fetched and transmitted in the message body. + HEAD: Representation headers are included in the response without any + message body. + PUT or POST: The resource describing the result of the action is + transmitted in the message body. + TRACE: The message body contains the request as received by the server. + """ + + CREATED = 201 + """ + The request succeeded, and a new resource was created as a result. This is + typically the response sent after POST requests, or some PUT requests. + """ + + ACCEPTED = 202 + """ + The request has been received but not yet acted upon. It is noncommittal, + since there is no way in HTTP to later send an asynchronous response + indicating the outcome of the request. It is intended for cases where + another process or server handles the request, or for batch processing. + """ + + NONAUTHORITATIVE_INFORMATION = 203 + """ + This response code means the returned metadata is not exactly the same as + is available from the origin server, but is collected from a local or a + third-party copy. This is mostly used for mirrors or backups of another + resource. Except for that specific case, the 200 OK response is preferred + to this status. + """ + + NO_CONTENT = 204 + """ + There is no content to send for this request, but the headers are useful. + The user agent may update its cached headers for this resource with the + new ones. + """ + + RESET_CONTENT = 205 + """ + Tells the user agent to reset the document which sent this request. + """ + + PARTIAL_CONTENT = 206 + """ + This response code is used in response to a range request when the client + has requested a part or parts of a resource. + """ + + MULTISTATUS = 207 + """ + Conveys information about multiple resources, for situations where + multiple status codes might be appropriate. + """ + + ALREADY_REPORTED = 208 + """ + Used inside a response element to avoid repeatedly + enumerating the internal members of multiple bindings to the same + collection. + """ + + IM_USED = 226 + """ + The server has fulfilled a GET request for the resource, and the response + is a representation of the result of one or more instance-manipulations + applied to the current instance. + """ + + class HTTPError(Exception): """ Base class for all HTTP errors. @@ -90,7 +167,7 @@ def __init_subclass__(cls, ignore: bool = False) -> None: STATUS_EXCEPTIONS[cls.status_code] = cls cls.description = STATUS_STRINGS[cls.status_code] - def as_response(self) -> BytesResponse: + def as_response(self) -> StrOrBytesResponse[str]: cls = type(self) if cls.status_code == 0: raise TypeError(f"{cls} is not a real response") @@ -100,7 +177,7 @@ def as_response(self) -> BytesResponse: else: message = str(self) - return BytesResponse.from_bytes( + return StrOrBytesResponse.from_content( message.encode("utf-8"), status_code=cls.status_code ) @@ -422,15 +499,13 @@ class InternalServerError(ServerSideError): status_code = 500 - def as_response(self) -> BytesResponse: + def as_response(self) -> StrOrBytesResponse[str]: if not __debug__: return super().as_response() cls = type(self) message = traceback.format_exc() - return BytesResponse.from_bytes( - message.encode("utf-8"), status_code=cls.status_code - ) + return StrOrBytesResponse.from_content(message, status_code=cls.status_code) class NotImplemented(ServerSideError): diff --git a/status.py b/status.py new file mode 100644 index 00000000..e2c47161 --- /dev/null +++ b/status.py @@ -0,0 +1,15 @@ +with open("./status.txt") as f: + lines = f.read().split("\n") + skip_next = False + for index, value in enumerate(lines): + if skip_next: + skip_next = False + continue + + next = lines[index + 1] + status, name = value.split(" ", maxsplit=1) + name = name.replace("-", "").replace(" ", "_").upper() + print(name, "=", status) + print('"""\n' + next + '\n"""') + print() + skip_next = True diff --git a/status.txt b/status.txt new file mode 100644 index 00000000..56895796 --- /dev/null +++ b/status.txt @@ -0,0 +1,20 @@ +200 OK +The request succeeded. The result and meaning of "success" depends on the HTTP method. +201 Created +The request succeeded, and a new resource was created as a result. This is typically the response sent after POST requests, or some PUT requests. +202 Accepted +The request has been received but not yet acted upon. It is noncommittal, since there is no way in HTTP to later send an asynchronous response indicating the outcome of the request. It is intended for cases where another process or server handles the request, or for batch processing. +203 Non-Authoritative Information +This response code means the returned metadata is not exactly the same as is available from the origin server, but is collected from a local or a third-party copy. This is mostly used for mirrors or backups of another resource. Except for that specific case, the 200 OK response is preferred to this status. +204 No Content +There is no content to send for this request, but the headers are useful. The user agent may update its cached headers for this resource with the new ones. +205 Reset Content +Tells the user agent to reset the document which sent this request. +206 Partial Content +This response code is used in response to a range request when the client has requested a part or parts of a resource. +207 Multi-Status +Conveys information about multiple resources, for situations where multiple status codes might be appropriate. +208 Already Reported +Used inside a response element to avoid repeatedly enumerating the internal members of multiple bindings to the same collection. +226 IM Used +The server has fulfilled a GET request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance. From edacecd1dc0e52cffaf0601cf63475dfcae9f630 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:23:19 -0400 Subject: [PATCH 063/188] Remove generation artifacts. --- status.py | 15 --------------- status.txt | 20 -------------------- 2 files changed, 35 deletions(-) delete mode 100644 status.py delete mode 100644 status.txt diff --git a/status.py b/status.py deleted file mode 100644 index e2c47161..00000000 --- a/status.py +++ /dev/null @@ -1,15 +0,0 @@ -with open("./status.txt") as f: - lines = f.read().split("\n") - skip_next = False - for index, value in enumerate(lines): - if skip_next: - skip_next = False - continue - - next = lines[index + 1] - status, name = value.split(" ", maxsplit=1) - name = name.replace("-", "").replace(" ", "_").upper() - print(name, "=", status) - print('"""\n' + next + '\n"""') - print() - skip_next = True diff --git a/status.txt b/status.txt deleted file mode 100644 index 56895796..00000000 --- a/status.txt +++ /dev/null @@ -1,20 +0,0 @@ -200 OK -The request succeeded. The result and meaning of "success" depends on the HTTP method. -201 Created -The request succeeded, and a new resource was created as a result. This is typically the response sent after POST requests, or some PUT requests. -202 Accepted -The request has been received but not yet acted upon. It is noncommittal, since there is no way in HTTP to later send an asynchronous response indicating the outcome of the request. It is intended for cases where another process or server handles the request, or for batch processing. -203 Non-Authoritative Information -This response code means the returned metadata is not exactly the same as is available from the origin server, but is collected from a local or a third-party copy. This is mostly used for mirrors or backups of another resource. Except for that specific case, the 200 OK response is preferred to this status. -204 No Content -There is no content to send for this request, but the headers are useful. The user agent may update its cached headers for this resource with the new ones. -205 Reset Content -Tells the user agent to reset the document which sent this request. -206 Partial Content -This response code is used in response to a range request when the client has requested a part or parts of a resource. -207 Multi-Status -Conveys information about multiple resources, for situations where multiple status codes might be appropriate. -208 Already Reported -Used inside a response element to avoid repeatedly enumerating the internal members of multiple bindings to the same collection. -226 IM Used -The server has fulfilled a GET request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance. From decc47b9b43873c893f67573809b8214f3a7ea4c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:24:43 -0400 Subject: [PATCH 064/188] Normalize exception message capitalization. --- src/view/body.py | 8 ++++---- src/view/router.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/view/body.py b/src/view/body.py index 9a4a3fda..097d4a79 100644 --- a/src/view/body.py +++ b/src/view/body.py @@ -23,14 +23,14 @@ async def body(self) -> bytes: Read the full body from the stream. """ if self.consumed: - raise RuntimeError("body has already been consumed") + raise RuntimeError("Body has already been consumed") self.consumed = True buffer = BytesIO() async for data in self.receive_data(): if __debug__ and not isinstance(data, bytes): - raise TypeError(f"expected bytes, got {data!r}") + raise TypeError(f"Expected bytes, got {data!r}") buffer.write(data) return buffer.getvalue() @@ -41,11 +41,11 @@ async def stream_body(self) -> AsyncGenerator[bytes]: in-memory at a given time. """ if self.consumed: - raise RuntimeError("body has already been consumed") + raise RuntimeError("Body has already been consumed") self.consumed = True async for data in self.receive_data(): if __debug__ and not isinstance(data, bytes): - raise TypeError(f"expected bytes, got {data!r}") + raise TypeError(f"Expected bytes, got {data!r}") yield data diff --git a/src/view/router.py b/src/view/router.py index 60eaf3ea..326c2ef4 100644 --- a/src/view/router.py +++ b/src/view/router.py @@ -62,7 +62,8 @@ def parameter(self, name: str) -> PathNode: return next_node if __debug__ and name != self.path_parameter.name: raise ValueError( - f"path parameter {name} in the same place as {self.path_parameter.name} but with a different name", + f"Path parameter {name} in the same place as" + f" {self.path_parameter.name}, but with a different name", ) return self.path_parameter @@ -131,7 +132,7 @@ def push_route(self, view: RouteView, path: str, method: Method) -> None: parent_node = parent_node.next(part) if parent_node.routes.get(method) is not None: - raise RuntimeError(f"the route {path!r} was already used") + raise RuntimeError(f"The route {path!r} was already used") parent_node.routes[method] = route @@ -145,7 +146,7 @@ def push_error(self, error: int | type[HTTPError], view: RouteView) -> None: elif issubclass(error, HTTPError): error_type = error else: - raise TypeError(f"expected a status code or type, but got {error!r}") + raise TypeError(f"Expected a status code or type, but got {error!r}") self.error_views[error_type] = view From ded0419ffa7744df48fbb5287dce58d7550634df Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:30:23 -0400 Subject: [PATCH 065/188] Remove unused import. --- src/view/response.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/view/response.py b/src/view/response.py index 34afe7a2..6b0be8f9 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -3,7 +3,6 @@ import mimetypes import warnings from collections.abc import AsyncIterator -from contextlib import suppress from dataclasses import dataclass from os import PathLike from typing import AnyStr, AsyncGenerator, Generic, TypeAlias From ade5d1a29b9b8b3773ea05b4fe36d4b0776a7295 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:43:32 -0400 Subject: [PATCH 066/188] Fix status enums. --- src/view/app.py | 15 ++++++++------- src/view/response.py | 10 ++++++++++ src/view/status_codes.py | 4 ++-- tests/test_responses.py | 13 +++++++++++-- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 8225421f..21d29664 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -136,9 +136,6 @@ async def execute_view( except HTTPError as error: logger.info(f"HTTP Error {error.status_code}") raise - except BaseException as exception: - logger.exception(exception) - raise InternalServerError() from exception SingleView = Callable[["Request"], Union["ResponseLike", Awaitable["ResponseLike"]]] @@ -188,10 +185,14 @@ async def _process_request_internal(self, request: Request) -> Response: if found_route is None: raise NotFound() - # Extend instead of replacing? - request.parameters = found_route.path_parameters - response = await execute_view(found_route.route.view) - return wrap_response(response) + try: + # Extend instead of replacing? + request.parameters = found_route.path_parameters + response = await execute_view(found_route.route.view) + return wrap_response(response) + except BaseException as exception: + logger.exception(exception) + raise InternalServerError() from exception async def process_request(self, request: Request) -> Response: with self.request_context(request): diff --git a/src/view/response.py b/src/view/response.py index 6b0be8f9..3c4ca3e5 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -26,6 +26,16 @@ class Response(BodyMixin): status_code: int headers: CIMultiDict[str] + def __post_init__(self) -> None: + if __debug__: + # Avoid circular import issues + from view.status_codes import STATUS_STRINGS + + if self.status_code not in STATUS_STRINGS: + raise ValueError( + f"{self.status_code!r} is not a valid HTTP status code" + ) + async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: """ Process the response as a tuple. This is mainly useful diff --git a/src/view/status_codes.py b/src/view/status_codes.py index 5c5ddda5..ea0acc6d 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -1,7 +1,7 @@ from __future__ import annotations import traceback -from enum import Enum +from enum import IntEnum from typing import ClassVar from view.response import StrOrBytesResponse @@ -74,7 +74,7 @@ } -class Success(Enum): +class Success(IntEnum): OK = 200 """ The request succeeded. The result and meaning of "success" depends on diff --git a/tests/test_responses.py b/tests/test_responses.py index 8a9442cc..46925cf3 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -2,9 +2,9 @@ from view.app import as_app from view.request import Request -from view.response import ResponseLike +from view.response import Response, ResponseLike from view.testing import AppTestClient, into_tuple - +from view.status_codes import Success @pytest.mark.asyncio async def test_str_or_bytes_response(): @@ -26,3 +26,12 @@ def app(request: Request) -> ResponseLike: assert (await into_tuple(client.get("/"))) == (b"Hello", 200, {}) assert (await into_tuple(client.get("/bytes"))) == (b"World", 200, {}) assert (await into_tuple(client.get("/my-string"))) == (b"My string", 200, {}) + + +@pytest.mark.asyncio +async def test_raw_response(): + @as_app + def app(request: Request) -> ResponseLike: + async def stream(): + yield b"test" + return Response(receive_data=stream, status_code=Success.OK, headers={"hello": "world"}) From 479e3efdf264b39782a31db787dacff490003494 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:50:02 -0400 Subject: [PATCH 067/188] Add a test for tuple responses. --- tests/test_requests.py | 7 ++++-- tests/test_responses.py | 48 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/tests/test_requests.py b/tests/test_requests.py index 4cc65440..0233df2e 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -13,7 +13,7 @@ def ok(body: str | bytes) -> tuple[bytes, int, dict[str, str]]: if isinstance(body, str): body = body.encode("utf-8") - return (body, 200,{}) + return (body, 200, {}) @pytest.mark.asyncio @@ -36,7 +36,10 @@ def app(request: Request) -> ResponseLike: client = AppTestClient(app) assert (await into_tuple(client.get("/"))) == ok("Hello") - assert (await into_tuple(client.get("/1", headers={"test-something": "42"}))) == ok("World") + assert (await into_tuple(client.get("/1", headers={"test-something": "42"}))) == ok( + "World" + ) + @pytest.mark.asyncio async def test_manual_request(): diff --git a/tests/test_responses.py b/tests/test_responses.py index 46925cf3..16a9e508 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,10 +1,12 @@ import pytest from view.app import as_app +from view.headers import as_multidict from view.request import Request from view.response import Response, ResponseLike +from view.status_codes import BadRequest, Success from view.testing import AppTestClient, into_tuple -from view.status_codes import Success + @pytest.mark.asyncio async def test_str_or_bytes_response(): @@ -20,7 +22,7 @@ def app(request: Request) -> ResponseLike: elif request.path == "/my-string": return MyString("My string") else: - raise RuntimeError() + raise BadRequest() client = AppTestClient(app) assert (await into_tuple(client.get("/"))) == (b"Hello", 200, {}) @@ -33,5 +35,43 @@ async def test_raw_response(): @as_app def app(request: Request) -> ResponseLike: async def stream(): - yield b"test" - return Response(receive_data=stream, status_code=Success.OK, headers={"hello": "world"}) + yield b"Test" + + return Response( + receive_data=stream, + status_code=Success.CREATED, + headers=as_multidict({"hello": "world"}), + ) + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"Test", 201, {"hello": "world"}) + + +@pytest.mark.asyncio +async def test_tuple_response(): + @as_app + def app(request: Request) -> ResponseLike: + if request.path == "/status": + return "Test", Success.CREATED + elif request.path == "/status-bytes": + return b"Bytes", Success.CREATED + elif request.path == "/headers": + return "Headers", Success.CREATED, {"hello": "world"} + elif request.path == "/headers-bytes": + return b"HBytes", Success.OK, {b"1": b"2"} + else: + raise BadRequest() + + client = AppTestClient(app) + assert (await into_tuple(client.get("/status"))) == (b"Test", 201, {}) + assert (await into_tuple(client.get("/status-bytes"))) == (b"Bytes", 201, {}) + assert (await into_tuple(client.get("/headers"))) == ( + b"Headers", + 201, + {"hello": "world"}, + ) + assert (await into_tuple(client.get("/headers-bytes"))) == ( + b"HBytes", + 200, + {"1": "2"}, + ) From 4c302afef4e11eb723bbabd2fbfcde8dcf832ea2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 10:55:48 -0400 Subject: [PATCH 068/188] Add a test for streaming responses. --- src/view/response.py | 16 +++++++++++----- tests/test_responses.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/view/response.py b/src/view/response.py index 3c4ca3e5..e27028e4 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -2,10 +2,10 @@ import mimetypes import warnings -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass from os import PathLike -from typing import AnyStr, AsyncGenerator, Generic, TypeAlias +from typing import AnyStr, Generic, TypeAlias import aiofiles from loguru import logger @@ -48,7 +48,7 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: StrOrBytes: TypeAlias = str | bytes ResponseTuple: TypeAlias = tuple[StrOrBytes, int] | tuple[StrOrBytes, int, HeadersLike] ResponseLike: TypeAlias = ( - Response | StrOrBytes | AsyncIterator[StrOrBytes] | ResponseTuple + Response | StrOrBytes | AsyncGenerator[StrOrBytes] | Generator[StrOrBytes] | ResponseTuple ) StrPath: TypeAlias = str | PathLike[str] @@ -174,12 +174,18 @@ def wrap_response(response: ResponseLike, /) -> Response: return StrOrBytesResponse.from_content(response) elif isinstance(response, tuple): return _wrap_response_tuple(response) - elif isinstance(response, AsyncIterator): + elif isinstance(response, AsyncGenerator): - async def stream() -> AsyncIterator[bytes]: + 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): + 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}") diff --git a/tests/test_responses.py b/tests/test_responses.py index 16a9e508..cb5aeec1 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,3 +1,4 @@ +import asyncio import pytest from view.app import as_app @@ -75,3 +76,32 @@ def app(request: Request) -> ResponseLike: 200, {"1": "2"}, ) + +@pytest.mark.asyncio +async def test_stream_response_async(): + @as_app + async def app(request: Request) -> ResponseLike: + yield b"This " + await asyncio.sleep(0) + yield "Is " + await asyncio.sleep(0) + yield b"A " + await asyncio.sleep(0) + yield "Test" + await asyncio.sleep(0) + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"This Is A Test", 200, {}) + + +@pytest.mark.asyncio +async def test_stream_response_sync(): + @as_app + def app(request: Request) -> ResponseLike: + yield b"This " + yield "Is " + yield b"A " + yield "Test" + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"This Is A Test", 200, {}) From a17475dfa998033a1c867d06aaf915dba37dd841 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 11:00:29 -0400 Subject: [PATCH 069/188] Add a test for file responses. --- tests/test_responses.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_responses.py b/tests/test_responses.py index cb5aeec1..069c4bbd 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,10 +1,12 @@ import asyncio + +import aiofiles import pytest from view.app import as_app from view.headers import as_multidict from view.request import Request -from view.response import Response, ResponseLike +from view.response import FileResponse, Response, ResponseLike from view.status_codes import BadRequest, Success from view.testing import AppTestClient, into_tuple @@ -77,6 +79,7 @@ def app(request: Request) -> ResponseLike: {"1": "2"}, ) + @pytest.mark.asyncio async def test_stream_response_async(): @as_app @@ -105,3 +108,22 @@ def app(request: Request) -> ResponseLike: client = AppTestClient(app) assert (await into_tuple(client.get("/"))) == (b"This Is A Test", 200, {}) + + +@pytest.mark.asyncio +async def test_file_response(): + async with aiofiles.tempfile.NamedTemporaryFile("w") as file: + await file.write("A" * 10000) + + @as_app + def app(request: Request) -> ResponseLike: + return FileResponse.from_file( + str(file.name), status_code=Success.CREATED, headers={"hello": "world"} + ) + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == ( + b"A" * 10000, + 201, + {"hello": "world", "content-type": "text/plain"}, + ) From da1bad8daa2a9f117a005e8088748955d85c1537 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 11:02:00 -0400 Subject: [PATCH 070/188] Improve a comment. --- src/view/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 21d29664..0130dfb1 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -105,8 +105,8 @@ def run( server's API with the app's `asgi` or `wsgi` method. """ - # production=True, __debug__ should be False. - # production=False, __debug__ should be True. + # If production is True, __debug__ should be False. + # If production is False, __debug__ should be True. if production is __debug__: warnings.warn( f"The app was run with {production=}, but Python's {__debug__=}", From 2d439d20068cda800fee7ba14423bfff45c8fd45 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 11:05:25 -0400 Subject: [PATCH 071/188] Add a sanity check for response tuples. --- src/view/response.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/view/response.py b/src/view/response.py index e27028e4..cea5cf31 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -151,6 +151,10 @@ def _wrap_response_tuple(response: ResponseTuple) -> Response: return StrOrBytesResponse.from_content(response[0]) content = response[0] + if __debug__ and isinstance(content, Response): + raise ValueError(f"Response() objects cannot be used with response" + " tuples. Instead, use the status_code parameter.") + status = response[1] headers: HeadersLike | None = None From dead1bd0e5b0bb3c0fba0923aab7b89f8c8d2b32 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 11:23:03 -0400 Subject: [PATCH 072/188] Add some colors and a sanity check. --- src/view/app.py | 12 ++++++++++-- src/view/response.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 0130dfb1..5c94f391 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -134,7 +134,7 @@ async def execute_view( return result except HTTPError as error: - logger.info(f"HTTP Error {error.status_code}") + logger.opt(colors=True).info(f"HTTP Error {error.status_code}") raise @@ -164,6 +164,9 @@ def as_app(view: SingleView, /) -> SingleViewApp: """ Decorator for using a single function as an app. """ + if not callable(view): + raise TypeError(f"Expected a callable, got {view!r}") + return SingleViewApp(view) @@ -178,7 +181,9 @@ def __init__(self, *, router: Router | None = None) -> None: self.router = router or Router() async def _process_request_internal(self, request: Request) -> Response: - logger.info(f"{request.method} {request.path}") + logger.opt(colors=True).info( + f"{request.method} {request.path}" + ) found_route: FoundRoute | None = self.router.lookup_route( request.path, request.method ) @@ -191,6 +196,9 @@ async def _process_request_internal(self, request: Request) -> Response: response = await execute_view(found_route.route.view) return wrap_response(response) except BaseException as exception: + # Let HTTP errors pass through, so the caller can deal with it + if isinstance(exception, HTTPError): + raise logger.exception(exception) raise InternalServerError() from exception diff --git a/src/view/response.py b/src/view/response.py index cea5cf31..6f95fc74 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -48,7 +48,11 @@ async def as_tuple(self) -> tuple[bytes, int, RequestHeaders]: StrOrBytes: TypeAlias = str | bytes ResponseTuple: TypeAlias = tuple[StrOrBytes, int] | tuple[StrOrBytes, int, HeadersLike] ResponseLike: TypeAlias = ( - Response | StrOrBytes | AsyncGenerator[StrOrBytes] | Generator[StrOrBytes] | ResponseTuple + Response + | StrOrBytes + | AsyncGenerator[StrOrBytes] + | Generator[StrOrBytes] + | ResponseTuple ) StrPath: TypeAlias = str | PathLike[str] @@ -152,8 +156,10 @@ def _wrap_response_tuple(response: ResponseTuple) -> Response: content = response[0] if __debug__ and isinstance(content, Response): - raise ValueError(f"Response() objects cannot be used with response" - " tuples. Instead, use the status_code parameter.") + raise ValueError( + f"Response() objects cannot be used with response" + " tuples. Instead, use the status_code parameter." + ) status = response[1] headers: HeadersLike | None = None @@ -186,6 +192,7 @@ async def stream() -> AsyncGenerator[bytes]: return Response(stream, status_code=200, headers=CIMultiDict()) elif isinstance(response, Generator): + async def stream() -> AsyncGenerator[bytes]: for data in response: yield as_bytes(data) From 62bc8f48c07f8d7f085e2a85dd16a012c458a727 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 11:27:40 -0400 Subject: [PATCH 073/188] Add a simple test for status codes. --- tests/test_responses.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_responses.py b/tests/test_responses.py index 069c4bbd..bdf97f9c 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -127,3 +127,18 @@ def app(request: Request) -> ResponseLike: 201, {"hello": "world", "content-type": "text/plain"}, ) + +@pytest.mark.asyncio +async def test_status_codes(): + @as_app + def app(request: Request) -> ResponseLike: + if request.path == "/": + raise BadRequest() + elif request.path == "/message": + raise BadRequest("Test") + else: + raise RuntimeError() + + client = AppTestClient(app) + assert (await into_tuple(client.get("/"))) == (b"400 Bad Request", 400, {}) + assert (await into_tuple(client.get("/message"))) == (b"Test", 400, {}) From 92dc2b707c5fb953a8efb57080c4a565b7786d71 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 24 Jun 2025 16:48:48 -0400 Subject: [PATCH 074/188] Fix a docstring. --- src/view/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/response.py b/src/view/response.py index 6f95fc74..4ef35653 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -130,7 +130,7 @@ def from_content( headers: HeadersLike | None = None, ) -> StrOrBytesResponse[AnyStr]: """ - Generate a `StringResponse` from a `string` object. + Generate a `StrOrBytesResponse` from either a `str` or `bytes` object. """ if __debug__ and not isinstance(content, (str, bytes)): From 65bcfefe9738518f75165baa93b0d57558cb7ada Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:16:32 -0400 Subject: [PATCH 075/188] Add error codes and explanations. --- src/view/__main__.py | 6 ++++ src/view/app.py | 1 + src/view/exceptions.py | 82 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/view/__main__.py create mode 100644 src/view/exceptions.py diff --git a/src/view/__main__.py b/src/view/__main__.py new file mode 100644 index 00000000..cd9ac480 --- /dev/null +++ b/src/view/__main__.py @@ -0,0 +1,6 @@ +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/src/view/app.py b/src/view/app.py index 5c94f391..6221a4c3 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -10,6 +10,7 @@ from loguru import logger +from view.exceptions import InvalidType from view.request import Method, Request from view.response import Response, ResponseLike, wrap_response from view.router import FoundRoute, Router, RouteView diff --git a/src/view/exceptions.py b/src/view/exceptions.py new file mode 100644 index 00000000..19577835 --- /dev/null +++ b/src/view/exceptions.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import sys +from typing import Any, ClassVar + +__all__ = "ViewError", "explain" + + +def _base_help(code: int) -> str: + return f"Unsure what to do? Run `view explain {code}`" + + +CODES: dict[int, type[ViewError]] = {} +_NEXT_GLOBAL_CODE: int = 1000 + + +class ViewError(Exception): + """ + Base class for all exceptions in view.py + """ + + code: ClassVar[int] = -1 + + def __init_subclass__(cls) -> None: + global _NEXT_GLOBAL_CODE, __all__ + cls.code = _NEXT_GLOBAL_CODE + _NEXT_GLOBAL_CODE += 1 + + __all__ += (cls.__name__,) + + def __init__(self, *msg: str) -> None: + self.message = " ".join(msg) + super().__init__( + f"[Error code {self.code}]: {self.message}" f"{_base_help(self.code)}" + ) + + +class InvalidType(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 this error. + """ + + def __init__(self, *, expected: type, got: Any) -> None: + super().__init__(f"Expected {expected.__name__}, got {got!r}") + + +class UnknownErrorCode(ViewError, ValueError): + """ + `explain()` got an error code that wasn't found. + + This is likely caused by a misinput. + """ + + def __init__(self, code: int) -> None: + super().__init__(f"Got unknown error code: {code}") + + +class DebugOnly(ViewError, RuntimeError): + """ + An operation is only supported under debug mode, likely because some + information (such as a docstring) has been stripped out. + + To fix this, run Python without `-O` or `-OO`. + """ + + +def explain(code: int) -> str: + """ + Get the explanation for a given error code. + """ + error_cls = CODES.get(code) + if error_cls is None: + raise UnknownErrorCode(code) + + if sys.flags.optimize >= 2: + raise DebugOnly("Cannot explain errors when -OO is passed to Python") + + assert error_cls.__doc__ is not None, "Error class should have a docstring" + return error_cls.__doc__ From 321799539ee9ebc1f0adc8eaf9910874d2d9d97b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:23:13 -0400 Subject: [PATCH 076/188] Use InvalidType in some places. --- src/view/exceptions.py | 8 ++++++-- src/view/headers.py | 4 +++- src/view/response.py | 6 ++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/view/exceptions.py b/src/view/exceptions.py index 19577835..d436a065 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -43,8 +43,12 @@ class InvalidType(ViewError, TypeError): was expected would raise this error. """ - def __init__(self, *, expected: type, got: Any) -> None: - super().__init__(f"Expected {expected.__name__}, got {got!r}") + def __init__(self, expected: type | tuple[type, ...], got: Any) -> None: + if isinstance(expected, type): + super().__init__(f"Expected {expected.__name__}, got {got!r}") + else: + expected_string = ", ".join([exception.__name__ for exception in expected]) + super().__init__(f"Expected {expected_string}, got {got!r}") class UnknownErrorCode(ViewError, ValueError): diff --git a/src/view/headers.py b/src/view/headers.py index 6020119a..11e8f741 100644 --- a/src/view/headers.py +++ b/src/view/headers.py @@ -4,6 +4,8 @@ from multidict import CIMultiDict +from view.exceptions import InvalidType + if TYPE_CHECKING: from view.asgi import ASGIHeaders @@ -23,7 +25,7 @@ def as_multidict(headers: HeadersLike | None, /) -> RequestHeaders: return headers if __debug__ and not isinstance(headers, dict): - raise TypeError(f"Invalid headers: {headers}") + raise InvalidType(dict, headers) assert isinstance(headers, dict) multidict = CIMultiDict[str]() diff --git a/src/view/response.py b/src/view/response.py index 4ef35653..bc25632f 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -80,9 +80,7 @@ def from_file( Generate a `FileResponse` from a file path. """ if __debug__ and not isinstance(chunk_size, int): - raise TypeError( - f"expected an integer for chunk_size, but got {chunk_size!r}" - ) + raise InvalidType(int, chunk_size) async def stream(): async with aiofiles.open(path, "rb") as file: @@ -134,7 +132,7 @@ def from_content( """ if __debug__ and not isinstance(content, (str, bytes)): - raise TypeError(f"Expected a string or bytes object, got {content!r}") + raise InvalidType((str, bytes), content) async def stream() -> AsyncGenerator[bytes]: yield as_bytes(content) From fb4677e8b34f89234bdd8a1131cf0ada7e813eb6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:23:36 -0400 Subject: [PATCH 077/188] Simple change to the morals. --- morals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/morals.md b/morals.md index 73e5f565..fd4fedab 100644 --- a/morals.md +++ b/morals.md @@ -5,5 +5,5 @@ 3. But resist the temptation to sprinkle in magic. 4. Remember the Zen of Python (PEP 20). 5. Swiss-army knives don't make good APIs. -6. An independent system is a better one. Global state is evil. +6. An independent system is a better one; global state is evil. 7. A solution should solve a problem that's practical, not just theoretical. From 45323c34c2e01dee5bbe76da17a7d4d1b482416e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:27:14 -0400 Subject: [PATCH 078/188] Improve the __all__ of the HTTP exceptions module. --- src/view/status_codes.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/view/status_codes.py b/src/view/status_codes.py index ea0acc6d..ae27bc45 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -6,6 +6,8 @@ from view.response import StrOrBytesResponse +__all__ = "HTTPError", "Success", "status_exception" + STATUS_EXCEPTIONS: dict[int, type[HTTPError]] = {} STATUS_STRINGS: dict[int, str] = { 100: "Continue", @@ -166,6 +168,9 @@ def __init_subclass__(cls, ignore: bool = False) -> None: assert cls.status_code != 0, cls STATUS_EXCEPTIONS[cls.status_code] = cls cls.description = STATUS_STRINGS[cls.status_code] + + global __all__ + __all__ += (cls.__name__,) def as_response(self) -> StrOrBytesResponse[str]: cls = type(self) From c8a5643f700bf910ee0a298bbbe6fd4f99da0fa2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:30:06 -0400 Subject: [PATCH 079/188] Improve the docstring of InvalidType. --- src/view/exceptions.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/view/exceptions.py b/src/view/exceptions.py index d436a065..c5c83b25 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -37,10 +37,14 @@ def __init__(self, *msg: str) -> None: class InvalidType(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 this error. + 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 + this error. + + In order to fix this, please review the documentation of the function + you're attempting to call and ensure that you are passing it the correct + types. view.py is completely type-safe, so if your editor/IDE is + complaining about something, it is very likely the culprit. """ def __init__(self, expected: type | tuple[type, ...], got: Any) -> None: From 41aed3bfc70cba27cc2cc905118ab398043d2d5d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:33:27 -0400 Subject: [PATCH 080/188] Add docstrings to Request. --- src/view/request.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/view/request.py b/src/view/request.py index 10b16b05..7dab2571 100644 --- a/src/view/request.py +++ b/src/view/request.py @@ -89,10 +89,31 @@ class Request(BodyMixin): """ app: "BaseApp" + """ + The app associated with the HTTP request. + """ + path: str + """ + The path of the request, with the leading '/' and without a trailing '/' + or query string. + """ + method: Method + """ + The HTTP method of the request. See `Method`. + """ + headers: RequestHeaders + """ + A "multi-dictionary" containing the request headers. This is `dict`-like, + but if a header has multiple values, it is represented by a list. + """ + parameters: dict[str, str] = field(init=False, default_factory=dict) + """ + The query string parameters of the HTTP request. + """ def __post_init__(self) -> None: self.path = normalize_route(self.path) From 99dfedaffa7d3542f53db6b7c403617c983987bf Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:37:46 -0400 Subject: [PATCH 081/188] Add __all__ to the headers to module. --- src/view/headers.py | 9 +++++++++ src/view/status_codes.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/view/headers.py b/src/view/headers.py index 11e8f741..db300bc9 100644 --- a/src/view/headers.py +++ b/src/view/headers.py @@ -9,6 +9,15 @@ if TYPE_CHECKING: from view.asgi import ASGIHeaders +__all__ = ( + "RequestHeaders", + "HeadersLike", + "as_multidict", + "asgi_as_multidict", + "multidict_as_asgi", + "wsgi_as_multidict", +) + RequestHeaders: TypeAlias = CIMultiDict[str] HeadersLike = RequestHeaders | dict[str, str] | dict[bytes, bytes] diff --git a/src/view/status_codes.py b/src/view/status_codes.py index ae27bc45..237364d7 100644 --- a/src/view/status_codes.py +++ b/src/view/status_codes.py @@ -168,7 +168,7 @@ def __init_subclass__(cls, ignore: bool = False) -> None: assert cls.status_code != 0, cls STATUS_EXCEPTIONS[cls.status_code] = cls cls.description = STATUS_STRINGS[cls.status_code] - + global __all__ __all__ += (cls.__name__,) From 6a4251bc2233a6fed926be3d6d4cb04bfea0a9b9 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:40:50 -0400 Subject: [PATCH 082/188] Remove the outdated contributing file. --- CONTRIBUTING.md | 109 ------------------------------------------------ 1 file changed, 109 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 9ffacee5..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,109 +0,0 @@ -# Contributing - -**Thanks for wanting to contribute to view.py!** - -view.py is very new and is under heavy development. Whether you're completely new to GitHub contribution or an experienced developer, view.py has something you could help out with. If you want to jump right in, the [issues tab](https://github.com/ZeroIntensity/view.py/labels/beginner) has plenty of good starts. - -If you're stuck, confused, or just don't know what to start with, our [Discord](https://discord.gg/tZAfuWAbm2) is a great resource for questions regarding the internal mechanisms or anything related to view.py development. If you are actively working on an issue, you may ask for the contributor role (assuming it wasn't given to you already). - -## Getting Started - -Assuming you have Git installed, simply clone the repo and install view.py locally (under a virtual environment): - -``` -$ git clone https://github.com/ZeroIntensity/view.py -$ cd view.py -$ python3 -m venv .venv -$ source .venv/bin/activate -$ pip install . -``` - -Congratulations, you have just started your development with view.py! - -Note that this cannot be an editable install (the `-e` flag), as `scikit-build-core` does not support it. - -## Workflow - -First, you should create a new branch: - -``` -$ git switch -c my-cool-feature -``` - -All of your code should be contained on this branch. - -Generally, a simple `test.py` file that starts a view app will be all you need. For example, if you made a change to the router, a way to test it would be: - -```py -from view import new_app - -app = new_app() - -@app.get("/", some_cool_feature_you_made='...') -async def index(): - return "Hello from view.py locally!" - -app.run() -``` - -Note that you do need to `pip install .` to get your changes under the `view` module. However, waiting for pip every time can be a headache. Unless you're modifying the [C API](https://github.com/ZeroIntensity/view.py/tree/master/src/_view), you don't actually need it. Instead, you can test your code via just importing from `src.view`. A `test.py` file **should not** be inside of the `src/view` folder, but instead outside it (i.e. in the same directory as `src`). - -For example, a simple `test.py` could look like: - -```py -# test.py -from src.view import new_app - -app = new_app() - -@app.get("/") -async def index(): - return "Hello from view.py locally!" - -app.run() -``` - -**Note:** Import from `view` internally _does not_ work when using `src.view`. Instead, your imports inside of view.py should look like `from .foo import bar`. For example, if you wanted to import `view.routing.get` from `src/view/app.py`, your import would look like `from .routing import get` - -For debugging purposes, you're also going to want to disable `fancy` and `server_logger` in the configuration: - -```toml -[log] -fancy = false -server_logger = true -``` - -These settings will stop view.py's fancy output from showing, as well as stopping the hijack of the server's logger, and you'll get the raw server output. - -## Writing Tests - -**Note:** You do need to `pip install .` to update the tests, as they import from `view` and not `src.view`. - -View uses [pytest](https://docs.pytest.org/en/8.2.x/) for writing tests, as well as [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/) and [pytest-memray](https://pytest-memray.readthedocs.io/en/latest/). If you have any questions regarding test semantics, it's likely on their docs. The only thing you need to understand for writing tests is how to use the `App.test` API. - -`App.test` is a method that lets you start a virtual server for testing responses. It works like so: - -```py -async def test_my_feature(): - app = new_app() - - @app.get("/") - async def index(): - return "test" - - async with app.test() as test: - res = await test.get("/") - assert res.message == "test" -``` - -In the above code, a server **would not** be started, and instead just virtualized for testing purposes. - -## Updating the changelog - -View follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), so nothing fancy is needed for updating changelogs. Don't put your code under a version, and instead just keep it under the `Unreleased` section. - -## Merging your code - -Now that you're done writing your code and want to share it with the world of view.py, you can make a pull request on GitHub. After tests pass, your code will be merged. - -**Note:** Your code will not be immediatly available on PyPI, as view.py doesn't create a new release automatically. When the release is ready (which might take time), your code will be available under the [view.py package](https://pypi.org/project/view.py) on PyPI. From ad4a07e957d9f9242a3da9168c1c4452d43fc0f5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:46:32 -0400 Subject: [PATCH 083/188] Remove unused import. --- src/view/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index 6221a4c3..5c94f391 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -10,7 +10,6 @@ from loguru import logger -from view.exceptions import InvalidType from view.request import Method, Request from view.response import Response, ResponseLike, wrap_response from view.router import FoundRoute, Router, RouteView From e8a83d341bd019b6156c8095cfb9b43bf367f86b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 11:49:21 -0400 Subject: [PATCH 084/188] Fix a missing import. --- src/view/response.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/view/response.py b/src/view/response.py index bc25632f..7428b9ce 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -12,6 +12,7 @@ from multidict import CIMultiDict from view.body import BodyMixin +from view.exceptions import InvalidType from view.headers import HeadersLike, RequestHeaders, as_multidict __all__ = "Response", "ResponseLike" From 94fb3220a4cc3f2da6d3680bd4c98a54c55c34a7 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 26 Jul 2025 12:05:51 -0400 Subject: [PATCH 085/188] Add some additional logging to the server lifecycle. --- src/view/app.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index 5c94f391..80f8ff90 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -113,9 +113,17 @@ def run( RuntimeWarning, ) + logger.info(f"Serving app on port {port}") self._production = production settings = ServerSettings(self, host=host, port=port, hint=server_hint) - settings.run_app_on_any_server() + try: + settings.run_app_on_any_server() + except KeyboardInterrupt: + logger.info("CTRL^C received, shutting down") + except Exception: + logger.exception("Error in server lifecycle") + finally: + logger.info("Server finished") P = ParamSpec("P") From 6b9bc0e9ad88025c5cb9b4655d547fa485ae1585 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 11 Oct 2025 11:26:59 -0400 Subject: [PATCH 086/188] Add pre-commit. --- .pre-commit-config.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1c4258f6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.1.0 + hooks: + - id: black + language_version: python3.13 From 578a759eb70ec3837c4a9518b16e692bf066bf4b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 26 Nov 2025 20:52:48 -0500 Subject: [PATCH 087/188] Outline for HTML responses. --- sample.py | 2 + src/view/dom.py | 138 ++++++++++++++++++++++++++++++++++++++++ tests/test_responses.py | 1 + 3 files changed, 141 insertions(+) create mode 100644 src/view/dom.py diff --git a/sample.py b/sample.py index 26e680d6..02658467 100644 --- a/sample.py +++ b/sample.py @@ -3,10 +3,12 @@ app = new_app() + @app.get("/") def index(): request = app.current_request() return HTML.from_file("index/test.html") + if __name__ == "__main__": app.run(production=True) diff --git a/src/view/dom.py b/src/view/dom.py new file mode 100644 index 00000000..978dbf69 --- /dev/null +++ b/src/view/dom.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass, field +from queue import LifoQueue +from typing import (AsyncIterator, Callable, ClassVar, Iterator, ParamSpec, + Protocol, Self) + +from view.exceptions import InvalidType +from view.headers import as_multidict +from view.response import Response +from view.router import RouteView + + +def _indent_iterator(iterator: Iterator[str]) -> Iterator[str]: + for line in iterator: + try: + yield " " + line + except TypeError as error: + raise TypeError(f"unexpected line: {line!r}") from error + + +@dataclass(slots=True) +class HTMLNode: + """ + Data class representing an HTML node in the tree. + """ + + node_stack: ClassVar[ContextVar[LifoQueue[HTMLNode]]] = ContextVar("node_stack") + + node_name: str + text: str = "" + attributes: dict[str, str] = field(default_factory=dict) + children: list[HTMLNode] = field(default_factory=list) + + def __enter__(self) -> Self: + stack = self.node_stack.get() + stack.put_nowait(self) + return self + + def __exit__(self, *_) -> None: + stack = self.node_stack.get() + popped = stack.get_nowait() + assert popped is self + + def _html_body(self) -> Iterator[str]: + if self.text != "": + yield self.text + + for child in self.children: + yield from child.as_html() + + def as_html(self) -> Iterator[str]: + """ + Convert this node to actual HTML code. + """ + + if self.node_name != "": + if self.attributes == {}: + yield f"<{self.node_name}>" + else: + yield f"<{self.node_name}" + for name, value in self.attributes.items(): + yield f' {name}="{value}"' + yield ">" + yield from _indent_iterator(self._html_body()) + yield f"" + else: + assert self.attributes == {} + yield from self._html_body() + + +class HTMLNodeConstructor(Protocol): + def __call__(self, text: str = "", /, **attributes: str) -> HTMLNode: ... + + +def _construct_node(name: str, text: str = "", **attributes: str) -> HTMLNode: + if __debug__ and not isinstance(text, str): + raise InvalidType(str, text) + + stack = HTMLNode.node_stack.get() + top = stack.queue[-1] + # Since "class" is a reserved Python keyword, we have to use cls instead + if "cls" in attributes: + attributes["class"] = attributes.pop("cls") + + for attribute in list(attributes.keys()): + if "_" in attribute: + attributes[attribute.replace("_", "-")] = attributes.pop(attribute) + + new_node = HTMLNode(name, text, attributes, []) + top.children.append(new_node) + return new_node + + +def html(text: str = "", **attributes: str) -> HTMLNode: + return _construct_node("html", text, **attributes) + + +P = ParamSpec("P") + + +def html_response( + function: Callable[P, Iterator[HTMLNode] | AsyncIterator[HTMLNode]], +) -> RouteView: + """ + Return a `Response` object from a function returning HTML. + """ + + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: + stack = LifoQueue() + HTMLNode.node_stack.set(stack) + + # Special top-level node that won't be included in the output + special = HTMLNode("") + stack.put_nowait(special) + + iterator = function(*args, **kwargs) + + if isinstance(iterator, AsyncIterator): + # Drain the iterator; we don't care about its contents + async for _ in iterator: + pass + else: + if __debug__ and not isinstance(iterator, Iterator): + raise InvalidType((AsyncIterator, Iterator), iterator) + + for _ in iterator: + pass + + async def stream() -> AsyncIterator[bytes]: + yield b"\n" + for line in special.as_html(): + yield line.encode("utf-8") + b"\n" + + return Response(stream, 200, as_multidict({"content-type": "text/html"})) + + return wrapper diff --git a/tests/test_responses.py b/tests/test_responses.py index bdf97f9c..d3c805d5 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -128,6 +128,7 @@ def app(request: Request) -> ResponseLike: {"hello": "world", "content-type": "text/plain"}, ) + @pytest.mark.asyncio async def test_status_codes(): @as_app From 12e423c67a4a261acf0958199c44d375fae551a4 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 26 Nov 2025 21:02:22 -0500 Subject: [PATCH 088/188] Add support for status codes in HTML responses. --- src/view/dom.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/view/dom.py b/src/view/dom.py index 978dbf69..0c0686d1 100644 --- a/src/view/dom.py +++ b/src/view/dom.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from queue import LifoQueue from typing import (AsyncIterator, Callable, ClassVar, Iterator, ParamSpec, - Protocol, Self) + Protocol, Self, TypeAlias) from view.exceptions import InvalidType from view.headers import as_multidict @@ -98,10 +98,12 @@ def html(text: str = "", **attributes: str) -> HTMLNode: P = ParamSpec("P") +HTMLViewResponseItem: TypeAlias = HTMLNode | int +HTMLView: TypeAlias = Callable[P, AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem]] def html_response( - function: Callable[P, Iterator[HTMLNode] | AsyncIterator[HTMLNode]], + function: HTMLView, ) -> RouteView: """ Return a `Response` object from a function returning HTML. @@ -116,23 +118,31 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: stack.put_nowait(special) iterator = function(*args, **kwargs) + status_code: int | None = None + + def try_item(item: HTMLViewResponseItem) -> None: + nonlocal status_code + + if isinstance(item, int): + if __debug__ and status_code is not None: + raise RuntimeError("Status code was already set") + status_code = item if isinstance(iterator, AsyncIterator): - # Drain the iterator; we don't care about its contents - async for _ in iterator: - pass + async for item in iterator: + try_item(item) else: if __debug__ and not isinstance(iterator, Iterator): raise InvalidType((AsyncIterator, Iterator), iterator) - for _ in iterator: - pass + for item in iterator: + try_item(item) async def stream() -> AsyncIterator[bytes]: yield b"\n" for line in special.as_html(): yield line.encode("utf-8") + b"\n" - return Response(stream, 200, as_multidict({"content-type": "text/html"})) + return Response(stream, status_code or 200, as_multidict({"content-type": "text/html"})) return wrapper From 321694961e30f69487ee925a3c9729a7634c12d0 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 26 Nov 2025 21:02:39 -0500 Subject: [PATCH 089/188] Run formatter. --- src/view/dom.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/view/dom.py b/src/view/dom.py index 0c0686d1..cc130371 100644 --- a/src/view/dom.py +++ b/src/view/dom.py @@ -99,7 +99,9 @@ def html(text: str = "", **attributes: str) -> HTMLNode: P = ParamSpec("P") HTMLViewResponseItem: TypeAlias = HTMLNode | int -HTMLView: TypeAlias = Callable[P, AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem]] +HTMLView: TypeAlias = Callable[ + P, AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem] +] def html_response( @@ -119,7 +121,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: iterator = function(*args, **kwargs) status_code: int | None = None - + def try_item(item: HTMLViewResponseItem) -> None: nonlocal status_code @@ -143,6 +145,8 @@ async def stream() -> AsyncIterator[bytes]: for line in special.as_html(): yield line.encode("utf-8") + b"\n" - return Response(stream, status_code or 200, as_multidict({"content-type": "text/html"})) + return Response( + stream, status_code or 200, as_multidict({"content-type": "text/html"}) + ) return wrapper From e981aacbf46259f8749facb3190822fd33dbbcb2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 26 Nov 2025 21:02:53 -0500 Subject: [PATCH 090/188] Let Black pick the imports. I forgot I didn't want to use isort anymore. --- src/view/app.py | 11 +++++++++-- src/view/asgi.py | 13 +++++++++++-- src/view/dom.py | 12 ++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 80f8ff90..492abc89 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -5,8 +5,15 @@ import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable -from typing import (TYPE_CHECKING, Callable, Iterator, ParamSpec, TypeAlias, - TypeVar, Union) +from typing import ( + TYPE_CHECKING, + Callable, + Iterator, + ParamSpec, + TypeAlias, + TypeVar, + Union, +) from loguru import logger diff --git a/src/view/asgi.py b/src/view/asgi.py index feb0a989..52041cb5 100644 --- a/src/view/asgi.py +++ b/src/view/asgi.py @@ -1,7 +1,16 @@ from __future__ import annotations -from typing import (Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, - NotRequired, TypeAlias, TypedDict) +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Iterable, + Literal, + NotRequired, + TypeAlias, + TypedDict, +) from view.app import BaseApp from view.headers import asgi_as_multidict, multidict_as_asgi diff --git a/src/view/dom.py b/src/view/dom.py index cc130371..b44122dd 100644 --- a/src/view/dom.py +++ b/src/view/dom.py @@ -3,8 +3,16 @@ from contextvars import ContextVar from dataclasses import dataclass, field from queue import LifoQueue -from typing import (AsyncIterator, Callable, ClassVar, Iterator, ParamSpec, - Protocol, Self, TypeAlias) +from typing import ( + AsyncIterator, + Callable, + ClassVar, + Iterator, + ParamSpec, + Protocol, + Self, + TypeAlias, +) from view.exceptions import InvalidType from view.headers import as_multidict From 2277bd5c237a675626cd858bfcce87e31c079567 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 26 Nov 2025 21:11:11 -0500 Subject: [PATCH 091/188] Improve the sample. --- sample.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/sample.py b/sample.py index 02658467..1efc6fbc 100644 --- a/sample.py +++ b/sample.py @@ -1,13 +1,20 @@ from view.app import new_app -from view.responses import HTML +from view.status_codes import Success +from view.dom import html_response, h1, html, head, body, title app = new_app() - @app.get("/") +@html_response def index(): - request = app.current_request() - return HTML.from_file("index/test.html") + yield Success.OK + + with html(): + with head(): + yield title("Hello, view.py!") + + with body(): + yield h1("My first page") if __name__ == "__main__": From 463b5e0dc204b66e22f01c7a0b22b721a4de7084 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Sat, 29 Nov 2025 01:42:22 -0500 Subject: [PATCH 092/188] Add a bunch of HTML nodes. --- src/view/dom.py | 2740 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2731 insertions(+), 9 deletions(-) diff --git a/src/view/dom.py b/src/view/dom.py index b44122dd..a08ce1cd 100644 --- a/src/view/dom.py +++ b/src/view/dom.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from queue import LifoQueue from typing import ( + Any, AsyncIterator, Callable, ClassVar, @@ -12,6 +13,9 @@ Protocol, Self, TypeAlias, + TypedDict, + Unpack, + Literal, ) from view.exceptions import InvalidType @@ -82,29 +86,36 @@ class HTMLNodeConstructor(Protocol): def __call__(self, text: str = "", /, **attributes: str) -> HTMLNode: ... -def _construct_node(name: str, text: str = "", **attributes: str) -> HTMLNode: - if __debug__ and not isinstance(text, str): - raise InvalidType(str, text) +def _construct_node( + name: str, + child_text: str | None = None, + *, + attributes: dict[str, Any], + global_attributes: GlobalAttributes, + data: dict[str, str], +) -> HTMLNode: + if __debug__ and not isinstance(child_text, str): + raise InvalidType(str, child_text) + attributes = {**attributes, **global_attributes} + for name, value in data.items(): + attributes[f"data-{name}"] = value stack = HTMLNode.node_stack.get() top = stack.queue[-1] + # Since "class" is a reserved Python keyword, we have to use cls instead if "cls" in attributes: attributes["class"] = attributes.pop("cls") for attribute in list(attributes.keys()): if "_" in attribute: - attributes[attribute.replace("_", "-")] = attributes.pop(attribute) + attributes[attribute.replace("_", "-")] = str(attributes.pop(attribute)) - new_node = HTMLNode(name, text, attributes, []) + new_node = HTMLNode(name, child_text or "", attributes, []) top.children.append(new_node) return new_node -def html(text: str = "", **attributes: str) -> HTMLNode: - return _construct_node("html", text, **attributes) - - P = ParamSpec("P") HTMLViewResponseItem: TypeAlias = HTMLNode | int HTMLView: TypeAlias = Callable[ @@ -158,3 +169,2714 @@ async def stream() -> AsyncIterator[bytes]: ) return wrapper + + +class GlobalAttributes(TypedDict): + accesskey: str + """Specifies a keyboard shortcut to activate or focus the element""" + + cls: str + """Specifies one or more class names for an element (refers to a class in a style sheet)""" + + contenteditable: Literal["true", "false", "plaintext-only"] + """Specifies whether the content of an element is editable or not""" + + dir: Literal["ltr", "rtl", "auto"] + """Specifies the text direction for the content in an element""" + + draggable: Literal["true", "false", "auto"] + """Specifies whether an element is draggable or not""" + + hidden: bool + """Specifies that an element is not yet, or is no longer, relevant""" + + id: str + """Specifies a unique id for an element""" + + lang: str + """Specifies the language of the element's content""" + + spellcheck: Literal["true", "false"] + """Specifies whether the element is to have its spelling and grammar checked or not""" + + style: str + """Specifies an inline CSS style for an element""" + + tabindex: int + """Specifies the tabbing order of an element""" + + title: str + """Specifies extra information about an element (displayed as a tooltip)""" + + translate: Literal["yes", "no"] + """Specifies whether the content of an element should be translated or not""" + + onabort: str + """Script to be run on abort""" + + onblur: str + """Script to be run when an element loses focus""" + + oncancel: str + """Script to be run when a dialog is canceled""" + + oncanplay: str + """Script to be run when a file is ready to start playing""" + + oncanplaythrough: str + """Script to be run when a file can be played all the way through without pausing""" + + onchange: str + """Script to be run when the value of an element is changed""" + + onclick: str + """Script to be run on a mouse click""" + + onclose: str + """Script to be run when a dialog is closed""" + + oncontextmenu: str + """Script to be run when a context menu is triggered""" + + oncopy: str + """Script to be run when the content of an element is copied""" + + oncuechange: str + """Script to be run when the cue changes in a track element""" + + oncut: str + """Script to be run when the content of an element is cut""" + + ondblclick: str + """Script to be run on a mouse double-click""" + + ondrag: str + """Script to be run when an element is dragged""" + + ondragend: str + """Script to be run at the end of a drag operation""" + + ondragenter: str + """Script to be run when an element has been dragged to a valid drop target""" + + ondragleave: str + """Script to be run when an element leaves a valid drop target""" + + ondragover: str + """Script to be run when an element is being dragged over a valid drop target""" + + ondragstart: str + """Script to be run at the start of a drag operation""" + + ondrop: str + """Script to be run when dragged element is being dropped""" + + ondurationchange: str + """Script to be run when the length of the media changes""" + + onemptied: str + """Script to be run when media resource is suddenly unavailable""" + + onended: str + """Script to be run when the media has reach the end""" + + onerror: str + """Script to be run when an error occurs""" + + onfocus: str + """Script to be run when an element gets focus""" + + oninput: str + """Script to be run when an element gets user input""" + + oninvalid: str + """Script to be run when an element is invalid""" + + onkeydown: str + """Script to be run when a user is pressing a key""" + + onkeypress: str + """Script to be run when a user presses a key""" + + onkeyup: str + """Script to be run when a user releases a key""" + + onload: str + """Script to be run when the element has finished loading""" + + onloadeddata: str + """Script to be run when media data is loaded""" + + onloadedmetadata: str + """Script to be run when meta data is loaded""" + + onloadstart: str + """Script to be run just as the file begins to load""" + + onmousedown: str + """Script to be run when a mouse button is pressed down on an element""" + + onmouseenter: str + """Script to be run when the mouse pointer enters an element""" + + onmouseleave: str + """Script to be run when the mouse pointer leaves an element""" + + onmousemove: str + """Script to be run when the mouse pointer is moving over an element""" + + onmouseout: str + """Script to be run when the mouse pointer moves out of an element""" + + onmouseover: str + """Script to be run when the mouse pointer moves over an element""" + + onmouseup: str + """Script to be run when a mouse button is released over an element""" + + onpaste: str + """Script to be run when content is pasted into an element""" + + onpause: str + """Script to be run when the media is paused""" + + onplay: str + """Script to be run when the media starts playing""" + + onplaying: str + """Script to be run when the media actually has started playing""" + + onprogress: str + """Script to be run when the browser is in the process of getting the media data""" + + onratechange: str + """Script to be run each time the playback rate changes""" + + onreset: str + """Script to be run when a form is reset""" + + onresize: str + """Script to be run when the browser window is being resized""" + + onscroll: str + """Script to be run when an element's scrollbar is being scrolled""" + + onseeked: str + """Script to be run when seeking has ended""" + + onseeking: str + """Script to be run when seeking begins""" + + onselect: str + """Script to be run when the element gets selected""" + + onshow: str + """Script to be run when a context menu is shown""" + + onstalled: str + """Script to be run when the browser is unable to fetch the media data""" + + onsubmit: str + """Script to be run when a form is submitted""" + + onsuspend: str + """Script to be run when fetching the media data is stopped""" + + ontimeupdate: str + """Script to be run when the playing position has changed""" + + ontoggle: str + """Script to be run when the user opens or closes a details element""" + + onvolumechange: str + """Script to be run each time the volume is changed""" + + onwaiting: str + """Script to be run when the media has paused but is expected to resume""" + + onwheel: str + """Script to be run when the mouse wheel rolls up or down over an element""" + + +def a( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + href: str | None = None, + target: Literal["_blank", "_self", "_parent", "_top"] = "_self", + download: str | None = None, + rel: str | None = None, + hreflang: str | None = None, + type: str | None = None, + referrerpolicy: ( + Literal[ + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + ] + | None + ) = None, + ping: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a hyperlink that links to another page or location within the same page""" + return _construct_node( + "a", + child_text=child_text, + attributes={ + "href": href, + "target": target, + "download": download, + "rel": rel, + "hreflang": hreflang, + "type": type, + "referrerpolicy": referrerpolicy, + "ping": ping, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def abbr( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines an abbreviation or acronym, optionally with its expansion""" + return _construct_node( + "abbr", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def address( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines contact information for the author/owner of a document or article""" + return _construct_node( + "address", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def span( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines an inline container with no semantic meaning, used for styling or scripting""" + return _construct_node( + "span", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def strong( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines important text with strong importance (typically bold)""" + return _construct_node( + "strong", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def style( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + media: str | None = None, + type: str = "text/css", + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Contains style information (CSS) for a document""" + return _construct_node( + "style", + child_text=child_text, + attributes={ + "media": media, + "type": type, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def sub( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines subscript text""" + return _construct_node( + "sub", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def summary( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a visible heading for a details element""" + return _construct_node( + "summary", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def sup( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines superscript text""" + return _construct_node( + "sup", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def table( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a table""" + return _construct_node( + "table", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def tbody( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Groups the body content in a table""" + return _construct_node( + "tbody", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def td( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + colspan: int = 1, + rowspan: int = 1, + headers: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a standard data cell in a table""" + return _construct_node( + "td", + child_text=child_text, + attributes={ + "colspan": colspan, + "rowspan": rowspan, + "headers": headers, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def template( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a container for content that should not be rendered when the page loads""" + return _construct_node( + "template", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def textarea( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + name: str | None = None, + rows: int | None = None, + cols: int | None = None, + placeholder: str | None = None, + required: bool = False, + readonly: bool = False, + disabled: bool = False, + maxlength: int | None = None, + minlength: int | None = None, + wrap: Literal["hard", "soft"] = "soft", + autocomplete: Literal["on", "off"] | None = None, + autofocus: bool = False, + form: str | None = None, + dirname: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a multi-line text input control""" + return _construct_node( + "textarea", + child_text=child_text, + attributes={ + "name": name, + "rows": rows, + "cols": cols, + "placeholder": placeholder, + "required": required, + "readonly": readonly, + "disabled": disabled, + "maxlength": maxlength, + "minlength": minlength, + "wrap": wrap, + "autocomplete": autocomplete, + "autofocus": autofocus, + "form": form, + "dirname": dirname, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def tfoot( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Groups the footer content in a table""" + return _construct_node( + "tfoot", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def th( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + colspan: int = 1, + rowspan: int = 1, + headers: str | None = None, + scope: Literal["col", "row", "colgroup", "rowgroup"] | None = None, + abbr: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a header cell in a table""" + return _construct_node( + "th", + child_text=child_text, + attributes={ + "colspan": colspan, + "rowspan": rowspan, + "headers": headers, + "scope": scope, + "abbr": abbr, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def thead( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Groups the header content in a table""" + return _construct_node( + "thead", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def time( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + datetime: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a specific time (or datetime)""" + return _construct_node( + "time", + child_text=child_text, + attributes={ + "datetime": datetime, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def title( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines the title of the document (shown in browser's title bar or tab)""" + return _construct_node( + "title", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def tr( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a row in a table""" + return _construct_node( + "tr", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def track( + *, + data: dict[str, str] | None = None, + kind: Literal[ + "subtitles", "captions", "descriptions", "chapters", "metadata" + ] = "subtitles", + src: str | None, + srclang: str | None = None, + label: str | None = None, + default: bool = False, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines text tracks for media elements (video and audio)""" + return _construct_node( + "track", + attributes={ + "kind": kind, + "src": src, + "srclang": srclang, + "label": label, + "default": default, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def u( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines text with an unarticulated, non-textual annotation (typically underlined)""" + return _construct_node( + "u", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def ul( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines an unordered (bulleted) list""" + return _construct_node( + "ul", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def var( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a variable in programming or mathematical contexts""" + return _construct_node( + "var", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def video( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + src: str | None = None, + controls: bool = False, + width: int | None = None, + height: int | None = None, + autoplay: bool = False, + loop: bool = False, + muted: bool = False, + preload: Literal["auto", "metadata", "none"] = "auto", + poster: str | None = None, + playsinline: bool = False, + crossorigin: Literal["anonymous", "use-credentials"] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Embeds video content in the document""" + return _construct_node( + "video", + child_text=child_text, + attributes={ + "src": src, + "controls": controls, + "width": width, + "height": height, + "autoplay": autoplay, + "loop": loop, + "muted": muted, + "preload": preload, + "poster": poster, + "playsinline": playsinline, + "crossorigin": crossorigin, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def wbr( + *, + 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 {}, + ) + + +def area( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + alt: str | None, + coords: str | None = None, + shape: Literal["default", "rect", "circle", "poly"] = "rect", + href: str | None = None, + target: Literal["_blank", "_self", "_parent", "_top"] | None = None, + download: str | None = None, + rel: str | None = None, + referrerpolicy: ( + Literal[ + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + ] + | None + ) = None, + ping: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a clickable area inside an image map""" + return _construct_node( + "area", + child_text=child_text, + attributes={ + "alt": alt, + "coords": coords, + "shape": shape, + "href": href, + "target": target, + "download": download, + "rel": rel, + "referrerpolicy": referrerpolicy, + "ping": ping, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def article( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines independent, self-contained content that could be distributed independently""" + return _construct_node( + "article", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def aside( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines content aside from the main content (like a sidebar)""" + return _construct_node( + "aside", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def audio( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + src: str | None = None, + controls: bool = False, + autoplay: bool = False, + loop: bool = False, + muted: bool = False, + preload: Literal["auto", "metadata", "none"] = "auto", + crossorigin: Literal["anonymous", "use-credentials"] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Embeds sound content in documents""" + return _construct_node( + "audio", + child_text=child_text, + attributes={ + "src": src, + "controls": controls, + "autoplay": autoplay, + "loop": loop, + "muted": muted, + "preload": preload, + "crossorigin": crossorigin, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def b( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines bold text without extra importance (use for importance)""" + return _construct_node( + "b", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def base( + *, + data: dict[str, str] | None = None, + href: str | None = None, + target: Literal["_blank", "_self", "_parent", "_top"] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Specifies the base URL and/or target for all relative URLs in a document""" + return _construct_node( + "base", + attributes={ + "href": href, + "target": target, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def bdi( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Isolates text that might be formatted in a different direction from other text""" + return _construct_node( + "bdi", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def bdo( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + dir: Literal["ltr", "rtl"] | None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Overrides the current text direction""" + return _construct_node( + "bdo", + child_text=child_text, + attributes={ + "dir": dir, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def blockquote( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + cite: str | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a section that is quoted from another source""" + return _construct_node( + "blockquote", + child_text=child_text, + attributes={ + "cite": cite, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def body( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines the document's body, containing all visible contents""" + return _construct_node( + "body", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def br( + *, + 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 {}, + ) + + +def button( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + type: Literal["button", "submit", "reset"] = "submit", + name: str | None = None, + value: str | None = None, + disabled: bool = False, + form: str | None = None, + formaction: str | None = None, + formenctype: ( + Literal[ + "application/x-www-form-urlencoded", "multipart/form-data", "text/plain" + ] + | None + ) = None, + formmethod: Literal["get", "post"] | None = None, + formnovalidate: bool = False, + formtarget: Literal["_blank", "_self", "_parent", "_top"] | None = None, + autofocus: bool = False, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a clickable button""" + return _construct_node( + "button", + child_text=child_text, + attributes={ + "type": type, + "name": name, + "value": value, + "disabled": disabled, + "form": form, + "formaction": formaction, + "formenctype": formenctype, + "formmethod": formmethod, + "formnovalidate": formnovalidate, + "formtarget": formtarget, + "autofocus": autofocus, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def canvas( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + width: int = 300, + height: int = 150, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Provides a container for graphics that can be drawn using JavaScript""" + return _construct_node( + "canvas", + child_text=child_text, + attributes={ + "width": width, + "height": height, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def caption( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a table caption""" + return _construct_node( + "caption", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def cite( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines the title of a creative work (book, movie, song, etc.)""" + return _construct_node( + "cite", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def code( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Defines a piece of computer code""" + return _construct_node( + "code", + child_text=child_text, + attributes={}, + global_attributes=global_attributes, + data=data or {}, + ) + + +def col( + *, + data: dict[str, str] | None = None, + span: int = 1, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Specifies column properties for each column within a element""" + return _construct_node( + "col", + attributes={ + "span": span, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def colgroup( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + span: int = 1, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Specifies a group of one or more columns in a table for formatting""" + return _construct_node( + "colgroup", + child_text=child_text, + attributes={ + "span": span, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def data( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + value: str | None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Links content with a machine-readable translation""" + return _construct_node( + "data", + child_text=child_text, + attributes={ + "value": value, + }, + global_attributes=global_attributes, + data=data or {}, + ) + + +def datalist( + child_text: str = "", + /, + *, + data: dict[str, str] | None = None, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Contains a set of