Skip to main content

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 configsetup(), 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:

  1. Card template — Jinja runs first; it outputs a <Button .../> tag (not final HTML yet).
  2. Tag expansion — PascalCase tags in template output are resolved and rendered next.
  3. Button — tag attrs become Button fields (Pydantic validation); button.html runs.
  4. 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 &lt;Button/&gt; 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; configure Renderer.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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyjinhx-0.7.4.tar.gz (202.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyjinhx-0.7.4-py3-none-any.whl (91.4 kB view details)

Uploaded Python 3

File details

Details for the file pyjinhx-0.7.4.tar.gz.

File metadata

  • Download URL: pyjinhx-0.7.4.tar.gz
  • Upload date:
  • Size: 202.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.25

File hashes

Hashes for pyjinhx-0.7.4.tar.gz
Algorithm Hash digest
SHA256 f05adccd70719ad9222aaef84113f4d7779db9d234de57cf456253a83f138000
MD5 9a79291576a2705edc2898afa92f88b6
BLAKE2b-256 b1ed41bb2ab754b1b1541684b4795713c60128f11ab2d22a3fd125ce0130c601

See more details on using hashes here.

File details

Details for the file pyjinhx-0.7.4-py3-none-any.whl.

File metadata

  • Download URL: pyjinhx-0.7.4-py3-none-any.whl
  • Upload date:
  • Size: 91.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.25

File hashes

Hashes for pyjinhx-0.7.4-py3-none-any.whl
Algorithm Hash digest
SHA256 7aec9535b55ea0db9b3de969dda61b2ecdb2b8672b679f7084bc34e8ef2b7a76
MD5 ebf533b47800ce98c4f881cc01c7da9f
BLAKE2b-256 b928a46640613c301cbe79f33085a562397a2bbf404308126a9be41b55edc4da

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page