PyJinHx
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.pjx -->
<button id="{{ id }}" class="btn btn-{{ variant }}">{{ text }}</button>
<!-- components/card.pjx -->
<div id="{{ id }}" class="card">
<h2>{{ title }}</h2>
<Button id="cta" text="{{ button_text }}" variant="primary"/>
</div>
# components/card.py
from pyjinhx import BaseComponent, setup
class Card(BaseComponent):
id: str
title: str
button_text: str = "Sign up"
setup(components_root="./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
ReactiveComponentlevels 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
listwith a JSON-looking string value, so every field goes throughjson.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
Slotfield, 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
- Usage tiers — adopt only the layers you need
- Components · PascalCase tags · Assets
- Reactivity guide
- Built-in components
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-1.4.0.tar.gz.
File metadata
- Download URL: pyjinhx-1.4.0.tar.gz
- Upload date:
- Size: 773.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.9.25
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b07bafc936ec15dceda51fa8a627cb394058fdb7f1513d9b2830ba87e0c72c9e
|
|
| MD5 |
8908488fe3deec26e11515af1f8f6bf5
|
|
| BLAKE2b-256 |
ac52f1db0eb6c4352a2ae0889df387fef46248e19d8c659141851dde0b2753ab
|
File details
Details for the file pyjinhx-1.4.0-py3-none-any.whl.
File metadata
- Download URL: pyjinhx-1.4.0-py3-none-any.whl
- Upload date:
- Size: 296.1 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 |
6d8477c96ee999d39c7fd307e3cf2e994d518c3c5195aa5127c93f42556d0a33
|
|
| MD5 |
2870dae7e35c86c4ad7df320df3b7f85
|
|
| BLAKE2b-256 |
24b0ce4d1d6c6659f3dfa4d0288f7159095e3e56f2efd6b5c544adeac6df1eca
|