Skip to content

Commit d356396

Browse files
Changes from development branch (#210)
2 parents 82f2cde + 7ae393c commit d356396

17 files changed

Lines changed: 397 additions & 225 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Python
22
__pycache__/
33
.venv/
4+
.hypothesis/
45

56
# LSP
67
.vscode/

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,70 @@
1010

1111
This is a work-in-progress!
1212

13+
## Installation
14+
15+
It's highly recommended to install from source at the moment:
16+
17+
```
18+
$ pip install git+https://github.com/zerointensity/view.py
19+
```
20+
21+
## Examples
22+
23+
### Simple Hello World
24+
25+
```py
26+
from view.core.app import App
27+
28+
from view.dom.core import html_response
29+
from view.dom.components import page
30+
from view.dom.primitives import h1
31+
32+
app = App()
33+
34+
35+
@app.get("/")
36+
@html_response
37+
async def home():
38+
with page("Hello, view.py!"):
39+
yield h1("Nobody expects the Spanish Inquisition")
40+
41+
42+
app.run()
43+
```
44+
45+
### Button Counter
46+
47+
```py
48+
from view.core.app import App
49+
from view.dom.core import HTMLNode, html_response
50+
from view.dom.components import page
51+
from view.dom.primitives import button, p
52+
53+
from view.javascript import javascript_compiler, as_javascript_expression
54+
55+
app = App()
56+
57+
58+
@javascript_compiler
59+
def click_button(counter: HTMLNode):
60+
yield f"let node = {as_javascript_expression(counter)};"
61+
yield f"let currentNumber = parseInt(node.innerHTML);"
62+
yield f"node.innerHTML = ++currentNumber;"
63+
64+
65+
@app.get("/")
66+
@html_response
67+
async def home():
68+
with page("Counter"):
69+
count = p("0")
70+
yield count
71+
yield button("Click me!", onclick=click_button(count))
72+
73+
74+
app.run()
75+
```
76+
1377
## Copyright
1478

1579
`view.py` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

hatch.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ extra-dependencies = [
1717
"daphne",
1818
"gunicorn",
1919
"werkzeug",
20+
"hypothesis",
2021
]
2122
randomize = true
2223
retries = 3
2324
retries-delay = 1
24-
parallel = false
25+
parallel = true
2526

2627
[[envs.hatch-test.matrix]]
2728
python = ["3.14", "3.13", "3.12", "3.11", "3.10"]

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ classifiers = [
2323
dependencies = ["typing_extensions>=4"]
2424
dynamic = ["version", "license"]
2525

26-
[project.optional-dependencies]
26+
#[project.optional-dependencies]
2727

2828
[project.urls]
2929
Documentation = "https://view.zintensity.dev"
3030
Issues = "https://github.com/ZeroIntensity/view.py/issues"
3131
Source = "https://github.com/ZeroIntensity/view.py"
3232
Funding = "https://github.com/sponsors/ZeroIntensity"
3333

34-
[project.scripts]
35-
view = "view.__main__:main"
36-
view-py = "view.__main__:main"
34+
#[project.scripts]
35+
#view = "view.__main__:main"
36+
#view-py = "view.__main__:main"
3737

3838
[tool.ruff]
3939
exclude = ["tests/", "docs/"]

src/view/__main__.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

src/view/cache.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,39 @@ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Response:
9898

9999

100100
def minutes(number: int, /) -> int:
101+
"""
102+
Convert minutes to seconds.
103+
104+
This is for use in cache decorators.
105+
"""
101106
return number * 60
102107

103108

104109
def seconds(number: int, /) -> int:
110+
"""
111+
Do nothing and return ``number``. This only exists for making it
112+
semantically clear that the intended time is seconds.
113+
114+
This is for use in cache decorators.
115+
"""
105116
return number
106117

107118

108119
def hours(number: int, /) -> int:
120+
"""
121+
Convert hours to seconds.
122+
123+
This is for use in cache decorators.
124+
"""
109125
return minutes(60) * number
110126

111127

112128
def days(number: int, /) -> int:
129+
"""
130+
Convert days to seconds.
131+
132+
This is for use in cache decorators.
133+
"""
113134
return hours(24) * number
114135

115136

src/view/core/app.py

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from importlib.metadata import Distribution, PackageNotFoundError
1717
from multiprocessing import Process
1818
from pathlib import Path
19-
from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar
19+
from typing import TYPE_CHECKING, ParamSpec, TypeAlias, TypeVar, Unpack
2020

2121
from view.core._colors import ColorfulFormatter
2222
from view.core.request import Method, Request
@@ -35,6 +35,7 @@
3535
)
3636
from view.exceptions import InvalidTypeError
3737
from view.responses import FileResponse
38+
from view.run.servers import ServerConfigArgs, run_app_on_any_server
3839
from view.utils import reraise
3940

4041
if TYPE_CHECKING:
@@ -195,23 +196,16 @@ def asgi(self) -> ASGIProtocol:
195196

196197
return asgi_for_app(self)
197198

198-
def run(
199-
self,
200-
*,
201-
host: str = "localhost",
202-
port: int = 5000,
203-
production: bool = False,
204-
server_hint: str | None = None,
205-
) -> None:
199+
def run(self, **kwargs: Unpack[ServerConfigArgs]) -> None:
206200
"""
207201
Run the app.
208202
209203
This is a sort of magic function that's supposed to "just work". If
210204
finer control over the server settings is desired, explicitly use the
211205
server's API with the app's :meth:`asgi` or :meth:`wsgi` method.
212206
"""
213-
from view.run.servers import ServerSettings
214207

208+
production = kwargs.get("production", False)
215209
# If production is True, then __debug__ should be False.
216210
# If production is False, then __debug__ should be True.
217211
if production is __debug__:
@@ -230,11 +224,11 @@ def run(
230224
"If that doesn't sound correct, set VIEW_DEVMODE to 0."
231225
)
232226

233-
self.logger.info("Serving app on http://localhost:%d", port)
234-
self._production = production
235-
settings = ServerSettings(self, host=host, port=port, hint=server_hint)
227+
self.logger.info(
228+
"Serving app on http://localhost:%d", kwargs.get("port") or 5000
229+
)
236230
try:
237-
settings.run_app_on_any_server()
231+
run_app_on_any_server(self, **kwargs)
238232
except KeyboardInterrupt:
239233
self.logger.info("CTRL^C received, shutting down")
240234
except Exception:
@@ -244,11 +238,7 @@ def run(
244238

245239
def run_detached(
246240
self,
247-
*,
248-
host: str = "localhost",
249-
port: int = 5000,
250-
production: bool = False,
251-
server_hint: str | None = None,
241+
**kwargs: Unpack[ServerConfigArgs],
252242
) -> Process:
253243
"""
254244
Run the app in a separate process. This means that the server is
@@ -257,12 +247,7 @@ def run_detached(
257247

258248
process = Process(
259249
target=self.run,
260-
kwargs={
261-
"host": host,
262-
"port": port,
263-
"production": production,
264-
"server_hint": server_hint,
265-
},
250+
kwargs=kwargs,
266251
)
267252
process.start()
268253
return process

src/view/core/headers.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from __future__ import annotations
66

7-
from collections.abc import Mapping
7+
from collections.abc import Iterable, Mapping
88
from typing import TYPE_CHECKING, Any, TypeAlias
99

1010
from typing_extensions import Self
@@ -62,6 +62,9 @@ class HTTPHeaders(MultiMap[str, str]):
6262
Case-insensitive multi-map of HTTP headers.
6363
"""
6464

65+
def __init__(self, items: Iterable[tuple[str, str]] = ()) -> None:
66+
super().__init__((LowerStr(key), value) for key, value in items)
67+
6568
def __getitem__(self, key: str, /) -> str:
6669
return super().__getitem__(LowerStr(key))
6770

@@ -71,6 +74,19 @@ def __contains__(self, key: object, /) -> bool:
7174
def __repr__(self) -> str:
7275
return f"HTTPHeaders({self.as_sequence()})"
7376

77+
def __eq__(self, other: object, /) -> bool:
78+
if isinstance(other, HTTPHeaders):
79+
return other._values == self._values
80+
81+
if isinstance(other, dict):
82+
return self._as_flat() == {
83+
LowerStr(key): value for key, value in other.items()
84+
}
85+
86+
return NotImplemented
87+
88+
__hash__ = MultiMap.__hash__
89+
7490
def get_exactly_one(self, key: str) -> str:
7591
return super().get_exactly_one(LowerStr(key))
7692

src/view/core/router.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,24 +67,24 @@ class DuplicateRouteError(ViewError):
6767

6868

6969
@dataclass(slots=True)
70-
class PathNode:
70+
class _PathNode:
7171
"""
7272
A node in the "path tree".
7373
"""
7474

7575
name: str
7676
routes: MutableMapping[Method, Route] = field(default_factory=dict)
77-
children: MutableMapping[str, PathNode] = field(default_factory=dict)
78-
path_parameter: PathNode | None = None
77+
children: MutableMapping[str, _PathNode] = field(default_factory=dict)
78+
path_parameter: _PathNode | None = None
7979
subrouter: SubRouter | None = None
8080

81-
def parameter(self, name: str) -> PathNode:
81+
def parameter(self, name: str) -> _PathNode:
8282
"""
8383
Mark this node as having a path parameter (if not already), and
8484
return the path parameter node.
8585
"""
8686
if self.path_parameter is None:
87-
next_node = PathNode(name=name)
87+
next_node = _PathNode(name=name)
8888
self.path_parameter = next_node
8989
return next_node
9090
if __debug__ and name != self.path_parameter.name:
@@ -94,7 +94,7 @@ def parameter(self, name: str) -> PathNode:
9494
)
9595
return self.path_parameter
9696

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

106-
new_node = PathNode(name=part)
106+
new_node = _PathNode(name=part)
107107
self.children[part] = new_node
108108
return new_node
109109

110110

111-
def is_path_parameter(part: str) -> bool:
111+
def _is_path_parameter(part: str) -> bool:
112112
"""
113113
Is this part a path parameter?
114114
"""
115115
return part.startswith("{") and part.endswith("}")
116116

117117

118-
def extract_path_parameter(part: str) -> str:
118+
def _extract_path_parameter(part: str) -> str:
119119
"""
120120
Extract the name of a path parameter from a string given by the user
121121
in a route string.
@@ -143,11 +143,11 @@ class Router:
143143
error_views: MutableMapping[type[HTTPError], RouteView] = field(
144144
default_factory=dict
145145
)
146-
parent_node: PathNode = field(default_factory=lambda: PathNode(name=""))
146+
parent_node: _PathNode = field(default_factory=lambda: _PathNode(name=""))
147147

148148
def _get_node_for_path(
149149
self, path: str, *, allow_path_parameters: bool
150-
) -> PathNode:
150+
) -> _PathNode:
151151
if __debug__ and not isinstance(path, str):
152152
raise InvalidTypeError(path, str)
153153

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

158158
for part in parts:
159-
if is_path_parameter(part):
159+
if _is_path_parameter(part):
160160
if not allow_path_parameters:
161161
raise RuntimeError("Path parameters are not allowed here")
162162
parent_node = parent_node.parameter(
163-
extract_path_parameter(part)
163+
_extract_path_parameter(part)
164164
)
165165
else:
166-
parent_node = parent_node.next(part)
166+
parent_node = parent_node.next_node(part)
167167

168168
return parent_node
169169

src/view/dom/core.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
if TYPE_CHECKING:
2828
from view.core.router import RouteView
29+
from view.dom.components import Component
2930

3031
__all__ = ("HTMLNode", "html_response")
3132

@@ -175,7 +176,7 @@ def html_context() -> HTMLTree:
175176

176177

177178
P = ParamSpec("P")
178-
HTMLViewResponseItem: TypeAlias = HTMLNode | int
179+
HTMLViewResponseItem: TypeAlias = "HTMLNode | int | Component"
179180
HTMLViewResult = (
180181
AsyncIterator[HTMLViewResponseItem] | Iterator[HTMLViewResponseItem]
181182
)

0 commit comments

Comments
 (0)