Metadata-Version: 2.4
Name: ryne
Version: 0.4.4
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Rust
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Dist: maturin>=1.7 ; extra == 'dev'
Requires-Dist: pytest>=8 ; extra == 'dev'
Requires-Dist: httpx>=0.27 ; extra == 'dev'
Requires-Dist: psutil>=5.9 ; extra == 'dev'
Requires-Dist: watchfiles>=0.21 ; extra == 'dev'
Requires-Dist: ruff>=0.6 ; extra == 'dev'
Requires-Dist: jinja2>=3.1 ; extra == 'templates'
Requires-Dist: httpx>=0.27 ; extra == 'test'
Requires-Dist: websockets>=12 ; extra == 'test'
Provides-Extra: dev
Provides-Extra: templates
Provides-Extra: test
License-File: LICENSE
Summary: Fast, secure, full-stack Python backend framework with a native Rust core
Keywords: web,framework,backend,rust,http,orm,async
Author: RYNE contributors
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Project-URL: Changelog, https://github.com/ryne-framework/ryne/blob/main/CHANGELOG.md
Project-URL: Homepage, https://github.com/ryne-framework/ryne
Project-URL: Issues, https://github.com/ryne-framework/ryne/issues
Project-URL: Repository, https://github.com/ryne-framework/ryne

# RYNE

<p align="center">
  <a href="https://pypi.org/project/ryne/"><img src="https://img.shields.io/pypi/v/ryne?color=f59e0b" alt="PyPI version"></a>
  <a href="https://pypi.org/project/ryne/"><img src="https://img.shields.io/pypi/pyversions/ryne" alt="Python versions"></a>
  <a href="https://github.com/ryne-framework/ryne/actions/workflows/ci.yml"><img src="https://github.com/ryne-framework/ryne/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="https://pypi.org/project/ryne/"><img src="https://img.shields.io/pypi/l/ryne" alt="License"></a>
</p>

**Fast, secure, full-stack Python backend framework with a native Rust core.**

Write your entire application in pure Python. RYNE runs the HTTP server
(HTTP/1.1 + HTTP/2, native TLS), routing, JSON, validation, the SQLite
driver and cryptography in compiled Rust — and installing it never requires
a Rust toolchain:

```bash
pip install ryne
```

<p align="center">
  <img src="https://raw.githubusercontent.com/ryne-framework/ryne/main/docs/benchmarks/benchmark_rps_python.png" alt="RYNE vs Python web frameworks: RPS by scenario" width="780">
</p>

## Why RYNE

| Goal | How RYNE delivers it |
|---|---|
| **Python-only install** | Prebuilt wheels (CPython 3.10+, Windows / Linux / macOS, abi3) bundle the Rust core via PyO3. |
| **Native Rust core** | hyper + tokio event loop, trie router, JSON, schema validation, SQLite and argon2 — all native. |
| **Developer experience** | Django-style models, migrations, decorators, a CLI with hot reload, and a built-in test client. No Rust knowledge needed, ever. |
| **Full-stack** | REST API, WebSockets, Jinja2 templates, static files, uploads, ORM, migrations, authentication, revocable sessions, CORS — plus a built-in admin panel and a PostgreSQL backend. |
| **Performance-first** | Rust owns the socket, serializes responses, serves statics and runs WebSockets; Python is invoked exactly once per request. |
| **Security-first** | Native TLS (rustls), rate limiting, backpressure, signed CSRF, revocable sessions, argon2id, parameterized SQL, request IDs and access logs — safe defaults everywhere. |

## Benchmarks

