Repository: ZeroIntensity/view.py Branch: main Commit: d356396c5450 Files: 57 Total size: 218.3 KB Directory structure: gitextract_2hoa3wei/ ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ ├── config.yml │ │ └── feature.yml │ └── workflows/ │ ├── build.yml │ ├── lint.yml │ ├── tests.yml │ └── triage.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs/ │ ├── Makefile │ ├── api.rst │ ├── conf.py │ ├── index.rst │ └── make.bat ├── hatch.toml ├── netlify.toml ├── pyproject.toml ├── requirements.txt ├── runtime.txt ├── src/ │ └── view/ │ ├── __about__.py │ ├── __init__.py │ ├── cache.py │ ├── core/ │ │ ├── __init__.py │ │ ├── _colors.py │ │ ├── app.py │ │ ├── body.py │ │ ├── headers.py │ │ ├── multi_map.py │ │ ├── request.py │ │ ├── response.py │ │ ├── router.py │ │ └── status_codes.py │ ├── dom/ │ │ ├── __init__.py │ │ ├── components.py │ │ ├── core.py │ │ └── primitives.py │ ├── exceptions.py │ ├── javascript.py │ ├── responses.py │ ├── run/ │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── servers.py │ │ └── wsgi.py │ ├── testing.py │ └── utils.py └── tests/ ├── test_cache.py ├── test_dom.py ├── test_misc.py ├── test_requests.py ├── test_responses.py ├── test_servers.py └── test_utils.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ docs/Makefile generated docs/make.bat generated *.svg generated *.png binary ================================================ FILE: .github/FUNDING.yml ================================================ github: ZeroIntensity ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: Bug report description: Submit a bug report labels: ["bug"] body: - type: textarea attributes: label: "Problem:" description: > Give a clear description on what's going wrong and how to reproduce it, if possible. 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 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature.yml ================================================ name: Feature description: Suggest a new feature. labels: ["feature"] body: - type: markdown attributes: value: | # Feature Proposal This is where you should propose a new feature to view.py. - type: textarea attributes: label: "Proposal:" description: > Outline your idea and why it would be a good idea for view.py. Make sure to include an example API for what this could look like if implemented. value: | ```py # Example API ``` validations: required: true ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: tags: - v* branches: - main paths: - "src/**" concurrency: group: build-${{ github.head_ref }} cancel-in-progress: true jobs: pure-python-wheel-and-sdist: name: Build a pure Python wheel and source distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Install build dependencies run: python -m pip install --upgrade build - name: Build run: python -m build - uses: actions/upload-artifact@v4 with: name: artifacts path: dist/* if-no-files-found: error publish: name: Publish release needs: - 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@v4 with: name: artifacts path: dist - name: Push build artifacts to PyPI uses: pypa/gh-action-pypi-publish@v1.13.0 with: skip_existing: true user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: pull_request: branches: - main concurrency: group: build-${{ github.head_ref }} cancel-in-progress: true jobs: lint: name: Lint source code runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Install Hatch run: pip install hatch - name: Run linter run: hatch fmt -l ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: - main pull_request: branches: - main concurrency: group: test-${{ github.head_ref }} cancel-in-progress: true env: PYTHONUNBUFFERED: "1" FORCE_COLOR: "1" PYTHONIOENCODING: "utf8" jobs: changes: name: Check for changed files runs-on: ubuntu-latest outputs: source: ${{ steps.filter.outputs.source }} tests: ${{ steps.filter.outputs.tests }} steps: - uses: actions/checkout@v2 - uses: dorny/paths-filter@v3 id: filter with: filters: | source: - 'src/**' tests: - 'tests/**' run-tests: needs: changes if: ${{ needs.changes.outputs.source == 'true' || needs.changes.outputs.tests == 'true' }} name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }} runs-on: ${{ matrix.os }} strategy: fail-fast: true matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Hatch uses: pypa/hatch@install - name: Run tests run: hatch test tests-pass: runs-on: ubuntu-latest name: All tests passed if: always() needs: - run-tests steps: - name: Check whether all tests passed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} allowed-skips: ${{ toJSON(needs) }} ================================================ FILE: .github/workflows/triage.yml ================================================ name: Triage on: pull_request: types: - "opened" - "reopened" - "synchronize" - "labeled" - "unlabeled" jobs: changelog_check: runs-on: ubuntu-latest name: Check for changelog updates steps: - name: "Check if the source directory was changed" uses: dorny/paths-filter@v3 id: changes with: filters: | src: - 'src/**' - name: "Check for changelog updates" if: steps.changes.outputs.src == 'true' uses: brettcannon/check-for-changed-files@v1 with: file-pattern: | CHANGELOG.md skip-label: "skip changelog" failure-message: "Missing a CHANGELOG.md update; please add one or apply the ${skip-label} label to the pull request" tests_check: runs-on: ubuntu-latest name: Check for updated tests steps: - name: "Check if the source directory was changed" uses: dorny/paths-filter@v3 id: changes with: filters: | src: - 'src/**' - name: "Check for test updates" if: steps.changes.outputs.src == 'true' uses: brettcannon/check-for-changed-files@v1 with: file-pattern: | tests/* skip-label: "skip tests" failure-message: "Missing unit tests; please add some or apply the ${skip-label} label to the pull request" all_green: runs-on: ubuntu-latest name: PR has no missing information if: always() needs: - changelog_check - tests_check steps: - name: Check whether jobs passed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} ================================================ FILE: .gitignore ================================================ # Python __pycache__/ .venv/ .hypothesis/ # LSP .vscode/ compile_flags.txt # Sphinx docs/_build/ docs/generated/ ================================================ FILE: .pre-commit-config.yaml ================================================ 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/astral-sh/ruff-pre-commit # Ruff version. rev: v0.14.10 hooks: # Run the linter. - id: ruff-check args: [ --fix ] # Run the formatter. - id: ruff-format ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased - Removed everything from prior releases! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025-present Peter Bierma Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
view.py logo

The Batteries-Detachable Web Framework

This is a work-in-progress! ## Installation It's highly recommended to install from source at the moment: ``` $ pip install git+https://github.com/zerointensity/view.py ``` ## Examples ### Simple Hello World ```py from view.core.app import App from view.dom.core import html_response from view.dom.components import page from view.dom.primitives import h1 app = App() @app.get("/") @html_response async def home(): with page("Hello, view.py!"): yield h1("Nobody expects the Spanish Inquisition") app.run() ``` ### Button Counter ```py from view.core.app import App from view.dom.core import HTMLNode, html_response from view.dom.components import page from view.dom.primitives import button, p from view.javascript import javascript_compiler, as_javascript_expression app = App() @javascript_compiler def click_button(counter: HTMLNode): yield f"let node = {as_javascript_expression(counter)};" yield f"let currentNumber = parseInt(node.innerHTML);" yield f"node.innerHTML = ++currentNumber;" @app.get("/") @html_response async def home(): with page("Counter"): count = p("0") yield count yield button("Click me!", onclick=click_button(count)) app.run() ``` ## Copyright `view.py` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/api.rst ================================================ API Reference ============= .. autosummary:: :toctree: generated :recursive: view ================================================ FILE: docs/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "view.py" copyright = "2026, Peter Bierma" author = "Peter Bierma" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ "sphinx.ext.intersphinx", 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', ] autosummary_generate = True add_module_names = False # Cleaner output # This is the key part for making detailed pages: autodoc_default_options = { 'members': True, 'undoc-members': True, 'show-inheritance': True, "inherited-members": True, "ignore-module-all": True, } exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "shibuya" html_theme_options = { "accent_color": "blue", "light_logo": "_static/logo_black.svg", "dark_logo": "_static/logo_white.svg", "logo_target": "https://view.zintensity.dev", "github_url": "https://github.com/ZeroIntensity/view.py", "announcement": "view.py is currently in alpha and not considered ready for production", } html_static_path = ["_static"] html_favicon = "_static/favicon.ico" html_context = { "source_type": "github", "source_user": "ZeroIntensity", "source_repo": "view.py", "source_version": "main", "source_docs_path": "/docs/", } ================================================ FILE: docs/index.rst ================================================ view.py documentation ===================== Nothing here yet... .. toctree:: :maxdepth: 2 :caption: Contents: api ================================================ FILE: docs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ================================================ FILE: hatch.toml ================================================ [version] path = "src/view/__about__.py" [build.targets.sdist] only-include = ["src/"] [build.targets.wheel] packages = ["src/view"] [envs.hatch-test] extra-args = ["-vv"] extra-dependencies = [ "pytest-asyncio", "requests", "uvicorn", "hypercorn", "daphne", "gunicorn", "werkzeug", "hypothesis", ] randomize = true retries = 3 retries-delay = 1 parallel = true [[envs.hatch-test.matrix]] python = ["3.14", "3.13", "3.12", "3.11", "3.10"] ================================================ FILE: netlify.toml ================================================ [build] command = "pip install . && sphinx-build -M html ./docs ./site" publish = "site/html" ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["hatchling>=1"] build-backend = "hatchling.build" [project] name = "view.py" description = 'The Batteries-Detachable Web Framework.' readme = "README.md" requires-python = ">=3.9" keywords = [] authors = [ { name = "Peter Bierma", email = "peter@python.org" }, ] classifiers = [ "Programming Language :: Python", "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 = ["typing_extensions>=4"] dynamic = ["version", "license"] #[project.optional-dependencies] [project.urls] Documentation = "https://view.zintensity.dev" Issues = "https://github.com/ZeroIntensity/view.py/issues" Source = "https://github.com/ZeroIntensity/view.py" Funding = "https://github.com/sponsors/ZeroIntensity" #[project.scripts] #view = "view.__main__:main" #view-py = "view.__main__:main" [tool.ruff] exclude = ["tests/", "docs/"] line-length = 79 indent-width = 4 [tool.ruff.lint] ignore = [ "S101", # We intentionally want assertions to be debug-only "EM101", # Improves traceback readability(?), but damages code readability "EM102", # Same as above "TRY003", # Moves relevant messages away from where they are raised. "PLC0415", # This is generally done to avoid circular imports. ] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["PLC0414"] "status_codes.py" = ["N818"] "primitives.py" = ["A001", "A002", "B008"] "servers.py" = ["PLC0415", "RET503"] ================================================ FILE: requirements.txt ================================================ # Requirements for Netlify sphinx>=7.0 shibuya>=2025 ================================================ FILE: runtime.txt ================================================ 3.10 ================================================ FILE: src/view/__about__.py ================================================ __version__ = "0.1.0-dev" __author__ = "Peter Bierma " __license__ = "MIT" ================================================ FILE: src/view/__init__.py ================================================ """ view.py - The Batteries-Detachable Web Framework. """ 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 * # noqa: F403 ================================================ FILE: src/view/cache.py ================================================ """ Utilities for caching responses from views. """ from __future__ import annotations import math import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Generic, ParamSpec, TypeVar if TYPE_CHECKING: from collections.abc import Callable from view.core.headers import HTTPHeaders 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: HTTPHeaders 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( init=False, 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: """ Convert minutes to seconds. This is for use in cache decorators. """ return number * 60 def seconds(number: int, /) -> int: """ Do nothing and return ``number``. This only exists for making it semantically clear that the intended time is seconds. This is for use in cache decorators. """ return number def hours(number: int, /) -> int: """ Convert hours to seconds. This is for use in cache decorators. """ return minutes(60) * number def days(number: int, /) -> int: """ Convert days to seconds. This is for use in cache decorators. """ 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 ================================================ FILE: src/view/core/__init__.py ================================================ """ The parts absolutely necessary for web applications using view.py. """ from view.core import app as app from view.core import headers as headers from view.core import request as request from view.core import response as response from view.core import router as router from view.core import status_codes as status_codes ================================================ FILE: src/view/core/_colors.py ================================================ """ This is mostly stolen from CPython's _colorize module. If that becomes part of the standard library someday, we can hopefully remove this. """ from __future__ import annotations import logging import os import sys from typing import IO class ANSIColors: """ Namespace of ANSI color codes. """ RESET = "\x1b[0m" BLACK = "\x1b[30m" BLUE = "\x1b[34m" CYAN = "\x1b[36m" GREEN = "\x1b[32m" GREY = "\x1b[90m" MAGENTA = "\x1b[35m" RED = "\x1b[31m" WHITE = "\x1b[37m" # more like LIGHT GRAY YELLOW = "\x1b[33m" BOLD = "\x1b[1m" BOLD_BLACK = "\x1b[1;30m" # DARK GRAY BOLD_BLUE = "\x1b[1;34m" BOLD_CYAN = "\x1b[1;36m" BOLD_GREEN = "\x1b[1;32m" BOLD_MAGENTA = "\x1b[1;35m" BOLD_RED = "\x1b[1;31m" BOLD_WHITE = "\x1b[1;37m" # actual WHITE BOLD_YELLOW = "\x1b[1;33m" # intense = like bold but without being bold INTENSE_BLACK = "\x1b[90m" INTENSE_BLUE = "\x1b[94m" INTENSE_CYAN = "\x1b[96m" INTENSE_GREEN = "\x1b[92m" INTENSE_MAGENTA = "\x1b[95m" INTENSE_RED = "\x1b[91m" INTENSE_WHITE = "\x1b[97m" INTENSE_YELLOW = "\x1b[93m" BACKGROUND_BLACK = "\x1b[40m" BACKGROUND_BLUE = "\x1b[44m" BACKGROUND_CYAN = "\x1b[46m" BACKGROUND_GREEN = "\x1b[42m" BACKGROUND_MAGENTA = "\x1b[45m" BACKGROUND_RED = "\x1b[41m" BACKGROUND_WHITE = "\x1b[47m" BACKGROUND_YELLOW = "\x1b[43m" INTENSE_BACKGROUND_BLACK = "\x1b[100m" INTENSE_BACKGROUND_BLUE = "\x1b[104m" INTENSE_BACKGROUND_CYAN = "\x1b[106m" INTENSE_BACKGROUND_GREEN = "\x1b[102m" INTENSE_BACKGROUND_MAGENTA = "\x1b[105m" INTENSE_BACKGROUND_RED = "\x1b[101m" INTENSE_BACKGROUND_WHITE = "\x1b[107m" INTENSE_BACKGROUND_YELLOW = "\x1b[103m" NoColors = ANSIColors() for attribute in ANSIColors.__dict__: if not attribute.startswith("__"): setattr(NoColors, attribute, "") def _supports_colors(*, file: IO[str] | IO[bytes] | None = None) -> bool: """ Does the current environment support ANSI color codes? """ if file is None: file = sys.stdout assert file is not None if os.environ.get("NO_COLOR"): return False if os.environ.get("FORCE_COLOR"): return True if os.environ.get("TERM") == "dumb": return False if not hasattr(file, "fileno"): return False if sys.platform == "win32": try: import nt if not nt._supports_virtual_terminal(): # noqa: SLF001 return False except (ImportError, AttributeError): return False try: return os.isatty(file.fileno()) except OSError: return hasattr(file, "isatty") and file.isatty() def get_colors(*, file: IO[str] | IO[bytes] | None = None) -> ANSIColors: """ Get a namespace containing color names as attributes. If colors are enabled, these attributes will contain ANSI color codes. Otherwise, they'll be empty string. """ if _supports_colors(file=file): return ANSIColors() return NoColors class ColorfulFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: colors = get_colors() mapping = { logging.DEBUG: colors.BOLD_BLUE, logging.INFO: colors.BOLD_GREEN, logging.WARNING: colors.BOLD_YELLOW, logging.ERROR: colors.BOLD_RED, logging.CRITICAL: colors.INTENSE_BACKGROUND_RED, } color_code = mapping.get(record.levelno) if color_code is not None: record.levelname = f"{color_code}{record.levelname}{colors.RESET}" return super().format(record) ================================================ FILE: src/view/core/app.py ================================================ """ Primary app implementation. """ from __future__ import annotations import contextlib import contextvars import json import logging import os import sys import warnings from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Iterator from importlib.metadata import Distribution, PackageNotFoundError from multiprocessing import Process from pathlib import Path from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar, Unpack from view.core._colors import ColorfulFormatter from view.core.request import Method, Request from view.core.response import ( 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.exceptions import InvalidTypeError from view.responses import FileResponse from view.run.servers import ServerConfigArgs, run_app_on_any_server from view.utils import reraise if TYPE_CHECKING: from view.run.asgi import ASGIProtocol from view.run.wsgi import WSGIProtocol __all__ = "App", "BaseApp", "as_app" T = TypeVar("T") P = ParamSpec("P") def _is_development_mode() -> bool: devmode_variable = os.environ.get("VIEW_DEVMODE") if devmode_variable is not None: if not devmode_variable.isdigit(): raise RuntimeError( f"Invalid value for VIEW_DEVMODE: {devmode_variable!r}" ) return bool(int(devmode_variable)) try: view_distribution = Distribution.from_name("view.py") except PackageNotFoundError: # view.py isn't even installed -- we're definitely in some sort of # local copy. return True json_data = view_distribution.read_text("direct_url.json") if json_data is None: return False return json.loads(json_data).get("dir_info", {}).get("editable", False) class BaseApp(ABC): """Base view.py application.""" _CURRENT_APP = contextvars.ContextVar["BaseApp"]("Current app being used.") def __init__(self) -> None: self._request = contextvars.ContextVar[Request]("request") self._production: bool | None = None # We use a private variable for this to artificially disallow people # from writing to development_mode. self._development_mode: bool = _is_development_mode() self.logger = self._new_logger() """ The logger used by the app. """ @property def development_mode(self) -> bool: """ Whether view.py is in "development mode". If this is ``True``, then that means you're working on contributing to the library itself. This cannot be set from Python. If you'd like to control this behavior, set the ``VIEW_DEVMODE`` environment variable to ``1`` or ``0``. """ return self._development_mode def _new_logger(self) -> logging.Logger: """ Create a new logger for this app. """ # TODO: This should be configurable log_level = logging.INFO if self.development_mode: log_level = logging.DEBUG # In the future, we might want to add a use-case for multiple apps in # the same process. To support this, we use the ID of this instance in # the logger name to keep it unique. # XXX: Should this create a new logger for each class, or for each instance? logger = logging.getLogger( f"{__name__}.{self.__class__.__name__}-{id(self)}" ) logger.setLevel(log_level) handler = logging.StreamHandler(sys.stdout) handler.setLevel(log_level) formatter = ColorfulFormatter( "view: %(asctime)s -- [%(levelname)s]: %(message)s" ) handler.setFormatter(formatter) logger.addHandler(handler) return logger def shut_up(self) -> None: """ Stop the logger. """ self.logger.disabled = True @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. """ 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, **kwargs: Unpack[ServerConfigArgs]) -> 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 :meth:`asgi` or :meth:`wsgi` method. """ production = kwargs.get("production", False) # 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, stacklevel=2, ) if self.development_mode: self.logger.info("You're in development mode!") self.logger.info( "Development mode implies that you're working on view.py itself and plan on contributing to the library." ) self.logger.info( "If that doesn't sound correct, set VIEW_DEVMODE to 0." ) self.logger.info( "Serving app on http://localhost:%d", kwargs.get("port") or 5000 ) try: run_app_on_any_server(self, **kwargs) except KeyboardInterrupt: self.logger.info("CTRL^C received, shutting down") except Exception: self.logger.exception("Error in server lifecycle") finally: self.logger.info("Server finished") def run_detached( self, **kwargs: Unpack[ServerConfigArgs], ) -> Process: """ Run the app in a separate process. This means that the server is killable. """ process = Process( target=self.run, kwargs=kwargs, ) process.start() return process async def _execute_view_internal( self, view: Callable[P, ViewResult], *args: P.args, **kwargs: P.kwargs, ) -> Response: self.logger.debug("Executing view: %s", view) try: result = view(*args, **kwargs) return await wrap_view_result(result) except HTTPError as error: self.logger.warning("HTTP Error %d", error.status_code) raise async def execute_view( self, view: Callable[P, ViewResult], *args: P.args, **kwargs: P.kwargs ) -> Response: try: return await self._execute_view_internal(view, *args, **kwargs) except BaseException as exception: # Let HTTP errors pass through, so the caller can deal with it if isinstance(exception, HTTPError): raise self.logger.exception("Error while processing response") if __debug__: raise InternalServerError.from_current_exception() from exception raise InternalServerError from exception 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 self.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 InvalidTypeError(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: self.logger.info("%s on route %s", 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 self.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 self.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 InvalidTypeError(path, str) if __debug__ and not isinstance(method, Method): raise InvalidTypeError(method, Method) def decorator(view: RouteView, /) -> Route: return self.router.push_route(view, path, method) 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 InvalidTypeError(path, str) def decorator(function: SubRouterViewT, /) -> SubRouterViewT: if __debug__ and not callable(function): raise InvalidTypeError(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 InvalidTypeError(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) ================================================ FILE: src/view/core/body.py ================================================ """ The implementation of request and response bodies. """ 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 InvalidTypeError, ViewError __all__ = ("BodyMixin",) BodyStream: TypeAlias = AsyncIterator[bytes] class BodyAlreadyUsedError(ViewError): """ The body was already used on this response. Generally, this means that the same response object was executed multiple times. """ def __init__(self, receive_data: BodyStream) -> None: super().__init__(f"Body {receive_data!r} has already been consumed") class InvalidJSONError(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 stream_body(self) -> AsyncIterator[bytes]: """ Incrementally stream the body without keeping the whole thing in-memory at a given time. """ if __debug__ and not isinstance(self.receive_data, AsyncIterator): raise InvalidTypeError(self.receive_data, AsyncIterator) if self.consumed: raise BodyAlreadyUsedError(self.receive_data) self.consumed = True async for data in self.receive_data: if __debug__ and not isinstance(data, bytes): raise InvalidTypeError(data, bytes) yield data async def body(self) -> bytes: """ Read the full body from the stream. """ buffer = BytesIO() async for data in self.stream_body(): buffer.write(data) return buffer.getvalue() 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 InvalidJSONError( "Body does not contain valid UTF-8 data" ) from error try: return parse_function(text) except Exception as error: raise InvalidJSONError("Failed to parse JSON") from error ================================================ FILE: src/view/core/headers.py ================================================ """ Utilities and implementation for HTTP request/response headers. """ from __future__ import annotations from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, TypeAlias from typing_extensions import Self from view.core.multi_map import MultiMap from view.exceptions import InvalidTypeError if TYPE_CHECKING: from view.run.asgi import ASGIHeaders from view.run.wsgi import WSGIHeaders __all__ = ( "HTTPHeaders", "HeadersLike", "as_real_headers", "asgi_to_headers", "headers_to_asgi", "wsgi_to_headers", ) class LowerStr(str): """ A string that always acts in lowercase. This is useful for case-insensitive comparisons. """ __slots__ = () def __new__(cls, data: object) -> Self: return super().__new__(cls, cls._to_lower(data)) @staticmethod def _to_lower(data: object) -> object: if isinstance(data, str): data = data.lower() return data def __contains__(self, key: str, /) -> bool: return super().__contains__(key.lower()) def __eq__(self, string: object) -> bool: return super().__eq__(self._to_lower(string)) def __ne__(self, value: object, /) -> bool: return super().__ne__(self._to_lower(value)) def __hash__(self) -> int: return hash(str(self)) class HTTPHeaders(MultiMap[str, str]): """ Case-insensitive multi-map of HTTP headers. """ def __init__(self, items: Iterable[tuple[str, str]] = ()) -> None: super().__init__((LowerStr(key), value) for key, value in items) def __getitem__(self, key: str, /) -> str: return super().__getitem__(LowerStr(key)) def __contains__(self, key: object, /) -> bool: return super().__contains__(LowerStr(key)) def __repr__(self) -> str: return f"HTTPHeaders({self.as_sequence()})" def __eq__(self, other: object, /) -> bool: if isinstance(other, HTTPHeaders): return other._values == self._values if isinstance(other, dict): return self._as_flat() == { LowerStr(key): value for key, value in other.items() } return NotImplemented __hash__ = MultiMap.__hash__ def get_exactly_one(self, key: str) -> str: return super().get_exactly_one(LowerStr(key)) def with_new_value(self, key: str, value: str) -> HTTPHeaders: new_sequence = [*list(self.as_sequence()), (LowerStr(key), value)] return type(self)(new_sequence) HeadersLike: TypeAlias = ( HTTPHeaders | Mapping[str, str] | Mapping[bytes, bytes] ) def as_real_headers(headers: HeadersLike | None, /) -> HTTPHeaders: """ Convenience function for casting a "header-like object" (or ``None``) to a :class:`MultiMap`. """ if headers is None: return HTTPHeaders() if isinstance(headers, HTTPHeaders): return headers if __debug__ and not isinstance(headers, Mapping): raise InvalidTypeError(Mapping, headers) assert isinstance(headers, dict) all_values: list[tuple[LowerStr, str]] = [] for key, value in headers.items(): if isinstance(key, bytes): key = key.decode("utf-8") # noqa: PLW2901 if isinstance(value, bytes): value = value.decode("utf-8") # noqa: PLW2901 all_values.append((LowerStr(key), value)) return HTTPHeaders(all_values) def wsgi_to_headers(environ: Mapping[str, Any]) -> HTTPHeaders: """ Convert WSGI headers (from the ``environ``) to a case-insensitive multi-map. """ values: list[tuple[LowerStr, str]] = [] for key, value in environ.items(): if not key.startswith("HTTP_"): continue assert isinstance(value, str) key = key.removeprefix("HTTP_").replace("_", "-").lower() # noqa: PLW2901 values.append((LowerStr(key), value)) return HTTPHeaders(values) def headers_to_wsgi(headers: HTTPHeaders, /) -> WSGIHeaders: """ Convert a case-insensitive multi-map to a WSGI header iterable. """ wsgi_headers: WSGIHeaders = [] for key, value in headers.items(): wsgi_headers.append((str(key), value)) return wsgi_headers def asgi_to_headers(headers: ASGIHeaders, /) -> HTTPHeaders: """ Convert ASGI headers to a case-insensitive multi-map. """ values: list[tuple[LowerStr, str]] = [] for key, value in headers: lower_str = LowerStr(key.decode("utf-8")) values.append((lower_str, value.decode("utf-8"))) return HTTPHeaders(values) def headers_to_asgi(headers: HTTPHeaders, /) -> ASGIHeaders: """ Convert a case-insensitive multi-map 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 ================================================ FILE: src/view/core/multi_map.py ================================================ """ A "multi-map" implementation intended for use in HTTP headers and query strings. """ from __future__ import annotations from collections.abc import ( ItemsView, Iterable, Iterator, KeysView, Mapping, Sequence, ValuesView, ) from typing import Any, TypeVar from view.exceptions import ViewError __all__ = "HasMultipleValuesError", "MultiMap" KeyT = TypeVar("KeyT") ValueT = TypeVar("ValueT") T = TypeVar("T") class HasMultipleValuesError(ViewError): """ Multiple values were found when they were explicitly disallowed. """ def __init__(self, key: Any) -> None: super().__init__(f"{key!r} has multiple values") class MultiMap(Mapping[KeyT, ValueT]): """ Mapping of individual keys to one or many values. """ __slots__ = ("_values",) def __init__(self, items: Iterable[tuple[KeyT, ValueT]] = ()) -> None: self._values: dict[KeyT, list[ValueT]] = {} for key, value in items: values = self._values.setdefault(key, []) values.append(value) def __getitem__(self, key: KeyT, /) -> ValueT: """ Get the first value if it exists, or else raise a :exc:`KeyError`. """ return self._values[key][0] def __len__(self) -> int: return len(self._values) def __iter__(self) -> Iterator[KeyT]: return iter(self._values) def __contains__(self, key: object, /) -> bool: return key in self._values def __eq__(self, other: object, /) -> bool: if isinstance(other, MultiMap): return other._values == self._values if isinstance(other, dict): return self._as_flat() == other return NotImplemented def __ne__(self, other: object, /) -> bool: if isinstance(other, MultiMap): return other._values != self._values return NotImplemented def __repr__(self) -> str: return f"MultiMap({self.as_sequence()})" def __hash__(self) -> int: return hash(self._values) def _as_flat(self) -> dict[KeyT, ValueT]: """ Turn this into a "flat" representation of the mapping in which all keys have exactly one value. """ return {key: value[0] for key, value in self._values.items()} def keys(self) -> KeysView[KeyT]: """ Return a view of all the keys in this map. """ return self._values.keys() def values(self) -> ValuesView[ValueT]: """ Return a view of the first value for each key in the mapping. """ return self._as_flat().values() def many_values(self) -> ValuesView[Sequence[ValueT]]: """ Return a view of all values in the mapping. """ return self._values.values() def items(self) -> ItemsView[KeyT, ValueT]: """ Return a view of all items in the mapping, using the first value for each key. """ return self._as_flat().items() def many_items(self) -> ItemsView[KeyT, Sequence[ValueT]]: """ Return a view of all items in the mapping. """ return self._values.items() def get_many(self, key: KeyT) -> Sequence[ValueT]: """ Get one or many values for a given key. """ return self._values[key] def get_exactly_one(self, key: KeyT) -> ValueT: """ Get precisely one value for a key. If more than one value is present, then this raises a :exc:`HasMultipleValuesError`. """ value = self._values[key] if len(value) != 1: raise HasMultipleValuesError(key) return value[0] def as_sequence(self) -> Sequence[tuple[KeyT, ValueT]]: """ Return all the keys and values in a sequence of (key, value) tuples. """ result: list[tuple[KeyT, ValueT]] = [] for key, values in self._values.items(): for value in values: result.append((key, value)) # noqa: PERF401 return result def with_new_value( self, key: KeyT, value: ValueT ) -> MultiMap[KeyT, ValueT]: """ Create a copy of this map with a new key and value included. """ new_sequence = [*list(self.as_sequence()), (key, value)] return type(self)(new_sequence) ================================================ FILE: src/view/core/request.py ================================================ """ Implementation and utilities for HTTP requests. """ from __future__ import annotations import sys import urllib.parse from dataclasses import dataclass, field from enum import auto from typing import TYPE_CHECKING, Any from view.core.body import BodyMixin from view.core.multi_map import MultiMap from view.core.router import normalize_route if TYPE_CHECKING: from collections.abc import Mapping from view.core.app import BaseApp from view.core.headers import HTTPHeaders __all__ = "Method", "Request" if sys.version_info >= (3, 11): from enum import StrEnum as _StrEnum else: from enum import Enum class _StrEnum(str, Enum): pass class _UpperStrEnum(_StrEnum): @staticmethod def _generate_next_value_( name: str, *_: Any, ) -> 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 :class:`Method`. """ headers: HTTPHeaders """ A "multi-dictionary" containing the request headers. This is :class:`dict`-like, but if a header has multiple values, it is represented by a list. """ query_parameters: MultiMap[str, 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) -> MultiMap[str, str]: """ Extract a query string from a URL and return it as a multi-map. """ if isinstance(query_string, bytes): query_string = query_string.decode("utf-8") assert isinstance(query_string, str), query_string return MultiMap(urllib.parse.parse_qsl(query_string)) ================================================ FILE: src/view/core/response.py ================================================ """ Implementation and utilities for HTTP responses. """ from __future__ import annotations import warnings from collections.abc import ( AsyncGenerator, Awaitable, Generator, ) from dataclasses import dataclass from typing import AnyStr, Generic, TypeAlias from view.core.body import BodyMixin from view.core.headers import ( HeadersLike, HTTPHeaders, as_real_headers, ) from view.exceptions import InvalidTypeError, ViewError __all__ = "Response", "ResponseLike", "ViewResult" @dataclass(slots=True) class Response(BodyMixin): """ Low-level dataclass representing a response from a view. """ status_code: int headers: HTTPHeaders 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, HTTPHeaders]: """ 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] 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") 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 :class:`TextResponse` from either a :class:`str` or :class:`bytes` object. """ if __debug__ and not isinstance(content, (str, bytes)): raise InvalidTypeError(content, str, bytes) async def stream() -> AsyncGenerator[bytes]: yield _as_bytes(content) return cls(stream(), status_code, as_real_headers(headers), content) class InvalidResponseError(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 InvalidResponseError("Response cannot be an empty tuple") if __debug__ and len(response) == 1: warnings.warn( f"Returned tuple {response!r} with a single item," " which is useless. Return the item directly.", RuntimeWarning, stacklevel=2, ) return TextResponse.from_content(response[0]) content = response[0] if __debug__ and isinstance(content, Response): raise InvalidResponseError( "Response() objects cannot be used with response" " tuples. Instead, use the status_code and/or headers parameter(s)." ) status = response[1] headers: HeadersLike | None = None # Ruff wants me to use a constant here, but I think this is clear enough # for lengths. if len(response) > 2: # noqa: PLR2004 headers = response[2] if __debug__ and len(response) > 3: # noqa: PLR2004 raise InvalidResponseError( 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 :class:`Response` object. """ if isinstance(response, Response): return response if isinstance(response, (str, bytes)): return TextResponse.from_content(response) if isinstance(response, tuple): return _wrap_response_tuple(response) if isinstance(response, AsyncGenerator): async def stream() -> AsyncGenerator[bytes]: async for data in response: yield _as_bytes(data) return Response(stream(), status_code=200, headers=HTTPHeaders()) if isinstance(response, Generator): async def stream() -> AsyncGenerator[bytes]: for data in response: yield _as_bytes(data) return Response(stream(), status_code=200, headers=HTTPHeaders()) 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 :class:`Response` object. """ if isinstance(result, Awaitable): result = await result return _wrap_response(result) ================================================ FILE: src/view/core/router.py ================================================ """ The router implementation. """ 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 InvalidTypeError, 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 DuplicateRouteError(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 DuplicateRouteError( 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_node(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 InvalidTypeError(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_node(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 InvalidTypeError(view, Callable) node = self._get_node_for_path(path, allow_path_parameters=True) if node.routes.get(method) is not None: raise DuplicateRouteError( 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 InvalidTypeError(subrouter, Callable) node = self._get_node_for_path(path, allow_path_parameters=False) if node.subrouter is not None: raise DuplicateRouteError( f"The route {path!r} already has a subrouter" ) 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 InvalidTypeError(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) ================================================ FILE: src/view/core/status_codes.py ================================================ """ Utilities and data regarding all HTTP status codes. """ from __future__ import annotations import sys import traceback from enum import IntEnum from typing import ClassVar 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] # It's too much of a hassle to add an explicit __all__ with every status code. global __all__ # noqa: PLW0603 __all__ += (cls.__name__,) def as_response(self) -> TextResponse[str]: 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): # noqa: A001 """ The request method is not supported by the server and cannot be handled. The only methods that servers are required to support (and therefore that 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 ================================================ FILE: src/view/dom/__init__.py ================================================ """ A Document Object Model (DOM) API for Python, allowing users to write HTML in their Python code. """ from view.dom import components as components from view.dom import core as core from view.dom import primitives as primitives ================================================ FILE: src/view/dom/components.py ================================================ """ Implementation of "components" -- DOM nodes defined by the user. """ from __future__ import annotations from dataclasses import dataclass from functools import wraps from typing import TYPE_CHECKING, NoReturn, ParamSpec from view.dom.core import HTMLNode, HTMLTree from view.dom.primitives import base, body, html, link, meta, script from view.dom.primitives import title as title_node if TYPE_CHECKING: from collections.abc import Callable, Iterable __all__ = "Children", "Component", "component" 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(slots=True, 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() ================================================ FILE: src/view/dom/core.py ================================================ """ The implementation of the DOM API. """ 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 TYPE_CHECKING, ClassVar, ParamSpec, TypeAlias from view.core.headers import as_real_headers from view.core.response import Response from view.exceptions import InvalidTypeError from view.javascript import SupportsJavaScript if TYPE_CHECKING: from view.core.router import RouteView from view.dom.components import Component __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 | Component" HTMLViewResult = ( AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem] ) HTMLView: TypeAlias = Callable[P, HTMLViewResult] def html_response( function: HTMLView, ) -> RouteView: """ Return a :class:`~view.core.response.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 InvalidTypeError(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_real_headers({"content-type": "text/html"}), ) return wrapper ================================================ FILE: src/view/dom/primitives.py ================================================ """ Constructor functions for all HTTP elements. """ from __future__ import annotations from typing import TYPE_CHECKING, Any, Literal, TypedDict from typing_extensions import NotRequired, Unpack from view.dom.core import HTMLNode from view.exceptions import InvalidTypeError if TYPE_CHECKING: from collections.abc import Callable class ImplicitDefault(str): """ 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. """ __slots__ = () 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 InvalidTypeError(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 InvalidTypeError(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