Skip to main content

Hayavega

License: MIT

Hayavega is an early, runnable experiment in a conventional, batteries-included project structure and automatic, interactive API documentation, backed by a Rust core.

A browsable documentation site (tutorials, topic guides, a generated API reference, and the published benchmark numbers) lives in docs/ and is published at hayavega.vercel.app (built via vercel.json, which runs mkdocs build and serves its site/ output directly -- the standalone index.html landing page is not part of this deploy). This README stays the first thing a repository visitor sees; the docs site is the deeper reference it points to.

To build or browse it locally instead:

  • mkdocs build --strict -- builds static HTML into the gitignored site/ directory.
  • mkdocs serve -- live-reloading local server at http://127.0.0.1:8000, for editing.
  • python -m http.server --directory site 8000 -- serves an already-built site/ as plain static files, no mkdocs process running.

Version switching works locally too, via mike (already a docs extra): mike commits each built version to a local gh-pages git branch -- it never pushes anywhere unless you explicitly run mike ... --push against a remote. To try it: mike deploy dev latest --update-aliases && mike set-default latest && mike serve, then open http://127.0.0.1:8000 and use the version dropdown in the header. As real versions get tagged, deploy each one the same way (mike deploy <version>) to build up real version history in that local branch.

See UPCOMING_IMPROVEMENT_ROADMAP.md for the near-term plan and INDUSTRY_LEADERSHIP_ROADMAP.md for the longer-range one: performance benchmarks against FastAPI/Django/ Next.js and a higher developer-experience bar (the Signals rebrand there is delivered -- see Hayavega Signals below). Published benchmark results against FastAPI and Django live in benchmarks/results/RESULTS.md, with the full methodology, scope, and honest caveats in benchmarks/README.md.

What works now

  • newproject and newapp scaffolding (startproject/startapp still work as deprecated aliases)
  • A conventional manage.py, settings.py, and routes.py project layout
  • A development server and Python views
  • Both WSGI (default) and ASGI deployment, the latter for projects that need async def views
  • A small PyO3 native module (hayavega_native) for the Rust/Python boundary
  • Server-rendered HTML via hayavega.templates, plus hayavega.ui: server-rendered components and a first-party htmx-style hypermedia runtime (hayavega-ui.js), no JS build step

Quick start

Published on PyPI as hayavega:

python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install hayavega
hayavega-admin newproject mysite
cd mysite
python manage.py newapp blog
python manage.py launch

Working from this repository checkout instead (e.g. to build the optional Rust-native extension)? Install editable from source:

cd D:\separate_code\hayavega
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e .

Open http://127.0.0.1:8000/.

ASGI support

WSGI (above) is the default for every scaffolded project and needs nothing extra. Reach for ASGI only when a view needs async def for concurrent I/O (calling another service, an async database driver) that WSGI's one-thread-per-request model can't overlap as cheaply. hayavega newproject also scaffolds config/asgi.py, built with hayavega.asgi.create_asgi_application() instead of hayavega.runtime.create_application() but otherwise exposing the same kind of standard application object:

uvicorn config.asgi:application --host 0.0.0.0 --port 8000

Views may be async def (awaited directly) or ordinary synchronous functions (run via a dedicated worker-thread pool, max_sync_workers= on create_asgi_application, default 16), so every existing sync view -- including ones wrapped in api_route, csrf_protect, or login_required/permission_required -- keeps working unmodified under either transport with no migration required. hayavega.testing.AsyncTestClient tests ASGI apps in-process the same way TestClient tests WSGI apps. See DEPLOYMENT.md for worker guidance and benchmarks/README.md for how it measures against FastAPI on the same server.

WebSockets

WebSocket connections only exist under ASGI -- WSGI has no equivalent scope type. Register a handler with websocket_path() and pass it to create_asgi_application(websocket_patterns=[...]):

from hayavega.websocket import WebSocketDisconnect, websocket_path


async def echo(ws):
    await ws.accept()
    try:
        while True:
            await ws.send_text(await ws.receive_text())
    except WebSocketDisconnect:
        pass


websocket_patterns = [websocket_path("ws/echo", echo)]
application = create_asgi_application(urlpatterns, websocket_patterns=websocket_patterns)

One async def handler owns a connection's whole lifetime: call ws.accept() (or return without calling it to reject the connection), then ws.receive_text()/ws.receive_json() and ws.send_text()/ws.send_json() in a loop, catching WebSocketDisconnect when the client goes away. Only exact-path routes are supported (no <path> wildcard segment). If hardening's allowed_hosts/allowed_origins are configured, a WebSocket connection with a mismatched Host/Origin header is rejected before accept() is ever called -- the same protection those settings already give HTTP requests, applied here because WebSocket connections bypass the browser's ordinary same-origin/CORS enforcement ("cross-site WebSocket hijacking"). hayavega.testing.connect_websocket() drives a connection against an ASGI application in-process, the WebSocket equivalent of AsyncTestClient.

