Skip to main content

PyJinHx

Pydantic Jinja HTMX

Type-safe UI components for Python web apps. A component is a Pydantic model plus a Jinja template sitting next to it — nest them with PascalCase tags, and co-located JS/CSS is collected automatically at render.

pip install pyjinhx

Example

A Card that renders a Button — the tag's attributes become validated Pydantic fields:

# components/button.py
from pyjinhx import BaseComponent


class Button(BaseComponent):
    id: str
    text: str
    variant: str = "default"
<!-- components/button.html -->
<button id="{{ id }}" class="btn btn-{{ variant }}">{{ text }}</button>
<!-- components/card.html -->
<div id="{{ id }}" class="card">
  <h2>{{ title }}</h2>
  <Button id="cta" text="{{ button_text }}" variant="primary"/>
</div>
# components/card.py
from pyjinhx import BaseComponent, Renderer


class Card(BaseComponent):
    id: str
    title: str
    button_text: str = "Sign up"


Renderer.set_default_environment("./components")
html = Card(id="hero", title="Get Started").render()

Drop a button.css or card.js next to the component and it's included once, automatically.

Performance

  • Linear component-count scaling: ~0.03 ms/component, flat from 50 to 10,000 components in a tree — no super-linear blowup from breadth.
  • Flat nesting-depth cost: ~0.4 ms/level regardless of chain length (10 to 160 levels deep).
  • No static/reactive mixing penalty: a tree with some ReactiveComponent levels and some plain ones costs the same as an all-reactive tree of the same shape — noise-level delta at every size.

Run via uv run python scripts/bench_*.py on origin/master, single machine, no averaging across runs — directional, not authoritative. Full numbers, including field-count, slot-payload-size, and reactive-fanout sweeps:

Full benchmark tables

Component count scaling (bench_render_scaling_v2.py)

Renders one nested tree per data point — a fixed 3-level shape (root → mids → leaves), with breadth scaled so the total component count hits the target n. This is the shape a real page has: a few structural layers, many repeated leaves.

          BenchRoot
         /    |    \
     Mid1   Mid2  ... Midk
     /  \    /  \
  Leaf  Leaf Leaf  Leaf  ...
n total ms/component
50 1.7 ms 0.034
100 3.2 ms 0.032
197 6.1 ms 0.031
507 15.2 ms 0.030
993 29.6 ms 0.030
1981 58.3 ms 0.029
4971 142.2 ms 0.029
10000 283.8 ms 0.028

ms/component holds flat (even trends slightly down) as the tree grows — no super-linear blowup from breadth.

Nesting depth scaling (bench_render_depth.py)

Breadth pinned at 1 — a single linear chain, no siblings — with depth swept instead. Isolates any cost that scales with nesting depth specifically (recursive fill/serialize, the ancestor-chain cycle guard, scope propagation through nested renders), which the component-count sweep above can't see since it holds depth fixed at 3.

Root → Level1 → Level2 → Level3 → ... → LevelN
depth total ms/level
10 3.95 ms 0.395
20 7.68 ms 0.384
40 15.28 ms 0.382
80 32.26 ms 0.403
160 64.48 ms 0.403

ms/level is flat — depth alone doesn't cost more per level as the chain gets longer.

Field count scaling, 200 children/tree (bench_field_count.py)

Tree shape pinned; declared field count per component swept instead. Targets two costs that scale with field count specifically: the JSON-attr-coercion validator (loops over every field on each instantiation) and child-attr copying. Two arms per field count:

  • plain — every field is str, coercion takes the cheap early-out.
  • json — every field is list with a JSON-looking string value, so every field goes through json.loads.
fields plain json us/child (json)
5 6.91 ms 9.61 ms 48.1
20 10.64 ms 18.40 ms 92.0
50 17.54 ms 34.85 ms 174.3
100 30.91 ms 81.72 ms 408.6

JSON-coercion cost scales roughly linearly with field count, as expected — no quadratic surprise.

Slot payload size, 50 components/tree (bench_slot_payload.py)

Component count pinned; payload size in bytes swept instead, to isolate costs that scale with byte count rather than component count (the segment parser scanning every character, and slot-placeholder splicing walking each string segment). Two arms:

  • children — payload rides in as a tag's body text (children-field merge).
  • slot — payload rides in as a list of leaf component instances on a Slot field, so the parent emits one placeholder token per leaf that must be found and replaced.
bytes children slot plain slot us/KB (children)
64 2.48 ms 1.72 ms 794.53
256 6.15 ms 3.48 ms 492.15
1024 19.56 ms 9.95 ms 391.21
4096 78.22 ms 37.48 ms 391.09
16384 297.93 ms 139.86 ms 372.41
65536 1184.57 ms 554.10 ms 370.18

