Metadata-Version: 2.4
Name: caspian-utils
Version: 0.4.23
Summary: A utility package for Caspian projects
Home-page: https://github.com/TheSteelNinjaCode/caspian_utils
Author: Jefferson Abraham
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.14
Description-Content-Type: text/markdown
Dynamic: author
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: requires-python
Dynamic: summary

# 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

```bash
pip install caspian-utils
```

`casp` declares no `install_requires`; the host application installs and pins the runtime dependencies. See [Dependencies](#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 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

```py
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.

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

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

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

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

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

```py
@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`, 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.

```py
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](https://github.com/TheSteelNinjaCode/caspian_utils)

## License

MIT

## Author

Jefferson Abraham