Rust development runtime

Settings.server.engine = 'rust' in settings switches hayavega launch from the default wsgiref dev server to hayavega_native's own WSGI-compatible HTTP/1.1 server -- a synchronous, fixed-thread-pool design (the same concurrency model waitress uses; CPython's GIL means async I/O would not let more Python view code run concurrently), benchmarked at 1.41x-2.68x faster than waitress across every published scenario, with real HTTP/1.1 keep-alive. Development/load-testing tooling, not a production-server recommendation of its own -- see DEPLOYMENT.md and benchmarks/README.md for the full, disclosed result including two real bugs found while benchmarking it.

Settings

Each project has a conventional config/settings.py, but instead of Django's flat ALL_CAPS module globals, Hayavega settings live on a single class Settings: with explicit, typed sections -- instantiated once as settings = Settings() and read everywhere through hayavega.conf.load_settings():

  • base_dir, secret_key, debug, and allowed_hosts for project identity and environment behavior.
  • installed_apps, middleware, and root_urlconf for application composition.
  • database (a nested class database: section) for the database location and credentials.
  • server (a nested class server: section) for the development host and port.

For example, change the default development port in Settings.server.port, or override it temporarily with python manage.py launch 8001.

middleware is a list of dotted paths to request/response middleware factories, resolved and wired into config/wsgi.py/config/asgi.py automatically -- see Middleware for the factory contract and ordering rules.

Databases

Hayavega's database layer accepts SQLite, PostgreSQL, MySQL, and MariaDB through the database section of Settings. SQLite works with Python alone. For other engines, add the driver with UV before starting the project:

uv add "psycopg[binary]"   # PostgreSQL
uv add pymysql cryptography # MySQL or MariaDB -- cryptography is required by
                             # pymysql to authenticate against MySQL 8's
                             # default caching_sha2_password plugin; every
                             # connection fails without it

Example PostgreSQL configuration:

class database:
    engine = "postgresql"
    name = "hayavega"
    host = "localhost"
    port = 5432
    user = "hayavega"
    password = os.getenv("HAYAVEGA_DATABASE_PASSWORD")

There is exactly one database section -- Collection/admin_routes/auth_routes/migrations each take one database dict, with no Django-style named-connection registry. A project talking to a second database defines its own extra section and converts it with hayavega.conf.load_database(); see Multiple databases.

Connection pooling

By default, every database call opens a new connection and closes it when done. create_application(pool_connections=True) (or hayavega.db.enable_connection_pooling() directly) reuses one connection per worker thread instead -- no other code changes needed, since it only changes what connect()/Connection.close() do internally.

Benchmark this for your own deployment before enabling it -- it is not a universal win. It's a real, measured improvement under ASGI/uvicorn, but a real, measured ~10-15% regression under WSGI/waitress with SQLite specifically (many threads holding a persistent SQLite connection open under contention is itself slower than each opening and closing one quickly -- confirmed with a framework-free reproduction, not a guess). benchmarks/hayavega_asgi_app/app.py enables it; benchmarks/hayavega_app/app.py (WSGI) deliberately does not. See INDUSTRY_LEADERSHIP_ROADMAP.md's "Connection pooling, attempted" section for the full numbers.

