Skip to main content

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

  1. Quick Start
  2. Architecture
  3. HTTP Server & Routing
  4. Middleware
  5. Request Object
  6. Response Objects
  7. UI / HTML DSL
  8. Component System
  9. Page Builder
  10. CSS Engine
  11. Tailwind-like Utilities
  12. ORM
  13. Template Engine
  14. Session Authentication
  15. Route Guards
  16. Forms & Validation
  17. WebSocket
  18. CLI
  19. Error Handling
  20. PyPI Publishing
  21. 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):

  1. Go to pypi.org → your project → Publishing → Add a trusted publisher.
  2. Fill in: Owner = your GitHub username, Repo = Pyrith, Workflow = publish.yml, Environment = pypi.
  3. On GitHub, create an environment named pypi (Settings → Environments).
  4. 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)

Source distribution for pyrith 0.1.0
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)

Table of built distributions (wheels) for pyrith 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

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

Select from 2 files

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 log

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

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page