PyLevate is a Python-syntax full-stack framework that compiles to Preact (for web apps) and Phaser (for 2D games). You write standard Python -- classes, dicts, decorators, pygame calls -- and the compiler transforms it into optimized JavaScript bundles. There is no Python interpreter at runtime; everything compiles away at build time. The same codebase deploys to the browser, iOS, and Android via Capacitor.
from pylevate import Component, h, state, mount
class Counter(Component):
count = state(0)
def increment(self):
self.count += 1
template = {
h.button(onClick={'self.increment'}): 'Clicked [[self.count]] times',
}
mount(Counter, '#app')That compiles to a ~12KB gzipped Preact bundle -- no virtual DOM diffing of Python, no WASM, no runtime interpreter. Just JavaScript.
- One language, three targets. The same Python skills build reactive web UIs (
app), 2D games (game), or both at once (hybrid). - Real Python syntax. Classes, dicts, decorators, comprehensions,
pygamecalls -- not a Python-flavored DSL. Your editor, type checker, and muscle memory just work. - Compiles away entirely. Ships idiomatic Preact + Phaser JS. No interpreter tax at runtime; bundles start around ~12KB gzipped.
- Reactive by default.
state()andstore()build on@preact/signalsfor fine-grained updates without a virtual-DOM re-render. - pygame in the browser. Write
pygame-style game loops; they hoist into Phaser's render loop automatically. - LLM apps out of the box.
pylevate.chatships chat UI components andpylevate.aia streaming client for OpenAI-compatible APIs and Anthropic — with tool calling, embeddings, and a key-hiding dev proxy. - A browser IDE included.
pylevate idecreates, edits, and live-previews projects with zero editor setup. - Mobile from day one.
--mobilewires up Capacitor so the same code base builds for iOS and Android. - Fast dev loop. Live reload with store-state restore and a compile-error overlay, scoped CSS, and a zero-config playground you can run with a single command.
- Quickstart
- Three Modes
- App Mode
- AI & Chat Apps
- Browser IDE
- Game Mode
- Hybrid Mode
- Mobile
- CLI Reference
- How the Compiler Works
- Project Structure
- Configuration
- Development
Prerequisites: Python 3.10+, Node.js 20+, Pixi for environment management.
Fastest taste -- no scaffolding: spin up the live playground and write PyLevate in your browser.
pixi install
pixi run python -m pylevate.cli playground # → http://localhost:4000Start a real project:
# Install and scaffold
pixi install
pixi run init my-app
# Develop with HMR
cd my-app
pixi run dev
# Production build
pixi run buildThe init command accepts a template flag: --template app (default), game, hybrid, dashboard, chat, agent, or rag.
Add --mobile to pre-configure Capacitor for iOS/Android builds:
pixi run init my-app --template app --mobilePyLevate supports three project modes, set in pylevate.config.py:
| Mode | Compiler Path | Runtime | Use Case |
|---|---|---|---|
app |
Dict walker + CSS scoper | Preact + @preact/signals | Web apps, dashboards, tools |
game |
Loop hoister + asset hoister | Phaser + pygame shim | 2D games |
hybrid |
Both paths | Preact + Phaser + event bridge | Game with DOM UI overlay |
from pylevate import Component, h, state, css, mount
class App(Component):
count = state(0)
style = css("""
.counter { display: flex; gap: 1rem; align-items: center; }
.btn { background: #5c6bc0; color: white; border: none; padding: 0.5rem 1.5rem; }
""")
def increment(self):
self.count += 1
template = {
h.div(Class='counter'): {
h.button(Class='btn', onClick={'self.increment'}): '+1',
h.span(): '[[self.count]]',
}
}
mount(App, '#app')import pylevate.game as pg
pg.init()
screen = pg.display.set_mode((800, 600))
class Player(pg.Sprite):
def __init__(self):
super().__init__()
self.image = pg.image.load('assets/player.png')
self.rect = self.image.get_rect()
self.rect.center = (400, 500)
def update(self):
keys = pg.key.get_pressed()
if keys[pg.K_LEFT]:
self.rect.x -= 5
if keys[pg.K_RIGHT]:
self.rect.x += 5
all_sprites = pg.sprite.Group()
player = Player()
all_sprites.add(player)
clock = pg.time.Clock()
running = True
while running:
for event in pg.event.get():
if event.type == pg.QUIT:
running = False
all_sprites.update()
screen.fill((0, 0, 30))
all_sprites.draw(screen)
pg.display.flip()
clock.tick(60)This same file runs locally with real pygame (pip install pygame && python main.py) and compiles to Phaser for web/mobile.
# main.py -- Preact HUD overlay
from pylevate import Component, h, state, css, mount
from pylevate.events import game_events
class HUD(Component):
score = state(0)
def on_mount(self):
game_events.on('score_change', self._on_score)
def _on_score(self, val):
self.score = val
template = {
h.div(Class='hud'): {
h.span(): 'Score: [[self.score]]',
}
}
mount(HUD, '#ui-layer')# game.py -- Phaser game underneath
import pylevate.game as pg
from pylevate.events import game_events
# ... sprites, setup ...
while running:
collected = pg.sprite.spritecollide(player, coins, True)
if len(collected) > 0:
score += len(collected) * 100
game_events.emit('score_change', score)
# ...Every UI unit is a class extending Component. The template is a Python dict that compiles to Preact h() calls at build time. No Python executes at runtime.
from pylevate import Component, SlotsEnum, state, css, h
class Card(Component):
class S(SlotsEnum):
default = ()
footer = ()
def __init__(self, title: str, elevated: bool = False, **kw):
super().__init__(title=title, elevated=elevated, **kw)
expanded = state(False)
style = css("""
.card { border-radius: 8px; padding: 1.25rem; background: var(--surface); }
.card.elevated { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.card-header { display: flex; justify-content: space-between; cursor: pointer; }
.card-body { margin-top: 0.75rem; }
.card-body.hidden { display: none; }
""")
def toggle(self):
self.expanded = not self.expanded
def get_context(self, props: dict) -> dict:
props['chevron'] = '...' if self.expanded else '...'
props['body_class'] = 'card-body' if self.expanded else 'card-body hidden'
return props
template = {
h.div(Class='card'): {
h.div(Class='card-header', onClick={'self.toggle'}): {
h.h3(): '[[title]]',
h.span(): '[[chevron]]',
},
h.div(Class='[[body_class]]'): {
S.default.slot(): '',
h.div(Class='card-footer'): {
S.footer.slot(): '',
}
}
}
}Lifecycle hooks:
| Hook | When |
|---|---|
on_mount() |
After component mounts to DOM |
on_unmount() |
Before component unmounts |
on_update(prev_props) |
After component updates |
get_context(props) |
Before every render -- returns extended props with derived values |
get_context compiles to a pre-render method. Use it to compute derived values that the template needs, keeping the template dict free of logic.
template_factory(cls) is a static method for recursive components (tree views, nested menus). It receives the class as an argument so the template can reference itself.
__init__ style (preferred -- gives IDE autocomplete and docstrings):
def __init__(self, label: str, variant: str = 'primary', on_click=None, **kw):
"""Button component.
Args:
label: Button text
variant: primary | ghost | danger
"""
super().__init__(label=label, variant=variant, on_click=on_click, **kw)props dict style (for simple components):
class Badge(Component):
props = {'text': 'New', 'color': 'brand'}Reactive state uses @preact/signals under the hood. state(x) compiles to signal(x), with reads and writes going through .value automatically.
Component-level state:
class Counter(Component):
count = state(0) # compiles to: this.count = signal(0)
def increment(self):
self.count += 1 # compiles to: this.count.value += 1Cross-component stores:
from pylevate import Store, computed, action, effect
from pylevate.signals import signal
class CartStore(Store):
items = signal([])
currency = signal('EUR')
@computed
def total(self):
return sum(i['price'] * i['qty'] for i in self.items.value)
@action
def add_item(self, product, qty=1):
existing = next(
(i for i in self.items.value if i['id'] == product['id']), None
)
if existing:
existing['qty'] += qty
self.items.value = [*self.items.value]
else:
self.items.value = [*self.items.value, {**product, 'qty': qty}]
@effect
def persist(self):
v"localStorage.setItem('cart', JSON.stringify(this.items.value))"
cart = CartStore()The v"..." syntax is a verbatim JS literal -- the escape hatch for browser APIs that have no Python equivalent. For multi-line JS, use the triple-quoted form:
@effect
def persist(self):
v"""
const payload = JSON.stringify(this.items.value);
localStorage.setItem('cart', payload);
"""Backslashes inside triple-quoted verbatim JS are preserved as written (regexes like /\d+/ survive intact).
v-literals are detected with Python's tokenizer, so v"..." appearing inside ordinary strings, docstrings, or comments is left untouched.
Keyword arguments always compile to a single trailing object literal:
Card(title='Hello', elevated=True) # → h(Card, {title: 'Hello', elevated: true})
fetch('/api', method='POST') # → fetch('/api', {method: 'POST'})That convention is right for PyLevate components and stores, and happens to be right for fetch -- but most native JS APIs take positional arguments. The compiler emits a warning when kwargs are passed to a call rooted at a known JS global (document, window, Math, setTimeout, ...). Where the object form isn't what the API expects, use positional arguments, pass a dict literal explicitly, or drop to a v"..." verbatim literal.
Templates are Python dicts. Keys are tag instances created via h.tagname(...), values are children (strings, dicts for nesting, or None).
| Pattern | Compiles To |
|---|---|
h.div(Class='foo'): {...} |
h('div', {class: 'foo-a3f9b2'}, ...) |
h.div(Class={'expr'}): ... |
h('div', {class: expr}, ...) |
h.div(Class=b'lit'): ... |
h('div', {class: 'lit'}, ...) (unprocessed) |
ComponentName(...): {...} |
h(ComponentName, {...}, ...) |
h.Template(For='x in xs'): {...} |
xs.map(x => h(...)) |
h.Template(If='expr'): {...} |
expr ? h(...) : null |
h.Template(If=...) / Elif / Else |
Chained ternary |
h.Template(Is='expr'): {...} |
h(resolveComponent(expr), ...) |
S.name.slot(): content |
Slot definition |
Comp.S.name(): {...} |
Slot fill |
'[[expr]]' in text |
`${expr}` (template literal) |
h.Template is a meta-tag that applies control flow but emits no DOM element.
[[expr]] uses double-square-bracket delimiters to avoid collision with Vue, Mustache, and Handlebars if mixing template systems.
Full example with iteration and conditionals:
template = {
h.div(Class='product-list'): {
h.input(
type='text',
value={'self.filter_text'},
onInput={'e => self.filter_text = e.target.value'}
): None,
h.Template(If={'len(filtered) == 0'}): {
h.p(Class='empty'): 'No results.'
},
h.div(Class='grid'): {
h.Template(For='product in filtered'): {
h.div(Class='card', key={'product["id"]'}): {
h.h3(): '[[product["name"]]]',
h.p(): '[[currency]] [[product["price"]]]',
}
}
}
}
}Semantic custom tags wrap CSS framework classes:
from pylevate import Tag
class NavItem(Tag):
tag_name = 'a'
ident_class = 'navbar-item'
template = {
NavItem(href='/home'): 'Home',
NavItem(href='/about', Tag='div'): 'About',
}Named slots:
class Modal(Component):
class S(SlotsEnum):
default = ()
header = ()
actions = ()
template = {
h.div(Class='modal'): {
h.div(Class='modal-header'): { S.header.slot(): {h.h2(): '[[title]]'} },
h.div(Class='modal-body'): { S.default.slot(): '' },
h.div(Class='modal-actions'):{ S.actions.slot(): {h.button(onClick={'on_close'}): 'Close'} }
}
}
# Filling slots when using the component:
template = {
Modal(title='Confirm', on_close={'handle_close'}): {
Modal.S.default(): {h.p(): 'Are you sure?'},
Modal.S.actions(): {
h.button(Class='btn-danger', onClick={'confirm'}): 'Delete',
h.button(onClick={'handle_close'}): 'Cancel',
}
}
}Template attribute values come in three tiers:
# Tier 1 -- static string: no processing, passed as-is
h.div(Class='card'): 'Hello'
# Tier 2 -- bytes literal: bypass all processing, literal output
h.meta(charset=b'utf-8'): ''
# Tier 3 -- set wrapping a string: evaluated as a JS expression
h.button(disabled={'not allow_submit'}): 'Submit'
# True -> <button disabled>
# False -> <button>Tier 3 (set syntax) is the primary way to bind dynamic values. The string inside the set is emitted as a JavaScript expression.
style = css(...) inside a component is extracted at compile time. Class names receive a SHA1-based suffix derived from the file path:
.card -> .card-a3f9b2 (first 6 characters of SHA1 of the file path)
The suffix is deterministic -- the same file path always produces the same hash. This means .card in button.py can never collide with .card in card.py. Builds are reproducible.
Global styles go in styles/global.css using CSS custom properties:
:root {
--color-brand: #5c6bc0;
--color-danger: #e53935;
--color-surface: #ffffff;
--radius-md: 6px;
}Implementation: pylevate/compiler/css_scoper.py
from pylevate import App, Router
from pages.home import Home
from pages.dashboard import Dashboard
from pages.profile import Profile
app = App(
router=Router([
('/', Home),
('/dashboard', Dashboard),
('/profile/:id', Profile),
]),
theme='styles/global.css',
)
app.mount('#app')Page components use the @page decorator:
from pylevate import Component, h, page
@page(title='Profile', route='/profile/:id')
class Profile(Component):
def get_context(self, props):
props['display_name'] = props['user']['name'] if props.get('user') else 'Loading...'
return props
template = {
h.div(Class='profile'): {
h.h1(): '[[display_name]]',
}
}Routing compiles to preact-router under the hood. Users never import from preact-router directly.
- Route params (
:id) arrive as props on the page component -- read them inget_context(props). @page(title=...)setsdocument.titlewhen the route becomes active.- Plain
h.a(href='/dashboard')anchors navigate client-side; no special Link component needed. - The dev server serves
index.htmlfor extensionless paths, so deep links like/profile/42work out of the box. Configure equivalent history-API fallback on your production host.
The dashboard template (pylevate init my-app --template dashboard) is a working routed app: pages, shared nav component, route params, and a cross-page store.
PyLevate ships out-of-the-box building blocks for LLM chat and agent apps: pylevate.chat (UI components) and pylevate.ai (a streaming LLM client). Everything runs in the browser -- no Python backend required.
Quick start:
pylevate init my-bot --template chat
cd my-bot && pylevate dev
# Point the endpoint at a local Ollama (http://localhost:11434/v1) and chat.from pylevate import Component, Store, h, mount
from pylevate.ai import AIClient
from pylevate.chat import ChatInput, ChatWindow, MessageList
from pylevate.signals import signal
class Chat(Store):
messages = signal([])
streaming_text = signal(None)
busy = signal(False)
async def send(self, text):
self.messages = [*self.messages, {'role': 'user', 'content': text}]
self.busy = True
self.streaming_text = ''
client = AIClient(base_url='http://localhost:11434/v1', model='llama3.2')
reply = await client.chat(
self.messages,
on_token=lambda t: self.push_token(t), # callbacks are lambdas!
)
self.messages = [*self.messages, reply]
self.streaming_text = None
self.busy = False| Component | Props | Notes |
|---|---|---|
ChatWindow |
slots: header, footer |
flex-column layout shell; children = body |
MessageList |
messages, streaming_text, streaming, markdown, empty_text |
smart autoscroll, streaming bubble, tool cards |
MessageBubble |
role, content, streaming, markdown |
assistant content rendered as markdown |
ChatInput |
on_send(text), disabled, placeholder |
Enter sends, Shift+Enter = newline |
ToolCallCard |
name, args, result, status, open |
collapsible tool-invocation card |
TypingIndicator |
-- | three-dot pulse |
Markdown |
source |
sanitized markdown → HTML |
Messages are plain dicts ({'id', 'role', 'content', ...}, the OpenAI chat shape), so a conversation kept in a Store survives dev reloads. All component CSS uses the reserved pl- class prefix; override those classes (or the --pl-chat-* CSS variables) to theme.
client = AIClient(base_url='http://localhost:11434/v1', api_key='', model='llama3.2')
reply = await client.chat(messages, on_token=lambda t: store.push(t)) # streaming
text = await client.complete('One-line haiku about ducks') # one-shot
vecs = await client.embeddings(chunks, model='nomic-embed-text') # embeddings
result = await client.run_tools(messages, tools=[calculator], # agent loop
on_step=lambda s: store.log(s))- Callbacks must be lambdas (
on_token=lambda t: self.push(t)). A bare method reference likeself.pushlosesthisin the compiled JS. - Abort a stream by passing
signal=controller.signalfrom anAbortController(); the partial text streamed so far is preserved. tool(name=..., description=..., parameters={...JSON Schema...}, handler=lambda args: ...)declares a tool forrun_tools; each step emitstool_call/tool_resultevents that map straight ontoToolCallCard.
Providers (auto-detected from base_url):
| Provider | base_url | Notes |
|---|---|---|
| Ollama | http://localhost:11434/v1 |
no key; run ollama serve |
| LM Studio | http://localhost:1234/v1 |
no key |
| OpenAI | https://api.openai.com/v1 |
api_key required |
| vLLM / any OpenAI-compatible | your server's /v1 |
server must allow CORS, or use the proxy |
| Anthropic | https://api.anthropic.com |
browser-direct via anthropic-dangerous-direct-browser-access; suitable for local/trusted apps. No embeddings endpoint. |
Never ship API keys in a bundle. For development, keep the key out of the browser entirely: start the dev server with a key in the environment and point the client at the built-in proxy:
OPENAI_API_KEY=sk-... pylevate dev # or ANTHROPIC_API_KEY, or
PYLEVATE_LLM_BASE_URL=http://localhost:11434/v1 pylevate devclient = AIClient(base_url='/api/llm', model='gpt-4o-mini')The proxy (POST /api/llm/...) forwards whitelisted routes (chat/completions, embeddings, v1/messages) to the configured upstream, attaches the key server-side, and streams SSE straight through. It is localhost-only and rejects cross-origin requests. For production, put the same contract behind your own backend.
Assistant output renders through a built-in, dependency-free markdown renderer: the entire input is HTML-escaped before parsing, so model (or prompt-injected) HTML can never execute; link URLs are restricted to http/https/mailto (a javascript: URL becomes #). Supported: headings, bold/italic, inline code, fenced code blocks, links, single-level lists, blockquotes, ---. Not supported by design: tables, nested lists, images, raw HTML.
chat-- streaming chatbot: settings panel (endpoint/model/key), markdown replies, stop button, history that survives dev reloads.agent-- tool-calling agent: a no-evalcalculator and afetch_urltool, with every tool call shown as an expandable card next to the chat.rag-- embeddings Q&A: chunks a corpus (corpus.py), embeds it once (the index survives dev reloads), retrieves top chunks by cosine similarity, and answers with numbered citations plus expandable sources.
pylevate ide launches a browser IDE for creating, editing, and running PyLevate projects — no editor setup required:
pylevate ide --workspace ~/pylevate-projects --open- Workspace: any directory; every subdirectory containing a
pylevate.config.pyappears as a project. An empty directory works -- create projects from any template in the New Project dialog. - Editing: file tree, tabs, CodeMirror with Python highlighting (falls back to a plain editor when offline).
Cmd/Ctrl+Ssaves to disk; the dev pipeline rebuilds and the preview reloads automatically. - Preview: the real built app served by the dev server, with the standard error overlay; compile errors also appear in the IDE's error panel -- click one to jump to the file and line.
- Requirements: Node.js + npm for builds (
npm installruns automatically when you create a project).
| Flag | Default | Description |
|---|---|---|
--workspace, -w |
. |
Workspace directory |
--port, -p |
3000 |
IDE/dev server port |
--hmr-port |
3001 |
HMR WebSocket port |
--open, -o |
false |
Open the IDE in a browser |
Note: per-project dev_port/hmr_port values in pylevate.config.py are ignored in IDE mode -- the server ports are fixed at launch. The IDE is a local development tool: it binds to localhost and edits files with your user's permissions.
Game mode uses a pygame-compatible Python API. import pylevate.game as pg mirrors pygame's namespace. The same code runs locally with real pygame for rapid iteration, then compiles to Phaser for web and mobile deployment.
import pylevate.game as pg
pg.init()
screen = pg.display.set_mode((800, 600))
pg.display.set_caption('My Game')
clock = pg.time.Clock()
running = True
while running:
for event in pg.event.get():
if event.type == pg.QUIT:
running = False
screen.fill((0, 0, 30))
pg.display.flip()
clock.tick(60)pygame to Phaser translation:
| pygame | Phaser Equivalent |
|---|---|
pg.init() + set_mode() |
new Phaser.Game(config) |
pg.image.load('f.png') |
this.load.image(key, url) in preload() |
pg.mixer.Sound('f.wav') |
this.load.audio(key, url) in preload() |
pg.sprite.Sprite |
Phaser.GameObjects.Sprite |
pg.sprite.Group() |
this.add.group() |
pg.sprite.spritecollide(s,g,kill) |
this.physics.add.overlap(s, g, cb) |
pg.key.get_pressed() |
this.input.keyboard.addKeys(...) |
screen.fill((r,g,b)) |
this.cameras.main.setBackgroundColor(hex) |
pg.display.flip() |
No-op (Phaser renders automatically) |
clock.tick(60) |
fps: { target: 60 } in Phaser config |
pg.font.Font(None, 36) |
this.add.text(x, y, '', style) |
sprite.kill() |
Destroy Phaser object + remove from all groups |
import pylevate.game as pg
class Player(pg.Sprite):
def __init__(self):
super().__init__()
self.image = pg.image.load('assets/player.png')
self.rect = self.image.get_rect()
self.rect.center = (400, 500)
self.speed = 5
def update(self):
keys = pg.key.get_pressed()
if keys[pg.K_LEFT]:
self.rect.x -= self.speed
if keys[pg.K_RIGHT]:
self.rect.x += self.speed
def shoot(self):
bullet = Bullet(self.rect.centerx, self.rect.top)
all_sprites.add(bullet)
bullets.add(bullet)
all_sprites = pg.sprite.Group()
bullets = pg.sprite.Group()
enemies = pg.sprite.Group()Sprite.kill() destroys the Phaser game object and removes the sprite from all groups.
# Sprite vs Group -- returns list of colliding sprites
hits = pg.sprite.spritecollide(player, enemies, False)
for hit in hits:
player.take_damage(10)
# With kill -- True removes colliders from the group
hits = pg.sprite.spritecollide(player, coins, True)
score += len(hits) * 100
# Group vs Group -- returns dict {sprite_in_g1: [sprites_in_g2]}
collisions = pg.sprite.groupcollide(enemies, bullets, True, True)
for enemy, bullet_list in collisions.items():
score += 10 * len(bullet_list)# Continuous hold (checked every frame)
keys = pg.key.get_pressed()
if keys[pg.K_LEFT]:
self.rect.x -= self.speed
# Event-based (fire once on press)
for event in pg.event.get():
if event.type == pg.KEYDOWN:
if event.key == pg.K_SPACE:
player.shoot()
# Available key constants:
# pg.K_LEFT, pg.K_RIGHT, pg.K_UP, pg.K_DOWN
# pg.K_SPACE, pg.K_RETURN, pg.K_ESCAPE# Sound effects
shot_sound = pg.mixer.Sound('assets/shoot.wav')
shot_sound.play()
# Music
pg.mixer.music.load('assets/theme.mp3')
pg.mixer.music.play(loops=-1)
pg.mixer.music.set_volume(0.5)
# Text rendering
font = pg.font.Font(None, 36)
text_surface = font.render(f'Score: {score}', True, (255, 255, 255))
screen.blit(text_surface, (10, 10))Font objects compile to Phaser Text objects that update in place. blit on a text surface compiles to .setText() rather than creating a new object each frame.
For games needing gravity and physics bodies, pg.physics wraps Phaser Arcade Physics:
class Player(pg.Sprite):
def __init__(self):
super().__init__()
self.image = pg.image.load('assets/player.png')
self.rect = self.image.get_rect()
self.rect.center = (100, 400)
self.physics = pg.physics.body(self)
self.physics.gravity_y = 400
self.physics.bounce = 0.1
def update(self):
keys = pg.key.get_pressed()
if keys[pg.K_LEFT]:
self.physics.vel_x = -200
elif keys[pg.K_RIGHT]:
self.physics.vel_x = 200
else:
self.physics.vel_x = 0
if keys[pg.K_UP] and self.physics.on_ground:
self.physics.vel_y = -500The compiler performs three structural transforms on game files (see pylevate/compiler/loop_hoister.py):
1. Asset hoisting -- All pg.image.load(), pg.mixer.Sound(), and pg.mixer.music.load() calls are collected into Phaser's preload():
function _preload(scene) {
scene.load.image('player', 'assets/player.png');
scene.load.image('enemy', 'assets/enemy.png');
scene.load.audio('shoot', 'assets/shoot.wav');
}2. Setup hoisting -- Sprite instantiation, group creation, and variable initialization before the main loop are emitted into create():
function _create(scene) {
let all_sprites = pg.sprite.Group();
let player = new Player();
all_sprites.add(player);
// ...
}3. Loop body to update -- The while running: body becomes update(). Explicit no-ops (display.flip(), group.draw(), screen.fill()) are elided:
function _update(scene) {
all_sprites.update();
let hits = pg.sprite.groupcollide(bullets, enemies, true, true);
// ...
}The final output calls createGame() with width, height, fps, background color, and the three functions.
Hybrid mode runs both compiler paths. The game renders in a Phaser canvas; Preact components overlay the canvas for HUD, menus, and other DOM UI.
The game_events bridge (pylevate.events) connects the two worlds:
# From game code:
from pylevate.events import game_events
game_events.emit('score_change', score)
game_events.emit('life_lost')
# From UI components:
from pylevate.events import game_events
class HUD(Component):
score = state(0)
lives = state(3)
def on_mount(self):
game_events.on('score_change', self._on_score)
game_events.on('life_lost', self._on_life_lost)
def _on_score(self, val):
self.score = val
def _on_life_lost(self):
self.lives = self.lives - 1game_events is a singleton EventBus (implemented in js/pylevate-events.js) shared across all modules. It supports on(), once(), emit(), and off().
In hybrid mode, pylevate.config.py uses mode = 'hybrid', and the project has separate entry points for UI (main.py) and game (game.py). Both are bundled as entry points by esbuild.
PyLevate uses Capacitor 5 to wrap the compiled web output for iOS and Android.
# Initialize with mobile support
pylevate init my-app --mobile
# Or add mobile to an existing project
cd my-app
pylevate mobile init ios
pylevate mobile init android
pylevate mobile init bothpylevate mobile ios # Build, sync, open Xcode
pylevate mobile android # Build, sync, open Android Studio
pylevate mobile run ios # Build, sync, run on device/simulatorImport native capabilities from pylevate.native:
from pylevate.native import Camera, Geolocation, Haptics, Storage, Share
class ProfileEditor(Component):
async def pick_avatar(self):
photo = await Camera.get_photo(quality=90, result_type='uri')
self.avatar_url = photo.web_path
await Haptics.impact(style='medium')
async def save_location(self):
pos = await Geolocation.get_current_position()
await Storage.set(key='last_pos', value={
'lat': pos.coords.latitude,
'lng': pos.coords.longitude,
})| Python Import | Capacitor Plugin |
|---|---|
Camera |
@capacitor/camera |
Geolocation |
@capacitor/geolocation |
Haptics |
@capacitor/haptics |
Storage |
@capacitor/preferences |
Share |
@capacitor/share |
PushNotifications |
@capacitor/push-notifications |
The compiler rewrites pylevate.native imports to direct Capacitor plugin imports during capacitor builds. Snake-case method names are converted to camelCase (get_photo becomes getPhoto). See pylevate/compiler/native_bridge.py.
All commands are available via pylevate or pixi run:
Scaffold a new project.
| Flag | Default | Description |
|---|---|---|
--template, -t |
app |
Template: app, game, hybrid, dashboard, chat, agent, rag |
--mobile |
false |
Pre-configure Capacitor |
Start the dev server with file watching and live reload.
| Flag | Default | Description |
|---|---|---|
--port, -p |
3000 |
Dev server port |
--hmr-port |
3001 |
HMR WebSocket port |
--open, -o |
false |
Open browser on start |
On every .py save the project is rebuilt and the page reloads; .css changes refresh stylesheets in place. The reload is designed to land you back where you were:
- Route -- preserved via the URL (with routing enabled).
- Store state -- JSON-serializable signal values of every
Storeare snapshotted tosessionStoragebefore the reload and restored after it. This machinery is compiled out of production bundles. - Not preserved -- component-local
state()fields, non-JSON store values, and game-mode state (Phaser restarts cleanly by design).
Compile errors appear as a full-screen overlay in the browser (with file/line) and dismiss automatically when fixed.
Launch the browser IDE (see Browser IDE): create projects from templates, edit files with live rebuild + preview.
| Flag | Default | Description |
|---|---|---|
--workspace, -w |
. |
Workspace directory |
--port, -p |
3000 |
IDE/dev server port |
--hmr-port |
3001 |
HMR WebSocket port |
--open, -o |
false |
Open the IDE in a browser |
Launch the interactive, zero-config playground -- write PyLevate in the browser and see it compile live. No scaffolding required.
| Flag | Default | Description |
|---|---|---|
--port, -p |
4000 |
Playground server port |
Create a production build: minified bundles with content-hashed file names (main-<hash>.js), and index.html rewritten to reference them -- so output can be served with long-lived cache headers. CDN references (e.g. Phaser) are left untouched.
| Flag | Default | Description |
|---|---|---|
--target, -t |
web |
Build target: web, capacitor, all |
--out-dir, -o |
dist/ |
Output directory |
--analyze |
false |
Show bundle size analysis |
--no-minify |
false |
Skip minification and asset hashing |
Initialize Capacitor. Platform: ios, android, or both.
Build for Capacitor, sync native project, and open the IDE (Xcode or Android Studio).
Build, sync, and run on a connected device or simulator.
The compiler uses Python's ast module to parse Python source and emit ES6 JavaScript. There is no RapydScript-NG dependency. The entire compilation pipeline is native Python.
.py source files
|
v
Python ast.parse() pylevate/compiler/py2js.py
|
v
ES6 JavaScript output
|
+---[app/hybrid]----> Template walker pylevate/compiler/template_walker.py
| [[expr]] -> ${expr}
|
+---[app/hybrid]----> CSS scoper pylevate/compiler/css_scoper.py
| .class -> .class-{sha1[:6]}
|
+---[game/hybrid]---> Loop hoister pylevate/compiler/loop_hoister.py
| while loop -> preload/create/update
|
+---[capacitor]-----> Native bridge pylevate/compiler/native_bridge.py
| pylevate.native -> @capacitor/*
|
v
esbuild bundle pylevate/compiler/esbuild.py
(ESM, splitting, tree-shaking, sourcemaps)
|
v
dist/web/ or dist/capacitor/
Python to JS translation (pylevate/compiler/py2js.py):
state(x)compiles tosignal(x). Reads and writes to state fields go through.value.- Python builtins are mapped:
printtoconsole.log,len()to.length,str()toString(), etc. - String methods are translated:
.strip()to.trim(),.startswith()to.startsWith(), etc. is/is notcompile to===/!==.in/not inare special-cased.- Template attribute names are mapped:
ClasstoclassName,on_clicktoonClick, etc. - Imports from
pylevateare rewritten to runtime package imports. Local imports become relative.jspaths.
Template dicts compile to h() calls at build time. The Python dict is never evaluated at runtime. This avoids the class of bugs that affected UPYTL when Python 3.11 changed dict key hashing semantics.
CSS scoping uses the first 6 characters of the SHA1 hash of the file path as a suffix on every class name. The same hash is applied to the CSS file and to class name references in the compiled JS.
esbuild bundling (pylevate/compiler/esbuild.py):
- ESM output format with code splitting and tree-shaking.
- Runtime JS files (
js/*.js) are aliased for esbuild resolution. - Phaser is external (loaded from CDN) to keep game bundles small.
- Sourcemaps are always generated.
Bundle sizes:
- App mode: ~12KB gzipped (Preact 3KB + signals 1.5KB + baselib + app code)
- Game mode: ~7KB gzipped (excluding Phaser, which loads from CDN)
pyframework/
├── pylevate/
│ ├── __init__.py # Package entry: Config + IDE-facing stubs
│ ├── __main__.py # python -m pylevate entry
│ ├── cli.py # CLI: init, dev, ide, playground, build, mobile
│ ├── config.py # Config dataclass + loader
│ ├── scaffold.py # Project scaffolding (shared by CLI + IDE)
│ ├── server.py # Dev server with HMR + /api/llm proxy
│ ├── ai.py / chat.py / native.py # IDE-facing import mirrors of runtime stubs
│ ├── compiler/
│ │ ├── pipeline.py # Orchestrates: discover -> compile -> transform -> bundle
│ │ ├── py2js.py # Python AST -> JavaScript emitter
│ │ ├── loop_hoister.py # Game: while loop -> preload/create/update
│ │ ├── css_scoper.py # SHA1-scoped class names
│ │ ├── esbuild.py # esbuild runner (generates Node script, parses output)
│ │ └── native_bridge.py # pylevate.native -> @capacitor/* rewriting
│ ├── ide/ # Browser IDE
│ │ ├── server.py # IDEServer: workspace + switchable active project
│ │ ├── handler.py # /api/ide/* routes + IDE UI serving
│ │ ├── files.py # Path-guarded file tree/read/write
│ │ ├── llm_proxy.py # /api/llm streaming proxy (keys stay server-side)
│ │ └── static/index.html # The IDE UI (single file)
│ ├── runtime/ # Python type stubs (IDE only, never shipped)
│ │ ├── component.py # Component, Tag, h, Slot, SlotsEnum, state, css, prop
│ │ ├── signals.py # signal, computed, effect, batch
│ │ ├── chat.py # ChatWindow, MessageList, ChatInput, ToolCallCard...
│ │ ├── ai.py # AIClient, tool, cosine_similarity, chunk_text
│ │ ├── native.py # Camera, Geolocation, Haptics, Storage, Share
│ │ └── game.py # pygame-compatible API stubs
│ ├── templates/ # Project scaffolding templates
│ │ ├── app/ game/ hybrid/ # Core templates
│ │ ├── dashboard/ # Routing showcase
│ │ └── chat/ agent/ rag/ # LLM app templates
│ └── mobile/
│ └── capacitor.py # Capacitor config, sync, IDE launch
├── js/
│ ├── pylevate-runtime.js # Re-exports Preact + @preact/signals; Store, Router, App
│ ├── pylevate-chat-runtime.js # Chat UI components (+ pylevate-chat.css)
│ ├── pylevate-ai-runtime.js # AIClient, tool loop (pure logic in pylevate-ai-core.js)
│ ├── pylevate-md.js # Dependency-free sanitizing markdown renderer
│ ├── pylevate-game-runtime.js # pygame shim on top of Phaser
│ ├── pylevate-native-runtime.js # Native bridge stubs (web fallbacks)
│ ├── pylevate-events.js # EventBus for hybrid mode
│ ├── baselib.js # Shared JS baselib (injected by esbuild)
│ └── hmr-client.js # Dev-only HMR WebSocket listener
├── tests/
│ ├── compiler/ # Codegen unit + golden tests
│ ├── ide/ # File guards, scaffold, LLM proxy, IDE routes
│ ├── js/ # Node smoke tests (markdown, SSE, AI client)
│ └── integration/ # Full pipeline e2e through npm/esbuild
├── pixi.toml # Pixi workspace config
├── pixi.lock
└── spec.md # Full implementation spec
my-app/
├── main.py # App entry point
├── pylevate.config.py # mode = 'app', entry = 'main.py'
├── package.json # Node dependencies (preact, esbuild, etc.)
├── index.html # HTML shell
└── dist/ # Build output
my-game/
├── main.py # Game entry point (pygame-style code)
├── pylevate.config.py # mode = 'game'
├── package.json
├── index.html
├── assets/ # Images, audio
└── dist/
my-hybrid/
├── main.py # Preact UI overlay
├── game.py # Phaser game scene
├── components/ # UI components
├── scenes/ # Game scenes
├── assets/
├── pylevate.config.py # mode = 'hybrid'
├── package.json
├── index.html
└── dist/
Project configuration lives in pylevate.config.py at the project root:
from pylevate.config import Config
config = Config(
mode="app", # "app" | "game" | "hybrid"
entry="main.py", # Main entry point
out_dir="dist/", # Build output directory
dev_port=3000, # Dev server port
hmr_port=3001, # HMR WebSocket port
)| Field | Type | Default | Description |
|---|---|---|---|
mode |
"app" / "game" / "hybrid" |
"app" |
Compilation mode |
entry |
str |
"main.py" |
Entry point file |
out_dir |
str |
"dist/" |
Output directory for builds |
dev_port |
int |
3000 |
Development server port |
hmr_port |
int |
3001 |
HMR WebSocket port |
If pylevate.config.py is not found, defaults are used. The config file is excluded from compilation.
Implementation: pylevate/config.py
- Pixi for Python/Node environment management
- Python 3.10+
- Node.js 20+
git clone <repo-url> && cd pyframework
pixi installpixi run -e test test237 tests across 18 test files:
| Test File | Count | Covers |
|---|---|---|
tests/compiler/test_py2js.py |
51 | Core Python-to-JS compilation, import resolution |
tests/compiler/test_stores.py |
27 | Store, computed, action, effect, v-strings |
tests/compiler/test_components.py |
23 | Component class compilation |
tests/ide/test_llm_proxy.py |
17 | LLM proxy: env resolution, SSE streaming, origin checks |
tests/compiler/test_templates.py |
15 | Template dict syntax |
tests/ide/test_files.py |
14 | IDE file guards: traversal, symlinks, atomic writes |
tests/ide/test_ide_routes.py |
11 | IDE HTTP routes: workspace, create, tree, file APIs |
tests/compiler/test_native_bridge.py |
11 | Capacitor import/method rewriting |
tests/compiler/test_loop_hoister.py |
10 | Game loop restructuring |
tests/compiler/test_chat_ai.py |
10 | Golden codegen for pylevate.chat/.ai, async methods |
tests/integration/test_pipeline_e2e.py |
10 | Full pipeline: templates → compile → esbuild bundle |
tests/compiler/test_warnings.py |
7 | Compile warnings (kwargs on native JS APIs) |
tests/compiler/test_html_rewrite.py |
7 | Production HTML asset-hash rewriting |
tests/compiler/test_css_scoper.py |
6 | CSS class name scoping |
tests/compiler/test_routing.py |
5 | Golden codegen shapes for App/Router/@page |
tests/ide/test_scaffold.py |
5 | Shared project scaffolding |
tests/compiler/test_esbuild_alias.py |
4 | Runtime alias map generation |
tests/integration/test_js_core.py |
4 | Node JS tests: markdown XSS, SSE parsing, AI client, jsdom runtime |
The integration tests bundle real template projects through npm/esbuild; they skip automatically when node or npm is unavailable. The node smoke tests (tests/js/*.mjs) exercise the dependency-free JS modules directly, and tests/js/runtime_jsdom.mjs runs the browser runtime (routing incl. <base href> sub-paths, store rehydration, chat components) under jsdom — install its dependencies once with npm install at the repo root.
| Task | Command |
|---|---|
pixi run dev |
Start dev server |
pixi run build |
Production build |
pixi run test |
Run test suite |
pixi run init |
Scaffold new project |
pixi run ide |
Launch the browser IDE |
Python (via pixi): typer, rich, watchdog, websockets, pytest (test env)
Node (via npm in generated projects): preact, @preact/signals, preact-router, esbuild, phaser (game/hybrid only)