Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Python
__pycache__/
.venv/
.hypothesis/

# LSP
.vscode/
Expand Down
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,70 @@

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.
3 changes: 2 additions & 1 deletion hatch.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ extra-dependencies = [
"daphne",
"gunicorn",
"werkzeug",
"hypothesis",
]
randomize = true
retries = 3
retries-delay = 1
parallel = false
parallel = true

[[envs.hatch-test.matrix]]
python = ["3.14", "3.13", "3.12", "3.11", "3.10"]
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ classifiers = [
dependencies = ["typing_extensions>=4"]
dynamic = ["version", "license"]

[project.optional-dependencies]
#[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"
#[project.scripts]
#view = "view.__main__:main"
#view-py = "view.__main__:main"

[tool.ruff]
exclude = ["tests/", "docs/"]
Expand Down
6 changes: 0 additions & 6 deletions src/view/__main__.py

This file was deleted.

21 changes: 21 additions & 0 deletions src/view/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,39 @@ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> 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


Expand Down
35 changes: 10 additions & 25 deletions src/view/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from importlib.metadata import Distribution, PackageNotFoundError
from multiprocessing import Process
from pathlib import Path
from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar
from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar, Unpack

from view.core._colors import ColorfulFormatter
from view.core.request import Method, Request
Expand All @@ -35,6 +35,7 @@
)
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:
Expand Down Expand Up @@ -195,23 +196,16 @@ def asgi(self) -> ASGIProtocol:

return asgi_for_app(self)

def run(
self,
*,
host: str = "localhost",
port: int = 5000,
production: bool = False,
server_hint: str | None = None,
) -> None:
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.
"""
from view.run.servers import ServerSettings

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__:
Expand All @@ -230,11 +224,11 @@ def run(
"If that doesn't sound correct, set VIEW_DEVMODE to 0."
)

self.logger.info("Serving app on http://localhost:%d", port)
self._production = production
settings = ServerSettings(self, host=host, port=port, hint=server_hint)
self.logger.info(
"Serving app on http://localhost:%d", kwargs.get("port") or 5000
)
try:
settings.run_app_on_any_server()
run_app_on_any_server(self, **kwargs)
except KeyboardInterrupt:
self.logger.info("CTRL^C received, shutting down")
except Exception:
Expand All @@ -244,11 +238,7 @@ def run(

def run_detached(
self,
*,
host: str = "localhost",
port: int = 5000,
production: bool = False,
server_hint: str | None = None,
**kwargs: Unpack[ServerConfigArgs],
) -> Process:
"""
Run the app in a separate process. This means that the server is
Expand All @@ -257,12 +247,7 @@ def run_detached(

process = Process(
target=self.run,
kwargs={
"host": host,
"port": port,
"production": production,
"server_hint": server_hint,
},
kwargs=kwargs,
)
process.start()
return process
Expand Down
18 changes: 17 additions & 1 deletion src/view/core/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from __future__ import annotations

from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, TypeAlias

from typing_extensions import Self
Expand Down Expand Up @@ -62,6 +62,9 @@ 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))

Expand All @@ -71,6 +74,19 @@ def __contains__(self, key: object, /) -> bool:
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))

Expand Down
28 changes: 14 additions & 14 deletions src/view/core/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,24 @@ class DuplicateRouteError(ViewError):


@dataclass(slots=True)
class PathNode:
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
children: MutableMapping[str, _PathNode] = field(default_factory=dict)
path_parameter: _PathNode | None = None
subrouter: SubRouter | None = None

def parameter(self, name: str) -> PathNode:
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)
next_node = _PathNode(name=name)
self.path_parameter = next_node
return next_node
if __debug__ and name != self.path_parameter.name:
Expand All @@ -94,7 +94,7 @@ def parameter(self, name: str) -> PathNode:
)
return self.path_parameter

def next(self, part: str) -> PathNode:
def next_node(self, part: str) -> _PathNode:
"""
Get the next node for the given path part, creating it if it doesn't
exist.
Expand All @@ -103,19 +103,19 @@ def next(self, part: str) -> PathNode:
if node is not None:
return node

new_node = PathNode(name=part)
new_node = _PathNode(name=part)
self.children[part] = new_node
return new_node


def is_path_parameter(part: str) -> bool:
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:
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.
Expand Down Expand Up @@ -143,11 +143,11 @@ class Router:
error_views: MutableMapping[type[HTTPError], RouteView] = field(
default_factory=dict
)
parent_node: PathNode = field(default_factory=lambda: PathNode(name=""))
parent_node: _PathNode = field(default_factory=lambda: _PathNode(name=""))

def _get_node_for_path(
self, path: str, *, allow_path_parameters: bool
) -> PathNode:
) -> _PathNode:
if __debug__ and not isinstance(path, str):
raise InvalidTypeError(path, str)

Expand All @@ -156,14 +156,14 @@ def _get_node_for_path(
parts = path.split("/")

for part in parts:
if is_path_parameter(part):
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)
_extract_path_parameter(part)
)
else:
parent_node = parent_node.next(part)
parent_node = parent_node.next_node(part)

return parent_node

Expand Down
3 changes: 2 additions & 1 deletion src/view/dom/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

if TYPE_CHECKING:
from view.core.router import RouteView
from view.dom.components import Component

__all__ = ("HTMLNode", "html_response")

Expand Down Expand Up @@ -175,7 +176,7 @@ def html_context() -> HTMLTree:


P = ParamSpec("P")
HTMLViewResponseItem: TypeAlias = HTMLNode | int
HTMLViewResponseItem: TypeAlias = "HTMLNode | int | Component"
HTMLViewResult = (
AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem]
)
Expand Down
Loading