us/KB drops and then flattens as payload grows — fixed per-call overhead dominates at small sizes, byte-scanning cost dominates and stabilizes at larger ones.

Mixed static/reactive tree, identical node counts (bench_mixed_reactive_tree.py)

Same tree shape and node count in both arms — only which levels are ReactiveComponent vs plain BaseComponent changes. Isolates the one place the two paths diverge: every child instantiation unconditionally calls pjx_mount(), a no-op on a plain component but a cache-routed load() on a reactive one.

mixed:                       pure:
  [static Root]                [reactive Root]
        |                            |
  [static Mid] ...             [reactive Mid] ...
        |                            |
  [reactive Leaf] ...           [reactive Leaf] ...
nodes mixed pure reactive delta
56 2.26 ms 2.21 ms -0.05 ms
211 8.00 ms 7.78 ms -0.22 ms
821 28.73 ms 29.69 ms +0.96 ms
1831 66.75 ms 65.29 ms -1.46 ms

No consistent overhead from mixing static and reactive components in the same tree — noise-level delta at every size, meaning a page's reactive share isn't a meaningful cost driver on its own.

state_hash() cost (bench_state_hash.py)

Calls state_hash() directly in a loop — no session, no render — since inside a full render it's normally dwarfed by load() and render_level(), hiding any regression in the hash itself. state_hash() is three stacked costs: model_dump(mode="json"), a sorted json.dumps, and a SHA-256 digest. Two axes swept independently:

