Metadata-Version: 2.4
Name: pyrith
Version: 0.1.0
Summary: A pure-Python full-stack web framework — backend, frontend, CSS, ORM, auth, all in Python. Zero external dependencies.
Author-email: Saad Salman <saadsalmanakram1@gmail.com>
License: Apache-2.0
Project-URL: Homepage, https://github.com/saadsalmanakram/Pyrith
Project-URL: Repository, https://github.com/saadsalmanakram/Pyrith
Project-URL: Issues, https://github.com/saadsalmanakram/Pyrith/issues
Keywords: web,framework,backend,frontend,python,fullstack,orm,html,css
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# 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](#quick-start)
2. [Architecture](#architecture)
3. [HTTP Server & Routing](#http-server--routing)
4. [Middleware](#middleware)
5. [Request Object](#request-object)
6. [Response Objects](#response-objects)
7. [UI / HTML DSL](#ui--html-dsl)
8. [Component System](#component-system)
9. [Page Builder](#page-builder)
10. [CSS Engine](#css-engine)
11. [Tailwind-like Utilities](#tailwind-like-utilities)
12. [ORM](#orm)
13. [Template Engine](#template-engine)
14. [Session Authentication](#session-authentication)
15. [Route Guards](#route-guards)
16. [Forms & Validation](#forms--validation)
17. [WebSocket](#websocket)
18. [CLI](#cli)
19. [Error Handling](#error-handling)
20. [PyPI Publishing](#pypi-publishing)
21. [Roadmap](#roadmap)

---

## Quick Start

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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
```

```bash
pyrith new blog
cd blog
python app.py
```

---

## Error Handling

```python
@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](https://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)
- [x] HTTP router with path parameters and route groups
- [x] Middleware pipeline (CORS, Logging, Static Files, GZip)
- [x] HTML element DSL + Component system + Page builder
- [x] Programmatic CSS engine + Tailwind-like utilities
- [x] ActiveRecord ORM (SQLite backend, QuerySet API)
- [x] Template engine (extends, blocks, for, if, include)
- [x] Session store (HMAC-signed cookies)
- [x] Route guards (login_required, role_required)
- [x] Form validation (StringField, IntegerField, EmailField, BooleanField)
- [x] WebSocket support (stdlib frame codec, upgrade handshake)
- [x] 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](LICENSE).

---

## Author

**Saad Salman** · [github.com/saadsalmanakram](https://github.com/saadsalmanakram)
