Docs/pyrith/
Latest
Framework Reference — v0.4.0

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.

pip install pyrith==0.4.0GitHub →PyPI →

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.py

Architecture

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 / version

Routing

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 / AttributeReturnsDescription
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 / AttributeReturnsDescription
request.methodstr"GET", "POST", etc. — always uppercase
request.pathstrURL path, e.g. "/users/42"
request.paramsdict[str, str]Path parameters from {name} segments
request.querydict[str, Any]Parsed query string key/value pairs
request.headersdict[str, str]All headers, keys lowercased
request.bodybytesRaw request body bytes
request.json()AnyParse body as JSON (cached after first call)
request.form()dictParse URL-encoded or multipart body
request.files()dict[str, UploadedFile]Uploaded files from multipart/form-data
request.file(name)UploadedFile | NoneSingle uploaded file by field name
request.text()strDecode body as UTF-8 string
request.cookies()dict[str, str]Parse Cookie header
request.bearer_tokenstr | NoneExtracts Bearer token from Authorization header
request.content_typestrContent-Type header value
request.is_jsonboolTrue if Content-Type is application/json
request.is_formboolTrue if Content-Type is form/multipart
request.statedictPer-request state dict for middleware ↔ handler communication
request.get_header(name, default)strCase-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 resp

Return coercion

ParameterTypeDescription
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> tags

File 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 / AttributeReturnsDescription
file.filenamestrOriginal filename from the browser
file.content_typestrMIME type, e.g. "image/jpeg"
file.databytesRaw file bytes
file.sizeintFile size in bytes
file.save(path)NoneWrite 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

ParameterTypeDescriptionDefault
IntFieldINTEGER / intInteger value. Use primary_key=True for PK.—
FloatFieldREAL / floatFloating-point value.—
TextFieldTEXT / strString value. Supports nullable, unique, default.—
BoolFieldINTEGER / boolStored as 0/1, returned as Python bool.False
DateTimeFieldTEXT / datetimeISO 8601 string; auto-converts to datetime.—
ForeignKey(table)INTEGER / intRaw 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 / AttributeReturnsDescription
StringField(min_length, max_length)strText with optional length bounds
IntegerField(min_val, max_val)intInteger with optional range bounds
EmailField()strEmail format validation via regex
PasswordField(min_length)strLike StringField, renders as type=password
SelectField(choices)strSingle select; validates against choices list
TextareaField(min_length, max_length)strMulti-line text
FileField(allowed_extensions, max_size)UploadedFileFile upload; validates extension and size
BooleanField()boolCheckbox; 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 / AttributeReturnsDescription
ws.send(data)NoneSend text (str) or binary (bytes) frame; thread-safe
ws.recv()str | bytes | NoneReceive next frame; None means connection closed
ws.ping()NoneSend a ping frame; browser responds with pong automatically
ws.close(code, reason)NoneSend close frame and shut down connection
ws.closedboolTrue if the connection has been closed
rooms.get(name)RoomGet or create a named room
room.join(ws)NoneAdd a WebSocket to the room
room.leave(ws)NoneRemove a WebSocket from the room
room.broadcast(data, exclude)NoneSend to all in room, optionally excluding one connection
room.sizeintNumber 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

