agentdag
agentdag is a coordinator for small graphs of AI-agent nodes. It dispatches each node, gates what the node produced on something mechanical, and branches only on typed records, never on prose an agent wrote.
The graph A baseline is the first coordinator graph it ships (0.0.1 on PyPI is the scaffold alone): a fleet migration that gives every repository its own worktree, its own agent and its own gate run, and pushes what passed to scratch clones after one console approval. It deliberately has no journal, no token cap and no unattended approve. Those are later milestones, and what their absence costs is what this baseline is for. See Graph A: fleet migration below.
- CLI entry point styled with rich-click (rich output + click ergonomics).
- Layered configuration system with lib_layered_config (defaults -> app -> host -> user -> .env -> env).
The
.envis the first one found from the current directory upward; when there is none,$XDG_CONFIG_HOME/agentdag/.env(~/.config/...when unset) is read on Linux (see CONFIG.md for macOS and Windows). - Rich structured logging with lib_log_rich (console, journald, eventlog, Graylog/GELF).
- Exit-code and messaging helpers powered by lib_cli_exit_tools.
- Metadata helpers ready for packaging, testing, and release automation.
Python 3.12+ Baseline
- The project targets Python 3.12 and newer.
- Runtime dependencies are declared with >= floors in pyproject.toml. Dev dependencies (pytest, ruff, pyright, bandit, etc.) are declared the same way.
- CI workflows exercise GitHub's rolling runner images (
ubuntu-latest,macos-latest,windows-latest) and cover CPython 3.12 through 3.15 alongside the latest available 3.x release provided by Actions.
Install - recommended via uv
uv is an ultrafast Python package manager written in Rust (10-20x faster than pip/poetry).
Install uv (if not already installed)
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Copy the actual binaries
cp /root/.local/bin/uv /usr/local/bin/uv
cp /root/.local/bin/uvx /usr/local/bin/uvx
# Ensure world-executable
chmod 755 /usr/local/bin/uv /usr/local/bin/uvx
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
One-shot run (no install needed)
uvx agentdag@latest --help
Persistent install as CLI tool
# One-time install from PyPI (isolated environment, added to PATH)
uv tool install agentdag
# or from the git repository
uv tool install --from "git+https://github.com/bitranox/agentdag.git" agentdag
# Pick the interpreter explicitly if needed, e.g. --python 3.14
# Update (requires network)
uv tool upgrade agentdag
# Run
agentdag --help
Install as project dependency
uv venv && source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
uv pip install agentdag
For alternative install paths (pip, pipx, source builds, etc.), see
INSTALL.md. All supported methods register the agentdag command on your PATH.
Why we recommend uv: supply-chain protection
This repository commits a uv.lock. It pins every package the project needs, transitive
dependencies included, to an exact version and to the hashes of that version's files. The
exclude-newer setting under [tool.uv] in pyproject.toml adds a cooldown: uv leaves any
release younger than that out of the lock. CI installs from the lock, so a hijacked release that
gets pulled again within the cooldown never reaches our builds, and a file whose hash does not
match is refused.
That protection stays in this repository. The installable package declares only lower bounds
(>=) for its dependencies, so a plain pip install takes the newest release of every transitive
dependency at the moment you run it. If one of them was hijacked an hour earlier, you get the
hijacked version. pipx install and uv tool install without options do the same.
To install the command line tool with a cooldown of your own (uv then skips any release younger than 7 days, transitive ones included), or to run the exact tree our CI tests:
uv tool install --exclude-newer "7 days" agentdag
git clone https://github.com/bitranox/agentdag.git && cd agentdag && uv sync --locked && uv run agentdag --help
If you depend on agentdag in your own project, give that project the same cooldown (7 days here, as
an example) and commit the uv.lock that uv add agentdag writes:
[tool.uv]
exclude-newer = "7 days"
Configuration
See CONFIG.md for detailed documentation on the layered configuration system, including precedence rules, profile support, and customization best practices.
Quick Start
# Install
uv tool install agentdag
# Verify
agentdag --version
# deploy config files (user level; --target app or host needs sudo)
agentdag config-deploy --target user
# Try it out
agentdag hello
agentdag info
agentdag config
Usage
The CLI leverages rich-click so help output, validation errors, and prompts render with Rich styling while keeping the familiar click ergonomics.
Available Commands
# Graph A fleet migration (see the section below)
agentdag graph-a scratch real-repos.txt
agentdag graph-a run /tmp/agentdag-scratch/REPOS.txt brief.md
# Display package information
agentdag info
# Greeting and error-handling demos
agentdag hello
agentdag fail
agentdag --traceback fail
# Configuration management
agentdag config # Show current configuration
agentdag config --format json # Show as JSON
agentdag config --section lib_log_rich # Show specific section
agentdag config --profile production # Use a named profile
# Deploy configuration templates to target directories
# Without profile:
agentdag config-deploy --target app # -> /etc/xdg/{slug}/config.toml
agentdag config-deploy --target host # -> /etc/xdg/{slug}/hosts/{hostname}.toml
agentdag config-deploy --target user # -> ~/.config/{slug}/config.toml
# With profile:
agentdag config-deploy --target app --profile production # -> /etc/xdg/{slug}/profile/production/config.toml
agentdag config-deploy --target host --profile production # -> /etc/xdg/{slug}/profile/production/hosts/{hostname}.toml
agentdag config-deploy --target user --profile production # -> ~/.config/{slug}/profile/production/config.toml
# With custom permissions (POSIX only):
agentdag config-deploy --target user --file-mode 640 # Files with rw-r----- (640)
agentdag config-deploy --target user --dir-mode 750 # Directories with rwxr-x--- (750)
agentdag config-deploy --target app --no-permissions # Skip permission setting (use umask)
# Profile names: alphanumeric, hyphens, underscores; max 64 chars; must start with letter/digit
# See CONFIG.md for full validation rules
# Deploy configuration examples
agentdag config-generate-examples --destination ./examples
# Load configuration from an explicit .env file (skips upward directory search)
agentdag --env-file /path/to/.env config
agentdag --env-file ./environments/production.env send-notification ...
# Override configuration at runtime (repeatable --set)
agentdag --set lib_log_rich.console_level=DEBUG config
agentdag --set email.smtp_hosts='["smtp.example.com:587"]' config --format json
# Logging demo
agentdag logdemo
agentdag --set lib_log_rich.console_level=DEBUG logdemo
# Send email
agentdag send-email \
--to recipient@example.com \
--subject "Test Email" \
--body "Hello from bitranox!"
# Send email with HTML body and attachments
agentdag send-email \
--to recipient@example.com \
--subject "Monthly Report" \
--body "See attached." \
--body-html "<h1>Report</h1><p>Details in the PDF.</p>" \
--attachment report.pdf
# Send plain-text notification
agentdag send-notification \
--to ops@example.com \
--subject "Deploy OK" \
--message "Application deployed successfully"
# All commands work with any entry point
python -m agentdag info
uvx agentdag info
Graph A: fleet migration
Graph A applies one brief to a fleet of repositories: each repository gets its own worktree, its own agent node and its own gate run, and only what passed the gate is offered for pushing.
Two commands, in the order they are used:
# 1. read the real repositories once and mirror each into the scratch fleet
agentdag graph-a scratch real-repos.txt --scratch /tmp/agentdag-scratch
# a mirror that already exists is reused as it stands, however stale;
# --refresh throws it away and reads the real repository again
agentdag graph-a scratch real-repos.txt --refresh
# 2. run the graph over the mirrors that step wrote
agentdag graph-a run /tmp/agentdag-scratch/REPOS.txt brief.md --parallel 2 --model sonnet
real-repos.txt holds one path per line; blank lines and lines starting with # are ignored. brief.md is the change to make, handed to every node as its system prompt. graph-a scratch writes the list of mirrors to <scratch>/REPOS.txt, which is what graph-a run reads.
graph-a run prints the run directory as it starts and the path of its tally.json when it finishes. That file holds one row per repository, with the gate's exit code and the node's turn and token counts.
The scratch-clone rule
A run never writes to a real repository.
graph-a scratchreads each real repository exactly once, withgit clone --mirror.- The bare clones under
<scratch>/origin/are the only push targets a run accepts. A target anywhere else stops the run before the first node is dispatched, and the same check guards the push step itself. - Neither clone keeps a remote: the mirror does not point back at the real repository and the worktree does not point at the mirror, so a node's reflex
git pushhas nowhere to go. This is not containment. A node with unrestricted Bash can still push to any path it can name, and that needs a sandbox, which the baseline does not have.
The gate is a separate process, and gates are not serialised
The gate is make test in the node's own worktree, run as a separate process; the coordinator reads its exit code and nothing else. The baseline's gate command is fixed at that, which is part of what makes it the control; the kernel's is configurable, see Coordinator below. Gate runs are not serialised against each other: bmk 3.17.0 guards its own shared tool environment, so every bmk holds a shared lock on it for its lifetime and only the provisioning waits, never the whole gate. --parallel therefore bounds the gates as well as the agent nodes.
One credential per node
Each node runs with CLAUDE_CONFIG_DIR pointing at its own directory under the run store, holding its own copy of ~/.claude/.credentials.json, created owner-only. A node's token refresh lands in that copy rather than in the operator's file, and parallel nodes never share one credential file. An operator with no credential file is not an error: the node then fails with the CLI's own "not logged in" message.
What the baseline does not have
- No journal. The run store is a fresh timestamped directory per invocation, so a crash after the nodes have run and before the approval throws that work away.
- No token or spend cap.
max_turnsper node is a turn count, not a budget. - No unattended approve. The run blocks on a console confirmation, so it cannot run on a schedule or in CI.
Coordinator (agentdag run)
agentdag run is the general coordinator kernel: a journal, a resumable run directory, a
tier policy and a Claude executor with an allowlisted per-node credential. It runs the SAME
graph-a workflow the baseline above runs, plus a hold-by-default human approve step and
a crash window that re-dispatches only what a journal shows never finished. agentdag graph-a ... stays in the repo unchanged as the M1 BASELINE - the control this kernel is measured
against until M5 compares the two.
Codex (a second executor arm) and a per-turn token spend cap are NOT in this version.
The gate is the run's own command. [kernel] gate_command is the argv every gate node
runs, ["make", "test"] by default, in the node's own worktree and under an allowlisted
environment rather than the coordinator's whole one. What the node RECORDS is what ran: the
argv comes from the wired gate itself, so a node's input.json, its brief and its journal key
all name the command the machine executed, and the planner is told it too. A plan cannot choose
it - the gate is the step the agent that wrote the change has no say over - and an explicit
empty command is refused before a run directory exists, because a run with no gate is what the
gate exists to prevent. Like every other [kernel] setting it is resolved once at run start
and travels on the run, so the background coordinator and every later resume, approve or
retry gate on the command the run was started with.
One-time setup: the run directory needs to exist and be writable before the first run start.
sudo install -d -m 0700 -o "$USER" -g "$USER" /var/lib/agentdag/runs
Five verbs, over one run directory per run id:
# start a run; --foreground drives the coordinator in-process (the testable path);
# without it, the coordinator is launched detached (a systemd --user scope on Linux,
# a plain child process elsewhere) and the command returns immediately
agentdag run start graph-a \
--arg repos_file=/tmp/agentdag-scratch/REPOS.txt --arg brief_file=brief.md \
--arg scratch=/tmp/agentdag-scratch --runs /var/lib/agentdag/runs
# print state.json's fields and the journal's last event
agentdag run status <run-id>
# print one line per result the journal holds, or --json for the same as JSON
agentdag run records <run-id>
# relaunch a run that suspended, crashed, or otherwise is not yet `done`
agentdag run resume <run-id> --reason manual
# record a decision for a suspended approve node, then relaunch (unless --no-relaunch)
agentdag run approve <run-id> a_push_list --decision approve
A run directory (<runs>/<run-id>/) holds journal.jsonl (the append-only, replayable
log - one JSON line per event), audit.jsonl (a copy, written first), state.json
(status, cursor, token totals, and the settings the run was started with), lock (the
exclusive run lock), launch.log (a
background launch's redirected stdout/stderr), and per-node subdirectories:
decisions/ (recorded approvals), retries/ (recorded retry grants), intents/,
artefacts/, wt/ (worktrees), nodes/ (each dispatch's brief, input and record),
manifest/ (map/reduce manifests), and attempted/ and done/ (the two apply markers).
The Claude executor authenticates each node from one of two sources, chosen once at run start, printed there and carried on the run: the config's [credentials] claude_oauth_token_file keyfile if that path exists, else a private owner-only copy of the
operator's own ~/.claude/.credentials.json. A run started on a keyfile that has since gone is
refused by name at its next launch rather than quietly switched to the copy. Either way, the
CLI itself never reads the credential's content - only the executor does, inside the
coordinator process, at the point it actually dispatches a node.
What the kernel enforces, and what it does not
The per-node credential and the environment allowlists stop ACCIDENTAL leakage through inherited environment variables. They are not isolation. A node is a process running as the same operating-system user as the coordinator, with no sandbox and no separate account, so its own Bash tool can read files anywhere on the machine that user can read and can make outbound network requests. The M1 baseline's "this is not containment" caveat above applies to the kernel unchanged in kind.
The boundary the kernel does enforce, stated in one place. A node's Write and Edit tool
calls are refused outside its declared write set inside the run directory, by a live
PreToolUse deny rather than a scan. A plan-goal run may name a second root with
--arg workspace=DIR, an operator-supplied directory the plan works in instead of a worktree
the run owns; a node is then bounded by both roots and by nothing else, and the workspace is
granted whole rather than by glob. What that costs is stated where it is recorded: the
after-the-fact scan compares the run directory alone, so writes made in a workspace are writes
no scan judged, and every scan names the roots it did not cover in its own unwatched_roots key
fact, empty when there is nothing to name. A workspace inside the run
directory is refused, which is what keeps the two statements from overlapping. A planner node's
reads are confined to its own directory and working directory, and it gets no Bash at all. Every
other node's Bash is filtered by the
[kernel] deny_bash substring list, which is refused by name when set to a blank value rather
than read as an empty list. The tools that reach the network or spawn a sub-agent are refused
outright: WebFetch, WebSearch and Task by default, [kernel] deny_tools. An operator
widens either list deliberately by writing an explicit []. What a node may reach for WITHOUT
being asked is a separate, weaker setting - [kernel] tools, the SDK's auto-approval list, and
[kernel] permission_mode, what the CLI does with a call no hook denied. Neither is part of the
boundary: neither offered mode prompts, so naming a tool in tools auto-approves it and
omitting one closes nothing under either mode, and a node runs a tool it was never handed
whenever no deny hook refuses it - under bypassPermissions provided the provider CLI honours
that mode rather than silently downgrading it in this headless setup, which is unconfirmed. The
CLI consults a PreToolUse decision before it evaluates any permission rule, read at source
rather than measured live. Bash is the hole in that boundary:
a shell command can read and write anywhere the operating-system user can, and nothing catches
a write it makes outside the run directory. Both lists, like every other [kernel] setting, are
resolved once at run start and written to the run's state.json as its settings block; the
background coordinator and every later resume, approve or retry read them back from there,
and the deadline sweep announces a crashed run through the sink it was started with, so a value
supplied by an environment variable, --set or --profile reaches the run, and a relaunch
under a changed configuration does not change it.
- The Bash denylist blocks the exact command shapes the policy lists, and nothing
else. Measured against the shipped policy:
curl -XPOST ...,curl -d ..., a plain GET with the data in the URL,git -C some/path pushandpython3 -c ...all pass. It raises the cost of an accident; it does not stop a determined process. - A Bash write is caught only by the post-hoc scan, and only inside the run directory.
The live write-set deny above sees the Write and Edit tools; a shell command writes first
and the scan reports afterwards, and a write outside the run directory is not seen at all.
Under
--parallelgreater than 1 a stray write that lands inside a SIBLING's declared region cannot be attributed to either node, and the scan says so rather than naming one. byandtoken_idon a decision are not an authentication mechanism. They record who the recording process said it was. Any process running as the same operating-system user can write a decision file, so the run directory's own permissions are the actual control.- Cancel and deadline teardown reaps grandchildren only under the systemd scope.
There, the whole cgroup goes. Under
NoScope- off Linux, or with no livesystemd --usermanager - POSIX hosts kill the coordinator's process GROUP, which reaches its children, and Windows kills only the one launched process. - A failed CODE node (gate, scan, tally, discover) is final in this version. Its record is served from the journal on every resume and no command mints a new attempt, so a run that failed on one can only be restarted as a NEW run. M3 adds the retry path.
- A
Sandboxport now exists, but the shippednoneadapter enforces nothing. It turns the paragraph above into a typed declaration rather than only prose - every node the coordinator dispatches gets a record carryingsandbox.adapter == "none"withfilesystem,network_egressandseparate_uidallfalse, persisted inrecord.jsonand the journal'sresultline, soagentdag run records --jsonshows it too (the default plain-text output prints only node id, attempt, status, charged tokens and cost). A record served from the journal on replay keeps the declaration it was originally dispatched under, even when a later launch is wired with a differentSandboxadapter.
Email Sending
The application includes email sending capabilities via btx-lib-mail, supporting both simple notifications and rich HTML emails with attachments.
Email Configuration
Configure email settings via environment variables, .env file, or configuration files:
Environment Variables:
Environment variables use the format: <PREFIX>___<SECTION>__<KEY>=value
- Triple underscore (
___) separates PREFIX from SECTION - Double underscore (
__) separates SECTION from KEY
# A list is a JSON array; a comma-separated value would be ONE host string
export AGENTDAG___EMAIL__SMTP_HOSTS='["smtp.gmail.com:587","smtp.backup.com:587"]'
export AGENTDAG___EMAIL__FROM_ADDRESS="alerts@myapp.com"
export AGENTDAG___EMAIL__SMTP_USERNAME="your-email@gmail.com"
export AGENTDAG___EMAIL__SMTP_PASSWORD="your-app-password"
export AGENTDAG___EMAIL__USE_STARTTLS="true"
export AGENTDAG___EMAIL__TIMEOUT="60.0"
Configuration File:
[email]
smtp_hosts = ["smtp.gmail.com:587", "smtp.backup.com:587"] # Fallback to backup if primary fails
from_address = "alerts@myapp.com"
smtp_username = "myuser@gmail.com"
smtp_password = "secret_password" # Consider using environment variables for sensitive data
use_starttls = true
starttls_verify = true # false accepts any certificate: only for a known internal relay
timeout = 60.0
# local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
# delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
A key [email] or [email.attachments] does not know is refused (exit 78,
email.<key>: unknown key), whichever layer it comes from, so a typo cannot leave a setting at
its default.
.env File (no prefix in .env; a prefixed key is not recognised):
# Email configuration for local testing
EMAIL__SMTP_HOSTS=["smtp.gmail.com:587"]
EMAIL__FROM_ADDRESS=noreply@example.com
Gmail Configuration Example
For Gmail, create an App Password instead of using your account password.
In a .env file (no prefix in .env; a prefixed key is not recognised):
EMAIL__SMTP_HOSTS=["smtp.gmail.com:587"]
EMAIL__FROM_ADDRESS=your-email@gmail.com
EMAIL__SMTP_USERNAME=your-email@gmail.com
EMAIL__SMTP_PASSWORD=your-16-char-app-password
or as environment variables (these need the prefix):
export AGENTDAG___EMAIL__SMTP_HOSTS='["smtp.gmail.com:587"]'
export AGENTDAG___EMAIL__FROM_ADDRESS=your-email@gmail.com
export AGENTDAG___EMAIL__SMTP_USERNAME=your-email@gmail.com
export AGENTDAG___EMAIL__SMTP_PASSWORD=your-16-char-app-password
Send Simple Email
# Send basic email to one recipient
agentdag send-email \
--to recipient@example.com \
--subject "Test Email" \
--body "Hello from bitranox!"
# Send to multiple recipients
agentdag send-email \
--to user1@example.com \
--to user2@example.com \
--subject "Team Update" \
--body "Please review the latest changes"
Send HTML Email with Attachments
agentdag send-email \
--to recipient@example.com \
--subject "Monthly Report" \
--body "Please find the monthly report attached." \
--body-html "<h1>Monthly Report</h1><p>See attached PDF for details.</p>" \
--attachment report.pdf \
--attachment data.csv
Send Notifications
For simple plain-text notifications, use the convenience command:
# Single recipient
agentdag send-notification \
--to ops@example.com \
--subject "Deployment Success" \
--message "Application deployed successfully to production at $(date)"
# Multiple recipients
agentdag send-notification \
--to admin1@example.com \
--to admin2@example.com \
--subject "System Alert" \
--message "Database backup completed successfully"
Programmatic Email Usage
from pydantic import SecretStr
from agentdag.adapters.email.sender import EmailConfig
from agentdag.composition import send_email, send_notification
# Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
# library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
# smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
# use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
# '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
# the plain text.
config = EmailConfig(
smtphosts=["smtp.gmail.com:587"],
from_address="alerts@myapp.com",
smtp_username="myuser@gmail.com",
smtp_password=SecretStr("app-password"),
smtp_timeout=60.0,
)
# Send simple email
send_email(
config=config,
recipients="recipient@example.com",
subject="Test Email",
body="Hello from Python!",
)
# Send email with HTML and attachments
from pathlib import Path
send_email(
config=config,
recipients=["user1@example.com", "user2@example.com"],
subject="Report",
body="See attached report",
body_html="<h1>Report</h1><p>Details in attachment</p>",
attachments=[Path("report.pdf")],
)
# Send notification
send_notification(
config=config,
recipients="ops@example.com",
subject="Deployment Complete",
message="Production deployment finished successfully",
)
Email Troubleshooting
Connection Failures:
- Verify SMTP hostname and port are correct
- Check firewall allows outbound connections on SMTP port
- Test connectivity:
telnet smtp.gmail.com 587
Authentication Errors:
- For Gmail: Use App Password, not account password
- Ensure username/password are correct
- Check for 2FA requirements
Emails Not Arriving:
- Check recipient's spam folder
- Verify
from_addressis valid and not blacklisted - Review SMTP server logs for delivery status
Further Documentation
- Documentation index - what agentdag is, why it is shaped this way, and how it works
- Install Guide
- Development Handbook
- Contributor Guide
- Changelog
- Module Reference
- AI transparency - who decided what, what is checked, and what is not
- Our stance on AI
- License
Metadata
Release files for agentdag 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| agentdag-0.1.2.tar.gz | 1.4 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| agentdag-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.9 MB
Release files / agentdag-0.1.2.tar.gz
| Download URL | agentdag-0.1.2.tar.gz |
|---|---|
| Size | 1.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
40a5f30f921ad11a8d25f4c8ee086ec12f738a172f8ed22c64e8896b0a279296
|
|
BLAKE2b-256 checksum How to use checksums |
f3fd2dcd0038ef1600e1b0c487ee21af790c852ef10edefb56ebcf5df0b902eb
|
| 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 12, 2026.
Transparency logRelease files / agentdag-0.1.2-py3-none-any.whl
| Download URL | agentdag-0.1.2-py3-none-any.whl |
|---|---|
| Size | 489.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
48ce13adbd9ecb074338ff2b9fe032f5ef30395bbb0ef0bda267c949fc1a1a52
|
|
BLAKE2b-256 checksum How to use checksums |
91ce2a9463bf022958924304e3be6fa34224fb583b4e4e5eb0991d91381a2078
|
| 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 12, 2026.
Transparency log