Skip to main content

Python Frontend Framework with Magic Syntax, Dual-Runtime Support (Pyodide/MicroPython), Powered by Crank.js

Project description

⚙️🐍 Crank.py

Modern components for Python frontend development.

PyScript Compatible Pyodide Compatible MicroPython Compatible MIT License

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 (Pyodide)

<py-config type="toml">
packages = ["crankpy"]

[js_modules.main]
"https://cdn.jsdelivr.net/npm/@b9g/crank@latest/crank.js" = "crank_core"
"https://cdn.jsdelivr.net/npm/@b9g/crank@latest/dom.js" = "crank_dom"
</py-config>

PyScript (MicroPython)

<py-config type="toml">
type = "micropython"
packages = ["crankpy"]

[js_modules.main]
"https://cdn.jsdelivr.net/npm/@b9g/crank@latest/crank.js" = "crank_core"
"https://cdn.jsdelivr.net/npm/@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:  # Props automatically update!
        user_id = props.user_id
        user = fetch_user(user_id)  # Fetches when props change

        yield h.div[
            h.img(src=user.avatar),
            h.h2[user.name],
            h.p[user.bio]
        ]

# Usage
h(UserProfile, user_id=123)

Hyperscript Syntax Guide

Crank.py uses a clean, Pythonic hyperscript syntax:

HTML Elements

# Simple text content
h.div["Hello World"]
h.p["Some text"]

# With properties
h.input(type="text", value=text)
h.div(className="my-class")["Content"]

# Snake_case → kebab-case conversion
h.div(
    data_test_id="button",     # becomes data-test-id
    aria_hidden="true"         # becomes aria-hidden
)["Content"]

# Props spreading (explicit + spread)
h.button(className="btn", **userProps)["Click me"]
h.input(type="text", required=True, **formProps)

# Multiple dict merging (when needed)
h.div(**{**defaults, **themeProps, **userProps})["Content"]

# Nested elements
h.ul[
    h.li["Item 1"],
    h.li["Item 2"],
    h.li[
        "Item with ",
        h.strong["nested"],
        " content"
    ]
]

# Style objects (snake_case → kebab-case)
h.div(style={
    "background_color": "#f0f0f0",  # becomes background-color
    "border_radius": "5px"          # becomes border-radius
})["Styled content"]

# Reserved keywords with spreading
h.div(**{"class": "container", **userProps})["Content"]
# Or better: use className instead of class
h.div(className="container", **userProps)["Content"]

Components

# Component without props
h(MyComponent)

# Component with props
h(MyComponent, name="Alice", count=42)

# Component with children
h(MyComponent)[
    h.p["Child content"]
]

# Component with props and children
h(MyComponent, title="Hello")[
    h.p["Child content"]
]

Fragments

# Simple fragments - just use Python lists!
["Multiple", "children", "without", "wrapper"]
[h.div["Item 1"], h.div["Item 2"]]

# Fragment with props (when you need keys, etc.)
h("", key="my-fragment")["Child 1", "Child 2"]

# In context
h.div[
    h.h1["Title"],
    [h.p["Para 1"], h.p["Para 2"]],  # Simple fragment
    h.footer["Footer"]
]

Component Lifecycle

Component Signatures

Crank.py supports three component signatures:

# 1. Static components (no state)
@component
def Logo():
    return h.div["🔧 Crank.py"]

# 2. Context-only (internal state)
@component
def Timer(ctx):
    start_time = time.time()
    for _ in ctx:
        elapsed = time.time() - start_time
        yield h.div[f"Time: {elapsed:.1f}s"]

# 3. Context + Props (dynamic)
@component
def TodoItem(ctx, props):
    for props in ctx:  # New props each iteration
        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():
        # Automatically triggers re-render
        pass

    @ctx.schedule
    def before_render():
        # Runs before each render
        pass

    @ctx.after
    def after_render(node):
        # Runs after DOM update
        node.style.color = "blue"

    @ctx.cleanup
    def on_unmount():
        # Cleanup when component unmounts
        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()

    # Start the update loop
    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

