Skip to main content

Python-first application framework over SPECTER and RAGOT

Project description

SPRAG

One Python language, two runtimes.

Write your entire web app in Python — server logic, UI components, browser behavior, state management, realtime events — and SPRAG compiles, ships, and runs it as a single coherent application.

No JavaScript to write. No frontend build chain to maintain. No "API layer" between your server and your UI.

pip install spragkit
sprag new myapp && cd myapp && sprag dev

Status: pre-alpha. The framework is real and working. The API surface is not pinned yet.

Read the docs →


What SPRAG Actually Is

SPRAG is a full-stack Python web framework where server controllers, browser components, browser modules, state stores, realtime events, and deployment artifacts are all authored in Python and managed by one toolchain.

  • Routes are file-discovered under app/routes/
  • SSR is the default — document routes are pure server HTML, hybrid routes render first then hydrate
  • Browser code is compiled Python — your Module and Component classes are real Python that SPRAG compiles to JavaScript at build time
  • State is declared oncestore(...) works identically on server and browser
  • Actions are typed — server mutations go through schema-validated action dispatch
  • Realtime is built in — SSE, websockets, queues, watchers, and broadcast events are framework primitives
  • sprag build produces a deployable artifactsprag pack optimizes it for production

Not This

SPRAG is not a template language, not a Python wrapper around REST calls, not "Flask + React you still wire yourself," and not a virtual DOM framework.


Install

pip install spragkit

PyPI package: spragkit. Import package: sprag.

Requires Python 3.9+. Runtime dependency: specter-runtime.


60-Second Example

A hybrid route with server-rendered HTML, browser hydration, and a typed action:

# app/routes/counter/server.py
from sprag import Controller, Field, Schema, action

class CounterController(Controller):
    route = "/counter"

    def load(self):
        return {"count": 0}

    @action(schema=Schema("increment", {"count": Field(int, required=True)}))
    def increment(self, count):
        return {"count": count + 1}
# app/routes/counter/components.py
from sprag import Component, ui

class CounterCard(Component):
    def render(self, props=None):
        return ui.div(
            ui.div(str(self.state["count"]), class_="counter-display"),
            ui.button("Increment", type="button", data_role="increment"),
            class_="counter-card",
        )
# app/routes/counter/modules.py
from sprag import Module

class CounterModule(Module):
    def __init__(self, screen=None, state=None):
        super().__init__(screen=screen, state=state or {"count": 0})

    def on_start(self):
        self.delegate(self.element, "click", "[data-role='increment']", self.on_click)

    def on_click(self, event):
        event.prevent_default()
        self.call_action("increment", {"count": self.state["count"]}).then(self.on_result)

    def on_result(self, result):
        self.set_state(result.value)
# app/routes/counter/web.py
from sprag import Screen, hydrate
from .components import CounterCard
from .modules import CounterModule

class CounterScreen(Screen):
    modules = [CounterModule]

    def render(self, data):
        counter = self.module(CounterModule)
        return hydrate(CounterCard, module=counter)
# app/routes/counter/page.py
from sprag import page
from .server import CounterController
from .web import CounterScreen

counter = page(
    path="/counter",
    controller=CounterController,
    screen=CounterScreen,
    mode="hybrid",
)

The Module above is Python. SPRAG compiles it to JavaScript, ships it with the route, wires the action bridge, and hydrates the component in place. No handoff. No separate frontend.


Core Concepts

Routes and Mounts

SPRAG has two surface types:

Surface Purpose Render
page(mode="document") Pure SSR page Server HTML, no JS
page(mode="hybrid") SSR + hydration Server HTML, then browser takes over
mount(...) Browser-owned app Boot document, browser owns the root

Routes live under app/routes/. Mounts live under app/mounts/. Both are file-discovered.

Shared State

One declaration, two runtimes:

# app/stores.py
from sprag import store

