Trading Engine
Run algorithmic trading strategies as supervised tasks, with typed dependencies between them, a live dashboard, per-task logs and restart policies. Venue access goes through the Tribulnation SDK gateway.
A process is a class you write; a task is one running instance of it,
keyed by process/id (for example maker/btc). Tasks declare what they
require from each other, and the engine starts, waits for, stops and restarts
them accordingly.
The normative behaviour is specified in specs/; this README
is the practical guide and yields to the specs where they disagree.
Install
pip install tribulnation-engine
tribulnation-engine 0.2.0 requires Python 3.11+, tribulnation-sdk>=2.9.0
(which contains the gateway) and tribulnation-cli. The CLI provides the tn
launcher; the engine registers its commands under tn engine, and the SDK
registers tn gateway:
tn --help # engine Provided by tribulnation-engine
# gateway Provided by tribulnation-sdk
tn engine --help
tn only finds commands installed in its own Python environment. Use the tn
from the environment the engine is installed in (e.g. .venv/bin/tn), or add
the engine to a global one: uv tool install tribulnation-cli --with tribulnation-engine.
Quickstart
tn engine init # engine.toml, .env and strats/depth_listener.py
# fill in credentials in .env
tn engine start -v # gateway + manager, dashboard on http://localhost:3121
tn engine task start depth-listener btc -p market=hl::BTC
tn engine task stop depth-listener btc
Writing a process
# strats/hedger.py
from dataclasses import dataclass
from tribulnation.engine import (
BaseParams,
Process,
RestartLimit,
RestartPolicy,
State,
StopReason,
Stream,
)
class HedgerParams(BaseParams):
market: str = 'hl::BTC'
@dataclass(frozen=True)
class Fill:
size: float
price: float
class Hedger(Process[HedgerParams]):
"""Hedges fills on one market."""
Params = HedgerParams
restart = RestartPolicy(
on='error', delay=5.0, limit=RestartLimit(max_restarts=5, within=60)
)
connected = State[bool](False)
position = State[float](0.0)
fills = Stream[Fill]()
async def run(self, p: Params):
market = await self.sdk.market(p.market)
async with market.depth_stream(queue_size=1, overflow='latest') as books:
self.connected.set(True)
async for book in books:
...
async def shutdown(self, reason: StopReason):
self.log.info('stopping: %s', reason)
@Process.rpc
async def hedge(self, size: float) -> float:
"""Hedge `size` and return the new position."""
self.position.set(self.position() - size)
self.fills.publish(Fill(size=size, price=0.0))
return self.position()
-
Params. Declare a pydantic model subclassing
BaseParamsoutside the class, and parameterize the base with it:Process[HedgerParams]. TheParams = HedgerParamsalias is optional; it allowsHedger.Params(...)andp: Paramsannotations. A process without params usesProcess[BaseParams]. The params' JSON schema becomes the start form in the dashboard; raw params from the CLI or API are validated against it. -
Name. Defaults to the lowercased class name (
hedger). Override withclass Maker(Process[MakerParams], name='dydx-maker'). -
run(params)runs one attempt. A fresh instance is created per attempt, sorunmust reconcile with the venue rather than assume a clean state. Instances are constructed with no arguments: the runtime setsself.sdk,self.logand the task binding, then calls__init__(). A process may be a@dataclass(every field with a default, orfield(init=False)) or not, and may use mixins with their own__init__; one that needs arguments is rejected when it's registered. Returning normally ends the attempt with reasonrequested. -
shutdown(reason)runs once after every attempt, before any restart, shielded from cancellation and bounded byshutdown_timeout(10 s by default, a class attribute).reasonis one ofrequested(explicit stop, orrunreturned),error,dependency(a requirement stopped holding) orshutdown(the engine is stopping). -
restart = RestartPolicy(...):on='never'(default),'error'or'always';delayseconds between attempts;max_attemptstotal attempts per explicit start;limit=RestartLimit(max_restarts=N, within=T)at most N automatic restarts in any rolling T seconds. An explicit start or restart resets both budgets. -
State[T](default)is a value that always has a current one. The owner reads it withself.position()and writes it withself.position.set(v). States reset to their default at the start of every attempt. The dashboard shows each live task's States as they change; passui=Falseto hide a large or noisy one:book = State[dict]({}, ui=False). A Derived value is computed from the States (or Derived values) its function reads, and recomputed synchronously on everyset()of one. Itsselfis a read-only view exposing only States and Derived values; reads of anything else, like the clock, aren't tracked. It's read like a read-only State, and its age is its oldest dependency's:@Process.derived # or @Process.derived(ui=False) def free(self) -> float: return self.available() - self.reserved()
-
Stream[T]()carries discrete events:self.fills.publish(event)delivers to current subscribers without blocking. Nothing is replayed. -
@Process.rpcexposes a method to dependents. The owner can still call it directly as a normal method. -
Injected attributes:
self.sdk(the SDK's market interface, backed by the gateway in deployments),self.log(a logger captured into the task's log),self.tasks(the control plane, see below) andself.id(the task's id, e.g. the account a keyed service likecollateral-manager/dydxserves).
Requirements and handles
A process uses another task's RPCs, States and Streams only through a declared requirement:
# strats/maker.py
from tribulnation.engine import BaseParams, Handle, Process
from .hedger import Hedger
class MakerParams(BaseParams):
market: str
hedger: str = 'btc'
class Maker(Process[MakerParams]):
Params = MakerParams
@Handle.require(Hedger, when=Hedger.connected)
def hedger(self, p: Params) -> str:
return p.hedger
async def run(self, p: Params):
position = self.hedger.get(Hedger.position, max_age=60)
new_position = await self.hedger.call(Hedger.hedge, 0.1)
fills = self.hedger.subscribe(Hedger.fills, queue_size=100, overflow='latest')
async with fills as events:
async for fill in events:
self.log.info('hedged %s @ %s', fill.size, fill.price)
@Handle.require(Target, when=...)decorates a key function of the dependent's params that returns exactly one target id, or the id and the params to auto-start the target with (return p.hedger, HedgerParams(market=p.market)). It must only read params. Require a fixed number of tasks with separate declarations.whenis optional: aState[bool]orDerived[bool]of the target (Hedger.connected) or a synchronous, side-effect-free predicateHandle[Hedger] -> bool. A predicate that raises counts as not holding. A requirement holds while the target task isrunning, in the same attempt the handle was bound to, andwhenis true.- Handle use, via the decorated attribute (
self.hedger):await handle.call(Target.rpc, *args): within the RPC's timeout (5 s by default;Target.rpc.with_timeout(10)for one call), raisingTimeoutErrorpast it.handle.get(Target.state, max_age=None): the current value, raisingStaleif it's older thanmax_ageseconds.handle.subscribe(Target.stream, queue_size=1000, overflow='fail'): an async context manager yielding the events. With'fail', a subscriber more thanqueue_sizeevents behind getsOverflowafter its buffered events;'latest'drops the oldest queued event instead.handle.watch(Target.state): an async context manager yielding a State's or Derived value's current value, then its value after each owner update. Latest-only: a slow consumer skips to the newest value. Use it to react to a State without pollingget.handle.id,handle.statusandhandle.available.
- Optional requirements,
@Handle.require(Target, when=..., optional=True): planned and auto-started like any requirement, but not a precondition, and the target failing never stops the dependent. While the target isn't running withwhentrue,handle.availableis false andcall,get,subscribeandwatchraiseUnavailable(in-flight calls, subscriptions and watches fail with it too); the handle follows the target to its next attempt. Checkavailable, or catchUnavailable, at each use; there is no waiting API. - Requirements on a set of tasks,
@Handle.require_all(Target, when=..., optional=...): the key function returns a set of ids, started with defaults (return set(p.hedges)), or a mapping from id to auto-start params (return {m: BookParams(market=m) for m in p.hedges}). Each id is a requirement of its own with the samewhenandoptional. The attribute is a read-onlyMapping[str, Handle[Target]]from id to handle, in no particular order (for market, book in self.books.items(): ...). An empty result requires nothing. Losing one hard entry stops the dependent like any hard requirement. - Only members declared on the target class type-check (
Handle[Hedger]). - Requirement cycles between processes are rejected when a process is
registered (
DependencyCycle),require_allincluded.
Control plane
self.tasks starts, stops and inspects other tasks; actions are attributed to
the calling task in the logs and the dashboard:
await self.tasks.start(Maker, 'eth', Maker.Params(market='hl::ETH', hedger='eth'))
await self.tasks.restart(Maker, 'eth') # current params, or pass new ones
await self.tasks.stop(Maker, 'eth') # stops its dependents first; waits for cleanup
info = self.tasks.get(Maker, 'eth') # TaskInfo | None
everything = self.tasks.list()
start raises AlreadyRunning, UnmetDependency, ParamsConflict or
DependencyCycle, all subclasses of ControlError. stop first stops every
task that requires the task, dependents first, attributed like
stop maker/eth by api (requires hedger/eth); restart doesn't, so
dependents see dependency loss and recover. get and list give lifecycle snapshots
(TaskInfo: status, attempt, stop_reason, error, ...); they grant no
IPC access.
Logging
Records logged inside a task are captured into that task's log, tagged with
the attempt, and streamed to the dashboard. Prefer self.log: the runtime sets
it, and tribulnation.engine, to INFO. Other loggers keep their own levels.
Statuses and lifecycle
| Status | Meaning |
|---|---|
starting |
An attempt is scheduled but not started, including during a restart delay |
waiting |
The attempt waits for its requirements to hold, at first start or after dependency loss |
running |
Inside run() |
stopping |
shutdown(reason) is running |
stopped |
Ended by an explicit stop, a normal return without restart, or engine shutdown |
error |
Ended by an error, with no restart left |
- Waiting. Each attempt waits in
waitinguntil its requirements hold. A wait longer thanwaiting_timeout(30 s by default) ends the attempt as an error, and the restart policy decides what follows. - Dependency loss. When a bound requirement stops holding, the dependent's
runis cancelled andshutdown('dependency')runs. A fresh attempt with the same params then waits for its requirements again, independently of the restart policy and without consuming its budgets. Handles never switch to a new target attempt. - Auto-start. Starting a task starts the required tasks that aren't
active, with the params its requirements ask for, or else with defaults
alone if they're valid. Otherwise the start fails with
UnmetDependency, naming every missing task. Asking for other params than an active task has, or two requirements asking for one key with different params, fails withParamsConflict; equal params share the task. Every attempt plans again, so a required task that ended (e.g. past its restart budget) is started again by its dependents; a failed plan fails the attempt as an error. - Auto-stop. An auto-started task that nothing requires any more is
stopped after a grace period: the process's
unneeded_graceclass variable (the runtime's 30 s default if unset), and at least the dependents' restartdelay. Explicitly started tasks are never auto-stopped. - Explicit stops cascade. Stopping a task first stops, with reason
'requested', every task that requires it, directly or transitively, dependents first. Crashes, restarts, auto-stop and engine shutdown don't cascade. - Engine shutdown stops dependents before the tasks they require.
Running in-process
Runtime runs processes as tasks in the current event loop, against any SDK
implementation; no gateway, manager or database is needed:
import asyncio
from tribulnation.engine import Runtime
from tribulnation.sdk.gateway import ProxySDK
from strats.maker import Maker
async def main():
async with ProxySDK.at('unix:///tmp/engine-gateway.sock') as sdk:
async with Runtime(sdk, waiting_timeout=30, auto_stop_grace=30) as runtime:
await runtime.start(Maker, 'btc', Maker.Params(market='hl::BTC'))
await runtime.wait()
asyncio.run(main())
Runtime has the same control methods as self.tasks (start, stop,
restart, get, list), plus register, wait and shutdown. start
also accepts raw params as a mapping. Leaving the async with block stops
every task.
Deployment
engine.toml
[engine]
processes = ["strats.hedger:Hedger", "strats.maker:Maker"]
[daemon]
host = "localhost"
port = 3121
socket = "/tmp/engine-gateway.sock" # gateway Unix socket
db = "engine.db" # SQLite store
[daemon.logs]
default_limit = 500 # rows per logs API page by default
[daemon.logs.retention] # stored rows per task and level
info = 20000
[accounts.hl] # every venue used needs an account
venue = "hyperliquid"
address = "$HL_ADDRESS" # or `public = true`, for market data only
private_key = "$HL_PRIVATE_KEY"
[[tasks]]
process = "maker"
id = "btc"
args = { market = "hl::BTC" }
[engine] processesaremodule:Class[,Class...]orpath/to/module.py:Classtargets, imported at startup. Required processes are registered with them.- Account values starting with
$are read from the environment; commands load.envfrom the directory ofengine.toml. [[tasks]]are started at startup, attributed toconfig.argsholds the task's params.
Commands
tn engine start # managed: launches gateway + manager
tn engine start -d # background; writes .engine.pid and engine.log
tn engine stop # stop a background engine
tn engine start owns both subprocesses. To use a gateway you run separately,
start only the manager against its socket:
tn gateway --config sdk.toml --socket /tmp/engine-gateway.sock # SDK, reads sdk.toml
tn engine manager --config engine.toml --socket /tmp/engine-gateway.sock
tn engine gateway runs the same SDK gateway but reads accounts and the socket
from engine.toml. These are alternative entry points, not commands to run
together on one socket; see Spec 05.
- No takeover:
tn engine startandtn engine gatewayrefuse to start when a gateway is already listening on the socket, and suggesttn engine manager --socket <path>instead. - Shutdown order:
tn engine startstops the manager first, so tasks can still reach venues during cleanup, then the gateway. Each waits up to--shutdown-timeoutbefore being killed.
On a systemd host, install tn engine start as a user service:
tn engine service install --cwd . --memory 2G --timeout-stop-sec 20 -v
tn engine service status
tn engine service logs -f
tn engine service restart | stop | start | uninstall
CLI reference
| Command | Purpose |
|---|---|
tn engine init [DIR] [-f] |
Scaffold engine.toml, .env and an example strategy |
tn engine start [-c CONFIG] [-d] [-v] [--host] [-p PORT] [-s SOCKET] |
Start gateway + manager |
tn engine stop |
Stop a detached engine |
tn engine gateway [-c CONFIG] [-s SOCKET] |
SDK gateway configured from engine.toml |
tn engine manager [-c CONFIG] [-s SOCKET] [--host] [-p PORT] |
Task manager and HTTP API over an existing gateway |
tn engine register TARGET [-u URL] |
Load process classes into the running manager |
tn engine service install|uninstall|start|stop|restart|status|logs |
User systemd service |
tn engine task list|get|start|restart|stop |
Control tasks, attributed to cli |
tn engine dev profile |
Sample CPU, memory, threads and file descriptors of an engine process |
task start and task restart take params as repeated -p key=value
(--param; -a/--arg are kept as aliases); the engine validates and coerces
them. task restart without -p keeps the current params. Every command takes
--help.
HTTP API
Served by the manager (default http://localhost:3121), with the dashboard at
/. Control routes take an optional ?actor= for attribution (default api).
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
{status, processes, tasks} |
GET |
/api/processes |
Registered processes: params schema, restart, requirements ({name, process, all}), states ({name, ui}), streams, rpcs |
POST |
/api/processes |
Load processes: {"target": "module:Class"} |
GET |
/api/tasks |
Active tasks |
GET |
/api/tasks/history?status= |
Stored tasks, with log_count |
GET |
/api/task/{process}/{id} |
One task, live or from history |
POST |
/api/task/{process}/{id} |
Start: {"params": {...}} |
POST |
/api/task/{process}/{id}/restart |
Restart: {"params": {...}}, or null for the current ones |
DELETE |
/api/task/{process}/{id} |
Stop and wait for cleanup; ?purge=true also deletes its history |
GET |
/api/task/{process}/{id}/logs?limit=&before=&level=&start=&attempt= |
The latest limit stored log rows, in order; before=<row id> pages back; start/attempt filter |
DELETE |
/api/task/{process}/{id}/logs |
Clear stored logs and attempts |
GET |
/api/task/{process}/{id}/attempts |
Every attempt of every start, newest first, with log_count |
GET |
/api/markets |
Accounts with catalogue markets, for the market picker |
GET |
/api/markets/{account} |
An account's exchanges, markets and assets, from the catalogue |
GET |
/api/debug/memory |
Memory diagnostics |
WS |
/api/ws |
Live feed |
- Tasks are
{process, id, key, status, params, start, attempt, stop_reason, error, traceback, started_at, stopped_at}.startcounts the task's explicit starts and restarts, across manager restarts;attemptcounts attempts within the current start. Logs and attempts carry both, so history is never overwritten. Live tasks also carry theiruiStates' current values,states: {name: {value, updated_at, truncated}}; these are never stored. Values are JSON (Decimalanddatetimeas strings, unknown objects as theirrepr); one longer than 2 KB of JSON is a cut string preview withtruncated: true. - Errors: unknown process
404;AlreadyRunning409;UnmetDependency409with{message, missing};ParamsConflict409with{message, key, running, requested};DependencyCycle422with{message, cycle}; invalid params422. - Websocket: the first message is a
snapshot(tasks,processes,health,history), followed bytask_update,task_end,log,control,stateandpingevents, each tagged byt. Astateevent (key, start, attempt, name, value, updated_at, truncated) carries a State's latest value, at most every 250 ms per State; each attempt begins with its defaults.
Market picker
Type a params field as MarketId to let the dashboard browse markets instead
of typing ids:
from tribulnation.engine import BaseParams, MarketId
class MakerParams(BaseParams):
maker: MarketId
hedger: MarketId | None = None
-
A
MarketIdis a plainstrof the form<account>:<exchange>:<market>, e.g.hl::BTCoraster:perp:ETHUSDT. The exchange may be empty, and the market may contain colons. Other values fail validation. -
AccountId(hl) andExchangeId(<account>:<exchange>, e.g.hl:ordydx:perp; the exchange may be empty) work the same way for params that need only an account or an exchange. -
In the start form, such fields get a Browse button that builds the id from the catalogue: pick an account, a market type, then a market, each with a search box. For
AccountIdandExchangeIdthe picker stops after the account or market type. -
Accounts are the
[accounts.*]fromengine.toml(the SDK has no implicit ones), hiding venues whose connector isn't installed; testnet accounts list their mainnet markets. Listings don't need the gateway. -
The catalogue is downloaded on first use, cached and refreshed at most daily. If it can't be reached, ids can still be typed.
-
MarketSpecis a market with its contract multiplier: the asset units per unit of the market's quantity (e.g. 1000 for HyperliquidkPEPE, which trades thousands of PEPE). The multiplier is a plain param defaulting to 1, so markets the catalogue doesn't list work too; a plain market id validates as a spec with multiplier 1. Specs are frozen and hashable:from tribulnation.engine import BaseParams, MarketSpec class HedgerParams(BaseParams): maker: MarketSpec hedges: set[MarketSpec] = set()
[[tasks]] process = "hedger" id = "main" args = { maker = "hl::BTC", hedges = [{ market = "hl::kPEPE", multiplier = 1000 }] }
In the start form, a
MarketSpecgets the market picker plus a multiplier input; picking a market from the catalogue fills in its multiplier, which stays editable. -
Lists, sets and dicts of any of these markers (e.g.
list[MarketId],dict[str, MarketSpec]) get one picker row per item, with add and remove buttons; dict rows also have a key.
Python client
from tribulnation.engine import TradingEngine
async with TradingEngine.at(
'unix:///tmp/engine-gateway.sock', api_url='http://localhost:3121'
) as engine:
market = await engine.sdk.market('hl::BTC')
await engine.task.start('maker', 'btc', {'market': 'hl::BTC'})
await engine.task.stop('maker', 'btc')
engine.task is a TaskClient with list, get, start(process, id, params), restart(process, id, params=None) and stop(process, id, purge=False).
Upgrading from 0.1
- Gateway moved to the SDK.
tribulnation.engine.gatewayis gone; importProxySDKfromtribulnation.sdk.gateway. The wire protocol is the SDK's. Upgrade the gateway and manager together. - Params are explicit. Declare
class Maker(Process[MakerParams]);runreceives the params model. - Declared dependencies replace
self.require(...). Use@Handle.require(Target, when=Target.some_state)with a key function returning one id. Named states set withself.set_state(...)becomeState[bool]members passed aswhen.self.ipcis replaced by handlecall,getandsubscribe. - Restart budgets: the rolling limit is
RestartPolicy(limit=RestartLimit(max_restarts=N, within=T)). - API and client fields renamed:
argsis nowparamsin request bodies and task data, andattempt_countis nowattempt.TaskClient.start(process, id, params)takes a mapping instead of keyword arguments.[[tasks]] argsinengine.tomlis unchanged. - Store migration is automatic. The SQLite schema is versioned with
PRAGMA user_version; a 0.1 database is migrated in place on first start. Back it up first: a migrated database can't be opened by 0.1. - Commands: use
tn engine ...; theenginecommand is gone. Service units installed by 0.1 call it, so reinstall them withtn engine service install.
Architecture
tn engine start
├── gateway SDK gateway: owns venue connections and credentials (Unix socket)
└── manager task manager
├── Runtime: processes run as asyncio tasks, with self.sdk = ProxySDK
├── REST + WebSocket API and dashboard (FastAPI / uvicorn)
└── SQLite store: tasks, attempts and logs
Strategies never hold venue connections directly: the gateway owns them and
shares them between tasks. Process declarations (tribulnation.engine.process)
are separate from the runtime that runs them (tribulnation.engine.runtime);
see docs/architecture.md for the module map.
Releases
GitHub Actions builds and publishes releases. PRs and pushes to main type-check the package, run its Python tests,
build the dashboard using npm ci, and build/check the Python wheel and source
distribution. CI verifies that
the wheel contains the dashboard and uploads both distributions as workflow artifacts.
- Create a branch named
release/enginefrommain. - Bump
versioninlib/pyproject.tomland open a PR tomain. - Merge after checks pass. The release workflow checks the version bump and rebuilds
the exact merged commit, publishes its distributions to PyPI, then creates a
v<version>tag and GitHub release with the distributions attached.
Ordinary PR merges do not publish. Re-running the release workflow skips files already uploaded to PyPI and preserves an existing GitHub release.
Configure a PyPI trusted publisher
once for tribulnation-engine: owner tribulnation, repository engine, workflow
release.yml, with the environment field left empty (matching the SDK setup).
No PyPI API token or exchange credentials are required by CI.
Metadata
Release files for tribulnation-engine 0.2.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 | |
|---|---|---|---|
| tribulnation_engine-0.2.1.tar.gz | 263.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tribulnation_engine-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 544.0 kB
Release files / tribulnation_engine-0.2.1.tar.gz
| Download URL | tribulnation_engine-0.2.1.tar.gz |
|---|---|
| Size | 263.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0be8d6039ba49cf5d7bcc4073041539132ae5086f41e775e010f4e0c8e2e28df
|
|
BLAKE2b-256 checksum How to use checksums |
df20d4024645c37315329c08c1902947e184e5bd3e7b7a88d5ff22cb9e47b5c5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Oct 14, 2026.
Transparency logRelease files / tribulnation_engine-0.2.1-py3-none-any.whl
| Download URL | tribulnation_engine-0.2.1-py3-none-any.whl |
|---|---|
| Size | 280.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ffe55d3e4cc89de83660c3b2f7974b17fbbee4fb71db93d81f3ba6946c19fdf9
|
|
BLAKE2b-256 checksum How to use checksums |
b5e6942f5b6454f4a9988c848783298f62bf34d25fbe00fb7729bffae242e18d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Oct 14, 2026.
Transparency log