# Required and optional props
class ButtonProps(TypedDict, total=False):
    onclick: Callable[[], None]  # Event handlers always lowercase
    disabled: bool
    variant: str  # e.g., "primary", "secondary" 
    children: Children

# Complex component with nested data
class TodoItemProps(TypedDict):
    todo: "TodoDict"  # Reference to another type
    ontoggle: Callable[[int], None]
    ondelete: Callable[[int], None] 
    onedit: Callable[[int, str], None]

class TodoDict(TypedDict):
    id: int
    title: str
    completed: bool

# Type-safe components
@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

# Basic types
Props = Dict[str, Any]  # General props dict
Children = Union[str, Element, List["Children"]]  # Nested content

# Generic Context typing (similar to Crank.js)
Context[PropsType, ResultType]  # T = props type, TResult = element result type

# Context with full method typing
def my_component(ctx: Context[MyProps, Element], props: MyProps):
    # All context methods are typed
    ctx.refresh()  # () -> None
    ctx.schedule(callback)  # (Callable) -> None  
    ctx.after(callback)    # (Callable) -> None
    ctx.cleanup(callback)  # (Callable) -> None
    
    # Iterator protocol for generator components
    for props in ctx:  # Each iteration gets updated props (typed as MyProps)
        yield h.div["Updated with new props"]
    
    # Direct props access with typing
    current_props: MyProps = ctx.props

Component Patterns & Generics

Create reusable, typed component patterns:

from typing import TypedDict, Generic, TypeVar, List

# Generic list component
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:  # props is properly typed as ListProps[T]
        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"])]
        ]

# Usage with type inference
user_list_props: ListProps[User] = {
    "items": users,
    "render_item": lambda user: h.span[user.name],
    "onselect": handle_user_select
}

Advanced Props Patterns

# Union types for polymorphic components
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]
    # Discriminated union based on field type
    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:  # text
            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:

# Install type checker
uv add --dev pyright

# Run type checking
uv run pyright crank/

# Run all checks (lint + types)
make check

pyproject.toml configuration:

[tool.pyright]
pythonVersion = "3.8"
typeCheckingMode = "basic"
reportUnknownMemberType = false  # For JS interop
reportMissingImports = false     # Ignore PyScript imports
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:
        # Access props using dict syntax
        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:

# Install dependencies
pip install pytest playwright

# Run tests
pytest tests/

Development

# Clone the repository
git clone https://github.com/bikeshaving/crankpy.git crankpy
cd crankpy

# Install in development mode
pip install -e ".[dev]"

# Run examples
python -m http.server 8000
# Visit http://localhost:8000/examples/

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

Learn More

Contributing

Contributions welcome! Please read our Contributing Guide first.

License

MIT © 2024

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

crankpy-0.1.4.tar.gz (47.3 kB view details)

Uploaded Source

Built Distribution

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

crankpy-0.1.4-py3-none-any.whl (14.1 kB view details)

Uploaded Python 3

File details

Details for the file crankpy-0.1.4.tar.gz.

File metadata

  • Download URL: crankpy-0.1.4.tar.gz
  • Upload date:
  • Size: 47.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for crankpy-0.1.4.tar.gz
Algorithm Hash digest
SHA256 58e28a8d227c70184c3a51673c66b80f0f9d59d67cc5b7718784cdb6a6976727
MD5 16cea05d07c29b70935c368b608382c0
BLAKE2b-256 e84b5a96a90e6e2cb1e5dee4c905c758bb6d1131e7276901885e9a84f2b463e9

See more details on using hashes here.

File details

Details for the file crankpy-0.1.4-py3-none-any.whl.

File metadata

  • Download URL: crankpy-0.1.4-py3-none-any.whl
  • Upload date:
  • Size: 14.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for crankpy-0.1.4-py3-none-any.whl
Algorithm Hash digest
SHA256 973aeace05dc1c79ead8c0a546da430b7ae936976fbea9bb910015ac1916bc81
MD5 2fb2d5b86d4b58d8481a4760a3bfedb0
BLAKE2b-256 8d98d8610041ccf9ef6d5dd5f08744906cfe635e48e261e25a54eba7e24f1b01

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