session = store("session", initial={
    "user": {"name": "Ada"},
    "prefs": {"theme": "dark"},
})
# works identically on server or browser
session.set("user.name", "Grace")
session.patch({"prefs": {"theme": "light"}})
session.subscribe(
    lambda user: print(user["name"]),
    selector=lambda s: s["user"],
    immediate=True,
)

Server-side it backs a Specter model. Browser-side SPRAG rewrites the import to a generated shim hydrated from window.__SPRAG_PAYLOAD__.stores.

Shells

The shared frame is plain HTML and CSS:

from sprag import App, shell

app = App(
    routes="app.routes",
    shell=shell(template="app/shell.html", css=["app/shell.css"]),
)
<!-- app/shell.html -->
<div class="shell">
  <header class="nav">My App</header>
  <main>{{ sprag_slot }}</main>
</div>

Per-surface extras live on the surface itself: use css=[...] for route- or mount-specific styles, js=[...] for extra scripts, and put pass-through files under app/static/. Files in app/static/ are published at /static/...; other declared local assets land under /assets/....

JavaScript Interop

SPRAG ships two JS integration patterns.

Classic/global libraries go through js=[...] and browser.*:

from sprag import Module, browser, page

chart_page = page(
    path="/charts",
    controller=ChartController,
    screen=ChartScreen,
    js=["app/static/vendor/chart.umd.js"],
)

class ChartModule(Module):
    def on_start(self):
        Chart = browser.Chart
        canvas = self.element.query_selector("canvas")
        self.chart = Chart(canvas, {"type": "line"})

    def on_stop(self):
        if self.chart and self.chart.destroy:
            self.chart.destroy()

ESM libraries go through modules={...} and imports.*:

from sprag import Module, imports, module, page

analytics = page(
    path="/analytics",
    controller=AnalyticsController,
    screen=AnalyticsScreen,
    modules={
        "dayjs": module("app/static/vendor/dayjs.mjs"),
        "nanoid": module("https://cdn.example/nanoid.mjs", export="nanoid"),
    },
)

class AnalyticsModule(Module):
    def on_start(self):
        dayjs = imports.dayjs
        nanoid = imports.nanoid
        self.set_state({
            "today": dayjs().format("YYYY-MM-DD"),
            "id": nanoid(),
        })

The authoring contract is:

  • Put vendored JS, CSS, workers, WASM, and tiny adapters under app/static/.
  • Use js=[...] for deferred classic scripts that expose globals.
  • Use modules={alias: module(src, export=...)} for browser import()-loaded ESM.
  • Use browser.* to read globals from classic scripts; it lowers to globalThis.*.
  • Use imports.* to read declared ESM aliases; undeclared aliases fail at build time.
  • Create third-party instances in on_start() and tear them down in on_stop().

Environment Variables

SPRAG has a built-in env convention that works on both runtimes.

Server-side:

from sprag import env

app_name = env("APP_NAME", "SPRAG")
debug = env("DEBUG", False, cast=bool)
port = env("PORT", 8000, cast=int)

Browser-side:

from sprag import Module, env, public_env

class SettingsModule(Module):
    def on_start(self):
        api_url = env("SPRAG_PUBLIC_API_URL", "/api")
        flags = public_env()
        self.set_state({
            "api_url": api_url,
            "site_name": flags.get("SPRAG_PUBLIC_SITE_NAME", "SPRAG"),
        })

Same authoring rule as the rest of SPRAG:

  • env(...) is the Python spelling on both server and browser.

  • On the server it reads process env after SPRAG loads .env / .env.local.

  • In browser-authored Module / Component code it reads from the public env payload shipped as window.__SPRAG_ENV__.

  • .env and .env.local are loaded automatically when SPRAG imports your app.

  • Existing process env wins over file values.

  • .env.local overrides .env.

  • Only vars prefixed with SPRAG_PUBLIC_ are exposed to the browser.

  • public_env() returns the full public env mapping.

  • cast= supports bool, int, float, and str.

  • Use plain env("SECRET_KEY", required=True) on the server for secrets; do not prefix secrets with SPRAG_PUBLIC_.

