# ⚙️🐍 Crank.py
Modern components for Python frontend development.
[](https://pyscript.net)
[](https://pyodide.org)
[](https://micropython.org)
[](LICENSE)
Built on the [Crank.js](https://crank.js.org/) 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
```html
<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
```bash
pip install crankpy
```
## Quick Start
### Hello World
```python
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
```python
@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
```python
@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
```python
# 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
```python
# 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
```python
# 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:
```python
# 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
```python
@component
def MyComponent(ctx):
@ctx.refresh
def handle_click():
# Automatically triggers re-render
pass
@ctx.schedule
def schedule_render():
# Runs before the DOM nodes are inserted
pass
@ctx.after
def after_render(node):
# Runs after DOM updates
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
```python
@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
```python
@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:
```python
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
```python
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:
```python
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
```python
# 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:
```bash
# Install type checker
uv add --dev pyright
# Run type checking
uv run pyright crank/
# Run all checks (lint + types)
make check
```
**pyproject.toml configuration:**
```toml
[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):
```python
@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:
```bash
# Install dependencies
pip install pytest playwright
# Run tests
pytest tests/
```
## Development
```bash
# 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
## Advanced Features
### Refs - Direct DOM Access
Use `ref` callbacks to access rendered DOM elements directly:
```python
@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
```python
@component
def AutoFocusInput(ctx, props):
for props in ctx:
yield h.input(
type="text",
placeholder=props.get("placeholder", ""),
ref=lambda el: el.focus() # Auto-focus when rendered
)
```
### Fragments - Multiple Children Without Wrappers
Fragments let you return multiple elements without extra DOM nodes:
```python
# Simple fragments - just use Python lists!
@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']}"]
]
# Fragment with props (for keys, etc.)
@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"]
# Mixed fragments in JSX-like syntax
@component
def Navigation(ctx):
for _ in ctx:
yield h.nav[
h.div(className="logo")["MyApp"],
[ # Fragment for nav items
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:
```python
@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]
]
# Without keys - elements match by position (can cause issues)
# With keys - elements match by identity (preserves state correctly)
@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:
```python
@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) # Skip render if unchanged
)[
h(ExpensiveComponent, data=item["data"])
] for item in items]
]
# Copy with string selectors (Crank 0.7+)
@component
def SmartForm(ctx, props):
for props in ctx:
yield h.form[
# Copy all props except value (keeps input uncontrolled)
h.input(
copy="!value",
type="text",
placeholder="Enter text...",
name="username"
),
# Copy only specific props
h.div(
copy="class id",
className="form-section",
id="user-info",
data_updated=props.get("timestamp")
)[
h.label["Username"]
],
# Copy children from previous render
h.div(copy="children", className="dynamic")[
# Children preserved from last render
]
]
```
**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
```python
@component
def MarkdownRenderer(ctx, props):
for props in ctx:
# Process markdown to HTML
markdown_text = props["markdown"]
html_content = markdown_to_html(markdown_text) # Your markdown processor
yield h.div[
h(Raw, value=html_content)
]
# Insert actual DOM nodes
@component
def CanvasChart(ctx, props):
for props in ctx:
# Create chart canvas with external library
canvas_node = create_chart(props["data"])
yield h.div[
h.h3["Sales Chart"],
h(Raw, value=canvas_node)
]
```
#### Portal - Render Into Different DOM Location
```python
from js import document
@component
def Modal(ctx, props):
for props in ctx:
is_open = props.get("isOpen", False)
if is_open:
# Render modal into document body instead of current location
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", [])
]
]
]
]
# Usage
@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
```python
@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"]
]
# Multiple separate text nodes (no concatenation)
@component
def FormattedText(ctx, props):
for props in ctx:
yield h.p[
h(Text, value="Hello "),
h(Text, value=props["name"]),
h(Text, value="!")
] # Creates 3 separate Text nodes
```
#### Copy - Prevent Subtree Re-rendering
```python
@component
def MemoizedComponent(ctx, props):
last_props = None
for props in ctx:
if last_props and props_equal(props, last_props):
# Don't re-render if props haven't changed
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())
)
# Higher-order memo component
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
# Usage
@memo
@component
def ExpensiveItem(ctx, props):
for props in ctx:
# Expensive computation here
yield h.div[f"Processed: {props['data']}"]
```
### Performance Patterns
```python
# Combining keys, copy, and memoization
@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) # Skip clean items
)[
h(MemoizedItem,
data=item["data"],
onUpdate=props["onItemUpdate"]
)
] for item in items]
]
# Selective prop copying for performance
@component
def SmartComponent(ctx, props):
for props in ctx:
yield h.div[
# Only re-render when content changes, preserve styling
h.div(copy="class style")[
props["dynamicContent"]
],
# Expensive chart that rarely changes
h.div(copy=not props.get("chartDataChanged", False))[
h(ChartComponent, data=props["chartData"])
]
]
```
## Learn More
- **[Crank.js Documentation](https://crank.js.org/)** - The underlying framework
- **[PyScript Guide](https://pyscript.net/)** - Running Python in browsers
- **[Examples](examples/)** - See Crank.py in action
## Contributing
Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md) first.
## License
MIT © 2025
Raw data
{
"_id": null,
"home_page": null,
"name": "crankpy",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.8",
"maintainer_email": "Brian Kim <briankimpossible@gmail.com>",
"keywords": "crank, components, pyscript, pyodide, micropython, generators, ui, hyperscript, jsx, browser",
"author": null,
"author_email": "Brian Kim <briankimpossible@gmail.com>",
"download_url": "https://files.pythonhosted.org/packages/73/7a/a966fd3ae55ffdb7d5afb009be7fba6aa76840f973932b1f03ec67d478a5/crankpy-0.2.0.tar.gz",
"platform": null,
"description": "# \u2699\ufe0f\ud83d\udc0d Crank.py\n\nModern components for Python frontend development.\n\n[](https://pyscript.net)\n[](https://pyodide.org)\n[](https://micropython.org)\n[](LICENSE)\n\nBuilt on the [Crank.js](https://crank.js.org/) framework.\n\n## Features\n\n- **Pythonic Hyperscript** - Clean template `h.div[\"content\"]` syntax inspired by JSX\n- **Generator Components** - Natural state management using Python generators\n- **Async Components** - Components can use `async def`/`await` and `await for`\n- **Lifecycle Decorators** - `@ctx.refresh`, `@ctx.after`, `@ctx.cleanup`\n- **Dual Runtime** - Full compatibility with both Pyodide and MicroPython runtimes\n- **Browser Native** - No build step\n\n## Installation\n\n### PyScript\n\n```html\n<py-config type=\"toml\">\npackages = [\"crankpy\"]\n\n[js_modules.main]\n\"https://esm.run/@b9g/crank@latest/crank.js\" = \"crank_core\"\n\"https://esm.run/@b9g/crank@latest/dom.js\" = \"crank_dom\"\n</py-config>\n```\n\n### pip\n\n```bash\npip install crankpy\n```\n\n## Quick Start\n\n### Hello World\n\n```python\nfrom crank import h, component\nfrom crank.dom import renderer\nfrom js import document\n\n@component\ndef Greeting(ctx):\n for _ in ctx:\n yield h.div[\"Hello, Crank.py!\"]\n\nrenderer.render(h(Greeting), document.body)\n```\n\n### Interactive Counter\n\n```python\n@component\ndef Counter(ctx):\n count = 0\n\n @ctx.refresh\n def increment():\n nonlocal count\n count += 1\n\n @ctx.refresh\n def decrement():\n nonlocal count\n count -= 1\n\n for _ in ctx:\n yield h.div[\n h.h2[f\"Count: {count}\"],\n h.button(onclick=increment)[\"+\"],\n h.button(onclick=decrement)[\"-\"]\n ]\n```\n\n### Props Reassignment\n\n```python\n@component\ndef UserProfile(ctx, props):\n for props in ctx: # Props automatically update!\n user_id = props.user_id\n user = fetch_user(user_id) # Fetches when props change\n\n yield h.div[\n h.img(src=user.avatar),\n h.h2[user.name],\n h.p[user.bio]\n ]\n\n# Usage\nh(UserProfile, user_id=123)\n```\n\n## Hyperscript Syntax Guide\n\nCrank.py uses a clean, Pythonic hyperscript syntax:\n\n### HTML Elements\n\n```python\n# Simple text content\nh.div[\"Hello World\"]\nh.p[\"Some text\"]\n\n# With properties\nh.input(type=\"text\", value=text)\nh.div(className=\"my-class\")[\"Content\"]\n\n# Snake_case \u2192 kebab-case conversion\nh.div(\n data_test_id=\"button\", # becomes data-test-id\n aria_hidden=\"true\" # becomes aria-hidden\n)[\"Content\"]\n\n# Props spreading (explicit + spread)\nh.button(className=\"btn\", **userProps)[\"Click me\"]\nh.input(type=\"text\", required=True, **formProps)\n\n# Multiple dict merging (when needed)\nh.div(**{**defaults, **themeProps, **userProps})[\"Content\"]\n\n# Nested elements\nh.ul[\n h.li[\"Item 1\"],\n h.li[\"Item 2\"],\n h.li[\n \"Item with \",\n h.strong[\"nested\"],\n \" content\"\n ]\n]\n\n# Style objects (snake_case \u2192 kebab-case)\nh.div(style={\n \"background_color\": \"#f0f0f0\", # becomes background-color\n \"border_radius\": \"5px\" # becomes border-radius\n})[\"Styled content\"]\n\n# Reserved keywords with spreading\nh.div(**{\"class\": \"container\", **userProps})[\"Content\"]\n# Or better: use className instead of class\nh.div(className=\"container\", **userProps)[\"Content\"]\n```\n\n### Components\n\n```python\n# Component without props\nh(MyComponent)\n\n# Component with props\nh(MyComponent, name=\"Alice\", count=42)\n\n# Component with children\nh(MyComponent)[\n h.p[\"Child content\"]\n]\n\n# Component with props and children\nh(MyComponent, title=\"Hello\")[\n h.p[\"Child content\"]\n]\n```\n\n### Fragments\n\n```python\n# Simple fragments - just use Python lists!\n[\"Multiple\", \"children\", \"without\", \"wrapper\"]\n[h.div[\"Item 1\"], h.div[\"Item 2\"]]\n\n# Fragment with props (when you need keys, etc.)\nh(\"\", key=\"my-fragment\")[\"Child 1\", \"Child 2\"]\n\n# In context\nh.div[\n h.h1[\"Title\"],\n [h.p[\"Para 1\"], h.p[\"Para 2\"]], # Simple fragment\n h.footer[\"Footer\"]\n]\n```\n\n## Component Lifecycle\n\n### Component Signatures\n\nCrank.py supports three component signatures:\n\n```python\n# 1. Static components (no state)\n@component\ndef Logo():\n return h.div[\"\ud83d\udd27 Crank.py\"]\n\n# 2. Context-only (internal state)\n@component\ndef Timer(ctx):\n start_time = time.time()\n for _ in ctx:\n elapsed = time.time() - start_time\n yield h.div[f\"Time: {elapsed:.1f}s\"]\n\n# 3. Context + Props (dynamic)\n@component\ndef TodoItem(ctx, props):\n for props in ctx: # New props each iteration\n todo = props.todo\n yield h.li[\n h.input(type=\"checkbox\", checked=todo.done),\n h.span[todo.text]\n ]\n```\n\n### Lifecycle Decorators\n\n```python\n@component\ndef MyComponent(ctx):\n @ctx.refresh\n def handle_click():\n # Automatically triggers re-render\n pass\n\n @ctx.schedule\n def schedule_render():\n # Runs before the DOM nodes are inserted\n pass\n\n @ctx.after\n def after_render(node):\n # Runs after DOM updates\n node.style.color = \"blue\"\n\n @ctx.cleanup\n def on_unmount():\n # Cleanup when component unmounts\n clear_interval(timer)\n\n for _ in ctx:\n yield h.div(onclick=handle_click)[\"Click me\"]\n```\n\n## Examples\n\n### Todo App\n\n```python\n@component\ndef TodoApp(ctx):\n todos = []\n new_todo = \"\"\n\n @ctx.refresh\n def add_todo():\n nonlocal todos, new_todo\n if new_todo.strip():\n todos.append({\"text\": new_todo, \"done\": False})\n new_todo = \"\"\n\n @ctx.refresh\n def toggle_todo(index):\n nonlocal todos\n todos[index][\"done\"] = not todos[index][\"done\"]\n\n for _ in ctx:\n yield h.div[\n h.h1[\"Todo List\"],\n h.input(\n type=\"text\",\n value=new_todo,\n oninput=lambda e: setattr(sys.modules[__name__], 'new_todo', e.target.value)\n ),\n h.button(onclick=add_todo)[\"Add\"],\n h.ul[\n [h.li(key=i)[\n h.input(\n type=\"checkbox\",\n checked=todo[\"done\"],\n onchange=lambda i=i: toggle_todo(i)\n ),\n h.span[todo[\"text\"]]\n ] for i, todo in enumerate(todos)]\n ]\n ]\n```\n\n### Real-time Clock\n\n```python\n@component\ndef Clock(ctx):\n import asyncio\n\n async def update_time():\n while True:\n await asyncio.sleep(1)\n ctx.refresh()\n\n # Start the update loop\n asyncio.create_task(update_time())\n\n for _ in ctx:\n current_time = time.strftime(\"%H:%M:%S\")\n yield h.div[\n h.strong[\"Current time: \"],\n current_time\n ]\n```\n\n## TypeScript-Style Typing\n\nCrank.py provides comprehensive type safety with TypedDict interfaces, Context typing, and full IDE support through Pyright.\n\n### Component Props with TypedDict\n\nDefine strict component interfaces using TypedDict:\n\n```python\nfrom typing import TypedDict, Callable, Optional\nfrom crank import component, Context, Props, Children\n\n# Required and optional props\nclass ButtonProps(TypedDict, total=False):\n onclick: Callable[[], None] # Event handlers always lowercase\n disabled: bool\n variant: str # e.g., \"primary\", \"secondary\"\n children: Children\n\n# Complex component with nested data\nclass TodoItemProps(TypedDict):\n todo: \"TodoDict\" # Reference to another type\n ontoggle: Callable[[int], None]\n ondelete: Callable[[int], None]\n onedit: Callable[[int, str], None]\n\nclass TodoDict(TypedDict):\n id: int\n title: str\n completed: bool\n\n# Type-safe components\n@component\ndef Button(ctx: Context, props: ButtonProps):\n for props in ctx:\n yield h.button(\n onclick=props.get(\"onclick\"),\n disabled=props.get(\"disabled\", False),\n className=f\"btn btn-{props.get('variant', 'primary')}\"\n )[props.get(\"children\", \"Click me\")]\n\n@component\ndef TodoItem(ctx: Context, props: TodoItemProps):\n for props in ctx:\n todo = props[\"todo\"]\n yield h.li[\n h.input(\n type=\"checkbox\",\n checked=todo[\"completed\"],\n onchange=lambda: props[\"ontoggle\"](todo[\"id\"])\n ),\n h.span[todo[\"title\"]],\n h.button(onclick=lambda: props[\"ondelete\"](todo[\"id\"]))[\"\u00d7\"]\n ]\n```\n\n### Core Crank.py Types\n\n```python\nfrom crank import Element, Context, Props, Children\n\n# Basic types\nProps = Dict[str, Any] # General props dict\nChildren = Union[str, Element, List[\"Children\"]] # Nested content\n\n# Generic Context typing (similar to Crank.js)\nContext[PropsType, ResultType] # T = props type, TResult = element result type\n\n# Context with full method typing\ndef my_component(ctx: Context[MyProps, Element], props: MyProps):\n # All context methods are typed\n ctx.refresh() # () -> None\n ctx.schedule(callback) # (Callable) -> None\n ctx.after(callback) # (Callable) -> None\n ctx.cleanup(callback) # (Callable) -> None\n\n # Iterator protocol for generator components\n for props in ctx: # Each iteration gets updated props (typed as MyProps)\n yield h.div[\"Updated with new props\"]\n\n # Direct props access with typing\n current_props: MyProps = ctx.props\n```\n\n### Component Patterns & Generics\n\nCreate reusable, typed component patterns:\n\n```python\nfrom typing import TypedDict, Generic, TypeVar, List\n\n# Generic list component\nT = TypeVar('T')\n\nclass ListProps(TypedDict, Generic[T]):\n items: List[T]\n render_item: Callable[[T], Element]\n onselect: Callable[[T], None]\n\n@component\ndef GenericList(ctx: Context[ListProps[T], Element], props: ListProps[T]):\n for props in ctx: # props is properly typed as ListProps[T]\n yield h.ul[\n [h.li(\n key=i,\n onclick=lambda item=item: props[\"onselect\"](item)\n )[props[\"render_item\"](item)]\n for i, item in enumerate(props[\"items\"])]\n ]\n\n# Usage with type inference\nuser_list_props: ListProps[User] = {\n \"items\": users,\n \"render_item\": lambda user: h.span[user.name],\n \"onselect\": handle_user_select\n}\n```\n\n### Advanced Props Patterns\n\n```python\n# Union types for polymorphic components\nfrom typing import Union, Literal\n\nclass IconButtonProps(TypedDict, total=False):\n variant: Literal[\"icon\", \"text\", \"both\"]\n icon: str\n onclick: Callable[[], None]\n children: Children\n\nclass FormFieldProps(TypedDict):\n name: str\n value: Union[str, int, bool]\n onchange: Callable[[Union[str, int, bool]], None]\n # Discriminated union based on field type\n field_type: Literal[\"text\", \"number\", \"checkbox\"]\n\n@component\ndef FormField(ctx: Context, props: FormFieldProps):\n for props in ctx:\n field_type = props[\"field_type\"]\n\n if field_type == \"checkbox\":\n yield h.input(\n type=\"checkbox\",\n name=props[\"name\"],\n checked=bool(props[\"value\"]),\n onchange=lambda e: props[\"onchange\"](e.target.checked)\n )\n elif field_type == \"number\":\n yield h.input(\n type=\"number\",\n name=props[\"name\"],\n value=str(props[\"value\"]),\n onchange=lambda e: props[\"onchange\"](int(e.target.value))\n )\n else: # text\n yield h.input(\n type=\"text\",\n name=props[\"name\"],\n value=str(props[\"value\"]),\n onchange=lambda e: props[\"onchange\"](e.target.value)\n )\n```\n\n### Type Checking Setup\n\nInstall and configure Pyright for comprehensive type checking:\n\n```bash\n# Install type checker\nuv add --dev pyright\n\n# Run type checking\nuv run pyright crank/\n\n# Run all checks (lint + types)\nmake check\n```\n\n**pyproject.toml configuration:**\n```toml\n[tool.pyright]\npythonVersion = \"3.8\"\ntypeCheckingMode = \"basic\"\nreportUnknownMemberType = false # For JS interop\nreportMissingImports = false # Ignore PyScript imports\ninclude = [\"crank\"]\nexclude = [\"tests\", \"examples\"]\n```\n\n### Props as Dictionaries\n\nComponents receive props as Python dictionaries (converted from JS objects):\n\n```python\n@component\ndef MyComponent(ctx: Context, props: Props):\n for props in ctx:\n # Access props using dict syntax\n title = props[\"title\"]\n onclick = props[\"onclick\"]\n\n yield h.div[\n h.h1[title],\n h.button(onclick=onclick)[\"Click me\"]\n ]\n```\n\n### Event Props Convention\n\nUse lowercase for all event and callback props:\n\n- `onclick` not `onClick`\n- `onchange` not `onChange`\n- `ontoggle` not `onToggle`\n\nThis matches HTML attribute conventions and provides consistency.\n\n## Testing\n\nRun the test suite:\n\n```bash\n# Install dependencies\npip install pytest playwright\n\n# Run tests\npytest tests/\n```\n\n## Development\n\n```bash\n# Clone the repository\ngit clone https://github.com/bikeshaving/crankpy.git crankpy\ncd crankpy\n\n# Install in development mode\npip install -e \".[dev]\"\n\n# Run examples\npython -m http.server 8000\n# Visit http://localhost:8000/examples/\n```\n\n## Why Crank.py?\n\n### Python Web Development, Modernized\n\nTraditional Python web frameworks use templates and server-side rendering. Crank.py brings component-based architecture to Python:\n\n- **Reusable Components** - Build UIs from composable pieces\n- **Dynamic Updates** - Explicit re-rendering with ctx.refresh()\n- **Generator-Powered** - Natural state management with Python generators\n- **Browser-Native** - Run Python directly in the browser via PyScript\n\n### Perfect for:\n\n- **PyScript Applications** - Rich client-side Python apps\n- **Educational Projects** - Teaching web development with Python\n- **Prototyping** - Rapid UI development without JavaScript\n- **Data Visualization** - Interactive Python data apps in the browser\n\n## Advanced Features\n\n### Refs - Direct DOM Access\n\nUse `ref` callbacks to access rendered DOM elements directly:\n\n```python\n@component\ndef VideoPlayer(ctx):\n video_element = None\n\n def set_video_ref(el):\n nonlocal video_element\n video_element = el\n\n @ctx.refresh\n def play():\n if video_element:\n video_element.play()\n\n @ctx.refresh\n def pause():\n if video_element:\n video_element.pause()\n\n for _ in ctx:\n yield h.div[\n h.video(\n src=\"/path/to/video.mp4\",\n ref=set_video_ref\n ),\n h.button(onclick=play)[\"Play\"],\n h.button(onclick=pause)[\"Pause\"]\n ]\n```\n\n**Ref Patterns:**\n- Refs fire once when elements are first rendered\n- Don't work on fragments - use on host elements only\n- For components, explicitly pass `ref` to child elements\n- Useful for focus management, DOM measurements, third-party integrations\n\n```python\n@component\ndef AutoFocusInput(ctx, props):\n for props in ctx:\n yield h.input(\n type=\"text\",\n placeholder=props.get(\"placeholder\", \"\"),\n ref=lambda el: el.focus() # Auto-focus when rendered\n )\n```\n\n### Fragments - Multiple Children Without Wrappers\n\nFragments let you return multiple elements without extra DOM nodes:\n\n```python\n# Simple fragments - just use Python lists!\n@component\ndef UserInfo(ctx, props):\n user = props[\"user\"]\n for props in ctx:\n yield [\n h.h2[user[\"name\"]],\n h.p[user[\"bio\"]],\n h.span[f\"Joined: {user['joined']}\"]\n ]\n\n# Fragment with props (for keys, etc.)\n@component\ndef ConditionalContent(ctx, props):\n show_content = props.get(\"show\", False)\n for props in ctx:\n if show_content:\n yield h(\"\", key=\"content-fragment\")[\n h.div[\"Content block 1\"],\n h.div[\"Content block 2\"]\n ]\n else:\n yield h(\"\", key=\"empty-fragment\")[\"No content\"]\n\n# Mixed fragments in JSX-like syntax\n@component\ndef Navigation(ctx):\n for _ in ctx:\n yield h.nav[\n h.div(className=\"logo\")[\"MyApp\"],\n [ # Fragment for nav items\n h.a(href=\"/home\")[\"Home\"],\n h.a(href=\"/about\")[\"About\"],\n h.a(href=\"/contact\")[\"Contact\"]\n ],\n h.button[\"Menu\"]\n ]\n```\n\n### Key Prop - List Reconciliation\n\nKeys help Crank identify which elements have changed in lists:\n\n```python\n@component\ndef TodoList(ctx, props):\n for props in ctx:\n todos = props[\"todos\"]\n yield h.ul[\n [h.li(key=todo[\"id\"])[\n h.input(\n type=\"checkbox\",\n checked=todo[\"completed\"],\n onchange=lambda todo_id=todo[\"id\"]: props[\"onToggle\"](todo_id)\n ),\n h.span[todo[\"text\"]],\n h.button(onclick=lambda todo_id=todo[\"id\"]: props[\"onDelete\"](todo_id))[\"\u00d7\"]\n ] for todo in todos]\n ]\n\n# Without keys - elements match by position (can cause issues)\n# With keys - elements match by identity (preserves state correctly)\n\n@component\ndef DynamicList(ctx):\n items = [\"A\", \"B\", \"C\", \"D\"]\n reversed_items = False\n\n @ctx.refresh\n def toggle_order():\n nonlocal reversed_items\n reversed_items = not reversed_items\n\n for _ in ctx:\n current_items = items[::-1] if reversed_items else items\n yield h.div[\n h.button(onclick=toggle_order)[\"Toggle Order\"],\n h.ul[\n [h.li(key=item)[\n f\"Item {item} (with preserved state)\"\n ] for item in current_items]\n ]\n ]\n```\n\n**Key Guidelines:**\n- Use stable, unique values (IDs, not array indices)\n- Keys only need to be unique among siblings\n- Can be strings, numbers, or any JavaScript value\n- Essential for stateful components and form inputs\n\n### Copy Prop - Prevent Re-rendering\n\nThe `copy` prop prevents elements from re-rendering for performance optimization:\n\n```python\n@component\ndef ExpensiveList(ctx, props):\n for props in ctx:\n items = props[\"items\"]\n yield h.ul[\n [h.li(\n key=item[\"id\"],\n copy=not item.get(\"hasChanged\", True) # Skip render if unchanged\n )[\n h(ExpensiveComponent, data=item[\"data\"])\n ] for item in items]\n ]\n\n# Copy with string selectors (Crank 0.7+)\n@component\ndef SmartForm(ctx, props):\n for props in ctx:\n yield h.form[\n # Copy all props except value (keeps input uncontrolled)\n h.input(\n copy=\"!value\",\n type=\"text\",\n placeholder=\"Enter text...\",\n name=\"username\"\n ),\n\n # Copy only specific props\n h.div(\n copy=\"class id\",\n className=\"form-section\",\n id=\"user-info\",\n data_updated=props.get(\"timestamp\")\n )[\n h.label[\"Username\"]\n ],\n\n # Copy children from previous render\n h.div(copy=\"children\", className=\"dynamic\")[\n # Children preserved from last render\n ]\n ]\n```\n\n**Copy Prop Syntax:**\n- `copy=True` - Prevent all re-rendering\n- `copy=False` - Normal re-rendering (default)\n- `copy=\"!value\"` - Copy all props except `value`\n- `copy=\"class children\"` - Copy only `class` and `children`\n- Cannot mix `!` and regular syntax\n\n### Special Components\n\n#### Raw - Inject HTML/DOM Nodes\n\n```python\n@component\ndef MarkdownRenderer(ctx, props):\n for props in ctx:\n # Process markdown to HTML\n markdown_text = props[\"markdown\"]\n html_content = markdown_to_html(markdown_text) # Your markdown processor\n\n yield h.div[\n h(Raw, value=html_content)\n ]\n\n# Insert actual DOM nodes\n@component\ndef CanvasChart(ctx, props):\n for props in ctx:\n # Create chart canvas with external library\n canvas_node = create_chart(props[\"data\"])\n\n yield h.div[\n h.h3[\"Sales Chart\"],\n h(Raw, value=canvas_node)\n ]\n```\n\n#### Portal - Render Into Different DOM Location\n\n```python\nfrom js import document\n\n@component\ndef Modal(ctx, props):\n for props in ctx:\n is_open = props.get(\"isOpen\", False)\n if is_open:\n # Render modal into document body instead of current location\n modal_root = document.getElementById(\"modal-root\")\n yield h(Portal, root=modal_root)[\n h.div(className=\"modal-backdrop\", onclick=props[\"onClose\"])[\n h.div(className=\"modal-content\", onclick=lambda e: e.stopPropagation())[\n h.div(className=\"modal-header\")[\n h.h2[props[\"title\"]],\n h.button(onclick=props[\"onClose\"])[\"\u00d7\"]\n ],\n h.div(className=\"modal-body\")[\n props.get(\"children\", [])\n ]\n ]\n ]\n ]\n\n# Usage\n@component\ndef App(ctx):\n show_modal = False\n\n @ctx.refresh\n def open_modal():\n nonlocal show_modal\n show_modal = True\n\n @ctx.refresh\n def close_modal():\n nonlocal show_modal\n show_modal = False\n\n for _ in ctx:\n yield h.div[\n h.h1[\"My App\"],\n h.button(onclick=open_modal)[\"Open Modal\"],\n h(Modal,\n isOpen=show_modal,\n title=\"Example Modal\",\n onClose=close_modal\n )[\"Modal content here!\"]\n ]\n```\n\n#### Text - Explicit Text Node Control\n\n```python\n@component\ndef TextManipulator(ctx):\n text_node = None\n\n def set_text_ref(el):\n nonlocal text_node\n text_node = el\n\n @ctx.refresh\n def update_text():\n if text_node:\n text_node.textContent = \"Updated directly!\"\n\n for _ in ctx:\n yield h.div[\n h(Text, value=\"Original text\", ref=set_text_ref),\n h.button(onclick=update_text)[\"Update Text\"]\n ]\n\n# Multiple separate text nodes (no concatenation)\n@component\ndef FormattedText(ctx, props):\n for props in ctx:\n yield h.p[\n h(Text, value=\"Hello \"),\n h(Text, value=props[\"name\"]),\n h(Text, value=\"!\")\n ] # Creates 3 separate Text nodes\n```\n\n#### Copy - Prevent Subtree Re-rendering\n\n```python\n@component\ndef MemoizedComponent(ctx, props):\n last_props = None\n\n for props in ctx:\n if last_props and props_equal(props, last_props):\n # Don't re-render if props haven't changed\n yield h(Copy)\n else:\n yield h(ExpensiveComponent, **props)\n last_props = props\n\ndef props_equal(a, b):\n \"\"\"Shallow comparison of props\"\"\"\n return (\n set(a.keys()) == set(b.keys()) and\n all(a[key] == b[key] for key in a.keys())\n )\n\n# Higher-order memo component\ndef memo(Component):\n @component\n def MemoWrapper(ctx, props):\n last_props = None\n yield h(Component, **props)\n\n for props in ctx:\n if last_props and props_equal(props, last_props):\n yield h(Copy)\n else:\n yield h(Component, **props)\n last_props = props\n\n return MemoWrapper\n\n# Usage\n@memo\n@component\ndef ExpensiveItem(ctx, props):\n for props in ctx:\n # Expensive computation here\n yield h.div[f\"Processed: {props['data']}\"]\n```\n\n### Performance Patterns\n\n```python\n# Combining keys, copy, and memoization\n@component\ndef OptimizedList(ctx, props):\n for props in ctx:\n items = props[\"items\"]\n yield h.ul[\n [h.li(\n key=item[\"id\"],\n copy=not item.get(\"_dirty\", False) # Skip clean items\n )[\n h(MemoizedItem,\n data=item[\"data\"],\n onUpdate=props[\"onItemUpdate\"]\n )\n ] for item in items]\n ]\n\n# Selective prop copying for performance\n@component\ndef SmartComponent(ctx, props):\n for props in ctx:\n yield h.div[\n # Only re-render when content changes, preserve styling\n h.div(copy=\"class style\")[\n props[\"dynamicContent\"]\n ],\n\n # Expensive chart that rarely changes\n h.div(copy=not props.get(\"chartDataChanged\", False))[\n h(ChartComponent, data=props[\"chartData\"])\n ]\n ]\n```\n\n## Learn More\n\n- **[Crank.js Documentation](https://crank.js.org/)** - The underlying framework\n- **[PyScript Guide](https://pyscript.net/)** - Running Python in browsers\n- **[Examples](examples/)** - See Crank.py in action\n\n## Contributing\n\nContributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md) first.\n\n## License\nMIT \u00a9 2025\n",
"bugtrack_url": null,
"license": null,
"summary": "Python Frontend Framework with Async/Generators, Powered by Crank.js",
"version": "0.2.0",
"project_urls": {
"Bug Tracker": "https://github.com/bikeshaving/crankpy/issues",
"Documentation": "https://github.com/bikeshaving/crankpy#readme",
"Homepage": "https://github.com/bikeshaving/crankpy",
"JavaScript Core": "https://crank.js.org",
"Repository": "https://github.com/bikeshaving/crankpy"
},
"split_keywords": [
"crank",
" components",
" pyscript",
" pyodide",
" micropython",
" generators",
" ui",
" hyperscript",
" jsx",
" browser"
],
"urls": [
{
"comment_text": null,
"digests": {
"blake2b_256": "cf5c5c162d982376dbdc2a85bbeecbfe06a316d68780cb120a7636853427007b",
"md5": "0c1be6f62ae4c76069dc4819a869e394",
"sha256": "cd68b70859e5d772d898848e24b4392b99d6ac5ae2ecd2eb257fff7a94a72244"
},
"downloads": -1,
"filename": "crankpy-0.2.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "0c1be6f62ae4c76069dc4819a869e394",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.8",
"size": 16808,
"upload_time": "2025-10-06T17:51:52",
"upload_time_iso_8601": "2025-10-06T17:51:52.402403Z",
"url": "https://files.pythonhosted.org/packages/cf/5c/5c162d982376dbdc2a85bbeecbfe06a316d68780cb120a7636853427007b/crankpy-0.2.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": null,
"digests": {
"blake2b_256": "737aa966fd3ae55ffdb7d5afb009be7fba6aa76840f973932b1f03ec67d478a5",
"md5": "ecad4ab571e53e2d26ce702369dcb59e",
"sha256": "6f8dd04a377a7525e859d131a155ad5a2f069b3f7f64ec9a564f164e5a6a6740"
},
"downloads": -1,
"filename": "crankpy-0.2.0.tar.gz",
"has_sig": false,
"md5_digest": "ecad4ab571e53e2d26ce702369dcb59e",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.8",
"size": 53037,
"upload_time": "2025-10-06T17:51:53",
"upload_time_iso_8601": "2025-10-06T17:51:53.461653Z",
"url": "https://files.pythonhosted.org/packages/73/7a/a966fd3ae55ffdb7d5afb009be7fba6aa76840f973932b1f03ec67d478a5/crankpy-0.2.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2025-10-06 17:51:53",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "bikeshaving",
"github_project": "crankpy",
"travis_ci": false,
"coveralls": false,
"github_actions": false,
"lcname": "crankpy"
}