Skip to main content

Caspian Utils (casp)

The shared Python runtime behind Caspian applications: components, layouts, the component compiler, RPC, auth, validation, and supporting utilities.

This repository is the package, not an application starter, so this README documents the surface that ships here.

  • PyPI package: caspian-utils
  • Python package: casp
  • Python requirement: >=3.14

Installation

pip install caspian-utils

casp declares no install_requires; the host application installs and pins the runtime dependencies. See Dependencies.

Core Model

Caspian is HTML-first and Python-only. There are no .html sidecar files and no Caspian-specific template language:

  • markup is authored inline in the owning .py file, returned from html(r"""...""")
  • server rendering is native Jinja — {{ value }}, {% for %}, filters
  • components are Python functions decorated with @component
  • components are composed with real Python imports and rendered as <x-*> tags
  • a layout places its child route with <slot /> or {{ children }}

Two things that older versions had are gone. @import HTML comments no longer import anything and are rejected with an error — composition is Python imports. render_html() and sibling .html component templates no longer exist — markup lives in the Python file.

The three brace dialects

Every template renders through Jinja before the component compiler sees it, so three brace forms coexist. Confusing them is the most common source of broken markup:

Syntax Layer Meaning
{{ value }} server (Jinja) Python-to-HTML interpolation, autoescaped
{{ value | json }} server (Jinja) serialize a server value into a <script>; returns Markup, so it is not double-escaped
{# comment #} server (Jinja) stripped from output
{ expression } browser left untouched by the server, evaluated by the client runtime

Autoescaping is on, so {{ value }} is safe for user text; trusted HTML needs Markup(...) or | safe. A children value is marked safe automatically.

Never author markup as an f-string. An f-string consumes single braces, so the two dialects invert: {count} intended for the browser has to become {{count}}, and server interpolation stops being escaped while the result is still marked trusted. html(r"""...""") is the one markup entrypoint; prefer the raw form so backslashes in a <script> (regex, \n) mean the same thing in the source and at render.

Braces in server values are escaped

Jinja's finalize hook encodes { and } as &#123;/&#125; on every non-Markup value, because the compiler compiles the rendered DOM — a stored {fetch(...)} would otherwise be executed as a client-side expression.

Markup is the trust boundary. | safe, the json filter, get_attributes(...), merge_classes(...), and rendered layout children all return Markup and keep their braces live. The practical consequence: you cannot build a client expression by interpolating a plain server stringclass="{{ some_expr }}" renders inert. Author the expression in the template, or return Markup from the helper. A new helper that emits client syntax must return Markup; a helper that formats user data must not.

Components

from casp.component_decorator import component, html


@component
def AlertBox(title: str, children: str = "", **props):
    return html(r"""
    <section class="alert">
      <h2>{{ title }}</h2>
      <div>{{ children }}</div>
    </section>
    """, title=title, children=children)

@component wraps the function in a Component. Both sync and async components are supported; an async component must be rendered through the async pipeline (await Component.acall(...)), which the framework does for you.

Root shape

The default is one authored top-level element, with any owned <script> nested inside it. Three shapes are legal:

Authored roots Result
one native element the boundary is that element — the only shape that can receive props
sibling top-level nodes in a component a fragment: framed by a compiler comment pair, materialized as <pp-fragment style="display: contents">, adding no element
sibling top-level nodes in a page or layout, or a component whose root is another x-* tag a layout-neutral <div pp-component style="display: contents"> host

A fragment has no root element for props to land on, so passing any attribute to a fragment component raises FragmentPropsError rather than leaving props silently empty. Give the component a single native root when it needs props.

Never author pp-component or the fragment markers by hand — the compiler injects them.

Receiving props

There are two separate handoffs, and the Python function is the deliberate bridge between them. Skipping the bridge produces no error anywhere, just missing props in the browser.

  1. Tag to Python. Attributes on the <x-*> tag arrive as raw string kwargs, kebab-case converted to camelCase (on-apply becomes onApply). Client expressions are not evaluated server-side: open="{isOpen}" arrives in Python as the literal string "{isOpen}".
  2. Python to the browser. The browser computes props from the rendered root element's attributes, never from the Python signature. Every prop the template's {...} expressions read must be re-emitted on that root.
from casp.html_attrs import get_attributes, merge_classes
from casp.component_decorator import component, html


@component
def Panel(open=None, on_apply=None, **props):
    attributes = get_attributes({
        "class": merge_classes("panel", props.pop("class", "")),
        "open": open,
        "onApply": on_apply,
    }, props)

    return html(r"""
      <section {{ attributes }} hidden="{!open}">
        ...
        <script>const { open, onApply } = pp.props;</script>
      </section>
    """, attributes=attributes)

{{ attributes }} on the root and attributes=attributes in the html(...) call are both required. A named Python parameter is consumed out of **props, so it must be listed explicitly in the defaults dict or it never reaches the root.

Forwarding fixes presence, not type. A brace expression keeps its real type (it is evaluated in the parent's scope), but a literal server value arrives as a string, a valueless attribute becomes true, and None / False / "" / empty collections are omitted entirely — so the prop reads as absent rather than false.

get_attributes and merge_classes

get_attributes(props, overrides) builds one attribute string as Markup. It resolves aliases first, then normalizes every key to kebab-case, then merges defaults with overrides so precedence is predictable.

Alias Emitted as
className, class_name class
htmlFor, html_for for
defaultValue defaultvalue
defaultChecked defaultchecked

merge_classes(*classes) builds the class value as Markup. When the project config enables Tailwind it emits a live {twMerge(...)} expression for the browser to resolve; otherwise it joins the parts. Pass its result straight through — never wrap or re-merge it — and pop any incoming class out of props first so it is not emitted twice.

Composition Is Python Imports

An <x-*> tag resolves from the Component objects in the globals of the module that authors the tag. The exported name maps to the tag by kebab-casing it and prefixing x-: Container renders <x-container />, CommandDialog renders <x-command-dialog />.

Three import forms resolve:

# 1. The component from its own file.
from src.components.Card import Card

# 2. Several exports from a file that defines several.
from src.lib.ui.Breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbList

# 3. Straight from a one-component-per-file DIRECTORY, without naming each file.
from src.lib.icons import Search, ArrowLeft          # -> <x-search />, <x-arrow-left />

Form 3 needs explaining, because Python does not bind what it looks like it binds. A generated component directory is one component per file with no __init__.py, so Search is not a name the package defines and Python imports the submodule src.lib.icons.Search instead. casp unwraps a module binding that exports a component under its own file name (Search.py exporting Search), which is what makes a one-line import of many icons work.

All forms follow the binding name rather than the file name, so an alias renames the tag: from src.lib.icons import Search as MagnifyIcon renders <x-magnify-icon />.

The unwrapping is deliberately narrow. A module with no same-named component is never mistaken for a tag — an ordinary import os, a helper module, and a bare package import are all left alone. Neither is a file whose function is missing the @component decorator: the import looks correct and the tag still raises UnknownComponentError, so check the decorator before suspecting the import.

Form 3 also reaches only the component named after its file, so a multi-export file's other exports keep form 2 — from src.lib.ui import BreadcrumbItem is a plain ImportError, since there is no BreadcrumbItem.py.

When a directory name is not a valid Python identifier (hyphens, (group) folders), bind through importlib:

import importlib

# Either spelling works: the attribute, or the module (unwrapped as above).
Actions = importlib.import_module("src.app.some-dir.Actions").Actions
Actions = importlib.import_module("src.app.some-dir.Actions")

Resolution precedence inside a component's output, lowest to highest:

  1. components inherited from an ancestor template
  2. the component's own Python module imports

So a Python import wins over an inherited same-name component, which is what disambiguates variantA/Tag.py from variantB/Tag.py in each consumer.

Imported components are also callable from Jinja directly — {{ Card("hi") }} works without passing the component through the context. A directly-called component's own nested <x-*> tags still resolve from its module's imports: html(...) stashes the caller's component scope and tags the rendered string with a <!--pp-scope:TOKEN--> marker that the compiler consumes and strips. Slot content resolves in the scope where it was authored, so the module that writes a tag in markup must import it.

Pages And Layouts

A page returns html(...). A layout returns html(...) too — but a layout's markup is deferred, not rendered when it runs, because children is the page beneath it and does not exist yet:

from casp.component_decorator import html
from casp.layout import Metadata

metadata = Metadata(title="Dashboard")


def layout():
    return html(r"""
    <div class="{{ shell_class }}">
      <slot />
    </div>
    """, shell_class="dashboard-shell")

html(...) called from a layout() returns a LayoutTemplate — the unrendered source plus the author's context — which the layout engine renders later with children, layout, and metadata merged in. Those three names are engine-owned and always win over author context. Deferral is keyed on the layout() frame specifically, so a component the layout calls, or a helper in the same file, still renders eagerly.

A layout must place its children with <slot /> or {{ children }}, or it raises LayoutChildrenError — without that check the page below renders into nothing, with no error and no warning. During nested layout rendering the engine parses the layout HTML and replaces real <slot> elements; escaped documentation text such as &lt;slot /&gt; is not treated as an outlet.

A layout may also return (html(...), props_dict), a bare props dict, or None. Props become {{ layout.* }} for the subtree.

Metadata

Metadata(title=..., description=..., extra={...}) registers itself. At module scope it writes itself into the module's metadata global, so a bare Metadata(title="Dashboard") with no assignment still applies; called inside a function it sets the request-scoped value, which overrides the static one. Layouts read the resolved values as {{ metadata.title }}.

Errors

The compiler and layout engine fail loudly rather than shipping silently-wrong markup:

Exception Raised when
UnknownComponentError an <x-*> tag has no matching import — including an @import HTML comment, which imports nothing
TemplateRootError a template has no root, or a component's only root is an x-* tag
FragmentPropsError a fragment component (sibling top-level nodes) is passed props it has nowhere to put
LayoutChildrenError a layout never places its children
CaspianConfigError, FilesListError project config or the file index is malformed
InvalidAppTimezoneError APP_TIMEZONE names a zone that cannot be resolved

RPC

from casp.rpc import rpc


@rpc(require_auth=True, allowed_roles=["admin"], limits="30/minute")
async def save_profile(name: str):
    return {"ok": True, "name": name}

An @rpc() in a page (index.py) is scoped to that page's URL; in a component it registers globally by function name.

Request gates

Every call is checked before the decorated function runs. A call that fails a gate never reaches application code:

Gate Failure
Origin against the allow-list (skipped when the header is absent) 403 Invalid origin
Content-Type is application/json or multipart/form-data when a body is present 415 Invalid content type
X-CSRF-Token compared against the session token 403 Missing CSRF token / Invalid CSRF token
require_auth=True 401 Authentication required
allowed_roles=[...] 403 Permission denied
per-route rate limit 429

The allow-list is the request's own base URL, plus APP_BASE_URL, plus CORS_ALLOWED_ORIGINS, plus the forwarded origin when TRUST_FORWARDED_HEADERS is on. Outside production, http://localhost:<port> and http://127.0.0.1:<port> are also accepted.

Payload keys are filtered against the function signature, so a parameter is settable by the client only when it is declared. Declaring **kwargs opts the function into the entire payload — do that deliberately. Identity, ownership, and privilege must be derived server-side, never accepted as an argument.

Streaming

A generator @rpc() is wrapped as Server-Sent Events by casp.streaming.SSE, so one-way streams (LLM tokens, progress) are ordinary RPC:

@rpc()
async def ask(prompt: str):
    async for chunk in provider.stream(prompt):
        yield chunk

Security

casp.runtime_security owns environment resolution, headers, and safe static-file serving.

is_production_environment() resolves fail-closed: only dev, development, local, staging, test, or testing select development behavior. Unset or misspelled counts as production. Import this helper rather than re-testing APP_ENV in a new module, so callers cannot disagree.

  • resolve_safe_public_path(...) / public_file_response(...) / PublicFilesMiddleware — serve files under a public root, rejecting traversal and symlink escape, handling only GET/HEAD, and falling through when no file exists. Directories that receive untrusted uploads can be marked so their content is served as an attachment; only allow-listed real image types render inline.
  • build_security_headers(...), build_content_security_policy(...) — response headers; CONTENT_SECURITY_POLICY replaces the default policy wholesale.
  • get_session_secret(...) — required in production; a missing or placeholder value raises.
  • client_error_message(...) — how much error detail reaches the client.

Environment

casp.rpc and casp.runtime_security read these on use, so loading .env at any point before the first request is enough — import order does not matter.

Variable Default Effect
APP_ENV unset Resolved fail-closed; see above.
AUTH_SECRET none Session secret. Required in production.
APP_TIMEZONE UTC Zone for casp.app_time. An unknown name raises instead of falling back.
CORS_ALLOWED_ORIGINS unset Comma-separated additions to the origin allow-list.
APP_BASE_URL unset Public origin, for deployments behind a proxy.
TRUST_FORWARDED_HEADERS off Honour X-Forwarded-* / Forwarded for the origin check and rate-limit bucket. Enable only behind a proxy you control.
CONTENT_SECURITY_POLICY built-in policy Replaces the default CSP wholesale.
RATE_LIMIT_DEFAULT 200/minute Default limit passed to slowapi.
RATE_LIMIT_RPC 60/minute Applied to @rpc() without explicit limits.
RATE_LIMIT_AUTH 60/minute Applied to @rpc(require_auth=True) without explicit limits.
RATE_LIMIT_MAX_BUCKETS 10000 Cap on tracked rate-limit buckets.
RATE_LIMIT_CLEANUP_INTERVAL 60 Seconds between bucket sweeps.
CASPIAN_ROOT auto-detected Explicit project root override for config and file-index lookup.

APP_ENV is the widest switch in the package: it gates the session secret check, the HSTS header, how much error detail reaches the client, and the localhost origin bypass. Leaving it unset in development is what produces a working-looking app whose every RPC call returns 403 Invalid origin.

OAuth providers read GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_REDIRECT_URI.

The two redirect URIs behave differently. Google is skipped entirely without GOOGLE_REDIRECT_URI. GITHUB_REDIRECT_URI is optional: when it is empty the provider derives <APP_BASE_URL><api_auth_prefix>/callback/github, and with no APP_BASE_URL either it sends no redirect_uri at all, which makes GitHub fall back to the first redirect URI registered on the App. That fallback is why registering a second URI (a localhost one beside production) has no effect until an environment names the one it wants. A resolved value is sent on the authorize request and repeated in the token exchange, as GitHub requires.

Auth

casp.auth provides sessions, route checks, decorators, CSRF helpers, and OAuth providers: Auth, AuthSettings, configure_auth(...), get_auth_settings(), require_auth, require_role, guest_only, get_csrf_token(), GoogleProvider, GithubProvider.

Route matching understands dynamic ([id]), catch-all ([...slug]), optional catch-all ([[...slug]]), and FastAPI-style ({id}) segments, and picks the most specific matching scope. Note that this matcher accepts more forms than the router creates: casp.caspian_config._to_fastapi_rule converts [id] and [...slug] only, so an optional-catch-all folder produces a malformed rule. Use [[...slug]] in an AuthSettings route pattern, not as a route folder name. Redirect targets are validated, so an absolute or protocol-relative URL cannot be used to bounce a user off-site.

Application auth policy — route privacy, redirects, RBAC — belongs in an app-owned config module that builds AuthSettings and applies it at startup with configure_auth(...), not in casp.auth.

Validation

casp.validate exposes Validate for single-value coercion and Rule for multi-constraint payloads.

from casp.validate import Validate, Rule

email = Validate.email(payload.get("email"))
name = Validate.string(payload.get("name"))              # trims and HTML-escapes by default
checked = Validate.with_rules(password, [Rule.REQUIRED, Rule.min(8), Rule.confirmed()],
                              confirmation_value=payload.get("password_confirm"))
if checked is not True:
    return {"error": checked}

with_rules returns True when every rule passes, or the first failing rule's message as a string — it does not return a tuple and it does not collect every error. Compare with is not True rather than truthiness, since a message string is itself truthy.

Coercion helpers return None when the value does not validate: email, url, ip, uuid, ulid, cuid, cuid2, nanoid, int, big_int, float, decimal, date, date_time, boolean, bytes, xml. Plus string, json, is_json, enum, enum_class, and emojis. Date formats accept PHP-style patterns (Y-m-d H:i:s).

Validate every mutation payload in Python; the client cannot be trusted to have done it.

Application Time

casp.app_time resolves a single application timezone from APP_TIMEZONE and raises InvalidAppTimezoneError rather than silently falling back to UTC.

Function Use
now() / today() the current moment or calendar date in app time
to_app_time(value) convert a stored timestamp for display; a naive value is read as UTC, not server-local
day_bounds_utc(day) half-open [start, end) UTC bounds for a calendar day — query with gte / lt
as_naive_utc(value) convert, then strip tzinfo, for naive-UTC columns
get_app_timezone() the resolved ZoneInfo

Prefer these over a bare datetime.now(), which returns the server's local wall clock and silently disagrees with UTC timestamps in storage. Session expiry and cache TTLs stay on UTC and should not be routed through this module.

Caching And Server State

casp.cache_handler provides Cache and CacheHandler. Like Metadata, a module-scope Cache(ttl=3600, enabled=True) registers itself as that module's cache_settings.

CacheHandler keys on the request URI alone, so only public, shareable HTML is safe to cache — the host application must refuse to cache authenticated renders, and a route's own Cache(...) cannot override that. Invalidate after writes with CacheHandler.invalidate_by_uri(...).

casp.state_manager provides StateManager for transient request-scoped server state (get_state, set_state, reset_state, subscribe) — flash-style messages, not a session store and not browser state. Do not assume it persists across requests unless the host bridges it to the session.

Main Modules

Module Purpose
casp.component_decorator @component, Component, html(), layout deferral (LayoutTemplate), and component-scope resolution
casp.components_compiler Resolve and render <x-*> tags, inject pp-component roots, handle fragments, slots, and owned content
casp.layout The Jinja environment, page and nested-layout rendering, <slot /> replacement, Metadata, inject_html()
casp.html_attrs get_attributes(), merge_classes(), prop alias normalization, attribute escaping
casp.html_native BeautifulSoup-backed fragment parsing helpers used by the layout and component transforms
casp.rpc The @rpc() decorator, registration, serialization, and the request gates above
casp.streaming Server-Sent Events helpers including SSE and ServerSentEvent
casp.auth Auth, AuthSettings, configure_auth(), decorators, CSRF, OAuth providers, route checks
casp.runtime_security Fail-closed APP_ENV resolution, safe public-file serving, security headers, production secret checks
casp.validate Validate and Rule for strings, ids, files, dates, and numbers
casp.app_time Application timezone resolution and calendar-day query bounds
casp.cache_handler Cache and CacheHandler page caching
casp.state_manager Request-scoped server state
casp.caspian_config caspian.config.json loading and the route / layout / loading file index
casp.loading Route-scoped loading UI discovery from loading.py modules
casp.string_helpers Case conversion between component names and <x-*> tags

Dependencies

pip install caspian-utils installs casp alone and pins nothing. These are expected in the host application's environment, where a Caspian project installs and pins them:

Package Required by
fastapi and starlette casp.rpc, casp.auth, casp.runtime_security, casp.streaming
jinja2 and markupsafe casp.layout, casp.component_decorator, casp.html_attrs
beautifulsoup4 casp.html_native, casp.components_compiler
slowapi casp.rpc
python-multipart multipart RPC payloads and file uploads, through FastAPI
httpx2 casp.auth OAuth provider calls
python-ulid casp.validate
tzdata casp.app_time — required wherever the OS ships no zone database, including Windows

Importing casp without one of these raises ImportError at import time.

Optional, detected at import and degraded gracefully when absent:

Package Enables
cuid2 Validate.cuid2()
cuid Validate.cuid()
python-magic real MIME sniffing during file validation instead of extension-based guessing

Repository

TheSteelNinjaCode/caspian_utils

License

MIT

Author

Jefferson Abraham

Release files for caspian-utils 0.4.27

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

Source distribution (sdist)

Source distribution for caspian-utils 0.4.27
File Size Uploaded
caspian_utils-0.4.27.tar.gz 99.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for caspian-utils 0.4.27
File Interpreter ABI Platform
caspian_utils-0.4.27-py3-none-any.whl Python 3 none any Details

Total release size:195.2 kB

Release files / caspian_utils-0.4.27.tar.gz

Download URL caspian_utils-0.4.27.tar.gz
Size 99.4 kB
Tags Source
SHA-256 checksum
How to use checksums
75aa97ec46cce195dd3bc5cb23cbcbac4fbf302d87b2187bc6ba09360fdbb564
BLAKE2b-256 checksum
How to use checksums
2aa20a54f652b48d6e57ecce3281719c8ba9dce1ae4c0a3599c482a73cbeaa50
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / caspian_utils-0.4.27-py3-none-any.whl

Download URL caspian_utils-0.4.27-py3-none-any.whl
Size 95.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1de5195d66c74bf4e49d7ee46318036c5d328f5c0db900f188a56bdac17a46e9
BLAKE2b-256 checksum
How to use checksums
d3f358f3b5d11ee37d3a2907d14dfd87e25aa359a0ea4cc0a3c399a1899a182b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release history Release notifications | RSS feed

0.5.1

2 release files

0.5.0

2 release files

This release

0.4.27 This release

2 release files

0.4.26

2 release files

0.4.25

2 release files

0.4.24

2 release files

0.4.23

2 release files

0.4.22

2 release files

0.4.13

2 release files

0.4.12

2 release files

0.4.11

2 release files

0.4.10

2 release files

0.4.9

2 release files

0.4.8

2 release files

0.4.7

2 release files

0.4.6

2 release files

0.4.5

2 release files

0.4.4

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.17

2 release files

0.3.16

2 release files

0.3.15

2 release files

0.3.14

2 release files

0.3.13

2 release files

0.3.12

2 release files

0.3.11

2 release files

0.3.10

2 release files

0.3.9

2 release files

0.3.8

2 release files

0.3.7

2 release files

0.3.6

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.17

2 release files

0.2.16

2 release files

0.2.15

2 release files

0.2.14

2 release files

0.2.13

2 release files

0.2.12

2 release files

0.2.9

2 release files

0.2.8

2 release files

0.2.7

2 release files

0.2.6

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.0

2 release files

0.0.35

2 release files

0.0.34

2 release files

0.0.32

2 release files

0.0.31

2 release files

0.0.30

2 release files

0.0.29

2 release files

0.0.28

2 release files

0.0.27

2 release files

0.0.26

2 release files

0.0.25

2 release files

0.0.24

2 release files

0.0.23

2 release files

0.0.22

2 release files

0.0.21

2 release files

0.0.20

2 release files

0.0.19

2 release files

0.0.18

2 release files

0.0.17

2 release files

0.0.16

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

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