Pyrith
A pure-Python full-stack web framework. Backend routing, HTML components, CSS generation, ORM, authentication, and form validation — all in Python. Zero external dependencies.
pip install pyrith
Overview
Pyrith is a monolithic, self-contained web framework for Python 3.10+. Everything that a modern web application needs — HTTP server, declarative routing, component-based UI, programmatic CSS, database ORM, session auth, and form validation — lives inside a single package that ships with no dependencies beyond the Python standard library.
The design philosophy is total Python. You never leave the Python ecosystem. There is no HTML template language to learn, no CSS preprocessor to install, no JavaScript build step, no configuration file format, and no external process to run. If you can write Python, you can build a full web application with Pyrith.
Table of Contents
- Quick Start
- Architecture
- HTTP Server & Routing
- Middleware
- Request Object
- Response Objects
- UI / HTML DSL
- Component System
- Page Builder
- CSS Engine
- Tailwind-like Utilities
- ORM
- Template Engine
- Session Authentication
- Route Guards
- Forms & Validation
- WebSocket
- CLI
- Error Handling
- PyPI Publishing
- Roadmap
Quick Start
from pyrith import Pyrith
from pyrith import ui, style
from pyrith.response import HTMLResponse
app = Pyrith(__name__)
@app.get("/")
def index(request):
css = style.CSS()
css.rule("body", font_family="system-ui", text_align="center", padding="4rem")
css.rule("h1", color="#2563eb")
page = ui.Page(title="Hello Pyrith")
page.head_add(f"<style>{css}</style>")
page.body_add(
ui.h1("Hello from Pyrith"),
ui.p("A full-stack web framework — 100% Python."),
ui.a("GitHub →", href="https://github.com/saadsalmanakram/Pyrith"),
)
return HTMLResponse(page.to_html())
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8080, debug=True)
python app.py
# [Pyrith] Running on http://127.0.0.1:8080 (debug=True)
Or use the CLI:
pyrith new myapp
cd myapp
python app.py
Architecture
pyrith/
├── app.py # Pyrith class, HTTP server (ThreadingHTTPServer), middleware pipeline
├── request.py # Request — method, path, headers, query, body, params, cookies
├── response.py # Response, HTMLResponse, JSONResponse, RedirectResponse, StreamResponse
├── router.py # Router, Route (regex param extraction), RouteGroup
├── middleware.py # Middleware base, CORS, Logging, StaticFiles, GZip
├── websocket.py # WebSocket frame codec, upgrade handshake (stdlib only)
├── template.py # Template engine ({{ }}, {% if %}, {% for %}, extends, include)
├── ui/
│ ├── html.py # Element DSL — div(), span(), h1(), input_(), raw(), …
│ ├── component.py # Component base class, render(), page()
│ └── page.py # Page layout builder (head slots, body slots, scripts)
├── style/
│ ├── css.py # CSS, Rule, MediaQuery — programmatic stylesheet
│ ├── stylesheet.py# StyleSheet — scoped component CSS
│ ├── tailwind.py # TailwindSheet — utility-class → CSS generator, tw() helper
│ └── units.py # px(), rem(), em(), pct(), vw(), vh(), fr(), s(), ms()
├── orm/
│ ├── model.py # Model metaclass, save(), delete(), get(), create_table()
│ ├── fields.py # IntField, TextField, BoolField, DateTimeField, ForeignKey
│ └── query.py # QuerySet — filter(), order_by(), limit(), offset(), count()
├── auth/
│ ├── session.py # SessionStore (HMAC-signed cookies), Session
│ └── decorators.py# login_required(), role_required()
├── forms/
│ └── form.py # Form, StringField, IntegerField, EmailField, BooleanField
└── cli/
└── commands.py # pyrith new / run / version
Key invariant: Every module uses only the Python standard library (http.server, urllib, json, sqlite3, hashlib, hmac, gzip, socket, threading, re, html, mimetypes, time, os). There is intentionally no requirements.txt for the core library.
HTTP Server & Routing
from pyrith import Pyrith
app = Pyrith(__name__)
# Static route
@app.get("/")
def home(request):
return "<h1>Home</h1>"
# Path parameter
@app.get("/users/{user_id}")
def get_user(request):
uid = request.params["user_id"] # always a str
return f"User: {uid}"
# Multiple methods on one path
@app.route("/items", methods=["GET", "POST"])
def items(request):
if request.method == "POST":
return "created"
return "list"
# Route groups (prefix namespacing)
api = app.group("/api/v1")
@api.get("/status")
def status(request):
return {"status": "ok"}
# Start the server
app.run(host="0.0.0.0", port=8080, debug=False)
Path parameter syntax
/users/{id} → params["id"]
/files/{path} → params["path"] (single segment only)
/static/* → matches any suffix (wildcard)
Internally, {name} is compiled to the regex capture group ([^/]+). The router iterates routes in declaration order and returns the first match.
Middleware
from pyrith.middleware import CORSMiddleware, LoggingMiddleware, StaticFilesMiddleware, GZipMiddleware
app.use(LoggingMiddleware())
app.use(CORSMiddleware(allow_origins=["https://example.com"], allow_credentials=True))
app.use(StaticFilesMiddleware(directory="./public", url_prefix="/static"))
app.use(GZipMiddleware())
Writing a custom middleware
from pyrith.middleware import Middleware
class RateLimitMiddleware(Middleware):
def __init__(self, limit: int = 100) -> None:
self._counts: dict = {}
self._limit = limit
def before_request(self, request):
ip = request.get_header("x-forwarded-for", "unknown")
self._counts[ip] = self._counts.get(ip, 0) + 1
if self._counts[ip] > self._limit:
from pyrith.response import Response
return Response("Too Many Requests", status=429)
return None # continue to handler
def after_request(self, request, response):
response.set_header("X-RateLimit-Limit", str(self._limit))
return response
app.use(RateLimitMiddleware(limit=200))
before_request returns None to continue the pipeline or a Response to short-circuit it. after_request always returns a (possibly modified) Response.
Request Object
| Attribute / Method | Type | Description |
|---|---|---|
request.method |
str |
"GET", "POST", etc. (always uppercase) |
request.path |
str |
URL path, e.g. "/users/42" |
request.params |
dict[str,str] |
Path parameters from {name} segments |
request.query |
dict[str,str] |
Query string key/value pairs |
request.headers |
dict[str,str] |
All headers, keys lowercased |
request.body |
bytes |
Raw request body |
request.json() |
Any |
Parse body as JSON (cached) |
request.form() |
dict |
Parse application/x-www-form-urlencoded body |
request.text() |
str |
Decode body as UTF-8 string |
request.get_header(name, default) |
str |
Case-insensitive header lookup |
request.cookies() |
dict[str,str] |
Parse Cookie header |
Response Objects
from pyrith.response import Response, HTMLResponse, JSONResponse, RedirectResponse, StreamResponse
# Plain text
Response("Hello", status=200)
# HTML
HTMLResponse("<h1>OK</h1>")
# JSON — any dict or list is auto-serialised
JSONResponse({"id": 1, "name": "Alice"}, status=201)
# Redirect
RedirectResponse("/login", status=302)
# Streaming (generator-based)
def gen():
for chunk in ["one", "two", "three"]:
yield chunk.encode()
StreamResponse(gen(), content_type="text/plain")
# Cookies
response = JSONResponse({"ok": True})
response.set_cookie("token", "abc", max_age=3600, secure=True, http_only=True)
response.set_header("X-Custom", "value")
Handler return coercion: if your handler returns a dict/list, it is wrapped in JSONResponse. A str starting with < is wrapped in HTMLResponse. Any other str becomes text/plain.
UI / HTML DSL
from pyrith import ui
# Elements are Python calls
card = ui.div(
ui.h2("Product"),
ui.p("A great item."),
ui.a("Buy →", href="/buy"),
class_="card",
id="main-card",
)
print(str(card))
# <div class="card" id="main-card"><h2>Product</h2><p>A great item.</p><a href="/buy">Buy →</a></div>
Key conventions
| Python syntax | HTML output | Note |
|---|---|---|
class_="x" |
class="x" |
Trailing _ stripped |
for_="id" |
for="id" |
Same rule |
checked=True |
checked |
Boolean attribute |
disabled=False |
(omitted) | False/None omitted |
ui.raw("<b>x</b>") |
<b>x</b> |
Bypass HTML escaping |
All text content is HTML-escaped automatically. Use ui.raw() only for trusted HTML you construct yourself.
Available elements
div span p h1 h2 h3 h4 h5 h6 a img button input_ textarea form label select option ul ol li table thead tbody tr th td nav header footer main section article aside code pre blockquote strong em br hr script style link meta
Component System
from pyrith.ui import Component
from pyrith import ui
class Alert(Component):
def render(self):
kind = self.props.get("kind", "info")
colors = {"info": "#dbeafe", "warn": "#fef3c7", "error": "#fee2e2"}
bg = colors.get(kind, colors["info"])
return ui.div(
ui.strong(self.props.get("title", "")),
ui.p(self.props.get("message", "")),
class_="alert",
style=f"background:{bg};padding:1rem;border-radius:8px",
)
alert = Alert(kind="warn", title="Heads up", message="Something needs attention.")
html = str(alert)
Components are plain Python classes. self.props is the kwargs dict passed to the constructor. render() returns any Node (Element, Component, or string).
Page Builder
from pyrith.ui import Page
page = Page(title="My App")
page.head_add('<link rel="stylesheet" href="/static/app.css">')
page.head_add('<meta name="description" content="My Pyrith app">')
page.body_add(ui.nav(...))
page.body_add(ui.main(...))
page.script_add(src="/static/app.js")
page.script_add(inline="console.log('loaded')")
html = page.to_html()
Page builds a complete <!DOCTYPE html> document. Head extras and body nodes are independent lists so you can compose them from multiple places before calling to_html().
CSS Engine
from pyrith import style
css = style.CSS()
# Basic rule
css.rule("body", font_family="system-ui", margin="0", background="#f9fafb")
# Underscores in property names become hyphens: font_size → font-size
css.rule("h1", font_size="2rem", font_weight="700", color="#111827")
# Chained modifier
css.rule("a").set(color="#2563eb", text_decoration="none")
# Media query
mq = css.media("(max-width: 768px)")
mq.rule("h1", font_size="1.5rem")
# Keyframe animation
css.keyframes("fadeIn", {
"from": {"opacity": "0", "transform": "translateY(8px)"},
"to": {"opacity": "1", "transform": "translateY(0)"},
})
# Raw CSS block (for vendor prefixes, complex selectors, etc.)
css.raw(":root { --primary: #2563eb; }")
print(css.to_css())
Scoped component styles
from pyrith.style import StyleSheet
sheet = StyleSheet(scope="card")
sheet.scoped("", background="#fff", border_radius="12px", padding="1.5rem")
sheet.scoped(" h3", color="#1d4ed8", margin_top="0")
sheet.scoped(":hover", box_shadow="0 4px 16px rgba(0,0,0,0.12)")
# Emits: .card { … } .card h3 { … } .card:hover { … }
Tailwind-like Utilities
from pyrith.style import TailwindSheet, tw
# Class string for use in HTML attributes
classes = tw("flex", "items-center", "gap-4", "p-6", "bg-gray-900", "text-white")
# → "flex items-center gap-4 p-6 bg-gray-900 text-white"
# Emit the CSS for those classes
sheet = TailwindSheet()
sheet.add("flex", "items-center", "gap-4", "p-6", "bg-gray-900", "text-white",
"text-xl", "font-bold", "rounded-lg")
print(sheet.to_css())
# .flex { display: flex; }
# .items-center { align-items: center; }
# ...
Supported utility families: spacing (p-*, m-*, px-*, py-*, pt/pb/pl/pr-*, mt/mb/ml/mr-*, mx/my-*, w-*, h-*, gap-*), colors (text-*, bg-*, border-*), font size (text-xs → text-5xl), font weight, display, flex utilities, border radius, overflow, position, opacity, cursor.
ORM
from pyrith.orm import Model, IntField, TextField, BoolField, DateTimeField
class Post(Model):
class Meta:
db = "blog.db" # SQLite file (or ":memory:")
table_name = "posts"
id = IntField(primary_key=True)
title = TextField(nullable=False, unique=True)
body = TextField()
published = BoolField(default=False)
created_at = DateTimeField()
Post.create_table()
# Create
post = Post(title="Hello World", body="First post.", published=True)
post.save()
print(post.id) # auto-assigned PK
# Read
p = Post.get(title="Hello World")
# QuerySet
drafts = Post.objects.filter(published=False).order_by("-created_at").limit(10).all()
count = Post.objects.count()
# Update
p.title = "Updated Title"
p.save()
# Delete
p.delete()
# Bulk delete
Post.objects.filter(published=False).delete()
Field types
| Field | SQLite type | Python type |
|---|---|---|
IntField |
INTEGER |
int |
FloatField |
REAL |
float |
TextField |
TEXT |
str |
BoolField |
INTEGER |
bool |
DateTimeField |
TEXT |
datetime.datetime |
ForeignKey |
INTEGER |
int (raw FK value) |
All connections are persistent per-process (connection pool keyed by db path). create_table(if_not_exists=True) is idempotent.
Template Engine
from pyrith.template import TemplateLoader
loader = TemplateLoader("templates/")
# templates/base.html
# <!DOCTYPE html><html><head><title>{% block title %}App{% endblock %}</title></head>
# <body>{% block content %}{% endblock %}</body></html>
# templates/page.html
# {% extends "base.html" %}
# {% block title %}{{ page_title }}{% endblock %}
# {% block content %}
# <ul>{% for item in items %}<li>{{ item }}</li>{% empty %}<li>None</li>{% endfor %}</ul>
# {% endblock %}
html = loader.render("page.html", page_title="My List", items=["one", "two", "three"])
Syntax:
{{ expr }}— HTML-escaped expression output{{ expr | raw }}— unescaped output{% if expr %}…{% elif expr %}…{% else %}…{% endif %}{% for x in iterable %}…{% empty %}…{% endfor %}{% extends "base.html" %}with{% block name %}…{% endblock %}{% include "partial.html" %}{# comment #}— stripped at render time
Session Authentication
from pyrith.auth import SessionStore, login_required
store = SessionStore(secret="change-this-in-production", max_age=86400)
app.use(store.middleware()) # attaches request.session to every request
@app.post("/login")
def login(request):
data = request.form()
if authenticate(data["username"], data["password"]):
request.session.set("user_id", user.id)
request.session.set("role", "admin")
return RedirectResponse("/dashboard")
return HTMLResponse("Invalid credentials", status=401)
@app.get("/logout")
def logout(request):
request.session.clear()
return RedirectResponse("/")
Sessions are stored in memory (dict) and identified by a random 256-bit session ID. The session ID is HMAC-SHA256 signed before being stored in the browser cookie, preventing forgery. The max_age evicts idle sessions server-side.
Route Guards
from pyrith.auth import login_required, role_required
@app.get("/dashboard")
@login_required(redirect_to="/login")
def dashboard(request):
return "you are logged in"
@app.get("/admin")
@login_required()
@role_required("admin", redirect_to="/forbidden")
def admin(request):
return "admin panel"
login_required checks request.session.get("user_id") is not None. role_required checks request.session.get("role") == role. Both redirect on failure.
Forms & Validation
from pyrith.forms import Form, StringField, IntegerField, EmailField, BooleanField
class RegisterForm(Form):
username = StringField(required=True, min_length=3, max_length=30)
email = EmailField(required=True)
age = IntegerField(min_val=13, max_val=120)
agree = BooleanField(required=True, label="I agree to the terms")
@app.post("/register")
def register(request):
form = RegisterForm(request.form())
if form.is_valid():
data = form.cleaned_data
# data["username"], data["email"], data["age"], data["agree"]
return RedirectResponse("/welcome")
# Re-render form with errors
return HTMLResponse(form.render(action="/register"))
form.errors is a dict[str, list[str]]. form.cleaned_data contains coerced Python values (age is int, agree is bool). form.render() produces an HTML <form> with labels, inputs, and inline error messages.
WebSocket
from pyrith.websocket import WebSocket
@app.websocket("/ws/chat")
def chat(ws: WebSocket):
while not ws.closed:
msg = ws.recv()
if msg is None:
break
ws.send(f"echo: {msg}")
Pyrith performs the WebSocket upgrade handshake (RFC 6455) in pure Python. Each WebSocket connection runs in its own daemon thread. ws.send(str) sends a text frame; ws.send(bytes) sends a binary frame. ws.recv() returns str (text), bytes (binary), or None (closed).
CLI
pyrith new <project> Scaffold a new project directory with app.py + requirements.txt
pyrith run [file] Import and run an app file (default: app.py); calls app.run(debug=True)
pyrith version Print the installed version
pyrith new blog
cd blog
python app.py
Error Handling
@app.error_handler(404)
def not_found(request):
return HTMLResponse("<h1>404 — Page not found</h1>", status=404)
@app.error_handler(500)
def server_error(request):
return HTMLResponse("<h1>500 — Something went wrong</h1>", status=500)
If no handler is registered, the default sends a plain-text response with the status message.
PyPI Publishing
The repo ships a GitHub Actions workflow at .github/workflows/publish.yml that uses OIDC trusted publishing (no token stored in secrets):
- Go to pypi.org → your project → Publishing → Add a trusted publisher.
- Fill in: Owner = your GitHub username, Repo =
Pyrith, Workflow =publish.yml, Environment =pypi. - On GitHub, create an environment named
pypi(Settings → Environments). - Push your code, then create a GitHub Release — the workflow triggers automatically and publishes the package.
Roadmap
Phase 1 — Core (complete)
- HTTP router with path parameters and route groups
- Middleware pipeline (CORS, Logging, Static Files, GZip)
- HTML element DSL + Component system + Page builder
- Programmatic CSS engine + Tailwind-like utilities
- ActiveRecord ORM (SQLite backend, QuerySet API)
- Template engine (extends, blocks, for, if, include)
- Session store (HMAC-signed cookies)
- Route guards (login_required, role_required)
- Form validation (StringField, IntegerField, EmailField, BooleanField)
- WebSocket support (stdlib frame codec, upgrade handshake)
- CLI (new, run, version)
Phase 2 — Extended (in research)
- PostgreSQL ORM backend (via
socket+ PostgreSQL wire protocol) - Server-Sent Events (SSE) streaming helper
- File upload handling (multipart/form-data parser)
- Background task queue (threading-based)
- Hot-reload dev server
- WebSocket rooms / broadcast bus
Phase 3 — Ecosystem (planned)
- Reactive UI diff-patch (server-side rendering with partial updates)
- Declarative API schema + automatic OpenAPI generation
- Multi-process worker pool
- Plugin / extension system
License
Apache 2.0 — see LICENSE.
Author
Saad Salman · github.com/saadsalmanakram
Metadata
Release files for pyrith 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pyrith-0.1.0.tar.gz | 46.6 kB | With provenance | Details for pyrith-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pyrith-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pyrith-0.1.0-py3-none-any.whl |
Total release size: 90.3 kB
Release files / pyrith-0.1.0.tar.gz
| Download URL | pyrith-0.1.0.tar.gz |
|---|---|
| Size | 46.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6ab026294bd63da5bd9a976775ac4c60ef35b62f71370d97b82624468a214d89
|
|
BLAKE2b-256 checksum How to use checksums |
ba76ab0c007c79e1ab9f9f389ae57515c8b7d601065b0741ec47846d8e64b182
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logRelease files / pyrith-0.1.0-py3-none-any.whl
| Download URL | pyrith-0.1.0-py3-none-any.whl |
|---|---|
| Size | 43.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
71e6d971d4d72787d1a52bbd5969749af9523098d5d6d260be468da82f3d1d7d
|
|
BLAKE2b-256 checksum How to use checksums |
2b789c4d7fce15e5750dba6020f7a07ebdf6ce4eaf55ef7f13840fc4bc764b1c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logSecurity for pyrith 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for pyrith-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.