Pyrith
A pure-Python full-stack web framework. Backend routing, HTML components, full Tailwind utility system, design tokens, CVA variants, icon library, reactive state, pre-built component library, page templates, CSS generation, ORM, authentication, file uploads, WebSocket rooms, SSE, CSRF, rate limiting, TestClient — all in Python. Zero external dependencies.
pip install pyrith
Overview
Pyrith is a monolithic, self-contained web framework for Python 3.9+. Everything that a modern web application needs — HTTP server, declarative routing, component-based UI, programmatic CSS, database ORM, session auth, password hashing, Bearer token auth, CSRF protection, file upload handling, multipart parsing, SSE, WebSocket rooms, rate limiting, security headers, response caching, TestClient — lives inside a single package that ships with no dependencies beyond the Python standard library.
The design philosophy is total Python. You never leave the Python ecosystem. There is no HTML template language to learn, no CSS preprocessor to install, no JavaScript build step, no configuration file format, and no external process to run. If you can write Python, you can build a full web application with Pyrith — including production-quality UIs that rival React + Tailwind apps.
Table of Contents
- Quick Start
- Architecture
- HTTP Server & Routing
- Middleware
- Request Object
- Response Objects
- File Uploads
- Server-Sent Events (SSE)
- UI / HTML DSL
- Component System
- Page Builder
- CSS Engine
- Full Tailwind Utility System
- Design Tokens
- Class Variance Authority (CVA)
- Animation Utilities
- Theme System
- Icon Library
- Reactive State System
- Pre-Built Component Library
- Layout Engine
- Page Templates
- ORM
- Template Engine
- Session Authentication
- Route Guards
- Forms & Validation
- WebSocket
- CLI
- Error Handling
- PyPI Publishing
- Roadmap
Quick Start
from pyrith import Pyrith
from pyrith import ui, style
from pyrith.response import HTMLResponse
app = Pyrith(__name__)
@app.get("/")
def index(request):
css = style.CSS()
css.rule("body", font_family="system-ui", text_align="center", padding="4rem")
css.rule("h1", color="#2563eb")
page = ui.Page(title="Hello Pyrith")
page.head_add(f"<style>{css}</style>")
page.body_add(
ui.h1("Hello from Pyrith"),
ui.p("A full-stack web framework — 100% Python."),
ui.a("GitHub →", href="https://github.com/saadsalmanakram/Pyrith"),
)
return HTMLResponse(page.to_html())
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8080, debug=True)
python app.py
# [Pyrith] Running on http://127.0.0.1:8080 (debug=True)
Or use the CLI:
pyrith new myapp
cd myapp
python app.py
Architecture
pyrith/
├── app.py # Pyrith class, HTTP server (ThreadingHTTPServer), middleware pipeline
├── request.py # Request — method, path, headers, query, body, params, cookies
├── response.py # Response, HTMLResponse, JSONResponse, RedirectResponse, StreamResponse
├── router.py # Router, Route (regex param extraction), RouteGroup
├── middleware.py # Middleware base, CORS, Logging, StaticFiles, GZip
├── websocket.py # WebSocket frame codec, upgrade handshake (stdlib only)
├── template.py # Template engine ({{ }}, {% if %}, {% for %}, extends, include)
├── ui/
│ ├── html.py # Element DSL — div(), span(), h1(), input_(), raw(), …
│ ├── component.py # Component base class, render(), page()
│ ├── page.py # Page layout builder (head slots, body slots, scripts)
│ ├── state.py # Signal, Computed, Effect, useState, ReactiveComponent
│ ├── icons.py # 50+ inline SVG icons (Heroicons paths), Icon(), icon(), list_icons()
│ ├── layout.py # MasonryGrid, SplitPane, StickyHeader, AspectRatio, ScrollArea
│ ├── components/ # Pre-built UI component library
│ │ ├── layout.py # Stack, Grid, Container, Spacer, Divider
│ │ ├── navigation.py # NavBar, Sidebar, Breadcrumb, Tabs, Pagination
│ │ ├── display.py # Table, Badge, Avatar, Tag, ProgressBar, Spinner, Skeleton, Stat, Timeline
│ │ ├── feedback.py # Alert, Toast, Modal, Tooltip, Popover
│ │ ├── forms.py # Input, Textarea, Select, Checkbox, Radio, Switch, FileInput, DatePicker, RangeSlider, SearchInput
│ │ ├── cards.py # Card, HeroSection, FeatureCard, PricingCard, ProfileCard, EmptyState
│ │ └── interactive.py # Accordion, Dropdown, Collapsible
│ └── templates/ # Full-page layout templates
│ └── layouts.py # DashboardLayout, AuthLayout, DocsLayout, LandingLayout, BlogLayout
├── style/
│ ├── css.py # CSS, Rule, MediaQuery — programmatic stylesheet
│ ├── stylesheet.py# StyleSheet — scoped component CSS
│ ├── tailwind.py # TailwindSheet — full Tailwind utility system + tw() helper
│ ├── tokens.py # Design tokens — full Tailwind palette, spacing, typography, shadow, etc.
│ ├── variants.py # ClassVariance (CVA pattern) + cv() factory
│ ├── animations.py# 20+ keyframe animations, AnimationSheet, animate()
│ ├── theme.py # Theme, ThemeProvider, CSS variables, LightTheme, DarkTheme, PurpleTheme
│ └── units.py # px(), rem(), em(), pct(), vw(), vh(), fr(), s(), ms()
├── orm/
│ ├── model.py # Model metaclass, save(), delete(), get(), create_table()
│ ├── fields.py # IntField, TextField, BoolField, DateTimeField, ForeignKey
│ └── query.py # QuerySet — filter(), order_by(), limit(), offset(), count()
├── auth/
│ ├── session.py # SessionStore (HMAC-signed cookies), Session
│ └── decorators.py# login_required(), role_required()
├── forms/
│ └── form.py # Form, StringField, IntegerField, EmailField, BooleanField
└── cli/
└── commands.py # pyrith new / run / version
Key invariant: Every module uses only the Python standard library (http.server, urllib, json, sqlite3, hashlib, hmac, gzip, socket, threading, re, html, mimetypes, time, os). There is intentionally no requirements.txt for the core library.
HTTP Server & Routing
from pyrith import Pyrith
app = Pyrith(__name__)
# Static route
@app.get("/")
def home(request):
return "<h1>Home</h1>"
# Path parameter
@app.get("/users/{user_id}")
def get_user(request):
uid = request.params["user_id"] # always a str
return f"User: {uid}"
# Multiple methods on one path
@app.route("/items", methods=["GET", "POST"])
def items(request):
if request.method == "POST":
return "created"
return "list"
# Route groups (prefix namespacing)
api = app.group("/api/v1")
@api.get("/status")
def status(request):
return {"status": "ok"}
# Start the server
app.run(host="0.0.0.0", port=8080, debug=False)
Path parameter syntax
/users/{id} → params["id"]
/files/{path} → params["path"] (single segment only)
/static/* → matches any suffix (wildcard)
Internally, {name} is compiled to the regex capture group ([^/]+). The router iterates routes in declaration order and returns the first match.
Middleware
from pyrith.middleware import (
CORSMiddleware, LoggingMiddleware, StaticFilesMiddleware,
GZipMiddleware, SecurityHeadersMiddleware, RequestIDMiddleware,
RateLimitMiddleware, CacheMiddleware,
)
app.use(LoggingMiddleware())
app.use(RequestIDMiddleware()) # X-Request-ID on every req/resp
app.use(CORSMiddleware(allow_origins=["https://example.com"]))
app.use(SecurityHeadersMiddleware( # CSP, HSTS, X-Frame-Options, ...
csp="default-src 'self'",
hsts=True,
frame_options="DENY",
))
app.use(RateLimitMiddleware(limit=100, window=60)) # 100 req/min per IP
app.use(CacheMiddleware(ttl=30)) # 30s in-memory GET cache
app.use(StaticFilesMiddleware(directory="./public", url_prefix="/static"))
app.use(GZipMiddleware(min_size=500))
Built-in middleware
| Middleware | Description |
|---|---|
LoggingMiddleware |
Logs method, path, status, elapsed ms |
RequestIDMiddleware |
Attaches X-Request-ID UUID to request and response |
CORSMiddleware |
CORS preflight + response headers |
SecurityHeadersMiddleware |
CSP, HSTS, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy |
RateLimitMiddleware(limit, window) |
Sliding-window rate limit per IP; returns 429 with Retry-After |
CacheMiddleware(ttl, max_size) |
In-memory GET response cache with LRU eviction |
StaticFilesMiddleware |
Serve directory under URL prefix; path-traversal safe |
GZipMiddleware(min_size) |
Compress responses when client accepts gzip |
Writing a custom middleware
from pyrith.middleware import Middleware
class TimingMiddleware(Middleware):
def before_request(self, request):
import time
request.state["start"] = time.perf_counter()
return None
def after_request(self, request, response):
import time
elapsed = (time.perf_counter() - request.state.get("start", 0)) * 1000
response.set_header("X-Response-Time", f"{elapsed:.2f}ms")
return response
app.use(TimingMiddleware())
before_request returns None to continue the pipeline or a Response to short-circuit it. after_request always returns a (possibly modified) Response.
Request Object
| Attribute / Method | Type | Description |
|---|---|---|
request.method |
str |
"GET", "POST", etc. (always uppercase) |
request.path |
str |
URL path, e.g. "/users/42" |
request.params |
dict[str,str] |
Path parameters from {name} segments |
request.query |
dict[str,Any] |
Query string key/value pairs |
request.headers |
dict[str,str] |
All headers, keys lowercased |
request.body |
bytes |
Raw request body |
request.json() |
Any |
Parse body as JSON (cached) |
request.form() |
dict |
Parse application/x-www-form-urlencoded or multipart/form-data |
request.files() |
dict[str, UploadedFile] |
Uploaded files from multipart/form-data |
request.file(name) |
UploadedFile | None |
Single uploaded file by field name |
request.text() |
str |
Decode body as UTF-8 string |
request.get_header(name, default) |
str |
Case-insensitive header lookup |
request.cookies() |
dict[str,str] |
Parse Cookie header |
request.bearer_token |
str | None |
Extract Bearer token from Authorization header |
request.content_type |
str |
Content-Type header |
request.content_length |
int |
Content-Length as int |
request.is_json |
bool |
True if Content-Type is application/json |
request.is_form |
bool |
True if Content-Type is form/multipart |
request.state |
dict |
Per-request state dict for middleware communication |
Response Objects
from pyrith.response import Response, HTMLResponse, JSONResponse, RedirectResponse, StreamResponse
# Plain text
Response("Hello", status=200)
# HTML
HTMLResponse("<h1>OK</h1>")
# JSON — any dict or list is auto-serialised
JSONResponse({"id": 1, "name": "Alice"}, status=201)
# Redirect
RedirectResponse("/login", status=302)
# Streaming (generator-based)
def gen():
for chunk in ["one", "two", "three"]:
yield chunk.encode()
StreamResponse(gen(), content_type="text/plain")
# Cookies
response = JSONResponse({"ok": True})
response.set_cookie("token", "abc", max_age=3600, secure=True, http_only=True)
response.set_header("X-Custom", "value")
Handler return coercion: dict/list → JSONResponse; str starting with < → HTMLResponse; bytes → application/octet-stream; None → 204 No Content; any other str → text/plain.
# File response with ETag + Range support
from pyrith.response import FileResponse
resp = FileResponse("./uploads/report.pdf", as_attachment=True)
resp = resp.serve(request) # handles If-None-Match (304) and Range (206)
return resp
# Server-Sent Events
from pyrith.response import SSEResponse, sse_message
def event_stream():
for i in range(5):
yield sse_message({"count": i}, event="tick")
return SSEResponse(event_stream())
File Uploads
@app.post("/upload")
def upload(request):
# Automatically handles multipart/form-data
file = request.file("avatar")
if file is None:
return {"error": "No file"}, 400
file.save(f"./uploads/{file.filename}")
return {"uploaded": file.filename, "size": file.size}
# Multiple files
@app.post("/gallery")
def gallery(request):
files = request.files()
saved = []
for name, uf in files.items():
uf.save(f"./gallery/{uf.filename}")
saved.append(uf.filename)
return {"saved": saved}
UploadedFile attributes: filename, content_type, data (bytes), size. Method: save(path).
Server-Sent Events (SSE)
from pyrith.response import SSEResponse, sse_message
import time
@app.get("/events")
def events(request):
def stream():
for i in range(10):
yield sse_message({"value": i}, event="update", id=str(i))
time.sleep(0.5)
return SSEResponse(stream())
# Format: sse_message(data, event=None, id=None, retry=None)
# data can be a dict (auto-JSON), list, or string
Reverse URL Generation
@app.get("/users/{id}", name="get_user")
def get_user(request):
return f"User {request.params['id']}"
# Build URL by route name
url = app.url_for("get_user", id=42) # "/users/42"
url = app.url_for("get_user", id=42, tab="profile") # "/users/42?tab=profile"
UI / HTML DSL
from pyrith import ui
# Elements are Python calls
card = ui.div(
ui.h2("Product"),
ui.p("A great item."),
ui.a("Buy →", href="/buy"),
class_="card",
id="main-card",
)
print(str(card))
# <div class="card" id="main-card"><h2>Product</h2><p>A great item.</p><a href="/buy">Buy →</a></div>
Key conventions
| Python syntax | HTML output | Note |
|---|---|---|
class_="x" |
class="x" |
Trailing _ stripped |
for_="id" |
for="id" |
Same rule |
checked=True |
checked |
Boolean attribute |
disabled=False |
(omitted) | False/None omitted |
ui.raw("<b>x</b>") |
<b>x</b> |
Bypass HTML escaping |
All text content is HTML-escaped automatically. Use ui.raw() only for trusted HTML you construct yourself.
Available elements
div span p h1 h2 h3 h4 h5 h6 a img button input_ textarea form label select option ul ol li table thead tbody tr th td nav header footer main section article aside code pre blockquote strong em br hr script style link meta
Component System
from pyrith.ui import Component
from pyrith import ui
class Alert(Component):
def render(self):
kind = self.props.get("kind", "info")
colors = {"info": "#dbeafe", "warn": "#fef3c7", "error": "#fee2e2"}
bg = colors.get(kind, colors["info"])
return ui.div(
ui.strong(self.props.get("title", "")),
ui.p(self.props.get("message", "")),
class_="alert",
style=f"background:{bg};padding:1rem;border-radius:8px",
)
alert = Alert(kind="warn", title="Heads up", message="Something needs attention.")
html = str(alert)
Components are plain Python classes. self.props is the kwargs dict passed to the constructor. render() returns any Node (Element, Component, or string).
Page Builder
from pyrith.ui import Page
page = Page(title="My App")
page.head_add('<link rel="stylesheet" href="/static/app.css">')
page.head_add('<meta name="description" content="My Pyrith app">')
page.body_add(ui.nav(...))
page.body_add(ui.main(...))
page.script_add(src="/static/app.js")
page.script_add(inline="console.log('loaded')")
html = page.to_html()
Page builds a complete <!DOCTYPE html> document. Head extras and body nodes are independent lists so you can compose them from multiple places before calling to_html().
CSS Engine
from pyrith import style
css = style.CSS()
# Basic rule
css.rule("body", font_family="system-ui", margin="0", background="#f9fafb")
# Underscores in property names become hyphens: font_size → font-size
css.rule("h1", font_size="2rem", font_weight="700", color="#111827")
# Chained modifier
css.rule("a").set(color="#2563eb", text_decoration="none")
# Media query
mq = css.media("(max-width: 768px)")
mq.rule("h1", font_size="1.5rem")
# Keyframe animation
css.keyframes("fadeIn", {
"from": {"opacity": "0", "transform": "translateY(8px)"},
"to": {"opacity": "1", "transform": "translateY(0)"},
})
# Raw CSS block (for vendor prefixes, complex selectors, etc.)
css.raw(":root { --primary: #2563eb; }")
print(css.to_css())
Scoped component styles
from pyrith.style import StyleSheet
sheet = StyleSheet(scope="card")
sheet.scoped("", background="#fff", border_radius="12px", padding="1.5rem")
sheet.scoped(" h3", color="#1d4ed8", margin_top="0")
sheet.scoped(":hover", box_shadow="0 4px 16px rgba(0,0,0,0.12)")
# Emits: .card { … } .card h3 { … } .card:hover { … }
Full Tailwind Utility System
Pyrith ships a complete Tailwind CSS equivalent implemented in pure Python. Every utility class compiles to precise CSS declarations. No PostCSS, no Node.js, no build step — just Python.
from pyrith.style import TailwindSheet, tw
# Build a class string for use in HTML attributes
classes = tw("flex", "items-center", "gap-4", "p-6", "bg-gray-900", "text-white",
"rounded-xl", "shadow-lg", "hover:bg-gray-800", "md:flex-col",
"dark:bg-zinc-900", "transition-all", "duration-300")
# Generate the actual CSS for those classes
sheet = TailwindSheet()
sheet.add(*classes.split())
print(sheet.to_css())
Supported utility families
| Family | Examples |
|---|---|
| Spacing | p-4, m-auto, px-6, py-2, mt-8, gap-3, space-x-2 |
| Sizing | w-full, h-screen, min-w-0, max-w-7xl, w-1/2, size-10 |
| Typography | text-xs → text-9xl, font-bold, leading-6, tracking-wide, uppercase, truncate, line-clamp-3 |
| Colors | text-blue-500, bg-red-600, border-green-400, ring-purple-300 |
| Borders | border, border-2, border-t, rounded-full, rounded-t-xl, divide-y |
| Display & Layout | hidden, flex, grid, inline-flex, block, contents |
| Flexbox | flex-col, flex-wrap, items-center, justify-between, flex-1, flex-none, grow, shrink |
| Grid | grid-cols-3, col-span-2, col-start-1, row-span-2, auto-cols-fr |
| Position | relative, absolute, fixed, sticky, inset-0, top-4, z-50 |
| Overflow | overflow-hidden, overflow-x-auto, overflow-y-scroll |
| Effects | opacity-50, blur-md, brightness-110, contrast-125, saturate-150, grayscale, drop-shadow-lg |
| Shadows | shadow-sm, shadow-lg, shadow-xl, shadow-2xl, shadow-none |
| Transitions | transition-all, transition-colors, duration-300, ease-in-out, delay-150 |
| Transforms | scale-110, rotate-45, translate-x-4, translate-y-[-20px], skew-x-6 |
| Animations | animate-spin, animate-ping, animate-pulse, animate-bounce |
| Interactions | cursor-pointer, select-none, pointer-events-none, resize, appearance-none |
| Outline & Ring | outline-none, ring-2, ring-offset-2, ring-blue-500 |
| Aspect Ratio | aspect-square, aspect-video, aspect-[4/3] |
| Object Fit | object-cover, object-contain, object-fill |
Responsive prefixes
All utilities support responsive prefixes: sm:, md:, lg:, xl:, 2xl: — these emit @media (min-width: …) blocks.
sheet.add("w-full", "md:w-1/2", "lg:w-1/3")
# .w-full { width: 100%; }
# @media (min-width: 768px) { .md\:w-1\/2 { width: 50%; } }
# @media (min-width: 1024px) { .lg\:w-1\/3 { width: 33.333%; } }
Dark mode
Use the dark: prefix to emit rules inside @media (prefers-color-scheme: dark):
sheet.add("bg-white", "dark:bg-zinc-900", "text-gray-900", "dark:text-white")
Pseudo-class variants
hover:, focus:, active:, disabled:, checked:, first:, last:, odd:, even:, focus-within:, focus-visible:, group-hover:, placeholder:
sheet.add("hover:scale-105", "focus:ring-2", "disabled:opacity-50", "focus-within:ring-blue-500")
Arbitrary values
Any class with […] is treated as an arbitrary value and emitted verbatim:
sheet.add("w-[320px]", "text-[#ff0000]", "bg-[rgba(0,0,0,0.5)]",
"top-[calc(100%+8px)]", "grid-cols-[200px_1fr_auto]")
Design Tokens
A complete Tailwind design token system is available as Python dicts:
from pyrith.style.tokens import theme, resolve_color, COLORS, SPACING, FONT_SIZE, SHADOW, BORDER_RADIUS
# Dot-path access to any token
blue500 = theme("colors.blue.500") # → "#3b82f6"
spacing4 = theme("spacing.4") # → "1rem"
shadowLg = theme("shadow.lg") # → "0 10px 15px -3px rgba(0,0,0,0.1) ..."
# Resolve shorthand color names
hex_color = resolve_color("blue-500") # → "#3b82f6"
white = resolve_color("white") # → "#ffffff"
Color palette
All 20 Tailwind color families with 11 shades each (50 → 950): slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose, plus white and black.
from pyrith.style.tokens import COLORS
print(COLORS["blue"][500]) # "#3b82f6"
print(COLORS["emerald"][400]) # "#34d399"
Class Variance Authority (CVA)
The CVA pattern (popularized by shadcn/ui) lets you define component variants declaratively — the same pattern used by Radix/shadcn in the React ecosystem, now in Python:
from pyrith.style import cv
# Define a Button with variants
button = cv(
base="inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none",
variants={
"intent": {
"primary": "bg-blue-600 text-white hover:bg-blue-700",
"secondary": "bg-gray-100 text-gray-900 hover:bg-gray-200",
"danger": "bg-red-600 text-white hover:bg-red-700",
"ghost": "hover:bg-gray-100 hover:text-gray-900",
"link": "text-blue-600 underline-offset-4 hover:underline",
},
"size": {
"sm": "h-8 px-3 text-xs",
"md": "h-10 px-4 text-sm",
"lg": "h-12 px-6 text-base",
"icon": "h-10 w-10",
},
},
default_variants={"intent": "primary", "size": "md"},
compound_variants=[
{"intent": "primary", "size": "lg", "class_": "shadow-lg"},
],
)
# Use it
print(button()) # defaults: primary + md
print(button(intent="danger", size="lg")) # danger + lg + compound shadow-lg
print(button(intent="ghost", class_="mt-4")) # ghost + extra class
Animation Utilities
from pyrith.style import animate, AnimationSheet, KEYFRAMES
# Inline style string for a single element
style = animate("fadeIn", duration="0.4s", easing="ease-out", delay="0.1s")
# → "animation: fadeIn 0.4s ease-out 0.1s both;"
# Or emit @keyframes CSS for use in a stylesheet
sheet = AnimationSheet()
sheet.use("fadeIn", "slideInUp", "shake", "pulse")
css = sheet.to_css()
# → @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } ...
# .animate-fadeIn { animation: fadeIn 0.3s ease both; }
# ...
Available animations: fadeIn, fadeOut, slideInUp, slideInDown, slideInLeft, slideInRight, slideOutUp, slideOutDown, zoomIn, zoomOut, shake, pulse, bounce, spin, ping, float, wiggle, flash, heartbeat, ripple, gradientX, typing, blinkCaret
Theme System
Define a complete design system as Python CSS custom properties:
from pyrith.style import Theme, ThemeProvider, LightTheme, DarkTheme, PurpleTheme
# Custom theme
theme = Theme(
primary="#2563eb",
primary_hover="#1d4ed8",
secondary="#64748b",
background="#ffffff",
surface="#f8fafc",
border="#e2e8f0",
foreground="#0f172a",
muted_foreground="#64748b",
font_sans="Inter, system-ui, sans-serif",
font_mono="JetBrains Mono, monospace",
radius="0.5rem",
)
# Emit as CSS variables
print(theme.to_css_vars())
# :root { --color-primary: #2563eb; --color-background: #ffffff; ... }
# Use CSS variable references in your CSS
print(theme.var("primary")) # → "var(--color-primary)"
# ThemeProvider: auto-switches between light and dark
provider = ThemeProvider(theme=LightTheme, dark_theme=DarkTheme)
html = provider.to_style_tag()
# <style>:root { … } @media (prefers-color-scheme: dark) { :root { … } }</style>
# Embed in a page
page = Page(title="Themed App")
page.head_add(provider.to_style_tag())
Built-in presets: LightTheme, DarkTheme, PurpleTheme
Icon Library
50+ inline SVG icons from the Heroicons set, rendered as Python strings — no external font, no CDN, no base64 blobs:
from pyrith.ui.icons import Icon, icon, list_icons
# Render as an HTML string
svg = Icon("check", size=20, color="#22c55e")
svg = Icon("arrow-right", size=16, stroke_width=2.5)
svg = Icon("star", size=24, filled=True)
# Use inside the HTML DSL
from pyrith import ui
el = ui.button(icon("arrow-right"), "Next step", class_="btn")
# Discover all icon names
names = list_icons() # ['arrow-down', 'arrow-left', 'arrow-right', ...]
Available icons: check, x, arrow-up/down/left/right, chevron-up/down/left/right, menu, search, user, users, home, settings, bell, heart, star, plus, minus, edit, trash, eye, eye-off, download, upload, external-link, link, copy, info, warning, error, success, spinner, loading, calendar, clock, mail, phone, lock, unlock, folder, document, image, chart-bar, shield, code, terminal, cpu, globe, logout, filter, sort, drag, more-horizontal, more-vertical
Reactive State System
Client-side reactivity patterns, implemented server-side in Python. Use Signal/Computed/Effect to build reactive data flows, plus ReactiveComponent for WebSocket-based live DOM patching:
from pyrith.ui.state import Signal, Computed, Effect, useState, effect
# Signal — a reactive value
count = Signal(0)
count.set(1)
count.update(lambda x: x + 1)
# Subscribe to changes
unsubscribe = count.subscribe(lambda val: print(f"count is now {val}"))
# Computed — derives from signals automatically
first = Signal("Alice")
last = Signal("Smith")
full_name = Computed(lambda: f"{first.get()} {last.get()}")
print(full_name.get()) # "Alice Smith"
first.set("Bob")
print(full_name.get()) # "Bob Smith" ← re-computed automatically
# Effect — runs a side effect when dependencies change
effect(lambda: print(f"Name changed to {full_name.get()}"))
# useState convenience tuple
value, set_value = useState("initial")
set_value("updated")
print(value.get()) # "updated"
ReactiveComponent — live DOM patching over WebSocket
from pyrith.ui.state import Signal, ReactiveComponent
class Counter(ReactiveComponent):
def __init__(self):
super().__init__()
self.count = Signal(0)
self.track(self.count) # re-render + push patch when count changes
def render(self) -> str:
return f'<div id="counter"><h1>{self.count.get()}</h1></div>'
counter = Counter()
@app.websocket("/live")
def live(ws):
counter.add_client(ws)
while not ws.closed:
msg = ws.recv()
if msg == "increment":
counter.count.update(lambda x: x + 1)
# All subscribed clients receive a JSON patch and update the DOM
Client-side script (ReactiveComponent.PATCH_SCRIPT) is a tiny <script> block that opens the WebSocket and applies JSON patches using element.outerHTML = patch.
Pre-Built Component Library
A complete library of 30+ production-quality components in pyrith.ui.components:
from pyrith.ui import components as c
Layout
c.layout.Stack("<p>A</p>", "<p>B</p>", direction="col", gap="1rem", align="center")
c.layout.Grid("<div>1</div>", "<div>2</div>", "<div>3</div>", cols=3, gap="1.5rem")
c.layout.Container("<p>Content</p>", max_width="800px", center=True)
c.layout.Divider(color="#e5e7eb", thickness="1px", label="OR")
Navigation
c.navigation.NavBar(
brand="MyApp",
links=[{"label": "Home", "href": "/"}, {"label": "Docs", "href": "/docs"}],
actions='<button class="btn">Sign In</button>',
)
c.navigation.Breadcrumb(items=[
{"label": "Home", "href": "/"},
{"label": "Blog", "href": "/blog"},
{"label": "My Post"}, # no href = current page
])
c.navigation.Tabs(
tabs=[{"label": "Overview", "value": "overview"}, {"label": "API", "value": "api"}],
active="overview",
variant="underline", # "underline" | "pill" | "boxed"
)
c.navigation.Pagination(total=100, page=5, per_page=10)
Display
c.display.Badge("Active", color="green") # pill badge
c.display.Badge("Beta", color="yellow")
c.display.Avatar(src="/img.jpg", name="Alice Smith", size=48)
c.display.Avatar(name="Alice Smith") # auto-initials fallback
c.display.ProgressBar(value=72, label="Progress", show_value=True, color="blue")
c.display.Spinner(size=24, color="#3b82f6")
c.display.Skeleton(lines=3, animate=True) # loading placeholder
c.display.Stat(label="Revenue", value="$42K", delta="12%", delta_type="increase")
c.display.Table(
columns=[{"key": "name", "label": "Name"}, {"key": "score", "label": "Score"}],
rows=[{"name": "Alice", "score": "98"}, {"name": "Bob", "score": "87"}],
striped=True,
sortable=True,
)
c.display.Timeline(items=[
{"title": "Signed up", "description": "Welcome!", "date": "Jan 1"},
{"title": "First purchase", "date": "Jan 15"},
])
Feedback
c.feedback.Alert("Your session will expire in 5 minutes.", kind="warning", title="Warning", dismissible=True)
c.feedback.Alert("Changes saved.", kind="success")
c.feedback.Toast("Copied to clipboard!", kind="success", duration=3000)
# CSS :target modal — no JavaScript required
c.feedback.Modal(
title="Delete item",
body="Are you sure you want to delete this? This cannot be undone.",
modal_id="delete-modal",
confirm_label="Delete",
confirm_href="#",
)
c.feedback.Tooltip(text="Hover me", content="Explanation text", placement="top")
c.feedback.Popover(trigger="Click me", content="<p>Popover body</p>")
Forms
c.forms.Input(name="email", type="email", label="Email", placeholder="you@example.com", required=True)
c.forms.Input(name="card", error="Invalid card number")
c.forms.Select(name="role", options=["user", "admin", "moderator"], label="Role")
c.forms.Checkbox(name="agree", label="I agree to the terms", checked=False)
c.forms.Radio(name="plan", options=["free", "pro", "enterprise"], value="pro")
c.forms.Switch(name="notifications", label="Enable email notifications", checked=True)
c.forms.FileInput(name="avatar", label="Profile photo", accept="image/*")
c.forms.DatePicker(name="dob", label="Date of birth", value="2000-01-01")
c.forms.RangeSlider(name="volume", min=0, max=100, value=70, label="Volume")
c.forms.SearchInput(name="q", placeholder="Search…", suggestions=["Python", "Pyrith", "PyPI"])
Cards
c.cards.Card(
header="<h3>Title</h3>",
body="<p>Main content goes here.</p>",
footer='<a href="#">Read more →</a>',
variant="elevated", # "solid" | "glass" | "outline" | "elevated"
)
c.cards.HeroSection(
title="Build beautiful web apps in pure Python",
subtitle="No JavaScript. No Node. No build step.",
cta_label="Get Started", cta_href="/docs",
secondary_label="View on GitHub", secondary_href="https://github.com/...",
)
c.cards.FeatureCard(title="Zero Dependencies", description="Ships with nothing but Python.", icon="⚡", color="blue")
c.cards.PricingCard(
name="Pro",
price="$29",
period="/mo",
features=["Unlimited projects", "Priority support", "Custom domain"],
cta_label="Get Pro",
highlighted=True, # → "Most Popular" badge + accent border
)
c.cards.ProfileCard(name="Alice Smith", role="Senior Engineer", bio="Loves Python and coffee.", avatar_src="/img.jpg")
c.cards.EmptyState(
title="No results found",
description="Try adjusting your filters.",
icon="🔍",
action_label="Clear filters", action_href="#",
)
Interactive
c.interactive.Accordion(items=[
{"title": "What is Pyrith?", "content": "A pure-Python web framework.", "open": True},
{"title": "Does it need Node?", "content": "No. Zero dependencies."},
])
c.interactive.Dropdown(
label="Actions",
items=[
{"label": "Edit", "href": "/edit"},
{"label": "Delete", "href": "/delete", "danger": True},
],
)
c.interactive.Collapsible(title="Advanced settings", content="<p>Hidden by default.</p>")
Layout Engine
from pyrith.ui.layout import MasonryGrid, SplitPane, StickyHeader, AspectRatio, ScrollArea, ResponsiveLayout
# Pinterest-style masonry
MasonryGrid("<div>Card 1</div>", "<div>Card 2</div>", "<div>Card 3</div>", cols=3, gap="1rem")
# Two-column split
SplitPane(left="<nav>…</nav>", right="<main>…</main>", ratio="240px 1fr", gap="2rem")
# Sticky header with content below
StickyHeader(content="<header>…</header>", offset="0", z_index=100)
# Aspect-ratio box (great for video embeds, images)
AspectRatio("<iframe …></iframe>", ratio="16/9")
# Scrollable area with constrained height
ScrollArea("<p>Lots of content…</p>", max_height="400px")
# Responsive layout: different content per breakpoint
ResponsiveLayout(
mobile="<div class='mobile-nav'>…</div>",
tablet="<div class='tablet-nav'>…</div>",
desktop="<div class='desktop-nav'>…</div>",
)
Page Templates
Five production-ready full-page HTML document builders:
from pyrith.ui.templates import DashboardLayout, AuthLayout, DocsLayout, LandingLayout, BlogLayout
# Admin dashboard (sidebar + header + main)
html = DashboardLayout(
sidebar="<nav>…sidebar navigation…</nav>",
header="<div>…top bar…</div>",
content="<div>…page content…</div>",
title="Admin — MyApp",
)
# Auth page (centered card, gradient background)
html = AuthLayout(
form="<form>…login form…</form>",
brand="MyApp",
title="Sign In",
)
# Three-column docs (sidebar | content | on-this-page TOC)
html = DocsLayout(
sidebar_nav="<nav>…doc links…</nav>",
content="<article>…documentation…</article>",
on_this_page="<ul>…table of contents…</ul>",
title="Getting Started — MyApp Docs",
)
# Marketing landing page
html = LandingLayout(
sections=["<section>Hero</section>", "<section>Features</section>"],
nav="<nav>…brand + links…</nav>",
footer="<footer>…</footer>",
title="MyApp — Build Fast",
)
# Blog index
html = BlogLayout(
posts=[
{"title": "Hello World", "excerpt": "First post.", "href": "/blog/hello",
"date": "2026-11-07", "author": "Alice", "tags": ["Python"]},
],
title="My Blog",
)
All templates return a complete <!DOCTYPE html> document with responsive CSS built in. They automatically collapse to single-column on mobile.
ORM
from pyrith.orm import Model, IntField, TextField, BoolField, DateTimeField
class Post(Model):
class Meta:
db = "blog.db" # SQLite file (or ":memory:")
table_name = "posts"
id = IntField(primary_key=True)
title = TextField(nullable=False, unique=True)
body = TextField()
published = BoolField(default=False)
created_at = DateTimeField()
Post.create_table()
# Create
post = Post(title="Hello World", body="First post.", published=True)
post.save()
print(post.id) # auto-assigned PK
# Read
p = Post.get(title="Hello World")
# QuerySet
drafts = Post.objects.filter(published=False).order_by("-created_at").limit(10).all()
count = Post.objects.count()
# Update
p.title = "Updated Title"
p.save()
# Delete
p.delete()
# Bulk delete
Post.objects.filter(published=False).delete()
Field types
| Field | SQLite type | Python type |
|---|---|---|
IntField |
INTEGER |
int |
FloatField |
REAL |
float |
TextField |
TEXT |
str |
BoolField |
INTEGER |
bool |
DateTimeField |
TEXT |
datetime.datetime |
ForeignKey |
INTEGER |
int (raw FK value) |
All connections are persistent per-process (connection pool keyed by db path). WAL mode and foreign keys are enabled by default. create_table(if_not_exists=True) is idempotent.
Advanced ORM — filter lookups, bulk ops, transactions
# Django-style filter lookups
Post.objects.filter(score__lt=10).all()
Post.objects.filter(score__gte=100).all()
Post.objects.filter(title__contains="Python").all()
Post.objects.filter(title__startswith="Hello").all()
Post.objects.filter(id__in=[1, 2, 3]).all()
Post.objects.filter(deleted_at__isnull=True).all()
# Exclude
Post.objects.exclude(published=False).all()
# Bulk queryset update
Post.objects.filter(published=False).update(archived=True)
# exists()
Post.objects.filter(slug="my-post").exists()
# Class-method shortcuts
post = Post.create(title="Hello", body="...", published=True)
# Bulk insert (single transaction)
posts = [Post(title=f"Post {i}") for i in range(100)]
Post.bulk_create(posts)
# Explicit transaction
from pyrith.orm import transaction
with transaction(Post):
post1.save()
post2.save()
# Both commit atomically, or both roll back on exception
# Runtime migration (add column to existing table)
Post.add_column("view_count", IntField(default=0))
Template Engine
from pyrith.template import TemplateLoader
loader = TemplateLoader("templates/")
# templates/base.html
# <!DOCTYPE html><html><head><title>{% block title %}App{% endblock %}</title></head>
# <body>{% block content %}{% endblock %}</body></html>
# templates/page.html
# {% extends "base.html" %}
# {% block title %}{{ page_title }}{% endblock %}
# {% block content %}
# <ul>{% for item in items %}<li>{{ item }}</li>{% empty %}<li>None</li>{% endfor %}</ul>
# {% endblock %}
html = loader.render("page.html", page_title="My List", items=["one", "two", "three"])
Syntax:
{{ expr }}— HTML-escaped expression output{{ expr | raw }}— unescaped output{% if expr %}…{% elif expr %}…{% else %}…{% endif %}{% for x in iterable %}…{% empty %}…{% endfor %}{% extends "base.html" %}with{% block name %}…{% endblock %}{% include "partial.html" %}{# comment #}— stripped at render time
Session Authentication
from pyrith.auth import SessionStore, login_required
store = SessionStore(secret="change-this-in-production", max_age=86400)
app.use(store.middleware()) # attaches request.session to every request
@app.post("/login")
def login(request):
data = request.form()
if authenticate(data["username"], data["password"]):
request.session.set("user_id", user.id)
request.session.set("role", "admin")
return RedirectResponse("/dashboard")
return HTMLResponse("Invalid credentials", status=401)
@app.get("/logout")
def logout(request):
request.session.clear()
return RedirectResponse("/")
Sessions are stored in memory (dict) and identified by a random 256-bit session ID. The session ID is HMAC-SHA256 signed before being stored in the browser cookie, preventing forgery. The max_age evicts idle sessions server-side.
Password Hashing
from pyrith.auth import hash_password, check_password
# Hash (PBKDF2-SHA256, 260,000 iterations, random salt — OWASP compliant)
hashed = hash_password("user_plaintext_password")
# → "pbkdf2:sha256:260000$<salt>$<hash>"
# Verify (constant-time comparison)
if check_password(submitted_password, stored_hash):
... # valid
Bearer Token Auth
from pyrith.auth import TokenStore, token_required
tokens = TokenStore(secret="change-me", ttl=3600)
@app.post("/auth/token")
def get_token(request):
data = request.json()
# validate credentials ...
token = tokens.create({"user_id": user.id, "role": user.role})
return {"access_token": token, "token_type": "bearer"}
@app.get("/protected")
@token_required(tokens)
def protected(request):
user_id = request.token_payload["user_id"]
return {"user_id": user_id}
# Refresh a near-expiry token
new_token = tokens.refresh(old_token)
Token format: <header_b64>.<payload_b64>.<hmac_sig_b64> — all HMAC-SHA256 signed, no external library.
CSRF Protection
from pyrith.auth import CSRFMiddleware
app.use(CSRFMiddleware(secret="change-me"))
# Every unsafe request (POST/PUT/DELETE) must include the CSRF token.
# In HTML forms:
# <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}">
# In AJAX: send header X-CSRF-Token: <token>
# (token is in the pyrith_csrf cookie, readable by JS since http_only=False)
Route Guards
from pyrith.auth import login_required, role_required
@app.get("/dashboard")
@login_required(redirect_to="/login")
def dashboard(request):
return "you are logged in"
@app.get("/admin")
@login_required()
@role_required("admin", redirect_to="/forbidden")
def admin(request):
return "admin panel"
login_required checks request.session.get("user_id") is not None. role_required checks request.session.get("role") == role. Both redirect on failure.
Forms & Validation
from pyrith.forms import (
Form, StringField, IntegerField, EmailField, BooleanField,
PasswordField, SelectField, TextareaField, FileField,
)
class RegisterForm(Form):
username = StringField(required=True, min_length=3, max_length=30)
email = EmailField(required=True)
password = PasswordField(required=True, min_length=8)
role = SelectField(choices=["user", "moderator", "admin"], required=True)
bio = TextareaField(min_length=0, max_length=500)
avatar = FileField(allowed_extensions=["jpg", "png", "gif"], max_size=2*1024*1024)
agree = BooleanField(required=True, label="I agree to the terms")
@app.post("/register")
def register(request):
form = RegisterForm(request.form())
# Inject uploaded files into form for FileField validation
for name, uf in request.files().items():
form._data[name] = uf
if form.is_valid():
data = form.cleaned_data
return RedirectResponse("/welcome")
return HTMLResponse(form.render(action="/register", method="post"))
| Field | Description |
|---|---|
StringField |
Text with optional min/max length |
IntegerField |
Integer with optional min/max value |
EmailField |
Email format validation |
PasswordField |
Like StringField, renders as type=password |
SelectField(choices) |
Single-select dropdown; validates against choices |
TextareaField |
Multi-line text with min/max length |
FileField |
File upload; validates extension and size |
BooleanField |
Checkbox |
form.errors → dict[str, list[str]]. form.cleaned_data → coerced Python values. form.render() produces a complete <form> with labels, inputs, and inline error messages.
WebSocket
from pyrith.websocket import WebSocket, rooms
@app.websocket("/ws/chat/{room_id}")
def chat(ws: WebSocket):
room_id = ws._rooms # set before handler is called — or extract from path
# Join a named room
room = rooms.get("global")
room.join(ws)
# Notify others
room.broadcast(f"A new user joined. ({room.size} online)", exclude=ws)
while not ws.closed:
msg = ws.recv()
if msg is None:
break
# Echo to sender
ws.send(f"you: {msg}")
# Broadcast to all others in the room
room.broadcast(f"user: {msg}", exclude=ws)
room.leave(ws)
room.broadcast(f"A user left. ({room.size} online)")
| Feature | Detail |
|---|---|
| RFC 6455 handshake | Pure Python, stdlib socket only |
| Ping/pong keepalive | Automatic pong on incoming ping; ws.ping() to send |
| Thread-safe send | ws.send() uses an internal lock |
| Rooms/broadcast | rooms.get(name) → Room; room.broadcast(data, exclude=ws) |
| Connection cleanup | Connections auto-removed from rooms on close |
ws.send(str) → text frame; ws.send(bytes) → binary frame; ws.recv() → str | bytes | None; ws.ping() → sends ping; ws.close(code, reason) → graceful close.
Testing
from pyrith import Pyrith, TestClient
app = Pyrith(__name__)
@app.get("/items")
def list_items(request):
return [{"id": 1}, {"id": 2}]
@app.post("/items")
def create_item(request):
data = request.json()
return {"id": 3, **data}
# In tests — no server started, no ports, no sockets
client = TestClient(app)
def test_list_items():
resp = client.get("/items")
assert resp.status_code == 200
assert len(resp.json()) == 2
def test_create_item():
resp = client.post("/items", json={"name": "Widget"})
assert resp.status_code == 200
assert resp.json()["name"] == "Widget"
def test_404():
resp = client.get("/nonexistent")
assert resp.status_code == 404
def test_with_query_params():
resp = client.get("/items", params={"page": "2", "limit": "10"})
...
def test_with_form_data():
resp = client.post("/login", data={"username": "alice", "password": "secret"})
...
def test_session_persists():
client2 = TestClient(app)
client2.post("/login", data={"username": "alice", "password": "secret"})
resp = client2.get("/dashboard")
assert resp.status_code == 200 # session cookie carried automatically
TestClient persists cookies across requests (session, CSRF, etc.), making full end-to-end auth flows testable without a real server.
CLI
pyrith new <project> Scaffold a new project directory with app.py + requirements.txt
pyrith run [file] Import and run an app file (default: app.py); calls app.run(debug=True)
pyrith version Print the installed version
pyrith new blog
cd blog
python app.py
Error Handling
@app.error_handler(404)
def not_found(request):
return HTMLResponse("<h1>404 — Page not found</h1>", status=404)
@app.error_handler(500)
def server_error(request):
return HTMLResponse("<h1>500 — Something went wrong</h1>", status=500)
If no handler is registered, the default sends a plain-text response with the status message. In debug=True mode, unhandled exceptions render a styled HTML error page with traceback instead of a bare 500.
Running Tests
pytest tests/ -v
160 tests covering routing, middleware, request/response, multipart file uploads, auth (session, password, tokens, CSRF), ORM (filter lookups, bulk ops, transactions), forms, UI rendering, CSS generation, SSE, TestClient, URL generation, full Tailwind utility system, design tokens, CVA variants, animations, theme system, icons, reactive state, all 30+ UI components, layout engine, and page templates.
PyPI Publishing
The repo ships a GitHub Actions workflow at .github/workflows/publish.yml that uses OIDC trusted publishing (no token stored in secrets):
- Go to pypi.org → your project → Publishing → Add a trusted publisher.
- Fill in: Owner = your GitHub username, Repo =
Pyrith, Workflow =publish.yml, Environment =pypi. - On GitHub, create an environment named
pypi(Settings → Environments). - Push your code, then create a GitHub Release — the workflow triggers automatically and publishes the package.
Roadmap
Phase 1 — Core (complete · v0.1.0)
- HTTP router with path parameters, route groups, reverse URL generation
- Proper 404/405 handling with Allow header
- 16 MB body size limit; pretty debug error pages in debug mode
- Auto-reload dev server (
run(reload=True)) - Middleware pipeline (CORS, Logging, Static Files, GZip)
- SecurityHeadersMiddleware (CSP, HSTS, X-Frame-Options, etc.)
- RateLimitMiddleware (sliding-window per IP, 429 + Retry-After)
- RequestIDMiddleware (UUID4 on every request/response)
- CacheMiddleware (in-memory GET cache with LRU eviction + X-Cache header)
- HTML element DSL + Component system + Page builder
- Programmatic CSS engine + scoped stylesheets + unit helpers
- ActiveRecord ORM (SQLite backend, QuerySet API)
- ORM: Django-style filter lookups (
__lt,__gte,__in,__contains,__isnull, etc.) - ORM:
exclude(),update(),exists(),last(),values() - ORM:
bulk_create(),bulk_update(),create(),add_column() - ORM: Explicit transactions, WAL mode, foreign keys enabled
- Template engine (extends, blocks, for, if, include)
- Session store (HMAC-signed cookies)
- Password hashing (PBKDF2-SHA256, 260k iterations, OWASP-compliant)
- Bearer token auth (HMAC-signed, JWT-compatible, refresh support)
- CSRF protection (double-submit cookie pattern)
- Route guards (login_required, role_required, token_required)
- Form validation — 8 field types including PasswordField, SelectField, TextareaField, FileField
- Multipart/form-data parsing + file uploads (UploadedFile with save())
- FileResponse with ETag, If-None-Match (304), Range requests (206)
- Server-Sent Events (SSEResponse + sse_message helper)
- WebSocket (RFC 6455, ping/pong keepalive, rooms, broadcast, thread-safe send)
- TestClient (no network, cookie persistence, full request cycle)
- CLI (new, run, version)
Phase 2 — Frontend (complete · v0.2.0)
- Full Tailwind utility class system (all families: spacing, sizing, typography, colors, borders, shadows, effects, layout, flex, grid, position, overflow, transforms, transitions, animations, interactions, ring, outline, aspect-ratio, object-fit)
- Responsive prefixes:
sm:,md:,lg:,xl:,2xl:→@media (min-width: …) - Dark mode:
dark:prefix →@media (prefers-color-scheme: dark) - Pseudo-class variants:
hover:,focus:,active:,disabled:,checked:,first:,last:,group-hover:,focus-within:,focus-visible:,placeholder: - Arbitrary value support:
w-[320px],text-[#ff0000],bg-[rgba(0,0,0,0.5)] - Complete Tailwind design token system (20 color families × 11 shades, spacing, font sizes, shadows, border radius, breakpoints, z-index)
- Class Variance Authority (CVA) — component variant system matching React/shadcn patterns
- 23 keyframe animations + AnimationSheet +
animate()helper - Theme system with CSS custom properties, dark mode media query, built-in presets
- 50+ inline SVG icons (Heroicons paths), no external font or CDN
- Reactive state: Signal, Computed, Effect, useState (observer pattern, thread-safe)
- ReactiveComponent — WebSocket live DOM patching via JSON patches
- 30+ pre-built UI components: Stack, Grid, Container, NavBar, Sidebar, Breadcrumb, Tabs, Pagination, Table, Badge, Avatar, ProgressBar, Spinner, Skeleton, Stat, Timeline, Alert, Toast, Modal, Tooltip, Popover, Input, Select, Checkbox, Switch, RangeSlider, SearchInput, Card, HeroSection, PricingCard, ProfileCard, EmptyState, Accordion, Dropdown, Collapsible
- Layout engine: MasonryGrid, SplitPane, StickyHeader, AspectRatio, ScrollArea, ResponsiveLayout
- 5 full-page HTML templates: DashboardLayout, AuthLayout, DocsLayout, LandingLayout, BlogLayout
- 160 passing tests
Phase 3 — Ecosystem (planned)
- PostgreSQL ORM backend (via
socket+ PostgreSQL wire protocol) - Background task queue (threading-based)
- Declarative API schema + automatic OpenAPI generation
- Multi-process worker pool
- Plugin / extension system
- CLI:
pyrith generate component <name>scaffold
License
Apache 2.0 — see LICENSE.
Author
Saad Salman · github.com/saadsalmanakram
Metadata
Release files for pyrith 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pyrith-0.4.0.tar.gz | 279.2 kB | With provenance | Details for pyrith-0.4.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pyrith-0.4.0-py3-none-any.whl | Python 3 | none | any | Details for pyrith-0.4.0-py3-none-any.whl |
Total release size: 560.6 kB
Release files / pyrith-0.4.0.tar.gz
| Download URL | pyrith-0.4.0.tar.gz |
|---|---|
| Size | 279.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
00971061ea6718181cf693dc5f8fab6e9b904da4772212a30766f99a6670124a
|
|
BLAKE2b-256 checksum How to use checksums |
b23cf29e278ac7e556cdb8a465c26b4990dd4963cf6c053f2c7c21a0fa226036
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.
Transparency logRelease files / pyrith-0.4.0-py3-none-any.whl
| Download URL | pyrith-0.4.0-py3-none-any.whl |
|---|---|
| Size | 281.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7aaf74224e7fe149418181f446f84f7f694fce35d7bb895a8b11c94051b3cf52
|
|
BLAKE2b-256 checksum How to use checksums |
c5eea1675acb462d6b74830d0538a1c6710346c6ee9bf7f35675e90a03191a55
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.
Transparency logSecurity for pyrith 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for pyrith-0.4.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 7, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.