Pyrith
The universal full-stack Python framework. Django + FastAPI + Next.js + Rails — unified in one import with zero required dependencies. 38+ modules covering HTTP routing, Virtual DOM, SSG, Tailwind CSS, ORM, OAuth2, MFA, background tasks, caching, vector search, AI/LLM streaming, payments, real-time WebSocket, admin panel, observability, multi-tenancy, and more.
Overview
Pyrith is a monolithic, self-contained web framework for Python 3.9+. The design philosophy is total Python — you never leave the Python ecosystem. No HTML template language, no CSS preprocessor, no JavaScript build step, no configuration file format. Every layer — HTTP server, HTML rendering, CSS generation, database access, session management, form validation — is implemented from scratch using the Python standard library only.
The frontend system rivals React + Tailwind apps in capability: a full Tailwind-equivalent utility system, design tokens, CVA-style variant components, 30+ pre-built UI components, reactive state with live DOM patching over WebSocket, and five production-ready page templates — all without touching JavaScript.
Pyrith uses only Python stdlib — http.server, sqlite3, socket, hashlib, hmac, urllib.parse, json, threading, re. Zero pip install required beyond Pyrith itself.
Installation
pip install pyrith
# with dev extras
pip install "pyrith[dev]"Requires Python ≥ 3.9. No external runtime dependencies.
Quick Start
A minimal but complete application — routing, inline CSS, HTML DSL, and the dev server in one file:
from pyrith import Pyrith
from pyrith import ui, style
from pyrith.response import HTMLResponse, JSONResponse
app = Pyrith(__name__)
@app.get('/')
def index(request):
css = style.CSS()
css.rule('body', font_family='system-ui', background='#0f172a',
color='#f1f5f9', min_height='100vh', display='flex',
align_items='center', justify_content='center')
css.rule('h1', font_size='3rem', font_weight='800', color='#38bdf8')
css.rule('p', color='#94a3b8', margin_top='0.5rem')
page = ui.Page(title='My Pyrith App')
page.head_add(f'<style>{css}</style>')
page.body_add(
ui.div(
ui.h1('Hello from Pyrith'),
ui.p('Pure Python. Zero dependencies. Full stack.'),
ui.a('Read the docs →', href='/docs', style='color:#38bdf8'),
)
)
return HTMLResponse(page.to_html())
@app.get('/api/health')
def health(request):
return JSONResponse({'status': 'ok', 'version': '0.4.0'})
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 scaffold a new project with the CLI:
pyrith new myapp
cd myapp
python app.pyArchitecture
Every module is a Python file. No magic metaclasses, no hidden registry, no import-time side effects beyond registering routes.
pyrith/
├── app.py # Pyrith class, ThreadingHTTPServer, middleware pipeline
├── request.py # Request — method, path, headers, query, body, params, cookies, files
├── response.py # Response, HTMLResponse, JSONResponse, RedirectResponse,
│ # StreamResponse, FileResponse, SSEResponse
├── router.py # Router, Route (regex param extraction), RouteGroup
├── middleware.py # CORS, Logging, Static, GZip, Security, RateLimit, Cache, RequestID
├── websocket.py # RFC 6455 WebSocket + rooms/broadcast + ping/pong
├── template.py # Template engine ({{ }}, {% if %}, {% for %}, extends, include)
├── testing.py # TestClient — no network, cookie persistence
├── ui/
│ ├── html.py # Element DSL — div(), span(), h1(), input_(), raw(), …
│ ├── component.py # Component base class, render(), props
│ ├── page.py # Page layout builder (head, body, scripts)
│ ├── state.py # Signal, Computed, Effect, useState, ReactiveComponent
│ ├── icons.py # 50+ inline SVG icons, Icon(), icon(), list_icons()
│ ├── layout.py # MasonryGrid, SplitPane, StickyHeader, AspectRatio, ScrollArea
│ ├── components/ # 30+ pre-built components
│ │ ├── layout.py # Stack, Grid, Container, Spacer, Divider
│ │ ├── navigation.py # NavBar, Sidebar, Breadcrumb, Tabs, Pagination
│ │ ├── display.py # Table, Badge, Avatar, Stat, Timeline, Spinner, Skeleton
│ │ ├── feedback.py # Alert, Toast, Modal, Tooltip, Popover
│ │ ├── forms.py # Input, Textarea, Select, Checkbox, Switch, FileInput, …
│ │ ├── cards.py # Card, HeroSection, PricingCard, ProfileCard, EmptyState
│ │ └── interactive.py # Accordion, Dropdown, Collapsible
│ └── 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 # theme(), resolve_color(), full Tailwind palette as Python dicts
│ ├── variants.py # cv() — Class Variance Authority pattern
│ ├── animations.py # 23 keyframe animations, AnimationSheet, animate()
│ ├── theme.py # Theme, ThemeProvider, LightTheme, DarkTheme, PurpleTheme
│ └── units.py # px(), rem(), em(), pct(), vw(), vh(), fr()
├── orm/
│ ├── model.py # Model metaclass, save(), delete(), get(), create_table(), bulk_create()
│ ├── fields.py # IntField, TextField, BoolField, DateTimeField, FloatField, ForeignKey
│ └── query.py # QuerySet — filter(), exclude(), order_by(), limit(), count(), update()
├── auth/
│ ├── session.py # SessionStore (HMAC-signed cookies), hash_password, check_password
│ ├── tokens.py # TokenStore, token_required, CSRFMiddleware
│ └── decorators.py # login_required(), role_required(), token_required()
├── forms/
│ └── form.py # Form + 8 field types
└── cli/
└── commands.py # pyrith new / run / versionRouting
Routes are registered with decorator methods on the app instance. Path parameters use {name} syntax and always arrive as strings. Routes are matched in declaration order.
from pyrith import Pyrith
from pyrith.response import JSONResponse, RedirectResponse
app = Pyrith(__name__)
# ── Basic verbs
@app.get('/')
def home(request):
return '<h1>Home</h1>'
@app.post('/articles')
def create_article(request):
data = request.json()
# save to database ...
return JSONResponse({'created': True}, status=201)
@app.put('/articles/{article_id}')
def update_article(request):
return JSONResponse({'id': request.params['article_id']})
@app.delete('/articles/{article_id}')
def delete_article(request):
return JSONResponse({'deleted': request.params['article_id']})
# ── Multiple methods on one route
@app.route('/profile', methods=['GET', 'POST'])
def profile(request):
if request.method == 'POST':
# handle form submission
return RedirectResponse('/profile')
return '<h1>Profile</h1>'
# ── Route groups (prefix namespacing)
api = app.group('/api/v1')
@api.get('/users')
def list_users(request):
page = int(request.query.get('page', '1'))
return JSONResponse({'page': page, 'users': []})
@api.get('/users/{user_id}', name='get_user') # named route
def get_user(request):
return JSONResponse({'id': request.params['user_id']})
# ── Reverse URL generation (use name= to name the route)
url = app.url_for('get_user', user_id=42) # "/api/v1/users/42"
url = app.url_for('get_user', user_id=42, tab='info') # "/api/v1/users/42?tab=info"
app.run(port=8080)Path parameter segments {name} compile to regex ([^/]+) — they match a single URL segment. Query parameters are always in request.query.
Middleware
Middleware is added with app.use() and applied in declaration order. Each middleware can short-circuit the pipeline by returning a Response from before_request.
from pyrith.middleware import (
LoggingMiddleware,
RequestIDMiddleware,
CORSMiddleware,
SecurityHeadersMiddleware,
RateLimitMiddleware,
CacheMiddleware,
StaticFilesMiddleware,
GZipMiddleware,
)
app.use(LoggingMiddleware()) # logs method, path, status, elapsed ms
app.use(RequestIDMiddleware()) # attaches X-Request-ID UUID to req + resp
app.use(CORSMiddleware(
allow_origins=['https://app.example.com'],
allow_methods=['GET', 'POST', 'PUT', 'DELETE'],
allow_credentials=True,
))
app.use(SecurityHeadersMiddleware(
csp="default-src 'self'; script-src 'none'",
hsts=True,
frame_options='DENY',
))
app.use(RateLimitMiddleware(limit=100, window=60)) # 100 req/min per IP; 429 + Retry-After
app.use(CacheMiddleware(ttl=30, max_size=500)) # LRU cache for GET responses
app.use(StaticFilesMiddleware('./public', '/static'))
app.use(GZipMiddleware(min_size=500))Built-in middleware reference
| Method / Attribute | Returns | Description |
|---|---|---|
| LoggingMiddleware() | — | Logs method, path, status, elapsed ms to stdout |
| RequestIDMiddleware() | — | Attaches X-Request-ID (UUID4) to request and response |
| CORSMiddleware(allow_origins, ...) | — | CORS preflight + response headers |
| SecurityHeadersMiddleware(csp, hsts, frame_options) | — | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection |
| 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 + X-Cache header |
| StaticFilesMiddleware(directory, url_prefix) | — | Serve directory under URL prefix; path-traversal safe |
| GZipMiddleware(min_size) | — | Compress responses when client accepts gzip |
Writing 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 # None = continue pipeline; return a Response to short-circuit
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 # always return the response
app.use(TimingMiddleware())Request
Every handler receives a single Request object. All body-parsing methods are lazy and cached.
| Method / Attribute | Returns | 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] | Parsed query string key/value pairs |
| request.headers | dict[str, str] | All headers, keys lowercased |
| request.body | bytes | Raw request body bytes |
| request.json() | Any | Parse body as JSON (cached after first call) |
| request.form() | dict | Parse URL-encoded or multipart body |
| 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.cookies() | dict[str, str] | Parse Cookie header |
| request.bearer_token | str | None | Extracts Bearer token from Authorization header |
| request.content_type | str | Content-Type header value |
| 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 ↔ handler communication |
| request.get_header(name, default) | str | Case-insensitive header lookup with default |
Response
Pyrith provides typed response classes for every use case. Handlers can also return raw Python values — Pyrith coerces them automatically.
from pyrith.response import (
Response, HTMLResponse, JSONResponse,
RedirectResponse, StreamResponse,
FileResponse, SSEResponse, sse_message,
)
# Plain text
Response('OK', status=200)
# HTML
HTMLResponse('<h1>Welcome</h1>')
# JSON — dicts and lists auto-serialised
JSONResponse({'id': 42, 'name': 'Alice'}, status=201)
# Redirect (302 by default)
RedirectResponse('/login')
RedirectResponse('/new-url', status=301) # permanent
# Streaming response
def generate_csv():
yield b'id,name\n'
for row in db_rows:
yield f'{row.id},{row.name}\n'.encode()
StreamResponse(generate_csv(), content_type='text/csv')
# Set cookies and custom headers on any response
resp = JSONResponse({'ok': True})
resp.set_cookie('session', token, max_age=86400, http_only=True, secure=True, same_site='Lax')
resp.set_header('X-Request-Id', '123abc')
return respReturn coercion
| Parameter | Type | Description |
|---|---|---|
| dict / list | — | Wrapped in JSONResponse(200) |
| str starting with < | — | Wrapped in HTMLResponse(200) |
| bytes | — | application/octet-stream, 200 |
| None | — | 204 No Content |
| other str | — | text/plain, 200 |
FileResponse — ETag, Range, 304
from pyrith.response import FileResponse
@app.get('/download/{filename}')
def download(request):
path = f'./uploads/{request.params["filename"]}'
resp = FileResponse(path, as_attachment=True)
return resp.serve(request) # handles If-None-Match (304) + Range (206)
# as_attachment=True sets Content-Disposition: attachment
# Range support enables video/audio seeking in <video> / <audio> tagsFile Uploads
Pyrith parses multipart/form-data automatically. Uploaded files arrive as UploadedFile instances.
import os
from pyrith.response import JSONResponse
@app.post('/upload/avatar')
def upload_avatar(request):
file = request.file('avatar') # single file by field name
if file is None:
return JSONResponse({'error': 'No file provided'}, status=400)
# Validate type and size in-handler (or use FileField in a Form)
allowed = {'image/jpeg', 'image/png', 'image/webp'}
if file.content_type not in allowed:
return JSONResponse({'error': f'Unsupported type: {file.content_type}'}, status=415)
if file.size > 5 * 1024 * 1024: # 5 MB limit
return JSONResponse({'error': 'File too large'}, status=413)
os.makedirs('./uploads', exist_ok=True)
file.save(f'./uploads/{file.filename}')
return JSONResponse({'uploaded': file.filename, 'bytes': file.size})
@app.post('/upload/gallery')
def upload_gallery(request):
files = request.files() # all files as dict[name, UploadedFile]
saved = []
for field_name, uf in files.items():
uf.save(f'./gallery/{uf.filename}')
saved.append({'field': field_name, 'name': uf.filename, 'size': uf.size})
return JSONResponse({'saved': saved})UploadedFile attributes
| Method / Attribute | Returns | Description |
|---|---|---|
| file.filename | str | Original filename from the browser |
| file.content_type | str | MIME type, e.g. "image/jpeg" |
| file.data | bytes | Raw file bytes |
| file.size | int | File size in bytes |
| file.save(path) | None | Write file.data to the given path |
Server-Sent Events
SSE is the simplest way to push real-time updates to the browser — a persistent HTTP connection where the server streams events as plain text. No WebSocket needed for one-directional streams (live feeds, progress bars, notifications).
import time
from pyrith.response import SSEResponse, sse_message
@app.get('/events/live-feed')
def live_feed(request):
def stream():
for i in range(100):
yield sse_message(
data={'count': i, 'value': i * 2.5},
event='tick', # optional event type
id=str(i), # optional event ID (browser uses for reconnect)
retry=3000, # ms before browser reconnects on drop
)
time.sleep(0.5)
return SSEResponse(stream())
# In the browser:
# const es = new EventSource('/events/live-feed')
# es.addEventListener('tick', e => console.log(JSON.parse(e.data)))sse_message(data) — data can be a dict (auto-JSON), a list, or a plain string. The function returns a properly formatted SSE frame string.
Error Handling
Register handlers for HTTP status codes. In debug=True mode, unhandled exceptions render a styled HTML error page with a full Python traceback instead of a bare 500.
from pyrith.response import HTMLResponse, JSONResponse
@app.error_handler(404)
def not_found(request):
# Return JSON for API routes, HTML for browser requests
if request.path.startswith('/api/'):
return JSONResponse({'error': 'Not found', 'path': request.path}, status=404)
return HTMLResponse('''
<h1 style="font-family:system-ui">404 — Page not found</h1>
<a href="/">Back home</a>
''', status=404)
@app.error_handler(500)
def server_error(request):
return JSONResponse({'error': 'Internal server error'}, status=500)
@app.error_handler(429)
def too_many_requests(request):
retry_after = request.get_header('x-ratelimit-reset', '60')
return JSONResponse({'error': 'Rate limit exceeded', 'retry_after': retry_after}, status=429)Session Auth
HMAC-signed server-side sessions. The session ID is a 256-bit random token signed with HMAC-SHA256 — the browser stores the signed token; the server holds the data. Forgery is impossible without the secret.
from pyrith.auth import SessionStore
from pyrith.response import JSONResponse, RedirectResponse, HTMLResponse
store = SessionStore(secret='change-this-in-production', max_age=86400)
app.use(store.middleware()) # attaches request.session to every request
@app.post('/auth/login')
def login(request):
data = request.form()
user = User.objects.get(email=data['email'])
if user and check_password(data['password'], user.password_hash):
request.session.set('user_id', user.id)
request.session.set('role', user.role)
request.session.set('email', user.email)
return RedirectResponse('/dashboard')
return HTMLResponse('Invalid credentials', status=401)
@app.post('/auth/logout')
def logout(request):
request.session.clear()
return RedirectResponse('/login')
@app.get('/dashboard')
def dashboard(request):
user_id = request.session.get('user_id')
if not user_id:
return RedirectResponse('/login')
return JSONResponse({'user_id': user_id, 'role': request.session.get('role')})Always use a strong random secret in production. Session data is stored server-side in memory — it clears on server restart. For persistence across restarts, store session data in SQLite via the ORM.
Bearer Token Auth
HMAC-signed tokens for API authentication — compatible with the Bearer token standard. No JWT library needed.
from pyrith.auth import TokenStore, token_required
from pyrith.response import JSONResponse
tokens = TokenStore(secret='change-me-in-production', ttl=3600)
@app.post('/api/auth/token')
def get_token(request):
data = request.json()
user = User.objects.get(email=data.get('email'))
if not user or not check_password(data.get('password', ''), user.password_hash):
return JSONResponse({'error': 'Invalid credentials'}, status=401)
access_token = tokens.create({
'user_id': user.id,
'role': user.role,
'email': user.email,
})
return JSONResponse({
'access_token': access_token,
'token_type': 'bearer',
'expires_in': 3600,
})
@app.post('/api/auth/refresh')
def refresh_token(request):
old_token = request.bearer_token
if not old_token:
return JSONResponse({'error': 'No token provided'}, status=401)
new_token = tokens.refresh(old_token)
if not new_token:
return JSONResponse({'error': 'Token expired or invalid'}, status=401)
return JSONResponse({'access_token': new_token})
@app.get('/api/me')
@token_required(tokens)
def get_me(request):
# request.token_payload is populated by @token_required
return JSONResponse({
'user_id': request.token_payload['user_id'],
'email': request.token_payload['email'],
'role': request.token_payload['role'],
})Token format: base64(header).base64(payload).hmac_sig. All HMAC-SHA256 signed. No external JWT library. The @token_required decorator returns 401 automatically if the token is missing, malformed, or expired.
Password Hashing
PBKDF2-SHA256 with 260,000 iterations and a random salt — OWASP-compliant. Constant-time comparison prevents timing attacks.
from pyrith.auth import hash_password, check_password
# Hash a plaintext password before storing
password_hash = hash_password('correct-horse-battery-staple')
# → "pbkdf2:sha256:260000$<random_salt>$<derived_key>"
# Always store the hash, never the plaintext
user = User(email='[email protected]', password_hash=password_hash)
user.save()
# Later, verify a login attempt (constant-time comparison)
stored_hash = User.objects.get(email='[email protected]').password_hash
if check_password('correct-horse-battery-staple', stored_hash):
print('Login successful')
else:
print('Wrong password')260,000 PBKDF2 iterations is the OWASP 2023 recommendation for SHA-256. The salt is generated per-password with secrets.token_hex(16) — no two hashes will be identical even for the same password.
CSRF Protection
Double-submit cookie pattern — a CSRF token is set in a cookie and must also appear in the request (form field or header). Stateless: no server-side storage needed.
from pyrith.auth import CSRFMiddleware
app.use(CSRFMiddleware(secret='csrf-secret-change-me'))
# Every unsafe request (POST, PUT, DELETE, PATCH) must include the token.
# GET/HEAD/OPTIONS are exempt.
# In HTML forms — embed the token as a hidden field:
# <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}">
# In AJAX / fetch requests — read from the cookie and send as a header:
# const token = document.cookie.match(/pyrith_csrf=([^;]+)/)?.[1]
# fetch('/api/data', { method: 'POST', headers: { 'X-CSRF-Token': token } })The pyrith_csrf cookie is set with http_only=False so JavaScript can read it. This is intentional — the security guarantee is that an attacker on another origin cannot read the cookie. Do not set it to http-only.
Route Guards
from pyrith.auth import login_required, role_required, token_required
# ── Session-based guards
@app.get('/dashboard')
@login_required(redirect_to='/login')
def dashboard(request):
return f'<h1>Welcome, user {request.session.get("user_id")}</h1>'
@app.get('/admin/panel')
@login_required()
@role_required('admin', redirect_to='/forbidden')
def admin_panel(request):
return '<h1>Admin panel</h1>'
# ── Token-based guard (for API routes)
@app.delete('/api/users/{user_id}')
@token_required(tokens)
@role_required_token('admin') # checks token_payload['role']
def delete_user(request):
return JSONResponse({'deleted': request.params['user_id']})ORM
ActiveRecord-style ORM backed by SQLite (stdlib sqlite3). WAL mode and foreign keys enabled by default. Connections are pooled per-process by database path.
from pyrith.orm import Model, IntField, TextField, BoolField, DateTimeField, ForeignKey
from pyrith.orm import transaction
class User(Model):
class Meta:
db = 'app.db'
table_name = 'users'
id = IntField(primary_key=True)
email = TextField(nullable=False, unique=True)
password_hash = TextField(nullable=False)
role = TextField(default='user')
is_active = BoolField(default=True)
created_at = DateTimeField()
class Post(Model):
class Meta:
db = 'app.db'
table_name = 'posts'
id = IntField(primary_key=True)
author_id = ForeignKey('users')
title = TextField(nullable=False)
slug = TextField(unique=True)
body = TextField()
published = BoolField(default=False)
view_count = IntField(default=0)
created_at = DateTimeField()
# Create tables (idempotent)
User.create_table()
Post.create_table()
# ── Create
user = User.create(email='[email protected]', password_hash=hash_password('password123'))
post = Post(title='Hello World', slug='hello-world', body='Content here.', author_id=user.id)
post.save()
print(post.id) # auto-assigned PK
# ── Read
alice = User.objects.get(email='[email protected]')
posts = Post.objects.filter(published=True).order_by('-created_at').limit(10).all()
count = Post.objects.filter(author_id=alice.id).count()
exists = Post.objects.filter(slug='hello-world').exists()
# ── Filter lookups
Post.objects.filter(view_count__gte=100).all() # >= 100
Post.objects.filter(view_count__lt=10).all() # < 10
Post.objects.filter(title__contains='Python').all() # LIKE %Python%
Post.objects.filter(title__startswith='Hello').all() # LIKE Hello%
Post.objects.filter(id__in=[1, 2, 3]).all() # WHERE id IN (1,2,3)
Post.objects.filter(deleted_at__isnull=True).all() # IS NULL
Post.objects.exclude(published=False).all() # NOT published=False
# ── Update
post.view_count += 1
post.save()
Post.objects.filter(author_id=alice.id).update(archived=True) # bulk update
# ── Delete
post.delete()
Post.objects.filter(archived=True).delete() # bulk delete
# ── Bulk create (single transaction)
posts = [Post(title=f'Post {i}', slug=f'post-{i}', author_id=alice.id) for i in range(50)]
Post.bulk_create(posts)
# ── Explicit transaction (atomic)
with transaction(Post):
draft = Post(title='Draft', slug='draft', author_id=alice.id)
draft.save()
draft.published = True
draft.save()
# Both operations commit together — exception rolls back both
# ── Runtime migration
Post.add_column('featured', BoolField(default=False))Field types
| Parameter | Type | Description | Default |
|---|---|---|---|
| IntField | INTEGER / int | Integer value. Use primary_key=True for PK. | — |
| FloatField | REAL / float | Floating-point value. | — |
| TextField | TEXT / str | String value. Supports nullable, unique, default. | — |
| BoolField | INTEGER / bool | Stored as 0/1, returned as Python bool. | False |
| DateTimeField | TEXT / datetime | ISO 8601 string; auto-converts to datetime. | — |
| ForeignKey(table) | INTEGER / int | Raw FK integer. Pass table name as string. | — |
Forms & Validation
Declarative form classes with server-side validation and automatic HTML rendering. form.render() produces a complete <form> with labels, inputs, and inline error messages.
from pyrith.forms import (
Form, StringField, IntegerField, EmailField,
PasswordField, SelectField, TextareaField,
BooleanField, FileField,
)
from pyrith.response import JSONResponse, HTMLResponse, RedirectResponse
class RegisterForm(Form):
username = StringField(required=True, min_length=3, max_length=30,
label='Username')
email = EmailField(required=True, label='Email address')
password = PasswordField(required=True, min_length=8,
label='Password')
role = SelectField(choices=['user', 'moderator', 'admin'],
required=True, label='Role')
bio = TextareaField(max_length=500, label='Bio')
avatar = FileField(allowed_extensions=['jpg', 'png', 'webp'],
max_size=2 * 1024 * 1024, label='Profile photo')
agree = BooleanField(required=True, label='I agree to the terms of service')
@app.post('/register')
def register(request):
form = RegisterForm(request.form())
for name, uf in request.files().items():
form._data[name] = uf # inject files for FileField validation
if form.is_valid():
data = form.cleaned_data # coerced Python values
hashed = hash_password(data['password'])
user = User.create(email=data['email'], password_hash=hashed,
role=data['role'])
if data.get('avatar'):
data['avatar'].save(f'./uploads/avatars/{user.id}.png')
return RedirectResponse('/welcome')
# Re-render with inline errors
return HTMLResponse(form.render(action='/register', method='post'), status=422)
@app.get('/register')
def register_page(request):
return HTMLResponse(RegisterForm().render(action='/register', method='post'))Field reference
| Method / Attribute | Returns | Description |
|---|---|---|
| StringField(min_length, max_length) | str | Text with optional length bounds |
| IntegerField(min_val, max_val) | int | Integer with optional range bounds |
| EmailField() | str | Email format validation via regex |
| PasswordField(min_length) | str | Like StringField, renders as type=password |
| SelectField(choices) | str | Single select; validates against choices list |
| TextareaField(min_length, max_length) | str | Multi-line text |
| FileField(allowed_extensions, max_size) | UploadedFile | File upload; validates extension and size |
| BooleanField() | bool | Checkbox; converts "on"/"1"/"true" → True |
Templates
Python-native template engine with Django/Jinja2-compatible syntax. Use it when you want to manage HTML in separate files rather than the Python DSL.
from pyrith.template import TemplateLoader
loader = TemplateLoader('templates/')
# templates/base.html
# <!DOCTYPE html>
# <html>
# <head><title>{% block title %}App{% endblock %}</title></head>
# <body>
# <nav>{% include "partials/nav.html" %}</nav>
# <main>{% block content %}{% endblock %}</main>
# </body>
# </html>
# templates/posts/list.html
# {% extends "base.html" %}
# {% block title %}Posts — {{ site_name }}{% endblock %}
# {% block content %}
# <h1>{{ heading }}</h1>
# {% if posts %}
# <ul>
# {% for post in posts %}
# <li>
# <a href="/posts/{{ post.slug }}">{{ post.title }}</a>
# <span>{{ post.created_at }}</span>
# </li>
# {% endfor %}
# </ul>
# {% else %}
# <p>No posts yet.</p>
# {% endif %}
# {% endblock %}
@app.get('/posts')
def post_list(request):
posts = Post.objects.filter(published=True).order_by('-created_at').all()
html = loader.render('posts/list.html',
site_name='My Blog',
heading='Latest Posts',
posts=posts)
return HTMLResponse(html)Template syntax: {{ expr }} escaped · {{ expr | raw }} unescaped · {% if %}…{% endif %} · {% for x in y %}…{% empty %}…{% endfor %} · {% extends %} / {% block %} · {% include %} · {# comment #}
WebSocket
RFC 6455 WebSocket implemented in pure Python using stdlib socket only. Supports rooms, broadcast, ping/pong keepalive, and thread-safe send. Each connection runs in its own daemon thread.
from pyrith.websocket import WebSocket, rooms
from pyrith.response import JSONResponse
# ── Chat room example
@app.websocket('/ws/chat/{room_name}')
def chat(ws: WebSocket):
room_name = ws.request.params.get('room_name', 'global')
room = rooms.get(room_name) # creates room if it doesn't exist
username = ws.request.query.get('username', 'Anonymous')
room.join(ws)
room.broadcast(
f'[{username} joined] {room.size} online',
exclude=ws,
)
ws.send(f'Welcome to #{room_name}! {room.size} users online.')
try:
while not ws.closed:
msg = ws.recv() # returns str, bytes, or None on close
if msg is None:
break
if msg == '/ping':
ws.ping() # send WebSocket ping frame
continue
if msg.startswith('/dm '):
# Private message — handled by application logic
continue
# Broadcast to everyone else in the room
room.broadcast(f'{username}: {msg}', exclude=ws)
finally:
room.leave(ws)
room.broadcast(f'[{username} left] {room.size} online')
# ── REST endpoint: get room info
@app.get('/api/rooms')
def list_rooms(request):
return JSONResponse({
name: {'size': room.size}
for name, room in rooms.all().items()
})WebSocket API
| Method / Attribute | Returns | Description |
|---|---|---|
| ws.send(data) | None | Send text (str) or binary (bytes) frame; thread-safe |
| ws.recv() | str | bytes | None | Receive next frame; None means connection closed |
| ws.ping() | None | Send a ping frame; browser responds with pong automatically |
| ws.close(code, reason) | None | Send close frame and shut down connection |
| ws.closed | bool | True if the connection has been closed |
| rooms.get(name) | Room | Get or create a named room |
| room.join(ws) | None | Add a WebSocket to the room |
| room.leave(ws) | None | Remove a WebSocket from the room |
| room.broadcast(data, exclude) | None | Send to all in room, optionally excluding one connection |
| room.size | int | Number of currently connected clients |
HTML DSL
Every HTML5 element is a Python function. Children are positional arguments; attributes are keyword arguments. Text content is HTML-escaped automatically.
from pyrith import ui
# ── Basic elements
heading = ui.h1('Welcome to Pyrith', class_='hero-title', id='main-heading')
link = ui.a('Read the docs', href='/docs', class_='btn btn-primary')
# ── Nesting
card = ui.div(
ui.div(
ui.h2('Getting Started'),
ui.p('Install with pip in under 10 seconds.'),
class_='card-body',
),
ui.div(
ui.code('pip install pyrith'),
class_='card-footer',
),
class_='card',
)
print(str(card))
# <div class="card"><div class="card-body">…</div><div class="card-footer">…</div></div>
# ── Forms
login_form = ui.form(
ui.div(
ui.label('Email', for_='email'),
ui.input_(type='email', name='email', id='email',
placeholder='[email protected]', required=True),
),
ui.div(
ui.label('Password', for_='password'),
ui.input_(type='password', name='password', id='password',
min_length='8', required=True),
),
ui.button('Sign In', type='submit', class_='btn-primary'),
action='/auth/login', method='POST',
class_='auth-form',
)
# ── Boolean attributes
ui.input_(type='checkbox', name='agree', checked=True) # → checked attribute
ui.input_(type='text', disabled=False) # → omitted
# ── Raw HTML (bypass escaping — use only for trusted content)
icon_svg = ui.raw('<svg>…</svg>')The _ suffix is stripped from any kwarg — so class_="x" → class="x", for_="id" → for="id". This avoids Python reserved word conflicts.
Components
Subclass Component and implement render(). self.props holds the constructor kwargs. Components compose with the HTML DSL and other components.
from pyrith.ui import Component
from pyrith import ui
from pyrith.style import cv
# ── CVA-driven button component
button_cv = cv(
base='inline-flex items-center justify-center rounded-lg font-semibold transition-colors focus:outline-none',
variants={
'intent': {
'primary': 'bg-blue-600 text-white hover:bg-blue-700',
'danger': 'bg-red-600 text-white hover:bg-red-700',
'ghost': 'text-gray-700 hover:bg-gray-100',
},
'size': {'sm': 'h-8 px-3 text-xs', 'md': 'h-10 px-4 text-sm', 'lg': 'h-12 px-6'},
},
default_variants={'intent': 'primary', 'size': 'md'},
)
class Button(Component):
def render(self):
cls = button_cv(intent=self.props.get('intent', 'primary'),
size=self.props.get('size', 'md'))
label = self.props.get('label', 'Button')
href = self.props.get('href')
if href:
return ui.a(label, href=href, class_=cls)
return ui.button(label, type_=self.props.get('type', 'button'), class_=cls)
# ── Composable dashboard widget
class MetricWidget(Component):
def render(self):
value = self.props['value']
label = self.props['label']
delta = self.props.get('delta')
color = '#22c55e' if (delta or 0) > 0 else '#ef4444'
return ui.div(
ui.p(label, class_='text-sm text-gray-500 font-medium'),
ui.p(str(value), class_='text-3xl font-bold text-gray-900 mt-1'),
ui.p(f'{"+" if delta > 0 else ""}{delta}%',
style=f'color:{color}',
class_='text-sm font-semibold mt-1') if delta is not None else '',
class_='bg-white rounded-xl border border-gray-200 p-6',
)
# Usage
widget = MetricWidget(label='Monthly Revenue', value='$84,320', delta=12)
html = str(widget)Component Library
30+ production-ready components in pyrith.ui.components — covering every pattern you need for a modern web UI.
Layout & Navigation
from pyrith.ui import components as c
# ── Navigation bar
html = c.navigation.NavBar(
brand='MyApp',
links=[
{'label': 'Features', 'href': '/features'},
{'label': 'Docs', 'href': '/docs'},
{'label': 'Pricing', 'href': '/pricing'},
],
actions='<a href="/login" class="btn">Login</a>',
)
# ── Breadcrumb
html = c.navigation.Breadcrumb(items=[
{'label': 'Home', 'href': '/'},
{'label': 'Settings', 'href': '/settings'},
{'label': 'Profile'}, # no href = current page
])
# ── Tabs (underline / pill / boxed)
html = c.navigation.Tabs(
tabs=[
{'label': 'Overview', 'value': 'overview'},
{'label': 'API Docs', 'value': 'api'},
{'label': 'Changelog', 'value': 'changelog'},
],
active='overview',
variant='underline',
)
# ── Pagination
html = c.navigation.Pagination(total=250, page=4, per_page=20)Data Display
# ── Data table with sort indicators
html = c.display.Table(
columns=[
{'key': 'name', 'label': 'Name'},
{'key': 'email', 'label': 'Email'},
{'key': 'role', 'label': 'Role'},
{'key': 'status', 'label': 'Status'},
],
rows=[
{'name': 'Alice Smith', 'email': '[email protected]', 'role': 'Admin', 'status': 'Active'},
{'name': 'Bob Jones', 'email': '[email protected]', 'role': 'User', 'status': 'Inactive'},
],
striped=True,
sortable=True,
)
# ── KPI stat tile
html = c.display.Stat(label='Monthly Revenue', value='$84,320', delta='12%', delta_type='increase')
# ── Badges
html = c.display.Badge('Active', color='green')
html = c.display.Badge('Beta', color='yellow')
html = c.display.Badge('Blocked', color='red')
# ── Avatar (falls back to initials if no src)
html = c.display.Avatar(src='/avatars/alice.jpg', name='Alice Smith', size=48)
html = c.display.Avatar(name='Alice Smith') # initials: "AS"
# ── Progress bar
html = c.display.ProgressBar(value=72, label='Upload progress', show_value=True, color='blue')
# ── Skeleton loading placeholder
html = c.display.Skeleton(lines=3, animate=True)Feedback & Overlays
# ── Alerts
html = c.feedback.Alert('Your account is set up.', kind='success', title='Done!')
html = c.feedback.Alert('Session expires in 5 minutes.', kind='warning', dismissible=True)
html = c.feedback.Alert('Payment failed.', kind='error')
# ── Toast notification
html = c.feedback.Toast('Link copied!', kind='success', duration=3000)
# ── Modal (CSS :target — zero JS)
html = c.feedback.Modal(
modal_id='delete-confirm',
title='Delete project',
body='Are you sure? This action cannot be undone.',
confirm_label='Delete',
confirm_href='#',
)
# Trigger: <a href="#delete-confirm">Delete</a>
# ── Tooltip
html = c.feedback.Tooltip(text='Save', content='Ctrl + S', placement='top')Cards & Marketing
# ── Pricing card
html = c.cards.PricingCard(
name='Pro',
price='$29',
period='/month',
features=['Unlimited projects', '50 GB storage', 'Priority support', 'Custom domain'],
cta_label='Start free trial',
cta_href='/signup?plan=pro',
highlighted=True, # adds "Most Popular" badge + accent border
)
# ── Hero section
html = c.cards.HeroSection(
title='Build beautiful web apps in pure Python',
subtitle='No JavaScript. No Node.js. No build step. Full-stack in one import.',
cta_label='Get started free',
cta_href='/docs',
secondary_label='View on GitHub',
secondary_href='https://github.com/vectorphile/Pyrith',
)
# ── Feature card
html = c.cards.FeatureCard(
icon='⚡',
title='Zero Dependencies',
description='Ships with nothing but Python. No pip install chain, no venv bloat.',
color='blue',
)
# ── Empty state
html = c.cards.EmptyState(
title='No results found',
description='Try a different search term or clear your filters.',
icon='🔍',
action_label='Clear filters',
action_href='?',
)
# ── Interactive: Accordion (uses HTML <details> — no JS)
html = c.interactive.Accordion(items=[
{'title': 'What is Pyrith?', 'content': 'A pure-Python web framework.', 'open': True},
{'title': 'Does it need Node.js?', 'content': 'No. Zero dependencies.'},
{'title': 'Can I use it in production?', 'content': 'Yes — v0.2.0 is stable.'},
])Page Builder
Page builds a complete <!DOCTYPE html> document. Head and body slots are independent lists — compose from anywhere before calling to_html().
from pyrith.ui import Page
from pyrith.style import ThemeProvider, DarkTheme, LightTheme
from pyrith import ui
provider = ThemeProvider(theme=LightTheme, dark_theme=DarkTheme)
page = Page(title='My App — Dashboard')
# Head extras
page.head_add(provider.to_style_tag()) # CSS variables for theming
page.head_add('<link rel="icon" href="/favicon.ico">')
page.head_add('<meta name="description" content="Dashboard">')
# Body content (use the HTML DSL or any component)
page.body_add(ui.nav(ui.a('Home', href='/'), class_='topnav'))
page.body_add(ui.main(
ui.h1('Dashboard'),
ui.p('Welcome back.'),
class_='container mx-auto px-6',
))
# Scripts (deferred by default)
page.script_add(src='/static/app.js')
page.script_add(inline="console.log('page loaded')")
return HTMLResponse(page.to_html())Icon Library
50+ inline SVG icons — no external font, no CDN, no base64 blobs. Heroicons paths embedded as Python strings.
from pyrith.ui.icons import Icon, icon, list_icons
from pyrith import ui
# Render as an HTML string
check_icon = Icon('check', size=20, color='#22c55e')
arrow_icon = Icon('arrow-right', size=16, stroke_width=2.5)
star_icon = Icon('star', size=24, filled=True)
# Use inside the HTML DSL
save_btn = ui.button(
icon('check', size=16), # icon() is a shorthand returning the SVG string
'Save changes',
type_='submit',
class_='btn-primary',
)
# Discover available icon names
print(list_icons())
# ['arrow-down', 'arrow-left', 'arrow-right', 'arrow-up', 'bell', 'calendar',
# 'check', 'chevron-down', 'code', 'copy', 'download', 'edit', 'eye', ...]Reactive State
Observer-pattern reactive state implemented server-side. Signal holds a value; Computed derives from signals; ReactiveComponent pushes HTML patches to connected browsers over WebSocket.
from pyrith.ui.state import Signal, Computed, Effect, useState, effect
# ── Signals
count = Signal(0)
count.set(5)
count.update(lambda x: x + 1)
print(count.get()) # 6
unsub = count.subscribe(lambda val: print(f'count → {val}'))
# ── Computed (auto-tracks dependencies)
price = Signal(29.99)
quantity = Signal(3)
total = Computed(lambda: price.get() * quantity.get())
print(total.get()) # 89.97
quantity.set(5)
print(total.get()) # 149.95 ← recomputed automatically
# ── Effect (side effects on change)
effect(lambda: print(f'Total: {total.get():.2f}'))
# ── useState convenience
value, set_value = useState('initial')
set_value('updated')
print(value.get()) # "updated"ReactiveComponent — live DOM patching
from pyrith.ui.state import Signal, ReactiveComponent
class LiveCounter(ReactiveComponent):
"""A counter that pushes updates to all connected browsers via WebSocket."""
def __init__(self):
super().__init__()
self.count = Signal(0)
self.track(self.count) # re-render + push JSON patch when count changes
def render(self) -> str:
return f'''
<div id="live-counter">
<h2 style="font-size:4rem;text-align:center">{self.count.get()}</h2>
<div style="display:flex;gap:1rem;justify-content:center">
<button onclick="fetch('/counter/dec',{{method:'POST'}})">−</button>
<button onclick="fetch('/counter/inc',{{method:'POST'}})">+</button>
</div>
</div>'''
counter = LiveCounter()
@app.websocket('/ws/counter')
def counter_ws(ws):
counter.add_client(ws)
# Inject the patch script on connect
ws.send(LiveCounter.PATCH_SCRIPT)
while not ws.closed:
if ws.recv() is None:
break
counter.remove_client(ws)
@app.post('/counter/inc')
def increment(request):
counter.count.update(lambda x: x + 1) # all connected clients update instantly
return JSONResponse({'count': counter.count.get()})
@app.post('/counter/dec')
def decrement(request):
counter.count.update(lambda x: x - 1)
return JSONResponse({'count': counter.count.get()})ReactiveComponent.PATCH_SCRIPT is a tiny <script> block (~300 bytes) that opens the WebSocket and applies JSON patches using element.outerHTML = patch. No framework, no virtual DOM.
Page Templates
Five production-ready full-page HTML document builders — each returns a complete <!DOCTYPE html> with responsive CSS built in. All collapse to single-column on mobile automatically.
from pyrith.ui.templates import (
DashboardLayout, AuthLayout, DocsLayout,
LandingLayout, BlogLayout,
)
# ── Admin dashboard (sidebar + header + main content area)
html = DashboardLayout(
sidebar=str(c.navigation.NavBar(brand='Admin', links=[
{'label': 'Dashboard', 'href': '/admin'},
{'label': 'Users', 'href': '/admin/users'},
{'label': 'Settings', 'href': '/admin/settings'},
])),
header='<div>Top bar with user menu</div>',
content='<div>Page content here</div>',
title='Dashboard — MyApp',
)
# ── Auth page (centered card, gradient background)
html = AuthLayout(
form=str(RegisterForm().render(action='/register')),
brand='MyApp',
title='Create account',
)
# ── Three-column documentation layout
html = DocsLayout(
sidebar_nav='<nav>…doc section links…</nav>',
content='<article>…documentation content…</article>',
on_this_page='<ul>…table of contents…</ul>',
title='Getting Started — MyApp Docs',
)
# ── Marketing landing page
html = LandingLayout(
sections=[
str(c.cards.HeroSection(title='Build fast', subtitle='With pure Python.')),
'<section id="features">…</section>',
'<section id="pricing">…</section>',
],
nav='<nav>Brand + links</nav>',
footer='<footer>© 2026 MyApp</footer>',
title='MyApp — Build Fast',
)
# ── Blog index
html = BlogLayout(
posts=[
{
'title': 'Pyrith v0.2.0 Released',
'excerpt': 'Full Tailwind system, 30+ components, and reactive state.',
'href': '/blog/pyrith-v0-2-0',
'date': '2026-11-07',
'author': 'Saad Salman',
'tags': ['Python', 'Web', 'Open Source'],
},
],
title='The Pyrith Blog',
)CSS Engine
Programmatic CSS — build stylesheets as Python objects. Underscores in property names become hyphens: font_size → font-size.
from pyrith import style
from pyrith.style.units import px, rem, pct
css = style.CSS()
# ── Basic rules
css.rule('*', box_sizing='border-box', margin='0', padding='0')
css.rule('body',
font_family='Inter, system-ui, sans-serif',
background='#0f172a',
color='#f1f5f9',
line_height='1.6',
)
# ── Hover / pseudo
css.rule('a', color='#38bdf8', text_decoration='none')
css.rule('a:hover', text_decoration='underline', opacity='0.85')
css.rule('button:focus-visible', outline=f'2px solid #38bdf8', outline_offset='2px')
# ── Media queries
mobile = css.media('(max-width: 640px)')
mobile.rule('h1', font_size=rem(2))
mobile.rule('.container', padding=f'0 {px(16)}')
# ── Keyframe animation
css.keyframes('slideUp', {
'from': {'transform': 'translateY(20px)', 'opacity': '0'},
'to': {'transform': 'translateY(0)', 'opacity': '1'},
})
css.rule('.card', animation='slideUp 0.3s ease both')
# ── Raw block (vendor prefixes, CSS custom props)
css.raw(':root { --radius: 0.5rem; --shadow: 0 4px 24px rgba(0,0,0,0.3); }')
page.head_add(f'<style>{css.to_css()}</style>')
# ── Scoped component styles (BEM-style)
from pyrith.style import StyleSheet
card_sheet = StyleSheet(scope='card')
card_sheet.scoped('', background='#1e293b', border_radius=rem(0.75), padding=rem(1.5))
card_sheet.scoped(' .title', font_size=rem(1.25), font_weight='700')
card_sheet.scoped(' .body', color='#94a3b8', font_size=rem(0.875))
card_sheet.scoped(':hover', border='1px solid rgba(56,189,248,0.4)')
# Emits: .card { … } .card .title { … } .card .body { … } .card:hover { … }Tailwind Utilities
A complete Tailwind CSS equivalent in pure Python. Every utility class compiles to precise CSS. No PostCSS, no Node.js, no build step.
from pyrith.style import TailwindSheet, tw
sheet = TailwindSheet()
# ── Basic classes — same names as Tailwind CSS
sheet.add(
'flex', 'items-center', 'justify-between',
'px-6', 'py-4',
'bg-white', 'dark:bg-zinc-900',
'border-b', 'border-gray-200', 'dark:border-zinc-800',
'shadow-sm',
)
# ── Responsive prefixes — emit @media (min-width: …)
sheet.add('w-full', 'md:w-1/2', 'lg:w-1/3', 'xl:w-1/4')
# → .w-full { width: 100%; }
# → @media (min-width: 768px) { .md\:w-1\/2 { width: 50%; } }
# ── Dark mode
sheet.add('bg-white', 'dark:bg-zinc-900', 'text-gray-900', 'dark:text-white')
# → .bg-white { background-color: #fff; }
# → @media (prefers-color-scheme: dark) { .dark\:bg-zinc-900 { background-color: #18181b; } }
# ── Hover, focus, disabled
sheet.add('hover:bg-blue-700', 'focus:ring-2', 'focus:ring-blue-500',
'disabled:opacity-50', 'disabled:cursor-not-allowed')
# ── Arbitrary values (pass-through verbatim)
sheet.add('w-[320px]', 'text-[#ff6b6b]', 'bg-[rgba(0,0,0,0.5)]',
'top-[calc(100%+8px)]')
# ── tw() helper — build a class string without generating CSS
classes = tw('flex', 'gap-4', 'rounded-xl', 'bg-blue-600', 'text-white', 'px-6', 'py-3')
# → "flex gap-4 rounded-xl bg-blue-600 text-white px-6 py-3"
# Inject into a page
page.head_add(f'<style>{sheet.to_css()}</style>')
html_el = ui.div('Content', class_=tw('container', 'mx-auto', 'px-6'))Utility families supported
| 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→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, 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 |
| Effects | opacity-50, blur-md, brightness-110, 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, skew-x-6 |
| Animations | animate-spin, animate-ping, animate-pulse, animate-bounce |
| Interactions | cursor-pointer, select-none, pointer-events-none, resize, appearance-none |
| Ring & Outline | outline-none, ring-2, ring-offset-2, ring-blue-500 |
| Responsive | sm: md: lg: xl: 2xl: prefixes — emit @media blocks |
| Dark mode | dark: prefix — emits @media (prefers-color-scheme: dark) |
| Pseudo-classes | hover: focus: active: disabled: checked: first: last: group-hover: |
| Arbitrary values | w-[320px], text-[#ff0000], bg-[rgba(0,0,0,0.5)] |
Design Tokens
The complete Tailwind design token system exposed as Python dicts. 20 color families × 11 shades, spacing scale, type scale, shadow definitions, border radii, z-index.
from pyrith.style.tokens import theme, resolve_color, COLORS, SPACING, FONT_SIZE
# ── Dot-path access to any token
blue500 = theme('colors.blue.500') # → "#3b82f6"
spacing8 = theme('spacing.8') # → "2rem"
shadowLg = theme('shadow.lg') # → "0 10px 15px -3px rgba(0,0,0,0.1) ..."
textSm = theme('font_size.sm') # → "0.875rem"
# ── Shorthand color resolver
color = resolve_color('emerald-400') # → "#34d399"
color = resolve_color('white') # → "#ffffff"
color = resolve_color('zinc-950') # → "#09090b"
# ── Direct dict access
print(COLORS['violet'][500]) # "#8b5cf6"
print(COLORS['rose'][400]) # "#fb7185"
# ── Use in CSS
from pyrith import style
css = style.CSS()
css.rule('.btn-primary',
background=theme('colors.blue.600'),
color=theme('colors.white'),
border_radius=theme('border_radius.lg'),
padding=f'{theme("spacing.2")} {theme("spacing.4")}',
box_shadow=theme('shadow.md'),
)Class Variance Authority (CVA)
The same CVA pattern popularized by shadcn/ui and Radix — now in Python. Define component variants declaratively; get a type-safe callable that returns the right class string.
from pyrith.style import cv
from pyrith import ui
# ── Define a Button with intent × size variants
button = cv(
base='inline-flex items-center justify-center rounded-lg font-semibold transition-colors '
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 '
'disabled:pointer-events-none disabled:opacity-50',
variants={
'intent': {
'primary': 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600',
'secondary': 'bg-gray-100 text-gray-900 hover:bg-gray-200',
'danger': 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600',
'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 p-0',
},
},
default_variants={'intent': 'primary', 'size': 'md'},
compound_variants=[
# large primary buttons get an extra shadow
{'intent': 'primary', 'size': 'lg', 'class_': 'shadow-lg'},
],
)
# ── Usage — returns class string
print(button()) # "… bg-blue-600 text-white … h-10 px-4 …"
print(button(intent='danger', size='lg')) # danger + lg + compound shadow-lg
print(button(intent='ghost', class_='mt-4')) # ghost + extra classes appended
# ── Use in HTML DSL
el = ui.button('Delete', type_='button', class_=button(intent='danger', size='sm'))Theme System
Define a complete design system as CSS custom properties. ThemeProvider emits a <style> block with :root variables — all components reference var(--color-primary) etc.
from pyrith.style import Theme, ThemeProvider, LightTheme, DarkTheme, PurpleTheme
from pyrith.ui import Page
# ── Custom theme
brand_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 :root { --color-primary: …; … }
print(brand_theme.to_css_vars())
# ── Use in CSS rules
print(brand_theme.var('primary')) # → "var(--color-primary)"
print(brand_theme.var('background')) # → "var(--color-background)"
# ── Auto dark-mode switching (prefers-color-scheme)
provider = ThemeProvider(theme=LightTheme, dark_theme=DarkTheme)
style_tag = provider.to_style_tag()
# → <style>:root { … light tokens … } @media (prefers-color-scheme: dark) { :root { … dark tokens … } }</style>
# ── Inject into a page
page = Page(title='Themed App')
page.head_add(provider.to_style_tag())
# Now all CSS rules can use var(--color-primary) etc.Animations
23 built-in keyframe animations. Use animate() for inline styles or AnimationSheet to emit @keyframes + utility classes.
from pyrith.style import animate, AnimationSheet, KEYFRAMES
from pyrith import ui
# ── Inline animation style
entrance_style = animate('fadeIn', duration='0.4s', easing='ease-out', delay='0.1s')
# → "animation: fadeIn 0.4s ease-out 0.1s both;"
hero = ui.div(
ui.h1('Welcome', style=animate('slideInUp', duration='0.6s', easing='cubic-bezier(0.16,1,0.3,1)')),
ui.p('Pure Python', style=animate('fadeIn', duration='0.4s', delay='0.2s')),
class_='hero',
)
# ── AnimationSheet — emit @keyframes + utility classes
sheet = AnimationSheet()
sheet.use('fadeIn', 'slideInUp', 'slideInLeft', 'shake', 'pulse', 'bounce')
# → @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
# → .animate-fadeIn { animation: fadeIn 0.3s ease both; }
# → … (all requested animations)
page.head_add(f'<style>{sheet.to_css()}</style>')
# ── Use utility class in HTML
alert = ui.div(
'Something went wrong!',
class_='alert error animate-shake',
)Available animations
TestClient
Make requests against your Pyrith app without starting a real server. No ports, no sockets, no teardown. Cookies persist across requests within a client instance — full auth flows are testable.
from pyrith import Pyrith, TestClient
from pyrith.auth import SessionStore, hash_password, check_password
from pyrith.orm import Model, TextField, BoolField
app = Pyrith(__name__)
store = SessionStore(secret='test-secret')
app.use(store.middleware())
@app.get('/api/items')
def list_items(request):
if not request.session.get('user_id'):
return {'error': 'Unauthenticated'}, 401
return [{'id': 1, 'name': 'Widget'}]
@app.post('/auth/login')
def login(request):
data = request.json()
if data['username'] == 'alice' and data['password'] == 'secret':
request.session.set('user_id', 42)
return {'ok': True}
return {'error': 'Invalid'}, 401
# ── Tests (no pytest plugin needed — just plain assertions)
client = TestClient(app)
def test_unauthenticated():
resp = client.get('/api/items')
assert resp.status_code == 401
def test_login_and_access():
resp = client.post('/auth/login', json={'username': 'alice', 'password': 'secret'})
assert resp.status_code == 200
assert resp.json()['ok'] is True
# Session cookie is carried automatically
resp = client.get('/api/items')
assert resp.status_code == 200
items = resp.json()
assert items[0]['name'] == 'Widget'
def test_bad_login():
c2 = TestClient(app)
resp = c2.post('/auth/login', json={'username': 'alice', 'password': 'wrong'})
assert resp.status_code == 401TestClient API
| Method / Attribute | Returns | Description |
|---|---|---|
| client.get(path, params, headers) | Response | GET request |
| client.post(path, json, data, headers) | Response | POST request — json= or data= (form) |
| client.put(path, json, headers) | Response | PUT request |
| client.delete(path, headers) | Response | DELETE request |
| resp.status_code | int | HTTP status code |
| resp.json() | Any | Parse response body as JSON |
| resp.text | str | Response body as string |
| resp.headers | dict | Response headers |
| resp.cookies | dict | Response cookies |
CLI
pyrith new <project> # Scaffold a new project with app.py
pyrith run [file] # Import and run an app file (default: app.py); debug=True
pyrith version # Print the installed version# Full workflow
pyrith new myapp
cd myapp
python app.py
# [Pyrith] Running on http://127.0.0.1:8080 (debug=True)
# Auto-reload (restarts on file change)
app.run(host='127.0.0.1', port=8080, debug=True, reload=True)In debug=True mode, unhandled exceptions render a styled HTML error page with a full Python traceback instead of a bare 500 response — with file name, line number, and local variables.
Changelog
Full release history for Pyrith. Each version entry documents every feature added, the test count, and the release date.
Frontend & Backend Overhaul — complete Tailwind utility system, 30+ UI components, reactive state engine, enhanced security layer, file handling, real-time streaming, and advanced ORM capabilities. 160 passing tests.
- File uploads — multipart/form-data parsed from scratch; UploadedFile with .save(), .size, .content_type
- Password hashing — PBKDF2-SHA256, 260k iterations (OWASP-compliant), constant-time verify via stdlib hashlib + hmac
- CSRF protection — double-submit cookie pattern, stateless, all unsafe verbs protected
- Bearer token auth — HMAC-signed tokens, TTL, refresh endpoint, @token_required decorator
- FileResponse — ETag header, If-None-Match → 304, HTTP Range requests → 206 (video/audio seeking)
- Server-Sent Events — SSEResponse + sse_message(); generator-based streaming, event types, IDs, retry
- ORM: ForeignKey field, bulk_create(), explicit transactions (context manager), .update() bulk patching
- ORM filter lookups: __gte, __lt, __lte, __gt, __contains, __startswith, __endswith, __in, __isnull
- ORM: .exists(), .exclude(), runtime add_column() migration, WAL mode + FK enforcement
- WebSocket rooms — rooms.get(), room.join(), room.leave(), room.broadcast(exclude=), room.size
- SecurityHeadersMiddleware — CSP, HSTS, X-Frame-Options, X-XSS-Protection, X-Content-Type-Options
- RateLimitMiddleware — sliding-window rate limit per IP, 429 + Retry-After header
- CacheMiddleware — in-memory LRU GET cache, X-Cache header, configurable TTL
- RequestIDMiddleware — UUID4 X-Request-ID attached to request and response
- Reverse URL generation — app.url_for("route_name", **path_params, **query_params)
- TestClient — full request cycle without network, cookie persistence across calls
- Auto-reload dev server — restarts on file change when reload=True
- Debug error pages — styled HTML with full Python traceback, file + line + locals on 500
- Full Tailwind CSS utility system — all families, all values, correct CSS output
- Responsive prefixes: sm: md: lg: xl: 2xl: → @media (min-width: …) blocks
- Dark mode: dark: prefix → @media (prefers-color-scheme: dark)
- Pseudo-class variants: hover: focus: active: disabled: checked: first: last: group-hover:
- Arbitrary value support: w-[320px], text-[#ff6b6b], bg-[rgba(0,0,0,0.5)], top-[calc(100%+8px)]
- tw() helper — compose class strings without generating CSS
- Complete Tailwind design token system — 20 color families × 11 shades, spacing, type, shadow, radii, z-index
- theme() dot-path accessor and resolve_color() shorthand
- Class Variance Authority cv() — base, variants, default_variants, compound_variants, extra classes
- 23 keyframe animations — fadeIn/Out, slideIn/Out (all 4 directions), zoomIn/Out, shake, pulse, bounce, spin, ping, float, wiggle, flash, heartbeat, ripple, gradientX, typing, blinkCaret
- AnimationSheet — emit @keyframes + utility classes; animate() inline style helper
- Theme system — Theme, ThemeProvider, LightTheme, DarkTheme, PurpleTheme, CSS custom properties
- 50+ inline SVG icons — zero CDN, Heroicons paths embedded as Python strings
- Reactive state — Signal, Computed (auto-tracks deps), Effect, useState — thread-safe observer pattern
- ReactiveComponent — WebSocket live DOM patching via JSON patches (~300 byte patch script)
- 30+ pre-built UI components: Stack, Grid, Container, Spacer, Divider, NavBar, Sidebar, Breadcrumb, Tabs, Pagination, Table, Badge, Avatar, Stat, Timeline, Spinner, Skeleton, ProgressBar, Alert, Toast, Modal, Tooltip, Popover, Input, Textarea, Select, Checkbox, Switch, FileInput, Card, HeroSection, PricingCard, ProfileCard, EmptyState, Accordion, Dropdown, Collapsible
- Layout engine — MasonryGrid, SplitPane, StickyHeader, AspectRatio, ScrollArea
- 5 full-page HTML templates — DashboardLayout, AuthLayout, DocsLayout, LandingLayout, BlogLayout
Initial release — full HTTP layer, HTML DSL, CSS engine, SQLite ORM, session auth, form validation, and WebSocket support. Zero external dependencies. 35 passing tests.
- HTTP routing — path parameters, route groups, all HTTP verbs, error handlers
- HTML element DSL — all HTML5 elements as Python functions, nested composition, boolean attributes
- Component base class — render(), props, composable with HTML DSL
- Page builder — Page(title), head_add(), body_add(), script_add(), to_html()
- Programmatic CSS engine — CSS(), rule(), media(), keyframes(), raw(), scoped stylesheets
- Tailwind-like utility classes (initial subset)
- ActiveRecord ORM — SQLite, Model metaclass, IntField, TextField, BoolField, DateTimeField, QuerySet API
- QuerySet: filter(), order_by(), limit(), count(), all(), get()
- Template engine — {{ }}, {% if %}, {% for %}, extends, blocks, include
- Session store — HMAC-SHA256 signed cookies, per-request session object
- Route guards — @login_required, @role_required
- Form validation — StringField, IntegerField, EmailField, PasswordField, SelectField, TextareaField, BooleanField, FileField
- WebSocket — RFC 6455 handshake in pure Python stdlib socket, send/recv, thread-per-connection
- Middleware system — CORS, Logging, Static files, GZip
- CLI — pyrith new <project>, pyrith run, pyrith version
- Zero external dependencies — Python 3.9+ stdlib only
Roadmap
Phase 1 — Core
Completev0.1.0- HTTP router — path params, route groups, reverse URL generation
- Middleware pipeline — CORS, Logging, Static, GZip, Security, RateLimit, Cache, RequestID
- HTML element DSL + Component system + Page builder
- Programmatic CSS engine + scoped stylesheets + unit helpers
- ActiveRecord ORM — SQLite, Django-style filter lookups, bulk ops, transactions
- Template engine — extends, blocks, for, if, include
- Session auth (HMAC-signed cookies)
- Password hashing (PBKDF2-SHA256, 260k iterations, OWASP-compliant)
- Bearer token auth — HMAC-signed, refresh support
- CSRF protection (double-submit cookie)
- Route guards — login_required, role_required, token_required
- Form validation — 8 field types including FileField
- Multipart/form-data parsing + file uploads
- FileResponse — ETag, If-None-Match (304), Range requests (206)
- Server-Sent Events (SSEResponse + sse_message)
- WebSocket — RFC 6455, rooms, broadcast, ping/pong, thread-safe send
- TestClient — no network, cookie persistence
- Auto-reload dev server + pretty debug error pages
- CLI — new / run / version
Phase 2 — Frontend
Completev0.2.0- Full Tailwind utility class system (all families, all values)
- Responsive prefixes: sm: md: lg: xl: 2xl:
- Dark mode: dark: prefix
- Pseudo-class variants: hover: focus: active: disabled: group-hover:
- Arbitrary values: w-[320px], text-[#ff0000]
- Complete Tailwind design token system (20 color families × 11 shades)
- Class Variance Authority (cv()) — shadcn/Radix-style variants in Python
- 23 keyframe animations + AnimationSheet + animate()
- Theme system — CSS custom properties, dark mode media query, built-in presets
- 50+ inline SVG icons — no external font or CDN
- Reactive state — Signal, Computed, Effect, useState (thread-safe)
- ReactiveComponent — WebSocket live DOM patching via JSON patches
- 30+ pre-built UI components
- Layout engine — MasonryGrid, SplitPane, StickyHeader, AspectRatio, ScrollArea
- 5 full-page HTML templates — DashboardLayout, AuthLayout, DocsLayout, LandingLayout, BlogLayout
- 160 passing tests
Phase 3 — Ecosystem
PlannedPlanned- PostgreSQL ORM backend (stdlib socket + 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
Virtual DOM
pyrith.vdom provides a full virtual DOM engine. Build VNode trees server-side, diff old and new trees, and ship a minimal JavaScript runtime (~200 lines) that applies patches in the browser — no React, no Vue, no build step.
from pyrith.vdom import h, diff, patch_js, RUNTIME_JS
# Server-side render a VNode tree
vnode = h('div', {'class': 'card'},
h('h1', {}, 'Hello'),
h('p', {}, 'World'),
)
html = vnode.render_html()
# → <div class="card"><h1>Hello</h1><p>World</p></div>
# Client-side diffing — compute minimal patches
patches = diff(old_tree, new_tree)
js_code = patch_js(patches) # JavaScript to apply patches to real DOM
# Embed the runtime in a page (once) for reactive updates
page.script_add(inline=RUNTIME_JS)VNode API
| Method / Attribute | Returns | Description |
|---|---|---|
| h(tag, props, *children) | VNode | Create a virtual DOM node |
| vnode.render_html() | str | Render VNode tree to HTML string (server-side) |
| vnode.render_js_literal() | str | Render as JavaScript object literal for the runtime |
| diff(old, new) | list[dict] | Compute minimal patch set between two VNode trees |
| patch_js(patches) | str | Generate JS to apply patches to the real DOM |
| RUNTIME_JS | str | ~200-line JS runtime: h(), diff(), patch(), Signal bridge, event delegation |
Page Builder (v0.3.0)
pyrith.ui.page_builder provides a chainable Page and pre-wired DarkPage that compose full HTML documents declaratively. Automatically includes the vsite-compatible design tokens, Inter + JetBrains Mono fonts, smooth scroll, and custom scrollbar.
from pyrith.ui.page_builder import DarkPage
from pyrith.ui.components.advanced import (
Navbar, HeroSection, FeatureGrid, PricingSection, Footer
)
page = DarkPage(title="My SaaS App")
page.meta(description="Build fast.", og_image="/og.png")
page.fonts("Inter:wght@400;500;600;700", "JetBrains+Mono")
page.add(Navbar(
brand="MyApp",
links=[{"label":"Docs","href":"/docs"}, {"label":"Pricing","href":"/pricing"}],
cta={"label":"Get Started","href":"/signup"},
sticky=True, blur=True,
))
page.add(HeroSection(
title="Build Modern Websites",
subtitle="Pure Python. Zero JavaScript frameworks.",
background="particles",
cta_primary={"label":"Get Started","href":"/docs"},
cta_secondary={"label":"GitHub","href":"https://github.com"},
))
page.add(FeatureGrid(features=[
{"icon":"⚡","title":"Static Generation","description":"Build entire sites to static HTML."},
{"icon":"🎨","title":"Design System","description":"Dark glassmorphism tokens built in."},
{"icon":"📝","title":"Markdown","description":"Render MDX with syntax highlighting."},
], columns=3))
page.add(PricingSection(plans=[
{"name":"Free","price":"$0","features":["5 pages","Community support"],"cta":{"label":"Start Free","href":"/signup"}},
{"name":"Pro","price":"$29","features":["Unlimited pages","Priority support","Custom domain"],"cta":{"label":"Get Pro","href":"/signup?plan=pro"},"highlighted":True},
]))
page.add(Footer(brand="MyApp", links={"Product":[{"label":"Docs","href":"/docs"}]}))
html = page.render() # complete <!DOCTYPE html> document| Method / Attribute | Returns | Description |
|---|---|---|
| Page(title, theme, lang, favicon) | Page | Create a page. theme: "dark" | "light" |
| page.meta(**kwargs) | Page | Set SEO/OG meta tags (description, url, image, etc.) |
| page.fonts(*families) | Page | Load Google Fonts (auto-generates <link> tag) |
| page.css(*stylesheets) | Page | Add extra CSS (URL or inline string) |
| page.js(*scripts) | Page | Add extra JS (URL or inline string) |
| page.add(*components) | Page | Append HTML sections/components |
| page.render() | str | Return complete <!DOCTYPE html> document |
| DarkPage(title) | DarkPage | Pre-configured: void bg, neon accents, Inter+JetBrains fonts |
Design Tokens (v0.3.0)
pyrith.style.design_tokens provides the vsite-compatible dark glassmorphism palette and CSS generation helpers. Use generate_css_vars() to emit :root custom properties for the entire palette.
from pyrith.style.design_tokens import (
VOID_PALETTE, GLASS_VARIANTS,
glow, text_glow, gradient_text, generate_css_vars, base_css
)
# VOID_PALETTE
# void: #0a0a0f neon: #00ff9d mint: #7fffd4 ghost: #a0a0b0
# ember: #ff6b35 frost: #60a5fa rose: #fb7185 gold: #fbbf24
# Generate :root CSS vars for entire palette
css_vars = generate_css_vars()
# → :root { --pyrith-neon: #00ff9d; --pyrith-mint: #7fffd4; ... }
# Glow effects
box_glow = glow('neon', 'md') # CSS box-shadow string
text_glow_css = text_glow('neon') # CSS text-shadow string
gradient = gradient_text('#00ff9d', '#7fffd4') # background-clip: text CSS
# Glass card variants
glass_css = GLASS_VARIANTS['default']
# → "background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);backdrop-filter:blur(12px);"
# Full base CSS (vars + resets + scrollbar + selection)
page.head_add(f'<style>{base_css()}</style>')Visual Effects
pyrith.style.effects provides advanced CSS and JavaScript visual effects — mesh gradients, aurora backgrounds, glow cards, animated borders, typewriter, scramble text, animated counters, and more.
from pyrith.style.effects import (
mesh_gradient, glow_card, gradient_border,
typewriter, animated_counter, particles_css,
AURORA_CSS, TILT_JS, custom_scrollbar, stagger_children
)
# Glassmorphism card with glow border
card = glow_card(content="<p>Hello</p>", glow_color='neon')
# Animated rotating gradient border
border_card = gradient_border(
content="<p>Hello</p>",
colors=['#00ff9d', '#7fffd4', '#60a5fa'],
speed=3.0,
)
# Typewriter effect
tw = typewriter("Hello, World!", speed_ms=60, cursor=True)
# Animated number counter (counts from 0 to target on scroll into view)
counter = animated_counter(1000, suffix='+', duration_ms=1500)
# CSS-only particle animation (no canvas)
particles = particles_css(count=20, color='#00ff9d', size_range=(2, 6))
# Inject aurora animated background
page.head_add(f'<style>{AURORA_CSS}</style>')
# 3D tilt on hover
page.script_add(inline=TILT_JS)
# Stagger children (animate in one by one)
stagger_css = stagger_children('.feature-grid', delay_ms=80)Effects reference
| Method / Attribute | Returns | Description |
|---|---|---|
| mesh_gradient(*colors, blur) | str | CSS mesh gradient background (multiple radial gradients) |
| glow_card(content, glow_color) | str | Glassmorphism card with colored glow border |
| gradient_border(content, colors, speed) | str | Card with animated rotating gradient border |
| typewriter(text, speed_ms, cursor) | str | Typewriter effect component |
| scramble_text(text) | str | Matrix-style character scramble reveal on hover |
| animated_counter(target, duration_ms, prefix, suffix) | str | Counts from 0 to target on scroll into view |
| particles_css(count, color, size_range) | str | CSS-only particle animation (no canvas needed) |
| AURORA_CSS | str | Animated aurora/gradient background keyframes + class |
| TILT_JS | str | Vanilla JS for perspective tilt on hover |
| custom_scrollbar(thumb_color, track_color) | str | CSS custom scrollbar styles |
| stagger_children(parent_class, delay_ms) | str | CSS stagger animation for child elements |
Tailwind Utilities (v0.3.0 additions)
The Tailwind utility engine gains vsite-compatible color tokens and glassmorphism utilities in v0.3.0.
from pyrith.style import TailwindSheet, tw
sheet = TailwindSheet()
# New color tokens (void/neon/mint/ghost palette)
sheet.add('bg-void', 'text-neon', 'border-mint', 'text-ghost')
sheet.add('bg-neon/10', 'border-neon/30', 'text-neon/70')
# Glass utility (shorthand for glassmorphism card)
sheet.add('glass') # → background + border + backdrop-filter
# Glow utilities
sheet.add('glow-neon', 'glow-mint', 'glow-ember') # → box-shadow glow effects
# Gradient text
sheet.add('gradient-text') # → background-clip: text with neon→mint gradient
# New animation utilities
sheet.add('animate-shimmer', 'animate-aurora', 'animate-float')
# Font stack utilities
sheet.add('font-display', 'font-mono')
# Scroll utilities
sheet.add('scroll-smooth', 'scroll-mt-20', 'scroll-mt-28')Advanced Components (v0.3.0)
pyrith.ui.components.advanced provides 30+ production-grade components for building any modern website. All components output HTML strings — no JavaScript framework required.
from pyrith.ui.components.advanced import (
Navbar, HeroSection, FeatureGrid, PricingSection, TestimonialGrid,
CodeBlock, CodeTabs, CommandPalette, Modal, Drawer, Tabs, Accordion,
DataTable, StatCard, Timeline, Tooltip, Badge, Avatar, Progress,
Skeleton, Footer, CTASection, LogoCloud, AnnouncementBanner,
Section, Container, Grid, Divider as UIDivider,
)
# Navigation
nav = Navbar(brand="App", links=[{"label":"Docs","href":"/docs"}], sticky=True, blur=True)
# Hero with particle background
hero = HeroSection(
title="Build Fast", subtitle="Pure Python.",
background="particles", # 'particles' | 'grid' | 'gradient' | 'none'
cta_primary={"label":"Start","href":"/docs"},
)
# Code block with syntax highlighting + copy button
code = CodeBlock(
code="from pyrith.ui.page_builder import DarkPage",
language="python",
filename="app.py",
show_copy=True,
theme="void", # 'void' | 'light' | 'github' | 'dracula'
line_numbers=True,
)
# Multi-language code tabs
tabs = CodeTabs(tabs=[
{"label":"Python","code":"import pyrith","language":"python"},
{"label":"Bash","code":"pip install pyrith","language":"bash"},
])
# Command palette (Cmd+K)
palette = CommandPalette(commands=[
{"label":"New File","shortcut":"N","action":"new-file"},
{"label":"Open Docs","shortcut":"D","href":"/docs"},
], placeholder="Search commands...")
# Data table with sort + search + pagination
table = DataTable(
headers=["Name","Role","Status"],
rows=[["Alice","Admin","Active"],["Bob","User","Inactive"]],
sortable=True, searchable=True, paginate=10,
)
# Stat cards (KPI tiles)
stat = StatCard(label="Monthly Revenue", value="$84,320", change="+12%", trend="up")
# Pricing section
pricing = PricingSection(plans=[
{"name":"Free","price":"$0","features":["5 pages","Community"],"cta":{"label":"Start Free","href":"/signup"}},
{"name":"Pro","price":"$29","features":["Unlimited","Priority support"],"cta":{"label":"Get Pro","href":"/signup?plan=pro"},"highlighted":True},
])
# Timeline
timeline = Timeline(items=[
{"date":"Nov 2026","title":"v0.3.0","description":"Production-complete release"},
{"date":"Nov 2025","title":"v0.2.0","description":"Frontend & component system"},
])
# Footer
footer = Footer(
brand="MyApp",
links={"Product":[{"label":"Docs","href":"/docs"}],"Company":[{"label":"About","href":"/about"}]},
social={"github":"https://github.com","twitter":"https://twitter.com"},
copyright="© 2026 MyApp. All rights reserved.",
)Canvas & WebGL Effects
pyrith.ui.canvas provides canvas-based and CSS-only visual effects for hero sections and backgrounds — with zero external dependencies.
from pyrith.ui.canvas import (
particle_field, matrix_rain, animated_noise_bg,
wave_divider, gradient_orbs, beam_background, hero_grid
)
# Interactive particle network (connects nearby particles, responds to mouse)
particles = particle_field(count=80, color='#00ff9d', connect_distance=120, speed=0.5)
# Matrix digital rain
rain = matrix_rain(color='#00ff9d', font_size=14, speed=1.0)
# Wave divider between sections
divider = wave_divider(color='#00ff9d', height=60, animated=True)
# Dot / line / cross grid overlay (like Vercel's hero)
grid = hero_grid(style='dots', color='#ffffff', opacity=0.05, size=30)
# Large blurred gradient orbs (like Linear's background)
orbs = gradient_orbs('#00ff9d', '#7fffd4', '#60a5fa', blur=80)
# Beam/grid background
beam = beam_background(color='#00ff9d', opacity=0.03)| Method / Attribute | Returns | Description |
|---|---|---|
| particle_field(count, color, connect_distance, speed, background) | str | Interactive particle network canvas (mouse proximity effect) |
| matrix_rain(color, font_size, speed) | str | Matrix digital rain canvas effect |
| animated_noise_bg(color1, color2, scale, speed) | str | Animated Perlin-like noise background |
| wave_divider(color, height, animated) | str | SVG wave divider between sections |
| gradient_orbs(*colors, blur) | str | Large blurred gradient orb decorations |
| beam_background(color, opacity) | str | Grid/beam overlay background |
| hero_grid(style, color, opacity, size) | str | "dots" | "lines" | "crosses" grid overlay |
Markdown Renderer
pyrith.ui.markdown renders CommonMark + GFM markdown to HTML with syntax highlighting, frontmatter support, and table of contents generation — no Pygments, no markdown2, no mistune required.
from pyrith.ui.markdown import render, frontmatter, toc, SyntaxHighlighter
# Render markdown to HTML
html = render("""
# Hello World
This is a **bold** statement.
```python
def greet(name: str) -> str:
return f"Hello, {name}!"
print(greet("pyrith"))
```
> [!NOTE]
> Callout blocks are supported.
| Column 1 | Column 2 |
|----------|----------|
| Row 1 | Data |
""", highlight=True, toc=True, base_url='/docs')
# Extract YAML frontmatter
meta, body = frontmatter("""---
title: My Post
date: 2026-11-07
tags: [python, web]
---
# My Post content
""")
# meta → {'title': 'My Post', 'date': '2026-11-07', 'tags': ['python', 'web']}
# Generate table of contents
entries = toc(md_source)
# → [{'level': 2, 'text': 'Installation', 'anchor': 'installation'}, ...]
# Syntax highlighting
hl = SyntaxHighlighter()
highlighted = hl.highlight("def foo(): pass", language='python')
theme_css = hl.css_theme('void') # 'void' | 'light' | 'github' | 'dracula'Supported fenced code languages: python, javascript, typescript, jsx, tsx, css, html, bash, shell, json, yaml, toml, sql, rust, go, java, cpp, c, markdown.
Fonts & Typography
pyrith.ui.fonts handles Google Fonts loading, custom font faces, modular type scale CSS variables, and beautiful prose styles for markdown content.
from pyrith.ui.fonts import (
google_fonts, font_face, typography_scale, prose_css, FONT_STACKS
)
# Load Google Fonts (generates <link> tag)
link_tag = google_fonts('Inter:wght@400;500;600;700', 'JetBrains+Mono:ital,wght@0,400;1,400')
page.head_add(link_tag)
# Custom @font-face
ff = font_face('CalSans', src='/fonts/cal-sans.woff2', weight='600', display='swap')
page.head_add(f'<style>{ff}</style>')
# Modular type scale CSS vars
scale_css = typography_scale(base_size=16, scale_ratio=1.25)
# → :root { --text-xs: 0.64rem; --text-sm: 0.8rem; --text-base: 1rem; --text-lg: 1.25rem; ... }
# Prose styles for markdown/MDX content
prose = prose_css(theme='dark') # 'dark' | 'light'
# Styles p, h1-h6, ul, ol, blockquote, code, pre, table, hr
page.head_add(f'<style>{prose}</style>')
# Font stack constants
# FONT_STACKS['sans'] → 'Inter, SF Pro Display, system-ui, sans-serif'
# FONT_STACKS['mono'] → 'Geist Mono, JetBrains Mono, Fira Code, monospace'
# FONT_STACKS['display'] → 'Cal Sans, Clash Display, Plus Jakarta Sans, sans-serif'Static Site Generation
pyrith.ssg exports your entire Pyrith app to a static dist/ directory — ready for Cloudflare Pages, Netlify, Vercel static, or GitHub Pages. HTML, CSS, and JS are all minified.
from pyrith import Pyrith
from pyrith.ui.page_builder import DarkPage
from pyrith.ui.components.advanced import HeroSection, Footer
from pyrith.ssg import build, StaticSiteGenerator
app = Pyrith()
@app.route('/')
def home(req):
page = DarkPage(title='My Site')
page.add(HeroSection(title='Hello', subtitle='Built with pyrith'))
page.add(Footer(brand='MySite', links={}))
return page.render()
@app.route('/about')
def about(req):
page = DarkPage(title='About — My Site')
page.add(HeroSection(title='About', subtitle='About this site'))
return page.render()
# Build all routes to dist/
result = build(app, output_dir='dist', minify=True)
print(f"Built {result['pages']} pages in {result['time_ms']:.0f}ms")
print(f"Output size: {result['size_kb']:.1f} KB")
# Or use the class directly for more control
ssg = StaticSiteGenerator(app, output_dir='dist', base_url='https://mysite.com')
routes = ssg.collect_routes() # auto-discovers all @app.route() paths
ssg.build(routes)
# Preview the built site locally
ssg.serve_preview(port=3000)# CLI workflow
pyrith build # SSG export → dist/
pyrith serve # serve dist/ locally on :3000
pyrith build --watch # rebuild on file changes| Method / Attribute | Returns | Description |
|---|---|---|
| build(app, output_dir, minify) | dict | Build all routes. Returns {pages, assets, size_kb, time_ms} |
| StaticSiteGenerator(app, output_dir, base_url, minify) | — | SSG instance for more control |
| ssg.collect_routes() | list[str] | Discover all static routes from the router |
| ssg.render_page(path) | dict | Render a route to {html, css, js} |
| ssg.build(routes) | dict | Build specific routes to output_dir |
| ssg.serve_preview(port) | None | Serve built static files locally for preview |
SEO & Metadata
pyrith.ui.seo generates complete SEO metadata — Open Graph, Twitter Cards, JSON-LD structured data, XML sitemaps, and robots.txt — all from Python.
from pyrith.ui.seo import MetaTags, json_ld, sitemap, robots_txt, breadcrumbs
# Full Open Graph + Twitter Card + canonical + robots meta tags
meta = MetaTags(
title="My App — Build Fast",
description="Build modern websites with pure Python.",
url="https://myapp.com",
image="https://myapp.com/og.png",
site_name="MyApp",
locale="en_US",
type="website",
twitter_card="summary_large_image",
keywords=["python", "web", "framework"],
author="Saad Salman",
theme_color="#0a0a0f",
canonical="https://myapp.com",
)
head_html = meta.render() # inject into <head>
# JSON-LD structured data
ld_website = json_ld('WebSite', name='MyApp', url='https://myapp.com')
ld_article = json_ld('Article', headline='My Post', author='Saad', datePublished='2026-11-07')
ld_faq = json_ld('FAQPage', mainEntity=[
{'@type':'Question','name':'What is pyrith?','acceptedAnswer':{'@type':'Answer','text':'A Python web framework.'}}
])
# Breadcrumbs (renders nav + JSON-LD BreadcrumbList)
bc = breadcrumbs([('Home','/'), ('Docs','/docs'), ('pyrith','')])
# XML sitemap
xml = sitemap([
{'loc': '/', 'priority': '1.0', 'changefreq': 'weekly'},
{'loc': '/docs', 'priority': '0.8', 'changefreq': 'monthly'},
{'loc': '/about', 'priority': '0.5'},
], base_url='https://myapp.com')
# robots.txt
robots = robots_txt(
allow=['/'],
disallow=['/admin', '/api/'],
sitemap_url='https://myapp.com/sitemap.xml',
)Page Transitions
pyrith.ui.page_transitions provides SPA-like page transitions — intercepts link clicks, plays exit animations, fetches the next page, and plays enter animations — without any JavaScript framework.
from pyrith.ui.page_transitions import transition_css, TRANSITION_JS, animated_route
# 5 transition types: 'fade', 'slide_up', 'slide_left', 'zoom', 'blur'
css = transition_css('fade', duration_ms=300, easing='ease-out')
page.head_add(f'<style>{css}</style>')
# Embed TRANSITION_JS once to enable SPA navigation
# (intercepts <a> clicks, plays exit, fetches next page, plays enter)
page.script_add(inline=TRANSITION_JS)
# Or use the route decorator to set transition per-page
@animated_route('/about', transition='slide_up')
def about(req):
page = DarkPage(title='About')
page.add(HeroSection(title='About'))
return page.render()
@animated_route('/docs', transition='fade')
def docs(req):
page = DarkPage(title='Docs')
return page.render()| Method / Attribute | Returns | Description |
|---|---|---|
| transition_css(name, duration_ms, easing) | str | CSS for named transition. Names: fade, slide_up, slide_left, zoom, blur |
| TRANSITION_JS | str | JS runtime: intercepts links, plays exit/enter animations, fetches pages |
| animated_route(path, transition) | decorator | Wrap a route with page transition support |
ASGI Support v0.4.0
Run pyrith under any ASGI server — Uvicorn, Hypercorn, or Daphne — by wrapping the app in ASGIApp. The adapter handles HTTP, WebSocket, and lifespan events.
# asgi_app.py
from pyrith import Pyrith
from pyrith.asgi import ASGIApp
app = Pyrith(__name__)
@app.get('/')
def index(request):
return {'hello': 'world'}
asgi = ASGIApp(app)uvicorn asgi_app:asgi --host 0.0.0.0 --port 8000 --workers 4ASGI mode enables async workers, graceful shutdown, and production-grade concurrency. Uvicorn is recommended; install with pip install uvicorn.
Dependency Injection v0.4.0
FastAPI-style Depends() with recursive resolution, caching, and generator-aware teardown. Use @inject on any handler to enable injection.
from pyrith.di import Depends, inject
def get_db():
db = connect_db()
try:
yield db # auto-cleaned up after the request
finally:
db.close()
def get_current_user(db=Depends(get_db)):
token = ... # read from request context
return db.query(User).filter(id=token['sub']).first()
@app.get('/profile')
@inject
def profile(request, user=Depends(get_current_user)):
return {'user': user.name, 'email': user.email}| Method / Attribute | Returns | Description |
|---|---|---|
| Depends(fn, use_cache=True) | — | Declare a dependency. use_cache=True shares one instance per request |
| @inject | decorator | Enable dependency injection on a handler function |
| DependencyResolver | class | Resolves recursive deps, calls generator teardown, respects scopes |
OAuth2 & Social Login v0.4.0
Six OAuth2 providers out-of-the-box. All use pure urllib — zero external dependencies. Full PKCE support for public clients.
from pyrith.auth.oauth import GitHubProvider, GoogleProvider
github = GitHubProvider(
client_id='your_client_id',
client_secret='your_secret',
redirect_uri='https://myapp.com/auth/github/callback',
)
@app.get('/auth/github')
def github_login(request):
url, state = github.get_authorization_url(pkce=True)
request.session.set('oauth_state', state)
return RedirectResponse(url)
@app.get('/auth/github/callback')
def github_callback(request):
code = request.query['code']
token = github.exchange_code(code)
user_info = github.get_user(token['access_token'])
# user_info: { id, login, email, avatar_url, name }
request.session.set('user_id', user_info['id'])
return RedirectResponse('/dashboard')Available providers
| Method / Attribute | Returns | Description |
|---|---|---|
| GitHubProvider | login, email, avatar_url, name | GitHub OAuth2 App |
| GoogleProvider | sub, email, name, picture | Google OAuth2 / OIDC |
| DiscordProvider | id, username, email, avatar | Discord OAuth2 |
| TwitterProvider | id, username, name, profile_image_url | Twitter/X OAuth2 |
| MicrosoftProvider | id, mail, displayName | Microsoft/Azure AD OAuth2 |
| AppleProvider | sub, email | Sign in with Apple (PKCE required) |
MFA / TOTP v0.4.0
RFC 6238 Time-based One-Time Passwords implemented from scratch in pure Python — no pyotp dependency. Compatible with Google Authenticator, Authy, and 1Password.
from pyrith.auth.mfa import TOTP, BackupCodes
# ── Enrolment
totp = TOTP(issuer='MyApp')
secret, uri = totp.setup(user_email='[email protected]')
# Store secret in DB. Show QR code from uri to user.
# uri = "otpauth://totp/MyApp:[email protected]?secret=BASE32&issuer=MyApp"
# ── Verification (at login)
code_from_user = request.form()['totp_code']
if totp.verify(secret, code_from_user):
request.session.set('mfa_verified', True)
return RedirectResponse('/dashboard')
return 'Invalid code', 401
# ── Backup codes (generate once, store hashes)
codes = BackupCodes.generate(count=10) # ["ABCD-EFGH", ...]
hashed = BackupCodes.hash_all(codes) # store these in DB
user.backup_code_hashes = hashed
user.save()
# At login — verify and consume a backup code
if BackupCodes.verify(user.backup_code_hashes, submitted_code):
user.save() # hashes list now has the used code removed
return RedirectResponse('/dashboard')Database Backends v0.4.0
Connect to PostgreSQL, MySQL, or MongoDB in addition to the default SQLite backend. The ORM API is identical across backends.
from pyrith.db import Database
# SQLite (default, zero deps)
db = Database('sqlite:///myapp.db')
# PostgreSQL — pip install pyrith[postgres]
db = Database('postgres://user:pass@localhost:5432/myapp')
# MySQL — pip install pyrith[mysql]
db = Database('mysql://user:pass@localhost:3306/myapp')
# MongoDB — pip install pyrith[mongodb]
db = Database('mongodb://localhost:27017/myapp')
# Raw query
rows = db.execute('SELECT id, email FROM users WHERE active = ?', [True])
# Schema migration
db.migrate([
'ALTER TABLE users ADD COLUMN last_login TEXT',
'CREATE INDEX idx_users_email ON users(email)',
])Cache v0.4.0
from pyrith import cache
# In-memory LRU cache (zero deps)
store = cache.MemoryCache(max_size=1000, default_ttl=300)
store.set('key', {'data': 1}, ttl=60)
val = store.get('key') # None if expired
store.delete('key')
store.clear()
# Decorator — cache expensive function results
@cache.cached(ttl=120)
def get_user_profile(user_id: int):
return User.objects.get(id=user_id)
# Redis backend — pip install pyrith[redis]
redis_store = cache.RedisCache(host='localhost', port=6379, db=0, password='…')
# Rate limiting
limiter = cache.RateLimiter(store, limit=100, window=60)
if not limiter.allow(request.headers.get('x-forwarded-for', '127.0.0.1')):
return JSONResponse({'error': 'rate limited'}, status=429)Search v0.4.0
Full-text search via SQLite FTS5, vector similarity search with cosine scoring, and hybrid search combining both — no Elasticsearch, no Algolia.
from pyrith import search
# ── Full-text search (SQLite FTS5)
idx = search.FullTextIndex('search.db', table='articles', fields=['title', 'body'])
# Index documents
idx.index({'id': 1, 'title': 'Python tips', 'body': 'Learn Python fast'})
idx.index({'id': 2, 'title': 'Web dev with Pyrith', 'body': 'Build servers in Python'})
# Search
results = idx.search('python tips', limit=10)
# [{'id': 1, 'title': 'Python tips', 'rank': -0.42, ...}]
# ── Vector similarity search
vec_idx = search.VectorIndex('vectors.db')
vec_idx.add(doc_id=1, vector=[0.1, 0.9, 0.3, ...]) # pre-computed embeddings
similar = vec_idx.query([0.1, 0.8, 0.4, ...], top_k=5)
# ── Hybrid search (text + vector, re-ranked)
hybrid = search.HybridSearch(fts=idx, vectors=vec_idx)
results = hybrid.search('machine learning', query_vector=embed('machine learning'), limit=10)Background Tasks v0.4.0
Threading-based task queue with priorities, retries, and a cron scheduler. No Redis required for the default backend.
from pyrith import tasks
@tasks.task
def send_welcome_email(user_id: int):
user = User.objects.get(id=user_id)
email.send(to=user.email, subject='Welcome!', html='<h1>Hi!</h1>')
# Enqueue for background execution
send_welcome_email.delay(user_id=42)
# Cron schedule (runs in background worker)
@tasks.cron('0 9 * * *') # every day at 9am
def daily_digest():
for user in User.objects.filter(notifications=True).all():
send_digest.delay(user_id=user.id)
# Retry on failure
@tasks.task(retries=3, retry_delay=60, queue='critical')
def charge_subscription(subscription_id: int):
result = stripe.charges.create(subscription_id=subscription_id)
if not result.success:
raise ValueError('Charge failed')
# Start the worker (call once at app startup)
tasks.start_worker(queues=['default', 'critical'], concurrency=4)API Layer v0.4.0
Declarative API router with typed response schemas, auto-generated OpenAPI spec, and built-in pagination. No FastAPI or DRF needed.
from pyrith.api import APIRouter, Schema, paginate
router = APIRouter(prefix='/api')
class UserSchema(Schema):
id: int
name: str
email: str
@router.get('/users', response=list[UserSchema])
def list_users(request):
return User.objects.all()
@router.post('/users', response=UserSchema, status=201)
def create_user(request):
data = request.json()
return User.create(**data)
@router.get('/posts')
def list_posts(request):
qs = Post.objects.filter(published=True)
return paginate(qs, request, per_page=20)
# → { items: [...], total: 100, page: 1, per_page: 20, pages: 5 }
app.mount(router)
# GET /api/openapi.json — auto-generated OpenAPI 3.1 spec
# GET /api/docs — Swagger UIEmail & SMS v0.4.0
from pyrith import email
# SMTP (works with Gmail, Postfix, Mailhog, etc.)
mailer = email.SMTPMailer(
host='smtp.gmail.com', port=587,
username='[email protected]', password='app_password',
use_tls=True,
)
mailer.send(
to='[email protected]',
subject='Welcome to MyApp',
html='<h1>Welcome!</h1><p>Thanks for signing up.</p>',
from_='MyApp <[email protected]>',
)
# Template-based email
mailer.send_template(
to='[email protected]',
template='emails/welcome.html',
context={'user': user, 'confirm_url': 'https://myapp.com/confirm/…'},
subject='Please confirm your email',
)
# Provider APIs (pure urllib, zero extra deps)
sendgrid = email.SendGridMailer(api_key='SG.…')
resend = email.ResendMailer(api_key='re_…')
ses = email.SESMailer(access_key='…', secret_key='…', region='us-east-1')SMS
from pyrith import sms
twilio = sms.TwilioSMS(account_sid='AC…', auth_token='…', from_='+15550001111')
twilio.send(to='+15559998888', body='Your verification code is 123456')
vonage = sms.VonageSMS(api_key='…', api_secret='…', from_='MyApp')
vonage.send(to='+44…', body='Your code: 456789')
# Incoming SMS webhook
@app.post('/sms/inbound')
def inbound_sms(request):
data = request.form()
print(f'From: {data["From"]} → {data["Body"]}')
return sms.TwiML.message('Got it!')File Storage v0.4.0
from pyrith import storage
# Local filesystem
local = storage.LocalStorage(base_dir='./uploads', base_url='/files')
path = local.save('avatar.png', file_bytes, content_type='image/png')
url = local.url(path) # "/files/avatar.png"
# S3 / Cloudflare R2 — pip install pyrith[s3]
s3 = storage.S3Storage(
bucket='my-bucket',
access_key='AKIA…',
secret_key='…',
region='us-east-1',
# endpoint_url='https://…r2.cloudflarestorage.com' # for R2
)
s3.save('uploads/photo.jpg', file_bytes)
url = s3.url('uploads/photo.jpg')
signed_url = s3.signed_url('uploads/photo.jpg', expires=3600)
# File upload handler
@app.post('/upload')
def upload(request):
file = request.file('photo')
if file is None:
return JSONResponse({'error': 'No file'}, status=400)
path = s3.save(f'uploads/{file.filename}', file.data, content_type=file.content_type)
return JSONResponse({'url': s3.url(path), 'size': file.size})Payments v0.4.0
Stripe, Lemon Squeezy, and Paddle out-of-the-box. Webhook signature verification is HMAC-SHA256 — no Stripe SDK required.
from pyrith import payments
stripe = payments.StripeClient(api_key='sk_live_…')
# Checkout session → redirect to Stripe hosted page
session = stripe.checkout.create(
line_items=[{'price': 'price_…', 'quantity': 1}],
mode='subscription',
success_url='https://myapp.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url='https://myapp.com/pricing',
)
return RedirectResponse(session.url)
# Webhook (HMAC-SHA256 verified)
@app.post('/webhooks/stripe')
def stripe_webhook(request):
event = stripe.webhooks.construct_event(
payload=request.body,
sig_header=request.get_header('stripe-signature'),
secret='whsec_…',
)
if event.type == 'customer.subscription.created':
sub = event.data['object']
User.objects.filter(stripe_customer_id=sub['customer']).update(plan='pro')
elif event.type == 'invoice.payment_failed':
...
return JSONResponse({'received': True})Always verify Stripe webhooks. The construct_event() call raises SignatureVerificationError if the signature is invalid — handle it with a 400 response.
Webhooks v0.4.0
from pyrith import webhooks
hub = webhooks.WebhookHub(signing_secret='wh_secret_…')
# Register a subscriber endpoint
hub.register('https://partner.example.com/hook', events=['order.created', 'order.fulfilled'])
# Send a webhook (auto-signed, retried on failure)
hub.send(event='order.created', payload={'order_id': 42, 'total': 99.00})
# Incoming webhook — verify signature
@app.post('/incoming')
def incoming(request):
if not hub.verify(request.body, request.get_header('x-webhook-signature')):
return JSONResponse({'error': 'Unauthorized'}, status=401)
data = request.json()
# process data...
return JSONResponse({'ok': True})AI / LLM Integration v0.4.0
Multi-provider LLM client with streaming, structured output, embeddings, and a full RAG pipeline. No LangChain dependency.
from pyrith import ai
llm = ai.LLM(provider='anthropic', model='claude-sonnet-4-6', api_key='sk-ant-…')
# Simple generation
response = llm.generate('Explain Python async in one paragraph.')
print(response.text)
# Streaming response (SSE to browser)
@app.get('/chat')
def chat(request):
prompt = request.query.get('q', 'Hello!')
return ai.stream_response(llm.stream(prompt))
# Structured output — returns a typed Python object
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
product = llm.generate_object(
Product,
'Extract: Blue widget, $29.99, available now'
)
print(product.name, product.price) # "Blue widget" 29.99Embeddings & RAG
from pyrith import ai, search
embedder = ai.Embeddings(provider='openai', api_key='sk-…')
# Embed a string
vec = embedder.embed('What is pyrith?')
# RAG pipeline: ingest → query
rag = ai.RAG(embedder=embedder, vector_index=search.VectorIndex('vecs.db'))
# Ingest documents (chunked + embedded automatically)
rag.ingest('Pyrith is a Python web framework.', metadata={'source': 'readme'})
rag.ingest_file('./docs/guide.md')
# Query: retrieves relevant chunks, assembles prompt, calls LLM
answer = rag.query('What is pyrith?', llm=llm)
print(answer.text)
print(answer.sources) # list of metadata dicts| Method / Attribute | Returns | Description |
|---|---|---|
| LLM(provider, model, api_key) | — | Supported providers: anthropic, openai, gemini, groq, ollama |
| llm.generate(prompt) | LLMResponse | Single-shot generation |
| llm.stream(prompt) | Generator[str] | Token stream for SSE |
| llm.generate_object(cls, prompt) | cls instance | Structured output — returns a dataclass instance |
| Embeddings(provider, api_key) | — | Embed text → float[] |
| embedder.embed(text) | list[float] | Single embedding vector |
| ai.stream_response(gen) | SSEResponse | Wrap a stream generator as an SSE response |
Admin Panel v0.4.0
Auto-generates a full CRUD admin UI for any registered ORM model — list, filter, search, paginate, create, edit, delete.
from pyrith import admin
class UserAdmin(admin.ModelAdmin):
list_display = ['id', 'username', 'email', 'role', 'created_at']
list_filter = ['role', 'is_active']
search_fields = ['username', 'email']
ordering = ['-created_at']
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'author_id', 'published', 'view_count', 'created_at']
list_filter = ['published']
search_fields = ['title', 'body']
site = admin.AdminSite(prefix='/admin', secret='super-secret-admin-key')
site.register(User, UserAdmin)
site.register(Post, PostAdmin)
app.mount(site)
# Auto-generated routes:
# GET /admin/ — dashboard
# GET /admin/users/ — list (search, filter, pagination)
# GET /admin/users/new — create form
# POST /admin/users/new
# GET /admin/users/{id}/edit
# POST /admin/users/{id}/edit
# POST /admin/users/{id}/deleteTelemetry & Observability v0.4.0
Prometheus-compatible metrics, automatic request instrumentation, and health/readiness endpoints — all in pure Python.
from pyrith import telemetry
# Auto-instrument all requests (latency histogram + request counter)
telemetry.instrument(app)
# Endpoints:
# GET /metrics → Prometheus text format
# GET /health → {"status":"ok","checks":{"database":true,...}}
# GET /ready → 200 or 503
# Custom metrics
counter = telemetry.counter('api_calls_total', labels=['endpoint', 'status'])
gauge = telemetry.gauge('active_connections')
hist = telemetry.histogram('checkout_duration_seconds')
@app.post('/checkout')
def checkout(request):
with hist.time():
result = process_order(request)
counter.inc(endpoint='/checkout', status='200')
return result
# Health checks — contributes to /health response
@telemetry.health_check('database')
def check_db():
try:
Post.objects.count()
return True
except Exception:
return False
@telemetry.health_check('redis')
def check_redis():
return redis_store.ping()Content Collections v0.4.0
Astro-style file-based CMS. Load Markdown files with frontmatter, type-check fields, query and sort — all from Python.
from pyrith import content
# Load all .md files from ./content/blog/
collection = content.Collection('./content/blog', schema={
'title': str,
'date': str,
'author': str,
'tags': list,
'draft': bool,
})
# Query
all_posts = collection.all()
published = collection.where(draft=False).sort_by('date', reverse=True).limit(10).all()
tagged_python = collection.where(tags__contains='python').all()
# Single post
post = collection.get('hello-world') # finds ./content/blog/hello-world.md
print(post.frontmatter['title']) # "Hello World"
print(post.html) # rendered HTML body
print(post.excerpt(words=50)) # auto-generated excerpt
print(post.reading_time) # estimated reading time in minutesMulti-Tenancy v0.4.0
from pyrith import tenancy
# Subdomain-based tenant routing: acme.myapp.com → tenant "acme"
resolver = tenancy.SubdomainResolver(domain='myapp.com', tenant_model=Organization)
app.use(tenancy.TenantMiddleware(resolver))
# In any handler — tenant is resolved from the subdomain automatically
@app.get('/dashboard')
def dashboard(request):
tenant = tenancy.current_tenant() # Organization instance
# ORM queries are scoped to this tenant automatically
posts = Post.objects.filter(published=True).all()
return render_dashboard(tenant, posts)
# Scoped model — tenant_id auto-set from current_tenant()
@tenancy.scoped_model
class Post(Model):
class Meta:
db = 'app.db'
table_name = 'posts'
tenant_id = IntField() # auto-populated
title = TextField()
body = TextField()Feature Flags v0.4.0
from pyrith import flags
store = flags.FlagStore()
# Define flags with defaults and rollout rules
store.define('new_dashboard', default=False)
store.define('beta_checkout', rollout_percent=20) # 20% of users
store.define('dark_mode', default=True)
@app.get('/dashboard')
def dashboard(request):
user_id = request.session.get('user_id')
if flags.is_enabled('new_dashboard', user_id=user_id):
return render_new_dashboard(request)
return render_legacy_dashboard(request)
# A/B testing
variant = flags.variant('checkout_flow', user_id=user_id,
variants=['control', 'v2', 'v3'])
# → "control" | "v2" | "v3" (deterministic per user)Config & Environment v0.4.0
from pyrith import config
cfg = config.Config() # auto-loads .env from project root
SECRET_KEY = cfg('SECRET_KEY') # required, raises if missing
DATABASE_URL = cfg('DATABASE_URL', default='sqlite:///app.db')
DEBUG = cfg('DEBUG', cast=bool, default=False)
PORT = cfg('PORT', cast=int, default=8080)
# Schema-validated config (recommended for production)
class AppConfig(config.BaseConfig):
secret_key: str
database_url: str = 'sqlite:///app.db'
debug: bool = False
allowed_hosts: list[str] = ['localhost']
smtp_host: str = 'localhost'
stripe_key: str = ''
app_cfg = AppConfig.load() # validates all required fields; raises on missingi18n / Localization v0.4.0
from pyrith import i18n
i18n.load_translations('./locales') # loads locales/en.json, locales/fr.json, …
# locales/en.json
# {
# "welcome_message": "Welcome, {name}!",
# "items_count": "{n} item||{n} items"
# }
@app.get('/hello')
def hello(request):
lang = i18n.detect_locale(request) # from Accept-Language, ?lang=, or session
_ = i18n.get_translator(lang)
return HTMLResponse(f'<p>{_("welcome_message", name="Alice")}</p>')
# Pluralization
_ = i18n.get_translator('en')
print(_('items_count', n=1)) # "1 item"
print(_('items_count', n=5)) # "5 items"Deploy v0.4.0
ASGI (Uvicorn / Railway / Render)
# Procfile or railway.toml
# web: uvicorn asgi_app:asgi --host 0.0.0.0 --port $PORTDocker
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install pyrith[all]
EXPOSE 8000
CMD ["uvicorn", "asgi_app:asgi", "--host", "0.0.0.0", "--port", "8000"]Cloudflare Workers
from pyrith.deploy import cloudflare_handler
from app import app # your Pyrith app
# Export for the CF Workers runtime
cf_app = cloudflare_handler(app)Vercel / Netlify
from pyrith.deploy import vercel_handler
from app import app
def handler(request, response):
return vercel_handler(app, request, response)