11 frameworks × 6 scenarios × 60 seconds per test, `oha` load generator,
50 concurrent connections, identical endpoints and payloads
([full methodology and complete tables](https://github.com/ryne-framework/ryne/blob/main/BENCHMARKS.md)).

| scenario | RYNE | Sanic | Starlette | Litestar | FastAPI | Flask | Django |
|---|---|---|---|---|---|---|---|
| json | **30 636** | 14 532 | 9 180 | 8 059 | 5 629 | 3 942 | 1 578 |
| plaintext | **27 829** | 12 881 | 9 098 | 7 546 | 6 447 | 4 281 | 925 |
| router | **27 472** | 10 984 | 8 168 | 6 861 | 4 641 | 3 995 | 988 |
| http | **22 333** | 12 506 | 7 347 | 6 159 | 4 316 | 3 929 | 1 281 |
| db (SQLite) | **7 348** | 2 162 | 2 104 | 2 520 | 1 625 | 1 488 | 1 323 |
| validation | **17 066** | 9 640 | 6 516 | 5 366 | 4 503 | 3 621 | 1 004 |
| **peak RAM (db)** | **38 MB** | 79 MB | 73 MB | 204 MB | 87 MB | 115 MB | 125 MB |

<p align="center">
  <img src="https://raw.githubusercontent.com/ryne-framework/ryne/main/docs/benchmarks/benchmark_latency.png" alt="Latency percentiles: RYNE has the lowest p50/p95/p99 among Python frameworks" width="780">
</p>

**How to read this.** RYNE is the fastest full-featured Python framework in
*all six* scenarios — 1.9–2.7× ahead of the runner-up (Sanic), 3.5–5.4×
ahead of FastAPI, 7–30× ahead of Flask and Django — while using the least
memory (now with WebSockets, templates, statics and uploads built in). Native Rust frameworks (Actix, Axum, Hyper, Rocket) are still
3–6× faster than RYNE: that is the honest price of running *user code* in
Python, and the trade-off RYNE makes to stay a pure-Python framework.

---

## Documentation

Everything below is the complete documentation, inlined — the same content
as [docs/](https://github.com/ryne-framework/ryne/tree/main/docs) in the repository.

| Section | Contents |
|---|---|
| [Quickstart](#quickstart) | Install, first app in 5 commands |
| [Tutorial](#tutorial-a-small-blog-api) | A small blog API end to end |
| [Validation](#validation) | Schemas, coercion, nested objects, fast paths |
| [ORM and migrations](#orm-and-migrations) | Django-style models, queries, migrations |
| [PostgreSQL backend](#postgresql-backend) | Same ORM on `postgres://` URLs |
| [Auth and sessions](#auth-and-sessions) | Users, login, rotation, logout everywhere |
| [WebSockets](#websockets) | Native RFC 6455 in Rust, origin checks |
| [Templates](#templates) | Jinja2 integration, autoescaping, CSRF injection |
| [Static files and uploads](#static-files-and-uploads) | Rust-served assets, multipart parsing |
| [Admin panel](#admin-panel) | Zero-config CRUD for every model |
| [Middleware, CORS, lifespan and background](#middleware-cors-lifespan-and-background-tasks) | Cross-origin requests, hooks, fire-and-forget |
| [Security](#security) | Defaults, TLS, proxies, hardening checklist |
| [Testing](#testing) | TestClient, patterns |
| [Configuration](#configuration) | Every setting, env vars, CLI |
| [Performance](#performance) | Benchmarks, methodology, tuning |
| [The full stack](#the-full-stack) | All of it as one code page |
| [CLI reference](#cli-reference) | Every `ryne` command |

---

## Quickstart

### Install

```bash
pip install ryne
```

Prebuilt wheels (CPython 3.10+, Windows/Linux/macOS, abi3) bundle the whole
Rust core — no compiler required.

### Your first project

```bash
ryne startproject mysite
cd mysite
ryne makemigrations && ryne migrate
ryne run
```

Open http://127.0.0.1:8000 — done. `ryne run --reload` restarts the server
automatically on every `*.py` change while you develop.

### Or a single file

```python
# hello.py
from ryne import Ryne

app = Ryne()

@app.get("/hello/{name}")
async def hello(request, name: str):
    return {"message": f"Hello, {name}!"}

app.run()   # ryne run hello:app
```

Handlers return `dict` (JSON), `str` (plain text) or a `Response` object.
Route parameters arrive as keyword arguments.

### CLI cheat sheet

```
ryne run [module:app] [--reload] [--host H] [--port P] [--debug]
         [--tls-cert C --tls-key K]
ryne startproject <name>     scaffold a project
ryne makemigrations [--name] generate a migration from model changes
ryne migrate                 apply pending migrations
ryne createsuperuser         create an admin user
ryne shell [module:app]      interactive shell with your app loaded
ryne check [module:app]      validate configuration
```

### Next steps

- [Tutorial](#tutorial-a-small-blog-api) — build a small blog API
- [Validation](#validation) — schemas compiled to Rust
- [WebSockets](#websockets) — real-time endpoints in pure Python
- [Templates](#templates) + [Static files](#static-files-and-uploads) — server-rendered pages
- [Security](#security) — what's on by default, how to deploy with TLS

---

## Tutorial: a small blog API

This walks through a complete application: models, validation, auth, and a
few routes. The finished version lives in `examples/demo/`.

### 1. Project

```bash
ryne startproject blog && cd blog
```

### 2. Models

```python
# models.py
from ryne.models import Model, CharField, IntField, ForeignKey, TextField


class Author(Model):
    name = CharField(max_length=100)


class Article(Model):
    title = CharField(max_length=200)
    body = TextField(default="")
    views = IntField(default=0)
    author = ForeignKey(Author, null=True)
```

### 3. App and routes

```python
# app.py
from ryne import Ryne, HTTPException
from models import Article, Author  # noqa: E402

app = Ryne()


@app.get("/articles")
async def list_articles(request):
    articles = await Article.objects.order_by("-id").limit(20)
    return {"articles": [a.to_dict() for a in await articles.all()]}


@app.get("/articles/{id}")
async def get_article(request, id: int):
    article = await Article.objects.get(id=int(id))
    if article is None:
        raise HTTPException(404, "no such article")
    article.views += 1
    await article.save()
    return article.to_dict()
```

### 4. Validation

```python
from ryne.validation import Schema, Str, Int, List


class CreateArticle(Schema):
    title: Str(min_length=1, max_length=200)
    body: Str(max_length=50_000) = ""
    tags: List(Str(), max_items=10) = []
    views: Int(ge=0) = 0


@app.post("/articles")
async def create_article(request):
    data = CreateArticle.validate_body(request)   # raises ValidationError -> 422
    article = await Article.objects.create(**data)
    return article.to_dict(), 201
```

`validate_body(request)` is the fast path — the body is parsed and validated
in Rust without a Python round trip. Malformed JSON raises `ValueError`,
schema violations raise `ValidationError` (rendered as 422 with all errors
accumulated).

### 5. Migrations

```bash
ryne makemigrations      # writes migrations/0001_auto.py — review it
ryne migrate             # applies atomically, records in ryne_migrations
```

### 6. Users and login

```bash
ryne createsuperuser --username ann
```

```python
from ryne import HTTPException
from ryne.auth import authenticate, login, get_user, requires_login


@app.post("/login")
async def login_route(request):
    data = request.json()
    user = await authenticate(data["username"], data["password"])
    if user is None:
        raise HTTPException(401, "bad credentials")
    await login(request, user)      # rotates the session id server-side
    return {"ok": True}


@app.post("/logout")
async def logout_route(request):
    from ryne.auth import logout
    await logout(request)           # revokes the session server-side
    return {"ok": True}


@app.get("/me")
@requires_login
async def me(request):
    return {"user": (await get_user(request)).username}
```

Sessions are signed cookies backed by a server-side registry: `logout` kills
the session for every holder of that cookie, and `logout_all(user_id)` kills
all of a user's sessions.

### 7. Run it

```bash
ryne makemigrations && ryne migrate
ryne run --reload
```

```bash
curl -X POST localhost:8000/articles -d '{"title": "Hi"}'
# {"id": 1, "title": "Hi", "body": "", "views": 0, "author": null}
```

### 8. Test it

```python
# test_blog.py
from ryne.test import TestClient
from app import app


def test_create_and_get():
    with TestClient(app) as client:
        r = client.post("/articles", json={"title": "Hi"})
        assert r.status_code == 201
        r = client.get(f"/articles/{r.json()['id']}")
        assert r.json()["title"] == "Hi"
```

Run with `pytest` (`pip install ryne[test]`).

### Where to next

- [Validation](#validation) for the full schema language
- [ORM and migrations](#orm-and-migrations) for queries and lookups
- [Security](#security) to deploy this behind TLS

---

## Validation

Schemas are declared in Python and **compiled to Rust at import time** —
regexes pre-compiled, constraints native. Validation runs parse → coerce →
validate → convert in native code and accumulates *all* errors.

### Declaring a schema

```python
import enum
from ryne.validation import Schema, Str, Int, Float, Bool, List, Object, Enum


class Color(enum.Enum):
    RED = "red"
    BLUE = "blue"


class Address(Schema):
    city: Str(max_length=80)


class CreateArticle(Schema):
    title: Str(min_length=1, max_length=200)
    tags: List(Str(), max_items=10) = []      # default -> optional
    views: Int(ge=0) = 0
    rating: Float(gt=0, le=5) = 1.0
    published: Bool() = False
    color: Enum(Color)                        # -> Color member on output
    address: Object(nested=Address)           # nested schema
    nickname: Str(max_length=30) = None       # optional + nullable
```

Field kinds: `Str`, `Int`, `Float`, `Bool`, `DateTime`, `Date`, `Email`,
`Url`, `Uuid`, `List`, `Object`, `Enum`, `Any`.

Common options: `min_length` / `max_length` (strings), `ge` / `le` / `gt` /
`lt` / `multiple_of` (numbers), `min_items` / `max_items` (lists),
`pattern` (regex, linear-time — ReDoS-safe), `choices`.

### Validating

```python
data = CreateArticle.validate(payload)          # dict/any object
data = CreateArticle.validate_body(request)     # raw request body (fast path)
clean, errors = CreateArticle.try_validate(payload)   # no raising
one = CreateArticle.validate_field("title", "Hi")     # one field in isolation
```

- Coercion is safe by default: `"42"` → `42`, `"true"` → `True` (documented,
  deliberate — HTML forms and query strings are strings).
- Unknown fields are rejected (`forbid_unknown`) with an `unknown field` error.
- `ValidationError` carries `.errors`: a list of `(path, message)` with dotted
  paths and list indices (`address.zip_code`, `tags[2]`).
- `validate_body` raises `ValueError` for malformed JSON (like
  `request.json()`) and `ValidationError` for schema violations.

### Fast paths (performance)

```python
@app.post("/articles")
async def create(request):
    data = CreateArticle.validate_body(request)  # parse+validate in Rust, GIL released
    return {"ok": data["title"]}
```

`validate_body(request)` skips the Python-dict round trip entirely: the raw
body is parsed and validated in Rust while the GIL is released, so concurrent
requests validate in parallel. On the benchmarked 8-field schema it is
~2.7x faster than `validate(request.json())`.

### Errors as JSON

`ValidationError` is an `HTTPException` (422) raised inside a handler; the
default renderer returns the accumulated errors as a summary string:

```json
{"detail": "title: field required; views: must be greater than or equal to 0"}
```

`exc.errors` on the exception itself holds the structured list of
`(path, message)` tuples — use it in a custom renderer:

### Custom error rendering

```python
from ryne.exceptions import ValidationError

@app.on_error
def render_errors(request, exc):
    if isinstance(exc, ValidationError):
        return {"errors": [{"path": p, "message": m} for p, m in exc.errors]}, 422
    return {"detail": exc.detail}, exc.status
```

---

## ORM and migrations

A Django-style ORM over the bundled SQLite engine (WAL mode, read pool, one
serialized writer, GIL released for every query) — or over PostgreSQL; see
[the PostgreSQL backend](#postgresql-backend) below.

### Models

```python
from ryne.models import Model, CharField, IntField, FloatField, BoolField, TextField, DateTimeField, ForeignKey, JSONField


class Author(Model):
    name = CharField(max_length=100)


class Article(Model):
    title = CharField(max_length=200)
    body = TextField(default="")
    views = IntField(default=0)
    score = FloatField(default=0.0)
    published = BoolField(default=False)
    created_at = DateTimeField(auto_now_add=True)
    author = ForeignKey(Author, null=True, on_delete="SET NULL")
    meta = JSONField(default=dict)
```

The table name is the pluralized class name (`articles`); every model has an
auto-increment `id` primary key exposed as `.pk`.

### CRUD

```python
article = await Article.objects.create(title="Hi", views=0)

article = await Article.objects.get(id=1)          # None when missing
article.views += 1
await article.save()

await article.delete()

await Article.objects.bulk_create([Article(title="a"), Article(title="b")])
```

Sync contexts (management commands, scripts) can call the sync variants:
`Article.objects.create_sync(...)`, `save_sync()`, etc. — the server always
uses the async forms.

### Queries

```python
await Article.objects.filter(views__gte=10, title__contains="ry")
await Article.objects.exclude(published=False)
await Article.objects.filter(author__isnull=True)
await Article.objects.order_by("-created_at").limit(10).offset(5)
await Article.objects.count()
await Article.objects.first()          # None when empty
await Article.objects.values("id", "title")   # dicts, skips model construction
```

Supported lookups: `exact` (default), `gt`, `gte`, `lt`, `lte`, `contains`,
`startswith`, `endswith`, `in`, `isnull`. Every value is parameterized —
identifiers are validated against the model definition, so SQL injection is
structurally impossible.

### Migrations

```bash
ryne makemigrations [--name initial]   # diff models -> reviewable Python file
ryne migrate                           # apply pending, atomically, recorded
```

Generated files live in `migrations/` and are plain Python (`operations = [...]`)
— review and edit them like any code. Adding tables/columns is automated;
SQLite cannot ALTER a column, so type changes/removals produce a warning and
expect a manual edit of the generated file.

Applied migrations are recorded in the `ryne_migrations` table; re-running
`ryne migrate` is a no-op when nothing is pending.

---

## PostgreSQL backend

The ORM runs on two engines: bundled SQLite (the default) and PostgreSQL.
The engine is selected by the `database` URL — models, lookups, migrations
and the admin panel work identically on both:

```python
from ryne import Ryne, Settings

app = Ryne(settings=Settings(
    database="postgres://user:pass@localhost:5432/ryne",  # postgres:// → PostgreSQL
    db_pool_size=8,      # pool size (RYNE_DB_POOL)
))
```

- URLs starting with `postgres://` or `postgresql://` select the native
  PostgreSQL driver compiled into the wheel — no `psycopg` or any other
  Python driver is needed. Anything else is treated as a SQLite file path.
- The pool is bounded (`db_pool_size`, default 8); queries are
  parameterized and run with the GIL released, exactly like SQLite.
- `ryne.db.backend()` reports the active engine (`'sqlite'` / `'postgres'`);
  `ryne.db.set_default(handle)` overrides the default database explicitly.
- Migrations work the same way: `ryne migrate` records applied migrations
  in the `ryne_migrations` table on either engine.

A runnable example lives in
[examples/pg_demo](https://github.com/ryne-framework/ryne/tree/main/examples/pg_demo) — point
`RYNE_TEST_PG` at your server and run it.

---

## Auth and sessions

### The model

`ryne.auth.User` is a built-in model (`users` table) with argon2id password
hashing done in native code:

```python
from ryne.auth import User

user = await User.objects.create(username="ann")
user.set_password("wonderland")
await user.save()

user.check_password("wonderland")   # True
```

### Login / logout

```python
from ryne import HTTPException
from ryne.auth import authenticate, login, logout, logout_all, get_user, requires_login


@app.post("/login")
async def login_route(request):
    data = request.json()
    user = await authenticate(data["username"], data["password"])
    if user is None:
        raise HTTPException(401, "bad credentials")
    await login(request, user)
    return {"ok": True}


@app.post("/logout")
async def logout_route(request):
    await logout(request)
    return {"ok": True}


@app.get("/me")
@requires_login
async def me(request):
    return {"user": (await get_user(request)).username}
```

`authenticate` verifies with argon2id in Rust (GIL released) and runs a
dummy verification for unknown usernames, so response timing does not leak
which usernames exist.

### How sessions work

A session is a **signed cookie** (HMAC-SHA256, constant-time verified in
Rust) carrying a JSON payload plus `_exp`. On top of that, RYNE keeps a
server-side registry of session ids (enabled by default via
`session_store=True`):

- **Rotation** — `login()` issues a *fresh* session id and revokes the old
  one, so a session fixed before authentication cannot be reused.
- **Revocation** — `logout()` revokes the id server-side: every holder of a
  copy of that cookie is logged out immediately, not just at cookie expiry.
- **Logout everywhere** — `await logout_all(user.pk)` revokes all of a
  user's live sessions.
- **Fail closed** — a cookie whose id is not in the registry (server
  restart, revoked, expired) is treated as anonymous. The registry is
  in-memory by design: restarting the server invalidates all sessions.

Set `session_store=False` to fall back to purely stateless signed cookies
(survives restarts, but revocation stops working).

Cookies are `HttpOnly; SameSite=Lax`; `Secure` is added automatically when
the server runs TLS (or set `session_secure_cookies=True`).

### Decorators

```python
from ryne.auth import requires_login, requires_superuser


@app.get("/admin")
@requires_superuser
async def admin_area(request):
    ...
```

401 for anonymous requests, 403 for authenticated non-superusers.

### CSRF

Form submissions (`application/x-www-form-urlencoded` and
`multipart/form-data`) require a CSRF token; JSON APIs are exempt. In a
template/form, embed the token from the `ryne_csrf` cookie into a
`csrfmiddlewaretoken` field or an `x-csrf-token` header:

```html
<form method="post">
  <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
  ...
</form>
```

The token is *signed* (`nonce.timestamp.hmac` over the app's secret key) and
expires after `csrf_token_age` (1 day) — an attacker who can plant cookies on
a sibling domain still cannot forge a valid token. See
[Security](#security) for the full model.

---

## WebSockets

RYNE implements WebSockets **natively in Rust** (RFC 6455: framing, masking,
fragmentation, pings, close handshakes, message limits) — Python only sees
messages. HTTP/1.1 upgrades, **WebSockets over HTTP/2** (RFC 8441 extended
CONNECT) and TLS (`wss://` via native TLS) are supported.

### A handler

```python
@app.websocket("/ws")
async def chat(ws):
    await ws.send_text("welcome!")
    async for message in ws:              # str for text, bytes for binary
        await ws.send_text(f"echo: {message}")
```

`ws` is a `ryne.websockets.WebSocket`:

| API | Meaning |
|---|---|
| `await ws.recv()` | Next message (`str`/`bytes`), or `None` after close |
| `await ws.send_text(str)` / `await ws.send_bytes(bytes)` | Queue a frame (never blocks; raises when closed or the send buffer is full) |
| `await ws.close(code=1000)` | Begin the close handshake |
| `async for message in ws` | Iterate until the peer disconnects |
| `ws.closed` | True after the close handshake started |

The handler's cleanup is guaranteed: when it returns (or raises), the socket
is closed automatically. Errors close with `1011`.

```python
@app.websocket("/ws/rooms/{room}")
async def room(ws, room: str):
    ...
```

### Where the protocol runs

- **Handshake** — validated in Rust: `Sec-WebSocket-Key` → `Sec-WebSocket-Accept`
  (SHA-1 per RFC 6455), version check, **origin check** (see below).
- **Frames** — read/written in Rust on the connection's tokio task. Pings are
  answered automatically; fragmented messages are reassembled before delivery.
- **Python** — `recv()` awaits an asyncio future that the Rust reader
  resolves; sends go into a bounded channel (`1024` frames). A slow consumer
  that never calls `recv()` gets disconnected with `1008` once its buffer
  (64 messages) overflows.

### Origin policy (CSRF for WebSockets)

Browsers always send `Origin` on the handshake. RYNE rejects a handshake when
the Origin is present and matches neither the request's `Host` nor
`ws_allowed_origins`:

```python
Settings(ws_allowed_origins=["https://app.example.com"])
# env: RYNE_WS_ORIGINS="https://app.example.com,https://staging.example.com"
```

Absent Origin (non-browser clients, curl, tests) is always allowed.
Cross-origin handshakes get `403`.

### Limits

- `ws_max_message` (default 1 MiB): messages (across fragments) larger than
  this end the session with `1009`.
- A message size below the limit but larger than the frame buffer is handled
  incrementally; control frames (ping/pong/close, ≤125 bytes) are bounded by
  the protocol.

### Testing WebSockets

The `websockets` client library (`pip install ryne[test]`) works directly:

```python
import asyncio
import websockets

def test_echo():
    async def go():
        async with websockets.connect("ws://127.0.0.1:8000/ws") as ws:
            await ws.send("hi")
            assert await ws.recv() == "echo: hi"
    asyncio.run(go())
```

See `examples/chat_demo/` for a complete multi-client chat.

---

## Templates

RYNE integrates **Jinja2** — the same engine Flask uses — as an optional
dependency. Autoescaping for HTML/XML is on by default.

```bash
pip install ryne[templates]
```

### Setup the templates directory

```python
from ryne import Ryne

app = Ryne()
app.templates("templates")     # directory next to your app module
```

### Rendering

```python
@app.get("/")
async def home(request):
    return app.render(request, "home.html", title="Home", posts=posts)
```

`app.render(request, name, **context)`:

- returns an `HTMLResponse` (autoescaping on),
- injects `request` and `csrf_token` into the context automatically,

```html
<!-- templates/home.html -->
{% extends "base.html" %}
{% block content %}
  <h1>{{ title }}</h1>
  {% for post in posts %}
    <article><h2>{{ post.title }}</h2><p>{{ post.body }}</p></article>
  {% endfor %}
  <form method="post" action="/comment">
    <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
    <textarea name="body"></textarea>
    <button>post</button>
  </form>
{% endblock %}
```

Everything Jinja2 supports works: `{% extends %}`, `{% include %}`, filters,
macros, custom globals (`app._templates.environment.globals.update(...)`).

### Forms and CSRF

HTML forms are CSRF-protected (see [Security](#security)); the token is
injected as `csrf_token` in every template context — put it in the form:

```html
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
```

### File uploads through forms

```html
<form method="post" action="/upload" enctype="multipart/form-data">
  <input type="file" name="avatar">
  <button>upload</button>
</form>
```

```python
@app.post("/upload")
async def upload(request):
    files = request.files["avatar"]       # list of {"filename", "content_type", "data"}
    form = request.form()                 # text fields
    (Path("uploads") / files[0]["filename"]).write_bytes(files[0]["data"])
    return {"ok": True, "saved": files[0]["filename"]}
```

### Serving static assets

Pair templates with a static mount — files are served by Rust with ETag /
Cache-Control / 304 support and traversal protection:

```python
app.mount_static("/static/", "static")
```

```html
<link rel="stylesheet" href="/static/style.css">
```

See [Static files and uploads](#static-files-and-uploads) for details, and
`examples/templates_demo/` for a runnable app.

---

## Static files and uploads

### Static mounts

```python
app.mount_static("/static/", "static")
```

Files under `static/` are served **directly by the Rust core** — a static
request never touches Python, no matter how many you serve:

- **Content types** for the common web formats (html, css, js, images,
  fonts, wasm, media...) — unknown extensions default to
  `application/octet-stream`.
- **ETag** (`mtime-size`) + **`Cache-Control: public, max-age=...`**
  (`static_cache_seconds`, default 3600) + **`If-None-Match` → 304**.
- **HEAD** requests work (body suppressed per spec).
- **Range requests are not supported** (v1) — media players needing seeking
  should use a CDN/object store in front.

#### Safety

Mounts are traversal-safe by construction: after percent-decoding, path
segments that are `..`, absolute, or **dot-files** (`.env`, `.git/...`) are
rejected with 404 before the filesystem is touched. Filenames uploaded by
users are reduced to their basename on save-side handling too.

```python
Settings(static_cache_seconds=86400)   # aggressive caching for immutable assets
```

### Parsing forms and uploads

`multipart/form-data` bodies are parsed **in Rust** (no Python round trip):

```python
@app.post("/upload")
async def upload(request):
    form = request.form()          # {"field": "value", ...}  (urlencoded or multipart text parts)
    files = request.files          # {"field": [{"filename", "content_type", "data"}, ...]}
    ...
```

- `request.form()` — cached dict of text fields (works for plain
  `application/x-www-form-urlencoded` bodies too).
- `request.files` — dict of `field name → list of files`; one field may carry
  several files (`<input type="file" multiple>`).
- Each file is `{"filename": str, "content_type": str, "data": bytes}`.
- The whole body is already bounded by `max_body` (default 1 MiB — raise it
  for large uploads: `Settings(max_body=64 * 1024 * 1024)`).

CSRF applies to form posts exactly like anywhere else (JSON APIs are exempt):
include `csrfmiddlewaretoken` or the `x-csrf-token` header.

### Layouts

A typical project:

```
myapp/
  app.py
  templates/          # Jinja2 (app.templates("templates"))
    base.html
    home.html
  static/             # app.mount_static("/static/", "static")
    style.css
    app.js
```

See `examples/templates_demo/` for a runnable example with both.

---

## Admin panel

An opt-in, zero-dependency admin panel: CRUD for every registered model,
server-rendered HTML, no JavaScript and no per-model configuration — the
panel renders straight from the model registry:

```python
from ryne.admin import setup_admin

setup_admin(app, "/admin/")   # any prefix works
```

- **Dashboard** with per-model row counts, **paginated lists** with text
  search, **add/edit forms** generated from the field metadata, and
  **delete** buttons.
- Access requires a **logged-in superuser**: anonymous requests get `401`,
  authenticated non-superusers get `403`. Create the first superuser with
  `ryne createsuperuser --username ann`.
- Every form carries the CSRF token; responses are marked `noindex`.

Works on both the SQLite and the PostgreSQL backend.

---

## Middleware, CORS, lifespan and background tasks

**CORS** — preflight answering and access-control headers:

```python
from ryne.cors import CORSMiddleware

app.middlewares.append(
    CORSMiddleware(
        allow_origins=["https://app.example.com"],
        allow_methods=["GET", "POST"],
        allow_credentials=True,   # requires explicit origins, not '*'
        max_age=600,
    )
)
```

Preflight `OPTIONS` requests are answered immediately (204); ordinary
responses get `Access-Control-Allow-Origin` and friends. `allow_origins`
defaults to every origin; `allow_credentials=True` combined with `*` is
rejected at construction time. With CORS installed, OPTIONS dispatches to
the pipeline instead of the built-in auto-OPTIONS.

**Lifespan hooks** — run before serving / after serving:

```python
@app.on_startup
async def open_pool(): ...

@app.on_shutdown
async def close_pool(): ...
```

Startup failures abort serving; shutdown hooks run only after a completed
startup. Both accept sync or async functions.

**Background tasks** — fire-and-forget, started immediately (not after
the response, unlike FastAPI's BackgroundTasks):

```python
app.background(send_email, user_id=user.pk)   # sync → daemon thread
app.background(notify_ws(message))            # async → framework loop
```

Async callables run on the framework's event loop, sync ones on a daemon
thread; errors are logged, never raised into the caller. Usable from sync
and async handlers alike.

---

## Security

RYNE's philosophy: **safe defaults, opt-in escape hatches**. This page lists
what is enforced by default, how to deploy securely, and the hardening
checklist.

### On by default

| Protection | How it works |
|---|---|
| Security headers | `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `COOP`, `Permissions-Policy` on every response (user values are never clobbered) |
| Sessions | Signed cookies (HMAC-SHA256, constant-time in Rust) + server-side registry: rotation on login, revocation on logout, `logout_all` |
| Passwords | argon2id (OWASP-level defaults), verified in native code, timing-equalized against unknown users |
| CSRF | Signed double-submit tokens for form posts; JSON APIs exempt; multipart supported |
| Rate limiting | Per-IP token bucket in Rust → `429` + `Retry-After` (off unless `rate_limit` is set; burst defaults to the rate) |
| Backpressure | `max_inflight` caps concurrent dispatches → immediate `503` instead of unbounded queues |
| Body limits | 1 MiB default, enforced mid-stream — oversized bodies are cut off at `413`, never buffered |
| Slowloris | Idle timeout between body frames (`body_timeout`, 30s) → `408` |
| Connection cap | `max_connections` (10 000) — extra connections are refused |
| SQL injection | Every value parameterized; identifiers validated against model definitions |
| ReDoS | Router is a trie (no regexes); validation regexes use Rust's linear-time engine |
| WebSocket origins | Handshakes with a browser `Origin` that mismatches the Host / `ws_allowed_origins` get 403 (CSRF for WebSockets) |
| Static mounts | Path traversal, absolute paths and dot-files rejected before touching the filesystem; uploaded filenames reduced to basenames |
| Error hygiene | `debug=False` never leaks internals; unhandled exceptions log a traceback in debug, a generic 500 in production |
| Observability | `x-request-id` on every response + one access-log line per request (`access_log`) |

### Native TLS

RYNE terminates TLS itself (rustls, HTTP/2 via ALPN):

```bash
ryne run --tls-cert cert.pem --tls-key key.pem
# or: RYNE_TLS_CERT / RYNE_TLS_KEY, or settings tls_cert=/tls_key=
```

When TLS is on, session/CSRF cookies get the `Secure` flag automatically.
For a quick self-signed certificate:

```bash
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem -subj "/CN=localhost"
```

### Behind a reverse proxy

If nginx/traefik terminates TLS for you, declare it as trusted so client IPs
resolve correctly for rate limiting and `request.client`:

```python
Settings(trusted_proxies=["127.0.0.1", "10.0.0.5"])
# env: RYNE_TRUSTED_PROXIES="127.0.0.1,10.0.0.5"
```

Only requests arriving *from* a trusted proxy consult `X-Forwarded-For`
(rightmost untrusted entry wins); direct connections always use the TCP peer.

### Secrets

Set a strong `secret_key` in production (`RYNE_SECRET_KEY` or settings) —
it signs sessions and CSRF tokens. Without it a random key is generated per
start (sessions do not survive restarts). `ryne check` warns about missing,
short or placeholder keys:

```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
ryne check
```

### Opt-in hardening

```python
Settings(
    hsts=True,          # Strict-Transport-Security (only with TLS!)
    csp=True,           # Content-Security-Policy: default-src 'self'
)
```

Override any default header per-response: setting the header yourself always
wins over the framework default.

### Hardening checklist

- [ ] `RYNE_SECRET_KEY` set to a long random value
- [ ] TLS enabled (`--tls-cert/--tls-key`) or a trusted proxy in front
- [ ] `debug=False` (the default)
- [ ] `rate_limit` set for public endpoints (e.g. `100`)
- [ ] `trusted_proxies` configured when behind a proxy
- [ ] `hsts=True` once HTTPS is stable
- [ ] `csp=True` for anything serving HTML
- [ ] `session_max_age` tuned (default 14 days; shorter for sensitive apps)
- [ ] `ryne check` green in CI

---

## Testing

RYNE ships a built-in test client — no ASGI adapter, no mocks: your tests hit
the **real server** (hyper, routing, sessions, CSRF, limits) on an ephemeral
local port.

### Install the test extras

```bash
pip install ryne[test]     # brings httpx
```

### Usage

```python
from ryne.test import TestClient
from app import app       # your Ryne application


def test_hello():
    with TestClient(app) as client:
        r = client.get("/hello/world")
        assert r.status_code == 200
        assert r.json() == {"message": "Hello, world!"}


def test_create():
    with TestClient(app) as client:
        r = client.post("/articles", json={"title": "Hi"})
        assert r.status_code == 201


def test_auth_flow():
    with TestClient(app) as client:
        client.post("/login", json={"username": "ann", "password": "wonderland"})
        assert client.get("/me").status_code == 200
```

The client is an `httpx.Client` under the hood — every httpx feature
(cookies, JSON, multipart, timeouts) works. Entering the context starts the
server on a free port; leaving it stops supervising.

### Notes and patterns

- **One server per process**: RYNE's core allows a single live server per
  process, so one `TestClient` per test function (fresh state per test) is
  the natural pattern.
- **Settings per test**: pass overrides via `TestClient(app, rate_limit=10)`
  — any `Settings` attribute works.
- **CSRF in tests**: form posts need a token; JSON posts do not. Grab the
  cookie if you test forms:
  ```python
  client.get("/")  # mints the ryne_csrf cookie
  token = client.cookies["ryne_csrf"]
  client.post("/form", data={"csrfmiddlewaretoken": token})
  ```
- **Database**: point `Settings(database=...)` at a tmp_path file per test
  and run migrations in a fixture.

### WebSockets in tests

The `websockets` client (`pip install ryne[test]`) connects to the live port:

```python
import asyncio
import websockets
from ryne.test import TestClient
from app import app

def test_echo():
    with TestClient(app) as client:
        async def go():
            async with websockets.connect(f"ws://127.0.0.1:{client.port}/ws") as ws:
                await ws.send("hi")
                assert await ws.recv() == "echo: hi"
        asyncio.run(go())
```

### What RYNE itself tests

The framework's own suite (`python -m pytest`) covers the dispatch paths,
HTTP/2, TLS, rate limiting, backpressure, sessions/CSRF, the ORM and
migrations — 86 tests including live-server and subprocess scenarios.

---

## Configuration

Everything is configured through `Settings` (per-app), environment variables
(process-wide) and CLI flags (per-invocation). Precedence: CLI flag →
`Settings(...)` override → env var → default.

```python
from ryne import Ryne, Settings

app = Ryne(settings=Settings(debug=False, port=8080))
```

Unknown settings raise a `ValueError` listing the offenders — typos fail
loudly.

### Reference

#### Server

| Setting | Env | Default | Meaning |
|---|---|---|---|
| `host` | `RYNE_HOST` | `127.0.0.1` | Bind address (use `0.0.0.0` for containers) |
| `port` | `RYNE_PORT` / `PORT` | `8000` | Listen port |
| `workers` | `RYNE_WORKERS` | CPU count | Tokio worker threads |
| `debug` | `RYNE_DEBUG` | `False` | Tracebacks on stderr + error details (never in production) |
| `max_body` | `RYNE_MAX_BODY` | 1 MiB | Request-body cap, enforced mid-stream |
| `tls_cert` / `tls_key` | `RYNE_TLS_CERT` / `RYNE_TLS_KEY` | `""` | PEM paths; both set → native TLS (HTTP/2 via ALPN) |

#### Protection

| Setting | Env | Default | Meaning |
|---|---|---|---|
| `rate_limit` | `RYNE_RATE_LIMIT` | None | Requests/second per client IP; `0` disables |
| `rate_burst` | `RYNE_RATE_BURST` | `rate` | Bucket depth (how many requests can land at once) |
| `max_inflight` | `RYNE_MAX_INFLIGHT` | 2048 | Concurrent Python dispatch cap; 0 disables |
| `max_connections` | `RYNE_MAX_CONNECTIONS` | 10000 | Concurrent connection cap; 0 disables |
| `body_timeout` | `RYNE_BODY_TIMEOUT` | 30 s | Idle timeout between body frames (slowloris) |
| `trusted_proxies` | `RYNE_TRUSTED_PROXIES` | `[]` | Proxy IPs allowed to set `X-Forwarded-For` |
| `access_log` | `RYNE_ACCESS_LOG` | `True` | One log line per request on stderr |

#### Security headers & sessions

| Setting | Env | Default | Meaning |
|---|---|---|---|
| `security_headers` | `RYNE_SECURITY_HEADERS` | `True` | nosniff / DENY / Referrer-Policy / COOP / Permissions-Policy |
| `hsts` | `RYNE_HSTS` | `False` | Strict-Transport-Security (enable with TLS) |
| `csp` | `RYNE_CSP` | `False` | `Content-Security-Policy: default-src 'self'` |
| `secret_key` | `RYNE_SECRET_KEY` | random | HMAC key for sessions/CSRF (set in production) |
| `session_backend` | `RYNE_SESSION_BACKEND` | `cookie` | `cookie` or `none` |
| `session_cookie` | `RYNE_SESSION_COOKIE` | `ryne_session` | Cookie name |
| `session_max_age` | `RYNE_SESSION_MAX_AGE` | 14 days | Cookie + server-registry lifetime |
| `session_secure_cookies` | `RYNE_SESSION_SECURE` | auto | `Secure` flag (auto-on with TLS) |
| `session_store` | `RYNE_SESSION_STORE` | `True` | Server-side revocation/rotation registry |
| `csrf_enabled` | `RYNE_CSRF` | `True` | Signed double-submit CSRF for forms |
| `csrf_token_age` | `RYNE_CSRF_TOKEN_AGE` | 1 day | CSRF token lifetime |
| `ws_allowed_origins` | `RYNE_WS_ORIGINS` | `[]` | Extra WebSocket origins (same-host always allowed) |
| `ws_max_message` | `RYNE_WS_MAX_MESSAGE` | 1 MiB | WebSocket message cap (fragments included) |
| `static_cache_seconds` | `RYNE_STATIC_CACHE` | 3600 | Cache-Control for `mount_static()` |

#### Performance tuning

| Setting | Env | Default | Meaning |
|---|---|---|---|
| `sync_pool` | `RYNE_SYNC_POOL` | `False` | Run sync handlers on a bounded blocking pool (for slow/blocking handlers) |
| `sync_pool_size` | `RYNE_SYNC_POOL_SIZE` | 64 | Blocking-pool bound when `sync_pool=True` |
| `database` | `RYNE_DATABASE` | `db.sqlite3` | SQLite path (empty string disables the ORM) |
| `db_pool_size` | `RYNE_DB_POOL` | 8 | SQLite read-connection pool |

#### CLI flags (`ryne run`)

```
--host H --port P --debug --reload --tls-cert C --tls-key K
```

`--reload` watches `*.py` files in the project (watchfiles when installed,
otherwise mtime polling) and restarts the server on changes.

---

## Performance

### Numbers

11 frameworks × 6 scenarios × 60 seconds per test, `oha` load generator,
50 concurrent connections, identical endpoints and payloads — full
methodology and tables in [BENCHMARKS.md](https://github.com/ryne-framework/ryne/blob/main/BENCHMARKS.md).

RYNE vs the fastest established Python frameworks (requests/second, higher
is better):

| scenario | RYNE | Sanic | Starlette | FastAPI | Flask | Django |
|---|---|---|---|---|---|---|
| json | **30 636** | 14 532 | 9 180 | 5 629 | 3 942 | 1 578 |
| plaintext | **27 829** | 12 881 | 9 098 | 6 447 | 4 281 | 925 |
| router | **27 472** | 10 984 | 8 168 | 4 641 | 3 995 | 988 |
| http | **22 333** | 12 506 | 7 347 | 4 316 | 3 929 | 1 281 |
| db (SQLite) | **7 348** | 2 162 | 2 104 | 1 625 | 1 488 | 1 323 |
| validation | **17 066** | 9 640 | 6 516 | 4 503 | 3 621 | 1 004 |
| peak RAM (db) | **38 MB** | 79 MB | 73 MB | 87 MB | 115 MB | 125 MB |

RYNE is the fastest full-featured Python framework in **all six scenarios**,
1.9–2.7x ahead of the runner-up, 3.5–5.4x ahead of FastAPI, and 16–30x ahead
of Django — while using the least memory. (Native Rust frameworks like Actix
remain 3–6x faster still: that is the honest price of running *user code* in
Python.)

### Where the speed comes from

- **Rust owns the socket.** hyper + tokio parse HTTP, route, apply limits and
  serialize JSON; Python is invoked exactly once per request.
- **Deferred response serialization.** Handlers returning `dict`/`str` skip
  Python `json.dumps` — the Rust boundary serializes with serde directly.
- **GIL-free validation.** `Schema.validate_body(request)` parses and
  validates raw bytes in Rust with the GIL released.
- **Cached request views.** Headers, cookies, body and parsed JSON convert
  to Python exactly once per request.
- **Inline sync dispatch.** Short sync handlers run directly under the GIL on
  tokio workers — no channel hops. `sync_pool=True` moves slow handlers onto
  a bounded executor instead.
- **SQLite done right.** WAL readers run in parallel on a pool; a single
  serialized writer eliminates `SQLITE_BUSY`; the GIL is released for every
  query.

### Tuning checklist

- Return `dict` from handlers (hits the Rust serialization fast path).
- Use `Schema.validate_body(request)` on hot POST endpoints.
- Slow/blocking handlers: enable `sync_pool=True`, or make them `async`.
- `workers` defaults to CPU count; containers should leave it be.
- Behind a proxy: set `trusted_proxies` so rate limiting keys on real IPs.
- `access_log=False` shaves the log write off ultra-hot paths.

### Reproducing

```bash
python benches/run_bench.py --suite ryne          # RYNE only
python benches/run_bench.py --suite all           # every framework
python benches/report.py                          # aggregate results/*.json
```

The harness falls back to a built-in asyncio load generator when `oha` is
not installed. CI runs a shorter suite on every push.

---

## The full stack

### Validation — compiled to Rust at import time

```python
import enum
from ryne import HTTPException
from ryne.validation import Schema, Str, Int, List, Enum, Object

class Color(enum.Enum):
    RED = "red"
    BLUE = "blue"

class Address(Schema):
    city: Str(max_length=80)

class CreateArticle(Schema):
    title: Str(min_length=1, max_length=200)
    tags: List(Str(), max_items=10) = []
    views: Int(ge=0) = 0
    color: Enum(Color)                  # -> Color.RED member on output
    author: Object(nested=Address)      # nested schema
    nickname: Str(max_length=30) = None # optional + nullable

@app.post("/articles")
async def create_article(request):
    data = CreateArticle.validate_body(request)  # parse+validate in Rust, GIL-free
    ...
```

### ORM + migrations — Django-style

```python
from ryne.models import Model, CharField, IntField, ForeignKey

class Article(Model):
    title = CharField(max_length=200)
    views = IntField(default=0)
    author = ForeignKey(Author, null=True)

article = await Article.objects.create(title="Hi")
articles = await Article.objects.filter(views__gte=1).order_by("-id").limit(10)
```

```bash
ryne makemigrations   # schema diff -> reviewable Python migration files
ryne migrate          # applied atomically, recorded in ryne_migrations
```

SQLite ships compiled inside the wheel: WAL mode, a read connection pool and
a single serialized writer connection (in-process writes never fight over
SQLite's one-writer lock). The GIL is released during every query.
Point `database` at a `postgres://` URL and the same ORM runs on PostgreSQL — see [the PostgreSQL backend](#postgresql-backend).

### Auth & revocable sessions

```python
from ryne.auth import authenticate, login, logout, logout_all, get_user, requires_login

@app.post("/login")
async def login_route(request):
    user = await authenticate(request.json()["username"], request.json()["password"])
    if user is None:
        raise HTTPException(401)
    await login(request, user)      # rotates the server-side session id
    return {"ok": True}

@app.post("/logout")
async def logout_route(request):
    await logout(request)           # revokes it server-side
    return {"ok": True}

@app.get("/me")
@requires_login
async def me(request):
    return {"user": (await get_user(request)).username}
```

Sessions are signed cookies (HMAC-SHA256, constant-time verification in
Rust) backed by a server-side registry: `login` rotates the session id, so
stolen pre-auth cookies are useless; `logout` revokes; `logout_all(user_id)`
kills every session of a user. Passwords are hashed with argon2id in native
code; unknown usernames cost the same as known ones (no timing oracle).

### Security defaults

- **Native TLS** (rustls, HTTP/2 via ALPN): `ryne run --tls-cert C --tls-key K`
- `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
  `Permissions-Policy`, `COOP` on every response (opt-in HSTS and CSP)
- **Signed CSRF tokens** for form submissions (JSON APIs unaffected)
- **Per-IP rate limiting** (sharded token bucket in Rust) → `429` + `Retry-After`
- **Backpressure**: `max_inflight` caps concurrent dispatches → instant `503`
- Slowloris body timeout + connection cap
- 1 MiB request-body limit, enforced mid-stream in Rust
- `x-request-id` on every response, access log, tracebacks in debug
- `DEBUG=False` by default — unexpected errors never leak internals
- Trusted-proxy aware client IPs (`trusted_proxies`)

For blocking sync workloads: `settings.sync_pool = True` runs sync handlers
on a bounded blocking executor (`sync_pool_size`, default 64) so a slow
handler can never starve the async runtime.

### WebSockets, templates, statics — the full stack

```python
@app.websocket("/ws/{room}")
async def chat(ws, room: str):
    async for message in ws:
        await ws.send_text(f"{room}: {message}")

@app.get("/")
async def page(request):
    return app.render(request, "index.html", posts=posts)   # Jinja2

app.mount_static("/static/", "static")                      # served by Rust
```

Origin-checked handshakes, RFC 6455 framing, multipart uploads
(`request.files`) and typed route params (`{id:int}`) are all built in.

---

## CLI reference

```
ryne startproject <name>     scaffold a project
ryne run [module:app]        run the server (--reload, --host, --port,
                             --debug, --tls-cert/--tls-key)
ryne makemigrations [--name] generate a migration from model changes
ryne migrate                 apply pending migrations
ryne createsuperuser         create an admin user (--username)
ryne shell [module:app]      interactive shell with your app loaded
ryne check [module:app]      validate configuration
ryne version                 print the installed version
```

---

## Development

```bash
python -m venv .venv && .venv/Scripts/pip install -e '.[dev]'
python -m maturin develop     # build the Rust core into the venv
python -m pytest              # full test suite
cargo test --workspace        # Rust unit tests
python -m ruff check .        # lint
```

See [CHANGELOG.md](https://github.com/ryne-framework/ryne/blob/main/CHANGELOG.md) for the
release history — 0.4.x shipped WebSockets, templates, statics, uploads,
the admin panel and the PostgreSQL backend.

---

## License

MIT — see [LICENSE](https://github.com/ryne-framework/ryne/blob/main/LICENSE).