A pooled MySQL/PostgreSQL connection that the server drops after sitting idle (MySQL's wait_timeout, a network blip) is detected and transparently reconnected on its next use, retrying the statement that hit the dropped connection once. SQLite has no server-side idle timeout, so it's never affected.

Migrations and transactions

Hayavega uses small, ordered SQL migrations. Add a numbered file such as blog/migrations/0001_products.py to an installed app:

operations = [
    "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
]

Run python manage.py applymigrations to apply pending migrations, or python manage.py migrationstatus to inspect their state. Each migration runs in its own transaction; if any operation fails, neither its schema changes nor its history row are committed. Applied files are checksummed, so changed or missing migration files are rejected instead of silently proceeding.

For an app with declared Entity classes, create the reviewed initial table migration with python manage.py newmigration blog (after adding "blog" to Settings.installed_apps). Later invocations generate only nullable ADD COLUMN operations and record a dependency on the preceding migration. New tables, non-nullable fields, field alterations, and removals must remain explicit, reviewed SQL migrations; newmigration detects a type, nullability, or uniqueness change on an already-migrated column and raises an error naming the column, rather than silently leaving the schema out of sync with the declared entity.

Production validation and probes

Before starting with Settings.debug = False, Hayavega requires a non-default secret_key, explicit allowed_hosts, and security section settings for force_https and secure_cookies. Add *health_routes(_settings.database) to your URL patterns to expose GET /healthz (liveness) and GET /readyz (database readiness) for your deployment platform.

Run python manage.py diagnose (or hayavega diagnose) to catch the same class of issue before a deploy attempt fails on it: unsafe settings (debug left on, a placeholder secret_key, a wildcard allowed_hosts), deprecated Pulse-era API usage anywhere in your own project files, and pending migrations -- each finding names exactly where the problem is and what to do about it, e.g. "Set Settings.secret_key ... in config.settings."

Rate limiting

The built-in limiter is process-local and intended for development or a small single-instance deployment. Set Settings.security.rate_limit_max_clients to bound tracked client identities (default: 10,000); idle clients are pruned and the least-recently-used identity is evicted when full. Use an edge proxy or Redis rate limiter for coordinated limits across multiple application instances.

Caching

hayavega.cache gives you a small, pluggable cache behind one interface -- get/set/delete/clear/get_or_set -- with three backends:

from hayavega.cache import InMemoryCache, RedisCache, SqliteCache

cache = InMemoryCache(max_entries=10_000)  # one process, no dependency
cache = SqliteCache("cache.sqlite3")  # every process on one machine, no dependency, no server
cache = RedisCache("redis://localhost:6379/0")  # shared across every process pointed at it, any number of machines

InMemoryCache is a thread-safe, TTL-aware, bounded LRU cache -- the same design as hardening.RateLimiter, just for arbitrary values instead of request counts; it stores Python objects directly, no serialization. SqliteCache is Redis's actual value (one cache shared across every worker process instead of one per worker) for a single-machine deployment, with no separate server and no new dependency -- it reuses db.connect(), the same connection layer hayavega.jobs/hayavega.migrations already use, with WAL mode enabled so a writer never blocks a reader. Like RedisCache, a key_prefix scopes every key an instance owns -- including clear(), which only clears its own prefix -- so more than one logical cache can safely share one file. It can't share state across multiple machines, though -- SQLite has no network protocol, so a deployment spanning more than one host still needs RedisCache. RedisCache JSON-encodes values via msgspec (already a core dependency, same JSON-safety constraint SqliteCache has), so only JSON-safe values round-trip; it shares state across processes the same way hardening.RedisRateLimiter does, and requires uv add redis.

Cache a whole view's response with cached_view:

from hayavega.cache import cached_view


@cached_view(cache, ttl=60)
def product_list(request): ...

Only GET requests are served from, or written to, the cache -- a request meant to have a side effect is never cached. The cache key defaults to the request's path and query string; pass key_func= to key on something else. Nothing invalidates a cached entry except its ttl expiring or an explicit cache.delete()/cache.clear() call, so pick ttl deliberately for data that changes.

Build a cache from Settings.cache instead of hardcoding a backend:

class Settings:
    class cache:
        backend = "sqlite"  # "in_memory" (default), "sqlite", or "redis"
        path = BASE_DIR / "cache.sqlite3"
        default_ttl = 300
from hayavega.cache import cache_from_settings

cache = cache_from_settings(settings.cache)

Don't want to install Redis at all? hayavega cacheserver is a built-in, networked, in-memory cache server speaking enough of the real Redis wire protocol (verified against the real redis package, not just its own code) that RedisCache()'s own zero-argument default points straight at it -- no client code changes, reachable from any number of machines (unlike SqliteCache), refuses to bind beyond localhost without a --password. See Caching for what it does and doesn't implement (no persistence, no EVAL/Lua scripting -- a real Redis instance is still needed for hardening.RedisRateLimiter).

See Caching for the full option list.

Observability

Every response carries an X-Request-ID; a valid incoming ID is preserved, otherwise Hayavega generates one. Audit logs contain the ID, method, path, status, and duration without request headers. Enable JSON output in an application entrypoint with configure_logging(json_output=True).

Request-level metrics are opt-in: pass a MetricsRegistry as metrics= to create_application to record a count and a duration observation for every completed request, labeled by the matched route pattern (not the raw path, so cardinality stays bounded) and status code. Nothing changes for applications that do not pass one. Add metrics_routes(registry) to your URL patterns to expose the counters at /metrics in Prometheus text exposition format, ready for a standard Prometheus scrape config:

from hayavega import MetricsRegistry, metrics_routes

metrics = MetricsRegistry()
urlpatterns = [..., *metrics_routes(metrics)]
application = create_application(urlpatterns, metrics=metrics)

Call metrics.error_rate() for the fraction of recorded requests that returned a 5xx status, or metrics.snapshot() for the raw counters.

Reverse proxies

Set Settings.security.trust_proxy_headers = True only with an explicit Settings.security.trusted_proxy_ips list (IP addresses or CIDR networks). Hayavega then honors forwarded client and HTTPS headers only from those proxies. HTTPS redirects retain the original query string.

Audit events

Hayavega persists privacy-safe authentication and administration events in hayavega_audit_events. Use audit_events(_settings.database) for operational review. Events contain an actor ID, event type, timestamp, and small metadata; passwords, session tokens, and request headers are never stored.

See DEPLOYMENT.md for reverse-proxy, HTTPS, backup, restore, and rollback procedures, and SECURITY_REVIEW_CHECKLIST.md for release-review controls. PostgreSQL and MySQL/MariaDB integration tests are opt-in: set HAYAVEGA_TEST_POSTGRES_NAME or HAYAVEGA_TEST_MYSQL_NAME plus optional host, port, user, and password variables before running the test suite.

Background jobs

Hayavega persists durable background work in hayavega_jobs, reusing the same SQLite/PostgreSQL/MySQL connection layer as sessions, audit events, and migrations -- no new broker (no Redis, no RabbitMQ) required. A project defines plain handler functions and enqueues work:

# myapp/jobs.py
def send_email(payload: dict) -> None: ...


# config/settings.py, inside class Settings:
job_handlers = {"send_email": "myapp.jobs.send_email"}
from hayavega.jobs import enqueue

enqueue(_settings.database, "send_email", {"to": "a@example.com"})

Run hayavega runworker to start a worker process that polls for eligible jobs and dispatches them to the registered handlers, retrying failed jobs with capped exponential backoff (up to max_attempts, default 3) before marking them dead. This is poll-based, not push-based: a job becomes eligible the instant its run_at passes, but a worker only notices on its next poll, so pickup latency is bounded by poll_interval (default 1 second), not instant.

Pass jobs=True to admin_routes() to add a superuser-only /admin/jobs view: a paginated, status-filterable list of jobs (pending/running/ succeeded/dead) with a one-click retry action for dead-lettered jobs.

Custom management commands

manage.py's command set (launch, newapp, createadmin, ...) is a fixed, closed list -- but a project can register its own, resolved the same dotted-path-from-settings way job_handlers already is:

# myapp/commands.py
def hello_command(args: list[str]) -> None:
    print("hello", " ".join(args))


# config/settings.py, inside class Settings:
commands = {"hello": "myapp.commands.hello_command"}
python manage.py hello world

A custom command receives the remaining CLI arguments as a plain list[str] -- no argument-parsing framework imposed. A name that collides with a built-in command (launch, diagnose, ...) is never shadowed: the built-in always wins.

Testing and CI

Install the development extra and run the suite with pip install -e ".[dev]" then pytest. This enforces a 100% coverage gate by default (see pyproject.toml); pass --no-cov to skip it locally. GitHub Actions (.github/workflows/ci.yml) runs the suite on Python 3.10, 3.11, 3.12, and 3.15 for every push and pull request, plus a separate job that builds the hayavega_native Rust extension and runs the tests it backs, and separate opt-in jobs that run the PostgreSQL, MySQL/MariaDB, and Redis integration tests against real postgres:16/mysql:8/redis:7 service containers.

Lint and format with ruff: ruff check . and ruff format . (the latter without --check applies fixes). Both run as a required, separate lint CI job. Configuration lives in pyproject.toml under [tool.ruff]; line length is set generously (320) to match this codebase's existing dense style rather than forcing a rewrite, and E501 (line-too-long) is intentionally not a lint-enforced rule for the handful of lines -- an __all__ list, embedded HTML/JS template strings -- that are already one deliberate unit and would only get harder to read if wrapped.

Type-check with mypy: mypy (config lives in pyproject.toml under [tool.mypy], scoped to src/hayavega). This runs as its own required typecheck CI job, separate from lint and test, on the pure-Python fallback build. src/hayavega/py.typed (PEP 561) ships in the built wheel, so downstream projects that import Hayavega get real type checking against its actual annotations rather than an implicit Any. Two narrow [[tool.mypy.overrides]] blocks turn off missing-import errors for hayavega_native (the optional, self-built Rust extension) and for psycopg/pymysql (optional database drivers not installed in the default dev environment) -- everything else is checked at mypy's default strictness.

Scan dependencies for known vulnerabilities with pip-audit (Python: pip-audit, checked against the PyPI/OSV advisory database) and cargo-audit (Rust: cargo audit, checked against the RustSec advisory database). Both run as a required, separate dependency-scan CI job on every push and pull request, and Dependabot (.github/dependabot.yml) opens a weekly update pull request for outdated pip, cargo, and GitHub Actions dependencies -- so a fix is proposed automatically, not just flagged.

Hayavega Signals

The opt-in in-process event bus is available under hayavega.addons.signals. Applications explicitly create a SignalBus, subscribe to named events, and publish events. Delivery is always concurrent and off the caller's thread -- there is no sync/async choice to make -- through a Rust-native worker pool (hayavega_native.SignalDispatcher) when the optional extension is built, or a behavior-compatible Python ThreadPoolExecutor otherwise, and isolates subscriber failures either way. Pass the same SignalBus as signals= to create_application, auth_routes, admin_routes, or migrations.apply to receive request.*, auth.*, admin.action, and migration.applied events from those components; nothing changes for applications that do not pass one. See src/hayavega/addons/signals/README.md for its full API, delivery semantics, and how to build the Rust-backed dispatcher.

This add-on originally shipped as "Pulse." hayavega.addons.pulse and every pulse= keyword argument still work exactly as before (PulseBus is SignalBus, PulseEvent is SignalEvent) but raise a DeprecationWarning and will be removed in a future release -- update to hayavega.addons.signals and signals= when convenient. Event names themselves never changed.

REST and GraphQL

Hayavega includes a small dependency-free API layer. The bundled example exposes:

  • GET /api/health - JSON REST health response.
  • POST /graphql - GraphQL JSON endpoint. Send {"query":"{ greeting }"}.

Use api_route('api/items', view, methods=('GET', 'POST')) for REST endpoints. Define GraphQL fields with Schema(query={'field': resolver}, mutation={'field': resolver}) and mount them with path('graphql', graphql_view(schema), methods=('POST',)).

Hayavega's GraphQL layer is a small, dependency-free executor, not a full spec-compliant engine -- it supports what most resolvers actually need:

  • Arguments: field(name: "Ada", limit: 5, active: true) are parsed and passed to the resolver as keyword arguments: def field(request, name, limit, active): ....
  • Variables: send {"query": "query($id: ID!) { user(id: $id) { name } }", "variables": {"id": "42"}}. Declared defaults ($active: Boolean = true) and required variables ($id: ID! with no default) are honored.
  • Nesting: when a resolver returns a mapping (or a list of mappings), a sub-selection like { user { id team { name } } } is projected from it recursively; requesting a field the mapping doesn't have is a per-field error, not a crash.
  • Mutations: pass Schema(query=..., mutation={'createUser': resolver}) and call them with mutation { createUser(name: "Ada") { id } }.
  • Limits: Schema(..., max_depth=10, max_fields=200) (the defaults) reject queries that nest or fan out further than that, and a hard structural limit rejects pathologically nested query text during parsing, before any resolver runs.
  • Introspection: off by default, since there's no declared type system to expose a real __schema from. __typename always works (it doesn't reveal schema shape). Pass Schema(..., introspection=True) to allow __schema { queryType { fields { name } } mutationType { fields { name } } }, which lists root field names only -- not a spec-complete introspection schema.

