hyperclass 0.0.2__tar.gz → 0.0.3__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/PKG-INFO +348 -0
- hyperclass-0.0.3/README.md +318 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass/__init__.py +1 -1
- hyperclass-0.0.3/hyperclass/__main__.py +37 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass/wsgi.py +90 -6
- hyperclass-0.0.3/hyperclass.egg-info/PKG-INFO +348 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass.egg-info/SOURCES.txt +2 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/pyproject.toml +1 -1
- {hyperclass-0.0.2 → hyperclass-0.0.3}/tests/test_bookmarks.py +64 -3
- hyperclass-0.0.3/tests/test_cli.py +25 -0
- hyperclass-0.0.3/tests/test_package.py +5 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/tests/test_wsgi.py +66 -0
- hyperclass-0.0.2/PKG-INFO +0 -283
- hyperclass-0.0.2/README.md +0 -253
- hyperclass-0.0.2/hyperclass.egg-info/PKG-INFO +0 -283
- hyperclass-0.0.2/tests/test_package.py +0 -5
- {hyperclass-0.0.2 → hyperclass-0.0.3}/LICENSE +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass/css.py +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass/html.py +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass/htmx.py +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass.egg-info/dependency_links.txt +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass.egg-info/requires.txt +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/hyperclass.egg-info/top_level.txt +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/setup.cfg +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/tests/test_html.py +0 -0
- {hyperclass-0.0.2 → hyperclass-0.0.3}/tests/test_htmx.py +0 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyperclass
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Build interactive web applications as Python class hierarchies.
|
|
5
|
+
Author: Grant Jenks
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/grantjenks/python-hyperclass
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/grantjenks/python-hyperclass/issues
|
|
9
|
+
Project-URL: Source Code, https://github.com/grantjenks/python-hyperclass
|
|
10
|
+
Keywords: html,htmx,css,wsgi,web
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: nox>=2024.4.15; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Hyperclass
|
|
32
|
+
|
|
33
|
+
> **Subclass the web.**
|
|
34
|
+
|
|
35
|
+
Hyperclass is a small experiment in building interactive web applications as
|
|
36
|
+
Python class hierarchies.
|
|
37
|
+
|
|
38
|
+
HTML elements are Python base classes. Python subclasses become CSS classes.
|
|
39
|
+
Styles follow inheritance. Routes are methods. Decorated handlers are URLs.
|
|
40
|
+
Interaction is ordinary HTTP over WSGI, with htmx 4 in the browser.
|
|
41
|
+
|
|
42
|
+
~~~console
|
|
43
|
+
pip install hyperclass
|
|
44
|
+
~~~
|
|
45
|
+
|
|
46
|
+
## Sixty-second tour
|
|
47
|
+
|
|
48
|
+
~~~python
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
|
|
51
|
+
from hyperclass import (
|
|
52
|
+
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
53
|
+
post, rem,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class card(div):
|
|
58
|
+
style = css(
|
|
59
|
+
display=grid,
|
|
60
|
+
gap=1 * rem,
|
|
61
|
+
padding=1.25 * rem,
|
|
62
|
+
border="1px solid #ddd",
|
|
63
|
+
border_radius=.75 * rem,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class guest_form(form):
|
|
68
|
+
style = css(display=grid, gap=.75 * rem)
|
|
69
|
+
|
|
70
|
+
def content(self):
|
|
71
|
+
yield input(name="name", placeholder="Your name", required=True)
|
|
72
|
+
yield button("Say hello", type="submit")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Guest:
|
|
77
|
+
name: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class guestbook(App):
|
|
81
|
+
@get("/")
|
|
82
|
+
def index(self, request):
|
|
83
|
+
return card(
|
|
84
|
+
"Who are you?",
|
|
85
|
+
guest_form(
|
|
86
|
+
hx=hx.post(
|
|
87
|
+
guestbook.create,
|
|
88
|
+
target=card,
|
|
89
|
+
swap=outer_morph,
|
|
90
|
+
)
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@post("/guests")
|
|
95
|
+
def create(self, request, form: Guest):
|
|
96
|
+
return card(f"Hello, {form.name}!")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
app = guestbook(title="Guestbook")
|
|
100
|
+
~~~
|
|
101
|
+
|
|
102
|
+
Run it:
|
|
103
|
+
|
|
104
|
+
~~~console
|
|
105
|
+
python -m hyperclass myapp:app
|
|
106
|
+
~~~
|
|
107
|
+
|
|
108
|
+
Then open <http://127.0.0.1:8000>. There is no JavaScript build, template
|
|
109
|
+
language, ASGI dependency, or CSS file hidden elsewhere.
|
|
110
|
+
|
|
111
|
+
## HTML classes are Python classes
|
|
112
|
+
|
|
113
|
+
Every built-in element can be subclassed:
|
|
114
|
+
|
|
115
|
+
~~~python
|
|
116
|
+
from hyperclass import css, div, grid, orange, rem
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class card(div):
|
|
120
|
+
style = css(display=grid, gap=1 * rem, padding=1.25 * rem)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class warning_card(card):
|
|
124
|
+
style = css(
|
|
125
|
+
border_color=orange,
|
|
126
|
+
background=orange.fade(0.08),
|
|
127
|
+
)
|
|
128
|
+
~~~
|
|
129
|
+
|
|
130
|
+
Calling:
|
|
131
|
+
|
|
132
|
+
~~~python
|
|
133
|
+
warning_card("Something happened")
|
|
134
|
+
~~~
|
|
135
|
+
|
|
136
|
+
produces ordinary, inspectable HTML:
|
|
137
|
+
|
|
138
|
+
~~~html
|
|
139
|
+
<div class="card warning-card">Something happened</div>
|
|
140
|
+
~~~
|
|
141
|
+
|
|
142
|
+
The first built-in HTML ancestor determines the tag. Each semantic subclass
|
|
143
|
+
contributes a CSS class. `snake_case` becomes `kebab-case`.
|
|
144
|
+
|
|
145
|
+
Multiple inheritance composes behavior and styles:
|
|
146
|
+
|
|
147
|
+
~~~python
|
|
148
|
+
class compact:
|
|
149
|
+
style = css(padding=.5 * rem)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class clickable:
|
|
153
|
+
style = css(cursor="pointer")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class result_card(card, compact, clickable):
|
|
157
|
+
pass
|
|
158
|
+
~~~
|
|
159
|
+
|
|
160
|
+
~~~html
|
|
161
|
+
<div class="card compact clickable result-card"></div>
|
|
162
|
+
~~~
|
|
163
|
+
|
|
164
|
+
Components use normal Python state and methods:
|
|
165
|
+
|
|
166
|
+
~~~python
|
|
167
|
+
from hyperclass import strong
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class greeting(card):
|
|
171
|
+
def __init__(self, name):
|
|
172
|
+
self.name = name
|
|
173
|
+
|
|
174
|
+
def content(self):
|
|
175
|
+
yield "Hello, "
|
|
176
|
+
yield strong(self.name)
|
|
177
|
+
~~~
|
|
178
|
+
|
|
179
|
+
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
180
|
+
escape hatch for trusted HTML.
|
|
181
|
+
|
|
182
|
+
## CSS is Python too
|
|
183
|
+
|
|
184
|
+
Base styles, pseudo-states, and media rules live on the component:
|
|
185
|
+
|
|
186
|
+
~~~python
|
|
187
|
+
from hyperclass import button, css, media, rem
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class primary_button(button):
|
|
191
|
+
style = css(
|
|
192
|
+
padding=".7rem 1rem",
|
|
193
|
+
background="#6d28d9",
|
|
194
|
+
color="white",
|
|
195
|
+
border=0,
|
|
196
|
+
border_radius=.5 * rem,
|
|
197
|
+
)
|
|
198
|
+
hover = css(background="#5b21b6")
|
|
199
|
+
focus_visible = css(outline="3px solid #c4b5fd")
|
|
200
|
+
narrow = media(max_width=40 * rem, width="100%")
|
|
201
|
+
~~~
|
|
202
|
+
|
|
203
|
+
Pages collect only the rules used by their element tree. Python inheritance and
|
|
204
|
+
the CSS cascade cooperate instead of imitating one another.
|
|
205
|
+
|
|
206
|
+
## Classes and IDs are selectors
|
|
207
|
+
|
|
208
|
+
Classes can be used directly anywhere a selector is expected:
|
|
209
|
+
|
|
210
|
+
~~~python
|
|
211
|
+
hx.get(search, target=result_card)
|
|
212
|
+
closest(card)
|
|
213
|
+
~~~
|
|
214
|
+
|
|
215
|
+
IDs are lazy, interned Python objects:
|
|
216
|
+
|
|
217
|
+
~~~python
|
|
218
|
+
from hyperclass import id, span
|
|
219
|
+
|
|
220
|
+
span("3 unread", id=id.unread_count)
|
|
221
|
+
hx.get(count, target=id.unread_count)
|
|
222
|
+
|
|
223
|
+
assert id.unread_count is id.unread_count
|
|
224
|
+
~~~
|
|
225
|
+
|
|
226
|
+
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
227
|
+
selector, it renders as `#unread-count`.
|
|
228
|
+
|
|
229
|
+
## Routes are references, not strings
|
|
230
|
+
|
|
231
|
+
Application subclasses collect decorated method routes:
|
|
232
|
+
|
|
233
|
+
~~~python
|
|
234
|
+
from hyperclass import App, get, patch
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class bookmarks(App):
|
|
238
|
+
@get("/")
|
|
239
|
+
def index(self, request):
|
|
240
|
+
return bookmark_list(...)
|
|
241
|
+
|
|
242
|
+
@patch("/bookmarks/<int:bookmark_id>")
|
|
243
|
+
def toggle(self, request, bookmark_id):
|
|
244
|
+
return bookmark_card(...)
|
|
245
|
+
~~~
|
|
246
|
+
|
|
247
|
+
Decorated handlers retain their route metadata:
|
|
248
|
+
|
|
249
|
+
~~~python
|
|
250
|
+
bookmarks.toggle.url(bookmark_id=42)
|
|
251
|
+
# '/bookmarks/42'
|
|
252
|
+
|
|
253
|
+
hx.patch(
|
|
254
|
+
bookmarks.toggle,
|
|
255
|
+
bookmark_id=42,
|
|
256
|
+
target=bookmark_card,
|
|
257
|
+
)
|
|
258
|
+
~~~
|
|
259
|
+
|
|
260
|
+
Typed path parameters are converted before the handler runs. Query strings can
|
|
261
|
+
be attached with `.url(query={...})` or the `query=` option on an htmx request.
|
|
262
|
+
|
|
263
|
+
## Forms bind to dataclasses
|
|
264
|
+
|
|
265
|
+
Annotate a route parameter with a dataclass and Hyperclass builds it from the
|
|
266
|
+
submitted form:
|
|
267
|
+
|
|
268
|
+
~~~python
|
|
269
|
+
@dataclass
|
|
270
|
+
class NewBookmark:
|
|
271
|
+
url: str
|
|
272
|
+
title: str = ""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class bookmarks(App):
|
|
276
|
+
@post("/bookmarks")
|
|
277
|
+
def create(self, request, form: NewBookmark):
|
|
278
|
+
self.store.add(form.url, form.title)
|
|
279
|
+
return bookmark_list(...)
|
|
280
|
+
~~~
|
|
281
|
+
|
|
282
|
+
Binding supports strings, integers, floats, booleans, optional values, and
|
|
283
|
+
lists or tuples of those values. Dataclass defaults remain defaults. Invalid or
|
|
284
|
+
missing required values produce a `400 Bad Request`; application validation can
|
|
285
|
+
return a more specific `Response`.
|
|
286
|
+
|
|
287
|
+
The underlying values remain available as `request.form`, `request.query`,
|
|
288
|
+
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
289
|
+
|
|
290
|
+
## WSGI and htmx 4
|
|
291
|
+
|
|
292
|
+
A Hyperclass application is a normal WSGI callable. Use the standard-library
|
|
293
|
+
development server:
|
|
294
|
+
|
|
295
|
+
~~~console
|
|
296
|
+
python -m hyperclass package.module:app
|
|
297
|
+
python -m hyperclass package.module:app --host 0.0.0.0 --port 9000
|
|
298
|
+
~~~
|
|
299
|
+
|
|
300
|
+
Production can use any WSGI server. Returning an element from an ordinary
|
|
301
|
+
browser request wraps it in a complete page. Returning the same element to an
|
|
302
|
+
htmx request sends only the fragment to swap.
|
|
303
|
+
|
|
304
|
+
`Page(...)` controls the document explicitly. Pages include a pinned htmx 4
|
|
305
|
+
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
306
|
+
object-selected regions from one request.
|
|
307
|
+
|
|
308
|
+
## Try the examples
|
|
309
|
+
|
|
310
|
+
Clone the repository and run the persistent SQLite bookmark inbox:
|
|
311
|
+
|
|
312
|
+
~~~console
|
|
313
|
+
git clone https://github.com/grantjenks/python-hyperclass
|
|
314
|
+
cd python-hyperclass
|
|
315
|
+
python -m hyperclass examples.bookmarks:app
|
|
316
|
+
~~~
|
|
317
|
+
|
|
318
|
+
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
319
|
+
Its implementation is Python plus SQLite, WSGI, generated CSS, and htmx. It is
|
|
320
|
+
also a compact integration test for the framework's ideas.
|
|
321
|
+
|
|
322
|
+
For the smallest example:
|
|
323
|
+
|
|
324
|
+
~~~console
|
|
325
|
+
python -m hyperclass examples.counter:app
|
|
326
|
+
~~~
|
|
327
|
+
|
|
328
|
+
## Principles
|
|
329
|
+
|
|
330
|
+
- **Python is the authoring language.** Control flow, composition, inheritance,
|
|
331
|
+
validation, and reuse are ordinary Python.
|
|
332
|
+
- **The browser remains the browser.** Hyperclass emits standard HTML and CSS
|
|
333
|
+
rather than recreating the DOM on the server.
|
|
334
|
+
- **Classes mean classes.** Python inheritance has a visible relationship to
|
|
335
|
+
HTML classes and the CSS cascade.
|
|
336
|
+
- **HTTP is the state boundary.** There is no hydration protocol or hidden
|
|
337
|
+
client component lifecycle.
|
|
338
|
+
- **Output should be boring.** Generated markup stays readable in View Source
|
|
339
|
+
and DevTools.
|
|
340
|
+
- **Small is a feature.** Prefer the standard library, WSGI, and a pinned htmx
|
|
341
|
+
asset over a framework stack.
|
|
342
|
+
|
|
343
|
+
## Status
|
|
344
|
+
|
|
345
|
+
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
346
|
+
and young enough for its API to change. Python 3.10 through 3.14 are tested.
|
|
347
|
+
|
|
348
|
+
Apache-2.0 licensed.
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# Hyperclass
|
|
2
|
+
|
|
3
|
+
> **Subclass the web.**
|
|
4
|
+
|
|
5
|
+
Hyperclass is a small experiment in building interactive web applications as
|
|
6
|
+
Python class hierarchies.
|
|
7
|
+
|
|
8
|
+
HTML elements are Python base classes. Python subclasses become CSS classes.
|
|
9
|
+
Styles follow inheritance. Routes are methods. Decorated handlers are URLs.
|
|
10
|
+
Interaction is ordinary HTTP over WSGI, with htmx 4 in the browser.
|
|
11
|
+
|
|
12
|
+
~~~console
|
|
13
|
+
pip install hyperclass
|
|
14
|
+
~~~
|
|
15
|
+
|
|
16
|
+
## Sixty-second tour
|
|
17
|
+
|
|
18
|
+
~~~python
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from hyperclass import (
|
|
22
|
+
App, button, css, div, form, get, grid, hx, input, outer_morph,
|
|
23
|
+
post, rem,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class card(div):
|
|
28
|
+
style = css(
|
|
29
|
+
display=grid,
|
|
30
|
+
gap=1 * rem,
|
|
31
|
+
padding=1.25 * rem,
|
|
32
|
+
border="1px solid #ddd",
|
|
33
|
+
border_radius=.75 * rem,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class guest_form(form):
|
|
38
|
+
style = css(display=grid, gap=.75 * rem)
|
|
39
|
+
|
|
40
|
+
def content(self):
|
|
41
|
+
yield input(name="name", placeholder="Your name", required=True)
|
|
42
|
+
yield button("Say hello", type="submit")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Guest:
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class guestbook(App):
|
|
51
|
+
@get("/")
|
|
52
|
+
def index(self, request):
|
|
53
|
+
return card(
|
|
54
|
+
"Who are you?",
|
|
55
|
+
guest_form(
|
|
56
|
+
hx=hx.post(
|
|
57
|
+
guestbook.create,
|
|
58
|
+
target=card,
|
|
59
|
+
swap=outer_morph,
|
|
60
|
+
)
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@post("/guests")
|
|
65
|
+
def create(self, request, form: Guest):
|
|
66
|
+
return card(f"Hello, {form.name}!")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
app = guestbook(title="Guestbook")
|
|
70
|
+
~~~
|
|
71
|
+
|
|
72
|
+
Run it:
|
|
73
|
+
|
|
74
|
+
~~~console
|
|
75
|
+
python -m hyperclass myapp:app
|
|
76
|
+
~~~
|
|
77
|
+
|
|
78
|
+
Then open <http://127.0.0.1:8000>. There is no JavaScript build, template
|
|
79
|
+
language, ASGI dependency, or CSS file hidden elsewhere.
|
|
80
|
+
|
|
81
|
+
## HTML classes are Python classes
|
|
82
|
+
|
|
83
|
+
Every built-in element can be subclassed:
|
|
84
|
+
|
|
85
|
+
~~~python
|
|
86
|
+
from hyperclass import css, div, grid, orange, rem
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class card(div):
|
|
90
|
+
style = css(display=grid, gap=1 * rem, padding=1.25 * rem)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class warning_card(card):
|
|
94
|
+
style = css(
|
|
95
|
+
border_color=orange,
|
|
96
|
+
background=orange.fade(0.08),
|
|
97
|
+
)
|
|
98
|
+
~~~
|
|
99
|
+
|
|
100
|
+
Calling:
|
|
101
|
+
|
|
102
|
+
~~~python
|
|
103
|
+
warning_card("Something happened")
|
|
104
|
+
~~~
|
|
105
|
+
|
|
106
|
+
produces ordinary, inspectable HTML:
|
|
107
|
+
|
|
108
|
+
~~~html
|
|
109
|
+
<div class="card warning-card">Something happened</div>
|
|
110
|
+
~~~
|
|
111
|
+
|
|
112
|
+
The first built-in HTML ancestor determines the tag. Each semantic subclass
|
|
113
|
+
contributes a CSS class. `snake_case` becomes `kebab-case`.
|
|
114
|
+
|
|
115
|
+
Multiple inheritance composes behavior and styles:
|
|
116
|
+
|
|
117
|
+
~~~python
|
|
118
|
+
class compact:
|
|
119
|
+
style = css(padding=.5 * rem)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class clickable:
|
|
123
|
+
style = css(cursor="pointer")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class result_card(card, compact, clickable):
|
|
127
|
+
pass
|
|
128
|
+
~~~
|
|
129
|
+
|
|
130
|
+
~~~html
|
|
131
|
+
<div class="card compact clickable result-card"></div>
|
|
132
|
+
~~~
|
|
133
|
+
|
|
134
|
+
Components use normal Python state and methods:
|
|
135
|
+
|
|
136
|
+
~~~python
|
|
137
|
+
from hyperclass import strong
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class greeting(card):
|
|
141
|
+
def __init__(self, name):
|
|
142
|
+
self.name = name
|
|
143
|
+
|
|
144
|
+
def content(self):
|
|
145
|
+
yield "Hello, "
|
|
146
|
+
yield strong(self.name)
|
|
147
|
+
~~~
|
|
148
|
+
|
|
149
|
+
Text and attribute values are escaped by default. `markup(...)` is the explicit
|
|
150
|
+
escape hatch for trusted HTML.
|
|
151
|
+
|
|
152
|
+
## CSS is Python too
|
|
153
|
+
|
|
154
|
+
Base styles, pseudo-states, and media rules live on the component:
|
|
155
|
+
|
|
156
|
+
~~~python
|
|
157
|
+
from hyperclass import button, css, media, rem
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class primary_button(button):
|
|
161
|
+
style = css(
|
|
162
|
+
padding=".7rem 1rem",
|
|
163
|
+
background="#6d28d9",
|
|
164
|
+
color="white",
|
|
165
|
+
border=0,
|
|
166
|
+
border_radius=.5 * rem,
|
|
167
|
+
)
|
|
168
|
+
hover = css(background="#5b21b6")
|
|
169
|
+
focus_visible = css(outline="3px solid #c4b5fd")
|
|
170
|
+
narrow = media(max_width=40 * rem, width="100%")
|
|
171
|
+
~~~
|
|
172
|
+
|
|
173
|
+
Pages collect only the rules used by their element tree. Python inheritance and
|
|
174
|
+
the CSS cascade cooperate instead of imitating one another.
|
|
175
|
+
|
|
176
|
+
## Classes and IDs are selectors
|
|
177
|
+
|
|
178
|
+
Classes can be used directly anywhere a selector is expected:
|
|
179
|
+
|
|
180
|
+
~~~python
|
|
181
|
+
hx.get(search, target=result_card)
|
|
182
|
+
closest(card)
|
|
183
|
+
~~~
|
|
184
|
+
|
|
185
|
+
IDs are lazy, interned Python objects:
|
|
186
|
+
|
|
187
|
+
~~~python
|
|
188
|
+
from hyperclass import id, span
|
|
189
|
+
|
|
190
|
+
span("3 unread", id=id.unread_count)
|
|
191
|
+
hx.get(count, target=id.unread_count)
|
|
192
|
+
|
|
193
|
+
assert id.unread_count is id.unread_count
|
|
194
|
+
~~~
|
|
195
|
+
|
|
196
|
+
As an HTML attribute, `id.unread_count` renders as `unread-count`. As a
|
|
197
|
+
selector, it renders as `#unread-count`.
|
|
198
|
+
|
|
199
|
+
## Routes are references, not strings
|
|
200
|
+
|
|
201
|
+
Application subclasses collect decorated method routes:
|
|
202
|
+
|
|
203
|
+
~~~python
|
|
204
|
+
from hyperclass import App, get, patch
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class bookmarks(App):
|
|
208
|
+
@get("/")
|
|
209
|
+
def index(self, request):
|
|
210
|
+
return bookmark_list(...)
|
|
211
|
+
|
|
212
|
+
@patch("/bookmarks/<int:bookmark_id>")
|
|
213
|
+
def toggle(self, request, bookmark_id):
|
|
214
|
+
return bookmark_card(...)
|
|
215
|
+
~~~
|
|
216
|
+
|
|
217
|
+
Decorated handlers retain their route metadata:
|
|
218
|
+
|
|
219
|
+
~~~python
|
|
220
|
+
bookmarks.toggle.url(bookmark_id=42)
|
|
221
|
+
# '/bookmarks/42'
|
|
222
|
+
|
|
223
|
+
hx.patch(
|
|
224
|
+
bookmarks.toggle,
|
|
225
|
+
bookmark_id=42,
|
|
226
|
+
target=bookmark_card,
|
|
227
|
+
)
|
|
228
|
+
~~~
|
|
229
|
+
|
|
230
|
+
Typed path parameters are converted before the handler runs. Query strings can
|
|
231
|
+
be attached with `.url(query={...})` or the `query=` option on an htmx request.
|
|
232
|
+
|
|
233
|
+
## Forms bind to dataclasses
|
|
234
|
+
|
|
235
|
+
Annotate a route parameter with a dataclass and Hyperclass builds it from the
|
|
236
|
+
submitted form:
|
|
237
|
+
|
|
238
|
+
~~~python
|
|
239
|
+
@dataclass
|
|
240
|
+
class NewBookmark:
|
|
241
|
+
url: str
|
|
242
|
+
title: str = ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class bookmarks(App):
|
|
246
|
+
@post("/bookmarks")
|
|
247
|
+
def create(self, request, form: NewBookmark):
|
|
248
|
+
self.store.add(form.url, form.title)
|
|
249
|
+
return bookmark_list(...)
|
|
250
|
+
~~~
|
|
251
|
+
|
|
252
|
+
Binding supports strings, integers, floats, booleans, optional values, and
|
|
253
|
+
lists or tuples of those values. Dataclass defaults remain defaults. Invalid or
|
|
254
|
+
missing required values produce a `400 Bad Request`; application validation can
|
|
255
|
+
return a more specific `Response`.
|
|
256
|
+
|
|
257
|
+
The underlying values remain available as `request.form`, `request.query`,
|
|
258
|
+
`.get(...)`, `.getlist(...)`, and `.int(...)` when explicit parsing is clearer.
|
|
259
|
+
|
|
260
|
+
## WSGI and htmx 4
|
|
261
|
+
|
|
262
|
+
A Hyperclass application is a normal WSGI callable. Use the standard-library
|
|
263
|
+
development server:
|
|
264
|
+
|
|
265
|
+
~~~console
|
|
266
|
+
python -m hyperclass package.module:app
|
|
267
|
+
python -m hyperclass package.module:app --host 0.0.0.0 --port 9000
|
|
268
|
+
~~~
|
|
269
|
+
|
|
270
|
+
Production can use any WSGI server. Returning an element from an ordinary
|
|
271
|
+
browser request wraps it in a complete page. Returning the same element to an
|
|
272
|
+
htmx request sends only the fragment to swap.
|
|
273
|
+
|
|
274
|
+
`Page(...)` controls the document explicitly. Pages include a pinned htmx 4
|
|
275
|
+
asset from jsDelivr. htmx 4 `<hx-partial>` responses can update several
|
|
276
|
+
object-selected regions from one request.
|
|
277
|
+
|
|
278
|
+
## Try the examples
|
|
279
|
+
|
|
280
|
+
Clone the repository and run the persistent SQLite bookmark inbox:
|
|
281
|
+
|
|
282
|
+
~~~console
|
|
283
|
+
git clone https://github.com/grantjenks/python-hyperclass
|
|
284
|
+
cd python-hyperclass
|
|
285
|
+
python -m hyperclass examples.bookmarks:app
|
|
286
|
+
~~~
|
|
287
|
+
|
|
288
|
+
The bookmark app adds, searches, filters, edits, marks, and deletes bookmarks.
|
|
289
|
+
Its implementation is Python plus SQLite, WSGI, generated CSS, and htmx. It is
|
|
290
|
+
also a compact integration test for the framework's ideas.
|
|
291
|
+
|
|
292
|
+
For the smallest example:
|
|
293
|
+
|
|
294
|
+
~~~console
|
|
295
|
+
python -m hyperclass examples.counter:app
|
|
296
|
+
~~~
|
|
297
|
+
|
|
298
|
+
## Principles
|
|
299
|
+
|
|
300
|
+
- **Python is the authoring language.** Control flow, composition, inheritance,
|
|
301
|
+
validation, and reuse are ordinary Python.
|
|
302
|
+
- **The browser remains the browser.** Hyperclass emits standard HTML and CSS
|
|
303
|
+
rather than recreating the DOM on the server.
|
|
304
|
+
- **Classes mean classes.** Python inheritance has a visible relationship to
|
|
305
|
+
HTML classes and the CSS cascade.
|
|
306
|
+
- **HTTP is the state boundary.** There is no hydration protocol or hidden
|
|
307
|
+
client component lifecycle.
|
|
308
|
+
- **Output should be boring.** Generated markup stays readable in View Source
|
|
309
|
+
and DevTools.
|
|
310
|
+
- **Small is a feature.** Prefer the standard library, WSGI, and a pinned htmx
|
|
311
|
+
asset over a framework stack.
|
|
312
|
+
|
|
313
|
+
## Status
|
|
314
|
+
|
|
315
|
+
Hyperclass is deliberately pre-alpha: useful enough to build small applications
|
|
316
|
+
and young enough for its API to change. Python 3.10 through 3.14 are tested.
|
|
317
|
+
|
|
318
|
+
Apache-2.0 licensed.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Run a Hyperclass WSGI application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load(target: str) -> Any:
|
|
11
|
+
try:
|
|
12
|
+
module_name, attribute_path = target.split(":", 1)
|
|
13
|
+
except ValueError as error:
|
|
14
|
+
raise ValueError("application must be written as module:attribute") from error
|
|
15
|
+
value: Any = import_module(module_name)
|
|
16
|
+
for name in attribute_path.split("."):
|
|
17
|
+
value = getattr(value, name)
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> None:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="python -m hyperclass")
|
|
23
|
+
parser.add_argument("application", help="WSGI application as module:attribute")
|
|
24
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
25
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
26
|
+
options = parser.parse_args(argv)
|
|
27
|
+
app = load(options.application)
|
|
28
|
+
try:
|
|
29
|
+
run = app.run
|
|
30
|
+
except AttributeError as error:
|
|
31
|
+
parser.error(f"{options.application} is not a Hyperclass application")
|
|
32
|
+
raise AssertionError from error
|
|
33
|
+
run(host=options.host, port=options.port)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|