ifdd-be-core
GitHub: ifdd-de/be-core — the shared runtime used by services generated from
the ifdd-de/be template.
Shared runtime for IFDD JSON-LD micro services. This package holds everything that is identical across generated service instances: the bitemporal data model and its queries, JSON-LD processing, OIDC authentication, the generic routers and the FastAPI application factory.
It contains no template placeholders and is developed, tested and released like any other Python library.
Usage from a generated instance
from ifdd.be.core import create_app
from app.settings import Settings
app = create_app(Settings())
Configuration
All configuration is read from the environment (and from .env). The instance
may subclass ifdd.be.core.CoreSettings to add its own fields.
Two settings replace what used to be hard-coded in the template:
| Setting | Purpose |
|---|---|
IFDD_ASPECTS |
JSON object mapping REST path segment to aspect name |
IFDD_GRAPH_PREDICATES |
JSON object mapping predicate to traversal depth |
Context access (see Contexts and access):
| Setting | Purpose |
|---|---|
IFDD_SCHEMA_URL |
contexts are accepted only from below this URL |
IFDD_CONTEXT_ROOT |
context pattern of the development user (IFDD_BE_AUTH_ENABLED=false), default ifdd.de |
Example:
IFDD_ASPECTS={"products":"product","functions":"function"}
Version and title:
| Setting | Purpose |
|---|---|
IFDD_BE_VERSION |
version of the instance, shown by /version, /prot_version and the OpenAPI document; default: the version of this package |
API_TITLE, API_SUMMARY |
title and summary of the OpenAPI document |
ifdd.be.core.__version__ is read from the package metadata, so it always
equals the version in pyproject.toml. API_VERSION no longer exists; a
value left in an old .env is ignored.
Token cache:
| Setting | Purpose |
|---|---|
IFDD_TOKEN_CACHE_SIZE |
number of checked tokens kept, default 1000 |
IFDD_TOKEN_CACHE_TTL |
seconds a checked token is kept, default 300 |
A cache hit does not check the token's expiry again, so a token that expires
while it is cached is still accepted until its entry ends, at most
IFDD_TOKEN_CACHE_TTL seconds. This is accepted; a smaller value shortens the
window at the cost of more signature checks.
API documentation
The backend serves its documentation itself:
| Path | Content |
|---|---|
/docs |
Swagger UI |
/redoc |
ReDoc |
/openapi.json |
the OpenAPI 3.1 document |
/docs/oauth2-redirect |
where the realm returns the browser after "Authorize" |
/docs/assets/… |
the static files of both pages |
The pages and the document are open: they describe the API and give no
access to data. Every data endpoint still needs an access token and its
roles. The document explains the concepts (contexts, access, time, cache
metadata), describes every endpoint with the role it needs and the errors it
answers, and carries examples from the example domains grid and ops.
FastAPI's own pages are switched off: they start Swagger UI with an inline
script and load it from a CDN, which the proxy's Content-Security-Policy
forbids (the page stayed empty). The pages of ifdd.be.core.docs_ui contain
no inline script or style; Swagger UI is started by
docs_assets/ifdd/swagger-init.js, which reads the values of the instance
from data-* attributes. A CSP for the backend host needs:
default-src 'self'; frame-ancestors 'none'; img-src 'self' data:;
style-src 'self' 'unsafe-inline'; script-src 'self';
connect-src 'self' <realm origin>; worker-src 'self' blob:
connect-src names the origin of the realm (for example https://id):
"Authorize" fetches the token there. worker-src … blob: is for the search
of ReDoc, which runs in a worker. style-src 'unsafe-inline' is for ReDoc,
which inserts its styles at run time (Swagger UI works without it). ReDoc
also tries to load its
logo from cdn.redoc.ly; the policy blocks it, which only leaves the logo
out.
"Authorize" in Swagger UI signs in at the realm with the authorization
code flow and PKCE, as the public client IFDD_OIDC_JWKS_AUDIENCE, which must
allow the redirect URI <backend URL>/docs/oauth2-redirect exactly. Swagger UI
keeps the token in the browser's localStorage of the backend host
(persistAuthorization), so a reload keeps the login until the token
expires. Alternatively, paste an access token obtained elsewhere (for
example with client credentials) under BearerToken. With
IFDD_BE_AUTH_ENABLED=false there is no login; every request acts as the
development user.
Meta endpoints:
| Endpoint | Token | Answer |
|---|---|---|
/version |
none | {"version": IFDD_BE_VERSION}; for health checks and tests through the proxy |
/prot_version |
role IFDD_ROLE_PUBLIC |
{"version": "<IFDD_BE_VERSION>_prot"}; ifdd-ui shows it on its dashboard, keep it stable |
/whoami |
any valid token | preferred_username, client_roles (without a dot) and context_patterns (with a dot, lower-case), both sorted: what the backend reads from the token |
Third-party files
src/ifdd/be/core/docs_assets/ holds unchanged copies of these npm
packages; docs_assets/assets.json names version, tarball, npm integrity and
the SHA-256 of every file, and tests/test_docs_assets.py checks them.
| Package | Version | License | Files |
|---|---|---|---|
swagger-ui-dist |
5.33.0 | Apache-2.0 (with NOTICE) |
swagger-ui-bundle.js, swagger-ui.css, oauth2-redirect.js |
redoc |
2.5.4 | MIT | bundles/redoc.standalone.js |
The license texts (LICENSE, NOTICE, and the *.LICENSE.txt of the
bundles, which list the bundled dependencies) are shipped next to the files
and in the wheel. The bundles contain code under further licenses, mainly
MIT, also BSD-3-Clause (ieee754) and DOMPurify ("Apache license 2.0 and
Mozilla Public License 2.0", see swagger-ui-bundle.js.LICENSE.txt).
docs_assets/ifdd/ is part of this package (BSD-3-Clause).
To update, for each package:
npm pack swagger-ui-dist@<version> # or redoc@<version>
tar -xzf swagger-ui-dist-<version>.tgz # unpacks to package/
cp package/{swagger-ui-bundle.js,swagger-ui.css,oauth2-redirect.js,LICENSE,NOTICE,swagger-ui-bundle.js.LICENSE.txt} \
src/ifdd/be/core/docs_assets/swagger-ui/
# redoc: package/LICENSE, package/bundles/redoc.standalone.js{,.LICENSE.txt} -> docs_assets/redoc/
then enter version, tarball, integrity (npm view <package>@<version> dist.integrity) and the new SHA-256 values (sha256sum) in assets.json,
and check the pages in a browser behind the proxy. Prefer a version that has
been published for a while over one from the same week.
Documentation (docs/)
docs/en and docs/de are Sphinx sources (MyST Markdown) for a
documentation project that covers all IFDD services: an introduction, the
HTTP API (sphinxcontrib-openapi, from an openapi.json exported at build
time, see docs/en/api-http.md) and the Python API (sphinx.ext.autodoc
with sphinx.ext.napoleon, from the docstrings). Labels start with
ifdd-be-core-. The project that copies them needs those extensions and this
package installed. tests/check_docs.py checks that both languages have the
same files, labels and headings and that each builds without warnings
(configuration tests/docs/conf.py, versions tests/docs/requirements.txt).
Storage model
Every document is stored as versions of the pair (@id, @context), one
row each in the table nodes. A version has two half-open periods
[start, end):
| Period | Column | Set by | Meaning |
|---|---|---|---|
| valid time | valid_period |
the source, via c_validNotBefore / c_validNotAfter; default [now, infinity) |
when the version holds in the real world |
| system time | sys_period |
the backend | when the backend knew the version; open-ended = current |
Cache metadata (c_*)
Six terms with the prefix c_ are defined by every schema. They are never
stored inside the document (neither json nor expanded); their values are
columns of the row, and every read adds them again.
| Term | Origin | Column |
|---|---|---|
c_created, c_modified |
source, kept as sent | src_created, src_modified |
c_validNotBefore, c_validNotAfter |
source | bounds of valid_period |
c_cacheCreated |
backend: first time the document was stored | cache_created |
c_cacheUpdated |
backend: when a write last stored or confirmed this version | cache_updated |
Backend values sent by a client are discarded. Any other c_* term, or a
c_* term written as an IRI ("product:c_created"), is answered with 422.
Values without a time zone are read as UTC; all values are answered in UTC,
whatever time zone the database session uses. Lists add the terms as plain
terms; a single GET adds them per context in that context's namespace
(product:c_cacheUpdated next to location:c_cacheUpdated).
Writing
PUT /{segment}/{id} writes a document for the valid time it names. It
replaces only that part of the valid time, as SQL:2011 FOR PORTION OF:
stored: A [2020 ---------------------------------->
PUT B for [2027 --> 2028)
stored: A [2020 -> 2027) B [2027 -> 2028) A [2028 ->
- The versions overlapping the written period end in system time; the parts of them outside it are recorded again unchanged.
- If the content (expanded form without
c_*, plusc_createdandc_modified) is already known for the whole period, no version is written (200 No changes detected). Adjacent versions with the same content count together. The versions the write repeated are confirmed: theirc_cacheUpdatedbecomes the time of the write, in place, with or without versioning. Versions outside the written period are not touched. - A PUT never shortens a version: to end the validity at a time,
DELETEfrom that time on. - Without valid time, a write applies from now on and therefore also replaces versions planned for later.
PATCH takes the version valid at the start of the named valid time
(default: now), replaces the fields sent (top level) and writes the result
like a PUT, confirmation included; without such a version it answers 404. DELETE ends the document
in every context for the valid time in its optional body
(c_validNotBefore, c_validNotAfter; default: from now on), and only if
the client may write every one of them (403 otherwise, nothing is ended in
part). DELETE …?context=<context URL> ends that one context only, e.g. a
domain source withdrawing its values.
Every write checks that the path of the @id is the request path (the host
is not compared) and that aspect belongs to the first path segment
(IFDD_ASPECTS); otherwise 422. The context must qualify (see
Contexts and access); otherwise 422, or 403 for a
domain context without a matching pattern.
Writes of one document are serialised with a transaction-level advisory lock
and use the database clock, so all workers agree on "now". An exclusion
constraint (btree_gist) refuses two versions of a document that overlap in
both times; should it ever fire, the answer is 409.
Reading
All read endpoints (/{segment}, /{segment}/{id}, /nodes/, /types,
/scopes, /graph_data) take one optional instant at (ISO 8601, default
now). They return what the backend knows now and is valid at that instant,
so a document appears at most once per context. Planned versions are seen
with an at in the future. Queries of the system time (history) are not
offered yet.
Every read sees the base and aspect contexts and the domain contexts the
client has a pattern for. Lists, /types and /scopes leave the others out
(X-Total-Count counts what is visible); a list asked for a foreign domain
context with context= answers 403. A single document leaves out foreign
domain contexts and answers 403 only if none of its contexts is visible.
/graph_data follows base and aspect contexts only and labels nodes with
their objName.
The single document GET /{segment}/{id} merges all visible contexts. The
prefix of each context is its name, and @context maps the names to their
namespaces, so every JSON-LD processor expands the answer again:
{
"@context": {"product": "https://type/v1/product/",
"breaker.grid.example.ifdd.de": "https://type/v1/breaker.grid.example.ifdd.de/"},
"@id": "https://rest/products/7f5d",
"product:objName": "Q1",
"breaker.grid.example.ifdd.de:serialNumber": "SN-1"
}
Lists sort by sort (default c_cacheUpdated, order ASC). Allowed keys
are the six c_* terms and objName, objType, objScope; each has an
index, so a sorted page stops after the page. Any other key is answered with
422 (the OpenAPI schema lists the keys).
Versioning switch
IFDD_VERSIONING |
A write replaces a version by ... | Table holds |
|---|---|---|
true (default) |
ending it in system time | current versions and their history |
false |
deleting it | current versions only |
Valid-time periods are kept either way.
Read performance
Reads only touch current rows, so every read index is partial on
upper_inf(sys_period): history does not make them larger. Indexes cover the
single document by path, @id lookups, the aspect at an instant (GiST over
context and valid time), objType/objScope filters (GIN, @>), the
full-text search (pg_trgm) and every sort key (the c_* columns and
expression indexes on objName, objType, objScope). Lists do not
load the expanded form. A page and its total are two queries: with a window
count in the page query, PostgreSQL 18 read and sorted every match before the
LIMIT, about five times slower for 20,000 documents. A page that is not full
needs no count at all.
JSON-LD contexts
The @context URLs of the documents are fetched over HTTP, typically from the
schema service through the proxy. Each process keeps them in memory for
JSONLD_CACHE_EXPIRE_AFTER seconds (default 120); JSONLD_LOADER_TIMEOUT
(default 10 s) limits each request. An HTTP error status fails the fetch
instead of being parsed as JSON.
A request whose JSON-LD processing fails is answered with
- 502 if a document or context could not be fetched; the detail names the
URL and the cause, e.g.
429 Client Error: Too Many Requests; - 422 for every other JSON-LD error.
Contexts and access
Every row has one @context URL. Its last path segment without .jsonld is
the context name; the version in the path does not count. Contexts are
accepted only from below IFDD_SCHEMA_URL, and there are three kinds:
| Kind | Names | Read | Write |
|---|---|---|---|
| base | base |
node read role | node write role |
| aspect | the values of IFDD_ASPECTS |
node read role | node write role |
| domain | dotted lower-case names, e.g. sub1.dom1.ifdd.de |
node read role and a matching pattern | node write role and a matching pattern |
Any other name is refused with 422, so a typo never makes a context readable
for everyone. Every context must define the six c_* terms in one
namespace that ends with / or # (JSON-LD 1.1 uses a term as a prefix
only then); otherwise writes answer 422. The backend finds that namespace by
expanding the c_* terms with the context itself and assumes nothing about
prefixes.
A context pattern is a client role of the backend client
(resource_access.<audience>.roles) whose name contains a dot. It matches
the domain context of the same name and every one below it, at label
boundaries: dom1.ifdd.de matches sub1.dom1.ifdd.de, not
xdom1.ifdd.de. Read and write are not given per pattern: the node roles
apply to every context a client may use. Without a pattern a client sees no
domain context.
| Client pattern | sub1.dom1.ifdd.de |
sub2.dom1.ifdd.de |
sub1.dom2.ifdd.de |
|---|---|---|---|
ifdd.de |
yes | yes | yes |
dom1.ifdd.de |
yes | yes | no |
sub1.dom2.ifdd.de |
no | no | yes |
The table keeps the name of a domain context in the column domain (NULL
for base and aspects); reads filter on it.
Maintenance commands
ifdd-be-create-tables # create the extensions, the nodes table and its indexes
ifdd-be-purge # end the current versions not written for 50 hours
ifdd-be-create-tables needs PostgreSQL 13 or later (the extensions
btree_gist and pg_trgm are trusted there, so the database owner may
create them). It creates what is missing and never alters a table; there are
no migrations. A nodes table of 26.10.2 or earlier, or one without the
domain column, is refused with a message: drop it and load the data again.
ifdd-be-purge ends stale versions in system time (with versioning) or
deletes them (without). An unchanged PUT or PATCH renews c_cacheUpdated,
so a document its source keeps sending is not purged.
Tests
pip install -e ".[dev]"
pytest # unit tests only
IFDD_TEST_DB_URL=postgresql+psycopg://ifdd:ifdd@localhost:5432/ifdd_test pytest
With IFDD_TEST_DB_URL the tests in tests/db run against that database;
they drop and create the nodes table. They serve their own JSON-LD contexts
from tests/db/contexts on a local port. tests/test_temporal.py describes
the write semantics as timelines without a database.
benchmarks/read_paths.py (optional, not part of the suite) fills the test
database and reports how a read splits into database and JSON-LD time.
CI
| Workflow | Trigger | Does |
|---|---|---|
test.yml |
push, PR | pytest on Python 3.11-3.14 against a postgres:18 service container; checks and builds docs/en and docs/de; builds the distribution and verifies that ifdd and ifdd.be stay implicit namespace packages and that the wheel carries the documentation assets |
release.yml |
tag v* |
checks that the tag matches the project version, then publishes to PyPI |
Publishing uses PyPI Trusted Publishing, so no API token is stored here. The
publisher has to be configured once on PyPI for this repository, workflow
release.yml and environment pypi.
Honesty notes
Documentation docs/ (2026-10-13): tests/check_docs.py passes (Sphinx
9.1.0, myst-parser 5.1.0, sphinxcontrib-mermaid 2.1.1, sphinxcontrib-openapi
0.10.0, Python 3.13) with the OpenAPI document exported as in the CI job;
both languages build with -W. The HTTP page shows every endpoint with
examples; sphinxcontrib-openapi stopped with a KeyError when the option
:request: was added (a request body given as a $ref), so the page does
without it. autodoc needs the docstring tables to be valid reST: the table
in docs_ui was not and is fixed. The docs job passes actionlint 1.7.12;
it has not run on GitHub.
API documentation (2026-10-13): tested on Python 3.14.6 and 3.11.17 with
FastAPI 0.142.4 against PostgreSQL 18.4 (zonky binaries, see above): 226
tests each. Counter-check: the 41 new or changed tests in test_docs.py,
test_docs_assets.py, test_version.py, test_app.py and test_auth.py
were run against the previous state; 33 fail there (among them the inline
script on /docs, /docs/oauth2-redirect and /redoc). Of the other
eight, five guard unchanged behaviour (/version, /prot_version, the
configured aspect routes) and three pass there only because the old pages
reference no local asset. The wheel check fails for a wheel built from the previous state
(no docs_assets). End to end in headless Chromium (Playwright 1.56.0,
Chromium build 1194) behind Traefik v3.7.13 with the rendered proxy
configuration and the Keycloak 26.8.0 distribution with the rendered realm
(no Docker): Swagger UI renders under the new CSP without violations;
"Authorize" opens the realm's login, returns through /docs/oauth2-redirect
and fetches the token; "Try it out" sends it (/whoami, /prot_version,
/nodes/, /products answer 200); after a reload the login is still there;
ReDoc renders and its search works. Counter-checks in the same setup: the
previous package under the previous CSP refuses the inline script of /docs
(the error seen in a real instance), this package under the previous CSP
cannot fetch the token (connect-src), without worker-src … blob: ReDoc's
search finds nothing, and without style-src 'unsafe-inline' ReDoc's styles
are refused (Swagger UI keeps working). Not checked: the container image,
other browsers than Chromium, and the CI workflow itself.
Known and accepted (2026-10-14): a validated token is kept in the token
cache for IFDD_TOKEN_CACHE_TTL seconds (default 300) by its text, and a
cache hit does not check the expiry again; a token that expires meanwhile is
accepted until its cache entry ends (see Configuration).
Read in auth/oidc.py, not tested.
JSON-LD loader and errors (2026-10-05): before, the context cache never took
effect (PyLD 3.3 creates its requests session when the loader is created, and
the cache was installed globally afterwards), so every document fetched its
contexts again; through the proxy this ran into its rate limit (429), and the
error handler failed on err.cause, which PyLD 3.3 does not have (500
instead of the intended answer). Checked: tests/test_jsonld.py (fails
against the previous code), and with PostgreSQL 16, the Keycloak 26.8.0
distribution and the ct integration tests: 224 documents loaded with 5 context
fetches instead of 448. Not checked: the path through the proxy after the
change, and the image.
Dependency update (2026-10-05): fastapi 0.142.2, SQLAlchemy 2.1.3, cachetools 7.2.1, PyJWT 2.15.1; the other pins were already current. Resolved and tested on Python 3.14 (the backend image now uses python:3.14-slim) as well as 3.11-3.13 in CI: the unit tests pass, and the ct integration tests (real Keycloak 26.8.0, PostgreSQL 16) pass with the updated packages. The FastAPI route test known to fail (see the be template) still fails; the version is unchanged (26.10.2).
Bitemporal storage (2026-10-06): tested on Python 3.14 and 3.11 against
PostgreSQL 18.6, built from the REL_18_STABLE sources with btree_gist and
pg_trgm (no Docker available): 138 tests, of which 73 need the database.
Counter-checks: ten deliberate faults (no write lock, no idempotency check,
c_* left in the document, lost remainders, reads ignoring system time, a
versioning switch without effect, no path check, unescaped search wildcards,
c_cacheCreated overwritten, constraint on one period only) each make tests
fail; without the write lock the exclusion constraint stops the second
writer. The planner tests document what PostgreSQL 18 picks for 20,000
current documents; other versions or statistics may choose differently.
Measured with benchmarks/read_paths.py (2,000 documents, median): GET of a
single document 1.0 ms database plus 0.5 ms PyLD compaction; a list page of
100 documents 5.6 ms database plus 0.7 ms presentation. Not checked: the CI
workflow itself, the postgres:18 image (its contrib extensions are assumed
to be present), the ui and ct against the new API (next round), and the be template
(not run in this round).
Confirmation, sort keys, UTC (2026-10-06, second round): tested on Python
3.14 against PostgreSQL 18.4 (zonky binaries from the npm package
@embedded-postgres/linux-x64 18.4.0-beta.17; the sandbox could no longer
reach the PostgreSQL sources): 149 tests. The 15 new or changed tests fail
against the previous state. The time zone fault was found by that cluster: it
runs in Europe/Berlin, and nine tests of the first round failed because the
API answered in the time zone of the database session.
Context access (2026-10-12): tested on Python 3.14 against PostgreSQL 18.4
(same zonky binaries): 189 tests. Counter-check: the new tests were run
against the previous state; the unit tests (tests/test_contexts.py) and 16
of 17 new API tests fail there, the remaining one guards unchanged behaviour
(aspect lists show the aspect context only), and the changed expectation of
the single document's @context fails there too. The planner tests now carry
the access filter and a tenth of the rows in a domain context; PostgreSQL 18
still picks the same partial indexes. Dotted client role and group names were
checked against the Keycloak 26.8.0 distribution: a role
grid.example.ifdd.de reached resource_access.<client>.roles of a
user's access token through a group. That the answer of the single document
expands again was checked with PyLD 3.3.0 in the tests and, by hand, with
jsonld.js 8.3.3. Measured with benchmarks/read_paths.py (1,000 documents,
median, previous state against this one in the same sandbox): the JSON-LD
part of a single GET first grew from 0.6 ms to 1.7 ms, because the namespace
of each context was found by expansion on every read; it is now kept per
context URL for JSONLD_CACHE_EXPIRE_AFTER seconds, back to 0.6 ms. A list
page of 100 took 6.5 ms in the database before and 6.9 to 8.3 ms after, within
the spread of repeated runs. Not checked: the CI workflow, the be template
image, the ui and ct against this state (later steps of the same round).
Metadata
Release files for ifdd-be-core 26.10.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 | |
|---|---|---|---|
| ifdd_be_core-26.10.2.tar.gz | 873.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ifdd_be_core-26.10.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.7 MB
Release files / ifdd_be_core-26.10.2.tar.gz
| Download URL | ifdd_be_core-26.10.2.tar.gz |
|---|---|
| Size | 873.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e6d0ce5fe735f6e77e445be3e4efa81c8061f057b04bc5714d3494f2572492aa
|
|
BLAKE2b-256 checksum How to use checksums |
07e171f878e6e809301108ed366f332a75e50f04700589da4249b29b16a1b95c
|
| 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 15, 2026.
Transparency logRelease files / ifdd_be_core-26.10.2-py3-none-any.whl
| Download URL | ifdd_be_core-26.10.2-py3-none-any.whl |
|---|---|
| Size | 870.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
180cab93e0096235d473f8699e5db4b5b5563ef8ecc4f69bd2753b63bd8f0eac
|
|
BLAKE2b-256 checksum How to use checksums |
8f140b004248c5603a48a8fc48090e6ad8e62182f59d03f86b6c2f532a7a2d0b
|
| 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 15, 2026.
Transparency log