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(), PyJinhxSettings, 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.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 enum import Enum
from typing import ClassVar
from pyjinhx import ReactiveComponent
class StateKey(str, Enum):
TODOS = "todos"
class Counter(ReactiveComponent):
remaining: int
reacts_to: ClassVar[set[StateKey]] = {StateKey.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()
Wire setup(app, ...) once — lifespan and registry middleware handle cache scope, optional invalidation, LoadContext, and ClientBackend for header auto-resolution. Mutation routes call Cls.render(*args) with no framework kwargs; pjx.js is injected on root full-page renders unless X-PJX-Mounted is already present.
Reactive ergonomics: use StateKey enums for typed keys; @mutates on store methods to accumulate pending dirtied keys for the next reactive render(); PjxLoad on one model field per keyed component for data-pjx-load round-trip; LoadContext.bind() / LoadContext.current() to inject dependencies into load(); enable_reactive_dev() and dependency_graph() for guardrails and debugging.
Reactive design decisions:
- Three identities:
data-pjx-id(HTMX swap target),data-pjx-load(round-tripload()arg from aPjxLoadfield), and state keys inreacts_to/@mutates(pub-sub only). - Pub-sub OOB: every mounted region whose
reacts_tointersects pending mutations may OOB-reload viaload(manifest.load); the trigger region and primary are excluded viaX-PJX-Trigger+ primary id. depends_on(): optional runtime narrowing for load-cache indexing;oob_swapsmatches on staticreacts_toonly.state_hash(): canonical sorted JSON frommodel_dump(mode="json")withidexcluded by default (state_hash_excludeto omit more fields). Overridestate_hash()for custom hashing.
Load cache scope (default CacheScope.REQUEST): load() results are cached within each HTTP request via LoadCache. Use LoadCache.invalidate() to evict entries; MutationTracker accumulates dirtied keys from @mutates for the next reactive render. Use Registry.request_scope() on every request for instance registry isolation (included in setup(app, ...)). For cross-request caching per worker, pass cache_scope=CacheScope.PROCESS to setup() and pair with an InvalidationBackend + InvalidationHub (Redis reference, pip install pyjinhx[redis]).
API shape: reactive internals expose class methods directly (LoadCache, MutationTracker, InvalidationHub, MountedManifest, LoadedAssets) — no thin module-level wrapper functions.
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)
Root full-page renders inject the client runtime (pjx.js) automatically unless the request already sent X-PJX-Mounted. A toggle route returns the row as the primary swap; the counter updates out-of-band because both declare reacts_to={StateKey.TODOS}.
# components/todo_row.py
from enum import Enum
from typing import ClassVar
from pyjinhx import BaseComponent, ReactiveComponent
class StateKey(str, Enum):
TODOS = "todos"
class TodoRow(ReactiveComponent):
title: str
done: bool = False
reacts_to: ClassVar[set[StateKey]] = {StateKey.TODOS}
@classmethod
def load(cls, key: int) -> "TodoRow":
todo = db.get(key)
return cls(id=f"row-{key}", title=todo.text, done=todo.done)
class TodoCounter(ReactiveComponent):
remaining: int
reacts_to: ClassVar[set[StateKey]] = {StateKey.TODOS}
@classmethod
def load(cls) -> "TodoCounter":
return cls(id="counter", remaining=db.remaining())
class TodoList(BaseComponent):
items: list[TodoRow] = []
class TodoApp(BaseComponent):
todo_list: TodoList | None = None
counter: TodoCounter | None = None
<!-- todo_list.html -->
<ul id="{{ id }}">
{% for item in items %}{{ item }}{% endfor %}
</ul>
<!-- todo_row.html -->
<li>
<button hx-post="/rows/{{ key }}/toggle"
hx-target="closest [data-pjx-id]" hx-swap="outerHTML">toggle</button>
<span>{{ title }}</span>
</li>
<!-- todo_counter.html -->
<span>{{ remaining }} left</span>
<!-- todo_app.html -->
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
</head>
<body>
<h1>Todos</h1>
{{ todo_list }}
{{ counter }}
</body>
</html>
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pyjinhx import FastAPIClientBackend, Registry, Renderer
from starlette.middleware.base import BaseHTTPMiddleware
Renderer.set_default_environment("./components")
app = FastAPI()
class RegistryScopeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
with Registry.request_scope(client_backend=FastAPIClientBackend(request)):
return await call_next(request)
app.add_middleware(RegistryScopeMiddleware)
@app.get("/", response_class=HTMLResponse)
def index():
return str(
TodoApp(
id="todo-app",
todo_list=TodoList(
id="todo-list",
items=[TodoRow.load(t.id) for t in db.all()],
),
counter=TodoCounter.load(),
).render()
)
@app.post("/rows/{todo_id}/toggle", response_class=HTMLResponse)
def toggle_row(todo_id: int):
db.toggle(todo_id)
return TodoRow.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.0.tar.gz.
File metadata
- Download URL: pyjinhx-0.7.0.tar.gz
- Upload date:
- Size: 193.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 |
90ffc8b79994d3c85108c01e7c0d998f2f88d3c334e6008f9e744db0d83eeccc
|
|
| MD5 |
3696da8ea54c7b4031d6f3c2e7297950
|
|
| BLAKE2b-256 |
501f5f952a9ff2dddc4cb5dcc51ce683471a37e573d2ddfc367c40a660181bcc
|
File details
Details for the file pyjinhx-0.7.0-py3-none-any.whl.
File metadata
- Download URL: pyjinhx-0.7.0-py3-none-any.whl
- Upload date:
- Size: 87.2 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 |
15c168e3e476e3d7ccb5546b8ef6d3d2e19f20ab89c93fb226be1451c0184d92
|
|
| MD5 |
076d87c786160df44be64809538e11c1
|
|
| BLAKE2b-256 |
8587b06b5fac555b9fd5e56306b9bbb5ccfa596dccd3a5a87e0a13276070eef0
|