Fragments and directives are not supported and are rejected with a clear error rather than a confusing parse failure.

Every JSON error response uses the same envelope: {"detail": "<message>"}. Return it with json_response({"detail": "..."}, status) so clients only ever parse one error shape; hayavega.swagger.ERROR_SCHEMA is that shape as a JSON Schema, reused automatically for the errors Hayavega documents for you (see below) and importable for your own routes' responses.

There's no framework-enforced versioning scheme; the common convention is a version segment in the path, such as api_route('api/v1/items', view). Give each version's application its own api_version (see below) so /openapi.json reports the version actually being served.

Built-in Swagger documentation

Every Hayavega application automatically exposes an OpenAPI 3 document at /openapi.json and an interactive Swagger UI at /docs. REST endpoints declared with api_route are added to the document automatically, along with a bearerAuth security scheme and a shared Error schema ({"detail": string}) that any endpoint's error responses can reference. The documentation routes are reserved only when an application has not defined routes with those paths itself.

Describe an endpoint precisely instead of relying on the generic fallback:

from hayavega import ERROR_SCHEMA, api_route

api_route(
    "api/items",
    view,
    methods=("POST",),
    secured=True,  # requires a Bearer token; adds `security` + a 401 response
    request_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
    responses={
        201: {"description": "Created.", "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}},
        400: {"description": "Invalid input.", "schema": ERROR_SCHEMA},
    },
)

create_application(urlpatterns, api_title='Bookstore API', api_version='2.1.0') names and versions the generated document. All of the built-in auth_routes endpoints (login, password reset, email verification, sessions) already describe themselves this way.

Development autoreload

Hayavega can launch directly from this source checkout without pip. From the repository root, run ./hayavega.cmd launch 8001. It watches Python files in the project and automatically restarts the development server after code changes. Use ./hayavega.cmd launch 8001 --noreload to disable this behavior.

If you later install Hayavega as a package, hayavega launch 8001 is also available as a terminal command.

Press Ctrl+C in the same terminal to stop the server cleanly, including when autoreload is enabled.

MVP polish

Render templates with render_template("page.html", context, directories=(_settings.base_dir / "templates",)), and expose development assets with *static_routes(_settings.base_dir / "static"). The included TestClient exercises a WSGI application without a network socket. Unhandled view errors are logged to hayavega.runtime and return a safe 500 response without a traceback when debug mode is disabled.

Templates

hayavega.templates is a small, dependency-free engine -- every interpolated value is HTML-escaped and no template ever executes arbitrary Python:

  • {{ user.name }} -- dotted-path lookups into dicts or objects.
  • {% if role == "admin" %}...{% elif a and not b %}...{% else %}...{% endif %} -- ==, !=, in, and, or, not, and string/number/true/false/none literals.
  • {% for item in items %}{{ loop.index }}: {{ item }}{% endfor %} -- with loop.index/loop.index0/loop.first/loop.last inside the body.
  • {% extends "base.html" %} + {% block name %}...{% endblock %} -- a child overrides a parent's named blocks; unoverridden blocks keep the parent's default content. extends must be the template's first tag.

Compiled templates are cached per file, invalidated by mtime, so an edited template is picked up on the next render without a restart, and an unchanged one skips re-parsing. Fragments, directives, and filters are not supported, and undefined variables render as empty text by default; pass render_template(..., debug=True) in development to raise TemplateRenderError/TemplateSyntaxError on an undefined variable, non-iterable loop target, or a block a child defines that the base template never declares, instead of silently rendering nothing.

i18n

hayavega.i18n does translations with plain JSON catalogs, not gettext -- no .po/.mo files, no msgfmt compiler, no makemessages/ compilemessages-equivalent command. A project hand-edits locale/en.json/locale/es.json directly:

{"welcome": "Welcome!", "item_count": {"one": "{count} item", "other": "{count} items"}}
from hayavega.i18n import catalog_from_settings, translator_for

catalog = catalog_from_settings(settings.i18n)  # Settings.i18n.languages/default_language/locale_dir


def home(request):
    t = translator_for(request, catalog)  # negotiates Accept-Language, q= values, falls back cleanly
    return Response(render_template("home.html", {"t": t}))
<h1>{{ t.welcome }}</h1>

translator_for isn't Request-state or middleware-based -- it's a plain function a view calls, returning a LanguageCatalog that exposes translations through dotted-path attribute access (templates.py's expression grammar has no function-call syntax) or as a callable with interpolation (t("item_count", count=3)) for use in view code. A missing key returns the key itself, never raises. Built-in error messages (forms.py, auth.py's validate_password, ...) are not translated automatically. See i18n for language negotiation details, pluralization, and an explicit ?lang=-style override.

UI framework

hayavega.ui is a server-rendered component system plus a first-party htmx-style hypermedia runtime, both living inside this package -- no npm, no Node.js, no bundler, no separate frontend project. A component is a plain Python function returning Markup:

from hayavega import path
from hayavega.response import Response
from hayavega.ui import Alert, Button, Page, ui_routes


def home(request):
    body = Page(
        "Home",
        Alert("Welcome back!", variant="success"),
        Button("Refresh", hx_get="/fragment", hx_target="#content"),
        stylesheets=["/hayavega-ui/hayavega-ui.css"],
        scripts=["/hayavega-ui/hayavega-ui.js"],
    )
    return Response(body)


def fragment(request):
    return Response("<div id='content'>Refreshed.</div>")


urlpatterns = [
    path("", home),
    path("fragment", fragment),
    *ui_routes(),  # mounts /hayavega-ui/hayavega-ui.js and /hayavega-ui/hayavega-ui.css
]

hx-get/hx-post/hx-put/hx-delete fetch a URL (on click for buttons/links, on submit for forms) and swap the returned HTML fragment into hx-target using hx-swap -- the HTMX pattern, implemented in one small dependency-free JS file with a single delegated click listener for both hx-* triggers and mgo-boost client-side link routing. It reads the hayavega_csrf cookie and sends it as X-Csrf-Token automatically, so an hx-post to a @csrf_protect-guarded view is protected without extra client-side wiring. Run the example site (./hayavega.cmd launch 8001 from example_site/) and visit /ui-demo for a runnable version of this pattern, or see tests/test_ui_hypermedia.py for the full round trip proven against the real WSGI stack.

mgo-boost adds client-side routing on top of the same runtime: a link inside (or carrying) an mgo-boost attribute navigates via fetch + history.pushState instead of a full page load, swapping the element both pages mark mgo-boost-target; popstate re-fetches and swaps for back/forward. See tests/test_ui_boost.py. There is no client-side reactive/signals layer in this version (a prior one existed and was removed in favor of keeping this to a single, simpler hypermedia model); a genuine Node.js/npm-based SPA framework (JSX, a virtual DOM, a bundler) remains a distinct, separate, unscoped possibility.

Rust acceleration for fragment rendering was benchmarked and found to be a genuine win for the small, fixed-shape markup a fragment swap renders (4x-8x faster in isolation) -- unlike full-page template rendering, where every general-purpose Rust candidate lost to Python. It is not wired into hayavega.ui's shipped API (a hardcoded native renderer only ever handles one exact fragment shape, so generalizing it would reintroduce the same problem full-page Rust templating already failed at); see benchmarks/native_htmx_fragment.py for the measurement and benchmarks/hayavega_app/app.py's posts_fragment_native for a worked example of writing your own for a hot fragment in your own project.

Rust-native Hayavega types

Use Hayavega-specific types where performance-sensitive code benefits from a Rust implementation:

from hayavega import mango_list

items = mango_list(["first", "second"])
items.append("third")

mango_list remains usable without a Rust toolchain through a compatible fallback. When the hayavega_native PyO3 extension is built, it automatically becomes a Rust-owned collection. Hayavega Signals' event dispatcher follows the same pattern: see Hayavega Signals above. The next Rust migration targets are request routing, JSON serialization, authentication operations, and database execution.

Declarative data entities

Hayavega uses modern entity definitions and its own distinct query API, designed independently rather than copying an existing ORM's names:

from hayavega import Decimal, Entity, Text


class Product(Entity):
    name = Text(unique=True)
    price = Decimal()


products = Product.collection(_settings.database)
products.append(name="Notebook", price=9.5)
items = products.seek(price__above=5).arrange("-price").window(20).fetch()

collection, seek, arrange, window, fetch, only, revise, and erase provide the query workflow. The Python implementation works now; its selection-plan compiler also exists in the Rust core for the native path.

For read-only rendering (a list page, a JSON API response) where entity behavior isn't needed, values() returns plain dict rows instead of Entity objects, skipping per-column coercion entirely -- a measured 1.28-1.36x faster than fetch() plus manual dict extraction for the same rows:

posts = Post.collection(_settings.database).browse().arrange("id").window(20).values("title", "views")
# [{"title": "First post", "views": 12}, ...]

Pass no field names to get every column. It composes with seek/arrange/window exactly like fetch() does.

Every value in a generated query is a parameterized placeholder, never interpolated into SQL text -- seek(name__contains=user_input) cannot become a SQL injection no matter what user_input contains. Column and table names (which SQL can't parameterize) are checked against a strict identifier allowlist and the entity's own declared columns before they ever reach a query string, so seek(**{"unknown_field": 1}) or a crafted __table__ raises EntityError instead of running. This applies everywhere the ORM is used, including the admin's generated forms. See tests/test_sql_injection.py.

API serializers

Use a schema-driven Serializer for external payload validation or an EntitySerializer for model output:

from hayavega import EntitySerializer

product_view = EntitySerializer(Product)
payload = product_view.json(product)

The serializer normalizes entities, mappings, lists, and dates, then encodes them with msgspec for consistent high-performance JSON output.

Superuser

Create the first administrator for a project with:

python manage.py createadmin

Hayavega prompts for a username, email address, and password confirmation. It stores users in the project's local SQLite database (hayavega.sqlite3 by default), with salted scrypt password hashes. Application code can validate credentials with hayavega.authenticate(database_path, username, password).

Authentication security

Hayavega's built-in authentication uses salted, memory-hard scrypt password hashes; by default requires passwords of at least 12 characters with lowercase, uppercase, numeric, and symbol characters (replaceable with your own validators=[...] list, or project-wide via Settings.password_validators -- see Password policy); locks an account for 15 minutes after five failed attempts; and stores only hashes of session tokens. Enable authenticator-app two-factor authentication with:

python manage.py enable2fa admin

The command prints a secret to add to a TOTP-compatible authenticator app. Keep it private: it is shown only when two-factor authentication is enabled. manage.py enable2fa automatically encrypts the stored secret with a key derived from Settings.secret_key when cryptography is installed (uv add cryptography); without it, hayavega diagnose reports that the secret is stored in plaintext instead. Calling enable_totp/authenticate/auth_routes directly from your own code needs secret_key= passed explicitly -- see Authentication.

The generated project also exposes JSON authentication endpoints: POST /api/auth/login, GET /api/auth/me, and POST /api/auth/logout. Login accepts username, password, and optional totp_code; it returns a Bearer session token. Use HTTPS before exposing these endpoints outside local development.

Admin

admin_routes(_settings.database) always provides administrator-only /admin and /admin/users pages. Pass models=[ModelAdmin(YourEntity)] to add paginated list/add/edit/delete pages for your own hayavega.models.Entity subclasses:

from hayavega import admin_routes
from hayavega.admin import ModelAdmin
from hayavega.models import Entity, Integer, Text


class Product(Entity):
    name = Text()
    price = Integer()


urlpatterns = [*admin_routes(_settings.database, models=[ModelAdmin(Product)])]

Each registered model gets:

  • Pagination: ?page=N on the list view, sized by ModelAdmin(..., page_size=25).
  • Validation: a blank required field or a value that fails a column's type coercion (e.g. text in an Integer field) is a form error, not a crash.
  • Permissions: ModelAdmin(..., can_access=lambda user: ...) gates a model on any predicate; the default requires user.is_superuser, same as superuser_required.
  • Change history: the edit page lists every create/update/delete recorded against that record, sourced from the same audit log audit_events() reads -- updates record which field names changed, not their values.
  • Filtering, searching, and bulk actions: ModelAdmin(..., list_filter=("published",), search_fields=("title", "body"), actions={"archive": archive_fn}) -- filter dropdowns populated from the column's actual distinct values, a substring search box across named columns, and named bulk operations (callable(list[Entity])) run against every checked row. See Admin for the full behavior.

Every add/edit/delete submission is CSRF-protected the same way as auth_routes: the rendered form carries a hidden csrf_token field (plain HTML forms can't set a custom header), checked against the hayavega_csrf cookie set when the form was shown. Bearer-token requests skip the check, since they aren't vulnerable to cross-site forgery the way cookies are.

Authentication lifecycle

auth_routes also exposes password reset, email verification, and session management. Reset and verification tokens are single-use, expire (1 hour and 24 hours respectively), and are never stored or logged in plain text:

  • POST /api/auth/password-reset/request and POST /api/auth/password-reset/confirm - requesting a reset always returns 202, whether or not the username exists, to avoid revealing valid accounts. Confirming one revokes every existing session for that user.
  • POST /api/auth/email/verify/request (authenticated) and POST /api/auth/email/verify/confirm - GET /api/auth/me reports email_verified; gate a view on it with email_verified_required(database_path).
  • GET /api/auth/sessions lists the current user's active sessions (id, timestamps, IP address, and user agent -- never a token); POST /api/auth/sessions/revoke-others revokes every session except the caller's.

By default, reset and verification tokens are logged rather than emailed, so the flow works without a mail provider configured. Pass your own notify(kind, user, token) callable as auth_routes(..., notify=...) to send real email. Every step -- request, completion, rotation, and bulk revocation -- is recorded through the audit log without ever storing the token itself.

Login and session telemetry

Every login attempt and every session records the client's IP address and User-Agent, both in the hayavega_sessions table and in the audit log (auth.login_succeeded, auth.login_failed, and auth.session_created), so you can review where and from what an account signed in -- without storing anything sensitive: no credential, token, or cookie is ever recorded, matching the audit log's existing rule. This is IP-address-level context, not resolved geolocation -- Hayavega stays dependency-free and does not ship or call a GeoIP service; add one yourself and log its result as audit metadata if you need city/country-level location.

X-Forwarded-For is only trusted from a proxy you've explicitly configured. Pass the same HardeningOptions you gave create_application to auth_routes(..., hardening=...) so login recovers the real client address behind a trusted reverse proxy instead of recording the proxy's own IP; without it, the immediate connection's address is used, exactly like the runtime's own rate limiter and TRUSTED_PROXY_IPS handling.

The Rust extension is optional. Install a Rust toolchain (cargo on PATH) before running pip install -e . (or a plain pip install .) and it builds and bundles automatically -- no separate command. Without Rust, the same install command gives you the pure-Python fallback.

See ROADMAP.md for the planned delivery path and boundaries.

Metadata

Release files for hayavega 0.1.0

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

Source distribution (sdist)

Source distribution for hayavega 0.1.0
File Size Uploaded Provenance Actions
hayavega-0.1.0.tar.gz 583.6 kB With provenance Details for hayavega-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for hayavega 0.1.0
File Interpreter ABI Platform Actions
hayavega-0.1.0-py3-none-any.whl Python 3 none any Details for hayavega-0.1.0-py3-none-any.whl

Total release size: 753.3 kB

Release files / hayavega-0.1.0.tar.gz

Download URL hayavega-0.1.0.tar.gz
Size 583.6 kB
Tags Source
SHA-256 checksum
How to use checksums
73f401ae8acfff1ab61a422d8bce43259f1c8ca957390856bdb7f1982a8eb93f
BLAKE2b-256 checksum
How to use checksums
31760737e1fcff34090405b062f85b25a73c3da2c78f8765d52066e4e6ec108e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 12, 2026.

Transparency log

Release files / hayavega-0.1.0-py3-none-any.whl

Download URL hayavega-0.1.0-py3-none-any.whl
Size 169.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fb67784af843c81cfabcb7d8b5fbfd302fc778741005f49b4e2dc6b4b0bd5ca7
BLAKE2b-256 checksum
How to use checksums
5398af80ff46e9619829a16162e3679b8f3968ea209a9507788e0fb609a4968b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 12, 2026.

Transparency log

Security for hayavega 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for hayavega-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 12, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

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