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.
Files changed (23) hide show
  1. {hyperclass-0.0.3 → hyperclass-0.0.5}/PKG-INFO +65 -6
  2. {hyperclass-0.0.3 → hyperclass-0.0.5}/README.md +64 -5
  3. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/__init__.py +1 -1
  4. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/html.py +91 -29
  5. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/htmx.py +1 -1
  6. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/wsgi.py +31 -13
  7. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/PKG-INFO +65 -6
  8. {hyperclass-0.0.3 → hyperclass-0.0.5}/pyproject.toml +1 -1
  9. {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_bookmarks.py +9 -0
  10. {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_html.py +64 -5
  11. {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_htmx.py +17 -1
  12. hyperclass-0.0.5/tests/test_package.py +5 -0
  13. {hyperclass-0.0.3 → hyperclass-0.0.5}/tests/test_wsgi.py +30 -0
  14. hyperclass-0.0.3/tests/test_package.py +0 -5
  15. {hyperclass-0.0.3 → hyperclass-0.0.5}/LICENSE +0 -0
  16. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/__main__.py +0 -0
  17. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass/css.py +0 -0
  18. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/SOURCES.txt +0 -0
  19. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/dependency_links.txt +0 -0
  20. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/requires.txt +0 -0
  21. {hyperclass-0.0.3 → hyperclass-0.0.5}/hyperclass.egg-info/top_level.txt +0 -0
  22. {hyperclass-0.0.3 → hyperclass-0.0.5}/setup.cfg +0 -0
  23. {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
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 input(name="name", placeholder="Your name", required=True)
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. Python inheritance and
204
- the CSS cascade cooperate instead of imitating one another.
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 IDs are selectors
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 input(name="name", placeholder="Your name", required=True)
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. Python inheritance and
174
- the CSS cascade cooperate instead of imitating one another.
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 IDs are selectors
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:
@@ -62,7 +62,7 @@ from .wsgi import (
62
62
  )
63
63
  from .wsgi import delete as delete_route
64
64
 
65
- __version__ = "0.0.3"
65
+ __version__ = "0.0.5"
66
66
 
67
67
  hidden = "hidden"
68
68
  submit = "submit"
@@ -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 Media, PSEUDO_STATES, Style
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
- identity = getattr(value, "_attrs", {}).get("id")
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.styled_classes: list[type] = []
143
- self._seen_styles: set[type] = set()
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._seen_styles:
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 rule in value.__dict__.values()
185
+ for semantic_class in classes
186
+ for rule in semantic_class.__dict__.values()
151
187
  ):
152
- self._seen_styles.add(value)
153
- self.styled_classes.append(value)
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 value in self.styled_classes:
158
- base = f".{class_name(value)}"
159
- for name, rule in value.__dict__.items():
160
- if name == "style" and isinstance(rule, Style):
161
- rules.append(f"{base}{{{rule.render()}}}")
162
- elif name in PSEUDO_STATES and isinstance(rule, Style):
163
- state = name.replace("_", "-")
164
- rules.append(f"{base}:{state}{{{rule.render()}}}")
165
- elif isinstance(rule, Media):
166
- rules.append(
167
- f"@media {rule.query()}{{{base}{{{rule.style.render()}}}}}"
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
- if name == "hx" and isinstance(value, Mapping):
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 = dict(getattr(node, "_attrs", {}))
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
- for semantic_class in classes:
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 = f"<style>{stylesheet}</style>" if stylesheet else ""
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 Fragment, Page, element, render
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: str) -> str:
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: str, default: Any = None) -> Any:
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: str) -> list[str]:
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: str, default: int | None = None) -> int:
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
- text = (
483
- render(body)
484
- if request.is_htmx
485
- else Page(body, title=self.title).render()
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
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 input(name="name", placeholder="Your name", required=True)
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. Python inheritance and
204
- the CSS cascade cooperate instead of imitating one another.
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 IDs are selectors
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperclass"
3
- version = "0.0.3"
3
+ version = "0.0.5"
4
4
  description = "Build interactive web applications as Python class hierarchies."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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 ".card{display:grid;gap:1rem;padding:1.25rem}" in document
70
+ assert '<style id="hyperclass-styles">' in document
67
71
  assert (
68
- ".warning-card{border-color:orange;background:rgb(255 165 0 / 0.08)}"
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}" in document
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{grid-template-columns:1fr}}"
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=&quot;query&quot;]" hx-target="#results" '
41
+ 'hx-swap="outerMorph"></div>'
42
+ )
@@ -0,0 +1,5 @@
1
+ import hyperclass
2
+
3
+
4
+ def test_version():
5
+ assert hyperclass.__version__ == "0.0.5"
@@ -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
+ )
@@ -1,5 +0,0 @@
1
- import hyperclass
2
-
3
-
4
- def test_version():
5
- assert hyperclass.__version__ == "0.0.3"
File without changes
File without changes
File without changes
File without changes