diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 846da00d..63836798 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -8,8 +8,6 @@ body: description: > Give a clear description on what's going wrong and how to reproduce it, if possible. - This should only be for *bugs*, not crashes. For that, use the crash template. - value: | ```py # Add your code here, if needed diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml deleted file mode 100644 index 29c96ad5..00000000 --- a/.github/ISSUE_TEMPLATE/crash.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Crash report -description: Submit a crash report -labels: ["crash"] -body: - - type: textarea - 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). - - This should only be for *crashes* not bugs. For that, use the bug template. - - value: | - ```py - # Add your code here, if needed - ``` - validations: - required: true - - type: input - attributes: - label: "Version:" - value: | - What version(s) of view.py are you using? - validations: - required: true - - type: dropdown - attributes: - label: "Operating system(s) tested on:" - multiple: true - options: - - Linux - - macOS - - Windows - - Other - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/discussion.yml b/.github/ISSUE_TEMPLATE/discussion.yml deleted file mode 100644 index 6e5cc7da..00000000 --- a/.github/ISSUE_TEMPLATE/discussion.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Discussion -description: General discussion about something related to the development of view.py. This can be used for meta issues, proposals for big changes, etc. -labels: ["discussion"] -body: - - type: textarea - attributes: - label: "Discussion:" - description: > - Write anything relevant to this discussion! - value: | - Hello world... - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index ecdb10ff..d2e18223 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -7,7 +7,7 @@ body: value: | # Feature Proposal - This is where you should propose a *new* feature to view.py. New features should not make breaking changes. This should be for something totally new, such as adding a new function. For general improvements to existing features, make an improvement request. + This is where you should propose a new feature to view.py. - type: textarea attributes: label: "Proposal:" diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml deleted file mode 100644 index 661792fd..00000000 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Improvement -description: Suggest an improvement to an existing feature. -labels: ["improvement"] -body: - - type: markdown - attributes: - value: | - # Improvement - - An improvement proposal should be related to an *existing* feature. - - type: textarea - attributes: - label: "Description:" - description: > - Outline what needs to be improved and why? Be sure to include an example API if necessary. - value: | - ```py - # Example API - ``` - validations: - required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c6c3b6a9..cfcd9c0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,91 +4,55 @@ on: push: tags: - v* + branches: + - master + paths: + - "src/**" + pull_request: + branches: + - master concurrency: group: build-${{ github.head_ref }} cancel-in-progress: true -env: - SKBUILD_CMAKE_BUILD_TYPE: "Release" - CIBW_SKIP: > - pp* - jobs: - binary-wheels-standard: - name: Binary wheels for ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - - steps: - - uses: actions/checkout@v2 - with: - # Fetch all tags - fetch-depth: 0 - - - name: Build wheels - uses: pypa/cibuildwheel@v2.16.5 - env: - CIBW_ARCHS_MACOS: x86_64 - HATCH_BUILD_HOOKS_ENABLE: "true" - - - uses: actions/upload-artifact@v2 - with: - name: artifacts - path: wheelhouse/*.whl - if-no-files-found: error - - binary-wheels-arm: - name: Build Linux wheels for ARM + pure-python-wheel-and-sdist: + name: Build a pure Python wheel and source distribution runs-on: ubuntu-latest - # Very slow, no need to run on PRs - if: > - github.event_name == 'push' - && - (github.ref == 'refs/heads/master' || startsWith(github.event.ref, 'refs/tags')) steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: - # Fetch all tags fetch-depth: 0 - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - with: - platforms: arm64 + - name: Install build dependencies + run: python -m pip install --upgrade build - - name: Build wheels - uses: pypa/cibuildwheel@v2.15.0 - env: - CIBW_ARCHS_LINUX: aarch64 - HATCH_BUILD_HOOKS_ENABLE: "true" + - name: Build + run: python -m build - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: artifacts - path: wheelhouse/*.whl + path: dist/* if-no-files-found: error publish: name: Publish release needs: - - binary-wheels-standard - - binary-wheels-arm + - pure-python-wheel-and-sdist runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') steps: - - uses: actions/download-artifact@v2 + - uses: actions/download-artifact@v4 with: name: artifacts path: dist - name: Push build artifacts to PyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 + uses: pypa/gh-action-pypi-publish@v1.12.4 with: skip_existing: true user: __token__ diff --git a/.github/workflows/memory_check.yml b/.github/workflows/memory_check.yml deleted file mode 100644 index 5dd646fa..00000000 --- a/.github/workflows/memory_check.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Memory Problems - -on: - push: - branches: - - master - pull_request: - branches: - - master - -env: - PYTHONUNBUFFERED: "1" - FORCE_COLOR: "1" - PYTHONIOENCODING: "utf8" - PYTHONMALLOC: "malloc" - PYTHONDEVMODE: "1" - HATCH_VERBOSE: "1" - -jobs: - run: - name: Run memory tests on Ubuntu - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Set up Python 3.12 - uses: actions/setup-python@v2 - with: - python-version: 3.12 - - - name: Install Pytest - run: | - pip install pytest pytest-asyncio pytest-memray - shell: bash - - - name: Build view.py - run: pip install .[full] - - - name: Install Valgrind - run: sudo apt-get update && sudo apt-get -y install valgrind - - - name: Run tests with Valgrind - run: valgrind --error-exitcode=1 pytest - - - name: Run tests with Memray - run: pytest --enable-leak-tracking diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57b079e0..939b7667 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v2 @@ -37,11 +37,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install Pytest - run: pip install pytest pytest-asyncio - - - name: Build view.py - run: pip install .[full] + - name: Install Hatch + run: pip install hatch - name: Run tests - run: pytest -x + run: hatch test 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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9d5a40..d5b8c764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,176 +7,4 @@ 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. - -## [1.0.0-alpha10] - 2024-5-26 - -- Added the `view docs` command -- Reworked internal logging API and changed default logger format -- Added the `websocket` and `app.websocket` router -- Made hijack optional in fancy mode -- Added a startup message -- Added support for `daphne` and `hypercorn` as servers -- Added documentation for `view.env` and added environment variables to configuration -- Removed dead file `src/view/nodes.py` and `src/view/compiler.py` -- Added `patterns` loader to `view init` -- Updated internal C API structure -- Added `build` to config -- Added the `build_app` and `build_steps` functions -- `Route.__call__` is now used internally over `Route.func` -- Added the `to_response` function -- Improved type checking on functions decorated with a router function -- Added preset values to the `view.toml` generated by `view init` -- Fixed fancy logging not exiting after a `KeyboardInterrupt` -- Added prettier input prompts to `view init` -- Added `HTML.from_file` -- **Breaking Change:** Middleware functions must now take `call_next` - -## [1.0.0-alpha9] - 2024-2-4 - -- Fixed `template` attribute with the `view` template renderer -- Added the `context` decorator and the `Context` type -- Added the `headers` parameter to functions on `TestingContext` -- Modified some behavior of automatic route inputs -- Fixed syntax errors in `view init` -- Added `Route.middleware` -- Routes with equivalent paths but different methods now return `405 Method Not Allowed` when accessed -- Added `route` and `App.route` -- Added docstrings to router functions -- Added the `JSON` response class -- Added the `custom` body translate strategy -- Made `method` a keyword-only parameter in `path` -- Added the `extract_path` utility -- Added the `view build` command -- Added `App.template` -- Route errors now display the error message when `dev` is `True` -- Changed exception rendering in route errors to use the `rich` renderer -- Added `compile_type` and `TCValidator` -- Added `markdown` and `App.markdown` -- Added the `Error` class -- Added the `error_class` parameter to both `new_app` and `App` -- Added the `ERROR_CODES` constant -- Completely rewrote docs -- **Breaking Change:** The `body` parameter in `Response` is now required - -## [1.0.0-alpha8] - 2024-1-21 - -- Added optional dependencies for `databases` and `templates` -- Added environment prefixes for database configuration -- Added `templates` and `TemplatesConfig` to config -- Added the `templates` function -- Added support for `attrs` in type validation -- Added documentation for caching -- Added the `cache_rate` parameter to routers -- Removed `psutil` and `plotext` as a global dependency -- Added `fancy` optional dependencies -- Fixed route inputs with synchronous routes -- **Breaking Change:** Route inputs are now applied in the order of the decorator call as it appears in code - -## [1.0.0-alpha7] - 2023-12-7 - -**Quick Patch Release** - -- Remerged new `view init` command. - -## [1.0.0-alpha6] - 2023-11-30 - -- Added `get_app` -- Added documentation generation -- Added database support (NOT FINISHED) -- Removed `attempt_import` and `MissingLibraryError` -- Added support for lists in type validation -- Added support for implicit query parameters -- Renamed `debug` to `enable_debug` -- Added `debug`, `info`, `warning`, `error`, and `critical` logging functions -- Added `InvalidRouteError`, `DuplicateRouteError`, `ViewInternalError`, and `ConfigurationError` -- Renamed `EnvironmentError` to `BadEnvironmentError` -- Added logging functions to `App` -- Changed environment prefixes for configuration -- Rewrote documentation -- Added `patterns` loader -- Added handling of relative paths in the configuration setting `loader_path` -- Added exists validation to `loader_path` -- Add path to `PATH` environment variable during loading -- Upgraded `view init` - -## [1.0.0-alpha5] - 2023-09-24 - -- Added `app.query` and `app.body` -- Patched warning with starting app from incorrect filename -- Updated `__all__` for `routing.py` -- Added `view.Response` and `view.HTML` -- Fixed `__view_result__` -- Added support for `__view_body__` and `__view_construct__` -- Added support for Pydantic, `NamedTuple`, and dataclasses for type validation -- Support for direct union types (i.e. `str | int`, `Union[str, int]`) on type validation -- Added support for non async routes - -## [1.0.0-alpha4] - 2023-09-10 - -- Added type validation (without support for `__view_body__`) -- Patched query strings on app testing -- Added tests for query and body parameters -- Patched body parameters -- Documented type validation -- Patched bodies with testing - -## [1.0.0-alpha3] - 2023-09-9 - -- Patched header responses -- Added tests for headers -- Updated repr for `Route` -- Patched responses with three values -- Documented responses and result protocol - -## [1.0.0-alpha2] - 2023-09-9 - -- Added `App.test()` -- Added warning when filename does not match `app_path` -- Added more tests -- Upgrade CIBW to work on 3.11 - -## [1.0.0-alpha1] - 2023-08-17 - -Initial. +- Removed everything from prior releases! diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 226c9871..00000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,30 +0,0 @@ -cmake_minimum_required(VERSION 3.15...3.26) -project(${SKBUILD_PROJECT_NAME} LANGUAGES C) - -message(STATUS "CMAKE_BUILD_TYPE set to '${CMAKE_BUILD_TYPE}'") - -# Source code -file(GLOB _view_SRC - ${CMAKE_CURRENT_SOURCE_DIR}/src/_view/*.c -) -MESSAGE(DEBUG ${_view_SRC}) - -# Find Python -find_package( - Python - COMPONENTS Interpreter Development.Module - REQUIRED) - -# 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}) - -MESSAGE(STATUS "Everything looks good, let's install!") -# Install extension module -install(TARGETS _view DESTINATION .) 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. diff --git a/README.md b/README.md index 472fb7c7..2a7d0d25 100644 --- a/README.md +++ b/README.md @@ -8,95 +8,8 @@

