PyLage
PyLage is a reactive, server-driven, differential UI framework written entirely in Python. You build a component tree in Python, bind it to reactive State objects, and PyLage renders it to HTML — either as a static file or as a live, WebSocket-powered application where state changes on the server are diffed and patched straight into the browser DOM, with no client-side framework code to write.
Python State → DependencyGraph → DirtyNodes → Scheduler
│ │
▼ ▼
Component Tree ──── snapshot/diff/patch ────► WebSocket ──► Browser DOM
Table of Contents
- Features
- Installation
- Quickstart
- Architecture Overview
- Component Reference
- Styling & Theming
- Reactivity Model
- Client ↔ Server Protocol
- Testing
- Project Layout
- Known Limitations
- License
Features
- Pure Python component model — no JSX, no templates. Components are plain trees built with functions like
Column,Row,Text,Button. - Fine-grained reactivity —
Stateobjects, aDependencyGraphmapping state → (component, prop),DirtyNodestracking, and aSchedulerthat batches multiple synchronous state changes into a single flush. - Two render modes:
serve=False(default): renders one staticindex.htmlfile.serve=True: starts an HTTP server (initial HTML) + a WebSocket server (live diff/patch updates) and opens a browser.
- Snapshot → Diff → Patch pipeline for tree-level changes (
insert,remove,replace,update,events) and a generic reactive prop pipeline for value-level changes, both delivered over the same WebSocket connection. - Central component registry — every component's HTML tag, prop contract (
kind:attribute/boolean/text,reactive,html_name) and optional custom renderer are declared once and used consistently by the renderer, the state binder, and the IR/compiler layer. - Two-way input binding —
Input(value=state)auto-wires anon_inputhandler that callsstate.set(...), and the browser round-trips edits back to Python. - Structured
Style/ResponsiveStyle/Themesystem with CSS custom-property support and breakpoint-based media queries. - 463 passing tests covering components, registry contracts, reactivity, diffing, protocol messages, the browser client runtime, and live browser integration (Playwright).
Installation
PyLage is a local Python package (no PyPI release referenced in this codebase). Clone/copy the pylage/ package into your project and install its runtime dependency:
pip install websockets
The WebSocket server (
pylage/runtime/websocket.py) importswebsockets.asyncio.server. Browser-integration tests additionally requireplaywright(pip install playwright && playwright install chromium), andpytest-asynciois used by several async tests — these are test-only dependencies, not required to use PyLage itself.
Project layout expected on PYTHONPATH:
your_project/
├── pylage/ # the framework package
│ ├── app.py
│ ├── components/
│ ├── core/
│ ├── renderers/
│ ├── runtime/
│ └── styling/
└── your_app.py
Quickstart
1. Static HTML rendering
The simplest usage — build a tree, call pylage.run(), get a self-contained HTML file back.
import pylage as ps
app = ps.Column(
ps.Heading("Hello PyLage"),
ps.Text("This file was rendered statically."),
ps.Button("Click me", variant="primary"),
)
output_path = ps.run(
app,
title="My PyLage App",
output="dist/index.html", # defaults to "index.html"
)
print("Rendered to:", output_path)
ps.run(app, serve=False, ...) (the default) never starts a server; it writes one HTML document (including the embedded client runtime <script> — inert since no WebSocket URL is configured) to output and returns a pathlib.Path.
2. Live, reactive app
Set serve=True to start a local HTTP + WebSocket server, wire up State, and get live DOM updates whenever Python-side state changes — including from Python-side timers/threads, not just click handlers.
import pylage as ps
count = ps.State(0)
def increment():
count.set(count.value + 1)
return count.value
app = ps.Column(
ps.Heading(count), # State passed directly as a prop
ps.Button("Increment", on_click=increment),
)
ps.run(
app,
title="Reactive Counter",
serve=True, # starts HTTP + WebSocket servers, opens a browser
output="live/index.html",
)
Two-way input binding works the same way — pass a State as value= to Input and PyLage wires the browser input event back to state.set(...) automatically:
name = ps.State("Dollar")
app = ps.Column(
ps.Heading(name),
ps.Input(value=name), # typing in the browser updates `name` in Python
)
For production/embedded use without the blocking run() loop, drive the Runtime object directly:
from pylage.runtime import Runtime
runtime = Runtime(app, title="My App", output="dist/index.html")
url = runtime.start() # returns "http://127.0.0.1:<port>/"
...
runtime.stop()
Architecture Overview
PyLage is organized into five layers. Data flows one way on state change, and the browser only ever receives small JSON patch messages, never a full re-render.
┌───────────────────────────────────────────────────────────────────────┐
│ 1. COMPONENT LAYER (pylage/core/component.py, pylage/components/) │
│ Component: type, props, children, events, id (uuid4 hex[:10]) │
│ component() factory splits on_* kwargs into `events`, │
│ consults ComponentRegistry for the prop contract │
└───────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────────────────────────────────────────────────┐
│ 2. REGISTRY LAYER (pylage/core/registry.py) │
│ ComponentRegistry: type -> ComponentDefinition(tag, void, renderer,│
│ props: {name: PropDefinition(kind, reactive, html_name)}) │
│ Single source of truth used by HTMLRenderer, StateBinding, IR │
└───────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────────────────────────────────────────────────┐
│ 3. REACTIVITY LAYER (pylage/core/state.py, graph.py, dirty.py, │
│ scheduler.py, binding.py) │
│ State.set() -> notifies subscribers │
│ StateBinding walks the tree at construction time, subscribes to │
│ every State-valued *reactive* prop, and on change either: │
│ a) calls the callback immediately (no scheduler), or │
│ b) marks the owning Component dirty in DirtyNodes and calls │
│ Scheduler.request() (coalesces N sync changes -> 1 flush) │
└───────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────────────────────────────────────────────────┐
│ 4. RENDER / DIFF / PATCH LAYER │
│ HTMLRenderer (core/renderer.py): Component tree -> HTML string, │
│ registry-driven tag/attribute/boolean/text resolution │
│ component_to_snapshot (core/snapshot.py): Component -> JSON dict │
│ diff() (core/diff.py): previous snapshot, current snapshot -> │
│ [update | insert | remove | replace | events] operations │
│ operation_to_message() (core/patch.py): operation -> protocol │
│ message (UpdateMessage / TreeAddMessage / TreeRemoveMessage / │
│ TreeReplaceMessage) │
│ Tree mutations (add/remove/replace/clear/set_children/move_to) │
│ are additionally observed live via TreeMutationObserver and │
│ broadcast directly as Tree*Message, independent of diff/patch │
└───────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────────────────────────────────────────────────┐
│ 5. RUNTIME / TRANSPORT LAYER (pylage/runtime/) │
│ LocalServer: threaded http.server serving the rendered index.html │
│ WebSocketServer: websockets.asyncio.server; binds StateBinding + │
│ TreeMutationObserver to the root, dispatches inbound `event` │
│ messages via EventDispatcher, broadcasts outbound update/tree_* │
│ messages to all connected clients │
│ client.py (CLIENT_RUNTIME): vanilla-JS runtime embedded in the │
│ HTML document; listens for click/input/change, sends `event` │
│ messages, and applies update/tree_add/tree_remove/tree_replace/ │
│ tree_clear/tree_set_children/tree_move patches to the real DOM │
└───────────────────────────────────────────────────────────────────────┘
Round trip for a browser click:
click (DOM) → data-pylage-events lookup → sendEvent() → WebSocket event message → EventDispatcher.dispatch() → your Python handler → State.set() → StateBinding._changed() → DirtyNodes.mark() + Scheduler.request() → (next loop tick) Scheduler.flush() → UpdateMessage broadcast → client onResponse → DOM attribute/text/property patch.
Round trip for structural changes (component.add(), .remove(), .replace(), .clear(), .set_children(), .move_to()): each mutation method emits a single mutation event synchronously to TreeMutationObserver, which is translated straight into a Tree*Message and broadcast — this path does not go through the scheduler/batching layer.
Component Reference
All components are exported from the top-level pylage package (import pylage as ps). Every component maps 1:1 to an entry in pylage.core.registry.registry.
| Component | HTML tag | Void element | Notable props |
|---|---|---|---|
Column(*children, **props) |
div |
no | class_name, title; default style=Style(display="flex", flex_direction="column"), overridable via style= |
Row(*children, **props) |
div |
no | same as Column, default flex_direction="row" |
Grid(*children, **props) |
div |
no | class_name, title; layout via style=Style(display="grid", ...) |
Text(text, **props) |
div |
no | text (kind=text, HTML-escaped), supports State |
Heading(text, **props) |
h1 |
no | text (kind=text) |
Button(text, **props) |
button |
no | text, value, disabled (boolean), title; use on_click=... |
Input(value="", **props) |
input |
yes | value, disabled, title; passing value=State(...) auto-wires on_input |
Form(*children, **props) |
form |
no | arbitrary attrs (e.g. method, action) pass through generically |
Table(*children, **props) |
table |
no | class_name, title |
Dialog(*children, **props) |
dialog |
no | class_name, title |
Navigation(*children, **props) |
nav |
no | class_name, title |
Tabs(*children, **props) |
div |
no | class_name, title |
Card(*children, **props) |
div |
no | class_name, title; default block layout style |
Divider(*children, **props) |
hr |
yes | class_name, title |
Badge / Avatar / Accordion / Carousel |
span / span / div / div |
no | class_name, title |
Image(**props) |
img |
yes | src, alt, class_name, title |
Video / Audio |
video / audio |
no | src, controls (boolean), class_name, title |
Icon(**props) |
span |
no | name (kind=text), class_name, title |
Canvas(*children, **props) |
svg |
no | width, height, class_name, title |
Checkbox(**props) |
input type="checkbox" |
no | checked (boolean), class_name, title |
Switch(**props) |
input type="checkbox" |
no | same as Checkbox |
RadioGroup(*children, **props) |
div |
no | class_name, title |
Select(*children, **props) |
select |
no | value, class_name, title |
Slider(**props) |
input type="range" |
yes | value, min, max, step |
DatePicker(**props) |
input type="date" |
yes | value, min, max |
Alert / Toast / Spinner / Skeleton |
div |
no | text (kind=text), class_name, title |
ProgressBar(**props) |
progress |
no | value, max, text |
Breadcrumbs(*children, **props) |
nav |
no | renders children inside <ol><li>...</li></ol> |
Pagination(*children, **props) |
nav |
no | class_name, title |
Menu(*children, **props) |
menu |
no | class_name, title |
Drawer(*children, **props) |
aside |
no | class_name, title |
Tooltip(*children, **props) |
span |
no | class_name, title |
Popover(*children, **props) |
div |
no | class_name, title |
Every rendered element also carries data-pylage-id="<component.id>" and, if the component has any on_* handlers, data-pylage-events="click,input,..." — these two attributes are what the client runtime uses to route DOM events back to Python and apply patches.
Unknown/custom component types still render (falling back to a <div> tag with generic attribute/text handling) so you can register your own components without modifying the framework:
from pylage.core.registry import registry, PropDefinition
registry.register(
"RatingStars",
"div",
props={
"value": PropDefinition("value", kind="attribute", html_name="data-value"),
"class_name": PropDefinition("class_name", kind="attribute", html_name="class"),
},
)
Styling & Theming
import pylage as ps
card = ps.Card(
ps.Text("Styled content"),
style=ps.Style(
padding="16px",
border_radius="8px",
box_shadow="0 1px 3px rgba(0,0,0,.15)",
),
)
Styleis an immutable dataclass covering box model, flex, grid, border, and typography properties, plus acustom={"--my-var": "..."}escape hatch for arbitrary CSS custom properties.Style.merge(override)combines a component's default layout style (e.g.Column'sdisplay:flex;flex-direction:column) with user-supplied overrides — user values always win.ResponsiveStyle(base=..., sm=..., md=..., lg=..., xl=...)emits@media (min-width: ...)blocks (640px/768px/1024px/1280px).Theme(colors=..., spacing=..., radius=..., fonts=...)renders--color-*,--spacing-*,--radius-*,--font-*CSS custom properties; passtheme=toHTMLRenderer(theme=theme)to inject a<style>:root{...}</style>block once per document.Stylevalues may themselves beStateobjects —to_css()unwraps them automatically, so layout can be reactive too.
Reactivity Model
State(value)— holds a value, notifies subscribers onset()only if the new value compares unequal to the old one.- Any component prop can be a
Stateinstance.StateBindingwalks the tree once at construction and subscribes to everyState-valued prop whosePropDefinition.reactiveisTrue(the default for unknown props/components, preserving backward compatibility). DependencyGraphrecordsState -> {(Component, prop_name)}edges — used for introspection/tooling and byDirtyNodes.mark_from_state.DirtyNodesis aset+ insertion-orderedlist, so multiple state changes to the same component in one batch collapse into a single scheduled update, and processing order is deterministic (first-marked-first-flushed).Scheduler.request()callsschedule_flush()at most once per batch (WebSocketServerschedules a flush ~1ms later on its own event loop viacall_later), thenScheduler.flush()drainsDirtyNodesand re-reads each dirty component's current prop values — so three synchronousstate.set()calls in a row produce exactly one WebSocket message reflecting the final value, not three.
Client ↔ Server Protocol
All messages are newline-free compact JSON (json.dumps(..., separators=(",", ":"))) sent over a single WebSocket per client.
| Direction | Type | Purpose |
|---|---|---|
| client → server | event |
{id, event, payload?} — DOM event dispatched to EventDispatcher |
| server → client | update |
{id, props, remove_props?, prop_meta?} — reactive prop change |
| server → client | tree_add |
{parent_id, components[], index?} — component(s) inserted |
| server → client | tree_remove |
{parent_id, component_ids[]} — component(s) removed |
| server → client | tree_replace |
{parent_id, old_component_id, new_component, index} |
| server → client | tree_clear |
{parent_id, component_ids[]} — all children removed |
| server → client | tree_set_children |
{parent_id, children[]} — full children replacement |
| server → client | tree_move |
{component_id, old_parent_id, new_parent_id} |
| server → client | response |
{ok, result?, error?} — reply to an event message |
prop_meta (on update) carries each prop's registry kind (attribute/boolean/text) and html_name, so the client can apply the correct DOM operation (attribute set/remove, boolean toggle, or textContent assignment) without any component-specific JavaScript.
Testing
The project ships an extensive test/ suite (unit, integration, protocol, benchmark, and Playwright browser tests). Run everything with:
pip install pytest websockets playwright pytest-asyncio
playwright install chromium # only needed for browser tests
pytest -q
Latest recorded run: 463 passed (see pylage_code_and_test_log.txt, Section 3).
Notable test categories:
test_*_component.py— one file per component, verifying tag, props, and children rendering.test_registry_*.py— registry contract enforcement (PropDefinitionkind/reactive/html_name validation, custom renderer preservation, void-element rendering).test_diff.py,test_tree_patch_protocol.py— diff/patch algorithm correctness.test_batching.py,test_scheduler.py,test_dirty_nodes.py— reactive pipeline batching semantics.test_browser_*.py— full round-trip tests using Playwright against a real runningRuntime.test_phase6_*.py— latency/throughput benchmarks for diff, patch, scheduler, and WebSocket broadcast.
Project Layout
pylage/
├── app.py # pylage.run() — the top-level entry point
├── components/
│ ├── basic.py # Text, Column, Row, Card, Button, Input, ... factories
│ └── __init__.py
├── core/
│ ├── component.py # Component dataclass, component() factory, mutation API
│ ├── registry.py # ComponentRegistry, PropDefinition, ComponentDefinition
│ ├── renderer.py # HTMLRenderer — Component -> HTML
│ ├── state.py # State (reactive value + subscribers)
│ ├── graph.py # DependencyGraph (State -> (Component, prop))
│ ├── dirty.py # DirtyNodes (batched-update tracking)
│ ├── scheduler.py # Scheduler (coalesced flush)
│ ├── binding.py # StateBinding (wires State subscriptions into the tree)
│ ├── events.py # EventDispatcher (component_id/event -> handler)
│ ├── snapshot.py # component_to_snapshot (Component -> JSON dict)
│ ├── diff.py # diff() — snapshot A/B -> patch operations
│ ├── patch.py # operation_to_message() — ops -> protocol messages
│ ├── protocol.py # EventMessage, UpdateMessage, Tree*Message dataclasses
│ ├── ir.py # Compiler-layer IR (IRNode, normalize/analyze/optimize)
│ └── tree.py # print_tree, collect_ids, TreeMutationObserver
├── renderers/
│ └── html.py # HTMLDocumentRenderer — full standalone <html> document
├── runtime/
│ ├── runtime.py # Runtime — coordinates HTTP + WebSocket servers
│ ├── server.py # LocalServer — threaded static-file HTTP server
│ ├── websocket.py # WebSocketServer — live transport + broadcast
│ └── client.py # CLIENT_RUNTIME — embedded browser JS
└── styling/
├── style.py # Style dataclass + to_css()
├── responsive.py # ResponsiveStyle + media-query generation
└── theme.py # Theme — CSS custom-property tokens
Known Limitations
See Developer_Manual.md → Known Limitations & Edge Cases for the full, test-verified list (registry mutation is process-global, events diff operations have no dedicated protocol message, State.__eq__ gates change notification, single-file HTTP server, no SSR hydration diffing, etc.).
License
No LICENSE file is present in the provided codebase — add one appropriate to your organization before distributing PyLage externally.
Release files for pylage 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pylage-1.0.1.tar.gz | 124.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pylage-1.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 299.0 kB
Release files / pylage-1.0.1.tar.gz
| Download URL | pylage-1.0.1.tar.gz |
|---|---|
| Size | 124.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3ddb3abbb0cefaf30a9a5400fd03f9aa934253b30a15ba01727be75cfe4ea481
|
|
BLAKE2b-256 checksum How to use checksums |
5c20723dbdb607854e1f5238936c2824891c8a5a1733000237050b9f0592e2f6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / pylage-1.0.1-py3-none-any.whl
| Download URL | pylage-1.0.1-py3-none-any.whl |
|---|---|
| Size | 174.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
65f3d8e23f3d6eed5605c7f946d0bc00fb9ecd4726f2093fa644bb1dcc3469ca
|
|
BLAKE2b-256 checksum How to use checksums |
080557a3966bb95a1c75f3bc0ec19d24dc624398f51cad5baa5ca7ce8830c6c1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|