⚙️🐍 Crank.py
Modern components for Python frontend development.

Built on the Crank.js framework.
Features
- Pythonic Hyperscript - Clean template
h.div["content"] syntax inspired by JSX
- Generator Components - Natural state management using Python generators
- Async Components - Components can use
async def/await and await for
- Lifecycle Decorators -
@ctx.refresh, @ctx.after, @ctx.cleanup
- Dual Runtime - Full compatibility with both Pyodide and MicroPython runtimes
- Browser Native - No build step
Installation
PyScript
<py-config type="toml">
packages = ["crankpy"]
[js_modules.main]
"https://esm.run/@b9g/crank@latest/crank.js" = "crank_core"
"https://esm.run/@b9g/crank@latest/dom.js" = "crank_dom"
</py-config>
pip
pip install crankpy
Quick Start
Hello World
from crank import h, component
from crank.dom import renderer
from js import document
@component
def Greeting(ctx):
for _ in ctx:
yield h.div["Hello, Crank.py!"]
renderer.render(h(Greeting), document.body)
Interactive Counter
@component
def Counter(ctx):
count = 0
@ctx.refresh
def increment():
nonlocal count
count += 1
@ctx.refresh
def decrement():
nonlocal count
count -= 1
for _ in ctx:
yield h.div[
h.h2[f"Count: {count}"],
h.button(onclick=increment)["+"],
h.button(onclick=decrement)["-"]
]
Props Reassignment
@component
def UserProfile(ctx, props):
for props in ctx:
user_id = props.user_id
user = fetch_user(user_id)
yield h.div[
h.img(src=user.avatar),
h.h2[user.name],
h.p[user.bio]
]
h(UserProfile, user_id=123)
Hyperscript Syntax Guide
Crank.py uses a clean, Pythonic hyperscript syntax:
HTML Elements
h.div["Hello World"]
h.p["Some text"]
h.input(type="text", value=text)
h.div(className="my-class")["Content"]
h.div(
data_test_id="button",
aria_hidden="true"
)["Content"]
h.button(className="btn", **userProps)["Click me"]
h.input(type="text", required=True, **formProps)
h.div(**{**defaults, **themeProps, **userProps})["Content"]
h.ul[
h.li["Item 1"],
h.li["Item 2"],
h.li[
"Item with ",
h.strong["nested"],
" content"
]
]
h.div(style={
"background_color": "#f0f0f0",
"border_radius": "5px"
})["Styled content"]
h.div(**{"class": "container", **userProps})["Content"]
h.div(className="container", **userProps)["Content"]
Components
h(MyComponent)
h(MyComponent, name="Alice", count=42)
h(MyComponent)[
h.p["Child content"]
]
h(MyComponent, title="Hello")[
h.p["Child content"]
]
Fragments
["Multiple", "children", "without", "wrapper"]
[h.div["Item 1"], h.div["Item 2"]]
h("", key="my-fragment")["Child 1", "Child 2"]
h.div[
h.h1["Title"],
[h.p["Para 1"], h.p["Para 2"]],
h.footer["Footer"]
]
Component Lifecycle
Component Signatures
Crank.py supports three component signatures:
@component
def Logo():
return h.div["🔧 Crank.py"]
@component
def Timer(ctx):
start_time = time.time()
for _ in ctx:
elapsed = time.time() - start_time
yield h.div[f"Time: {elapsed:.1f}s"]
@component
def TodoItem(ctx, props):
for props in ctx:
todo = props.todo
yield h.li[
h.input(type="checkbox", checked=todo.done),
h.span[todo.text]
]
Lifecycle Decorators
@component
def MyComponent(ctx):
@ctx.refresh
def handle_click():
pass
@ctx.schedule
def schedule_render():
pass
@ctx.after
def after_render(node):
node.style.color = "blue"
@ctx.cleanup
def on_unmount():
clear_interval(timer)
for _ in ctx:
yield h.div(onclick=handle_click)["Click me"]
Examples
Todo App
@component
def TodoApp(ctx):
todos = []
new_todo = ""
@ctx.refresh
def add_todo():
nonlocal todos, new_todo
if new_todo.strip():
todos.append({"text": new_todo, "done": False})
new_todo = ""
@ctx.refresh
def toggle_todo(index):
nonlocal todos
todos[index]["done"] = not todos[index]["done"]
for _ in ctx:
yield h.div[
h.h1["Todo List"],
h.input(
type="text",
value=new_todo,
oninput=lambda e: setattr(sys.modules[__name__], 'new_todo', e.target.value)
),
h.button(onclick=add_todo)["Add"],
h.ul[
[h.li(key=i)[
h.input(
type="checkbox",
checked=todo["done"],
onchange=lambda i=i: toggle_todo(i)
),
h.span[todo["text"]]
] for i, todo in enumerate(todos)]
]
]
Real-time Clock
@component
def Clock(ctx):
import asyncio
async def update_time():
while True:
await asyncio.sleep(1)
ctx.refresh()
asyncio.create_task(update_time())
for _ in ctx:
current_time = time.strftime("%H:%M:%S")
yield h.div[
h.strong["Current time: "],
current_time
]
TypeScript-Style Typing
Crank.py provides comprehensive type safety with TypedDict interfaces, Context typing, and full IDE support through Pyright.
Component Props with TypedDict
Define strict component interfaces using TypedDict:
from typing import TypedDict, Callable, Optional
from crank import component, Context, Props, Children
class ButtonProps(TypedDict, total=False):
onclick: Callable[[], None]
disabled: bool
variant: str
children: Children
class TodoItemProps(TypedDict):
todo: "TodoDict"
ontoggle: Callable[[int], None]
ondelete: Callable[[int], None]
onedit: Callable[[int, str], None]
class TodoDict(TypedDict):
id: int
title: str
completed: bool
@component
def Button(ctx: Context, props: ButtonProps):
for props in ctx:
yield h.button(
onclick=props.get("onclick"),
disabled=props.get("disabled", False),
className=f"btn btn-{props.get('variant', 'primary')}"
)[props.get("children", "Click me")]
@component
def TodoItem(ctx: Context, props: TodoItemProps):
for props in ctx:
todo = props["todo"]
yield h.li[
h.input(
type="checkbox",
checked=todo["completed"],
onchange=lambda: props["ontoggle"](todo["id"])
),
h.span[todo["title"]],
h.button(onclick=lambda: props["ondelete"](todo["id"]))["×"]
]
Core Crank.py Types
from crank import Element, Context, Props, Children
Props = Dict[str, Any]
Children = Union[str, Element, List["Children"]]
Context[PropsType, ResultType]
def my_component(ctx: Context[MyProps, Element], props: MyProps):
ctx.refresh()
ctx.schedule(callback)
ctx.after(callback)
ctx.cleanup(callback)
for props in ctx:
yield h.div["Updated with new props"]
current_props: MyProps = ctx.props
Component Patterns & Generics
Create reusable, typed component patterns:
from typing import TypedDict, Generic, TypeVar, List
T = TypeVar('T')
class ListProps(TypedDict, Generic[T]):
items: List[T]
render_item: Callable[[T], Element]
onselect: Callable[[T], None]
@component
def GenericList(ctx: Context[ListProps[T], Element], props: ListProps[T]):
for props in ctx:
yield h.ul[
[h.li(
key=i,
onclick=lambda item=item: props["onselect"](item)
)[props["render_item"](item)]
for i, item in enumerate(props["items"])]
]
user_list_props: ListProps[User] = {
"items": users,
"render_item": lambda user: h.span[user.name],
"onselect": handle_user_select
}
Advanced Props Patterns
from typing import Union, Literal
class IconButtonProps(TypedDict, total=False):
variant: Literal["icon", "text", "both"]
icon: str
onclick: Callable[[], None]
children: Children
class FormFieldProps(TypedDict):
name: str
value: Union[str, int, bool]
onchange: Callable[[Union[str, int, bool]], None]
field_type: Literal["text", "number", "checkbox"]
@component
def FormField(ctx: Context, props: FormFieldProps):
for props in ctx:
field_type = props["field_type"]
if field_type == "checkbox":
yield h.input(
type="checkbox",
name=props["name"],
checked=bool(props["value"]),
onchange=lambda e: props["onchange"](e.target.checked)
)
elif field_type == "number":
yield h.input(
type="number",
name=props["name"],
value=str(props["value"]),
onchange=lambda e: props["onchange"](int(e.target.value))
)
else:
yield h.input(
type="text",
name=props["name"],
value=str(props["value"]),
onchange=lambda e: props["onchange"](e.target.value)
)
Type Checking Setup
Install and configure Pyright for comprehensive type checking:
uv add --dev pyright
uv run pyright crank/
make check
pyproject.toml configuration:
[tool.pyright]
pythonVersion = "3.8"
typeCheckingMode = "basic"
reportUnknownMemberType = false
reportMissingImports = false
include = ["crank"]
exclude = ["tests", "examples"]
Props as Dictionaries
Components receive props as Python dictionaries (converted from JS objects):
@component
def MyComponent(ctx: Context, props: Props):
for props in ctx:
title = props["title"]
onclick = props["onclick"]
yield h.div[
h.h1[title],
h.button(onclick=onclick)["Click me"]
]
Event Props Convention
Use lowercase for all event and callback props:
onclick not onClick
onchange not onChange
ontoggle not onToggle
This matches HTML attribute conventions and provides consistency.
Testing
Run the test suite:
pip install pytest playwright
pytest tests/
Development
git clone https://github.com/bikeshaving/crankpy.git crankpy
cd crankpy
pip install -e ".[dev]"
python -m http.server 8000
Why Crank.py?
Python Web Development, Modernized
Traditional Python web frameworks use templates and server-side rendering. Crank.py brings component-based architecture to Python:
- Reusable Components - Build UIs from composable pieces
- Dynamic Updates - Explicit re-rendering with ctx.refresh()
- Generator-Powered - Natural state management with Python generators
- Browser-Native - Run Python directly in the browser via PyScript
Perfect for:
- PyScript Applications - Rich client-side Python apps
- Educational Projects - Teaching web development with Python
- Prototyping - Rapid UI development without JavaScript
- Data Visualization - Interactive Python data apps in the browser
Advanced Features
Refs - Direct DOM Access
Use ref callbacks to access rendered DOM elements directly:
@component
def VideoPlayer(ctx):
video_element = None
def set_video_ref(el):
nonlocal video_element
video_element = el
@ctx.refresh
def play():
if video_element:
video_element.play()
@ctx.refresh
def pause():
if video_element:
video_element.pause()
for _ in ctx:
yield h.div[
h.video(
src="/path/to/video.mp4",
ref=set_video_ref
),
h.button(onclick=play)["Play"],
h.button(onclick=pause)["Pause"]
]
Ref Patterns:
- Refs fire once when elements are first rendered
- Don't work on fragments - use on host elements only
- For components, explicitly pass
ref to child elements
- Useful for focus management, DOM measurements, third-party integrations
@component
def AutoFocusInput(ctx, props):
for props in ctx:
yield h.input(
type="text",
placeholder=props.get("placeholder", ""),
ref=lambda el: el.focus()
)
Fragments - Multiple Children Without Wrappers
Fragments let you return multiple elements without extra DOM nodes:
@component
def UserInfo(ctx, props):
user = props["user"]
for props in ctx:
yield [
h.h2[user["name"]],
h.p[user["bio"]],
h.span[f"Joined: {user['joined']}"]
]
@component
def ConditionalContent(ctx, props):
show_content = props.get("show", False)
for props in ctx:
if show_content:
yield h("", key="content-fragment")[
h.div["Content block 1"],
h.div["Content block 2"]
]
else:
yield h("", key="empty-fragment")["No content"]
@component
def Navigation(ctx):
for _ in ctx:
yield h.nav[
h.div(className="logo")["MyApp"],
[
h.a(href="/home")["Home"],
h.a(href="/about")["About"],
h.a(href="/contact")["Contact"]
],
h.button["Menu"]
]
Key Prop - List Reconciliation
Keys help Crank identify which elements have changed in lists:
@component
def TodoList(ctx, props):
for props in ctx:
todos = props["todos"]
yield h.ul[
[h.li(key=todo["id"])[
h.input(
type="checkbox",
checked=todo["completed"],
onchange=lambda todo_id=todo["id"]: props["onToggle"](todo_id)
),
h.span[todo["text"]],
h.button(onclick=lambda todo_id=todo["id"]: props["onDelete"](todo_id))["×"]
] for todo in todos]
]
@component
def DynamicList(ctx):
items = ["A", "B", "C", "D"]
reversed_items = False
@ctx.refresh
def toggle_order():
nonlocal reversed_items
reversed_items = not reversed_items
for _ in ctx:
current_items = items[::-1] if reversed_items else items
yield h.div[
h.button(onclick=toggle_order)["Toggle Order"],
h.ul[
[h.li(key=item)[
f"Item {item} (with preserved state)"
] for item in current_items]
]
]
Key Guidelines:
- Use stable, unique values (IDs, not array indices)
- Keys only need to be unique among siblings
- Can be strings, numbers, or any JavaScript value
- Essential for stateful components and form inputs
Copy Prop - Prevent Re-rendering
The copy prop prevents elements from re-rendering for performance optimization:
@component
def ExpensiveList(ctx, props):
for props in ctx:
items = props["items"]
yield h.ul[
[h.li(
key=item["id"],
copy=not item.get("hasChanged", True)
)[
h(ExpensiveComponent, data=item["data"])
] for item in items]
]
@component
def SmartForm(ctx, props):
for props in ctx:
yield h.form[
h.input(
copy="!value",
type="text",
placeholder="Enter text...",
name="username"
),
h.div(
copy="class id",
className="form-section",
id="user-info",
data_updated=props.get("timestamp")
)[
h.label["Username"]
],
h.div(copy="children", className="dynamic")[
]
]
Copy Prop Syntax:
copy=True - Prevent all re-rendering
copy=False - Normal re-rendering (default)
copy="!value" - Copy all props except value
copy="class children" - Copy only class and children
- Cannot mix
! and regular syntax
Special Components
Raw - Inject HTML/DOM Nodes
@component
def MarkdownRenderer(ctx, props):
for props in ctx:
markdown_text = props["markdown"]
html_content = markdown_to_html(markdown_text)
yield h.div[
h(Raw, value=html_content)
]
@component
def CanvasChart(ctx, props):
for props in ctx:
canvas_node = create_chart(props["data"])
yield h.div[
h.h3["Sales Chart"],
h(Raw, value=canvas_node)
]
Portal - Render Into Different DOM Location
from js import document
@component
def Modal(ctx, props):
for props in ctx:
is_open = props.get("isOpen", False)
if is_open:
modal_root = document.getElementById("modal-root")
yield h(Portal, root=modal_root)[
h.div(className="modal-backdrop", onclick=props["onClose"])[
h.div(className="modal-content", onclick=lambda e: e.stopPropagation())[
h.div(className="modal-header")[
h.h2[props["title"]],
h.button(onclick=props["onClose"])["×"]
],
h.div(className="modal-body")[
props.get("children", [])
]
]
]
]
@component
def App(ctx):
show_modal = False
@ctx.refresh
def open_modal():
nonlocal show_modal
show_modal = True
@ctx.refresh
def close_modal():
nonlocal show_modal
show_modal = False
for _ in ctx:
yield h.div[
h.h1["My App"],
h.button(onclick=open_modal)["Open Modal"],
h(Modal,
isOpen=show_modal,
title="Example Modal",
onClose=close_modal
)["Modal content here!"]
]
Text - Explicit Text Node Control
@component
def TextManipulator(ctx):
text_node = None
def set_text_ref(el):
nonlocal text_node
text_node = el
@ctx.refresh
def update_text():
if text_node:
text_node.textContent = "Updated directly!"
for _ in ctx:
yield h.div[
h(Text, value="Original text", ref=set_text_ref),
h.button(onclick=update_text)["Update Text"]
]
@component
def FormattedText(ctx, props):
for props in ctx:
yield h.p[
h(Text, value="Hello "),
h(Text, value=props["name"]),
h(Text, value="!")
]
Copy - Prevent Subtree Re-rendering
@component
def MemoizedComponent(ctx, props):
last_props = None
for props in ctx:
if last_props and props_equal(props, last_props):
yield h(Copy)
else:
yield h(ExpensiveComponent, **props)
last_props = props
def props_equal(a, b):
"""Shallow comparison of props"""
return (
set(a.keys()) == set(b.keys()) and
all(a[key] == b[key] for key in a.keys())
)
def memo(Component):
@component
def MemoWrapper(ctx, props):
last_props = None
yield h(Component, **props)
for props in ctx:
if last_props and props_equal(props, last_props):
yield h(Copy)
else:
yield h(Component, **props)
last_props = props
return MemoWrapper
@memo
@component
def ExpensiveItem(ctx, props):
for props in ctx:
yield h.div[f"Processed: {props['data']}"]
Performance Patterns
@component
def OptimizedList(ctx, props):
for props in ctx:
items = props["items"]
yield h.ul[
[h.li(
key=item["id"],
copy=not item.get("_dirty", False)
)[
h(MemoizedItem,
data=item["data"],
onUpdate=props["onItemUpdate"]
)
] for item in items]
]
@component
def SmartComponent(ctx, props):
for props in ctx:
yield h.div[
h.div(copy="class style")[
props["dynamicContent"]
],
h.div(copy=not props.get("chartDataChanged", False))[
h(ChartComponent, data=props["chartData"])
]
]
Learn More
Contributing
Contributions welcome! Please read our Contributing Guide first.
License
MIT © 2025