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 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.
@@ -0,0 +1,3 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include src/joist/templates *.html
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