By field count (16-byte values — moves model_dump's per-field work and the number of keys json.dumps sorts):

fields us/call us/field
5 3.66 0.733
20 6.96 0.348
50 13.28 0.266
100 21.22 0.212

By value size (10 fields, byte size swept — moves the encoded byte count json.dumps/SHA-256 consume, field count held constant):

bytes us/call us/KB
16 4.54 29.03
256 9.19 3.67
4096 78.38 1.96
65536 1544.74 2.41

Load-cache indexing cost (bench_reactive_cache.py)

Isolates cache_put()/invalidate()'s index bookkeeping (reverse/forward bucket maintenance) from the render path entirely — just N cache entries, indexed and then evicted. The check: doubling N should roughly double each column, not quadruple it (a prior bug made full eviction quadratic — PR #619/#600 fixed the related _drop_nested cost below).

entries put re-put invalidate all
500 2.22 ms 0.41 ms 0.11 ms
1000 0.81 ms 0.84 ms 0.24 ms
2000 1.72 ms 1.92 ms 0.63 ms
4000 3.45 ms 4.05 ms 1.15 ms
8000 8.70 ms 8.19 ms 2.99 ms

Roughly linear scaling holds (the 500-entry put row is a one-off warm-up outlier).

Reactive fanout (bench_reactive_fanout.py)

Four sub-benchmarks over the machinery that runs after a render, driven by the client's mounted manifest rather than the tree just rendered:

Load-cache memoization — one instance, cold call (real load()) vs. warm call (cache hit): cold 18.7 us, warm 3.0 us.

walk_manifest() — cost scales with "how many components the client currently has mounted," not with the size of the render that just happened. A clean candidate costs one cache lookup; a dirty one costs a real load() + render_level() + state_hash(). Swept over manifest size at three dirty shares:

n 0% dirty 50% dirty 100% dirty
50 0.15 ms 2.29 ms 2.45 ms
100 0.25 ms 2.82 ms 4.33 ms
200 0.55 ms 4.55 ms 8.51 ms
500 1.23 ms 11.03 ms 20.24 ms
1000 2.58 ms 22.46 ms 41.14 ms
2000 5.14 ms 43.75 ms 83.14 ms
5000 13.79 ms 108.36 ms 208.39 ms

Cost is driven almost entirely by dirty share, not raw manifest size — the 0%-dirty column stays cheap even at n=5000.

oob_swaps() alone — the response-body build that runs after the walk: per dirty candidate, stamps hx-swap-oob/data-pjx-hash at the recorded span and serializes. Swept over region count × per-region "span" count (how many discontiguous markup pieces make up one region), with levels prebuilt so no render is in the timed frame:

region 1: [span][span][span] ...  (× spans-per-region)
region 2: [span][span][span] ...
   ...    × region count
regions 1 span 10 spans 50 spans
10 0.23 ms 0.07 ms 0.14 ms
50 0.21 ms 0.29 ms 0.65 ms
100 0.40 ms 0.56 ms 1.31 ms
200 0.80 ms 1.11 ms 2.65 ms

_drop_nested() — the containment walk that drops a dirty candidate already covered by an ancestor's swap. The candidate-count axis was made linear by #600/#619; this sweeps the other axis, per-candidate rendered-subtree size:

subtree size 50 candidates 200 candidates
1 0.03 ms 0.12 ms
10 0.10 ms 0.43 ms
50 0.51 ms 1.80 ms
200 1.63 ms 6.58 ms

Reactivity (HTMX)

Components declare what state they depend on. Return one component from a mutation route — every other mounted region that reacts to the same keys updates via out-of-band swaps, no manual wiring:

from pyjinhx import ReactiveComponent, MutationKey, mutates, setup


class Keys(MutationKey):
    TODOS = "todos"


class Counter(ReactiveComponent, react={Keys.TODOS}):
    remaining: int

    @classmethod
    def load(cls) -> "Counter":
        return cls(remaining=db.remaining())


@mutates(Keys.TODOS)
def toggle_all():
    db.toggle_all()


setup(app)  # FastAPI: lifespan + middleware, done


@app.post("/todos/toggle")
def toggle():
    toggle_all()
    return Counter.render()  # other regions reacting to TODOS update too

Learn more

Download files

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

Source Distribution

pyjinhx-1.1.0.tar.gz (680.3 kB view details)

Uploaded Source

Built Distribution

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

pyjinhx-1.1.0-py3-none-any.whl (263.7 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for pyjinhx-1.1.0.tar.gz
Algorithm Hash digest
SHA256 aa389563b7c535c12a72639a5aec5295763130aca0e10c4425eabcdd4ffd2c1d
MD5 1c04e0ae051dc8a0e95b5308bf83ceb9
BLAKE2b-256 a33fc74a64241acab4a714fe0cf8f1b532fcde909ad506d1576d0f5111e6534f

See more details on using hashes here.

File details

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

File metadata

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

File hashes

Hashes for pyjinhx-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b647ccd3230ec3fde6d14cb13a53c077b0fd3f369e8202f4df9fd737dba8748a
MD5 5d20ee86412dccb96965b5f9f78ac55f
BLAKE2b-256 bd7d5c889a9471e3f7700c0f9f01ecb90a4a3179f296d06049a64815a8043dd9

See more details on using hashes here.

Release history Release notifications | RSS feed

1.9.8

2 files

1.9.7

2 files

1.9.6

2 files

1.9.5

2 files

1.9.4

2 files

1.9.3

2 files

1.9.2

2 files

1.9.1

2 files

1.9.0

2 files

1.8.0

2 files

1.7.1

2 files

1.7.0

2 files

1.6.6

2 files

1.6.4

2 files

1.6.3

2 files

1.6.2

2 files

1.6.1

2 files

1.6.0

2 files

1.5.2

2 files

1.5.1

2 files

1.5.0

2 files

1.4.0

2 files

1.3.0

2 files

1.2.0

2 files

This release

1.1.0 This release

2 files

1.0.0

2 files

0.36.4

2 files

0.36.3

2 files

0.36.2

2 files

0.36.1

2 files

0.36.0

2 files

0.35.0

2 files

0.34.0

2 files

0.33.0

2 files

0.32.4

2 files

0.32.3

2 files

0.32.2

2 files

0.32.1

2 files

0.32.0

2 files

0.31.0

2 files

0.30.0

2 files

0.29.0

2 files

0.28.5

2 files

0.28.4

2 files

0.28.2

2 files

0.28.1

2 files

0.28.0

2 files

0.27.1

2 files

0.27.0

2 files

0.26.0

2 files

0.25.1

2 files

0.25.0

2 files

0.24.1

2 files

0.24.0

2 files

0.23.2

2 files

0.23.1

2 files

0.23.0

2 files

0.22.0

2 files

0.21.1

2 files

0.21.0

2 files

0.20.0

2 files

0.19.1

2 files

0.19.0

2 files

0.18.0

2 files

0.17.0

2 files

0.16.0

2 files

0.15.0

2 files

0.14.0

2 files

0.13.0

2 files

0.11.0

2 files

0.10.1

2 files

0.10.0

2 files

0.8.0

2 files

0.7.4

2 files

0.7.3

2 files

0.7.2

2 files

0.7.1

2 files

0.7.0

2 files

0.5.2

2 files

0.5.1

2 files

0.4.2

2 files

0.4.1

2 files

0.4.0

2 files

0.3.3

2 files

0.3.2

2 files

0.3.1

2 files

0.3.0

2 files

0.2.8

2 files

0.2.7

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.6

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page