The Batteries-Detachable Web Framework

-
- Tests - Valgrind - 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 -``` +This is a work-in-progress! ## 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/SECURITY.md b/SECURITY.md deleted file mode 100644 index c0cb3397..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,14 +0,0 @@ -# Security Policy - -## Supported Versions - -| Version | Supported | -| -------------- | ------------------ | -| < Latest Alpha | :x: | -| Latest Alpha | :white_check_mark: | - -view.py does not have any production releases! As of now, all releases are alpha versions. Security patches are not made for previous alpha releases! - -## Reporting a Vulnerability - -Depending on the severity of the vulnerability, you can make an issue on the [issue tracker](https://github.com/ZeroIntensity/pyawaitable/issues), or send an email explaining the vulnerability to 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 d2aa1fe7..00000000 Binary files a/assets/logo_theme_dark.png and /dev/null differ diff --git a/assets/logo_theme_light.png b/assets/logo_theme_light.png deleted file mode 100644 index 3189113c..00000000 Binary files a/assets/logo_theme_light.png and /dev/null differ 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/hatch.toml b/hatch.toml index ddc50be8..b8c43655 100644 --- a/hatch.toml +++ b/hatch.toml @@ -2,50 +2,17 @@ path = "src/view/__about__.py" [build.targets.sdist] -only-include = ["src/", "_view.pyi"] +only-include = ["src/"] [build.targets.wheel] 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 - -[build.targets.wheel.hooks.scikit-build.cmake] -source-dir = "." -build-type = "Debug" -verbose = true - -[build.targets.wheel.hooks.scikit-build.install] -strip = false - [envs.hatch-test] -features = ["full"] -dev-mode = false -dependencies = [ - "coverage", - "pytest", - "pytest-memray", +extra-args = ["-vv"] +extra-dependencies = [ "pytest-asyncio", + "requests" ] -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" +[[envs.hatch-test.matrix]] +python = ["3.14", "3.13", "3.12", "3.11", "3.10"] 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/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/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/pyproject.toml b/pyproject.toml index c05ad45c..2b055e33 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 = ["hatchling>=1"] build-backend = "hatchling.build" [project] @@ -9,60 +9,21 @@ readme = "README.md" requires-python = ">=3.9" keywords = [] authors = [ - { name = "ZeroIntensity", email = "zintensitydev@gmail.com" }, + { name = "Peter Bierma", email = "peter@python.org" }, ] classifiers = [ "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "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 = ["multidict~=6.5", "loguru~=0.7", "aiofiles~=24.1", "typing_extensions>=4"] 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" -] [project.urls] Documentation = "https://view.zintensity.dev" 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 cc1923a4..00000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -3.8 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 index 656205a5..14c2cba5 100644 --- a/src/view/__about__.py +++ b/src/view/__about__.py @@ -1,2 +1,3 @@ -__version__ = "1.0.0-alpha11" +__version__ = "0.1.0-dev" +__author__ = "Peter Bierma " __license__ = "MIT" diff --git a/src/view/__init__.py b/src/view/__init__.py index 1f23f851..cf1822a3 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -1,45 +1,12 @@ -# 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() -``` +view.py - The Batteries-Detachable Web Framework. """ -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 * +from view import cache as cache +from view import core as core +from view import dom as dom +from view import javascript as javascript +from view import run as run +from view import testing as testing +from view import utils as utils +from view.__about__ import * diff --git a/src/view/__main__.py b/src/view/__main__.py index 6e1a5e24..bc2fdfce 100644 --- a/src/view/__main__.py +++ b/src/view/__main__.py @@ -1,482 +1,5 @@ -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}`") +def main(): + print() if __name__ == "__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/cache.py b/src/view/cache.py new file mode 100644 index 00000000..09198d64 --- /dev/null +++ b/src/view/cache.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import math +import time +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Generic, ParamSpec, TypeVar + +from multidict import CIMultiDict + +from view.core.response import Response, TextResponse, ViewResult, wrap_view_result + +__all__ = ("in_memory_cache",) + +T = TypeVar("T", bound=ViewResult) +P = ParamSpec("P") + + +@dataclass(slots=True) +class BaseCache(ABC, Generic[P, T]): + """ + Base class for caches. + """ + + callable: Callable[P, T] + + @abstractmethod + def invalidate(self) -> None: + """ + Invalidate the cache. + """ + + @abstractmethod + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response: ... + + +@dataclass(slots=True, frozen=True) +class _CachedResponse: + body: bytes + headers: CIMultiDict[str] + status: int + last_reset: float + + @classmethod + async def from_response(cls, response: Response) -> _CachedResponse: + body = await response.body() + return cls(body, response.headers, response.status_code, time.time()) + + def as_response(self) -> Response: + return TextResponse.from_content( + self.body, status_code=self.status, headers=self.headers + ) + + +@dataclass(slots=True) +class InMemoryCache(BaseCache[P, T]): + """ + Wrapper class for a cache stored in memory. + """ + + callable: Callable[P, T] + reset_frequency: float + _cached_response: _CachedResponse | None = field(repr=False, default=None) + + def invalidate(self) -> None: + self._cached_response = None + + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response: + if self._cached_response is None: + result = await wrap_view_result(self.callable(*args, **kwargs)) + cached = await _CachedResponse.from_response(result) + self._cached_response = cached + return cached.as_response() + + if (time.time() - self._cached_response.last_reset) > self.reset_frequency: + self.invalidate() + return await self(*args, **kwargs) + + return self._cached_response.as_response() + + +def minutes(number: int, /) -> int: + return number * 60 + + +def seconds(number: int, /) -> int: + return number + + +def hours(number: int, /) -> int: + return minutes(60) * number + + +def days(number: int, /) -> int: + return hours(24) * number + + +def in_memory_cache( + reset_frequency: int | None = None, +) -> Callable[[Callable[P, T]], InMemoryCache[P, T]]: + """ + Decorator to cache the result from a given view in-memory. + """ + + def decorator_factory(function: Callable[P, T], /) -> InMemoryCache[P, T]: + return InMemoryCache(function, reset_frequency=reset_frequency or math.inf) + + return decorator_factory 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/core/__init__.py b/src/view/core/__init__.py new file mode 100644 index 00000000..7dd1b91b --- /dev/null +++ b/src/view/core/__init__.py @@ -0,0 +1,6 @@ +from view.core import app as app +from view.core import headers as headers +from view.core import request as request +from view.core import response as response +from view.core import router as router +from view.core import status_codes as status_codes diff --git a/src/view/core/app.py b/src/view/core/app.py new file mode 100644 index 00000000..c6463980 --- /dev/null +++ b/src/view/core/app.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import contextlib +import contextvars +import warnings +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Iterator +from multiprocessing import Process +from pathlib import Path +from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar + +from loguru import logger + +from view.core.request import Method, Request +from view.core.response import ( + FileResponse, + Response, + ResponseLike, + ViewResult, + wrap_view_result, +) +from view.core.router import FoundRoute, Route, Router, RouteView +from view.core.status_codes import Forbidden, HTTPError, InternalServerError, NotFound +from view.utils import reraise + +if TYPE_CHECKING: + from view.run.asgi import ASGIProtocol + from view.run.wsgi import WSGIProtocol + +__all__ = "BaseApp", "as_app", "App" + +T = TypeVar("T") +P = ParamSpec("P") + + +class BaseApp(ABC): + """Base view.py application.""" + + _CURRENT_APP = contextvars.ContextVar["BaseApp"]("Current app being used.") + + def __init__(self): + self._request = contextvars.ContextVar[Request]( + "The current request being handled." + ) + self._production: bool | None = None + + @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__ + + return self._production + + @contextlib.contextmanager + def request_context(self, request: Request) -> Iterator[None]: + """ + Enter a context for the given request. + """ + with logger.contextualize(request=request): + app_token = self._CURRENT_APP.set(self) + request_token = self._request.set(request) + try: + yield + finally: + self._request.reset(request_token) + self._CURRENT_APP.reset(app_token) + + @classmethod + def current_app(cls) -> BaseApp: + return cls._CURRENT_APP.get() + + def current_request(self) -> Request: + """ + Get the current request being handled. + """ + return self._request.get() + + @abstractmethod + async def process_request(self, request: Request) -> Response: + """ + Get the response from the server for a given request. + """ + + def wsgi(self) -> WSGIProtocol: + """ + Get the WSGI callable for the app. + """ + from view.run.wsgi import wsgi_for_app + + return wsgi_for_app(self) + + def asgi(self) -> ASGIProtocol: + """ + Get the ASGI callable for the app. + """ + from view.run.asgi import asgi_for_app + + return asgi_for_app(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. + """ + from view.run.servers import ServerSettings + + # If production is True, then __debug__ should be False. + # If production is False, then __debug__ should be True. + if production is __debug__: + warnings.warn( + f"The app was run with {production=}, but Python's {__debug__=}", + RuntimeWarning, + ) + + logger.info(f"Serving app on http://localhost:{port}") + self._production = production + settings = ServerSettings(self, host=host, port=port, hint=server_hint) + try: + settings.run_app_on_any_server() + except KeyboardInterrupt: + logger.info("CTRL^C received, shutting down") + except Exception: + logger.exception("Error in server lifecycle") + finally: + logger.info("Server finished") + + def run_detached( + self, + *, + host: str = "localhost", + port: int = 5000, + production: bool = False, + server_hint: str | None = None, + ) -> Process: + """ + Run the app in a separate process. This means that the server is + killable. + """ + + process = Process( + target=self.run, + kwargs={ + "host": host, + "port": port, + "production": production, + "server_hint": server_hint, + }, + ) + process.start() + return process + + +async def _execute_view_internal( + view: Callable[P, ViewResult], + *args: P.args, + **kwargs: P.kwargs, +) -> Response: + logger.debug(f"Executing view: {view}") + try: + result = view(*args, **kwargs) + return await wrap_view_result(result) + except HTTPError as error: + logger.opt(colors=True).info(f"HTTP Error {error.status_code}") + raise + + +async def execute_view( + view: Callable[P, ViewResult], *args: P.args, **kwargs: P.kwargs +) -> Response: + try: + return await _execute_view_internal(view, *args, **kwargs) + except BaseException as exception: + # Let HTTP errors pass through, so the caller can deal with it + if isinstance(exception, HTTPError): + raise + logger.exception(exception) + if __debug__: + raise InternalServerError.from_current_exception() + else: + raise InternalServerError() + + +SingleView = Callable[["Request"], ViewResult] + + +class SingleViewApp(BaseApp): + """ + Application with a single view function that + processes all requests. + """ + + def __init__(self, view: SingleView) -> None: + super().__init__() + self.view = view + + async def process_request(self, request: Request) -> Response: + with self.request_context(request): + try: + return await execute_view(self.view, request) + except HTTPError as error: + return error.as_response() + + +def as_app(view: SingleView, /) -> SingleViewApp: + """ + Decorator for using a single function as an app. + """ + if __debug__ and not callable(view): + raise InvalidType(view, Callable) + + return SingleViewApp(view) + + +RouteDecorator: TypeAlias = Callable[[RouteView], Route] +SubRouterView: TypeAlias = Callable[[str], ResponseLike | Awaitable[ResponseLike]] +SubRouterViewT = TypeVar("SubRouterViewT", bound=SubRouterView) + + +class App(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 _process_request_internal(self, request: Request) -> Response: + logger.opt(colors=True).info( + f"{request.method} {request.path}" + ) + found_route: FoundRoute | None = self.router.lookup_route( + request.path, request.method + ) + if found_route is None: + raise NotFound() + + # Extend instead of replacing? + request.path_parameters = found_route.path_parameters + return await execute_view(found_route.route.view) + + async def process_request(self, request: Request) -> Response: + with self.request_context(request): + 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 error.as_response() + + def route(self, path: str, /, *, method: Method) -> RouteDecorator: + """ + Decorator interface for adding a route to the app. + """ + + if __debug__ and not isinstance(path, str): + raise InvalidType(path, str) + + if __debug__ and not isinstance(method, Method): + raise InvalidType(method, Method) + + def decorator(view: RouteView, /) -> Route: + route = self.router.push_route(view, path, method) + return route + + 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], / + ) -> Callable[[RouteView], RouteView]: + """ + Decorator interface for adding an error handler to the app. + """ + + def decorator(view: RouteView, /) -> RouteView: + self.router.push_error(status, view) + return view + + return decorator + + def subrouter(self, path: str) -> Callable[[SubRouterViewT], SubRouterViewT]: + if __debug__ and not isinstance(path, str): + raise InvalidType(path, str) + + def decorator(function: SubRouterViewT, /) -> SubRouterViewT: + if __debug__ and not callable(function): + raise InvalidType(Callable, function) + + def router_function(path_from_url: str) -> Route: + def route() -> ResponseLike | Awaitable[ResponseLike]: + return function(path_from_url) + + return Route(route, path_from_url, Method.GET) + + self.router.push_subrouter(router_function, path) + return function + + return decorator + + def static_files(self, path: str, directory: str | Path) -> None: + if __debug__ and not isinstance(directory, (str, Path)): + raise InvalidType(directory, str, Path) + + directory = Path(directory) + + @self.subrouter(path) + def serve_static_file(path_from_url: str) -> ResponseLike: + file = directory / path_from_url + if not file.is_file(): + raise NotFound() + + if not file.is_relative_to(directory): + raise Forbidden() + + with reraise(Forbidden, OSError): + return FileResponse.from_file(file) diff --git a/src/view/core/body.py b/src/view/core/body.py new file mode 100644 index 00000000..c4b31a05 --- /dev/null +++ b/src/view/core/body.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, field +from io import BytesIO +from typing import Any, TypeAlias + +from view.exceptions import InvalidType, ViewError + +__all__ = ("BodyMixin",) + +BodyStream: TypeAlias = Callable[[], AsyncIterator[bytes]] + + +class BodyAlreadyUsed(ViewError): + """ + The body was already used on this response. + + Generally, this means that the same response object was executed multiple + times. + """ + + +class InvalidJSON(ViewError): + """ + The body is not valid JSON data or something went wrong when parsing it. + + If this occurred when parsing the body for a request, the fix is + usually to reraise this with an error 400 (Bad Request). + """ + + +@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 BodyAlreadyUsed("Body has already been consumed") + + self.consumed = True + + buffer = BytesIO() + async for data in self.receive_data(): + if __debug__ and not isinstance(data, bytes): + raise InvalidType(data, bytes) + buffer.write(data) + + return buffer.getvalue() + + async def json( + self, *, parse_function: Callable[[str], dict[str, Any]] = json.loads + ) -> dict[str, Any]: + """ + Read the body as JSON data. + """ + + data = await self.body() + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + raise InvalidJSON("Body does not contain valid UTF-8 data") from error + + try: + return parse_function(text) + except Exception as error: + raise InvalidJSON("Failed to parse JSON") from error + + async def stream_body(self) -> AsyncIterator[bytes]: + """ + Incrementally stream the body, not keeping the whole thing + in-memory at a given time. + """ + if self.consumed: + raise BodyAlreadyUsed("Body has already been consumed") + + self.consumed = True + + async for data in self.receive_data(): + if __debug__ and not isinstance(data, bytes): + raise InvalidType(data, bytes) + yield data diff --git a/src/view/core/headers.py b/src/view/core/headers.py new file mode 100644 index 00000000..f7b1db93 --- /dev/null +++ b/src/view/core/headers.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeAlias + +from multidict import CIMultiDict + +from view.exceptions import InvalidType + +if TYPE_CHECKING: + from view.run.asgi import ASGIHeaders + +__all__ = ( + "RequestHeaders", + "HeadersLike", + "as_multidict", + "asgi_as_multidict", + "multidict_as_asgi", + "wsgi_as_multidict", +) + +RequestHeaders: TypeAlias = CIMultiDict[str] +HeadersLike: TypeAlias = RequestHeaders | Mapping[str, str] | Mapping[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[str]() + + if isinstance(headers, CIMultiDict): + return headers + + if __debug__ and not isinstance(headers, Mapping): + raise InvalidType(Mapping, headers) + + assert isinstance(headers, dict) + multidict = CIMultiDict[str]() + 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: Mapping[str, Any]) -> RequestHeaders: + """ + Convert WSGI headers (from the `environ`) to a case-insensitive multidict. + """ + headers = CIMultiDict[str]() + + for key, value in environ.items(): + if not key.startswith("HTTP_"): + continue + + assert isinstance(value, str) + key = key.removeprefix("HTTP_").replace("_", "-").lower() + headers[key] = value + + return headers + + +def asgi_as_multidict(headers: ASGIHeaders, /) -> RequestHeaders: + """ + Convert ASGI headers to a case-insensitive multidict. + """ + multidict = CIMultiDict[str]() + + 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/core/request.py b/src/view/core/request.py new file mode 100644 index 00000000..9384da88 --- /dev/null +++ b/src/view/core/request.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import urllib.parse +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import auto +from typing import TYPE_CHECKING +import sys +from multidict import MultiDict + +from view.core.body import BodyMixin +from view.core.headers import RequestHeaders +from view.core.router import normalize_route + +if TYPE_CHECKING: + from view.core.app import BaseApp + +__all__ = "Method", "Request" + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +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. + """ + + 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) +class Request(BodyMixin): + """ + Dataclass representing an HTTP request. + """ + + 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. + """ + + query_parameters: MultiDict[str] + """ + The query string parameters of the HTTP request. + """ + + path_parameters: Mapping[str, str] = field(default_factory=dict, init=False) + """ + The path parameters of this request. + """ + + def __post_init__(self) -> None: + self.path = normalize_route(self.path) + + +def extract_query_parameters(query_string: str | bytes) -> MultiDict[str]: + """ + Extract a query string from a URL and return it as a multidict. + """ + if isinstance(query_string, bytes): + query_string = query_string.decode("utf-8") + + assert isinstance(query_string, str), query_string + parsed = urllib.parse.parse_qsl(query_string) + result = MultiDict() + + for key, value in parsed: + result[key] = value + + return result diff --git a/src/view/core/response.py b/src/view/core/response.py new file mode 100644 index 00000000..cd022d02 --- /dev/null +++ b/src/view/core/response.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import json +import mimetypes +import warnings +import sys +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from dataclasses import dataclass +from os import PathLike +from typing import Any, AnyStr, Generic, TypeAlias + +import aiofiles +from loguru import logger +from multidict import CIMultiDict + +from view.core.body import BodyMixin +from view.core.headers import HeadersLike, RequestHeaders, as_multidict +from view.exceptions import InvalidType, ViewError + +__all__ = "Response", "ViewResult", "ResponseLike" + + +@dataclass(slots=True) +class Response(BodyMixin): + """ + Low-level dataclass representing a response from a view. + """ + + status_code: int + headers: CIMultiDict[str] + + def __post_init__(self) -> None: + if __debug__: + # Avoid circular import issues + from view.core.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 + for assertions in testing. + """ + return (await self.body(), self.status_code, self.headers) + + +# 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 + | AsyncGenerator[StrOrBytes] + | Generator[StrOrBytes] + | _ResponseTuple +) +ViewResult = ResponseLike | Awaitable[ResponseLike] +StrPath: TypeAlias = str | PathLike[str] + + +def _guess_file_type(path: Path, /) -> list[str]: + if sys.version_info >= (3, 13): + return mimetypes.guess_file_type(path)[0] or "text/plain" + else: + return mimetypes.guess_type(path)[0] or "text/plain" + + +@dataclass(slots=True) +class FileResponse(Response): + """ + Response containing a file, streamed asynchronously. + """ + + path: StrPath + + @classmethod + def from_file( + cls, + path: StrPath, + /, + *, + status_code: int = 200, + headers: HeadersLike | None = None, + chunk_size: int = 512, + content_type: str | None = None, + ) -> FileResponse: + """ + Generate a `FileResponse` from a file path. + """ + if __debug__ and not isinstance(chunk_size, int): + raise InvalidType(chunk_size, int) + + async def stream(): + async with aiofiles.open(path, "rb") as file: + length = chunk_size + while length == chunk_size: + data = await file.read(chunk_size) + length = len(data) + yield data + + multidict = as_multidict(headers) + if "content-type" not in multidict: + content_type = content_type or _guess_file_type(path) + multidict["content-type"] = content_type + + 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 TextResponse(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, + ) -> TextResponse[AnyStr]: + """ + Generate a `TextResponse` from either a `str` or `bytes` object. + """ + + if __debug__ and not isinstance(content, (str, bytes)): + raise InvalidType(content, str, bytes) + + async def stream() -> AsyncGenerator[bytes]: + yield _as_bytes(content) + + return cls(stream, status_code, as_multidict(headers), content) + + +@dataclass(slots=True) +class JSONResponse(Response): + content: dict[str, Any] + parsed_data: str + + @classmethod + def from_content( + cls, + content: dict[str, Any], + *, + parse_function: Callable[[dict[str, Any]], str] = json.dumps, + status_code: int = 200, + headers: HeadersLike | None = None, + ) -> JSONResponse: + data = parse_function(content) + + async def stream() -> AsyncGenerator[bytes]: + yield data.encode("utf-8") + + return cls( + content=content, + parsed_data=data, + headers=as_multidict(headers), + status_code=status_code, + receive_data=stream, + ) + + +class InvalidResponse(ViewError): + """ + A view returned an object that view.py doesn't know how to convert into a + response object. + """ + + +def _wrap_response_tuple(response: _ResponseTuple) -> Response: + if __debug__ and response == (): + raise InvalidResponse("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, + ) + return TextResponse.from_content(response[0]) + + content = response[0] + if __debug__ and isinstance(content, Response): + raise InvalidResponse( + f"Response() objects cannot be used with response" + " tuples. Instead, use the status_code and/or headers parameter(s)." + ) + + status = response[1] + headers: HeadersLike | None = None + + if len(response) > 2: + headers = response[2] + + if __debug__ and len(response) > 3: + raise InvalidResponse(f"Got excess data in response tuple {response[3:]!r}") + + return TextResponse.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!r}") + if isinstance(response, Response): + return response + elif isinstance(response, (str, bytes)): + return TextResponse.from_content(response) + elif isinstance(response, tuple): + return _wrap_response_tuple(response) + elif isinstance(response, AsyncGenerator): + + async def stream() -> AsyncGenerator[bytes]: + async for data in response: + yield _as_bytes(data) + + return Response(stream, status_code=200, headers=CIMultiDict()) + elif isinstance(response, Generator): + + 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}") + + +async def wrap_view_result(result: ViewResult, /) -> Response: + """ + Turn the raw result of a view, which might be a coroutine, into a usable + `Response` object. + """ + if isinstance(result, Awaitable): + result = await result + + return _wrap_response(result) diff --git a/src/view/core/router.py b/src/view/core/router.py new file mode 100644 index 00000000..874385f0 --- /dev/null +++ b/src/view/core/router.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, MutableMapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypeAlias + +from view.core.status_codes import HTTPError, status_exception +from view.exceptions import InvalidType, ViewError + +if TYPE_CHECKING: + from view.core.request import Method + from view.core.response import ResponseLike + +__all__ = "Route", "Router" + + +RouteView: TypeAlias = Callable[[], "ResponseLike | Awaitable[ResponseLike]"] + + +@dataclass(slots=True, frozen=True) +class Route: + """ + Dataclass representing a route in a router. + """ + + view: RouteView + path: str + method: Method + + def __truediv__(self, other: object) -> str: + if not isinstance(other, str): + return NotImplemented + + path = f"{self.path}/{other}" + return normalize_route(path) + + +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 + + +class DuplicateRoute(ViewError): + """ + The router found multiple views for the same route. + + Generally, this means that a typo is present, or perhaps the user + misunderstood something about route normalization. For example, "/" and "" + are equivalent to the router. + """ + + +SubRouter: TypeAlias = Callable[[str], "Route"] + + +@dataclass(slots=True) +class PathNode: + """ + A node in the "path tree". + """ + + name: str + routes: MutableMapping[Method, Route] = field(default_factory=dict) + children: MutableMapping[str, PathNode] = field(default_factory=dict) + path_parameter: PathNode | None = None + subrouter: SubRouter | None = 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 DuplicateRoute( + f"Path parameter {name} is in the same place as" + f" {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: + """ + Is this part a path parameter? + """ + return part.startswith("{") and part.endswith("}") + + +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: MutableMapping[str, str] = field(default_factory=dict) + + +@dataclass(slots=True, frozen=True) +class Router: + """ + Standard router that supports error and route lookups. + """ + + error_views: MutableMapping[type[HTTPError], RouteView] = field( + default_factory=dict + ) + parent_node: PathNode = field(default_factory=lambda: PathNode(name="")) + + def _get_node_for_path(self, path: str, *, allow_path_parameters: bool) -> PathNode: + if __debug__ and not isinstance(path, str): + raise InvalidType(path, str) + + path = normalize_route(path) + parent_node = self.parent_node + parts = path.split("/") + + for part in parts: + if is_path_parameter(part): + if not allow_path_parameters: + raise RuntimeError("Path parameters are not allowed here") + parent_node = parent_node.parameter(extract_path_parameter(part)) + else: + parent_node = parent_node.next(part) + + return parent_node + + def push_route(self, view: RouteView, path: str, method: Method) -> Route: + """ + Register a view with the router. + """ + + if __debug__ and not callable(view): + raise InvalidType(view, Callable) + + node = self._get_node_for_path(path, allow_path_parameters=True) + if node.routes.get(method) is not None: + raise DuplicateRoute( + f"The route {path!r} was already used for method {method.value}" + ) + + route = Route(view=view, path=path, method=method) + node.routes[method] = route + return route + + def push_subrouter(self, subrouter: SubRouter, path: str) -> None: + """ + Register a subrouter that will be used to delegate parsing when nothing + else is found. + """ + + if __debug__ and not callable(subrouter): + raise InvalidType(subrouter, Callable) + + node = self._get_node_for_path(path, allow_path_parameters=False) + if node.subrouter is not None: + raise DuplicateRoute(f"The route {path!r} already has a subrouter") + + node.subrouter = subrouter + + 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) + elif issubclass(error, HTTPError): + error_type = error + else: + raise InvalidType(error, int, type) + + self.error_views[error_type] = view + + def lookup_route(self, path: str, method: Method, /) -> FoundRoute | None: + """ + Look up the view for the route. + """ + path_parameters: dict[str, str] = {} + assert normalize_route(path) == path, "Request() should've normalized the route" + + parent_node = self.parent_node + parts = path.split("/") + + for index, part in enumerate(parts): + node = parent_node.children.get(part) + if node is None: + node = parent_node.path_parameter + if node is None: + if parent_node.subrouter is not None: + remaining = "/".join(parts[index:]) + return FoundRoute(parent_node.subrouter(remaining)) + + # This route doesn't exist + return None + + path_parameters[node.name] = part + + parent_node = node + + final_route: Route | None = parent_node.routes.get(method) + if final_route is None: + if parent_node.subrouter is not None: + return FoundRoute(parent_node.subrouter("/")) + return None + + return FoundRoute(final_route, path_parameters) + + def lookup_error(self, error: type[HTTPError], /) -> RouteView | None: + """ + Look up the error view for the given HTTP error. + """ + return self.error_views.get(error) diff --git a/src/view/core/status_codes.py b/src/view/core/status_codes.py new file mode 100644 index 00000000..d7234163 --- /dev/null +++ b/src/view/core/status_codes.py @@ -0,0 +1,631 @@ +from __future__ import annotations + +import traceback +from enum import IntEnum +from typing import ClassVar +import sys + +from view.core.response import TextResponse + +__all__ = "HTTPError", "Success", "status_exception" + +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 Success(IntEnum): + 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. + """ + + +HTTP_ERROR_TRACEBACK_NOTE = """ +----- + +If you're seeing this message, then something has gone horribly wrong. +HTTP errors should never be in a real traceback, and instead only +be used for indicating something to a caller. If you meant to +access the message included with this HTTP error, use the +.message attribute. + +----- +""" + + +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 + description: ClassVar[str] = "" + + def __init__(self, *msg: object) -> None: + if msg: + self.message: str | None = " ".join([str(item) for item in msg]) + else: + self.message = None + + if sys.version_info < (3, 11): + super().__init__(*msg, HTTP_ERROR_TRACEBACK_NOTE) + else: + super().__init__(*msg) + super().add_note(HTTP_ERROR_TRACEBACK_NOTE) + + def __init_subclass__(cls, ignore: bool = False) -> None: + if not ignore: + assert cls.status_code != 0, cls + STATUS_EXCEPTIONS[cls.status_code] = cls + cls.description = STATUS_STRINGS[cls.status_code] + + global __all__ + __all__ += (cls.__name__,) + + def as_response(self) -> TextResponse[str]: + cls = type(self) + if cls.status_code == 0: + raise TypeError(f"{cls} is not a real response") + + if self.message is None: + message = f"{cls.status_code} {cls.description}" + else: + message = self.message + + return TextResponse.from_content(message, status_code=cls.status_code) + + +def status_exception(status: int) -> type[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 + + return status_type + + +class ClientSideError(HTTPError, ignore=True): + """ + Base class for all HTTP errors between 400 and 500. + """ + + +class ServerSideError(HTTPError, ignore=True): + """ + Base class for all HTTP errors between 500 and 600. + """ + + +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 TooEarly(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 + + @classmethod + def from_current_exception(cls) -> InternalServerError: + message = traceback.format_exc() + return cls(message) + + +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/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/dom/__init__.py b/src/view/dom/__init__.py new file mode 100644 index 00000000..b2e3e632 --- /dev/null +++ b/src/view/dom/__init__.py @@ -0,0 +1,3 @@ +from view.dom import components as components +from view.dom import core as core +from view.dom import primitives as primitives diff --git a/src/view/dom/components.py b/src/view/dom/components.py new file mode 100644 index 00000000..09890716 --- /dev/null +++ b/src/view/dom/components.py @@ -0,0 +1,115 @@ +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from functools import wraps +from typing import NoReturn, ParamSpec + +from view.dom.core import HTMLNode, HTMLTree +from view.dom.primitives import base, body, html, link, meta, script +from view.dom.primitives import title as title_node + +__all__ = "Children", "Component", "component" + + +class Children(HTMLNode): + """ + Sentinel class marking where to inject the body in a component. + """ + + def __init__(self) -> None: + super().__init__("_children_node", is_real=False) + + def __enter__(self) -> NoReturn: + raise RuntimeError("Children() cannot be used in a 'with' block") + + def as_html(self) -> str: + raise RuntimeError( + "Children() cannot be turned into HTML -- this is likely a bug with view.py" + ) + + +@dataclass(frozen=True) +class Component: + """ + A node with an "injectable" body. + """ + + generator: HTMLTree + + def __enter__(self) -> None: + stack = HTMLNode.node_stack.get() + for node in self.generator: + if isinstance(node, Children): + capture_node = HTMLNode.virtual("capture") + stack.put_nowait(capture_node) + return + + def __exit__(self, *_) -> None: + stack = HTMLNode.node_stack.get() + capture_node = stack.get_nowait() + assert not capture_node.is_real + + parent_node = stack.queue[-1] + parent_node.children.extend(capture_node.children) + + for node in self.generator: + if __debug__ and isinstance(node, Children): + raise RuntimeError( + "Cannot use Children() multiple times for the same component" + ) + + +P = ParamSpec("P") + + +def component(function: Callable[P, HTMLTree]) -> Callable[P, Component]: + """ + Make a function usable as an HTML node. + """ + + @wraps(function) + def inner(*args: P.args, **kwargs: P.kwargs) -> Component: + return Component(function(*args, **kwargs)) + + return inner + + +@component +def page( + title: str, + *, + language: str = "en", + stylesheets: Iterable[str] | None = None, + scripts: Iterable[str] | None = [], + description: str | None = None, + keywords: Iterable[str] | None = None, + author: str | None = None, + page_url: str | None = None, +) -> HTMLTree: + """ + Common layout for an HTML page. + """ + with html(lang=language): + yield meta(charset="utf-8") + yield meta(name="viewport", content="width=device-width, initial-scale=1.0") + + if description is not None: + yield meta(name="description", content=description) + + if keywords is not None: + yield meta(name="keywords", content=",".join(keywords)) + + if author is not None: + yield meta(name="author", content=author) + + if page_url is not None: + yield link(rel="canonical", href=page_url) + yield base(href=page_url) + + for stylesheet in stylesheets or []: + yield link(rel="stylesheet", href=stylesheet) + + yield title_node(title) + for script_url in scripts or []: + yield script(src=script_url, defer=True) + with body(): + yield Children() diff --git a/src/view/dom/core.py b/src/view/dom/core.py new file mode 100644 index 00000000..91e4fc2e --- /dev/null +++ b/src/view/dom/core.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import uuid +from collections.abc import ( + AsyncIterator, + Callable, + Iterator, + MutableMapping, + MutableSequence, +) +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from io import StringIO +from queue import LifoQueue +from typing import ClassVar, ParamSpec, TypeAlias + +from view.core.headers import as_multidict +from view.core.response import Response +from view.core.router import RouteView +from view.exceptions import InvalidType +from view.javascript import SupportsJavaScript + +__all__ = ("HTMLNode", "html_response") + +HTMLTree: TypeAlias = Iterator["HTMLNode"] + + +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(SupportsJavaScript): + """ + Data class representing an HTML node in the tree. + """ + + node_stack: ClassVar[ContextVar[LifoQueue[HTMLNode]]] = ContextVar("node_stack") + + node_name: str + """ + Name of the node as it will appear in the HTML. For example, in an + node, this will be the string 'html'. + """ + + is_real: bool = True + """ + Whether this node will actually be included in the output. Generally, most + nodes will be rendered, but there are a few special types of nodes that + are only used during the rendering process. + """ + + text: str = "" + """ + The direct text of this node, not including any other children. + """ + + attributes: MutableMapping[str, str] = field(default_factory=dict) + """ + Dictionary containing attribute names and values as they will be rendered + in the final output. + """ + + children: MutableSequence[HTMLNode] = field(default_factory=list) + """ + All nodes that are a direct descendant of this node. + """ + + @classmethod + def virtual(cls, name: str) -> HTMLNode: + """ + Create a new "fake" node. + """ + + return cls(f"__view_internal_{name}_node", is_real=False) + + @classmethod + def new( + cls, + name: str, + *, + child_text: str | None = None, + attributes: MutableMapping[str, str] | None = None, + ) -> HTMLNode: + """ + Create a new node that will be included in the final HTML. + """ + return cls( + name, + is_real=True, + text=child_text or "", + attributes=attributes or {}, + children=[], + ) + + def __enter__(self) -> None: + stack = self.node_stack.get() + stack.put_nowait(self) + + def __exit__(self, *_) -> None: + stack = self.node_stack.get() + popped = stack.get_nowait() + assert popped is self, popped + + def _html_body(self) -> Iterator[str]: + if self.text != "": + yield self.text + + for child in self.children: + yield from child.as_html_stream() + + def as_html_stream(self) -> Iterator[str]: + """ + Convert this node to actual HTML code, streaming each line individually. + """ + + if self.is_real: + 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 == {}, self.attributes + yield from self._html_body() + + def as_html(self) -> str: + """ + Convert this node to HTML code. + """ + + buffer = StringIO() + for line in self.as_html_stream(): + buffer.write(line + "\n") + + return buffer.getvalue() + + def as_javascript(self) -> str: + element_id = self.attributes.setdefault("id", uuid.uuid4().hex) + return f"document.getElementById({element_id!r})" + + +@contextmanager +def html_context() -> HTMLTree: + """ + Enter a context in which HTML nodes can be created under a fresh tree. + """ + stack = LifoQueue() + token = HTMLNode.node_stack.set(stack) + + tree = HTMLNode.virtual("tree_top") + stack.put_nowait(tree) + + try: + yield tree + finally: + HTMLNode.node_stack.reset(token) + + +P = ParamSpec("P") +HTMLViewResponseItem: TypeAlias = HTMLNode | int +HTMLViewResult = AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem] +HTMLView: TypeAlias = Callable[P, HTMLViewResult] + + +def html_response( + function: HTMLView, +) -> RouteView: + """ + Return a `Response` object from a function returning HTML. + """ + + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response: + with html_context() as parent: + 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): + async for item in iterator: + try_item(item) + else: + if __debug__ and not isinstance(iterator, Iterator): + raise InvalidType(iterator, AsyncIterator, Iterator) + + for item in iterator: + try_item(item) + + async def stream() -> AsyncIterator[bytes]: + yield b"\n" + for line in parent.as_html_stream(): + yield line.encode("utf-8") + b"\n" + + return Response( + stream, status_code or 200, as_multidict({"content-type": "text/html"}) + ) + + return wrapper diff --git a/src/view/dom/primitives.py b/src/view/dom/primitives.py new file mode 100644 index 00000000..1a7cdfc6 --- /dev/null +++ b/src/view/dom/primitives.py @@ -0,0 +1,2813 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal, TypedDict +from typing_extensions import NotRequired, Unpack + +from view.dom.core import HTMLNode +from view.exceptions import InvalidType + + +class ImplicitDefault(str): + """ + Sentinel class to mark a default value in an HTML node as "implicit", and + thus does not need to be included in the rendered output. + """ + + +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 ((child_text is not None) and not isinstance(child_text, str)): + raise InvalidType(child_text, str) + + for attribute_name, value in attributes.copy().items(): + if value in {None, False}: + attributes.pop(attribute_name) + elif value is True: + attributes[attribute_name] = "" + + attributes = {**attributes, **global_attributes} + for data_name, value in data.items(): + if __debug__ and not isinstance(value, str): + raise InvalidType(value, str) + + attributes[f"data-{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_name, value in attributes.copy().items(): + if isinstance(value, ImplicitDefault): + attributes.pop(attribute_name) + continue + + if "_" in attribute_name: + attributes[attribute_name.replace("_", "-")] = str( + attributes.pop(attribute_name) + ) + + new_node = HTMLNode.new(name, child_text=child_text, attributes=attributes) + top.children.append(new_node) + return new_node + + +class GlobalAttributes(TypedDict): + accesskey: NotRequired[str] + """Specifies a keyboard shortcut to activate or focus the element""" + + cls: NotRequired[str] + """Specifies one or more class names for an element (refers to a class in a style sheet)""" + + contenteditable: NotRequired[Literal["true", "false", "plaintext-only"]] + """Specifies whether the content of an element is editable or not""" + + dir: NotRequired[Literal["ltr", "rtl", "auto"]] + """Specifies the text direction for the content in an element""" + + draggable: NotRequired[Literal["true", "false", "auto"]] + """Specifies whether an element is draggable or not""" + + hidden: NotRequired[bool] + """Specifies that an element is not yet, or is no longer, relevant""" + + id: NotRequired[str] + """Specifies a unique id for an element""" + + lang: NotRequired[str] + """Specifies the language of the element's content""" + + spellcheck: NotRequired[Literal["true", "false"]] + """Specifies whether the element is to have its spelling and grammar checked or not""" + + style: NotRequired[str] + """Specifies an inline CSS style for an element""" + + tabindex: NotRequired[int] + """Specifies the tabbing order of an element""" + + title: NotRequired[str] + """Specifies extra information about an element (displayed as a tooltip)""" + + translate: NotRequired[Literal["yes", "no"]] + """Specifies whether the content of an element should be translated or not""" + + onabort: NotRequired[str] + """Script to be run on abort""" + + onblur: NotRequired[str] + """Script to be run when an element loses focus""" + + oncancel: NotRequired[str] + """Script to be run when a dialog is canceled""" + + oncanplay: NotRequired[str] + """Script to be run when a file is ready to start playing""" + + oncanplaythrough: NotRequired[str] + """Script to be run when a file can be played all the way through without pausing""" + + onchange: NotRequired[str] + """Script to be run when the value of an element is changed""" + + onclick: NotRequired[str] + """Script to be run on a mouse click""" + + onclose: NotRequired[str] + """Script to be run when a dialog is closed""" + + oncontextmenu: NotRequired[str] + """Script to be run when a context menu is triggered""" + + oncopy: NotRequired[str] + """Script to be run when the content of an element is copied""" + + oncuechange: NotRequired[str] + """Script to be run when the cue changes in a track element""" + + oncut: NotRequired[str] + """Script to be run when the content of an element is cut""" + + ondblclick: NotRequired[str] + """Script to be run on a mouse double-click""" + + ondrag: NotRequired[str] + """Script to be run when an element is dragged""" + + ondragend: NotRequired[str] + """Script to be run at the end of a drag operation""" + + ondragenter: NotRequired[str] + """Script to be run when an element has been dragged to a valid drop target""" + + ondragleave: NotRequired[str] + """Script to be run when an element leaves a valid drop target""" + + ondragover: NotRequired[str] + """Script to be run when an element is being dragged over a valid drop target""" + + ondragstart: NotRequired[str] + """Script to be run at the start of a drag operation""" + + ondrop: NotRequired[str] + """Script to be run when dragged element is being dropped""" + + ondurationchange: NotRequired[str] + """Script to be run when the length of the media changes""" + + onemptied: NotRequired[str] + """Script to be run when media resource is suddenly unavailable""" + + onended: NotRequired[str] + """Script to be run when the media has reach the end""" + + onerror: NotRequired[str] + """Script to be run when an error occurs""" + + onfocus: NotRequired[str] + """Script to be run when an element gets focus""" + + oninput: NotRequired[str] + """Script to be run when an element gets user input""" + + oninvalid: NotRequired[str] + """Script to be run when an element is invalid""" + + onkeydown: NotRequired[str] + """Script to be run when a user is pressing a key""" + + onkeypress: NotRequired[str] + """Script to be run when a user presses a key""" + + onkeyup: NotRequired[str] + """Script to be run when a user releases a key""" + + onload: NotRequired[str] + """Script to be run when the element has finished loading""" + + onloadeddata: NotRequired[str] + """Script to be run when media data is loaded""" + + onloadedmetadata: NotRequired[str] + """Script to be run when meta data is loaded""" + + onloadstart: NotRequired[str] + """Script to be run just as the file begins to load""" + + onmousedown: NotRequired[str] + """Script to be run when a mouse button is pressed down on an element""" + + onmouseenter: NotRequired[str] + """Script to be run when the mouse pointer enters an element""" + + onmouseleave: NotRequired[str] + """Script to be run when the mouse pointer leaves an element""" + + onmousemove: NotRequired[str] + """Script to be run when the mouse pointer is moving over an element""" + + onmouseout: NotRequired[str] + """Script to be run when the mouse pointer moves out of an element""" + + onmouseover: NotRequired[str] + """Script to be run when the mouse pointer moves over an element""" + + onmouseup: NotRequired[str] + """Script to be run when a mouse button is released over an element""" + + onpaste: NotRequired[str] + """Script to be run when content is pasted into an element""" + + onpause: NotRequired[str] + """Script to be run when the media is paused""" + + onplay: NotRequired[str] + """Script to be run when the media starts playing""" + + onplaying: NotRequired[str] + """Script to be run when the media actually has started playing""" + + onprogress: NotRequired[str] + """Script to be run when the browser is in the process of getting the media data""" + + onratechange: NotRequired[str] + """Script to be run each time the playback rate changes""" + + onreset: NotRequired[str] + """Script to be run when a form is reset""" + + onresize: NotRequired[str] + """Script to be run when the browser window is being resized""" + + onscroll: NotRequired[str] + """Script to be run when an element's scrollbar is being scrolled""" + + onseeked: NotRequired[str] + """Script to be run when seeking has ended""" + + onseeking: NotRequired[str] + """Script to be run when seeking begins""" + + onselect: NotRequired[str] + """Script to be run when the element gets selected""" + + onshow: NotRequired[str] + """Script to be run when a context menu is shown""" + + onstalled: NotRequired[str] + """Script to be run when the browser is unable to fetch the media data""" + + onsubmit: NotRequired[str] + """Script to be run when a form is submitted""" + + onsuspend: NotRequired[str] + """Script to be run when fetching the media data is stopped""" + + ontimeupdate: NotRequired[str] + """Script to be run when the playing position has changed""" + + ontoggle: NotRequired[str] + """Script to be run when the user opens or closes a details element""" + + onvolumechange: NotRequired[str] + """Script to be run each time the volume is changed""" + + onwaiting: NotRequired[str] + """Script to be run when the media has paused but is expected to resume""" + + onwheel: NotRequired[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"] | ImplicitDefault + ) = ImplicitDefault("_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 = ImplicitDefault("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 | ImplicitDefault = ImplicitDefault(1), + rowspan: int | ImplicitDefault = ImplicitDefault(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"] | ImplicitDefault = ImplicitDefault("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 | ImplicitDefault = ImplicitDefault(1), + rowspan: int | ImplicitDefault = ImplicitDefault(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"] + | ImplicitDefault + ) = ImplicitDefault("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"] | ImplicitDefault = ImplicitDefault( + "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"] | ImplicitDefault + ) = ImplicitDefault("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"] | ImplicitDefault = ImplicitDefault( + "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, + **global_attributes: Unpack[GlobalAttributes], +) -> HTMLNode: + """Overrides the current text direction""" + return _construct_node( + "bdo", + child_text=child_text, + attributes={}, + 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"] | ImplicitDefault = ImplicitDefault( + "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 | ImplicitDefault = ImplicitDefault(300), + height: int | ImplicitDefault = ImplicitDefault(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 | ImplicitDefault = ImplicitDefault(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 | ImplicitDefault = ImplicitDefault(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