acronymkit
Bi-directional, multi-tiered acronym processing for production systems. Generate acronyms from text, synthesise backronyms, extract abbreviations already defined in a document, and resolve ambiguous ones from context — from one typed library with a Tier 0 path that needs nothing but the standard library.
from acronymkit import AcronymEngine
engine = AcronymEngine()
engine.generate("Application Programming Interface").primary_acronym # 'API'
engine.generate("Portable Document Format").primary_acronym # 'PDF'
Generation is measured, and nothing else measures it. Fed the 1,221 human-authored
short-form/long-form pairs of the MED1250 gold standard backwards, acronymkit returns the
abbreviation the human actually chose at rank 1 for 75.5 % of the 546 pairs an initialism
generator can address, and within the top 25 for 89.7 %. No competing library has a number
here, because no competing library generates.
Extraction is measured too, against four other systems through one harness: precision 92.07 %, recall 76.99 %, F1 83.85 %. We are third of five there, and the table showing exactly that is in docs/EVALUATION.md — including where we lose.
Why
The open-source acronym ecosystem is split in two, with nothing in between:
| What it does well | Where it stops | |
|---|---|---|
Naive string utilities — acronymcreator, countless gists |
Fast, no dependencies | No tokenisation grammar, no phonetics, no semantics |
| Corpus matchers — ACRONYM (Cook, 2019) | Finds candidates that spell real words | Offline batch, static corpus, no library bindings |
| Rule-based extractors — Schwartz & Hearst (2003) in scispaCy, Blackstone | F₁ > 96 % on inline definitions, cheap | Strictly extractive; the definition must be present |
| Neural disambiguators — AcroBERT, SDU/SciAD/GLADIS | Resolves standalone acronyms from context | GPU-bound, slow cold start, research codebases |
A system that needs to generate, extract and resolve has to stitch three incompatible
codebases together. acronymkit is the missing single library.
Install
pip install acronymkit
The base install is the complete Tier 0 engine — extraction, generation, backronyms, scoring, CLI-free
API — with pydantic as the only runtime dependency. Optional extras add higher tiers:
pip install "acronymkit[cli]" # click-powered command line
pip install "acronymkit[nlp]" # Tier 1: spaCy / NLTK part-of-speech evidence
pip install "acronymkit[all]" # everything
CI enforces the split: a dedicated job installs the base package alone and asserts that no optional
dependency reaches sys.modules after a full generate + extract + backronym + disambiguate cycle.
What it does
Forward generation
from acronymkit import AcronymEngine, Config
from acronymkit.enums import EngineTier, ScoringStrategy
engine = AcronymEngine(Config(
engine_tier=EngineTier.HYBRID_NLP,
scoring_strategy=ScoringStrategy.STRICT_INITIALISM,
include_articles=False,
min_word_length=2,
max_acronym_length=6,
))
result = engine.generate("Application Programming Interface")
result.primary_acronym # 'API'
result.score # 21.297
result.primary.breakdown.explain()
# 'S = 1*30.000 + 0.25*-2.811 + 2*0.000 - 25*0.000 = 21.297'
[(c.acronym, round(c.score, 2)) for c in result.alternatives[:4]]
# [('API', 21.3), ('APIN', 15.42), ('APRI', 15.38), ('APPI', 15.29)]
Every candidate carries a per-character alignment trace and a term-by-term score decomposition, so a ranking decision is always explainable.
Backronym synthesis
Align a phrase onto a target word:
result = engine.generate_backronym(
phrase="Network Exchange Unified Security",
target_word="NEXUS",
)
result.primary_expansion # 'Network Exchange Unified Security'
result.candidates[0].coverage # 1.0
[m.kind.value for m in result.candidates[0].mappings]
# ['initial', 'initial', 'contiguous', 'initial', 'initial']
Note the contiguous mapping: E and X both come from "Exchange". Sub-word matches are
first-class, exactly as in the ACRONYM formulation.
Or expand a word with no source phrase at all:
engine.synthesize_backronym("NEXUS").candidates[0].expansion_text
# 'nag ear xenon urn sad'
Extraction (Schwartz & Hearst)
engine.extract_definitions(
"The National Aeronautics and Space Administration (NASA) launched the mission."
)
# [AcronymPair(short_form='NASA', long_form='National Aeronautics and Space Administration')]
Both arrangements are supported — Long Form (Short Form) and the inverted
Short Form (Long Form) — with exact source spans (text[pair.short_form_span[0]:...] == short_form
always holds) and a confidence estimate. Prose parentheticals such as (see Figure 3) and
enumerations such as (1) … (2) … are correctly rejected.
Measured, not asserted. On the MED1250 gold standard (1,221 human-annotated pairs from the Ab3P corpus): precision 92.07 %, recall 76.99 %, F1 83.85 %, at 4,219 documents/second. High precision with recall the weak side is the expected shape for this algorithm — it refuses rather than guesses. The full breakdown, including where the misses come from and one optimisation that was tried and reverted, is in docs/EVALUATION.md. Reproduce with:
python tools/fetch_data.py med1250 && python bench/run_extraction.py
Contextual disambiguation
from acronymkit.disambiguation import ExpansionDictionary
vocab = ExpansionDictionary({"MS": [
"multiple sclerosis", "Microsoft", "mass spectrometry", "manuscript",
]})
engine.disambiguate("MS", "The patient was diagnosed with MS after an MRI showed brain lesions.",
dictionary=vocab).primary_expansion # 'multiple sclerosis'
engine.disambiguate("MS", "The MS Office suite shipped a new version of Word and Excel.",
dictionary=vocab).primary_expansion # 'Microsoft'
engine.disambiguate("MS", "Peak intensity in the MS spectrum identified the ionised compound.",
dictionary=vocab).primary_expansion # 'mass spectrometry'
With no dictionary supplied, the engine builds one from the document's own inline definitions, so a term defined once on first use resolves everywhere afterwards.
Governed naming — expansion with no sentence to lean on
A schema-governance pipeline needs the opposite and simpler thing. Given a bare column token and a governed vocabulary, return the long form — deterministically, with no prose to disambiguate against, and with a record of which catalog entry produced each word.
from acronymkit.governed import GovernedNamer, load_bundle
# A real standard is a directory of files, and one line reads it:
# nds = GovernedNamer.from_bundle("nds_standard/")
# nds = GovernedNamer(load_bundle("nds_standard/"), policy) # the same, unbundled
# Inline below, so this block runs with nothing on disk.
nds = GovernedNamer.from_mapping(
{"TXN": "Transaction", "APPLNT": "Applicant", "ID": "Identifier",
"NBR": "Number", "NUM": "Number", "DT": "Date"},
approved_abbreviations=["TXN", "APPLNT", "ID", "NBR", "DT"],
class_words={"ID": "Identifier", "DT": "Date"},
)
nds.expand_identifier("TXN_APPLNT_ID").phrase # 'Transaction Applicant Identifier'
nds.expand_identifier("TXN_KYC_ID").is_fully_known # False — the catalog has no KYC
nds.normalize("txnApplntNum") # 'TXN_APPLNT_NBR'
GovernedNamer binds one vocabulary and one policy and then takes the subject as its only argument;
expand_many and check_many take a whole corpus. A naming standard on disk is a catalog, three
allow-lists, a class-word map, a pin sheet and a term glossary, which is what from_bundle and
load_bundle read; from_csv and from_json read the narrower shapes.
Nothing is dropped in silence. A character the splitter cannot account for is reported rather
than discarded, and is_fully_known is true only when every token resolved and nothing went
unaccounted for:
nds.expand_identifier("TXN_©_ID").phrase # 'Transaction Identifier'
nds.expand_identifier("TXN_©_ID").unaccounted # ('©',)
nds.expand_identifier("TXN_©_ID").is_fully_known # False
Your acronyms win. with_custom layers an overlay above the catalog and reports it as such:
result = nds.with_custom({"KYC": "Know Your Customer"}).expand_token("KYC")
result.long, result.source.value # ('Know Your Customer', 'custom')
Three directions over one vocabulary — expand_identifier reads a physical name, to_physical_name
renders a logical one, is_compliant checks a name somebody else wrote — so a catalog change moves
all three at once. Every answer carries the rule that produced it, the catalog row behind it and the
candidates it beat.
This is not disambiguation, and that is the design. A column name is not a sentence: there is no context to weigh, so the answer comes from the catalog or it does not come at all. An unknown token is reported as unknown, with zero confidence, never approximated — because an unknown reported as unknown is recoverable and an unknown quietly guessed is not. Governed mode is exact by construction, which is a tautology rather than a measurement, and no accuracy figure is attached to it anywhere in this project. docs/GOVERNED_NAMING.md has the precedence chain, every verb with runnable output, the four invariants and an honest limits section.
Multilingual — English complete, others experimental
Categorised stop-word taxonomies ship for English, French, Spanish and German, and generation works for all four:
from acronymkit.enums import Language
AcronymEngine(Config(language=Language.FR)).generate("Système de Gestion de Base de Données") # SGBD
AcronymEngine(Config(language=Language.DE)).generate("Allgemeine Deutsche Automobil Club") # ADAC
But only English ships a lexicon and a character model. For fr/es/de, Λ(A) is identically zero (no candidate is ever reported as a dictionary word) and Φ(A) is uniform (pronounceability stops discriminating). Positional fidelity carries generation on its own, which is why the examples above are still correct — but treat those languages as experimental.
This is deliberate. The v0.1.0 word lists for those languages were model-authored, which made every dictionary claim about them unverifiable, and unlike English there is no permissively licensed replacement to swap in: the available Hunspell dictionaries are copyleft, and German's only permissive arm grants distribution solely alongside ODF applications. Given the choice between invented data and no data, no data is correct — invented data produces confident wrong answers a caller cannot detect.
The engine says so rather than degrading silently:
result = AcronymEngine(Config(language=Language.FR)).generate("Système de Gestion de Base de Données")
result.metadata.warnings
# ["no bundled lexicon for language 'fr': Lambda(A) is always 0, ...",
# "no bundled n-gram model for language 'fr': Phi(A) is uniform, ..."]
To get real coverage, install a dictionary yourself — it stays on your machine, so its licence is your call, not ours:
python tools/fetch_data.py hunspell-fr
python tools/build_lexicons.py --language fr --output ~/fr.txt
Config(language=Language.FR, lexicon_path=Path("~/fr.txt").expanduser())
Execution tiers
| Tier | EngineTier |
Dependencies | Latency | Use for |
|---|---|---|---|---|
| 0 | ZERO_DEPENDENCY |
stdlib + Pydantic | 98 µs/call, 2.3 ms to import | Edge, high-throughput indexing, hot paths |
| 1 | STATISTICAL_NLP |
spaCy or NLTK | single-digit ms | POS-aware generation on messy human text |
| 1 | HYBRID_NLP |
optional | either | Production default — degrades gracefully |
| 2 | NEURAL |
ONNX Runtime | — | Phase 3, not in this release |
| — | AUTO |
— | — | Best tier available at import time |
Degradation is never silent in the payload: the effective tier lands in metadata.engine_tier, the
requested one in metadata.requested_tier, and the reason in metadata.warnings. Set
Config(strict=True) to turn degradation into a TierUnavailableError instead.
The scoring function
Candidates are ranked by a configurable composite objective:
$$S(A, T) = \alpha \sum_{i} \omega(c_i, w_{j(i)}) ;+; \beta , \Phi(A) ;+; \gamma , \Lambda(A) ;-; \delta , \Psi(T, A)$$
| Term | Meaning |
|---|---|
ω(cᵢ, w) |
Positional mapping weight — 10 when cᵢ is the initial character of w, 3 when internal or terminal, 2 when it directly follows a previously matched character of the same token |
Φ(A) |
Phonotactic pronounceability — mean character-bigram log-likelihood, (1/(k−1))·Σ log P(c_{m+1} | c_m) |
Λ(A) |
Lexical match — 1 when A is a real word in the target lexicon (a successful backronym), else 0 |
Ψ(T, A) |
Information loss — count of semantically critical tokens the acronym drops |
Φ behaves the way you would hope: Φ("SCALE") = −2.29, Φ("PDF") = −6.22, Φ("XKCD") = −7.46.
The syllable heuristic underpinning it is measured, not asserted: against the 117,485 CMUdict
entries it scores 84.1 % exact and 99.5 % within one syllable (mean absolute error 0.16).
Reproduce with python tools/build_lexicons.py --validate-syllables.
Presets
ScoringStrategy selects a calibrated (α, β, γ, δ) vector:
| Strategy | Behaviour |
|---|---|
STRICT_INITIALISM |
Default. Positional fidelity dominates; reproduces the textbook initialism |
BALANCED_PRONOUNCEABLE |
A real trade: pronounceability and dictionary hits can outrank the literal initialism |
MAX_PRONOUNCEABLE |
Phonotactics dominate — for product and project naming |
DICTIONARY_BACKRONYM |
A real word outweighs almost everything else |
CUSTOM |
You supply ScoringWeights |
# "Structured Query Language Transaction Protocol"
STRICT_INITIALISM -> 'SQLTP' pronounceability 0.11
MAX_PRONOUNCEABLE -> 'SQULTP' pronounceability 0.65
# "Network Exchange Unified Security"
DICTIONARY_BACKRONYM -> 'NEXUS' (dictionary word, score 92.95)
The defaults are calibrated rather than guessed, by a committed script
(tools/tune_presets.py) so the claim is falsifiable. STRICT_INITIALISM reproduces all sixteen
entries of a canonical corpus of textbook initialisms (API, PDF, NASA, HTML, RAM, CPU, GPU, SCUBA,
LASER, SQL, CRM, QA, TCP, SOAP, BIOS, ROM), and keeps doing so when β, γ or δ are perturbed by
50–100 % — a genuine plateau rather than a fitted point. tests/test_scoring_presets.py pins the
corpus so retuning cannot silently regress it.
BALANCED_PRONOUNCEABLE deliberately does not reproduce that corpus, and it is worth knowing why.
Against the real 76,879-word lexicon there is provably no coefficient vector that both weights
dictionary hits meaningfully and returns every textbook initialism: suppressing a vowel insertion
needs a length penalty above ~14, which then over-penalises the short acronyms instead. So the preset
honours its name — it returns QUA for "Quality Assurance", because "qua" is a word. See
docs/DECISIONS.md.
One deliberate deviation from the published formulation. The positional term is a sum, so it grows monotonically with length — used unmodified as a generation objective it prefers
PODOFOtoScoringWeights.length_penalty(default 8.0) closes that gap, sitting betweencontiguous_weight(2) andinitial_weight(10) so that covering a new token nets +2 while taking a second letter from a token already used nets −6. Setlength_penalty=0.0to recover the unmodified objective exactly.
Command line
pip install "acronymkit[cli]"
acronymkit generate "Self Contained Underwater Breathing Apparatus" --top 3
Phrase: Self Contained Underwater Breathing Apparatus
Primary: SCUBA (score 27.264)
RANK ACRONYM SCORE PRONOUNCE DICT
---- ------- ------ --------- ----
1 SCUBA 27.264 0.75 yes
2 SCOUBA 19.352 0.77 no
3 SCUBRA 19.284 0.75 no
Score breakdown for SCUBA:
TERM VALUE COEFF CONTRIBUTION
---------------- ------ ----- ------------
positional 50.000 1 50.000
phonotactic -2.943 0.25 -0.736
lexical 1.000 2 2.000
information_loss 0.000 25 -0.000
total 27.264
Also available: backronym, synthesize, extract, score, tokens, schema, version. Every
command takes --format json for the schema-conformant payload, and extract reads stdin.
Governed naming from the command line
Seven more commands read a governed vocabulary. expand-token, expand-identifier, physical-name,
check-name and normalize-name answer one subject each — check-name exits 1 when the name does
not conform, so it works as a gate. Two answer a whole schema in one process:
acronymkit governed-batch --dictionary std/ --op expand < columns.txt > answers.jsonl
acronymkit governed-audit --dictionary std/ --suggest < columns.txt
governed-batch streams: one record per line in, one JSON object per line out, nothing
accumulates, so a fifty-thousand-column schema costs the same memory as a hundred-column one and the
reader sees the first answer before the last question is asked. --op selects expand, physical,
check, normalize or audit; every record carries line, input and any id it arrived with.
An error rides on its own record and never aborts the run, the process exits 1 if any record failed,
and the one-line summary goes to stderr so every line of stdout is a record. This is the command that
makes the library usable from another language: answering one name takes microseconds and starting a
Python interpreter takes tens of milliseconds, so a pipeline that shells out per column pays almost
all of its cost outside the work.
governed-audit reduces the same corpus to one report — coverage, round-trip breaks, compliance
findings by reason code, and the unknown-token backlog ranked by how often each token appears and in
how many of them. --suggest proposes catalog rows to write, --limit controls truncation,
--details keeps one record per name in the JSON.
--dictionary takes a bundle directory, a JSON catalog or a CSV on all seven, with
--dictionary-format, --columns and --delimiter saying how to read it — and it refuses to guess
which way round a two-column CSV is meant to be read, because both readings are valid vocabularies
that mean different things. Every command here except governed-batch also takes --format json.
docs/QUICKSTART_GOVERNED.md runs all of it end to end, from a
spreadsheet export to a diff against whatever you use today. Driving governed-batch from another
runtime is docs/JAVA_INTEROP.md, with a runnable Maven project in
examples/java/.
Batch and async
batch = engine.batch_generate(phrases, max_workers=8)
batch.succeeded # results in submission order
batch.errors # {index: message} — one failure never aborts the batch
results = await engine.abatch_generate(phrases, concurrency=16)
A single AcronymEngine is immutable and safe to share across threads and event loops.
Interoperability
Results serialise to a versioned JSON Schema,
schemas/acronym-engine-result.schema.json, shared with
the planned acronym4j Java port:
from acronymkit.serialization import validate_result
validate_result(result.to_dict()) # raises if the payload drifts from the contract
Data provenance
The bundled English lexicon is derived from SCOWL (size cut ≤ 60, 76,879 entries) and the syllable validation from CMUdict. Both are permissively licensed and redistributed with their notices in the resource headers. Every asset is pinned to a commit or release tarball and verified by SHA-256, because a silent upstream edit to a word list changes Λ(A), which changes every score.
data/LICENSES.md is the ledger: source, licence, checksum, and — for anything not
redistributable — the reasoning for why not. tools/build_lexicons.py refuses to write a
non-redistributable asset into the package, so the rule is enforced by code rather than by diligence.
python tools/fetch_data.py --list # the registry
python tools/fetch_data.py --verify # re-check every checksum
Honest scope
- Extraction is evaluated on two corpora, both biomedical. MED1250 is biomedical abstracts and is a tuning set — its miss taxonomy has been read in full. PLOD was added to close the domain-generalisation gap and turned out not to: it is PLOS journal text, also dominated by the life sciences. No evidence exists for legal, financial or general-web text. Treat the comparison as sound and the absolute level as provisional.
- System rankings are corpus-dependent, and we have measured that.
pyab3pbeats us on MED1250; we beat it on PLOD's span-detection task. Any single-corpus ranking, including ours, should be read with that in mind. - 14.01 % of that corpus is found by no system at all, ours or anyone's. The practical ceiling is 85.99 %, not 100 %, and every figure here should be read against it.
- English only. French, Spanish and German ship no lexicon; those languages degrade honestly and
say so in
metadata.warnings. - Disambiguation is measured and it loses. On SDU@AAAI-21 it scores 41.65 % accuracy against 72.84 % for simply always picking the most common expansion. It beats random, so the context signal is real, but on that benchmark it is worth less than memorising frequencies. Use it for document-local resolution, where inline definitions carry it; do not expect it to win a disambiguation benchmark.
- Every number in this README is generated from
bench/results.json, and CI fails the build if a performance claim anywhere in the docs or the source cannot be traced back to a benchmark run.
Documentation
- Architecture — subsystem map, tier policy, resource formats, extension points
- Governed naming — deterministic short→long expansion against a schema catalog: the custom-first precedence chain, every verb with real output, the four invariants, and why refusing to guess is the right design for governed data
- Governed naming in five minutes — the same subsystem from the command line, from a spreadsheet export to a whole schema in one process, ending in a diff against the implementation you have now
- Technical note: the governed JSON wire contract — the exact JSON shape of every DTO and fixture, written so a port can be validated against the same golden files. No JVM artifact exists; this is what would make writing one mechanical
- Calling this library from the JVM — which route a Java Maven project should
take and what each one needs on the machine: the
governed-batchco-process, GraalPy as a Maven dependency, Py4J, JPype and a hand-written port, with the measurements behind the recommendation examples/java/— a runnable Maven project for the recommended route: a co-process client, the wire format by hand, and the real output. No CI job builds it- Contributing — the invariants that are easy to break by accident
- Technical note: using a ranking function as a generation objective
- Evaluation — measured extraction accuracy against a gold standard
- Decisions — what was tried and rejected, and why
- Enterprise review — the short answer for someone deciding whether to allow this package: air-gapped install, security posture with each claim's proof named, and the exact error text when something is missing
- Support matrix — capability by tier by "works offline" by "what it needs", including what English ships that fr/es/de do not
- Offline and air-gap review — the long form: how the no-network claim was measured, what the measurements cannot see, and how to reproduce them
- Installing without PyPI — release-asset wheel, git tag, source checkout, or a
per-platform offline bundle that installs with
--no-indexand needs no package index at all - Security policy — threat model and disclosure
- Changelog
Extending
| You want to… | Do this |
|---|---|
| Use a bigger dictionary | Config(lexicon_path=Path("/usr/share/dict/words")) |
| Score against a domain corpus | CharNGramModel.train(my_words) → JSON → Config(ngram_model_path=…) |
| Add a language | Add stopwords_<lang>.json + lexicon_<lang>.txt, generate ngram_<lang>.json, add a Language member |
| Change ranking | Config(scoring_strategy=ScoringStrategy.CUSTOM, scoring_weights=ScoringWeights(...)) |
| Suppress domain noise | Config(custom_stop_words=frozenset({"solution", "platform"})) |
| Plug in your own tagger | Implement the NlpBackend protocol |
Roadmap
- Phase 1 — Tier 0 engine and extractive foundation. ✅ Shipped in 0.1.0
- Phase 2 — Statistical NLP and phonetic scoring. ✅ Shipped in 0.1.0
- Phase 3 — Tier 2 neural disambiguation. Quantised sentence encoder via ONNX Runtime (no PyTorch
runtime dependency), indexed against SciAD/GLADIS-scale expansion dictionaries.
LexicalDisambiguatoralready implements the contract the neural backend will satisfy, andEngineTier.NEURALis accepted today, so selecting it is forward-compatible. - Phase 4 —
acronym4jon Maven Central. API parity, byte-identical JSON, same schema.
Licence
Metadata
Release files for acronymkit 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| acronymkit-0.3.0.tar.gz | 1.2 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| acronymkit-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.7 MB
Release files / acronymkit-0.3.0.tar.gz
| Download URL | acronymkit-0.3.0.tar.gz |
|---|---|
| Size | 1.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7a909c7af7f1c8a2e3b2f1cdcc9d7ae09d67a60c7ad5f436dff7711f901f4fe7
|
|
BLAKE2b-256 checksum How to use checksums |
ee8ca2848d5f5ca28a21c169b2bc7a8e9ff5a294df7615e57f30bc26a930ecc4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.12.9
|
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 Aug 11, 2026.
Transparency logRelease files / acronymkit-0.3.0-py3-none-any.whl
| Download URL | acronymkit-0.3.0-py3-none-any.whl |
|---|---|
| Size | 564.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fade7bfbe2e001c49a5172b8c4a5b02c3f4d2db38b7279adff38dfdfa4d81e0c
|
|
BLAKE2b-256 checksum How to use checksums |
5558ae64ce2b4fcc57a1ae86b641d8942019b108e10959196345ea50b12469e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.12.9
|
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 Aug 11, 2026.
Transparency log