FamilyExamples
Spacingp-4, m-auto, px-6, py-2, mt-8, gap-3, space-x-2
Sizingw-full, h-screen, min-w-0, max-w-7xl, w-1/2, size-10
Typographytext-xs→9xl, font-bold, leading-6, tracking-wide, uppercase, truncate, line-clamp-3
Colorstext-blue-500, bg-red-600, border-green-400, ring-purple-300
Bordersborder, border-2, border-t, rounded-full, rounded-t-xl, divide-y
Display & Layouthidden, flex, grid, inline-flex, block, contents
Flexboxflex-col, flex-wrap, items-center, justify-between, flex-1, grow, shrink
Gridgrid-cols-3, col-span-2, col-start-1, row-span-2, auto-cols-fr
Positionrelative, absolute, fixed, sticky, inset-0, top-4, z-50
Effectsopacity-50, blur-md, brightness-110, grayscale, drop-shadow-lg
Shadowsshadow-sm, shadow-lg, shadow-xl, shadow-2xl, shadow-none
Transitionstransition-all, transition-colors, duration-300, ease-in-out, delay-150
Transformsscale-110, rotate-45, translate-x-4, skew-x-6
Animationsanimate-spin, animate-ping, animate-pulse, animate-bounce
Interactionscursor-pointer, select-none, pointer-events-none, resize, appearance-none
Ring & Outlineoutline-none, ring-2, ring-offset-2, ring-blue-500
Responsivesm: md: lg: xl: 2xl: prefixes — emit @media blocks
Dark modedark: prefix — emits @media (prefers-color-scheme: dark)
Pseudo-classeshover: focus: active: disabled: checked: first: last: group-hover:
Arbitrary valuesw-[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

fadeInfadeOutslideInUpslideInDownslideInLeftslideInRightslideOutUpslideOutDownzoomInzoomOutshakepulsebouncespinpingfloatwiggleflashheartbeatripplegradientXtypingblinkCaret

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 == 401

TestClient API

Method / AttributeReturnsDescription
client.get(path, params, headers)ResponseGET request
client.post(path, json, data, headers)ResponsePOST request — json= or data= (form)
client.put(path, json, headers)ResponsePUT request
client.delete(path, headers)ResponseDELETE request
resp.status_codeintHTTP status code
resp.json()AnyParse response body as JSON
resp.textstrResponse body as string
resp.headersdictResponse headers
resp.cookiesdictResponse 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.

v0.2.0LatestStable2026-11-07

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.

Backend
  • 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
Frontend & UI
  • 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
Tests:160 passing
v0.1.0Initial Release2026-11-06

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
Tests:35 passing

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 / AttributeReturnsDescription
h(tag, props, *children)VNodeCreate a virtual DOM node
vnode.render_html()strRender VNode tree to HTML string (server-side)
vnode.render_js_literal()strRender as JavaScript object literal for the runtime
diff(old, new)list[dict]Compute minimal patch set between two VNode trees
patch_js(patches)strGenerate JS to apply patches to the real DOM
RUNTIME_JSstr~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 / AttributeReturnsDescription
Page(title, theme, lang, favicon)PageCreate a page. theme: "dark" | "light"
page.meta(**kwargs)PageSet SEO/OG meta tags (description, url, image, etc.)
page.fonts(*families)PageLoad Google Fonts (auto-generates <link> tag)
page.css(*stylesheets)PageAdd extra CSS (URL or inline string)
page.js(*scripts)PageAdd extra JS (URL or inline string)
page.add(*components)PageAppend HTML sections/components
page.render()strReturn complete <!DOCTYPE html> document
DarkPage(title)DarkPagePre-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 / AttributeReturnsDescription
mesh_gradient(*colors, blur)strCSS mesh gradient background (multiple radial gradients)
glow_card(content, glow_color)strGlassmorphism card with colored glow border
gradient_border(content, colors, speed)strCard with animated rotating gradient border
typewriter(text, speed_ms, cursor)strTypewriter effect component
scramble_text(text)strMatrix-style character scramble reveal on hover
animated_counter(target, duration_ms, prefix, suffix)strCounts from 0 to target on scroll into view
particles_css(count, color, size_range)strCSS-only particle animation (no canvas needed)
AURORA_CSSstrAnimated aurora/gradient background keyframes + class
TILT_JSstrVanilla JS for perspective tilt on hover
custom_scrollbar(thumb_color, track_color)strCSS custom scrollbar styles
stagger_children(parent_class, delay_ms)strCSS 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 / AttributeReturnsDescription
particle_field(count, color, connect_distance, speed, background)strInteractive particle network canvas (mouse proximity effect)
matrix_rain(color, font_size, speed)strMatrix digital rain canvas effect
animated_noise_bg(color1, color2, scale, speed)strAnimated Perlin-like noise background
wave_divider(color, height, animated)strSVG wave divider between sections
gradient_orbs(*colors, blur)strLarge blurred gradient orb decorations
beam_background(color, opacity)strGrid/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 / AttributeReturnsDescription
build(app, output_dir, minify)dictBuild 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)dictRender a route to {html, css, js}
ssg.build(routes)dictBuild specific routes to output_dir
ssg.serve_preview(port)NoneServe 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 / AttributeReturnsDescription
transition_css(name, duration_ms, easing)strCSS for named transition. Names: fade, slide_up, slide_left, zoom, blur
TRANSITION_JSstrJS runtime: intercepts links, plays exit/enter animations, fetches pages
animated_route(path, transition)decoratorWrap 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 4
💡

ASGI 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 / AttributeReturnsDescription
Depends(fn, use_cache=True)—Declare a dependency. use_cache=True shares one instance per request
@injectdecoratorEnable dependency injection on a handler function
DependencyResolverclassResolves 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 / AttributeReturnsDescription
GitHubProviderlogin, email, avatar_url, nameGitHub OAuth2 App
GoogleProvidersub, email, name, pictureGoogle OAuth2 / OIDC
DiscordProviderid, username, email, avatarDiscord OAuth2
TwitterProviderid, username, name, profile_image_urlTwitter/X OAuth2
MicrosoftProviderid, mail, displayNameMicrosoft/Azure AD OAuth2
AppleProvidersub, emailSign 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)

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 UI

Email & SMS v0.4.0

Email

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.99

Embeddings & 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 / AttributeReturnsDescription
LLM(provider, model, api_key)—Supported providers: anthropic, openai, gemini, groq, ollama
llm.generate(prompt)LLMResponseSingle-shot generation
llm.stream(prompt)Generator[str]Token stream for SSE
llm.generate_object(cls, prompt)cls instanceStructured output — returns a dataclass instance
Embeddings(provider, api_key)—Embed text → float[]
embedder.embed(text)list[float]Single embedding vector
ai.stream_response(gen)SSEResponseWrap 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}/delete

Telemetry & 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 minutes

Multi-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 missing

i18n / 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 $PORT

Docker

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)