UI components for Python using Pydantic and Jinja2 templates
Project description
PyJinHx
Build reusable, type-safe UI components for template-based web apps in Python. PyJinHx combines Pydantic models with co-located Jinja templates — compose in Python, with PascalCase tags in templates, or in HTML strings.
- Template discovery — templates live next to component classes
- Composability — nest via Pydantic fields or PascalCase tags in templates
- Assets — co-located JS/CSS collected at the root render
- Reactivity — dependency-aware out-of-band swaps for HTMX apps
- Type safety — Pydantic validates component fields
Installation
pip install pyjinhx
Package layout
Import from the top level only — from pyjinhx import BaseComponent, ReactiveComponent, setup, .... Internal module paths are not a stable API.
| Area | Modules |
|---|---|
| Render engine (tiers 1–2) | base, renderer, assets, tags, finder, registry |
| Reactivity (tier 3+) | reactive, client, cache, mutations, keys, context, dev |
| Setup | config — setup(), PjxSettings, lifespan helpers |
pyjinhx/integrations/ |
FastAPI wiring, Redis invalidation backend |
pyjinhx/builtins/ |
Optional UI kit |
pyjinhx/runtime/ |
Client runtime (pjx.js) |
See usage tiers for which layers to adopt when.
Development
Structural audits: invoke the code-audit-sweep skill (.cursor/skills/code-audit-sweep/) to run read-only architecture reviews before large PRs. Individual lenses (indirection-audit, module-placement-audit, etc.) can be run alone. Dry-run baselines: VALIDATION.md.
Example
Two levels: Card → Button. Card is built in Python; Button is declared as a PascalCase tag in card.html.
Components
# components/ui/button.py
from pyjinhx import BaseComponent
class Button(BaseComponent):
id: str
text: str
variant: str = "default"
# components/ui/card.py
from pyjinhx import BaseComponent
class Card(BaseComponent):
id: str
title: str
button_text: str = "Sign up"
Templates
<!-- components/ui/button.html -->
<button id="{{ id }}" class="btn btn-{{ variant }}">{{ text }}</button>
<!-- components/ui/card.html -->
<div id="{{ id }}" class="card">
<h2>{{ title }}</h2>
<Button id="cta" text="{{ button_text }}" variant="primary"/>
</div>
Render
from pyjinhx import Renderer
from components.ui.card import Card
Renderer.set_default_environment("./components")
html = Card(id="hero", title="Get Started", button_text="Sign up").render()
Render order & tag resolution
When Card.render() runs:
- Card template — Jinja runs first; it outputs a
<Button .../>tag (not final HTML yet). - Tag expansion — PascalCase tags in template output are resolved and rendered next.
- Button — tag attrs become
Buttonfields (Pydantic validation);button.htmlruns. - Assets — co-located JS/CSS are collected once at the root render and injected last (CSS before HTML, JS after).
When <Button id="cta" text="..." variant="primary"/> is expanded:
| Priority | Rule | In this example |
|---|---|---|
| 1 | Existing instance with same class + id |
— (none yet) |
| 2 | Registered class matching the tag name | Button instantiated from attrs |
| 3 | Template only (no class) | — |
Inner tag content becomes the content field. See PascalCase tags for details.
flowchart TB
subgraph order["Render order (inside-out)"]
C["1. Card template runs<br/>emits <Button/> tag"]
T["2. Tag expansion"]
B["3. Button template runs"]
A["4. Root CSS / JS injected"]
end
subgraph resolve["Tag resolution for Button"]
R1["Existing instance?"]
R2["Registered Button class"]
R3["Generic + template"]
end
C --> T
T --> R1
R1 -->|no| R2
R2 --> B
R1 -->|yes| B
B --> A
You can also start from an HTML string — Renderer.get_default_renderer().render("<Card ...><Button .../></Card>") — same order and tag rules.
Reactivity
Declare what state each component depends on. After a mutation, return Cls.render(); PyJinHx appends out-of-band swaps for other mounted regions whose dependencies overlap.
from typing import ClassVar
from pyjinhx import ReactiveComponent, MutationKey
class Keys(MutationKey):
TODOS = "todos"
class Counter(ReactiveComponent):
remaining: int
reacts_to: ClassVar[set[str]] = {Keys.TODOS}
@classmethod
def load(cls) -> "Counter":
return cls(remaining=db.remaining()) # id defaults to "counter"
@app.post("/todos/toggle")
def toggle():
db.toggle_all()
return Counter.render()
Call setup(app, ...) once: it installs the lifespan and registry middleware that handle cache scope, optional invalidation, PjxContext, and the ClientBackend that resolves the X-PJX-* headers — so mutation routes call Cls.render(*args) with no framework kwargs. The client runtime (pjx.js) is injected on root full-page renders unless X-PJX-Mounted is already present.
Reactive ergonomics: MutationKey enums for typed keys; @mutates on store methods to accumulate pending dirtied keys for the next reactive render(); one PjxKey-annotated field per keyed component for the data-pjx-load round-trip; PjxContext to inject request-scoped dependencies into load(); enable_reactive_dev() and dependency_graph() for guardrails and debugging.
Loading indicators: a reactive region can show an in-flight indicator while it reloads — add data-pjx-loading="skeleton" (or "spinner") to a template element, and pjx.js lights it automatically off the region's reacts_to. Themeable via --pjx-* CSS variables. See the reactivity guide.
The cache, hashing, depends_on(), MutationTracker/InvalidationHub, and the oob_swaps walk are all covered in the reactivity guide. Load results are cached per request by default (CacheScope.REQUEST); pass cache_scope=CacheScope.PROCESS to setup() plus a Redis InvalidationBackend (reference, pip install pyjinhx[redis]) for cross-request caching across workers.
Details: usage tiers · reactivity guide · Build an App · examples/reactive_todo/.
JS & CSS collection
Place kebab-case asset files next to the component class (auto-collected at the root render):
components/ui/
├── card.py
├── card.html
├── card.css # Card → card.css
└── button.js # Button → button.js
| Class | JS / CSS file |
|---|---|
Button |
button.js, button.css |
ActionButton |
action-button.js, action-button.css |
Each asset is included once per render session. Output order: <style> tags, HTML, then <script> tags (inline mode). Pass extra paths via js=[...] and css=[...] on the component.
Asset delivery modes (AssetMode.INLINE, REFERENCE, NONE):
- INLINE (default): zero-config demos — assets are inlined as
<style>/<script>blocks. - REFERENCE: production — emits
<link href="...">/<script src="...">from a per-render manifest; configureRenderer.set_asset_url_resolver(). - NONE: no asset tags.
Reactive partial responses (when mounted is set) and OOB swaps never emit assets. Full-page layout renders emit them once. For boosted navigation in REFERENCE mode, enable client asset dedup so root renders skip URLs the browser already has (X-PJX-Assets via ClientBackend in middleware, or render(client=request)).
from pyjinhx import AssetMode, Renderer
Renderer.set_default_js_mode(AssetMode.REFERENCE)
Renderer.set_default_css_mode(AssetMode.REFERENCE)
Renderer.set_asset_url_resolver(lambda path: f"/static/{path.split('/')[-1]}")
Renderer.set_default_asset_dedup(True)
Details: asset collection guide. Optional UI kit: pyjinhx.builtins.
FastAPI + HTMX (reactive)
A toggle route returns the row as the primary swap; the counter updates out-of-band because both declare reacts_to={Keys.TODOS}. (Matches examples/reactive_todo/.)
# components.py
from typing import Annotated, ClassVar
from pyjinhx import BaseComponent, PjxKey, ReactiveComponent, MutationKey
class Keys(MutationKey):
TODOS = "todos"
class ItemRow(ReactiveComponent):
todo_id: Annotated[int, PjxKey()]
title: str = ""
done: bool = False
reacts_to: ClassVar[set[str]] = {Keys.TODOS}
@classmethod
def load(cls, todo_id: int) -> "ItemRow":
todo = db.get(todo_id)
return cls(id=f"row-{todo_id}", todo_id=todo_id, title=todo.text, done=todo.done)
class Counter(ReactiveComponent):
remaining: int = 0
reacts_to: ClassVar[set[str]] = {Keys.TODOS}
@classmethod
def load(cls) -> "Counter": # id defaults to "counter"
return cls(remaining=db.remaining())
class ItemList(BaseComponent):
items: list[ItemRow] = []
class App(BaseComponent):
item_list: ItemList | None = None
remaining: Counter | None = None
<!-- item_list.html -->
<ul id="{{ id }}">
{% for item in items %}{{ item }}{% endfor %}
</ul>
<!-- item_row.html -->
<li>
<button hx-post="/rows/{{ todo_id }}/toggle"
hx-target="closest [data-pjx-id]" hx-swap="outerHTML">toggle</button>
<span>{{ title }}</span>
</li>
<!-- counter.html -->
<span>{{ remaining }} left</span>
<!-- app.html -->
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
</head>
<body>
<h1>Todos</h1>
{{ item_list }}
{{ remaining }}
</body>
</html>
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pyjinhx import Renderer, setup
Renderer.set_default_environment("./components")
app = FastAPI()
setup(app) # installs the lifespan + registry/ClientBackend middleware
@app.get("/", response_class=HTMLResponse)
def index():
return App(
id="app",
item_list=ItemList(id="list", items=[ItemRow.load(t.id) for t in db.all()]),
remaining=Counter.load(),
).render()
@app.post("/rows/{todo_id}/toggle", response_class=HTMLResponse)
def toggle_row(todo_id: int):
db.toggle(todo_id)
return ItemRow.render(todo_id)
Run the full app: uv run uvicorn examples.reactive_todo.app:app --reload — see examples/reactive_todo/README.md.
More: components · nesting · public API index · FastAPI integration · HTMX integration
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyjinhx-0.7.3.tar.gz.
File metadata
- Download URL: pyjinhx-0.7.3.tar.gz
- Upload date:
- Size: 198.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.25
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2684834e6be9082b85cdacdebd10a7032dafc70bb1234d15191a72b3da0c9913
|
|
| MD5 |
976353813eb4b49f52b588ad4aa59916
|
|
| BLAKE2b-256 |
456068022e2ecc09db3e36eac70a41ebd995c7e441832c73e283031f444f5a18
|
File details
Details for the file pyjinhx-0.7.3-py3-none-any.whl.
File metadata
- Download URL: pyjinhx-0.7.3-py3-none-any.whl
- Upload date:
- Size: 88.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.25
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68a1dd7c4104664b582b37c004c415360ab5ffe894b8b4a0ae03f4b845674488
|
|
| MD5 |
3f78f73d2e67c2384146687cd1b3df94
|
|
| BLAKE2b-256 |
cfc89aa18d962644e10d83fae4a17a63810f86d951b8748b0939c762082a2375
|