From 70289d6d5428740b5fbee602fa57fe084b1711e1 Mon Sep 17 00:00:00 2001 From: Giles Knap Date: Thu, 8 Oct 2026 11:16:37 +0000 Subject: [PATCH] Add authenticated blueapi/tiled scans and PV health checks Components checks: blueapi and tiled health, tiled anonymous read and /binned route, plus read-only PV checks (aioca/p4p via the EPICS gateway) for flippers, align, PandAs and IOC UPTIME. Experiments: spectroscopy submits step and fly scans to blueapi with an OAuth2 client_credentials token and verifies the runs in tiled. httpx, pytest and pytest-asyncio become runtime dependencies so the image can run its checks. README documents configuration and how to provision and seal the client secret. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AnYWTg1W6CEzGFkugPgeUa --- README.md | 151 ++++++- pyproject.toml | 9 +- src/visr_tests/components/align.py | 11 + src/visr_tests/components/blueapi.py | 8 + src/visr_tests/components/flipper.py | 20 +- src/visr_tests/components/iocs.py | 11 + src/visr_tests/components/panda.py | 11 + src/visr_tests/components/tiled.py | 23 ++ src/visr_tests/conftest.py | 107 ++++- src/visr_tests/epics.py | 61 +++ src/visr_tests/experiments/spectroscopy.py | 77 +++- src/visr_tests/services.py | 429 ++++++++++++++++++++ tests/ca_server.py | 42 ++ tests/fake_services.py | 178 +++++++++ tests/test_cli.py | 57 +++ tests/test_epics.py | 227 +++++++++++ tests/test_services.py | 312 +++++++++++++++ uv.lock | 442 +++++++++++++++++++++ 18 files changed, 2156 insertions(+), 20 deletions(-) create mode 100644 src/visr_tests/components/align.py create mode 100644 src/visr_tests/components/blueapi.py create mode 100644 src/visr_tests/components/iocs.py create mode 100644 src/visr_tests/components/panda.py create mode 100644 src/visr_tests/components/tiled.py create mode 100644 src/visr_tests/epics.py create mode 100644 src/visr_tests/services.py create mode 100644 tests/ca_server.py create mode 100644 tests/fake_services.py create mode 100644 tests/test_epics.py create mode 100644 tests/test_services.py diff --git a/README.md b/README.md index 2e71d44..a1cdc3e 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ Scheduled component tests and end-to-end experiment runs for ViSR -This is where you should write a short paragraph that describes what your module does, -how it does it, and why people should use it. +`visr-tests` runs pytest checks against a ViSR beamline (b01-1) on a schedule, +as CronJobs from the beamline's services repo, and writes a JUnit report per +check. Component checks look at one service or device at a time; experiment +checks run a plan through blueapi and check the data it wrote to tiled. What | Where :---: | :---: @@ -16,18 +18,147 @@ Source | Docker | `docker run ghcr.io/diamondlightsource/visr-tests:latest` Releases | -This is where you should put some images or code snippets that illustrate -some relevant examples. If it is a library then you might put some -introductory code here: +## Running checks -```python -from visr_tests import __version__ +``` +visr-tests run CHECK [ARGUMENT ...] [CHECK [ARGUMENT ...] ...] +``` + +Each check runs in its own pytest session, in the order given. Reports go to +`$REPORT_DIR//$JOB_NAME/.xml`. The exit status is non-zero +if any check fails. + +Check | What it checks +:--- | :--- +`components/blueapi` | blueapi `/healthz` answers 200 through its public URL +`components/tiled` | tiled `/healthz`, an anonymous search, and that `/api/v1/binned/` answers 422 (the `dls_tiled.visr` router is mounted) +`components/flipper` | each flipper (PV prefixes as arguments, e.g. `BL01C-EA-FLIP-01`) answers `caget :Position`, and its `Position_RBV` is 0 or 1 and not in INVALID alarm +`components/align` | each alignment stage (PV prefixes as arguments, e.g. `BL01C-EA-ALIGN-01`) answers `caget :Pitch:Acceleration_RBV` +`components/panda` | each PandA (PV prefixes as arguments, e.g. `BL01C-EA-PANDA-01 BL01C-MO-PPANDA-01`) answers `pvget :Pcap:Active` +`components/iocs` | each IOC in `IOC_NAMES` answers `caget :UPTIME` (devIocStats) +`experiments/spectroscopy` | runs `demo_spectroscopy` as a step scan and as a fly scan through blueapi, then finds each run in tiled by `start.blueapi_task_id` and checks its exit status is `success`, it has 4 primary events, and `/binned?width=2&height=2` gives a 2x2 `RedTotal` +`experiments/tomography` | the tomography experiment + +The PV checks only read: they never write a PV or move anything. A PV must +answer within `PV_TIMEOUT` seconds; one that does not is tried once more before +the check fails. Moving each flipper between its two positions and back is a +possible future check. + +The scans move the sample stage; the fly scan also needs the PandA and PMAC. +The plan, its parameters and the expected results are in +`src/visr_tests/experiments/spectroscopy.py`. -print(f"Hello visr_tests {__version__}") +### Environment + +Variable | Default | Used by +:--- | :--- | :--- +`BLUEAPI_URL` | `https://b01-1-blueapi.diamond.ac.uk` | blueapi checks. The public URL, so requests go through oauth2-proxy as users' do +`TILED_URL` | `http://b01-1-tiled:8000` | tiled checks. The in-cluster Service +`TOKEN_URL` | `https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/token` | experiments +`CLIENT_ID` | none | experiments: the Keycloak service account's client id +`CLIENT_SECRET` | none | experiments: its client secret +`INSTRUMENT_SESSION` | none | experiments: the instrument session the plans run in, e.g. `cm44191-4`. The service account must be allowed to run plans in it +`SCAN_TIMEOUT` | `300` | experiments: seconds each scan may take, from submitting it to its run stopping in tiled +`PV_TIMEOUT` | `5` | PV checks: seconds to wait for each PV read, as `caget -w 5` +`IOC_NAMES` | none | `components/iocs`: the IOCs to check, separated by commas or spaces, e.g. `bl01c-di-dcam-01,bl01c-mo-ioc-01`. Each must load devIocStats (`devIocStats.iocAdminSoft` in its `ioc.yaml`) +`EPICS_CA_NAME_SERVERS` | none | PV checks over CA: in the cluster the gateway, `b01-1-epics-gateways:9064` +`EPICS_PVA_NAME_SERVERS` | none | PV checks over PVA: in the cluster the gateway, `b01-1-epics-gateways:9075` +`EPICS_CA_AUTO_ADDR_LIST`, `EPICS_PVA_AUTO_ADDR_LIST` | `YES` | set both to `NO` in the cluster, so PVs are searched for only through the gateway + +Experiments authenticate to blueapi with an OAuth2 `client_credentials` token +(`client_secret_basic`), fetched once per check. A check that needs a variable +which is not set fails at setup and says which. The client secret and the token +are never logged or written to reports. + +The PV checks use the standard EPICS client environment above (aioca for CA, +p4p for PVA), as blueapi does in `b01-1-blueapi/values.yaml`. In the cluster +the components job needs no host network: it reaches the IOCs through the +gateway Service. + +For example, against the beamline from a workstation (tiled through its +LoadBalancer address): + +``` +CLIENT_ID=... CLIENT_SECRET=... INSTRUMENT_SESSION=cm44191-4 \ +TILED_URL=http://:8000 \ + uv run visr-tests run components/blueapi components/tiled experiments/spectroscopy ``` -Or if it is a commandline tool then you might put some example commands here: +and the PV checks through the gateway's LoadBalancer address: ``` -python -m visr_tests --version +EPICS_CA_NAME_SERVERS=:9064 EPICS_CA_AUTO_ADDR_LIST=NO \ +EPICS_PVA_NAME_SERVERS=:9075 EPICS_PVA_AUTO_ADDR_LIST=NO \ +IOC_NAMES=bl01c-di-dcam-01,bl01c-di-dcam-02,bl01c-di-dcam-04,bl01c-di-dcam-05,bl01c-di-locum-01,bl01c-mo-ioc-01 \ + uv run visr-tests run \ + components/flipper BL01C-EA-FLIP-01 BL01C-EA-FLIP-02 BL01C-EA-FLIP-03 \ + components/align BL01C-EA-ALIGN-01 \ + components/panda BL01C-EA-PANDA-01 BL01C-MO-PPANDA-01 \ + components/iocs ``` + +## Provisioning the client secret + +The experiment checks run as a Keycloak service account. Its client secret +reaches the CronJob as a SealedSecret in the services repo. + +1. Request the service account at the + [Keycloak service desk](https://jira.diamond.ac.uk/servicedesk/customer/portal/5) + with: + + - audience: `account` + - instrument: `b01-1` + - client id: `b01-1-blueapi-test-runner` + - environment: Dev/Test and Production + - your GPG public key, so the secret can be sent to you encrypted (see + [request a registration with Keycloak](https://dev-guide.diamond.ac.uk/authn/how-tos/request-a-registration-with-keycloak/)) + + You may receive separate credentials for identity, identity-dev and + identity-test. Use the identity (production) ones: `TOKEN_URL` defaults + to it. + +2. Decrypt the secret and seal it as the Secret `visr-tests-secret` in the + `b01-1-beamline` namespace, with keys `client-id` and `client-secret`. + From a clone of `b01-1-services`, with your GPG key available: + + ```bash + module load k8s-b01-1 # kubectl and kubeseal for the b01-1 cluster + + tmp=$(mktemp -d) + gpg --decrypt client-secret.pgp | tr -d '\r\n' > "$tmp/client-secret" + kubectl create secret generic visr-tests-secret \ + --namespace b01-1-beamline \ + --from-literal=client-id=b01-1-blueapi-test-runner \ + --from-file=client-secret="$tmp/client-secret" \ + --dry-run=client -o yaml | + kubeseal --format yaml \ + > services/b01-1-nightly-experiments/templates/secret.yaml + rm -rf "$tmp" + ``` + + Replace `client-secret.pgp` with the file you received. kubeseal uses the + cluster's sealed-secrets controller with its default name and namespace, + as the other SealedSecrets in `b01-1-services` do. The sealed values only + decrypt as `visr-tests-secret` in `b01-1-beamline`: do not rename it. The + SealedSecret is safe to commit; the decrypted file is not, so make sure + it is gone. + +3. Point the CronJob at it, in + `services/b01-1-nightly-experiments/values.yaml`: + + ```yaml + scheduled-jobs: + env: + - name: CLIENT_ID + valueFrom: + secretKeyRef: {name: visr-tests-secret, key: client-id} + - name: CLIENT_SECRET + valueFrom: + secretKeyRef: {name: visr-tests-secret, key: client-secret} + - name: INSTRUMENT_SESSION + value: cm44191-4 + ``` + + The components job needs no credentials. A Secret is per namespace, so + another service in `b01-1-beamline` can reference `visr-tests-secret` the + same way. diff --git a/pyproject.toml b/pyproject.toml index 7946a56..78a0d56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,13 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] description = "Scheduled component tests and end-to-end experiment runs for ViSR" -dependencies = [] # Add project dependencies here, e.g. ["click", "numpy"] +dependencies = [ + "aioca>=2.1", + "httpx>=0.28.1", + "p4p>=4.3.0", + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", +] dynamic = ["version"] license.file = "LICENSE" readme = "README.md" @@ -26,6 +32,7 @@ dev = [ "pytest", "pytest-cov", "ruff", + "softioc>=4.7.2", "tox-uv", "types-mock", ] diff --git a/src/visr_tests/components/align.py b/src/visr_tests/components/align.py new file mode 100644 index 0000000..b0f045c --- /dev/null +++ b/src/visr_tests/components/align.py @@ -0,0 +1,11 @@ +"""Check the alignment stage's IOC answers over Channel Access. + +Arguments: stage PV prefixes, e.g. BL01C-EA-ALIGN-01. Reads the fastcs-ximc +``Pitch:Acceleration_RBV``. +""" + +from visr_tests.epics import ca_read + + +async def test_align_answers(check_arg: str, pv_timeout: float): + await ca_read(f"{check_arg}:Pitch:Acceleration_RBV", pv_timeout) diff --git a/src/visr_tests/components/blueapi.py b/src/visr_tests/components/blueapi.py new file mode 100644 index 0000000..c9f8071 --- /dev/null +++ b/src/visr_tests/components/blueapi.py @@ -0,0 +1,8 @@ +"""Check blueapi is up, through its public URL (BLUEAPI_URL).""" + +from visr_tests.services import Blueapi + + +def test_blueapi_healthz(blueapi: Blueapi): + status = blueapi.healthz() + assert status == 200, f"blueapi /healthz returned HTTP {status}" diff --git a/src/visr_tests/components/flipper.py b/src/visr_tests/components/flipper.py index 1efdcd8..22dcb8a 100644 --- a/src/visr_tests/components/flipper.py +++ b/src/visr_tests/components/flipper.py @@ -1,9 +1,19 @@ -"""Check a flipper moves between its two positions.""" +"""Check a flipper's IOC serves its position. Read only: the flipper does not move. -import logging +Arguments: flipper PV prefixes, e.g. BL01C-EA-FLIP-01. fastcs-thorlabs-mff +serves ``Position``, the setpoint (a boolOut), and ``Position_RBV``, the +position read back from the device (a boolIn, ZNAM False, ONAM True). +""" -log = logging.getLogger(__name__) +from visr_tests.epics import INVALID, ca_read -def test_flipper_cycles(check_arg: str): - log.info("pass cycle of flipper %s", check_arg) +async def test_flipper_position_connects(check_arg: str, pv_timeout: float): + await ca_read(f"{check_arg}:Position", pv_timeout) + + +async def test_flipper_position_valid(check_arg: str, pv_timeout: float): + pv = f"{check_arg}:Position_RBV" + value = await ca_read(pv, pv_timeout) + assert value.severity < INVALID, f"{pv} is in INVALID alarm" + assert value in (0, 1), f"{pv} is {value!r}, expected 0 (False) or 1 (True)" diff --git a/src/visr_tests/components/iocs.py b/src/visr_tests/components/iocs.py new file mode 100644 index 0000000..903626f --- /dev/null +++ b/src/visr_tests/components/iocs.py @@ -0,0 +1,11 @@ +"""Check each IOC in IOC_NAMES answers over Channel Access. + +Reads ``:UPTIME``, which every IOC that loads +devIocStats (``devIocStats.iocAdminSoft`` in its ioc.yaml) serves. +""" + +from visr_tests.epics import ca_read + + +async def test_ioc_uptime(ioc: str, pv_timeout: float): + await ca_read(f"{ioc.upper()}:UPTIME", pv_timeout) diff --git a/src/visr_tests/components/panda.py b/src/visr_tests/components/panda.py new file mode 100644 index 0000000..dd52b76 --- /dev/null +++ b/src/visr_tests/components/panda.py @@ -0,0 +1,11 @@ +"""Check a PandA's IOC answers over PV Access. + +Arguments: PandA PV prefixes, e.g. BL01C-EA-PANDA-01 BL01C-MO-PPANDA-01. +Reads the fastcs-pandablocks ``Pcap:Active``. +""" + +from visr_tests.epics import pva_read + + +async def test_panda_answers(check_arg: str, pv_timeout: float): + await pva_read(f"{check_arg}:Pcap:Active", pv_timeout) diff --git a/src/visr_tests/components/tiled.py b/src/visr_tests/components/tiled.py new file mode 100644 index 0000000..8138b12 --- /dev/null +++ b/src/visr_tests/components/tiled.py @@ -0,0 +1,23 @@ +"""Check tiled is up, readable anonymously and serves the ViSR /binned route.""" + +from visr_tests.services import Tiled + +# no run has this name, so /binned must reject it +NO_SUCH_RUN = "visr-tests-no-such-run" + + +def test_tiled_healthz(tiled: Tiled): + status = tiled.healthz() + assert status == 200, f"tiled /healthz returned HTTP {status}" + + +def test_tiled_anonymous_read(tiled: Tiled): + assert tiled.count() >= 0 + + +def test_tiled_binned_route(tiled: Tiled): + """422 for an unknown run shows that the dls_tiled.visr router is mounted.""" + status = tiled.binned_status(NO_SUCH_RUN) + assert status == 422, ( + f"tiled /binned returned HTTP {status} for an unknown run, expected 422" + ) diff --git a/src/visr_tests/conftest.py b/src/visr_tests/conftest.py index d3514ca..d647e6f 100644 --- a/src/visr_tests/conftest.py +++ b/src/visr_tests/conftest.py @@ -2,10 +2,24 @@ import json import os +from collections.abc import Callable, Iterator +from typing import NoReturn +import httpx import pytest +from .services import ( + Blueapi, + CheckError, + Config, + ConfigError, + Tiled, + fetch_token, + ioc_names, +) + CHECK_ARGS = "VISR_CHECK_ARGS" +HTTP_TIMEOUT = 30.0 def arguments() -> list[str]: @@ -17,6 +31,8 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: """Run any test wanting check_arg once per argument the check was given.""" if "check_arg" in metafunc.fixturenames and arguments(): metafunc.parametrize("check_arg", arguments(), scope="session") + if "ioc" in metafunc.fixturenames and ioc_names(): + metafunc.parametrize("ioc", ioc_names(), scope="session") @pytest.fixture(scope="session") @@ -24,5 +40,94 @@ def check_arg() -> str: """Only reached when the check needs an argument but was given none.""" raise AssertionError( "this check needs at least one argument, e.g. " - "`visr-tests run components/flipper BL01C-MO-FLIP-01`" + "`visr-tests run components/flipper BL01C-EA-FLIP-01`" + ) + + +@pytest.fixture(scope="session") +def ioc() -> NoReturn: + """Only reached when IOC_NAMES names no IOC.""" + _fail( + ConfigError( + "IOC_NAMES not set: set it to the IOCs to check, separated by commas " + "or spaces, e.g. IOC_NAMES=bl01c-di-dcam-01,bl01c-mo-ioc-01" + ) ) + + +def _fail(error: Exception) -> NoReturn: + """Fail without a traceback: the message says everything. + + Call it after the ``except`` block, so pytest shows no exception chain. + """ + pytest.fail(f"{type(error).__name__}: {error}", pytrace=False) + + +@pytest.fixture(scope="session") +def config() -> Config: + """Service URLs and credentials, from the environment.""" + try: + return Config.from_env() + except ConfigError as error: + failure = error + _fail(failure) + + +@pytest.fixture(scope="session") +def pv_timeout(config: Config) -> float: + """Seconds to wait for each PV read, from PV_TIMEOUT.""" + return config.pv_timeout + + +@pytest.fixture(scope="session") +def instrument_session(config: Config) -> str: + """The instrument session to run plans in, from INSTRUMENT_SESSION.""" + try: + return config.require_instrument_session() + except ConfigError as error: + failure = error + _fail(failure) + + +@pytest.fixture(scope="session") +def http() -> Iterator[httpx.Client]: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + yield client + + +@pytest.fixture(scope="session") +def token_source(config: Config, http: httpx.Client) -> Callable[[], str]: + """Fetches the bearer token on first call, then returns the same one.""" + cache: dict[str, str] = {} + + def token() -> str: + if "token" not in cache: + try: + cache["token"] = fetch_token(http, config) + except (ConfigError, CheckError) as error: + failure = error + else: + return cache["token"] + _fail(failure) + return cache["token"] + + return token + + +@pytest.fixture(scope="session") +def token(token_source: Callable[[], str]) -> str: + """A client_credentials bearer token for blueapi. Never log it.""" + return token_source() + + +@pytest.fixture(scope="session") +def blueapi( + config: Config, http: httpx.Client, token_source: Callable[[], str] +) -> Blueapi: + """blueapi; the token is fetched only for its first authenticated request.""" + return Blueapi(http, config.blueapi_url, token_source) + + +@pytest.fixture(scope="session") +def tiled(config: Config, http: httpx.Client) -> Tiled: + return Tiled(http, config.tiled_url) diff --git a/src/visr_tests/epics.py b/src/visr_tests/epics.py new file mode 100644 index 0000000..ef8a85e --- /dev/null +++ b/src/visr_tests/epics.py @@ -0,0 +1,61 @@ +"""Read PVs over Channel Access (aioca) and PV Access (p4p). Nothing is written. + +Which servers are searched comes from the standard EPICS environment, e.g. in +the cluster the beamline's gateway: ``EPICS_CA_NAME_SERVERS``, +``EPICS_PVA_NAME_SERVERS`` and ``EPICS_{CA,PVA}_AUTO_ADDR_LIST=NO``. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from aioca import FORMAT_TIME, CANothing, caget +from p4p.client.asyncio import Context + +from .services import CheckError + +log = logging.getLogger(__name__) + +ATTEMPTS = 2 # a PV that does not answer in time gets one retry +INVALID = 3 # alarm severity: the value cannot be trusted + + +async def ca_read(pv: str, timeout: float) -> Any: + """``pv``'s value with its timestamp and alarm, read over Channel Access.""" + failure: CANothing | None = None + for attempt in range(1, ATTEMPTS + 1): + try: + return await caget(pv, timeout=timeout, format=FORMAT_TIME) + except CANothing as error: + failure = error # a timeout, or the channel dropped mid-read + _retrying(pv, timeout, attempt) + raise CheckError(f"{_no_answer('CA', pv, timeout)}: {failure}") + + +async def pva_read(pv: str, timeout: float) -> Any: + """``pv``'s value, read over PV Access.""" + with Context("pva") as context: + for attempt in range(1, ATTEMPTS + 1): + try: + return await asyncio.wait_for(context.get(pv), timeout) + except TimeoutError: + _retrying(pv, timeout, attempt) + except Exception as error: + raise CheckError( + f"pvget {pv} failed: {type(error).__name__}: {error}" + ) from None + raise CheckError(_no_answer("PVA", pv, timeout)) + + +def _retrying(pv: str, timeout: float, attempt: int) -> None: + if attempt < ATTEMPTS: + log.warning("%s did not answer within %g s, retrying", pv, timeout) + + +def _no_answer(protocol: str, pv: str, timeout: float) -> str: + return ( + f"{pv} did not answer over {protocol} within {timeout:g} s " + f"({ATTEMPTS} attempts)" + ) diff --git a/src/visr_tests/experiments/spectroscopy.py b/src/visr_tests/experiments/spectroscopy.py index 3f97271..4624b08 100644 --- a/src/visr_tests/experiments/spectroscopy.py +++ b/src/visr_tests/experiments/spectroscopy.py @@ -1,9 +1,80 @@ -"""Run the spectroscopy experiment end to end and check the data it wrote.""" +"""Run the spectroscopy experiment end to end and check the data it wrote. + +Each scan runs demo_spectroscopy through blueapi, then finds its run in tiled by +the blueapi task id and checks the run's stop document and /binned image. +The scans move the sample stage; the fly scan also needs the PandA and the PMAC. +""" import logging +import time +from typing import Any + +import pytest + +from visr_tests.services import Blueapi, Config, Tiled log = logging.getLogger(__name__) +PLAN = "demo_spectroscopy" +POINTS = 4 +SIDE = 2 # bins along each side of the /binned image: sqrt(POINTS) +# demo_spectroscopy flies when its stage velocity, grid_size / +# (sqrt(points) * exposure_time), is at most 9.8 mm/s, and steps otherwise +SCANS: dict[str, dict[str, Any]] = { + "step": { + "total_number_of_scan_points": POINTS, + "grid_size": 2.0, + "exposure_time": 0.1, + }, + "fly": { + "total_number_of_scan_points": POINTS, + "grid_size": 2.0, + "exposure_time": 0.2, + }, +} + + +def check_scan( + blueapi: Blueapi, tiled: Tiled, params: dict[str, Any], session: str, timeout: float +) -> None: + """Run one scan and check its run in tiled, all within ``timeout`` seconds.""" + deadline = blueapi.clock() + timeout + task_id = blueapi.run_plan(PLAN, params, session, deadline) + run, stop = tiled.wait_for_run(task_id, deadline) + + exit_status = stop.get("exit_status") + assert exit_status == "success", ( + f"run {run} exit status {exit_status!r}: {stop.get('reason', '')}" + ) + events = (stop.get("num_events") or {}).get("primary", 0) + assert events == POINTS, f"run {run} has {events} events, expected {POINTS}" + + binned = tiled.binned(run, SIDE, SIDE) + red = binned.get("RedTotal") + shape = ( + (len(red), len(red[0])) + if isinstance(red, list) and red and isinstance(red[0], list) + else None + ) + assert shape == (SIDE, SIDE), ( + f"run {run} /binned RedTotal is {shape}, expected {(SIDE, SIDE)}" + ) + log.info( + "run %s: %s", + run, + {key: binned.get(key) for key in ("RedTotal", "GreenTotal", "BlueTotal")}, + ) + -def test_spectroscopy(): - log.info("pass spectroscopy experiment") +@pytest.mark.parametrize("params", SCANS.values(), ids=SCANS.keys()) +def test_spectroscopy( + config: Config, + instrument_session: str, + token: str, # fail at setup, before anything runs, without credentials + blueapi: Blueapi, + tiled: Tiled, + params: dict[str, Any], +): + started = time.monotonic() + check_scan(blueapi, tiled, params, instrument_session, config.scan_timeout) + log.info("scan passed in %.1fs", time.monotonic() - started) diff --git a/src/visr_tests/services.py b/src/visr_tests/services.py new file mode 100644 index 0000000..414bd59 --- /dev/null +++ b/src/visr_tests/services.py @@ -0,0 +1,429 @@ +"""Clients for the services the checks talk to: Keycloak, blueapi and tiled. + +Everything is plain HTTP with httpx. Configuration comes from the environment +(:meth:`Config.from_env`). The client secret and the bearer token are held only +in memory: they are never logged, and no message raised here contains them. +""" + +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +import httpx + +log = logging.getLogger(__name__) + +BLUEAPI_URL = "https://b01-1-blueapi.diamond.ac.uk" +TILED_URL = "http://b01-1-tiled:8000" +TOKEN_URL = "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/token" +SCAN_TIMEOUT = 300.0 +PV_TIMEOUT = 5.0 +POLL_INTERVAL = 2.0 + + +class ConfigError(Exception): + """The environment does not configure what a check needs.""" + + +class CheckError(AssertionError): + """A service did something wrong; the message is safe to log and report.""" + + +@dataclass(frozen=True) +class Config: + """Where the services are and how to authenticate, from the environment.""" + + blueapi_url: str = BLUEAPI_URL + tiled_url: str = TILED_URL + token_url: str = TOKEN_URL + client_id: str = "" + client_secret: str = field(default="", repr=False) + instrument_session: str = "" + scan_timeout: float = SCAN_TIMEOUT + pv_timeout: float = PV_TIMEOUT + + @classmethod + def from_env(cls, environ: Mapping[str, str] | None = None) -> Config: + env = os.environ if environ is None else environ + + def get(name: str, default: str = "") -> str: + return env.get(name, "").strip() or default + + def seconds(name: str, default: float) -> float: + text = get(name, str(default)) + try: + value = float(text) + except ValueError: + raise ConfigError( + f"{name} must be a number of seconds, not {text!r}" + ) from None + if value <= 0: + raise ConfigError(f"{name} must be positive, not {text!r}") + return value + + return cls( + blueapi_url=get("BLUEAPI_URL", BLUEAPI_URL).rstrip("/"), + tiled_url=get("TILED_URL", TILED_URL).rstrip("/"), + token_url=get("TOKEN_URL", TOKEN_URL), + client_id=get("CLIENT_ID"), + client_secret=get("CLIENT_SECRET"), + instrument_session=get("INSTRUMENT_SESSION"), + scan_timeout=seconds("SCAN_TIMEOUT", SCAN_TIMEOUT), + pv_timeout=seconds("PV_TIMEOUT", PV_TIMEOUT), + ) + + def require_credentials(self) -> None: + missing = [ + name + for name, value in ( + ("CLIENT_ID", self.client_id), + ("CLIENT_SECRET", self.client_secret), + ) + if not value + ] + if missing: + raise ConfigError( + f"{' and '.join(missing)} not set: this check needs an OAuth2 " + "client_credentials client for blueapi (see the README, " + "'Provisioning the client secret')" + ) + + def require_instrument_session(self) -> str: + if not self.instrument_session: + raise ConfigError( + "INSTRUMENT_SESSION not set: set it to an instrument session the " + "service account may run plans in, e.g. INSTRUMENT_SESSION=cm12345-1" + ) + return self.instrument_session + + +def ioc_names(environ: Mapping[str, str] | None = None) -> list[str]: + """The IOCs named in IOC_NAMES, separated by commas or whitespace.""" + env = os.environ if environ is None else environ + return env.get("IOC_NAMES", "").replace(",", " ").split() + + +def _shorten(text: str, limit: int = 300) -> str: + text = " ".join(text.split()) + return text if len(text) <= limit else text[: limit - 3] + "..." + + +def _json(response: httpx.Response, what: str) -> Any: + try: + return response.json() + except ValueError: + raise CheckError(f"{what} did not return JSON") from None + + +def _detail(response: httpx.Response) -> str: + """blueapi's (FastAPI's) error ``detail``, shortened, without echoed input.""" + try: + detail = response.json().get("detail") + except (ValueError, AttributeError): + return "" + if isinstance(detail, list): + parts = [ + f"{'.'.join(str(p) for p in err.get('loc', []))}: {err.get('msg')}" + for err in detail + if isinstance(err, dict) + ] + detail = "; ".join(dict.fromkeys(parts)) # blueapi repeats some errors + if not isinstance(detail, str) or not detail: + return "" + return ": " + _shorten(detail) + + +def fetch_token(client: httpx.Client, config: Config) -> str: + """An access token from the OAuth2 client_credentials grant. + + The client authenticates with client_secret_basic (credentials in the + Authorization header). Only the RFC 6749 ``error`` code of a refusal is + reported, never the response body. + """ + config.require_credentials() + try: + response = client.post( + config.token_url, + data={"grant_type": "client_credentials"}, + auth=(config.client_id, config.client_secret), + ) + except httpx.HTTPError as error: + raise CheckError( + f"token endpoint {config.token_url} unreachable: {type(error).__name__}" + ) from None + if response.status_code != 200: + try: + code = response.json().get("error") + except (ValueError, AttributeError): + code = None + reason = f": {code}" if isinstance(code, str) else "" + raise CheckError(f"token endpoint returned HTTP {response.status_code}{reason}") + try: + token = response.json()["access_token"] + except (ValueError, KeyError, TypeError): + raise CheckError("token endpoint response has no access_token") from None + if not isinstance(token, str) or not token: + raise CheckError("token endpoint returned an empty access_token") + log.info("obtained a client_credentials token for %s", config.client_id) + return token + + +class Blueapi: + """blueapi's REST API. Requests under /api/v1 carry the bearer token. + + ``token`` is called only when an authenticated request is first made, so a + check that only reads /healthz needs no credentials. + """ + + def __init__( + self, + client: httpx.Client, + url: str, + token: Callable[[], str], + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + poll_interval: float = POLL_INTERVAL, + ): + self.client = client + self.url = url + self._token = token + self.clock = clock + self.sleep = sleep + self.poll_interval = poll_interval + + def request( + self, method: str, path: str, json: Any = None, authenticated: bool = True + ) -> httpx.Response: + headers = {"Accept": "application/json"} + if authenticated: + headers["Authorization"] = f"Bearer {self._token()}" + try: + response = self.client.request( + method, f"{self.url}{path}", headers=headers, json=json + ) + except httpx.HTTPError as error: + raise CheckError( + f"{method} {self.url}{path} failed: {type(error).__name__}: {error}" + ) from None + if response.status_code in (401, 403): + raise CheckError( + f"{method} {path} not authorised (HTTP {response.status_code})" + ) + return response + + def get_json(self, path: str) -> Any: + response = self.request("GET", path) + if response.status_code != 200: + raise CheckError(f"GET {path} returned HTTP {response.status_code}") + return _json(response, f"GET {path}") + + def healthz(self) -> int: + """The HTTP status of the unauthenticated /healthz.""" + return self.request("GET", "/healthz", authenticated=False).status_code + + def run_plan( + self, + plan: str, + params: Mapping[str, Any], + instrument_session: str, + deadline: float, + ) -> str: + """Run a plan on the worker and wait for it to succeed; its task id. + + POST /api/v1/tasks, PUT /api/v1/worker/task, then poll + GET /api/v1/tasks/{id} until complete. A task submitted but not + started is deleted; one still running at ``deadline`` (on ``clock``) + is aborted, if it is still the worker's active task. + """ + response = self.request( + "POST", + "/api/v1/tasks", + json={ + "name": plan, + "params": dict(params), + "instrument_session": instrument_session, + }, + ) + if response.status_code == 404: + raise CheckError(f"plan {plan!r} not found (HTTP 404)") + if response.status_code == 422: + raise CheckError( + f"plan {plan!r} rejected its parameters{_detail(response)}" + ) + if response.status_code != 201: + raise CheckError( + f"submitting plan {plan!r} returned HTTP {response.status_code}" + f"{_detail(response)}" + ) + body = _json(response, "POST /api/v1/tasks") + task_id = body.get("task_id") if isinstance(body, dict) else None + if not isinstance(task_id, str) or not task_id: + raise CheckError("POST /api/v1/tasks response has no task_id") + log.info("submitted %s as task %s", plan, task_id) + + response = self.request("PUT", "/api/v1/worker/task", json={"task_id": task_id}) + if response.status_code != 200: + self._forget(task_id) + if response.status_code == 409: + raise CheckError(f"worker busy, could not start {plan!r} (HTTP 409)") + raise CheckError( + f"starting task {task_id} returned HTTP {response.status_code}" + f"{_detail(response)}" + ) + log.info("started task %s", task_id) + + while True: + task = self.get_json(f"/api/v1/tasks/{task_id}") + if not isinstance(task, dict): + raise CheckError( + f"GET /api/v1/tasks/{task_id} returned unexpected JSON" + ) + if task.get("is_complete") is True: + break + if self.clock() >= deadline: + raise CheckError( + f"task {task_id} ({plan}) did not complete in time" + f"{self._abort(task_id)}" + ) + self.sleep(self.poll_interval) + + outcome = task.get("outcome") + errors = task.get("errors") or [] + if not isinstance(outcome, dict) or outcome.get("outcome") != "success": + if isinstance(outcome, dict) and outcome.get("outcome") == "error": + why = f"{outcome.get('type')}: {outcome.get('message')}" + else: + why = f"no success outcome ({outcome!r})" + raise CheckError(f"task {task_id} ({plan}) failed: {_shorten(why)}") + if errors: + raise CheckError( + f"task {task_id} ({plan}) reported {len(errors)} error(s): " + f"{_shorten(str(errors[0]))}" + ) + log.info("task %s completed successfully", task_id) + return task_id + + def _forget(self, task_id: str) -> None: + """Best effort: delete a task that was submitted but not started.""" + try: + self.request("DELETE", f"/api/v1/tasks/{task_id}") + except CheckError as error: + log.warning("could not delete unstarted task %s: %s", task_id, error) + + def _abort(self, task_id: str) -> str: + """Abort the worker's active task, only if it is ``task_id``.""" + try: + active = self.get_json("/api/v1/worker/task") + if not isinstance(active, dict) or active.get("task_id") != task_id: + return "; it is no longer the active task, so not aborted" + response = self.request( + "PUT", + "/api/v1/worker/state", + json={"new_state": "ABORTING", "reason": "visr-tests scan timeout"}, + ) + except CheckError as error: + return f"; abort failed: {error}" + if response.status_code != 202: + return f"; abort returned HTTP {response.status_code}{_detail(response)}" + return "; aborted it" + + +class Tiled: + """tiled's REST API, read anonymously.""" + + def __init__( + self, + client: httpx.Client, + url: str, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + poll_interval: float = POLL_INTERVAL, + ): + self.client = client + self.url = url + self.clock = clock + self.sleep = sleep + self.poll_interval = poll_interval + + def get(self, path: str, params: Mapping[str, Any] | None = None) -> httpx.Response: + try: + return self.client.get(f"{self.url}{path}", params=params) + except httpx.HTTPError as error: + raise CheckError( + f"GET {self.url}{path} failed: {type(error).__name__}: {error}" + ) from None + + def get_json(self, path: str, params: Mapping[str, Any] | None = None) -> Any: + response = self.get(path, params) + if response.status_code != 200: + raise CheckError(f"GET {path} returned HTTP {response.status_code}") + return _json(response, f"GET {path}") + + def healthz(self) -> int: + return self.get("/healthz").status_code + + def count(self) -> int: + """How many runs an anonymous search of the root finds.""" + body = self.get_json("/api/v1/search/", {"page[limit]": 0}) + count = body.get("meta", {}).get("count") if isinstance(body, dict) else None + if not isinstance(count, int): + raise CheckError("GET /api/v1/search/ response has no meta.count") + return count + + def binned_status(self, run: str) -> int: + return self.get(f"/api/v1/binned/{run}").status_code + + def find_run(self, task_id: str) -> str | None: + """The run whose start document has ``blueapi_task_id == task_id``.""" + body = self.get_json( + "/api/v1/search/", + { + "filter[eq][condition][key]": "start.blueapi_task_id", + "filter[eq][condition][value]": f'"{task_id}"', + }, + ) + data = body.get("data") if isinstance(body, dict) else None + if isinstance(data, list) and data and isinstance(data[0], dict): + run = data[0].get("id") + return run if isinstance(run, str) else None + return None + + def stop(self, run: str) -> dict[str, Any] | None: + """The run's stop document, or None while it is still running.""" + body = self.get_json(f"/api/v1/metadata/{run}") + try: + stop = body["data"]["attributes"]["metadata"].get("stop") + except (KeyError, TypeError, AttributeError): + raise CheckError( + f"GET /api/v1/metadata/{run} has no data.attributes.metadata" + ) from None + return stop if isinstance(stop, dict) else None + + def wait_for_run(self, task_id: str, deadline: float) -> tuple[str, dict[str, Any]]: + """Poll until the task's run is in tiled and stopped: (run, stop).""" + run: str | None = None + while True: + run = run or self.find_run(task_id) + if run: + stop = self.stop(run) + if stop is not None: + log.info("run %s for task %s is in tiled", run, task_id) + return run, stop + if self.clock() >= deadline: + if run is None: + raise CheckError(f"no run in tiled for task {task_id}") + raise CheckError(f"run {run} for task {task_id} did not stop in time") + self.sleep(self.poll_interval) + + def binned(self, run: str, width: int, height: int) -> dict[str, Any]: + body = self.get_json( + f"/api/v1/binned/{run}", {"width": width, "height": height} + ) + if not isinstance(body, dict): + raise CheckError(f"GET /api/v1/binned/{run} returned unexpected JSON") + return body diff --git a/tests/ca_server.py b/tests/ca_server.py new file mode 100644 index 0000000..f392e74 --- /dev/null +++ b/tests/ca_server.py @@ -0,0 +1,42 @@ +"""A Channel Access server for the PV checks' tests, run as its own process. + +It serves on the port in EPICS_CA_SERVER_PORT, prints READY when it is +serving, and stops when its stdin closes. +""" + +import sys + +from softioc import asyncio_dispatcher, builder, softioc + +INVALID = 3 +STATE_ALARM = 7 + + +def flipper(prefix: str): + builder.SetDeviceName(prefix) + builder.boolOut("Position", ZNAM="False", ONAM="True", initial_value=0) + + +def main() -> None: + # healthy + flipper("BL01C-EA-FLIP-01") + builder.boolIn("Position_RBV", ZNAM="False", ONAM="True", initial_value=1) + # position out of range + flipper("BL01C-EA-FLIP-02") + builder.longIn("Position_RBV", initial_value=7) + # position in INVALID alarm + flipper("BL01C-EA-FLIP-03") + invalid = builder.boolIn("Position_RBV", ZNAM="False", ONAM="True") + builder.SetDeviceName("BL01C-EA-ALIGN-01") + builder.aIn("Pitch:Acceleration_RBV", initial_value=1.5) + builder.SetDeviceName("BL01C-DI-DCAM-01") + builder.stringIn("UPTIME", initial_value="00:00:42") + builder.LoadDatabase() + softioc.iocInit(asyncio_dispatcher.AsyncioDispatcher()) + invalid.set(1, severity=INVALID, alarm=STATE_ALARM) + print("READY", flush=True) + sys.stdin.read() + + +if __name__ == "__main__": + main() diff --git a/tests/fake_services.py b/tests/fake_services.py new file mode 100644 index 0000000..c3becd9 --- /dev/null +++ b/tests/fake_services.py @@ -0,0 +1,178 @@ +"""A fake Keycloak, blueapi and tiled behind one httpx.MockTransport.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx + +from visr_tests.services import Blueapi, Config, Tiled + +TOKEN_URL = "https://keycloak.test/token" +BLUEAPI_URL = "https://blueapi.test" +TILED_URL = "http://tiled.test:8000" +CLIENT_ID = "b01-1-blueapi-test-runner" +CLIENT_SECRET = "s3cret-client-value" +TOKEN = "t0ken-bearer-value" +TASK_ID = "0b0b0b0b-1111-2222-3333-444444444444" +RUN = "run-abc" + +CONFIG = Config( + blueapi_url=BLUEAPI_URL, + tiled_url=TILED_URL, + token_url=TOKEN_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + instrument_session="cm12345-1", + scan_timeout=10.0, +) + + +class Clock: + """A fake monotonic clock that sleeping advances.""" + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +class FakeServices: + """Answers each request from attributes a test can change.""" + + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + # Keycloak + self.token_status = 200 + self.token_body: Any = {"access_token": TOKEN, "expires_in": 300} + # blueapi + self.healthz_status = 200 + self.submit_status = 201 + self.submit_body: Any = {"task_id": TASK_ID} + self.start_status = 200 + self.polls_until_complete = 2 + self.outcome: Any = {"outcome": "success"} + self.errors: list[Any] = [] + self.active_task: Any = {"task_id": TASK_ID} + self.abort_status = 202 + # tiled + self.tiled_healthz_status = 200 + self.count = 7 + self.searches_until_found = 1 + self.metadata_until_stopped = 1 + self.stop: dict[str, Any] = { + "exit_status": "success", + "num_events": {"primary": 4}, + } + self.binned_body: Any = { + "RedTotal": [[1, 2], [3, 4]], + "GreenTotal": [[1, 2], [3, 4]], + "BlueTotal": [[1, 2], [3, 4]], + } + self.unknown_binned_status = 422 + self._polls = 0 + self._searches = 0 + self._metadata = 0 + + def paths(self) -> list[str]: + return [f"{r.method} {r.url.path}" for r in self.requests] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + url = str(request.url) + if url.startswith(TOKEN_URL): + return httpx.Response(self.token_status, json=self.token_body) + if url.startswith(BLUEAPI_URL): + return self._blueapi(request) + if url.startswith(TILED_URL): + return self._tiled(request) + return httpx.Response(599) + + def _authorised(self, request: httpx.Request) -> bool: + return request.headers.get("Authorization") == f"Bearer {TOKEN}" + + def _blueapi(self, request: httpx.Request) -> httpx.Response: + method, path = request.method, request.url.path + if path == "/healthz": + return httpx.Response(self.healthz_status, json={"status": "ok"}) + if not self._authorised(request): + return httpx.Response(401) + body = json.loads(request.content) if request.content else None + if (method, path) == ("POST", "/api/v1/tasks"): + assert isinstance(body, dict) + assert body.keys() == {"name", "params", "instrument_session"} + assert body["name"] == "demo_spectroscopy" + assert body["instrument_session"] == CONFIG.instrument_session + return httpx.Response(self.submit_status, json=self.submit_body) + if (method, path) == ("PUT", "/api/v1/worker/task"): + assert body == {"task_id": TASK_ID} + return httpx.Response(self.start_status, json={"task_id": TASK_ID}) + if (method, path) == ("GET", f"/api/v1/tasks/{TASK_ID}"): + self._polls += 1 + complete = self._polls >= self.polls_until_complete + return httpx.Response( + 200, + json={ + "task_id": TASK_ID, + "is_complete": complete, + "outcome": self.outcome if complete else None, + "errors": self.errors if complete else [], + }, + ) + if (method, path) == ("DELETE", f"/api/v1/tasks/{TASK_ID}"): + return httpx.Response(200) + if (method, path) == ("GET", "/api/v1/worker/task"): + return httpx.Response(200, json=self.active_task) + if (method, path) == ("PUT", "/api/v1/worker/state"): + return httpx.Response(self.abort_status, json="ABORTING") + return httpx.Response(404) + + def _tiled(self, request: httpx.Request) -> httpx.Response: + path, params = request.url.path, request.url.params + if path == "/healthz": + return httpx.Response(self.tiled_healthz_status) + if path == "/api/v1/search/": + if params.get("page[limit]") == "0": + return httpx.Response(200, json={"meta": {"count": self.count}}) + assert params["filter[eq][condition][key]"] == "start.blueapi_task_id" + assert params["filter[eq][condition][value]"] == f'"{TASK_ID}"' + self._searches += 1 + found = self._searches >= self.searches_until_found + return httpx.Response(200, json={"data": [{"id": RUN}] if found else []}) + if path == f"/api/v1/metadata/{RUN}": + self._metadata += 1 + stopped = self._metadata >= self.metadata_until_stopped + metadata = {"start": {}, "stop": self.stop if stopped else None} + return httpx.Response( + 200, json={"data": {"attributes": {"metadata": metadata}}} + ) + if path == f"/api/v1/binned/{RUN}": + assert (params["width"], params["height"]) == ("2", "2") + return httpx.Response(200, json=self.binned_body) + if path.startswith("/api/v1/binned/"): + return httpx.Response(self.unknown_binned_status) + return httpx.Response(404) + + +def make( + fake: FakeServices, clock: Clock | None = None +) -> tuple[httpx.Client, Blueapi, Tiled]: + """A client on the fake, and blueapi and tiled clients that use it.""" + from visr_tests.services import fetch_token + + clock = clock or Clock() + client = httpx.Client(transport=httpx.MockTransport(fake)) + blueapi = Blueapi( + client, + BLUEAPI_URL, + lambda: fetch_token(client, CONFIG), + clock=clock, + sleep=clock.sleep, + ) + tiled = Tiled(client, TILED_URL, clock=clock, sleep=clock.sleep) + return client, blueapi, tiled diff --git a/tests/test_cli.py b/tests/test_cli.py index c883852..c6772df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,7 @@ +import os import subprocess import sys +from pathlib import Path from visr_tests import __version__ @@ -7,3 +9,58 @@ def test_cli_version(): cmd = [sys.executable, "-m", "visr_tests", "--version"] assert subprocess.check_output(cmd).decode().strip() == __version__ + + +def run_cli(tmp_path: Path, *args: str, **env: str) -> subprocess.CompletedProcess: + keep = { + k: v + for k, v in os.environ.items() + if k + not in { + "BLUEAPI_URL", + "TILED_URL", + "TOKEN_URL", + "CLIENT_ID", + "CLIENT_SECRET", + "INSTRUMENT_SESSION", + "SCAN_TIMEOUT", + } + } + return subprocess.run( + [sys.executable, "-m", "visr_tests", "run", *args], + env={**keep, "REPORT_DIR": str(tmp_path), **env}, + capture_output=True, + text=True, + ) + + +def reports(tmp_path: Path) -> str: + return "".join(path.read_text() for path in tmp_path.rglob("*.xml")) + + +def test_scan_without_instrument_session_fails_clearly(tmp_path: Path): + result = run_cli(tmp_path, "experiments/spectroscopy") + assert result.returncode == 1 + assert "ConfigError: INSTRUMENT_SESSION not set" in result.stdout + assert "INSTRUMENT_SESSION not set" in reports(tmp_path) + + +def test_scan_without_credentials_fails_clearly(tmp_path: Path): + result = run_cli(tmp_path, "experiments/spectroscopy", INSTRUMENT_SESSION="cm1-1") + assert result.returncode == 1 + assert "ConfigError: CLIENT_ID and CLIENT_SECRET not set" in result.stdout + + +def test_secret_not_in_output_or_reports(tmp_path: Path): + secret = "s3cret-client-value" + result = run_cli( + tmp_path, + "experiments/spectroscopy", + INSTRUMENT_SESSION="cm1-1", + CLIENT_ID="visr-tests", + CLIENT_SECRET=secret, + TOKEN_URL="http://127.0.0.1:9/token", # refused + ) + assert result.returncode == 1 + assert "token endpoint http://127.0.0.1:9/token unreachable" in result.stdout + assert secret not in result.stdout + result.stderr + reports(tmp_path) diff --git a/tests/test_epics.py b/tests/test_epics.py new file mode 100644 index 0000000..7023110 --- /dev/null +++ b/tests/test_epics.py @@ -0,0 +1,227 @@ +"""The PV checks, run through the CLI against real CA and PVA servers on loopback. + +The CA server is a pythonSoftIOC in its own process (ca_server.py); the PVA +server is a p4p server in this one. The checks find both only through +EPICS_CA_NAME_SERVERS and EPICS_PVA_NAME_SERVERS, as they find the gateway in +the cluster, with every automatic address list off. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path + +import pytest +from p4p.nt import NTScalar +from p4p.server import Server +from p4p.server.thread import SharedPV + +from visr_tests.services import PV_TIMEOUT, Config, ConfigError, ioc_names + +LOOPBACK = "127.0.0.1" +TIMEOUT = 1.0 # PV_TIMEOUT for the checks, so a missing PV fails quickly + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp: + udp.bind((LOOPBACK, 0)) + return udp.getsockname()[1] + + +def without_epics(environ: dict[str, str]) -> dict[str, str]: + """``environ`` without EPICS_* or check settings, so nothing real is reached.""" + return { + k: v + for k, v in environ.items() + if not k.startswith("EPICS_") and k not in {"PV_TIMEOUT", "IOC_NAMES"} + } + + +@pytest.fixture(scope="module") +def ca_port() -> Iterator[int]: + port = free_port() + env = without_epics(dict(os.environ)) | { + "EPICS_CA_SERVER_PORT": str(port), + "EPICS_CAS_INTF_ADDR_LIST": LOOPBACK, + "EPICS_CA_AUTO_ADDR_LIST": "NO", + "EPICS_CAS_AUTO_BEACON_ADDR_LIST": "NO", + "EPICS_CAS_BEACON_ADDR_LIST": LOOPBACK, + # pythonSoftIOC also serves PVA: keep it off the real ports + "EPICS_PVAS_INTF_ADDR_LIST": LOOPBACK, + "EPICS_PVAS_SERVER_PORT": str(free_port()), + "EPICS_PVAS_BROADCAST_PORT": str(free_port()), + "EPICS_PVAS_AUTO_BEACON_ADDR_LIST": "NO", + } + server = subprocess.Popen( + [sys.executable, str(Path(__file__).parent / "ca_server.py")], + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert server.stdin and server.stdout + try: + for line in server.stdout: + if line.strip() == "READY": + break + else: + raise RuntimeError("CA server exited before it was ready") + yield port + finally: + server.stdin.close() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + server.stdout.close() + + +@pytest.fixture(scope="module") +def pva_port() -> Iterator[int]: + active = SharedPV(nt=NTScalar("?"), initial=True) + with Server( + providers=[{"BL01C-EA-PANDA-01:Pcap:Active": active}], + conf={ + "EPICS_PVAS_INTF_ADDR_LIST": LOOPBACK, + "EPICS_PVAS_SERVER_PORT": "0", + "EPICS_PVAS_BROADCAST_PORT": str(free_port()), + "EPICS_PVAS_AUTO_BEACON_ADDR_LIST": "NO", + }, + useenv=False, + ) as server: + yield int(server.conf()["EPICS_PVAS_SERVER_PORT"]) + + +def run( + tmp_path: Path, ca_port: int, pva_port: int, *args: str, **env: str +) -> tuple[subprocess.CompletedProcess, float]: + """Run the CLI against the local servers: its result and how long it took.""" + start = time.monotonic() + result = subprocess.run( + [sys.executable, "-m", "visr_tests", "run", *args], + env=without_epics(dict(os.environ)) + | { + "REPORT_DIR": str(tmp_path), + "PV_TIMEOUT": str(TIMEOUT), + "EPICS_CA_NAME_SERVERS": f"{LOOPBACK}:{ca_port}", + "EPICS_CA_AUTO_ADDR_LIST": "NO", + "EPICS_CA_ADDR_LIST": "", + "EPICS_PVA_NAME_SERVERS": f"{LOOPBACK}:{pva_port}", + "EPICS_PVA_AUTO_ADDR_LIST": "NO", + "EPICS_PVA_ADDR_LIST": "", + **env, + }, + capture_output=True, + text=True, + ) + return result, time.monotonic() - start + + +@pytest.fixture +def cli(tmp_path: Path, ca_port: int, pva_port: int): + def cli(*args: str, **env: str) -> tuple[subprocess.CompletedProcess, float]: + return run(tmp_path, ca_port, pva_port, *args, **env) + + return cli + + +# --- configuration + + +def test_pv_timeout_from_env(): + assert Config.from_env({}).pv_timeout == PV_TIMEOUT == 5.0 + assert Config.from_env({"PV_TIMEOUT": "2.5"}).pv_timeout == 2.5 + + +@pytest.mark.parametrize("timeout", ["soon", "0"]) +def test_bad_pv_timeout(timeout: str): + with pytest.raises(ConfigError, match="PV_TIMEOUT"): + Config.from_env({"PV_TIMEOUT": timeout}) + + +def test_ioc_names(): + assert ioc_names({}) == [] + assert ioc_names({"IOC_NAMES": " a,b c,\nd "}) == ["a", "b", "c", "d"] + + +# --- healthy + + +def test_healthy_devices_pass(cli): + result, _ = cli( + "components/flipper", + "BL01C-EA-FLIP-01", + "components/align", + "BL01C-EA-ALIGN-01", + "components/panda", + "BL01C-EA-PANDA-01", + "components/iocs", + IOC_NAMES="bl01c-di-dcam-01", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.count(" PASSED") == 5 + + +# --- failures + + +def test_missing_ca_pv_fails_within_timeout(cli): + result, took = cli("components/align", "BL01C-EA-ALIGN-99") + assert result.returncode == 1 + assert ( + "BL01C-EA-ALIGN-99:Pitch:Acceleration_RBV did not answer over CA within " + "1 s (2 attempts)" in result.stdout + ) + assert "retrying" in result.stdout + assert took < 2 * TIMEOUT + 15 + + +def test_missing_pva_pv_fails_within_timeout(cli): + result, took = cli("components/panda", "BL01C-MO-PPANDA-99") + assert result.returncode == 1 + assert ( + "BL01C-MO-PPANDA-99:Pcap:Active did not answer over PVA within 1 s " + "(2 attempts)" in result.stdout + ) + assert took < 2 * TIMEOUT + 15 + + +def test_missing_ioc_fails(cli): + result, _ = cli("components/iocs", IOC_NAMES="bl01c-di-dcam-01,bl01c-no-such-ioc") + assert result.returncode == 1 + assert "test_ioc_uptime[bl01c-di-dcam-01] PASSED" in result.stdout + assert "BL01C-NO-SUCH-IOC:UPTIME did not answer over CA" in result.stdout + + +def test_flipper_position_out_of_range_fails(cli): + result, _ = cli("components/flipper", "BL01C-EA-FLIP-02") + assert result.returncode == 1 + assert "test_flipper_position_connects[BL01C-EA-FLIP-02] PASSED" in result.stdout + assert "BL01C-EA-FLIP-02:Position_RBV is 7, expected 0 (False) or 1 (True)" in ( + result.stdout + ) + + +def test_flipper_position_invalid_alarm_fails(cli): + result, _ = cli("components/flipper", "BL01C-EA-FLIP-03") + assert result.returncode == 1 + assert "BL01C-EA-FLIP-03:Position_RBV is in INVALID alarm" in result.stdout + + +def test_iocs_without_ioc_names_fails_clearly(cli): + result, _ = cli("components/iocs") + assert result.returncode == 1 + assert "ConfigError: IOC_NAMES not set" in result.stdout + + +def test_bad_pv_timeout_fails_clearly(cli): + result, _ = cli("components/align", "BL01C-EA-ALIGN-01", PV_TIMEOUT="soon") + assert result.returncode == 1 + assert "ConfigError: PV_TIMEOUT must be a number of seconds" in result.stdout diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..e80d517 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,312 @@ +import base64 +import logging + +import httpx +import pytest + +from fake_services import ( + CLIENT_ID, + CLIENT_SECRET, + CONFIG, + RUN, + TASK_ID, + TOKEN, + Clock, + FakeServices, + make, +) +from visr_tests.components import blueapi as blueapi_check +from visr_tests.components import tiled as tiled_check +from visr_tests.experiments.spectroscopy import SCANS, check_scan +from visr_tests.services import ( + BLUEAPI_URL, + SCAN_TIMEOUT, + TILED_URL, + TOKEN_URL, + CheckError, + Config, + ConfigError, + fetch_token, +) + +STEP = SCANS["step"] +SESSION = CONFIG.instrument_session + + +# --- configuration + + +def test_config_defaults(): + config = Config.from_env({}) + assert config.blueapi_url == BLUEAPI_URL == "https://b01-1-blueapi.diamond.ac.uk" + assert config.tiled_url == TILED_URL == "http://b01-1-tiled:8000" + assert config.token_url == TOKEN_URL + assert config.scan_timeout == SCAN_TIMEOUT == 300.0 + assert config.instrument_session == "" + + +def test_config_from_env(): + config = Config.from_env( + { + "BLUEAPI_URL": "https://b/", + "TILED_URL": "http://t:1/", + "TOKEN_URL": "https://k/token", + "CLIENT_ID": "id", + "CLIENT_SECRET": "secret\n", + "INSTRUMENT_SESSION": "cm1-1", + "SCAN_TIMEOUT": "60", + } + ) + assert config == Config("https://b", "http://t:1", "https://k/token", "id", + "secret", "cm1-1", 60.0) # fmt: skip + + +@pytest.mark.parametrize("timeout", ["soon", "0", "-1"]) +def test_config_bad_timeout(timeout: str): + with pytest.raises(ConfigError, match="SCAN_TIMEOUT"): + Config.from_env({"SCAN_TIMEOUT": timeout}) + + +def test_missing_instrument_session_fails_clearly(): + with pytest.raises(ConfigError, match="INSTRUMENT_SESSION not set"): + Config.from_env({}).require_instrument_session() + + +def test_config_repr_hides_secret(): + assert CLIENT_SECRET not in repr(CONFIG) + assert CLIENT_SECRET not in str(CONFIG) + + +# --- token + + +def test_token_fetch(): + fake = FakeServices() + client = httpx.Client(transport=httpx.MockTransport(fake)) + assert fetch_token(client, CONFIG) == TOKEN + (request,) = fake.requests + basic = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode() + assert request.headers["Authorization"] == f"Basic {basic}" + assert request.content == b"grant_type=client_credentials" + + +@pytest.mark.parametrize("missing", ["client_id", "client_secret"]) +def test_token_needs_credentials(missing: str): + config = Config(**{**CONFIG.__dict__, missing: ""}) + with pytest.raises(ConfigError, match="not set"): + fetch_token(httpx.Client(transport=httpx.MockTransport(FakeServices())), config) + + +@pytest.mark.parametrize( + "status, body, message", + [ + (401, {"error": "unauthorized_client", "error_description": CLIENT_SECRET}, + "HTTP 401: unauthorized_client$"), + (500, "not json", "HTTP 500$"), + (200, {"token_type": "Bearer"}, "no access_token"), + (200, {"access_token": ""}, "empty access_token"), + ], +) # fmt: skip +def test_token_failure(status: int, body: object, message: str): + fake = FakeServices() + fake.token_status, fake.token_body = status, body + client = httpx.Client(transport=httpx.MockTransport(fake)) + with pytest.raises(CheckError, match=message) as error: + fetch_token(client, CONFIG) + assert CLIENT_SECRET not in str(error.value) + + +def test_token_endpoint_unreachable(): + def refuse(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError(f"refused {request.headers['Authorization']}") + + client = httpx.Client(transport=httpx.MockTransport(refuse)) + with pytest.raises(CheckError, match="unreachable: ConnectError$"): + fetch_token(client, CONFIG) + + +# --- component checks + + +def test_components_pass(): + _, blueapi, tiled = make(fake := FakeServices()) + blueapi_check.test_blueapi_healthz(blueapi) + tiled_check.test_tiled_healthz(tiled) + tiled_check.test_tiled_anonymous_read(tiled) + tiled_check.test_tiled_binned_route(tiled) + # none of these needs a token + assert not any(r.url.host == "keycloak.test" for r in fake.requests) + assert "Authorization" not in fake.requests[0].headers + + +def test_blueapi_healthz_fails(): + fake = FakeServices() + fake.healthz_status = 502 + _, blueapi, _ = make(fake) + with pytest.raises(AssertionError, match="HTTP 502"): + blueapi_check.test_blueapi_healthz(blueapi) + + +def test_tiled_healthz_fails(): + fake = FakeServices() + fake.tiled_healthz_status = 503 + _, _, tiled = make(fake) + with pytest.raises(AssertionError, match="HTTP 503"): + tiled_check.test_tiled_healthz(tiled) + + +def test_tiled_anonymous_read_fails(): + fake = FakeServices() + fake.count = None # type: ignore[assignment] + _, _, tiled = make(fake) + with pytest.raises(CheckError, match="no meta.count"): + tiled_check.test_tiled_anonymous_read(tiled) + + +def test_tiled_binned_route_fails(): + fake = FakeServices() + fake.unknown_binned_status = 404 + _, _, tiled = make(fake) + with pytest.raises(AssertionError, match="HTTP 404 for an unknown run"): + tiled_check.test_tiled_binned_route(tiled) + + +def test_unreachable_service_fails_clearly(): + def refuse(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("refused") + + _, blueapi, _ = make(FakeServices()) + blueapi.client = httpx.Client(transport=httpx.MockTransport(refuse)) + with pytest.raises(CheckError, match="GET https://blueapi.test/healthz failed"): + blueapi.healthz() + + +# --- the scan + + +def test_scan_passes(): + fake = FakeServices() + fake.searches_until_found = 2 + fake.metadata_until_stopped = 2 + _, blueapi, tiled = make(fake) + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + assert fake.paths()[:4] == [ + "POST /token", + "POST /api/v1/tasks", + "POST /token", + "PUT /api/v1/worker/task", + ] + assert fake.paths()[-1] == f"GET /api/v1/binned/{RUN}" + + +def test_scan_sends_plan_and_params(): + fake = FakeServices() + _, blueapi, tiled = make(fake) + check_scan(blueapi, tiled, SCANS["fly"], SESSION, 10.0) + submit = next(r for r in fake.requests if r.url.path == "/api/v1/tasks") + assert b'"exposure_time":0.2' in submit.content.replace(b" ", b"") + + +@pytest.mark.parametrize( + "change, message", + [ + ({"submit_status": 422}, "rejected its parameters"), + ({"submit_status": 404}, "not found"), + ({"submit_body": {}}, "no task_id"), + ({"start_status": 409}, "worker busy"), + ({"outcome": {"outcome": "error", "type": "ValueError", "message": "bad"}}, + "failed: ValueError: bad"), + ({"errors": ["motor stalled"]}, "1 error"), + ({"stop": {"exit_status": "abort", "reason": "stopped", + "num_events": {"primary": 4}}}, "exit status 'abort': stopped"), + ({"stop": {"exit_status": "success", "num_events": {"primary": 3}}}, + "3 events, expected 4"), + ({"binned_body": {"RedTotal": [[1, 2, 3]]}}, r"RedTotal is \(1, 3\)"), + ({"binned_body": {}}, "RedTotal is None"), + ], +) # fmt: skip +def test_scan_fails(change: dict, message: str): + fake = FakeServices() + fake.__dict__.update(change) + _, blueapi, tiled = make(fake) + with pytest.raises(AssertionError, match=message): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + + +def test_unstarted_task_is_deleted(): + fake = FakeServices() + fake.start_status = 409 + _, blueapi, tiled = make(fake) + with pytest.raises(CheckError): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + assert f"DELETE /api/v1/tasks/{TASK_ID}" in fake.paths() + + +def test_scan_timeout_aborts_our_task(): + fake = FakeServices() + fake.polls_until_complete = 1000 + clock = Clock() + _, blueapi, tiled = make(fake, clock) + with pytest.raises(CheckError, match="did not complete in time; aborted it"): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + assert clock.now == 10.0 + assert fake.paths()[-1] == "PUT /api/v1/worker/state" + + +def test_scan_timeout_leaves_other_task(): + fake = FakeServices() + fake.polls_until_complete = 1000 + fake.active_task = {"task_id": "someone-else"} + _, blueapi, tiled = make(fake) + with pytest.raises(CheckError, match="no longer the active task, so not aborted"): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + assert "PUT /api/v1/worker/state" not in fake.paths() + + +def test_scan_timeout_no_run_in_tiled(): + fake = FakeServices() + fake.searches_until_found = 1000 + _, blueapi, tiled = make(fake) + with pytest.raises(CheckError, match=f"no run in tiled for task {TASK_ID}"): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + + +def test_scan_timeout_run_not_stopped(): + fake = FakeServices() + fake.metadata_until_stopped = 1000 + _, blueapi, tiled = make(fake) + with pytest.raises(CheckError, match=f"run {RUN} .* did not stop in time"): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + + +def test_blueapi_unauthorised(): + fake = FakeServices() + _, blueapi, tiled = make(fake) + blueapi._token = lambda: "wrong" + with pytest.raises(CheckError, match="not authorised"): + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + + +# --- secrets + + +def test_secret_never_logged(caplog: pytest.LogCaptureFixture): + caplog.set_level(logging.DEBUG) + basic = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode() + messages = [] + + fake = FakeServices() + _, blueapi, tiled = make(fake) + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + for change in ({"token_status": 401}, {"submit_status": 422}): + fake = FakeServices() + fake.__dict__.update(change) + _, blueapi, tiled = make(fake) + with pytest.raises(CheckError) as error: + check_scan(blueapi, tiled, STEP, SESSION, 10.0) + messages.append(str(error.value)) + + assert caplog.text # something was logged + for secret in (CLIENT_SECRET, TOKEN, basic): + assert secret not in caplog.text + assert not any(secret in message for message in messages) diff --git a/uv.lock b/uv.lock index 77e2047..a83a122 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,37 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "aioca" +version = "2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "epicscorelibs" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/fc/0d09d80af3bb7bedd474313daea7541914b157ae17f3bcebd10ac148432f/aioca-2.1.tar.gz", hash = "sha256:094b815fa26c0fec705ecaf9c1814883b3cf3b5ea973baeb669959661391b5bc", size = 233509, upload-time = "2026-05-06T16:28:57.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/89/da0f32b679b8769dd472eb2927308d328bf75c296629fd9f95135afacd0a/aioca-2.1-py3-none-any.whl", hash = "sha256:df0f062b81a4846d3e5705f0bfaf7bee9f876009302cf3b06ada93d6f5153b6f", size = 26480, upload-time = "2026-05-06T16:28:56.622Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] [[package]] name = "cachetools" @@ -11,6 +42,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/bb/1c6e7a89b11da19f137e4ef5a5c5fe47a5bb686449ba343944ccf8fb34b2/cachetools-7.2.0-py3-none-any.whl", hash = "sha256:3045213f186b89fdd95d94441354c4bd87c570b7a38d8c3fd1d9dd37f6dc90d8", size = 16918, upload-time = "2026-09-16T20:48:25.575Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -157,6 +197,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "epicscorelibs" +version = "7.0.10.99.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "setuptools" }, + { name = "setuptools-dso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/74/a5e060bbe9c489c4aae010d9692aeb5b041f79c4c721292802641aaf1f07/epicscorelibs-7.0.10.99.0.2.tar.gz", hash = "sha256:a5de1b5144fe3e64593f396f94c5b4389338118963d29d50349d7d28059acd2e", size = 1671392, upload-time = "2026-09-25T22:36:53.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/55/1be48726bc613ecf686b585702fce4e2bebbbb20c93f81c0e317009ac08f/epicscorelibs-7.0.10.99.0.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:36cf7ec5c50d4a3556e0fd70e3b75c481c3715bb29a574b4897290045bbbfbf0", size = 7862271, upload-time = "2026-09-25T22:36:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c2/384b1242aa51a50b4722e09fbb3fb165b337c5a313d520b8f70f4a116f6e/epicscorelibs-7.0.10.99.0.2-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:64fa1714eba80d4c4e7085722829607f0225275ac0fc16ea4eb04dfd45282e81", size = 5421562, upload-time = "2026-09-25T22:36:23.85Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f7/06298036271188982a06d7da4dce2e57e7aec15d452481d7bf8cffc0e486/epicscorelibs-7.0.10.99.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:648f7018451b2658f13481b32fe08ca0c268fa83c7e08fbb78d6f003fc65a84b", size = 2525199, upload-time = "2026-09-25T22:36:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7e/f9cb79143f6828ad8b4b20155eff0c3a670be8bad986661b559a5fc20cc9/epicscorelibs-7.0.10.99.0.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2899fea6b30927f7b990dc5a4f002ce51847cd860a247545d2eed29ee6b0d8dc", size = 7863711, upload-time = "2026-09-25T22:36:27.065Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/d8d3234b1db8ba4c7cdea909bb90c869c159ca9613796005425f85efb722/epicscorelibs-7.0.10.99.0.2-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:f12d3f1e2e2b5661f7185ee43980654c55802254de2469588fed11f2eee45956", size = 5423679, upload-time = "2026-09-25T22:36:28.655Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a7/a0048bb0575667595881903baa65946e2f479439e608db2dd7b7539e7b50/epicscorelibs-7.0.10.99.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:685a7ec303e32c415aec13e71189b74007f12d406375d1bf9d86e05884133cc8", size = 2525179, upload-time = "2026-09-25T22:36:30.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/cd022133ea86220cfbffc0ef8a56ab8eb2b70dd6974dff5698676c89b895/epicscorelibs-7.0.10.99.0.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a0478a07e16a583123652b419d23647b82d2b0eae28179a0eb2c6b8990fa9161", size = 7863965, upload-time = "2026-09-25T22:36:31.546Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/2e45eb89653a6bdf1056ee701fffda7f6b8ea5774da66f373f6c0b68580d/epicscorelibs-7.0.10.99.0.2-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:80b92315b5fbc98d8d6b5c7604206c903f1b9ad1a09c82162b23b88a6554cbd2", size = 5423666, upload-time = "2026-09-25T22:36:33.089Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/377f824eb43c967ad0991e0d919ca89812e0672b6e52bc17ab487913f9f2/epicscorelibs-7.0.10.99.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:46afbe3eb9a8f2e816c59f587eef552f6262e3e7625bf29855e73bfc534667ee", size = 2525166, upload-time = "2026-09-25T22:36:34.352Z" }, + { url = "https://files.pythonhosted.org/packages/27/d3/00e1223c170d74f52bb38847d1cab7323f8e6f604cbc5dad2293f0f24ed3/epicscorelibs-7.0.10.99.0.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c2f4f8ecf34858683e9116b7024bfd9b94a5842b69ecac1f8e62f55707d1338", size = 7863805, upload-time = "2026-09-25T22:36:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/1fedf3a5d6aab46cb10837f619d28d80c259c971673187510105e88a09b9/epicscorelibs-7.0.10.99.0.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:c2d217ba121fbedac5985ee2b8468a69ee9442e09940e96a2467fa3b19d448c7", size = 5153882, upload-time = "2026-09-25T22:36:36.927Z" }, + { url = "https://files.pythonhosted.org/packages/d4/41/680f24574b8f8e0432025d23eefb9c884d2fdcd7411884f94ee40d297ebc/epicscorelibs-7.0.10.99.0.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:23c07e7b9ededce742019a66cadc30f09d192f5eea2deaa7d930ee7c1df92620", size = 5358445, upload-time = "2026-09-25T22:36:38.145Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9b/cfb3ebf487e047d8169721df8e33c80845865000960fc06be5deea3e768a/epicscorelibs-7.0.10.99.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae8cec3ef5a90ce69d463bb0a836a35e131b0fbcc73bf6562ff6f0b568c67c10", size = 2572430, upload-time = "2026-09-25T22:36:39.539Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9d/7812592992c184d9f76c23d57bde229373f564588041cb02817c9dfc3b5a/epicscorelibs-7.0.10.99.0.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:761b28cf1a4303a49aa75d7c748d18eb7813938978a979ae05c2fc1d16828273", size = 7864024, upload-time = "2026-09-25T22:36:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/d0/e3/bc08dce676df4d4f8f66a1009fc2deb161946729ef718a34650f993aab2b/epicscorelibs-7.0.10.99.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7dd763e66554446b38d587cdc8b299a5454dda57f00e962eb7d0dde784d45e5e", size = 5153887, upload-time = "2026-09-25T22:36:42.214Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a45e2ef66a63bc446b7f85a8b2c05c72d971355a3767c6c2d5ecc9641b8b/epicscorelibs-7.0.10.99.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:694e52a952dbe5f431f14508a6fc4140ca2ec9536f1f5c05dcf5b3d2bd4869d9", size = 5358459, upload-time = "2026-09-25T22:36:43.739Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9d/2167520adc7d5d24dee0264cecc375256ffc36fe8fa7bc64b1b6d702e952/epicscorelibs-7.0.10.99.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:63536491f0f253f060fc7fc7e77380b980f00b369e2901ae93e4c18657a91377", size = 2572383, upload-time = "2026-09-25T22:36:45.051Z" }, +] + +[[package]] +name = "epicsdbbuilder" +version = "1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/31/c1b5f49621934d1bcd87d306817d9d81fa5d0935476d7ba73b98181f6557/epicsdbbuilder-1.5.tar.gz", hash = "sha256:698ad5882de4a626a82086139ff95204e8de72e63d03d9f22d4418da3a6c8566", size = 19193, upload-time = "2021-12-17T14:05:58.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/f8/858c81f516cb9d1c1f73e8bf379be2cbec4388c93f48a4eb118c47f32d4d/epicsdbbuilder-1.5-py3-none-any.whl", hash = "sha256:ae8dc724c72478d2c6a68b08145d027a50af98702d17e4692f2d73f145818e74", size = 23682, upload-time = "2021-12-17T14:05:57.141Z" }, +] + [[package]] name = "filelock" version = "4.0.6" @@ -166,6 +246,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/3b/b74ae9db2f78f1354278731372ae89870626be7b86cbb46b8d707acd1a98/filelock-4.0.6-py3-none-any.whl", hash = "sha256:9b139fb93b2ac5807f7feaf63aa4546fbd74074a2d3f554c040f40a4694d7b9f", size = 109077, upload-time = "2026-09-28T22:16:14.731Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.20" @@ -175,6 +292,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, ] +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -193,6 +319,206 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] +[[package]] +name = "nose2" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/b2/73553c0e19c4bfe174940de03c2e1814f407e3524628690800f2536bf7f7/nose2-0.16.0.tar.gz", hash = "sha256:19db5ad20e264501a8ee64e3e157a3766a5e744170e54ceecb4e5ca09b08655a", size = 172667, upload-time = "2026-03-02T00:49:52.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/3b/e93d177001ac3ba3cb2ebed7727442e1ee61990271f2bf39caef63798ead/nose2-0.16.0-py3-none-any.whl", hash = "sha256:a637c508b1fff5882c5f0f573226abe6f43b2f8005fef8896f57174d7d0e0c31", size = 213451, upload-time = "2026-03-02T00:49:50.609Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "p4p" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "epicscorelibs" }, + { name = "nose2" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ply" }, + { name = "pvxslibs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/bb/65e548f9da93507da9fcf93647e141a93f44b0393184aa0221fdf3b8885b/p4p-4.3.0.tar.gz", hash = "sha256:4efcc5bd12b3a0e28a47810ef19fbc6049b8c3bbf27372a346631ea116338bb8", size = 402474, upload-time = "2026-09-29T22:12:16.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/45/dd10b95870531fe47cf2bb214bbea09edee7fb969e432b5cc17db81edcf6/p4p-4.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a9bac6559328cd022e8c37d2d174910f4f139ffab8aab80903ce9e48080f05de", size = 694993, upload-time = "2026-09-29T22:11:51.192Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bd/2b8f68a5a164f7e6fd00ddb920aa933a1f3db3fc34d3a2fc98d9cec57246/p4p-4.3.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:b38288455d063ae1744f2941b1ab7c7312700e3b05256ca1e00b5592fc8af0e3", size = 416649, upload-time = "2026-09-29T22:11:52.29Z" }, + { url = "https://files.pythonhosted.org/packages/82/aa/14e31d10d55fa5f39996d5e20c24dd2d2b8b18825ad689a5334f989f411a/p4p-4.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:063fef5c7e76010565df0ebc025b399aaf7d3a11a6bcbd7c33130a2c7f42074c", size = 331269, upload-time = "2026-09-29T22:11:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/d9a3dbcbb3c7157e568e1200cb1da1c948b6ccb99ec251bbaa99e5eaa569/p4p-4.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:bd699bcb95e5d206eeb5a22b9ea407f8efffd11fb6a0339e5794ab4c8c1ebe11", size = 698174, upload-time = "2026-09-29T22:11:54.21Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/9e6f9b9326e94bc6f10f9f1fe2c4ad338e255717578fcee9c23351ba0dff/p4p-4.3.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:63ea58d978a171e6a9bb28d95d2e64c1bbd936faf56cafd9a61a992893fbca84", size = 412284, upload-time = "2026-09-29T22:11:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/2f2cb0fb9a1cf762066626d3a39f427f1b0222a342b8a90f3753c3bd6b78/p4p-4.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:badb4b4202f06575c215ad9d2587bb50e8bdddf4ca7ec27a5ae9b6c54ffa23a4", size = 330910, upload-time = "2026-09-29T22:11:56.359Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/1c1ad445518fe84ab0dee46cee41e5d19d7bcd370058970fcf91b3e39942/p4p-4.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:23e45b475c3ad8ac9bb2b56ae67d6e97bcb3a9dbf6cb5243e5f11254e388a7a5", size = 696890, upload-time = "2026-09-29T22:11:57.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/55/2c393b8fcd3f330f994a139a93b1b81537d6c251911fead40e60c93e9ca0/p4p-4.3.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:d0a87bb4c599f5cc0dd910b22c742f0b706fb14b8c48bba8ccf6ec5e5a71c662", size = 409568, upload-time = "2026-09-29T22:11:58.463Z" }, + { url = "https://files.pythonhosted.org/packages/60/1c/b4c8f1b3ae7c877677b7ef730c3e7e7e8d4679e7d2b3e0b484ccaa8e3f90/p4p-4.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:e76cb82428b8719f195c2d06cdf5888445e25631cf2db43852aec6cc51bc9b85", size = 330059, upload-time = "2026-09-29T22:11:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/2e/04/6f821dcd9aa8c3f917a0671afa48177f07c7b2543f0d6f4f1539e35fd97f/p4p-4.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:83118d5698c1b126cdeccfc1d2bc64b6f79cc912109217997ec3f2410bffe8c6", size = 699788, upload-time = "2026-09-29T22:12:00.537Z" }, + { url = "https://files.pythonhosted.org/packages/04/d5/8e0c9d451b5e563908b884318ce852da8451270c8c6a1b2acdf62733d29f/p4p-4.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d4525918e8eff8a83127c96589b91d320c0c56372a1addc040b30e4806ecad57", size = 392666, upload-time = "2026-09-29T22:12:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/6c5c7bba22268d899645e13fd09e0ab9e3646d03f64d9a92c1d2beb0a002/p4p-4.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f3d62b3f02b833ae45e02008446970c1fbb30e765df61d32405a60dbef92bff2", size = 424161, upload-time = "2026-09-29T22:12:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6a/4829bb0f606bbb1121ea8f4a3ef1c48d0a598b9701f34dcc4c9bd9ded193/p4p-4.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:c91c8495869124e240448640fd915655f120a7a7137ee567db32e5f0e2345963", size = 336196, upload-time = "2026-09-29T22:12:03.817Z" }, + { url = "https://files.pythonhosted.org/packages/08/1c/e29cb80616414ff5495c57b4b4986b5c8f7de9d92de730a048218dbf4156/p4p-4.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:780474b71ae00841ceab172b7a54919164ec40933dbbf25b18bcb075c51eb9e1", size = 718086, upload-time = "2026-09-29T22:12:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/9bedb62eda1016386808bd66ec21ec2fc4af06fc042f1077a5a7aa4cc4d9/p4p-4.3.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3042b6d18bfeba85acb583801b8834bfc5d1e0a8718c164a0cae87a4def9e544", size = 393204, upload-time = "2026-09-29T22:12:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/75/f5094f7d5a0c2e55535325f500e49d25e927a31d36638c540531f2de2087/p4p-4.3.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:55662f9d36417518bccc098b8176560fef7b8a4899406dced9478e0811c6efa3", size = 422725, upload-time = "2026-09-29T22:12:07.632Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bd/5e57dde59902b62d40a07e83710c2d41b222fcea4d858bbefcd4fba1ed39/p4p-4.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ec36bd984f6855e01479c78b43a8a73e8fe45e71ecc4ce71565f0a0b70c5d73", size = 349350, upload-time = "2026-09-29T22:12:08.819Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -220,6 +546,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "ply" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, +] + [[package]] name = "pre-commit" version = "4.6.2" @@ -236,6 +571,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] +[[package]] +name = "pvxslibs" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "epicscorelibs" }, + { name = "setuptools-dso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/c0/602edb25dd41c3fdffaa248e7a784e3e9e2b280a366cc0fc4c4526ed5420/pvxslibs-1.5.3.tar.gz", hash = "sha256:d366308ce09790a459631acc300f1df0b4091292a753bc08b0667ca9d59fb469", size = 674269, upload-time = "2026-09-28T23:44:43.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/04/b11cabd89236a76324b7e570eb6f427389580b03ee5e13e5518086e1c2b8/pvxslibs-1.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:0455cbf61bb277c305fd24e3d1e8a9a6564a6c9b383f3ffda639bf89ed51dfc1", size = 2949862, upload-time = "2026-09-28T23:44:15.862Z" }, + { url = "https://files.pythonhosted.org/packages/0e/99/e3d4e3b8feae70ea2546533dc348ae325884db2ddf88b423f4452cc21d46/pvxslibs-1.5.3-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:d5095011570d28aa78f38c1b2cc48deeedcdfbd7e406cca057ef26a0d0a25d3a", size = 2674252, upload-time = "2026-09-28T23:44:17.299Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fe/3d90c07f30c26a2737f6745137c4ad42572d9c1cf48645bdb0736608c13b/pvxslibs-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:3d497e3081699ba56144f9dfc48fe17ddf41090b10c447d8c0efce5756f638a0", size = 809523, upload-time = "2026-09-28T23:44:18.757Z" }, + { url = "https://files.pythonhosted.org/packages/4a/38/bb51c5c320832c5365b01aaf5ed2b87c8c29515569b1f77699d42d1c4420/pvxslibs-1.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7d2115294f877cf8dec35e86d6c396e847ff5b20aaeb6438c940b6ca059d6e3c", size = 2951700, upload-time = "2026-09-28T23:44:19.838Z" }, + { url = "https://files.pythonhosted.org/packages/e7/67/0b8f6929b1b203d6e4f570e5b5a5c02da4a5eb0ab3e3d9da901431ddd7b3/pvxslibs-1.5.3-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:12a40eb36c0c5e162c0d1ec94de7b029c12403895187f24e54070c639ed96d3a", size = 2673624, upload-time = "2026-09-28T23:44:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/510fe0bb8b5c0ad44424c371f2a3beb5dc77914f8ad84def0abe7bdce4fb/pvxslibs-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:e4dbed186564241acc8acd2463e8d0cf49c63d5b2a0eb64570a0e663156f1d6e", size = 809478, upload-time = "2026-09-28T23:44:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/9dc103e340177a747233a82621a0e434862a19a6b6e20f7cbf7fbdf94206/pvxslibs-1.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aac254f06bfcd517017d6abb0d4ebebb77b5975440478e142ea699f4e892d0ce", size = 2951668, upload-time = "2026-09-28T23:44:24.059Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/2a9030f911d1b6c7bbbab34bd2e8ef7a42f0764ad000b6bad6275023e223/pvxslibs-1.5.3-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:6feaab0caefa06d86cc46aef47a321149f4b737692038934bd0ed4bff38161b4", size = 2673632, upload-time = "2026-09-28T23:44:25.495Z" }, + { url = "https://files.pythonhosted.org/packages/80/a6/d605f25ba70a37da1e8113cc33aca6d48ce4e9e9599261b14028f5d074d5/pvxslibs-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:64fbef84494f3c8183384c76b144ee9da67717d9ced98dfd4e40ae8f1a91921b", size = 809509, upload-time = "2026-09-28T23:44:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f1/9dd574d2e155cf9aaca1817e6e49e279edaeedcd78948cc6d6b056399887/pvxslibs-1.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49f3ea357871f36f1d75e120725a79843561264a839257f8ce4245befae73f97", size = 2952019, upload-time = "2026-09-28T23:44:27.726Z" }, + { url = "https://files.pythonhosted.org/packages/7b/24/307f9769b01a506a612c23b6194a47b978d39284f4210d0bb6b8cad403e6/pvxslibs-1.5.3-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:140c3491408091735469f3fa5970675e2538071edf3528fff6baaf092597f57d", size = 2488033, upload-time = "2026-09-28T23:44:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/5f87b24c052bc73d4bdca9e2c13ca73dfa6d9138ed3f1d4a9ca112331a64/pvxslibs-1.5.3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:acde6a6aa77eacaf7298cd8dfb48c243c66db7ec72825d6f34be942ddf4131b0", size = 2659698, upload-time = "2026-09-28T23:44:30.136Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/516c084b8ec75faf88e6f9b1c5cee391317f20fb62dde5bfb5a86f4daaba/pvxslibs-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:60e300cd215c44bf92644aa2c2f3546162e0b9fe1eab5a26376ccc17796d6205", size = 831285, upload-time = "2026-09-28T23:44:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/bb/30/9ab4ffff8944003117ab71e231bf9a34f62b8ce27785457217dc738ba329/pvxslibs-1.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:7612e3faa645a78112ee67fb1626f86282ad0b79db3a6a97dfc2236081520a5a", size = 2951982, upload-time = "2026-09-28T23:44:32.596Z" }, + { url = "https://files.pythonhosted.org/packages/be/26/bb6a750f947e4e7d1018426ed29b6e4645af6d40d789ac26cef8581cffeb/pvxslibs-1.5.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf9e272a2b015e9ad13b5cd816a41576d1150367bd1faa86155ce60b5c1580e5", size = 2488006, upload-time = "2026-09-28T23:44:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/f7e7dcd556f8f16d1d8a4629c0024ffbb16a03be3f0c97ff51b0ab5938ba/pvxslibs-1.5.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:48316142b410861a109dea25114f6833ffa5b243f8fefa58742a4fa924a2f49b", size = 2659725, upload-time = "2026-09-28T23:44:35.659Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fd/475a3a1c60b133cb0365efdbbd8aa80f0d36dc36c5148884ec354837ca33/pvxslibs-1.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c3b89233dd18d756c16163eb55afb4dea394ff900bff9350bf423f7fa5566e21", size = 831280, upload-time = "2026-09-28T23:44:36.756Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -286,6 +650,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" @@ -392,6 +769,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "setuptools-dso" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/16/3193318f3012890c3499a74c58b6ba27c9b9bad793479852f2f21508e6c2/setuptools_dso-2.12.4.tar.gz", hash = "sha256:5b665849c0b37c019dee72c8b8b4cfbae131d06d49364bc90444e8596942ed6f", size = 21729, upload-time = "2026-08-28T17:17:56.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/c2/2ec71837ac862d823de280e72c245686b37de6dde0a18a02c9c18f589c39/setuptools_dso-2.12.4-py2.py3-none-any.whl", hash = "sha256:fce70c8b6fb58f1ba609e455355776ddaa53363b84d1b7f0ec2c820e63544033", size = 24201, upload-time = "2026-08-28T17:17:55.895Z" }, +] + +[[package]] +name = "softioc" +version = "4.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "epicscorelibs" }, + { name = "epicsdbbuilder" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pvxslibs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/98/15e812e8b6345a2fa305d5d08b7ec8f3545ef815b9e10d1d00e39175516f/softioc-4.7.2.tar.gz", hash = "sha256:993ef8f6b81f0908baa4403422068334757057f006a61f554c450755c9ebf0c6", size = 94258, upload-time = "2026-09-11T11:27:47.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b0/cf1a69acff090f6034804ba945c1645cb3de27726e946a4326901e24ecd6/softioc-4.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95f587d42b1e73fb823b9eadc1b2f0a91f314a2493b6eb45c549ce8613837b72", size = 119316, upload-time = "2026-09-11T11:27:28.343Z" }, + { url = "https://files.pythonhosted.org/packages/34/1e/4670dbf11a23807877c76f966835860416a4461743a87e3097384065359b/softioc-4.7.2-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:64cde2e63a11dec42fef779a3184fa0aec89c6f4c3096b37245ee5c82dbd4813", size = 125013, upload-time = "2026-09-11T11:27:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/5f/89/adcc47447aca20f85f3acca3604827d6dae83fef8a1990cf9f81c74bbff4/softioc-4.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:b88058cb273d88aa5e6b767fa35e5e00a50a28feaec60bf8ae494322d572e13b", size = 118670, upload-time = "2026-09-11T11:27:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/d5/79/ac63645d0777bbd61d77e7c38367cafacc5600cc5e7af9ddae82e5117e88/softioc-4.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ee2f22f915f68a61530376ead58d1621a290eae23a5316fc39b23ccfa24cbe5", size = 119378, upload-time = "2026-09-11T11:27:31.331Z" }, + { url = "https://files.pythonhosted.org/packages/19/19/ccd147090d2ed8f45d00e6532c402123038d1129aee734319254724806dc/softioc-4.7.2-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:d156345d83aea327a41aad7a88a1e51cde02add8a2d88f802ca6dce31967c67a", size = 125038, upload-time = "2026-09-11T11:27:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc85c9daf13dfce03d9f19011e26ad37149f11c1cae0e5abffbc2b48c80b/softioc-4.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:97c8d1b33b32a960b27c1730fc5a5d03c6cd4788f09563c293a455057def2a8f", size = 118671, upload-time = "2026-09-11T11:27:33.05Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a7/2e760be9f0de3e67b227179a577aa08b3feed5d889667de18e209d8f1c44/softioc-4.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e24d71331371b10f619c191c67d862751deba5274c436b00dd6e4c7be3f70aff", size = 119365, upload-time = "2026-09-11T11:27:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/b5/15/726bc9785fb40b6ec90c326d67e4e3ee8219c4bbc3363c3301238f233a32/softioc-4.7.2-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:22614d21cd6b40eb36b16581a42ca248b546b00c8de14d0189858de9f0678b04", size = 125029, upload-time = "2026-09-11T11:27:34.964Z" }, + { url = "https://files.pythonhosted.org/packages/bf/58/18f6e3c0f86d83f8a61cb6a2334ce75b658e1ab0d0e983c6840c7ca1cbbb/softioc-4.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:14006ac1240cf11eb8e18491bea3b7d1b5a5ef786cd24da6561c8ecadabb61c4", size = 118673, upload-time = "2026-09-11T11:27:35.853Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e8/dacb401d4e1637a37c35ddfbd2aad29cefdf3d569f1ed8b5dfb9b0953417/softioc-4.7.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4090a25a1f43e91ffbfac433fd770343e587f660a1970f9aee10340341a496c5", size = 119351, upload-time = "2026-09-11T11:27:36.75Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cb/435623ee70aa9fee0353f0a4425eadb807bdefd4cef89d6cdc52675009a4/softioc-4.7.2-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:9dbee9ead4f25b9abc55545d1ba75fd0231b379afe4f594376cb3a91bdf510ea", size = 124984, upload-time = "2026-09-11T11:27:37.755Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/f05bb6f363680b44374bdc10545130155a0603138a6c5d977c7717eaab74/softioc-4.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:9c6c3bc10642e1750ded116bc4fb077375d76083eef58f1cbbeb2b9f4e084dee", size = 119252, upload-time = "2026-09-11T11:27:38.893Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -564,6 +990,13 @@ wheels = [ [[package]] name = "visr-tests" source = { editable = "." } +dependencies = [ + { name = "aioca" }, + { name = "httpx" }, + { name = "p4p" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] [package.dev-dependencies] dev = [ @@ -572,11 +1005,19 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "softioc" }, { name = "tox-uv" }, { name = "types-mock" }, ] [package.metadata] +requires-dist = [ + { name = "aioca", specifier = ">=2.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "p4p", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, +] [package.metadata.requires-dev] dev = [ @@ -585,6 +1026,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "softioc", specifier = ">=4.7.2" }, { name = "tox-uv" }, { name = "types-mock" }, ]