pyjoist 0.1.0__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.
- pyjoist-0.1.0/LICENSE +21 -0
- pyjoist-0.1.0/MANIFEST.in +3 -0
- pyjoist-0.1.0/PKG-INFO +450 -0
- pyjoist-0.1.0/README.md +424 -0
- pyjoist-0.1.0/pyproject.toml +54 -0
- pyjoist-0.1.0/setup.cfg +4 -0
- pyjoist-0.1.0/src/joist/__init__.py +41 -0
- pyjoist-0.1.0/src/joist/component.py +398 -0
- pyjoist-0.1.0/src/joist/htmx.py +234 -0
- pyjoist-0.1.0/src/joist/integrators/__init__.py +35 -0
- pyjoist-0.1.0/src/joist/integrators/fastapi.py +194 -0
- pyjoist-0.1.0/src/joist/py.typed +0 -0
- pyjoist-0.1.0/src/joist/templates/joist/page.html +12 -0
- pyjoist-0.1.0/src/pyjoist.egg-info/PKG-INFO +450 -0
- pyjoist-0.1.0/src/pyjoist.egg-info/SOURCES.txt +18 -0
- pyjoist-0.1.0/src/pyjoist.egg-info/dependency_links.txt +1 -0
- pyjoist-0.1.0/src/pyjoist.egg-info/requires.txt +11 -0
- pyjoist-0.1.0/src/pyjoist.egg-info/top_level.txt +1 -0
- pyjoist-0.1.0/tests/test_component.py +288 -0
- pyjoist-0.1.0/tests/test_htmx.py +203 -0
pyjoist-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Wong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyjoist-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyjoist
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build HTML UI with Python dataclasses + Jinja2 templates, with first-class HTMX support
|
|
5
|
+
Author-email: Eric Wong <ericsyw@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/manyoo/joist
|
|
8
|
+
Project-URL: Source, https://github.com/manyoo/joist
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: jinja2>=3.1
|
|
17
|
+
Requires-Dist: markupsafe>=2.1
|
|
18
|
+
Provides-Extra: fastapi
|
|
19
|
+
Requires-Dist: fastapi>=0.115; extra == "fastapi"
|
|
20
|
+
Requires-Dist: starlette>=0.40; extra == "fastapi"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
24
|
+
Requires-Dist: uvicorn>=0.32; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# pyjoist
|
|
28
|
+
|
|
29
|
+
**A server-side component architecture for Jinja2 + HTMX applications.**
|
|
30
|
+
|
|
31
|
+
> `pip install pyjoist` — import as `joist` (the package name on PyPI differs from the import name).
|
|
32
|
+
|
|
33
|
+
joist introduces a component layer between your routes and your templates.
|
|
34
|
+
Components are typed dataclasses that map to Jinja2 templates. Builder
|
|
35
|
+
functions assemble them into trees. Routes just load data and render.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Routes (load data)
|
|
39
|
+
↓
|
|
40
|
+
Builders (assemble component trees) ── joist lives here
|
|
41
|
+
↓
|
|
42
|
+
Templates (render markup)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
joist ships as a single architectural primitive — **Component = dataclass + template** — plus HTMX utilities and optional FastAPI integration. There is no framework, no lifecycle, and no state management. Render and forget.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Why this exists
|
|
50
|
+
|
|
51
|
+
Jinja works well for server-rendered pages, and HTMX works well for
|
|
52
|
+
incremental HTML updates. But once an application mixes full-page
|
|
53
|
+
rendering, partial swaps, modal content, and out-of-band updates,
|
|
54
|
+
the template layer becomes hard to manage.
|
|
55
|
+
|
|
56
|
+
Common problems:
|
|
57
|
+
|
|
58
|
+
- **Unstructured context dictionaries.** Route handlers assemble large,
|
|
59
|
+
untyped dicts of template variables. Required fields are implicit,
|
|
60
|
+
relationships are hard to see, and refactoring is risky.
|
|
61
|
+
- **Full-page and partial rendering drift apart.** The same UI element
|
|
62
|
+
may be rendered by two different templates — one for the initial page
|
|
63
|
+
load, another for an HTMX swap. Markup diverges over time.
|
|
64
|
+
- **Reusable fragments are awkward.** Avatars, badges, cards, and modals
|
|
65
|
+
must be duplicated across templates or assembled with Jinja macros
|
|
66
|
+
that mix logic with markup.
|
|
67
|
+
- **Route handlers become view assemblers.** Instead of focusing on
|
|
68
|
+
HTTP and data loading, routes shape nested context structures,
|
|
69
|
+
duplicate view logic, and decide between fragment vs. page rendering.
|
|
70
|
+
|
|
71
|
+
joist solves these problems with a lightweight component layer:
|
|
72
|
+
|
|
73
|
+
- Each UI fragment becomes a **typed Python object** with an explicit
|
|
74
|
+
data contract.
|
|
75
|
+
- Builders (plain functions) **assemble components into trees**.
|
|
76
|
+
- The same component and template serve both **full-page and partial
|
|
77
|
+
rendering** through the same code path.
|
|
78
|
+
- Templates stay thin — they render their own markup and place children,
|
|
79
|
+
nothing more.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Quick start
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install pyjoist
|
|
87
|
+
# with FastAPI integration:
|
|
88
|
+
pip install "pyjoist[fastapi]"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 1. Define a component
|
|
92
|
+
|
|
93
|
+
Components are `@dataclass` subclasses of `Component`. Fields become
|
|
94
|
+
template variables automatically.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# components.py
|
|
98
|
+
from dataclasses import dataclass
|
|
99
|
+
from typing import ClassVar
|
|
100
|
+
from joist import Component
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(kw_only=True)
|
|
104
|
+
class UserCard(Component):
|
|
105
|
+
template_path: ClassVar = "user_card.html"
|
|
106
|
+
name: str = ""
|
|
107
|
+
email: str = ""
|
|
108
|
+
role: str = ""
|
|
109
|
+
|
|
110
|
+
def get_props(self):
|
|
111
|
+
return {"initial": self.name[0] if self.name else "?"}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 2. Write its template
|
|
115
|
+
|
|
116
|
+
```html
|
|
117
|
+
<!-- templates/user_card.html -->
|
|
118
|
+
<div class="card">
|
|
119
|
+
<div class="avatar">{{ initial }}</div>
|
|
120
|
+
<h3>{{ name }}</h3>
|
|
121
|
+
<p class="email">{{ email }}</p>
|
|
122
|
+
<span class="badge">{{ role }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. Build a component tree with a builder function
|
|
127
|
+
|
|
128
|
+
Builders are ordinary Python functions. They receive domain data and
|
|
129
|
+
return component instances. This is where UI composition happens.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# builders.py
|
|
133
|
+
from joist import Page
|
|
134
|
+
from components import UserCard
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_profile_page(user: dict, stats: dict) -> Page:
|
|
138
|
+
profile = UserCard(
|
|
139
|
+
name=user["name"],
|
|
140
|
+
email=user["email"],
|
|
141
|
+
role=user["role"],
|
|
142
|
+
)
|
|
143
|
+
# Page.content accepts a Component directly (auto-rendered).
|
|
144
|
+
# Equivalent to: Page(page_title=..., content=profile.render_html())
|
|
145
|
+
return Page(
|
|
146
|
+
page_title=f"Profile — {user['name']}",
|
|
147
|
+
content=profile,
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 4. Wire up the route
|
|
152
|
+
|
|
153
|
+
Routes load data, call a builder, and return the result. They do not
|
|
154
|
+
assemble context dicts or choose between fragment/page rendering — that
|
|
155
|
+
is handled by the framework integration.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
# app.py
|
|
159
|
+
from fastapi import FastAPI, Request
|
|
160
|
+
from joist.integrators.fastapi import setup_fastapi
|
|
161
|
+
|
|
162
|
+
app = FastAPI()
|
|
163
|
+
joist = setup_fastapi(app, template_dirs=["templates"])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.get("/users/{uid}")
|
|
167
|
+
async def user_profile(request: Request, uid: str):
|
|
168
|
+
user = load_user(uid) # data loading
|
|
169
|
+
stats = load_user_stats(uid) # data loading
|
|
170
|
+
page = build_profile_page(user, stats) # builder — pure composition
|
|
171
|
+
return joist.fragment_or_page(request, "Profile", page)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Architecture
|
|
177
|
+
|
|
178
|
+
joist divides UI construction into three distinct layers:
|
|
179
|
+
|
|
180
|
+
### Routes
|
|
181
|
+
|
|
182
|
+
Routes handle HTTP concerns: request parsing, authentication,
|
|
183
|
+
authorization, and data loading. They call builders and return the
|
|
184
|
+
result. Routes do not construct template context dicts or decide
|
|
185
|
+
between fragment and page rendering.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
@router.get("/items")
|
|
189
|
+
async def list_items(request: Request):
|
|
190
|
+
items = await load_items()
|
|
191
|
+
return joist.fragment_or_page(
|
|
192
|
+
request,
|
|
193
|
+
"Items",
|
|
194
|
+
build_item_table(items),
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Builders
|
|
199
|
+
|
|
200
|
+
Builders are the composition layer. They take domain data and produce
|
|
201
|
+
component instances. Builders can be tested independently, reused
|
|
202
|
+
across different routes, and composed with each other.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
def build_item_table(items: list[Item]) -> Table:
|
|
206
|
+
"""Builder — assembles a table from domain data."""
|
|
207
|
+
rows = [build_item_row(item) for item in items]
|
|
208
|
+
return Table(
|
|
209
|
+
title=f"{len(items)} items",
|
|
210
|
+
rows=rows,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_item_row(item: Item) -> Row:
|
|
215
|
+
"""Builder — assembles a single row."""
|
|
216
|
+
return Row(
|
|
217
|
+
name=item.name,
|
|
218
|
+
status="active" if item.is_active else "archived",
|
|
219
|
+
price=f"${item.price:.2f}",
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Templates
|
|
224
|
+
|
|
225
|
+
Templates render their own markup and place children. They receive
|
|
226
|
+
typed data from component fields. They do not fetch data, compute
|
|
227
|
+
values, or make rendering decisions.
|
|
228
|
+
|
|
229
|
+
```html
|
|
230
|
+
<!-- table.html -->
|
|
231
|
+
<section>
|
|
232
|
+
<h2>{{ title }}</h2>
|
|
233
|
+
<div class="table-wrap">
|
|
234
|
+
{% for row in rows %}
|
|
235
|
+
{{ row }} {# pre-rendered Markup — no |safe needed #}
|
|
236
|
+
{% endfor %}
|
|
237
|
+
</div>
|
|
238
|
+
</section>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Core API
|
|
244
|
+
|
|
245
|
+
### `Component` — base class
|
|
246
|
+
|
|
247
|
+
Subclass with `@dataclass(kw_only=True)`. Dataclass fields become
|
|
248
|
+
template context. Override `get_props()` for computed properties.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
@dataclass(kw_only=True)
|
|
252
|
+
class MyComponent(Component):
|
|
253
|
+
template_path: ClassVar = "my_template.html"
|
|
254
|
+
count: int = 0
|
|
255
|
+
|
|
256
|
+
def get_props(self):
|
|
257
|
+
return {"label": f"Count: {self.count}"}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
| Method | Returns | Purpose |
|
|
261
|
+
|--------|---------|---------|
|
|
262
|
+
| `render_html()` | `Markup` | Safe HTML markup for embedding |
|
|
263
|
+
| `render()` | `str` | Plain HTML string |
|
|
264
|
+
| `props()` | `dict` | Full template context (for testing/debugging) |
|
|
265
|
+
| `has_any` | `bool` | Whether component has content (override to skip empty states) |
|
|
266
|
+
|
|
267
|
+
Nested `Component` instances are automatically pre-rendered to `Markup`.
|
|
268
|
+
Templates use `{{ child }}` directly.
|
|
269
|
+
|
|
270
|
+
### `Fragment` — HTMX partials
|
|
271
|
+
|
|
272
|
+
`Fragment` is identical to `Component` except `template_path` is an
|
|
273
|
+
instance field. This makes it convenient for inline use in HTMX
|
|
274
|
+
partial routes.
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from joist import Fragment
|
|
278
|
+
|
|
279
|
+
# Inline — no subclass needed
|
|
280
|
+
frag = Fragment(
|
|
281
|
+
template_path="users/_row.html",
|
|
282
|
+
name=user.name,
|
|
283
|
+
role=user.role,
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Or subclass for reuse:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
@dataclass(kw_only=True)
|
|
291
|
+
class UserRow(Fragment):
|
|
292
|
+
template_path: str = "users/_row.html"
|
|
293
|
+
user_id: str = ""
|
|
294
|
+
name: str = ""
|
|
295
|
+
role: str = ""
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### `Page` — document shell
|
|
299
|
+
|
|
300
|
+
`Page` wraps content in a `<!DOCTYPE html>` document. Ships with a
|
|
301
|
+
minimal built-in template that you can override or replace by
|
|
302
|
+
subclassing.
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
from joist import Page
|
|
306
|
+
|
|
307
|
+
page = Page(
|
|
308
|
+
page_title="Users",
|
|
309
|
+
content=table.render_html(),
|
|
310
|
+
head_extras=Markup('<link rel="stylesheet" href="app.css">'),
|
|
311
|
+
)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## HTMX
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
from joist.htmx import (
|
|
320
|
+
is_hx_request, # bool: is this an HTMX request?
|
|
321
|
+
HX, # response header builders
|
|
322
|
+
render_page_or_fragment, # one route for both HTMX and direct
|
|
323
|
+
)
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| `HX.*` call | Header produced |
|
|
327
|
+
|---|---|
|
|
328
|
+
| `HX.trigger("evt")` | `HX-Trigger: evt` |
|
|
329
|
+
| `HX.trigger("evt", {...})` | `HX-Trigger: {"evt": {...}}` |
|
|
330
|
+
| `HX.trigger("evt", after="swap")` | `HX-Trigger-After-Swap` |
|
|
331
|
+
| `HX.retarget("#el")` | `HX-Retarget: #el` |
|
|
332
|
+
| `HX.redirect("/url")` | `HX-Redirect: /url` |
|
|
333
|
+
| `HX.refresh()` | `HX-Refresh: true` |
|
|
334
|
+
| `HX.push_url("/url")` | `HX-Push-Url: /url` |
|
|
335
|
+
| `HX.replace_url("/url")` | `HX-Replace-Url: /url` |
|
|
336
|
+
| `HX.reselect(".sel")` | `HX-Reselect: .sel` |
|
|
337
|
+
|
|
338
|
+
`render_page_or_fragment(request, page_title, content)` returns a dict
|
|
339
|
+
that your framework integration uses to produce the right kind of
|
|
340
|
+
response — fragment for HTMX, full page for direct navigation.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Framework integration
|
|
345
|
+
|
|
346
|
+
### FastAPI
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from joist.integrators.fastapi import setup_fastapi
|
|
350
|
+
|
|
351
|
+
app = FastAPI()
|
|
352
|
+
joist = setup_fastapi(app, template_dirs=["templates"])
|
|
353
|
+
|
|
354
|
+
# Methods on the joist helper:
|
|
355
|
+
joist.response(component) # bare HTML
|
|
356
|
+
joist.page_response(request, "Title", component) # full page shell
|
|
357
|
+
joist.fragment_or_page(request, "Title", component) # HTMX-aware
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Any framework
|
|
361
|
+
|
|
362
|
+
The core has no framework dependency. Use `render()` to get an HTML
|
|
363
|
+
string and return it however your framework expects:
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
from joist import Component
|
|
367
|
+
|
|
368
|
+
html = MyComponent(name="test").render()
|
|
369
|
+
|
|
370
|
+
# Works with Django, Flask, aiohttp, etc.:
|
|
371
|
+
return HttpResponse(html)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Example: three layers in practice
|
|
377
|
+
|
|
378
|
+
```python
|
|
379
|
+
# ── 1. Components (data contracts) ─────────────────────────────────
|
|
380
|
+
@dataclass(kw_only=True)
|
|
381
|
+
class ArticleCard(Component):
|
|
382
|
+
template_path: ClassVar = "article_card.html"
|
|
383
|
+
title: str = ""
|
|
384
|
+
summary: str = ""
|
|
385
|
+
author: str = ""
|
|
386
|
+
tags: list = field(default_factory=list)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass(kw_only=True)
|
|
390
|
+
class ArticleList(Component):
|
|
391
|
+
template_path: ClassVar = "article_list.html"
|
|
392
|
+
section_title: str = ""
|
|
393
|
+
articles: list = field(default_factory=list)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ── 2. Builder (composition) ────────────────────────────────────────
|
|
397
|
+
def build_article_page(articles: list[dict]) -> Page:
|
|
398
|
+
cards = [ArticleCard(**a) for a in articles]
|
|
399
|
+
listing = ArticleList(section_title="Latest", articles=cards)
|
|
400
|
+
return Page(
|
|
401
|
+
page_title="Articles",
|
|
402
|
+
content=listing, # Component auto-rendered — no .render_html() needed
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ── 3. Routes (HTTP + data) ──────────────────────────────────────────
|
|
407
|
+
@router.get("/articles")
|
|
408
|
+
async def articles_page(request: Request):
|
|
409
|
+
articles = await fetch_articles() # data
|
|
410
|
+
page = build_article_page(articles) # build
|
|
411
|
+
return joist.fragment_or_page(request, "Articles", page) # render
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Design notes
|
|
417
|
+
|
|
418
|
+
**No component state or lifecycle.** Components are render-and-forget.
|
|
419
|
+
They receive data, produce HTML, and are done. There is no mounting,
|
|
420
|
+
updating, or unmounting. This keeps the mental model simple and avoids
|
|
421
|
+
an entire class of bugs.
|
|
422
|
+
|
|
423
|
+
**Builder functions are the right place for composition.** Putting
|
|
424
|
+
composition in the route handler embeds presentation logic in HTTP code.
|
|
425
|
+
Putting it in the component couples the component to specific children.
|
|
426
|
+
Builders — plain functions that receive data and return components —
|
|
427
|
+
are the natural middle ground.
|
|
428
|
+
|
|
429
|
+
**Templates are pure markup.** Conditionals, formatting, and data
|
|
430
|
+
transformation happen in Python (`get_props()`, builder functions).
|
|
431
|
+
Templates only interpolate values and iterate over lists.
|
|
432
|
+
|
|
433
|
+
**The same component renders the same HTML everywhere.** Whether it is
|
|
434
|
+
the initial page load, an HTMX partial swap, or an out-of-band update,
|
|
435
|
+
the rendering path is identical. This eliminates markup drift.
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## Requirements
|
|
440
|
+
|
|
441
|
+
- Python 3.13+
|
|
442
|
+
- Jinja2 3.1+
|
|
443
|
+
|
|
444
|
+
Optional: FastAPI 0.115+, Starlette 0.40+
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## License
|
|
449
|
+
|
|
450
|
+
MIT
|