File Uploads

Keep ordinary forms on the JSON action path, and use the dedicated multipart upload path when native file inputs are involved:

class AssetModule(Module):
    def on_submit(self, event):
        event.prevent_default()
        self.upload_form("upload_asset", event, self.on_progress).then(self.on_uploaded)
class AssetController(Controller):
    route = "/assets"

    @action(schema=Schema("upload_asset", {"title": Field(str, required=True)}))
    def upload_asset(self, title):
        primary = self.request.file("asset")
        extras = self.request.files_list("gallery")
        return {"title": title, "filename": primary.filename, "extra_count": len(extras)}

files on self.request.files, self.request.file(name), and upload_form(...) sends multipart/form-data to a separate upload endpoint, keeps non-file fields JSON-safe for schema validation, and exposes uploaded files on self.request.files, self.request.file(name), and self.request.files_list(name). The intended realtime pairing is: store the authoritative upload snapshot on the server, emit a lightweight socket signal, and let the browser refetch status through a normal action.

Background Jobs

QueueService now has a first-class SPRAG job contract on top of Specter's QueueService has a first-class SPRAG job contract on top of Specter's raw queue workers:

class BuildQueue(QueueService):
    def handle_item(self, item):
        total = 3
        for step in range(total):
            self.check_cancelled()
            self.report_progress(
                current=step + 1,
                total=total,
                message=f"Building {item['label']} ({step + 1}/{total})...",
            )
        return {"label": item["label"]}


class BuildController(Controller):
    route = "/jobs"

    @action(name="start", schema=Schema("start", {"label": Field(str, required=True)}))
    def queue_start(self, label):
        return self.enqueue("build_queue", {"label": label}, label=label)

    @action(schema=Schema("status", {"job_id": Field(str, required=True)}))
    def status(self, job_id):
        return self.job_status("build_queue", job_id)

    @action(schema=Schema("cancel", {"job_id": Field(str, required=True)}))
    def cancel(self, job_id):
        return self.request_job_cancel("build_queue", job_id)

self.enqueue(...) returns one stable payload shape: {"accepted": ..., "job": ..., "queue": ..., "message": ...}. The queue owns the authoritative job record (queued/running/cancelling/completed/failed/cancelled), progress, result/error fields, and targeted socket invalidation metadata. The intended browser pattern is: call the action, listen for sprag:queue.job.changed, then refetch status(...) for the job.

Dynamic Routes and Content

File-based dynamic params and catch-all segments:

app/routes/blog/[slug]/page.py       -> /blog/my-post
app/routes/docs/[...segments]/page.py -> /docs/getting-started/install

Static builds expand dynamic routes via page(..., static_paths=...):

docs = page(
    path="/docs/[...segments]",
    controller=DocsController,
    screen=DocsScreen,
    mode="document",
    static_paths=lambda: [{"segments": list(d.path_parts)} for d in docs_collection()],
)

Markdown content loading is built in via load_markdown_tree() and load_markdown_document().

Realtime

SSE broadcast through the bus bridge:

class LabJobQueue(QueueService):
    def handle_item(self, item):
        bus.emit("sprag:broadcast", {"event": "lab:job.done", "payload": item})
class JobModule(Module):
    def on_start(self):
        self.listen("lab:job.done", self.on_job_done)

Websocket ingress through the shared socket bridge:

class ChatController(Controller):
    def build_events(self, handler):
        handler.on("chat:message", self.handle_message)
class ChatModule(Module):
    def on_start(self):
        self.on_socket("chat:reply", self.on_reply)

    def send(self, text):
        self.emit_socket("chat:message", {"text": text})

Targeting:

self.emit_socket("upload:changed", {"reason": "stored"}, session_id=self.request.session_id)
self.emit_socket("room:notice", {"room": "alpha"}, topic="room:alpha")
self.emit_socket("private:notice", {"ok": True}, client_id="sprag-socket-7")

