hyperclass 0.0.3__tar.gz → 0.0.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hyperclass-0.0.3 → hyperclass-0.0.5}/PKG-INFO +65 -6
- {hyperclass-0.0.3 → hyperclass-0.0.5}/README.md +64 -5
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/__init__.py +1 -1
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/html.py +91 -29
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/htmx.py +1 -1
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/wsgi.py +31 -13
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/PKG-INFO +65 -6
- {hyperclass-0.0.3 → hyperclass-0.0.5}/pyproject.toml +1 -1
- {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_bookmarks.py +9 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_html.py +64 -5
- {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_htmx.py +17 -1
- hyperclass-0.0.5/tests/test_package.py +5 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_wsgi.py +30 -0
- hyperclass-0.0.3/tests/test_package.py +0 -5
- {hyperclass-0.0.3 → hyperclass-0.0.5}/LICENSE +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/__main__.py +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/css.py +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/SOURCES.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/requires.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/setup.cfg +0 -0
- {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperclass
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: Build interactive web applications as Python class hierarchies.
|
|
5
5
|
Author: Grant Jenks
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,7 +50,7 @@ from dataclasses import dataclass
|
|
|
50
50
|
|
|
51
51
|
from hyperclass import (
|
|
52
52
|
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
53
|
-
post, rem,
|
|
53
|
+
name, post, rem,
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
|
|
@@ -64,11 +64,17 @@ class card(div):
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
class guest_name(input):
|
|
68
|
+
name = name.name
|
|
69
|
+
placeholder = "Your name"
|
|
70
|
+
required = True
|
|
71
|
+
|
|
72
|
+
|
|
67
73
|
class guest_form(form):
|
|
68
74
|
style = css(display=grid, gap=.75 * rem)
|
|
69
75
|
|
|
70
76
|
def content(self):
|
|
71
|
-
yield
|
|
77
|
+
yield guest_name()
|
|
72
78
|
yield button("Say hello", type="submit")
|
|
73
79
|
|
|
74
80
|
|
|
@@ -179,6 +185,39 @@ class greeting(card):
|
|
|
179
185
|
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
180
186
|
escape hatch for trusted HTML.
|
|
181
187
|
|
|
188
|
+
## HTML attributes inherit too
|
|
189
|
+
|
|
190
|
+
Non-private class values become default HTML attributes:
|
|
191
|
+
|
|
192
|
+
~~~python
|
|
193
|
+
from hyperclass import a, input, name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class external_link(a):
|
|
197
|
+
target = "_blank"
|
|
198
|
+
rel = "noreferrer"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class url_field(input):
|
|
202
|
+
type = "url"
|
|
203
|
+
name = name.url
|
|
204
|
+
required = True
|
|
205
|
+
autocomplete = "url"
|
|
206
|
+
~~~
|
|
207
|
+
|
|
208
|
+
The defaults follow the same base-to-derived order as styles. Subclasses and
|
|
209
|
+
multiple-inheritance mixins can override them. Attributes passed to an instance
|
|
210
|
+
win last:
|
|
211
|
+
|
|
212
|
+
~~~python
|
|
213
|
+
external_link("Same tab", href="/", target="_self", rel=None)
|
|
214
|
+
~~~
|
|
215
|
+
|
|
216
|
+
`None` and `False` suppress an inherited attribute. Underscores in Python names
|
|
217
|
+
become hyphens, so `aria_label` renders as `aria-label`. Boolean `True` renders
|
|
218
|
+
as a valueless HTML attribute. An `hx = hx.get(...)` class default expands into
|
|
219
|
+
the corresponding htmx attributes.
|
|
220
|
+
|
|
182
221
|
## CSS is Python too
|
|
183
222
|
|
|
184
223
|
Base styles, pseudo-states, and media rules live on the component:
|
|
@@ -200,10 +239,11 @@ class primary_button(button):
|
|
|
200
239
|
narrow = media(max_width=40 * rem, width="100%")
|
|
201
240
|
~~~
|
|
202
241
|
|
|
203
|
-
Pages collect only the rules used by their element tree.
|
|
204
|
-
|
|
242
|
+
Pages collect only the rules used by their element tree. Rules use the concrete
|
|
243
|
+
semantic class chain as their selector, so Python inheritance and the CSS
|
|
244
|
+
cascade cooperate even when new component styles arrive later.
|
|
205
245
|
|
|
206
|
-
## Classes and
|
|
246
|
+
## Classes, IDs, and names are selectors
|
|
207
247
|
|
|
208
248
|
Classes can be used directly anywhere a selector is expected:
|
|
209
249
|
|
|
@@ -226,6 +266,19 @@ assert id.unread_count is id.unread_count
|
|
|
226
266
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
227
267
|
selector, it renders as `#unread-count`.
|
|
228
268
|
|
|
269
|
+
Form names work the same way while preserving Python underscores:
|
|
270
|
+
|
|
271
|
+
~~~python
|
|
272
|
+
from hyperclass import name
|
|
273
|
+
|
|
274
|
+
input(name=name.search_query)
|
|
275
|
+
request.form[name.search_query]
|
|
276
|
+
hx.get(search, include=name.search_query, target=id.results)
|
|
277
|
+
~~~
|
|
278
|
+
|
|
279
|
+
As an attribute, `name.search_query` renders as `search_query`. As a selector,
|
|
280
|
+
it renders as `[name="search_query"]`. Repeated access returns the same object.
|
|
281
|
+
|
|
229
282
|
## Routes are references, not strings
|
|
230
283
|
|
|
231
284
|
Application subclasses collect decorated method routes:
|
|
@@ -286,6 +339,7 @@ return a more specific `Response`.
|
|
|
286
339
|
|
|
287
340
|
The underlying values remain available as `request.form`, `request.query`,
|
|
288
341
|
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
342
|
+
Those accessors accept first-class `name.*` objects as well as strings.
|
|
289
343
|
|
|
290
344
|
## WSGI and htmx 4
|
|
291
345
|
|
|
@@ -305,6 +359,11 @@ htmx request sends only the fragment to swap.
|
|
|
305
359
|
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
306
360
|
object-selected regions from one request.
|
|
307
361
|
|
|
362
|
+
When an htmx response introduces a component that was not present on the first
|
|
363
|
+
page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
364
|
+
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
365
|
+
a reload or a global CSS build.
|
|
366
|
+
|
|
308
367
|
## Try the examples
|
|
309
368
|
|
|
310
369
|
Clone the repository and run the persistent SQLite bookmark inbox:
|
|
@@ -20,7 +20,7 @@ from dataclasses import dataclass
|
|
|
20
20
|
|
|
21
21
|
from hyperclass import (
|
|
22
22
|
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
23
|
-
post, rem,
|
|
23
|
+
name, post, rem,
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
|
|
@@ -34,11 +34,17 @@ class card(div):
|
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
class guest_name(input):
|
|
38
|
+
name = name.name
|
|
39
|
+
placeholder = "Your name"
|
|
40
|
+
required = True
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
class guest_form(form):
|
|
38
44
|
style = css(display=grid, gap=.75 * rem)
|
|
39
45
|
|
|
40
46
|
def content(self):
|
|
41
|
-
yield
|
|
47
|
+
yield guest_name()
|
|
42
48
|
yield button("Say hello", type="submit")
|
|
43
49
|
|
|
44
50
|
|
|
@@ -149,6 +155,39 @@ class greeting(card):
|
|
|
149
155
|
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
150
156
|
escape hatch for trusted HTML.
|
|
151
157
|
|
|
158
|
+
## HTML attributes inherit too
|
|
159
|
+
|
|
160
|
+
Non-private class values become default HTML attributes:
|
|
161
|
+
|
|
162
|
+
~~~python
|
|
163
|
+
from hyperclass import a, input, name
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class external_link(a):
|
|
167
|
+
target = "_blank"
|
|
168
|
+
rel = "noreferrer"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class url_field(input):
|
|
172
|
+
type = "url"
|
|
173
|
+
name = name.url
|
|
174
|
+
required = True
|
|
175
|
+
autocomplete = "url"
|
|
176
|
+
~~~
|
|
177
|
+
|
|
178
|
+
The defaults follow the same base-to-derived order as styles. Subclasses and
|
|
179
|
+
multiple-inheritance mixins can override them. Attributes passed to an instance
|
|
180
|
+
win last:
|
|
181
|
+
|
|
182
|
+
~~~python
|
|
183
|
+
external_link("Same tab", href="/", target="_self", rel=None)
|
|
184
|
+
~~~
|
|
185
|
+
|
|
186
|
+
`None` and `False` suppress an inherited attribute. Underscores in Python names
|
|
187
|
+
become hyphens, so `aria_label` renders as `aria-label`. Boolean `True` renders
|
|
188
|
+
as a valueless HTML attribute. An `hx = hx.get(...)` class default expands into
|
|
189
|
+
the corresponding htmx attributes.
|
|
190
|
+
|
|
152
191
|
## CSS is Python too
|
|
153
192
|
|
|
154
193
|
Base styles, pseudo-states, and media rules live on the component:
|
|
@@ -170,10 +209,11 @@ class primary_button(button):
|
|
|
170
209
|
narrow = media(max_width=40 * rem, width="100%")
|
|
171
210
|
~~~
|
|
172
211
|
|
|
173
|
-
Pages collect only the rules used by their element tree.
|
|
174
|
-
|
|
212
|
+
Pages collect only the rules used by their element tree. Rules use the concrete
|
|
213
|
+
semantic class chain as their selector, so Python inheritance and the CSS
|
|
214
|
+
cascade cooperate even when new component styles arrive later.
|
|
175
215
|
|
|
176
|
-
## Classes and
|
|
216
|
+
## Classes, IDs, and names are selectors
|
|
177
217
|
|
|
178
218
|
Classes can be used directly anywhere a selector is expected:
|
|
179
219
|
|
|
@@ -196,6 +236,19 @@ assert id.unread_count is id.unread_count
|
|
|
196
236
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
197
237
|
selector, it renders as `#unread-count`.
|
|
198
238
|
|
|
239
|
+
Form names work the same way while preserving Python underscores:
|
|
240
|
+
|
|
241
|
+
~~~python
|
|
242
|
+
from hyperclass import name
|
|
243
|
+
|
|
244
|
+
input(name=name.search_query)
|
|
245
|
+
request.form[name.search_query]
|
|
246
|
+
hx.get(search, include=name.search_query, target=id.results)
|
|
247
|
+
~~~
|
|
248
|
+
|
|
249
|
+
As an attribute, `name.search_query` renders as `search_query`. As a selector,
|
|
250
|
+
it renders as `[name="search_query"]`. Repeated access returns the same object.
|
|
251
|
+
|
|
199
252
|
## Routes are references, not strings
|
|
200
253
|
|
|
201
254
|
Application subclasses collect decorated method routes:
|
|
@@ -256,6 +309,7 @@ return a more specific `Response`.
|
|
|
256
309
|
|
|
257
310
|
The underlying values remain available as `request.form`, `request.query`,
|
|
258
311
|
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
312
|
+
Those accessors accept first-class `name.*` objects as well as strings.
|
|
259
313
|
|
|
260
314
|
## WSGI and htmx 4
|
|
261
315
|
|
|
@@ -275,6 +329,11 @@ htmx request sends only the fragment to swap.
|
|
|
275
329
|
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
276
330
|
object-selected regions from one request.
|
|
277
331
|
|
|
332
|
+
When an htmx response introduces a component that was not present on the first
|
|
333
|
+
page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
334
|
+
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
335
|
+
a reload or a global CSS build.
|
|
336
|
+
|
|
278
337
|
## Try the examples
|
|
279
338
|
|
|
280
339
|
Clone the repository and run the persistent SQLite bookmark inbox:
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from html import escape
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from .css import
|
|
10
|
+
from .css import PSEUDO_STATES, Media, Style
|
|
11
11
|
|
|
12
12
|
HTMX_SRC = "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0"
|
|
13
13
|
HTMX_INTEGRITY = (
|
|
@@ -51,6 +51,38 @@ class IdNamespace:
|
|
|
51
51
|
id = IdNamespace()
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Name:
|
|
56
|
+
"""A first-class form name which is also usable as a selector."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def selector(self) -> str:
|
|
62
|
+
return f'[name="{self.name}"]'
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return self.name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NameNamespace:
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._values: dict[str, Name] = {}
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, value: str) -> Name:
|
|
73
|
+
if value.startswith("_"):
|
|
74
|
+
raise AttributeError(value)
|
|
75
|
+
try:
|
|
76
|
+
return self._values[value]
|
|
77
|
+
except KeyError:
|
|
78
|
+
field_name = Name(value)
|
|
79
|
+
self._values[value] = field_name
|
|
80
|
+
return field_name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
name = NameNamespace()
|
|
84
|
+
|
|
85
|
+
|
|
54
86
|
class ElementMeta(type):
|
|
55
87
|
@property
|
|
56
88
|
def selector(cls) -> str:
|
|
@@ -93,12 +125,14 @@ def semantic_classes(value: type) -> tuple[type, ...]:
|
|
|
93
125
|
def selector(value: Any) -> str:
|
|
94
126
|
"""Turn a selector string, element class, or element instance into CSS."""
|
|
95
127
|
|
|
96
|
-
if isinstance(value, Id):
|
|
128
|
+
if isinstance(value, (Id, Name)):
|
|
97
129
|
return value.selector
|
|
98
130
|
if isinstance(value, str):
|
|
99
131
|
return value
|
|
100
132
|
if isinstance(value, element):
|
|
101
|
-
|
|
133
|
+
attributes = _class_attributes(type(value))
|
|
134
|
+
attributes.update(getattr(value, "_attrs", {}))
|
|
135
|
+
identity = attributes.get("id")
|
|
102
136
|
if identity:
|
|
103
137
|
return f"#{identity}"
|
|
104
138
|
return selector(type(value))
|
|
@@ -139,33 +173,38 @@ def fragment(*children: Any) -> Fragment:
|
|
|
139
173
|
|
|
140
174
|
class RenderContext:
|
|
141
175
|
def __init__(self) -> None:
|
|
142
|
-
self.
|
|
143
|
-
self.
|
|
176
|
+
self.styled_elements: list[type] = []
|
|
177
|
+
self._seen_elements: set[type] = set()
|
|
144
178
|
|
|
145
179
|
def register(self, value: type) -> None:
|
|
146
|
-
if value in self.
|
|
180
|
+
if value in self._seen_elements:
|
|
147
181
|
return
|
|
182
|
+
classes = semantic_classes(value)
|
|
148
183
|
if any(
|
|
149
184
|
isinstance(rule, (Style, Media))
|
|
150
|
-
for
|
|
185
|
+
for semantic_class in classes
|
|
186
|
+
for rule in semantic_class.__dict__.values()
|
|
151
187
|
):
|
|
152
|
-
self.
|
|
153
|
-
self.
|
|
188
|
+
self._seen_elements.add(value)
|
|
189
|
+
self.styled_elements.append(value)
|
|
154
190
|
|
|
155
191
|
def stylesheet(self) -> str:
|
|
156
192
|
rules: list[str] = []
|
|
157
|
-
for
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
193
|
+
for element_type in self.styled_elements:
|
|
194
|
+
classes = semantic_classes(element_type)
|
|
195
|
+
base = "".join(f".{class_name(value)}" for value in classes)
|
|
196
|
+
for value in classes:
|
|
197
|
+
for name, rule in value.__dict__.items():
|
|
198
|
+
if name == "style" and isinstance(rule, Style):
|
|
199
|
+
rules.append(f"{base}{{{rule.render()}}}")
|
|
200
|
+
elif name in PSEUDO_STATES and isinstance(rule, Style):
|
|
201
|
+
state = name.replace("_", "-")
|
|
202
|
+
rules.append(f"{base}:{state}{{{rule.render()}}}")
|
|
203
|
+
elif isinstance(rule, Media):
|
|
204
|
+
rules.append(
|
|
205
|
+
f"@media {rule.query()}"
|
|
206
|
+
f"{{{base}{{{rule.style.render()}}}}}"
|
|
207
|
+
)
|
|
169
208
|
return "".join(rules)
|
|
170
209
|
|
|
171
210
|
|
|
@@ -183,6 +222,27 @@ def _attribute_value(value: Any) -> str:
|
|
|
183
222
|
return str(value)
|
|
184
223
|
|
|
185
224
|
|
|
225
|
+
def _attribute(target: dict[str, Any], name: str, value: Any) -> None:
|
|
226
|
+
if name == "hx" and isinstance(value, Mapping):
|
|
227
|
+
target.update(value)
|
|
228
|
+
else:
|
|
229
|
+
target[_attribute_name(name)] = value
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _class_attributes(value: type) -> dict[str, Any]:
|
|
233
|
+
attributes: dict[str, Any] = {}
|
|
234
|
+
for semantic_class in semantic_classes(value):
|
|
235
|
+
for name, default in semantic_class.__dict__.items():
|
|
236
|
+
if name.startswith("_") or isinstance(default, (Style, Media, type)):
|
|
237
|
+
continue
|
|
238
|
+
if isinstance(default, (classmethod, staticmethod, property)):
|
|
239
|
+
continue
|
|
240
|
+
if callable(default):
|
|
241
|
+
continue
|
|
242
|
+
_attribute(attributes, name, default)
|
|
243
|
+
return attributes
|
|
244
|
+
|
|
245
|
+
|
|
186
246
|
class element(metaclass=ElementMeta):
|
|
187
247
|
"""Base class for all rendered HTML elements."""
|
|
188
248
|
|
|
@@ -190,10 +250,7 @@ class element(metaclass=ElementMeta):
|
|
|
190
250
|
self._children = children
|
|
191
251
|
self._attrs: dict[str, Any] = {}
|
|
192
252
|
for name, value in attributes.items():
|
|
193
|
-
|
|
194
|
-
self._attrs.update(value)
|
|
195
|
-
else:
|
|
196
|
-
self._attrs[_attribute_name(name)] = value
|
|
253
|
+
_attribute(self._attrs, name, value)
|
|
197
254
|
|
|
198
255
|
@property
|
|
199
256
|
def selector(self) -> str:
|
|
@@ -256,7 +313,8 @@ partial = ElementMeta(
|
|
|
256
313
|
|
|
257
314
|
|
|
258
315
|
def _render_attributes(node: element, classes: tuple[type, ...]) -> str:
|
|
259
|
-
attributes =
|
|
316
|
+
attributes = _class_attributes(type(node))
|
|
317
|
+
attributes.update(getattr(node, "_attrs", {}))
|
|
260
318
|
generated = [class_name(value) for value in classes]
|
|
261
319
|
explicit = attributes.pop("class", attributes.pop("class-", None))
|
|
262
320
|
if explicit:
|
|
@@ -287,8 +345,7 @@ def _render(value: Any, context: RenderContext) -> str:
|
|
|
287
345
|
value_type = type(value)
|
|
288
346
|
tag = _tag_for(value_type)
|
|
289
347
|
classes = semantic_classes(value_type)
|
|
290
|
-
|
|
291
|
-
context.register(semantic_class)
|
|
348
|
+
context.register(value_type)
|
|
292
349
|
attributes = _render_attributes(value, classes)
|
|
293
350
|
if tag in VOID_TAGS:
|
|
294
351
|
return f"<{tag}{attributes}>"
|
|
@@ -330,7 +387,9 @@ class Page:
|
|
|
330
387
|
body_html = _render(self.children, context)
|
|
331
388
|
head_html = _render(self.head, context)
|
|
332
389
|
stylesheet = context.stylesheet()
|
|
333
|
-
style_html =
|
|
390
|
+
style_html = (
|
|
391
|
+
f'<style id="{id.hyperclass_styles}">{stylesheet}</style>'
|
|
392
|
+
)
|
|
334
393
|
script_html = ""
|
|
335
394
|
if self.htmx:
|
|
336
395
|
script_html = (
|
|
@@ -365,12 +424,15 @@ __all__ = [
|
|
|
365
424
|
"Id",
|
|
366
425
|
"IdNamespace",
|
|
367
426
|
"Markup",
|
|
427
|
+
"Name",
|
|
428
|
+
"NameNamespace",
|
|
368
429
|
"Page",
|
|
369
430
|
"class_name",
|
|
370
431
|
"element",
|
|
371
432
|
"fragment",
|
|
372
433
|
"id",
|
|
373
434
|
"markup",
|
|
435
|
+
"name",
|
|
374
436
|
"page",
|
|
375
437
|
"partial",
|
|
376
438
|
"render",
|
|
@@ -66,7 +66,7 @@ class Htmx:
|
|
|
66
66
|
for name, value in options.items():
|
|
67
67
|
if value is None:
|
|
68
68
|
continue
|
|
69
|
-
if name in {"target", "select", "select_oob", "sync"}:
|
|
69
|
+
if name in {"include", "target", "select", "select_oob", "sync"}:
|
|
70
70
|
value = selector(value) if not isinstance(value, Target) else value
|
|
71
71
|
values[_option_name(name)] = value
|
|
72
72
|
return Attributes(values)
|
|
@@ -13,7 +13,16 @@ from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
|
13
13
|
from urllib.parse import parse_qs, quote, urlencode
|
|
14
14
|
from wsgiref.simple_server import make_server
|
|
15
15
|
|
|
16
|
-
from .html import
|
|
16
|
+
from .html import (
|
|
17
|
+
Fragment,
|
|
18
|
+
Page,
|
|
19
|
+
RenderContext,
|
|
20
|
+
element,
|
|
21
|
+
id,
|
|
22
|
+
markup,
|
|
23
|
+
partial,
|
|
24
|
+
render,
|
|
25
|
+
)
|
|
17
26
|
|
|
18
27
|
Handler = Callable[..., Any]
|
|
19
28
|
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
|
@@ -230,8 +239,8 @@ class Values(Mapping[str, str]):
|
|
|
230
239
|
def __init__(self, values: Mapping[str, list[str]] | None = None):
|
|
231
240
|
self._values = dict(values or {})
|
|
232
241
|
|
|
233
|
-
def __getitem__(self, key:
|
|
234
|
-
return self._values[key][-1]
|
|
242
|
+
def __getitem__(self, key: Any) -> str:
|
|
243
|
+
return self._values[str(key)][-1]
|
|
235
244
|
|
|
236
245
|
def __iter__(self) -> Iterator[str]:
|
|
237
246
|
return iter(self._values)
|
|
@@ -239,14 +248,14 @@ class Values(Mapping[str, str]):
|
|
|
239
248
|
def __len__(self) -> int:
|
|
240
249
|
return len(self._values)
|
|
241
250
|
|
|
242
|
-
def get(self, key:
|
|
243
|
-
values = self._values.get(key)
|
|
251
|
+
def get(self, key: Any, default: Any = None) -> Any:
|
|
252
|
+
values = self._values.get(str(key))
|
|
244
253
|
return values[-1] if values else default
|
|
245
254
|
|
|
246
|
-
def getlist(self, key:
|
|
247
|
-
return list(self._values.get(key, ()))
|
|
255
|
+
def getlist(self, key: Any) -> list[str]:
|
|
256
|
+
return list(self._values.get(str(key), ()))
|
|
248
257
|
|
|
249
|
-
def int(self, key:
|
|
258
|
+
def int(self, key: Any, default: int | None = None) -> int:
|
|
250
259
|
value = self.get(key)
|
|
251
260
|
if value is None:
|
|
252
261
|
if default is not None:
|
|
@@ -479,11 +488,20 @@ class App:
|
|
|
479
488
|
if isinstance(body, Page):
|
|
480
489
|
text = body.render()
|
|
481
490
|
elif isinstance(body, (element, Fragment)):
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
491
|
+
if request.is_htmx:
|
|
492
|
+
context = RenderContext()
|
|
493
|
+
text = render(body, context=context)
|
|
494
|
+
stylesheet = context.stylesheet()
|
|
495
|
+
if stylesheet:
|
|
496
|
+
text += render(
|
|
497
|
+
partial(
|
|
498
|
+
markup(stylesheet),
|
|
499
|
+
id=id.hyperclass_styles,
|
|
500
|
+
hx_swap="append",
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
text = Page(body, title=self.title).render()
|
|
487
505
|
else:
|
|
488
506
|
text = str(body)
|
|
489
507
|
payload = text.encode("utf-8")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperclass
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: Build interactive web applications as Python class hierarchies.
|
|
5
5
|
Author: Grant Jenks
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -50,7 +50,7 @@ from dataclasses import dataclass
|
|
|
50
50
|
|
|
51
51
|
from hyperclass import (
|
|
52
52
|
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
53
|
-
post, rem,
|
|
53
|
+
name, post, rem,
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
|
|
@@ -64,11 +64,17 @@ class card(div):
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
class guest_name(input):
|
|
68
|
+
name = name.name
|
|
69
|
+
placeholder = "Your name"
|
|
70
|
+
required = True
|
|
71
|
+
|
|
72
|
+
|
|
67
73
|
class guest_form(form):
|
|
68
74
|
style = css(display=grid, gap=.75 * rem)
|
|
69
75
|
|
|
70
76
|
def content(self):
|
|
71
|
-
yield
|
|
77
|
+
yield guest_name()
|
|
72
78
|
yield button("Say hello", type="submit")
|
|
73
79
|
|
|
74
80
|
|
|
@@ -179,6 +185,39 @@ class greeting(card):
|
|
|
179
185
|
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
180
186
|
escape hatch for trusted HTML.
|
|
181
187
|
|
|
188
|
+
## HTML attributes inherit too
|
|
189
|
+
|
|
190
|
+
Non-private class values become default HTML attributes:
|
|
191
|
+
|
|
192
|
+
~~~python
|
|
193
|
+
from hyperclass import a, input, name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class external_link(a):
|
|
197
|
+
target = "_blank"
|
|
198
|
+
rel = "noreferrer"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class url_field(input):
|
|
202
|
+
type = "url"
|
|
203
|
+
name = name.url
|
|
204
|
+
required = True
|
|
205
|
+
autocomplete = "url"
|
|
206
|
+
~~~
|
|
207
|
+
|
|
208
|
+
The defaults follow the same base-to-derived order as styles. Subclasses and
|
|
209
|
+
multiple-inheritance mixins can override them. Attributes passed to an instance
|
|
210
|
+
win last:
|
|
211
|
+
|
|
212
|
+
~~~python
|
|
213
|
+
external_link("Same tab", href="/", target="_self", rel=None)
|
|
214
|
+
~~~
|
|
215
|
+
|
|
216
|
+
`None` and `False` suppress an inherited attribute. Underscores in Python names
|
|
217
|
+
become hyphens, so `aria_label` renders as `aria-label`. Boolean `True` renders
|
|
218
|
+
as a valueless HTML attribute. An `hx = hx.get(...)` class default expands into
|
|
219
|
+
the corresponding htmx attributes.
|
|
220
|
+
|
|
182
221
|
## CSS is Python too
|
|
183
222
|
|
|
184
223
|
Base styles, pseudo-states, and media rules live on the component:
|
|
@@ -200,10 +239,11 @@ class primary_button(button):
|
|
|
200
239
|
narrow = media(max_width=40 * rem, width="100%")
|
|
201
240
|
~~~
|
|
202
241
|
|
|
203
|
-
Pages collect only the rules used by their element tree.
|
|
204
|
-
|
|
242
|
+
Pages collect only the rules used by their element tree. Rules use the concrete
|
|
243
|
+
semantic class chain as their selector, so Python inheritance and the CSS
|
|
244
|
+
cascade cooperate even when new component styles arrive later.
|
|
205
245
|
|
|
206
|
-
## Classes and
|
|
246
|
+
## Classes, IDs, and names are selectors
|
|
207
247
|
|
|
208
248
|
Classes can be used directly anywhere a selector is expected:
|
|
209
249
|
|
|
@@ -226,6 +266,19 @@ assert id.unread_count is id.unread_count
|
|
|
226
266
|
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
227
267
|
selector, it renders as `#unread-count`.
|
|
228
268
|
|
|
269
|
+
Form names work the same way while preserving Python underscores:
|
|
270
|
+
|
|
271
|
+
~~~python
|
|
272
|
+
from hyperclass import name
|
|
273
|
+
|
|
274
|
+
input(name=name.search_query)
|
|
275
|
+
request.form[name.search_query]
|
|
276
|
+
hx.get(search, include=name.search_query, target=id.results)
|
|
277
|
+
~~~
|
|
278
|
+
|
|
279
|
+
As an attribute, `name.search_query` renders as `search_query`. As a selector,
|
|
280
|
+
it renders as `[name="search_query"]`. Repeated access returns the same object.
|
|
281
|
+
|
|
229
282
|
## Routes are references, not strings
|
|
230
283
|
|
|
231
284
|
Application subclasses collect decorated method routes:
|
|
@@ -286,6 +339,7 @@ return a more specific `Response`.
|
|
|
286
339
|
|
|
287
340
|
The underlying values remain available as `request.form`, `request.query`,
|
|
288
341
|
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
342
|
+
Those accessors accept first-class `name.*` objects as well as strings.
|
|
289
343
|
|
|
290
344
|
## WSGI and htmx 4
|
|
291
345
|
|
|
@@ -305,6 +359,11 @@ htmx request sends only the fragment to swap.
|
|
|
305
359
|
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
306
360
|
object-selected regions from one request.
|
|
307
361
|
|
|
362
|
+
When an htmx response introduces a component that was not present on the first
|
|
363
|
+
page, Hyperclass includes its CSS in a partial targeting the page's stable
|
|
364
|
+
`hyperclass-styles` stylesheet. The new fragment is styled immediately, without
|
|
365
|
+
a reload or a global CSS build.
|
|
366
|
+
|
|
308
367
|
## Try the examples
|
|
309
368
|
|
|
310
369
|
Clone the repository and run the persistent SQLite bookmark inbox:
|
|
@@ -38,6 +38,8 @@ def test_bookmark_lifecycle(tmp_path):
|
|
|
38
38
|
assert payload.startswith("<!doctype html>")
|
|
39
39
|
assert "Bookmark inbox" in payload
|
|
40
40
|
assert "0 unread bookmarks" in payload
|
|
41
|
+
assert '<style id="hyperclass-styles">' in payload
|
|
42
|
+
assert ".bookmark-card" not in payload
|
|
41
43
|
assert 'content="width=device-width, initial-scale=1"' in payload
|
|
42
44
|
assert (
|
|
43
45
|
"@media (max-width:40rem){.bookmark-form{grid-template-columns:1fr}}"
|
|
@@ -55,7 +57,14 @@ def test_bookmark_lifecycle(tmp_path):
|
|
|
55
57
|
assert "<!doctype html>" not in payload
|
|
56
58
|
assert "Read this" in payload
|
|
57
59
|
assert "1 unread bookmark" in payload
|
|
60
|
+
assert (
|
|
61
|
+
'<input class="text-field url-field" type="url" name="url" required'
|
|
62
|
+
in payload
|
|
63
|
+
)
|
|
64
|
+
assert 'target="_blank" rel="noreferrer"' in payload
|
|
58
65
|
assert 'hx-patch="/bookmarks/1"' in payload
|
|
66
|
+
assert '<hx-partial id="hyperclass-styles" hx-swap="append">' in payload
|
|
67
|
+
assert ".bookmark-card.unread.unread-bookmark{display:grid" in payload
|
|
59
68
|
bookmark = app.store.list()[0]
|
|
60
69
|
|
|
61
70
|
captured, payload = request(
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from hyperclass import (
|
|
2
2
|
Page,
|
|
3
|
+
a,
|
|
4
|
+
button,
|
|
3
5
|
css,
|
|
4
6
|
div,
|
|
5
7
|
fragment,
|
|
6
8
|
grid,
|
|
7
9
|
id,
|
|
10
|
+
input,
|
|
8
11
|
media,
|
|
12
|
+
name,
|
|
9
13
|
orange,
|
|
10
14
|
outer_morph,
|
|
11
15
|
partial,
|
|
@@ -63,9 +67,14 @@ def test_page_collects_styles_and_pins_htmx_4():
|
|
|
63
67
|
document = Page(warning_card("Careful"), title="Demo").render()
|
|
64
68
|
assert document.startswith("<!doctype html>")
|
|
65
69
|
assert "<title>Demo</title>" in document
|
|
66
|
-
assert "
|
|
70
|
+
assert '<style id="hyperclass-styles">' in document
|
|
67
71
|
assert (
|
|
68
|
-
".warning-card{
|
|
72
|
+
".card.warning-card{display:grid;gap:1rem;padding:1.25rem}"
|
|
73
|
+
in document
|
|
74
|
+
)
|
|
75
|
+
assert (
|
|
76
|
+
".card.warning-card{border-color:orange;"
|
|
77
|
+
"background:rgb(255 165 0 / 0.08)}"
|
|
69
78
|
in document
|
|
70
79
|
)
|
|
71
80
|
assert "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0" in document
|
|
@@ -99,11 +108,61 @@ def test_page_collects_state_and_media_styles_from_classes():
|
|
|
99
108
|
narrow = media(max_width=40 * rem, grid_template_columns="1fr")
|
|
100
109
|
|
|
101
110
|
document = Page(interactive_card()).render()
|
|
102
|
-
assert ".interactive-card:hover{background:lavender}" in document
|
|
111
|
+
assert ".card.interactive-card:hover{background:lavender}" in document
|
|
103
112
|
assert (
|
|
104
|
-
".interactive-card:focus-visible{outline:2px solid purple}"
|
|
113
|
+
".card.interactive-card:focus-visible{outline:2px solid purple}"
|
|
114
|
+
in document
|
|
105
115
|
)
|
|
106
116
|
assert (
|
|
107
|
-
"@media (max-width:40rem){.interactive-card
|
|
117
|
+
"@media (max-width:40rem){.card.interactive-card"
|
|
118
|
+
"{grid-template-columns:1fr}}"
|
|
108
119
|
in document
|
|
109
120
|
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_html_attributes_are_inherited_and_can_be_overridden():
|
|
124
|
+
class external_link(a):
|
|
125
|
+
target = "_blank"
|
|
126
|
+
rel = "noreferrer"
|
|
127
|
+
|
|
128
|
+
assert render(external_link("Python", href="https://python.org")) == (
|
|
129
|
+
'<a class="external-link" target="_blank" rel="noreferrer" '
|
|
130
|
+
'href="https://python.org">Python</a>'
|
|
131
|
+
)
|
|
132
|
+
assert render(external_link("Here", href="/", target="_self", rel=None)) == (
|
|
133
|
+
'<a class="external-link" target="_self" href="/">Here</a>'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_multiple_inheritance_composes_attribute_defaults():
|
|
138
|
+
class disabled:
|
|
139
|
+
disabled = True
|
|
140
|
+
aria_disabled = "true"
|
|
141
|
+
|
|
142
|
+
class caution:
|
|
143
|
+
title = "Careful"
|
|
144
|
+
|
|
145
|
+
class cautious_button(button, disabled, caution):
|
|
146
|
+
title = "Really careful"
|
|
147
|
+
|
|
148
|
+
assert render(cautious_button("Continue")) == (
|
|
149
|
+
'<button class="disabled caution cautious-button" disabled '
|
|
150
|
+
'aria-disabled="true" title="Really careful">Continue</button>'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_names_are_lazy_interned_attributes_and_selectors():
|
|
155
|
+
assert name.search_query is name.search_query
|
|
156
|
+
assert str(name.search_query) == "search_query"
|
|
157
|
+
assert name.search_query.selector == '[name="search_query"]'
|
|
158
|
+
assert selector(name.search_query) == '[name="search_query"]'
|
|
159
|
+
assert render(input(name=name.search_query)) == (
|
|
160
|
+
'<input name="search_query">'
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_class_default_id_controls_instance_selector():
|
|
165
|
+
class results(div):
|
|
166
|
+
id = id.search_results
|
|
167
|
+
|
|
168
|
+
assert results().selector == "#search-results"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from hyperclass import closest, div, hx, outer_morph, render
|
|
1
|
+
from hyperclass import closest, div, hx, id, name, outer_morph, render
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class counter(div):
|
|
@@ -24,3 +24,19 @@ def test_plain_class_target_becomes_a_selector():
|
|
|
24
24
|
assert render(div(hx=hx.get("/counter", target=counter))) == (
|
|
25
25
|
'<div hx-get="/counter" hx-target=".counter"></div>'
|
|
26
26
|
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_class_attributes_can_declare_htmx_and_name_selectors():
|
|
30
|
+
class live_search(div):
|
|
31
|
+
hx = hx.get(
|
|
32
|
+
"/search",
|
|
33
|
+
include=name.query,
|
|
34
|
+
target=id.results,
|
|
35
|
+
swap=outer_morph,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert render(live_search()) == (
|
|
39
|
+
'<div class="live-search" hx-get="/search" '
|
|
40
|
+
'hx-include="[name="query"]" hx-target="#results" '
|
|
41
|
+
'hx-swap="outerMorph"></div>'
|
|
42
|
+
)
|
|
@@ -5,14 +5,17 @@ from wsgiref.util import setup_testing_defaults
|
|
|
5
5
|
|
|
6
6
|
from hyperclass import (
|
|
7
7
|
App,
|
|
8
|
+
Values,
|
|
8
9
|
button,
|
|
9
10
|
closest,
|
|
11
|
+
css,
|
|
10
12
|
div,
|
|
11
13
|
form,
|
|
12
14
|
get,
|
|
13
15
|
hidden,
|
|
14
16
|
hx,
|
|
15
17
|
input,
|
|
18
|
+
name,
|
|
16
19
|
outer_morph,
|
|
17
20
|
output,
|
|
18
21
|
patch,
|
|
@@ -266,3 +269,30 @@ def test_dataclass_binding_uses_defaults_and_reports_bad_values():
|
|
|
266
269
|
)
|
|
267
270
|
assert captured["status"] == "400 Bad Request"
|
|
268
271
|
assert payload == "invalid form value for priority: 'high'"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_values_accept_first_class_form_names():
|
|
275
|
+
values = Values({"search_query": ["python", "wsgi"], "page": ["3"]})
|
|
276
|
+
assert values[name.search_query] == "wsgi"
|
|
277
|
+
assert values.get(name.search_query) == "wsgi"
|
|
278
|
+
assert values.getlist(name.search_query) == ["python", "wsgi"]
|
|
279
|
+
assert values.int(name.page) == 3
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_htmx_fragment_appends_required_styles_to_page_stylesheet():
|
|
283
|
+
class styled_result(div):
|
|
284
|
+
style = css(color="purple")
|
|
285
|
+
|
|
286
|
+
app = App()
|
|
287
|
+
|
|
288
|
+
@app.get("/result")
|
|
289
|
+
def result(request):
|
|
290
|
+
return styled_result("Found")
|
|
291
|
+
|
|
292
|
+
captured, payload = request(app, path="/result", htmx=True)
|
|
293
|
+
assert captured["status"] == "200 OK"
|
|
294
|
+
assert payload == (
|
|
295
|
+
'<div class="styled-result">Found</div>'
|
|
296
|
+
'<hx-partial id="hyperclass-styles" hx-swap="append">'
|
|
297
|
+
".styled-result{color:purple}</hx-partial>"
|
|
298
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|