Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3498b83
Change my email in the license.
ZeroIntensity Dec 31, 2025
64fd2de
Fix the logo in the README.
ZeroIntensity Dec 31, 2025
8d2761d
Merge branch 'main' of https://github.com/zerointensity/view.py into …
ZeroIntensity Dec 31, 2025
4a4fd04
Fix type hints in _guess_file_type.
ZeroIntensity Dec 31, 2025
3ac9c68
Add missing slots=True
ZeroIntensity Dec 31, 2025
02caadd
Update Hatch test configuration.
ZeroIntensity Dec 31, 2025
1400733
Run formatter and linter.
ZeroIntensity Dec 31, 2025
526c394
Add some ruff things.
ZeroIntensity Dec 31, 2025
df31313
Fix linter complaints in utils.
ZeroIntensity Dec 31, 2025
b14024c
Fix more linter problems.
ZeroIntensity Dec 31, 2025
ab9a5fb
Fix some exception linter things.
ZeroIntensity Dec 31, 2025
1bd3890
Suffix errors with "Error".
ZeroIntensity Dec 31, 2025
504e786
Fix more type checking things with the linter.
ZeroIntensity Dec 31, 2025
0104716
Add missing __future__ import.
ZeroIntensity Dec 31, 2025
1f37a0f
Add missing __slots__.
ZeroIntensity Dec 31, 2025
77edabb
Type checking shenanigans.
ZeroIntensity Dec 31, 2025
c15b9e6
More linter fixes.
ZeroIntensity Dec 31, 2025
00e241a
More linter stuff.
ZeroIntensity Dec 31, 2025
6982ce0
Almost there with linter fixes.
ZeroIntensity Dec 31, 2025
265e453
Fix remaining linter problems.
ZeroIntensity Dec 31, 2025
2d2d7d7
Run hatch fmt.
ZeroIntensity Dec 31, 2025
b062e19
Add a CI check for linting.
ZeroIntensity Dec 31, 2025
f4f52d5
Fix lint I think.
ZeroIntensity Dec 31, 2025
6005074
Don't run tests in parallel.
ZeroIntensity Dec 31, 2025
174fe55
Really fix lint I think.
ZeroIntensity Dec 31, 2025
5cb03ca
Increase wait time for detached server tests.
ZeroIntensity Dec 31, 2025
2bff0d6
Stupid Ruff.
ZeroIntensity Dec 31, 2025
be4b2bb
Skip Gunicorn on non-Linux.
ZeroIntensity Dec 31, 2025
131ee28
Skip the whole test on non-Linux.
ZeroIntensity Dec 31, 2025
80fc58e
:(
ZeroIntensity Dec 31, 2025
c517e4f
:(((
ZeroIntensity Dec 31, 2025
aad3442
I love asyncio errors so much.
ZeroIntensity Dec 31, 2025
ea0b1a7
Remove PRs from build CI.
ZeroIntensity Dec 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@ on:
tags:
- v*
branches:
- master
- main
paths:
- "src/**"
pull_request:
branches:
- master

concurrency:
group: build-${{ github.head_ref }}
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Lint

on:
pull_request:
branches:
- main

concurrency:
group: build-${{ github.head_ref }}
cancel-in-progress: true

jobs:
lint:
name: Lint source code
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Install Hatch
run: pip install hatch

- name: Run linter
run: hatch fmt -l
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ name: Tests
on:
push:
branches:
- master
- main
pull_request:
branches:
- master
- main

concurrency:
group: test-${{ github.head_ref }}
Expand Down
12 changes: 8 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ repos:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]

- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.1.0
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.10
hooks:
- id: black
language_version: python3.13
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025 Peter Bierma <[email protected]>
Copyright (c) 2025-present Peter Bierma <[email protected]>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ZeroIntensity/view.py/master/assets/logo_theme_dark.png" alt="view.py logo (dark)" width=450 height=auto>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ZeroIntensity/view.py/master/assets/logo_theme_light.png" alt="view.py logo (light)" width=450 height=auto>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ZeroIntensity/view.py/main/logos/logo_theme_dark.png" alt="view.py logo (dark)" width=450 height=auto>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ZeroIntensity/view.py/main/logos/logo_theme_light.png" alt="view.py logo (light)" width=450 height=auto>
<img alt="view.py logo">
</picture>
</div>
Expand Down
11 changes: 10 additions & 1 deletion hatch.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@ packages = ["src/view"]
extra-args = ["-vv"]
extra-dependencies = [
"pytest-asyncio",
"requests"
"requests",
"uvicorn",
"hypercorn",
"daphne",
"gunicorn",
"werkzeug",
]
randomize = true
retries = 3
retries-delay = 1
parallel = false

[[envs.hatch-test.matrix]]
python = ["3.14", "3.13", "3.12", "3.11", "3.10"]
Binary file added logos/logo_theme_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added logos/logo_theme_light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,23 @@ Funding = "https://github.com/sponsors/ZeroIntensity"
[project.scripts]
view = "view.__main__:main"
view-py = "view.__main__:main"

[tool.ruff]
exclude = ["tests/"]
line-length = 79
indent-width = 4

[tool.ruff.lint]
ignore = [
"S101", # We intentionally want assertions to be debug-only
"EM101", # Improves traceback readability(?), but damages code readability
"EM102", # Same as above
"TRY003", # Moves relevant messages away from where they are raised.
"PLC0415", # This is generally done to avoid circular imports.
]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["PLC0414"]
"status_codes.py" = ["N818"]
"primitives.py" = ["A001", "A002", "B008"]
"servers.py" = ["PLC0415", "RET503"]
2 changes: 1 addition & 1 deletion src/view/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
from view import run as run
from view import testing as testing
from view import utils as utils
from view.__about__ import *
from view.__about__ import * # noqa: F403
2 changes: 1 addition & 1 deletion src/view/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
def main():
print()
pass


if __name__ == "__main__":
Expand Down
27 changes: 20 additions & 7 deletions src/view/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
import math
import time
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Generic, ParamSpec, TypeVar
from typing import TYPE_CHECKING, Generic, ParamSpec, TypeVar

from multidict import CIMultiDict
if TYPE_CHECKING:
from collections.abc import Callable

from view.core.response import Response, TextResponse, ViewResult, wrap_view_result
from multidict import CIMultiDict

from view.core.response import (
Response,
TextResponse,
ViewResult,
wrap_view_result,
)

__all__ = ("in_memory_cache",)

Expand All @@ -32,7 +39,9 @@ def invalidate(self) -> None:
"""

@abstractmethod
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response: ...
async def __call__(
self, *args: P.args, **kwargs: P.kwargs
) -> Response: ...


@dataclass(slots=True, frozen=True)
Expand Down Expand Up @@ -73,7 +82,9 @@ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response:
self._cached_response = cached
return cached.as_response()

if (time.time() - self._cached_response.last_reset) > self.reset_frequency:
if (
time.time() - self._cached_response.last_reset
) > self.reset_frequency:
self.invalidate()
return await self(*args, **kwargs)

Expand Down Expand Up @@ -104,6 +115,8 @@ def in_memory_cache(
"""

def decorator_factory(function: Callable[P, T], /) -> InMemoryCache[P, T]:
return InMemoryCache(function, reset_frequency=reset_frequency or math.inf)
return InMemoryCache(
function, reset_frequency=reset_frequency or math.inf
)

return decorator_factory
51 changes: 32 additions & 19 deletions src/view/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
wrap_view_result,
)
from view.core.router import FoundRoute, Route, Router, RouteView
from view.core.status_codes import Forbidden, HTTPError, InternalServerError, NotFound
from view.core.status_codes import (
Forbidden,
HTTPError,
InternalServerError,
NotFound,
)
from view.exceptions import InvalidTypeError
from view.utils import reraise

if TYPE_CHECKING:
Expand Down Expand Up @@ -127,6 +133,7 @@ def run(
warnings.warn(
f"The app was run with {production=}, but Python's {__debug__=}",
RuntimeWarning,
stacklevel=2,
)

logger.info(f"Serving app on http://localhost:{port}")
Expand All @@ -136,7 +143,7 @@ def run(
settings.run_app_on_any_server()
except KeyboardInterrupt:
logger.info("CTRL^C received, shutting down")
except Exception:
except Exception: # noqa
logger.exception("Error in server lifecycle")
finally:
logger.info("Server finished")
Expand Down Expand Up @@ -177,7 +184,9 @@ async def _execute_view_internal(
result = view(*args, **kwargs)
return await wrap_view_result(result)
except HTTPError as error:
logger.opt(colors=True).info(f"<red>HTTP Error {error.status_code}</red>")
logger.opt(colors=True).info(
f"<red>HTTP Error {error.status_code}</red>"
)
raise


Expand All @@ -191,10 +200,11 @@ async def execute_view(
if isinstance(exception, HTTPError):
raise
logger.exception(exception)

if __debug__:
raise InternalServerError.from_current_exception()
else:
raise InternalServerError()
raise InternalServerError.from_current_exception() from exception

raise InternalServerError from exception


SingleView = Callable[["Request"], ViewResult]
Expand Down Expand Up @@ -223,13 +233,15 @@ def as_app(view: SingleView, /) -> SingleViewApp:
Decorator for using a single function as an app.
"""
if __debug__ and not callable(view):
raise InvalidType(view, Callable)
raise InvalidTypeError(view, Callable)

return SingleViewApp(view)


RouteDecorator: TypeAlias = Callable[[RouteView], Route]
SubRouterView: TypeAlias = Callable[[str], ResponseLike | Awaitable[ResponseLike]]
SubRouterView: TypeAlias = Callable[
[str], ResponseLike | Awaitable[ResponseLike]
]
SubRouterViewT = TypeVar("SubRouterViewT", bound=SubRouterView)


Expand All @@ -251,7 +263,7 @@ async def _process_request_internal(self, request: Request) -> Response:
request.path, request.method
)
if found_route is None:
raise NotFound()
raise NotFound

# Extend instead of replacing?
request.path_parameters = found_route.path_parameters
Expand All @@ -274,14 +286,13 @@ def route(self, path: str, /, *, method: Method) -> RouteDecorator:
"""

if __debug__ and not isinstance(path, str):
raise InvalidType(path, str)
raise InvalidTypeError(path, str)

if __debug__ and not isinstance(method, Method):
raise InvalidType(method, Method)
raise InvalidTypeError(method, Method)

def decorator(view: RouteView, /) -> Route:
route = self.router.push_route(view, path, method)
return route
return self.router.push_route(view, path, method)

return decorator

Expand Down Expand Up @@ -352,13 +363,15 @@ def decorator(view: RouteView, /) -> RouteView:

return decorator

def subrouter(self, path: str) -> Callable[[SubRouterViewT], SubRouterViewT]:
def subrouter(
self, path: str
) -> Callable[[SubRouterViewT], SubRouterViewT]:
if __debug__ and not isinstance(path, str):
raise InvalidType(path, str)
raise InvalidTypeError(path, str)

def decorator(function: SubRouterViewT, /) -> SubRouterViewT:
if __debug__ and not callable(function):
raise InvalidType(Callable, function)
raise InvalidTypeError(Callable, function)

def router_function(path_from_url: str) -> Route:
def route() -> ResponseLike | Awaitable[ResponseLike]:
Expand All @@ -373,18 +386,18 @@ def route() -> ResponseLike | Awaitable[ResponseLike]:

def static_files(self, path: str, directory: str | Path) -> None:
if __debug__ and not isinstance(directory, (str, Path)):
raise InvalidType(directory, str, Path)
raise InvalidTypeError(directory, str, Path)

directory = Path(directory)

@self.subrouter(path)
def serve_static_file(path_from_url: str) -> ResponseLike:
file = directory / path_from_url
if not file.is_file():
raise NotFound()
raise NotFound

if not file.is_relative_to(directory):
raise Forbidden()
raise Forbidden

with reraise(Forbidden, OSError):
return FileResponse.from_file(file)
Loading