Browser Modules can opt into topics on the shared socket:

class UploadModule(Module):
    def on_start(self):
        self.join_topic("upload:123")
        self.on_socket("upload:changed", self.on_changed)

    def on_changed(self, payload):
        self.call_action("status", {}).then(self.on_status)

Recommended pattern: treat sockets as a selective invalidation channel, not the source of truth. Mutate authoritative server state first, emit a small signal event, then refetch the current snapshot through normal SPRAG actions or route loads. Topic names should be app-namespaced strings such as upload:123 or room:alpha.

SSE remains the broadcast-only path in this release.

If any surface declares socket ingress, server_mode="auto" promotes to websocket transport automatically.

Auth and Sessions

SPRAG includes built-in session management and authentication:

from sprag import App, SessionPolicy, InMemorySessionStore, AnonymousAuthService

app = App(
    routes="app.routes",
    session_store=InMemorySessionStore(),
    auth_service=AnonymousAuthService(),
    session_policy=SessionPolicy(
        idle_ttl_seconds=3600,
        absolute_ttl_seconds=86400,
        remember_me_ttl_seconds=2592000,
    ),
)

Protect routes with @requires_auth:

from sprag import requires_auth

class DashboardController(Controller):
    route = "/dashboard"

    @requires_auth(roles=["admin"])
    def load(self):
        return {"user": self.request.user}

Redirects

First-class redirects with fluent API:

from sprag import redirect

class AuthController(Controller):
    route = "/auth"

    @action
    def login(self):
        # Server-side redirect
        return redirect("/dashboard", status=302)

    @action
    def login_replace(self):
        # Browser history replacement
        return redirect("/dashboard", replace=True)

CLI

Create

sprag new myapp                      # default template
sprag new myapp --template=bare      # minimal skeleton
sprag new myapp --template=docs      # static docs/blog site
sprag new myapp --template=labs      # full framework showcase

Develop

sprag dev                            # dev server with hot reload
sprag dev --port 3000
sprag routes                         # list all routes, mounts, and actions

Scaffold

sprag add route dashboard --mode=hybrid
sprag add route about --mode=document
sprag add mount admin-panel
sprag add content guides             # markdown collection + routes

Build and Deploy

sprag build                          # compile to dist/
sprag pack                           # optimize dist for production
sprag pack --zip                     # optimize + archive

sprag pack runs:

  • CSS/JS minification (terser/cleancss if installed, regex fallback otherwise)
  • Python bytecode compilation with source stripping
  • Image optimization with WebP + responsive variants (requires Pillow)
  • Pre-gzip compression of static assets
  • Build validation
sprag pack --skip-bytecode           # minify + images + gzip only
sprag pack --skip-images             # no image optimization
sprag pack --image-quality 60        # aggressive image compression
sprag pack --no-webp --no-srcset     # skip variant generation

Diagnostics

sprag doctor                         # structural health check
sprag doctor --verbose               # with tracebacks
sprag inspect /counter --rebuild   # show compiled output for a route
sprag inspect /counter --open-files # just the generated file paths

Project Shape

myapp/
├── app/
│   ├── shell.html           # shared layout
│   ├── shell.css           # shared styles
│   ├── stores.py          # cross-runtime state
│   ├── routes/
│   │   ├── home/         # document route
│   │   ├── counter/      # hybrid route
│   │   └── blog/[slug]/ # dynamic route
│   ├── mounts/
│   │   └── dashboard/    # browser-owned mount
│   └── content/
│       └── docs/         # markdown content
└── requirements.txt

Each hybrid route:

app/routes/counter/
├── page.py            # page(...) declaration
├── server.py          # Controller + @actions
├── components.py    # Component classes (both runtimes)
└── modules.py        # Module classes (browser, compiled to JS)

Browser Codegen

