lume-pva-apg
lume-pva-apg serves EPICS PVs based on a LUMEModel subclass and its supported
variables. It is a distribution of lume-pva
maintained as a staging area for changes intended to land upstream; see
Relationship to lume-pva below.
Features:
- Model outputs served over PVAccess (PVA) and/or ChannelAccess (CA).
- PVA PVs support a subset of the EPICS NormativeTypes metadata.
- CA PVs support a subset of the standard EPICS CA meta, such as alarms, display limits and control limits.
- Model inputs served as writable PVs over PVA and/or CA.
Installation
The core distribution is pure-Python and carries no EPICS transport, so it installs from wheels on every platform without triggering a native build. Serving requires at least one transport extra:
pip install 'lume-pva-apg[ca,pva]' # both transports
pip install 'lume-pva-apg[pva]' # PVAccess only
| Extra | Pulls in | Needed for |
|---|---|---|
pva |
p4p |
PVAccess serving, and the NormativeTypes value layer |
ca |
pcaspy>=0.8.1 |
Channel Access serving |
torch |
torch, lume-torch |
the TorchScalarVariable / TorchNDVariable types |
The pva extra is required for any serving at all: the value layer in
lume_pva_apg.variables is expressed in p4p types and is used by the CA path
too. A CA-only install is therefore not currently possible. Importing a module
whose transport is absent raises an ImportError naming the extra to install.
pcaspy>=0.8.1 is a floor rather than an exact pin. On 0.8.0 the asynchronous
write path passes a casClientInfo where a casCtx is required, which raises
inside the SWIG director and terminates the server process; a client issuing a
put with callback is told the write succeeded while the value never lands.
0.8.1 fixes it, and the fix depends on a binding 0.8.0 does not compile, so it
cannot be worked around from Python. 0.8.1 publishes no linux/aarch64 wheel, so
the bound is left open: arm64 consumers must stay free to build from sdist or
to take a later release that restores the wheel.
Basic Usage
from lume_pva_apg.runner import Runner
myModel = MyLUMEModel()
r = Runner(model=myModel)
r.run()
Operational Description
Model Outputs
Model outputs can be served over PVA and/or CA. If the output is solely an output, it will be configured as a read-only PV.
Model Inputs
Model inputs are served by the Runner class and can be interacted with using pvput, caput or other
CA/PVA tools on the command line.
Like model outputs, inputs can be served over CA or PVA, depending on the Runner configuration.
The Write Path
A write to an input PV is queued, applied to the model on the next simulation
cycle, and only then acknowledged: a caput -c or a PVA put with wait=True
stays blocked until the cycle that consumed it has finished. Three properties
of what happens in between are selectable, and each defaults to the behaviour
the runner has always had.
echo_unconfirmed_writes (default True) decides whether an input PV may
advertise a value the model has not accepted. By default the requested value is
published immediately on PVA, and recorded on CA whether or not the cycle
succeeded, so a client reading back an input after a failed cycle can see a
value the model never took. Set it to False and the echo waits for the model
to accept the write, and is withheld entirely if the model refuses it, leaving
the PV on the last value that actually landed.
alarm_on_refused_write (default False) raises WRITE_ALARM/INVALID_ALARM
on the CA PV when the model refuses a write. Channel Access put-completion
carries no failure channel — it can only ever report success — so an alarm is
the only way to tell a CA client its write did not land. PVAccess reports the
failure through the put itself and needs no equivalent.
clamp_writes (default False) clamps a written value into the variable's
value_range before handing it to the model. LUMEModel.set does not enforce
value_range, so by default an out-of-range write reaches the model unchanged
and it is the model's job to reject it. The clamp is applied where the write
enters the server, so the value echoed back to the client is the same value the
model was given.
update_rate is the length in seconds of the window during which arriving
writes are batched into a single model.set(). Set it to zero and the window
is skipped: every queued write drives a model.set() of its own, so one
client's write is never merged into another's.
Control PVs
The runner exposes a control PV of its own:
{prefix}RESET: any write requestsmodel.reset()and publishes the reset state to output PVs.
The control PV is served over PVA, and is also available over CA when protocol includes "ca".
Set control_pvs to False and the runner serves the model's variables and
nothing else. Use it when the runner shares its prefix with another server, or
when the surrounding deployment offers reset through a channel of its own, so
the runner claims no name the model did not ask for.
prefix is passed to the constructor of the Runner class and defines a prefix to prepend to the start of PV names.
Extension Points
A consumer that needs to serve more than the model describes can subclass
Runner rather than fork it. Each seam below defaults to what the base class
has always done.
_extend_pvdb() returns additional pcaspy database entries. They are keyed by
base PV name, exactly as the runner's own are — prefix is applied by the
server, not by the caller. It is called once, after the model's variables and
the control PVs are in the database and before the server is created, and only
when protocol includes "ca". Merging refuses to shadow a name already
there, so a contributed PV can never silently replace one the model owns.
ca_driver_cls is the driver class instantiated to serve that database,
constructed with the runner as its only argument. The stock driver resolves a
reason through the model's variables and refuses anything it cannot find, so
a subclass contributing PVs of its own supplies a driver that knows how to
write them and delegates the rest to super(). Reads need no override: the
pcaspy default reads back through getParam.
_post_outputs(out_values, ts) is the run loop's publishing step, given the
values model.get returned and the timestamp to stamp them with. Overriding it
lets a subclass publish elsewhere, publish more, or publish nothing. Raising
from it fails the cycle exactly as raising inline did: the model is rolled back
to its cached state and every waiting put completes with the error.
class CoHostedRunner(Runner):
class Driver(Runner.CaDriver):
def write(self, reason, value):
if reason == "STATUS":
self.setParam(reason, value)
self.updatePV(reason)
return True
return super().write(reason, value)
ca_driver_cls = Driver
def _extend_pvdb(self):
return {"STATUS": {"type": "int", "value": 0}}
def _post_outputs(self, out_values, ts):
self.ca_driver.setParam("STATUS", len(out_values))
super()._post_outputs(out_values, ts)
Configuration
Runner.generate_config() can be used to generate a dict describing the default configuration for the model.
You can either edit this on the fly, or serialize it and edit it by hand later.
print(Runner.generate_config(model=myModel))
An example configuration:
{
'prefix': 'MY_PV_PREFIX:',
'update_rate': 0.1, # Update period under which PVs will be batched together into one model.set(). Set to 0 to disable the window.
'protocol': ['ca', 'pva'], # Serve this as both CA and PVA (the default)
'echo_unconfirmed_writes': True, # Echo a written value before the model has accepted it
'alarm_on_refused_write': False, # Alarm the CA PV when the model refuses a write
'clamp_writes': False, # Clamp a written value into the variable's value_range
'control_pvs': True, # Serve the runner's own {prefix}RESET control PV
'variables': {
'input_a': {
'name': 'input_a',
'pv': 'input_a_pv',
'mode': 'rw', # 'rw' means we can read and write this PV
'precision': 3, # Optional, float scalars only: display precision (0..17), CA `prec` and PVA `display.precision`
'description': 'Input A' # Optional, scalar/str/bool/enum only: PVA `display.description`; defaults to the variable's own description (lume-base >= 0.6)
},
'output_b': {
'name': 'output_b',
'pv': 'output_b_pv',
'mode': 'ro' # 'ro' means the PV is read-only
}
}
}
Consumer contract
A deployment that configures the runner, supplies its model, or subclasses it relies on the names and rules below. They are stable across patch releases; anything not listed here is an implementation detail.
Configuration keys.
config["variables"][name]["precision"]: optional. Must be anint(not abool) in0..17, and is allowed only on a float scalar variable — aScalarVariablethat is not anIntVariable. Int, bool, string, enum, array and Torch variables reject it. A configured precision reaches CA asprecand PVA asdisplay.precision; a float without one keeps its previous display fields,display.formatincluded.config["variables"][name]["description"]: optional. Must be astr, and is allowed on scalar, int, string, bool and enum variables; array and Torch variables reject it. Without one, the variable's owndescriptionis used where the installedlume-basehas it (0.6 and later). It reaches PVA asdisplay.description; an empty string means no description. The model's variable is never modified.config["tick_interval_s"]: optional. Absent orNonemeans no periodic passes. Otherwise it must be anintorfloat(not abool), finite and greater than zero, in seconds.
Every violation raises ValueError from the Runner constructor, naming the
variable and the key. A variable's value_range always comes from the model's
Variable: a value_range key in the config entry is ignored.
The output_severity hook. A model may define
def output_severity(self, names: list[str]) -> dict[str, dict[str, str]]:
return {"some_output": {"condition": "udf"}}
It is called once per pass, on the run-loop thread, before anything is
published, with the names of the outputs that pass publishes. Each name it
reports "udf" is published as (INVALID, UDF) on both transports until a later
pass stops reporting it. Only requested names are evaluated: keys outside
names are ignored, so a model may report on outputs the pass does not
publish. For a requested name, anything other than {"condition": "udf"} —
an unknown condition, a non-dict entry, a reply that is not a dict — raises
ValueError, nothing is published, and the pass fails as any failed pass does
(the model rolls back and every waiting put completes with the error).
Reporting a name undefined does not excuse its value. model.get validates
every output against its variable before publishing starts, so an undefined
output must still return a type-valid value: a float may be NaN, and an enum's
value must be one of its options. An invalid value fails the pass.
Alarm-aware _generate_value. _generate_value(name, value, ts) applies
the UDF alarm to any name currently undefined, so a subclass publishing
through it — its own echo, an extra post — carries the model's latest verdict
without further work. _pack_value builds the same Value without that
overlay. One race remains: a _generate_value called off the run-loop thread
for a name, between the moment a pass records its new verdict and the moment
that pass publishes the name, may carry the previous verdict. The next pass
that publishes the name corrects it, which with tick_interval_s set happens
within one tick.
Periodic-pass signals. Every queued item carries item["tick"]: False
for a write or reset, True for a periodic pass. Read it as
item.get("tick", False), since items a subclass builds may lack the key.
During a pass, self._pass_is_tick is true only when every item of the batch
was a tick and the batch carries no input values and no reset; it is set
before the pass's jobs run, so _cycle_output_names and _post_outputs can
read it. A subclass running with update_rate of zero may equally treat
"input values present" as the mark of a write pass.
Intended wire changes. Three differences a client can observe are deliberate:
- An
IntVariableis served on PVA as an NTScalar"i"(int32, the width of a CAlong) rather than a double, and reads back as an integer. - String and bool NTScalars and NTEnums carry a
displayblock. - When building a pass's outputs fails, nothing from that pass is published on either transport. Previously a CA conversion error could leave part of the pass published.
Relationship to lume-pva
This distribution exists so that changes can be exercised against a real deployment before they are proposed upstream. It is a staging area, not a long-lived divergence: each entry below is either an intended upstream pull request or a packaging consequence of publishing under a second name, and the distribution retires once the upstream ones are merged and released.
Intended upstream pull requests
| Change | Rationale |
|---|---|
Declare p4p as a first-class dependency behind a pva extra |
p4p is imported unconditionally by variables.py and runner.py but was declared only under the dev extra, so a non-dev install of the project could not import its own runner. |
Split the EPICS transports into ca and pva extras, leaving a pure-Python core |
Installing the project currently forces a pcaspy build on any platform without a matching wheel, even for consumers that only generate configuration or only speak one protocol. |
Raise the pcaspy floor to >=0.8.1 |
On 0.8.0 an asynchronous CA write raises inside the SWIG director and terminates the server process, while the client is told the put succeeded. The fix needs a binding 0.8.0 does not compile, so a version floor is the only remedy. |
Replace the lume-torch VCS reference with the released lume-torch>=3.0.0 |
PyPI rejects direct-URL dependencies in any extra, so the torch extra as written makes the project unpublishable. |
Adopt the PEP 639 SPDX licence expression and drop the deprecated [project.license] table |
Published metadata is immutable, so the deprecated form has to go before a release rather than after one. |
Drop pydantic and pyyaml from the dependency list |
Neither is imported anywhere in the package. |
Move CI from conda to uv, pin the interpreter per matrix leg, and assert it at runtime |
A matrix that silently degrades to one interpreter tests the same leg repeatedly and stays green. |
| Run the test suite from outside the checkout, against the installed distribution | Run from the source tree, the tests import the adjacent package regardless of what the packaging metadata ships, so a packaging mistake cannot fail the build. |
| Add a job asserting the core install is pure-Python on Linux and macOS | The pure-Python core is a property consumers depend on to provision a host without an EPICS toolchain. Stated only in a comment it decays; the job checks the built wheel is py3-none-any and that no transport reaches the environment. |
| Add a job rejecting direct-URL dependencies in the metadata | PyPI refuses a distribution carrying one, in any extra. Without the gate this is discovered by the release that fails, after the tag is spent. |
| Flush the input echo before signalling put-completion | pcaspy publishes a value to monitoring clients only when updatePV runs, so a client released by put-completion was told its write had finished while its own monitor still carried the value that write replaced. |
Withhold the input echo when the model refuses a write (echo_unconfirmed_writes) |
A failed simulation cycle leaves the model on its previous value, but the requested value is recorded on the input PV regardless, so a read-back reports a value the model never took. Put-completion cannot report the failure, so withholding the echo is the only signal available. |
Alarm a refused write (alarm_on_refused_write) |
Channel Access put-completion ends an asynchronous write with S_casApp_success unconditionally; there is no failure channel. An alarm is the only way to tell a CA client its write did not land. |
Skip the batching window when update_rate is zero |
The window was skipped only because its deadline had already elapsed by the time it was tested, making per-write isolation an accident of the clock rather than something the documented update_rate of zero guarantees. |
Clamp a write into the variable's value_range (clamp_writes) |
LUMEModel.set does not enforce value_range, so an out-of-range write reaches the model unchallenged and, on failure, costs a whole simulation cycle. Applied at the point the write enters the server, so the echo matches what the model was given. |
Let a subclass contribute PVs to the served CA database (_extend_pvdb) |
A consumer serving anything the model does not describe — a status channel, a second model's outputs — currently has to reach into Runner.pvdb between construction and the server being created, which is not a moment the constructor offers. A hook called before createPV gives it one, and refusing to shadow an existing name keeps the model's own PVs the model's. |
Make the CA driver class a class attribute (ca_driver_cls) |
The driver was constructed as Runner.CaDriver(self), naming the class literally, so serving a reason the stock driver does not know required replacing the constructor rather than the driver. |
Factor the run loop's output publishing into an overridable method (_post_outputs) |
The publishing step was inline in _run, so a subclass that wanted to publish anything alongside the model's outputs had to reimplement the whole loop — batching window, cached-state rollback, put-completion signalling and all. |
Make the runner's control PVs optional (control_pvs) |
{prefix}RESET is claimed unconditionally, which is a name collision waiting to happen for a runner sharing its prefix with another server, and dead weight where the deployment offers reset through a channel of its own. |
Publish value_range as CA display limits only, not as alarm thresholds |
pcaspy compares lolo/hihi with <=/>=, so thresholds taken from the variable's own range put a value driven to either end of its legal span into MAJOR alarm — where the PVA path, which compares strictly, reports no alarm for the same value. This does not preserve the old behaviour for a value outside the range, which no longer alarms on CA at all; pcaspy's inclusive comparison cannot express a threshold that alarms outside the range without also alarming at it. |
Add a CI leg that runs the suite without the torch extra |
Every other leg installs everything, so a module-scope import of an optional dependency passes them all and then takes collection — the whole run, not one file — down for any consumer who installed less. The leg also asserts every shipped test module contributed a passing test, so a guard widened until it skips whole modules cannot stay green either. |
| Add a CI leg that installs the built sdist and runs the suite out of it | Every other leg installs from the checkout, where every file is present whether or not the packaging ships it, so a file left out of the sdist passes the whole suite and fails on the first consumer to install the release — after the version number is spent. |
| Guard the test suite's optional imports so a partial install skips rather than failing collection | The suite imported epics, torch and p4p at module scope, so anything short of the full dev-plus-torch set died during collection and ran nothing at all — including the tests that needed none of them. Guarding turns "not installed" into a reported skip of the one module that could not run. |
Report an absent optional dependency as a ModuleNotFoundError carrying name |
Absent and broken were distinguished in the message but not in the exception, so a caller reraising one — the suite's own import guards do — saw every failure as the broken case and could not decide whether to skip. |
| Add tests for what an install guarantees | The pure-Python core is a property of what the modules import, not only of what the wheel declares: a convenience re-export in __init__ would pull a transport into the core install while the metadata still called it pure. Asserted by importing in a fresh interpreter with the optional dependencies made unimportable, which also covers the two answers given for an unusable transport. |
| Cover the configurations the constructor rejects | generate_config is documented as something to edit before passing on, so the variables table reaches the constructor hand-written: a name the model does not have, an unknown mode, or a writable PV over a read-only variable each serve a PV that accepts writes and drops them. Nothing exercised any of the validation. |
| Cover the CA display limits for a variable with a non-default range | Every ca_pvspec case used a variable with no range and no unit, so a spec that dropped the limits, zeroed them, or swapped them was indistinguishable from a correct one. |
| Apply the PV name prefix exactly once on the Channel Access path | prefix was written into the pvdb keys and then applied again by SimpleServer.createPV, so a runner configured with PFX: served PFX:PFX:name. The driver names a PV by its pvdb key, so the same mistake left the cycle's output pass calling setParam with a name the database did not hold: every cycle raised KeyError and was logged as a failed simulation. Keying the database by base name leaves the prefix to the server, which is also what names a PV in every driver callback. Invisible at prefix="", which is what every existing test used. |
Serve an IntVariable as an int32 NTScalar on PVA |
IntVariable subclasses ScalarVariable, and create_type tested for the base class first, so every integer variable was served as an NTScalar double and packed as a float. A PVA client saw a floating-point PV for an integer quantity; testing IntVariable first gives it wire code i and packs the value as an int. |
Configure a variable's display precision and description |
A client had no way to learn how many digits a float is meaningful to, or what a PV is, other than out of band. Both are per-variable configuration keys validated at construction: precision (float scalars only) reaches CA as prec and PVA as display.precision, and description — or, without one, the variable's own — reaches PVA as display.description on every type with a display block. A float configured without a precision keeps the display fields it had before. |
Let a model mark outputs undefined for a cycle (output_severity) |
A model whose calculation failed for some outputs but not others had no way to say so: the stale or placeholder value was published with no alarm, indistinguishable from a real one. A model defining output_severity(names) returns {name: {"condition": "udf"}} for the outputs it could not compute, and each is published as (INVALID, UDF) on both transports until a later cycle reports it defined again. The verdict is validated before anything is posted, so a malformed one fails the cycle rather than publishing half of it, and a CA put on an undefined variable restates the UDF alarm after its echo, since setParam would otherwise clear it. Names reported undefined must still carry values of their declared type. |
Run a periodic model pass (tick_interval_s) |
A model that evolves on its own — a relaxation, a drifting source, anything driven by time rather than by writes — was evaluated only when a client wrote an input, so between writes its outputs stood still. With tick_interval_s set, a daemon thread queues a pass with no input values every that many seconds for as long as the run loop runs. At most one such pass is ever waiting, so a model slower than the interval runs back to back rather than building a backlog, and a tick merged into a batch of writes rides along with it. A subclass can tell a pure periodic pass from a write by _pass_is_tick. |
Raise the p4p floor to >=4.2.2 |
The pva extra accepted any p4p, but 4.2.2 is the only release on which the runner's use of form=True has been verified, and it is the version OSPREY deployments lock. An unbounded requirement lets a resolver pick an older release that was never tested against the runner. |
Add a CI leg that runs the suite on lume-base==0.5.0 |
lume-base is an unpinned core dependency, so every other leg tests whatever release is newest that day, and a use of API an older release lacks passes CI and fails for a consumer whose environment is locked. Pinning 0.5.0 in the leg rather than in the metadata proves the oldest supported release still works while leaving consumers free to move. |
Fork-local changes
| Change | Rationale |
|---|---|
Import package renamed to lume_pva_apg, distribution to lume-pva-apg |
Two distributions cannot share an import package; the rename is what makes the two installable side by side. Retired when the fork is. |
| CI job failing on a facility-specific reference in the package | The staging fork is edited alongside a facility deployment, which is exactly the condition under which a site-specific name gets committed by accident. The gate keeps every change here shaped as something upstream can take. |
Dropped the no-commit-to-branch pre-commit hook |
Changes land on main here before they are proposed upstream, so the hook blocks the fork's only workflow. |
Removed the pvua dependency and the remote-input mode it backed |
pvua is only available as a VCS reference, which makes the project unpublishable, and the remote mode is unused here. Dropped rather than reworked: upstream owns pvua and should keep the feature, so this is not offered as a pull request. |
Removing the remote-input mode also removed what depended on it: the remote
PV mode, the remote_model_mode config key, the remote_inputs argument to
generate_config, snapshot mode, and the {prefix}SNAPSHOT control PV, whose
only purpose was to trigger a pull from remote inputs.
Supported Variables
Supported variable types and their metadata fields.
ScalarVariable and IntVariable
Represented as NTScalar with a double or int value field (depending on variable type).
Supported metadata:
timestampdisplay.unitsScalarVariable.unit
control.limitLowScalarVariable.value_range[0]
control.limitHighScalarVariable.value_range[1]
alarm.severityandalarm.status(PVA only)- Set from the value's position relative to
value_range: a value strictly outside it is a MAJOR alarm, a value anywhere within it — including at either limit — is no alarm.
- Set from the value's position relative to
On CA, value_range is served as the display limits (lolim/hilim) and is
not served as an alarm threshold. pcaspy compares its lolo/hihi thresholds
inclusively, so a range published as an alarm limit puts a value driven to
either end of its own legal span into MAJOR alarm. A CA client therefore sees
no range-derived alarm at all, while a PVA client still sees one for a value
outside the range.
NDVariable
Represented as NTNDArray with data representation matching the numpy shape and dtype.
Supported metadata:
timestamp
TorchScalarVariable
Requires the torch extra (pip install 'lume-pva-apg[torch]').
Represented as NTScalar with a double value field.
Supported metadata:
timestamp
TorchNDVariable
Requires the torch extra (pip install 'lume-pva-apg[torch]').
Represented as NTNDArray with data representation matching the Tensor shape and dtype.
Supported metadata:
timestamp
BoolVariable
Represented as NTScalar with a bool value field.
Supported metadata:
timestamp
StrVariable
Represented as NTScalar with a str value field.
Supported metadata:
timestamp
Metadata
Release files for lume-pva-apg 0.1.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lume_pva_apg-0.1.5.tar.gz | 118.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lume_pva_apg-0.1.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 223.3 kB
Release files / lume_pva_apg-0.1.5.tar.gz
| Download URL | lume_pva_apg-0.1.5.tar.gz |
|---|---|
| Size | 118.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
341236db040c4aead61373943dd3105ff0174771af6d1bb53c26eb469f5d345b
|
|
BLAKE2b-256 checksum How to use checksums |
ca46a7203e7c9cceb47a01157c3dcf5aea13e5fa01acd3e560890a3c68ad6af4
|
| 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 29, 2026.
Transparency logRelease files / lume_pva_apg-0.1.5-py3-none-any.whl
| Download URL | lume_pva_apg-0.1.5-py3-none-any.whl |
|---|---|
| Size | 104.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7ea92b344646cfb2110ede5cc1668e7dd5916e31b8773f436cf0f91ab2a21ce0
|
|
BLAKE2b-256 checksum How to use checksums |
2d0f27b13a7b5179b1b7fd7468e34ef7ca5add579c0ceb3867f09c28e8a7a934
|
| 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 29, 2026.
Transparency log