urun CLI
Deploy Python and Rust (native SDK) apps to urun from your terminal.
Install
uv tool install urun-cli
# or
pip install urun-cli
For Rust (native SDK) deploys, install with the [rust] extra — see
Deploy a Rust app (native SDK):
uv tool install "urun-cli[rust]"
# or
pip install "urun-cli[rust]"
The package installs the urun command:
urun --version
For one-off uvx usage:
uvx --from urun-cli urun --version
# or the package-matching command alias
uvx urun-cli --version
Quick start
Save an org-scoped deploy API key locally with urun login:
urun login
If URUN_API_KEY is not already set, urun login opens the urun console API
keys page, prompts you to paste the generated urun_sk_... key, verifies it
with the urun API, and stores credentials for later commands.
If you already have a key, you can pass it directly:
urun login --api-key urun_sk_<secret>
For CI or one-off commands, you can still use the environment variable:
export URUN_API_KEY=urun_sk_<secret>
Create app.py:
import urun
from urun import App
app = App("hello-h100")
@app.function(gpus="h100:1")
def hello(ctx: urun.Context):
print(f"running on {ctx.device}")
return {"device": str(ctx.device)}
Run it:
urun run app.py
In this release, urun run uses the same deploy pipeline as urun deploy.
deploy remains available as the lower-level command while the full
deploy/run/monitor workflow is being built.
Attach an official browser frontend
For official urun-examples apps, the shortest browser stream path is:
cd ~/workspace/urun-examples
urun deploy prompt_canary/backend/app.py --name prompt-canary --timeout 900
urun dev --attach prompt-canary --examples-root ~/workspace/urun-examples
The positional argument is the deployed app slug (as shown by urun app list). Nothing is declared in a manifest — every value is discovered:
| value | where it comes from |
|---|---|
| frontend directory | the one <example>/frontend under the examples root whose name matches the app slug (hyphens and underscores are equivalent, and a --dev suffix is ignored) |
| pnpm package | that frontend's own package.json "name" |
| local port | the --port N in that package's scripts.dev, else 3000 |
| function | read from the deployed app itself |
urun dev --attach uses your resolved API key server-side to request a short-lived,
function-scoped, origin-pinned browser JWT, sets the frontend's
NEXT_PUBLIC_* environment variables, starts the local Next.js frontend, and
opens it in your browser. The API key itself never reaches the page.
Discovery is never a guess: if no example directory matches the app — or if
more than one does — the command fails and names what it found. Point it at
the right directory yourself with --frontend-dir (relative to the examples
root):
urun dev --attach test-matrix-game --frontend-dir matrix-game-3/frontend
There is no curated list of attachable examples
urun dev --attach will launch any <example>/frontend it can match, and
it does not know which examples actually work. Earlier releases read a
manifest that carried a supported flag with a reason, and refused the
examples marked unsupported; that allowlist is gone along with the manifest.
The practical consequence: a frontend that is a stub, half-built, or known
broken now starts a dev server and opens a browser instead of failing up
front, and it fails on its own terms once loaded. notebook-playground is the
clearest case — its frontend/ is a deliberate loud-failing stub. If a demo
comes up wrong, check the example's own README before suspecting the CLI.
This was an accepted trade for deleting the manifest: which examples work is not derivable from the filesystem or the control plane, so the CLI cannot know it without a second declared source that would drift exactly as the manifest did.
Override the examples checkout with --examples-root or URUN_EXAMPLES_ROOT;
override the session API with --session-url or URUN_SESSION_BASE_URL; pin
the function with --function and the environment with --environment.
Deploy a Rust app (native SDK)
Rust apps use the native SDK crate urun and are declared by an inline module
owning #[urun::app(name = "...")] with nested #[urun::function] handlers.
The executable calls urun::main!(app) from fn main(). Each handler crosses
the ctx.self_test(...) barrier before accessing session state. The runnable
native-echo example below includes the complete lifecycle.
Install the CLI with the Rust parser extra (a plain install stays
dependency-free; the extra pulls the pinned tree-sitter/tree-sitter-rust
wheels used for static declaration extraction — no Rust toolchain needed):
uv tool install "urun-cli[rust]"
# or
pip install "urun-cli[rust]"
Deploy by pointing at the project's Cargo.toml (a src/main.rs or the
project directory also work):
urun deploy path/to/project/Cargo.toml
- Vendor the SDK in-root. The
urunSDK crate is not published to crates.io and the build environment has no private git credentials, so the crate must ship inside your deploy root: reference it by a relative path dependency (urun = { path = "vendor/urun" }) or as a member of the Cargo workspace rooted there. Credential-free registry/git dependencies are fine. Cargo.lockis mandatory. The builder builds with--locked; a missing lock file fails the deploy.- The bundle is the full in-root Cargo workspace / path-dependency / resource
closure, minus
target/,.git, and.cargo/credentials{,.toml}.
A runnable starting point is the SDK checkout's examples/native-echo app,
which vendors the SDK via a path dependency and deploys as-is:
git clone git@github.com:urun-sh/urun-rs.git
urun deploy urun-rs/examples/native-echo
Deploy a mixed Python + Rust app
ONE app can hold functions implemented in BOTH languages: one identity, one
release, the full function set replaced atomically on every deploy. Declare
the sources explicitly in the project's pyproject.toml — nothing is scanned
or inferred, so vendored SDK examples are never pulled into your app:
# pyproject.toml
[tool.urun]
entrypoints = ["app.py", "native/src/main.rs"]
Python functions register on one App("mixed-app"). Rust functions use
#[urun::function(...)] inside #[urun::app(name = "mixed-app")].
The builder assembles both into one function registry: the language belongs
to the function, not to a separate app. See the runnable
examples/mixed-app
for complete Python and Rust implementations, including self-tests and calls
in both directions.
Deploy the whole app with any of these — each resolves the ENTIRE declared app, so one component invocation never accidentally retires its sibling functions:
urun deploy . # the config directory
urun deploy pyproject.toml # the declaration itself
urun deploy app.py # a declared component
urun deploy native/src/main.rs
- One identity across all sources. Python helpers may import the same
Appobject; Rust declarations use the same app-name literal. A different App object, mismatched name, or duplicate function name is a hard error. - Declared paths stay in the config directory (root-relative, no
..escapes, every declared file present). The bundle root may widen through ordinary Cargo workspace resolution — e.g. a crate that is a member of a wider workspace ships that whole workspace closure — and every entrypoint andmanifest_pathrebases onto it. - Python dependencies stay Python. The manifest's
depsblock is resolved from the Python source exactly like a pure Python deploy (Dependencies(python=...),requirements.txt,python_version); the Rust sources'apt/build_depsfunction options fold into the same lists. - Resources ride with their code in both halves: the Python import/data
closure plus each Rust crate's full workspace/path closure and every
referenced
Cargo.lock.
A runnable starting point is the SDK checkout's examples/mixed-app app.
Serve a model from the catalog
urun serve deploys a model straight from the urun model catalog — no app code
required. It is urun deploy with a serve app templated from the resolved
catalog row (engine, HF repo, GPU, engine args).
List the enumerable model matrix (models, variants, the GPU each fits, engine shape, and whether the placement is dev-testable or prod-only):
urun serve catalog
urun serve catalog --json
Serve a model. With no variant the model's default (first) variant is used; with
no --gpu the variant's first placement is used:
urun serve qwen-coder # default variant + first placement
urun serve glm-5.2:UD-IQ2_M # explicit <id>:<variant>
urun serve qwen-coder-480b:fp8 --gpu b200:8
The catalog is anon-readable reference data published by a urun-infra migration.
Reads go over PostgREST on the same control-plane host as the API URL; supply the
public Supabase anon key via --anon-key or URUN_CATALOG_ANON_KEY
(URUN_SUPABASE_ANON_KEY is also honored). Override the PostgREST base with
--catalog-url / URUN_CATALOG_URL if needed.
By default urun serve <id> renders a templated serve app and deploys it. If you
maintain your own _serve app, point --entrypoint at it; the CLI deploys that
app with URUN_SERVE_CONFIG and URUN_SERVE_CATALOG_ROW set to the resolved
row instead of rendering one.
Inspect apps
List every app deployed in your org and its current status:
urun app list
Sample output:
APP FUNCTION COMPUTE STATUS RELEASE DETAIL
causal-forcing-stream generate_video h100:1 ready fa61f31b0961 0/1 GPU units in use
queued-app warmup a10:1 provisioning 000000000000 building
The STATUS column is one of provisioning, ready, pending, paused, or
failed. It is derived from three backend signals reporting on sequential
lifecycle phases:
| Build (S3 status) | Promotion (app_deployments) |
Capacity (function_ready) |
STATUS |
|---|---|---|---|
queued | building |
(no row yet) | - | provisioning |
failed |
(no row yet) | - | failed |
ready |
active |
false |
pending |
ready |
active |
true |
ready |
ready |
paused |
(irrelevant) | paused |
ready |
failed |
(irrelevant) | failed |
The DETAIL column carries the disambiguating signal (raw build state, error
message, ready_reason, or in-use GPU counts). Pass --json for the raw
payload.
This command is experimental and requires the server-side GET /apps
endpoint, which is in development.
View logs
Use urun build logs, urun app logs, urun function logs, urun session logs,
or urun log list for build logs or runtime logs scoped to an app, a function
within an app, or a session:
urun log list
urun build logs <build-id>
urun app logs <app>
urun function logs <app> <function>
urun session logs <session>
Runtime commands accept --follow/-f for live output and --json for JSON
(one-shot) or NDJSON (followed) output. Use urun log list without filters to
list all accessible logs, or add the same app, function, session, level, text,
and time filters. urun log tail remains available for the legacy follow
workflow. The current logs API does not filter by environment.
Inspect sessions
List live and historical sessions in your org. Newest sessions appear at the
bottom of the table so the command works well with tail:
urun session list
urun session list | tail -20
urun session list --state failed
urun session list --limit 500
Sample output:
ID APP FUNCTION SHAPE STARTED DURATION STATE DETAIL
2cc8a91f4b3d helios world_gen h100:4 2026-06-02 10:55 UTC 44s failed no_capacity
3f0017daee01 helios world_gen h100:1 2026-06-02 11:08 UTC 18m43s completed client_disconnect
4a1c886e2d0a causal-forcing-… generate_video h100:1 2026-06-02 14:21 UTC 3m12s live -
The STATE column maps the raw backend status to a user-friendly label:
| Backend status | STATE |
|---|---|
allocated |
starting |
connected |
live |
closed |
completed |
failed |
failed |
cancelled |
cancelled |
DURATION is computed from allocated_at to closed_at for terminal sessions,
or allocated_at to now for live ones. DETAIL carries close_reason when
present. Pass --json for the raw payload (full IDs, ISO timestamps, all
fields).
Pass --limit to control how many rows are fetched (default 100).
This command is experimental and requires the server-side GET /sessions
endpoint, which is in development.
Inspect active compute
List the compute slices your org currently has provisioned:
urun compute list
Sample output:
APP FUNCTION SHAPE INSTANCES GPU UNITS SESSIONS AGE
causal-forcing-stream generate_video h100:1 1/2 1/2 1 12s
helios world_gen h100:4 0/1 0/4 0 3m
Each row is one actively provisioned (app, function, compute_shape)
slice. INSTANCES and GPU UNITS show <allocated>/<provisioned> — a
row with 0/1 is an idle warm runtime with no active sessions on it.
SESSIONS is the live session count. AGE is how stale the capacity
snapshot is; very old ages may indicate the runtime is no longer
reporting.
Slices with no provisioned capacity are omitted, so this command answers
"what is running right now". For the full deployment catalogue
(including paused / failed / unprovisioned apps) use urun app list;
for historical or in-flight sessions use urun session list.
Pass --limit to control how many rows are fetched (default 100).
This command is experimental and requires the server-side GET /compute
endpoint, which is in development.
Manage apps
Manage the lifecycle of a single deployed app. The app is addressed by its
slug (the name shown under APP in urun app list); every operation is
org-scoped via your API key.
Show detailed status for one app (the single-app complement to app list):
urun app status lingbot
App: lingbot
Name: LingBot
Environment: prod
App status: active
Deployment: active
Desired replicas: 2
Function: handle_lingbot_runtime
Compute: b200:4
GPU: 4 x b200
Release: 1c6d6287abcd
Live sessions: 1
Scale an app's runtime replica count (the backend's scaling knob; the control plane turns it into the runtime StatefulSet replica count):
urun app scale lingbot --replicas 3
urun app scale lingbot --replicas 0 # drain to zero without retiring
GPU count and compute shape are fixed at deploy time per release (set via
@app.function), so scale intentionally exposes only --replicas.
Disable an app so the control plane stops running it (drives the deployment
to paused and the app to disabled, so the materializer stops recreating
its runtime). This is the clean, reversible, API-driven alternative to a
manual database edit:
urun app disable lingbot-handle # prompts for confirmation
urun app disable lingbot-handle --yes # skip confirmation
Enable a disabled app and bring it back online:
urun app enable lingbot-handle
If urun deploy or urun dev --attach reports that a function was paused
after repeated startup failures, inspect its runtime logs before overriding
the safety pause. Then explicitly resume every paused function:
urun app enable rapid-clips --force
--force may restart a known-crashing function; it does not change the
platform's automatic pause policy.
Stop a queued, starting, or live session:
urun session stop sess_123 --yes
All app subcommands accept --environment (default prod) and --json.
These commands are experimental and require the server-side app
lifecycle endpoint, which is in development.
Scratch instances (rung-4 verification, dev-only)
The verification ladder for platform changes is: unit tests -> CPU harness ->
kind hop -> scratch pod -> full deploy. Rungs 1-3 are one command each;
urun scratch makes rung 4 one command too — a real GPU pod running the
real render, without the 30-90 min deploy loop and without touching the
live app:
# Reproduce a broken app in isolation with debug env, candidate wheel overlaid:
urun scratch gemma-voice dg-brain --shape rtx6000:1 \
--env VLLM_LOGGING_LEVEL=DEBUG --env CUDA_LAUNCH_BLOCKING=1 \
--env PYTHONFAULTHANDLER=1 --name dg-brain-noble-repro
urun scratch ls # list scratch instances + expiry
urun scratch rm dg-brain-noble-repro # tear down
urun scratch rm --expired # reap everything past its TTL
What it does:
- Clones the app's rendered StatefulSet into an isolated instance with every platform label detached: the materializer never adopts it, the capacity reconciler never counts it, and no session is ever routed to it. The clone boots even when the source app is crashlooping or scaled to 0.
- Overlays candidate artifacts:
--wheel <req|url|path-on-storage>installs--no-depsinto a hardlink COPY of the deps venv (the shared venv is never modified);--env KEY=VALadds debug vars; hot reload is always pinned off so the instance stays on the currently-rendered release (--release <hash>asserts which one that is). - Takes GPUs explicitly:
--shapeis required and must match the source render; the target karpenter nodepool is checked against its GPU limit and the command refuses at capacity unless--allow-contention. Scratch never silently steals demo GPUs. - Never leaks: every instance carries a TTL annotation (default
4h,--ttl 6h/90m) consumed by the scratch reaper, which runs on every cluster (prod included); scratch-labeled objects without the annotation (hand-applied) are reaped after a default 24h TTL, with a loudScratchTTLExpiredEvent.urun scratch rmtears down sooner. Boot/self-test logs stream to your terminal (Ctrl-C detaches without tearing down).
Dev-only (v1): talks to a dev cluster via your kubeconfig — the ACTIVE
kubectl context by default (--context overrides; with no active context
the command fails and demands one). Naming is the enforced control: the
context name must mark a dev cluster (contain dev, e.g. dev-usw2,
dev-use2) and must not contain prod — anything else is refused. There
is no hardcoded default cluster: with two dev clusters, a silent default
would fire kubectl at a cluster you are not looking at.
What gets deployed
urun deploy creates a source manifest from your Python entrypoint:
| Entrypoint | Included source |
|---|---|
urun deploy app.py |
app.py and local Python files it imports |
urun deploy Cargo.toml |
The in-root Cargo workspace / path-dependency closure (see Deploy a Rust app (native SDK)). |
urun deploy . / pyproject.toml |
The whole explicitly-declared [tool.urun] app — Python + Rust sources, both closures (see Deploy a mixed Python + Rust app). A declared component (app.py, native/src/main.rs) resolves the same entire app. |
Dependencies are declared in your urun app code. Project-level files such as
pyproject.toml and requirements.txt are not uploaded as dependency
declarations by the CLI.
Generated/cache content such as .git, dotfiles, __pycache__, and .pyc
files is excluded. Add .urunignore to exclude additional paths.
Shipping extra files: Dependencies(files=[...])
The collector's heuristic is Python-first: imported .py source, plus the
non-Python files sitting next to that source. Anything it does not reach — a
prompt tensor in its own directory, an asset an .urunignore pattern drops —
is declared explicitly on your app's dependencies:
from urun import App, Dependencies
app = App("flashvsr-superres")
@app.function(
deps=Dependencies(
python=["torch"],
files=["flashvsr_utils/prompt_tensor/posi_prompt.pth"],
)
)
def superres(): ...
- Paths are relative to the entrypoint file (
app.py). - Declared files ship regardless of
.urunignoreand the built-in exclusions — that is the point of declaring them. files=is purely additive. With nofiles=, collection is exactly what it was before.- Entries are individual files, not directories or globs. List each file.
- A declared path that does not exist, escapes the app directory (
.., a symlink pointing outside), or is absolute fails the deploy immediately, naming the path and the directory it was looked up under. A declared file is never silently skipped.
Common options
Shared by run and deploy:
| Option | Description |
|---|---|
--name |
Override the derived app name. |
--api-url |
Override the API URL; defaults to URUN_API_URL, saved login credentials, or https://api.urun.sh/v1. |
--api-key |
Deploy API key; defaults to URUN_API_KEY or saved login credentials. |
--no-wait |
Finalize but do not poll for readiness. |
--poll-interval, --timeout |
Control readiness polling. |
Troubleshooting
| Error | Fix |
|---|---|
missing API key |
Run urun login, set URUN_API_KEY, or pass --api-key. |
invalid API key format |
Use urun_<32 lowercase hex chars>. |
entrypoint not found |
Run from the project root or pass the entrypoint path. |
path is outside the project root |
Move the file under the project before deploying. |
| Expected files are missing | Import local Python files from app.py. Non-Python assets are collected only when they sit next to collected source — declare anything else with Dependencies(files=[...]). |
Development
Contributing and test instructions are in CONTRIBUTING.md.
License
MIT.
Development environment
This repo has a Nix/direnv/devcontainer baseline:
direnv allow
just sync
just check
Use VS Code Dev Containers to open the repository with the same toolchain in a container. Copy devcontainer.env.example to .devcontainer.env if you need to pass local git identity or other non-secret development settings into the container.
Release files for urun-cli 0.7.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| urun_cli-0.7.1.tar.gz | 334.7 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| urun_cli-0.7.1-py3-none-manylinux_2_25_x86_64.whl | Python 3 | none | Linux glibc 2.25+ x86-64 | Details |
| urun_cli-0.7.1-py3-none-manylinux_2_25_aarch64.whl | Python 3 | none | Linux glibc 2.25+ ARM64 | Details |
| urun_cli-0.7.1-py3-none-macosx_15_0_arm64.whl | Python 3 | none | macOS 15.0+ ARM64 | Details |
| urun_cli-0.7.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 106.6 MB
Release files / urun_cli-0.7.1.tar.gz
| Download URL | urun_cli-0.7.1.tar.gz |
|---|---|
| Size | 334.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fa595135921d3226f9e94313d2c2ec68746cb6160daa80479f9395e61ddde245
|
|
BLAKE2b-256 checksum How to use checksums |
1653812ce7151d31737dae2df97a001962659ba3181141507a1cf793541bb83b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
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 Sep 10, 2026.
Transparency logRelease files / urun_cli-0.7.1-py3-none-manylinux_2_25_x86_64.whl
| Download URL | urun_cli-0.7.1-py3-none-manylinux_2_25_x86_64.whl |
|---|---|
| Size | 41.1 MB |
| Tags | Linux glibc 2.25+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
cded8fed3010bcb6f7a7e83837a157942a99a7725165e74cd974c0d50954e053
|
|
BLAKE2b-256 checksum How to use checksums |
22c1c0dc6133ed0fe8bae644f414887383cc28e049c462334e996afd5cb47d39
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
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 Sep 10, 2026.
Transparency logRelease files / urun_cli-0.7.1-py3-none-manylinux_2_25_aarch64.whl
| Download URL | urun_cli-0.7.1-py3-none-manylinux_2_25_aarch64.whl |
|---|---|
| Size | 40.7 MB |
| Tags | Linux glibc 2.25+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
1b367565099d525ef44041312d1b7243a728033712cbce9c19df8ed60fa0b67c
|
|
BLAKE2b-256 checksum How to use checksums |
c569574bd5f3da414da6543d01a8e292d07b328a032ed3ca7dd474e894469860
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
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 Sep 10, 2026.
Transparency logRelease files / urun_cli-0.7.1-py3-none-macosx_15_0_arm64.whl
| Download URL | urun_cli-0.7.1-py3-none-macosx_15_0_arm64.whl |
|---|---|
| Size | 24.2 MB |
| Tags | Python 3 macOS 15.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
a2662c3d94df873b80378ea498afc32ef0ea80d8f8dd80459af2fce317c3dcf9
|
|
BLAKE2b-256 checksum How to use checksums |
41a4b27faca0973f4a82f74a912562c5d24edc3dc484900bdfb3396f35a24a91
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
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 Sep 10, 2026.
Transparency logRelease files / urun_cli-0.7.1-py3-none-any.whl
| Download URL | urun_cli-0.7.1-py3-none-any.whl |
|---|---|
| Size | 334.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6d405aa936c1b21669ba53d3fdd72e6f95825e70920108b096c99fa709da074d
|
|
BLAKE2b-256 checksum How to use checksums |
753401a7c70aac905afd2e268cc6f21c88cc3296890fee16ed477374e65e149d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
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 Sep 10, 2026.
Transparency log