Browser Module and Component code is compiled Python — not an embedded Python interpreter. SPRAG compiles the subset of Python that maps cleanly to JavaScript and fails at build time when a construct would produce misleading behavior.

Supported:

  • Control flow: if/elif/else, for/while, break/continue, try/except/finally
  • Comprehensions: list, dict, set, generator (one-generator with if filters)
  • Destructuring: tuple unpacking in assigns and loop targets
  • Dict operations: spread {**a}, merge a | b, augmented merge a |= b
  • Pattern matching: match/case with literal, wildcard, capture, guard, sequence, mapping, as, and | patterns
  • Walrus operator: simple-name targets in expression contexts
  • String methods: .upper(), .lower(), .strip() map to JS equivalents
  • Builtins: len, str, int, float, bool, abs, min, max, round, print, range, sum
  • Async: async def, await
  • Decorators: @debounce, @throttle, @animate, @virtual_scroll, @infinite_scroll, @ref()

Deliberately rejected (clear JSCodegenError at build time):

  • Walrus inside comprehensions or lambda bodies
  • match/case class patterns, *rest, **rest, binding OR patterns
  • Server-only imports in browser code
  • Python constructs with no honest JS equivalent

The Labs Template

The labs template is the framework's running test surface — every primitive SPRAG exposes gets exercised in a real scaffolded app.

sprag new labs --template=labs && cd labs && sprag dev

Includes: counter, virtual scroll, flat store, nested store with selectors, queue + SSE, watcher polling, operation success/failure, CSS animation, websocket roundtrip, cross-wired queue-to-store flow, and a lifecycle mount.


Under the Hood

SPRAG sits on two runtimes:

  • Specter on the server — controllers, services, queues, watchers, operations, lifecycle management, and orchestration
  • Ragot in the browser — components, modules, DOM ownership, stores, hydration, virtual scrolling, animation, and teardown

SPRAG makes them feel like one framework. set_state, listen, emit, subscribe, timeout, interval, and adopt follow the same mental model on both sides.


Documentation

Full documentation lives in docs/ and is published to GitHub Pages:

bleedingxiko.github.io/SPRAG

The docs site is itself a SPRAG app built with the docs template — dogfooding the framework. Topics covered:

  • Getting Started — installation, first app, project structure
  • Framework — two runtimes, routes, controllers, stores, auth, uploads, realtime, codegen
  • Specter — services, schemas, queues (server runtime)
  • Ragot — components, modules, ui primitives, decorators (browser runtime)
  • Guides — deployment, forms, background jobs

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

spragkit-0.1.8.tar.gz (269.1 kB view details)

Uploaded Source

Built Distribution

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

spragkit-0.1.8-py3-none-any.whl (336.6 kB view details)

Uploaded Python 3

File details

Details for the file spragkit-0.1.8.tar.gz.

File metadata

  • Download URL: spragkit-0.1.8.tar.gz
  • Upload date:
  • Size: 269.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for spragkit-0.1.8.tar.gz
Algorithm Hash digest
SHA256 9ee6ae0c0a37fbf5cf2f0bf5b10cd805a71890c803eeef20d26b60e4833c6221
MD5 46839dbe86572341b8b55e5b50ee6700
BLAKE2b-256 04f12192db86a0794d7b02d87d7b0aa6a555540dce9c6a3c077132ea9f90360c

See more details on using hashes here.

File details

Details for the file spragkit-0.1.8-py3-none-any.whl.

File metadata

  • Download URL: spragkit-0.1.8-py3-none-any.whl
  • Upload date:
  • Size: 336.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for spragkit-0.1.8-py3-none-any.whl
Algorithm Hash digest
SHA256 adc63f0a5513beb79c2c3d9f74c1ba00a4a5ef481b84947736c2e4342db8f7ba
MD5 1c91959b260a4e4f9cb5f140dc31e4f2
BLAKE2b-256 f8021b9a69d4066573f6491a07342a67ad964faa5acbbb6ad0ff3c7f824e2ada

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