Skip to main content

RYNE

PyPI version Python versions CI License

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:

pip install ryne

RYNE vs Python web frameworks: RPS by scenario

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

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

Latency percentiles: RYNE has the lowest p50/p95/p99 among Python frameworks

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/ in the repository.

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

Quickstart

Install

pip install ryne

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

Your first project

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

# 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: 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

ryne startproject blog && cd blog

2. Models

# 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

# 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

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

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

6. Users and login

ryne createsuperuser --username ann
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

ryne makemigrations && ryne migrate
ryne run --reload
curl -X POST localhost:8000/articles -d '{"title": "Hi"}'
# {"id": 1, "title": "Hi", "body": "", "views": 0, "author": null}

8. Test it

# 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

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

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

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)

@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:

{"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

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

Models

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

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

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

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:

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 — 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:

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

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

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:

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

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

@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:

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:

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.

pip install ryne[templates]

Setup the templates directory

from ryne import Ryne

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

Rendering

@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,
<!-- 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); the token is injected as csrf_token in every template context — put it in the form:

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

File uploads through forms

<form method="post" action="/upload" enctype="multipart/form-data">
  <input type="file" name="avatar">
  <button>upload</button>
</form>
@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:

app.mount_static("/static/", "static")
<link rel="stylesheet" href="/static/style.css">

See Static files and uploads for details, and examples/templates_demo/ for a runnable app.


Static files and uploads

Static mounts

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.

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):

@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:

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:

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:

@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):

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):

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:

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:

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:

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

Opt-in hardening

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

pip install ryne[test]     # brings httpx

Usage

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:
    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:

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.

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.

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

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

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

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

Auth & revocable sessions

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

@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

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 for the release history — 0.4.x shipped WebSockets, templates, statics, uploads, the admin panel and the PostgreSQL backend.


License

MIT — see LICENSE.

Metadata

Release files for ryne 0.4.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ryne 0.4.4
File Size Uploaded Provenance Actions
ryne-0.4.4.tar.gz 4.2 MB Without provenance Details for ryne-0.4.4.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for ryne 0.4.4
File Interpreter ABI Platform Actions
ryne-0.4.4-cp310-abi3-win_amd64.whl CPython 3.10 abi3 Windows x86-64 Details for ryne-0.4.4-cp310-abi3-win_amd64.whl

Total release size: 7.8 MB

Release files / ryne-0.4.4.tar.gz

Download URL ryne-0.4.4.tar.gz
Size 4.2 MB
Tags Source
SHA-256 checksum
How to use checksums
e42930fa72bd380c751704e6a346abec1ff3862efa661a9b38385070342d950c
BLAKE2b-256 checksum
How to use checksums
f1c61750e7f962ad37c50d67914a920a2d14a04d629f15f1baefac05d848e771
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release files / ryne-0.4.4-cp310-abi3-win_amd64.whl

Download URL ryne-0.4.4-cp310-abi3-win_amd64.whl
Size 3.6 MB
Tags CPython 3.10 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
c822a2e977acbd00b8e7f793aa91957f3862f608f43986654ac69566b72effcf
BLAKE2b-256 checksum
How to use checksums
c6ad7d0e76a3933307b0f00058276a5d08358872496d28e3b2ac94bbcf2c6f2e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Security for ryne 0.4.4

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

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