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
.pyfile, returned fromhtml(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 {/} 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 string — class="{{ 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.
- Tag to Python. Attributes on the
<x-*>tag arrive as raw string kwargs, kebab-case converted to camelCase (on-applybecomesonApply). Client expressions are not evaluated server-side:open="{isOpen}"arrives in Python as the literal string"{isOpen}". - 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:
- components inherited from an ancestor template
- 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 <slot /> 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 onlyGET/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_POLICYreplaces 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, and GITHUB_CLIENT_SECRET.
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 the same segment forms the router does — dynamic ([id]), catch-all, optional catch-all, and FastAPI-style — and picks the most specific matching scope. 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
ok, errors = Validate.with_rules(password, [Rule.REQUIRED, Rule.min(8), Rule.confirmed()],
confirmation_value=payload.get("password_confirm"))
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.23
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| caspian_utils-0.4.23.tar.gz | 95.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| caspian_utils-0.4.23-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:187.9 kB
Release files / caspian_utils-0.4.23.tar.gz
| Download URL | caspian_utils-0.4.23.tar.gz |
|---|---|
| Size | 95.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
97cc2d6702be265142cd7fb98b4c8cbb13759ad5a0c6aaa8897d83fb15156d08
|
|
BLAKE2b-256 checksum How to use checksums |
0a147ea8022dfc1fa12c9b74e1477b06ee787a4d5192adc3ab998baee5050e7c
|
| 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.23-py3-none-any.whl
| Download URL | caspian_utils-0.4.23-py3-none-any.whl |
|---|---|
| Size | 92.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bafee6aaf6a150652be64946f73874f344942e2327a187659c8fcf25b912ed06
|
|
BLAKE2b-256 checksum How to use checksums |
4acfe9cf18be10fe860fbf352a290bf182236ee9a74ef3af95bfd15f95434f8c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.3
|