guideme
Judgments from TypeSafe Jev that read like Python control flow.
A yes/no question is an if. A choice is an exhaustive match over your own enum. A score is
a comparison against your own ordered levels. Thresholds, unsure bands and fallbacks are
explicit and composable. Every request is one span. The decision logic is pure and its
contract is published under spec/, so every guideme SDK, in any language, answers the same
way.
from guideme import Choice, Guide, Levels, choose, fallback, noul, score
class Department(Choice):
billing = "Payments, invoicing, refunds"
technical = "Bugs, outages, integrations"
sales = fallback("Pricing, upgrades, new accounts")
class Frustration(Levels):
calm = "Calm and polite"
frustrated = "Frustrated"
very_angry = "Very angry"
guide = Guide.from_env()
if guide.ask(noul("Should this ticket be escalated?"), ticket):
escalate()
match guide.ask(choose(Department, "Which team should handle this?").min_confidence(0.6), ticket):
case Department.billing:
route_billing()
case Department.technical:
route_tech()
case Department.sales:
route_sales()
if guide.ask(score(Frustration, "How frustrated is the customer?"), ticket) >= (
Frustration.frustrated
):
prioritise()
The value of each member is the rubric the model reads. The member's name is the wire key. A
docstring on a member is documentation, not a rubric. pyright in strict mode enforces that
every option is handled, so adding a department turns the match above into an error until
you handle it. from_env() reads TYPESAFE_API_KEY, and sales, marked with fallback(…),
is also the answer when confidence is below the floor. That floor is min_confidence, and it
is 0.0 unless you set it, which is why the choice above asks for 0.6: with the default a
choice is never unsure and a fallback(…) member can never be reached.
Two members may not share a rubric: Python would make the second an alias of the first, so a
repeat is a ConfigError on the class statement rather than a rubric quietly one option
short. fallback(…) marks a Choice member and only a Choice member; a Levels is
ordered, so the level to fall back to when a score is unsure is .otherwise(level) on the
question.
That example is tests/typing/readme.py, which the gate type-checks with an assert_type on
every inferred answer type, so what is on this page cannot drift from what the package infers.
The three asks above are held to it by their use sites instead: the match is exhaustive and
the >= is between two levels of one scale.
Install
uv add guideme
or
pip install guideme
Python 3.12 or newer. The package ships py.typed, so your checker sees every annotation.
Set TYPESAFE_API_KEY in the environment, or pass a key to
Guide.builder().api_key(ApiKey("…")).build(). Keys come from the TypeSafe console, on its
keys page.
guideme is not the official TypeSafe SDK. That one is
typesafe-sdk, which mirrors the API: you send
questions and read answers. guideme adds the layer above it, turning an answer into control
flow — your own enums as the option set, thresholds and an unsure ladder as policy, one span
per request — and talks to the API itself rather than wrapping that package.
Three kinds of question, five constructors
| Constructor | Sends | Plain output | .detail() output |
|---|---|---|---|
noul("…") |
a yes/no question | bool |
Verdict with the label and the probability |
choose(C, "…") where C is a Choice |
a choice over C's 1 to 255 members |
C |
Ranked[C] with confidence and probabilities, the whole distribution |
score(L, "…") where L is a Levels |
a score over L's 2 to 10 levels, low to high |
L, the most probable level |
Scored[L] with the expected value, the level, confidence and distribution |
choose_among("…", options) |
a choice over 1 to 255 runtime {key: rubric} pairs |
Key |
Ranked[Key] |
score_levels("…", levels) |
a score over 2 to 10 runtime level descriptions | Rank |
Scored[Rank] |
Those size limits are the API's, and guideme checks them where you write the rubric: a Choice
or Levels class outside the range is a ConfigError on the class statement, and a runtime
rubric is one on the constructor call.
A noul can carry .criteria("what yes means", "what no means"). Instructions accept a string
or any JSON-shaped value, so a question can reference structured data by field name the way
the TypeSafe docs describe.
Examples in a rubric
Two alternatives that read alike are told apart by showing inputs rather than by describing
harder. option(…) takes the inputs that belong to an alternative and the ones that belong
somewhere else, level(…) takes the inputs that score at that level, and fallback(…) is an
option(…) that also marks the unsure member. All three kinds of question take them: a noul's
.criteria(…) accepts an option(…) for the yes and for the no.
from guideme import Choice, Levels, fallback, level, option
class Department(Choice):
billing = option(
"Payments, invoicing, refunds",
examples=["My card was charged twice", "Where is my refund?"],
counterexamples=["The dashboard is down"],
)
technical = option("Bugs, outages, integrations", examples=["502 on every request"])
sales = fallback("Pricing, upgrades, new accounts", examples=["Do you have a team plan?"])
class Severity(Levels):
cosmetic = level("No impact to functionality", examples=["typo in a label"])
degraded = level("Broken feature, workaround exists", examples=["export fails in one browser"])
blocking = level("No workaround exists", examples=["cannot log in", "data loss"])
The member's value is still the bare rubric; the examples are composed into it only in the request, as
Payments, invoicing, refunds
Examples: My card was charged twice; Where is my refund?
Not this option: The dashboard is down
So a rubric with no examples sends exactly what it sent before, and the same strings work in
choose_among("…", {"billing": option(…)}) and score_levels("…", [level(…), …]). Examples
and counterexamples render in the order they are written, always: that order is part of the
published contract.
A yes and a no are two alternatives of one question, so they take examples too, and this is where they pay best — a vague pair is the easiest thing to get wrong:
urgent = noul("Is this ticket urgent?").criteria(
option("Urgent", examples=["customers cannot log in", "money is moving to the wrong place"]),
option("Not urgent", examples=["a broken job with a manual workaround", "a cosmetic bug"]),
)
Asked about a nightly export job that has been failing since Tuesday while the numbers are
pulled by hand, a plain Urgent / Not urgent answers yes at 0.75. The criteria above answer
no at 0.17, because one of the not-urgent examples is what the ticket describes.
A string may be an example of one option and a counterexample of another. That is the point when two options are confusable, and it is the one overlap that stays legal. Offering the same string as an example of two options, or as both an example and a counterexample of the same option, says an input belongs where it cannot, so each is refused.
level(…) has no counterexamples, because "not this option" means nothing on an ordered
scale — an input that does not belong at one level scores at another.
Leave a clause out to say there is none. An empty one written out — examples=[] or
examples=() — says nothing, so it is refused as the mistake it is, along with a clause given
as one string rather than a list of them (examples="refund" would otherwise be six one-letter
examples), a blank entry, a repeat within one clause, a newline or carriage return inside an
entry, a counterexample on a level, a fallback(…) given to choose_among, score_levels or
.criteria(…), and the two contradictions above. Each is a ConfigError where the rubric is
written.
Entries go on one line each, so a newline inside one would read as a clause you never wrote.
"; " inside an entry is fine — "card declined; retry failed" is ordinary prose, and it
changes how many examples a reader sees rather than which clause they are in. The rubric text
itself may still contain newlines; only the entries are restricted.
Attaching examples to a blank rubric is refused too, because they describe something that is not there. A blank rubric on its own is not: it means what it has always meant, and adding examples to the language does not make an old declaration an error.
The state is anything JSON-shaped: a text literal, a dict, a list of them. A dataclass goes
through dataclasses.asdict, a pydantic model through .model_dump().
Policy
Thresholds decide how a probability or a confidence becomes an answer. They form a patch that
merges from the question, over the guide, over the package defaults. A Policy is that patch,
with every field optional; settling one against the defaults gives a Thresholds, which is
what guideme.policy.resolve takes and what every golden vector is written against.
| Layer | How to set | Wins over |
|---|---|---|
| question | .yes_above(p), .no_below(p) on nouls; .min_confidence(c) on choice and score; .with_policy(Policy(…)) on any |
guide |
| guide | Guide.builder().policy(…), or guide.with_policy(…) for a scoped copy |
defaults |
| defaults | yes_above 0.5, no_below 0.5, min_confidence 0.0 |
nothing |
The rules:
- Noul:
p >= yes_aboveis yes,p <= no_belowis no, strictly between is unsure. With the defaults there is no unsure band. - Choice and score:
confidence < min_confidenceis unsure. With the default there is never an unsure answer.
When an answer is unsure, resolution goes down a ladder: .otherwise(value) on the question,
then the enum's fallback(…) member, then UnsureError naming the question and the boundary
it missed. .detail() skips the ladder and hands you the reading to decide yourself.
Policy is a frozen dataclass, so a house policy is a module constant:
CAUTIOUS = Policy(yes_above=0.7, no_below=0.3)
guide = Guide.builder().api_key(key).policy(CAUTIOUS).build()
strict = guide.with_policy(Policy(min_confidence=0.8))
reading = guide.ask(noul("Is this about billing?").detail(), ticket)
match reading.verdict:
case "yes":
billing()
case "no":
other()
case "unsure":
review(reading.p)
picked = strict.ask(choose(Department, "Which team?").detail(), ticket)
strict shares the connection pool and inherits CAUTIOUS, with min_confidence patched over
it, so the choice above is unsure below 0.8 while the noul still reads against 0.7 / 0.3.
Several judgments, one request
A tuple of questions is a question. So is a list or a dict, and they nest. The answer has the same shape, from one request and one span. Each question keeps its own policy.
urgent, dept, mood, flags = guide.ask(
(
noul("Is this urgent?").yes_above(0.7).no_below(0.3).otherwise(False),
choose(Department, "Which team?").min_confidence(0.6),
score(Frustration, "How frustrated?").detail(),
{"spam": noul("Is it spam?"), "vip": noul("Is the sender a VIP?")},
),
ticket,
)
# pyright infers tuple[bool, Department, Scored[Frustration], dict[str, bool]]
if urgent or mood.value > 1.5 or flags["vip"]:
prioritise()
Question ids are q0..qN in encounter order, which for a dict is insertion order. They appear
on the wire, in errors and in events. A batch is atomic: one answer that cannot be resolved
fails the whole call, so put .otherwise(…) or .detail() on the questions that may come
back unsure.
Any nesting works at runtime. The forms your checker infers a type for are a single question, a list, a dict, a tuple of up to eight questions, and a tuple of up to seven followed by one list or dict, which is the shape above.
Sync and async
Guide and AsyncGuide have the same surface over the same pure core. The difference is the
await and the httpx client underneath.
async with AsyncGuide.from_env() as guide:
verdict: Verdict = await guide.ask(noul("Is this about billing?").detail(), ticket)
The synchronous version is the same two lines with Guide, with and no await.
Guide.builder() and AsyncGuide.builder() return the same GuideBuilder; .build() gives
the synchronous guide and .build_async() the asynchronous one. Leaving the block closes the
guide, and guide.close() does the same thing by hand for a guide that outlives any block.
A guide holds a connection pool, so build one and share it: both kinds are safe to use from
several threads or several tasks at once, and one guide asking concurrently is what the pool
is for. Building one per request works but opens a pool per request, which is the cost the
pool exists to avoid. guide.with_policy(…) returns a second guide over the same pool, and
the two are counted, so closing either leaves the other able to ask and the pool closes when
the last of them does. Close each guide once.
Both guides also answer models(), which returns a tuple[ModelInfo, ...]: the models the
account may use, each with its name, description and release_date. It is one call to
GET /v1/models and gets no ask span of its own. It is retried on exactly the terms an ask
is, so a 429 while your process is starting up does not fail the start.
What a request cost
ask_with_receipt is ask with the response's own numbers kept. It takes the same shapes and
infers the same types; ask is this call followed by .answer.
receipt: Receipt[bool] = guide.ask_with_receipt(noul("Is this urgent?"), ticket)
if receipt.answer:
prioritise()
meter(model=receipt.model, tokens=receipt.usage.input_tokens)
Receipt is frozen and carries three things: answer, whatever ask would have returned;
model, the versioned id that actually answered, which is jev-1.13.0 and not jev-latest
even when an alias was asked for; and usage, a Usage with input_tokens and
output_tokens. Input tokens are what is billed. Log the model: thresholds are tuned against
one model's numbers, and the alias moves under you.
Configuration
Every setter on GuideBuilder returns the builder, and Guide.builder() starts one.
| Setter | Default | What it does |
|---|---|---|
api_key(ApiKey(…)) |
none; required | The key. from_env() reads it from TYPESAFE_API_KEY. |
base_url(…) |
https://api.typesafe.ai |
The API origin. It may not carry credentials. |
model(Model(…)) |
jev-latest |
The model or alias to ask. |
policy(Policy(…)) |
the defaults | The guide-wide policy patch; a question's own wins over it. |
max_retries(n) |
3 |
Resends per call, 0 to never resend. |
backoff(…) |
500 ms | Base of the exponential backoff. |
timeout(…) |
30 s | Per phase of one attempt. Read the paragraph below. |
transport(…) / async_transport(…) |
httpx's own |
Send through your httpx transport. |
record_state(True) |
off | Put the state JSON on the ask span. It is your users' data. |
events(…) |
"both" |
Whether an answer and a retry go to the span, a log record, or both. |
timeout is not a deadline for the attempt. httpx gives the whole budget to each phase
separately — connecting, writing, reading, and waiting for a pooled connection — so one attempt
that is slow in more than one phase takes longer than the timeout without breaching anything.
Worst case for a call is max_retries + 1 attempts of several phases each, plus the backoff
between them. The Rust SDK's reqwest deadline covers the attempt as a whole instead; the two
differ because their HTTP clients do, and docs/contract.md records it as a divergence rather
than leaving you to find it.
transport(…) and timeout(…) refuse each other, in whichever order you write them: a
timeout belongs to the transport that honours it, and httpx hands yours this budget as a
request extension it is free to ignore. A silent no-op would be worse than a ConfigError.
transport(…) is for build() and async_transport(…) for build_async(); using one with
the other build is a ConfigError too.
Testing your code
Pass a transport and your control flow is testable with no server, no port and no key:
import httpx
from guideme import ApiKey, Guide, noul
def answer(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"model": "jev-1.13.0",
"answers": {"q0": {"type": "noul", "noul": 0.95}},
"usage": {"input_tokens": 296, "output_tokens": 20},
},
)
def test_an_urgent_ticket_is_prioritised() -> None:
builder = Guide.builder().api_key(ApiKey("not-a-real-key"))
with builder.transport(httpx.MockTransport(answer)).build() as guide:
assert guide.ask(noul("Is this urgent?"), "payouts failing") is True
q0 is the first question in encounter order; a batch of three is answered with q0, q1
and q2. Raise from the handler instead of returning and you get the failure paths: an
httpx.ConnectError is resent inside the retry budget, and an httpx.ReadTimeout, an
httpx.ConnectTimeout or an httpx.RemoteProtocolError is not. For
AsyncGuide, hand the same httpx.MockTransport to async_transport(…) and build_async();
it is both kinds of transport at once.
Observability
guideme emits OpenTelemetry spans, span events and OTLP log records through
opentelemetry-api and installs nothing: no tracer provider, no logger provider, no exporter,
no logging handler. Install a provider and the data appears. The SDK and an exporter are not
dependencies of this package, so install them alongside it:
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc
The smallest provider that leaves the process:
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
One span named guideme.ask per request, shaped by the OpenTelemetry GenAI conventions:
gen_ai.request.model, gen_ai.response.model, gen_ai.usage.*, and on failure error.type
with an error status. Under it, one HTTP client span per attempt with
http.response.status_code, so a retry is visible as sibling spans, plus a guideme.retry
event when an attempt is resent. One guideme.answer event per question with the outcome,
the probability or confidence, the unsure verdict and the settled thresholds that produced it.
The state is never recorded unless you opt in with record_state(True). The API key never
appears anywhere.
Every answer and every retry is also an OTLP log record, at INFO and at WARN, carrying the
trace id and the span id of the span it came from, so a logs backend links one straight back to
the decision it explains. Install a LoggerProvider too and they arrive; install neither and
they cost nothing. events(...) on the builder picks which signal carries an event when you
export both; Choosing a signal in the observability document has the table, the default, and
what happens on an opentelemetry-api that has no logs API: asking for one is refused, and the
default falls back to the span event alone.
Because the shapes are standard, any OTLP backend reads them as is.
docs/observability.md
has the field tables and the environment variables that point the exporter anywhere.
examples/otlp runs
all of it against the live API with a collector that prints what arrives.
Errors
Every failure is a GuidemeError. .kind is the same string every other guideme SDK reports
and the value of error.type on the failed span.
| Class | .kind |
When |
|---|---|---|
AuthError |
auth |
401 |
InvalidError |
invalid |
422; .detail is the body |
RateLimitedError |
rate_limited |
429 after retries, or a retry-after too long to wait for; .retry_after carries it |
OverloadedError |
overloaded |
529 on the same terms; .retry_after carries it too |
TransportError |
transport |
connection, TLS, timeout |
UnexpectedStatusError |
unexpected_status |
anything the contract does not define |
ProtocolError |
protocol |
the response violates the contract: undecodable body, wrong answer kind, option or level not in the rubric, probability outside 0..1 |
UnsureError |
unsure |
the policy said unsure and nothing caught it |
ConfigError |
config |
raised where the mistake is written: bad thresholds, missing key, empty batch, unserialisable state, a duplicate rubric, a rubric outside 1..255 options or 2..10 levels, a bad events(...), a non-positive timeout, negative retries or backoff, a base_url carrying credentials, and so on |
Retries on 429 and 529 use exponential backoff with jitter, capped at 30 s, and honour an
integer retry-after. They apply to models() as much as to ask.
A failed connection is resent in the same budget: refused, reset, or a TLS handshake that did not complete. The request never reached a server, so nothing was judged and nothing is repeated. A disconnect part-way through a response is not resent — the request arrived, the API may have answered it, and asking again would buy the same judgment twice.
No timeout is resent, of any phase. A connect timeout included, although httpx names it
separately: Rust's SDK sets one deadline over the whole attempt and cannot tell a connect
timeout from a read timeout, so retrying one here would make the two SDKs disagree about the
same failure, and a retried timeout multiplies the wall time timeout(…) is there to bound.
Everything not resent raises TransportError on the first failure.
Lower layers
Everything above is re-exported from the guideme package, and guideme.__all__ is that list.
Three modules are a second supported tier: guideme.api, guideme.api.client and
guideme.policy. You import those by their own path, they are not re-exported at the top
level, and they are under the same rule as the first tier — nothing in them is removed or
renamed without a major version and a CHANGELOG.md entry. Anything else in the package is
private, whatever its name looks like.
The two bullets after them are not a tier. They say where some of the names above are declared, which is worth knowing when two of them share a spelling.
guideme.apiis the exact wire mirror ofPOST /v1/systemoneandGET /v1/models, andguideme.api.__all__is what it offers: the request and response models, its ownUsage, and the four adapters between them and the core. ThatUsageis the pydantic model a response is parsed into, not theUsagea receipt carries — a receipt gets the frozen dataclass of the same name from the top level, copied out of this one, so that nothing pydantic sits on the surface you import fromguideme.guideme.api.clientholdsClientandAsyncClientfor callers who want to build requests themselves. They live one level down rather than onguideme.apibecause re-exporting them would makeapiandapi.clientimport each other, and the gate fails an import cycle.guideme.policy.resolve(answer, thresholds)is the pure decision function.spec/holds its JSON Schemas and 42 golden vectors, vendored from guideme-rust, which publishes the contract.docs/contract.mdsays what every guideme SDK must satisfy anddocs/design.mdrecords the design and its sharp edges.guideme.questionis where the question types are declared, and all of them are re-exported above:Questionis whatnoul,choose,choose_among,scoreandscore_levelsreturn, andNoulQuestion,ChoiceQuestion,ScoreQuestion,DetailedNoul,DetailedChoiceandDetailedScoreare the concrete ones. Inference covers most uses, so reach for them when you need to annotate a question you are storing or passing on: adictis invariant, so adict[str, NoulQuestion]is not adict[str, Question[bool]]and the annotation has to be written. Everything else inguideme.questionis private.guideme.apideclares its ownQuestion,NoulQuestion,ChoiceQuestionandScoreQuestion: same names, different classes. Those are the wire shapes the ones above become on the way out, and you only meet them if you build requests by hand. The import line says which you have.- The scalars are validated once and never re-checked:
ProbabilityandConfidencehold the unit-interval numbers onVerdict,RankedandScored,KeyandRankare what a runtime rubric answers with,Modelnames the model to ask, andApiKeycarries the key without ever printing it. The first four areNewTypebrands, so the guarantee is that only the wire mints them, not thatProbability(2.0)is rejected; it is not.
Other SDKs
Every guideme SDK is written from scratch in its own language and answers the same way,
because they all satisfy one contract: the wire schemas, the 42 golden policy vectors and the
interface shape that guideme-rust publishes
under spec/ and states in
docs/contract.md.
| Language | Package | Repository |
|---|---|---|
| Python | guideme |
this repository |
| Rust | guideme |
guideme-rust |
This repository vendors that spec/ and records the commit it came from in spec/SOURCE; a
CI job fails when the copy drifts from the Rust repository's main. The span, event and
attribute names are shared too, so one dashboard reads both SDKs.
Environment
| Variable | Meaning |
|---|---|
TYPESAFE_API_KEY |
required by Guide.from_env() and AsyncGuide.from_env() |
TYPESAFE_BASE_URL |
optional API origin override |
GUIDEME_MODEL |
optional model or alias; default jev-latest |
Development
Tooling is managed by mise, which pins uv and gitleaks; uv pins
everything else from pyproject.toml and uv.lock.
mise install # fetch the tools
mise run sync # install the locked environment
mise run check # fmt-check, gen-check, ruff, pyright, pylint, pytest, build, audit
mise run test # pytest alone
mise run hooks # point core.hooksPath at the tracked hooks in .githooks
The hooks are tracked, not generated: mise run hooks sets this repository's core.hooksPath
to .githooks and verifies it took effect.
AGENTS.md says what
each stage runs.
Library code is held to a strict checker set: pyright in strict mode, ruff with every rule
selected, and pylint with every check enabled. Tests are few and high-grade: property tests
for the policy laws, a real local HTTP server for the wire and retry contract, structural
tracing assertions, pyright files that must fail, and a drift guard that re-resolves every
golden vector.
From a source distribution rather than a clone, mise.toml and uv.lock are not present, so
the suite runs with uv run --group dev pytest.
Two opt-in tests hit the real API and are deselected by default:
TYPESAFE_API_KEY=… uv run --locked pytest -m live
Contributor rules live in
AGENTS.md. Report a
vulnerability privately, as
SECURITY.md
describes, never in a public issue.
License
MIT or Apache-2.0, at your option.
Release files for guideme 0.2.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 | |
|---|---|---|---|
| guideme-0.2.0.tar.gz | 159.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| guideme-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 227.5 kB
Release files / guideme-0.2.0.tar.gz
| Download URL | guideme-0.2.0.tar.gz |
|---|---|
| Size | 159.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b40703bb67ac85b5a1fda604d6cda4d2093e3348a9270ac4cd219379fc5b6525
|
|
BLAKE2b-256 checksum How to use checksums |
de44b0713ef31e42be0912c319cad87ec0516c6cd45693ae9cf2c1196b20f80f
|
| 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 Sep 22, 2026.
Transparency logRelease files / guideme-0.2.0-py3-none-any.whl
| Download URL | guideme-0.2.0-py3-none-any.whl |
|---|---|
| Size | 67.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8566a7dbc8ea82223f3716c66c4e247e3620c73ecaf57fea4ad9111d4159d1e2
|
|
BLAKE2b-256 checksum How to use checksums |
368a0d978d3841d987b45362d969d16ca3605b7b9c948c65d776254794d43892
|
| 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 Sep 22, 2026.
Transparency log