From 5384eff0ba7c1363404469f0af647ef6780d26fe Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Thu, 23 Jul 2026 16:30:57 -0400 Subject: [PATCH 01/14] feat: add guarded AS-MS hardware runner --- docs/asms_flex_workflow_test.md | 32 +++ scripts/run_asms_hardware.py | 318 ++++++++++++++++++++++++ tests/scripts/test_run_asms_hardware.py | 204 +++++++++++++++ 3 files changed, 554 insertions(+) create mode 100644 scripts/run_asms_hardware.py create mode 100644 tests/scripts/test_run_asms_hardware.py diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index be14371..3066d2c 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -215,6 +215,38 @@ have the same token and audit requirement while the run is `awaiting-recovery`. ## Fastest safe real-Flex sequence +From macOS or Linux, `scripts/run_asms_hardware.py` provides the shortest +operator path through exact offline preflight, live connector health, deck +fixture validation, upload, analysis, execution, and command-count evidence. +Its default is analysis-only and cannot move hardware: + +```sh +uv run --extra test python scripts/run_asms_hardware.py \ + --host --columns 1 +``` + +After the empty-plate gripper qualification and the physical deck checklist +below are complete, opt in to the shortened one-column mechanics run: + +```sh +uv run --extra test python scripts/run_asms_hardware.py \ + --host --columns 1 --execute \ + --confirm-deck-ready ASMS-DECK-READY +``` + +Replace all test liquids and both complete tip racks before the two-column run: + +```sh +uv run --extra test python scripts/run_asms_hardware.py \ + --host --columns 2 --execute \ + --confirm-deck-ready ASMS-DECK-READY +``` + +Add `--scientific` only for the final real-sample run; it restores the full +programmed delays. The runner intentionally keeps mutation checkpoints disabled. +First prove the physical workflow, then use the separately authenticated +controlled-mutation procedure above. + 1. **Verify the exact bundle offline.** Run the default exact preflight against the checked-in protocol and `protocols/asms/labware/`. Continue only when it prints `READY`; record the pinned definition hashes. diff --git a/scripts/run_asms_hardware.py b/scripts/run_asms_hardware.py new file mode 100644 index 0000000..4df9456 --- /dev/null +++ b/scripts/run_asms_hardware.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Upload, analyze, and explicitly execute the prepared AS-MS protocol on a Flex.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from collections import Counter +from collections.abc import Sequence +from pathlib import Path + +import httpx + +from unitelabs.opentrons_flex.protocol_preflight import inspect_protocol + +_ROOT = Path(__file__).resolve().parents[1] +_PROTOCOL = _ROOT / "protocols" / "asms" / "asms_single_point_wash_and_elute.py" +_LABWARE_DIR = _PROTOCOL.parent / "labware" +_LABWARE_DEFINITIONS = tuple(sorted(_LABWARE_DIR.glob("*.json"))) +_HTTP_API_VERSION_HEADER = "Opentrons-Version" +_EXECUTION_CONFIRMATION = "ASMS-DECK-READY" +_TERMINAL_RUN_STATES = {"succeeded", "failed", "stopped"} +_TERMINAL_ANALYSIS_STATES = {"completed", "failed"} +_EXPECTED_LABWARE_HASHES = { + "azenta_96_wellplate_200ul_pcr": "43506d5482e3dfebff377e56b709150c81415473efc9cf2c6362dc4b68a1e20f", + "thermokingfisherdeepwell_96_wellplate_2000ul": ( + "2ea9c15468816ace3970fe497cef7e1dc22d5f9ab033656bf9472a62396dfb47" + ), +} +_EXPECTED_DECK_FIXTURES = { + "cutoutA3": "singleRightSlot", + "cutoutB2": "magneticBlockV1", + "cutoutC1": "temperatureModuleV2", + "cutoutD1": "trashBinAdapter", +} +_EXPECTED_TWO_COLUMN_COMMANDS = { + "moveLabware": 9, + "pickUpTip": 26, + "aspirate": 82, + "dispense": 66, + "temperatureModule/deactivate": 1, +} + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Run the exact AS-MS bundle through the connector's embedded robot-server. " + "The default uploads and analyzes without moving hardware. Execution requires " + f"--execute and --confirm-deck-ready {_EXECUTION_CONFIRMATION}." + ) + ) + parser.add_argument("--host", default="169.254.105.239", help="Flex IP or hostname.") + parser.add_argument("--port", type=int, default=31950, help="Connector robot-server HTTP port.") + parser.add_argument("--columns", type=int, choices=(1, 2), default=1) + parser.add_argument( + "--scientific", + action="store_true", + help="Use full scientific delays. The default connector test mode shortens delays.", + ) + parser.add_argument( + "--execute", + action="store_true", + help="Create and play the analyzed run. Omit this flag for analysis-only mode.", + ) + parser.add_argument( + "--confirm-deck-ready", + default=None, + metavar="PHRASE", + help=f"Required execution confirmation phrase: {_EXECUTION_CONFIRMATION}", + ) + parser.add_argument("--analysis-timeout", type=float, default=120.0) + parser.add_argument("--run-timeout", type=float, default=3600.0) + return parser + + +def _runtime_parameters(*, columns: int, scientific: bool) -> dict[str, bool | int]: + return { + "connector_test_mode": not scientific, + "number_of_columns": columns, + "enable_mutation_checkpoints": False, + } + + +def _exact_protocol_files() -> list[tuple[str, tuple[str, bytes, str]]]: + files = [("files", (_PROTOCOL.name, _PROTOCOL.read_bytes(), "text/x-python"))] + files.extend( + ("files", (definition.name, definition.read_bytes(), "application/json")) for definition in _LABWARE_DEFINITIONS + ) + return files + + +def _latest_analysis(protocol: dict) -> dict | None: + analyses = protocol.get("analysisSummaries") or [] + return analyses[-1] if analyses else None + + +def _deck_configuration_errors(deck_response: dict) -> list[str]: + fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) + fixture_by_cutout = {fixture.get("cutoutId"): fixture for fixture in fixtures} + errors = [] + for cutout, expected_fixture in _EXPECTED_DECK_FIXTURES.items(): + actual = fixture_by_cutout.get(cutout, {}).get("cutoutFixtureId") + if actual != expected_fixture: + errors.append(f"{cutout}: expected {expected_fixture}, found {actual or 'missing'}") + + temperature_fixture = fixture_by_cutout.get("cutoutC1", {}) + if not temperature_fixture.get("opentronsModuleSerialNumber"): + errors.append("cutoutC1: Temperature Module serial number is missing") + return errors + + +def _hardware_inventory_errors(pipettes: dict, modules: dict) -> list[str]: + errors = [] + right = pipettes.get("right") + if not isinstance(right, dict): + errors.append("right pipette mount is empty") + elif right.get("name") != "flex_8channel_1000": + errors.append(f"right pipette: expected flex_8channel_1000, found {right.get('name') or 'unknown'}") + + module_items = modules.get("data") + if not isinstance(module_items, list): + errors.append("module inventory response is invalid") + elif not any( + module.get("moduleModel") == "temperatureModuleV2" or module.get("model") == "temperatureModuleV2" + for module in module_items + ): + errors.append("Temperature Module GEN2 is not connected") + return errors + + +def _validate_exact_bundle() -> None: + report = inspect_protocol( + _PROTOCOL, + custom_labware_paths=[_LABWARE_DIR], + expected_custom_labware_hashes=_EXPECTED_LABWARE_HASHES, + ) + if not report.simulation_completed or not report.exact_bundle_ready: + raise RuntimeError(f"Exact AS-MS preflight failed: {report.to_dict()}") + print( + "Exact preflight READY: " + f"{report.command_count} records, {report.tip_pickups} tip pickups, " + f"{report.gripper_moves} gripper moves" + ) + + +def _response_data(response: httpx.Response, *, action: str) -> dict: + if not response.is_success: + raise RuntimeError(f"{action} failed with HTTP {response.status_code}: {response.text}") + payload = response.json() + data = payload.get("data") + if not isinstance(data, dict): + raise RuntimeError(f"{action} returned an unexpected response: {payload}") + return data + + +def _wait_for_analysis( + client: httpx.Client, + protocol: dict, + *, + timeout: float, +) -> dict: + protocol_id = protocol["id"] + deadline = time.monotonic() + timeout + analysis = _latest_analysis(protocol) + while (analysis is None or analysis.get("status") not in _TERMINAL_ANALYSIS_STATES) and time.monotonic() < deadline: + time.sleep(0.5) + protocol = _response_data(client.get(f"/protocols/{protocol_id}"), action="protocol analysis poll") + analysis = _latest_analysis(protocol) + if analysis is None or analysis.get("status") not in _TERMINAL_ANALYSIS_STATES: + raise TimeoutError(f"Protocol analysis did not finish within {timeout:g} seconds") + return analysis + + +def _wait_for_run(client: httpx.Client, run_id: str, *, timeout: float) -> dict: + deadline = time.monotonic() + timeout + previous = None + while time.monotonic() < deadline: + run = _response_data(client.get(f"/runs/{run_id}"), action="run status poll") + status = run.get("status") + current = run.get("current") + marker = (status, json.dumps(current, sort_keys=True)) + if marker != previous: + print(f"Run {run_id}: status={status}, current={current}") + previous = marker + if status in _TERMINAL_RUN_STATES: + return run + time.sleep(1) + raise TimeoutError(f"Run {run_id} did not finish within {timeout:g} seconds") + + +def _read_command_counts(client: httpx.Client, run_id: str) -> tuple[int, Counter]: + response = client.get(f"/runs/{run_id}/commands", params={"pageLength": 1000}) + if not response.is_success: + raise RuntimeError(f"Command log failed with HTTP {response.status_code}: {response.text}") + commands = response.json().get("data") + if not isinstance(commands, list): + raise RuntimeError(f"Command log returned an unexpected response: {response.text}") + return len(commands), Counter(command.get("commandType") for command in commands) + + +def _verify_two_column_evidence(total: int, counts: Counter) -> None: + errors = [] + if total != 462: + errors.append(f"total commands: expected 462, found {total}") + for command_type, expected in _EXPECTED_TWO_COLUMN_COMMANDS.items(): + actual = counts[command_type] + if actual != expected: + errors.append(f"{command_type}: expected {expected}, found {actual}") + if errors: + raise RuntimeError("Two-column command evidence mismatch: " + "; ".join(errors)) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + if args.execute and args.confirm_deck_ready != _EXECUTION_CONFIRMATION: + print( + f"BLOCKED: execution requires --confirm-deck-ready {_EXECUTION_CONFIRMATION}", + file=sys.stderr, + ) + return 2 + + _validate_exact_bundle() + runtime_parameters = _runtime_parameters(columns=args.columns, scientific=args.scientific) + base_url = f"http://{args.host}:{args.port}" + print(f"Target: {base_url}") + print(f"Runtime parameters: {json.dumps(runtime_parameters, sort_keys=True)}") + + with httpx.Client( + base_url=base_url, + headers={_HTTP_API_VERSION_HEADER: "*"}, + timeout=30.0, + ) as client: + health = client.get("/health") + if not health.is_success: + raise RuntimeError(f"Connector health check failed with HTTP {health.status_code}: {health.text}") + print("Connector health: PASS") + + pipettes_response = client.get("/pipettes") + if not pipettes_response.is_success: + raise RuntimeError( + f"Pipette inventory failed with HTTP {pipettes_response.status_code}: {pipettes_response.text}" + ) + modules_response = client.get("/modules") + if not modules_response.is_success: + raise RuntimeError( + f"Module inventory failed with HTTP {modules_response.status_code}: {modules_response.text}" + ) + inventory_errors = _hardware_inventory_errors(pipettes_response.json(), modules_response.json()) + if inventory_errors: + raise RuntimeError("Hardware inventory is not AS-MS ready: " + "; ".join(inventory_errors)) + print("Hardware inventory: PASS") + + deck = _response_data(client.get("/deck_configuration"), action="deck configuration") + deck_errors = _deck_configuration_errors({"data": deck}) + if deck_errors: + raise RuntimeError("Deck configuration is not AS-MS ready: " + "; ".join(deck_errors)) + print("Deck configuration: PASS") + + response = client.post( + "/protocols", + files=_exact_protocol_files(), + data={"run_time_parameter_values": json.dumps(runtime_parameters)}, + ) + protocol = _response_data(response, action="protocol upload") + analysis = _wait_for_analysis(client, protocol, timeout=args.analysis_timeout) + if analysis.get("status") != "completed": + raise RuntimeError(f"Protocol analysis failed: {analysis}") + print(f"Protocol analysis: PASS (protocol_id={protocol['id']})") + + if not args.execute: + print("ANALYSIS ONLY: no run was created and no hardware moved.") + return 0 + + response = client.post( + "/runs", + json={"data": {"protocolId": protocol["id"], "runTimeParameterValues": runtime_parameters}}, + ) + run = _response_data(response, action="run creation") + run_id = run["id"] + print(f"Created run {run_id}") + response = client.post( + f"/runs/{run_id}/actions", + json={"data": {"actionType": "play"}}, + ) + if response.status_code != 201: + raise RuntimeError(f"Run start failed with HTTP {response.status_code}: {response.text}") + + try: + run = _wait_for_run(client, run_id, timeout=args.run_timeout) + except KeyboardInterrupt: + print(f"\nInterrupt received; stopping run {run_id}...") + stop = client.post( + f"/runs/{run_id}/actions", + json={"data": {"actionType": "stop"}}, + ) + if stop.status_code != 201: + print(f"WARNING: stop returned HTTP {stop.status_code}: {stop.text}", file=sys.stderr) + return 130 + + total, counts = _read_command_counts(client, run_id) + print( + "Command evidence: " + f"total={total}, tips={counts['pickUpTip']}, aspirates={counts['aspirate']}, " + f"dispenses={counts['dispense']}, gripper_moves={counts['moveLabware']}" + ) + if run.get("status") != "succeeded": + raise RuntimeError(f"Run {run_id} ended in {run.get('status')}: {run.get('errors', [])}") + if args.columns == 2: + _verify_two_column_evidence(total, counts) + print(f"AS-MS hardware workflow PASS (run_id={run_id})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/scripts/test_run_asms_hardware.py b/tests/scripts/test_run_asms_hardware.py new file mode 100644 index 0000000..651b477 --- /dev/null +++ b/tests/scripts/test_run_asms_hardware.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from collections import Counter +from typing import Any + +import pytest + +from scripts import run_asms_hardware + + +class _Response: + def __init__(self, status_code: int, payload: dict[str, Any]) -> None: + self.status_code = status_code + self._payload = payload + self.text = str(payload) + self.is_success = 200 <= status_code < 300 + + def json(self) -> dict[str, Any]: + return self._payload + + +class _Client: + def __init__(self, *args, **kwargs) -> None: + self.posts: list[str] = [] + + def __enter__(self): + return self + + def __exit__(self, *args) -> None: + return None + + def get(self, path: str, *, params: dict | None = None) -> _Response: + if path == "/health": + return _Response(200, {"robot_model": "OT-3 Standard"}) + if path == "/pipettes": + return _Response(200, {"left": {}, "right": {"name": "flex_8channel_1000"}}) + if path == "/modules": + return _Response(200, {"data": [{"moduleModel": "temperatureModuleV2", "serialNumber": "TM-123"}]}) + if path == "/deck_configuration": + return _Response(200, _ready_deck()) + if path == "/runs/run-1": + return _Response(200, {"data": {"id": "run-1", "status": "succeeded", "current": None}}) + if path == "/runs/run-1/commands": + commands = [] + for command_type, count in run_asms_hardware._EXPECTED_TWO_COLUMN_COMMANDS.items(): + commands.extend({"commandType": command_type} for _ in range(count)) + commands.extend({"commandType": "other"} for _ in range(462 - len(commands))) + return _Response(200, {"data": commands}) + raise AssertionError(f"Unexpected GET {path} params={params}") + + def post( + self, + path: str, + *, + files: list | None = None, + data: dict | None = None, + json: dict | None = None, + ) -> _Response: + self.posts.append(path) + if path == "/protocols": + assert files is not None + assert data is not None + return _Response( + 201, + { + "data": { + "id": "protocol-1", + "analysisSummaries": [{"id": "analysis-1", "status": "completed"}], + } + }, + ) + if path == "/runs": + assert json is not None + return _Response(201, {"data": {"id": "run-1", "status": "idle"}}) + if path == "/runs/run-1/actions": + assert json == {"data": {"actionType": "play"}} + return _Response(201, {"data": {"id": "action-1"}}) + raise AssertionError(f"Unexpected POST {path}") + + +def _ready_deck() -> dict: + return { + "data": { + "cutoutFixtures": [ + {"cutoutId": "cutoutA3", "cutoutFixtureId": "singleRightSlot"}, + {"cutoutId": "cutoutB2", "cutoutFixtureId": "magneticBlockV1"}, + { + "cutoutId": "cutoutC1", + "cutoutFixtureId": "temperatureModuleV2", + "opentronsModuleSerialNumber": "TM-123", + }, + {"cutoutId": "cutoutD1", "cutoutFixtureId": "trashBinAdapter"}, + ] + } + } + + +def test_runtime_parameters_default_to_short_non_mutating_mechanics_run() -> None: + assert run_asms_hardware._runtime_parameters(columns=1, scientific=False) == { + "connector_test_mode": True, + "number_of_columns": 1, + "enable_mutation_checkpoints": False, + } + + +def test_runtime_parameters_preserve_full_scientific_mode() -> None: + assert run_asms_hardware._runtime_parameters(columns=2, scientific=True) == { + "connector_test_mode": False, + "number_of_columns": 2, + "enable_mutation_checkpoints": False, + } + + +def test_exact_upload_bundle_contains_protocol_and_two_labware_definitions() -> None: + files = run_asms_hardware._exact_protocol_files() + names = [file_info[1][0] for file_info in files] + assert names == [ + "asms_single_point_wash_and_elute.py", + "azenta_96_wellplate_200ul_pcr.json", + "thermokingfisherdeepwell_96_wellplate_2000ul.json", + ] + + +def test_ready_deck_has_no_errors() -> None: + assert run_asms_hardware._deck_configuration_errors(_ready_deck()) == [] + + +def test_deck_validation_reports_wrong_fixture_and_missing_module_serial() -> None: + deck = _ready_deck() + fixtures = deck["data"]["cutoutFixtures"] + fixtures[0]["cutoutFixtureId"] = "trashBinAdapter" + fixtures[2].pop("opentronsModuleSerialNumber") + + assert run_asms_hardware._deck_configuration_errors(deck) == [ + "cutoutA3: expected singleRightSlot, found trashBinAdapter", + "cutoutC1: Temperature Module serial number is missing", + ] + + +def test_hardware_inventory_requires_right_flex_pipette_and_temperature_module() -> None: + assert ( + run_asms_hardware._hardware_inventory_errors( + {"right": {"name": "flex_8channel_1000"}}, + {"data": [{"moduleModel": "temperatureModuleV2"}]}, + ) + == [] + ) + assert run_asms_hardware._hardware_inventory_errors( + {"right": {"name": "flex_1channel_1000"}}, + {"data": []}, + ) == [ + "right pipette: expected flex_8channel_1000, found flex_1channel_1000", + "Temperature Module GEN2 is not connected", + ] + + +def test_two_column_evidence_requires_the_offline_pinned_counts() -> None: + counts = Counter(run_asms_hardware._EXPECTED_TWO_COLUMN_COMMANDS) + run_asms_hardware._verify_two_column_evidence(462, counts) + + with pytest.raises(RuntimeError, match="aspirate: expected 82, found 81"): + counts["aspirate"] = 81 + run_asms_hardware._verify_two_column_evidence(462, counts) + + +def test_execute_requires_explicit_deck_confirmation(monkeypatch, capsys) -> None: + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + + result = run_asms_hardware.main(["--execute"]) + + assert result == 2 + assert "ASMS-DECK-READY" in capsys.readouterr().err + + +def test_default_mode_analyzes_without_creating_or_playing_a_run(monkeypatch, capsys) -> None: + client = _Client() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + + result = run_asms_hardware.main([]) + + assert result == 0 + assert client.posts == ["/protocols"] + assert "ANALYSIS ONLY" in capsys.readouterr().out + + +def test_confirmed_two_column_execution_requires_pinned_command_evidence(monkeypatch, capsys) -> None: + client = _Client() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + + result = run_asms_hardware.main( + [ + "--columns", + "2", + "--execute", + "--confirm-deck-ready", + "ASMS-DECK-READY", + ] + ) + + assert result == 0 + assert client.posts == ["/protocols", "/runs", "/runs/run-1/actions"] + assert "AS-MS hardware workflow PASS" in capsys.readouterr().out From b8dfb6a24907e780e1d2cd437ee3559840818504 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Thu, 23 Jul 2026 16:35:50 -0400 Subject: [PATCH 02/14] feat: automate controlled AS-MS checkpoint test --- docs/asms_flex_workflow_test.md | 22 ++- scripts/run_asms_hardware.py | 226 +++++++++++++++++++++++- tests/scripts/test_run_asms_hardware.py | 140 +++++++++++++++ 3 files changed, 381 insertions(+), 7 deletions(-) diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index 3066d2c..8bac66a 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -243,9 +243,25 @@ uv run --extra test python scripts/run_asms_hardware.py \ ``` Add `--scientific` only for the final real-sample run; it restores the full -programmed delays. The runner intentionally keeps mutation checkpoints disabled. -First prove the physical workflow, then use the separately authenticated -controlled-mutation procedure above. +programmed delays. After the first one-column mechanics pass, the same runner can +exercise the complete controlled-insertion path. Export the robot-provisioned +token without placing it on the command line, then provide the exact actor bound +to that token: + +```sh +read -r -s UNITELABS_RUN_MUTATION_TOKEN +export UNITELABS_RUN_MUTATION_TOKEN +uv run --extra test python scripts/run_asms_hardware.py \ + --host --columns 1 --execute \ + --checkpoint-transfer --mutation-actor \ + --confirm-deck-ready ASMS-DECK-READY +``` + +This inserts one 10 µL eight-channel reservoir-to-waste transfer at the first +named checkpoint, verifies allocation of eight clean tips and a terminal +`mutation_enqueued` audit record, then authenticates the remaining six checkpoint +resumes. The option is rejected for two-column runs because the base protocol +already consumes every available fresh tip. 1. **Verify the exact bundle offline.** Run the default exact preflight against the checked-in protocol and `protocols/asms/labware/`. Continue only when it diff --git a/scripts/run_asms_hardware.py b/scripts/run_asms_hardware.py index 4df9456..c9abe12 100644 --- a/scripts/run_asms_hardware.py +++ b/scripts/run_asms_hardware.py @@ -4,12 +4,15 @@ from __future__ import annotations import argparse +import getpass import json +import os import sys import time from collections import Counter from collections.abc import Sequence from pathlib import Path +from uuid import uuid4 import httpx @@ -65,6 +68,24 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Create and play the analyzed run. Omit this flag for analysis-only mode.", ) + parser.add_argument( + "--checkpoint-transfer", + action="store_true", + help=( + "In a one-column mechanics run, add one audited 10 uL eight-channel " + "transfer at the first named checkpoint and authenticate all seven resumes." + ), + ) + parser.add_argument( + "--mutation-actor", + default=None, + help="Audit identity bound to the robot's mutation token.", + ) + parser.add_argument( + "--mutation-token-env", + default="UNITELABS_RUN_MUTATION_TOKEN", + help="Environment variable holding the mutation token; prompts without echo when absent.", + ) parser.add_argument( "--confirm-deck-ready", default=None, @@ -76,11 +97,16 @@ def _parser() -> argparse.ArgumentParser: return parser -def _runtime_parameters(*, columns: int, scientific: bool) -> dict[str, bool | int]: +def _runtime_parameters( + *, + columns: int, + scientific: bool, + checkpoint_transfer: bool = False, +) -> dict[str, bool | int]: return { "connector_test_mode": not scientific, "number_of_columns": columns, - "enable_mutation_checkpoints": False, + "enable_mutation_checkpoints": checkpoint_transfer, } @@ -191,6 +217,155 @@ def _wait_for_run(client: httpx.Client, run_id: str, *, timeout: float) -> dict: raise TimeoutError(f"Run {run_id} did not finish within {timeout:g} seconds") +def _checkpoint_transfer_body(snapshot: dict, *, actor: str, mutation_id: str) -> dict: + def labware_id(load_name: str) -> str: + try: + return next(item["id"] for item in snapshot["labware"] if item["loadName"] == load_name) + except (KeyError, StopIteration) as exc: + raise RuntimeError(f"Checkpoint snapshot is missing {load_name}") from exc + + pipettes = snapshot.get("pipettes") + if not isinstance(pipettes, list) or not pipettes: + raise RuntimeError("Checkpoint snapshot has no attached pipette") + pipette = next( + ( + item + for item in pipettes + if str(item.get("mount", "")).lower() == "right" + or item.get("pipetteName") == "flex_8channel_1000" + or item.get("name") == "flex_8channel_1000" + ), + pipettes[0], + ) + + tip_racks = snapshot.get("tipRacks") + if not isinstance(tip_racks, dict) or not tip_racks: + raise RuntimeError("Checkpoint snapshot has no tracked tip rack") + disposal_areas = snapshot.get("disposalAreas") + if not isinstance(disposal_areas, list): + raise RuntimeError("Checkpoint snapshot has no disposal-area inventory") + try: + disposal_name = next( + area["addressableAreaName"] for area in disposal_areas if area.get("areaType") == "movableTrash" + ) + except StopIteration as exc: + raise RuntimeError("Checkpoint snapshot has no movable trash area") from exc + + return { + "mutationId": mutation_id, + "actor": actor, + "reason": "real-Flex connector checkpoint insertion acceptance", + "mode": "checkpoint", + "steps": [ + { + "stepType": "transfer", + "pipetteId": pipette["id"], + "tipRackIds": [next(iter(tip_racks))], + "source": { + "labwareId": labware_id("nest_12_reservoir_22ml"), + "wellName": "A2", + }, + "destination": { + "labwareId": labware_id("thermokingfisherdeepwell_96_wellplate_2000ul"), + "wellName": "A1", + }, + "disposal": { + "disposalType": "addressableArea", + "addressableAreaName": disposal_name, + }, + "volume": 10, + "aspirateFlowRate": 10, + "dispenseFlowRate": 10, + }, + { + "stepType": "comment", + "message": "REAL FLEX AUDITED CHECKPOINT MUTATION", + }, + ], + } + + +class _CheckpointController: + def __init__(self, client: httpx.Client, run_id: str, *, token: str, actor: str) -> None: + self._client = client + self._run_id = run_id + self._actor = actor + self._headers = {"Authorization": f"Bearer {token}"} + self.handled_checkpoint_ids: set[str] = set() + self.mutation_result: dict | None = None + + def handle_pause(self) -> None: + base_url = f"/unitelabs/runs/{self._run_id}" + snapshot_response = self._client.get(f"{base_url}/mutation-snapshot", headers=self._headers) + if not snapshot_response.is_success: + raise RuntimeError( + f"Paused run is not at an authorized mutation checkpoint: " + f"HTTP {snapshot_response.status_code}: {snapshot_response.text}" + ) + snapshot = snapshot_response.json() + current = snapshot.get("currentCommand") or {} + checkpoint_id = current.get("id") + message = (current.get("params") or {}).get("message", "") + if not checkpoint_id or not message.startswith("UNITELABS_MUTATION_CHECKPOINT:"): + raise RuntimeError(f"Unexpected paused command: {current}") + if checkpoint_id in self.handled_checkpoint_ids: + return + + checkpoint_number = len(self.handled_checkpoint_ids) + 1 + print(f"Checkpoint {checkpoint_number}/7: {message}") + if not self.handled_checkpoint_ids: + body = _checkpoint_transfer_body(snapshot, actor=self._actor, mutation_id=str(uuid4())) + response = self._client.post(f"{base_url}/mutations", headers=self._headers, json=body) + if response.status_code != 201: + raise RuntimeError(f"Checkpoint transfer rejected with HTTP {response.status_code}: {response.text}") + result = response.json() + allocated_tips = result.get("allocatedTips") or [] + if not allocated_tips or allocated_tips[0].get("tipCount") != 8: + raise RuntimeError(f"Checkpoint transfer did not allocate exactly 8 clean tips: {result}") + audit = self._client.get(f"{base_url}/mutations", headers=self._headers) + if not audit.is_success: + raise RuntimeError(f"Mutation audit failed with HTTP {audit.status_code}: {audit.text}") + records = audit.json() + if not isinstance(records, list) or not records or records[-1].get("event") != "mutation_enqueued": + raise RuntimeError(f"Mutation audit did not end in mutation_enqueued: {records}") + self.mutation_result = result + print(f"Checkpoint transfer: PASS (8 tips, {len(result.get('commandIds') or [])} Protocol Engine commands)") + + resume = self._client.post( + f"/runs/{self._run_id}/actions", + headers=self._headers, + json={"data": {"actionType": "play"}}, + ) + if resume.status_code != 201: + raise RuntimeError(f"Authenticated checkpoint resume failed with HTTP {resume.status_code}: {resume.text}") + self.handled_checkpoint_ids.add(checkpoint_id) + + +def _wait_for_checkpoint_run( + client: httpx.Client, + run_id: str, + controller: _CheckpointController, + *, + timeout: float, +) -> dict: + deadline = time.monotonic() + timeout + previous = None + while time.monotonic() < deadline: + run = _response_data(client.get(f"/runs/{run_id}"), action="run status poll") + status = run.get("status") + current = run.get("current") + marker = (status, json.dumps(current, sort_keys=True)) + if marker != previous: + print(f"Run {run_id}: status={status}, current={current}") + previous = marker + if status in _TERMINAL_RUN_STATES: + return run + if status == "paused": + controller.handle_pause() + time.sleep(1) + raise TimeoutError(f"Run {run_id} did not finish within {timeout:g} seconds") + + def _read_command_counts(client: httpx.Client, run_id: str) -> tuple[int, Counter]: response = client.get(f"/runs/{run_id}/commands", params={"pageLength": 1000}) if not response.is_success: @@ -221,9 +396,29 @@ def main(argv: Sequence[str] | None = None) -> int: file=sys.stderr, ) return 2 + if args.checkpoint_transfer and args.columns != 1: + print( + "BLOCKED: --checkpoint-transfer is limited to --columns 1 because the two-column run has no spare tips.", + file=sys.stderr, + ) + return 2 + if args.execute and args.checkpoint_transfer and not args.mutation_actor: + print("BLOCKED: --mutation-actor is required with --checkpoint-transfer.", file=sys.stderr) + return 2 + + mutation_token = None + if args.execute and args.checkpoint_transfer: + mutation_token = os.environ.get(args.mutation_token_env) or getpass.getpass("Mutation token: ") + if len(mutation_token) < 32: + print("BLOCKED: mutation token must contain at least 32 characters.", file=sys.stderr) + return 2 _validate_exact_bundle() - runtime_parameters = _runtime_parameters(columns=args.columns, scientific=args.scientific) + runtime_parameters = _runtime_parameters( + columns=args.columns, + scientific=args.scientific, + checkpoint_transfer=args.checkpoint_transfer, + ) base_url = f"http://{args.host}:{args.port}" print(f"Target: {base_url}") print(f"Runtime parameters: {json.dumps(runtime_parameters, sort_keys=True)}") @@ -289,7 +484,30 @@ def main(argv: Sequence[str] | None = None) -> int: raise RuntimeError(f"Run start failed with HTTP {response.status_code}: {response.text}") try: - run = _wait_for_run(client, run_id, timeout=args.run_timeout) + if args.checkpoint_transfer: + if mutation_token is None: + raise RuntimeError("Mutation token was not provisioned before starting the run") + checkpoint_controller = _CheckpointController( + client, + run_id, + token=mutation_token, + actor=args.mutation_actor, + ) + run = _wait_for_checkpoint_run( + client, + run_id, + checkpoint_controller, + timeout=args.run_timeout, + ) + if len(checkpoint_controller.handled_checkpoint_ids) != 7: + raise RuntimeError( + "Checkpoint workflow did not authenticate all 7 resumes: " + f"{len(checkpoint_controller.handled_checkpoint_ids)} handled" + ) + if checkpoint_controller.mutation_result is None: + raise RuntimeError("Checkpoint workflow finished without the required inserted transfer") + else: + run = _wait_for_run(client, run_id, timeout=args.run_timeout) except KeyboardInterrupt: print(f"\nInterrupt received; stopping run {run_id}...") stop = client.post( diff --git a/tests/scripts/test_run_asms_hardware.py b/tests/scripts/test_run_asms_hardware.py index 651b477..57ae079 100644 --- a/tests/scripts/test_run_asms_hardware.py +++ b/tests/scripts/test_run_asms_hardware.py @@ -78,6 +78,65 @@ def post( raise AssertionError(f"Unexpected POST {path}") +class _CheckpointClient: + def __init__(self) -> None: + self.posts: list[str] = [] + + def get(self, path: str, *, headers: dict | None = None) -> _Response: + assert headers == {"Authorization": "Bearer " + ("t" * 32)} + if path.endswith("/mutation-snapshot"): + return _Response( + 200, + { + "currentCommand": { + "id": "checkpoint-1", + "params": {"message": "UNITELABS_MUTATION_CHECKPOINT:ready-before-initial-separation"}, + }, + "pipettes": [{"id": "pipette-1", "mount": "right"}], + "tipRacks": {"tips-1": {}}, + "labware": [ + {"id": "reservoir-1", "loadName": "nest_12_reservoir_22ml"}, + { + "id": "waste-1", + "loadName": "thermokingfisherdeepwell_96_wellplate_2000ul", + }, + ], + "disposalAreas": [ + { + "areaType": "movableTrash", + "addressableAreaName": "movableTrashA3", + } + ], + }, + ) + if path.endswith("/mutations"): + return _Response(200, [{"event": "mutation_enqueued"}]) + raise AssertionError(f"Unexpected GET {path}") + + def post( + self, + path: str, + *, + headers: dict | None = None, + json: dict | None = None, + ) -> _Response: + assert headers == {"Authorization": "Bearer " + ("t" * 32)} + self.posts.append(path) + if path.endswith("/mutations"): + assert json is not None + return _Response( + 201, + { + "allocatedTips": [{"tipCount": 8}], + "commandIds": [f"command-{index}" for index in range(6)], + }, + ) + if path.endswith("/actions"): + assert json == {"data": {"actionType": "play"}} + return _Response(201, {"data": {"id": "action-1"}}) + raise AssertionError(f"Unexpected POST {path}") + + def _ready_deck() -> dict: return { "data": { @@ -103,6 +162,18 @@ def test_runtime_parameters_default_to_short_non_mutating_mechanics_run() -> Non } +def test_runtime_parameters_enable_checkpoints_only_for_explicit_transfer_test() -> None: + assert run_asms_hardware._runtime_parameters( + columns=1, + scientific=False, + checkpoint_transfer=True, + ) == { + "connector_test_mode": True, + "number_of_columns": 1, + "enable_mutation_checkpoints": True, + } + + def test_runtime_parameters_preserve_full_scientific_mode() -> None: assert run_asms_hardware._runtime_parameters(columns=2, scientific=True) == { "connector_test_mode": False, @@ -154,6 +225,60 @@ def test_hardware_inventory_requires_right_flex_pipette_and_temperature_module() ] +def test_checkpoint_transfer_uses_snapshot_ids_and_eight_channel_resources() -> None: + snapshot = { + "pipettes": [{"id": "pipette-1", "mount": "right", "name": "flex_8channel_1000"}], + "tipRacks": {"tips-1": {"wells": {}}}, + "labware": [ + {"id": "reservoir-1", "loadName": "nest_12_reservoir_22ml"}, + { + "id": "waste-1", + "loadName": "thermokingfisherdeepwell_96_wellplate_2000ul", + }, + ], + "disposalAreas": [ + { + "areaType": "movableTrash", + "addressableAreaName": "movableTrashA3", + } + ], + } + + body = run_asms_hardware._checkpoint_transfer_body( + snapshot, + actor="operator-1", + mutation_id="mutation-1", + ) + + transfer = body["steps"][0] + assert body["actor"] == "operator-1" + assert transfer["pipetteId"] == "pipette-1" + assert transfer["tipRackIds"] == ["tips-1"] + assert transfer["source"] == {"labwareId": "reservoir-1", "wellName": "A2"} + assert transfer["destination"] == {"labwareId": "waste-1", "wellName": "A1"} + assert transfer["volume"] == 10 + + +def test_checkpoint_controller_audits_mutation_and_resumes_once() -> None: + client = _CheckpointClient() + controller = run_asms_hardware._CheckpointController( + client, + "run-1", + token="t" * 32, + actor="operator-1", + ) + + controller.handle_pause() + controller.handle_pause() + + assert controller.handled_checkpoint_ids == {"checkpoint-1"} + assert controller.mutation_result is not None + assert client.posts == [ + "/unitelabs/runs/run-1/mutations", + "/runs/run-1/actions", + ] + + def test_two_column_evidence_requires_the_offline_pinned_counts() -> None: counts = Counter(run_asms_hardware._EXPECTED_TWO_COLUMN_COMMANDS) run_asms_hardware._verify_two_column_evidence(462, counts) @@ -172,6 +297,21 @@ def test_execute_requires_explicit_deck_confirmation(monkeypatch, capsys) -> Non assert "ASMS-DECK-READY" in capsys.readouterr().err +def test_checkpoint_transfer_rejects_two_column_run_before_network(monkeypatch, capsys) -> None: + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + + result = run_asms_hardware.main( + [ + "--columns", + "2", + "--checkpoint-transfer", + ] + ) + + assert result == 2 + assert "no spare tips" in capsys.readouterr().err + + def test_default_mode_analyzes_without_creating_or_playing_a_run(monkeypatch, capsys) -> None: client = _Client() monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) From 71aa17f42d6763e3ce1398907724a1296f7c9f67 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Thu, 23 Jul 2026 19:25:24 -0400 Subject: [PATCH 03/14] feat: harden Flex full-workflow deployment --- .github/workflows/build-flex-arm-wheels.yml | 15 +- .github/workflows/test.yml | 25 +- CHANGELOG.md | 15 +- Dockerfile.build | 43 +- README.md | 74 ++- config/flex_config.json | 5 +- config/smoketest_config.json | 5 +- deploy.sh | 219 ++++++-- docs/asms_flex_workflow_test.md | 28 +- pyproject.toml | 21 +- scripts/artifact_manifest.py | 338 ++++++++++++ scripts/install.sh | 40 +- scripts/install_connector_service.sh | 189 +++++-- scripts/preflight_flex.py | 131 +++++ scripts/rollback_connector.sh | 161 ++++++ scripts/run_asms_hardware.py | 99 +++- scripts/switch_mode.sh | 278 +++++----- src/unitelabs/opentrons_flex/__init__.py | 92 ++-- .../opentrons_flex/runtime_compat.py | 260 +++++++++ .../opentrons_flex/runtime_preflight.py | 115 ++++ tests/conftest.py | 6 + tests/integration/conftest.py | 1 + tests/scripts/test_artifact_manifest.py | 117 ++++ tests/scripts/test_deployment_scripts.py | 86 +++ tests/scripts/test_run_asms_hardware.py | 77 +++ tests/test_create_app.py | 20 + tests/test_create_app_with_robot_server.py | 21 + tests/test_runtime_compat.py | 146 +++++ tests/test_runtime_preflight.py | 98 ++++ tests/test_smoketest_config.py | 2 + uv.lock | 512 +++++++++++++++--- 31 files changed, 2815 insertions(+), 424 deletions(-) create mode 100755 scripts/artifact_manifest.py create mode 100755 scripts/preflight_flex.py create mode 100755 scripts/rollback_connector.sh mode change 100644 => 100755 scripts/run_asms_hardware.py mode change 100644 => 100755 scripts/switch_mode.sh create mode 100644 src/unitelabs/opentrons_flex/runtime_compat.py create mode 100644 src/unitelabs/opentrons_flex/runtime_preflight.py create mode 100644 tests/scripts/test_artifact_manifest.py create mode 100644 tests/scripts/test_deployment_scripts.py create mode 100644 tests/test_runtime_compat.py create mode 100644 tests/test_runtime_preflight.py diff --git a/.github/workflows/build-flex-arm-wheels.yml b/.github/workflows/build-flex-arm-wheels.yml index 1d5893f..39fff87 100644 --- a/.github/workflows/build-flex-arm-wheels.yml +++ b/.github/workflows/build-flex-arm-wheels.yml @@ -37,11 +37,22 @@ jobs: cache-to: type=gha,mode=max - name: List wheels - run: ls -la dist_arm/ + run: | + ls -la dist_arm/ + python scripts/artifact_manifest.py verify dist_arm \ + --connector-version 0.9.1 \ + --opentrons-version 9.0.0 \ + --robot-server-version 9.0.0 \ + --opentrons-source-commit 44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 \ + --python-version 3.10 \ + --architecture aarch64 - name: Upload flex-arm-wheels artifact uses: actions/upload-artifact@v7 with: name: flex-arm-wheels - path: dist_arm/*.whl + path: | + dist_arm/*.whl + dist_arm/runtime-manifest.json + dist_arm/SHA256SUMS retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92b6d75..24aea5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,10 +12,10 @@ jobs: strategy: matrix: include: - # Controlled mutation and the ARM artifact are validated only against - # the Opentrons 8.8.1 private Protocol Engine state surface. + # This is the exact runtime matrix deployed to Flex. - python-version: "3.10" - opentrons-version: "8.8.1" + opentrons-version: "9.0.0" + opentrons-commit: "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5" steps: - uses: actions/checkout@v6 @@ -59,29 +59,30 @@ jobs: # the util/ subpackage that robot_server imports. - name: Install opentrons robot-server ${{ matrix.opentrons-version }} run: | - VER="${{ matrix.opentrons-version }}" + REF="${{ matrix.opentrons-commit }}" # robot_server/server_utils are not on PyPI; install with --no-deps to skip the # monorepo-internal ==0.0.0 dep constraints that can't be satisfied from PyPI. uv pip install --no-deps \ - "robot_server @ git+https://github.com/Opentrons/opentrons.git@v${VER}#subdirectory=robot-server" \ - "server_utils @ git+https://github.com/Opentrons/opentrons.git@v${VER}#subdirectory=server-utils" + "robot_server @ git+https://github.com/Opentrons/opentrons.git@${REF}#subdirectory=robot-server" \ + "server_utils @ git+https://github.com/Opentrons/opentrons.git@${REF}#subdirectory=server-utils" # Reinstall opentrons monorepo packages from git so robot_server gets all the # internal symbols it imports at module load time. The PyPI builds omit or differ # from the monorepo source (e.g. PREVIEW_IMAGE, opentrons_shared_data.util, # opentrons_hardware which is not even a dep of the PyPI opentrons package). uv pip install --no-deps --reinstall \ - "opentrons @ git+https://github.com/Opentrons/opentrons.git@v${VER}#subdirectory=api" \ - "opentrons_shared_data @ git+https://github.com/Opentrons/opentrons.git@v${VER}#subdirectory=shared-data" \ - "opentrons_hardware @ git+https://github.com/Opentrons/opentrons.git@v${VER}#subdirectory=hardware" + "opentrons @ git+https://github.com/Opentrons/opentrons.git@${REF}#subdirectory=api" \ + "opentrons_shared_data @ git+https://github.com/Opentrons/opentrons.git@${REF}#subdirectory=shared-data" \ + "opentrons_hardware @ git+https://github.com/Opentrons/opentrons.git@${REF}#subdirectory=hardware" # Install robot_server's non-monorepo deps (versions from its Pipfile.lock). - # aiohttp: needed by the legacy routers in robot_server 8.8.1. + # Versions match the Opentrons 9.0 robot-server lockfile. uv pip install \ - "aiohttp" \ + "aiohttp==3.12.14" \ + "anyio==4.9.0" \ "fastapi==0.100.0" \ "pydantic==2.11.7" \ "pydantic-settings==2.4.0" \ "python-dotenv==1.0.1" \ - "python-multipart==0.0.6" \ + "python-multipart==0.0.18" \ "uvicorn==0.27.0.post1" \ "wsproto==1.2.0" \ "sqlalchemy==1.4.51" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d6869..dd200cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Version 0.9.1 aligns the deployable runtime with Flex Opentrons 9.0.0 on + Python 3.10, packages the co-versioned private robot-server stack, verifies a + checksummed ARM artifact manifest built from an immutable upstream commit, and + rejects cross-release imports before any hardware initialization. Deployments + now use versioned immutable releases with completion markers, bounded + no-motion/live health checks, automatic stock robot-server recovery, and + explicit release rollback. The multipart upload parser is upgraded to the + patched 0.0.18 release, and authenticated mutation clients are restricted to + an encrypted loopback tunnel. - Added a corrected, parameterized AS-MS wash/elution protocol candidate, pinned exact Azenta and Thermo KingFisher custom-labware definitions, exact-vs-shadow offline preflight, full exact-bundle Protocol Engine execution coverage, and a staged real-Flex @@ -19,9 +28,9 @@ restricted to token-authenticated, audited, non-actuating built-in-resource commands and serialized with play; protocol-backed runs and custom definitions fail closed. Stop remains available during state-provider failure, command audit is written in - bounded batches, and the ARM artifact is pinned to the validated Python 3.10/Opentrons - 8.8.1 runtime. Python 3.11+ is intentionally outside the supported matrix because - robot-server 8.8.1 does not import reliably on current Python 3.11 patch releases. + bounded batches, and the ARM artifact is pinned to the validated Python + 3.10/Opentrons 9.0.0 runtime. Python 3.11+ remains intentionally outside the + supported Flex deployment matrix. All notable changes to this project will be documented in this file. diff --git a/Dockerfile.build b/Dockerfile.build index 483665e..0f22249 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -11,27 +11,56 @@ # --target export --output type=local,dest=dist_arm . # # The resulting dist_arm/ feeds deploy.sh. -FROM --platform=linux/arm64 ghcr.io/astral-sh/uv:python3.10-bookworm AS wheels +FROM ghcr.io/astral-sh/uv:python3.10-bookworm AS wheels + +ARG OPENTRONS_VERSION=9.0.0 +ARG OPENTRONS_COMMIT=44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 +ARG CONNECTOR_VERSION=0.9.1 ENV UV_LINK_MODE=copy \ UV_PYTHON_DOWNLOADS=never # libusb is a runtime dep of opentrons; present here only so resolution matches # the device. Wheels are platform artifacts and do not embed system libs. -RUN apt-get -y update && apt-get -y install --no-install-recommends libusb-1.0-0 && rm -rf /var/lib/apt/lists/* +RUN apt-get -y update && \ + apt-get -y install --no-install-recommends git libusb-1.0-0 && \ + rm -rf /var/lib/apt/lists/* WORKDIR /build ADD pyproject.toml uv.lock README.md /build/ ADD src /build/src +ADD scripts/artifact_manifest.py /build/scripts/artifact_manifest.py # Download/build wheels for the project and all runtime deps into /wheels. -# robot_server (an Opentrons system package) is NOT on PyPI and is intentionally -# excluded — it is provided by the Flex system site-packages at install time -# (see scripts/install.sh --system-site-packages). +# Build the exact tagged robot_server and its four co-versioned Opentrons +# packages. The public PyPI artifacts do not contain every internal module that +# the embedded robot-server imports, so deployment must not mix a PyPI API wheel +# with whatever robot-server happens to be present in the system image. RUN uv build --wheel --out-dir /wheels . && \ uv pip compile pyproject.toml --output-file /tmp/req.txt && \ - (python -m pip download --no-deps -r /tmp/req.txt --dest /wheels --only-binary :all: || \ - python -m pip download --no-deps -r /tmp/req.txt --dest /wheels) + python -m pip wheel --no-deps -r /tmp/req.txt --wheel-dir /wheels + +RUN git clone --depth 1 --branch "v${OPENTRONS_VERSION}" \ + https://github.com/Opentrons/opentrons.git /opt/opentrons-source && \ + test "$(git -C /opt/opentrons-source rev-parse HEAD)" = "$OPENTRONS_COMMIT" && \ + rm -f \ + /wheels/opentrons-[0-9]*.whl \ + /wheels/opentrons_shared_data-[0-9]*.whl \ + /wheels/opentrons_hardware-[0-9]*.whl \ + /wheels/server_utils-[0-9]*.whl \ + /wheels/robot_server-[0-9]*.whl && \ + uv build --wheel --out-dir /wheels /opt/opentrons-source/shared-data && \ + uv build --wheel --out-dir /wheels /opt/opentrons-source/api && \ + uv build --wheel --out-dir /wheels /opt/opentrons-source/hardware && \ + uv build --wheel --out-dir /wheels /opt/opentrons-source/server-utils && \ + uv build --wheel --out-dir /wheels /opt/opentrons-source/robot-server && \ + python /build/scripts/artifact_manifest.py build /wheels \ + --connector-version "$CONNECTOR_VERSION" \ + --opentrons-version "$OPENTRONS_VERSION" \ + --robot-server-version "$OPENTRONS_VERSION" \ + --opentrons-source-commit "$OPENTRONS_COMMIT" \ + --python-version "3.10" \ + --architecture "aarch64" FROM scratch AS export COPY --from=wheels /wheels / diff --git a/README.md b/README.md index ff0b249..b8f1cce 100644 --- a/README.md +++ b/README.md @@ -562,9 +562,11 @@ against the live robot-server OpenAPI so the matrix cannot silently drift. ## Deploying to the Flex -The Flex host is aarch64 (ARM64) with a modern glibc, so standard PyPI -`manylinux_2_17_aarch64` wheels for C-extension packages (grpcio, numpy, …) install -directly — no from-source build is required. +The Flex host is aarch64 (ARM64) with a modern glibc, so standard +`manylinux_2_17_aarch64` dependency wheels install directly. The build also +packages the exact Opentrons 9.0.0 tag for `opentrons`, shared data, hardware, +server utilities, and `robot_server`; connector mode never relies on an +unverified mixture of those private packages from the robot image. ### Building `dist_arm/` @@ -579,15 +581,40 @@ Or trigger the **Build Flex aarch64 Wheels** GitHub Actions workflow (`.github/workflows/build-flex-arm-wheels.yml`) and download the `flex-arm-wheels` artifact into `dist_arm/`. +The artifact is accepted only when `runtime-manifest.json` and `SHA256SUMS` +verify every wheel, the connector/Opentrons/robot-server versions are exact, and +the declared target is Python 3.10 on aarch64. + ### Installing on the Flex -Copy the wheels and create the venv on the robot: +Provision the mutation credential outside any replaceable release directory: + +```sh +ssh root@ ' + umask 077 + mkdir -p /var/lib/unitelabs-opentrons-flex + token=$(od -An -N32 -tx1 /dev/urandom | tr -d " \n") + actor=operator-name + { + printf "UNITELABS_RUN_MUTATION_TOKEN=%s\n" "$token" + printf "UNITELABS_RUN_MUTATION_ACTOR=%s\n" "$actor" + } > /var/lib/unitelabs-opentrons-flex/run-mutation.env + printf "Save this token for the no-echo client prompt: %s\n" "$token" +' +``` + +Replace `operator-name` first. Then verify, install to an immutable versioned +directory, run the no-hardware import/configuration preflight, and atomically +activate the release: ```sh ./deploy.sh ``` -Then install the connector as a persistent systemd service (this disables the Opentrons robot server so the connector owns the hardware): +Then install the connector service. The stock service is stopped only after the +runtime preflight passes. If connector startup, gRPC, `/health`, +`/deck_configuration`, or the mutation OpenAPI check fails within its bounded +timeout, the script restores the stock robot-server automatically: ```sh ./scripts/install_connector_service.sh @@ -600,36 +627,37 @@ Switch between the SiLA connector and the stock opentrons robot-server at any ti ./scripts/switch_mode.sh opentrons ``` -To deploy Python source changes to a robot that already has the service installed: +Run all read-only readiness checks before uploading a protocol: ```sh -./scripts/deploy_python_changes.sh +uv run python scripts/preflight_flex.py ``` -Logs: +Restore the previously activated release while leaving stock Opentrons mode +active: ```sh -ssh root@ 'journalctl -u sila2-connector -f' +./scripts/rollback_connector.sh ``` -### Why `--system-site-packages` - -The `robot_server` HTTP server is pre-installed as a system package by the -Opentrons robot software and is inherited through `--system-site-packages` because -it is not published on PyPI. The ARM bundle pins and installs Opentrons 8.8.1 into -the venv, where it takes precedence; the connector then refuses controlled -mutation if the effective runtime does not match that validated version. - -`robot_server` is not distributed in the public `opentrons` PyPI wheel. Its source lives -in the Opentrons monorepo under -[`robot-server/robot_server`](https://github.com/Opentrons/opentrons/tree/edge/robot-server/robot_server). -Before the first hardware run, verify the Flex image exposes the same package: +Logs: ```sh -ssh root@ \ - '/var/sila2_flex/bin/python -c "import robot_server, robot_server.hardware, robot_server.app; print(robot_server.__file__)"' +ssh root@ 'journalctl -u sila2-connector -f' ``` +### Runtime isolation + +Each immutable release inherits the robot image only for operating-system +bindings that Opentrons does not distribute as portable wheels, notably +`systemd-python`. The ARM artifact supplies the complete portable dependency +closure plus all five co-versioned Opentrons Python packages from the immutable +9.0.0 source commit. Deployment rejects incomplete releases and writes a +completion marker only after no-hardware runtime validation. Startup rejects a +wrong Python/Opentrons version, a missing private symbol, or any of those five +Opentrons modules imported from outside the active release before initializing +OT3API or the CAN bus. + Then start the connector and run the live HTTP integration tests: ```sh diff --git a/config/flex_config.json b/config/flex_config.json index 32f89b6..addd393 100644 --- a/config/flex_config.json +++ b/config/flex_config.json @@ -12,7 +12,7 @@ "name": "Opentrons Flex", "type": "LiquidHandler", "description": "SiLA2 connector for Opentrons Flex motion, pipettes, gripper and calibration", - "version": "0.9.0", + "version": "0.9.1", "vendor_url": "https://opentrons.com/", "default_lifetime": 3600.0 }, @@ -63,5 +63,6 @@ "labware_movement_config": null, "run_mutation_ledger_path": "/var/lib/unitelabs-opentrons-flex/run-mutations.jsonl", "run_mutation_token_env": "UNITELABS_RUN_MUTATION_TOKEN", - "run_mutation_actor_env": "UNITELABS_RUN_MUTATION_ACTOR" + "run_mutation_actor_env": "UNITELABS_RUN_MUTATION_ACTOR", + "run_mutation_required": true } diff --git a/config/smoketest_config.json b/config/smoketest_config.json index 13ff7c9..5fadccc 100644 --- a/config/smoketest_config.json +++ b/config/smoketest_config.json @@ -12,7 +12,7 @@ "name": "Opentrons Flex Smoketest", "type": "LiquidHandler", "description": "Local smoketest config for the Opentrons Flex connector simulator", - "version": "0.9.0", + "version": "0.9.1", "vendor_url": "https://opentrons.com/", "default_lifetime": 3600.0 }, @@ -51,5 +51,6 @@ "labware_movement_config": null, "run_mutation_ledger_path": "/private/tmp/unitelabs-opentrons-flex-smoketest/run-mutations.jsonl", "run_mutation_token_env": "UNITELABS_RUN_MUTATION_TOKEN", - "run_mutation_actor_env": "UNITELABS_RUN_MUTATION_ACTOR" + "run_mutation_actor_env": "UNITELABS_RUN_MUTATION_ACTOR", + "run_mutation_required": true } diff --git a/deploy.sh b/deploy.sh index 089da72..334241b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,27 +1,14 @@ #!/bin/sh -# Deploy the SiLA2 Flex connector to the robot. -# -# Usage: -# ./deploy.sh [hostname] [wheel_dir] -# -# Arguments: -# hostname Flex hostname or IP (default: opentrons-flex) -# wheel_dir Local directory containing built aarch64 wheels (default: dist_arm) -# -# The Flex host is aarch64 (ARM64) running a modern glibc, so unlike the OT-2 -# (armv7l / glibc 2.25) the standard manylinux_2_17_aarch64 wheels from PyPI work -# directly — no from-source grpcio/OpenSSL build is required. The wheel directory -# must contain the output of the "Build Flex aarch64 Wheels" CI workflow (download -# the flex-arm-wheels artifact and unzip it into dist_arm/). - -set -e +# Verify, install, preflight, and atomically activate a Flex connector release. +set -eu HOST="${1:-opentrons-flex}" WHEEL_DIR="${2:-dist_arm}" -VENV_PATH="/var/sila2_flex" -REMOTE_DIR="/root/dist_arm" - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_DIR="/var/lib/unitelabs-opentrons-flex" +ACTIVE_PATH="/var/sila2_flex" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +ARTIFACT_DIR="$SCRIPT_DIR/$WHEEL_DIR" +MANIFEST_TOOL="$SCRIPT_DIR/scripts/artifact_manifest.py" if [ -f "$SCRIPT_DIR/config/flex_config.local.json" ]; then CONFIG_SRC="$SCRIPT_DIR/config/flex_config.local.json" @@ -30,32 +17,180 @@ else CONFIG_SRC="$SCRIPT_DIR/config/flex_config.json" fi -if [ ! -d "$SCRIPT_DIR/$WHEEL_DIR" ]; then - echo "ERROR: Wheel directory '$WHEEL_DIR' not found." - echo "Download the flex-arm-wheels artifact from CI and unzip into dist_arm/." +if [ ! -d "$ARTIFACT_DIR" ]; then + echo "ERROR: wheel directory '$ARTIFACT_DIR' not found." >&2 + echo "Download and extract the flex-arm-wheels artifact first." >&2 + exit 1 +fi + +python3 "$MANIFEST_TOOL" verify "$ARTIFACT_DIR" \ + --connector-version 0.9.1 \ + --opentrons-version 9.0.0 \ + --robot-server-version 9.0.0 \ + --opentrons-source-commit 44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 \ + --python-version 3.10 \ + --architecture aarch64 +RELEASE_ID="$(python3 "$MANIFEST_TOOL" field "$ARTIFACT_DIR" releaseId)" +CONFIG_SHA="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$CONFIG_SRC")" +CONFIG_ID="$(printf '%s' "$CONFIG_SHA" | cut -c1-12)" +DEPLOYMENT_ID="$RELEASE_ID-cfg$CONFIG_ID" + +case "$DEPLOYMENT_ID" in + *[!A-Za-z0-9._-]*|"") + echo "ERROR: unsafe deployment identifier: $DEPLOYMENT_ID" >&2 + exit 1 + ;; +esac + +UPLOAD_DIR="/root/unitelabs-flex-upload-$DEPLOYMENT_ID-$$" +RELEASE_PATH="$STATE_DIR/releases/$DEPLOYMENT_ID" + +echo "=== Flex connector staged deployment ===" +echo "Host: $HOST" +echo "Artifact: $RELEASE_ID" +echo "Config: $CONFIG_ID" +echo "Install: $RELEASE_PATH" +echo "Active: $ACTIVE_PATH" + +ssh "root@$HOST" "test ! -e '$UPLOAD_DIR' && mkdir -p '$UPLOAD_DIR'" +scp -O \ + "$MANIFEST_TOOL" \ + "$SCRIPT_DIR/scripts/install.sh" \ + "$ARTIFACT_DIR"/*.whl \ + "$ARTIFACT_DIR/runtime-manifest.json" \ + "$ARTIFACT_DIR/SHA256SUMS" \ + "root@$HOST:$UPLOAD_DIR/" +scp -O "$CONFIG_SRC" "root@$HOST:$UPLOAD_DIR/flex_config.json" + +ssh "root@$HOST" sh -s -- "$UPLOAD_DIR" "$RELEASE_PATH" "$ACTIVE_PATH" "$STATE_DIR" "$CONFIG_SHA" <<'REMOTE' +set -eu +UPLOAD_DIR="$1" +RELEASE_PATH="$2" +ACTIVE_PATH="$3" +STATE_DIR="$4" +EXPECTED_CONFIG_SHA="$5" + +python3 "$UPLOAD_DIR/artifact_manifest.py" verify "$UPLOAD_DIR" \ + --connector-version 0.9.1 \ + --opentrons-version 9.0.0 \ + --robot-server-version 9.0.0 \ + --opentrons-source-commit 44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 \ + --python-version 3.10 \ + --architecture aarch64 \ + --check-host-python \ + --check-host-architecture +UPLOADED_CONFIG_SHA="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$UPLOAD_DIR/flex_config.json")" +if [ "$UPLOADED_CONFIG_SHA" != "$EXPECTED_CONFIG_SHA" ]; then + echo "ERROR: uploaded Flex configuration checksum mismatch." >&2 + exit 1 +fi + +if systemctl is-active --quiet sila2-connector 2>/dev/null; then + echo "ERROR: sila2-connector is running. Switch to opentrons mode before deployment." >&2 exit 1 fi -echo "=== Flex SiLA2 Connector Deploy ===" -echo "Host: $HOST" -echo "Wheels: $WHEEL_DIR" -echo "Venv: $VENV_PATH" +mount -o remount,rw / +mkdir -p "$STATE_DIR/releases" +LOCK_DIR="$STATE_DIR/operation.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "ERROR: another Flex deployment or mode transition is in progress ($LOCK_DIR)." >&2 + exit 1 +fi +CLEANUP_INCOMPLETE=no +COMPLETE_MARKER="$RELEASE_PATH/.unitelabs-release-complete" +cleanup_deploy() { + if [ "$CLEANUP_INCOMPLETE" = yes ] && + [ -d "$RELEASE_PATH" ] && + [ ! -f "$COMPLETE_MARKER" ]; then + rm -rf "$RELEASE_PATH" + fi + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap cleanup_deploy 0 HUP INT TERM -echo "" -echo "Copying wheels and scripts to $HOST:$REMOTE_DIR ..." -ssh "root@$HOST" "rm -rf $REMOTE_DIR && mkdir -p $REMOTE_DIR" -scp -O "$SCRIPT_DIR/scripts/install.sh" "$SCRIPT_DIR/$WHEEL_DIR"/*.whl "root@$HOST:$REMOTE_DIR/" -scp -O "$CONFIG_SRC" "root@$HOST:$REMOTE_DIR/flex_config.json" +if [ ! -f "$STATE_DIR/run-mutation.env" ] && [ -f "$ACTIVE_PATH/run-mutation.env" ]; then + cp "$ACTIVE_PATH/run-mutation.env" "$STATE_DIR/run-mutation.env" + chmod 600 "$STATE_DIR/run-mutation.env" + echo "Migrated the existing mutation credential outside the release directory." +fi +if [ -f "$STATE_DIR/run-mutation.env" ]; then + set -a + . "$STATE_DIR/run-mutation.env" + set +a +fi -echo "" -echo "Installing on robot ..." -ssh "root@$HOST" "rm -rf $VENV_PATH /var/user-packages/var/sila2_flex && sh $REMOTE_DIR/install.sh $VENV_PATH" +if [ -e "$RELEASE_PATH" ] && { + [ ! -f "$COMPLETE_MARKER" ] || + [ "$(cat "$COMPLETE_MARKER")" != "$EXPECTED_CONFIG_SHA" ]; +}; then + if [ -L "$ACTIVE_PATH" ] && [ "$(readlink -f "$ACTIVE_PATH")" = "$RELEASE_PATH" ]; then + echo "ERROR: active release has no valid completion marker; roll back before repairing it." >&2 + exit 1 + fi + echo "Removing an incomplete, never-activated release at $RELEASE_PATH." + rm -rf "$RELEASE_PATH" +fi + +if [ ! -e "$RELEASE_PATH" ]; then + CLEANUP_INCOMPLETE=yes + sh "$UPLOAD_DIR/install.sh" "$RELEASE_PATH" + "$RELEASE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$RELEASE_PATH/config.json" \ + --require-robot-server \ + --require-mutation \ + --require-live-hardware + printf '%s\n' "$EXPECTED_CONFIG_SHA" > "$COMPLETE_MARKER" + CLEANUP_INCOMPLETE=no +else + echo "Release already installed and complete; reusing $RELEASE_PATH" + INSTALLED_CONFIG_SHA="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$RELEASE_PATH/config.json")" + if [ "$INSTALLED_CONFIG_SHA" != "$EXPECTED_CONFIG_SHA" ]; then + echo "ERROR: immutable release configuration checksum mismatch." >&2 + exit 1 + fi + if ! cmp -s "$RELEASE_PATH/runtime-manifest.json" "$UPLOAD_DIR/runtime-manifest.json"; then + echo "ERROR: immutable release manifest differs from the verified artifact." >&2 + exit 1 + fi +fi + +# This imports the exact HTTP and mutation symbols without touching the CAN bus. +"$RELEASE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$RELEASE_PATH/config.json" \ + --require-robot-server \ + --require-mutation \ + --require-live-hardware + +if [ -L "$ACTIVE_PATH" ]; then + PREVIOUS_TARGET="$(readlink -f "$ACTIVE_PATH")" + if [ "$PREVIOUS_TARGET" = "$RELEASE_PATH" ]; then + echo "Release is already active." + else + printf 'release:%s\n' "$PREVIOUS_TARGET" > "$STATE_DIR/previous-release" + ln -sfn "$RELEASE_PATH" "$ACTIVE_PATH" + fi +elif [ -d "$ACTIVE_PATH" ]; then + LEGACY_PATH="$STATE_DIR/legacy-pre-versioned-release" + if [ -e "$LEGACY_PATH" ]; then + echo "ERROR: cannot preserve legacy release because $LEGACY_PATH already exists." >&2 + exit 1 + fi + mv "$ACTIVE_PATH" "$LEGACY_PATH" + printf 'legacy:%s\n' "$LEGACY_PATH" > "$STATE_DIR/previous-release" + ln -s "$RELEASE_PATH" "$ACTIVE_PATH" +elif [ -e "$ACTIVE_PATH" ]; then + echo "ERROR: active path exists but is neither a directory nor symlink: $ACTIVE_PATH" >&2 + exit 1 +else + ln -s "$RELEASE_PATH" "$ACTIVE_PATH" +fi -echo "" -echo "Verifying ..." -ssh "root@$HOST" "$VENV_PATH/bin/python -c 'import grpc, unitelabs.opentrons_flex; print(\"OK grpc=\"+grpc.__version__)'" +echo "Activated release: $(readlink -f "$ACTIVE_PATH")" +echo "Stock robot-server remains in control until the connector service is explicitly installed/switched." +rmdir "$LOCK_DIR" +trap - 0 HUP INT TERM +REMOTE -echo "" -echo "=== Deploy complete ===" -echo "Install and start the service with:" -echo " sh scripts/install_connector_service.sh $HOST" +echo "=== Deploy and no-hardware runtime preflight complete ===" +echo "Next: sh scripts/install_connector_service.sh $HOST" diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index 8bac66a..4c49a4a 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -104,13 +104,14 @@ Flex, provision a long random token locally on the robot: ```sh ssh root@ ' umask 077 + mkdir -p /var/lib/unitelabs-opentrons-flex token=$(od -An -N32 -tx1 /dev/urandom | tr -d " \n") actor=operator-name { printf "UNITELABS_RUN_MUTATION_TOKEN=%s\n" "$token" printf "UNITELABS_RUN_MUTATION_ACTOR=%s\n" "$actor" - } > /var/sila2_flex/run-mutation.env - chmod 600 /var/sila2_flex/run-mutation.env + } > /var/lib/unitelabs-opentrons-flex/run-mutation.env + chmod 600 /var/lib/unitelabs-opentrons-flex/run-mutation.env printf "Copy this token into the no-echo client prompt: %s\n" "$token" ' ``` @@ -120,7 +121,8 @@ process arguments. It must contain at least 32 characters. Replace `operator-name` before running the command; every mutation or hold-release request must use that exact `actor`. The connector records the environment-bound identity, not an untrusted self-declared value. The service also fails closed -unless the installed Opentrons runtime is the validated 8.8.1 adapter target. +unless the installed Python 3.10, Opentrons, and embedded robot-server runtime is +the validated 9.0.0 target. Its durable ledger is `/var/lib/unitelabs-opentrons-flex/run-mutations.jsonl`. @@ -244,15 +246,15 @@ uv run --extra test python scripts/run_asms_hardware.py \ Add `--scientific` only for the final real-sample run; it restores the full programmed delays. After the first one-column mechanics pass, the same runner can -exercise the complete controlled-insertion path. Export the robot-provisioned -token without placing it on the command line, then provide the exact actor bound -to that token: +exercise the complete controlled-insertion path. Keep the SSH tunnel shown above +open, export the robot-provisioned token without placing it on the command line, +then provide the exact actor bound to that token: ```sh read -r -s UNITELABS_RUN_MUTATION_TOKEN export UNITELABS_RUN_MUTATION_TOKEN uv run --extra test python scripts/run_asms_hardware.py \ - --host --columns 1 --execute \ + --host 127.0.0.1 --port 31951 --columns 1 --execute \ --checkpoint-transfer --mutation-actor \ --confirm-deck-ready ASMS-DECK-READY ``` @@ -266,9 +268,13 @@ already consumes every available fresh tip. 1. **Verify the exact bundle offline.** Run the default exact preflight against the checked-in protocol and `protocols/asms/labware/`. Continue only when it prints `READY`; record the pinned definition hashes. -2. **Prepare rollback before deployment.** Keep - `scripts/switch_mode.sh opentrons` ready. Confirm the connector - deployment uses `use_simulator=false` and `with_robot_server=true`. +2. **Prepare rollback before deployment.** Provision + `/var/lib/unitelabs-opentrons-flex/run-mutation.env`, run `deploy.sh`, and + require its artifact and no-hardware runtime preflight to pass. Keep both + `scripts/switch_mode.sh opentrons` and + `scripts/rollback_connector.sh ` ready. The checked-in live + configuration requires `use_simulator=false`, `with_robot_server=true`, and + `run_mutation_required=true`. 3. **Start read-only.** Verify connector logs, `GET /health`, `GET /pipettes`, `GET /modules`, and SiLA `MachineStatus`. Confirm the right pipette model and Temperature Module identity; release E-stop, close the door, and clear errors. @@ -306,6 +312,8 @@ already consumes every available fresh tip. ## Offline commands ```sh +# Bounded no-motion robot readiness (after connector mode starts) +uv run python scripts/preflight_flex.py # Exact gate using the checked-in ASMS labware bundle (must print READY) uv run python scripts/validate_asms_protocol.py diff --git a/pyproject.toml b/pyproject.toml index 7ed6f75..ae78084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "unitelabs-opentrons-flex" -version = "0.9.0" +version = "0.9.1" description = "A SiLA2 connector for the Opentrons Flex liquid handling robot" readme = "README.md" authors = [{name = "Acceleration Consortium", email = "developers@accelerationconsortium.ca"},] @@ -20,10 +20,21 @@ classifiers = [ requires-python=">=3.10,<3.11" dependencies=[ "unitelabs-cdk~=0.9.0", - "opentrons==8.8.1", - "numpy<1.25", - "uvicorn>=0.20", - "wsproto", + "opentrons==9.0.0", + "numpy>=1.26.4,<2", + "aiohttp==3.12.14", + "anyio==4.9.0", + "fastapi==0.100.0", + "idna==3.3", + "paho-mqtt==1.6.1", + "pydantic==2.11.7", + "pydantic-settings==2.4.0", + "python-can==4.2.2", + "python-dotenv==1.0.1", + "python-multipart==0.0.18", + "sqlalchemy==1.4.51", + "uvicorn==0.27.0.post1", + "wsproto==1.2.0", ] [project.urls] diff --git a/scripts/artifact_manifest.py b/scripts/artifact_manifest.py new file mode 100755 index 0000000..03edf24 --- /dev/null +++ b/scripts/artifact_manifest.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Build and verify a self-describing Flex ARM wheel artifact.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import re +import sys +from pathlib import Path +from typing import Any + +MANIFEST_NAME = "runtime-manifest.json" +CHECKSUMS_NAME = "SHA256SUMS" +SCHEMA_VERSION = 1 +PINNED_RUNTIME_WHEELS = { + "aiohttp": "3.12.14", + "anyio": "4.9.0", + "fastapi": "0.100.0", + "idna": "3.3", + "paho-mqtt": "1.6.1", + "pydantic": "2.11.7", + "pydantic-settings": "2.4.0", + "pyro5": "5.17", + "python-can": "4.2.2", + "python-dotenv": "1.0.1", + "python-multipart": "0.0.18", + "sqlalchemy": "1.4.51", + "uvicorn": "0.27.0.post1", + "wsproto": "1.2.0", +} +_WHEEL_PATTERN = re.compile( + r"^(?P.+?)-(?P[0-9][^-]*)-(?P[^-]+)-(?P[^-]+)-(?P[^-]+)\.whl$" +) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + build = subparsers.add_parser("build", help="Create the manifest and checksum inventory.") + build.add_argument("directory", type=Path) + build.add_argument("--connector-version", required=True) + build.add_argument("--opentrons-version", required=True) + build.add_argument("--robot-server-version", required=True) + build.add_argument("--opentrons-source-commit", required=True) + build.add_argument("--python-version", required=True) + build.add_argument("--architecture", required=True) + + verify = subparsers.add_parser("verify", help="Verify every artifact file and expected runtime.") + verify.add_argument("directory", type=Path) + verify.add_argument("--connector-version") + verify.add_argument("--opentrons-version") + verify.add_argument("--robot-server-version") + verify.add_argument("--opentrons-source-commit") + verify.add_argument("--python-version") + verify.add_argument("--architecture") + verify.add_argument("--check-host-python", action="store_true") + verify.add_argument("--check-host-architecture", action="store_true") + + field = subparsers.add_parser("field", help="Print one top-level manifest field.") + field.add_argument("directory", type=Path) + field.add_argument("name") + return parser + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _normalize_distribution(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _wheel_identity(path: Path) -> tuple[str, str]: + match = _WHEEL_PATTERN.match(path.name) + if match is None: + raise RuntimeError(f"Unrecognized wheel filename: {path.name}") + return _normalize_distribution(match.group("name")), match.group("version") + + +def _require_wheel_version(wheels: list[dict[str, Any]], distribution: str, expected: str) -> None: + normalized = _normalize_distribution(distribution) + versions = sorted({item["version"] for item in wheels if item["distribution"] == normalized}) + if versions != [expected]: + actual = ", ".join(versions) if versions else "missing" + raise RuntimeError(f"{distribution} wheel version is {actual}; expected exactly {expected}.") + + +def build_manifest( + directory: Path, + *, + connector_version: str, + opentrons_version: str, + robot_server_version: str, + opentrons_source_commit: str, + python_version: str, + architecture: str, +) -> dict[str, Any]: + """Write a deterministic manifest and checksum inventory.""" + directory = directory.resolve() + wheel_paths = sorted(directory.glob("*.whl")) + if not wheel_paths: + raise RuntimeError(f"No wheels found in {directory}.") + + wheels: list[dict[str, Any]] = [] + for path in wheel_paths: + distribution, wheel_version = _wheel_identity(path) + wheels.append( + { + "filename": path.name, + "distribution": distribution, + "version": wheel_version, + "size": path.stat().st_size, + "sha256": _sha256(path), + } + ) + + _require_wheel_version(wheels, "unitelabs-opentrons-flex", connector_version) + _require_wheel_version(wheels, "opentrons", opentrons_version) + _require_wheel_version(wheels, "opentrons-shared-data", opentrons_version) + _require_wheel_version(wheels, "opentrons-hardware", opentrons_version) + _require_wheel_version(wheels, "server-utils", opentrons_version) + _require_wheel_version(wheels, "robot-server", robot_server_version) + for distribution, expected_version in PINNED_RUNTIME_WHEELS.items(): + _require_wheel_version(wheels, distribution, expected_version) + if not re.fullmatch(r"[0-9a-f]{40}", opentrons_source_commit): + raise RuntimeError("Opentrons source commit must be a full lowercase Git commit SHA.") + + bundle_digest = hashlib.sha256( + "\n".join(f"{item['sha256']} {item['filename']}" for item in wheels).encode() + ).hexdigest() + release_id = ( + f"flex-{connector_version}-ot{opentrons_version}-py{python_version}-{architecture}-{bundle_digest[:12]}" + ) + manifest = { + "schemaVersion": SCHEMA_VERSION, + "releaseId": release_id, + "connectorVersion": connector_version, + "opentronsVersion": opentrons_version, + "robotServerVersion": robot_server_version, + "opentronsSourceCommit": opentrons_source_commit, + "pythonVersion": python_version, + "architecture": architecture, + "bundleSha256": bundle_digest, + "pinnedRuntimeWheels": PINNED_RUNTIME_WHEELS, + "wheels": wheels, + } + manifest_path = directory / MANIFEST_NAME + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + checksum_paths = [*wheel_paths, manifest_path] + checksums = "\n".join(f"{_sha256(path)} {path.name}" for path in checksum_paths) + "\n" + (directory / CHECKSUMS_NAME).write_text(checksums, encoding="utf-8") + return manifest + + +def _load_manifest(directory: Path) -> dict[str, Any]: + manifest_path = directory / MANIFEST_NAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Cannot read {manifest_path}: {exc}") from exc + if not isinstance(manifest, dict) or manifest.get("schemaVersion") != SCHEMA_VERSION: + raise RuntimeError(f"{manifest_path} does not use schema version {SCHEMA_VERSION}.") + return manifest + + +def _safe_artifact_path(directory: Path, filename: object) -> Path: + if not isinstance(filename, str) or not filename or Path(filename).name != filename: + raise RuntimeError(f"Unsafe artifact filename in manifest: {filename!r}") + return directory / filename + + +def _verify_checksum_file(directory: Path) -> set[str]: + checksum_path = directory / CHECKSUMS_NAME + try: + lines = checksum_path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise RuntimeError(f"Cannot read {checksum_path}: {exc}") from exc + if not lines: + raise RuntimeError(f"{checksum_path} is empty.") + filenames: set[str] = set() + for line in lines: + parts = line.split(" ", maxsplit=1) + if len(parts) != 2 or not re.fullmatch(r"[0-9a-f]{64}", parts[0]): + raise RuntimeError(f"Malformed checksum line: {line!r}") + path = _safe_artifact_path(directory, parts[1]) + if path.name in filenames: + raise RuntimeError(f"Duplicate checksum entry: {path.name}.") + if not path.is_file() or _sha256(path) != parts[0]: + raise RuntimeError(f"Checksum verification failed for {parts[1]}.") + filenames.add(path.name) + return filenames + + +def verify_manifest( + directory: Path, + *, + expected: dict[str, str | None], + check_host_python: bool, + check_host_architecture: bool, +) -> dict[str, Any]: + """Verify artifact integrity and its declared deployment contract.""" + directory = directory.resolve() + manifest = _load_manifest(directory) + checksum_filenames = _verify_checksum_file(directory) + + wheel_records = manifest.get("wheels") + if not isinstance(wheel_records, list) or not wheel_records: + raise RuntimeError("Manifest wheel inventory is missing or empty.") + expected_filenames: set[str] = set() + for record in wheel_records: + if not isinstance(record, dict): + raise RuntimeError("Manifest wheel inventory contains a non-object entry.") + path = _safe_artifact_path(directory, record.get("filename")) + expected_filenames.add(path.name) + if not path.is_file(): + raise RuntimeError(f"Manifest wheel is missing: {path.name}") + if path.stat().st_size != record.get("size") or _sha256(path) != record.get("sha256"): + raise RuntimeError(f"Manifest verification failed for {path.name}.") + distribution, wheel_version = _wheel_identity(path) + if distribution != record.get("distribution") or wheel_version != record.get("version"): + raise RuntimeError(f"Manifest identity does not match wheel filename {path.name}.") + actual_filenames = {path.name for path in directory.glob("*.whl")} + if actual_filenames != expected_filenames: + extras = sorted(actual_filenames - expected_filenames) + missing = sorted(expected_filenames - actual_filenames) + raise RuntimeError(f"Wheel inventory mismatch; extra={extras}, missing={missing}.") + expected_checksum_filenames = expected_filenames | {MANIFEST_NAME} + if checksum_filenames != expected_checksum_filenames: + extras = sorted(checksum_filenames - expected_checksum_filenames) + missing = sorted(expected_checksum_filenames - checksum_filenames) + raise RuntimeError(f"Checksum inventory mismatch; extra={extras}, missing={missing}.") + + runtime_fields = { + "connectorVersion": manifest.get("connectorVersion"), + "opentronsVersion": manifest.get("opentronsVersion"), + "robotServerVersion": manifest.get("robotServerVersion"), + "opentronsSourceCommit": manifest.get("opentronsSourceCommit"), + "pythonVersion": manifest.get("pythonVersion"), + "architecture": manifest.get("architecture"), + } + if not all(isinstance(value, str) and value for value in runtime_fields.values()): + raise RuntimeError("Manifest runtime identity fields must be non-empty strings.") + _require_wheel_version(wheel_records, "unitelabs-opentrons-flex", runtime_fields["connectorVersion"]) + _require_wheel_version(wheel_records, "opentrons", runtime_fields["opentronsVersion"]) + _require_wheel_version(wheel_records, "opentrons-shared-data", runtime_fields["opentronsVersion"]) + _require_wheel_version(wheel_records, "opentrons-hardware", runtime_fields["opentronsVersion"]) + _require_wheel_version(wheel_records, "server-utils", runtime_fields["opentronsVersion"]) + _require_wheel_version(wheel_records, "robot-server", runtime_fields["robotServerVersion"]) + if manifest.get("pinnedRuntimeWheels") != PINNED_RUNTIME_WHEELS: + raise RuntimeError("Manifest pinned runtime wheel contract does not match the verifier.") + for distribution, expected_version in PINNED_RUNTIME_WHEELS.items(): + _require_wheel_version(wheel_records, distribution, expected_version) + + ordered_wheels = sorted(wheel_records, key=lambda item: item["filename"]) + bundle_digest = hashlib.sha256( + "\n".join(f"{item['sha256']} {item['filename']}" for item in ordered_wheels).encode() + ).hexdigest() + if manifest.get("bundleSha256") != bundle_digest: + raise RuntimeError("Manifest bundleSha256 does not match the verified wheel inventory.") + expected_release_id = ( + f"flex-{runtime_fields['connectorVersion']}-ot{runtime_fields['opentronsVersion']}-" + f"py{runtime_fields['pythonVersion']}-{runtime_fields['architecture']}-{bundle_digest[:12]}" + ) + if manifest.get("releaseId") != expected_release_id: + raise RuntimeError(f"Manifest releaseId does not match the verified artifact: {expected_release_id}.") + + for manifest_field, expected_value in expected.items(): + if expected_value is not None and manifest.get(manifest_field) != expected_value: + raise RuntimeError( + f"Manifest {manifest_field} is {manifest.get(manifest_field)!r}; expected {expected_value!r}." + ) + if check_host_python: + host_python = f"{sys.version_info.major}.{sys.version_info.minor}" + if manifest.get("pythonVersion") != host_python: + raise RuntimeError(f"Host Python is {host_python}; artifact requires {manifest.get('pythonVersion')}.") + if check_host_architecture: + host_architecture = platform.machine().lower() + aliases = {"arm64": "aarch64", "amd64": "x86_64"} + normalized_host = aliases.get(host_architecture, host_architecture) + if manifest.get("architecture") != normalized_host: + raise RuntimeError( + f"Host architecture is {normalized_host}; artifact requires {manifest.get('architecture')}." + ) + return manifest + + +def main(argv: list[str] | None = None) -> int: + """Run the artifact manifest CLI.""" + args = _parser().parse_args(argv) + try: + if args.command == "build": + result = build_manifest( + args.directory, + connector_version=args.connector_version, + opentrons_version=args.opentrons_version, + robot_server_version=args.robot_server_version, + opentrons_source_commit=args.opentrons_source_commit, + python_version=args.python_version, + architecture=args.architecture, + ) + sys.stdout.write(result["releaseId"] + "\n") + elif args.command == "verify": + result = verify_manifest( + args.directory, + expected={ + "connectorVersion": args.connector_version, + "opentronsVersion": args.opentrons_version, + "robotServerVersion": args.robot_server_version, + "opentronsSourceCommit": args.opentrons_source_commit, + "pythonVersion": args.python_version, + "architecture": args.architecture, + }, + check_host_python=args.check_host_python, + check_host_architecture=args.check_host_architecture, + ) + sys.stdout.write(f"verified {result['releaseId']}\n") + else: + result = _load_manifest(args.directory.resolve()) + if args.name not in result or isinstance(result[args.name], (dict, list)): + raise RuntimeError(f"Manifest field {args.name!r} is missing or not scalar.") + sys.stdout.write(f"{result[args.name]}\n") + return 0 + except RuntimeError as exc: + sys.stderr.write(f"ERROR: {exc}\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install.sh b/scripts/install.sh index 7e00dec..295ca6e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,22 +1,30 @@ #!/bin/sh -set -e -cd "$(dirname "$0")" +# Install one immutable Flex connector release from the current artifact folder. +set -eu -VENV_PATH="${1:-/var/sila2_flex}" -CONFIG_DEST="$VENV_PATH/config.json" +ARTIFACT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +RELEASE_PATH="${1:?Usage: install.sh }" -echo "Installing to $VENV_PATH..." -# --system-site-packages exposes robot_server (an Opentrons system package, not on -# PyPI) and its deps (uvicorn, wsproto, etc.) to the venv. Our bundled wheels are -# installed on top and take precedence over system packages where versions differ. -python3 -m venv --system-site-packages "$VENV_PATH" -# --root / overrides /etc/pip.conf's "root = /var/user-packages" (which would -# otherwise redirect installs away from the venv's own site-packages) -"$VENV_PATH/bin/pip" install --root / --no-index --no-deps *.whl +case "$RELEASE_PATH" in + /var/lib/unitelabs-opentrons-flex/releases/*) ;; + *) + echo "ERROR: release path must be under /var/lib/unitelabs-opentrons-flex/releases" >&2 + exit 1 + ;; +esac -if [ -f flex_config.json ]; then - cp flex_config.json "$CONFIG_DEST" - echo "Config installed to $CONFIG_DEST" +if [ -e "$RELEASE_PATH" ]; then + echo "ERROR: release path already exists: $RELEASE_PATH" >&2 + exit 1 fi -echo "Done. Run with: $VENV_PATH/bin/python -m unitelabs.opentrons_flex --config-path $CONFIG_DEST" +mkdir -p "$(dirname "$RELEASE_PATH")" +python3 -m venv --system-site-packages "$RELEASE_PATH" + +# The robot image's /etc/pip.conf sets "root=/var/user-packages". --root / +# is required even inside a venv or pip silently installs elsewhere. +"$RELEASE_PATH/bin/pip" install --root / --no-index --no-deps "$ARTIFACT_DIR"/*.whl +cp "$ARTIFACT_DIR/flex_config.json" "$RELEASE_PATH/config.json" +cp "$ARTIFACT_DIR/runtime-manifest.json" "$RELEASE_PATH/runtime-manifest.json" + +echo "Installed immutable release at $RELEASE_PATH" diff --git a/scripts/install_connector_service.sh b/scripts/install_connector_service.sh index 1bb77d0..58ab8ab 100755 --- a/scripts/install_connector_service.sh +++ b/scripts/install_connector_service.sh @@ -1,43 +1,64 @@ #!/bin/sh -# Install the SiLA2 connector as a systemd service on the Flex. -# Disables the Opentrons robot server first so we get exclusive hardware access. -# Usage: ./scripts/install_connector_service.sh -set -e +# Install and start connector mode only after no-hardware runtime validation. +set -eu HOST="${1:?Usage: $0 }" -SCRIPT_DIR="$(dirname "$0")" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" -echo "Copying start_connector.sh to robot..." scp "$SCRIPT_DIR/start_connector.sh" "root@$HOST:/data/start_connector.sh" -ssh "root@${HOST}" ' -set -e -mount -o remount,rw / +ssh "root@$HOST" sh <<'REMOTE' +set -eu + +STATE_DIR="/var/lib/unitelabs-opentrons-flex" +ACTIVE_PATH="/var/sila2_flex" +ENV_FILE="$STATE_DIR/run-mutation.env" +SERVICE_STATE="$STATE_DIR/stock-service-state" +HARDWARE_SERVICES="opentrons-robot-server opentrons-status-bar opentrons-gpio-setup opentrons-status-leds" -if ! grep -q "^UNITELABS_RUN_MUTATION_TOKEN=.\{32\}.*" /var/sila2_flex/run-mutation.env 2>/dev/null; then - echo "Missing /var/sila2_flex/run-mutation.env with a token of at least 32 random characters." >&2 - echo "Provision the root-readable secret described in docs/asms_flex_workflow_test.md, then rerun." >&2 +if [ ! -L "$ACTIVE_PATH" ] || [ ! -x "$ACTIVE_PATH/bin/python" ]; then + echo "ERROR: no activated versioned connector at $ACTIVE_PATH; run deploy.sh first." >&2 exit 1 fi -if ! grep -q "^UNITELABS_RUN_MUTATION_ACTOR=..*" /var/sila2_flex/run-mutation.env 2>/dev/null; then - echo "Missing UNITELABS_RUN_MUTATION_ACTOR in /var/sila2_flex/run-mutation.env." >&2 - echo "Bind the credential to the operator identity described in docs/asms_flex_workflow_test.md." >&2 +if ! grep -q "^UNITELABS_RUN_MUTATION_TOKEN=.\{32\}.*" "$ENV_FILE" 2>/dev/null; then + echo "ERROR: $ENV_FILE must contain UNITELABS_RUN_MUTATION_TOKEN with at least 32 characters." >&2 + exit 1 +fi +if ! grep -q "^UNITELABS_RUN_MUTATION_ACTOR=..*" "$ENV_FILE" 2>/dev/null; then + echo "ERROR: $ENV_FILE must contain UNITELABS_RUN_MUTATION_ACTOR." >&2 + exit 1 +fi +chmod 600 "$ENV_FILE" +set -a +. "$ENV_FILE" +set +a + +# Import every private API needed by connector mode before touching a service +# or initializing the CAN hardware. +"$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$ACTIVE_PATH/config.json" \ + --require-robot-server \ + --require-mutation \ + --require-live-hardware + +mount -o remount,rw / +mkdir -p "$STATE_DIR" +LOCK_DIR="$STATE_DIR/operation.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "ERROR: another Flex deployment or mode transition is in progress ($LOCK_DIR)." >&2 + exit 1 +fi +release_operation_lock() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap release_operation_lock 0 HUP INT TERM + +if systemctl is-active --quiet sila2-connector 2>/dev/null; then + echo "ERROR: connector is already active; switch to stock mode before reinstalling its service." >&2 exit 1 fi -chmod 600 /var/sila2_flex/run-mutation.env - -# Stop/disable the stock robot server so the connector owns the CAN hardware. -# On the Flex, motion/instruments/gripper are reached over CAN through OT3API; -# there are no OT-2 Pi-GPIO units, but we still try a few known auxiliary units -# (ignored if absent) so nothing else grabs the bus or the status bar. -echo "Disabling Opentrons services that hold the hardware..." -for svc in opentrons-robot-server opentrons-status-bar opentrons-gpio-setup opentrons-status-leds; do - systemctl disable "$svc" 2>/dev/null || true - systemctl stop "$svc" 2>/dev/null || true -done -echo "Installing sila2-connector service..." -cat > /etc/systemd/system/sila2-connector.service << EOF +cat > /etc/systemd/system/sila2-connector.service < "$SERVICE_STATE" +for svc in $HARDWARE_SERVICES; do + if systemctl cat "$svc" >/dev/null 2>&1; then + ENABLED=no + ACTIVE=no + systemctl is-enabled --quiet "$svc" 2>/dev/null && ENABLED=yes + systemctl is-active --quiet "$svc" 2>/dev/null && ACTIVE=yes + printf '%s|%s|%s\n' "$svc" "$ENABLED" "$ACTIVE" >> "$SERVICE_STATE" + fi +done + +restore_stock() { + echo "Restoring stock Opentrons services..." + systemctl stop sila2-connector 2>/dev/null || true + systemctl disable sila2-connector 2>/dev/null || true + while IFS='|' read -r svc enabled active; do + [ -n "$svc" ] || continue + if [ "$enabled" = yes ]; then + systemctl enable "$svc" 2>/dev/null || true + fi + if [ "$active" = yes ] || [ "$svc" = opentrons-robot-server ]; then + systemctl start "$svc" 2>/dev/null || true + fi + done < "$SERVICE_STATE" + i=60 + while [ "$i" -gt 0 ]; do + if systemctl is-active --quiet opentrons-robot-server 2>/dev/null && + curl --fail --silent --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:31950/health >/dev/null 2>&1; then + echo "Stock Opentrons mode restored and healthy." + return 0 + fi + i=$((i - 1)) + sleep 2 + done + echo "ERROR: stock robot-server did not recover within 120 seconds." >&2 + systemctl status opentrons-robot-server --no-pager || true + return 1 +} + +echo "Stopping stock hardware owners..." +for svc in $HARDWARE_SERVICES; do + systemctl stop "$svc" 2>/dev/null || true +done + +systemctl reset-failed sila2-connector 2>/dev/null || true +if ! systemctl start sila2-connector; then + systemctl status sila2-connector --no-pager || true + restore_stock + exit 1 +fi + +echo "Waiting up to 120 seconds for gRPC and embedded HTTP..." +READY=no +i=60 +while [ "$i" -gt 0 ]; do + if python3 -c "import socket; s=socket.create_connection(('127.0.0.1',50051),2); s.close()" 2>/dev/null && + curl --fail --silent --show-error --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:31950/health >/dev/null 2>&1; then + READY=yes + break + fi + i=$((i - 1)) + sleep 2 +done + +if [ "$READY" != yes ]; then + echo "ERROR: connector failed the bounded startup health check." >&2 + systemctl status sila2-connector --no-pager || true + journalctl -u sila2-connector -n 100 --no-pager || true + restore_stock + exit 1 +fi + +# These two read-only requests catch the exact failure mode where /health works +# but robot-server state or connector-owned mutation routes were not initialized. +if ! curl --fail --silent --show-error --connect-timeout 2 --max-time 10 \ + http://127.0.0.1:31950/deck_configuration >/dev/null; then + echo "ERROR: embedded /deck_configuration did not answer within 10 seconds." >&2 + restore_stock + exit 1 +fi +if ! curl --fail --silent --show-error --connect-timeout 2 --max-time 10 \ + http://127.0.0.1:31950/openapi.json | + python3 -c "import json,sys; paths=json.load(sys.stdin).get('paths',{}); assert '/unitelabs/runs/{run_id}/mutations' in paths"; then + echo "ERROR: controlled mutation routes are absent from embedded robot-server." >&2 + restore_stock + exit 1 +fi + +for svc in $HARDWARE_SERVICES; do + systemctl disable "$svc" 2>/dev/null || true +done systemctl enable sila2-connector -systemctl restart sila2-connector + +echo "Connector mode is healthy." systemctl status sila2-connector --no-pager -' +rmdir "$LOCK_DIR" +trap - 0 HUP INT TERM +REMOTE diff --git a/scripts/preflight_flex.py b/scripts/preflight_flex.py new file mode 100755 index 0000000..ea63406 --- /dev/null +++ b/scripts/preflight_flex.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Run bounded, read-only readiness checks before a Flex workflow test.""" + +from __future__ import annotations + +import argparse +import json +import socket +import subprocess +import sys +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass +from typing import Any + + +@dataclass(frozen=True) +class Check: + """One no-motion readiness result.""" + + name: str + ok: bool + detail: str + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("host", help="Flex hostname or IP address.") + parser.add_argument("--ssh-user", default="root") + parser.add_argument("--http-port", type=int, default=31950) + parser.add_argument("--grpc-port", type=int, default=50051) + parser.add_argument("--timeout", type=float, default=8.0) + parser.add_argument("--runtime-only", action="store_true", help="Skip live HTTP and gRPC checks.") + parser.add_argument( + "--allow-base-only", + action="store_true", + help="Do not require controlled mutation readiness or routes.", + ) + return parser + + +def _remote_runtime(args: argparse.Namespace) -> Check: + mutation_flag = "" if args.allow_base_only else "--require-mutation" + remote_command = ( + "set -eu; " + "test -x /var/sila2_flex/bin/python; " + "if test -f /var/lib/unitelabs-opentrons-flex/run-mutation.env; then " + "set -a; . /var/lib/unitelabs-opentrons-flex/run-mutation.env; set +a; fi; " + "exec /var/sila2_flex/bin/python -m unitelabs.opentrons_flex.runtime_preflight " + "--config /var/sila2_flex/config.json --require-robot-server " + f"{mutation_flag}" + ) + try: + completed = subprocess.run( + [ + "ssh", + "-o", + f"ConnectTimeout={max(1, int(args.timeout))}", + "-o", + "BatchMode=yes", + f"{args.ssh_user}@{args.host}", + remote_command, + ], + check=False, + capture_output=True, + text=True, + timeout=args.timeout + 5, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return Check("remote-runtime", False, str(exc)) + output = completed.stdout.strip() or completed.stderr.strip() + if completed.returncode != 0: + return Check("remote-runtime", False, output[-2000:]) + try: + report = json.loads(completed.stdout) + detail = ( + f"connector={report['connector_version']}, opentrons={report['opentrons_version']}, " + f"python={report['python_version']}, mutation_ready={report['mutation_ready']}" + ) + except (json.JSONDecodeError, KeyError): + detail = output[-2000:] + return Check("remote-runtime", True, detail) + + +def _tcp_check(host: str, port: int, timeout: float) -> Check: + try: + with socket.create_connection((host, port), timeout=timeout): + pass + except OSError as exc: + return Check(f"tcp:{port}", False, str(exc)) + return Check(f"tcp:{port}", True, "connection accepted") + + +def _http_json(host: str, port: int, path: str, timeout: float) -> tuple[Check, Any | None]: + url = f"http://{host}:{port}{path}" + request = urllib.request.Request(url, headers={"User-Agent": "unitelabs-flex-preflight/0.9.1"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + status = response.status + except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: + return Check(f"http:{path}", False, str(exc)), None + return Check(f"http:{path}", True, f"HTTP {status}"), payload + + +def main(argv: list[str] | None = None) -> int: + """Run all requested no-motion checks and print a machine-readable report.""" + args = _parser().parse_args(argv) + checks = [_remote_runtime(args)] + if not args.runtime_only: + checks.append(_tcp_check(args.host, args.grpc_port, args.timeout)) + for path in ("/health", "/deck_configuration", "/pipettes", "/modules", "/openapi.json"): + check, payload = _http_json(args.host, args.http_port, path, args.timeout) + checks.append(check) + if path == "/openapi.json" and check.ok and not args.allow_base_only: + paths = payload.get("paths", {}) if isinstance(payload, dict) else {} + mutation_path = "/unitelabs/runs/{run_id}/mutations" + checks.append( + Check( + "mutation-route", + mutation_path in paths, + f"{mutation_path} {'present' if mutation_path in paths else 'missing'}", + ) + ) + report = {"host": args.host, "checks": [asdict(check) for check in checks]} + sys.stdout.write(json.dumps(report, indent=2, sort_keys=True) + "\n") + return 0 if all(check.ok for check in checks) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rollback_connector.sh b/scripts/rollback_connector.sh new file mode 100755 index 0000000..8a7d913 --- /dev/null +++ b/scripts/rollback_connector.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# Restore the previously activated connector release while leaving stock mode on. +set -eu + +HOST="${1:?Usage: $0 }" + +ssh "root@$HOST" sh <<'REMOTE' +set -eu +STATE_DIR="/var/lib/unitelabs-opentrons-flex" +ACTIVE_PATH="/var/sila2_flex" +PREVIOUS_FILE="$STATE_DIR/previous-release" +SERVICE_STATE="$STATE_DIR/stock-service-state" +LEGACY_PATH="$STATE_DIR/legacy-pre-versioned-release" + +restore_stock_services() { + if [ -f "$SERVICE_STATE" ]; then + while IFS='|' read -r svc enabled active; do + [ -n "$svc" ] || continue + if [ "$enabled" = yes ]; then + systemctl enable "$svc" 2>/dev/null || true + fi + if [ "$active" = yes ] || [ "$svc" = opentrons-robot-server ]; then + systemctl start "$svc" 2>/dev/null || true + fi + done < "$SERVICE_STATE" + else + systemctl enable opentrons-robot-server 2>/dev/null || true + systemctl start opentrons-robot-server + fi +} + +wait_for_stock() { + i=60 + while [ "$i" -gt 0 ]; do + if systemctl is-active --quiet opentrons-robot-server 2>/dev/null && + curl --fail --silent --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:31950/health >/dev/null 2>&1; then + return 0 + fi + i=$((i - 1)) + sleep 2 + done + return 1 +} + +mount -o remount,rw / +mkdir -p "$STATE_DIR" +LOCK_DIR="$STATE_DIR/operation.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "ERROR: another Flex deployment or mode transition is in progress ($LOCK_DIR)." >&2 + exit 1 +fi +release_operation_lock() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap release_operation_lock 0 HUP INT TERM + +if [ ! -f "$PREVIOUS_FILE" ]; then + echo "ERROR: no previous connector release is recorded." >&2 + exit 1 +fi +RECORD="$(sed -n '1p' "$PREVIOUS_FILE")" +KIND="${RECORD%%:*}" +TARGET="${RECORD#*:}" +if [ "$TARGET" = "$RECORD" ] || [ -z "$TARGET" ]; then + echo "ERROR: invalid previous-release record." >&2 + exit 1 +fi + +systemctl stop sila2-connector 2>/dev/null || true +systemctl disable sila2-connector 2>/dev/null || true +restore_stock_services +if ! wait_for_stock; then + echo "ERROR: stock robot-server did not recover within 120 seconds; release activation is unchanged." >&2 + systemctl status opentrons-robot-server --no-pager || true + exit 1 +fi + +CURRENT_TARGET="" +if [ -L "$ACTIVE_PATH" ]; then + CURRENT_TARGET="$(readlink -f "$ACTIVE_PATH")" +elif [ -d "$ACTIVE_PATH" ]; then + if [ -e "$LEGACY_PATH" ]; then + echo "ERROR: both the active directory and managed legacy release exist." >&2 + exit 1 + fi + mv "$ACTIVE_PATH" "$LEGACY_PATH" + CURRENT_TARGET="$LEGACY_PATH" +elif [ -e "$ACTIVE_PATH" ]; then + echo "ERROR: active path is neither a directory nor symlink: $ACTIVE_PATH" >&2 + exit 1 +fi + +activate_target() { + target="$1" + next_link="$ACTIVE_PATH.rollback.$$" + ln -s "$target" "$next_link" + python3 -c 'import os,sys; os.replace(sys.argv[1], sys.argv[2])' "$next_link" "$ACTIVE_PATH" + if [ "$(readlink -f "$ACTIVE_PATH")" != "$target" ]; then + echo "ERROR: rollback activation verification failed for $target." >&2 + exit 1 + fi +} + +case "$KIND" in + release) + case "$TARGET" in + "$STATE_DIR"/releases/*) ;; + *) + echo "ERROR: recorded release is outside the managed release directory." >&2 + exit 1 + ;; + esac + if [ ! -x "$TARGET/bin/python" ]; then + echo "ERROR: recorded release is unavailable: $TARGET" >&2 + exit 1 + fi + if [ -f "$STATE_DIR/run-mutation.env" ]; then + set -a + . "$STATE_DIR/run-mutation.env" + set +a + fi + "$TARGET/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$TARGET/config.json" \ + --require-robot-server + activate_target "$TARGET" + ;; + legacy) + if [ "$TARGET" != "$LEGACY_PATH" ] || [ ! -d "$TARGET" ]; then + echo "ERROR: recorded legacy release is unavailable: $TARGET" >&2 + exit 1 + fi + activate_target "$TARGET" + ;; + *) + echo "ERROR: unsupported previous-release record type: $KIND" >&2 + exit 1 + ;; +esac + +case "$CURRENT_TARGET" in + "$STATE_DIR"/releases/*) + printf 'release:%s\n' "$CURRENT_TARGET" > "$PREVIOUS_FILE" + ;; + "$LEGACY_PATH") + printf 'legacy:%s\n' "$CURRENT_TARGET" > "$PREVIOUS_FILE" + ;; + "") + rm -f "$PREVIOUS_FILE" + ;; + *) + echo "ERROR: previous active target is outside the managed release locations: $CURRENT_TARGET" >&2 + exit 1 + ;; +esac + +echo "Rollback activation complete. Stock robot-server remains active." +echo "Run switch_mode.sh only after validating the restored connector's workflow compatibility." +rmdir "$LOCK_DIR" +trap - 0 HUP INT TERM +REMOTE diff --git a/scripts/run_asms_hardware.py b/scripts/run_asms_hardware.py old mode 100644 new mode 100755 index c9abe12..f647cb5 --- a/scripts/run_asms_hardware.py +++ b/scripts/run_asms_hardware.py @@ -5,6 +5,7 @@ import argparse import getpass +import ipaddress import json import os import sys @@ -94,6 +95,12 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument("--analysis-timeout", type=float, default=120.0) parser.add_argument("--run-timeout", type=float, default=3600.0) + parser.add_argument( + "--request-timeout", + type=float, + default=10.0, + help="Maximum seconds for any individual HTTP request, including deck inventory.", + ) return parser @@ -110,6 +117,15 @@ def _runtime_parameters( } +def _is_literal_loopback(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host.strip("[]")).is_loopback + except ValueError: + return False + + def _exact_protocol_files() -> list[tuple[str, tuple[str, bytes, str]]]: files = [("files", (_PROTOCOL.name, _PROTOCOL.read_bytes(), "text/x-python"))] files.extend( @@ -157,6 +173,20 @@ def _hardware_inventory_errors(pipettes: dict, modules: dict) -> list[str]: return errors +def _mutation_route_errors(openapi: dict) -> list[str]: + paths = openapi.get("paths") + mutation_path = "/unitelabs/runs/{run_id}/mutations" + snapshot_path = "/unitelabs/runs/{run_id}/mutation-snapshot" + if not isinstance(paths, dict): + return ["OpenAPI response has no path inventory"] + errors = [] + if mutation_path not in paths: + errors.append(f"missing {mutation_path}") + if snapshot_path not in paths: + errors.append(f"missing {snapshot_path}") + return errors + + def _validate_exact_bundle() -> None: report = inspect_protocol( _PROTOCOL, @@ -388,8 +418,25 @@ def _verify_two_column_evidence(total: int, counts: Counter) -> None: raise RuntimeError("Two-column command evidence mismatch: " + "; ".join(errors)) +def _stop_run_best_effort(client: httpx.Client, run_id: str, *, reason: str) -> None: + print(f"{reason}; stopping run {run_id}...", file=sys.stderr) + try: + stop = client.post( + f"/runs/{run_id}/actions", + json={"data": {"actionType": "stop"}}, + ) + except Exception as exc: + print(f"WARNING: stop request failed: {exc}", file=sys.stderr) + return + if stop.status_code != 201: + print(f"WARNING: stop returned HTTP {stop.status_code}: {stop.text}", file=sys.stderr) + + def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) + if args.request_timeout <= 0: + print("BLOCKED: --request-timeout must be greater than zero.", file=sys.stderr) + return 2 if args.execute and args.confirm_deck_ready != _EXECUTION_CONFIRMATION: print( f"BLOCKED: execution requires --confirm-deck-ready {_EXECUTION_CONFIRMATION}", @@ -405,6 +452,13 @@ def main(argv: Sequence[str] | None = None) -> int: if args.execute and args.checkpoint_transfer and not args.mutation_actor: print("BLOCKED: --mutation-actor is required with --checkpoint-transfer.", file=sys.stderr) return 2 + if args.execute and args.checkpoint_transfer and not _is_literal_loopback(args.host): + print( + "BLOCKED: authenticated checkpoint mode requires --host 127.0.0.1 or localhost " + "through the documented SSH tunnel; mutation tokens must not cross plaintext robot HTTP.", + file=sys.stderr, + ) + return 2 mutation_token = None if args.execute and args.checkpoint_transfer: @@ -426,13 +480,27 @@ def main(argv: Sequence[str] | None = None) -> int: with httpx.Client( base_url=base_url, headers={_HTTP_API_VERSION_HEADER: "*"}, - timeout=30.0, + timeout=httpx.Timeout(args.request_timeout, connect=min(args.request_timeout, 5.0)), ) as client: health = client.get("/health") if not health.is_success: raise RuntimeError(f"Connector health check failed with HTTP {health.status_code}: {health.text}") print("Connector health: PASS") + if args.checkpoint_transfer: + openapi_response = client.get("/openapi.json") + if not openapi_response.is_success: + raise RuntimeError( + "Controlled mutation readiness failed before any protocol upload: " + f"HTTP {openapi_response.status_code}: {openapi_response.text}" + ) + route_errors = _mutation_route_errors(openapi_response.json()) + if route_errors: + raise RuntimeError( + "Controlled mutation is unavailable before any protocol upload: " + "; ".join(route_errors) + ) + print("Controlled mutation routes: PASS") + pipettes_response = client.get("/pipettes") if not pipettes_response.is_success: raise RuntimeError( @@ -476,14 +544,14 @@ def main(argv: Sequence[str] | None = None) -> int: run = _response_data(response, action="run creation") run_id = run["id"] print(f"Created run {run_id}") - response = client.post( - f"/runs/{run_id}/actions", - json={"data": {"actionType": "play"}}, - ) - if response.status_code != 201: - raise RuntimeError(f"Run start failed with HTTP {response.status_code}: {response.text}") - + run_terminal = run.get("status") in _TERMINAL_RUN_STATES try: + response = client.post( + f"/runs/{run_id}/actions", + json={"data": {"actionType": "play"}}, + ) + if response.status_code != 201: + raise RuntimeError(f"Run start failed with HTTP {response.status_code}: {response.text}") if args.checkpoint_transfer: if mutation_token is None: raise RuntimeError("Mutation token was not provisioned before starting the run") @@ -499,6 +567,7 @@ def main(argv: Sequence[str] | None = None) -> int: checkpoint_controller, timeout=args.run_timeout, ) + run_terminal = run.get("status") in _TERMINAL_RUN_STATES if len(checkpoint_controller.handled_checkpoint_ids) != 7: raise RuntimeError( "Checkpoint workflow did not authenticate all 7 resumes: " @@ -508,15 +577,15 @@ def main(argv: Sequence[str] | None = None) -> int: raise RuntimeError("Checkpoint workflow finished without the required inserted transfer") else: run = _wait_for_run(client, run_id, timeout=args.run_timeout) + run_terminal = run.get("status") in _TERMINAL_RUN_STATES except KeyboardInterrupt: - print(f"\nInterrupt received; stopping run {run_id}...") - stop = client.post( - f"/runs/{run_id}/actions", - json={"data": {"actionType": "stop"}}, - ) - if stop.status_code != 201: - print(f"WARNING: stop returned HTTP {stop.status_code}: {stop.text}", file=sys.stderr) + if not run_terminal: + _stop_run_best_effort(client, run_id, reason="\nInterrupt received") return 130 + except Exception: + if not run_terminal: + _stop_run_best_effort(client, run_id, reason="Run ownership aborted by an error") + raise total, counts = _read_command_counts(client, run_id) print( diff --git a/scripts/switch_mode.sh b/scripts/switch_mode.sh old mode 100644 new mode 100755 index 6a55025..a63c6cd --- a/scripts/switch_mode.sh +++ b/scripts/switch_mode.sh @@ -1,58 +1,93 @@ #!/bin/sh -# Switch the Flex between two operating modes. -# -# Usage: -# sh scripts/switch_mode.sh -# -# Modes -# ----- -# connector (recommended) -# Runs the SiLA2 connector, which owns the hardware exclusively. -# The connector starts the opentrons HTTP robot-server IN-PROCESS, -# sharing one HardwareControlAPI (OT3API) so there is no CAN-bus conflict. -# Both interfaces are available simultaneously after switching: -# - SiLA2 gRPC: port 50051 -# - opentrons HTTP API: port 31950 (nginx -> /run/aiohttp.sock) -# All standard opentrons HTTP endpoints (/health, /pipettes, /runs, etc.) -# work exactly as they do under the original robot-server, because our -# connector injects the shared hardware into the robot-server app state -# before uvicorn starts. The opentrons-robot-server systemd service is -# intentionally disabled in this mode; the sila2-connector service owns -# the hardware. -# -# opentrons -# Runs only the original opentrons-robot-server (standalone). -# No SiLA2 interface is available. Use this when you need direct access -# via the Opentrons app or other opentrons-native tooling without the -# SiLA2 layer. -# -# Persistence -# ----------- -# The switch enables/disables the relevant systemd units so the selected -# mode survives a reboot. The Flex root filesystem is read-only; this -# script remounts it read-write before writing systemd enable/disable -# symlinks (the same pattern used by install_connector_service.sh). -# -# Hardware ownership -# ------------------ -# Both modes need exclusive access to the CAN bus that reaches the Flex -# motor-controller boards (gantry, head, pipettes, gripper). The Flex has no -# serial Smoothie port; the connector releases the bus when its process exits, -# so this script stops the current mode and waits for the next mode's port to -# come up rather than polling a serial device. - -set -e +# Safely switch exclusive Flex hardware ownership between connector and stock. +set -eu HOST="${1:?Usage: $0 }" MODE="${2:?Usage: $0 }" - case "$MODE" in connector|opentrons) ;; - *) echo "ERROR: mode must be 'connector' or 'opentrons'"; exit 1 ;; + *) + echo "ERROR: mode must be connector or opentrons" >&2 + exit 1 + ;; esac -ssh "root@${HOST}" sh << EOF -set -e +ssh "root@$HOST" sh -s -- "$MODE" <<'REMOTE' +set -eu +MODE="$1" +STATE_DIR="/var/lib/unitelabs-opentrons-flex" +ACTIVE_PATH="/var/sila2_flex" +ENV_FILE="$STATE_DIR/run-mutation.env" +SERVICE_STATE="$STATE_DIR/stock-service-state" +HARDWARE_SERVICES="opentrons-robot-server opentrons-status-bar opentrons-gpio-setup opentrons-status-leds" + +restore_stock_services() { + if [ -f "$SERVICE_STATE" ]; then + while IFS='|' read -r svc enabled active; do + [ -n "$svc" ] || continue + if [ "$enabled" = yes ]; then + systemctl enable "$svc" 2>/dev/null || true + fi + if [ "$active" = yes ] || [ "$svc" = opentrons-robot-server ]; then + systemctl start "$svc" 2>/dev/null || true + fi + done < "$SERVICE_STATE" + else + systemctl enable opentrons-robot-server 2>/dev/null || true + systemctl start opentrons-robot-server + fi +} + +stop_stock_services() { + for svc in $HARDWARE_SERVICES; do + systemctl stop "$svc" 2>/dev/null || true + done +} + +wait_for_connector() { + i=60 + while [ "$i" -gt 0 ]; do + if python3 -c "import socket; s=socket.create_connection(('127.0.0.1',50051),2); s.close()" 2>/dev/null && + curl --fail --silent --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:31950/health >/dev/null 2>&1; then + curl --fail --silent --connect-timeout 2 --max-time 10 \ + http://127.0.0.1:31950/deck_configuration >/dev/null && + curl --fail --silent --connect-timeout 2 --max-time 10 \ + http://127.0.0.1:31950/openapi.json | + python3 -c "import json,sys; assert '/unitelabs/runs/{run_id}/mutations' in json.load(sys.stdin).get('paths',{})" + return $? + fi + i=$((i - 1)) + sleep 2 + done + return 1 +} + +wait_for_stock() { + i=60 + while [ "$i" -gt 0 ]; do + if systemctl is-active --quiet opentrons-robot-server 2>/dev/null && + curl --fail --silent --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:31950/health >/dev/null 2>&1; then + return 0 + fi + i=$((i - 1)) + sleep 2 + done + return 1 +} + +mount -o remount,rw / +mkdir -p "$STATE_DIR" +LOCK_DIR="$STATE_DIR/operation.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "ERROR: another Flex deployment or mode transition is in progress ($LOCK_DIR)." >&2 + exit 1 +fi +release_operation_lock() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap release_operation_lock 0 HUP INT TERM if systemctl is-active --quiet sila2-connector 2>/dev/null; then CURRENT=connector @@ -61,99 +96,68 @@ elif systemctl is-active --quiet opentrons-robot-server 2>/dev/null; then else CURRENT=none fi +echo "Current: $CURRENT -> Target: $MODE" + +if [ "$MODE" = connector ]; then + if [ ! -L "$ACTIVE_PATH" ] || [ ! -x "$ACTIVE_PATH/bin/python" ]; then + echo "ERROR: no activated versioned connector; run deploy.sh first." >&2 + exit 1 + fi + if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: missing $ENV_FILE." >&2 + exit 1 + fi + set -a + . "$ENV_FILE" + set +a + "$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$ACTIVE_PATH/config.json" \ + --require-robot-server \ + --require-mutation \ + --require-live-hardware -echo "Current: \$CURRENT -> Target: $MODE" + if [ "$CURRENT" = connector ] && wait_for_connector; then + echo "Connector mode is already healthy." + exit 0 + fi -if [ "\$CURRENT" = "$MODE" ]; then - echo "Already in $MODE mode." + systemctl stop sila2-connector 2>/dev/null || true + stop_stock_services + systemctl reset-failed sila2-connector 2>/dev/null || true + if ! systemctl start sila2-connector || ! wait_for_connector; then + echo "ERROR: connector mode failed; restoring stock Opentrons mode." >&2 + systemctl status sila2-connector --no-pager || true + journalctl -u sila2-connector -n 100 --no-pager || true + systemctl stop sila2-connector 2>/dev/null || true + systemctl disable sila2-connector 2>/dev/null || true + restore_stock_services + if ! wait_for_stock; then + echo "ERROR: stock robot-server did not recover within 120 seconds." >&2 + systemctl status opentrons-robot-server --no-pager || true + fi + exit 1 + fi + for svc in $HARDWARE_SERVICES; do + systemctl disable "$svc" 2>/dev/null || true + done + systemctl enable sila2-connector + echo "Connector mode healthy: gRPC 50051, embedded HTTP 31950." exit 0 fi -# Remount root read-write so systemd enable/disable can write symlinks. -mount -o remount,rw / - -# Stop current mode first — the CAN bus must be free before starting the next. -echo "" -echo "Stopping \$CURRENT..." -case "\$CURRENT" in - connector) - systemctl stop sila2-connector - ;; - opentrons) - systemctl stop opentrons-robot-server || true - ;; -esac - -# The hardware controller releases the CAN bus as its process exits. Give it a -# moment to tear down before the next owner brings the bus back up. -sleep 3 -echo "Stopped." - -# Enable the target set and disable the outgoing set so the choice survives reboot. -echo "" -echo "Persisting mode selection..." -case "$MODE" in - connector) - systemctl enable sila2-connector - systemctl disable opentrons-robot-server 2>/dev/null || true - ;; - opentrons) - systemctl disable sila2-connector 2>/dev/null || true - systemctl enable opentrons-robot-server 2>/dev/null || true - ;; -esac +if [ "$CURRENT" = opentrons ] && wait_for_stock; then + echo "Stock Opentrons mode is already healthy." + exit 0 +fi -# Start target mode and verify. -echo "" -echo "Starting $MODE..." -case "$MODE" in - connector) - systemctl reset-failed sila2-connector 2>/dev/null || true - systemctl start sila2-connector - echo "Waiting for connector on port 50051 (up to 5 minutes on first start)..." - i=300 - while ! python3 -c " -import socket -s = socket.socket() -s.settimeout(1) -s.connect(('127.0.0.1', 50051)) -s.close() -" 2>/dev/null; do - i=\$((i - 2)) - if [ \$i -le 0 ]; then - echo "ERROR: timed out waiting for port 50051" - systemctl status sila2-connector --no-pager - exit 1 - fi - printf '.' - sleep 2 - done - echo " up." - echo "" - echo "SiLA2 gRPC: port 50051" - echo "opentrons HTTP API: port 31950 (via nginx -> /run/aiohttp.sock)" - echo "" - systemctl status sila2-connector --no-pager - ;; - opentrons) - systemctl start opentrons-robot-server - echo "Waiting for opentrons-robot-server to become active..." - i=120 - while ! systemctl is-active --quiet opentrons-robot-server 2>/dev/null; do - i=\$((i - 2)) - if [ \$i -le 0 ]; then - echo "ERROR: timed out waiting for opentrons-robot-server" - systemctl status opentrons-robot-server --no-pager - exit 1 - fi - printf '.' - sleep 2 - done - echo " up." - echo "" - echo "opentrons HTTP API: port 31950" - echo "" - systemctl status opentrons-robot-server --no-pager - ;; -esac -EOF +systemctl stop sila2-connector 2>/dev/null || true +systemctl disable sila2-connector 2>/dev/null || true +restore_stock_services +if ! wait_for_stock; then + echo "ERROR: stock robot-server failed its bounded health check." >&2 + systemctl status opentrons-robot-server --no-pager || true + journalctl -u opentrons-robot-server -n 100 --no-pager || true + exit 1 +fi +echo "Stock Opentrons mode healthy: HTTP 31950; SiLA gRPC disabled." +REMOTE diff --git a/src/unitelabs/opentrons_flex/__init__.py b/src/unitelabs/opentrons_flex/__init__.py index 2e2da2b..5ce6b50 100644 --- a/src/unitelabs/opentrons_flex/__init__.py +++ b/src/unitelabs/opentrons_flex/__init__.py @@ -48,13 +48,16 @@ RunMutationHttpGuard, ) from .io.simulator_compat import OT3SimulatorCompatibilityAdapter -from .run_mutation import MUTATION_CHECKPOINT_PREFIX, MutationLedger, RunMutationCoordinator +from .run_mutation import MUTATION_CHECKPOINT_PREFIX, MutationLedger, MutationLedgerError, RunMutationCoordinator +from .runtime_compat import ( + RuntimeCompatibilityReport, + mutation_configuration_issues, + require_compatible_runtime, +) log = logging.getLogger(__name__) __version__ = version("unitelabs-opentrons-flex") -_MINIMUM_MUTATION_TOKEN_LENGTH = 32 -_SUPPORTED_MUTATION_OPENTRONS_VERSIONS = frozenset({"8.8.1"}) class _AppWithState(typing.Protocol): @@ -163,6 +166,14 @@ class OpentronsFlexConfig(ConnectorBaseConfig): the environment-bound value rather than trusting caller-supplied identity. """ + run_mutation_required: bool = False + """Fail connector startup when controlled run mutation is not ready. + + The default keeps the base SiLA and HTTP interfaces available if only the + optional mutation extension is misconfigured. Set this to ``True`` for the + full workflow so an operator cannot accidentally start without checkpoints. + """ + # Module type -> (IO controller class, SiLA feature class). The Magnetic Module is # intentionally absent — the Flex does not support it. @@ -327,6 +338,7 @@ async def create_app(config: OpentronsFlexConfig) -> collections.abc.AsyncGenera config.use_simulator, config.with_robot_server, ) + compatibility = require_compatible_runtime(require_robot_server=config.with_robot_server) simulated_modules = [ name @@ -355,7 +367,7 @@ async def create_app(config: OpentronsFlexConfig) -> collections.abc.AsyncGenera ) if config.with_robot_server: - async for connector in _create_app_with_robot_server(config, labware_config): + async for connector in _create_app_with_robot_server(config, labware_config, compatibility): yield connector return @@ -400,6 +412,7 @@ async def create_app(config: OpentronsFlexConfig) -> collections.abc.AsyncGenera async def _create_app_with_robot_server( config: OpentronsFlexConfig, labware_config: LoadedLabwareMovementConfig, + compatibility: RuntimeCompatibilityReport, ) -> collections.abc.AsyncGenerator[Connector, None]: """ Start both the SiLA2 gRPC server and the opentrons HTTP robot-server in one process. @@ -435,39 +448,43 @@ async def _create_app_with_robot_server( mutation_api_token: str | None = None mutation_authenticated_actor: str | None = None mutation_ledger: MutationLedger | None = None - if config.run_mutation_ledger_path is not None: - mutation_api_token = os.environ.get(config.run_mutation_token_env) - if mutation_api_token is None or len(mutation_api_token) < _MINIMUM_MUTATION_TOKEN_LENGTH: - message = ( - "Controlled run mutation requires environment variable " - f"{config.run_mutation_token_env!r} to contain at least " - f"{_MINIMUM_MUTATION_TOKEN_LENGTH} random characters." - ) - raise RuntimeError(message) - mutation_authenticated_actor = os.environ.get(config.run_mutation_actor_env) - if ( - mutation_authenticated_actor is None - or not mutation_authenticated_actor.strip() - or len(mutation_authenticated_actor) > 200 - ): - message = ( - "Controlled run mutation requires environment variable " - f"{config.run_mutation_actor_env!r} to contain a non-empty operator identity " - "of at most 200 characters." - ) + mutation_api_token = os.environ.get(config.run_mutation_token_env) + mutation_authenticated_actor = os.environ.get(config.run_mutation_actor_env) + mutation_issues = mutation_configuration_issues( + compatibility, + ledger_path=config.run_mutation_ledger_path, + token=mutation_api_token, + actor=mutation_authenticated_actor, + ) + if mutation_issues: + detail = " ".join(mutation_issues) + if config.run_mutation_required: + message = f"Controlled run mutation preflight failed: {detail}" raise RuntimeError(message) - mutation_authenticated_actor = mutation_authenticated_actor.strip() - opentrons_version = version("opentrons") - if opentrons_version not in _SUPPORTED_MUTATION_OPENTRONS_VERSIONS: - supported = ", ".join(sorted(_SUPPORTED_MUTATION_OPENTRONS_VERSIONS)) - message = ( - f"Controlled run mutation is not validated for Opentrons {opentrons_version}; " - f"supported runtime version: {supported}." + log.warning( + "Controlled run mutation endpoints are disabled: %s " + "Protocol Engine run ownership protection remains active.", + detail, + ) + mutation_api_token = None + mutation_authenticated_actor = None + else: + mutation_authenticated_actor = typing.cast(str, mutation_authenticated_actor).strip() + try: + # Verify durable state before acquiring OT3API or mutating the + # global robot-server app. A corrupt ledger must never be ignored + # when mutation is required. + mutation_ledger = MutationLedger(typing.cast(str, config.run_mutation_ledger_path)) + except (MutationLedgerError, OSError) as exc: + if config.run_mutation_required: + message = f"Controlled run mutation ledger preflight failed: {exc}" + raise RuntimeError(message) from exc + log.error( + "Controlled run mutation endpoints are disabled because the durable ledger failed verification: %s", + exc, ) - raise RuntimeError(message) - # Verify durable state before acquiring OT3API or mutating the global - # robot-server app. A corrupt ledger must fail without leaking either. - mutation_ledger = MutationLedger(config.run_mutation_ledger_path) + mutation_api_token = None + mutation_authenticated_actor = None if config.use_simulator: log.info("Building shared OT3API (simulator)") @@ -617,10 +634,7 @@ async def _get_flex_deck_type() -> DeckType: robot_server_app.openapi_schema = None log.info("Controlled run mutation enabled with durable ledger %s", mutation_ledger.path) else: - log.warning( - "Controlled run mutation endpoints are disabled because run_mutation_ledger_path is not configured; " - "Protocol Engine run ownership protection remains active." - ) + log.info("Controlled run mutation routes were not registered.") guarded_robot_server_app = RunMutationHttpGuard( robot_server_app, diff --git a/src/unitelabs/opentrons_flex/runtime_compat.py b/src/unitelabs/opentrons_flex/runtime_compat.py new file mode 100644 index 0000000..df268dd --- /dev/null +++ b/src/unitelabs/opentrons_flex/runtime_compat.py @@ -0,0 +1,260 @@ +"""Runtime compatibility contract for the deployed Flex connector.""" + +from __future__ import annotations + +import importlib +import sys +from dataclasses import asdict, dataclass +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any + +SUPPORTED_OPENTRONS_VERSION = "9.0.0" +SUPPORTED_PYTHON_VERSION = (3, 10) +SUPPORTED_RUNTIME_PACKAGES = { + "aiohttp": "3.12.14", + "anyio": "4.9.0", + "fastapi": "0.100.0", + "idna": "3.3", + "paho-mqtt": "1.6.1", + "pydantic": "2.11.7", + "pydantic-settings": "2.4.0", + "Pyro5": "5.17", + "python-can": "4.2.2", + "python-dotenv": "1.0.1", + "python-multipart": "0.0.18", + "SQLAlchemy": "1.4.51", + "uvicorn": "0.27.0.post1", + "wsproto": "1.2.0", +} + +_ROBOT_SERVER_SYMBOLS = ( + ("robot_server.app", "app"), + ("robot_server.hardware", "_hw_api_accessor"), + ("robot_server.hardware", "_init_task_accessor"), + ("robot_server.hardware", "get_deck_type"), + ("robot_server.hardware", "get_robot_type"), + ("robot_server.hardware", "get_robot_type_enum"), + ("robot_server.runs.dependencies", "mark_light_control_startup_finished"), + ("robot_server.runs.dependencies", "start_light_control_task"), +) + +_OPENTRONS_BASE_SYMBOLS = ( + ("opentrons.hardware_control.ot3api", "OT3API"), + ("opentrons.hardware_control.types", "OT3Mount"), + ("opentrons.protocol_engine", "DeckType"), + ("opentrons_shared_data.robot.types", "RobotTypeEnum"), +) + +_RELEASE_MODULES = ( + "opentrons", + "opentrons_shared_data", + "opentrons_hardware", + "server_utils", +) + +_MUTATION_SYMBOLS = ( + ("opentrons.protocol_engine.state.tips", "TipView"), + ("opentrons.protocol_engine.types", "MotorAxis"), + ("opentrons.protocol_engine.types", "TipRackWellState"), +) + + +@dataclass(frozen=True) +class RuntimeCompatibilityReport: + """Read-only compatibility evidence gathered before hardware initialization.""" + + connector_version: str + python_version: str + opentrons_version: str + robot_server_version: str | None + robot_server_source: str | None + runtime_package_versions: dict[str, str] + base_compatible: bool + mutation_compatible: bool + issues: tuple[str, ...] + mutation_issues: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable report.""" + return asdict(self) + + +def inspect_runtime_compatibility(*, require_robot_server: bool) -> RuntimeCompatibilityReport: + """Inspect the effective Python, Opentrons, and robot-server runtime.""" + connector_version = _package_version("unitelabs-opentrons-flex") + opentrons_version = _package_version("opentrons") + python_version = ".".join(str(part) for part in sys.version_info[:3]) + issues: list[str] = [] + mutation_issues: list[str] = [] + + if sys.version_info[:2] != SUPPORTED_PYTHON_VERSION: + expected = ".".join(str(part) for part in SUPPORTED_PYTHON_VERSION) + issues.append(f"Python {python_version} is unsupported; expected Python {expected}.x.") + if opentrons_version != SUPPORTED_OPENTRONS_VERSION: + issues.append( + f"Opentrons {opentrons_version} is unsupported; expected Opentrons {SUPPORTED_OPENTRONS_VERSION}." + ) + runtime_package_versions = { + package_name: _package_version(package_name) for package_name in SUPPORTED_RUNTIME_PACKAGES + } + if require_robot_server: + for package_name, expected_version in SUPPORTED_RUNTIME_PACKAGES.items(): + actual_version = runtime_package_versions[package_name] + if actual_version != expected_version: + issues.append( + f"{package_name} {actual_version} is unsupported; expected {package_name} {expected_version}." + ) + for module_name, attribute_name in _OPENTRONS_BASE_SYMBOLS: + issue = _missing_symbol_issue(module_name, attribute_name) + if issue is not None: + issues.append(issue) + for module_name in _RELEASE_MODULES: + issue = _module_outside_release_issue(module_name) + if issue is not None: + issues.append(issue) + + robot_server_version: str | None = None + robot_server_source: str | None = None + if require_robot_server: + robot_server_version, robot_server_source, robot_server_issues = _inspect_robot_server() + issues.extend(robot_server_issues) + + for module_name, attribute_name in _MUTATION_SYMBOLS: + issue = _missing_symbol_issue(module_name, attribute_name) + if issue is not None: + mutation_issues.append(issue) + + base_compatible = not issues + if not base_compatible: + mutation_issues.insert(0, "The base connector runtime is incompatible.") + mutation_compatible = base_compatible and not mutation_issues + return RuntimeCompatibilityReport( + connector_version=connector_version, + python_version=python_version, + opentrons_version=opentrons_version, + robot_server_version=robot_server_version, + robot_server_source=robot_server_source, + runtime_package_versions=runtime_package_versions, + base_compatible=base_compatible, + mutation_compatible=mutation_compatible, + issues=tuple(issues), + mutation_issues=tuple(mutation_issues), + ) + + +def require_compatible_runtime(*, require_robot_server: bool) -> RuntimeCompatibilityReport: + """Return compatibility evidence or fail before any hardware is initialized.""" + report = inspect_runtime_compatibility(require_robot_server=require_robot_server) + if not report.base_compatible: + detail = " ".join(report.issues) + message = f"Flex connector runtime preflight failed: {detail}" + raise RuntimeError(message) + return report + + +def mutation_configuration_issues( + report: RuntimeCompatibilityReport, + *, + ledger_path: str | None, + token: str | None, + actor: str | None, +) -> tuple[str, ...]: + """Return reasons controlled mutation cannot be enabled.""" + issues = list(report.mutation_issues) + if ledger_path is None: + issues.append("run_mutation_ledger_path is not configured.") + if token is None or len(token) < 32: + issues.append("The configured mutation token is missing or shorter than 32 characters.") + if actor is None or not actor.strip() or len(actor.strip()) > 200: + issues.append("The configured mutation actor must contain 1 to 200 non-whitespace characters.") + return tuple(issues) + + +def _inspect_robot_server() -> tuple[str | None, str | None, tuple[str, ...]]: + issues: list[str] = [] + try: + package = importlib.import_module("robot_server") + except (ImportError, OSError) as exc: + return None, None, (f"robot_server could not be imported: {exc}",) + + source_value = getattr(package, "__file__", None) + source = str(Path(source_value).resolve()) if isinstance(source_value, str) else None + robot_server_version = _robot_server_version() + is_test_stub = getattr(package, "__spec__", None) is None + if not is_test_stub and robot_server_version != SUPPORTED_OPENTRONS_VERSION: + issues.append(f"robot_server {robot_server_version} does not match Opentrons {SUPPORTED_OPENTRONS_VERSION}.") + if not is_test_stub: + if source is None: + issues.append("robot_server source location is unavailable.") + else: + source_path = Path(source) + runtime_prefix = Path(sys.prefix).resolve() + if not source_path.is_relative_to(runtime_prefix): + issues.append( + f"robot_server is loaded from {source_path}, outside the active release {runtime_prefix}." + ) + for module_name, attribute_name in _ROBOT_SERVER_SYMBOLS: + issue = _missing_symbol_issue(module_name, attribute_name) + if issue is not None: + issues.append(issue) + return robot_server_version, source, tuple(issues) + + +def _robot_server_version() -> str | None: + for package_name in ("robot-server", "robot_server"): + try: + return version(package_name) + except PackageNotFoundError: + continue + try: + version_module = importlib.import_module("robot_server._version") + except (ImportError, OSError): + return None + value = getattr(version_module, "__version__", None) + return value if isinstance(value, str) else None + + +def _missing_symbol_issue(module_name: str, attribute_name: str) -> str | None: + try: + module = importlib.import_module(module_name) + except (ImportError, OSError) as exc: + return f"{module_name} could not be imported: {exc}" + if not hasattr(module, attribute_name): + return f"{module_name}.{attribute_name} is unavailable." + return None + + +def _module_outside_release_issue(module_name: str) -> str | None: + try: + module = importlib.import_module(module_name) + except (ImportError, OSError) as exc: + return f"{module_name} could not be imported: {exc}" + if getattr(module, "__spec__", None) is None: + return None + source_value = getattr(module, "__file__", None) + if not isinstance(source_value, str): + return f"{module_name} source location is unavailable." + source_path = Path(source_value).resolve() + runtime_prefix = Path(sys.prefix).resolve() + if not source_path.is_relative_to(runtime_prefix): + return f"{module_name} is loaded from {source_path}, outside the active release {runtime_prefix}." + return None + + +def _package_version(package_name: str) -> str: + try: + return version(package_name) + except PackageNotFoundError: + return "not-installed" + + +__all__ = [ + "SUPPORTED_OPENTRONS_VERSION", + "SUPPORTED_PYTHON_VERSION", + "SUPPORTED_RUNTIME_PACKAGES", + "RuntimeCompatibilityReport", + "inspect_runtime_compatibility", + "mutation_configuration_issues", + "require_compatible_runtime", +] diff --git a/src/unitelabs/opentrons_flex/runtime_preflight.py b/src/unitelabs/opentrons_flex/runtime_preflight.py new file mode 100644 index 0000000..0f12931 --- /dev/null +++ b/src/unitelabs/opentrons_flex/runtime_preflight.py @@ -0,0 +1,115 @@ +"""Command-line runtime preflight that never initializes Flex hardware.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +from .runtime_compat import inspect_runtime_compatibility, mutation_configuration_issues + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Validate the Flex connector runtime without initializing hardware.") + parser.add_argument("--config", type=Path, required=True, help="Installed connector JSON configuration.") + parser.add_argument( + "--require-robot-server", + action="store_true", + help="Require the embedded Opentrons robot-server imports used in connector mode.", + ) + parser.add_argument( + "--require-mutation", + action="store_true", + help="Fail unless controlled run mutation is ready and authenticated.", + ) + parser.add_argument( + "--require-live-hardware", + action="store_true", + help="Reject simulator configuration for an on-robot deployment.", + ) + return parser + + +def _read_config(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + message = f"Cannot read connector config {path}: {exc}" + raise RuntimeError(message) from exc + if not isinstance(payload, dict): + message = f"Connector config {path} must contain a JSON object." + raise RuntimeError(message) + return payload + + +def main(argv: list[str] | None = None) -> int: + """Run the no-hardware runtime preflight.""" + args = _parser().parse_args(argv) + try: + config = _read_config(args.config) + require_robot_server = bool(args.require_robot_server or config.get("with_robot_server")) + report = inspect_runtime_compatibility(require_robot_server=require_robot_server) + config_issues = _configuration_issues( + config, + connector_version=report.connector_version, + require_robot_server=args.require_robot_server, + require_live_hardware=args.require_live_hardware, + ) + token_env = str(config.get("run_mutation_token_env", "UNITELABS_RUN_MUTATION_TOKEN")) + actor_env = str(config.get("run_mutation_actor_env", "UNITELABS_RUN_MUTATION_ACTOR")) + mutation_issues = mutation_configuration_issues( + report, + ledger_path=_string_or_none(config.get("run_mutation_ledger_path")), + token=os.environ.get(token_env), + actor=os.environ.get(actor_env), + ) + mutation_ready = not mutation_issues + payload = { + **report.to_dict(), + "configuration_issues": config_issues, + "mutation_requested": config.get("run_mutation_ledger_path") is not None, + "mutation_required": bool(config.get("run_mutation_required", False)), + "mutation_ready": mutation_ready, + "mutation_configuration_issues": mutation_issues, + } + sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + if not report.base_compatible or config_issues: + return 1 + if (args.require_mutation or payload["mutation_required"]) and not mutation_ready: + return 2 + return 0 + except RuntimeError as exc: + sys.stderr.write(f"ERROR: {exc}\n") + return 1 + + +def _string_or_none(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _configuration_issues( + config: dict[str, Any], + *, + connector_version: str, + require_robot_server: bool, + require_live_hardware: bool, +) -> tuple[str, ...]: + issues = [] + if require_robot_server and config.get("with_robot_server") is not True: + issues.append("with_robot_server must be true for connector mode.") + if require_live_hardware and config.get("use_simulator") is not False: + issues.append("use_simulator must be false for a real Flex deployment.") + sila_config = config.get("sila_server") + configured_version = sila_config.get("version") if isinstance(sila_config, dict) else None + if configured_version != connector_version: + issues.append( + f"sila_server.version is {configured_version!r}; expected connector version {connector_version!r}." + ) + return tuple(issues) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 10794d9..ac930fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,9 +52,15 @@ async def _stub_get_deck_type(): _rs_app = types.ModuleType("robot_server.app") _rs_app.app = MagicMock(name="robot_server_app") _rs_app.app.dependency_overrides = {} + _rs_runs = types.ModuleType("robot_server.runs") + _rs_dependencies = types.ModuleType("robot_server.runs.dependencies") + _rs_dependencies.start_light_control_task = MagicMock(name="start_light_control_task") + _rs_dependencies.mark_light_control_startup_finished = MagicMock(name="mark_light_control_startup_finished") sys.modules["robot_server"] = _rs sys.modules["robot_server.hardware"] = _rs_hw sys.modules["robot_server.app"] = _rs_app + sys.modules["robot_server.runs"] = _rs_runs + sys.modules["robot_server.runs.dependencies"] = _rs_dependencies try: import unitelabs.bus.testing.fixtures diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 742734c..e8d4d03 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -424,6 +424,7 @@ async def _serve() -> None: cloud_server_endpoint=None, discovery=None, run_mutation_ledger_path=str(mutation_ledger_path), + run_mutation_required=True, ) previous_token = os.environ.get(config.run_mutation_token_env) previous_actor = os.environ.get(config.run_mutation_actor_env) diff --git a/tests/scripts/test_artifact_manifest.py b/tests/scripts/test_artifact_manifest.py new file mode 100644 index 0000000..9b83ad5 --- /dev/null +++ b/tests/scripts/test_artifact_manifest.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +from hashlib import sha256 + +import pytest + +from scripts import artifact_manifest +from unitelabs.opentrons_flex import runtime_compat + + +def _write_required_wheels(directory) -> None: + names = [ + "unitelabs_opentrons_flex-0.9.1-py3-none-any.whl", + "opentrons-9.0.0-py3-none-any.whl", + "opentrons_shared_data-9.0.0-py3-none-any.whl", + "opentrons_hardware-9.0.0-py3-none-any.whl", + "server_utils-9.0.0-py3-none-any.whl", + "robot_server-9.0.0-py3-none-any.whl", + ] + names.extend( + f"{distribution.replace('-', '_')}-{version}-py3-none-any.whl" + for distribution, version in artifact_manifest.PINNED_RUNTIME_WHEELS.items() + ) + for index, name in enumerate(names): + (directory / name).write_bytes(f"wheel-{index}".encode()) + + +def _build(directory): + return artifact_manifest.build_manifest( + directory, + connector_version="0.9.1", + opentrons_version="9.0.0", + robot_server_version="9.0.0", + opentrons_source_commit="44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", + python_version="3.10", + architecture="aarch64", + ) + + +def test_build_and_verify_exact_runtime_artifact(tmp_path) -> None: + _write_required_wheels(tmp_path) + + manifest = _build(tmp_path) + verified = artifact_manifest.verify_manifest( + tmp_path, + expected={ + "connectorVersion": "0.9.1", + "opentronsVersion": "9.0.0", + "robotServerVersion": "9.0.0", + "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", + "pythonVersion": "3.10", + "architecture": "aarch64", + }, + check_host_python=True, + check_host_architecture=False, + ) + + assert verified == manifest + assert manifest["releaseId"].startswith("flex-0.9.1-ot9.0.0-py3.10-aarch64-") + assert json.loads((tmp_path / artifact_manifest.MANIFEST_NAME).read_text())["bundleSha256"] + assert (tmp_path / artifact_manifest.CHECKSUMS_NAME).is_file() + + +def test_artifact_and_startup_runtime_contracts_cannot_drift() -> None: + expected = {name.lower(): version for name, version in runtime_compat.SUPPORTED_RUNTIME_PACKAGES.items()} + assert expected == artifact_manifest.PINNED_RUNTIME_WHEELS + + +def test_verify_rejects_tampered_wheel(tmp_path) -> None: + _write_required_wheels(tmp_path) + _build(tmp_path) + (tmp_path / "opentrons-9.0.0-py3-none-any.whl").write_bytes(b"tampered") + + with pytest.raises(RuntimeError, match=r"Checksum verification failed|Manifest verification failed"): + artifact_manifest.verify_manifest( + tmp_path, + expected={}, + check_host_python=False, + check_host_architecture=False, + ) + + +def test_build_rejects_mixed_opentrons_runtime(tmp_path) -> None: + _write_required_wheels(tmp_path) + old_wheel = tmp_path / "opentrons-9.0.0-py3-none-any.whl" + old_wheel.rename(tmp_path / "opentrons-8.8.1-py3-none-any.whl") + + with pytest.raises(RuntimeError, match=r"expected exactly 9\.0\.0"): + _build(tmp_path) + + +def test_verify_rejects_manifest_that_omits_required_runtime_wheel(tmp_path) -> None: + _write_required_wheels(tmp_path) + _build(tmp_path) + missing_name = "paho_mqtt-1.6.1-py3-none-any.whl" + (tmp_path / missing_name).unlink() + manifest_path = tmp_path / artifact_manifest.MANIFEST_NAME + manifest = json.loads(manifest_path.read_text()) + manifest["wheels"] = [record for record in manifest["wheels"] if record["filename"] != missing_name] + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + checksum_path = tmp_path / artifact_manifest.CHECKSUMS_NAME + checksum_lines = [ + line + for line in checksum_path.read_text().splitlines() + if not line.endswith(f" {missing_name}") and not line.endswith(f" {artifact_manifest.MANIFEST_NAME}") + ] + checksum_lines.append(f"{sha256(manifest_path.read_bytes()).hexdigest()} {artifact_manifest.MANIFEST_NAME}") + checksum_path.write_text("\n".join(checksum_lines) + "\n") + + with pytest.raises(RuntimeError, match=r"paho-mqtt wheel version is missing"): + artifact_manifest.verify_manifest( + tmp_path, + expected={}, + check_host_python=False, + check_host_architecture=False, + ) diff --git a/tests/scripts/test_deployment_scripts.py b/tests/scripts/test_deployment_scripts.py new file mode 100644 index 0000000..35ae7b9 --- /dev/null +++ b/tests/scripts/test_deployment_scripts.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def _text(path: str) -> str: + return (ROOT / path).read_text() + + +def test_deploy_verifies_artifact_and_runtime_before_activation() -> None: + script = _text("deploy.sh") + + local_verify = script.index('python3 "$MANIFEST_TOOL" verify') + upload = script.index("scp -O") + runtime_preflight = script.index("unitelabs.opentrons_flex.runtime_preflight") + activate = script.index('ln -sfn "$RELEASE_PATH" "$ACTIVE_PATH"') + + assert local_verify < upload + assert runtime_preflight < activate + assert "--check-host-python" in script + assert "--check-host-architecture" in script + assert "--require-live-hardware" in script + assert 'COMPLETE_MARKER="$RELEASE_PATH/.unitelabs-release-complete"' in script + assert "Removing an incomplete, never-activated release" in script + assert "rm -rf $VENV_PATH" not in script + + +def test_service_switch_has_bounded_health_and_stock_failover() -> None: + script = _text("scripts/install_connector_service.sh") + + preflight = script.index("unitelabs.opentrons_flex.runtime_preflight") + stop_stock = script.index('echo "Stopping stock hardware owners..."') + + assert preflight < stop_stock + assert "restore_stock" in script + assert "Stock Opentrons mode restored and healthy." in script + assert "--max-time 10" in script + assert "/deck_configuration" in script + assert "/unitelabs/runs/{run_id}/mutations" in script + + +def test_secrets_and_ledgers_live_outside_immutable_release() -> None: + for path in ("deploy.sh", "scripts/install_connector_service.sh", "scripts/switch_mode.sh"): + script = _text(path) + assert "/var/lib/unitelabs-opentrons-flex/run-mutation.env" in script or ( + 'STATE_DIR="/var/lib/unitelabs-opentrons-flex"' in script + and ('ENV_FILE="$STATE_DIR/run-mutation.env"' in script or '"$STATE_DIR/run-mutation.env"' in script) + ) + + +def test_all_state_transitions_share_a_fail_closed_operation_lock() -> None: + for path in ( + "deploy.sh", + "scripts/install_connector_service.sh", + "scripts/switch_mode.sh", + "scripts/rollback_connector.sh", + ): + script = _text(path) + assert 'LOCK_DIR="$STATE_DIR/operation.lock"' in script + assert 'mkdir "$LOCK_DIR"' in script + assert "trap " in script + + +def test_install_inherits_only_robot_image_bindings_and_overrides_pip_root() -> None: + script = _text("scripts/install.sh") + + assert "venv --system-site-packages" in script + assert 'pip" install --root / --no-index --no-deps' in script + + +def test_rollback_leaves_stock_robot_server_active() -> None: + script = _text("scripts/rollback_connector.sh") + + stop_connector = script.index("systemctl stop sila2-connector") + restore_stock = script.rindex("restore_stock_services") + activate_previous = script.rindex('activate_target "$TARGET"') + + assert stop_connector < restore_stock < activate_previous + assert "systemctl start opentrons-robot-server" in script + assert "stock-service-state" in script + assert "stock robot-server did not recover within 120 seconds" in script + assert "os.replace" in script + assert "printf 'legacy:%s\\n' \"$CURRENT_TARGET\"" in script diff --git a/tests/scripts/test_run_asms_hardware.py b/tests/scripts/test_run_asms_hardware.py index 57ae079..5bc1af5 100644 --- a/tests/scripts/test_run_asms_hardware.py +++ b/tests/scripts/test_run_asms_hardware.py @@ -137,6 +137,21 @@ def post( raise AssertionError(f"Unexpected POST {path}") +class _FailingRunClient(_Client): + def post( + self, + path: str, + *, + files: list | None = None, + data: dict | None = None, + json: dict | None = None, + ) -> _Response: + if path == "/runs/run-1/actions" and json == {"data": {"actionType": "stop"}}: + self.posts.append("stop") + return _Response(201, {"data": {"id": "stop-action"}}) + return super().post(path, files=files, data=data, json=json) + + def _ready_deck() -> dict: return { "data": { @@ -225,6 +240,19 @@ def test_hardware_inventory_requires_right_flex_pipette_and_temperature_module() ] +def test_checkpoint_preflight_requires_both_controlled_mutation_routes() -> None: + openapi = { + "paths": { + "/unitelabs/runs/{run_id}/mutations": {}, + "/unitelabs/runs/{run_id}/mutation-snapshot": {}, + } + } + assert run_asms_hardware._mutation_route_errors(openapi) == [] + + openapi["paths"].pop("/unitelabs/runs/{run_id}/mutation-snapshot") + assert run_asms_hardware._mutation_route_errors(openapi) == ["missing /unitelabs/runs/{run_id}/mutation-snapshot"] + + def test_checkpoint_transfer_uses_snapshot_ids_and_eight_channel_resources() -> None: snapshot = { "pipettes": [{"id": "pipette-1", "mount": "right", "name": "flex_8channel_1000"}], @@ -297,6 +325,15 @@ def test_execute_requires_explicit_deck_confirmation(monkeypatch, capsys) -> Non assert "ASMS-DECK-READY" in capsys.readouterr().err +def test_request_timeout_must_be_positive_before_network(monkeypatch, capsys) -> None: + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + + result = run_asms_hardware.main(["--request-timeout", "0"]) + + assert result == 2 + assert "greater than zero" in capsys.readouterr().err + + def test_checkpoint_transfer_rejects_two_column_run_before_network(monkeypatch, capsys) -> None: monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) @@ -312,6 +349,24 @@ def test_checkpoint_transfer_rejects_two_column_run_before_network(monkeypatch, assert "no spare tips" in capsys.readouterr().err +def test_checkpoint_transfer_requires_encrypted_loopback_tunnel(monkeypatch, capsys) -> None: + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + + result = run_asms_hardware.main( + [ + "--execute", + "--checkpoint-transfer", + "--mutation-actor", + "operator-1", + "--confirm-deck-ready", + "ASMS-DECK-READY", + ] + ) + + assert result == 2 + assert "SSH tunnel" in capsys.readouterr().err + + def test_default_mode_analyzes_without_creating_or_playing_a_run(monkeypatch, capsys) -> None: client = _Client() monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) @@ -342,3 +397,25 @@ def test_confirmed_two_column_execution_requires_pinned_command_evidence(monkeyp assert result == 0 assert client.posts == ["/protocols", "/runs", "/runs/run-1/actions"] assert "AS-MS hardware workflow PASS" in capsys.readouterr().out + + +def test_execution_error_stops_non_terminal_owned_run(monkeypatch) -> None: + client = _FailingRunClient() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + monkeypatch.setattr( + run_asms_hardware, + "_wait_for_run", + lambda *args, **kwargs: (_ for _ in ()).throw(TimeoutError("poll timed out")), + ) + + with pytest.raises(TimeoutError, match="poll timed out"): + run_asms_hardware.main( + [ + "--execute", + "--confirm-deck-ready", + "ASMS-DECK-READY", + ] + ) + + assert client.posts[-1] == "stop" diff --git a/tests/test_create_app.py b/tests/test_create_app.py index b59c763..bab7b26 100644 --- a/tests/test_create_app.py +++ b/tests/test_create_app.py @@ -203,3 +203,23 @@ async def test_shared_api_cleaned_up_on_shutdown(): await gen.__anext__() fake_api.clean_up.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_runtime_preflight_fails_before_simulator_initialization(): + """A mismatched release must fail before even simulated hardware is built.""" + with ( + patch( + "unitelabs.opentrons_flex.require_compatible_runtime", + side_effect=RuntimeError("runtime preflight failed"), + ), + patch( + "opentrons.hardware_control.ot3api.OT3API.build_hardware_simulator", + new_callable=AsyncMock, + ) as mock_build, + ): + gen = create_app(OpentronsFlexConfig(use_simulator=True)) + with pytest.raises(RuntimeError, match="runtime preflight failed"): + await gen.__anext__() + + mock_build.assert_not_awaited() diff --git a/tests/test_create_app_with_robot_server.py b/tests/test_create_app_with_robot_server.py index 3867e99..8579fff 100644 --- a/tests/test_create_app_with_robot_server.py +++ b/tests/test_create_app_with_robot_server.py @@ -12,6 +12,7 @@ import asyncio import contextlib +import dataclasses import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -140,6 +141,26 @@ async def test_hardware_built_via_ot3api(): await gen.__anext__() +@pytest.mark.asyncio +async def test_required_mutation_configuration_fails_before_hardware(monkeypatch, tmp_path): + """Full-workflow mode cannot silently start without its credential.""" + config = dataclasses.replace( + _CONFIG, + run_mutation_ledger_path=str(tmp_path / "mutations.jsonl"), + run_mutation_required=True, + ) + monkeypatch.delenv(config.run_mutation_token_env, raising=False) + monkeypatch.delenv(config.run_mutation_actor_env, raising=False) + mock_build = AsyncMock() + + with patch(_OT3API_BUILD, mock_build): + gen = create_app(config) + with pytest.raises(RuntimeError, match="Controlled run mutation preflight failed"): + await gen.__anext__() + + mock_build.assert_not_awaited() + + # ── app.state pre-population ────────────────────────────────────────────────── diff --git a/tests/test_runtime_compat.py b/tests/test_runtime_compat.py new file mode 100644 index 0000000..d11be64 --- /dev/null +++ b/tests/test_runtime_compat.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from unitelabs.opentrons_flex import runtime_compat + + +def _matching_report(*, require_robot_server: bool = True) -> runtime_compat.RuntimeCompatibilityReport: + package_versions = { + "unitelabs-opentrons-flex": "0.9.1", + "opentrons": "9.0.0", + **runtime_compat.SUPPORTED_RUNTIME_PACKAGES, + } + with ( + patch.object( + runtime_compat, + "_package_version", + side_effect=package_versions.__getitem__, + ), + patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", ())), + patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), + patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), + ): + return runtime_compat.inspect_runtime_compatibility(require_robot_server=require_robot_server) + + +def test_matching_runtime_enables_base_and_mutation() -> None: + report = _matching_report() + + assert report.base_compatible is True + assert report.mutation_compatible is True + assert report.robot_server_version == "9.0.0" + assert report.issues == () + + +def test_opentrons_version_mismatch_fails_before_hardware() -> None: + package_versions = { + "unitelabs-opentrons-flex": "0.9.1", + "opentrons": "8.8.1", + **runtime_compat.SUPPORTED_RUNTIME_PACKAGES, + } + with ( + patch.object( + runtime_compat, + "_package_version", + side_effect=package_versions.__getitem__, + ), + patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), + patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), + ): + report = runtime_compat.inspect_runtime_compatibility(require_robot_server=False) + + assert report.base_compatible is False + assert "expected Opentrons 9.0.0" in report.issues[0] + + +def test_http_dependency_drift_only_blocks_embedded_robot_server_profile() -> None: + package_versions = { + "unitelabs-opentrons-flex": "0.9.1", + "opentrons": "9.0.0", + **runtime_compat.SUPPORTED_RUNTIME_PACKAGES, + "python-multipart": "0.0.6", + } + with ( + patch.object( + runtime_compat, + "_package_version", + side_effect=package_versions.__getitem__, + ), + patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", ())), + patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), + patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), + ): + standalone = runtime_compat.inspect_runtime_compatibility(require_robot_server=False) + embedded = runtime_compat.inspect_runtime_compatibility(require_robot_server=True) + + assert standalone.base_compatible is True + assert embedded.base_compatible is False + assert "python-multipart 0.0.6 is unsupported" in embedded.issues[0] + + +def test_require_compatible_runtime_raises_actionable_error() -> None: + incompatible = runtime_compat.RuntimeCompatibilityReport( + connector_version="0.9.1", + python_version="3.11.0", + opentrons_version="9.0.0", + robot_server_version=None, + robot_server_source=None, + runtime_package_versions={}, + base_compatible=False, + mutation_compatible=False, + issues=("Python 3.11.0 is unsupported; expected Python 3.10.x.",), + mutation_issues=("The base connector runtime is incompatible.",), + ) + with ( + patch.object(runtime_compat, "inspect_runtime_compatibility", return_value=incompatible), + pytest.raises(RuntimeError, match=r"before hardware|runtime preflight failed"), + ): + runtime_compat.require_compatible_runtime(require_robot_server=False) + + +def test_mutation_configuration_reports_every_missing_guard() -> None: + report = _matching_report(require_robot_server=False) + + issues = runtime_compat.mutation_configuration_issues( + report, + ledger_path=None, + token="short", + actor=" ", + ) + + assert len(issues) == 3 + assert "ledger" in issues[0] + assert "32 characters" in issues[1] + assert "actor" in issues[2] + + +def test_opentrons_module_outside_active_release_is_rejected() -> None: + package_versions = { + "unitelabs-opentrons-flex": "0.9.1", + "opentrons": "9.0.0", + **runtime_compat.SUPPORTED_RUNTIME_PACKAGES, + } + with ( + patch.object( + runtime_compat, + "_package_version", + side_effect=package_versions.__getitem__, + ), + patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), + patch.object( + runtime_compat, + "_module_outside_release_issue", + side_effect=lambda name: "opentrons is loaded outside the active release." if name == "opentrons" else None, + ), + ): + report = runtime_compat.inspect_runtime_compatibility(require_robot_server=False) + + assert report.base_compatible is False + assert "outside the active release" in report.issues[0] diff --git a/tests/test_runtime_preflight.py b/tests/test_runtime_preflight.py new file mode 100644 index 0000000..39c175a --- /dev/null +++ b/tests/test_runtime_preflight.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json + +from unitelabs.opentrons_flex import runtime_preflight +from unitelabs.opentrons_flex.runtime_compat import RuntimeCompatibilityReport + + +def _report(*, compatible: bool = True) -> RuntimeCompatibilityReport: + return RuntimeCompatibilityReport( + connector_version="0.9.1", + python_version="3.10.20", + opentrons_version="9.0.0", + robot_server_version="9.0.0", + robot_server_source="/release/robot_server/__init__.py", + runtime_package_versions={}, + base_compatible=compatible, + mutation_compatible=compatible, + issues=() if compatible else ("base mismatch",), + mutation_issues=() if compatible else ("base mismatch",), + ) + + +def test_runtime_preflight_requires_mutation_when_configured(tmp_path, monkeypatch, capsys) -> None: + config = tmp_path / "config.json" + config.write_text( + json.dumps( + { + "with_robot_server": True, + "run_mutation_required": True, + "run_mutation_ledger_path": str(tmp_path / "ledger.jsonl"), + "sila_server": {"version": "0.9.1"}, + } + ) + ) + monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report()) + monkeypatch.delenv("UNITELABS_RUN_MUTATION_TOKEN", raising=False) + monkeypatch.delenv("UNITELABS_RUN_MUTATION_ACTOR", raising=False) + + assert runtime_preflight.main(["--config", str(config)]) == 2 + payload = json.loads(capsys.readouterr().out) + assert payload["base_compatible"] is True + assert payload["mutation_ready"] is False + + +def test_runtime_preflight_passes_complete_full_workflow_config(tmp_path, monkeypatch, capsys) -> None: + config = tmp_path / "config.json" + config.write_text( + json.dumps( + { + "with_robot_server": True, + "run_mutation_required": True, + "run_mutation_ledger_path": str(tmp_path / "ledger.jsonl"), + "sila_server": {"version": "0.9.1"}, + } + ) + ) + monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report()) + monkeypatch.setenv("UNITELABS_RUN_MUTATION_TOKEN", "t" * 32) + monkeypatch.setenv("UNITELABS_RUN_MUTATION_ACTOR", "operator-1") + + assert runtime_preflight.main(["--config", str(config)]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mutation_ready"] is True + + +def test_runtime_preflight_fails_incompatible_base_even_when_mutation_optional(tmp_path, monkeypatch) -> None: + config = tmp_path / "config.json" + config.write_text(json.dumps({"sila_server": {"version": "0.9.1"}})) + monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report(compatible=False)) + + assert runtime_preflight.main(["--config", str(config)]) == 1 + + +def test_runtime_preflight_rejects_simulator_for_live_deployment(tmp_path, monkeypatch, capsys) -> None: + config = tmp_path / "config.json" + config.write_text( + json.dumps( + { + "with_robot_server": True, + "use_simulator": True, + "sila_server": {"version": "0.9.1"}, + } + ) + ) + monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report()) + + result = runtime_preflight.main( + [ + "--config", + str(config), + "--require-robot-server", + "--require-live-hardware", + ] + ) + + assert result == 1 + assert "use_simulator must be false" in capsys.readouterr().out diff --git a/tests/test_smoketest_config.py b/tests/test_smoketest_config.py index 6de51b8..223fd06 100644 --- a/tests/test_smoketest_config.py +++ b/tests/test_smoketest_config.py @@ -28,6 +28,7 @@ def test_smoketest_config_never_targets_real_hardware() -> None: assert cfg["sila_server"]["hostname"] in {"127.0.0.1", "localhost"} assert cfg["cloud_server_endpoint"] is None assert cfg["discovery"] is None + assert cfg["run_mutation_required"] is True def test_real_flex_config_stays_explicitly_live() -> None: @@ -43,3 +44,4 @@ def test_real_flex_config_stays_explicitly_live() -> None: assert cfg["simulated_gripper"] is False assert cfg["with_robot_server"] is True assert cfg["sila_server"]["hostname"] == "0.0.0.0" + assert cfg["run_mutation_required"] is True diff --git a/uv.lock b/uv.lock index d4b4c9c..7115e41 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,50 @@ requires-python = "==3.10.*" [manifest] overrides = [{ name = "jsonschema", specifier = ">=4.25" }] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e39ad954107ebf213a2325038a3e7a506be3d98e1435e1f82086eec4cde2/aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2", size = 7822921, upload-time = "2025-07-10T13:05:33.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/88/f161f429f9de391eee6a5c2cffa54e2ecd5b7122ae99df247f7734dfefcb/aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248", size = 702641, upload-time = "2025-07-10T13:02:38.98Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b5/24fa382a69a25d242e2baa3e56d5ea5227d1b68784521aaf3a1a8b34c9a4/aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb", size = 479005, upload-time = "2025-07-10T13:02:42.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/fda1bc34adbfaa950d98d934a23900918f9d63594928c70e55045838c943/aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd", size = 466781, upload-time = "2025-07-10T13:02:44.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/96/3ce1ea96d3cf6928b87cfb8cdd94650367f5c2f36e686a1f5568f0f13754/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c", size = 1648841, upload-time = "2025-07-10T13:02:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/be/04/ddea06cb4bc7d8db3745cf95e2c42f310aad485ca075bd685f0e4f0f6b65/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95", size = 1622896, upload-time = "2025-07-10T13:02:48.422Z" }, + { url = "https://files.pythonhosted.org/packages/73/66/63942f104d33ce6ca7871ac6c1e2ebab48b88f78b2b7680c37de60f5e8cd/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663", size = 1695302, upload-time = "2025-07-10T13:02:50.078Z" }, + { url = "https://files.pythonhosted.org/packages/20/00/aab615742b953f04b48cb378ee72ada88555b47b860b98c21c458c030a23/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1", size = 1737617, upload-time = "2025-07-10T13:02:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4f/ef6d9f77225cf27747368c37b3d69fac1f8d6f9d3d5de2d410d155639524/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61", size = 1642282, upload-time = "2025-07-10T13:02:53.899Z" }, + { url = "https://files.pythonhosted.org/packages/37/e1/e98a43c15aa52e9219a842f18c59cbae8bbe2d50c08d298f17e9e8bafa38/aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656", size = 1582406, upload-time = "2025-07-10T13:02:55.515Z" }, + { url = "https://files.pythonhosted.org/packages/71/5c/29c6dfb49323bcdb0239bf3fc97ffcf0eaf86d3a60426a3287ec75d67721/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3", size = 1626255, upload-time = "2025-07-10T13:02:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/79/60/ec90782084090c4a6b459790cfd8d17be2c5662c9c4b2d21408b2f2dc36c/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288", size = 1637041, upload-time = "2025-07-10T13:02:59.008Z" }, + { url = "https://files.pythonhosted.org/packages/22/89/205d3ad30865c32bc472ac13f94374210745b05bd0f2856996cb34d53396/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda", size = 1612494, upload-time = "2025-07-10T13:03:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/48/ae/2f66edaa8bd6db2a4cba0386881eb92002cdc70834e2a93d1d5607132c7e/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc", size = 1692081, upload-time = "2025-07-10T13:03:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/08/3a/fa73bfc6e21407ea57f7906a816f0dc73663d9549da703be05dbd76d2dc3/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8", size = 1715318, upload-time = "2025-07-10T13:03:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b3/751124b8ceb0831c17960d06ee31a4732cb4a6a006fdbfa1153d07c52226/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3", size = 1643660, upload-time = "2025-07-10T13:03:06.406Z" }, + { url = "https://files.pythonhosted.org/packages/81/3c/72477a1d34edb8ab8ce8013086a41526d48b64f77e381c8908d24e1c18f5/aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c", size = 428289, upload-time = "2025-07-10T13:03:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c4/8aec4ccf1b822ec78e7982bd5cf971113ecce5f773f04039c76a083116fc/aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db", size = 451328, upload-time = "2025-07-10T13:03:10.146Z" }, +] + [[package]] name = "aionotify" version = "0.3.1" @@ -14,6 +58,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/6a/b43cb72f72ad650225854b630ab569b74071764fb562b88264aed4c9e350/aionotify-0.3.1-py2.py3-none-any.whl", hash = "sha256:25816a9eef030c774beaee22189a24e29bc43f81cebe574ef723851eaf89ddee", size = 7429, upload-time = "2024-05-15T17:05:27.184Z" }, ] +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -34,16 +91,17 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup" }, { name = "idna" }, + { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] [[package]] @@ -68,6 +126,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -358,6 +425,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "fastapi" +version = "0.100.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/e0/f9d77b3a1569e2217abf260b0b1462401736973d1c5d3d335f6f2009daa2/fastapi-0.100.0.tar.gz", hash = "sha256:acb5f941ea8215663283c10018323ba7ea737c571b67fc7e88e9469c7eb1d12e", size = 10440814, upload-time = "2023-07-07T17:33:19.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/f5/048206823aae9b3a4a61ba6b7a1dd1de36bd4c0a0283f2efb1f1f2289c8a/fastapi-0.100.0-py3-none-any.whl", hash = "sha256:271662daf986da8fa98dc2b7c7f61c4abdfdccfb4786d79ed8b2878f172c6d5f", size = 65736, upload-time = "2023-07-07T17:33:17.001Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -367,6 +448,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -391,6 +497,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, + { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, +] + [[package]] name = "griffe" version = "2.1.0" @@ -495,11 +616,11 @@ wheels = [ [[package]] name = "idna" -version = "3.18" +version = "3.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/08/e3fc7c8161090f742f504f40b1bccbfc544d4a4e09eb774bf40aafce5436/idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d", size = 286689, upload-time = "2021-10-12T23:33:41.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/04/a2/d918dcd22354d8958fe113e1a3630137e0fc8b44859ade3063982eacd2a4/idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", size = 61160, upload-time = "2021-10-12T23:33:38.02Z" }, ] [[package]] @@ -599,6 +720,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msgpack" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/4c/17adf86a8fbb02c144c7569dc4919483c01a2ac270307e2d59e1ce394087/msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3", size = 167014, upload-time = "2024-03-02T01:19:21.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/c2/8ecbafd6d3178ad408989c82d6d518fec76e053bae20c0fd9f47bffe7dda/msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868", size = 157691, upload-time = "2024-03-01T12:34:50.572Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7e/93373ffbe6561e719996a90b6d112604f52da3ab46e7c395db7607458553/msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c", size = 87954, upload-time = "2024-03-01T12:34:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/d000e53b067aee19d57a4f26d5bffed7890e6896538ac5f97605b0f64985/msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659", size = 84945, upload-time = "2024-03-01T12:34:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6e/3dcd4f7d8b978277393fd5b7c0abd9d2b6ef7ba8eb12834bed59158ecf5f/msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2", size = 376004, upload-time = "2024-03-01T12:34:57.709Z" }, + { url = "https://files.pythonhosted.org/packages/d9/96/a1868dd8997d65732476dfc70fef44d046c1b4dbe36ec1481ab744d87775/msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982", size = 385107, upload-time = "2024-03-01T12:35:00.529Z" }, + { url = "https://files.pythonhosted.org/packages/9b/db/8d629233bba3cbe6d7a6e0fd018ed684c5f0befea4428d4217ce066d2f20/msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa", size = 374290, upload-time = "2024-03-01T12:35:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/f0/75/553cc9ddfe59c62654dd398c16cd8ab1b3eeb145e56805f52115cbe9f5a0/msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128", size = 380759, upload-time = "2024-03-01T12:35:07.369Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/c6f31cef899b54e3f6a759204d0b152c9205aef7219c9d2279f608c421eb/msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d", size = 413943, upload-time = "2024-03-01T12:35:10.271Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a8/29426f7af85406116e1cdbd21d8f02e30ef8f4afe3cfcbb43c498cbadadf/msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653", size = 385405, upload-time = "2024-03-01T12:35:13.438Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -610,21 +778,23 @@ wheels = [ [[package]] name = "numpy" -version = "1.24.4" +version = "1.26.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9b/027bec52c633f6556dba6b722d9a0befb40498b9ceddd29cbe67a45a127c/numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463", size = 10911229, upload-time = "2023-06-26T13:39:33.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/80/6cdfb3e275d95155a34659163b83c09e3a3ff9f1456880bec6cc63d71083/numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64", size = 19789140, upload-time = "2023-06-26T13:22:33.184Z" }, - { url = "https://files.pythonhosted.org/packages/64/5f/3f01d753e2175cfade1013eea08db99ba1ee4bdb147ebcf3623b75d12aa7/numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1", size = 13854297, upload-time = "2023-06-26T13:22:59.541Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b3/2f9c21d799fa07053ffa151faccdceeb69beec5a010576b8991f614021f7/numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4", size = 13995611, upload-time = "2023-06-26T13:23:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/10/be/ae5bf4737cb79ba437879915791f6f26d92583c738d7d960ad94e5c36adf/numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6", size = 17282357, upload-time = "2023-06-26T13:23:51.446Z" }, - { url = "https://files.pythonhosted.org/packages/c0/64/908c1087be6285f40e4b3e79454552a701664a079321cff519d8c7051d06/numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc", size = 12429222, upload-time = "2023-06-26T13:24:13.849Z" }, - { url = "https://files.pythonhosted.org/packages/22/55/3d5a7c1142e0d9329ad27cece17933b0e2ab4e54ddc5c1861fbfeb3f7693/numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e", size = 14841514, upload-time = "2023-06-26T13:24:38.129Z" }, + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, ] [[package]] name = "opentrons" -version = "8.8.1" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aionotify" }, @@ -636,27 +806,29 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyro5" }, { name = "pyserial" }, { name = "pyusb" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/68/cd35ecf658e14d208040b3b9bb8f74f8271da2eff6c03f26ed2256853b04/opentrons-8.8.1.tar.gz", hash = "sha256:03b69936e2152f53e181ab06be3c7319d4b40848b78b2eafd53658ea77c9b9a9", size = 2152732, upload-time = "2026-01-21T20:03:50.115Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/a8/ec6646b02a87ed63500a1ff06e6fee7a58e86c9181b8a8b452af47697a7e/opentrons-9.0.0.tar.gz", hash = "sha256:6412385b1cf4cab6cb118a047feafca25527cc9974b88ec219fcc13a357e5915", size = 2257110, upload-time = "2026-04-07T18:44:26.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/75/9c0729f32782acd6e22c520646fd7260ea5c999b9e909c06fb83eba5f617/opentrons-8.8.1-py3-none-any.whl", hash = "sha256:4fbafd5fcd58fff04b76300f79d4423310e36667fed3ac654495be56474a6089", size = 1868281, upload-time = "2026-01-21T20:03:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/3ae391b6987f23e623dd3e59bb9e169bc24c3c69ad1043fb8d16a9702a75/opentrons-9.0.0-py3-none-any.whl", hash = "sha256:bee6fffec9347f91495240b1925dce6a027988622898460ce34cce0d095f0787", size = 1881209, upload-time = "2026-04-07T18:44:24.117Z" }, ] [[package]] name = "opentrons-shared-data" -version = "8.8.1" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema" }, + { name = "numpy" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/dd/e78bc19ccb81c4c65f51797e3c5d2f27ecf90612779243af18b8e0a9efb1/opentrons_shared_data-8.8.1.tar.gz", hash = "sha256:0c968f0b0ec099a429d64703284b4f0f11c7db82307819787bc6c4066c06c6cb", size = 658761, upload-time = "2026-01-21T19:53:48.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/f9/7d3cebc6304a8f9460281e9d6bcf5c03bc55be4838a990b2d0d247e340aa/opentrons_shared_data-9.0.0.tar.gz", hash = "sha256:145bf9d7674b4f54f5fbbbdb6a4a4fd979d81b7796a2d30f4de8d2f0c22b9f55", size = 734858, upload-time = "2026-04-07T18:45:33.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/70466d7c78cb5043a309ff93a0fa9333d7b3d991f66d540454966196fb0d/opentrons_shared_data-8.8.1-py3-none-any.whl", hash = "sha256:4dcf7670e7932001cce7757234168c5387a244ab290e4f32fdf6cdfa6a6db90a", size = 1169517, upload-time = "2026-01-21T19:53:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/3c/40/1b39bd72660b8e98acc0ccd777ffb27d9d7db30a33892aa4dcce473d5faf/opentrons_shared_data-9.0.0-py3-none-any.whl", hash = "sha256:e2611f66384c7faf52c3ffb36f08fe7776273507e930e00eb9f601c463ea81db", size = 1231695, upload-time = "2026-04-07T18:45:31.625Z" }, ] [[package]] @@ -668,6 +840,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "paho-mqtt" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/dd/4b75dcba025f8647bc9862ac17299e0d7d12d3beadbf026d8c8d74215c12/paho-mqtt-1.6.1.tar.gz", hash = "sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f", size = 99373, upload-time = "2021-10-21T10:33:59.864Z" } + [[package]] name = "platformdirs" version = "4.10.0" @@ -714,6 +892,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -725,7 +929,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.4" +version = "2.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -733,48 +937,55 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.4" +version = "2.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, ] [[package]] name = "pydantic-settings" -version = "2.14.2" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, - { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/14/7bfb313ccee79f97dc235721b035174af94ef4472cfe455c259cd2971f2f/pydantic_settings-2.4.0.tar.gz", hash = "sha256:ed81c3a0f46392b4d7c0a565c05884e6e54b3456e6f0fe4d8814981172dc9a88", size = 63033, upload-time = "2024-07-30T10:50:41.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, + { url = "https://files.pythonhosted.org/packages/93/46/7f90f36c1bdcf24962d2b7b0e11aba3bbd65ea7904cb2553072882a4e6b7/pydantic_settings-2.4.0-py3-none-any.whl", hash = "sha256:bb6849dc067f1687574c12a639e231f3a6feeed0a12d710c1382045c5db1c315", size = 23996, upload-time = "2024-07-30T10:50:40.334Z" }, ] [[package]] @@ -786,6 +997,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyro5" +version = "5.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "serpent" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b9/312055148dbd9d0c829d30e0fbc0cff9bcc6c6311bada80737879c7f5cc6/pyro5-5.17.tar.gz", hash = "sha256:cfac69638d80943aff9cc5f1466755dd0fef8aed0bb4bda41b5eb045818ce6fc", size = 268643, upload-time = "2026-06-19T23:40:52.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/62/878386c290088c6f614bf50e59c362e23c8aaa86de9a1d74d4ee67108736/pyro5-5.17-py3-none-any.whl", hash = "sha256:0e08866d46fe4e315fd1a6403c66e354a6bc75c5cc3d19cd3495bb292ece4eaa", size = 79715, upload-time = "2026-06-19T23:40:51.28Z" }, +] + [[package]] name = "pyserial" version = "3.5" @@ -841,6 +1064,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-can" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack", marker = "sys_platform != 'win32'" }, + { name = "packaging" }, + { name = "pywin32", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/f1/327caaf05b6bca594250053058a2adac537a88dfb5c41bb5498cfda9de78/python-can-4.2.2.tar.gz", hash = "sha256:6ad50f4613289f3c4d276b6d2ac8901d776dcb929994cce93f55a69e858c595f", size = 332677, upload-time = "2023-06-18T15:07:47.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/8a/c8228e4a54e9f64882a514b7eb39787e4d425a1f0b41908f0c0cd86e8d50/python_can-4.2.2-py3-none-any.whl", hash = "sha256:7eea9b81b0ff908000a825db024313f622895bd578e8a17433e0474cd7d2da83", size = 257040, upload-time = "2023-06-18T15:07:45.704Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -868,11 +1108,20 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/86/b6b38677dec2e2e7898fc5b6f7e42c2d011919a92d25339451892f27b89c/python_multipart-0.0.18.tar.gz", hash = "sha256:7a68db60c8bfb82e460637fa4750727b45af1d5e2ed215593f917f64694d34fe", size = 36622, upload-time = "2024-11-28T19:16:02.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/6b/b60f47101ba2cac66b4a83246630e68ae9bbe2e614cbae5f4465f46dee13/python_multipart-0.0.18-py3-none-any.whl", hash = "sha256:efe91480f485f6a361427a541db4796f9e1591afc0fb8e7a4ba06bfbc6708996", size = 24389, upload-time = "2024-11-28T19:16:00.947Z" }, ] [[package]] @@ -896,6 +1145,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/a8/4982498b2ab44d1fcd5c49f07ea3795eab01601dc143b009d333fcace3b9/pyusb-1.2.1-py3-none-any.whl", hash = "sha256:2b4c7cb86dbadf044dfb9d3a4ff69fd217013dbe78a792177a3feb172449ea36", size = 58439, upload-time = "2021-07-09T02:58:44.894Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1023,6 +1282,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "serpent" +version = "1.43" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/bb/58ab4dc0382c2386355cbcb7ffa490e542159de6012d500a602a6d0ed535/serpent-1.43.tar.gz", hash = "sha256:62dc242fd4ea2a50339f4f5aaaf6ecc55605ee74770d7eb2031e760d90a0d114", size = 91641, upload-time = "2026-05-30T16:30:13.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b0/75745c948bc814d145cc46cb6da219c7acf3875822d47f08d6323ccf0458/serpent-1.43-py3-none-any.whl", hash = "sha256:7907f40151da126d57bbd675364d23898efa6b6601d79379cbae800236586b54", size = 9787, upload-time = "2026-05-30T16:30:12.027Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1050,6 +1327,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "1.4.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/56/5a8dcb01ef7b68904f2a3224343d4ab3674b5cc8f48f7cefb0701bc75ab8/SQLAlchemy-1.4.51.tar.gz", hash = "sha256:e7908c2025eb18394e32d65dd02d2e37e17d733cdbe7d78231c2b6d7eb20cdb9", size = 8521574, upload-time = "2024-01-03T01:32:12.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/bf/84be9fd1ba46f6eb1be6d125946bfbb11cdfcb10ff1d4e4d69edfe1d7996/SQLAlchemy-1.4.51-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:1a09d5bd1a40d76ad90e5570530e082ddc000e1d92de495746f6257dc08f166b", size = 1572253, upload-time = "2024-01-11T19:28:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/004e8252157c35a8955d259e779606f136a8b4e532d179a2287eca626554/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2be4e6294c53f2ec8ea36486b56390e3bcaa052bf3a9a47005687ccf376745d1", size = 1626038, upload-time = "2024-01-03T04:33:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/1b/96/572636b93dfac56aa6fdb7e3154e52104178a3b258cbcbcebf9706486db8/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca484ca11c65e05639ffe80f20d45e6be81fbec7683d6c9a15cd421e6e8b340", size = 1637613, upload-time = "2024-01-03T04:24:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/f1/35/163fd75315f7989ef3e79679cd28be200beddbcbf45b746d663ce8885e06/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0535d5b57d014d06ceeaeffd816bb3a6e2dddeb670222570b8c4953e2d2ea678", size = 1637607, upload-time = "2024-01-03T04:24:44.444Z" }, + { url = "https://files.pythonhosted.org/packages/31/62/1ba10f442303734a3888e16e0c36724a4f5d323a453dcd5fed696707fc41/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af55cc207865d641a57f7044e98b08b09220da3d1b13a46f26487cc2f898a072", size = 1637598, upload-time = "2024-01-03T04:24:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/57d15ad64e5f9fe9a75fbe47ebaf815a74a278ca12cd6896480b7a7c44fd/SQLAlchemy-1.4.51-cp310-cp310-win32.whl", hash = "sha256:7af40425ac535cbda129d9915edcaa002afe35d84609fd3b9d6a8c46732e02ee", size = 1589495, upload-time = "2024-01-11T19:31:01.135Z" }, + { url = "https://files.pythonhosted.org/packages/97/93/8919814c73aeb8d4f6b16edd17f2bed6a274b65ca26a40aeb3e1015c40bc/SQLAlchemy-1.4.51-cp310-cp310-win_amd64.whl", hash = "sha256:8d1d7d63e5d2f4e92a39ae1e897a5d551720179bb8d1254883e7113d3826d43c", size = 1591561, upload-time = "2024-01-11T19:29:27.222Z" }, +] + +[[package]] +name = "starlette" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/68/559bed5484e746f1ab2ebbe22312f2c25ec62e4b534916d41a8c21147bf8/starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", size = 51394, upload-time = "2023-05-16T10:59:56.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f8/e2cca22387965584a409795913b774235752be4176d276714e15e1a58884/starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91", size = 66978, upload-time = "2023-05-16T10:59:53.927Z" }, +] + [[package]] name = "termcolor" version = "3.3.0" @@ -1173,11 +1489,22 @@ dev = [ [[package]] name = "unitelabs-opentrons-flex" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, + { name = "anyio" }, + { name = "fastapi" }, + { name = "idna" }, { name = "numpy" }, { name = "opentrons" }, + { name = "paho-mqtt" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-can" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, { name = "unitelabs-cdk" }, { name = "uvicorn" }, { name = "wsproto" }, @@ -1199,19 +1526,30 @@ test = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = "==3.12.14" }, + { name = "anyio", specifier = "==4.9.0" }, { name = "commitizen", marker = "extra == 'dev'" }, + { name = "fastapi", specifier = "==0.100.0" }, { name = "httpx", marker = "extra == 'test'" }, - { name = "numpy", specifier = "<1.25" }, - { name = "opentrons", specifier = "==8.8.1" }, + { name = "idna", specifier = "==3.3" }, + { name = "numpy", specifier = ">=1.26.4,<2" }, + { name = "opentrons", specifier = "==9.0.0" }, + { name = "paho-mqtt", specifier = "==1.6.1" }, { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", specifier = "==2.11.7" }, + { name = "pydantic-settings", specifier = "==2.4.0" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "python-can", specifier = "==4.2.2" }, + { name = "python-dotenv", specifier = "==1.0.1" }, + { name = "python-multipart", specifier = "==0.0.18" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "sqlalchemy", specifier = "==1.4.51" }, { name = "unitelabs-cdk", specifier = "~=0.9.0" }, { name = "unitelabs-cdk", extras = ["dev"], marker = "extra == 'dev'" }, - { name = "uvicorn", specifier = ">=0.20" }, - { name = "wsproto" }, + { name = "uvicorn", specifier = "==0.27.0.post1" }, + { name = "wsproto", specifier = "==1.2.0" }, ] provides-extras = ["test", "dev"] @@ -1242,16 +1580,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.49.0" +version = "0.27.0.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/56/7bc5cf1d693d0c8e5d9dd66c29808691c17260b31346e4ddfbee26ba9bc2/uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907", size = 40971, upload-time = "2024-01-29T09:30:55.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f3/29caa83f5795b20ed3aca357c648f3ae995ff6ff08e38b22387017abbdc5/uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f", size = 60730, upload-time = "2024-01-29T09:30:52.9Z" }, ] [[package]] @@ -1305,34 +1643,33 @@ wheels = [ [[package]] name = "wrapt" -version = "2.2.2" +version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/8b/59781d0fe7b0adfbea37f600857de4be68921e454aeecf1a11bda35cdccc/wrapt-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:055e6fcfaa28e58c6a8c247d48b92be9d56f818b7068aa4f22b15b3343a09931", size = 80556, upload-time = "2026-06-20T23:47:28.473Z" }, - { url = "https://files.pythonhosted.org/packages/94/dc/66c61aca927230c9cf97a3cb005c803971a1076ff9f7d61085d035c20085/wrapt-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8374eb6b1a58809211e84ff835a182bb17ab2807a5bfef23204c8cff38178a00", size = 81648, upload-time = "2026-06-20T23:47:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/23/1b/545eee1c18f3af4cf140bb5822b6ef81ebe569df0a63ac109973103a30a5/wrapt-2.2.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:656593bb3f5529f03d27af4136c4d7b11990e470bcbc6fefa5ef218695bece55", size = 152956, upload-time = "2026-06-20T23:47:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/44/a7/6f42a3d03e44dc612a5dcff324e7366075a7857f0be2d49a8cb8a68279b8/wrapt-2.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb00cb7bb22099e2f64b7340fb96113639aa7260c0972af3797ace2297b936c", size = 154771, upload-time = "2026-06-20T23:47:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/bf/55/4d76175aaa97523c38f1d28f79d18ab41a1b116814158a818bc0eba00571/wrapt-2.2.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7f10ee0bd53673bfd52b67cbce83336fe6cad90d2377b03baf66491d2bbfb91", size = 149460, upload-time = "2026-06-20T23:47:34.712Z" }, - { url = "https://files.pythonhosted.org/packages/84/9b/12e23264d8f4735e8483262f95c5a6b03c3665fd2a84bdf99a45b6a2f4ec/wrapt-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4402f57c5f0d0579599858ffbdd9bf4e3f0972f51096f2bd6cc7dab6b76ee49e", size = 153648, upload-time = "2026-06-20T23:47:36.092Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a3/bcd5ec37289dcd85ecd4d15395a6a6063d60bc45ff94a9d77814e1e54d64/wrapt-2.2.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3a4eb7964ff4643d333c84f880bcf554652b2a1050aebc54ae696327f61acfaf", size = 148502, upload-time = "2026-06-20T23:47:37.623Z" }, - { url = "https://files.pythonhosted.org/packages/f2/be/716d708f607fa70f8a6eb47dff8ee945d5278dfc89ffeeff33039d052e63/wrapt-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e542b7c5af91e2123a8aabf19894319d5ec4268d2a9ffd2f239386133fc47746", size = 152238, upload-time = "2026-06-20T23:47:39.118Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c0/1a48e7e54501274f5d906f18372221b13183b0afbb5b8bb4c7ca0392c0b4/wrapt-2.2.2-cp310-cp310-win32.whl", hash = "sha256:6e7e45b43d3c774d244fe7264378f5a3f0f383bc55a54a9866434e524540110f", size = 77278, upload-time = "2026-06-20T23:47:40.476Z" }, - { url = "https://files.pythonhosted.org/packages/b0/82/9cd69a1af288fbdedf01a10e3c8a0b6890b08c7f3f96d36a213699dbcd94/wrapt-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:955f1d6e72a352e478de8d8b503abe301c5e139a141b62eb0923bd694995025f", size = 80131, upload-time = "2026-06-20T23:47:41.785Z" }, - { url = "https://files.pythonhosted.org/packages/7f/73/8db7e27daef37ae70a53ea62bef7fe80cc51a8b5e9e9181a8be6eb9a999c/wrapt-2.2.2-cp310-cp310-win_arm64.whl", hash = "sha256:b89d8d73c82db2bb7e6090b3afd7973f980d24e905cc34394eab60b884b3bf67", size = 79615, upload-time = "2026-06-20T23:47:43.109Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] name = "wsproto" -version = "1.3.2" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" }, ] [[package]] @@ -1347,6 +1684,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/fb/8be39dc47b0ed9bbc80f384906eed87fa974c9b377a7a430118c3b405550/xmlschema-4.3.2-py3-none-any.whl", hash = "sha256:cf5c970a30f6ebcb3da35260e694704cc0b6794d8ace46125bb2a95e38ed9307", size = 504613, upload-time = "2026-06-30T06:11:47.462Z" }, ] +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ac/cacdda1f0a90441297210bc34cf7e4ac1b7318c8030ebd83bdf6fe82f1db/yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750", size = 135466, upload-time = "2026-07-20T02:04:21.695Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a5/1b2ceace0230e40c52ab1b263148059a43a6303219b996affc68f8381836/yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2", size = 97291, upload-time = "2026-07-20T02:04:24.045Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/340d1a0db7bbce1f291afc044255ebf4ebbce2b25ab1b3f7d3d069080f5d/yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871", size = 97154, upload-time = "2026-07-20T02:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/05/41/25596a33c2fb5098dca8dc3773b04221db64ded0b7f8f09885647d864610/yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0", size = 109196, upload-time = "2026-07-20T02:04:27.543Z" }, + { url = "https://files.pythonhosted.org/packages/f2/df/dd9f2fb8a5c6054fbefd1538d2b9b1127e612d2ee64b307a070173b57afd/yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e", size = 102556, upload-time = "2026-07-20T02:04:29.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/4754b9d2c8945880290ecba0864e8b0441e117bba70534fe819e3645e174/yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2", size = 117965, upload-time = "2026-07-20T02:04:30.845Z" }, + { url = "https://files.pythonhosted.org/packages/74/b5/6a9ece27d2043c3386f902dd078ab35d29ef5126b3206ebffb673283a7cb/yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621", size = 116266, upload-time = "2026-07-20T02:04:32.573Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/a6653249f6ee59ec85dcfec008d9cbc16586dad613963bb17a91b2b993a5/yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba", size = 110758, upload-time = "2026-07-20T02:04:34.235Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c5a12fb8208df7b981bc82256e7831ce428eeaf893f7bbe6179c57bb9252/yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950", size = 110120, upload-time = "2026-07-20T02:04:35.85Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/1b659b964626694667b3ec01bf4bcff564b73ae7c48ea1fbfe588b78b461/yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00", size = 108834, upload-time = "2026-07-20T02:04:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/bf48f55c2104e40c15b7b13fad0a5756a11552a55f01c90bc90a66ab81c3/yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed", size = 103442, upload-time = "2026-07-20T02:04:39.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/ac/84b273ac133ecdce598fc1f4140a08a1bf2044048bff8106371d207d105f/yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440", size = 117413, upload-time = "2026-07-20T02:04:41.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/55/9307e03977d3b290dfa42e5d2bae7b6140808fd1786fbe70cd9d3bee53c5/yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1", size = 109498, upload-time = "2026-07-20T02:04:43.468Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/791a6f314cb4c989c19f8e3a10271f1e469c077143915e52474d80f26b4b/yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6", size = 116062, upload-time = "2026-07-20T02:04:45.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/ddd3807b86055010e2f99aa89b3c640effdb65696766c20597f696f48a1c/yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d", size = 110941, upload-time = "2026-07-20T02:04:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/6d/03/f34271bba042d2187508bf62aea20a14129efb5a1acfc6a2efe7544630b4/yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224", size = 97534, upload-time = "2026-07-20T02:04:48.774Z" }, + { url = "https://files.pythonhosted.org/packages/e4/02/ecc8dc31b9f355731e700f8402b8075d2ea1737dbc4baf4abf0f0fc64288/yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13", size = 93603, upload-time = "2026-07-20T02:04:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + [[package]] name = "zeroconf" version = "0.150.0" From 6ad5f769e699f21ef3f6180e59d93f2af13e2093 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Mon, 27 Jul 2026 14:18:40 -0400 Subject: [PATCH 04/14] fix: harden Flex accessory SiLA contracts --- CHANGELOG.md | 10 + README.md | 28 +- pyproject.toml | 1 + src/unitelabs/opentrons_flex/__init__.py | 26 +- .../opentrons_flex/features/_progress.py | 44 ++ .../opentrons_flex/features/heater_shaker.py | 83 ++- .../opentrons_flex/features/thermocycler.py | 140 ++++- src/unitelabs/opentrons_flex/io/__init__.py | 4 + src/unitelabs/opentrons_flex/io/_errors.py | 20 + .../opentrons_flex/io/heater_shaker.py | 79 ++- .../opentrons_flex/io/thermocycler.py | 302 ++++++++--- .../features/test_module_feature_endpoints.py | 82 +++ .../test_module_feature_registration.py | 15 +- tests/features/test_sila_conformance.py | 14 + tests/integration/test_grpc_heater_shaker.py | 2 +- tests/io/test_module_from_module.py | 189 +++++++ tests/resources/sila-v1.2/README.md | 10 + tests/resources/sila-v1.2/fdl-validation.xsl | 503 ++++++++++++++++++ tests/test_create_app.py | 21 +- uv.lock | 26 + 20 files changed, 1457 insertions(+), 142 deletions(-) create mode 100644 tests/resources/sila-v1.2/README.md create mode 100644 tests/resources/sila-v1.2/fdl-validation.xsl diff --git a/CHANGELOG.md b/CHANGELOG.md index dd200cb..cb812bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Hardened accessory correctness and SiLA conformance: duplicate same-type + modules now fail connector startup with their serial numbers instead of + silently replacing a Feature; Heater-Shaker and Thermocycler autonomous waits + release the connector-wide lock and detect parallel target changes; thermal + values and direction-dependent Thermocycler ramp rates are validated before + actuation; long operations emit measured progress; cancelled profiles + deactivate thermal control. `HeaterShakerController` v3.1 and + `ThermocyclerController` v2.1 add observable `Status` and static `DeviceInfo` + properties while retaining read commands for existing clients. CI now runs + the vendored official SiLA v1.2 semantic XSLT in addition to XSD validation. - Version 0.9.1 aligns the deployable runtime with Flex Opentrons 9.0.0 on Python 3.10, packages the co-versioned private robot-server stack, verifies a checksummed ARM artifact manifest built from an immutable upstream commit, and diff --git a/README.md b/README.md index b8f1cce..9813224 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ robot-server, so Heater-Shaker, Thermocycler, Temperature Module, Plate Reader, Stacker HTTP operations use that same lock. Cancellation-sensitive Reader work holds ownership until its native operation settles; a cancelled Stacker move deactivates its motors before releasing ownership and requires a complete home before reuse. +Autonomous Heater-Shaker and Thermocycler temperature waits release that global +lock between short state reads, so unrelated gantry, pipette, or gripper work can +continue. A target changed through the parallel HTTP API fails the original SiLA +wait with a defined error instead of reporting success against the wrong target. The embedded server retains its native lifespan for persistence, notifications, and task management. After the connector injects the already-initialized shared hardware, it runs the native post-initialization callbacks that prepare Protocol @@ -70,22 +74,26 @@ of the robot/module under test before that combination is described as validated | `TipController` | Atomic move + `PickUpTip` / `DropTip`, `GetTipPresence`; sensor verification and defined recovery errors (use `MotionController.EmergencyStop` for a global halt) | | `GripperController` | `Grip`, `Ungrip`, `HomeJaw`; `Status`, `JawWidth` | | `CalibrationController` | `CalibratePipette`, `CalibrateGripperJaw`, `CalibrateDeck` (automatic probe-based routines) | -| `HeaterShakerController` | Observable heat, shake, stop, and latch commands with constrained °C/rpm inputs | +| `HeaterShakerController` | Observable heat, shake, stop, and latch commands with constrained °C/rpm inputs; observable `Status`, static `DeviceInfo` | | `AbsorbanceReaderController` | `InitializeSingle`, `InitializeSingleWithReference`, `InitializeMultiple`, `ReadPlate`, `Deactivate`; observable `Status`, static `DeviceInfo` | | `FlexStackerController` | Routine `RetrieveLabware` and `StoreLabware`; observable `Status`, static `DeviceInfo` | | `FlexStackerMaintenanceController` | `HomeAll`, axis/latch, LED, and motor-stop service commands; observable `Status` and `LimitSwitchStatus`, static `DeviceInfo` | | `TemperatureController` | `SetTemperature`, atomic `SetTemperatureAndWait`, `Deactivate`; observable `Status`, static `DeviceInfo` | -| `ThermocyclerController` | Observable lid control, lid/block temperatures, typed multi-step profiles, deactivation, status, and device identity | +| `ThermocyclerController` | Observable lid control, lid/block temperatures, typed multi-step profiles and deactivation; observable `Status`, static `DeviceInfo` | The Heater-Shaker controller exposes observable temperature, shaking, and latch operations with intermediate execution updates and defined module errors: `SetTemperature`, `WaitForTemperature`, `DeactivateHeater`, `SetSpeed`, `StopShaking`, `OpenLatch`, `CloseLatch`, `GetTemperature`, `GetSpeed`, `GetLatchStatus`, `GetStatus`, and `GetDeviceInfo`. -Temperature inputs carry a degrees Celsius unit constraint and a 0–95 °C range; +The read commands remain for compatibility; new clients should subscribe to +`Status` and read the static `DeviceInfo` property. Temperature inputs carry a +degrees Celsius unit constraint and the public 37–95 °C operating range; active shaking carries a revolutions-per-minute unit constraint and a 200–3000 rpm range. The physical unit stays in the FDL constraint rather than the endpoint identifier. Use `StopShaking` instead of sending an implicit zero-speed sentinel. +Cancelling `WaitForTemperature` stops the wait but intentionally leaves the active +target enabled; call `DeactivateHeater` when heating must stop. The Absorbance Plate Reader workflow follows the official physical sequence: use an allowlisted `LabwareMovementController` plan and the Flex Gripper to place @@ -112,6 +120,20 @@ so unrelated robot-server operations can continue; if the parallel HTTP path cha the module target, the SiLA command stops with a defined error instead of reporting false success. Status and identity are exposed as properties with structured state. +The Thermocycler validates finite temperatures, hold times, block maximum volume, +profile size, and direction-dependent ramp rates before actuation. A zero block +volume selects the Opentrons default of 25 µL; non-zero values are the greatest +volume in any individual well. Long waits and profiles publish current/target and +step progress without monopolizing the global hardware lock. Cancelling a profile +deactivates both thermal controllers, while cancelling a standalone wait leaves +its target active and reports that explicitly. + +The current Feature Definitions represent one physical instance of each accessory +type. If two supported modules of the same type are attached, startup fails with +their serial numbers instead of silently replacing one Feature registration. +Disconnect the extra module until a serial-routed, multi-instance Feature contract +is deployed. + The Magnetic Block is passive and has no powered state or command surface, so the connector intentionally does not register a Magnetic Block feature. Move plates on and off it with server-provisioned `LabwareMovementController` plans and the existing diff --git a/pyproject.toml b/pyproject.toml index ae78084..c4ae3ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ test = [ "pytest-asyncio", "pytest-cov", "httpx", + "lxml>=5,<7", ] dev = [ "unitelabs-cdk[dev]", diff --git a/src/unitelabs/opentrons_flex/__init__.py b/src/unitelabs/opentrons_flex/__init__.py index 5ce6b50..953b5ab 100644 --- a/src/unitelabs/opentrons_flex/__init__.py +++ b/src/unitelabs/opentrons_flex/__init__.py @@ -280,8 +280,32 @@ def _register_modules( attached_modules: collections.abc.Iterable, shared_lock: asyncio.Lock | RunAwareLock, ) -> None: + modules = list(attached_modules) factories = _module_factories() - for module in attached_modules: + modules_by_type: dict[object, list[object]] = {} + for module in modules: + if module.MODULE_TYPE in factories: + modules_by_type.setdefault(module.MODULE_TYPE, []).append(module) + duplicates = {module_type: group for module_type, group in modules_by_type.items() if len(group) > 1} + if duplicates: + details = [] + for module_type, group in sorted(duplicates.items(), key=lambda item: item[0].name): + serials = [] + for module in group: + device_info = getattr(module, "device_info", {}) + serial = device_info.get("serial") if isinstance(device_info, collections.abc.Mapping) else None + serials.append(str(serial or "unknown")) + details.append(f"{module_type.name}: {', '.join(serials)}") + duplicate_summary = "; ".join(details) + message = ( + "Multiple attached modules of the same type cannot be represented by the current " + "single-instance SiLA feature contract. Connector startup was refused to prevent " + f"silent feature replacement. Duplicate modules: {duplicate_summary}. " + "Disconnect the extra module or use a connector release with serial-number routing." + ) + raise RuntimeError(message) + + for module in modules: factory = factories.get(module.MODULE_TYPE) if factory is None: log.info("Skipping unsupported module type %s", module.MODULE_TYPE.name) diff --git a/src/unitelabs/opentrons_flex/features/_progress.py b/src/unitelabs/opentrons_flex/features/_progress.py index 0fb6ace..c793f72 100644 --- a/src/unitelabs/opentrons_flex/features/_progress.py +++ b/src/unitelabs/opentrons_flex/features/_progress.py @@ -2,6 +2,7 @@ import enum import asyncio +import contextlib import typing from dataclasses import dataclass @@ -56,3 +57,46 @@ async def run_observable( raise report_progress(status, intermediate, 1.0, OperationPhase.COMPLETED, complete_message) return result + + +async def run_observable_with_updates( + status: sila.Status, + intermediate: sila.Intermediate[OperationProgress], + start_message: str, + complete_message: str, + cancelled_message: str, + action: typing.Awaitable[_T], + read_update: typing.Callable[[], typing.Awaitable[tuple[float, str]]], + *, + interval: float = 0.25, +) -> _T: + """Run an observable action while polling truthful intermediate progress.""" + report_progress(status, intermediate, 0.0, OperationPhase.STARTING, start_message) + task = asyncio.ensure_future(action) + try: + while not task.done(): + done, _ = await asyncio.wait((task,), timeout=interval) + if done: + break + progress, message = await read_update() + report_progress( + status, + intermediate, + max(0.0, min(float(progress), 0.99)), + OperationPhase.RUNNING, + message, + ) + result = task.result() + except asyncio.CancelledError: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + report_progress(status, intermediate, 1.0, OperationPhase.CANCELLED, cancelled_message) + raise + except Exception: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + raise + report_progress(status, intermediate, 1.0, OperationPhase.COMPLETED, complete_message) + return result diff --git a/src/unitelabs/opentrons_flex/features/heater_shaker.py b/src/unitelabs/opentrons_flex/features/heater_shaker.py index be4b878..56d7163 100644 --- a/src/unitelabs/opentrons_flex/features/heater_shaker.py +++ b/src/unitelabs/opentrons_flex/features/heater_shaker.py @@ -1,5 +1,6 @@ """SiLA2 feature for Heater-Shaker module control.""" +import asyncio import enum import logging import typing @@ -12,13 +13,15 @@ COMMON_MODULE_ERRORS, DeviceInfo, HeaterShakerController, + InvalidHeaterShakerTemperatureError, Temperature, RPM, ) -from ._progress import OperationProgress, run_observable +from ._progress import OperationProgress, run_observable, run_observable_with_updates # The constraints mirror the public Opentrons Heater-Shaker contract. A target -# below ambient may not be reachable, but API 2.25+ accepts 0-95 degrees Celsius. +# below the documented operating range may not be reachable even if the lower +# hardware layer accepts it. # Active shaking is 200-3000 revolutions per minute; stopping is a separate # command so 0 is not overloaded as a hidden control value. _CELSIUS = constraints.Unit( @@ -33,7 +36,7 @@ ) _TempCelsius = typing.Annotated[ float, - constraints.MinimalInclusive(0.0), + constraints.MinimalInclusive(37.0), constraints.MaximalInclusive(95.0), _CELSIUS, ] @@ -48,6 +51,7 @@ log = logging.getLogger(__name__) +_HEATER_SHAKER_TEMPERATURE_ERRORS = (*COMMON_MODULE_ERRORS, InvalidHeaterShakerTemperatureError) class LatchStatus(enum.Enum): @@ -164,11 +168,11 @@ def __init__(self, controller: HeaterShakerController): category="modules", identifier="HeaterShakerController", name="Heater Shaker Controller", - version="3.0", + version="3.1", ) self._controller = controller - @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) + @sila.ObservableCommand(errors=_HEATER_SHAKER_TEMPERATURE_ERRORS) async def set_temperature( self, temperature: _TempCelsius, @@ -180,8 +184,7 @@ async def set_temperature( Set the target temperature. Args: - temperature: Target temperature in Celsius (valid range 0-95 C; - the module heats only, so the effective minimum is ambient). + temperature: Target temperature in Celsius (valid range 37-95 C). Returns: Current and target temperature. @@ -196,7 +199,7 @@ async def set_temperature( ) return _temperature_response(await self._controller.get_temperature()) - @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) + @sila.ObservableCommand(errors=_HEATER_SHAKER_TEMPERATURE_ERRORS) async def wait_for_temperature( self, temperature: _TempCelsius, @@ -216,13 +219,28 @@ async def wait_for_temperature( Returns: Current and target temperature. """ - await run_observable( + initial = await self._controller.get_temperature() + + async def read_update() -> tuple[float, str]: + reading = await self._controller.get_temperature() + span = abs(temperature - initial.current) + remaining = abs(temperature - reading.current) + progress = 0.5 if span <= 0.5 else max(0.0, 1.0 - remaining / span) + message = ( + f"Heater-Shaker temperature is {reading.current:.1f} °C; " + f"waiting for {temperature:.1f} °C. The active target remains enabled." + ) + return progress, message + + await run_observable_with_updates( status, intermediate, f"Waiting for heater-shaker to reach {temperature} C.", "Heater-shaker reached target temperature.", - "Heater-shaker temperature wait cancelled.", + "Heater-shaker temperature wait cancelled; the active heater target remains enabled. " + "Call DeactivateHeater if heating should stop.", self._controller.wait_for_temperature(temperature), + read_update, ) return _temperature_response(await self._controller.get_temperature()) @@ -438,19 +456,7 @@ async def get_status( "Heater-shaker status read cancelled.", self._controller.is_connected(), ) - temp = await self._controller.get_temperature() - speed = await self._controller.get_rpm() - latch = await self._controller.get_latch_status() - - return HeaterShakerStatus( - current_temperature=temp.current, - target_temperature=temp.target if temp.target is not None else 0.0, - temperature_target_active=temp.target is not None, - current_speed=speed.current, - target_speed=speed.target if speed.target is not None else 0, - speed_target_active=speed.target is not None, - latch_status=LatchStatus(latch.value), - ) + return await self._status() @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) async def get_device_info( @@ -473,3 +479,34 @@ async def get_device_info( "Heater-shaker device information read cancelled.", self._controller.get_device_info(), ) + + async def _status(self) -> HeaterShakerStatus: + """Read the structured state shared by the command and property surfaces.""" + temp = await self._controller.get_temperature() + speed = await self._controller.get_rpm() + latch = await self._controller.get_latch_status() + return HeaterShakerStatus( + current_temperature=temp.current, + target_temperature=temp.target if temp.target is not None else 0.0, + temperature_target_active=temp.target is not None, + current_speed=speed.current, + target_speed=speed.target if speed.target is not None else 0, + speed_target_active=speed.target is not None, + latch_status=LatchStatus(latch.value), + ) + + @sila.ObservableProperty(errors=COMMON_MODULE_ERRORS) + async def subscribe_status(self) -> sila.Stream[HeaterShakerStatus]: + """Subscribe to temperature, speed, target, and latch-state changes.""" + previous: object = object() + while True: + current = await self._status() + if current != previous: + yield current + previous = current + await asyncio.sleep(0.25) + + @sila.UnobservableProperty(errors=COMMON_MODULE_ERRORS) + def device_info(self) -> DeviceInfo: + """Return the attached module serial number, model, and firmware version.""" + return self._controller.device_info diff --git a/src/unitelabs/opentrons_flex/features/thermocycler.py b/src/unitelabs/opentrons_flex/features/thermocycler.py index c64736d..6c3dd3e 100644 --- a/src/unitelabs/opentrons_flex/features/thermocycler.py +++ b/src/unitelabs/opentrons_flex/features/thermocycler.py @@ -1,5 +1,6 @@ """SiLA2 feature for Thermocycler module control.""" +import asyncio import enum import logging import typing @@ -11,10 +12,11 @@ from ..io import ( COMMON_MODULE_ERRORS, DeviceInfo, + InvalidThermocyclerProfileError, ThermocyclerController, Temperature, ) -from ._progress import OperationProgress, run_observable +from ._progress import OperationProgress, run_observable, run_observable_with_updates # Sourced from opentrons protocol_api/module_contexts.py: block 4-99 C, lid 37-110 C. _CELSIUS = constraints.Unit( @@ -52,12 +54,23 @@ ] _TemperatureReading = typing.Annotated[float, _CELSIUS] _Duration = typing.Annotated[float, constraints.MinimalInclusive(0.0), _SECOND] -_Volume = typing.Annotated[float, constraints.MinimalInclusive(0.0), _MICROLITRE] -_RampRate = typing.Annotated[float, constraints.MinimalInclusive(0.0), _CELSIUS_PER_SECOND] +_Volume = typing.Annotated[ + float, + constraints.MinimalInclusive(0.0), + constraints.MaximalInclusive(200.0), + _MICROLITRE, +] +_RampRate = typing.Annotated[ + float, + constraints.MinimalInclusive(0.0), + constraints.MaximalInclusive(4.25), + _CELSIUS_PER_SECOND, +] _PositiveInteger = typing.Annotated[int, constraints.MinimalInclusive(1)] log = logging.getLogger(__name__) +_THERMOCYCLER_PROFILE_ERRORS = (*COMMON_MODULE_ERRORS, InvalidThermocyclerProfileError) class LidStatus(enum.Enum): @@ -99,6 +112,13 @@ class ThermocyclerProfileStep: ramp_rate: _RampRate +_ProfileSteps = typing.Annotated[ + list[ThermocyclerProfileStep], + constraints.MinimalElementCount(1), + constraints.MaximalElementCount(20), +] + + @dataclass class ThermocyclerTemperature: """Current and target temperature without an optional wire value.""" @@ -138,7 +158,7 @@ def __init__(self, controller: ThermocyclerController): category="modules", identifier="ThermocyclerController", name="Thermocycler Controller", - version="2.0", + version="2.1", ) self._controller = controller @@ -215,7 +235,7 @@ async def get_lid_status( # ============ Temperature Control ============ - @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) + @sila.ObservableCommand(errors=_THERMOCYCLER_PROFILE_ERRORS) async def set_lid_temperature( self, temperature: _LidTemperature, @@ -258,13 +278,29 @@ async def wait_for_lid_temperature( Returns: Current and target lid temperature. """ - await run_observable( + initial = await self._controller.get_lid_temperature() + target = initial.target if initial.target is not None else initial.current + + async def read_update() -> tuple[float, str]: + reading = await self._controller.get_lid_temperature() + active_target = reading.target if reading.target is not None else target + span = abs(target - initial.current) + remaining = abs(active_target - reading.current) + progress = 0.5 if span <= 0.5 else max(0.0, 1.0 - remaining / span) + return ( + progress, + f"Thermocycler lid is {reading.current:.1f} °C; waiting for {active_target:.1f} °C.", + ) + + await run_observable_with_updates( status, intermediate, "Waiting for thermocycler lid target temperature.", "Thermocycler lid reached target temperature.", - "Thermocycler lid temperature wait cancelled.", + "Thermocycler lid temperature wait cancelled; the lid target remains enabled. " + "Call DeactivateLid if heating should stop.", self._controller.wait_for_lid_temperature(), + read_update, ) return _temperature(await self._controller.get_lid_temperature()) @@ -291,7 +327,7 @@ async def get_lid_temperature( ) return _temperature(reading) - @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) + @sila.ObservableCommand(errors=_THERMOCYCLER_PROFILE_ERRORS) async def set_plate_temperature( self, temperature: _BlockTemperature, @@ -308,7 +344,8 @@ async def set_plate_temperature( Args: temperature: Target block temperature (valid range 4-99 degrees Celsius). hold_time: Hold time, or 0 for no hold time. - volume: Sample volume, or 0 for no volume. + volume: Greatest volume in any block well, or 0 to use the + Opentrons default of 25 microlitres. ramp_rate: Temperature ramp rate, or 0 for module default. Returns: @@ -345,13 +382,29 @@ async def wait_for_plate_temperature( Returns: Current and target plate temperature. """ - await run_observable( + initial = await self._controller.get_plate_temperature() + target = initial.target if initial.target is not None else initial.current + + async def read_update() -> tuple[float, str]: + reading = await self._controller.get_plate_temperature() + active_target = reading.target if reading.target is not None else target + span = abs(target - initial.current) + remaining = abs(active_target - reading.current) + progress = 0.5 if span <= 0.5 else max(0.0, 1.0 - remaining / span) + return ( + progress, + f"Thermocycler block is {reading.current:.1f} °C; waiting for {active_target:.1f} °C.", + ) + + await run_observable_with_updates( status, intermediate, "Waiting for thermocycler plate target temperature.", "Thermocycler plate reached target temperature.", - "Thermocycler plate temperature wait cancelled.", + "Thermocycler block temperature wait cancelled; the block target remains enabled. " + "Call DeactivateBlock if heating or cooling should stop.", self._controller.wait_for_plate_temperature(), + read_update, ) return _temperature(await self._controller.get_plate_temperature()) @@ -378,10 +431,10 @@ async def get_plate_temperature( ) return _temperature(reading) - @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) + @sila.ObservableCommand(errors=_THERMOCYCLER_PROFILE_ERRORS) async def execute_profile( self, - steps: list[ThermocyclerProfileStep], + steps: _ProfileSteps, repetitions: _PositiveInteger, volume: _Volume, *, @@ -394,7 +447,8 @@ async def execute_profile( Args: steps: Ordered profile steps. repetitions: Number of repetitions. - volume: Sample volume, or 0 for no volume. + volume: Greatest volume in any block well, or 0 to use the + Opentrons default of 25 microlitres. Yields: Update: Current profile execution progress update. @@ -410,18 +464,36 @@ async def execute_profile( } for step in steps ] - await run_observable( - status, - intermediate, - "Starting thermocycler profile.", - "Thermocycler profile completed.", - "Thermocycler profile cancelled.", - self._controller.execute_profile( - steps=profile, - repetitions=repetitions, - volume=volume if volume > 0 else None, - ), - ) + + async def read_update() -> tuple[float, str]: + completed, total, repetition, step, _ = self._controller.profile_progress + reading = await self._controller.get_plate_temperature() + progress = completed / total if total else 0.0 + target = reading.target if reading.target is not None else 0.0 + message = ( + f"Thermocycler profile repetition {repetition}, step {step}: " + f"{reading.current:.1f} °C toward {target:.1f} °C " + f"({completed} of {total} steps completed)." + ) + return progress, message + + try: + await run_observable_with_updates( + status, + intermediate, + "Starting thermocycler profile.", + "Thermocycler profile completed.", + "Thermocycler profile cancelled; lid and block control are being deactivated.", + self._controller.execute_profile( + steps=profile, + repetitions=repetitions, + volume=volume if volume > 0 else None, + ), + read_update, + ) + except asyncio.CancelledError: + await self._controller.deactivate_all() + raise return await self._status() @sila.ObservableCommand(errors=COMMON_MODULE_ERRORS) @@ -555,3 +627,19 @@ async def get_device_info( "Thermocycler device information read cancelled.", self._controller.get_device_info(), ) + + @sila.ObservableProperty(errors=COMMON_MODULE_ERRORS) + async def subscribe_status(self) -> sila.Stream[ThermocyclerStatus]: + """Subscribe to lid, block, target, and lid-position changes.""" + previous: object = object() + while True: + current = await self._status() + if current != previous: + yield current + previous = current + await asyncio.sleep(0.25) + + @sila.UnobservableProperty(errors=COMMON_MODULE_ERRORS) + def device_info(self) -> DeviceInfo: + """Return the attached module serial number, model, and firmware version.""" + return self._controller.device_info diff --git a/src/unitelabs/opentrons_flex/io/__init__.py b/src/unitelabs/opentrons_flex/io/__init__.py index 583e4f1..7adddf5 100644 --- a/src/unitelabs/opentrons_flex/io/__init__.py +++ b/src/unitelabs/opentrons_flex/io/__init__.py @@ -36,7 +36,9 @@ LabwareNotPickedError, LabwareNotPlacedError, InvalidWavelengthError, + InvalidHeaterShakerTemperatureError, InvalidTemperatureTargetError, + InvalidThermocyclerProfileError, InvalidStackerConfigurationError, MachineErrorStateError, NozzleConfigurationError, @@ -135,8 +137,10 @@ "GripperNotAttachedError", "HardwareProxy", "HeaterShakerController", + "InvalidHeaterShakerTemperatureError", "InvalidStackerConfigurationError", "InvalidTemperatureTargetError", + "InvalidThermocyclerProfileError", "InvalidWavelengthError", "LabwareGripGeometry", "LabwareMoveResult", diff --git a/src/unitelabs/opentrons_flex/io/_errors.py b/src/unitelabs/opentrons_flex/io/_errors.py index a48f475..c5e40f0 100644 --- a/src/unitelabs/opentrons_flex/io/_errors.py +++ b/src/unitelabs/opentrons_flex/io/_errors.py @@ -81,6 +81,26 @@ class InvalidTemperatureTargetError(Exception): """ +class InvalidHeaterShakerTemperatureError(Exception): + """ + The requested Heater-Shaker target is outside its supported operating range. + + Provide a finite value between 37 and 95 degrees Celsius and retry. Lower + targets are not exposed because they may be physically unreachable and can + otherwise leave a waiting workflow blocked indefinitely. + """ + + +class InvalidThermocyclerProfileError(Exception): + """ + A Thermocycler temperature, hold, volume, ramp rate, or profile is invalid. + + Correct the value using the range in the error message and retry. Ramp-rate + limits depend on whether the block is heating or cooling, so the attached + module's current temperature is included when that distinction matters. + """ + + class InvalidWavelengthError(Exception): """ The requested wavelength configuration is not supported by the attached reader. diff --git a/src/unitelabs/opentrons_flex/io/heater_shaker.py b/src/unitelabs/opentrons_flex/io/heater_shaker.py index 5acdfeb..8b77d6c 100644 --- a/src/unitelabs/opentrons_flex/io/heater_shaker.py +++ b/src/unitelabs/opentrons_flex/io/heater_shaker.py @@ -2,12 +2,14 @@ import asyncio import logging +import math from opentrons.drivers.heater_shaker.driver import HeaterShakerDriver from opentrons.drivers.heater_shaker.abstract import HeaterShakerLabwareLatchStatus +from ._errors import InvalidHeaterShakerTemperatureError, ModuleOperationError from ._module_base import ModuleControllerBase -from ._types import RPM, Temperature +from ._types import DeviceInfo, RPM, Temperature log = logging.getLogger(__name__) @@ -24,6 +26,26 @@ class HeaterShakerController(ModuleControllerBase): attached to a shared ``HardwareControlAPI`` (in-process robot-server mode). """ + _SHARED_LOCK_EXEMPT_METHODS = frozenset( + { + "deactivate_heater", + "set_temperature", + "wait_for_temperature", + } + ) + + def __init__(self, driver: object = None, module: object = None, lock: asyncio.Lock | None = None) -> None: + super().__init__(driver=driver, module=module, lock=lock) + self._temperature_control_lock = asyncio.Lock() + self._driver_device_info = DeviceInfo(serial_number="", model="", firmware_version="") + + @property + def device_info(self) -> DeviceInfo: + """Return the attached module identity without an additional device call.""" + if self._module is not None: + return DeviceInfo.from_dict(dict(self._module.device_info)) + return self._driver_device_info + @classmethod async def build(cls, port: str) -> "HeaterShakerController": """ @@ -37,14 +59,18 @@ async def build(cls, port: str) -> "HeaterShakerController": """ driver = await HeaterShakerDriver.create(port=port, loop=None) await driver.connect() - return cls(driver=driver) + controller = cls(driver=driver) + controller._driver_device_info = await controller.get_device_info() + return controller async def set_temperature(self, temperature: float) -> None: """Set target temperature in Celsius (does not wait for the target to be reached).""" - if self._module is not None: - await self._module.start_set_temperature(temperature) - else: - await self._driver.set_temperature(temperature=temperature) + self._validate_temperature(temperature) + async with self._temperature_control_lock, self._operation_lock(): + if self._module is not None: + await self._module.start_set_temperature(temperature) + else: + await self._driver.set_temperature(temperature=temperature) async def get_temperature(self) -> Temperature: """Get current and target temperature.""" @@ -54,22 +80,28 @@ async def get_temperature(self) -> Temperature: return Temperature(current=t.current, target=t.target) async def wait_for_temperature(self, temperature: float) -> None: - """Wait for the heater to reach a target temperature.""" - if self._module is not None: - await self._module.await_temperature(temperature) - else: + """Wait for one active target without monopolizing the connector-wide lock.""" + self._validate_temperature(temperature) + async with self._temperature_control_lock: while True: - current = (await self.get_temperature()).current - if abs(current - temperature) <= 0.5: + reading = await self.get_temperature() + if reading.target is None or abs(float(reading.target) - temperature) > 0.01: + message = ( + f"Heater-Shaker target changed while waiting: expected {temperature} °C, " + f"observed {reading.target!r}. Another client may have changed or deactivated the module." + ) + raise ModuleOperationError(message) + if abs(reading.current - temperature) <= 0.5: return - await asyncio.sleep(1.0) + await asyncio.sleep(0.25 if self._module is not None else 1.0) async def deactivate_heater(self) -> None: """Turn off the heater.""" - if self._module is not None: - await self._module.deactivate_heater() - else: - await self._driver.deactivate_heater() + async with self._temperature_control_lock, self._operation_lock(): + if self._module is not None: + await self._module.deactivate_heater() + else: + await self._driver.deactivate_heater() async def set_rpm(self, rpm: int) -> None: """Set shaking speed in RPM.""" @@ -111,3 +143,16 @@ async def get_latch_status(self) -> HeaterShakerLabwareLatchStatus: if self._module is not None: return self._module.labware_latch_status return await self._driver.get_labware_latch_status() + + @staticmethod + def _validate_temperature(temperature: float) -> None: + """Reject values that are not reliably reachable by the public workflow.""" + try: + is_valid = math.isfinite(temperature) and 37.0 <= temperature <= 95.0 + except (TypeError, ValueError): + is_valid = False + if not is_valid: + message = ( + f"Heater-Shaker target temperature must be a finite value from 37 to 95 °C; received {temperature!r}." + ) + raise InvalidHeaterShakerTemperatureError(message) diff --git a/src/unitelabs/opentrons_flex/io/thermocycler.py b/src/unitelabs/opentrons_flex/io/thermocycler.py index ec45576..879e953 100644 --- a/src/unitelabs/opentrons_flex/io/thermocycler.py +++ b/src/unitelabs/opentrons_flex/io/thermocycler.py @@ -2,12 +2,14 @@ import asyncio import logging +import math from opentrons.drivers.thermocycler.driver import ThermocyclerDriverV2 from opentrons.hardware_control.modules.types import ThermocyclerStep +from ._errors import InvalidThermocyclerProfileError, ModuleOperationError from ._module_base import ModuleControllerBase -from ._types import Temperature +from ._types import DeviceInfo, Temperature log = logging.getLogger(__name__) @@ -24,6 +26,48 @@ class ThermocyclerController(ModuleControllerBase): attached to a shared ``HardwareControlAPI`` (in-process robot-server mode). """ + _SHARED_LOCK_EXEMPT_METHODS = frozenset( + { + "deactivate_all", + "deactivate_block", + "deactivate_lid", + "execute_profile", + "set_lid_temperature", + "set_plate_temperature", + "wait_for_lid_temperature", + "wait_for_plate_temperature", + } + ) + + def __init__(self, driver: object = None, module: object = None, lock: asyncio.Lock | None = None) -> None: + super().__init__(driver=driver, module=module, lock=lock) + self._lid_control_lock = asyncio.Lock() + self._block_control_lock = asyncio.Lock() + self._driver_device_info = DeviceInfo(serial_number="", model="", firmware_version="") + self._profile_completed_steps = 0 + self._profile_total_steps = 0 + self._profile_repetition = 0 + self._profile_step = 0 + self._profile_active = False + + @property + def device_info(self) -> DeviceInfo: + """Return the attached module identity without an additional device call.""" + if self._module is not None: + return DeviceInfo.from_dict(dict(self._module.device_info)) + return self._driver_device_info + + @property + def profile_progress(self) -> tuple[int, int, int, int, bool]: + """Return completed/total steps, current repetition/step, and active state.""" + return ( + self._profile_completed_steps, + self._profile_total_steps, + self._profile_repetition, + self._profile_step, + self._profile_active, + ) + @classmethod async def build(cls, port: str) -> "ThermocyclerController": """ @@ -37,7 +81,9 @@ async def build(cls, port: str) -> "ThermocyclerController": """ driver = await ThermocyclerDriverV2.create(port=port, loop=None) await driver.connect() - return cls(driver=driver) + controller = cls(driver=driver) + controller._driver_device_info = await controller.get_device_info() + return controller async def open_lid(self) -> None: """Open the lid.""" @@ -61,10 +107,12 @@ async def get_lid_status(self) -> str: async def set_lid_temperature(self, temperature: float) -> None: """Set lid temperature in Celsius (does not wait for the target to be reached).""" - if self._module is not None: - await self._module.set_target_lid_temperature(temperature) - else: - await self._driver.set_lid_temperature(temp=temperature) + self._validate_range(temperature, "Lid temperature", 37.0, 110.0) + async with self._lid_control_lock, self._operation_lock(): + if self._module is not None: + await self._module.set_target_lid_temperature(temperature) + else: + await self._driver.set_lid_temperature(temp=temperature) async def set_plate_temperature( self, @@ -82,40 +130,140 @@ async def set_plate_temperature( volume: Optional sample volume in uL. ramp_rate: Optional ramp rate in C/s. """ - if self._module is not None: - kwargs = {"hold_time_seconds": hold_time, "volume": volume} + async with self._block_control_lock: + await self._set_plate_temperature(temperature, hold_time, volume, ramp_rate) + + async def _set_plate_temperature( + self, + temperature: float, + hold_time: float | None, + volume: float | None, + ramp_rate: float | None, + ) -> None: + """Validate and send one block target while briefly owning the shared lock.""" + self._validate_range(temperature, "Block temperature", 4.0, 99.0) + if hold_time is not None: + self._validate_range(hold_time, "Hold time", 0.0, None) + if volume is not None: + self._validate_range(volume, "Block maximum volume", 1.0, 200.0) + if ramp_rate is not None: + self._validate_range(ramp_rate, "Ramp rate", 0.01, 4.25) + + async with self._operation_lock(): + current_temperature = (await self._read_plate_temperature())[0].current if ramp_rate is not None: - kwargs["ramp_rate"] = ramp_rate - await self._module.set_target_block_temperature(temperature, **kwargs) - else: - await self._driver.set_plate_temperature( - temp=temperature, - hold_time=hold_time, - volume=volume, - ramp_rate=ramp_rate, - ) + maximum_ramp_rate = 4.25 if temperature >= current_temperature else 2.0 + if ramp_rate > maximum_ramp_rate: + direction = "heating" if temperature >= current_temperature else "cooling" + message = ( + f"Ramp rate for {direction} from {current_temperature} to {temperature} °C " + f"must be between 0.01 and {maximum_ramp_rate} °C/s; received {ramp_rate!r}." + ) + raise InvalidThermocyclerProfileError(message) + if self._module is not None: + kwargs = {"hold_time_seconds": hold_time, "volume": volume} + if ramp_rate is not None: + kwargs["ramp_rate"] = ramp_rate + await self._module.set_target_block_temperature(temperature, **kwargs) + else: + await self._driver.set_plate_temperature( + temp=temperature, + hold_time=hold_time, + volume=volume, + ramp_rate=ramp_rate, + ) async def wait_for_lid_temperature(self) -> None: - """Wait until the lid reaches its target temperature.""" - if self._module is not None: - await self._module.wait_for_lid_target() - else: - while True: - temp = await self.get_lid_temperature() - if temp.target is None or abs(temp.current - temp.target) <= 0.5: - return - await asyncio.sleep(1.0) + """Wait for the active lid target without monopolizing the global lock.""" + async with self._lid_control_lock: + reading = await self.get_lid_temperature() + if reading.target is None: + message = "The Thermocycler lid has no active target. Set a lid temperature before waiting." + raise ModuleOperationError(message) + await self._wait_for_lid_temperature(float(reading.target)) async def wait_for_plate_temperature(self) -> None: - """Wait until the plate reaches its target temperature.""" - if self._module is not None: - await self._module.wait_for_block_target() - else: - while True: - temp = await self.get_plate_temperature() - if temp.target is None or abs(temp.current - temp.target) <= 0.5: - return - await asyncio.sleep(1.0) + """Wait for the active block target without monopolizing the global lock.""" + async with self._block_control_lock: + reading = await self.get_plate_temperature() + if reading.target is None: + message = "The Thermocycler block has no active target. Set a block temperature before waiting." + raise ModuleOperationError(message) + await self._wait_for_plate_temperature(float(reading.target)) + + async def _wait_for_plate_temperature(self, expected_target: float) -> None: + """Wait for a specific block target and fail if another interface changes it.""" + while True: + async with self._operation_lock(observation=True): + reading, hold_remaining = await self._read_plate_temperature() + self._require_unchanged_target("block", expected_target, reading.target) + at_temperature = abs(reading.current - expected_target) <= 0.5 + hold_complete = hold_remaining is None or hold_remaining <= 0 + if at_temperature and hold_complete: + return + await asyncio.sleep(0.25 if self._module is not None else 1.0) + + async def _wait_for_lid_temperature(self, expected_target: float) -> None: + """Wait for a specific lid target and fail if another interface changes it.""" + while True: + reading = await self.get_lid_temperature() + self._require_unchanged_target("lid", expected_target, reading.target) + if abs(reading.current - expected_target) <= 0.5: + return + await asyncio.sleep(0.25 if self._module is not None else 1.0) + + @staticmethod + def _require_unchanged_target(name: str, expected: float, observed: float | None) -> None: + """Raise a recoverable error when another interface replaces a wait target.""" + if observed is None or abs(float(observed) - expected) > 0.01: + message = ( + f"Thermocycler {name} target changed while waiting: expected {expected} °C, " + f"observed {observed!r}. Another client may have changed or deactivated the module." + ) + raise ModuleOperationError(message) + + @staticmethod + def _validate_range(value: float, name: str, minimum: float, maximum: float | None) -> None: + """Validate a finite Thermocycler value before sending it to hardware.""" + try: + in_range = math.isfinite(value) and value >= minimum and (maximum is None or value <= maximum) + except (TypeError, ValueError): + in_range = False + if in_range: + return + maximum_text = f" and {maximum}" if maximum is not None else "" + message = f"{name} must be a finite value between {minimum}{maximum_text}; received {value!r}." + raise InvalidThermocyclerProfileError(message) + + @classmethod + def _validate_profile( + cls, + steps: list[ThermocyclerStep], + repetitions: int, + volume: float | None, + ) -> None: + """Validate profile shape and values before starting its first step.""" + if not 1 <= len(steps) <= 20: + message = f"Thermocycler profile must contain between 1 and 20 steps; received {len(steps)}." + raise InvalidThermocyclerProfileError(message) + if repetitions < 1: + message = f"Thermocycler repetitions must be at least 1; received {repetitions!r}." + raise InvalidThermocyclerProfileError(message) + if volume is not None: + cls._validate_range(volume, "Block maximum volume", 1.0, 200.0) + for index, step in enumerate(steps, start=1): + try: + temperature = float(step["temperature"]) + hold_time = step.get("hold_time_seconds") + ramp_rate = step.get("ramp_rate") + except (KeyError, TypeError, ValueError) as exc: + message = f"Thermocycler profile step {index} is missing a numeric temperature." + raise InvalidThermocyclerProfileError(message) from exc + cls._validate_range(temperature, f"Profile step {index} temperature", 4.0, 99.0) + if hold_time is not None: + cls._validate_range(float(hold_time), f"Profile step {index} hold time", 0.0, None) + if ramp_rate is not None: + cls._validate_range(float(ramp_rate), f"Profile step {index} ramp rate", 0.01, 4.25) async def execute_profile( self, @@ -124,27 +272,35 @@ async def execute_profile( volume: float | None = None, ) -> None: """ - Execute a simple thermocycler profile. + Execute a validated profile while releasing the shared lock between device calls. - Args: - steps: List of step dictionaries accepted by the opentrons module - object, or converted to sequential low-level set/wait calls. - repetitions: Number of times to repeat the steps. - volume: Optional sample volume in uL. + The local block-control lock prevents another SiLA block command from + replacing the profile target. A parallel HTTP command is detected by the + target check and fails the profile instead of allowing false success. """ - if self._module is not None: - await self._module.cycle_temperatures(steps=steps, repetitions=repetitions, volume=volume) - return - - for _ in range(repetitions): - for step in steps: - await self.set_plate_temperature( - temperature=float(step["temperature"]), - hold_time=step.get("hold_time_seconds"), - volume=volume, - ramp_rate=step.get("ramp_rate"), - ) - await self.wait_for_plate_temperature() + self._validate_profile(steps, repetitions, volume) + async with self._block_control_lock: + self._profile_completed_steps = 0 + self._profile_total_steps = len(steps) * repetitions + self._profile_repetition = 0 + self._profile_step = 0 + self._profile_active = True + try: + for repetition in range(1, repetitions + 1): + self._profile_repetition = repetition + for step_index, step in enumerate(steps, start=1): + self._profile_step = step_index + target = float(step["temperature"]) + await self._set_plate_temperature( + temperature=target, + hold_time=step.get("hold_time_seconds"), + volume=volume, + ramp_rate=step.get("ramp_rate"), + ) + await self._wait_for_plate_temperature(target) + self._profile_completed_steps += 1 + finally: + self._profile_active = False async def get_lid_temperature(self) -> Temperature: """Get lid temperature.""" @@ -155,28 +311,38 @@ async def get_lid_temperature(self) -> Temperature: async def get_plate_temperature(self) -> Temperature: """Get plate (block) temperature.""" + return (await self._read_plate_temperature())[0] + + async def _read_plate_temperature(self) -> tuple[Temperature, float | None]: + """Read block temperature and the firmware's remaining hold time.""" if self._module is not None: - return Temperature(current=self._module.temperature, target=self._module.target) + return ( + Temperature(current=self._module.temperature, target=self._module.target), + self._module.hold_time, + ) t = await self._driver.get_plate_temperature() - return Temperature(current=t.current, target=t.target) + return Temperature(current=t.current, target=t.target), t.hold async def deactivate_lid(self) -> None: """Turn off lid heater.""" - if self._module is not None: - await self._module.deactivate_lid() - else: - await self._driver.deactivate_lid() + async with self._lid_control_lock, self._operation_lock(): + if self._module is not None: + await self._module.deactivate_lid() + else: + await self._driver.deactivate_lid() async def deactivate_block(self) -> None: """Turn off block heater/cooler.""" - if self._module is not None: - await self._module.deactivate_block() - else: - await self._driver.deactivate_block() + async with self._block_control_lock, self._operation_lock(): + if self._module is not None: + await self._module.deactivate_block() + else: + await self._driver.deactivate_block() async def deactivate_all(self) -> None: """Turn off all heating/cooling.""" - if self._module is not None: - await self._module.deactivate() - else: - await self._driver.deactivate_all() + async with self._lid_control_lock, self._block_control_lock, self._operation_lock(): + if self._module is not None: + await self._module.deactivate() + else: + await self._driver.deactivate_all() diff --git a/tests/features/test_module_feature_endpoints.py b/tests/features/test_module_feature_endpoints.py index 4d9470b..d699cff 100644 --- a/tests/features/test_module_feature_endpoints.py +++ b/tests/features/test_module_feature_endpoints.py @@ -99,6 +99,10 @@ async def get_device_info(self) -> DeviceInfo: self.calls.append(("get_device_info",)) return DeviceInfo(serial_number="SN123", model="module", firmware_version="1.2.3") + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo(serial_number="SN123", model="module", firmware_version="1.2.3") + class _TemperatureController(_BaseController): async def set_temperature(self, temperature: float) -> None: @@ -203,6 +207,10 @@ async def get_plate_temperature(self) -> Temperature: async def execute_profile(self, steps: list[dict], repetitions: int, volume: float | None) -> None: self.calls.append(("execute_profile", steps, repetitions, volume)) + @property + def profile_progress(self) -> tuple[int, int, int, int, bool]: + return (0, 2, 1, 1, True) + async def deactivate_lid(self) -> None: self.calls.append(("deactivate_lid",)) @@ -401,6 +409,8 @@ async def test_heater_shaker_feature_endpoints() -> None: assert isinstance(await feature.get_status(status=status, intermediate=intermediate), HeaterShakerStatus) status, intermediate = _obs() assert (await feature.get_device_info(status=status, intermediate=intermediate)).model == "module" + assert isinstance(await _first(feature.subscribe_status()), HeaterShakerStatus) + assert feature.device_info().serial_number == "SN123" assert ("set_rpm", 1000) in controller.calls assert ("open_latch",) in controller.calls @@ -437,6 +447,41 @@ async def set_rpm(self, rpm: int) -> None: assert status.updates[-1]["progress"] == 1.0 +@pytest.mark.asyncio +async def test_heater_shaker_wait_reports_measured_running_progress() -> None: + """Long thermal waits publish measured current/target values before completion.""" + + class _ProgressHeaterShaker(_HeaterShakerController): + def __init__(self) -> None: + super().__init__(calls=[]) + self.current = 25.0 + self.release = asyncio.Event() + + async def get_temperature(self) -> Temperature: + return Temperature(current=self.current, target=42.0) + + async def wait_for_temperature(self, temperature: float) -> None: + await self.release.wait() + + controller = _ProgressHeaterShaker() + feature = HeaterShakerFeature(controller) + status, intermediate = _obs() + task = asyncio.create_task(feature.wait_for_temperature(42.0, status=status, intermediate=intermediate)) + + await asyncio.sleep(0) + controller.current = 30.0 + await asyncio.sleep(0.3) + controller.current = 42.0 + controller.release.set() + await task + + running = [message for message in intermediate.messages if message.phase is OperationPhase.RUNNING] + assert running + assert "30.0 °C" in running[0].message + assert "42.0 °C" in running[0].message + assert any(0.0 < update["progress"] < 1.0 for update in status.updates) + + @pytest.mark.asyncio async def test_thermocycler_feature_endpoints() -> None: controller = _ThermocyclerController(calls=[]) @@ -493,6 +538,8 @@ async def test_thermocycler_feature_endpoints() -> None: assert isinstance(await feature.get_status(status=status, intermediate=intermediate), ThermocyclerStatus) status, intermediate = _obs() assert (await feature.get_device_info(status=status, intermediate=intermediate)).firmware_version == "1.2.3" + assert isinstance(await _first(feature.subscribe_status()), ThermocyclerStatus) + assert feature.device_info().serial_number == "SN123" assert ("set_plate_temperature", 60.0, None, None, None) in controller.calls assert ("execute_profile", [{"temperature": 55.0, "hold_time_seconds": 10.0, "ramp_rate": None}], 2, 25.0) in ( @@ -500,6 +547,41 @@ async def test_thermocycler_feature_endpoints() -> None: ) +@pytest.mark.asyncio +async def test_thermocycler_profile_cancellation_deactivates_thermal_control() -> None: + """A cancelled profile leaves neither block nor lid energy enabled.""" + + class _BlockingThermocycler(_ThermocyclerController): + def __init__(self) -> None: + super().__init__(calls=[]) + self.started = asyncio.Event() + + async def execute_profile(self, steps: list[dict], repetitions: int, volume: float | None) -> None: + self.started.set() + await asyncio.Event().wait() + + controller = _BlockingThermocycler() + feature = ThermocyclerFeature(controller) + status, intermediate = _obs() + task = asyncio.create_task( + feature.execute_profile( + [ThermocyclerProfileStep(temperature=55.0, hold_time=10.0, ramp_rate=0.0)], + repetitions=2, + volume=25.0, + status=status, + intermediate=intermediate, + ) + ) + await controller.started.wait() + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert ("deactivate_all",) in controller.calls + assert intermediate.messages[-1].phase is OperationPhase.CANCELLED + + @pytest.mark.asyncio async def test_absorbance_reader_feature_endpoints() -> None: controller = _AbsorbanceController(calls=[]) diff --git a/tests/features/test_module_feature_registration.py b/tests/features/test_module_feature_registration.py index b1107cd..71a7d77 100644 --- a/tests/features/test_module_feature_registration.py +++ b/tests/features/test_module_feature_registration.py @@ -102,7 +102,7 @@ def test_heater_shaker_definition_has_units_constraints_and_controller_name() -> feature.attach() fdl = Serializer.serialize(feature.serialize) - assert 'FeatureVersion="3.0"' in fdl + assert 'FeatureVersion="3.1"' in fdl assert "HeaterShakerController" in fdl assert "Heater Shaker Controller" in fdl assert "SetSpeed" in fdl @@ -112,6 +112,11 @@ def test_heater_shaker_definition_has_units_constraints_and_controller_name() -> assert "TemperatureCelsius" not in fdl assert "200" in fdl assert "3000" in fdl + assert "37" in fdl + assert "Status" in fdl + assert _property_is_observable(fdl, "Status") + assert "DeviceInfo" in fdl + assert "InvalidHeaterShakerTemperatureError" in fdl assert "" in fdl assert "Second" in fdl assert "-1" in fdl @@ -129,13 +134,19 @@ def test_thermocycler_definition_uses_controller_naming() -> None: assert "ThermocyclerController" in fdl assert "Thermocycler Controller" in fdl - assert 'FeatureVersion="2.0"' in fdl + assert 'FeatureVersion="2.1"' in fdl assert "TemperatureCelsius" not in fdl assert "HoldTimeSeconds" not in fdl assert "VolumeUl" not in fdl assert "" in fdl assert "" in fdl assert "" in fdl + assert "4.25" in fdl + assert "200" in fdl + assert "1" in fdl + assert "20" in fdl + assert _property_is_observable(fdl, "Status") + assert "InvalidThermocyclerProfileError" in fdl @_REQUIRES_REAL_CDK diff --git a/tests/features/test_sila_conformance.py b/tests/features/test_sila_conformance.py index ed7f19e..c165d82 100644 --- a/tests/features/test_sila_conformance.py +++ b/tests/features/test_sila_conformance.py @@ -4,10 +4,12 @@ import re import typing from importlib.resources import files +from pathlib import Path from xml.etree import ElementTree import pytest import xmlschema +from lxml import etree from sila.framework.fdl import Serializer from unitelabs.opentrons_flex.features import ( @@ -43,6 +45,8 @@ TipController, ) _SCHEMA = xmlschema.XMLSchema(str(files("sila").joinpath("resources", "FeatureDefinition.xsd"))) +_SEMANTIC_VALIDATOR_PATH = Path(__file__).parents[1] / "resources" / "sila-v1.2" / "fdl-validation.xsl" +_SEMANTIC_VALIDATOR = etree.XSLT(etree.parse(str(_SEMANTIC_VALIDATOR_PATH))) _UNIT_SUFFIX = re.compile(r"(?:Celsius|Degrees|Milliseconds|Millimetres|Nanometres|Rpm|Seconds|Ul)$") @@ -94,6 +98,16 @@ def test_public_feature_fdl_obeys_cross_feature_conventions(feature_cls: type) - ) +@pytest.mark.parametrize("feature_cls", _FEATURE_CLASSES) +def test_public_feature_fdl_passes_official_sila_v1_2_semantic_validator(feature_cls: type) -> None: + """Run the official semantic XSLT in addition to schema validation.""" + fdl = _serialize(feature_cls) + + result = _SEMANTIC_VALIDATOR(etree.fromstring(fdl.encode())) + + assert str(result) == "" + + @pytest.mark.parametrize("feature_cls", _FEATURE_CLASSES) def test_public_commands_do_not_use_optional_parameters(feature_cls: type) -> None: """Keep command parameters explicit because the UniteLabs CDK forbids Optional inputs.""" diff --git a/tests/integration/test_grpc_heater_shaker.py b/tests/integration/test_grpc_heater_shaker.py index 524df5a..22277fc 100644 --- a/tests/integration/test_grpc_heater_shaker.py +++ b/tests/integration/test_grpc_heater_shaker.py @@ -85,7 +85,7 @@ async def test_set_speed_rejects_values_outside_operating_range( @pytest.mark.asyncio @pytest.mark.simulator_only -@pytest.mark.parametrize("temperature", [-0.1, 95.1]) +@pytest.mark.parametrize("temperature", [0.0, 36.9, 95.1]) async def test_set_temperature_rejects_values_outside_operating_range( heater_shaker: HeaterShakerClient, temperature: float, diff --git a/tests/io/test_module_from_module.py b/tests/io/test_module_from_module.py index 184bed3..0e04f85 100644 --- a/tests/io/test_module_from_module.py +++ b/tests/io/test_module_from_module.py @@ -8,6 +8,7 @@ """ import asyncio +from types import SimpleNamespace from typing import ClassVar import pytest @@ -22,6 +23,9 @@ FlexStackerLedColor, FlexStackerLedPattern, HeaterShakerController, + InvalidHeaterShakerTemperatureError, + InvalidThermocyclerProfileError, + ModuleOperationError, TemperatureModuleController, ThermocyclerController, ) @@ -231,6 +235,53 @@ async def test_module_action_waits_for_shared_hardware_lock(): assert ("close_labware_latch", (), {}) in mod.calls +@pytest.mark.asyncio +async def test_heater_shaker_wait_releases_shared_lock_and_tracks_target() -> None: + """Autonomous heating must not block unrelated Flex hardware calls.""" + mod = FakeHeaterShaker() + mod.target_temperature = 42.0 + mod.temperature = 25.0 + shared_lock = asyncio.Lock() + ctrl = HeaterShakerController.from_module(mod, lock=shared_lock) + + waiting = asyncio.create_task(ctrl.wait_for_temperature(42.0)) + await asyncio.sleep(0) + + await asyncio.wait_for(shared_lock.acquire(), timeout=0.2) + shared_lock.release() + mod.temperature = 42.0 + await waiting + + +@pytest.mark.asyncio +async def test_heater_shaker_wait_detects_target_changed_by_http_path() -> None: + """A parallel target change cannot make the original SiLA wait report success.""" + mod = FakeHeaterShaker() + mod.target_temperature = 42.0 + mod.temperature = 25.0 + ctrl = HeaterShakerController.from_module(mod) + + waiting = asyncio.create_task(ctrl.wait_for_temperature(42.0)) + await asyncio.sleep(0) + mod.target_temperature = 55.0 + + with pytest.raises(ModuleOperationError, match="target changed while waiting"): + await waiting + + +@pytest.mark.asyncio +@pytest.mark.parametrize("temperature", [0.0, 36.9, 95.1, float("nan"), float("inf"), float("-inf")]) +async def test_heater_shaker_rejects_unreachable_or_non_finite_targets(temperature: float) -> None: + """Invalid targets are rejected before the module receives a command.""" + mod = FakeHeaterShaker() + ctrl = HeaterShakerController.from_module(mod) + + with pytest.raises(InvalidHeaterShakerTemperatureError, match="37 to 95"): + await ctrl.set_temperature(temperature) + + assert not [call for call in mod.calls if call[0] == "start_set_temperature"] + + # ── Thermocycler ────────────────────────────────────────────────────────────── @@ -244,6 +295,7 @@ class FakeThermocycler(_Recorder): lid_temp = 100.0 lid_target = 105.0 lid_status = _LidStatus() + hold_time = None device_info: ClassVar[dict] = {"serial": "TC1", "model": "tc_v2", "version": "3.0"} async def open(self): @@ -303,6 +355,143 @@ async def test_thermocycler_from_module_maps_calls(): await ctrl.disconnect() # no-op +@pytest.mark.asyncio +async def test_thermocycler_wait_releases_shared_lock_and_detects_target_change() -> None: + """Block waits release the robot lock and fail if another interface changes the target.""" + mod = FakeThermocycler() + mod.temperature = 25.0 + mod.target = 60.0 + shared_lock = asyncio.Lock() + ctrl = ThermocyclerController.from_module(mod, lock=shared_lock) + + waiting = asyncio.create_task(ctrl.wait_for_plate_temperature()) + await asyncio.sleep(0) + await asyncio.wait_for(shared_lock.acquire(), timeout=0.2) + shared_lock.release() + + mod.target = 55.0 + with pytest.raises(ModuleOperationError, match="block target changed while waiting"): + await waiting + + +@pytest.mark.asyncio +async def test_thermocycler_profile_releases_shared_lock_between_device_calls() -> None: + """A profile wait must permit concurrent robot-server gantry operations.""" + + class _ProfileThermocycler(FakeThermocycler): + def __init__(self) -> None: + super().__init__() + self.target_started = asyncio.Event() + + async def set_target_block_temperature( + self, + celsius, + hold_time_seconds=None, + volume=None, + ramp_rate=None, + ): + self.record("set_target_block_temperature", celsius, hold_time_seconds, volume, ramp_rate) + self.target = celsius + self.target_started.set() + + mod = _ProfileThermocycler() + mod.temperature = 25.0 + shared_lock = asyncio.Lock() + ctrl = ThermocyclerController.from_module(mod, lock=shared_lock) + profile = asyncio.create_task( + ctrl.execute_profile( + [{"temperature": 60.0, "hold_time_seconds": None, "ramp_rate": 1.0}], + repetitions=1, + volume=25.0, + ) + ) + await mod.target_started.wait() + + await asyncio.wait_for(shared_lock.acquire(), timeout=0.2) + shared_lock.release() + mod.temperature = 60.0 + await profile + + assert ctrl.profile_progress == (1, 1, 1, 1, False) + + +@pytest.mark.asyncio +async def test_thermocycler_driver_waits_for_firmware_hold_time(monkeypatch: pytest.MonkeyPatch) -> None: + """The standalone serial backend must not advance while firmware hold time remains.""" + + class _Driver: + def __init__(self) -> None: + self.hold = 5.0 + self.read_count = 0 + + async def get_plate_temperature(self) -> SimpleNamespace: + self.read_count += 1 + return SimpleNamespace(current=60.0, target=60.0, hold=self.hold) + + driver = _Driver() + ctrl = ThermocyclerController(driver=driver) + original_sleep = asyncio.sleep + + async def finish_hold(_: float) -> None: + driver.hold = 0.0 + await original_sleep(0) + + monkeypatch.setattr(asyncio, "sleep", finish_hold) + + await ctrl.wait_for_plate_temperature() + + assert driver.read_count == 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ramp_rate", [2.01, 4.25]) +async def test_thermocycler_rejects_ramp_rate_above_cooling_limit(ramp_rate: float) -> None: + """Cooling uses the lower physical ramp-rate ceiling.""" + mod = FakeThermocycler() + mod.temperature = 70.0 + ctrl = ThermocyclerController.from_module(mod) + + with pytest.raises(InvalidThermocyclerProfileError, match=r"cooling.*0.01 and 2.0"): + await ctrl.set_plate_temperature(60.0, ramp_rate=ramp_rate) + + assert not [call for call in mod.calls if call[0] == "set_target_block_temperature"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"temperature": float("nan")}, "Block temperature"), + ({"temperature": 60.0, "hold_time": float("inf")}, "Hold time"), + ({"temperature": 60.0, "volume": 0.5}, "Block maximum volume"), + ({"temperature": 60.0, "ramp_rate": 0.001}, "Ramp rate"), + ], +) +async def test_thermocycler_rejects_invalid_values_before_actuation(kwargs: dict, message: str) -> None: + """Non-finite and out-of-range values never reach the Thermocycler.""" + mod = FakeThermocycler() + ctrl = ThermocyclerController.from_module(mod) + + with pytest.raises(InvalidThermocyclerProfileError, match=message): + await ctrl.set_plate_temperature(**kwargs) + + assert not [call for call in mod.calls if call[0] == "set_target_block_temperature"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("step_count", [0, 21]) +async def test_thermocycler_rejects_invalid_profile_size_before_actuation(step_count: int) -> None: + """Profile list limits are enforced at both the FDL and IO boundaries.""" + mod = FakeThermocycler() + ctrl = ThermocyclerController.from_module(mod) + steps = [{"temperature": 60.0, "hold_time_seconds": 1.0, "ramp_rate": 1.0}] * step_count + + with pytest.raises(InvalidThermocyclerProfileError, match="between 1 and 20 steps"): + await ctrl.execute_profile(steps, repetitions=1, volume=25.0) + + assert not [call for call in mod.calls if call[0] == "set_target_block_temperature"] + + # ── Flex Stacker ────────────────────────────────────────────────────────────── diff --git a/tests/resources/sila-v1.2/README.md b/tests/resources/sila-v1.2/README.md new file mode 100644 index 0000000..83b4cc9 --- /dev/null +++ b/tests/resources/sila-v1.2/README.md @@ -0,0 +1,10 @@ +# SiLA 2 v1.2 validation resource + +`fdl-validation.xsl` is vendored unchanged from the official SiLA Base `v1.2` +tag so CI validates generated Feature Definitions without depending on network +availability: + + + +Downloaded source SHA-256: +`28e1c0e682637e8cab8b7f0f5bb0237a38a3e5dfd7c3bb3f12ca8d0f1deb00e2`. diff --git a/tests/resources/sila-v1.2/fdl-validation.xsl b/tests/resources/sila-v1.2/fdl-validation.xsl new file mode 100644 index 0000000..d7fbdfb --- /dev/null +++ b/tests/resources/sila-v1.2/fdl-validation.xsl @@ -0,0 +1,503 @@ + + + + + + + + + + + + + + + + + + + + + + + + Nested lists are not allowed + + + + + + + + DefinedExecutionError '' is not defined + + + + + Data type identifier '' is not defined + + + + + + + + + Constrained structures are not allowed + + + Constrained constrained types are not allowed + + + Constrained data type identifiers are not allowed + + + Constrained booleans are not allowed + + + + + + + + + + + + Invalid constraint on type String: '' + + + + + + + Invalid constraint on type Integer: '' + + + + + + + Invalid constraint on type Real: '' + + + + + + + Invalid constraint on type Binary: '' + + + + + + + Invalid constraint on type Date: '' + + + + + + + Invalid constraint on type Time: '' + + + + + + + Invalid constraint on type Timestamp: '' + + + + + + + Invalid constraint on type Any: '' + + + + + + + Invalid constraint on type List: '' + + + + + + + + + + + + Execution error '' is defined multiple times + + + + + + Data type '' is defined multiple times + + + + + + Command '' is defined multiple times + + + + + + Property '' is defined multiple times + + + + + + Metadata '' is defined multiple times + + + + + + + + Command '': parameter '' is defined multiple times + + + + + + Command '': intermediate response '' is defined multiple times + + + + + + Command '': response '' is defined multiple times + + + + + + Command '': execution error '' is defined multiple times + + + + + + + + + + + + + + Cyclic reference in data type definitions '' and '' + + + + + + + + + Invalid SiLA2 version: '' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Invalid Timestamp: 11th character must be 'T' + + + + + + + + + + + + + + + + + + + + + + + + + + + DataTypeIdentifier is not allowed in AllowedTypes + + + + + + + + Not an integer value: '' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Not a decimal value: '' + + + + Not a decimal value: '' + + + + + + + + + + Invalid Timezone format: Must be 'Z' or '+HH:MM' or '-HH:MM', was '' + + + + Invalid Timezone format: Must be 'Z' or '+HH:MM' or '-HH:MM', was '' + + + + Invalid Timezone format: Must be 'Z' or '+HH:MM' or '-HH:MM', was '' + + + + Invalid Timezone format: Must be 'Z' or '+HH:MM' or '-HH:MM', was '' + + + + + Invalid Timezone format: Minute must be in range 00-59, was '' + + + Invalid Timezone format: Must be in range -14:00 to +14:00, was '' + + + + + + + + Invalid Date format: Must be 'YYYY-MM-DD', was '' + + + Invalid Date format: Must be 'YYYY-MM-DD', was '' + + + Invalid Date format: Must be 'YYYY-MM-DD', was '' + + + + + + Invalid Date format: Must be 'YYYY-MM-DD' (month or day out of range), was '' + + + Invalid Date format: Must be 'YYYY-MM-DD' (day out of range), was '' + + + + Invalid Date format: Must be 'YYYY-MM-DD' (day out of range), was '' + + + Invalid Date format: Must be 'YYYY-MM-DD' (day out of range), was '' + + + Invalid Date format: Must be 'YYYY-MM-DD' (day out of range), was '' + + + + + + + + + Invalid Time format: Must be 'HH:MM:SS' or 'HH:MM:SS.sss', was '' + + + + Invalid Time format: Must be 'HH:MM:SS' or 'HH:MM:SS.sss', was '' + + + + Invalid Time format: Must be 'HH:MM:SS' or 'HH:MM:SS.sss', was '' + + + + + + + Invalid Time format: Must be 'HH:MM:SS' or 'HH:MM:SS.sss', was '' (number out of range) + + + + + + Invalid Time format: Must be 'HH:MM:SS' or 'HH:MM:SS.sss', was '' + + + + Invalid Time format: Must be 'HH:MM:SS' or 'HH:MM:SS.sss', was '' + + + + + + + Unobservable commands must not have intermediate responses + + + diff --git a/tests/test_create_app.py b/tests/test_create_app.py index bab7b26..8af364c 100644 --- a/tests/test_create_app.py +++ b/tests/test_create_app.py @@ -8,12 +8,13 @@ is patched to capture registrations without starting a gRPC server. """ +import asyncio import contextlib from unittest.mock import AsyncMock, MagicMock, patch import pytest -from unitelabs.opentrons_flex import OpentronsFlexConfig, create_app +from unitelabs.opentrons_flex import OpentronsFlexConfig, _register_modules, create_app from unitelabs.opentrons_flex.features import ( AbsorbanceReaderFeature, CalibrationFeature, @@ -183,6 +184,24 @@ def __init__(self, module_type): assert ThermocyclerFeature in types +def test_duplicate_same_type_modules_fail_before_sila_module_registration() -> None: + """Two modules with one FQI must fail closed instead of last-write-wins replacement.""" + from opentrons.hardware_control.modules.types import ModuleType + + class _Module: + MODULE_TYPE = ModuleType.HEATER_SHAKER + + def __init__(self, serial: str) -> None: + self.device_info = {"serial": serial} + + connector = MagicMock() + + with pytest.raises(RuntimeError, match=r"HEATER_SHAKER: HS-1, HS-2.*serial-number routing"): + _register_modules(connector, [_Module("HS-1"), _Module("HS-2")], asyncio.Lock()) + + connector.register.assert_not_called() + + @pytest.mark.asyncio async def test_shared_api_cleaned_up_on_shutdown(): """create_app must clean up the shared OT3API when the generator is closed.""" diff --git a/uv.lock b/uv.lock index 7115e41..e487f79 100644 --- a/uv.lock +++ b/uv.lock @@ -680,6 +680,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" }, + { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" }, + { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1519,6 +1543,7 @@ dev = [ ] test = [ { name = "httpx" }, + { name = "lxml" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -1532,6 +1557,7 @@ requires-dist = [ { name = "fastapi", specifier = "==0.100.0" }, { name = "httpx", marker = "extra == 'test'" }, { name = "idna", specifier = "==3.3" }, + { name = "lxml", marker = "extra == 'test'", specifier = ">=5,<7" }, { name = "numpy", specifier = ">=1.26.4,<2" }, { name = "opentrons", specifier = "==9.0.0" }, { name = "paho-mqtt", specifier = "==1.6.1" }, From 3d13b8c6743ac43a17bc78bec99ad1d032f02a7c Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Mon, 27 Jul 2026 14:25:26 -0400 Subject: [PATCH 05/14] fix: align accessory contracts with Opentrons API --- CHANGELOG.md | 3 ++ README.md | 3 +- .../opentrons_flex/features/heater_shaker.py | 13 +++---- src/unitelabs/opentrons_flex/io/_errors.py | 6 ++-- .../opentrons_flex/io/heater_shaker.py | 6 ++-- .../test_module_feature_registration.py | 4 +-- tests/integration/heater_shaker_client.py | 22 ++++++++++++ tests/integration/test_grpc_heater_shaker.py | 34 ++++++++++++++++++- tests/integration/test_grpc_thermocycler.py | 24 ++++++++++++- tests/integration/thermocycler_client.py | 22 ++++++++++++ tests/io/test_module_from_module.py | 6 ++-- tests/io/test_ot3_api_contract.py | 31 +++++++++++++++++ 12 files changed, 154 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb812bc..4cbf548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Aligned `HeaterShakerController` v3.2 with the pinned Opentrons 9.0 API: + finite 0–95 °C targets are accepted while the documentation warns that + below-ambient targets may be physically unreachable. - Hardened accessory correctness and SiLA conformance: duplicate same-type modules now fail connector startup with their serial numbers instead of silently replacing a Feature; Heater-Shaker and Thermocycler autonomous waits diff --git a/README.md b/README.md index 9813224..815948d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ operations with intermediate execution updates and defined module errors: `GetLatchStatus`, `GetStatus`, and `GetDeviceInfo`. The read commands remain for compatibility; new clients should subscribe to `Status` and read the static `DeviceInfo` property. Temperature inputs carry a -degrees Celsius unit constraint and the public 37–95 °C operating range; +degrees Celsius unit constraint and the Opentrons 9.0 API's 0–95 °C range; +targets below ambient may be physically unreachable because the module does not cool. active shaking carries a revolutions-per-minute unit constraint and a 200–3000 rpm range. The physical unit stays in the FDL constraint rather than the endpoint identifier. Use `StopShaking` instead of sending an implicit zero-speed sentinel. diff --git a/src/unitelabs/opentrons_flex/features/heater_shaker.py b/src/unitelabs/opentrons_flex/features/heater_shaker.py index 56d7163..ce69241 100644 --- a/src/unitelabs/opentrons_flex/features/heater_shaker.py +++ b/src/unitelabs/opentrons_flex/features/heater_shaker.py @@ -19,9 +19,9 @@ ) from ._progress import OperationProgress, run_observable, run_observable_with_updates -# The constraints mirror the public Opentrons Heater-Shaker contract. A target -# below the documented operating range may not be reachable even if the lower -# hardware layer accepts it. +# The constraints mirror the Opentrons 9.0 Heater-Shaker contract. API 2.25+ +# accepts 0-95 degrees Celsius, although targets below ambient may be +# physically unreachable. # Active shaking is 200-3000 revolutions per minute; stopping is a separate # command so 0 is not overloaded as a hidden control value. _CELSIUS = constraints.Unit( @@ -36,7 +36,7 @@ ) _TempCelsius = typing.Annotated[ float, - constraints.MinimalInclusive(37.0), + constraints.MinimalInclusive(0.0), constraints.MaximalInclusive(95.0), _CELSIUS, ] @@ -168,7 +168,7 @@ def __init__(self, controller: HeaterShakerController): category="modules", identifier="HeaterShakerController", name="Heater Shaker Controller", - version="3.1", + version="3.2", ) self._controller = controller @@ -184,7 +184,8 @@ async def set_temperature( Set the target temperature. Args: - temperature: Target temperature in Celsius (valid range 37-95 C). + temperature: Target temperature in Celsius (valid range 0-95 C). + Targets below ambient may be physically unreachable. Returns: Current and target temperature. diff --git a/src/unitelabs/opentrons_flex/io/_errors.py b/src/unitelabs/opentrons_flex/io/_errors.py index c5e40f0..14d6c26 100644 --- a/src/unitelabs/opentrons_flex/io/_errors.py +++ b/src/unitelabs/opentrons_flex/io/_errors.py @@ -85,9 +85,9 @@ class InvalidHeaterShakerTemperatureError(Exception): """ The requested Heater-Shaker target is outside its supported operating range. - Provide a finite value between 37 and 95 degrees Celsius and retry. Lower - targets are not exposed because they may be physically unreachable and can - otherwise leave a waiting workflow blocked indefinitely. + Provide a finite value between 0 and 95 degrees Celsius and retry. Targets + below the surrounding ambient temperature are accepted by the module API + but may be physically unreachable because the device does not actively cool. """ diff --git a/src/unitelabs/opentrons_flex/io/heater_shaker.py b/src/unitelabs/opentrons_flex/io/heater_shaker.py index 8b77d6c..46bc35e 100644 --- a/src/unitelabs/opentrons_flex/io/heater_shaker.py +++ b/src/unitelabs/opentrons_flex/io/heater_shaker.py @@ -146,13 +146,13 @@ async def get_latch_status(self) -> HeaterShakerLabwareLatchStatus: @staticmethod def _validate_temperature(temperature: float) -> None: - """Reject values that are not reliably reachable by the public workflow.""" + """Reject values outside the pinned Opentrons Heater-Shaker contract.""" try: - is_valid = math.isfinite(temperature) and 37.0 <= temperature <= 95.0 + is_valid = math.isfinite(temperature) and 0.0 <= temperature <= 95.0 except (TypeError, ValueError): is_valid = False if not is_valid: message = ( - f"Heater-Shaker target temperature must be a finite value from 37 to 95 °C; received {temperature!r}." + f"Heater-Shaker target temperature must be a finite value from 0 to 95 °C; received {temperature!r}." ) raise InvalidHeaterShakerTemperatureError(message) diff --git a/tests/features/test_module_feature_registration.py b/tests/features/test_module_feature_registration.py index 71a7d77..cebf10c 100644 --- a/tests/features/test_module_feature_registration.py +++ b/tests/features/test_module_feature_registration.py @@ -102,7 +102,7 @@ def test_heater_shaker_definition_has_units_constraints_and_controller_name() -> feature.attach() fdl = Serializer.serialize(feature.serialize) - assert 'FeatureVersion="3.1"' in fdl + assert 'FeatureVersion="3.2"' in fdl assert "HeaterShakerController" in fdl assert "Heater Shaker Controller" in fdl assert "SetSpeed" in fdl @@ -112,7 +112,7 @@ def test_heater_shaker_definition_has_units_constraints_and_controller_name() -> assert "TemperatureCelsius" not in fdl assert "200" in fdl assert "3000" in fdl - assert "37" in fdl + assert "0" in fdl assert "Status" in fdl assert _property_is_observable(fdl, "Status") assert "DeviceInfo" in fdl diff --git a/tests/integration/heater_shaker_client.py b/tests/integration/heater_shaker_client.py index f14948d..a2f7a50 100644 --- a/tests/integration/heater_shaker_client.py +++ b/tests/integration/heater_shaker_client.py @@ -35,6 +35,20 @@ async def _observable(self, method: str, parameters: dict | None = None) -> obje ) return next(iter(decoded.values())) + async def _subscription(self, method: str) -> object: + stub = self._channel.unary_stream(f"/{_SERVICE}/{method}") + call = stub(b"") + response = await call.read() + call.cancel() + decoded = await self._protobuf.decode(f"{_PACKAGE}.{method}_Responses", response) + return next(iter(decoded.values())) + + async def _property(self, method: str) -> object: + stub = self._channel.unary_unary(f"/{_SERVICE}/{method}") + response = await stub(b"") + decoded = await self._protobuf.decode(f"{_PACKAGE}.{method}_Responses", response) + return next(iter(decoded.values())) + async def close_latch(self) -> LatchStatus: """Close the labware latch.""" return await self._observable("CloseLatch") @@ -82,3 +96,11 @@ async def get_latch_status(self) -> LatchStatus: async def get_device_info(self) -> DeviceInfo: """Read module identity information.""" return await self._observable("GetDeviceInfo") + + async def get_status_property(self) -> HeaterShakerStatus: + """Read the first Status property update.""" + return await self._subscription("Subscribe_Status") + + async def get_device_info_property(self) -> DeviceInfo: + """Read the static DeviceInfo property.""" + return await self._property("Get_DeviceInfo") diff --git a/tests/integration/test_grpc_heater_shaker.py b/tests/integration/test_grpc_heater_shaker.py index 22277fc..5604b70 100644 --- a/tests/integration/test_grpc_heater_shaker.py +++ b/tests/integration/test_grpc_heater_shaker.py @@ -1,5 +1,8 @@ """End-to-end SiLA gRPC workflow tests for the simulated Flex Heater-Shaker.""" +import base64 + +import grpc import pytest import pytest_asyncio from sila.framework.protobuf import ConversionError @@ -29,6 +32,8 @@ async def test_heater_shaker_workflow_round_trip(heater_shaker: HeaterShakerClie # The Opentrons SimulatingDriver currently reports its own non-empty # ``dummyModelHS`` identifier rather than echoing the configured model. assert info.model + property_info = await heater_shaker.get_device_info_property() + assert property_info == info assert await heater_shaker.close_latch() is LatchStatus.IDLE_CLOSED assert await heater_shaker.get_latch_status() is LatchStatus.IDLE_CLOSED @@ -58,6 +63,8 @@ async def test_heater_shaker_workflow_round_trip(heater_shaker: HeaterShakerClie assert status.target_speed == 500 assert status.speed_target_active is True assert status.latch_status is LatchStatus.IDLE_CLOSED + property_status = await heater_shaker.get_status_property() + assert property_status == status stopped = await heater_shaker.stop_shaking() assert stopped.current == 0 @@ -85,7 +92,7 @@ async def test_set_speed_rejects_values_outside_operating_range( @pytest.mark.asyncio @pytest.mark.simulator_only -@pytest.mark.parametrize("temperature", [0.0, 36.9, 95.1]) +@pytest.mark.parametrize("temperature", [-0.1, 95.1]) async def test_set_temperature_rejects_values_outside_operating_range( heater_shaker: HeaterShakerClient, temperature: float, @@ -93,3 +100,28 @@ async def test_set_temperature_rejects_values_outside_operating_range( """The FDL constraint rejects invalid temperature targets before execution.""" with pytest.raises(ConversionError): await heater_shaker.set_temperature(temperature) + + +@pytest.mark.asyncio +@pytest.mark.simulator_only +async def test_set_temperature_accepts_current_opentrons_low_target( + heater_shaker: HeaterShakerClient, +) -> None: + """Opentrons API 2.25+ accepts finite targets below the former 37 °C limit.""" + reading = await heater_shaker.set_temperature(20.0) + + assert reading.target == pytest.approx(20.0) + assert reading.target_active is True + await heater_shaker.deactivate_heater() + + +@pytest.mark.asyncio +@pytest.mark.simulator_only +async def test_set_temperature_rejects_nan_as_defined_error( + heater_shaker: HeaterShakerClient, +) -> None: + """NaN reaches runtime validation and surfaces as a Defined Execution Error.""" + with pytest.raises(grpc.aio.AioRpcError) as excinfo: + await heater_shaker.set_temperature(float("nan")) + assert excinfo.value.code() is grpc.StatusCode.ABORTED + assert b"InvalidHeaterShakerTemperatureError" in base64.b64decode(excinfo.value.details() or "") diff --git a/tests/integration/test_grpc_thermocycler.py b/tests/integration/test_grpc_thermocycler.py index 96322e6..cea326c 100644 --- a/tests/integration/test_grpc_thermocycler.py +++ b/tests/integration/test_grpc_thermocycler.py @@ -1,5 +1,8 @@ """End-to-end SiLA gRPC workflow tests for the simulated Flex Thermocycler.""" +import base64 + +import grpc import pytest import pytest_asyncio from sila.framework.protobuf import ConversionError @@ -27,6 +30,8 @@ async def test_thermocycler_workflow_round_trip(thermocycler: ThermocyclerClient info = await thermocycler.get_device_info() assert info.serial_number == "TC-SIM-1" assert info.model + property_info = await thermocycler.get_device_info_property() + assert property_info == info assert await thermocycler.open_lid() is LidStatus.OPEN assert await thermocycler.get_lid_status() is LidStatus.OPEN @@ -62,7 +67,9 @@ async def test_thermocycler_workflow_round_trip(thermocycler: ThermocyclerClient deactivated = await thermocycler.deactivate_all() assert deactivated.lid_target_active is False assert deactivated.plate_target_active is False - assert isinstance(await thermocycler.get_status(), ThermocyclerStatus) + status = await thermocycler.get_status() + assert isinstance(status, ThermocyclerStatus) + assert await thermocycler.get_status_property() == status @pytest.mark.asyncio @@ -87,3 +94,18 @@ async def test_thermocycler_rejects_invalid_lid_temperature( """The FDL blocks invalid lid temperatures before hardware execution.""" with pytest.raises(ConversionError): await thermocycler.set_lid_temperature(temperature) + + +@pytest.mark.asyncio +@pytest.mark.simulator_only +async def test_thermocycler_rejects_excessive_cooling_ramp_as_defined_error( + thermocycler: ThermocyclerClient, +) -> None: + """The direction-dependent cooling limit is enforced through the generated gRPC surface.""" + await thermocycler.set_plate_temperature(30.0, 0.0, 50.0, 0.0) + + with pytest.raises(grpc.aio.AioRpcError) as excinfo: + await thermocycler.set_plate_temperature(20.0, 0.0, 50.0, 4.25) + + assert excinfo.value.code() is grpc.StatusCode.ABORTED + assert b"InvalidThermocyclerProfileError" in base64.b64decode(excinfo.value.details() or "") diff --git a/tests/integration/thermocycler_client.py b/tests/integration/thermocycler_client.py index f773b9e..585c3f4 100644 --- a/tests/integration/thermocycler_client.py +++ b/tests/integration/thermocycler_client.py @@ -35,6 +35,20 @@ async def _observable(self, method: str, parameters: dict | None = None) -> obje ) return next(iter(decoded.values())) + async def _subscription(self, method: str) -> object: + stub = self._channel.unary_stream(f"/{_SERVICE}/{method}") + call = stub(b"") + response = await call.read() + call.cancel() + decoded = await self._protobuf.decode(f"{_PACKAGE}.{method}_Responses", response) + return next(iter(decoded.values())) + + async def _property(self, method: str) -> object: + stub = self._channel.unary_unary(f"/{_SERVICE}/{method}") + response = await stub(b"") + decoded = await self._protobuf.decode(f"{_PACKAGE}.{method}_Responses", response) + return next(iter(decoded.values())) + async def open_lid(self) -> LidStatus: """Open the Thermocycler lid.""" return await self._observable("OpenLid") @@ -116,3 +130,11 @@ async def get_status(self) -> ThermocyclerStatus: async def get_device_info(self) -> DeviceInfo: """Read Thermocycler identity information.""" return await self._observable("GetDeviceInfo") + + async def get_status_property(self) -> ThermocyclerStatus: + """Read the first Status property update.""" + return await self._subscription("Subscribe_Status") + + async def get_device_info_property(self) -> DeviceInfo: + """Read the static DeviceInfo property.""" + return await self._property("Get_DeviceInfo") diff --git a/tests/io/test_module_from_module.py b/tests/io/test_module_from_module.py index 0e04f85..d5d7af1 100644 --- a/tests/io/test_module_from_module.py +++ b/tests/io/test_module_from_module.py @@ -270,13 +270,13 @@ async def test_heater_shaker_wait_detects_target_changed_by_http_path() -> None: @pytest.mark.asyncio -@pytest.mark.parametrize("temperature", [0.0, 36.9, 95.1, float("nan"), float("inf"), float("-inf")]) -async def test_heater_shaker_rejects_unreachable_or_non_finite_targets(temperature: float) -> None: +@pytest.mark.parametrize("temperature", [-0.1, 95.1, float("nan"), float("inf"), float("-inf")]) +async def test_heater_shaker_rejects_out_of_range_or_non_finite_targets(temperature: float) -> None: """Invalid targets are rejected before the module receives a command.""" mod = FakeHeaterShaker() ctrl = HeaterShakerController.from_module(mod) - with pytest.raises(InvalidHeaterShakerTemperatureError, match="37 to 95"): + with pytest.raises(InvalidHeaterShakerTemperatureError, match="0 to 95"): await ctrl.set_temperature(temperature) assert not [call for call in mod.calls if call[0] == "start_set_temperature"] diff --git a/tests/io/test_ot3_api_contract.py b/tests/io/test_ot3_api_contract.py index 62d2252..cb6c0a9 100644 --- a/tests/io/test_ot3_api_contract.py +++ b/tests/io/test_ot3_api_contract.py @@ -7,8 +7,17 @@ import inspect +import pytest + from opentrons.hardware_control import ot3_calibration +from opentrons.hardware_control.modules.heater_shaker import HeaterShaker +from opentrons.hardware_control.modules.thermocycler import Thermocycler from opentrons.hardware_control.ot3api import OT3API +from opentrons.protocol_api.module_validation_and_errors import ( + APIVersion, + InvalidTargetTemperatureError, + validate_heater_shaker_temperature, +) def _params(fn: object) -> list[str]: @@ -41,3 +50,25 @@ def test_ot3_calibration_contract() -> None: assert {"hcapi", "mount", "slot"}.issubset(_params(ot3_calibration.calibrate_pipette)) assert {"hcapi", "probe", "slot"}.issubset(_params(ot3_calibration.calibrate_gripper_jaw)) assert {"hcapi", "mount", "pipette_id"}.issubset(_params(ot3_calibration.calibrate_belts)) + + +def test_accessory_module_method_contracts() -> None: + """Catch upstream changes to the high-level module methods used by the connector.""" + assert {"celsius"}.issubset(_params(HeaterShaker.start_set_temperature)) + assert {"celsius", "hold_time_seconds", "volume", "ramp_rate"}.issubset( + _params(Thermocycler.set_target_block_temperature) + ) + assert {"celsius"}.issubset(_params(Thermocycler.set_target_lid_temperature)) + + +def test_heater_shaker_temperature_contract_matches_opentrons_api_2_25_plus() -> None: + """Guard the API 2.25 removal of the former 37 °C lower limit.""" + api_version = APIVersion(2, 25) + + assert validate_heater_shaker_temperature(0.0, api_version) == 0.0 + assert validate_heater_shaker_temperature(36.9, api_version) == 36.9 + assert validate_heater_shaker_temperature(95.0, api_version) == 95.0 + with pytest.raises(InvalidTargetTemperatureError): + validate_heater_shaker_temperature(-0.1, api_version) + with pytest.raises(InvalidTargetTemperatureError): + validate_heater_shaker_temperature(95.1, api_version) From 15f2df3bc69fa521ca0c97daddc43160d355a781 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Tue, 28 Jul 2026 11:03:24 -0400 Subject: [PATCH 06/14] feat: harden Flex offline acceptance gates --- .github/workflows/test.yml | 35 ++ Dockerfile.build | 1 + README.md | 42 +- docs/asms_flex_workflow_test.md | 2 +- docs/flex_system_acceptance.md | 66 ++- .../flex-acceptance-contract/pyproject.toml | 10 + .../__init__.py | 49 ++ .../acceptance.py | 479 +++++++++++++++++ .../wire_types.py | 95 ++++ pyproject.toml | 4 + scripts/artifact_manifest.py | 1 + scripts/preflight_flex.py | 344 ++++++++++++- src/unitelabs/opentrons_flex/__init__.py | 143 +++--- src/unitelabs/opentrons_flex/acceptance.py | 480 +----------------- src/unitelabs/opentrons_flex/hitl_evidence.py | 204 ++++++++ .../opentrons_flex/protocol_preflight.py | 3 + .../opentrons_flex/robot_readiness.py | 120 +++++ .../opentrons_flex/robot_server_compat.py | 309 +++++++++++ .../opentrons_flex/runtime_compat.py | 117 ++++- .../opentrons_flex/runtime_identity_api.py | 54 ++ .../opentrons_flex/runtime_preflight.py | 74 ++- tests/conftest.py | 13 + tests/integration/conftest.py | 76 +++ .../hardware/test_hitl_full_workflow.py | 59 ++- tests/protocols/test_asms_protocol.py | 1 + tests/scripts/test_preflight_flex.py | 193 +++++++ tests/test_acceptance_contract_parity.py | 34 ++ tests/test_create_app_with_robot_server.py | 9 + tests/test_hitl_evidence.py | 124 +++++ tests/test_private_api_boundary.py | 28 + tests/test_robot_readiness.py | 114 +++++ tests/test_robot_server_compat.py | 189 +++++++ tests/test_runtime_compat.py | 17 +- tests/test_runtime_contract_consistency.py | 67 +++ tests/test_runtime_identity_api.py | 60 +++ tests/test_runtime_preflight.py | 65 +++ tests/test_workflow_manifests.py | 13 + uv.lock | 7 + .../flex-system-acceptance/pyproject.toml | 4 +- .../src/flex_system_acceptance/__main__.py | 20 +- .../src/flex_system_acceptance/_steps.py | 12 +- 41 files changed, 3079 insertions(+), 658 deletions(-) create mode 100644 packages/flex-acceptance-contract/pyproject.toml create mode 100644 packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/__init__.py create mode 100644 packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/acceptance.py create mode 100644 packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/wire_types.py create mode 100644 src/unitelabs/opentrons_flex/hitl_evidence.py create mode 100644 src/unitelabs/opentrons_flex/robot_readiness.py create mode 100644 src/unitelabs/opentrons_flex/robot_server_compat.py create mode 100644 src/unitelabs/opentrons_flex/runtime_identity_api.py create mode 100644 tests/scripts/test_preflight_flex.py create mode 100644 tests/test_acceptance_contract_parity.py create mode 100644 tests/test_hitl_evidence.py create mode 100644 tests/test_private_api_boundary.py create mode 100644 tests/test_robot_readiness.py create mode 100644 tests/test_robot_server_compat.py create mode 100644 tests/test_runtime_contract_consistency.py create mode 100644 tests/test_runtime_identity_api.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24aea5e..c17ea90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,9 @@ jobs: - name: Test (unit + gRPC simulator) run: uv run pytest --cov=src --cov-report=term --cov-report=xml --junit-xml=junit.xml + - name: Collect guarded HITL inventory (no hardware execution) + run: uv run pytest tests/integration/hardware --collect-only -q + - name: Upload coverage uses: actions/upload-artifact@v7 with: @@ -169,3 +172,35 @@ jobs: if total == 0: print(' No open issues.') " + + windows-operator-readiness: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.9.6" + enable-cache: true + + - name: Set up operator Python 3.10 + run: uv python install 3.10 + + - name: Install connector and test dependencies + run: uv sync --all-extras + + - name: Validate Windows readiness and evidence path + run: uv run pytest -p no:cacheprovider tests/scripts/test_preflight_flex.py tests/test_hitl_evidence.py tests/test_acceptance_manifest.py tests/test_runtime_contract_consistency.py -q + + - name: Build a valid offline workflow manifest + run: uv run python -c "import json; from tests.test_acceptance_manifest import _manifest; open('flex-acceptance-ci.json', 'w', encoding='utf-8').write(json.dumps(_manifest()))" + + - name: Set up Windows workflow Python 3.12 + run: | + uv python install 3.12 + uv venv --python 3.12 .venv-workflow + uv pip install --python .venv-workflow\Scripts\python.exe --no-deps -e packages\flex-acceptance-contract -e workflows\flex-system-acceptance + + - name: Execute the native PowerShell workflow entrypoint + run: .\.venv-workflow\Scripts\python.exe -m flex_system_acceptance --validate-only --manifest .\flex-acceptance-ci.json diff --git a/Dockerfile.build b/Dockerfile.build index 0f22249..d2b2d17 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -29,6 +29,7 @@ RUN apt-get -y update && \ WORKDIR /build ADD pyproject.toml uv.lock README.md /build/ ADD src /build/src +ADD packages/flex-acceptance-contract /build/packages/flex-acceptance-contract ADD scripts/artifact_manifest.py /build/scripts/artifact_manifest.py # Download/build wheels for the project and all runtime deps into /wheels. diff --git a/README.md b/README.md index 815948d..852645a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Motion is exposed per **mount** (`LEFT`, `RIGHT`, `GRIPPER`) in deck coordinates ### Verified models and environments Automated verification and the ARM deployment artifact use the real Opentrons -8.8.1 hardware simulators on Python 3.10. Controlled Protocol Engine +9.0.0 hardware simulators on Python 3.10. Controlled Protocol Engine mutation is intentionally unavailable on unvalidated Opentrons/Python matrices. The connector recognizes these official model identifiers: @@ -144,7 +144,15 @@ For commissioning the complete robot plus Heater-Shaker, Thermocycler, Temperatu Module, Absorbance Plate Reader, Flex Stacker, and passive Magnetic Block, use the [manifest-driven system acceptance workflow](docs/flex_system_acceptance.md). It provides both a publishable Unitelabs workflow and a guarded direct SiLA gRPC HITL -runner with JUnit identity evidence. +runner with JUnit identity evidence. The operator-side preflight and evidence +validation are pure Python and use `pathlib`, so the readiness, commissioning, +and routine workflow operator path runs natively on the lab Windows computer; +macOS remains a compatible backup environment. The `deploy.sh` and service +management scripts are POSIX tooling for the Flex's Linux environment: invoke +them from CI, macOS, WSL, or Git Bash rather than native PowerShell. +The connector/runtime remains pinned to Python 3.10, while the local Unitelabs +workflow uses Python 3.12 and depends only on the shared cross-runtime acceptance +contract; `uv run --directory` keeps those environments isolated. ### Module feature v2 migration @@ -255,8 +263,8 @@ Activate the virtual environment: - On **Windows**: - ```sh - .\venv\Scripts\activate.bat + ```powershell + .\.venv\Scripts\Activate.ps1 ``` - On **macOS**/**Linux**: @@ -267,8 +275,8 @@ Activate the virtual environment: If you are on a Windows machine, you may additionally wish to set the `UNITELABS_CDK_APP` environment variable to the connector's entry point: -```sh -set UNITELABS_CDK_APP=unitelabs.opentrons_flex:create_app +```powershell +$env:UNITELABS_CDK_APP = "unitelabs.opentrons_flex:create_app" ``` Setting this environment variable will allow you to run various CLI commands without providing `--app unitelabs.opentrons_flex:create_app` every time. @@ -411,8 +419,10 @@ controlled insertion is enabled only when `run_mutation_ledger_path` is configur and a bearer token of at least 32 random characters named by `run_mutation_token_env` is present at startup. The token is also bound to the operator identity named by `run_mutation_actor_env`; callers cannot self-attribute -another actor in the audit. The current private-state adapter is startup-gated to -its validated Opentrons 8.8.1 runtime. +another actor in the audit. The private-state adapter is startup-gated to the +validated Opentrons 9.0.0 runtime and reports a deterministic runtime contract +identifier. Startup checks the required robot-server callables, mutable app/router +surfaces, and package origins before OT3API or the CAN bus is initialized. Before a run starts, a small set of non-actuating setup commands remains available only when robot-server authoritatively identifies the run as protocol-less. This @@ -650,7 +660,10 @@ Switch between the SiLA connector and the stock opentrons robot-server at any ti ./scripts/switch_mode.sh opentrons ``` -Run all read-only readiness checks before uploading a protocol: +Run all general read-only readiness checks before uploading a protocol. For the +commissioning/HITL command on the Windows operator machine, including its exact +artifact-binding arguments, follow +[`docs/flex_system_acceptance.md`](docs/flex_system_acceptance.md): ```sh uv run python scripts/preflight_flex.py @@ -677,9 +690,14 @@ bindings that Opentrons does not distribute as portable wheels, notably closure plus all five co-versioned Opentrons Python packages from the immutable 9.0.0 source commit. Deployment rejects incomplete releases and writes a completion marker only after no-hardware runtime validation. Startup rejects a -wrong Python/Opentrons version, a missing private symbol, or any of those five -Opentrons modules imported from outside the active release before initializing -OT3API or the CAN bus. +wrong Python/Opentrons version, a missing or incorrectly shaped private +robot-server contract, or any of those five Opentrons modules imported from +outside the active release before initializing OT3API or the CAN bus. The +preflight report includes a deterministic `runtime_contract_id`; physical +acceptance evidence must match that identifier and the selected manifest. The +embedded `/unitelabs/runtime` route reports an identity captured by the running +connector process, so preflight rejects an active symlink or artifact that does +not match the process currently serving HTTP/gRPC. Then start the connector and run the live HTTP integration tests: diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index 4c49a4a..cb835b9 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -4,7 +4,7 @@ | Layer | Status | Evidence or remaining gate | | --- | --- | --- | -| Python syntax and Protocol API 2.27 | Pass | Installed Opentrons 8.8.1 supports API 2.27 | +| Python syntax and Protocol API 2.27 | Pass | Validated Opentrons 9.0.0 runtime supports API 2.27 | | Full command graph | Pass with exact labware | 344 run-log records; 82 aspirates, 66 dispenses, 9 gripper moves, and 634 programmed seconds | | Tip lifecycle | Pass | 26 pickups: 20 discarded full columns and 6 returned/reused pickups | | Temperature cleanup | Pass | Deactivation is in `finally` and appears once in the run log | diff --git a/docs/flex_system_acceptance.md b/docs/flex_system_acceptance.md index 007a5ec..4bc43ab 100644 --- a/docs/flex_system_acceptance.md +++ b/docs/flex_system_acceptance.md @@ -69,46 +69,61 @@ Operator preparation checklist: First run the simulator and static acceptance tests: ```sh -PYTHONPATH=src .venv/bin/pytest -p no:cacheprovider \ - tests/test_acceptance_manifest.py \ - tests/integration/test_grpc_thermocycler.py \ - tests/integration/test_grpc_advanced_flex.py \ - tests/integration/test_grpc_tip_controller.py \ - tests/integration/test_grpc_pipette.py -q +uv run pytest -p no:cacheprovider tests/test_acceptance_manifest.py tests/integration/test_grpc_thermocycler.py tests/integration/test_grpc_advanced_flex.py tests/integration/test_grpc_tip_controller.py tests/integration/test_grpc_pipette.py -q ``` -Then run only the guarded campaign against the robot. The manifest gate and the explicit actuation gate are both -required. Keep the JUnit file with the robot/module identity evidence: +Immediately before actuation, generate a no-motion readiness report from the operator computer: -```sh -PYTHONPATH=src .venv/bin/pytest -p no:cacheprovider \ - tests/integration/hardware/test_hitl_full_workflow.py \ - --robot ROBOT_HOST:50051 \ - --acceptance-manifest /absolute/path/flex_acceptance.json \ - --acceptance-workflow-actuation \ - --junitxml=flex-acceptance.xml -vv +```powershell +uv run python scripts/preflight_flex.py ROBOT_HOST --acceptance-manifest C:\path\to\flex_acceptance.json --runtime-manifest C:\path\to\verified-artifact\runtime-manifest.json --output .\flex-hitl-readiness.json +``` + +This command is native Python and works from PowerShell, Command Prompt, macOS, or Linux. On Windows it uses the +built-in OpenSSH client to run the robot-side, no-hardware runtime check; install the Windows OpenSSH Client feature if +`ssh` is unavailable. The remote shell remains on the Flex and is not executed locally. Use the +`runtime-manifest.json` from the wheel bundle that was verified before deployment. The check compares that artifact's +release ID and bundle digest with both the active Flex venv and the immutable release identity captured by the +currently running connector process. A symlink change without a service restart therefore blocks readiness instead +of approving the stale process. + +Then run only the guarded campaign against the robot. The manifest gate, fresh readiness evidence, and explicit +actuation gate are all required. The pytest fixture reruns the same live preflight in-process and overwrites the +specified report immediately before any actuation; an old or hand-edited JSON file is not an authorization token. +Keep the JUnit file with the robot/module/release identity evidence: + +```powershell +uv run pytest -p no:cacheprovider tests/integration/hardware/test_hitl_full_workflow.py --robot ROBOT_HOST:50051 --acceptance-manifest C:\path\to\flex_acceptance.json --acceptance-runtime-manifest C:\path\to\verified-artifact\runtime-manifest.json --acceptance-readiness-report .\flex-hitl-readiness.json --acceptance-workflow-actuation --junitxml=.\flex-acceptance.xml -vv ``` On failure, the runner de-energizes module actuators and lights, but it never guesses a Gripper recovery route. Inspect the physical deck, reconcile the state ledger, home the robot, and only then restart the campaign. +Evidence states are deliberately non-interchangeable: + +- `OFFLINE_VALIDATED` describes simulator/static coverage only; +- `READY_FOR_HITL` is emitted by the live, no-motion preflight and never claims physical success; +- `HARDWARE_PASSED` appears only in JUnit after every guarded physical phase and every safety-settlement operation + completes. Cleanup failure blocks this state. + ## Run or publish the Unitelabs workflow -The workflow package uses the same connector-side `AcceptanceManifest` contract and calls the independent SiLA +The workflow package uses the same shared `AcceptanceManifest` contract as the connector and calls the independent SiLA features through the Unitelabs SDK. After the direct run passes, provision its JUnit `commissioned_manifest_sha256` in the workflow worker environment, then run the identical manifest: -```sh -FLEX_ACCEPTANCE_MANIFEST_SHA256=THE_PASSED_JUNIT_DIGEST \ -uv run --directory workflows/flex-system-acceptance workflow \ - --manifest /absolute/path/flex_acceptance.json \ - --device "Opentrons Flex" +```powershell +$env:FLEX_ACCEPTANCE_MANIFEST_SHA256 = "THE_PASSED_JUNIT_DIGEST" +uv run --directory workflows/flex-system-acceptance workflow --manifest C:\path\to\flex_acceptance.json --device "Opentrons Flex" ``` +`uv` selects the workflow's Python 3.12 environment. It does not install the +Python 3.10 hardware connector into Windows: both sides share the dependency-free +`unitelabs-flex-acceptance-contract` package instead. + To publish it with the repository's existing workflow tooling: -```sh -cd workflows +```powershell +Set-Location workflows uv run scripts/publish_workflows.py flex-system-acceptance ``` @@ -117,3 +132,8 @@ module serials, pipette identity, manifest fingerprint, phase names, and the fin workflow for routine operator execution after that exact hardware/layout combination has passed commissioning. If any coordinate, labware, module identity, or operating value changes, rerun direct commissioning and update the approved fingerprint; never compute and approve a new fingerprint merely to bypass a failed workflow gate. + +Deployment and robot service switching are a separate maintainer path. The repository's `deploy.sh` and service +scripts target the Flex Linux host and require CI, macOS, WSL, or Git Bash; the lab's native PowerShell path above does +not depend on those scripts. The Mac checkout can therefore remain a backup/test operator without becoming the source +of truth for the Windows `main` deployment. diff --git a/packages/flex-acceptance-contract/pyproject.toml b/packages/flex-acceptance-contract/pyproject.toml new file mode 100644 index 0000000..179fd73 --- /dev/null +++ b/packages/flex-acceptance-contract/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "unitelabs-flex-acceptance-contract" +version = "0.1.0" +description = "Python 3.10-3.12 manifest and wire-value contract for Flex acceptance workflows." +requires-python = ">=3.10,<3.13" +dependencies = [] + +[build-system] +requires = ["uv_build>=0.4,<0.10"] +build-backend = "uv_build" diff --git a/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/__init__.py b/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/__init__.py new file mode 100644 index 0000000..e52ac0c --- /dev/null +++ b/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/__init__.py @@ -0,0 +1,49 @@ +"""Cross-runtime contract shared by the Flex connector and workflow engine.""" + +from importlib.metadata import PackageNotFoundError, version + +from .acceptance import ( + AcceptanceManifest, + Coordinate, + ModuleConfig, + PipettingConfig, + PlanContract, + ThermocyclerStepConfig, + WellGeometryConfig, + validate_plan_contract, +) +from .wire_types import ( + LiquidPosition, + Mount, + PipetteMount, + ThermocyclerProfileStep, + TipLocation, + TransferProfile, + VerifiedLiquidClass, + WellGeometry, +) + +try: + __version__ = version("unitelabs-flex-acceptance-contract") +except PackageNotFoundError: + __version__ = "0.1.0" + +__all__ = [ + "AcceptanceManifest", + "Coordinate", + "LiquidPosition", + "ModuleConfig", + "Mount", + "PipetteMount", + "PipettingConfig", + "PlanContract", + "ThermocyclerProfileStep", + "ThermocyclerStepConfig", + "TipLocation", + "TransferProfile", + "VerifiedLiquidClass", + "WellGeometry", + "WellGeometryConfig", + "__version__", + "validate_plan_contract", +] diff --git a/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/acceptance.py b/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/acceptance.py new file mode 100644 index 0000000..39dbf81 --- /dev/null +++ b/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/acceptance.py @@ -0,0 +1,479 @@ +"""Strict cross-runtime manifest for the Flex system acceptance workflow.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from pathlib import Path + + +def _object(value: object, label: str, fields: set[str]) -> Mapping[str, object]: + if not isinstance(value, Mapping): + msg = f"{label} must be a JSON object." + raise ValueError(msg) + missing = fields - set(value) + unknown = set(value) - fields + if missing: + msg = f"{label} is missing required fields: {', '.join(sorted(missing))}." + raise ValueError(msg) + if unknown: + msg = f"{label} contains unknown fields: {', '.join(sorted(unknown))}." + raise ValueError(msg) + return value + + +def _text(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + msg = f"{label} must be a non-empty string." + raise ValueError(msg) + normalized = value.strip() + if any(marker in normalized.upper() for marker in ("REPLACE", "YOUR_", " float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + msg = f"{label} must be a finite number." + raise ValueError(msg) + number = float(value) + if not minimum <= number <= maximum: + msg = f"{label} must be between {minimum} and {maximum}." + raise ValueError(msg) + return number + + +def _integer(value: object, label: str, minimum: int, maximum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + msg = f"{label} must be an integer." + raise ValueError(msg) + if not minimum <= value <= maximum: + msg = f"{label} must be between {minimum} and {maximum}." + raise ValueError(msg) + return value + + +@dataclass(frozen=True) +class Coordinate: + """Absolute calibrated deck coordinate in millimetres.""" + + x: float + y: float + z: float + + @classmethod + def parse(cls, value: object, label: str) -> Coordinate: + """Parse one strictly shaped coordinate object.""" + data = _object(value, label, {"x", "y", "z"}) + return cls( + x=_number(data["x"], f"{label}.x", -1000.0, 1000.0), + y=_number(data["y"], f"{label}.y", -1000.0, 1000.0), + z=_number(data["z"], f"{label}.z", -1000.0, 1000.0), + ) + + +@dataclass(frozen=True) +class WellGeometryConfig: + """Calibrated well centre and vertical geometry in millimetres.""" + + x: float + y: float + bottom_z: float + top_z: float + length: float + width: float + + @classmethod + def parse(cls, value: object, label: str) -> WellGeometryConfig: + """Parse and validate one well geometry object.""" + fields = {"x", "y", "bottom_z", "top_z", "length", "width"} + data = _object(value, label, fields) + parsed = cls( + x=_number(data["x"], f"{label}.x", -1000.0, 1000.0), + y=_number(data["y"], f"{label}.y", -1000.0, 1000.0), + bottom_z=_number(data["bottom_z"], f"{label}.bottom_z", -1000.0, 1000.0), + top_z=_number(data["top_z"], f"{label}.top_z", -1000.0, 1000.0), + length=_number(data["length"], f"{label}.length", 0.1, 200.0), + width=_number(data["width"], f"{label}.width", 0.1, 200.0), + ) + if parsed.bottom_z >= parsed.top_z: + msg = f"{label}.bottom_z must be below top_z." + raise ValueError(msg) + return parsed + + +@dataclass(frozen=True) +class PipettingConfig: + """Prepared safe-liquid transfer geometry and operating values.""" + + mount: str + tip_length: float + tiprack_diameter: float + tip_pickup: Coordinate + tip_drop: Coordinate + source: Coordinate + source_retract: Coordinate + destination: Coordinate + destination_retract: Coordinate + source_well: WellGeometryConfig + destination_well: WellGeometryConfig + transfer_volume: float + prepared_source_volume: float + probe_maximum_distance: float + mix_volume: float + mix_cycles: int + tiprack_uri: str + + @classmethod + def parse(cls, value: object) -> PipettingConfig: + """Parse the complete prepared liquid-handling configuration.""" + fields = { + "mount", + "tip_length", + "tiprack_diameter", + "tip_pickup", + "tip_drop", + "source", + "source_retract", + "destination", + "destination_retract", + "source_well", + "destination_well", + "transfer_volume", + "prepared_source_volume", + "probe_maximum_distance", + "mix_volume", + "mix_cycles", + "tiprack_uri", + } + data = _object(value, "pipetting", fields) + mount = _text(data["mount"], "pipetting.mount").upper() + if mount not in {"LEFT", "RIGHT"}: + msg = "pipetting.mount must be LEFT or RIGHT." + raise ValueError(msg) + transfer_volume = _number(data["transfer_volume"], "pipetting.transfer_volume", 1.0, 1000.0) + mix_volume = _number(data["mix_volume"], "pipetting.mix_volume", 1.0, 1000.0) + prepared_source_volume = _number( + data["prepared_source_volume"], "pipetting.prepared_source_volume", 1.0, 20000.0 + ) + minimum_required = transfer_volume * 2.0 + mix_volume + if prepared_source_volume < minimum_required: + msg = ( + "pipetting.prepared_source_volume must cover two transfers plus one mix " + f"({minimum_required:g} microlitres minimum)." + ) + raise ValueError(msg) + parsed = cls( + mount=mount, + tip_length=_number(data["tip_length"], "pipetting.tip_length", 1.0, 100.0), + tiprack_diameter=_number(data["tiprack_diameter"], "pipetting.tiprack_diameter", 0.1, 20.0), + tip_pickup=Coordinate.parse(data["tip_pickup"], "pipetting.tip_pickup"), + tip_drop=Coordinate.parse(data["tip_drop"], "pipetting.tip_drop"), + source=Coordinate.parse(data["source"], "pipetting.source"), + source_retract=Coordinate.parse(data["source_retract"], "pipetting.source_retract"), + destination=Coordinate.parse(data["destination"], "pipetting.destination"), + destination_retract=Coordinate.parse(data["destination_retract"], "pipetting.destination_retract"), + source_well=WellGeometryConfig.parse(data["source_well"], "pipetting.source_well"), + destination_well=WellGeometryConfig.parse(data["destination_well"], "pipetting.destination_well"), + transfer_volume=transfer_volume, + prepared_source_volume=prepared_source_volume, + probe_maximum_distance=_number( + data["probe_maximum_distance"], "pipetting.probe_maximum_distance", 1.0, 50.0 + ), + mix_volume=mix_volume, + mix_cycles=_integer(data["mix_cycles"], "pipetting.mix_cycles", 1, 20), + tiprack_uri=_text(data["tiprack_uri"], "pipetting.tiprack_uri"), + ) + for label, position, retract in ( + ("source", parsed.source, parsed.source_retract), + ("destination", parsed.destination, parsed.destination_retract), + ): + if (position.x, position.y) != (retract.x, retract.y) or retract.z <= position.z: + msg = f"pipetting.{label}_retract must be vertically above the matching {label} position." + raise ValueError(msg) + for label, position, well in ( + ("source", parsed.source, parsed.source_well), + ("destination", parsed.destination, parsed.destination_well), + ): + if (position.x, position.y) != (well.x, well.y) or not well.bottom_z <= position.z <= well.top_z: + msg = f"pipetting.{label} must be inside the matching well geometry." + raise ValueError(msg) + if parsed.probe_maximum_distance < parsed.source_retract.z - parsed.source.z: + msg = "pipetting.probe_maximum_distance must reach from source_retract to source." + raise ValueError(msg) + return parsed + + +@dataclass(frozen=True) +class ThermocyclerStepConfig: + """One guarded Thermocycler profile step.""" + + temperature: float + hold_time: float + ramp_rate: float + + @classmethod + def parse(cls, value: object, index: int) -> ThermocyclerStepConfig: + """Parse one constrained thermal profile step.""" + label = f"thermocycler.profile[{index}]" + data = _object(value, label, {"temperature", "hold_time", "ramp_rate"}) + return cls( + temperature=_number(data["temperature"], f"{label}.temperature", 4.0, 99.0), + hold_time=_number(data["hold_time"], f"{label}.hold_time", 0.0, 3600.0), + ramp_rate=_number(data["ramp_rate"], f"{label}.ramp_rate", 0.0, 10.0), + ) + + +@dataclass(frozen=True) +class ModuleConfig: + """Safe operating values and required module serial numbers.""" + + heater_shaker_serial: str + heater_shaker_temperature: float + heater_shaker_speed: int + thermocycler_serial: str + thermocycler_lid_temperature: float + thermocycler_volume: float + thermocycler_profile: tuple[ThermocyclerStepConfig, ...] + temperature_module_serial: str + temperature_module_target: float + plate_reader_serial: str + plate_reader_wavelengths: tuple[int, ...] + plate_reader_reference_wavelength: int + stacker_serial: str + stacker_labware_height: float + + @classmethod + def parse(cls, value: object) -> ModuleConfig: + """Parse accessory identities and guarded operating values.""" + fields = {"heater_shaker", "thermocycler", "temperature_module", "plate_reader", "stacker"} + data = _object(value, "modules", fields) + heater = _object(data["heater_shaker"], "modules.heater_shaker", {"serial", "temperature", "speed"}) + thermocycler = _object( + data["thermocycler"], + "modules.thermocycler", + {"serial", "lid_temperature", "volume", "profile"}, + ) + temperature = _object(data["temperature_module"], "modules.temperature_module", {"serial", "target"}) + reader = _object( + data["plate_reader"], + "modules.plate_reader", + {"serial", "wavelengths", "reference_wavelength"}, + ) + stacker = _object(data["stacker"], "modules.stacker", {"serial", "labware_height"}) + profile = thermocycler["profile"] + wavelengths = reader["wavelengths"] + if not isinstance(profile, list) or not 1 <= len(profile) <= 20: + msg = "modules.thermocycler.profile must contain between 1 and 20 steps." + raise ValueError(msg) + if not isinstance(wavelengths, list) or not 2 <= len(wavelengths) <= 6: + msg = "modules.plate_reader.wavelengths must contain between 2 and 6 wavelengths." + raise ValueError(msg) + parsed_wavelengths = tuple( + _integer(item, f"modules.plate_reader.wavelengths[{index}]", 350, 1000) + for index, item in enumerate(wavelengths) + ) + if len(set(parsed_wavelengths)) != len(parsed_wavelengths): + msg = "modules.plate_reader.wavelengths must contain unique wavelengths." + raise ValueError(msg) + reference = _integer(reader["reference_wavelength"], "modules.plate_reader.reference_wavelength", 350, 1000) + if reference not in parsed_wavelengths: + msg = "modules.plate_reader.reference_wavelength must also appear in wavelengths." + raise ValueError(msg) + if all(wavelength == reference for wavelength in parsed_wavelengths): + msg = "modules.plate_reader.wavelengths must include a sample wavelength distinct from the reference." + raise ValueError(msg) + parsed_profile = tuple(ThermocyclerStepConfig.parse(item, index) for index, item in enumerate(profile)) + if sum(step.hold_time for step in parsed_profile) > 3600.0: + msg = "modules.thermocycler.profile total hold time cannot exceed 3600 seconds." + raise ValueError(msg) + return cls( + heater_shaker_serial=_text(heater["serial"], "modules.heater_shaker.serial"), + heater_shaker_temperature=_number(heater["temperature"], "modules.heater_shaker.temperature", 37.0, 95.0), + heater_shaker_speed=_integer(heater["speed"], "modules.heater_shaker.speed", 200, 3000), + thermocycler_serial=_text(thermocycler["serial"], "modules.thermocycler.serial"), + thermocycler_lid_temperature=_number( + thermocycler["lid_temperature"], "modules.thermocycler.lid_temperature", 37.0, 110.0 + ), + thermocycler_volume=_number(thermocycler["volume"], "modules.thermocycler.volume", 1.0, 200.0), + thermocycler_profile=parsed_profile, + temperature_module_serial=_text(temperature["serial"], "modules.temperature_module.serial"), + temperature_module_target=_number(temperature["target"], "modules.temperature_module.target", 4.0, 95.0), + plate_reader_serial=_text(reader["serial"], "modules.plate_reader.serial"), + plate_reader_wavelengths=parsed_wavelengths, + plate_reader_reference_wavelength=reference, + stacker_serial=_text(stacker["serial"], "modules.stacker.serial"), + stacker_labware_height=_number(stacker["labware_height"], "modules.stacker.labware_height", 4.0, 102.5), + ) + + +_PLAN_FIELDS = { + "process_deck_to_thermocycler", + "thermocycler_to_process_deck", + "process_deck_to_heater_shaker", + "heater_shaker_to_magnetic_block", + "magnetic_block_to_process_deck", + "assay_deck_to_temperature_module", + "temperature_module_to_assay_deck", + "reader_lid_open", + "reader_lid_close", + "assay_deck_to_reader", + "reader_to_assay_deck", +} + + +@dataclass(frozen=True) +class AcceptanceManifest: + """Complete, fail-closed configuration for a physical acceptance campaign.""" + + schema_version: int + service_name: str + expected_robot_host: str + pipetting: PipettingConfig + modules: ModuleConfig + plans: Mapping[str, str] + + @classmethod + def parse(cls, value: object) -> AcceptanceManifest: + """Parse and reject any incomplete or ambiguous campaign manifest.""" + fields = {"schema_version", "service_name", "expected_robot_host", "pipetting", "modules", "plans"} + data = _object(value, "acceptance manifest", fields) + version = _integer(data["schema_version"], "schema_version", 1, 1) + plans = _object(data["plans"], "plans", _PLAN_FIELDS) + parsed_plans = {name: _text(plans[name], f"plans.{name}") for name in sorted(_PLAN_FIELDS)} + if len(set(parsed_plans.values())) != len(parsed_plans): + msg = "Every acceptance labware plan identifier must be unique." + raise ValueError(msg) + return cls( + schema_version=version, + service_name=_text(data["service_name"], "service_name"), + expected_robot_host=_text(data["expected_robot_host"], "expected_robot_host"), + pipetting=PipettingConfig.parse(data["pipetting"]), + modules=ModuleConfig.parse(data["modules"]), + plans=parsed_plans, + ) + + @classmethod + def load(cls, path: str | Path) -> AcceptanceManifest: + """Load and validate a manifest before any connector call is attempted.""" + manifest_path = Path(path).expanduser() + try: + value = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + msg = f"Cannot load acceptance manifest {manifest_path}: {exc}" + raise ValueError(msg) from exc + return cls.parse(value) + + def commissioning_digest(self) -> str: + """Return a stable SHA-256 fingerprint for an operator-approved manifest.""" + payload = asdict(self) + payload["plans"] = dict(self.plans) + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class PlanContract: + """Non-geometric metadata used to validate an allowlisted movement graph.""" + + plan_identifier: str + labware_identifier: str + source_location: str + destination_location: str + is_lid: bool + + +def validate_plan_contract( + manifest: AcceptanceManifest, + plans: tuple[PlanContract, ...], + *, + deck_valid: bool, + occupancy: Mapping[str, str], +) -> None: + """Fail closed unless plans and durable occupancy form the expected campaign graph.""" + by_identifier = {plan.plan_identifier: plan for plan in plans} + missing = set(manifest.plans.values()) - set(by_identifier) + if missing: + msg = f"Server is missing acceptance movement plans: {sorted(missing)}." + raise ValueError(msg) + named = {name: by_identifier[identifier] for name, identifier in manifest.plans.items()} + lid_names = {"reader_lid_open", "reader_lid_close"} + for name, plan in named.items(): + if plan.is_lid is not (name in lid_names): + msg = f"Plan {plan.plan_identifier!r} has the wrong lid classification." + raise ValueError(msg) + + def reversible(outbound: str, inbound: str) -> None: + first = named[outbound] + second = named[inbound] + if ( + first.labware_identifier != second.labware_identifier + or first.source_location != second.destination_location + or first.destination_location != second.source_location + ): + msg = f"Acceptance plans {outbound!r} and {inbound!r} are not a reversible pair." + raise ValueError(msg) + + reversible("process_deck_to_thermocycler", "thermocycler_to_process_deck") + reversible("assay_deck_to_temperature_module", "temperature_module_to_assay_deck") + reversible("reader_lid_open", "reader_lid_close") + reversible("assay_deck_to_reader", "reader_to_assay_deck") + + process_chain = ( + named["thermocycler_to_process_deck"], + named["process_deck_to_heater_shaker"], + named["heater_shaker_to_magnetic_block"], + named["magnetic_block_to_process_deck"], + ) + if len({plan.labware_identifier for plan in process_chain}) != 1: + msg = "Thermocycler, Heater-Shaker, and Magnetic Block plans must use one process plate." + raise ValueError(msg) + process_locations = ( + (process_chain[0].destination_location, process_chain[1].source_location), + (process_chain[1].destination_location, process_chain[2].source_location), + (process_chain[2].destination_location, process_chain[3].source_location), + (process_chain[3].destination_location, process_chain[1].source_location), + ) + if any(left != right for left, right in process_locations): + msg = "Process-plate movement plans do not form a closed location chain." + raise ValueError(msg) + + temperature_return = named["temperature_module_to_assay_deck"] + reader_outbound = named["assay_deck_to_reader"] + if ( + temperature_return.labware_identifier != reader_outbound.labware_identifier + or temperature_return.destination_location != reader_outbound.source_location + ): + msg = "Temperature Module and Plate Reader plans must share one assay plate and deck location." + raise ValueError(msg) + if named["reader_lid_open"].labware_identifier == reader_outbound.labware_identifier: + msg = "The Plate Reader lid and assay plate must have distinct labware identifiers." + raise ValueError(msg) + + if not deck_valid: + msg = "Connector deck ledger is invalid. Reconcile physical locations before acceptance." + raise ValueError(msg) + for name in ("process_deck_to_thermocycler", "assay_deck_to_temperature_module", "reader_lid_open"): + plan = named[name] + if occupancy.get(plan.source_location) != plan.labware_identifier: + msg = ( + f"{plan.labware_identifier!r} must start at {plan.source_location!r}; " + f"deck ledger reports {occupancy.get(plan.source_location)!r}." + ) + raise ValueError(msg) + + +__all__ = [ + "AcceptanceManifest", + "Coordinate", + "ModuleConfig", + "PipettingConfig", + "PlanContract", + "ThermocyclerStepConfig", + "WellGeometryConfig", + "validate_plan_contract", +] diff --git a/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/wire_types.py b/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/wire_types.py new file mode 100644 index 0000000..e0cb4f4 --- /dev/null +++ b/packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/wire_types.py @@ -0,0 +1,95 @@ +"""Dependency-free values serialized by the Unitelabs Flex workflow client.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass + + +class Mount(enum.Enum): + """A Flex instrument mount.""" + + LEFT = "LEFT" + RIGHT = "RIGHT" + GRIPPER = "GRIPPER" + + +class PipetteMount(enum.Enum): + """A Flex mount that can hold a pipette.""" + + LEFT = "LEFT" + RIGHT = "RIGHT" + + +class VerifiedLiquidClass(enum.Enum): + """Verified liquid-class definitions shipped by Opentrons.""" + + WATER = "water" + ETHANOL_80 = "ethanol_80" + GLYCEROL_50 = "glycerol_50" + + +@dataclass +class LiquidPosition: + """Absolute deck position of a pipette tip.""" + + x: float + y: float + z: float + + +@dataclass +class WellGeometry: + """Well geometry in deck coordinates.""" + + center_x: float + center_y: float + bottom_z: float + top_z: float + size_x: float + size_y: float + + +@dataclass +class TransferProfile: + """Fully specified advanced transfer behavior.""" + + aspirate_rate: float + dispense_rate: float + push_out: float + air_gap: float + mix_before_cycles: int + mix_before_volume: float + mix_after_cycles: int + mix_after_volume: float + blow_out: bool + + +@dataclass +class TipLocation: + """Absolute deck position for a pipette tip operation.""" + + x: float + y: float + z: float + + +@dataclass +class ThermocyclerProfileStep: + """One thermocycler profile step.""" + + temperature: float + hold_time: float + ramp_rate: float + + +__all__ = [ + "LiquidPosition", + "Mount", + "PipetteMount", + "ThermocyclerProfileStep", + "TipLocation", + "TransferProfile", + "VerifiedLiquidClass", + "WellGeometry", +] diff --git a/pyproject.toml b/pyproject.toml index c4ae3ad..1611c62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ requires-python=">=3.10,<3.11" dependencies=[ "unitelabs-cdk~=0.9.0", + "unitelabs-flex-acceptance-contract==0.1.0", "opentrons==9.0.0", "numpy>=1.26.4,<2", "aiohttp==3.12.14", @@ -37,6 +38,9 @@ dependencies=[ "wsproto==1.2.0", ] +[tool.uv.sources] +unitelabs-flex-acceptance-contract = { path = "packages/flex-acceptance-contract" } + [project.urls] repository = "https://github.com/AccelerationConsortium/opentrons-flex" documentation = "https://github.com/AccelerationConsortium/opentrons-flex" diff --git a/scripts/artifact_manifest.py b/scripts/artifact_manifest.py index 03edf24..ab953b9 100755 --- a/scripts/artifact_manifest.py +++ b/scripts/artifact_manifest.py @@ -28,6 +28,7 @@ "python-dotenv": "1.0.1", "python-multipart": "0.0.18", "sqlalchemy": "1.4.51", + "unitelabs-flex-acceptance-contract": "0.1.0", "uvicorn": "0.27.0.post1", "wsproto": "1.2.0", } diff --git a/scripts/preflight_flex.py b/scripts/preflight_flex.py index ea63406..3be096e 100755 --- a/scripts/preflight_flex.py +++ b/scripts/preflight_flex.py @@ -4,15 +4,32 @@ from __future__ import annotations import argparse +import ipaddress import json +import re import socket import subprocess import sys import urllib.error import urllib.request from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path from typing import Any +from unitelabs.opentrons_flex.acceptance import AcceptanceManifest +from unitelabs.opentrons_flex.hitl_evidence import ( + HITL_BLOCKED_STATUS, + HITL_READINESS_SCHEMA_VERSION, + HITL_READINESS_STATUS, +) +from unitelabs.opentrons_flex.runtime_compat import ( + RUNTIME_CONTRACT_ID, + SUPPORTED_OPENTRONS_SOURCE_COMMIT, + SUPPORTED_OPENTRONS_VERSION, + SUPPORTED_PYTHON_VERSION, +) + @dataclass(frozen=True) class Check: @@ -25,30 +42,48 @@ class Check: def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("host", help="Flex hostname or IP address.") - parser.add_argument("--ssh-user", default="root") - parser.add_argument("--http-port", type=int, default=31950) - parser.add_argument("--grpc-port", type=int, default=50051) - parser.add_argument("--timeout", type=float, default=8.0) + parser.add_argument("host", type=_host, help="Flex hostname or IP address.") + parser.add_argument("--ssh-user", type=_ssh_user, default="root") + parser.add_argument("--http-port", type=_port, default=31950) + parser.add_argument("--grpc-port", type=_port, default=50051) + parser.add_argument("--timeout", type=_positive_float, default=8.0) parser.add_argument("--runtime-only", action="store_true", help="Skip live HTTP and gRPC checks.") parser.add_argument( "--allow-base-only", action="store_true", help="Do not require controlled mutation readiness or routes.", ) + parser.add_argument( + "--acceptance-manifest", + type=Path, + help="Strict acceptance manifest used to bind readiness evidence to one live Flex.", + ) + parser.add_argument( + "--runtime-manifest", + type=Path, + help="Verified runtime-manifest.json from the exact wheel artifact deployed to the Flex.", + ) + parser.add_argument( + "--output", + type=Path, + help="Write the JSON report to this cross-platform path as well as stdout.", + ) return parser -def _remote_runtime(args: argparse.Namespace) -> Check: +def _remote_runtime( + args: argparse.Namespace, +) -> tuple[Check, dict[str, Any] | None, dict[str, Any]]: mutation_flag = "" if args.allow_base_only else "--require-mutation" + runtime_only_flag = "--runtime-only" if args.runtime_only else "" remote_command = ( "set -eu; " "test -x /var/sila2_flex/bin/python; " "if test -f /var/lib/unitelabs-opentrons-flex/run-mutation.env; then " "set -a; . /var/lib/unitelabs-opentrons-flex/run-mutation.env; set +a; fi; " - "exec /var/sila2_flex/bin/python -m unitelabs.opentrons_flex.runtime_preflight " - "--config /var/sila2_flex/config.json --require-robot-server " - f"{mutation_flag}" + "exec /var/sila2_flex/bin/python -m unitelabs.opentrons_flex.robot_readiness " + f"--config /var/sila2_flex/config.json --http-port {args.http_port} --timeout {args.timeout:g} " + f"{mutation_flag} {runtime_only_flag}" ) try: completed = subprocess.run( @@ -67,19 +102,24 @@ def _remote_runtime(args: argparse.Namespace) -> Check: timeout=args.timeout + 5, ) except (OSError, subprocess.TimeoutExpired) as exc: - return Check("remote-runtime", False, str(exc)) + return Check("remote-runtime", False, str(exc)), None, {} output = completed.stdout.strip() or completed.stderr.strip() if completed.returncode != 0: - return Check("remote-runtime", False, output[-2000:]) + return Check("remote-runtime", False, output[-2000:]), None, {} try: report = json.loads(completed.stdout) + runtime = report["runtime"] + http_payloads = report["http"] detail = ( - f"connector={report['connector_version']}, opentrons={report['opentrons_version']}, " - f"python={report['python_version']}, mutation_ready={report['mutation_ready']}" + f"connector={runtime['connector_version']}, opentrons={runtime['opentrons_version']}, " + f"python={runtime['python_version']}, contract={runtime['runtime_contract_id']}, " + f"mutation_ready={runtime['mutation_ready']}" ) - except (json.JSONDecodeError, KeyError): - detail = output[-2000:] - return Check("remote-runtime", True, detail) + except (json.JSONDecodeError, KeyError, TypeError): + return Check("remote-runtime", False, output[-2000:]), None, {} + if not isinstance(runtime, dict) or not isinstance(http_payloads, dict): + return Check("remote-runtime", False, "robot readiness returned an invalid object"), None, {} + return Check("remote-runtime", True, detail), runtime, http_payloads def _tcp_check(host: str, port: int, timeout: float) -> Check: @@ -92,10 +132,12 @@ def _tcp_check(host: str, port: int, timeout: float) -> Check: def _http_json(host: str, port: int, path: str, timeout: float) -> tuple[Check, Any | None]: - url = f"http://{host}:{port}{path}" + url = f"http://{_url_host(host)}:{port}{path}" request = urllib.request.Request(url, headers={"User-Agent": "unitelabs-flex-preflight/0.9.1"}) try: with urllib.request.urlopen(request, timeout=timeout) as response: + if response.geturl() != url: + return Check(f"http:{path}", False, f"unexpected redirect to {response.geturl()}"), None payload = json.loads(response.read().decode("utf-8")) status = response.status except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: @@ -106,25 +148,271 @@ def _http_json(host: str, port: int, path: str, timeout: float) -> tuple[Check, def main(argv: list[str] | None = None) -> int: """Run all requested no-motion checks and print a machine-readable report.""" args = _parser().parse_args(argv) - checks = [_remote_runtime(args)] + remote_runtime_check, runtime, authenticated_http_payloads = _remote_runtime(args) + checks = [remote_runtime_check] if not args.runtime_only: checks.append(_tcp_check(args.host, args.grpc_port, args.timeout)) for path in ("/health", "/deck_configuration", "/pipettes", "/modules", "/openapi.json"): - check, payload = _http_json(args.host, args.http_port, path, args.timeout) + check, _ = _http_json(args.host, args.http_port, path, args.timeout) checks.append(check) - if path == "/openapi.json" and check.ok and not args.allow_base_only: - paths = payload.get("paths", {}) if isinstance(payload, dict) else {} - mutation_path = "/unitelabs/runs/{run_id}/mutations" + if not args.allow_base_only: + openapi = authenticated_http_payloads.get("/openapi.json") + paths = openapi.get("paths", {}) if isinstance(openapi, dict) else {} + mutation_path = "/unitelabs/runs/{run_id}/mutations" + checks.append( + Check( + "mutation-route", + mutation_path in paths, + f"{mutation_path} {'present' if mutation_path in paths else 'missing'}", + ) + ) + manifest: AcceptanceManifest | None = None + manifest_sha256: str | None = None + expected_release_identity: dict[str, str] | None = None + if args.acceptance_manifest is not None: + try: + manifest = AcceptanceManifest.load(args.acceptance_manifest) + manifest_sha256 = manifest.commissioning_digest() + if args.runtime_manifest is None: + checks.append( + Check( + "artifact-runtime-manifest", + False, + "--runtime-manifest is required with --acceptance-manifest", + ) + ) + else: + expected_release_identity = _runtime_manifest_identity( + args.runtime_manifest, + runtime, + ) checks.append( Check( - "mutation-route", - mutation_path in paths, - f"{mutation_path} {'present' if mutation_path in paths else 'missing'}", + "artifact-runtime-manifest", + True, + f"release={expected_release_identity['release_id']}", ) ) - report = {"host": args.host, "checks": [asdict(check) for check in checks]} - sys.stdout.write(json.dumps(report, indent=2, sort_keys=True) + "\n") - return 0 if all(check.ok for check in checks) else 1 + checks.extend( + _acceptance_checks( + args.host, + manifest, + runtime, + authenticated_http_payloads, + expected_release_identity, + ) + ) + except ValueError as exc: + checks.append(Check("acceptance-manifest", False, str(exc))) + + checks_ok = all(check.ok for check in checks) + all_ready = bool(manifest is not None and runtime is not None and checks_ok) + evidence_status = ( + HITL_READINESS_STATUS + if all_ready + else "RUNTIME_READY" + if manifest is None and runtime is not None and checks_ok + else HITL_BLOCKED_STATUS + ) + runtime_contract_id = runtime.get("runtime_contract_id") if isinstance(runtime, dict) else None + release_identity = runtime.get("release_identity") if isinstance(runtime, dict) else None + report = { + "schemaVersion": HITL_READINESS_SCHEMA_VERSION, + "evidenceStatus": evidence_status, + "hardwarePassed": False, + "generatedAt": datetime.now(timezone.utc).isoformat(), + "host": args.host, + "manifestSha256": manifest_sha256, + "runtimeContractId": runtime_contract_id, + "releaseIdentity": release_identity, + "runtime": runtime, + "checks": [asdict(check) for check in checks], + } + encoded = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.output is not None: + args.output.expanduser().write_text(encoded, encoding="utf-8") + sys.stdout.write(encoded) + return 0 if checks_ok and (manifest is None or all_ready) else 1 + + +def _acceptance_checks( + host: str, + manifest: AcceptanceManifest, + runtime: dict[str, Any] | None, + http_payloads: dict[str, Any], + expected_release_identity: dict[str, str] | None, +) -> list[Check]: + checks = [ + Check( + "acceptance-host", + manifest.expected_robot_host.casefold() == host.casefold(), + f"manifest={manifest.expected_robot_host}, target={host}", + ) + ] + remote_contract = runtime.get("runtime_contract_id") if isinstance(runtime, dict) else None + checks.append( + Check( + "runtime-contract", + remote_contract == RUNTIME_CONTRACT_ID, + f"remote={remote_contract or 'missing'}, checkout={RUNTIME_CONTRACT_ID}", + ) + ) + remote_release_identity = runtime.get("release_identity") if isinstance(runtime, dict) else None + checks.append( + Check( + "release-identity", + expected_release_identity is not None and remote_release_identity == expected_release_identity, + f"remote={remote_release_identity or 'missing'}, expected={expected_release_identity or 'missing'}", + ) + ) + checks.append(_pipette_inventory_check(manifest, http_payloads.get("/pipettes"))) + checks.extend(_module_inventory_checks(manifest, http_payloads.get("/modules"))) + return checks + + +def _pipette_inventory_check(manifest: AcceptanceManifest, payload: object) -> Check: + inventory = payload.get("data", payload) if isinstance(payload, dict) else None + mount = manifest.pipetting.mount.lower() + pipette = inventory.get(mount) if isinstance(inventory, dict) else None + if not isinstance(pipette, dict) or not pipette: + return Check("acceptance-pipette", False, f"{mount} pipette mount is empty or unreadable") + identity = pipette.get("serialNumber") or pipette.get("pipetteId") or pipette.get("name") or "attached" + return Check("acceptance-pipette", True, f"{mount}={identity}") + + +def _module_inventory_checks(manifest: AcceptanceManifest, payload: object) -> list[Check]: + module_items = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(module_items, list): + return [Check("acceptance-modules", False, "module inventory is missing or unreadable")] + expected = { + "heater-shaker": (manifest.modules.heater_shaker_serial, "heaterShakerModuleV1"), + "thermocycler": (manifest.modules.thermocycler_serial, "thermocyclerModuleV2"), + "temperature-module": (manifest.modules.temperature_module_serial, "temperatureModuleV2"), + "plate-reader": (manifest.modules.plate_reader_serial, "absorbanceReaderV1"), + "stacker": (manifest.modules.stacker_serial, "flexStackerModuleV1"), + } + observed = {} + for item in module_items: + if not isinstance(item, dict): + continue + serial = str(item.get("serialNumber") or item.get("serial") or "") + model = str(item.get("moduleModel") or item.get("model") or "") + if serial: + observed[serial] = model + return [ + Check( + f"acceptance-module:{name}", + observed.get(serial) == model, + f"expected serial={serial} model={model}; observed model={observed.get(serial) or 'missing'}", + ) + for name, (serial, model) in expected.items() + ] + + +def _host(value: str) -> str: + normalized = value.strip() + try: + ipaddress.ip_address(normalized) + return normalized + except ValueError: + pass + if len(normalized) > 253: + raise argparse.ArgumentTypeError("host must be a valid IP address or DNS name") + labels = normalized.rstrip(".").split(".") + if not labels or any(not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in labels): + raise argparse.ArgumentTypeError("host must be a valid IP address or DNS name") + return normalized + + +def _ssh_user(value: str) -> str: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]{0,31}", value): + raise argparse.ArgumentTypeError("ssh user contains unsupported characters") + return value + + +def _port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("port must be an integer") from exc + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("port must be between 1 and 65535") + return port + + +def _positive_float(value: str) -> float: + try: + number = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("timeout must be a number") from exc + if number <= 0: + raise argparse.ArgumentTypeError("timeout must be positive") + return number + + +def _url_host(host: str) -> str: + try: + return f"[{host}]" if ipaddress.ip_address(host).version == 6 else host + except ValueError: + return host + + +def _runtime_manifest_identity( + path: Path, + runtime: dict[str, Any] | None, +) -> dict[str, str]: + manifest_path = path.expanduser() + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + message = f"Cannot read artifact runtime manifest {manifest_path}: {exc}" + raise ValueError(message) from exc + if not isinstance(manifest, dict): + message = f"Artifact runtime manifest {manifest_path} must be a JSON object." + raise ValueError(message) + connector_version = runtime.get("connector_version") if isinstance(runtime, dict) else None + expected_fields = { + "schemaVersion": 1, + "connectorVersion": connector_version, + "opentronsVersion": SUPPORTED_OPENTRONS_VERSION, + "robotServerVersion": SUPPORTED_OPENTRONS_VERSION, + "opentronsSourceCommit": SUPPORTED_OPENTRONS_SOURCE_COMMIT, + "pythonVersion": ".".join(str(part) for part in SUPPORTED_PYTHON_VERSION), + "architecture": "aarch64", + } + mismatches = [ + f"{name}={manifest.get(name)!r} (expected {expected!r})" + for name, expected in expected_fields.items() + if manifest.get(name) != expected + ] + if mismatches: + message = ( + f"Artifact runtime manifest {manifest_path} does not match the active connector contract: " + + "; ".join(mismatches) + ) + raise ValueError(message) + release_id = manifest.get("releaseId") + bundle_sha256 = manifest.get("bundleSha256") + if not isinstance(release_id, str) or not release_id: + message = f"Artifact runtime manifest {manifest_path} has no releaseId." + raise ValueError(message) + if ( + not isinstance(bundle_sha256, str) + or len(bundle_sha256) != 64 + or any(character not in "0123456789abcdef" for character in bundle_sha256) + ): + message = f"Artifact runtime manifest {manifest_path} has an invalid bundleSha256." + raise ValueError(message) + expected_release_id = ( + f"flex-{connector_version}-ot{SUPPORTED_OPENTRONS_VERSION}-" + f"py{expected_fields['pythonVersion']}-aarch64-{bundle_sha256[:12]}" + ) + if release_id != expected_release_id: + message = ( + f"Artifact runtime manifest {manifest_path} releaseId is {release_id!r}; expected {expected_release_id!r}." + ) + raise ValueError(message) + return {"release_id": release_id, "bundle_sha256": bundle_sha256} if __name__ == "__main__": diff --git a/src/unitelabs/opentrons_flex/__init__.py b/src/unitelabs/opentrons_flex/__init__.py index 953b5ab..26e14fb 100644 --- a/src/unitelabs/opentrons_flex/__init__.py +++ b/src/unitelabs/opentrons_flex/__init__.py @@ -49,6 +49,15 @@ ) from .io.simulator_compat import OT3SimulatorCompatibilityAdapter from .run_mutation import MUTATION_CHECKPOINT_PREFIX, MutationLedger, MutationLedgerError, RunMutationCoordinator +from .runtime_identity_api import create_runtime_identity_router, process_runtime_identity +from .robot_server_compat import ( + current_run_store, + include_robot_server_router, + install_robot_server_app, + load_robot_server_bindings, + protocol_run_state, + restore_robot_server_app, +) from .runtime_compat import ( RuntimeCompatibilityReport, mutation_configuration_issues, @@ -333,9 +342,14 @@ def _shared_hardware_robot_server_lifespan( @contextlib.asynccontextmanager async def lifespan(app: _AppWithState) -> collections.abc.AsyncGenerator[None, None]: - from robot_server.runs.dependencies import ( # type: ignore[import] - mark_light_control_startup_finished, - start_light_control_task, + bindings = load_robot_server_bindings() + start_light_control_task = typing.cast( + collections.abc.Callable[..., collections.abc.Awaitable[object]], + bindings.start_light_control_task, + ) + mark_light_control_startup_finished = typing.cast( + collections.abc.Callable[..., collections.abc.Awaitable[object]], + bindings.mark_light_control_startup_finished, ) async with original_lifespan(app): @@ -460,14 +474,7 @@ async def _create_app_with_robot_server( from opentrons.protocol_engine import DeckType from opentrons_shared_data.robot.types import RobotTypeEnum - from robot_server.hardware import ( # type: ignore[import] - _hw_api_accessor, - _init_task_accessor, - get_deck_type, - get_robot_type, - get_robot_type_enum, - ) - from robot_server.app import app as robot_server_app # type: ignore[import] + robot_server = load_robot_server_bindings() mutation_api_token: str | None = None mutation_authenticated_actor: str | None = None @@ -525,54 +532,15 @@ def _current_run_store() -> object | None: # by its AppStateAccessor. Reading the state directly keeps the shared # hardware wiring testable without importing robot-server's private runs # package on development hosts. - return getattr(robot_server_app.state, "run_orchestrator_store", None) + return current_run_store(robot_server, robot_server_ready=robot_server_ready) robot_server_ready = False def _current_run_state() -> ProtocolRunState: - store = _current_run_store() - if store is None: - if not robot_server_ready: - return ProtocolRunState( - run_id="robot-server-startup", - status="initializing", - started=False, - terminal=False, - ) - return ProtocolRunState(run_id=None, status="idle", started=False, terminal=True) - run_id = store.current_run_id - if run_id is None: - return ProtocolRunState(run_id=None, status="idle", started=False, terminal=True) - status = store.get_status() - status_value = str(getattr(status, "value", status)) - missing_protocol_runner = object() - protocol_runner = getattr(store.run_orchestrator, "_protocol_runner", missing_protocol_runner) - checkpoint_id = None - current_pointer = store.get_current_command() - if current_pointer is not None: - current_command = store.get_command(current_pointer.command_id) - message = getattr(current_command.params, "message", None) - command_status = str(getattr(current_command.status, "value", current_command.status)) - if ( - current_command.commandType == "waitForResume" - and command_status == "running" - and isinstance(message, str) - and message.startswith(MUTATION_CHECKPOINT_PREFIX) - ): - checkpoint_id = current_command.id - return ProtocolRunState( - run_id=run_id, - status=status_value, - started=bool(store.run_was_started()), - # PE marks its logical run result before halt/reset/home cleanup. - # Retain exclusive ownership throughout the observable FINISHING or - # STOP_REQUESTED phase until hardware cleanup has completed. - terminal=status_value in {"succeeded", "failed", "stopped"}, - mutation_checkpoint_id=checkpoint_id, - # In the version-pinned RunOrchestrator, only runs created without - # a protocol have no Python/JSON protocol runner. Unknown layouts - # fail closed as not protocol-less. - protocol_less=protocol_runner is None, + return protocol_run_state( + _current_run_store(), + robot_server_ready=robot_server_ready, + checkpoint_prefix=MUTATION_CHECKPOINT_PREFIX, ) run_authority = ProtocolRunAuthority(_current_run_state) @@ -604,8 +572,6 @@ async def _noop() -> None: init_task: asyncio.Task[None] = asyncio.create_task(_noop()) await init_task - _init_task_accessor.set_on(robot_server_app.state, init_task) - _hw_api_accessor.set_on(robot_server_app.state, proxy) # robot-server normally derives these values from process-wide feature flags. # Those flags identify an off-robot development host as OT-2 even though this @@ -620,25 +586,34 @@ async def _get_flex_robot_type_enum() -> RobotTypeEnum: async def _get_flex_deck_type() -> DeckType: return DeckType.OT3_STANDARD - dependency_overrides = robot_server_app.dependency_overrides flex_identity_overrides = { - get_robot_type: _get_flex_robot_type, - get_robot_type_enum: _get_flex_robot_type_enum, - get_deck_type: _get_flex_deck_type, - } - missing_override = object() - original_identity_overrides = { - dependency: dependency_overrides.get(dependency, missing_override) for dependency in flex_identity_overrides + robot_server.get_robot_type: _get_flex_robot_type, + robot_server.get_robot_type_enum: _get_flex_robot_type_enum, + robot_server.get_deck_type: _get_flex_deck_type, } - dependency_overrides.update(flex_identity_overrides) - - original_robot_server_lifespan = robot_server_app.router.lifespan_context - robot_server_app.router.lifespan_context = _shared_hardware_robot_server_lifespan( - original_robot_server_lifespan, - proxy, + robot_server_installation = install_robot_server_app( + robot_server, + initialization_task=init_task, + hardware_proxy=proxy, + identity_overrides=flex_identity_overrides, + lifespan_factory=lambda original_lifespan: _shared_hardware_robot_server_lifespan( + typing.cast(typing.Callable[..., object], original_lifespan), + proxy, + ), ) + robot_server_app = typing.cast(typing.Any, robot_server_installation.app) - added_mutation_routes: tuple[object, ...] = () + runtime_identity = process_runtime_identity( + compatibility, + require_release=not config.use_simulator, + ) + runtime_identity_router = create_runtime_identity_router(runtime_identity) + added_robot_server_routes = list( + include_robot_server_router( + robot_server_installation, + runtime_identity_router, + ) + ) mutation_coordinator: RunMutationCoordinator | None = None if mutation_ledger is not None: from .run_mutation_api import create_run_mutation_router @@ -653,9 +628,12 @@ async def _get_flex_deck_type() -> DeckType: mutation_coordinator, api_token=typing.cast(str, mutation_api_token), ) - added_mutation_routes = tuple(mutation_router.routes) - robot_server_app.include_router(mutation_router) - robot_server_app.openapi_schema = None + added_robot_server_routes.extend( + include_robot_server_router( + robot_server_installation, + mutation_router, + ) + ) log.info("Controlled run mutation enabled with durable ledger %s", mutation_ledger.path) else: log.info("Controlled run mutation routes were not registered.") @@ -720,17 +698,10 @@ async def _get_flex_deck_type() -> DeckType: finally: uv_server.should_exit = True await asyncio.gather(robot_server_task, return_exceptions=True) - robot_server_app.router.lifespan_context = original_robot_server_lifespan - if added_mutation_routes: - robot_server_app.router.routes[:] = [ - route for route in robot_server_app.router.routes if route not in added_mutation_routes - ] - robot_server_app.openapi_schema = None - for dependency, original_override in original_identity_overrides.items(): - if original_override is missing_override: - dependency_overrides.pop(dependency, None) - else: - dependency_overrides[dependency] = original_override + restore_robot_server_app( + robot_server_installation, + added_routes=tuple(added_robot_server_routes), + ) try: if labware_state is not None: labware_state.close() diff --git a/src/unitelabs/opentrons_flex/acceptance.py b/src/unitelabs/opentrons_flex/acceptance.py index 1e96b5b..8372c9f 100644 --- a/src/unitelabs/opentrons_flex/acceptance.py +++ b/src/unitelabs/opentrons_flex/acceptance.py @@ -1,471 +1,15 @@ -"""Strict client-side manifest for the Flex system acceptance workflow.""" - -from __future__ import annotations - -import hashlib -import json -import math -from collections.abc import Mapping -from dataclasses import asdict, dataclass -from pathlib import Path - - -def _object(value: object, label: str, fields: set[str]) -> Mapping[str, object]: - if not isinstance(value, Mapping): - msg = f"{label} must be a JSON object." - raise ValueError(msg) - missing = fields - set(value) - unknown = set(value) - fields - if missing: - msg = f"{label} is missing required fields: {', '.join(sorted(missing))}." - raise ValueError(msg) - if unknown: - msg = f"{label} contains unknown fields: {', '.join(sorted(unknown))}." - raise ValueError(msg) - return value - - -def _text(value: object, label: str) -> str: - if not isinstance(value, str) or not value.strip(): - msg = f"{label} must be a non-empty string." - raise ValueError(msg) - normalized = value.strip() - if any(marker in normalized.upper() for marker in ("REPLACE", "YOUR_", " float: - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): - msg = f"{label} must be a finite number." - raise ValueError(msg) - number = float(value) - if not minimum <= number <= maximum: - msg = f"{label} must be between {minimum} and {maximum}." - raise ValueError(msg) - return number - - -def _integer(value: object, label: str, minimum: int, maximum: int) -> int: - if isinstance(value, bool) or not isinstance(value, int): - msg = f"{label} must be an integer." - raise ValueError(msg) - if not minimum <= value <= maximum: - msg = f"{label} must be between {minimum} and {maximum}." - raise ValueError(msg) - return value - - -@dataclass(frozen=True) -class Coordinate: - """Absolute calibrated deck coordinate in millimetres.""" - - x: float - y: float - z: float - - @classmethod - def parse(cls, value: object, label: str) -> Coordinate: - """Parse one strictly shaped coordinate object.""" - data = _object(value, label, {"x", "y", "z"}) - return cls( - x=_number(data["x"], f"{label}.x", -1000.0, 1000.0), - y=_number(data["y"], f"{label}.y", -1000.0, 1000.0), - z=_number(data["z"], f"{label}.z", -1000.0, 1000.0), - ) - - -@dataclass(frozen=True) -class WellGeometryConfig: - """Calibrated well centre and vertical geometry in millimetres.""" - - x: float - y: float - bottom_z: float - top_z: float - length: float - width: float - - @classmethod - def parse(cls, value: object, label: str) -> WellGeometryConfig: - """Parse and validate one well geometry object.""" - fields = {"x", "y", "bottom_z", "top_z", "length", "width"} - data = _object(value, label, fields) - parsed = cls( - x=_number(data["x"], f"{label}.x", -1000.0, 1000.0), - y=_number(data["y"], f"{label}.y", -1000.0, 1000.0), - bottom_z=_number(data["bottom_z"], f"{label}.bottom_z", -1000.0, 1000.0), - top_z=_number(data["top_z"], f"{label}.top_z", -1000.0, 1000.0), - length=_number(data["length"], f"{label}.length", 0.1, 200.0), - width=_number(data["width"], f"{label}.width", 0.1, 200.0), - ) - if parsed.bottom_z >= parsed.top_z: - msg = f"{label}.bottom_z must be below top_z." - raise ValueError(msg) - return parsed - - -@dataclass(frozen=True) -class PipettingConfig: - """Prepared safe-liquid transfer geometry and operating values.""" - - mount: str - tip_length: float - tiprack_diameter: float - tip_pickup: Coordinate - tip_drop: Coordinate - source: Coordinate - source_retract: Coordinate - destination: Coordinate - destination_retract: Coordinate - source_well: WellGeometryConfig - destination_well: WellGeometryConfig - transfer_volume: float - prepared_source_volume: float - probe_maximum_distance: float - mix_volume: float - mix_cycles: int - tiprack_uri: str - - @classmethod - def parse(cls, value: object) -> PipettingConfig: - """Parse the complete prepared liquid-handling configuration.""" - fields = { - "mount", - "tip_length", - "tiprack_diameter", - "tip_pickup", - "tip_drop", - "source", - "source_retract", - "destination", - "destination_retract", - "source_well", - "destination_well", - "transfer_volume", - "prepared_source_volume", - "probe_maximum_distance", - "mix_volume", - "mix_cycles", - "tiprack_uri", - } - data = _object(value, "pipetting", fields) - mount = _text(data["mount"], "pipetting.mount").upper() - if mount not in {"LEFT", "RIGHT"}: - msg = "pipetting.mount must be LEFT or RIGHT." - raise ValueError(msg) - transfer_volume = _number(data["transfer_volume"], "pipetting.transfer_volume", 1.0, 1000.0) - mix_volume = _number(data["mix_volume"], "pipetting.mix_volume", 1.0, 1000.0) - prepared_source_volume = _number( - data["prepared_source_volume"], "pipetting.prepared_source_volume", 1.0, 20000.0 - ) - minimum_required = transfer_volume * 2.0 + mix_volume - if prepared_source_volume < minimum_required: - msg = ( - "pipetting.prepared_source_volume must cover two transfers plus one mix " - f"({minimum_required:g} microlitres minimum)." - ) - raise ValueError(msg) - parsed = cls( - mount=mount, - tip_length=_number(data["tip_length"], "pipetting.tip_length", 1.0, 100.0), - tiprack_diameter=_number(data["tiprack_diameter"], "pipetting.tiprack_diameter", 0.1, 20.0), - tip_pickup=Coordinate.parse(data["tip_pickup"], "pipetting.tip_pickup"), - tip_drop=Coordinate.parse(data["tip_drop"], "pipetting.tip_drop"), - source=Coordinate.parse(data["source"], "pipetting.source"), - source_retract=Coordinate.parse(data["source_retract"], "pipetting.source_retract"), - destination=Coordinate.parse(data["destination"], "pipetting.destination"), - destination_retract=Coordinate.parse(data["destination_retract"], "pipetting.destination_retract"), - source_well=WellGeometryConfig.parse(data["source_well"], "pipetting.source_well"), - destination_well=WellGeometryConfig.parse(data["destination_well"], "pipetting.destination_well"), - transfer_volume=transfer_volume, - prepared_source_volume=prepared_source_volume, - probe_maximum_distance=_number( - data["probe_maximum_distance"], "pipetting.probe_maximum_distance", 1.0, 50.0 - ), - mix_volume=mix_volume, - mix_cycles=_integer(data["mix_cycles"], "pipetting.mix_cycles", 1, 20), - tiprack_uri=_text(data["tiprack_uri"], "pipetting.tiprack_uri"), - ) - for label, position, retract in ( - ("source", parsed.source, parsed.source_retract), - ("destination", parsed.destination, parsed.destination_retract), - ): - if (position.x, position.y) != (retract.x, retract.y) or retract.z <= position.z: - msg = f"pipetting.{label}_retract must be vertically above the matching {label} position." - raise ValueError(msg) - for label, position, well in ( - ("source", parsed.source, parsed.source_well), - ("destination", parsed.destination, parsed.destination_well), - ): - if (position.x, position.y) != (well.x, well.y) or not well.bottom_z <= position.z <= well.top_z: - msg = f"pipetting.{label} must be inside the matching well geometry." - raise ValueError(msg) - if parsed.probe_maximum_distance < parsed.source_retract.z - parsed.source.z: - msg = "pipetting.probe_maximum_distance must reach from source_retract to source." - raise ValueError(msg) - return parsed - - -@dataclass(frozen=True) -class ThermocyclerStepConfig: - """One guarded Thermocycler profile step.""" - - temperature: float - hold_time: float - ramp_rate: float - - @classmethod - def parse(cls, value: object, index: int) -> ThermocyclerStepConfig: - """Parse one constrained thermal profile step.""" - label = f"thermocycler.profile[{index}]" - data = _object(value, label, {"temperature", "hold_time", "ramp_rate"}) - return cls( - temperature=_number(data["temperature"], f"{label}.temperature", 4.0, 99.0), - hold_time=_number(data["hold_time"], f"{label}.hold_time", 0.0, 3600.0), - ramp_rate=_number(data["ramp_rate"], f"{label}.ramp_rate", 0.0, 10.0), - ) - - -@dataclass(frozen=True) -class ModuleConfig: - """Safe operating values and required module serial numbers.""" - - heater_shaker_serial: str - heater_shaker_temperature: float - heater_shaker_speed: int - thermocycler_serial: str - thermocycler_lid_temperature: float - thermocycler_volume: float - thermocycler_profile: tuple[ThermocyclerStepConfig, ...] - temperature_module_serial: str - temperature_module_target: float - plate_reader_serial: str - plate_reader_wavelengths: tuple[int, ...] - plate_reader_reference_wavelength: int - stacker_serial: str - stacker_labware_height: float - - @classmethod - def parse(cls, value: object) -> ModuleConfig: - """Parse accessory identities and guarded operating values.""" - fields = {"heater_shaker", "thermocycler", "temperature_module", "plate_reader", "stacker"} - data = _object(value, "modules", fields) - heater = _object(data["heater_shaker"], "modules.heater_shaker", {"serial", "temperature", "speed"}) - thermocycler = _object( - data["thermocycler"], - "modules.thermocycler", - {"serial", "lid_temperature", "volume", "profile"}, - ) - temperature = _object(data["temperature_module"], "modules.temperature_module", {"serial", "target"}) - reader = _object( - data["plate_reader"], - "modules.plate_reader", - {"serial", "wavelengths", "reference_wavelength"}, - ) - stacker = _object(data["stacker"], "modules.stacker", {"serial", "labware_height"}) - profile = thermocycler["profile"] - wavelengths = reader["wavelengths"] - if not isinstance(profile, list) or not 1 <= len(profile) <= 20: - msg = "modules.thermocycler.profile must contain between 1 and 20 steps." - raise ValueError(msg) - if not isinstance(wavelengths, list) or not 2 <= len(wavelengths) <= 6: - msg = "modules.plate_reader.wavelengths must contain between 2 and 6 wavelengths." - raise ValueError(msg) - parsed_wavelengths = tuple( - _integer(item, f"modules.plate_reader.wavelengths[{index}]", 350, 1000) - for index, item in enumerate(wavelengths) - ) - if len(set(parsed_wavelengths)) != len(parsed_wavelengths): - msg = "modules.plate_reader.wavelengths must contain unique wavelengths." - raise ValueError(msg) - reference = _integer(reader["reference_wavelength"], "modules.plate_reader.reference_wavelength", 350, 1000) - if reference not in parsed_wavelengths: - msg = "modules.plate_reader.reference_wavelength must also appear in wavelengths." - raise ValueError(msg) - if all(wavelength == reference for wavelength in parsed_wavelengths): - msg = "modules.plate_reader.wavelengths must include a sample wavelength distinct from the reference." - raise ValueError(msg) - parsed_profile = tuple(ThermocyclerStepConfig.parse(item, index) for index, item in enumerate(profile)) - if sum(step.hold_time for step in parsed_profile) > 3600.0: - msg = "modules.thermocycler.profile total hold time cannot exceed 3600 seconds." - raise ValueError(msg) - return cls( - heater_shaker_serial=_text(heater["serial"], "modules.heater_shaker.serial"), - heater_shaker_temperature=_number(heater["temperature"], "modules.heater_shaker.temperature", 37.0, 95.0), - heater_shaker_speed=_integer(heater["speed"], "modules.heater_shaker.speed", 200, 3000), - thermocycler_serial=_text(thermocycler["serial"], "modules.thermocycler.serial"), - thermocycler_lid_temperature=_number( - thermocycler["lid_temperature"], "modules.thermocycler.lid_temperature", 37.0, 110.0 - ), - thermocycler_volume=_number(thermocycler["volume"], "modules.thermocycler.volume", 1.0, 200.0), - thermocycler_profile=parsed_profile, - temperature_module_serial=_text(temperature["serial"], "modules.temperature_module.serial"), - temperature_module_target=_number(temperature["target"], "modules.temperature_module.target", 4.0, 95.0), - plate_reader_serial=_text(reader["serial"], "modules.plate_reader.serial"), - plate_reader_wavelengths=parsed_wavelengths, - plate_reader_reference_wavelength=reference, - stacker_serial=_text(stacker["serial"], "modules.stacker.serial"), - stacker_labware_height=_number(stacker["labware_height"], "modules.stacker.labware_height", 4.0, 102.5), - ) - - -_PLAN_FIELDS = { - "process_deck_to_thermocycler", - "thermocycler_to_process_deck", - "process_deck_to_heater_shaker", - "heater_shaker_to_magnetic_block", - "magnetic_block_to_process_deck", - "assay_deck_to_temperature_module", - "temperature_module_to_assay_deck", - "reader_lid_open", - "reader_lid_close", - "assay_deck_to_reader", - "reader_to_assay_deck", -} - - -@dataclass(frozen=True) -class AcceptanceManifest: - """Complete, fail-closed configuration for a physical acceptance campaign.""" - - schema_version: int - service_name: str - expected_robot_host: str - pipetting: PipettingConfig - modules: ModuleConfig - plans: Mapping[str, str] - - @classmethod - def parse(cls, value: object) -> AcceptanceManifest: - """Parse and reject any incomplete or ambiguous campaign manifest.""" - fields = {"schema_version", "service_name", "expected_robot_host", "pipetting", "modules", "plans"} - data = _object(value, "acceptance manifest", fields) - version = _integer(data["schema_version"], "schema_version", 1, 1) - plans = _object(data["plans"], "plans", _PLAN_FIELDS) - parsed_plans = {name: _text(plans[name], f"plans.{name}") for name in sorted(_PLAN_FIELDS)} - if len(set(parsed_plans.values())) != len(parsed_plans): - msg = "Every acceptance labware plan identifier must be unique." - raise ValueError(msg) - return cls( - schema_version=version, - service_name=_text(data["service_name"], "service_name"), - expected_robot_host=_text(data["expected_robot_host"], "expected_robot_host"), - pipetting=PipettingConfig.parse(data["pipetting"]), - modules=ModuleConfig.parse(data["modules"]), - plans=parsed_plans, - ) - - @classmethod - def load(cls, path: str | Path) -> AcceptanceManifest: - """Load and validate a manifest before any connector call is attempted.""" - manifest_path = Path(path).expanduser() - try: - value = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - msg = f"Cannot load acceptance manifest {manifest_path}: {exc}" - raise ValueError(msg) from exc - return cls.parse(value) - - def commissioning_digest(self) -> str: - """Return a stable SHA-256 fingerprint for an operator-approved manifest.""" - payload = asdict(self) - payload["plans"] = dict(self.plans) - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() - return hashlib.sha256(encoded).hexdigest() - - -@dataclass(frozen=True) -class PlanContract: - """Non-geometric metadata used to validate an allowlisted movement graph.""" - - plan_identifier: str - labware_identifier: str - source_location: str - destination_location: str - is_lid: bool - - -def validate_plan_contract( - manifest: AcceptanceManifest, - plans: tuple[PlanContract, ...], - *, - deck_valid: bool, - occupancy: Mapping[str, str], -) -> None: - """Fail closed unless plans and durable occupancy form the expected campaign graph.""" - by_identifier = {plan.plan_identifier: plan for plan in plans} - missing = set(manifest.plans.values()) - set(by_identifier) - if missing: - msg = f"Server is missing acceptance movement plans: {sorted(missing)}." - raise ValueError(msg) - named = {name: by_identifier[identifier] for name, identifier in manifest.plans.items()} - lid_names = {"reader_lid_open", "reader_lid_close"} - for name, plan in named.items(): - if plan.is_lid is not (name in lid_names): - msg = f"Plan {plan.plan_identifier!r} has the wrong lid classification." - raise ValueError(msg) - - def reversible(outbound: str, inbound: str) -> None: - first = named[outbound] - second = named[inbound] - if ( - first.labware_identifier != second.labware_identifier - or first.source_location != second.destination_location - or first.destination_location != second.source_location - ): - msg = f"Acceptance plans {outbound!r} and {inbound!r} are not a reversible pair." - raise ValueError(msg) - - reversible("process_deck_to_thermocycler", "thermocycler_to_process_deck") - reversible("assay_deck_to_temperature_module", "temperature_module_to_assay_deck") - reversible("reader_lid_open", "reader_lid_close") - reversible("assay_deck_to_reader", "reader_to_assay_deck") - - process_chain = ( - named["thermocycler_to_process_deck"], - named["process_deck_to_heater_shaker"], - named["heater_shaker_to_magnetic_block"], - named["magnetic_block_to_process_deck"], - ) - if len({plan.labware_identifier for plan in process_chain}) != 1: - msg = "Thermocycler, Heater-Shaker, and Magnetic Block plans must use one process plate." - raise ValueError(msg) - process_locations = ( - (process_chain[0].destination_location, process_chain[1].source_location), - (process_chain[1].destination_location, process_chain[2].source_location), - (process_chain[2].destination_location, process_chain[3].source_location), - (process_chain[3].destination_location, process_chain[1].source_location), - ) - if any(left != right for left, right in process_locations): - msg = "Process-plate movement plans do not form a closed location chain." - raise ValueError(msg) - - temperature_return = named["temperature_module_to_assay_deck"] - reader_outbound = named["assay_deck_to_reader"] - if ( - temperature_return.labware_identifier != reader_outbound.labware_identifier - or temperature_return.destination_location != reader_outbound.source_location - ): - msg = "Temperature Module and Plate Reader plans must share one assay plate and deck location." - raise ValueError(msg) - if named["reader_lid_open"].labware_identifier == reader_outbound.labware_identifier: - msg = "The Plate Reader lid and assay plate must have distinct labware identifiers." - raise ValueError(msg) - - if not deck_valid: - msg = "Connector deck ledger is invalid. Reconcile physical locations before acceptance." - raise ValueError(msg) - for name in ("process_deck_to_thermocycler", "assay_deck_to_temperature_module", "reader_lid_open"): - plan = named[name] - if occupancy.get(plan.source_location) != plan.labware_identifier: - msg = ( - f"{plan.labware_identifier!r} must start at {plan.source_location!r}; " - f"deck ledger reports {occupancy.get(plan.source_location)!r}." - ) - raise ValueError(msg) - +"""Compatibility exports for the standalone Flex acceptance contract.""" + +from unitelabs_flex_acceptance_contract.acceptance import ( + AcceptanceManifest, + Coordinate, + ModuleConfig, + PipettingConfig, + PlanContract, + ThermocyclerStepConfig, + WellGeometryConfig, + validate_plan_contract, +) __all__ = [ "AcceptanceManifest", diff --git a/src/unitelabs/opentrons_flex/hitl_evidence.py b/src/unitelabs/opentrons_flex/hitl_evidence.py new file mode 100644 index 0000000..7bc1693 --- /dev/null +++ b/src/unitelabs/opentrons_flex/hitl_evidence.py @@ -0,0 +1,204 @@ +"""Cross-platform validation for no-motion HITL readiness evidence.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +HITL_READINESS_SCHEMA_VERSION = 1 +HITL_OFFLINE_VALIDATED_STATUS = "OFFLINE_VALIDATED" +HITL_READINESS_STATUS = "READY_FOR_HITL" +HITL_BLOCKED_STATUS = "BLOCKED" +HITL_HARDWARE_PASSED_STATUS = "HARDWARE_PASSED" +HITL_READINESS_MAX_AGE = timedelta(hours=4) + + +@dataclass(frozen=True) +class HitlReadinessEvidence: + """Validated readiness report bound to one robot, manifest, and runtime.""" + + host: str + generated_at: datetime + manifest_sha256: str + runtime_contract_id: str + connector_version: str + opentrons_version: str + python_version: str + release_id: str + bundle_sha256: str + report_sha256: str + + +async def settle_hitl_operations( + operations: Sequence[tuple[str, Callable[[], Awaitable[object]]]], +) -> tuple[str, ...]: + """Attempt every safety settlement operation and retain all failures.""" + errors = [] + for label, operation in operations: + try: + await operation() + except Exception as exc: # noqa: BLE001 - settlement attempts are independent + errors.append(f"{label}: {type(exc).__name__}: {exc}") + return tuple(errors) + + +def load_hitl_readiness_evidence( + path: str | Path, + *, + expected_host: str, + expected_manifest_sha256: str, + expected_runtime_contract_id: str, + now: datetime | None = None, +) -> HitlReadinessEvidence: + """Load a fresh readiness report and reject stale or mismatched evidence.""" + report_path = Path(path).expanduser() + try: + raw = report_path.read_bytes() + payload = json.loads(raw) + except (OSError, json.JSONDecodeError) as exc: + message = f"Cannot load HITL readiness report {report_path}: {exc}" + raise ValueError(message) from exc + if not isinstance(payload, Mapping): + message = "HITL readiness report must be a JSON object." + raise ValueError(message) + + required_fields = { + "schemaVersion", + "evidenceStatus", + "hardwarePassed", + "generatedAt", + "host", + "manifestSha256", + "runtimeContractId", + "releaseIdentity", + "runtime", + "checks", + } + missing = required_fields - set(payload) + if missing: + message = f"HITL readiness report is missing fields: {', '.join(sorted(missing))}." + raise ValueError(message) + if payload["schemaVersion"] != HITL_READINESS_SCHEMA_VERSION: + message = f"HITL readiness report schema must be {HITL_READINESS_SCHEMA_VERSION}." + raise ValueError(message) + if payload["evidenceStatus"] != HITL_READINESS_STATUS or payload["hardwarePassed"] is not False: + message = "HITL readiness report is not a no-motion READY_FOR_HITL result." + raise ValueError(message) + + host = _text(payload["host"], "host") + if host.casefold() != expected_host.casefold(): + message = f"HITL readiness report targets {host!r}; pytest targets {expected_host!r}." + raise ValueError(message) + manifest_sha256 = _sha256_text(payload["manifestSha256"], "manifestSha256") + if manifest_sha256 != expected_manifest_sha256: + message = "HITL readiness report does not match the selected acceptance manifest." + raise ValueError(message) + runtime_contract_id = _text(payload["runtimeContractId"], "runtimeContractId") + if runtime_contract_id != expected_runtime_contract_id: + message = ( + f"HITL readiness report uses runtime contract {runtime_contract_id!r}; " + f"this checkout requires {expected_runtime_contract_id!r}." + ) + raise ValueError(message) + release_identity = payload["releaseIdentity"] + if not isinstance(release_identity, Mapping): + message = "HITL readiness releaseIdentity must be a JSON object." + raise ValueError(message) + release_id = _text(release_identity.get("release_id"), "releaseIdentity.release_id") + bundle_sha256 = _sha256_text( + release_identity.get("bundle_sha256"), + "releaseIdentity.bundle_sha256", + ) + + generated_at = _timestamp(payload["generatedAt"]) + current_time = now or datetime.now(timezone.utc) + if current_time.tzinfo is None: + message = "Readiness validation time must be timezone-aware." + raise ValueError(message) + age = current_time.astimezone(timezone.utc) - generated_at + if age < -timedelta(minutes=5): + message = "HITL readiness report timestamp is in the future." + raise ValueError(message) + if age > HITL_READINESS_MAX_AGE: + message = "HITL readiness report is older than four hours; rerun the no-motion preflight." + raise ValueError(message) + + checks = payload["checks"] + if not isinstance(checks, list) or not checks: + message = "HITL readiness report has no check inventory." + raise ValueError(message) + if any(not isinstance(check, Mapping) or check.get("ok") is not True for check in checks): + message = "HITL readiness report contains a failed or malformed check." + raise ValueError(message) + + runtime = payload["runtime"] + if not isinstance(runtime, Mapping): + message = "HITL readiness runtime evidence must be a JSON object." + raise ValueError(message) + if runtime.get("runtime_contract_id") != runtime_contract_id: + message = "HITL readiness runtime contract fields are internally inconsistent." + raise ValueError(message) + if runtime.get("release_identity") != release_identity: + message = "HITL readiness release identity fields are internally inconsistent." + raise ValueError(message) + connector_version = _text(runtime.get("connector_version"), "runtime.connector_version") + opentrons_version = _text(runtime.get("opentrons_version"), "runtime.opentrons_version") + python_version = _text(runtime.get("python_version"), "runtime.python_version") + + return HitlReadinessEvidence( + host=host, + generated_at=generated_at, + manifest_sha256=manifest_sha256, + runtime_contract_id=runtime_contract_id, + connector_version=connector_version, + opentrons_version=opentrons_version, + python_version=python_version, + release_id=release_id, + bundle_sha256=bundle_sha256, + report_sha256=hashlib.sha256(raw).hexdigest(), + ) + + +def _text(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + message = f"HITL readiness {label} must be a non-empty string." + raise ValueError(message) + return value.strip() + + +def _sha256_text(value: object, label: str) -> str: + normalized = _text(value, label) + if len(normalized) != 64 or any(character not in "0123456789abcdef" for character in normalized): + message = f"HITL readiness {label} must be a lowercase SHA-256 digest." + raise ValueError(message) + return normalized + + +def _timestamp(value: object) -> datetime: + text = _text(value, "generatedAt") + try: + timestamp = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + message = "HITL readiness generatedAt must be an ISO-8601 timestamp." + raise ValueError(message) from exc + if timestamp.tzinfo is None: + message = "HITL readiness generatedAt must include a timezone." + raise ValueError(message) + return timestamp.astimezone(timezone.utc) + + +__all__ = [ + "HITL_BLOCKED_STATUS", + "HITL_HARDWARE_PASSED_STATUS", + "HITL_OFFLINE_VALIDATED_STATUS", + "HITL_READINESS_MAX_AGE", + "HITL_READINESS_SCHEMA_VERSION", + "HITL_READINESS_STATUS", + "HitlReadinessEvidence", + "load_hitl_readiness_evidence", + "settle_hitl_operations", +] diff --git a/src/unitelabs/opentrons_flex/protocol_preflight.py b/src/unitelabs/opentrons_flex/protocol_preflight.py index 9e5bf17..4fd09c3 100644 --- a/src/unitelabs/opentrons_flex/protocol_preflight.py +++ b/src/unitelabs/opentrons_flex/protocol_preflight.py @@ -25,6 +25,8 @@ from opentrons_shared_data.labware.labware_definition import labware_definition_type_adapter from pydantic import ValidationError +from .hitl_evidence import HITL_OFFLINE_VALIDATED_STATUS + @dataclass(frozen=True) class ProtocolPreflightReport: @@ -72,6 +74,7 @@ def to_dict(self) -> dict[str, Any]: result = asdict(self) result["exact_bundle_ready"] = self.exact_bundle_ready result["shadow_passed"] = self.shadow_passed + result["evidence_status"] = HITL_OFFLINE_VALIDATED_STATUS if self.exact_bundle_ready else "OFFLINE_INCOMPLETE" return result diff --git a/src/unitelabs/opentrons_flex/robot_readiness.py b/src/unitelabs/opentrons_flex/robot_readiness.py new file mode 100644 index 0000000..206e240 --- /dev/null +++ b/src/unitelabs/opentrons_flex/robot_readiness.py @@ -0,0 +1,120 @@ +"""Read the deployed runtime and robot-server inventory over robot loopback.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +_HTTP_PATHS = ( + "/health", + "/deck_configuration", + "/pipettes", + "/modules", + "/openapi.json", + "/unitelabs/runtime", +) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--http-port", type=int, default=31950) + parser.add_argument("--timeout", type=float, default=8.0) + parser.add_argument("--require-mutation", action="store_true") + parser.add_argument("--runtime-only", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + """Print one authenticated-transport readiness payload without initializing hardware.""" + args = _parser().parse_args(argv) + runtime_command = [ + sys.executable, + "-m", + "unitelabs.opentrons_flex.runtime_preflight", + "--config", + str(args.config), + "--require-robot-server", + "--require-live-hardware", + ] + if args.require_mutation: + runtime_command.append("--require-mutation") + try: + completed = subprocess.run( + runtime_command, + check=False, + capture_output=True, + text=True, + timeout=args.timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + sys.stderr.write(f"ERROR: runtime preflight failed: {exc}\n") + return 1 + if completed.returncode != 0: + sys.stderr.write(completed.stderr or completed.stdout) + return completed.returncode + try: + runtime = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + sys.stderr.write(f"ERROR: runtime preflight returned invalid JSON: {exc}\n") + return 1 + + http_payloads: dict[str, object] = {} + if not args.runtime_only: + for path in _HTTP_PATHS: + try: + http_payloads[path] = _loopback_json(args.http_port, path, args.timeout) + except RuntimeError as exc: + sys.stderr.write(f"ERROR: {exc}\n") + return 1 + service_identity = http_payloads["/unitelabs/runtime"] + if not _service_matches_runtime(service_identity, runtime): + sys.stderr.write( + "ERROR: the running connector process does not match the active release/runtime preflight.\n" + ) + return 1 + sys.stdout.write(json.dumps({"runtime": runtime, "http": http_payloads}, sort_keys=True) + "\n") + return 0 + + +def _loopback_json(port: int, path: str, timeout: float) -> object: + url = f"http://127.0.0.1:{port}{path}" + request = urllib.request.Request( + url, + headers={ + "Opentrons-Version": "*", + "User-Agent": "unitelabs-flex-robot-readiness", + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + if response.geturl() != url: + message = f"robot loopback {path} redirected to {response.geturl()}" + raise RuntimeError(message) + if response.status != 200: + message = f"robot loopback {path} returned HTTP {response.status}" + raise RuntimeError(message) + return json.loads(response.read().decode("utf-8")) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: + message = f"robot loopback {path} failed: {exc}" + raise RuntimeError(message) from exc + + +def _service_matches_runtime(service_identity: object, runtime: object) -> bool: + if not isinstance(service_identity, dict) or not isinstance(runtime, dict): + return False + return ( + service_identity.get("runtimeContractId") == runtime.get("runtime_contract_id") + and service_identity.get("connectorVersion") == runtime.get("connector_version") + and service_identity.get("opentronsVersion") == runtime.get("opentrons_version") + and service_identity.get("releaseIdentity") == runtime.get("release_identity") + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/unitelabs/opentrons_flex/robot_server_compat.py b/src/unitelabs/opentrons_flex/robot_server_compat.py new file mode 100644 index 0000000..683be94 --- /dev/null +++ b/src/unitelabs/opentrons_flex/robot_server_compat.py @@ -0,0 +1,309 @@ +"""Fail-closed adapter for the connector's version-pinned robot-server APIs.""" + +from __future__ import annotations + +import importlib +from collections.abc import Callable, Mapping, MutableMapping, MutableSequence +from dataclasses import dataclass + +from .io.run_authority import ProtocolRunState + + +@dataclass(frozen=True) +class RobotServerBindings: + """Private robot-server objects validated before hardware initialization.""" + + app: object + hardware_accessor: object + initialization_task_accessor: object + get_deck_type: object + get_robot_type: object + get_robot_type_enum: object + mark_light_control_startup_finished: object + start_light_control_task: object + + +@dataclass(frozen=True) +class RobotServerAppInstallation: + """Reversible mutation of the global robot-server FastAPI app.""" + + app: object + router: object + dependency_overrides: MutableMapping[object, object] + original_lifespan: object + original_identity_overrides: tuple[tuple[object, bool, object | None], ...] + + +def load_robot_server_bindings() -> RobotServerBindings: + """Load the exact private bindings covered by the runtime contract.""" + try: + app_module = importlib.import_module("robot_server.app") + hardware_module = importlib.import_module("robot_server.hardware") + run_dependencies = importlib.import_module("robot_server.runs.dependencies") + return RobotServerBindings( + app=_required_attribute(app_module, "app", "robot_server.app"), + hardware_accessor=_required_attribute( + hardware_module, + "_hw_api_accessor", + "robot_server.hardware", + ), + initialization_task_accessor=_required_attribute( + hardware_module, + "_init_task_accessor", + "robot_server.hardware", + ), + get_deck_type=_required_callable(hardware_module, "get_deck_type", "robot_server.hardware"), + get_robot_type=_required_callable(hardware_module, "get_robot_type", "robot_server.hardware"), + get_robot_type_enum=_required_callable( + hardware_module, + "get_robot_type_enum", + "robot_server.hardware", + ), + mark_light_control_startup_finished=_required_callable( + run_dependencies, + "mark_light_control_startup_finished", + "robot_server.runs.dependencies", + ), + start_light_control_task=_required_callable( + run_dependencies, + "start_light_control_task", + "robot_server.runs.dependencies", + ), + ) + except (ImportError, OSError) as exc: + message = f"Validated robot-server bindings could not be loaded: {exc}" + raise RuntimeError(message) from exc + + +def current_run_store( + bindings: RobotServerBindings, + *, + robot_server_ready: bool, +) -> object | None: + """Return the current run store, allowing absence only during startup.""" + app_state = _required_attribute(bindings.app, "state", "robot_server.app.app") + store = getattr(app_state, "run_orchestrator_store", None) + if store is None and robot_server_ready: + message = ( + "robot-server startup completed without the validated run_orchestrator_store. " + "Direct Flex control remains unavailable." + ) + raise RuntimeError(message) + return store + + +def install_robot_server_app( + bindings: RobotServerBindings, + *, + initialization_task: object, + hardware_proxy: object, + identity_overrides: Mapping[object, object], + lifespan_factory: Callable[[object], object], +) -> RobotServerAppInstallation: + """Install shared hardware and reversible Flex identity/lifespan wiring.""" + app = bindings.app + app_state = _required_attribute(app, "state", "robot_server.app.app") + set_initialization_task = _required_method( + bindings.initialization_task_accessor, + "set_on", + "robot-server initialization task accessor", + ) + set_hardware = _required_method( + bindings.hardware_accessor, + "set_on", + "robot-server hardware accessor", + ) + + dependency_overrides = _required_attribute( + app, + "dependency_overrides", + "robot_server.app.app", + ) + if not isinstance(dependency_overrides, MutableMapping): + message = "robot_server.app.app.dependency_overrides must be a mutable mapping." + raise RuntimeError(message) + router = _required_attribute(app, "router", "robot_server.app.app") + original_lifespan = _required_attribute(router, "lifespan_context", "robot-server app router") + replacement_lifespan = lifespan_factory(original_lifespan) + if not callable(replacement_lifespan): + message = "The shared-hardware robot-server lifespan factory must return a callable." + raise RuntimeError(message) + + original_overrides = tuple( + (dependency, dependency in dependency_overrides, dependency_overrides.get(dependency)) + for dependency in identity_overrides + ) + + # All fallible shape validation happens above. Mutate the pinned global app + # only after the complete installation contract is known to be usable. + set_initialization_task(app_state, initialization_task) + set_hardware(app_state, hardware_proxy) + dependency_overrides.update(identity_overrides) + router.lifespan_context = replacement_lifespan + return RobotServerAppInstallation( + app=app, + router=router, + dependency_overrides=dependency_overrides, + original_lifespan=original_lifespan, + original_identity_overrides=original_overrides, + ) + + +def include_robot_server_router( + installation: RobotServerAppInstallation, + router: object, +) -> tuple[object, ...]: + """Include one temporary router and return the exact route identities added.""" + temporary_routes = _required_attribute(router, "routes", "temporary robot-server router") + if not isinstance(temporary_routes, MutableSequence): + message = "Temporary robot-server router routes must be a mutable sequence." + raise RuntimeError(message) + app_routes = _required_attribute(installation.router, "routes", "robot-server app router") + if not isinstance(app_routes, MutableSequence): + message = "robot-server app router routes must be a mutable sequence." + raise RuntimeError(message) + include_router = _required_method(installation.app, "include_router", "robot_server.app.app") + original_route_ids = {id(route) for route in app_routes} + try: + include_router(router) + except Exception: + app_routes[:] = [route for route in app_routes if id(route) in original_route_ids] + installation.app.openapi_schema = None + raise + added_routes = tuple(route for route in app_routes if id(route) not in original_route_ids) + if temporary_routes and not added_routes: + message = "robot_server.app.app.include_router did not register the requested routes." + raise RuntimeError(message) + installation.app.openapi_schema = None + return added_routes + + +def restore_robot_server_app( + installation: RobotServerAppInstallation, + *, + added_routes: tuple[object, ...], +) -> None: + """Restore global robot-server app state after connector shutdown.""" + installation.router.lifespan_context = installation.original_lifespan + if added_routes: + routes = _required_attribute(installation.router, "routes", "robot-server app router") + if not isinstance(routes, MutableSequence): + message = "robot-server app router routes must remain a mutable sequence." + raise RuntimeError(message) + added_route_ids = {id(route) for route in added_routes} + routes[:] = [route for route in routes if id(route) not in added_route_ids] + installation.app.openapi_schema = None + for dependency, existed, original in installation.original_identity_overrides: + if existed: + installation.dependency_overrides[dependency] = original + else: + installation.dependency_overrides.pop(dependency, None) + + +def protocol_run_state( + store: object | None, + *, + robot_server_ready: bool, + checkpoint_prefix: str, +) -> ProtocolRunState: + """Read the private run-store contract used to arbitrate hardware ownership.""" + if store is None: + if not robot_server_ready: + return ProtocolRunState( + run_id="robot-server-startup", + status="initializing", + started=False, + terminal=False, + ) + return ProtocolRunState(run_id=None, status="idle", started=False, terminal=True) + + run_id = _required_attribute(store, "current_run_id", "robot-server run store") + if run_id is None: + return ProtocolRunState(run_id=None, status="idle", started=False, terminal=True) + if not isinstance(run_id, str) or not run_id: + message = "robot-server run store current_run_id must be a non-empty string or None." + raise RuntimeError(message) + + status = _required_method(store, "get_status", "robot-server run store")() + status_value = str(getattr(status, "value", status)) + orchestrator = _required_attribute(store, "run_orchestrator", "robot-server run store") + protocol_runner = _required_attribute( + orchestrator, + "_protocol_runner", + "robot-server run orchestrator", + ) + checkpoint_id = None + current_pointer = _required_method(store, "get_current_command", "robot-server run store")() + if current_pointer is not None: + command_id = _required_attribute( + current_pointer, + "command_id", + "robot-server current command pointer", + ) + current_command = _required_method(store, "get_command", "robot-server run store")(command_id) + params = _required_attribute(current_command, "params", "robot-server current command") + message = getattr(params, "message", None) + command_status = _required_attribute( + current_command, + "status", + "robot-server current command", + ) + command_status_value = str(getattr(command_status, "value", command_status)) + command_type = _required_attribute( + current_command, + "commandType", + "robot-server current command", + ) + if ( + command_type == "waitForResume" + and command_status_value == "running" + and isinstance(message, str) + and message.startswith(checkpoint_prefix) + ): + checkpoint_id = _required_attribute( + current_command, + "id", + "robot-server current command", + ) + started = _required_method(store, "run_was_started", "robot-server run store")() + + return ProtocolRunState( + run_id=run_id, + status=status_value, + started=bool(started), + terminal=status_value in {"succeeded", "failed", "stopped"}, + mutation_checkpoint_id=checkpoint_id, + protocol_less=protocol_runner is None, + ) + + +def _required_attribute(value: object, name: str, owner: str) -> object: + try: + return getattr(value, name) + except AttributeError as exc: + message = f"{owner}.{name} is unavailable in the installed robot-server runtime." + raise RuntimeError(message) from exc + + +def _required_callable(value: object, name: str, owner: str) -> object: + candidate = _required_attribute(value, name, owner) + if not callable(candidate): + message = f"{owner}.{name} must be callable in the installed robot-server runtime." + raise RuntimeError(message) + return candidate + + +def _required_method(value: object, name: str, owner: str) -> object: + return _required_callable(value, name, owner) + + +__all__ = [ + "RobotServerAppInstallation", + "RobotServerBindings", + "current_run_store", + "include_robot_server_router", + "install_robot_server_app", + "load_robot_server_bindings", + "protocol_run_state", + "restore_robot_server_app", +] diff --git a/src/unitelabs/opentrons_flex/runtime_compat.py b/src/unitelabs/opentrons_flex/runtime_compat.py index df268dd..1032b26 100644 --- a/src/unitelabs/opentrons_flex/runtime_compat.py +++ b/src/unitelabs/opentrons_flex/runtime_compat.py @@ -2,14 +2,18 @@ from __future__ import annotations +import hashlib import importlib +import json import sys +from collections.abc import MutableMapping, MutableSequence from dataclasses import asdict, dataclass from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any SUPPORTED_OPENTRONS_VERSION = "9.0.0" +SUPPORTED_OPENTRONS_SOURCE_COMMIT = "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5" SUPPORTED_PYTHON_VERSION = (3, 10) SUPPORTED_RUNTIME_PACKAGES = { "aiohttp": "3.12.14", @@ -24,6 +28,7 @@ "python-dotenv": "1.0.1", "python-multipart": "0.0.18", "SQLAlchemy": "1.4.51", + "unitelabs-flex-acceptance-contract": "0.1.0", "uvicorn": "0.27.0.post1", "wsproto": "1.2.0", } @@ -39,6 +44,32 @@ ("robot_server.runs.dependencies", "start_light_control_task"), ) +_ROBOT_SERVER_SHAPE_REQUIREMENTS = ( + ("robot_server.hardware", "_hw_api_accessor.set_on", "callable"), + ("robot_server.hardware", "_init_task_accessor.set_on", "callable"), + ("robot_server.hardware", "get_deck_type", "callable"), + ("robot_server.hardware", "get_robot_type", "callable"), + ("robot_server.hardware", "get_robot_type_enum", "callable"), + ("robot_server.runs.dependencies", "mark_light_control_startup_finished", "callable"), + ("robot_server.runs.dependencies", "start_light_control_task", "callable"), + ("robot_server.app", "app.state", "attribute"), + ("robot_server.app", "app.dependency_overrides", "mutable-mapping"), + ("robot_server.app", "app.router.lifespan_context", "callable"), + ("robot_server.app", "app.router.routes", "mutable-sequence"), + ("robot_server.app", "app.include_router", "callable"), + ("robot_server.app", "app.openapi_schema", "attribute"), +) + +_ROBOT_SERVER_INSTANCE_CONTRACTS = ( + "app.state.run_orchestrator_store.current_run_id", + "app.state.run_orchestrator_store.get_status()", + "app.state.run_orchestrator_store.run_was_started()", + "app.state.run_orchestrator_store.get_current_command()", + "app.state.run_orchestrator_store.get_command(command_id)", + "app.state.run_orchestrator_store.run_orchestrator._protocol_runner", + "app.state.run_orchestrator_store.run_orchestrator._protocol_engine.state_view", +) + _OPENTRONS_BASE_SYMBOLS = ( ("opentrons.hardware_control.ot3api", "OT3API"), ("opentrons.hardware_control.types", "OT3Mount"), @@ -46,9 +77,12 @@ ("opentrons_shared_data.robot.types", "RobotTypeEnum"), ) -_RELEASE_MODULES = ( +_BASE_RELEASE_MODULES = ( "opentrons", "opentrons_shared_data", +) + +_ROBOT_SERVER_RELEASE_MODULES = ( "opentrons_hardware", "server_utils", ) @@ -60,6 +94,15 @@ ) +@dataclass(frozen=True) +class RuntimeContractCheck: + """One machine-readable private runtime contract check.""" + + name: str + ok: bool + detail: str + + @dataclass(frozen=True) class RuntimeCompatibilityReport: """Read-only compatibility evidence gathered before hardware initialization.""" @@ -70,6 +113,8 @@ class RuntimeCompatibilityReport: robot_server_version: str | None robot_server_source: str | None runtime_package_versions: dict[str, str] + runtime_contract_id: str + private_api_checks: tuple[RuntimeContractCheck, ...] base_compatible: bool mutation_compatible: bool issues: tuple[str, ...] @@ -109,15 +154,19 @@ def inspect_runtime_compatibility(*, require_robot_server: bool) -> RuntimeCompa issue = _missing_symbol_issue(module_name, attribute_name) if issue is not None: issues.append(issue) - for module_name in _RELEASE_MODULES: + release_modules = _BASE_RELEASE_MODULES + if require_robot_server: + release_modules = (*release_modules, *_ROBOT_SERVER_RELEASE_MODULES) + for module_name in release_modules: issue = _module_outside_release_issue(module_name) if issue is not None: issues.append(issue) robot_server_version: str | None = None robot_server_source: str | None = None + private_api_checks: tuple[RuntimeContractCheck, ...] = () if require_robot_server: - robot_server_version, robot_server_source, robot_server_issues = _inspect_robot_server() + robot_server_version, robot_server_source, robot_server_issues, private_api_checks = _inspect_robot_server() issues.extend(robot_server_issues) for module_name, attribute_name in _MUTATION_SYMBOLS: @@ -136,6 +185,8 @@ def inspect_runtime_compatibility(*, require_robot_server: bool) -> RuntimeCompa robot_server_version=robot_server_version, robot_server_source=robot_server_source, runtime_package_versions=runtime_package_versions, + runtime_contract_id=RUNTIME_CONTRACT_ID, + private_api_checks=private_api_checks, base_compatible=base_compatible, mutation_compatible=mutation_compatible, issues=tuple(issues), @@ -171,12 +222,17 @@ def mutation_configuration_issues( return tuple(issues) -def _inspect_robot_server() -> tuple[str | None, str | None, tuple[str, ...]]: +def _inspect_robot_server() -> tuple[ + str | None, + str | None, + tuple[str, ...], + tuple[RuntimeContractCheck, ...], +]: issues: list[str] = [] try: package = importlib.import_module("robot_server") except (ImportError, OSError) as exc: - return None, None, (f"robot_server could not be imported: {exc}",) + return None, None, (f"robot_server could not be imported: {exc}",), () source_value = getattr(package, "__file__", None) source = str(Path(source_value).resolve()) if isinstance(source_value, str) else None @@ -198,7 +254,12 @@ def _inspect_robot_server() -> tuple[str | None, str | None, tuple[str, ...]]: issue = _missing_symbol_issue(module_name, attribute_name) if issue is not None: issues.append(issue) - return robot_server_version, source, tuple(issues) + private_api_checks = tuple( + _shape_check(module_name, attribute_path, expected_kind) + for module_name, attribute_path, expected_kind in _ROBOT_SERVER_SHAPE_REQUIREMENTS + ) + issues.extend(check.detail for check in private_api_checks if not check.ok) + return robot_server_version, source, tuple(issues), private_api_checks def _robot_server_version() -> str | None: @@ -225,6 +286,30 @@ def _missing_symbol_issue(module_name: str, attribute_name: str) -> str | None: return None +def _shape_check(module_name: str, attribute_path: str, expected_kind: str) -> RuntimeContractCheck: + name = f"{module_name}.{attribute_path}" + try: + value: object = importlib.import_module(module_name) + for part in attribute_path.split("."): + value = getattr(value, part) + except (ImportError, OSError, AttributeError) as exc: + return RuntimeContractCheck(name=name, ok=False, detail=f"{name} is unavailable: {exc}") + + kind_ok = { + "attribute": True, + "callable": callable(value), + "mutable-mapping": isinstance(value, MutableMapping), + "mutable-sequence": isinstance(value, MutableSequence), + }[expected_kind] + if not kind_ok: + return RuntimeContractCheck( + name=name, + ok=False, + detail=f"{name} does not satisfy the required {expected_kind} contract.", + ) + return RuntimeContractCheck(name=name, ok=True, detail=f"{name} satisfies {expected_kind}.") + + def _module_outside_release_issue(module_name: str) -> str | None: try: module = importlib.import_module(module_name) @@ -249,11 +334,31 @@ def _package_version(package_name: str) -> str: return "not-installed" +def _runtime_contract_id() -> str: + payload = { + "python": SUPPORTED_PYTHON_VERSION, + "opentrons": SUPPORTED_OPENTRONS_VERSION, + "opentronsSourceCommit": SUPPORTED_OPENTRONS_SOURCE_COMMIT, + "packages": SUPPORTED_RUNTIME_PACKAGES, + "robotServerShapes": _ROBOT_SERVER_SHAPE_REQUIREMENTS, + "robotServerInstances": _ROBOT_SERVER_INSTANCE_CONTRACTS, + "mutationSymbols": _MUTATION_SYMBOLS, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return f"flex-runtime-{hashlib.sha256(encoded).hexdigest()[:16]}" + + +RUNTIME_CONTRACT_ID = _runtime_contract_id() + + __all__ = [ + "RUNTIME_CONTRACT_ID", + "SUPPORTED_OPENTRONS_SOURCE_COMMIT", "SUPPORTED_OPENTRONS_VERSION", "SUPPORTED_PYTHON_VERSION", "SUPPORTED_RUNTIME_PACKAGES", "RuntimeCompatibilityReport", + "RuntimeContractCheck", "inspect_runtime_compatibility", "mutation_configuration_issues", "require_compatible_runtime", diff --git a/src/unitelabs/opentrons_flex/runtime_identity_api.py b/src/unitelabs/opentrons_flex/runtime_identity_api.py new file mode 100644 index 0000000..962d0fc --- /dev/null +++ b/src/unitelabs/opentrons_flex/runtime_identity_api.py @@ -0,0 +1,54 @@ +"""Process-bound release identity exposed by the embedded robot-server.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import APIRouter + +from .runtime_compat import RuntimeCompatibilityReport +from .runtime_preflight import _release_identity + +# Resolve these once when the connector process imports this module. If the +# /var/sila2_flex symlink changes without a service restart, the running process +# continues reporting the immutable release directory it actually launched from. +_PROCESS_RELEASE_PREFIX = Path(sys.prefix).resolve() + + +def process_runtime_identity( + compatibility: RuntimeCompatibilityReport, + *, + require_release: bool, +) -> dict[str, object]: + """Build identity evidence bound to this running Python process.""" + release_identity, issues = _release_identity( + connector_version=compatibility.connector_version, + opentrons_version=compatibility.opentrons_version, + required=require_release, + prefix=_PROCESS_RELEASE_PREFIX, + ) + if issues: + detail = " ".join(issues) + message = f"Running connector release identity is invalid: {detail}" + raise RuntimeError(message) + return { + "runtimeContractId": compatibility.runtime_contract_id, + "connectorVersion": compatibility.connector_version, + "opentronsVersion": compatibility.opentrons_version, + "releaseIdentity": release_identity, + } + + +def create_runtime_identity_router(identity: dict[str, object]) -> APIRouter: + """Create the read-only identity endpoint served by the live process.""" + router = APIRouter() + + @router.get("/unitelabs/runtime", include_in_schema=True) + async def get_runtime_identity() -> dict[str, object]: + return dict(identity) + + return router + + +__all__ = ["create_runtime_identity_router", "process_runtime_identity"] diff --git a/src/unitelabs/opentrons_flex/runtime_preflight.py b/src/unitelabs/opentrons_flex/runtime_preflight.py index 0f12931..0a9d192 100644 --- a/src/unitelabs/opentrons_flex/runtime_preflight.py +++ b/src/unitelabs/opentrons_flex/runtime_preflight.py @@ -5,11 +5,16 @@ import argparse import json import os +import platform import sys from pathlib import Path from typing import Any -from .runtime_compat import inspect_runtime_compatibility, mutation_configuration_issues +from .runtime_compat import ( + SUPPORTED_OPENTRONS_SOURCE_COMMIT, + inspect_runtime_compatibility, + mutation_configuration_issues, +) def _parser() -> argparse.ArgumentParser: @@ -58,6 +63,12 @@ def main(argv: list[str] | None = None) -> int: require_robot_server=args.require_robot_server, require_live_hardware=args.require_live_hardware, ) + release_identity, release_issues = _release_identity( + connector_version=report.connector_version, + opentrons_version=report.opentrons_version, + required=args.require_live_hardware, + ) + config_issues = (*config_issues, *release_issues) token_env = str(config.get("run_mutation_token_env", "UNITELABS_RUN_MUTATION_TOKEN")) actor_env = str(config.get("run_mutation_actor_env", "UNITELABS_RUN_MUTATION_ACTOR")) mutation_issues = mutation_configuration_issues( @@ -69,6 +80,7 @@ def main(argv: list[str] | None = None) -> int: mutation_ready = not mutation_issues payload = { **report.to_dict(), + "release_identity": release_identity, "configuration_issues": config_issues, "mutation_requested": config.get("run_mutation_ledger_path") is not None, "mutation_required": bool(config.get("run_mutation_required", False)), @@ -111,5 +123,65 @@ def _configuration_issues( return tuple(issues) +def _release_identity( + *, + connector_version: str, + opentrons_version: str, + required: bool, + prefix: Path | None = None, +) -> tuple[dict[str, str] | None, tuple[str, ...]]: + manifest_path = (prefix or Path(sys.prefix)).resolve() / "runtime-manifest.json" + if not manifest_path.is_file(): + issues = (f"Active release manifest is missing: {manifest_path}.",) if required else () + return None, issues + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, (f"Cannot read active release manifest {manifest_path}: {exc}",) + expected = { + "schemaVersion": 1, + "connectorVersion": connector_version, + "opentronsVersion": opentrons_version, + "robotServerVersion": opentrons_version, + "opentronsSourceCommit": SUPPORTED_OPENTRONS_SOURCE_COMMIT, + "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}", + "architecture": _normalized_architecture(), + } + issues = [ + f"Active release manifest {name} is {manifest.get(name)!r}; expected {value!r}." + for name, value in expected.items() + if manifest.get(name) != value + ] + release_id = manifest.get("releaseId") + bundle_sha256 = manifest.get("bundleSha256") + if not isinstance(release_id, str) or not release_id: + issues.append("Active release manifest releaseId is missing.") + if ( + not isinstance(bundle_sha256, str) + or len(bundle_sha256) != 64 + or any(character not in "0123456789abcdef" for character in bundle_sha256) + ): + issues.append("Active release manifest bundleSha256 is invalid.") + if isinstance(release_id, str) and release_id and isinstance(bundle_sha256, str): + expected_release_id = ( + f"flex-{connector_version}-ot{opentrons_version}-" + f"py{sys.version_info.major}.{sys.version_info.minor}-" + f"{_normalized_architecture()}-{bundle_sha256[:12]}" + ) + if release_id != expected_release_id: + issues.append(f"Active release manifest releaseId is {release_id!r}; expected {expected_release_id!r}.") + if issues: + return None, tuple(issues) + return { + "release_id": release_id, + "bundle_sha256": bundle_sha256, + }, () + + +def _normalized_architecture() -> str: + architecture = platform.machine().lower() + return {"arm64": "aarch64", "amd64": "x86_64"}.get(architecture, architecture) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index ac930fb..d623f28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,16 +51,29 @@ async def _stub_get_deck_type(): _rs_hw.get_deck_type = _stub_get_deck_type _rs_app = types.ModuleType("robot_server.app") _rs_app.app = MagicMock(name="robot_server_app") + _rs_app.app.state = types.SimpleNamespace() _rs_app.app.dependency_overrides = {} + _rs_app.app.router.routes = [] + _rs_app.app.router.lifespan_context = MagicMock(name="robot_server_lifespan") + + def _include_router(router): + _rs_app.app.router.routes.extend(router.routes) + + _rs_app.app.include_router = MagicMock(name="include_router", side_effect=_include_router) + _rs_app.app.openapi_schema = None _rs_runs = types.ModuleType("robot_server.runs") _rs_dependencies = types.ModuleType("robot_server.runs.dependencies") _rs_dependencies.start_light_control_task = MagicMock(name="start_light_control_task") _rs_dependencies.mark_light_control_startup_finished = MagicMock(name="mark_light_control_startup_finished") + _opentrons_hardware = types.ModuleType("opentrons_hardware") + _server_utils = types.ModuleType("server_utils") sys.modules["robot_server"] = _rs sys.modules["robot_server.hardware"] = _rs_hw sys.modules["robot_server.app"] = _rs_app sys.modules["robot_server.runs"] = _rs_runs sys.modules["robot_server.runs.dependencies"] = _rs_dependencies + sys.modules["opentrons_hardware"] = _opentrons_hardware + sys.modules["server_utils"] = _server_utils try: import unitelabs.bus.testing.fixtures diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e8d4d03..f39f649 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -36,8 +36,11 @@ import pytest import pytest_asyncio +from scripts import preflight_flex from unitelabs.cdk import SiLAServerConfig from unitelabs.opentrons_flex import OpentronsFlexConfig, create_app +from unitelabs.opentrons_flex.hitl_evidence import HitlReadinessEvidence, load_hitl_readiness_evidence +from unitelabs.opentrons_flex.runtime_compat import RUNTIME_CONTRACT_ID log = logging.getLogger("opentrons_flex.tests") @@ -200,6 +203,21 @@ def pytest_addoption(parser: pytest.Parser) -> None: "adapter, module, safe test liquid, tip, lid, and Stacker shuttle before opening this gate." ), ) + parser.addoption( + "--acceptance-readiness-report", + metavar="PATH", + default=None, + help=( + "Fresh READY_FOR_HITL JSON from scripts/preflight_flex.py. Required by the complete acceptance workflow " + "and bound to the selected robot, manifest, and runtime contract." + ), + ) + parser.addoption( + "--acceptance-runtime-manifest", + metavar="PATH", + default=None, + help="runtime-manifest.json from the exact verified wheel artifact deployed to the target Flex.", + ) def _is_hardware_run(config: pytest.Config) -> bool: @@ -363,6 +381,64 @@ def acceptance_manifest(request: pytest.FixtureRequest): return AcceptanceManifest.load(path) +@pytest.fixture(scope="session") +def acceptance_readiness_evidence( + request: pytest.FixtureRequest, + acceptance_manifest, + run_context: RunContext, +) -> HitlReadinessEvidence: + """Validate the no-motion evidence required before full workflow actuation.""" + path = request.config.getoption("--acceptance-readiness-report") + if path is None: + pytest.fail( + "--acceptance-readiness-report is required for the full acceptance workflow. " + "Generate it on the operator computer with scripts/preflight_flex.py immediately before HITL.", + pytrace=False, + ) + manifest_path = request.config.getoption("--acceptance-manifest") + runtime_manifest_path = request.config.getoption("--acceptance-runtime-manifest") + robot = request.config.getoption("--robot") + if runtime_manifest_path is None: + pytest.fail( + "--acceptance-runtime-manifest is required to bind HITL to the exact deployed wheel artifact.", + pytrace=False, + ) + if robot is None: + pytest.fail("--robot HOST:PORT is required for the full acceptance workflow.", pytrace=False) + grpc_port = robot.rsplit(":", 1)[1] + robot_http = request.config.getoption("--robot-http") + http_port = robot_http.rsplit(":", 1)[1] if robot_http and ":" in robot_http else str(_HTTP_API_PORT) + result = preflight_flex.main( + [ + run_context.device_id, + "--grpc-port", + grpc_port, + "--http-port", + http_port, + "--acceptance-manifest", + manifest_path, + "--runtime-manifest", + runtime_manifest_path, + "--output", + path, + ] + ) + if result != 0: + pytest.fail( + "The live no-motion preflight failed; HITL actuation remains disabled.", + pytrace=False, + ) + try: + return load_hitl_readiness_evidence( + path, + expected_host=run_context.device_id, + expected_manifest_sha256=acceptance_manifest.commissioning_digest(), + expected_runtime_contract_id=RUNTIME_CONTRACT_ID, + ) + except ValueError as exc: + pytest.fail(str(exc), pytrace=False) + + @pytest.fixture(scope="session") def is_smoketest_http(request: pytest.FixtureRequest) -> bool: """Whether tests are using the local simulator HTTP stack.""" diff --git a/tests/integration/hardware/test_hitl_full_workflow.py b/tests/integration/hardware/test_hitl_full_workflow.py index cd77ca5..50322df 100644 --- a/tests/integration/hardware/test_hitl_full_workflow.py +++ b/tests/integration/hardware/test_hitl_full_workflow.py @@ -9,7 +9,6 @@ from __future__ import annotations import asyncio -import contextlib import grpc.aio import httpx import pytest @@ -42,6 +41,12 @@ ) from unitelabs.opentrons_flex.features.motion_control import MachineStatus from unitelabs.opentrons_flex.features.thermocycler import ThermocyclerProfileStep +from unitelabs.opentrons_flex.hitl_evidence import ( + HITL_HARDWARE_PASSED_STATUS, + HITL_READINESS_STATUS, + HitlReadinessEvidence, + settle_hitl_operations, +) from ..absorbance_reader_client import AbsorbanceReaderClient from ..flex_stacker_client import FlexStackerClient @@ -195,6 +200,8 @@ async def _preflight( f"{name} serial mismatch: expected {expected_serial!r}, found {info.serial_number!r}" ) record_property(f"{name}_serial", info.serial_number) + record_property(f"{name}_model", info.model) + record_property(f"{name}_firmware", info.firmware_version) reader_status = await reader.get_status() unsupported = set(manifest.modules.plate_reader_wavelengths) - set(reader_status.supported_wavelengths) @@ -215,8 +222,9 @@ async def _preflight( async def test_manifest_driven_full_flex_acceptance_workflow( - sila_channel, acceptance_manifest: AcceptanceManifest, + acceptance_readiness_evidence: HitlReadinessEvidence, + sila_channel, run_context, http_client: httpx.Client, record_property, @@ -234,6 +242,7 @@ async def test_manifest_driven_full_flex_acceptance_workflow( reader = AbsorbanceReaderClient(channel, protobuf) stacker = FlexStackerClient(channel, protobuf) manifest = acceptance_manifest + readiness = acceptance_readiness_evidence p = manifest.pipetting m = manifest.modules plans = manifest.plans @@ -253,6 +262,14 @@ async def test_manifest_driven_full_flex_acceptance_workflow( record_property, ) record_property("acceptance_schema_version", manifest.schema_version) + record_property("readiness_evidence_status", HITL_READINESS_STATUS) + record_property("readiness_report_sha256", readiness.report_sha256) + record_property("runtime_contract_id", readiness.runtime_contract_id) + record_property("connector_version", readiness.connector_version) + record_property("opentrons_version", readiness.opentrons_version) + record_property("python_version", readiness.python_version) + record_property("release_id", readiness.release_id) + record_property("bundle_sha256", readiness.bundle_sha256) try: record_property("phase_01", "home_and_configuration") @@ -421,9 +438,6 @@ async def test_manifest_driven_full_flex_acceptance_workflow( await _assert_machine_ok(motion, "at workflow completion") await _assert_deck_valid(labware, "at workflow completion") - record_property("acceptance_result", "completed") - # A failed run must never leave an approval-looking fingerprint in JUnit. - record_property("commissioned_manifest_sha256", manifest.commissioning_digest()) finally: # Only de-energize mechanisms here. Never guess a recovery # gripper route after a failed physical move; the operator must reconcile @@ -431,17 +445,24 @@ async def test_manifest_driven_full_flex_acceptance_workflow( # A failed pipetting move may leave the tip or liquid state uncertain. # Do not add another gantry move in cleanup; the operator must reconcile # and discard the tip after confirming that the robot is safe to home. - with contextlib.suppress(Exception): - await heater_shaker.stop_shaking() - with contextlib.suppress(Exception): - await heater_shaker.deactivate_heater() - with contextlib.suppress(Exception): - await thermocycler.deactivate_all() - with contextlib.suppress(Exception): - await temperature_module.deactivate() - with contextlib.suppress(Exception): - await reader.deactivate() - with contextlib.suppress(Exception): - await stacker.deactivate() - with contextlib.suppress(Exception): - await motion.observable("SetLights", {"button": False, "rails": False}) + cleanup_errors = await settle_hitl_operations( + ( + ("stop Heater-Shaker motion", heater_shaker.stop_shaking), + ("deactivate Heater-Shaker", heater_shaker.deactivate_heater), + ("deactivate Thermocycler", thermocycler.deactivate_all), + ("deactivate Temperature Module", temperature_module.deactivate), + ("deactivate Plate Reader", reader.deactivate), + ("deactivate Flex Stacker", stacker.deactivate), + ("switch off Flex lights", lambda: motion.observable("SetLights", {"button": False, "rails": False})), + ) + ) + + if cleanup_errors: + pytest.fail( + "Physical workflow completed but safety settlement was incomplete; " + f"HARDWARE_PASSED was not issued: {'; '.join(cleanup_errors)}" + ) + record_property("acceptance_result", "completed") + record_property("hardware_evidence_status", HITL_HARDWARE_PASSED_STATUS) + # A failed or unsettled run must never leave an approval-looking fingerprint in JUnit. + record_property("commissioned_manifest_sha256", manifest.commissioning_digest()) diff --git a/tests/protocols/test_asms_protocol.py b/tests/protocols/test_asms_protocol.py index 901a699..bd4e258 100644 --- a/tests/protocols/test_asms_protocol.py +++ b/tests/protocols/test_asms_protocol.py @@ -58,6 +58,7 @@ def test_bundled_exact_preflight_executes_complete_command_and_tip_plan() -> Non ) assert report.exact_bundle_ready is True + assert report.to_dict()["evidence_status"] == "OFFLINE_VALIDATED" assert report.shadow_passed is False assert report.missing_labware == () assert report.shadow_substitutions == () diff --git a/tests/scripts/test_preflight_flex.py b/tests/scripts/test_preflight_flex.py new file mode 100644 index 0000000..e0a0a35 --- /dev/null +++ b/tests/scripts/test_preflight_flex.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import json + +import pytest + +from scripts import preflight_flex +from tests.test_acceptance_manifest import _manifest +from unitelabs.opentrons_flex.hitl_evidence import ( + HITL_BLOCKED_STATUS, + HITL_READINESS_STATUS, + load_hitl_readiness_evidence, +) +from unitelabs.opentrons_flex.runtime_compat import RUNTIME_CONTRACT_ID + +_RELEASE_IDENTITY = { + "release_id": "flex-0.9.1-ot9.0.0-py3.10-aarch64-bbbbbbbbbbbb", + "bundle_sha256": "b" * 64, +} + + +def _runtime() -> dict: + return { + "connector_version": "0.9.1", + "opentrons_version": "9.0.0", + "python_version": "3.10.20", + "runtime_contract_id": RUNTIME_CONTRACT_ID, + "mutation_ready": True, + "release_identity": _RELEASE_IDENTITY, + } + + +def _http_payload(path: str) -> object: + if path == "/pipettes": + return {"left": {"serialNumber": "PIP-001", "name": "flex_8channel_1000"}} + if path == "/modules": + return { + "data": [ + {"serialNumber": serial, "moduleModel": model} + for serial, model in ( + ("HS-001", "heaterShakerModuleV1"), + ("TC-001", "thermocyclerModuleV2"), + ("TM-001", "temperatureModuleV2"), + ("AR-001", "absorbanceReaderV1"), + ("FS-001", "flexStackerModuleV1"), + ) + ] + } + if path == "/openapi.json": + return {"paths": {"/unitelabs/runs/{run_id}/mutations": {}}} + return {"data": {}} + + +def _runtime_manifest() -> dict: + return { + "schemaVersion": 1, + "releaseId": _RELEASE_IDENTITY["release_id"], + "connectorVersion": "0.9.1", + "opentronsVersion": "9.0.0", + "robotServerVersion": "9.0.0", + "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", + "pythonVersion": "3.10", + "architecture": "aarch64", + "bundleSha256": _RELEASE_IDENTITY["bundle_sha256"], + } + + +def _patch_live_checks(monkeypatch) -> None: + monkeypatch.setattr( + preflight_flex, + "_remote_runtime", + lambda _: ( + preflight_flex.Check("remote-runtime", True, "ready"), + _runtime(), + { + path: _http_payload(path) + for path in ("/health", "/deck_configuration", "/pipettes", "/modules", "/openapi.json") + }, + ), + ) + monkeypatch.setattr( + preflight_flex, + "_tcp_check", + lambda *_: preflight_flex.Check("tcp:50051", True, "ready"), + ) + monkeypatch.setattr( + preflight_flex, + "_http_json", + lambda _host, _port, path, _timeout: ( + preflight_flex.Check(f"http:{path}", True, "ready"), + _http_payload(path), + ), + ) + + +def test_acceptance_preflight_writes_cross_platform_ready_evidence(tmp_path, monkeypatch, capsys) -> None: + _patch_live_checks(monkeypatch) + manifest_payload = _manifest() + manifest_path = tmp_path / "acceptance.json" + manifest_path.write_text(json.dumps(manifest_payload), encoding="utf-8") + output_path = tmp_path / "readiness.json" + runtime_manifest_path = tmp_path / "runtime-manifest.json" + runtime_manifest_path.write_text(json.dumps(_runtime_manifest()), encoding="utf-8") + + result = preflight_flex.main( + [ + manifest_payload["expected_robot_host"], + "--acceptance-manifest", + str(manifest_path), + "--runtime-manifest", + str(runtime_manifest_path), + "--output", + str(output_path), + ] + ) + + assert result == 0 + report = json.loads(capsys.readouterr().out) + assert report["evidenceStatus"] == HITL_READINESS_STATUS + assert report["hardwarePassed"] is False + evidence = load_hitl_readiness_evidence( + output_path, + expected_host=manifest_payload["expected_robot_host"], + expected_manifest_sha256=report["manifestSha256"], + expected_runtime_contract_id=RUNTIME_CONTRACT_ID, + ) + assert evidence.connector_version == "0.9.1" + + +def test_acceptance_preflight_blocks_manifest_target_mismatch(tmp_path, monkeypatch, capsys) -> None: + _patch_live_checks(monkeypatch) + manifest_payload = _manifest() + manifest_path = tmp_path / "acceptance.json" + manifest_path.write_text(json.dumps(manifest_payload), encoding="utf-8") + runtime_manifest_path = tmp_path / "runtime-manifest.json" + runtime_manifest_path.write_text(json.dumps(_runtime_manifest()), encoding="utf-8") + + result = preflight_flex.main( + [ + "192.0.2.99", + "--acceptance-manifest", + str(manifest_path), + "--runtime-manifest", + str(runtime_manifest_path), + ] + ) + + assert result == 1 + report = json.loads(capsys.readouterr().out) + assert report["evidenceStatus"] == HITL_BLOCKED_STATUS + assert next(check for check in report["checks"] if check["name"] == "acceptance-host")["ok"] is False + + +def test_general_runtime_preflight_keeps_legacy_success_without_manifest(monkeypatch, capsys) -> None: + _patch_live_checks(monkeypatch) + + assert preflight_flex.main(["192.0.2.10"]) == 0 + assert json.loads(capsys.readouterr().out)["evidenceStatus"] == "RUNTIME_READY" + + +def test_ssh_option_injection_is_rejected_by_cli_parser() -> None: + with pytest.raises(SystemExit): + preflight_flex._parser().parse_args(["192.0.2.10", "--ssh-user=-oProxyCommand=bad"]) + + +def test_acceptance_preflight_rejects_runtime_manifest_from_another_bundle( + tmp_path, + monkeypatch, + capsys, +) -> None: + _patch_live_checks(monkeypatch) + manifest_payload = _manifest() + manifest_path = tmp_path / "acceptance.json" + manifest_path.write_text(json.dumps(manifest_payload), encoding="utf-8") + runtime_manifest = _runtime_manifest() + runtime_manifest["releaseId"] = "flex-0.9.1-unrelated" + runtime_manifest_path = tmp_path / "runtime-manifest.json" + runtime_manifest_path.write_text(json.dumps(runtime_manifest), encoding="utf-8") + + result = preflight_flex.main( + [ + manifest_payload["expected_robot_host"], + "--acceptance-manifest", + str(manifest_path), + "--runtime-manifest", + str(runtime_manifest_path), + ] + ) + + assert result == 1 + report = json.loads(capsys.readouterr().out) + assert report["evidenceStatus"] == HITL_BLOCKED_STATUS + assert next(check for check in report["checks"] if check["name"] == "acceptance-manifest")["ok"] is False diff --git a/tests/test_acceptance_contract_parity.py b/tests/test_acceptance_contract_parity.py new file mode 100644 index 0000000..9e56760 --- /dev/null +++ b/tests/test_acceptance_contract_parity.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import dataclasses + +from unitelabs_flex_acceptance_contract import wire_types + +from unitelabs.opentrons_flex import features +from unitelabs.opentrons_flex.features.thermocycler import ThermocyclerProfileStep + + +def test_cross_runtime_wire_values_match_connector_feature_contracts() -> None: + pairs = ( + (wire_types.LiquidPosition, features.LiquidPosition), + (wire_types.WellGeometry, features.WellGeometry), + (wire_types.TransferProfile, features.TransferProfile), + (wire_types.TipLocation, features.TipLocation), + (wire_types.ThermocyclerProfileStep, ThermocyclerProfileStep), + ) + for workflow_type, connector_type in pairs: + assert [field.name for field in dataclasses.fields(workflow_type)] == [ + field.name for field in dataclasses.fields(connector_type) + ] + + +def test_cross_runtime_enums_match_connector_feature_contracts() -> None: + pairs = ( + (wire_types.Mount, features.Mount), + (wire_types.PipetteMount, features.PipetteMount), + (wire_types.VerifiedLiquidClass, features.VerifiedLiquidClass), + ) + for workflow_type, connector_type in pairs: + assert {member.name: member.value for member in workflow_type} == { + member.name: member.value for member in connector_type + } diff --git a/tests/test_create_app_with_robot_server.py b/tests/test_create_app_with_robot_server.py index 8579fff..867e309 100644 --- a/tests/test_create_app_with_robot_server.py +++ b/tests/test_create_app_with_robot_server.py @@ -54,6 +54,15 @@ def _reset_robot_server_stubs(): sys.modules["robot_server.hardware"]._init_task_accessor.reset_mock() sys.modules["robot_server.app"].app.reset_mock() sys.modules["robot_server.app"].app.dependency_overrides.clear() + sys.modules["robot_server.app"].app.router.routes.clear() + with patch( + "unitelabs.opentrons_flex.process_runtime_identity", + return_value={ + "runtimeContractId": "test", + "releaseIdentity": None, + }, + ): + yield def _make_api() -> AsyncMock: diff --git a/tests/test_hitl_evidence.py b/tests/test_hitl_evidence.py new file mode 100644 index 0000000..44b200f --- /dev/null +++ b/tests/test_hitl_evidence.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from unitelabs.opentrons_flex.hitl_evidence import load_hitl_readiness_evidence, settle_hitl_operations + +_NOW = datetime(2026, 7, 28, 16, 0, tzinfo=timezone.utc) + + +def _report() -> dict: + return { + "schemaVersion": 1, + "evidenceStatus": "READY_FOR_HITL", + "hardwarePassed": False, + "generatedAt": (_NOW - timedelta(minutes=5)).isoformat(), + "host": "flex-lab", + "manifestSha256": "a" * 64, + "runtimeContractId": "flex-runtime-test", + "releaseIdentity": { + "release_id": "flex-0.9.1-test", + "bundle_sha256": "b" * 64, + }, + "runtime": { + "connector_version": "0.9.1", + "opentrons_version": "9.0.0", + "python_version": "3.10.20", + "runtime_contract_id": "flex-runtime-test", + "release_identity": { + "release_id": "flex-0.9.1-test", + "bundle_sha256": "b" * 64, + }, + }, + "checks": [{"name": "runtime", "ok": True, "detail": "ready"}], + } + + +def test_ready_evidence_is_bound_to_report_bytes(tmp_path) -> None: + path = tmp_path / "readiness.json" + path.write_text(json.dumps(_report()), encoding="utf-8") + + evidence = load_hitl_readiness_evidence( + path, + expected_host="FLEX-LAB", + expected_manifest_sha256="a" * 64, + expected_runtime_contract_id="flex-runtime-test", + now=_NOW, + ) + + assert len(evidence.report_sha256) == 64 + assert evidence.opentrons_version == "9.0.0" + + +def test_stale_evidence_is_rejected(tmp_path) -> None: + report = _report() + report["generatedAt"] = (_NOW - timedelta(hours=5)).isoformat() + path = tmp_path / "readiness.json" + path.write_text(json.dumps(report), encoding="utf-8") + + with pytest.raises(ValueError, match="older than four hours"): + load_hitl_readiness_evidence( + path, + expected_host="flex-lab", + expected_manifest_sha256="a" * 64, + expected_runtime_contract_id="flex-runtime-test", + now=_NOW, + ) + + +def test_hardware_pass_claim_is_rejected_from_readiness_report(tmp_path) -> None: + report = _report() + report["hardwarePassed"] = True + path = tmp_path / "readiness.json" + path.write_text(json.dumps(report), encoding="utf-8") + + with pytest.raises(ValueError, match="no-motion READY_FOR_HITL"): + load_hitl_readiness_evidence( + path, + expected_host="flex-lab", + expected_manifest_sha256="a" * 64, + expected_runtime_contract_id="flex-runtime-test", + now=_NOW, + ) + + +def test_internally_inconsistent_release_identity_is_rejected(tmp_path) -> None: + report = _report() + report["runtime"]["release_identity"]["bundle_sha256"] = "c" * 64 + path = tmp_path / "readiness.json" + path.write_text(json.dumps(report), encoding="utf-8") + + with pytest.raises(ValueError, match="internally inconsistent"): + load_hitl_readiness_evidence( + path, + expected_host="flex-lab", + expected_manifest_sha256="a" * 64, + expected_runtime_contract_id="flex-runtime-test", + now=_NOW, + ) + + +@pytest.mark.asyncio +async def test_safety_settlement_attempts_every_operation_and_reports_failures() -> None: + attempted = [] + + async def pass_operation() -> object: + attempted.append("pass") + return object() + + async def fail_operation() -> object: + attempted.append("fail") + raise RuntimeError("not settled") + + errors = await settle_hitl_operations( + ( + ("first", fail_operation), + ("second", pass_operation), + ) + ) + + assert attempted == ["fail", "pass"] + assert errors == ("first: RuntimeError: not settled",) diff --git a/tests/test_private_api_boundary.py b/tests/test_private_api_boundary.py new file mode 100644 index 0000000..6fb0249 --- /dev/null +++ b/tests/test_private_api_boundary.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import ast +from pathlib import Path + +_PACKAGE_ROOT = Path(__file__).parents[1] / "src" / "unitelabs" / "opentrons_flex" +_ALLOWED_ROBOT_SERVER_IMPORTS = { + _PACKAGE_ROOT / "robot_server_compat.py", +} + + +def test_robot_server_imports_remain_inside_the_compatibility_adapter() -> None: + violations: list[str] = [] + for path in sorted(_PACKAGE_ROOT.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + else: + continue + if any(name == "robot_server" or name.startswith("robot_server.") for name in names) and ( + path not in _ALLOWED_ROBOT_SERVER_IMPORTS + ): + violations.append(f"{path.relative_to(_PACKAGE_ROOT)}:{node.lineno}") + + assert violations == [], f"robot_server imports escaped the compatibility adapter: {violations}" diff --git a/tests/test_robot_readiness.py b/tests/test_robot_readiness.py new file mode 100644 index 0000000..e90ddb3 --- /dev/null +++ b/tests/test_robot_readiness.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +from unitelabs.opentrons_flex import robot_readiness + + +def test_robot_readiness_combines_runtime_with_loopback_inventory(monkeypatch, capsys, tmp_path) -> None: + runtime = { + "connector_version": "0.9.1", + "opentrons_version": "9.0.0", + "runtime_contract_id": "flex-runtime-test", + "release_identity": { + "release_id": "flex-0.9.1-test", + "bundle_sha256": "b" * 64, + }, + } + monkeypatch.setattr( + robot_readiness.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps(runtime), + stderr="", + ), + ) + monkeypatch.setattr( + robot_readiness, + "_loopback_json", + lambda _port, path, _timeout: ( + { + "runtimeContractId": runtime["runtime_contract_id"], + "connectorVersion": runtime["connector_version"], + "opentronsVersion": "9.0.0", + "releaseIdentity": runtime["release_identity"], + } + if path == "/unitelabs/runtime" + else {"path": path} + ), + ) + + result = robot_readiness.main( + [ + "--config", + str(tmp_path / "config.json"), + "--require-mutation", + ] + ) + + assert result == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["runtime"] == runtime + assert set(payload["http"]) == set(robot_readiness._HTTP_PATHS) + + +def test_robot_readiness_fails_when_loopback_inventory_is_unavailable(monkeypatch, tmp_path) -> None: + monkeypatch.setattr( + robot_readiness.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps({"connector_version": "0.9.1"}), + stderr="", + ), + ) + + def _fail(*_args) -> object: + raise RuntimeError("loopback unavailable") + + monkeypatch.setattr(robot_readiness, "_loopback_json", _fail) + + assert robot_readiness.main(["--config", str(tmp_path / "config.json")]) == 1 + + +def test_robot_readiness_rejects_running_service_from_another_release( + monkeypatch, + tmp_path, +) -> None: + runtime = { + "connector_version": "0.9.1", + "opentrons_version": "9.0.0", + "runtime_contract_id": "flex-runtime-test", + "release_identity": { + "release_id": "release-a", + "bundle_sha256": "a" * 64, + }, + } + monkeypatch.setattr( + robot_readiness.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps(runtime), + stderr="", + ), + ) + + def _loopback(_port, path, _timeout) -> object: + if path == "/unitelabs/runtime": + return { + "runtimeContractId": runtime["runtime_contract_id"], + "connectorVersion": runtime["connector_version"], + "opentronsVersion": runtime["opentrons_version"], + "releaseIdentity": { + "release_id": "release-b", + "bundle_sha256": "b" * 64, + }, + } + return {"path": path} + + monkeypatch.setattr(robot_readiness, "_loopback_json", _loopback) + + assert robot_readiness.main(["--config", str(tmp_path / "config.json")]) == 1 diff --git a/tests/test_robot_server_compat.py b/tests/test_robot_server_compat.py new file mode 100644 index 0000000..1922190 --- /dev/null +++ b/tests/test_robot_server_compat.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from unitelabs.opentrons_flex.robot_server_compat import ( + RobotServerBindings, + current_run_store, + include_robot_server_router, + install_robot_server_app, + protocol_run_state, + restore_robot_server_app, +) + +_CHECKPOINT_PREFIX = "UNITELABS_MUTATION_CHECKPOINT:" + + +def test_startup_without_store_retains_protocol_engine_ownership() -> None: + state = protocol_run_state( + None, + robot_server_ready=False, + checkpoint_prefix=_CHECKPOINT_PREFIX, + ) + + assert state.status == "initializing" + assert state.owns_hardware is True + + +def test_ready_server_without_store_is_idle() -> None: + state = protocol_run_state( + None, + robot_server_ready=True, + checkpoint_prefix=_CHECKPOINT_PREFIX, + ) + + assert state.status == "idle" + assert state.owns_hardware is False + + +def test_missing_run_store_after_startup_fails_closed() -> None: + bindings = RobotServerBindings( + app=SimpleNamespace(state=SimpleNamespace()), + hardware_accessor=MagicMock(), + initialization_task_accessor=MagicMock(), + get_deck_type=MagicMock(), + get_robot_type=MagicMock(), + get_robot_type_enum=MagicMock(), + mark_light_control_startup_finished=MagicMock(), + start_light_control_task=MagicMock(), + ) + + with pytest.raises(RuntimeError, match="run_orchestrator_store"): + current_run_store(bindings, robot_server_ready=True) + + +def test_active_checkpoint_is_read_from_validated_store_shape() -> None: + command = SimpleNamespace( + id="command-1", + commandType="waitForResume", + status=SimpleNamespace(value="running"), + params=SimpleNamespace(message=f"{_CHECKPOINT_PREFIX}before-transfer"), + ) + store = SimpleNamespace( + current_run_id="run-1", + run_orchestrator=SimpleNamespace(_protocol_runner=object()), + get_status=lambda: SimpleNamespace(value="paused"), + get_current_command=lambda: SimpleNamespace(command_id="command-1"), + get_command=lambda command_id: command if command_id == "command-1" else None, + run_was_started=lambda: True, + ) + + state = protocol_run_state( + store, + robot_server_ready=True, + checkpoint_prefix=_CHECKPOINT_PREFIX, + ) + + assert state.run_id == "run-1" + assert state.status == "paused" + assert state.mutation_checkpoint_id == "command-1" + assert state.protocol_less is False + assert state.owns_hardware is True + + +def test_private_run_store_drift_fails_closed() -> None: + store = SimpleNamespace( + current_run_id="run-1", + run_orchestrator=SimpleNamespace(), + get_status=lambda: SimpleNamespace(value="running"), + ) + + with pytest.raises(RuntimeError, match=r"_protocol_runner.*unavailable"): + protocol_run_state( + store, + robot_server_ready=True, + checkpoint_prefix=_CHECKPOINT_PREFIX, + ) + + +def test_app_installation_and_router_registration_are_reversible() -> None: + original_lifespan = MagicMock() + original_dependency = object() + original_override = object() + app_routes = [object()] + app = SimpleNamespace( + state=SimpleNamespace(), + dependency_overrides={original_dependency: original_override}, + router=SimpleNamespace(routes=app_routes, lifespan_context=original_lifespan), + openapi_schema=object(), + ) + + def include_router(router) -> None: + app_routes.extend(object() for _ in router.routes) + + app.include_router = include_router + bindings = RobotServerBindings( + app=app, + hardware_accessor=SimpleNamespace(set_on=MagicMock()), + initialization_task_accessor=SimpleNamespace(set_on=MagicMock()), + get_deck_type=MagicMock(), + get_robot_type=MagicMock(), + get_robot_type_enum=MagicMock(), + mark_light_control_startup_finished=MagicMock(), + start_light_control_task=MagicMock(), + ) + added_dependency = object() + added_override = object() + replacement_lifespan = MagicMock() + + installation = install_robot_server_app( + bindings, + initialization_task=object(), + hardware_proxy=object(), + identity_overrides={ + original_dependency: object(), + added_dependency: added_override, + }, + lifespan_factory=lambda _: replacement_lifespan, + ) + baseline_route = app_routes[0] + added_routes = include_robot_server_router( + installation, + SimpleNamespace(routes=[object(), object()]), + ) + + assert len(added_routes) == 2 + assert app.router.lifespan_context is replacement_lifespan + assert app.dependency_overrides[added_dependency] is added_override + + restore_robot_server_app(installation, added_routes=added_routes) + + assert app.router.routes == [baseline_route] + assert app.router.lifespan_context is original_lifespan + assert app.dependency_overrides == {original_dependency: original_override} + + +def test_router_registration_that_adds_no_routes_fails_closed() -> None: + app = SimpleNamespace( + state=SimpleNamespace(), + dependency_overrides={}, + router=SimpleNamespace(routes=[], lifespan_context=MagicMock()), + openapi_schema=None, + include_router=lambda _: None, + ) + bindings = RobotServerBindings( + app=app, + hardware_accessor=SimpleNamespace(set_on=MagicMock()), + initialization_task_accessor=SimpleNamespace(set_on=MagicMock()), + get_deck_type=MagicMock(), + get_robot_type=MagicMock(), + get_robot_type_enum=MagicMock(), + mark_light_control_startup_finished=MagicMock(), + start_light_control_task=MagicMock(), + ) + installation = install_robot_server_app( + bindings, + initialization_task=object(), + hardware_proxy=object(), + identity_overrides={}, + lifespan_factory=lambda original: original, + ) + + with pytest.raises(RuntimeError, match="did not register"): + include_robot_server_router( + installation, + SimpleNamespace(routes=[object()]), + ) diff --git a/tests/test_runtime_compat.py b/tests/test_runtime_compat.py index d11be64..59274e6 100644 --- a/tests/test_runtime_compat.py +++ b/tests/test_runtime_compat.py @@ -20,7 +20,7 @@ def _matching_report(*, require_robot_server: bool = True) -> runtime_compat.Run side_effect=package_versions.__getitem__, ), patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), - patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", ())), + patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", (), ())), patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), ): @@ -33,6 +33,7 @@ def test_matching_runtime_enables_base_and_mutation() -> None: assert report.base_compatible is True assert report.mutation_compatible is True assert report.robot_server_version == "9.0.0" + assert report.runtime_contract_id.startswith("flex-runtime-") assert report.issues == () @@ -72,7 +73,7 @@ def test_http_dependency_drift_only_blocks_embedded_robot_server_profile() -> No side_effect=package_versions.__getitem__, ), patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), - patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", ())), + patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", (), ())), patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), ): @@ -92,6 +93,8 @@ def test_require_compatible_runtime_raises_actionable_error() -> None: robot_server_version=None, robot_server_source=None, runtime_package_versions={}, + runtime_contract_id=runtime_compat.RUNTIME_CONTRACT_ID, + private_api_checks=(), base_compatible=False, mutation_compatible=False, issues=("Python 3.11.0 is unsupported; expected Python 3.10.x.",), @@ -144,3 +147,13 @@ def test_opentrons_module_outside_active_release_is_rejected() -> None: assert report.base_compatible is False assert "outside the active release" in report.issues[0] + + +def test_shape_check_rejects_non_callable_private_binding(monkeypatch) -> None: + fake = type("FakeModule", (), {"accessor": type("Accessor", (), {"set_on": None})()})() + monkeypatch.setattr(runtime_compat.importlib, "import_module", lambda _: fake) + + check = runtime_compat._shape_check("robot_server.hardware", "accessor.set_on", "callable") + + assert check.ok is False + assert "callable contract" in check.detail diff --git a/tests/test_runtime_contract_consistency.py b/tests/test_runtime_contract_consistency.py new file mode 100644 index 0000000..a4e6942 --- /dev/null +++ b/tests/test_runtime_contract_consistency.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from scripts import artifact_manifest +from unitelabs.opentrons_flex import runtime_compat + +_ROOT = Path(__file__).parents[1] + + +def _text(path: str) -> str: + return (_ROOT / path).read_text(encoding="utf-8") + + +def _normalized_runtime_packages() -> dict[str, str]: + return { + re.sub(r"[-_.]+", "-", name).lower(): version + for name, version in runtime_compat.SUPPORTED_RUNTIME_PACKAGES.items() + } + + +def test_runtime_version_and_source_pin_are_consistent_across_release_surfaces() -> None: + opentrons_version = runtime_compat.SUPPORTED_OPENTRONS_VERSION + source_commit = runtime_compat.SUPPORTED_OPENTRONS_SOURCE_COMMIT + python_version = ".".join(str(part) for part in runtime_compat.SUPPORTED_PYTHON_VERSION) + pyproject = _text("pyproject.toml") + + assert f'"opentrons=={opentrons_version}"' in pyproject + assert f'requires-python=">={python_version},<3.11"' in pyproject + + required_fragments = { + "Dockerfile.build": ( + f"python{python_version}-bookworm", + f"ARG OPENTRONS_VERSION={opentrons_version}", + f"ARG OPENTRONS_COMMIT={source_commit}", + ), + "deploy.sh": ( + f"--opentrons-version {opentrons_version}", + f"--robot-server-version {opentrons_version}", + f"--opentrons-source-commit {source_commit}", + f"--python-version {python_version}", + ), + ".github/workflows/test.yml": ( + f'opentrons-version: "{opentrons_version}"', + f'opentrons-commit: "{source_commit}"', + f'python-version: "{python_version}"', + ), + ".github/workflows/build-flex-arm-wheels.yml": ( + f"--opentrons-version {opentrons_version}", + f"--robot-server-version {opentrons_version}", + f"--opentrons-source-commit {source_commit}", + f"--python-version {python_version}", + ), + } + for path, fragments in required_fragments.items(): + contents = _text(path) + assert all(fragment in contents for fragment in fragments), f"{path} drifted from the runtime contract" + + +def test_runtime_dependency_manifest_matches_startup_contract() -> None: + assert _normalized_runtime_packages() == artifact_manifest.PINNED_RUNTIME_WHEELS + + +def test_flex_documentation_does_not_claim_the_retired_runtime() -> None: + for path in ("README.md", "docs/asms_flex_workflow_test.md"): + assert "8.8.1" not in _text(path), f"{path} still claims the retired Flex runtime" diff --git a/tests/test_runtime_identity_api.py b/tests/test_runtime_identity_api.py new file mode 100644 index 0000000..5ab9165 --- /dev/null +++ b/tests/test_runtime_identity_api.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from unitelabs.opentrons_flex import runtime_identity_api +from unitelabs.opentrons_flex.runtime_compat import RuntimeCompatibilityReport + + +def _compatibility() -> RuntimeCompatibilityReport: + return RuntimeCompatibilityReport( + connector_version="0.9.1", + python_version="3.10.20", + opentrons_version="9.0.0", + robot_server_version="9.0.0", + robot_server_source="/release/robot_server/__init__.py", + runtime_package_versions={}, + runtime_contract_id="flex-runtime-test", + private_api_checks=(), + base_compatible=True, + mutation_compatible=True, + issues=(), + mutation_issues=(), + ) + + +def test_process_identity_uses_prefix_captured_at_process_import(monkeypatch, tmp_path) -> None: + release_a = tmp_path / "release-a" + release_a.mkdir() + release_b = tmp_path / "release-b" + release_b.mkdir() + active = tmp_path / "active" + active.symlink_to(release_a, target_is_directory=True) + captured_prefix = active.resolve() + active.unlink() + active.symlink_to(release_b, target_is_directory=True) + monkeypatch.setattr(runtime_identity_api, "_PROCESS_RELEASE_PREFIX", captured_prefix) + + observed_prefixes = [] + + def _release_identity_for_test(**kwargs): + observed_prefixes.append(kwargs["prefix"]) + return ( + { + "release_id": kwargs["prefix"].name, + "bundle_sha256": "a" * 64, + }, + (), + ) + + monkeypatch.setattr( + runtime_identity_api, + "_release_identity", + _release_identity_for_test, + ) + + identity = runtime_identity_api.process_runtime_identity( + _compatibility(), + require_release=True, + ) + + assert observed_prefixes == [release_a] + assert identity["releaseIdentity"]["release_id"] == "release-a" diff --git a/tests/test_runtime_preflight.py b/tests/test_runtime_preflight.py index 39c175a..1afa9a9 100644 --- a/tests/test_runtime_preflight.py +++ b/tests/test_runtime_preflight.py @@ -14,6 +14,8 @@ def _report(*, compatible: bool = True) -> RuntimeCompatibilityReport: robot_server_version="9.0.0", robot_server_source="/release/robot_server/__init__.py", runtime_package_versions={}, + runtime_contract_id="flex-runtime-test", + private_api_checks=(), base_compatible=compatible, mutation_compatible=compatible, issues=() if compatible else ("base mismatch",), @@ -96,3 +98,66 @@ def test_runtime_preflight_rejects_simulator_for_live_deployment(tmp_path, monke assert result == 1 assert "use_simulator must be false" in capsys.readouterr().out + + +def test_release_identity_binds_active_venv_to_verified_bundle(tmp_path, monkeypatch) -> None: + bundle_sha256 = "a" * 64 + architecture = "aarch64" + python_version = f"{runtime_preflight.sys.version_info.major}.{runtime_preflight.sys.version_info.minor}" + release_id = f"flex-0.9.1-ot9.0.0-py{python_version}-{architecture}-{bundle_sha256[:12]}" + (tmp_path / "runtime-manifest.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "connectorVersion": "0.9.1", + "opentronsVersion": "9.0.0", + "robotServerVersion": "9.0.0", + "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", + "pythonVersion": python_version, + "architecture": architecture, + "releaseId": release_id, + "bundleSha256": bundle_sha256, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(runtime_preflight.sys, "prefix", str(tmp_path)) + monkeypatch.setattr(runtime_preflight, "_normalized_architecture", lambda: architecture) + + identity, issues = runtime_preflight._release_identity( + connector_version="0.9.1", + opentrons_version="9.0.0", + required=True, + ) + + assert issues == () + assert identity == { + "release_id": release_id, + "bundle_sha256": bundle_sha256, + } + + +def test_release_identity_rejects_manifest_from_different_active_bundle(tmp_path, monkeypatch) -> None: + manifest = { + "schemaVersion": 1, + "connectorVersion": "0.9.1", + "opentronsVersion": "9.0.0", + "robotServerVersion": "9.0.0", + "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", + "pythonVersion": f"{runtime_preflight.sys.version_info.major}.{runtime_preflight.sys.version_info.minor}", + "architecture": "aarch64", + "releaseId": "flex-0.9.1-unrelated", + "bundleSha256": "a" * 64, + } + (tmp_path / "runtime-manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + monkeypatch.setattr(runtime_preflight.sys, "prefix", str(tmp_path)) + monkeypatch.setattr(runtime_preflight, "_normalized_architecture", lambda: "aarch64") + + identity, issues = runtime_preflight._release_identity( + connector_version="0.9.1", + opentrons_version="9.0.0", + required=True, + ) + + assert identity is None + assert any("releaseId" in issue for issue in issues) diff --git a/tests/test_workflow_manifests.py b/tests/test_workflow_manifests.py index 0209a91..7e0461a 100644 --- a/tests/test_workflow_manifests.py +++ b/tests/test_workflow_manifests.py @@ -114,6 +114,19 @@ def test_flex_acceptance_workflow_is_hardware_tagged() -> None: assert {"flex", "liquid-handler", "hardware-acceptance"} <= tags +def test_flex_workflow_uses_cross_runtime_contract_instead_of_connector_package() -> None: + """The Python 3.12 workflow must not depend on the Python 3.10 robot runtime.""" + data = _pyproject("flex-system-acceptance") + dependencies = set(data["project"]["dependencies"]) + + assert data["project"]["requires-python"] == ">=3.12" + assert "unitelabs-flex-acceptance-contract==0.1.0" in dependencies + assert all(not dependency.startswith("unitelabs-opentrons-flex") for dependency in dependencies) + assert data["tool"]["uv"]["sources"]["unitelabs-flex-acceptance-contract"]["path"] == ( + "../../packages/flex-acceptance-contract" + ) + + def test_transfer_workflow_uses_shared_labware_helpers() -> None: """The plate-transfer E2E workflow should keep its shared labware helper dependency.""" data = _pyproject("ot2-transfer") diff --git a/uv.lock b/uv.lock index e487f79..31f3b54 100644 --- a/uv.lock +++ b/uv.lock @@ -1511,6 +1511,11 @@ dev = [ { name = "watchfiles" }, ] +[[package]] +name = "unitelabs-flex-acceptance-contract" +version = "0.1.0" +source = { directory = "packages/flex-acceptance-contract" } + [[package]] name = "unitelabs-opentrons-flex" version = "0.9.1" @@ -1530,6 +1535,7 @@ dependencies = [ { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "unitelabs-cdk" }, + { name = "unitelabs-flex-acceptance-contract" }, { name = "uvicorn" }, { name = "wsproto" }, ] @@ -1574,6 +1580,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==1.4.51" }, { name = "unitelabs-cdk", specifier = "~=0.9.0" }, { name = "unitelabs-cdk", extras = ["dev"], marker = "extra == 'dev'" }, + { name = "unitelabs-flex-acceptance-contract", directory = "packages/flex-acceptance-contract" }, { name = "uvicorn", specifier = "==0.27.0.post1" }, { name = "wsproto", specifier = "==1.2.0" }, ] diff --git a/workflows/flex-system-acceptance/pyproject.toml b/workflows/flex-system-acceptance/pyproject.toml index 8a2c7e5..8a625d2 100644 --- a/workflows/flex-system-acceptance/pyproject.toml +++ b/workflows/flex-system-acceptance/pyproject.toml @@ -5,7 +5,7 @@ description = "Guarded end-to-end Opentrons Flex system acceptance workflow." requires-python = ">=3.12" dependencies = [ "unitelabs-sdk[automate]~=0.11.3", - "unitelabs-opentrons-flex~=0.9.0", + "unitelabs-flex-acceptance-contract==0.1.0", ] [project.optional-dependencies] @@ -31,7 +31,7 @@ authenticate = "always" [tool.uv.sources] unitelabs-sdk = { index = "unitelabs" } -unitelabs-opentrons-flex = { path = "../..", editable = true } +unitelabs-flex-acceptance-contract = { path = "../../packages/flex-acceptance-contract", editable = true } [build-system] requires = ["uv_build>=0.4,<0.10"] diff --git a/workflows/flex-system-acceptance/src/flex_system_acceptance/__main__.py b/workflows/flex-system-acceptance/src/flex_system_acceptance/__main__.py index eb10102..d8d810f 100644 --- a/workflows/flex-system-acceptance/src/flex_system_acceptance/__main__.py +++ b/workflows/flex-system-acceptance/src/flex_system_acceptance/__main__.py @@ -5,17 +5,31 @@ import json from pathlib import Path -from ._helpers import INSTRUMENT_NAME -from .workflow import flex_system_acceptance_flow +from unitelabs_flex_acceptance_contract import AcceptanceManifest + +_INSTRUMENT_NAME = "Opentrons Flex" def main() -> None: """Load the local manifest before starting the Unitelabs workflow.""" parser = argparse.ArgumentParser(description="Run the guarded Flex system acceptance workflow.") parser.add_argument("--manifest", required=True, type=Path, help="Path to the completed acceptance JSON manifest.") - parser.add_argument("--device", default=INSTRUMENT_NAME, help="Unitelabs service name.") + parser.add_argument("--device", default=_INSTRUMENT_NAME, help="Unitelabs service name.") + parser.add_argument( + "--validate-only", + action="store_true", + help="Validate the cross-runtime manifest and exit without connecting to Unitelabs or hardware.", + ) arguments = parser.parse_args() manifest = json.loads(arguments.manifest.read_text(encoding="utf-8")) + AcceptanceManifest.parse(manifest) + if arguments.validate_only: + return + + # Keep workflow-engine imports out of the cross-platform validation path so + # Windows CI can exercise this exact console entrypoint without credentials. + from .workflow import flex_system_acceptance_flow + asyncio.run(flex_system_acceptance_flow(manifest=manifest, device_name=arguments.device)) diff --git a/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py b/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py index 1e5f0e5..d9be7e6 100644 --- a/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py +++ b/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py @@ -8,23 +8,21 @@ from prefect.cache_policies import NONE from unitelabs.sdk import get_logger -from unitelabs.opentrons_flex.acceptance import ( +from unitelabs_flex_acceptance_contract import ( AcceptanceManifest, Coordinate, - PlanContract, - WellGeometryConfig, - validate_plan_contract, -) -from unitelabs.opentrons_flex.features import ( LiquidPosition, Mount, PipetteMount, + PlanContract, + ThermocyclerProfileStep, TipLocation, TransferProfile, VerifiedLiquidClass, WellGeometry, + WellGeometryConfig, + validate_plan_contract, ) -from unitelabs.opentrons_flex.features.thermocycler import ThermocyclerProfileStep from ._helpers import feature, field, get_flex_service, invoke From 1d305af8d571b697da125fb9b50f6cfe1340fab1 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Tue, 28 Jul 2026 12:17:25 -0400 Subject: [PATCH 07/14] feat: harden offline lab readiness --- .github/workflows/build-flex-arm-wheels.yml | 15 +- .github/workflows/test.yml | 6 + README.md | 14 + docs/flex_system_acceptance.md | 8 + pyproject.toml | 1 + scripts/preflight_flex.py | 3 +- scripts/sila_client_control.py | 224 ++++++++------ src/unitelabs/opentrons_flex/__init__.py | 111 ++++--- src/unitelabs/opentrons_flex/http_safety.py | 29 ++ .../opentrons_flex/robot_readiness.py | 4 +- .../opentrons_flex/robot_server_compat.py | 96 ++++-- .../opentrons_flex/runtime_compat.py | 2 + .../opentrons_flex/runtime_identity.py | 72 +++++ .../opentrons_flex/runtime_identity_api.py | 6 +- .../opentrons_flex/runtime_preflight.py | 67 +---- tests/scripts/test_deployment_scripts.py | 273 ++++++++++++++++++ tests/test_acceptance_manifest.py | 2 +- tests/test_create_app_with_robot_server.py | 39 ++- tests/test_http_safety.py | 21 ++ tests/test_robot_server_compat.py | 94 +++++- tests/test_runtime_contract_consistency.py | 23 ++ tests/test_runtime_identity_api.py | 2 +- tests/test_runtime_preflight.py | 16 +- tests/test_workflow_manifests.py | 216 +++++++++++++- tests/test_workflow_publishing.py | 63 ++++ .../src/flex_system_acceptance/_steps.py | 26 +- .../src/flex_system_acceptance/workflow.py | 18 +- workflows/scripts/publish_workflows.py | 62 +++- 28 files changed, 1251 insertions(+), 262 deletions(-) create mode 100644 src/unitelabs/opentrons_flex/http_safety.py create mode 100644 src/unitelabs/opentrons_flex/runtime_identity.py create mode 100644 tests/test_http_safety.py create mode 100644 tests/test_workflow_publishing.py diff --git a/.github/workflows/build-flex-arm-wheels.yml b/.github/workflows/build-flex-arm-wheels.yml index 39fff87..6e9d27d 100644 --- a/.github/workflows/build-flex-arm-wheels.yml +++ b/.github/workflows/build-flex-arm-wheels.yml @@ -7,14 +7,27 @@ name: Build Flex aarch64 Wheels on: workflow_dispatch: + pull_request: + branches: [main] + paths: + - '.github/workflows/build-flex-arm-wheels.yml' + - 'Dockerfile.build' + - 'packages/flex-acceptance-contract/**' + - 'pyproject.toml' + - 'scripts/artifact_manifest.py' + - 'src/**' + - 'uv.lock' push: branches: [main] tags: ['*'] paths: + - '.github/workflows/build-flex-arm-wheels.yml' - 'Dockerfile.build' + - 'packages/flex-acceptance-contract/**' - 'pyproject.toml' - - 'uv.lock' + - 'scripts/artifact_manifest.py' - 'src/**' + - 'uv.lock' jobs: build-flex-arm-wheels: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c17ea90..6da5bdb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -201,6 +201,12 @@ jobs: uv python install 3.12 uv venv --python 3.12 .venv-workflow uv pip install --python .venv-workflow\Scripts\python.exe --no-deps -e packages\flex-acceptance-contract -e workflows\flex-system-acceptance + uv pip install --python .venv-workflow\Scripts\python.exe "pytest==9.1.1" "pytest-asyncio==1.4.0" - name: Execute the native PowerShell workflow entrypoint run: .\.venv-workflow\Scripts\python.exe -m flex_system_acceptance --validate-only --manifest .\flex-acceptance-ci.json + + - name: Import and execute offline workflow phases on Windows Python 3.12 + run: | + .\.venv-workflow\Scripts\python.exe -m pytest -p no:cacheprovider ` + tests\test_workflow_manifests.py -m flex_workflow_offline -q diff --git a/README.md b/README.md index 852645a..f3b04aa 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,15 @@ SiLA feature-definition generation, and real gRPC calls through the full chain `gRPC → SiLA server → feature → hardware API`, including defined-execution errors propagating over the wire. +CI also creates an isolated Windows Python 3.12 workflow environment that does +not install the Python 3.10 connector. It validates the native PowerShell +entrypoint and runs the `flex_workflow_offline` import, phase-wiring, +commissioning-digest, and fail-closed shutdown tests with offline SDK/Prefect +stubs. Deployment-script tests execute the actual remote shell bodies under a +temporary filesystem root with fake services, covering preflight refusal, +bounded health failure with stock-service recovery, and atomic rollback +activation. + `config/smoketest_config.json` is the local no-hardware config. It keeps `use_simulator=true`, `with_robot_server=true`, and cloud/discovery disabled. The deployment config in `config/flex_config.json` remains explicitly live-hardware @@ -614,6 +623,11 @@ Or trigger the **Build Flex aarch64 Wheels** GitHub Actions workflow (`.github/workflows/build-flex-arm-wheels.yml`) and download the `flex-arm-wheels` artifact into `dist_arm/`. +Changes to the connector, shared acceptance contract, artifact-manifest tool, +Docker build, lockfile, or ARM workflow itself run this build on the pull +request as well as after a main-branch push. Repository branch protection +determines whether GitHub blocks merging on that job. + The artifact is accepted only when `runtime-manifest.json` and `SHA256SUMS` verify every wheel, the connector/Opentrons/robot-server versions are exact, and the declared target is Python 3.10 on aarch64. diff --git a/docs/flex_system_acceptance.md b/docs/flex_system_acceptance.md index 4bc43ab..83743da 100644 --- a/docs/flex_system_acceptance.md +++ b/docs/flex_system_acceptance.md @@ -120,6 +120,14 @@ uv run --directory workflows/flex-system-acceptance workflow --manifest C:\path\ Python 3.10 hardware connector into Windows: both sides share the dependency-free `unitelabs-flex-acceptance-contract` package instead. +The routine workflow always attempts every de-energization action: Heater-Shaker +stop and heater deactivation, Thermocycler deactivation, Temperature Module +deactivation, Plate Reader deactivation, Stacker deactivation, and lights off. +Failures are accumulated instead of stopping cleanup early, but any failure +marks the workflow unsuccessful and requires operator reconciliation. A +completed hardware sequence with incomplete shutdown is never logged as an +accepted run. + To publish it with the repository's existing workflow tooling: ```powershell diff --git a/pyproject.toml b/pyproject.toml index 1611c62..7a13c5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ markers = [ "temperature_module_actuation: opt-in Temperature Module GEN2 heating/cooling; requires --temperature-module-actuation", "gripper_labware_actuation: opt-in prepared-labware gripper movement; requires --gripper-labware-actuation", "acceptance_workflow_actuation: opt-in manifest-driven multi-module hardware acceptance workflow", + "flex_workflow_offline: Python 3.12-safe Flex workflow import, phase wiring, and shutdown coverage", ] [tool.ruff] diff --git a/scripts/preflight_flex.py b/scripts/preflight_flex.py index 3be096e..8a06d98 100755 --- a/scripts/preflight_flex.py +++ b/scripts/preflight_flex.py @@ -23,6 +23,7 @@ HITL_READINESS_SCHEMA_VERSION, HITL_READINESS_STATUS, ) +from unitelabs.opentrons_flex.http_safety import open_no_redirect from unitelabs.opentrons_flex.runtime_compat import ( RUNTIME_CONTRACT_ID, SUPPORTED_OPENTRONS_SOURCE_COMMIT, @@ -135,7 +136,7 @@ def _http_json(host: str, port: int, path: str, timeout: float) -> tuple[Check, url = f"http://{_url_host(host)}:{port}{path}" request = urllib.request.Request(url, headers={"User-Agent": "unitelabs-flex-preflight/0.9.1"}) try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with open_no_redirect(request, timeout=timeout) as response: if response.geturl() != url: return Check(f"http:{path}", False, f"unexpected redirect to {response.geturl()}"), None payload = json.loads(response.read().decode("utf-8")) diff --git a/scripts/sila_client_control.py b/scripts/sila_client_control.py index f353e1c..a6fdb0e 100644 --- a/scripts/sila_client_control.py +++ b/scripts/sila_client_control.py @@ -19,6 +19,7 @@ import argparse import asyncio +import contextlib import os import sys import base64 @@ -37,6 +38,7 @@ _PIPETTE_PKG = "sila2.ca.accelerationconsortium.robots.pipettefeature.v1" _PIPETTE_SERVICE = f"{_PIPETTE_PKG}.PipetteFeature" + async def call_observable( channel: grpc.aio.Channel, pb: object, @@ -69,6 +71,7 @@ async def call_observable( raise TimeoutError(f"{method} did not finish within {timeout_s}s") from exc await asyncio.sleep(0.05) + async def main(robot_ip: str, port: int): address = f"{robot_ip}:{port}" @@ -84,16 +87,16 @@ async def main(robot_ip: str, port: int): connector = await gen.__anext__() await connector.start() pb = connector.sila_server.protobuf - + print(f"Connecting to live robot SiLA server at {address}...") channel = grpc.aio.insecure_channel(address) - + # Helper to query properties async def get_property(service_name: str, package_name: str, name: str) -> dict: stub = channel.unary_unary(f"/{service_name}/{name}") resp_bytes = await stub(b"") return await pb.decode(f"{package_name}.{name}_Responses", resp_bytes) - + # Helper to call observable commands async def call_command(service_name: str, package_name: str, method: str, params: dict | None = None) -> dict: return await call_observable(channel, pb, service_name, package_name, method, params, timeout_s=60.0) @@ -102,39 +105,42 @@ async def call_command(service_name: str, package_name: str, method: str, params async def check_machine_status(stage: str): res = await get_property(_SERVICE, _PKG, "Get_MachineStatus") status = next(iter(res.values())) - print(f"[{stage}] MachineStatus: estop={status.estop}, door_open={status.door_open}, is_error_state={status.is_error_state}, message={status.message}") + print( + f"[{stage}] MachineStatus: estop={status.estop}, door_open={status.door_open}, " + f"is_error_state={status.is_error_state}, message={status.message}" + ) if status.is_error_state: print(f"ERROR: Robot reports error state: {status.message}") sys.exit(1) # Slot centers absolute coordinate mapping on standard Flex deck - SLOT_C1 = {"x": 64.0, "y": 150.0} # Row C, Column 1 + SLOT_C1 = {"x": 64.0, "y": 150.0} # Row C, Column 1 SLOT_C2 = {"x": 228.0, "y": 150.0} # Row C, Column 2 SLOT_C3 = {"x": 392.0, "y": 150.0} # Row C, Column 3 - SAFE_Z_GANTRY = 120.0 # Safe travel height above deck - ACTION_Z_GANTRY = 100.0 # Safe action height (remains high in the air) + SAFE_Z_GANTRY = 120.0 # Safe travel height above deck + ACTION_Z_GANTRY = 100.0 # Safe action height (remains high in the air) try: # 1. Initial status and verification print("\n--- 1. Initial Status Check ---") await check_machine_status("START") - + res_gripper = await get_property(_GRIPPER_SERVICE, _GRIPPER_PKG, "Get_Status") g_status = next(iter(res_gripper.values())) print(f"Gripper Attached: {g_status.attached}") print(f"Gripper Model: {g_status.model}") - + if not g_status.attached: print("ERROR: Gripper must be attached for this workflow test.") sys.exit(1) - + # Query Pipette Status (Check if LEFT pipette has a tip attached) print("\n--- 2. Querying Pipette Status ---") res_pipettes = await call_command(_PIPETTE_SERVICE, _PIPETTE_PKG, "GetAttachedPipettes") pipettes = next(iter(res_pipettes.values())) left_pipette = next((p for p in pipettes if p.mount == Mount.LEFT), None) - + has_tip = False if left_pipette and left_pipette.attached: print(f"LEFT Pipette: {left_pipette.name} (ID: {left_pipette.pipette_id})") @@ -142,163 +148,213 @@ async def check_machine_status(stage: str): has_tip = left_pipette.has_tip else: print("Warning: No pipette detected on LEFT mount.") - + if not has_tip: print("\nNOTE: No physical tip detected on the LEFT pipette nozzle.") print("To run the full liquid handling (aspiration/dispense) plunger sequence, attach a tip to the nozzle.") - print("The script will still move the gantry through all the target slots (C1, C2) to verify coordinates, but will bypass plunger commands.") - + print( + "The script will still move the gantry through all the target slots (C1, C2) " + "to verify coordinates, but will bypass plunger commands." + ) + # 2. Homing print("\n--- 3. Homing Robot ---") await call_command(_SERVICE, _PKG, "Home") await check_machine_status("after Home") - + # 3. PHASE 1: Simulated Labware Transport (Gripper) print("\n--- 4. PHASE 1: Labware Transport (Gripper Mount) ---") - + # 3.1 Move to Slot C3 (source labware) - print(f"Moving Gripper to Slot C3 center (X={SLOT_C3['x']}, Y={SLOT_C3['y']}, Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.GRIPPER, "x": SLOT_C3["x"], "y": SLOT_C3["y"], "z": SAFE_Z_GANTRY, "speed": 50.0 - }) + print( + f"Moving Gripper to Slot C3 center (X={SLOT_C3['x']}, Y={SLOT_C3['y']}, " + f"Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]..." + ) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_C3["x"], "y": SLOT_C3["y"], "z": SAFE_Z_GANTRY, "speed": 50.0}, + ) await check_machine_status("after Gripper Move to C3") - + # 3.2 Lower gripper print(f"Lowering Gripper to Z={ACTION_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.GRIPPER, "x": SLOT_C3["x"], "y": SLOT_C3["y"], "z": ACTION_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_C3["x"], "y": SLOT_C3["y"], "z": ACTION_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Gripper descend C3") - + # 3.3 Ungrip then Grip (simulate picking up plate) print("Opening gripper jaws (Ungrip)...") await call_command(_GRIPPER_SERVICE, _GRIPPER_PKG, "Ungrip") await check_machine_status("after Gripper Ungrip C3") - + print("Closing gripper jaws on plate (Grip 5.0 N)...") await call_command(_GRIPPER_SERVICE, _GRIPPER_PKG, "Grip", {"force": 5.0}) await check_machine_status("after Gripper Grip C3") - + # 3.4 Raise Gripper print(f"Raising Gripper back to Z={SAFE_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.GRIPPER, "x": SLOT_C3["x"], "y": SLOT_C3["y"], "z": SAFE_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_C3["x"], "y": SLOT_C3["y"], "z": SAFE_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Gripper ascend C3") - + # 3.5 Move Gripper to Slot C2 (destination labware slot) - print(f"Moving Gripper to Slot C2 center (X={SLOT_C2['x']}, Y={SLOT_C2['y']}, Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.GRIPPER, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 50.0 - }) + print( + f"Moving Gripper to Slot C2 center (X={SLOT_C2['x']}, Y={SLOT_C2['y']}, " + f"Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]..." + ) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 50.0}, + ) await check_machine_status("after Gripper Move to C2") - + # 3.6 Lower gripper print(f"Lowering Gripper to Z={ACTION_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.GRIPPER, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": ACTION_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": ACTION_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Gripper descend C2") - + # 3.7 Ungrip (simulate releasing plate) print("Opening gripper jaws (Ungrip)...") await call_command(_GRIPPER_SERVICE, _GRIPPER_PKG, "Ungrip") await check_machine_status("after Gripper Ungrip C2") - + # 3.8 Raise Gripper print(f"Raising Gripper back to Z={SAFE_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.GRIPPER, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Gripper ascend C2") - + # 4. PHASE 2: Sample Preparation / Liquid Handling (Pipette Left Mount) print("\n--- 5. PHASE 2: Sample Preparation (Pipette LEFT Mount) ---") - + # 4.1 Move Pipette to Slot C1 (source reagent) - print(f"Moving Pipette to Slot C1 center (X={SLOT_C1['x']}, Y={SLOT_C1['y']}, Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.LEFT, "x": SLOT_C1["x"], "y": SLOT_C1["y"], "z": SAFE_Z_GANTRY, "speed": 50.0 - }) + print( + f"Moving Pipette to Slot C1 center (X={SLOT_C1['x']}, Y={SLOT_C1['y']}, " + f"Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]..." + ) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.LEFT, "x": SLOT_C1["x"], "y": SLOT_C1["y"], "z": SAFE_Z_GANTRY, "speed": 50.0}, + ) await check_machine_status("after Pipette Move to C1") - + # 4.2 Lower pipette print(f"Lowering Pipette to Z={ACTION_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.LEFT, "x": SLOT_C1["x"], "y": SLOT_C1["y"], "z": ACTION_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.LEFT, "x": SLOT_C1["x"], "y": SLOT_C1["y"], "z": ACTION_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Pipette descend C1") - + # 4.3 Reagent Aspiration (only if tip attached) if has_tip: print("Preparing for aspiration (lowering plunger)...") await call_command(_SERVICE, _PKG, "PrepareForAspirate", {"mount": Mount.LEFT}) await check_machine_status("after PrepareForAspirate C1") - + print("Aspirating 20 uL sample reagent [Rate: 1.0]...") await call_command(_SERVICE, _PKG, "Aspirate", {"mount": Mount.LEFT, "volume": 20.0, "rate": 1.0}) await check_machine_status("after Aspirate C1") else: print("[Skipping plunger preparation & aspiration: No tip attached]") - + # 4.4 Raise Pipette print(f"Raising Pipette back to Z={SAFE_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.LEFT, "x": SLOT_C1["x"], "y": SLOT_C1["y"], "z": SAFE_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.LEFT, "x": SLOT_C1["x"], "y": SLOT_C1["y"], "z": SAFE_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Pipette ascend C1") - + # 4.5 Move Pipette to Slot C2 (destination plate) - print(f"Moving Pipette to Slot C2 center (X={SLOT_C2['x']}, Y={SLOT_C2['y']}, Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.LEFT, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 50.0 - }) + print( + f"Moving Pipette to Slot C2 center (X={SLOT_C2['x']}, Y={SLOT_C2['y']}, " + f"Z={SAFE_Z_GANTRY}) [Speed: 50 mm/s]..." + ) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.LEFT, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 50.0}, + ) await check_machine_status("after Pipette Move to C2") - + # 4.6 Lower pipette print(f"Lowering Pipette to Z={ACTION_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.LEFT, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": ACTION_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.LEFT, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": ACTION_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Pipette descend C2") - + # 4.7 Dispense sample & blowout (only if tip attached) if has_tip: print("Dispensing 20 uL sample [Rate: 1.0]...") - await call_command(_SERVICE, _PKG, "Dispense", { - "mount": Mount.LEFT, "volume": 20.0, "rate": 1.0, "push_out": 0.0 - }) + await call_command( + _SERVICE, _PKG, "Dispense", {"mount": Mount.LEFT, "volume": 20.0, "rate": 1.0, "push_out": 0.0} + ) await check_machine_status("after Dispense C2") - + print("Executing blowout...") await call_command(_SERVICE, _PKG, "BlowOut", {"mount": Mount.LEFT}) await check_machine_status("after BlowOut C2") else: print("[Skipping plunger dispense & blowout: No tip attached]") - + # 4.8 Raise Pipette print(f"Raising Pipette back to Z={SAFE_Z_GANTRY} [Speed: 30 mm/s]...") - await call_command(_SERVICE, _PKG, "MoveTo", { - "mount": Mount.LEFT, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 30.0 - }) + await call_command( + _SERVICE, + _PKG, + "MoveTo", + {"mount": Mount.LEFT, "x": SLOT_C2["x"], "y": SLOT_C2["y"], "z": SAFE_Z_GANTRY, "speed": 30.0}, + ) await check_machine_status("after Pipette ascend C2") - + # 5. Homing to finish print("\n--- 6. Homing Robot to Finish ---") await call_command(_SERVICE, _PKG, "Home") await check_machine_status("after final Home") - + print("\n=== Sample Preparation & Labware Transport Workflow Completed Successfully! ===") - + except Exception as e: print(f"\nError communicating with the robot: {e}") finally: await channel.close() await connector.stop() - try: + with contextlib.suppress(StopAsyncIteration): await gen.__anext__() - except StopAsyncIteration: - pass + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( diff --git a/src/unitelabs/opentrons_flex/__init__.py b/src/unitelabs/opentrons_flex/__init__.py index 26e14fb..784abd2 100644 --- a/src/unitelabs/opentrons_flex/__init__.py +++ b/src/unitelabs/opentrons_flex/__init__.py @@ -447,40 +447,14 @@ async def create_app(config: OpentronsFlexConfig) -> collections.abc.AsyncGenera await api.clean_up() -async def _create_app_with_robot_server( +def _prepare_mutation_runtime( config: OpentronsFlexConfig, - labware_config: LoadedLabwareMovementConfig, compatibility: RuntimeCompatibilityReport, -) -> collections.abc.AsyncGenerator[Connector, None]: - """ - Start both the SiLA2 gRPC server and the opentrons HTTP robot-server in one process. - - Mirrors the OT-2 connector's in-process design, but builds an ``OT3API`` (CAN) instead - of an OT-2 ``API`` (Smoothie serial): - - 1. ``OT3API.build_hardware_controller`` brings up the CAN bus once. - 2. ``HardwareProxy`` wraps it with an ``asyncio.Lock`` — every command from either - server acquires this lock. - 3. App-state pre-population sets a completed init task + our proxy on - ``robot_server_app.state`` so robot-server skips its own hardware init. - 4. uvicorn serves ``robot_server_app`` on a Unix domain socket (nginx proxies TCP 31950) - or a TCP port for testing. - - ``robot_server`` is a system package on the Flex (not on PyPI); its imports are deferred. - """ - import uvicorn - from opentrons.hardware_control.ot3api import OT3API - - from opentrons.protocol_engine import DeckType - from opentrons_shared_data.robot.types import RobotTypeEnum - - robot_server = load_robot_server_bindings() - - mutation_api_token: str | None = None - mutation_authenticated_actor: str | None = None - mutation_ledger: MutationLedger | None = None +) -> tuple[str | None, str | None, MutationLedger | None]: + """Validate controlled-mutation state before hardware acquisition.""" mutation_api_token = os.environ.get(config.run_mutation_token_env) mutation_authenticated_actor = os.environ.get(config.run_mutation_actor_env) + mutation_ledger: MutationLedger | None = None mutation_issues = mutation_configuration_issues( compatibility, ledger_path=config.run_mutation_ledger_path, @@ -516,7 +490,27 @@ async def _create_app_with_robot_server( ) mutation_api_token = None mutation_authenticated_actor = None + return mutation_api_token, mutation_authenticated_actor, mutation_ledger + + +async def _create_app_with_robot_server( + config: OpentronsFlexConfig, + labware_config: LoadedLabwareMovementConfig, + compatibility: RuntimeCompatibilityReport, +) -> collections.abc.AsyncGenerator[Connector, None]: + """ + Start both the SiLA2 gRPC server and the opentrons HTTP robot-server in one process. + + Hardware cleanup is registered immediately after acquisition. Synchronous + robot-server and labware cleanup is registered before any subsequent + fallible startup step. + """ + from opentrons.hardware_control.ot3api import OT3API + mutation_api_token, mutation_authenticated_actor, mutation_ledger = _prepare_mutation_runtime( + config, + compatibility, + ) if config.use_simulator: log.info("Building shared OT3API (simulator)") shared_hardware = await OT3API.build_hardware_simulator( @@ -527,6 +521,41 @@ async def _create_app_with_robot_server( log.info("Building shared OT3API on CAN bus") shared_hardware = await OT3API.build_hardware_controller() + try: + with contextlib.ExitStack() as setup_cleanup: + async for connector in _serve_with_robot_server( + config, + labware_config, + compatibility, + shared_hardware=shared_hardware, + mutation_api_token=mutation_api_token, + mutation_authenticated_actor=mutation_authenticated_actor, + mutation_ledger=mutation_ledger, + setup_cleanup=setup_cleanup, + ): + yield connector + finally: + await shared_hardware.clean_up() + + +async def _serve_with_robot_server( + config: OpentronsFlexConfig, + labware_config: LoadedLabwareMovementConfig, + compatibility: RuntimeCompatibilityReport, + *, + shared_hardware: object, + mutation_api_token: str | None, + mutation_authenticated_actor: str | None, + mutation_ledger: MutationLedger | None, + setup_cleanup: contextlib.ExitStack, +) -> collections.abc.AsyncGenerator[Connector, None]: + """Wire and serve robot-server using already-acquired shared hardware.""" + import uvicorn + from opentrons.protocol_engine import DeckType + from opentrons_shared_data.robot.types import RobotTypeEnum + + robot_server = load_robot_server_bindings() + def _current_run_store() -> object | None: # robot-server stores this singleton under the stable app-state key used # by its AppStateAccessor. Reading the state directly keeps the shared @@ -551,6 +580,8 @@ def _current_run_state() -> ProtocolRunState: if labware_config.state_file is not None else None ) + if labware_state is not None: + setup_cleanup.callback(labware_state.close) robot_server_hardware = ( OT3SimulatorCompatibilityAdapter(shared_hardware) if config.use_simulator else shared_hardware ) @@ -591,6 +622,7 @@ async def _get_flex_deck_type() -> DeckType: robot_server.get_robot_type_enum: _get_flex_robot_type_enum, robot_server.get_deck_type: _get_flex_deck_type, } + added_robot_server_routes: list[object] = [] robot_server_installation = install_robot_server_app( robot_server, initialization_task=init_task, @@ -601,6 +633,14 @@ async def _get_flex_deck_type() -> DeckType: proxy, ), ) + + def _restore_installed_robot_server() -> None: + restore_robot_server_app( + robot_server_installation, + added_routes=tuple(added_robot_server_routes), + ) + + setup_cleanup.callback(_restore_installed_robot_server) robot_server_app = typing.cast(typing.Any, robot_server_installation.app) runtime_identity = process_runtime_identity( @@ -608,7 +648,7 @@ async def _get_flex_deck_type() -> DeckType: require_release=not config.use_simulator, ) runtime_identity_router = create_runtime_identity_router(runtime_identity) - added_robot_server_routes = list( + added_robot_server_routes.extend( include_robot_server_router( robot_server_installation, runtime_identity_router, @@ -698,12 +738,3 @@ async def _get_flex_deck_type() -> DeckType: finally: uv_server.should_exit = True await asyncio.gather(robot_server_task, return_exceptions=True) - restore_robot_server_app( - robot_server_installation, - added_routes=tuple(added_robot_server_routes), - ) - try: - if labware_state is not None: - labware_state.close() - finally: - await shared_hardware.clean_up() diff --git a/src/unitelabs/opentrons_flex/http_safety.py b/src/unitelabs/opentrons_flex/http_safety.py new file mode 100644 index 0000000..3209a99 --- /dev/null +++ b/src/unitelabs/opentrons_flex/http_safety.py @@ -0,0 +1,29 @@ +"""Fail-closed HTTP helpers for robot readiness checks.""" + +from __future__ import annotations + +import http.client +import urllib.request +from typing import cast + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Prevent readiness probes from following redirects to another endpoint.""" + + def redirect_request(self, *_args: object, **_kwargs: object) -> None: + """Decline every redirect.""" + + +_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler()) + + +def open_no_redirect( + request: urllib.request.Request, + *, + timeout: float, +) -> http.client.HTTPResponse: + """Open one HTTP request without following redirects.""" + return cast( + http.client.HTTPResponse, + _NO_REDIRECT_OPENER.open(request, timeout=timeout), + ) diff --git a/src/unitelabs/opentrons_flex/robot_readiness.py b/src/unitelabs/opentrons_flex/robot_readiness.py index 206e240..3400065 100644 --- a/src/unitelabs/opentrons_flex/robot_readiness.py +++ b/src/unitelabs/opentrons_flex/robot_readiness.py @@ -10,6 +10,8 @@ import urllib.request from pathlib import Path +from unitelabs.opentrons_flex.http_safety import open_no_redirect + _HTTP_PATHS = ( "/health", "/deck_configuration", @@ -92,7 +94,7 @@ def _loopback_json(port: int, path: str, timeout: float) -> object: }, ) try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with open_no_redirect(request, timeout=timeout) as response: if response.geturl() != url: message = f"robot loopback {path} redirected to {response.geturl()}" raise RuntimeError(message) diff --git a/src/unitelabs/opentrons_flex/robot_server_compat.py b/src/unitelabs/opentrons_flex/robot_server_compat.py index 683be94..fc36f83 100644 --- a/src/unitelabs/opentrons_flex/robot_server_compat.py +++ b/src/unitelabs/opentrons_flex/robot_server_compat.py @@ -28,6 +28,11 @@ class RobotServerAppInstallation: """Reversible mutation of the global robot-server FastAPI app.""" app: object + app_state: object + hardware_accessor: object + initialization_task_accessor: object + original_hardware: object | None + original_initialization_task: object | None router: object dependency_overrides: MutableMapping[object, object] original_lifespan: object @@ -113,6 +118,16 @@ def install_robot_server_app( "set_on", "robot-server hardware accessor", ) + get_initialization_task = _required_method( + bindings.initialization_task_accessor, + "get_from", + "robot-server initialization task accessor", + ) + get_hardware = _required_method( + bindings.hardware_accessor, + "get_from", + "robot-server hardware accessor", + ) dependency_overrides = _required_attribute( app, @@ -133,21 +148,37 @@ def install_robot_server_app( (dependency, dependency in dependency_overrides, dependency_overrides.get(dependency)) for dependency in identity_overrides ) - - # All fallible shape validation happens above. Mutate the pinned global app - # only after the complete installation contract is known to be usable. - set_initialization_task(app_state, initialization_task) - set_hardware(app_state, hardware_proxy) - dependency_overrides.update(identity_overrides) - router.lifespan_context = replacement_lifespan - return RobotServerAppInstallation( + installation = RobotServerAppInstallation( app=app, + app_state=app_state, + hardware_accessor=bindings.hardware_accessor, + initialization_task_accessor=bindings.initialization_task_accessor, + original_hardware=get_hardware(app_state), + original_initialization_task=get_initialization_task(app_state), router=router, dependency_overrides=dependency_overrides, original_lifespan=original_lifespan, original_identity_overrides=original_overrides, ) + # All fallible shape validation happens above. Mutate the pinned global app + # only after the complete installation contract is known to be usable. + # Roll back internally because the caller cannot register cleanup until + # this function returns an installation handle. + try: + set_initialization_task(app_state, initialization_task) + set_hardware(app_state, hardware_proxy) + dependency_overrides.update(identity_overrides) + router.lifespan_context = replacement_lifespan + except Exception as exc: + try: + restore_robot_server_app(installation, added_routes=()) + except (AttributeError, RuntimeError, TypeError) as rollback_exc: + message = f"robot-server app installation failed and rollback was incomplete: {rollback_exc}" + raise RuntimeError(message) from exc + raise + return installation + def include_robot_server_router( installation: RobotServerAppInstallation, @@ -184,20 +215,41 @@ def restore_robot_server_app( added_routes: tuple[object, ...], ) -> None: """Restore global robot-server app state after connector shutdown.""" - installation.router.lifespan_context = installation.original_lifespan - if added_routes: - routes = _required_attribute(installation.router, "routes", "robot-server app router") - if not isinstance(routes, MutableSequence): - message = "robot-server app router routes must remain a mutable sequence." - raise RuntimeError(message) - added_route_ids = {id(route) for route in added_routes} - routes[:] = [route for route in routes if id(route) not in added_route_ids] - installation.app.openapi_schema = None - for dependency, existed, original in installation.original_identity_overrides: - if existed: - installation.dependency_overrides[dependency] = original - else: - installation.dependency_overrides.pop(dependency, None) + set_hardware = _required_method( + installation.hardware_accessor, + "set_on", + "robot-server hardware accessor", + ) + set_initialization_task = _required_method( + installation.initialization_task_accessor, + "set_on", + "robot-server initialization task accessor", + ) + try: + try: + installation.router.lifespan_context = installation.original_lifespan + if added_routes: + routes = _required_attribute(installation.router, "routes", "robot-server app router") + if not isinstance(routes, MutableSequence): + message = "robot-server app router routes must remain a mutable sequence." + raise RuntimeError(message) + added_route_ids = {id(route) for route in added_routes} + routes[:] = [route for route in routes if id(route) not in added_route_ids] + installation.app.openapi_schema = None + finally: + for dependency, existed, original in installation.original_identity_overrides: + if existed: + installation.dependency_overrides[dependency] = original + else: + installation.dependency_overrides.pop(dependency, None) + finally: + try: + set_hardware(installation.app_state, installation.original_hardware) + finally: + set_initialization_task( + installation.app_state, + installation.original_initialization_task, + ) def protocol_run_state( diff --git a/src/unitelabs/opentrons_flex/runtime_compat.py b/src/unitelabs/opentrons_flex/runtime_compat.py index 1032b26..2dbbc9c 100644 --- a/src/unitelabs/opentrons_flex/runtime_compat.py +++ b/src/unitelabs/opentrons_flex/runtime_compat.py @@ -45,7 +45,9 @@ ) _ROBOT_SERVER_SHAPE_REQUIREMENTS = ( + ("robot_server.hardware", "_hw_api_accessor.get_from", "callable"), ("robot_server.hardware", "_hw_api_accessor.set_on", "callable"), + ("robot_server.hardware", "_init_task_accessor.get_from", "callable"), ("robot_server.hardware", "_init_task_accessor.set_on", "callable"), ("robot_server.hardware", "get_deck_type", "callable"), ("robot_server.hardware", "get_robot_type", "callable"), diff --git a/src/unitelabs/opentrons_flex/runtime_identity.py b/src/unitelabs/opentrons_flex/runtime_identity.py new file mode 100644 index 0000000..69d920c --- /dev/null +++ b/src/unitelabs/opentrons_flex/runtime_identity.py @@ -0,0 +1,72 @@ +"""Release identity parsing shared by runtime preflight and live API code.""" + +from __future__ import annotations + +import json +import platform +import sys +from pathlib import Path + +from .runtime_compat import SUPPORTED_OPENTRONS_SOURCE_COMMIT + + +def release_identity( + *, + connector_version: str, + opentrons_version: str, + required: bool, + prefix: Path | None = None, +) -> tuple[dict[str, str] | None, tuple[str, ...]]: + """Read and validate the verified release manifest for one Python prefix.""" + manifest_path = (prefix or Path(sys.prefix)).resolve() / "runtime-manifest.json" + if not manifest_path.is_file(): + issues = (f"Active release manifest is missing: {manifest_path}.",) if required else () + return None, issues + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, (f"Cannot read active release manifest {manifest_path}: {exc}",) + expected = { + "schemaVersion": 1, + "connectorVersion": connector_version, + "opentronsVersion": opentrons_version, + "robotServerVersion": opentrons_version, + "opentronsSourceCommit": SUPPORTED_OPENTRONS_SOURCE_COMMIT, + "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}", + "architecture": normalized_architecture(), + } + issues = [ + f"Active release manifest {name} is {manifest.get(name)!r}; expected {value!r}." + for name, value in expected.items() + if manifest.get(name) != value + ] + release_id = manifest.get("releaseId") + bundle_sha256 = manifest.get("bundleSha256") + if not isinstance(release_id, str) or not release_id: + issues.append("Active release manifest releaseId is missing.") + if ( + not isinstance(bundle_sha256, str) + or len(bundle_sha256) != 64 + or any(character not in "0123456789abcdef" for character in bundle_sha256) + ): + issues.append("Active release manifest bundleSha256 is invalid.") + if isinstance(release_id, str) and release_id and isinstance(bundle_sha256, str): + expected_release_id = ( + f"flex-{connector_version}-ot{opentrons_version}-" + f"py{sys.version_info.major}.{sys.version_info.minor}-" + f"{normalized_architecture()}-{bundle_sha256[:12]}" + ) + if release_id != expected_release_id: + issues.append(f"Active release manifest releaseId is {release_id!r}; expected {expected_release_id!r}.") + if issues: + return None, tuple(issues) + return { + "release_id": release_id, + "bundle_sha256": bundle_sha256, + }, () + + +def normalized_architecture() -> str: + """Return the architecture label used by release manifests.""" + architecture = platform.machine().lower() + return {"arm64": "aarch64", "amd64": "x86_64"}.get(architecture, architecture) diff --git a/src/unitelabs/opentrons_flex/runtime_identity_api.py b/src/unitelabs/opentrons_flex/runtime_identity_api.py index 962d0fc..a8cab27 100644 --- a/src/unitelabs/opentrons_flex/runtime_identity_api.py +++ b/src/unitelabs/opentrons_flex/runtime_identity_api.py @@ -8,7 +8,7 @@ from fastapi import APIRouter from .runtime_compat import RuntimeCompatibilityReport -from .runtime_preflight import _release_identity +from .runtime_identity import release_identity # Resolve these once when the connector process imports this module. If the # /var/sila2_flex symlink changes without a service restart, the running process @@ -22,7 +22,7 @@ def process_runtime_identity( require_release: bool, ) -> dict[str, object]: """Build identity evidence bound to this running Python process.""" - release_identity, issues = _release_identity( + active_release_identity, issues = release_identity( connector_version=compatibility.connector_version, opentrons_version=compatibility.opentrons_version, required=require_release, @@ -36,7 +36,7 @@ def process_runtime_identity( "runtimeContractId": compatibility.runtime_contract_id, "connectorVersion": compatibility.connector_version, "opentronsVersion": compatibility.opentrons_version, - "releaseIdentity": release_identity, + "releaseIdentity": active_release_identity, } diff --git a/src/unitelabs/opentrons_flex/runtime_preflight.py b/src/unitelabs/opentrons_flex/runtime_preflight.py index 0a9d192..5965cf7 100644 --- a/src/unitelabs/opentrons_flex/runtime_preflight.py +++ b/src/unitelabs/opentrons_flex/runtime_preflight.py @@ -5,16 +5,15 @@ import argparse import json import os -import platform import sys from pathlib import Path from typing import Any from .runtime_compat import ( - SUPPORTED_OPENTRONS_SOURCE_COMMIT, inspect_runtime_compatibility, mutation_configuration_issues, ) +from .runtime_identity import release_identity def _parser() -> argparse.ArgumentParser: @@ -63,7 +62,7 @@ def main(argv: list[str] | None = None) -> int: require_robot_server=args.require_robot_server, require_live_hardware=args.require_live_hardware, ) - release_identity, release_issues = _release_identity( + active_release_identity, release_issues = release_identity( connector_version=report.connector_version, opentrons_version=report.opentrons_version, required=args.require_live_hardware, @@ -80,7 +79,7 @@ def main(argv: list[str] | None = None) -> int: mutation_ready = not mutation_issues payload = { **report.to_dict(), - "release_identity": release_identity, + "release_identity": active_release_identity, "configuration_issues": config_issues, "mutation_requested": config.get("run_mutation_ledger_path") is not None, "mutation_required": bool(config.get("run_mutation_required", False)), @@ -123,65 +122,5 @@ def _configuration_issues( return tuple(issues) -def _release_identity( - *, - connector_version: str, - opentrons_version: str, - required: bool, - prefix: Path | None = None, -) -> tuple[dict[str, str] | None, tuple[str, ...]]: - manifest_path = (prefix or Path(sys.prefix)).resolve() / "runtime-manifest.json" - if not manifest_path.is_file(): - issues = (f"Active release manifest is missing: {manifest_path}.",) if required else () - return None, issues - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return None, (f"Cannot read active release manifest {manifest_path}: {exc}",) - expected = { - "schemaVersion": 1, - "connectorVersion": connector_version, - "opentronsVersion": opentrons_version, - "robotServerVersion": opentrons_version, - "opentronsSourceCommit": SUPPORTED_OPENTRONS_SOURCE_COMMIT, - "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}", - "architecture": _normalized_architecture(), - } - issues = [ - f"Active release manifest {name} is {manifest.get(name)!r}; expected {value!r}." - for name, value in expected.items() - if manifest.get(name) != value - ] - release_id = manifest.get("releaseId") - bundle_sha256 = manifest.get("bundleSha256") - if not isinstance(release_id, str) or not release_id: - issues.append("Active release manifest releaseId is missing.") - if ( - not isinstance(bundle_sha256, str) - or len(bundle_sha256) != 64 - or any(character not in "0123456789abcdef" for character in bundle_sha256) - ): - issues.append("Active release manifest bundleSha256 is invalid.") - if isinstance(release_id, str) and release_id and isinstance(bundle_sha256, str): - expected_release_id = ( - f"flex-{connector_version}-ot{opentrons_version}-" - f"py{sys.version_info.major}.{sys.version_info.minor}-" - f"{_normalized_architecture()}-{bundle_sha256[:12]}" - ) - if release_id != expected_release_id: - issues.append(f"Active release manifest releaseId is {release_id!r}; expected {expected_release_id!r}.") - if issues: - return None, tuple(issues) - return { - "release_id": release_id, - "bundle_sha256": bundle_sha256, - }, () - - -def _normalized_architecture() -> str: - architecture = platform.machine().lower() - return {"arm64": "aarch64", "amd64": "x86_64"}.get(architecture, architecture) - - if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/scripts/test_deployment_scripts.py b/tests/scripts/test_deployment_scripts.py index 35ae7b9..2865e1c 100644 --- a/tests/scripts/test_deployment_scripts.py +++ b/tests/scripts/test_deployment_scripts.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import subprocess from pathlib import Path @@ -10,6 +12,224 @@ def _text(path: str) -> str: return (ROOT / path).read_text() +def _remote_body(path: str, runtime_root: Path) -> str: + script = _text(path) + marker = "<<'REMOTE'\n" + start = script.index(marker) + len(marker) + end = script.rindex("\nREMOTE") + body = script[start:end] + "\n" + replacements = { + 'STATE_DIR="/var/lib/unitelabs-opentrons-flex"': ( + f'STATE_DIR="{runtime_root}/var/lib/unitelabs-opentrons-flex"' + ), + 'ACTIVE_PATH="/var/sila2_flex"': f'ACTIVE_PATH="{runtime_root}/var/sila2_flex"', + } + for original, isolated in replacements.items(): + assert original in body, f"{path} no longer exposes the expected isolated path seam" + body = body.replace(original, isolated) + systemd_unit = "cat > /etc/systemd/system/sila2-connector.service" + if path == "scripts/install_connector_service.sh": + assert systemd_unit in body, f"{path} no longer exposes the expected isolated systemd seam" + if systemd_unit in body: + body = body.replace( + systemd_unit, + f'cat > "{runtime_root}/etc/systemd/system/sila2-connector.service"', + ) + assert 'STATE_DIR="/var/lib/unitelabs-opentrons-flex"' not in body + assert 'ACTIVE_PATH="/var/sila2_flex"' not in body + assert systemd_unit not in body + return body + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents) + path.chmod(0o755) + + +def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[str, Path]]: + runtime_root = tmp_path / "runtime-root" + state_dir = runtime_root / "var/lib/unitelabs-opentrons-flex" + releases_dir = state_dir / "releases" + current_release = releases_dir / "current" + previous_release = releases_dir / "previous" + active_path = runtime_root / "var/sila2_flex" + systemd_dir = runtime_root / "etc/systemd/system" + fake_bin = tmp_path / "fake-bin" + command_log = tmp_path / "commands.log" + connector_active = tmp_path / "connector-active" + stock_active = tmp_path / "stock-active" + + for directory in ( + current_release / "bin", + previous_release / "bin", + active_path.parent, + systemd_dir, + fake_bin, + ): + directory.mkdir(parents=True, exist_ok=True) + active_path.symlink_to(current_release, target_is_directory=True) + stock_active.touch() + (state_dir / "run-mutation.env").write_text( + "UNITELABS_RUN_MUTATION_TOKEN=" + ("t" * 32) + "\nUNITELABS_RUN_MUTATION_ACTOR=offline-harness\n" + ) + (state_dir / "stock-service-state").write_text("opentrons-robot-server|yes|yes\n") + for release in (current_release, previous_release): + (release / "config.json").write_text("{}\n") + _write_executable( + release / "bin/python", + """#!/bin/sh +printf '%s\n' "release-python $*" >> "$FAKE_COMMAND_LOG" +exit "${FAKE_PREFLIGHT_EXIT:-0}" +""", + ) + + _write_executable( + fake_bin / "systemctl", + """#!/bin/sh +set -eu +printf '%s\n' "systemctl $*" >> "$FAKE_COMMAND_LOG" +command="${1:-}" +service="${2:-}" +if [ "$command" = "is-active" ] || [ "$command" = "is-enabled" ]; then + for argument in "$@"; do + service="$argument" + done +fi +case "$command" in + cat) + exit 0 + ;; + is-enabled) + [ "$service" != "sila2-connector" ] + exit $? + ;; + is-active) + case "$service" in + sila2-connector) [ -f "$FAKE_CONNECTOR_ACTIVE" ] ;; + opentrons-robot-server) [ -f "$FAKE_STOCK_ACTIVE" ] ;; + *) [ -f "$FAKE_STOCK_ACTIVE" ] ;; + esac + exit $? + ;; + start) + if [ "$service" = "sila2-connector" ]; then + [ "${FAKE_CONNECTOR_START_FAIL:-0}" != 1 ] || exit 1 + : > "$FAKE_CONNECTOR_ACTIVE" + elif [ "$service" = "opentrons-robot-server" ]; then + : > "$FAKE_STOCK_ACTIVE" + fi + ;; + stop) + if [ "$service" = "sila2-connector" ]; then + rm -f "$FAKE_CONNECTOR_ACTIVE" + elif [ "$service" = "opentrons-robot-server" ]; then + rm -f "$FAKE_STOCK_ACTIVE" + fi + ;; +esac +exit 0 +""", + ) + _write_executable( + fake_bin / "curl", + """#!/bin/sh +set -eu +url="" +for argument in "$@"; do + url="$argument" +done +printf '%s\n' "curl $url" >> "$FAKE_COMMAND_LOG" +case "$url" in + */openapi.json) + [ -f "$FAKE_CONNECTOR_ACTIVE" ] && [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] || exit 1 + printf '%s\n' '{"paths":{"/unitelabs/runs/{run_id}/mutations":{}}}' + ;; + */deck_configuration) + [ -f "$FAKE_CONNECTOR_ACTIVE" ] && [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] + ;; + */health) + if [ -f "$FAKE_CONNECTOR_ACTIVE" ]; then + [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] + else + [ -f "$FAKE_STOCK_ACTIVE" ] && [ "${FAKE_STOCK_HEALTH:-1}" = 1 ] + fi + ;; + *) + exit 1 + ;; +esac +""", + ) + _write_executable( + fake_bin / "python3", + """#!/bin/sh +set -eu +printf '%s\n' "python3 $*" >> "$FAKE_COMMAND_LOG" +code="${2:-}" +case "$code" in + *socket.create_connection*) + [ -f "$FAKE_CONNECTOR_ACTIVE" ] && [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] + ;; + *json.load*) + cat >/dev/null + ;; + *os.replace*) + [ "${FAKE_ACTIVATION_FAIL:-0}" != 1 ] || exit 1 + rm -f "$4" + mv "$3" "$4" + ;; + *) + exit 1 + ;; +esac +""", + ) + for command in ("mount", "sleep", "journalctl"): + _write_executable( + fake_bin / command, + f"""#!/bin/sh +printf '%s\\n' "{command} $*" >> "$FAKE_COMMAND_LOG" +exit 0 +""", + ) + + environment = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "FAKE_RUNTIME_ROOT": str(runtime_root), + "FAKE_COMMAND_LOG": str(command_log), + "FAKE_CONNECTOR_ACTIVE": str(connector_active), + "FAKE_STOCK_ACTIVE": str(stock_active), + "FAKE_CONNECTOR_HEALTH": "1", + "FAKE_STOCK_HEALTH": "1", + "FAKE_PREFLIGHT_EXIT": "0", + "FAKE_ACTIVATION_FAIL": "0", + } + paths = { + "active": active_path, + "command_log": command_log, + "connector_active": connector_active, + "current_release": current_release, + "previous_release": previous_release, + "previous_record": state_dir / "previous-release", + "stock_active": stock_active, + } + return environment, paths + + +def _run_remote_body(path: str, environment: dict[str, str]) -> subprocess.CompletedProcess[str]: + runtime_root = Path(environment["FAKE_RUNTIME_ROOT"]) + return subprocess.run( + ["sh"], + input=_remote_body(path, runtime_root), + text=True, + capture_output=True, + check=False, + env=environment, + timeout=10, + ) + + def test_deploy_verifies_artifact_and_runtime_before_activation() -> None: script = _text("deploy.sh") @@ -84,3 +304,56 @@ def test_rollback_leaves_stock_robot_server_active() -> None: assert "stock robot-server did not recover within 120 seconds" in script assert "os.replace" in script assert "printf 'legacy:%s\\n' \"$CURRENT_TARGET\"" in script + + +def test_service_install_preflight_failure_never_touches_service_state(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + environment["FAKE_PREFLIGHT_EXIT"] = "1" + + result = _run_remote_body("scripts/install_connector_service.sh", environment) + + assert result.returncode != 0 + assert "systemctl " not in paths["command_log"].read_text() + assert paths["stock_active"].is_file() + assert not paths["connector_active"].exists() + + +def test_service_health_timeout_restores_stock_mode(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + environment["FAKE_CONNECTOR_HEALTH"] = "0" + + result = _run_remote_body("scripts/install_connector_service.sh", environment) + command_log = paths["command_log"].read_text() + + assert result.returncode != 0 + assert "connector failed the bounded startup health check" in result.stderr + assert "systemctl start sila2-connector" in command_log + assert "systemctl stop sila2-connector" in command_log + assert "systemctl start opentrons-robot-server" in command_log + assert paths["stock_active"].is_file() + assert not paths["connector_active"].exists() + + +def test_rollback_activation_failure_keeps_current_release_active(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + paths["previous_record"].write_text(f"release:{paths['previous_release']}\n") + environment["FAKE_ACTIVATION_FAIL"] = "1" + + result = _run_remote_body("scripts/rollback_connector.sh", environment) + + assert result.returncode != 0 + assert paths["active"].resolve() == paths["current_release"] + assert paths["previous_record"].read_text() == f"release:{paths['previous_release']}\n" + assert paths["stock_active"].is_file() + + +def test_rollback_atomically_swaps_release_and_records_prior_target(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + paths["previous_record"].write_text(f"release:{paths['previous_release']}\n") + + result = _run_remote_body("scripts/rollback_connector.sh", environment) + + assert result.returncode == 0, result.stderr + assert paths["active"].resolve() == paths["previous_release"] + assert paths["previous_record"].read_text() == f"release:{paths['current_release']}\n" + assert paths["stock_active"].is_file() diff --git a/tests/test_acceptance_manifest.py b/tests/test_acceptance_manifest.py index e4b0342..0f09106 100644 --- a/tests/test_acceptance_manifest.py +++ b/tests/test_acceptance_manifest.py @@ -4,7 +4,7 @@ import pytest -from unitelabs.opentrons_flex.acceptance import AcceptanceManifest, PlanContract, validate_plan_contract +from unitelabs_flex_acceptance_contract import AcceptanceManifest, PlanContract, validate_plan_contract def _coordinate(x: float, y: float, z: float) -> dict[str, float]: diff --git a/tests/test_create_app_with_robot_server.py b/tests/test_create_app_with_robot_server.py index 867e309..852d7df 100644 --- a/tests/test_create_app_with_robot_server.py +++ b/tests/test_create_app_with_robot_server.py @@ -14,7 +14,7 @@ import contextlib import dataclasses import sys -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -309,3 +309,40 @@ async def test_shutdown_disconnects_hardware(): async with _run() as r: pass r.api.clean_up.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_startup_failure_after_app_install_unwinds_all_resources(): + """A fail-closed identity error must restore globals and release CAN resources.""" + mock_api = _make_api() + mock_uv_server = MagicMock() + mock_uv_server.serve = AsyncMock() + robot_server_app = sys.modules["robot_server.app"].app + robot_server_hardware = sys.modules["robot_server.hardware"] + original_lifespan = robot_server_app.router.lifespan_context + original_hardware = robot_server_hardware._hw_api_accessor.get_from.return_value + original_initialization_task = robot_server_hardware._init_task_accessor.get_from.return_value + + with ( + _patches(mock_api, MagicMock(), mock_uv_server), + patch( + "unitelabs.opentrons_flex.process_runtime_identity", + side_effect=RuntimeError("invalid release identity"), + ), + ): + gen = create_app(_CONFIG) + with pytest.raises(RuntimeError, match="invalid release identity"): + await gen.__anext__() + + mock_api.clean_up.assert_awaited_once() + assert robot_server_app.dependency_overrides == {} + assert robot_server_app.router.routes == [] + assert robot_server_app.router.lifespan_context is original_lifespan + assert robot_server_hardware._hw_api_accessor.set_on.call_args_list[-1] == call( + robot_server_app.state, + original_hardware, + ) + assert robot_server_hardware._init_task_accessor.set_on.call_args_list[-1] == call( + robot_server_app.state, + original_initialization_task, + ) diff --git a/tests/test_http_safety.py b/tests/test_http_safety.py new file mode 100644 index 0000000..30e9eca --- /dev/null +++ b/tests/test_http_safety.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import urllib.request + +from unitelabs.opentrons_flex.http_safety import _NoRedirectHandler + + +def test_readiness_http_redirect_handler_declines_redirects() -> None: + handler = _NoRedirectHandler() + request = urllib.request.Request("http://127.0.0.1:31950/health") + + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {"Location": "http://192.0.2.10/internal"}, + "http://192.0.2.10/internal", + ) + + assert redirected is None diff --git a/tests/test_robot_server_compat.py b/tests/test_robot_server_compat.py index 1922190..463a4b3 100644 --- a/tests/test_robot_server_compat.py +++ b/tests/test_robot_server_compat.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest @@ -115,10 +115,22 @@ def include_router(router) -> None: app_routes.extend(object() for _ in router.routes) app.include_router = include_router + original_hardware = object() + original_initialization_task = object() + hardware_accessor = SimpleNamespace( + get_from=MagicMock(return_value=original_hardware), + set_on=MagicMock(), + ) + initialization_task_accessor = SimpleNamespace( + get_from=MagicMock(return_value=original_initialization_task), + set_on=MagicMock(), + ) + installed_hardware = object() + installed_initialization_task = object() bindings = RobotServerBindings( app=app, - hardware_accessor=SimpleNamespace(set_on=MagicMock()), - initialization_task_accessor=SimpleNamespace(set_on=MagicMock()), + hardware_accessor=hardware_accessor, + initialization_task_accessor=initialization_task_accessor, get_deck_type=MagicMock(), get_robot_type=MagicMock(), get_robot_type_enum=MagicMock(), @@ -131,8 +143,8 @@ def include_router(router) -> None: installation = install_robot_server_app( bindings, - initialization_task=object(), - hardware_proxy=object(), + initialization_task=installed_initialization_task, + hardware_proxy=installed_hardware, identity_overrides={ original_dependency: object(), added_dependency: added_override, @@ -154,6 +166,14 @@ def include_router(router) -> None: assert app.router.routes == [baseline_route] assert app.router.lifespan_context is original_lifespan assert app.dependency_overrides == {original_dependency: original_override} + assert hardware_accessor.set_on.call_args_list == [ + call(app.state, installed_hardware), + call(app.state, original_hardware), + ] + assert initialization_task_accessor.set_on.call_args_list == [ + call(app.state, installed_initialization_task), + call(app.state, original_initialization_task), + ] def test_router_registration_that_adds_no_routes_fails_closed() -> None: @@ -166,8 +186,14 @@ def test_router_registration_that_adds_no_routes_fails_closed() -> None: ) bindings = RobotServerBindings( app=app, - hardware_accessor=SimpleNamespace(set_on=MagicMock()), - initialization_task_accessor=SimpleNamespace(set_on=MagicMock()), + hardware_accessor=SimpleNamespace( + get_from=MagicMock(return_value=None), + set_on=MagicMock(), + ), + initialization_task_accessor=SimpleNamespace( + get_from=MagicMock(return_value=None), + set_on=MagicMock(), + ), get_deck_type=MagicMock(), get_robot_type=MagicMock(), get_robot_type_enum=MagicMock(), @@ -187,3 +213,57 @@ def test_router_registration_that_adds_no_routes_fails_closed() -> None: installation, SimpleNamespace(routes=[object()]), ) + + +def test_partial_app_installation_is_rolled_back_transactionally() -> None: + original_hardware = object() + original_initialization_task = object() + installed_hardware = object() + installed_initialization_task = object() + state = SimpleNamespace( + hardware=original_hardware, + initialization_task=original_initialization_task, + ) + + class _Accessor: + def __init__(self, attribute: str, fail_value: object | None = None) -> None: + self._attribute = attribute + self._fail_value = fail_value + + def get_from(self, app_state) -> object: + return getattr(app_state, self._attribute) + + def set_on(self, app_state, value: object) -> None: + setattr(app_state, self._attribute, value) + if value is self._fail_value: + raise RuntimeError("setter failed") + + app = SimpleNamespace( + state=state, + dependency_overrides={}, + router=SimpleNamespace(routes=[], lifespan_context=MagicMock()), + openapi_schema=None, + ) + app.include_router = MagicMock() + bindings = RobotServerBindings( + app=app, + hardware_accessor=_Accessor("hardware", fail_value=installed_hardware), + initialization_task_accessor=_Accessor("initialization_task"), + get_deck_type=MagicMock(), + get_robot_type=MagicMock(), + get_robot_type_enum=MagicMock(), + mark_light_control_startup_finished=MagicMock(), + start_light_control_task=MagicMock(), + ) + + with pytest.raises(RuntimeError, match="setter failed"): + install_robot_server_app( + bindings, + initialization_task=installed_initialization_task, + hardware_proxy=installed_hardware, + identity_overrides={}, + lifespan_factory=lambda original: original, + ) + + assert state.hardware is original_hardware + assert state.initialization_task is original_initialization_task diff --git a/tests/test_runtime_contract_consistency.py b/tests/test_runtime_contract_consistency.py index a4e6942..4df8444 100644 --- a/tests/test_runtime_contract_consistency.py +++ b/tests/test_runtime_contract_consistency.py @@ -3,6 +3,8 @@ import re from pathlib import Path +import yaml + from scripts import artifact_manifest from unitelabs.opentrons_flex import runtime_compat @@ -65,3 +67,24 @@ def test_runtime_dependency_manifest_matches_startup_contract() -> None: def test_flex_documentation_does_not_claim_the_retired_runtime() -> None: for path in ("README.md", "docs/asms_flex_workflow_test.md"): assert "8.8.1" not in _text(path), f"{path} still claims the retired Flex runtime" + + +def test_arm_artifact_build_is_a_pr_gate_for_every_release_input() -> None: + workflow = yaml.load( + _text(".github/workflows/build-flex-arm-wheels.yml"), + Loader=yaml.BaseLoader, + ) + triggers = workflow["on"] + required_paths = { + ".github/workflows/build-flex-arm-wheels.yml", + "Dockerfile.build", + "packages/flex-acceptance-contract/**", + "pyproject.toml", + "scripts/artifact_manifest.py", + "src/**", + "uv.lock", + } + + assert triggers["pull_request"]["branches"] == ["main"] + assert required_paths <= set(triggers["pull_request"]["paths"]) + assert required_paths <= set(triggers["push"]["paths"]) diff --git a/tests/test_runtime_identity_api.py b/tests/test_runtime_identity_api.py index 5ab9165..831e45c 100644 --- a/tests/test_runtime_identity_api.py +++ b/tests/test_runtime_identity_api.py @@ -47,7 +47,7 @@ def _release_identity_for_test(**kwargs): monkeypatch.setattr( runtime_identity_api, - "_release_identity", + "release_identity", _release_identity_for_test, ) diff --git a/tests/test_runtime_preflight.py b/tests/test_runtime_preflight.py index 1afa9a9..2267afb 100644 --- a/tests/test_runtime_preflight.py +++ b/tests/test_runtime_preflight.py @@ -2,7 +2,7 @@ import json -from unitelabs.opentrons_flex import runtime_preflight +from unitelabs.opentrons_flex import runtime_identity, runtime_preflight from unitelabs.opentrons_flex.runtime_compat import RuntimeCompatibilityReport @@ -121,10 +121,10 @@ def test_release_identity_binds_active_venv_to_verified_bundle(tmp_path, monkeyp ), encoding="utf-8", ) - monkeypatch.setattr(runtime_preflight.sys, "prefix", str(tmp_path)) - monkeypatch.setattr(runtime_preflight, "_normalized_architecture", lambda: architecture) + monkeypatch.setattr(runtime_identity.sys, "prefix", str(tmp_path)) + monkeypatch.setattr(runtime_identity, "normalized_architecture", lambda: architecture) - identity, issues = runtime_preflight._release_identity( + identity, issues = runtime_identity.release_identity( connector_version="0.9.1", opentrons_version="9.0.0", required=True, @@ -144,16 +144,16 @@ def test_release_identity_rejects_manifest_from_different_active_bundle(tmp_path "opentronsVersion": "9.0.0", "robotServerVersion": "9.0.0", "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", - "pythonVersion": f"{runtime_preflight.sys.version_info.major}.{runtime_preflight.sys.version_info.minor}", + "pythonVersion": f"{runtime_identity.sys.version_info.major}.{runtime_identity.sys.version_info.minor}", "architecture": "aarch64", "releaseId": "flex-0.9.1-unrelated", "bundleSha256": "a" * 64, } (tmp_path / "runtime-manifest.json").write_text(json.dumps(manifest), encoding="utf-8") - monkeypatch.setattr(runtime_preflight.sys, "prefix", str(tmp_path)) - monkeypatch.setattr(runtime_preflight, "_normalized_architecture", lambda: "aarch64") + monkeypatch.setattr(runtime_identity.sys, "prefix", str(tmp_path)) + monkeypatch.setattr(runtime_identity, "normalized_architecture", lambda: "aarch64") - identity, issues = runtime_preflight._release_identity( + identity, issues = runtime_identity.release_identity( connector_version="0.9.1", opentrons_version="9.0.0", required=True, diff --git a/tests/test_workflow_manifests.py b/tests/test_workflow_manifests.py index 7e0461a..7e8bcdd 100644 --- a/tests/test_workflow_manifests.py +++ b/tests/test_workflow_manifests.py @@ -74,6 +74,9 @@ class _Logger: def info(self, *_args, **_kwargs) -> None: pass + def error(self, *_args, **_kwargs) -> None: + pass + class _Client: pass @@ -108,12 +111,14 @@ def test_liquid_handler_workflows_are_tagged() -> None: assert "ot2" in tags +@pytest.mark.flex_workflow_offline def test_flex_acceptance_workflow_is_hardware_tagged() -> None: """The Flex workflow must be unmistakably hardware-facing in the catalog.""" tags = set(_pyproject("flex-system-acceptance")["tool"]["unitelabs"]["workflow"]["tags"]) assert {"flex", "liquid-handler", "hardware-acceptance"} <= tags +@pytest.mark.flex_workflow_offline def test_flex_workflow_uses_cross_runtime_contract_instead_of_connector_package() -> None: """The Python 3.12 workflow must not depend on the Python 3.10 robot runtime.""" data = _pyproject("flex-system-acceptance") @@ -157,6 +162,24 @@ def test_workflow_entrypoints_import_and_build_as_flows(monkeypatch) -> None: assert getattr(flow_fn, "__prefect_name__").startswith("Workflow:") +@pytest.mark.flex_workflow_offline +def test_flex_workflow_entrypoint_imports_as_a_python_312_safe_flow(monkeypatch) -> None: + """The Flex entrypoint imports without the Python 3.10 connector runtime.""" + _install_workflow_runtime_stubs(monkeypatch) + monkeypatch.syspath_prepend(str(WORKFLOWS / "flex-system-acceptance" / "src")) + for loaded in list(sys.modules): + if loaded == "flex_system_acceptance" or loaded.startswith("flex_system_acceptance."): + monkeypatch.delitem(sys.modules, loaded, raising=False) + + module = importlib.import_module("flex_system_acceptance.workflow") + flow_fn = module.flex_system_acceptance_flow + + assert inspect.iscoroutinefunction(flow_fn) + assert getattr(flow_fn, "__prefect_kind__") == "flow" + assert getattr(flow_fn, "__prefect_name__") == "Workflow: Flex System Acceptance" + + +@pytest.mark.flex_workflow_offline @pytest.mark.asyncio async def test_flex_workflow_phases_execute_the_declared_sdk_sequence(monkeypatch) -> None: """Run every hardware phase against a recorder so Python/SDK wiring errors fail offline.""" @@ -168,7 +191,7 @@ async def test_flex_workflow_phases_execute_the_declared_sdk_sequence(monkeypatc steps = importlib.import_module("flex_system_acceptance._steps") from tests.test_acceptance_manifest import _manifest - from unitelabs.opentrons_flex.acceptance import AcceptanceManifest + from unitelabs_flex_acceptance_contract import AcceptanceManifest manifest = AcceptanceManifest.parse(_manifest()) features = { @@ -220,6 +243,7 @@ async def fake_move(_features, plan_identifier: str, *, lid: bool = False): assert methods.count("move_lid") == 4 +@pytest.mark.flex_workflow_offline def test_flex_cloud_workflow_requires_the_direct_hitl_manifest_digest(monkeypatch) -> None: """Runtime callers cannot substitute uncommissioned coordinates or module identities.""" _install_workflow_runtime_stubs(monkeypatch) @@ -230,7 +254,7 @@ def test_flex_cloud_workflow_requires_the_direct_hitl_manifest_digest(monkeypatc workflow = importlib.import_module("flex_system_acceptance.workflow") from tests.test_acceptance_manifest import _manifest - from unitelabs.opentrons_flex.acceptance import AcceptanceManifest + from unitelabs_flex_acceptance_contract import AcceptanceManifest manifest = AcceptanceManifest.parse(_manifest()) monkeypatch.delenv("FLEX_ACCEPTANCE_MANIFEST_SHA256", raising=False) @@ -243,3 +267,191 @@ def test_flex_cloud_workflow_requires_the_direct_hitl_manifest_digest(monkeypatc monkeypatch.setenv("FLEX_ACCEPTANCE_MANIFEST_SHA256", manifest.commissioning_digest()) workflow._require_commissioned_manifest(manifest) + + +@pytest.mark.flex_workflow_offline +@pytest.mark.asyncio +async def test_flex_workflow_shutdown_attempts_every_action_and_reports_all_failures(monkeypatch) -> None: + """Shutdown must settle every energy-producing feature without hiding failures.""" + _install_workflow_runtime_stubs(monkeypatch) + monkeypatch.syspath_prepend(str(WORKFLOWS / "flex-system-acceptance" / "src")) + for loaded in list(sys.modules): + if loaded == "flex_system_acceptance" or loaded.startswith("flex_system_acceptance."): + monkeypatch.delitem(sys.modules, loaded, raising=False) + + steps = importlib.import_module("flex_system_acceptance._steps") + feature_names = ( + "heater_shaker", + "thermocycler", + "temperature_module", + "reader", + "stacker_maintenance", + "motion", + ) + features = {name: object() for name in feature_names} + names_by_feature = {id(feature): name for name, feature in features.items()} + calls: list[tuple[str, str, dict]] = [] + + async def fake_invoke(target, method_names, **parameters): + feature_name = names_by_feature[id(target)] + method = method_names if isinstance(method_names, str) else next(iter(method_names)) + calls.append((feature_name, method, parameters)) + if (feature_name, method) in { + ("heater_shaker", "stop_shaking"), + ("temperature_module", "deactivate"), + }: + raise RuntimeError(f"{method} failed") + + monkeypatch.setattr(steps, "invoke", fake_invoke) + + failures = await steps.safe_shutdown(features) + + assert [(feature, method) for feature, method, _ in calls] == [ + ("heater_shaker", "stop_shaking"), + ("heater_shaker", "deactivate_heater"), + ("thermocycler", "deactivate_all"), + ("temperature_module", "deactivate"), + ("reader", "deactivate"), + ("stacker_maintenance", "deactivate"), + ("motion", "set_lights"), + ] + assert calls[-1][2] == {"button": False, "rails": False} + assert failures == ( + "heater_shaker.stop_shaking: RuntimeError: stop_shaking failed", + "temperature_module.deactivate: RuntimeError: deactivate failed", + ) + + +@pytest.mark.flex_workflow_offline +@pytest.mark.asyncio +async def test_flex_workflow_refuses_success_when_shutdown_is_incomplete(monkeypatch) -> None: + """A successful hardware sequence is not accepted when de-energization fails.""" + _install_workflow_runtime_stubs(monkeypatch) + monkeypatch.syspath_prepend(str(WORKFLOWS / "flex-system-acceptance" / "src")) + for loaded in list(sys.modules): + if loaded == "flex_system_acceptance" or loaded.startswith("flex_system_acceptance."): + monkeypatch.delitem(sys.modules, loaded, raising=False) + + workflow = importlib.import_module("flex_system_acceptance.workflow") + from tests.test_acceptance_manifest import _manifest + from unitelabs_flex_acceptance_contract import AcceptanceManifest + + manifest = AcceptanceManifest.parse(_manifest()) + monkeypatch.setenv("FLEX_ACCEPTANCE_MANIFEST_SHA256", manifest.commissioning_digest()) + phase_calls: list[str] = [] + + async def validate(value): + return AcceptanceManifest.parse(value) + + async def connect(_device_name, _manifest): + return object(), object(), {} + + async def phase(_features, _manifest): + phase_calls.append("phase") + + async def shutdown(_features): + phase_calls.append("shutdown") + return ("heater_shaker.stop_shaking: RuntimeError: timeout",) + + monkeypatch.setattr(workflow, "validate_manifest_step", validate) + monkeypatch.setattr(workflow, "connect_and_preflight_step", connect) + for name in ( + "home_and_configure_step", + "stacker_step", + "thermocycler_step", + "liquid_handling_step", + "heater_shaker_step", + "temperature_module_step", + "plate_reader_step", + ): + monkeypatch.setattr(workflow, name, phase) + monkeypatch.setattr(workflow, "safe_shutdown", shutdown) + + with pytest.raises(RuntimeError, match="workflow is not accepted"): + await workflow.flex_system_acceptance_flow(_manifest(), device_name=manifest.service_name) + + assert phase_calls == ["phase"] * 7 + ["shutdown"] + + +@pytest.mark.flex_workflow_offline +@pytest.mark.asyncio +async def test_flex_workflow_preserves_run_failure_when_shutdown_succeeds(monkeypatch) -> None: + """Cleanup must run after a phase failure without masking that original failure.""" + _install_workflow_runtime_stubs(monkeypatch) + monkeypatch.syspath_prepend(str(WORKFLOWS / "flex-system-acceptance" / "src")) + for loaded in list(sys.modules): + if loaded == "flex_system_acceptance" or loaded.startswith("flex_system_acceptance."): + monkeypatch.delitem(sys.modules, loaded, raising=False) + + workflow = importlib.import_module("flex_system_acceptance.workflow") + from tests.test_acceptance_manifest import _manifest + from unitelabs_flex_acceptance_contract import AcceptanceManifest + + manifest = AcceptanceManifest.parse(_manifest()) + monkeypatch.setenv("FLEX_ACCEPTANCE_MANIFEST_SHA256", manifest.commissioning_digest()) + shutdown_called = False + + async def validate(value): + return AcceptanceManifest.parse(value) + + async def connect(_device_name, _manifest): + return object(), object(), {} + + async def fail_phase(_features, _manifest): + raise ValueError("phase failed") + + async def shutdown(_features): + nonlocal shutdown_called + shutdown_called = True + return () + + monkeypatch.setattr(workflow, "validate_manifest_step", validate) + monkeypatch.setattr(workflow, "connect_and_preflight_step", connect) + monkeypatch.setattr(workflow, "home_and_configure_step", fail_phase) + monkeypatch.setattr(workflow, "safe_shutdown", shutdown) + + with pytest.raises(ValueError, match="phase failed"): + await workflow.flex_system_acceptance_flow(_manifest(), device_name=manifest.service_name) + + assert shutdown_called is True + + +@pytest.mark.flex_workflow_offline +@pytest.mark.asyncio +async def test_flex_workflow_reports_shutdown_failure_with_phase_failure_as_cause(monkeypatch) -> None: + """Dual failures must preserve the phase error while making unsafe shutdown visible.""" + _install_workflow_runtime_stubs(monkeypatch) + monkeypatch.syspath_prepend(str(WORKFLOWS / "flex-system-acceptance" / "src")) + for loaded in list(sys.modules): + if loaded == "flex_system_acceptance" or loaded.startswith("flex_system_acceptance."): + monkeypatch.delitem(sys.modules, loaded, raising=False) + + workflow = importlib.import_module("flex_system_acceptance.workflow") + from tests.test_acceptance_manifest import _manifest + from unitelabs_flex_acceptance_contract import AcceptanceManifest + + manifest = AcceptanceManifest.parse(_manifest()) + monkeypatch.setenv("FLEX_ACCEPTANCE_MANIFEST_SHA256", manifest.commissioning_digest()) + + async def validate(value): + return AcceptanceManifest.parse(value) + + async def connect(_device_name, _manifest): + return object(), object(), {} + + async def fail_phase(_features, _manifest): + raise ValueError("phase failed") + + async def fail_shutdown(_features): + return ("thermocycler.deactivate_all: RuntimeError: timeout",) + + monkeypatch.setattr(workflow, "validate_manifest_step", validate) + monkeypatch.setattr(workflow, "connect_and_preflight_step", connect) + monkeypatch.setattr(workflow, "home_and_configure_step", fail_phase) + monkeypatch.setattr(workflow, "safe_shutdown", fail_shutdown) + + with pytest.raises(RuntimeError, match="safety shutdown was incomplete") as failure: + await workflow.flex_system_acceptance_flow(_manifest(), device_name=manifest.service_name) + + assert isinstance(failure.value.__cause__, ValueError) + assert str(failure.value.__cause__) == "phase failed" diff --git a/tests/test_workflow_publishing.py b/tests/test_workflow_publishing.py new file mode 100644 index 0000000..7305a43 --- /dev/null +++ b/tests/test_workflow_publishing.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import zipfile + +from workflows.scripts import publish_workflows + + +def test_contract_change_marks_flex_acceptance_workflow_affected() -> None: + workflows = publish_workflows.discover_workflows() + + affected = publish_workflows.affected_workflows( + ["packages/flex-acceptance-contract/src/unitelabs_flex_acceptance_contract/__init__.py"], + workflows, + ) + + assert affected == ["flex-system-acceptance"] + + +def test_repository_root_workflow_path_marks_workflow_affected() -> None: + workflows = publish_workflows.discover_workflows() + + affected = publish_workflows.affected_workflows( + ["workflows/flex-system-acceptance/src/flex_system_acceptance/workflow.py"], + workflows, + ) + + assert affected == ["flex-system-acceptance"] + + +def test_publish_script_change_marks_every_workflow_affected() -> None: + workflows = publish_workflows.discover_workflows() + + affected = publish_workflows.affected_workflows( + ["workflows/scripts/publish_workflows.py"], + workflows, + ) + + assert affected == sorted(workflows) + + +def test_flex_acceptance_contract_is_vendored_not_platform_installed() -> None: + workflow_dir = publish_workflows.REPO_ROOT / "flex-system-acceptance" + + dependencies = publish_workflows.resolve_dependencies(workflow_dir) + + assert all(not dependency.startswith("unitelabs-flex-acceptance-contract") for dependency in dependencies) + assert "unitelabs-sdk[automate]~=0.11.3" in dependencies + + +def test_flex_acceptance_bundle_contains_contract_source() -> None: + workflow_dir = publish_workflows.REPO_ROOT / "flex-system-acceptance" + bundle_path = publish_workflows.build_bundle( + workflow_dir, + publish_workflows.load_identity(workflow_dir), + ) + + try: + with zipfile.ZipFile(bundle_path) as bundle: + names = set(bundle.namelist()) + assert "unitelabs_flex_acceptance_contract/__init__.py" in names + assert "flex-system-acceptance/src/flex_system_acceptance/workflow.py" in names + finally: + bundle_path.unlink(missing_ok=True) diff --git a/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py b/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py index d9be7e6..4c7b9b7 100644 --- a/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py +++ b/workflows/flex-system-acceptance/src/flex_system_acceptance/_steps.py @@ -2,8 +2,6 @@ from __future__ import annotations -import contextlib - from prefect import task from prefect.cache_policies import NONE from unitelabs.sdk import get_logger @@ -344,8 +342,8 @@ async def plate_reader_step(features: dict, manifest: AcceptanceManifest) -> Non await invoke(reader, "deactivate") -async def safe_shutdown(features: dict) -> None: - """Best-effort de-energization that never invents a gripper recovery move.""" +async def safe_shutdown(features: dict) -> tuple[str, ...]: + """Attempt every de-energization action and report every failure.""" actions = ( ("heater_shaker", "stop_shaking"), ("heater_shaker", "deactivate_heater"), @@ -353,10 +351,20 @@ async def safe_shutdown(features: dict) -> None: ("temperature_module", "deactivate"), ("reader", "deactivate"), ("stacker_maintenance", "deactivate"), + ("motion", "set_lights"), ) + failures: list[str] = [] for feature_name, method in actions: - with contextlib.suppress(Exception): - await invoke(features[feature_name], method) - with contextlib.suppress(Exception): - await invoke(features["motion"], "set_lights", button=False, rails=False) - get_logger().info("Flex acceptance shutdown actions completed; reconcile labware positions after any failure") + parameters = {"button": False, "rails": False} if method == "set_lights" else {} + try: + await invoke(features[feature_name], method, **parameters) + except Exception as exc: + failures.append(f"{feature_name}.{method}: {type(exc).__name__}: {exc}") + if failures: + get_logger().error( + "Flex acceptance shutdown was incomplete; reconcile the robot before another run | failures=%s", + failures, + ) + else: + get_logger().info("Flex acceptance shutdown actions completed") + return tuple(failures) diff --git a/workflows/flex-system-acceptance/src/flex_system_acceptance/workflow.py b/workflows/flex-system-acceptance/src/flex_system_acceptance/workflow.py index 8b3eb66..314a7d6 100644 --- a/workflows/flex-system-acceptance/src/flex_system_acceptance/workflow.py +++ b/workflows/flex-system-acceptance/src/flex_system_acceptance/workflow.py @@ -48,6 +48,7 @@ async def flex_system_acceptance_flow(manifest: dict, device_name: str = INSTRUM ) raise ValueError(msg) _service, _client, features = await connect_and_preflight_step(device_name, validated) + workflow_failure: BaseException | None = None try: await home_and_configure_step(features, validated) await stacker_step(features, validated) @@ -56,6 +57,19 @@ async def flex_system_acceptance_flow(manifest: dict, device_name: str = INSTRUM await heater_shaker_step(features, validated) await temperature_module_step(features, validated) await plate_reader_step(features, validated) - finally: - await safe_shutdown(features) + except BaseException as exc: + workflow_failure = exc + + shutdown_failures = await safe_shutdown(features) + if shutdown_failures: + detail = "; ".join(shutdown_failures) + msg = ( + "Flex acceptance safety shutdown was incomplete. The workflow is not accepted; " + f"reconcile the robot before another run. Failures: {detail}" + ) + if workflow_failure is not None: + raise RuntimeError(msg) from workflow_failure + raise RuntimeError(msg) + if workflow_failure is not None: + raise workflow_failure logger.info("Flex system acceptance completed") diff --git a/workflows/scripts/publish_workflows.py b/workflows/scripts/publish_workflows.py index 2759d23..36706bd 100644 --- a/workflows/scripts/publish_workflows.py +++ b/workflows/scripts/publish_workflows.py @@ -9,8 +9,8 @@ A workflow is any top-level directory whose pyproject.toml declares `[tool.unitelabs.workflow]`. This script zips the workflow directory plus -the sibling `shared/` library verbatim and POSTs/PATCHes the bundle to the -platform. +the sibling `shared/` library and any workflow-specific local packages, then +POSTs/PATCHes the bundle to the platform. The PEP 723 header above declares this script's own runtime deps so `uv run scripts/deploy.py …` resolves them into an ephemeral env without @@ -27,11 +27,13 @@ │ ├── __init__.py │ ├── workflow.py ← @flow lives here │ └── phase_*.py - └── shared/ ← contents of dev `shared/src/shared/`, hoisted + ├── shared/ ← contents of dev `shared/src/shared/`, hoisted ├── __init__.py ├── config/ ├── steps/ └── ... + └── unitelabs_flex_acceptance_contract/ + └── ... ← vendored only for flex-system-acceptance Locally `shared` is a uv editable path dep with src-layout (`shared/src/shared/...`); the platform doesn't install it (no PyPI counterpart), @@ -70,15 +72,31 @@ import subprocess import sys import tempfile -import tomllib import zipfile from pathlib import Path import requests from dotenv import load_dotenv +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + REPO_ROOT = Path(__file__).resolve().parent.parent SHARED_DIR = REPO_ROOT / "shared" +FLEX_ACCEPTANCE_CONTRACT_DIR = REPO_ROOT.parent / "packages" / "flex-acceptance-contract" +WORKFLOW_LOCAL_PACKAGES = { + "flex-system-acceptance": { + "unitelabs-flex-acceptance-contract": ( + FLEX_ACCEPTANCE_CONTRACT_DIR / "src" / "unitelabs_flex_acceptance_contract", + Path("unitelabs_flex_acceptance_contract"), + ) + } +} +LOCAL_PACKAGE_CHANGE_PREFIXES = { + "packages/flex-acceptance-contract/": frozenset({"flex-system-acceptance"}), +} # Bundle exclusions. Everything dev-only or runtime-generated stays off the # worker — tests/, runtime artifacts (logs/, checkpoints/), build caches. @@ -140,12 +158,19 @@ def affected_workflows(changed_paths: list[str], workflows: dict[str, Path]) -> affected. Otherwise a workflow is affected iff a changed path is under its directory. """ - if any(p.startswith("shared/") or p == "scripts/deploy.py" for p in changed_paths): + workflow_relative_paths = [path.removeprefix("workflows/") for path in changed_paths] + if any( + path.startswith("shared/") or path in {"scripts/deploy.py", "scripts/publish_workflows.py"} + for path in workflow_relative_paths + ): return sorted(workflows.keys()) affected: set[str] = set() - for path in changed_paths: + for path, workflow_relative_path in zip(changed_paths, workflow_relative_paths, strict=True): + for prefix, dependent_workflows in LOCAL_PACKAGE_CHANGE_PREFIXES.items(): + if path.startswith(prefix): + affected.update(dependent_workflows.intersection(workflows)) for slug, wf_dir in workflows.items(): - if path.startswith(f"{wf_dir.name}/"): + if workflow_relative_path.startswith(f"{wf_dir.name}/"): affected.add(slug) return sorted(affected) @@ -198,7 +223,7 @@ def _pkg_name(req: str) -> str: def resolve_dependencies(workflow_dir: Path) -> list[str]: - """Direct-dep list = workflow's deps ⨁ shared's deps, deduped, minus `shared`. + """Resolve direct dependencies, excluding source packages vendored in the bundle. The platform's `dependencies` API field drives the runtime venv install. We can't traverse the editable `shared = { path = "../shared" }` source on the @@ -207,8 +232,9 @@ def resolve_dependencies(workflow_dir: Path) -> list[str]: platform from this list. Workflow deps win on duplicates (more specific to the workflow's actual - runtime). The local `shared` entry is dropped — it has no PyPI counterpart; - shared/'s source is vendored at the bundle root by `build_bundle`. + runtime). The local `shared` entry and workflow-specific local packages are + dropped from the platform install list because their source is vendored at + the bundle root by `build_bundle`. TODO: remove once the UniteLabs API resolves deps from the bundle's pyproject server-side (including [tool.uv.sources] path deps). @@ -219,10 +245,14 @@ def resolve_dependencies(workflow_dir: Path) -> list[str]: sh = tomllib.load(f) wf_deps = list(wf.get("project", {}).get("dependencies", [])) sh_deps = list(sh.get("project", {}).get("dependencies", [])) + vendored_dependency_names = { + "shared", + *WORKFLOW_LOCAL_PACKAGES.get(workflow_dir.name, {}), + } seen: dict[str, str] = {} for req in [*wf_deps, *sh_deps]: name = _pkg_name(req) - if name == "shared": + if name in vendored_dependency_names: continue seen.setdefault(name, req) return list(seen.values()) @@ -277,7 +307,7 @@ def _add_tree(zf: zipfile.ZipFile, src_root: Path, dst_root: Path) -> None: def build_bundle(workflow_dir: Path, identity: dict) -> Path: - """Zip the workflow dir verbatim and vendor `shared/` as a flat package. + """Zip the workflow and vendor all repository-local runtime packages. - Workflow dir ships rooted at its own name (the platform sees the same paths the developer does). @@ -295,6 +325,8 @@ def build_bundle(workflow_dir: Path, identity: dict) -> Path: with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as zf: _add_tree(zf, workflow_dir, Path(workflow_dir.name)) _add_tree(zf, SHARED_DIR / "src" / "shared", Path("shared")) + for source_dir, bundle_dir in WORKFLOW_LOCAL_PACKAGES.get(workflow_dir.name, {}).values(): + _add_tree(zf, source_dir, bundle_dir) return bundle_path @@ -411,9 +443,9 @@ def deploy_one( # to provision the worker venv. We send the stitched set from the # workflow's + shared's [project].dependencies (deduped, workflow wins) # so the platform doesn't have to traverse [tool.uv.sources] or the - # editable `shared` path dep itself. `shared` is dropped because it has - # no PyPI counterpart; its source is vendored at the bundle root by - # `build_bundle`. `version` is omitted because the API rejects unknown + # editable local path deps. Local packages are dropped because they have + # no guaranteed package-index counterpart; their source is vendored at + # the bundle root by `build_bundle`. `version` is omitted because the API rejects unknown # fields. `enabled: True` is forced because the UI "delete" only flips # enabled=false; without this, redeploying a "deleted" workflow silently # updates a hidden record. From 89086174416feb6709a9cb0aa4fbdd22973417c6 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 29 Jul 2026 11:20:37 -0400 Subject: [PATCH 08/14] fix: accept Flex pipette hardware name --- scripts/run_asms_hardware.py | 41 ++++++++++---- tests/scripts/test_run_asms_hardware.py | 75 ++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/scripts/run_asms_hardware.py b/scripts/run_asms_hardware.py index f647cb5..8baa608 100755 --- a/scripts/run_asms_hardware.py +++ b/scripts/run_asms_hardware.py @@ -27,6 +27,12 @@ _EXECUTION_CONFIRMATION = "ASMS-DECK-READY" _TERMINAL_RUN_STATES = {"succeeded", "failed", "stopped"} _TERMINAL_ANALYSIS_STATES = {"completed", "failed"} +_EXPECTED_RIGHT_PIPETTE_NAMES = frozenset( + { + "flex_8channel_1000", # Public Python Protocol API load name. + "p1000_multi_flex", # Internal name returned by the Opentrons /pipettes API. + } +) _EXPECTED_LABWARE_HASHES = { "azenta_96_wellplate_200ul_pcr": "43506d5482e3dfebff377e56b709150c81415473efc9cf2c6362dc4b68a1e20f", "thermokingfisherdeepwell_96_wellplate_2000ul": ( @@ -154,13 +160,23 @@ def _deck_configuration_errors(deck_response: dict) -> list[str]: return errors +def _pipette_name(pipette: dict) -> str | None: + value = pipette.get("pipetteName") or pipette.get("name") + return value if isinstance(value, str) else None + + def _hardware_inventory_errors(pipettes: dict, modules: dict) -> list[str]: errors = [] right = pipettes.get("right") if not isinstance(right, dict): errors.append("right pipette mount is empty") - elif right.get("name") != "flex_8channel_1000": - errors.append(f"right pipette: expected flex_8channel_1000, found {right.get('name') or 'unknown'}") + else: + observed_name = _pipette_name(right) + if observed_name not in _EXPECTED_RIGHT_PIPETTE_NAMES: + errors.append( + "right pipette: expected Flex 8-Channel 1000 µL " + f"(flex_8channel_1000 or p1000_multi_flex), found {observed_name or 'unknown'}" + ) module_items = modules.get("data") if not isinstance(module_items, list): @@ -257,16 +273,17 @@ def labware_id(load_name: str) -> str: pipettes = snapshot.get("pipettes") if not isinstance(pipettes, list) or not pipettes: raise RuntimeError("Checkpoint snapshot has no attached pipette") - pipette = next( - ( - item - for item in pipettes - if str(item.get("mount", "")).lower() == "right" - or item.get("pipetteName") == "flex_8channel_1000" - or item.get("name") == "flex_8channel_1000" - ), - pipettes[0], - ) + matching_pipettes = [ + item + for item in pipettes + if str(item.get("mount", "")).lower() == "right" and _pipette_name(item) in _EXPECTED_RIGHT_PIPETTE_NAMES + ] + if len(matching_pipettes) != 1: + raise RuntimeError( + "Checkpoint snapshot must contain exactly one right Flex 8-Channel 1000 µL pipette; " + f"found {len(matching_pipettes)}" + ) + pipette = matching_pipettes[0] tip_racks = snapshot.get("tipRacks") if not isinstance(tip_racks, dict) or not tip_racks: diff --git a/tests/scripts/test_run_asms_hardware.py b/tests/scripts/test_run_asms_hardware.py index 5bc1af5..f1cb4af 100644 --- a/tests/scripts/test_run_asms_hardware.py +++ b/tests/scripts/test_run_asms_hardware.py @@ -92,7 +92,7 @@ def get(self, path: str, *, headers: dict | None = None) -> _Response: "id": "checkpoint-1", "params": {"message": "UNITELABS_MUTATION_CHECKPOINT:ready-before-initial-separation"}, }, - "pipettes": [{"id": "pipette-1", "mount": "right"}], + "pipettes": [{"id": "pipette-1", "mount": "right", "name": "p1000_multi_flex"}], "tipRacks": {"tips-1": {}}, "labware": [ {"id": "reservoir-1", "loadName": "nest_12_reservoir_22ml"}, @@ -224,22 +224,81 @@ def test_deck_validation_reports_wrong_fixture_and_missing_module_serial() -> No def test_hardware_inventory_requires_right_flex_pipette_and_temperature_module() -> None: - assert ( - run_asms_hardware._hardware_inventory_errors( - {"right": {"name": "flex_8channel_1000"}}, - {"data": [{"moduleModel": "temperatureModuleV2"}]}, + for pipette_name in ("flex_8channel_1000", "p1000_multi_flex"): + assert ( + run_asms_hardware._hardware_inventory_errors( + {"right": {"name": pipette_name}}, + {"data": [{"moduleModel": "temperatureModuleV2"}]}, + ) + == [] ) - == [] - ) assert run_asms_hardware._hardware_inventory_errors( {"right": {"name": "flex_1channel_1000"}}, {"data": []}, ) == [ - "right pipette: expected flex_8channel_1000, found flex_1channel_1000", + ( + "right pipette: expected Flex 8-Channel 1000 µL " + "(flex_8channel_1000 or p1000_multi_flex), found flex_1channel_1000" + ), "Temperature Module GEN2 is not connected", ] +def test_checkpoint_transfer_accepts_internal_flex_pipette_name() -> None: + snapshot = { + "pipettes": [{"id": "pipette-1", "mount": "right", "name": "p1000_multi_flex"}], + "tipRacks": {"tips-1": {"wells": {}}}, + "labware": [ + {"id": "reservoir-1", "loadName": "nest_12_reservoir_22ml"}, + { + "id": "waste-1", + "loadName": "thermokingfisherdeepwell_96_wellplate_2000ul", + }, + ], + "disposalAreas": [ + { + "areaType": "movableTrash", + "addressableAreaName": "movableTrashA3", + } + ], + } + + body = run_asms_hardware._checkpoint_transfer_body( + snapshot, + actor="operator-1", + mutation_id="mutation-1", + ) + + assert body["steps"][0]["pipetteId"] == "pipette-1" + + snapshot["pipettes"][0].pop("name") + with pytest.raises(RuntimeError, match="must contain exactly one"): + run_asms_hardware._checkpoint_transfer_body( + snapshot, + actor="operator-1", + mutation_id="mutation-2", + ) + + snapshot["pipettes"][0]["name"] = "flex_1channel_1000" + with pytest.raises(RuntimeError, match="must contain exactly one"): + run_asms_hardware._checkpoint_transfer_body( + snapshot, + actor="operator-1", + mutation_id="mutation-3", + ) + + snapshot["pipettes"] = [ + {"id": "pipette-1", "mount": "right", "name": "p1000_multi_flex"}, + {"id": "pipette-2", "mount": "right", "name": "flex_8channel_1000"}, + ] + with pytest.raises(RuntimeError, match="found 2"): + run_asms_hardware._checkpoint_transfer_body( + snapshot, + actor="operator-1", + mutation_id="mutation-4", + ) + + def test_checkpoint_preflight_requires_both_controlled_mutation_routes() -> None: openapi = { "paths": { From c27bb6c7303b2e9fae9fd75d7becf5db8f08d9f9 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 29 Jul 2026 11:51:55 -0400 Subject: [PATCH 09/14] fix: accept compatible Flex staging fixture --- docs/asms_flex_workflow_test.md | 22 ++++++-- scripts/run_asms_hardware.py | 73 ++++++++++++++++++++++--- tests/scripts/test_run_asms_hardware.py | 49 ++++++++++++++++- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index cb835b9..791c842 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -259,6 +259,13 @@ uv run --extra test python scripts/run_asms_hardware.py \ --confirm-deck-ready ASMS-DECK-READY ``` +When A3 uses a physically installed staging-area fixture, inspect the fixture, +keep deck slot A4 empty, and add +`--confirm-staging-area-deck-slot-a4-empty ASMS-DECK-SLOT-A4-EMPTY` to every +command that also uses `--execute`. Analysis-only commands do not require this +additional confirmation. This requirement applies to deck slot A4, not reservoir +well A4, which contains the prepared methanol. + This inserts one 10 µL eight-channel reservoir-to-waste transfer at the first named checkpoint, verifies allocation of eight clean tips and a terminal `mutation_enqueued` audit record, then authenticates the remaining six checkpoint @@ -278,12 +285,15 @@ already consumes every available fresh tip. 3. **Start read-only.** Verify connector logs, `GET /health`, `GET /pipettes`, `GET /modules`, and SiLA `MachineStatus`. Confirm the right pipette model and Temperature Module identity; release E-stop, close the door, and clear errors. -4. **Match the Flex deck configuration.** In the Opentrons App, set A3 to a - right-slot fixture for the tip rack, D1 to the trash-bin adapter, C1 to the - connected Temperature Module GEN2, and B2 to Magnetic Block GEN1. Confirm - every other cutout matches the table above; the Flex default deck reserves - A3 for trash and treats C1/B2 as ordinary slots, so physical placement alone - is not sufficient. +4. **Match the Flex deck configuration.** In the Opentrons App, set A3 to either + the standard right-slot fixture or a physically installed staging-area + fixture for the tip rack, D1 to the trash-bin adapter, C1 to the connected + Temperature Module GEN2, and B2 to Magnetic Block GEN1. A staging-area + fixture replaces the standard A3 piece but still provides A3 while extending + into deck slot A4; keep deck slot A4 empty for this workflow. This is distinct + from reservoir well A4, which contains methanol. Confirm every other cutout + matches the table above; the Flex default deck reserves A3 for trash and + treats C1/B2 as ordinary slots, so physical placement alone is not sufficient. 5. **Analyze without motion.** Upload the prepared Python file and both exact labware JSON files. Confirm protocol analysis completes without warnings or labware-offset requests. Do not press Run yet. diff --git a/scripts/run_asms_hardware.py b/scripts/run_asms_hardware.py index 8baa608..3ab9b14 100755 --- a/scripts/run_asms_hardware.py +++ b/scripts/run_asms_hardware.py @@ -25,6 +25,7 @@ _LABWARE_DEFINITIONS = tuple(sorted(_LABWARE_DIR.glob("*.json"))) _HTTP_API_VERSION_HEADER = "Opentrons-Version" _EXECUTION_CONFIRMATION = "ASMS-DECK-READY" +_STAGING_AREA_EXECUTION_CONFIRMATION = "ASMS-DECK-SLOT-A4-EMPTY" _TERMINAL_RUN_STATES = {"succeeded", "failed", "stopped"} _TERMINAL_ANALYSIS_STATES = {"completed", "failed"} _EXPECTED_RIGHT_PIPETTE_NAMES = frozenset( @@ -39,11 +40,15 @@ "2ea9c15468816ace3970fe497cef7e1dc22d5f9ab033656bf9472a62396dfb47" ), } -_EXPECTED_DECK_FIXTURES = { - "cutoutA3": "singleRightSlot", - "cutoutB2": "magneticBlockV1", - "cutoutC1": "temperatureModuleV2", - "cutoutD1": "trashBinAdapter", +_A3_FIXTURE_EXECUTION_CONFIRMATIONS = { + "singleRightSlot": None, + "stagingAreaRightSlot": _STAGING_AREA_EXECUTION_CONFIRMATION, +} +_COMPATIBLE_DECK_FIXTURES = { + "cutoutA3": tuple(_A3_FIXTURE_EXECUTION_CONFIRMATIONS), + "cutoutB2": ("magneticBlockV1",), + "cutoutC1": ("temperatureModuleV2",), + "cutoutD1": ("trashBinAdapter",), } _EXPECTED_TWO_COLUMN_COMMANDS = { "moveLabware": 9, @@ -99,6 +104,15 @@ def _parser() -> argparse.ArgumentParser: metavar="PHRASE", help=f"Required execution confirmation phrase: {_EXECUTION_CONFIRMATION}", ) + parser.add_argument( + "--confirm-staging-area-deck-slot-a4-empty", + default=None, + metavar="PHRASE", + help=( + "Required in execution mode when A3 uses a staging-area fixture and deck slot A4 is empty: " + f"{_STAGING_AREA_EXECUTION_CONFIRMATION}" + ), + ) parser.add_argument("--analysis-timeout", type=float, default=120.0) parser.add_argument("--run-timeout", type=float, default=3600.0) parser.add_argument( @@ -149,10 +163,11 @@ def _deck_configuration_errors(deck_response: dict) -> list[str]: fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) fixture_by_cutout = {fixture.get("cutoutId"): fixture for fixture in fixtures} errors = [] - for cutout, expected_fixture in _EXPECTED_DECK_FIXTURES.items(): + for cutout, compatible_fixtures in _COMPATIBLE_DECK_FIXTURES.items(): actual = fixture_by_cutout.get(cutout, {}).get("cutoutFixtureId") - if actual != expected_fixture: - errors.append(f"{cutout}: expected {expected_fixture}, found {actual or 'missing'}") + if actual not in compatible_fixtures: + expected = " or ".join(compatible_fixtures) + errors.append(f"{cutout}: expected {expected}, found {actual or 'missing'}") temperature_fixture = fixture_by_cutout.get("cutoutC1", {}) if not temperature_fixture.get("opentronsModuleSerialNumber"): @@ -160,6 +175,38 @@ def _deck_configuration_errors(deck_response: dict) -> list[str]: return errors +def _deck_configuration_notes(deck_response: dict) -> list[str]: + fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) + fixture_by_cutout = {fixture.get("cutoutId"): fixture for fixture in fixtures} + if fixture_by_cutout.get("cutoutA3", {}).get("cutoutFixtureId") == "stagingAreaRightSlot": + return [ + "A3 staging-area fixture accepted: physically verify the fixture extends to deck slot A4 " + "and keep deck slot A4 empty." + ] + return [] + + +def _staging_area_execution_error( + deck_response: dict, + *, + execute: bool, + confirmation: str | None, +) -> str | None: + if not execute: + return None + fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) + fixture_by_cutout = {fixture.get("cutoutId"): fixture for fixture in fixtures} + a3_fixture = fixture_by_cutout.get("cutoutA3", {}).get("cutoutFixtureId") + required_confirmation = _A3_FIXTURE_EXECUTION_CONFIRMATIONS.get(a3_fixture) + if required_confirmation is not None and confirmation != required_confirmation: + return ( + "A3 uses a staging-area fixture; physically verify that it extends to deck slot A4 and " + "deck slot A4 is empty, then pass --confirm-staging-area-deck-slot-a4-empty " + f"{required_confirmation}" + ) + return None + + def _pipette_name(pipette: dict) -> str | None: value = pipette.get("pipetteName") or pipette.get("name") return value if isinstance(value, str) else None @@ -537,7 +584,17 @@ def main(argv: Sequence[str] | None = None) -> int: deck_errors = _deck_configuration_errors({"data": deck}) if deck_errors: raise RuntimeError("Deck configuration is not AS-MS ready: " + "; ".join(deck_errors)) + staging_execution_error = _staging_area_execution_error( + {"data": deck}, + execute=args.execute, + confirmation=args.confirm_staging_area_deck_slot_a4_empty, + ) + if staging_execution_error: + print(f"BLOCKED: {staging_execution_error}", file=sys.stderr) + return 2 print("Deck configuration: PASS") + for note in _deck_configuration_notes({"data": deck}): + print(f"Deck configuration note: {note}") response = client.post( "/protocols", diff --git a/tests/scripts/test_run_asms_hardware.py b/tests/scripts/test_run_asms_hardware.py index f1cb4af..4cb2bdd 100644 --- a/tests/scripts/test_run_asms_hardware.py +++ b/tests/scripts/test_run_asms_hardware.py @@ -211,6 +211,45 @@ def test_ready_deck_has_no_errors() -> None: assert run_asms_hardware._deck_configuration_errors(_ready_deck()) == [] +def test_staging_area_fixture_preserves_a3_and_requires_operator_note() -> None: + deck = _ready_deck() + deck["data"]["cutoutFixtures"][0]["cutoutFixtureId"] = "stagingAreaRightSlot" + + assert run_asms_hardware._deck_configuration_errors(deck) == [] + assert run_asms_hardware._deck_configuration_notes(deck) == [ + ( + "A3 staging-area fixture accepted: physically verify the fixture extends to deck slot A4 " + "and keep deck slot A4 empty." + ) + ] + + assert run_asms_hardware._staging_area_execution_error( + deck, + execute=True, + confirmation=None, + ) == ( + "A3 uses a staging-area fixture; physically verify that it extends to deck slot A4 and " + "deck slot A4 is empty, then pass --confirm-staging-area-deck-slot-a4-empty " + "ASMS-DECK-SLOT-A4-EMPTY" + ) + assert ( + run_asms_hardware._staging_area_execution_error( + deck, + execute=True, + confirmation="ASMS-DECK-SLOT-A4-EMPTY", + ) + is None + ) + assert ( + run_asms_hardware._staging_area_execution_error( + deck, + execute=False, + confirmation=None, + ) + is None + ) + + def test_deck_validation_reports_wrong_fixture_and_missing_module_serial() -> None: deck = _ready_deck() fixtures = deck["data"]["cutoutFixtures"] @@ -218,9 +257,17 @@ def test_deck_validation_reports_wrong_fixture_and_missing_module_serial() -> No fixtures[2].pop("opentronsModuleSerialNumber") assert run_asms_hardware._deck_configuration_errors(deck) == [ - "cutoutA3: expected singleRightSlot, found trashBinAdapter", + "cutoutA3: expected singleRightSlot or stagingAreaRightSlot, found trashBinAdapter", "cutoutC1: Temperature Module serial number is missing", ] + assert ( + run_asms_hardware._staging_area_execution_error( + _ready_deck(), + execute=True, + confirmation=None, + ) + is None + ) def test_hardware_inventory_requires_right_flex_pipette_and_temperature_module() -> None: From 233aaabdd008fce8cc85731847ea2eb68818c244 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 29 Jul 2026 12:10:44 -0400 Subject: [PATCH 10/14] fix: bind staging gate to live deck topology --- docs/asms_flex_workflow_test.md | 34 +++-- scripts/run_asms_hardware.py | 148 +++++++++++++++---- tests/scripts/test_run_asms_hardware.py | 187 +++++++++++++++++++++++- 3 files changed, 323 insertions(+), 46 deletions(-) diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index 791c842..55341ca 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -259,12 +259,20 @@ uv run --extra test python scripts/run_asms_hardware.py \ --confirm-deck-ready ASMS-DECK-READY ``` -When A3 uses a physically installed staging-area fixture, inspect the fixture, -keep deck slot A4 empty, and add -`--confirm-staging-area-deck-slot-a4-empty ASMS-DECK-SLOT-A4-EMPTY` to every -command that also uses `--execute`. Analysis-only commands do not require this -additional confirmation. This requirement applies to deck slot A4, not reservoir -well A4, which contains the prepared methanol. +When staging-area fixtures are physically installed, keep every corresponding +column-4 deck slot empty. The runner discovers the complete set from the live +deck configuration and binds it into the required execution phrase. For example, +fixtures providing A4 and B4 require +`--confirm-staging-area-slots-empty ASMS-STAGING-SLOTS-A4-B4-EMPTY` on every +command that also uses `--execute`. If the installed set changes, the old phrase +is rejected before protocol upload. Analysis-only commands report the detected +slots but do not require this additional confirmation. This requirement applies +to deck slots such as A4 and B4, not reservoir wells with the same names; reservoir +well A4 contains the prepared methanol. + +The earlier A4-only option remains temporarily accepted, with a deprecation +warning, only when A4 is the sole detected staging-area slot. It is rejected for +the observed A4/B4 configuration; use the topology-bound option above. This inserts one 10 µL eight-channel reservoir-to-waste transfer at the first named checkpoint, verifies allocation of eight clean tips and a terminal @@ -288,12 +296,14 @@ already consumes every available fresh tip. 4. **Match the Flex deck configuration.** In the Opentrons App, set A3 to either the standard right-slot fixture or a physically installed staging-area fixture for the tip rack, D1 to the trash-bin adapter, C1 to the connected - Temperature Module GEN2, and B2 to Magnetic Block GEN1. A staging-area - fixture replaces the standard A3 piece but still provides A3 while extending - into deck slot A4; keep deck slot A4 empty for this workflow. This is distinct - from reservoir well A4, which contains methanol. Confirm every other cutout - matches the table above; the Flex default deck reserves A3 for trash and - treats C1/B2 as ordinary slots, so physical placement alone is not sufficient. + Temperature Module GEN2, and B2 to Magnetic Block GEN1. Each staging-area + fixture replaces a standard column-3 piece while preserving that working slot + and extending into the matching column-4 deck slot. Keep every installed + staging-area deck slot empty for this workflow; with the observed A3 and B3 + fixtures, both A4 and B4 must be empty. This is distinct from reservoir well + A4, which contains methanol. Confirm every other cutout matches the table + above; the Flex default deck reserves A3 for trash and treats C1/B2 as ordinary + slots, so physical placement alone is not sufficient. 5. **Analyze without motion.** Upload the prepared Python file and both exact labware JSON files. Confirm protocol analysis completes without warnings or labware-offset requests. Do not press Run yet. diff --git a/scripts/run_asms_hardware.py b/scripts/run_asms_hardware.py index 3ab9b14..b6a86ef 100755 --- a/scripts/run_asms_hardware.py +++ b/scripts/run_asms_hardware.py @@ -12,6 +12,7 @@ import time from collections import Counter from collections.abc import Sequence +from dataclasses import dataclass from pathlib import Path from uuid import uuid4 @@ -25,7 +26,7 @@ _LABWARE_DEFINITIONS = tuple(sorted(_LABWARE_DIR.glob("*.json"))) _HTTP_API_VERSION_HEADER = "Opentrons-Version" _EXECUTION_CONFIRMATION = "ASMS-DECK-READY" -_STAGING_AREA_EXECUTION_CONFIRMATION = "ASMS-DECK-SLOT-A4-EMPTY" +_LEGACY_A4_EXECUTION_CONFIRMATION = "ASMS-DECK-SLOT-A4-EMPTY" _TERMINAL_RUN_STATES = {"succeeded", "failed", "stopped"} _TERMINAL_ANALYSIS_STATES = {"completed", "failed"} _EXPECTED_RIGHT_PIPETTE_NAMES = frozenset( @@ -40,16 +41,13 @@ "2ea9c15468816ace3970fe497cef7e1dc22d5f9ab033656bf9472a62396dfb47" ), } -_A3_FIXTURE_EXECUTION_CONFIRMATIONS = { - "singleRightSlot": None, - "stagingAreaRightSlot": _STAGING_AREA_EXECUTION_CONFIRMATION, -} -_COMPATIBLE_DECK_FIXTURES = { - "cutoutA3": tuple(_A3_FIXTURE_EXECUTION_CONFIRMATIONS), - "cutoutB2": ("magneticBlockV1",), - "cutoutC1": ("temperatureModuleV2",), - "cutoutD1": ("trashBinAdapter",), -} +_REQUIRED_DECK_CUTOUTS = ("cutoutA3", "cutoutB2", "cutoutC1", "cutoutD1") +_RIGHT_STAGING_SLOTS_BY_CUTOUT = ( + ("cutoutA3", "A4"), + ("cutoutB3", "B4"), + ("cutoutC3", "C4"), + ("cutoutD3", "D4"), +) _EXPECTED_TWO_COLUMN_COMMANDS = { "moveLabware": 9, "pickUpTip": 26, @@ -59,6 +57,44 @@ } +@dataclass(frozen=True) +class _DeckFixtureMetadata: + satisfies_required_cutouts: frozenset[str] + staging_slots_by_cutout: tuple[tuple[str, str], ...] + + +_DECK_FIXTURE_METADATA = { + "singleRightSlot": _DeckFixtureMetadata(frozenset({"cutoutA3"}), ()), + "stagingAreaRightSlot": _DeckFixtureMetadata( + frozenset({"cutoutA3"}), + _RIGHT_STAGING_SLOTS_BY_CUTOUT, + ), + "magneticBlockV1": _DeckFixtureMetadata(frozenset({"cutoutB2"}), ()), + "temperatureModuleV2": _DeckFixtureMetadata(frozenset({"cutoutC1"}), ()), + "trashBinAdapter": _DeckFixtureMetadata(frozenset({"cutoutD1"}), ()), + "stagingAreaSlotWithMagneticBlockV1": _DeckFixtureMetadata( + frozenset(), + _RIGHT_STAGING_SLOTS_BY_CUTOUT, + ), + "stagingAreaSlotWithWasteChuteRightAdapterCovered": _DeckFixtureMetadata( + frozenset(), + _RIGHT_STAGING_SLOTS_BY_CUTOUT, + ), + "stagingAreaSlotWithWasteChuteRightAdapterNoCover": _DeckFixtureMetadata( + frozenset(), + _RIGHT_STAGING_SLOTS_BY_CUTOUT, + ), +} +_COMPATIBLE_DECK_FIXTURES = { + cutout: tuple( + fixture_id + for fixture_id, metadata in _DECK_FIXTURE_METADATA.items() + if cutout in metadata.satisfies_required_cutouts + ) + for cutout in _REQUIRED_DECK_CUTOUTS +} + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( @@ -105,14 +141,20 @@ def _parser() -> argparse.ArgumentParser: help=f"Required execution confirmation phrase: {_EXECUTION_CONFIRMATION}", ) parser.add_argument( - "--confirm-staging-area-deck-slot-a4-empty", + "--confirm-staging-area-slots-empty", default=None, metavar="PHRASE", help=( - "Required in execution mode when A3 uses a staging-area fixture and deck slot A4 is empty: " - f"{_STAGING_AREA_EXECUTION_CONFIRMATION}" + "Required in execution mode when staging-area fixtures are installed. " + "The live deck inspection prints a confirmation phrase bound to every installed column-4 slot." ), ) + parser.add_argument( + "--confirm-staging-area-deck-slot-a4-empty", + default=None, + metavar="PHRASE", + help=argparse.SUPPRESS, + ) parser.add_argument("--analysis-timeout", type=float, default=120.0) parser.add_argument("--run-timeout", type=float, default=3600.0) parser.add_argument( @@ -176,32 +218,76 @@ def _deck_configuration_errors(deck_response: dict) -> list[str]: def _deck_configuration_notes(deck_response: dict) -> list[str]: - fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) - fixture_by_cutout = {fixture.get("cutoutId"): fixture for fixture in fixtures} - if fixture_by_cutout.get("cutoutA3", {}).get("cutoutFixtureId") == "stagingAreaRightSlot": + staging_slots = _installed_staging_area_slots(deck_response) + if staging_slots: + slot_list = ", ".join(staging_slots) return [ - "A3 staging-area fixture accepted: physically verify the fixture extends to deck slot A4 " - "and keep deck slot A4 empty." + f"Installed staging-area deck slots detected: {slot_list}. " + "Keep every listed deck slot physically empty for this workflow." ] return [] +def _installed_staging_area_slots(deck_response: dict) -> tuple[str, ...]: + fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) + staging_slots = set() + for fixture in fixtures: + fixture_id = fixture.get("cutoutFixtureId") + metadata = _DECK_FIXTURE_METADATA.get(fixture_id) + if metadata is None: + if isinstance(fixture_id, str) and "stagingArea" in fixture_id: + raise RuntimeError(f"Unknown staging-area fixture {fixture_id!r}; update the runner before execution.") + continue + cutout = fixture.get("cutoutId") + staging_slot = dict(metadata.staging_slots_by_cutout).get(cutout) + if not metadata.staging_slots_by_cutout: + continue + if staging_slot is None: + raise RuntimeError(f"Staging-area fixture is installed in an unsupported cutout: {cutout}") + staging_slots.add(staging_slot) + return tuple(sorted(staging_slots)) + + +def _staging_area_execution_confirmation(staging_slots: Sequence[str]) -> str: + return f"ASMS-STAGING-SLOTS-{'-'.join(staging_slots)}-EMPTY" + + def _staging_area_execution_error( deck_response: dict, *, execute: bool, confirmation: str | None, + legacy_a4_confirmation: str | None, ) -> str | None: if not execute: return None - fixtures = deck_response.get("data", {}).get("cutoutFixtures", []) - fixture_by_cutout = {fixture.get("cutoutId"): fixture for fixture in fixtures} - a3_fixture = fixture_by_cutout.get("cutoutA3", {}).get("cutoutFixtureId") - required_confirmation = _A3_FIXTURE_EXECUTION_CONFIRMATIONS.get(a3_fixture) - if required_confirmation is not None and confirmation != required_confirmation: + staging_slots = _installed_staging_area_slots(deck_response) + if legacy_a4_confirmation is not None: + if staging_slots != ("A4",): + detected = ", ".join(staging_slots) if staging_slots else "none" + replacement = ( + f" Use --confirm-staging-area-slots-empty {_staging_area_execution_confirmation(staging_slots)}." + if staging_slots + else "" + ) + return ( + "Deprecated --confirm-staging-area-deck-slot-a4-empty is valid only when A4 is the " + f"sole installed staging-area deck slot; detected {detected}.{replacement}" + ) + if legacy_a4_confirmation != _LEGACY_A4_EXECUTION_CONFIRMATION: + return ( + "Deprecated --confirm-staging-area-deck-slot-a4-empty requires the exact phrase " + f"{_LEGACY_A4_EXECUTION_CONFIRMATION}." + ) + return None + if not staging_slots: + return None + required_confirmation = _staging_area_execution_confirmation(staging_slots) + if confirmation != required_confirmation: + slot_list = ", ".join(staging_slots) return ( - "A3 uses a staging-area fixture; physically verify that it extends to deck slot A4 and " - "deck slot A4 is empty, then pass --confirm-staging-area-deck-slot-a4-empty " + f"Installed staging-area deck slots {slot_list} must all be physically empty; " + "verify every listed slot, then pass --confirm-staging-area-slots-empty " f"{required_confirmation}" ) return None @@ -587,11 +673,19 @@ def main(argv: Sequence[str] | None = None) -> int: staging_execution_error = _staging_area_execution_error( {"data": deck}, execute=args.execute, - confirmation=args.confirm_staging_area_deck_slot_a4_empty, + confirmation=args.confirm_staging_area_slots_empty, + legacy_a4_confirmation=args.confirm_staging_area_deck_slot_a4_empty, ) if staging_execution_error: print(f"BLOCKED: {staging_execution_error}", file=sys.stderr) return 2 + if args.execute and args.confirm_staging_area_deck_slot_a4_empty is not None: + print( + "WARNING: --confirm-staging-area-deck-slot-a4-empty is deprecated and is accepted only " + "when A4 is the sole installed staging-area deck slot. Use " + "--confirm-staging-area-slots-empty with the live topology-bound phrase.", + file=sys.stderr, + ) print("Deck configuration: PASS") for note in _deck_configuration_notes({"data": deck}): print(f"Deck configuration note: {note}") diff --git a/tests/scripts/test_run_asms_hardware.py b/tests/scripts/test_run_asms_hardware.py index 4cb2bdd..6f84caf 100644 --- a/tests/scripts/test_run_asms_hardware.py +++ b/tests/scripts/test_run_asms_hardware.py @@ -152,6 +152,25 @@ def post( return super().post(path, files=files, data=data, json=json) +class _StagingDeckClient(_Client): + def get(self, path: str, *, params: dict | None = None) -> _Response: + if path == "/deck_configuration": + deck = _ready_deck() + deck["data"]["cutoutFixtures"][0]["cutoutFixtureId"] = "stagingAreaRightSlot" + deck["data"]["cutoutFixtures"].append({"cutoutId": "cutoutB3", "cutoutFixtureId": "stagingAreaRightSlot"}) + return _Response(200, deck) + return super().get(path, params=params) + + +class _A4StagingDeckClient(_Client): + def get(self, path: str, *, params: dict | None = None) -> _Response: + if path == "/deck_configuration": + deck = _ready_deck() + deck["data"]["cutoutFixtures"][0]["cutoutFixtureId"] = "stagingAreaRightSlot" + return _Response(200, deck) + return super().get(path, params=params) + + def _ready_deck() -> dict: return { "data": { @@ -211,15 +230,17 @@ def test_ready_deck_has_no_errors() -> None: assert run_asms_hardware._deck_configuration_errors(_ready_deck()) == [] -def test_staging_area_fixture_preserves_a3_and_requires_operator_note() -> None: +def test_staging_area_fixtures_require_confirmation_bound_to_every_installed_slot() -> None: deck = _ready_deck() deck["data"]["cutoutFixtures"][0]["cutoutFixtureId"] = "stagingAreaRightSlot" + deck["data"]["cutoutFixtures"].append({"cutoutId": "cutoutB3", "cutoutFixtureId": "stagingAreaRightSlot"}) assert run_asms_hardware._deck_configuration_errors(deck) == [] + assert run_asms_hardware._installed_staging_area_slots(deck) == ("A4", "B4") assert run_asms_hardware._deck_configuration_notes(deck) == [ ( - "A3 staging-area fixture accepted: physically verify the fixture extends to deck slot A4 " - "and keep deck slot A4 empty." + "Installed staging-area deck slots detected: A4, B4. " + "Keep every listed deck slot physically empty for this workflow." ) ] @@ -227,16 +248,18 @@ def test_staging_area_fixture_preserves_a3_and_requires_operator_note() -> None: deck, execute=True, confirmation=None, + legacy_a4_confirmation=None, ) == ( - "A3 uses a staging-area fixture; physically verify that it extends to deck slot A4 and " - "deck slot A4 is empty, then pass --confirm-staging-area-deck-slot-a4-empty " - "ASMS-DECK-SLOT-A4-EMPTY" + "Installed staging-area deck slots A4, B4 must all be physically empty; " + "verify every listed slot, then pass --confirm-staging-area-slots-empty " + "ASMS-STAGING-SLOTS-A4-B4-EMPTY" ) assert ( run_asms_hardware._staging_area_execution_error( deck, execute=True, - confirmation="ASMS-DECK-SLOT-A4-EMPTY", + confirmation="ASMS-STAGING-SLOTS-A4-B4-EMPTY", + legacy_a4_confirmation=None, ) is None ) @@ -245,10 +268,88 @@ def test_staging_area_fixture_preserves_a3_and_requires_operator_note() -> None: deck, execute=False, confirmation=None, + legacy_a4_confirmation=None, + ) + is None + ) + + +@pytest.mark.parametrize( + "fixture_id", + [ + "stagingAreaRightSlot", + "stagingAreaSlotWithMagneticBlockV1", + "stagingAreaSlotWithWasteChuteRightAdapterCovered", + "stagingAreaSlotWithWasteChuteRightAdapterNoCover", + ], +) +def test_all_pinned_staging_area_fixture_variants_map_to_column_four(fixture_id: str) -> None: + deck = { + "data": { + "cutoutFixtures": [ + {"cutoutId": "cutoutD3", "cutoutFixtureId": fixture_id}, + ] + } + } + + assert run_asms_hardware._installed_staging_area_slots(deck) == ("D4",) + + +def test_staging_area_confirmation_changes_when_installed_slots_change() -> None: + assert run_asms_hardware._staging_area_execution_confirmation(("A4", "B4")) == "ASMS-STAGING-SLOTS-A4-B4-EMPTY" + assert ( + run_asms_hardware._staging_area_execution_confirmation(("A4", "B4", "C4")) + == "ASMS-STAGING-SLOTS-A4-B4-C4-EMPTY" + ) + + +def test_legacy_a4_confirmation_is_accepted_only_for_a4() -> None: + a4_only_deck = _ready_deck() + a4_only_deck["data"]["cutoutFixtures"][0]["cutoutFixtureId"] = "stagingAreaRightSlot" + + assert ( + run_asms_hardware._staging_area_execution_error( + a4_only_deck, + execute=True, + confirmation=None, + legacy_a4_confirmation="ASMS-DECK-SLOT-A4-EMPTY", ) is None ) + a4_b4_deck = _StagingDeckClient().get("/deck_configuration").json() + assert "ASMS-STAGING-SLOTS-A4-B4-EMPTY" in ( + run_asms_hardware._staging_area_execution_error( + a4_b4_deck, + execute=True, + confirmation=None, + legacy_a4_confirmation="ASMS-DECK-SLOT-A4-EMPTY", + ) + or "" + ) + assert "detected none" in ( + run_asms_hardware._staging_area_execution_error( + _ready_deck(), + execute=True, + confirmation=None, + legacy_a4_confirmation="ASMS-DECK-SLOT-A4-EMPTY", + ) + or "" + ) + + +def test_unknown_staging_area_fixture_fails_closed() -> None: + deck = { + "data": { + "cutoutFixtures": [ + {"cutoutId": "cutoutB3", "cutoutFixtureId": "stagingAreaFutureFixture"}, + ] + } + } + + with pytest.raises(RuntimeError, match="Unknown staging-area fixture"): + run_asms_hardware._installed_staging_area_slots(deck) + def test_deck_validation_reports_wrong_fixture_and_missing_module_serial() -> None: deck = _ready_deck() @@ -265,6 +366,7 @@ def test_deck_validation_reports_wrong_fixture_and_missing_module_serial() -> No _ready_deck(), execute=True, confirmation=None, + legacy_a4_confirmation=None, ) is None ) @@ -485,6 +587,77 @@ def test_default_mode_analyzes_without_creating_or_playing_a_run(monkeypatch, ca assert "ANALYSIS ONLY" in capsys.readouterr().out +def test_analysis_reports_all_staging_slots_without_requiring_confirmation(monkeypatch, capsys) -> None: + client = _StagingDeckClient() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + + result = run_asms_hardware.main([]) + + assert result == 0 + assert client.posts == ["/protocols"] + assert "Installed staging-area deck slots detected: A4, B4" in capsys.readouterr().out + + +def test_execution_blocks_before_upload_until_all_staging_slots_are_confirmed(monkeypatch, capsys) -> None: + client = _StagingDeckClient() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + + result = run_asms_hardware.main( + [ + "--execute", + "--confirm-deck-ready", + "ASMS-DECK-READY", + "--confirm-staging-area-slots-empty", + "ASMS-STAGING-SLOTS-A4-EMPTY", + ] + ) + + assert result == 2 + assert client.posts == [] + assert "ASMS-STAGING-SLOTS-A4-B4-EMPTY" in capsys.readouterr().err + + +def test_legacy_a4_option_warns_and_remains_accepted_for_a4_only(monkeypatch, capsys) -> None: + client = _A4StagingDeckClient() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + + result = run_asms_hardware.main( + [ + "--execute", + "--confirm-deck-ready", + "ASMS-DECK-READY", + "--confirm-staging-area-deck-slot-a4-empty", + "ASMS-DECK-SLOT-A4-EMPTY", + ] + ) + + assert result == 0 + assert client.posts == ["/protocols", "/runs", "/runs/run-1/actions"] + assert "deprecated" in capsys.readouterr().err + + +def test_execution_accepts_confirmation_bound_to_all_installed_staging_slots(monkeypatch) -> None: + client = _StagingDeckClient() + monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) + monkeypatch.setattr(run_asms_hardware.httpx, "Client", lambda *args, **kwargs: client) + + result = run_asms_hardware.main( + [ + "--execute", + "--confirm-deck-ready", + "ASMS-DECK-READY", + "--confirm-staging-area-slots-empty", + "ASMS-STAGING-SLOTS-A4-B4-EMPTY", + ] + ) + + assert result == 0 + assert client.posts == ["/protocols", "/runs", "/runs/run-1/actions"] + + def test_confirmed_two_column_execution_requires_pinned_command_evidence(monkeypatch, capsys) -> None: client = _Client() monkeypatch.setattr(run_asms_hardware, "_validate_exact_bundle", lambda: None) From eca552b9739a9d2b3c402db2a3293e7940500550 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 29 Jul 2026 13:12:47 -0400 Subject: [PATCH 11/14] feat: add guarded Flex unit-operation mode --- .github/workflows/test.yml | 2 +- README.md | 52 +- config/asms_unit_labware_movement.json | 153 ++++ config/asms_unit_operations.json | 73 ++ config/flex_unit_operations.json | 58 ++ deploy.sh | 33 +- docs/asms_flex_workflow_test.md | 159 +++- pyproject.toml | 1 + scripts/install.sh | 3 + scripts/install_connector_service.sh | 33 +- scripts/reconcile_asms_unit_deck.sh | 96 ++ scripts/rollback_connector.sh | 32 + scripts/run_asms_unit_operations.py | 7 + scripts/switch_mode.sh | 151 +++- src/unitelabs/opentrons_flex/__init__.py | 8 +- .../opentrons_flex/asms_unit_operations.py | 830 ++++++++++++++++++ .../opentrons_flex/features/__init__.py | 2 + .../features/labware_movement.py | 15 +- .../opentrons_flex/io/labware_movement.py | 17 +- .../opentrons_flex/runtime_preflight.py | 29 +- tests/integration/test_grpc_advanced_flex.py | 4 + tests/scripts/test_deployment_scripts.py | 161 +++- tests/test_asms_unit_operations.py | 351 ++++++++ tests/test_runtime_preflight.py | 66 ++ tests/test_smoketest_config.py | 13 + 25 files changed, 2282 insertions(+), 67 deletions(-) create mode 100644 config/asms_unit_labware_movement.json create mode 100644 config/asms_unit_operations.json create mode 100644 config/flex_unit_operations.json create mode 100755 scripts/reconcile_asms_unit_deck.sh create mode 100644 scripts/run_asms_unit_operations.py create mode 100644 src/unitelabs/opentrons_flex/asms_unit_operations.py create mode 100644 tests/test_asms_unit_operations.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6da5bdb..c7a5279 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -191,7 +191,7 @@ jobs: run: uv sync --all-extras - name: Validate Windows readiness and evidence path - run: uv run pytest -p no:cacheprovider tests/scripts/test_preflight_flex.py tests/test_hitl_evidence.py tests/test_acceptance_manifest.py tests/test_runtime_contract_consistency.py -q + run: uv run pytest -p no:cacheprovider tests/scripts/test_preflight_flex.py tests/test_hitl_evidence.py tests/test_acceptance_manifest.py tests/test_runtime_contract_consistency.py tests/test_asms_unit_operations.py -q - name: Build a valid offline workflow manifest run: uv run python -c "import json; from tests.test_acceptance_manifest import _manifest; open('flex-acceptance-ci.json', 'w', encoding='utf-8').write(json.dumps(_manifest()))" diff --git a/README.md b/README.md index f3b04aa..1c1e791 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # Opentrons Flex -A SiLA2 connector for the Opentrons Flex liquid-handling robot that also replaces the -standard Opentrons robot-server HTTP API. Both servers share a single hardware API -instance so they cannot conflict over the CAN bus. +A SiLA2 connector for the Opentrons Flex liquid-handling robot. It supports two +exclusive connector profiles: the full Protocol Engine profile also replaces the +standard Opentrons robot-server HTTP API, while the unit-operation profile gives +SiLA sole ownership of the hardware for guarded atomic commissioning. ## Architecture -This project runs two servers in the same process when deployed to a real Flex: +The deployed service has two selectable profiles: -| Server | Protocol | Port | Purpose | -|--------|----------|------|---------| -| SiLA2 connector | gRPC | 50051 | Lab automation clients (SiLA Browser, UniteLabs platform) | -| Opentrons robot-server | HTTP REST | 31950 | Opentrons App, any REST client | +| Profile | Interfaces | Purpose | +|--------|----------|---------| +| `connector-pe` | SiLA gRPC 50051 + embedded HTTP 31950 | Protocol upload, Protocol Engine runs, and controlled checkpoints | +| `connector-unit` | SiLA gRPC 50051 only | One guarded SiLA atomic operation at a time; no upload, run, or checkpoint path | -Both servers are backed by one shared `HardwareControlAPI` (the Flex `OT3API`) wrapped +In `connector-pe`, both servers are backed by one shared `HardwareControlAPI` (the Flex `OT3API`) wrapped in `HardwareProxy` — an `asyncio.Lock` around every hardware call. This serialises the SiLA gRPC server and the in-process HTTP robot-server so their CAN commands cannot interleave, and avoids the "hardware already initialised" error two separate processes @@ -33,12 +34,20 @@ Engine services, so `/runs`, `/protocols`, and `/commands` remain available with creating a second hardware controller. The connector remains the sole owner of the underlying asynchronous hardware cleanup. -The standard `opentrons-robot-server` systemd service is disabled on deployment. Our -`sila2-connector` service owns the hardware and starts the HTTP API in-process via -uvicorn on a Unix domain socket (`/run/aiohttp.sock`). nginx on the Flex proxies -external TCP port 31950 to that socket — so the HTTP API is reachable at -`http://:31950` exactly as it would be with the stock `opentrons-robot-server` -service. +In either connector profile, the standard `opentrons-robot-server` systemd +service is disabled and `sila2-connector` owns the hardware. In `connector-pe`, +it also starts the HTTP API in-process via uvicorn on a Unix domain socket +(`/run/aiohttp.sock`). nginx on the Flex proxies external TCP port 31950 to that +socket, so the HTTP API remains reachable at `http://:31950`. +`connector-unit` does not start that HTTP server. + +In `connector-unit`, `with_robot_server=false`: the connector builds one real +`OT3API`, exposes SiLA only on robot loopback (Windows clients use an +authenticated SSH tunnel), and loads a local gripper-plan registry plus durable +deck ledger. This is a reversible commissioning mode, not a second full-workflow +implementation. Switch back to `connector-pe` for the current one-hour AS-MS +Protocol Engine workflow until an external workflow owns all logical tip, +liquid, material-lineage, and recovery state. Motion is exposed per **mount** (`LEFT`, `RIGHT`, `GRIPPER`) in deck coordinates (x, y, z mm), matching how the Flex hardware API models the robot. @@ -667,13 +676,22 @@ timeout, the script restores the stock robot-server automatically: ./scripts/install_connector_service.sh ``` -Switch between the SiLA connector and the stock opentrons robot-server at any time (the choice persists across reboot): +Switch among full Protocol Engine connector mode, SiLA-only unit-operation mode, +and the stock Opentrons robot-server at any time (the choice persists across +reboot): ```sh -./scripts/switch_mode.sh connector +./scripts/switch_mode.sh connector-pe +./scripts/switch_mode.sh connector-unit ./scripts/switch_mode.sh opentrons ``` +`connector` remains an alias for `connector-pe`. The unit-operation mode requires +the selectable-config service installed by the current +`install_connector_service.sh`; rerun that installer after deploying this release. +For the exact guarded Windows commands, see +[`docs/asms_flex_workflow_test.md`](docs/asms_flex_workflow_test.md#connector-unit-fast-commissioning-path-scheme-b). + Run all general read-only readiness checks before uploading a protocol. For the commissioning/HITL command on the Windows operator machine, including its exact artifact-binding arguments, follow diff --git a/config/asms_unit_labware_movement.json b/config/asms_unit_labware_movement.json new file mode 100644 index 0000000..3da917f --- /dev/null +++ b/config/asms_unit_labware_movement.json @@ -0,0 +1,153 @@ +{ + "schema_version": 1, + "geometry_contract": { + "opentrons_version": "9.0.0", + "robot_type": "OT-3 Standard", + "labware_uri": "opentrons/axygen_96_wellplate_500ul/1", + "magnetic_block_model": "magneticBlockV1", + "notes": "Grip points and jaw-width limits are derived from the pinned Opentrons geometry model. B2 pickup and drop points intentionally differ by the Magnetic Block gripper offset." + }, + "state_file": "/var/lib/unitelabs-opentrons-flex/asms-unit-deck-state.json", + "initial_occupancy": { + "A1": "asms-plate", + "B1": "elution-plate" + }, + "plans": [ + { + "identifier": "asms-v1-a1-to-b2", + "labware_identifier": "asms-plate", + "is_lid": false, + "source": { + "identifier": "A1", + "kind": "DECK_SLOT", + "grip_point": { + "x": 64.0, + "y": 364.0, + "z": 7.175 + } + }, + "destination": { + "identifier": "B2", + "kind": "DECK_SLOT", + "grip_point": { + "x": 228.0, + "y": 257.0, + "z": -1.675 + } + }, + "grip": { + "force_newtons": 15.0, + "expected_width": 85.34, + "uncertainty_wider": 0.0, + "uncertainty_narrower": 13.84 + }, + "post_drop_slide_offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + { + "identifier": "asms-v1-b2-to-a1", + "labware_identifier": "asms-plate", + "is_lid": false, + "source": { + "identifier": "B2", + "kind": "DECK_SLOT", + "grip_point": { + "x": 228.0, + "y": 257.0, + "z": -2.675 + } + }, + "destination": { + "identifier": "A1", + "kind": "DECK_SLOT", + "grip_point": { + "x": 64.0, + "y": 364.0, + "z": 7.175 + } + }, + "grip": { + "force_newtons": 15.0, + "expected_width": 85.34, + "uncertainty_wider": 0.0, + "uncertainty_narrower": 13.84 + }, + "post_drop_slide_offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + { + "identifier": "asms-v1-b1-to-b2", + "labware_identifier": "elution-plate", + "is_lid": false, + "source": { + "identifier": "B1", + "kind": "DECK_SLOT", + "grip_point": { + "x": 64.0, + "y": 257.0, + "z": 7.175 + } + }, + "destination": { + "identifier": "B2", + "kind": "DECK_SLOT", + "grip_point": { + "x": 228.0, + "y": 257.0, + "z": -1.675 + } + }, + "grip": { + "force_newtons": 15.0, + "expected_width": 85.34, + "uncertainty_wider": 0.0, + "uncertainty_narrower": 13.84 + }, + "post_drop_slide_offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + { + "identifier": "asms-v1-b2-to-b1", + "labware_identifier": "elution-plate", + "is_lid": false, + "source": { + "identifier": "B2", + "kind": "DECK_SLOT", + "grip_point": { + "x": 228.0, + "y": 257.0, + "z": -2.675 + } + }, + "destination": { + "identifier": "B1", + "kind": "DECK_SLOT", + "grip_point": { + "x": 64.0, + "y": 257.0, + "z": 7.175 + } + }, + "grip": { + "force_newtons": 15.0, + "expected_width": 85.34, + "uncertainty_wider": 0.0, + "uncertainty_narrower": 13.84 + }, + "post_drop_slide_offset": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + ] +} diff --git a/config/asms_unit_operations.json b/config/asms_unit_operations.json new file mode 100644 index 0000000..5bb622c --- /dev/null +++ b/config/asms_unit_operations.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "movement_profile_sha256": "fb8fa69a3d781acd2f0edb1a6e710dec626cdf5661b5d9db46b35bab88df14f3", + "expected_staging_slots": [ + "A4", + "B4", + "C4", + "D4" + ], + "expected_pipette_models": [ + "flex_8channel_1000", + "p1000_multi_flex" + ], + "expected_plan_routes": { + "asms-v1-a1-to-b2": [ + "asms-plate", + "A1", + "B2" + ], + "asms-v1-b2-to-a1": [ + "asms-plate", + "B2", + "A1" + ], + "asms-v1-b1-to-b2": [ + "elution-plate", + "B1", + "B2" + ], + "asms-v1-b2-to-b1": [ + "elution-plate", + "B2", + "B1" + ] + }, + "initial_occupancy": { + "A1": "asms-plate", + "B1": "elution-plate" + }, + "pipetting": { + "mount": "RIGHT", + "tiprack_uri": "opentrons/opentrons_flex_96_tiprack_1000ul/1", + "tiprack_diameter": 5.47, + "tip_length": 95.6, + "tip_location": { + "x": 342.38, + "y": 395.38, + "z": 99.0 + }, + "source": { + "x": 23.38, + "y": 149.74, + "z": 18.85 + }, + "source_retract": { + "x": 23.38, + "y": 149.74, + "z": 51.4 + }, + "destination": { + "x": 178.21, + "y": 181.62, + "z": 12.22 + }, + "destination_retract": { + "x": 178.21, + "y": 181.62, + "z": 41.7 + }, + "volume": 20.0 + }, + "temperature_target": 4.0 +} diff --git a/config/flex_unit_operations.json b/config/flex_unit_operations.json new file mode 100644 index 0000000..8457cbe --- /dev/null +++ b/config/flex_unit_operations.json @@ -0,0 +1,58 @@ +{ + "sila_server": { + "hostname": "127.0.0.1", + "port": 50051, + "tls": false, + "require_client_auth": false, + "root_certificates": null, + "certificate_chain": null, + "private_key": null, + "options": {}, + "uuid": "9f1d2b5a-7c3e-4a18-9b2c-1e7f5a3d8c40", + "name": "Opentrons Flex", + "type": "LiquidHandler", + "description": "SiLA2 connector for guarded Opentrons Flex unit-operation qualification", + "version": "0.9.1", + "vendor_url": "https://opentrons.com/", + "default_lifetime": 3600.0 + }, + "cloud_server_endpoint": null, + "discovery": null, + "logging": { + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "simple": { + "format": "%(asctime)s %(levelname)s %(name)s: %(message)s" + } + }, + "handlers": { + "stderr": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + "formatter": "simple" + } + }, + "root": { + "level": "INFO", + "handlers": [ + "stderr" + ] + } + }, + "use_simulator": false, + "simulated_heater_shaker": false, + "simulated_flex_stacker": false, + "simulated_absorbance_reader": false, + "simulated_temperature_module": false, + "simulated_thermocycler": false, + "simulated_gripper": false, + "with_robot_server": false, + "robot_server_uds": "/run/aiohttp.sock", + "lock_timeout_s": 30.0, + "labware_movement_config": "/var/sila2_flex/asms-unit-labware-movement.json", + "run_mutation_ledger_path": null, + "run_mutation_token_env": "UNITELABS_RUN_MUTATION_TOKEN", + "run_mutation_actor_env": "UNITELABS_RUN_MUTATION_ACTOR", + "run_mutation_required": false +} diff --git a/deploy.sh b/deploy.sh index 334241b..ad0104c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -16,6 +16,9 @@ if [ -f "$SCRIPT_DIR/config/flex_config.local.json" ]; then else CONFIG_SRC="$SCRIPT_DIR/config/flex_config.json" fi +UNIT_CONFIG_SRC="$SCRIPT_DIR/config/flex_unit_operations.json" +UNIT_MOVEMENT_SRC="$SCRIPT_DIR/config/asms_unit_labware_movement.json" +UNIT_OPERATIONS_SRC="$SCRIPT_DIR/config/asms_unit_operations.json" if [ ! -d "$ARTIFACT_DIR" ]; then echo "ERROR: wheel directory '$ARTIFACT_DIR' not found." >&2 @@ -31,7 +34,8 @@ python3 "$MANIFEST_TOOL" verify "$ARTIFACT_DIR" \ --python-version 3.10 \ --architecture aarch64 RELEASE_ID="$(python3 "$MANIFEST_TOOL" field "$ARTIFACT_DIR" releaseId)" -CONFIG_SHA="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$CONFIG_SRC")" +CONFIG_SHA="$(python3 -c 'import hashlib,sys; h=hashlib.sha256(); [h.update(open(p,"rb").read()) for p in sys.argv[1:]]; print(h.hexdigest())' \ + "$CONFIG_SRC" "$UNIT_CONFIG_SRC" "$UNIT_MOVEMENT_SRC" "$UNIT_OPERATIONS_SRC")" CONFIG_ID="$(printf '%s' "$CONFIG_SHA" | cut -c1-12)" DEPLOYMENT_ID="$RELEASE_ID-cfg$CONFIG_ID" @@ -61,6 +65,11 @@ scp -O \ "$ARTIFACT_DIR/SHA256SUMS" \ "root@$HOST:$UPLOAD_DIR/" scp -O "$CONFIG_SRC" "root@$HOST:$UPLOAD_DIR/flex_config.json" +scp -O \ + "$UNIT_CONFIG_SRC" \ + "$UNIT_MOVEMENT_SRC" \ + "$UNIT_OPERATIONS_SRC" \ + "root@$HOST:$UPLOAD_DIR/" ssh "root@$HOST" sh -s -- "$UPLOAD_DIR" "$RELEASE_PATH" "$ACTIVE_PATH" "$STATE_DIR" "$CONFIG_SHA" <<'REMOTE' set -eu @@ -79,7 +88,11 @@ python3 "$UPLOAD_DIR/artifact_manifest.py" verify "$UPLOAD_DIR" \ --architecture aarch64 \ --check-host-python \ --check-host-architecture -UPLOADED_CONFIG_SHA="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$UPLOAD_DIR/flex_config.json")" +UPLOADED_CONFIG_SHA="$(python3 -c 'import hashlib,sys; h=hashlib.sha256(); [h.update(open(p,"rb").read()) for p in sys.argv[1:]]; print(h.hexdigest())' \ + "$UPLOAD_DIR/flex_config.json" \ + "$UPLOAD_DIR/flex_unit_operations.json" \ + "$UPLOAD_DIR/asms_unit_labware_movement.json" \ + "$UPLOAD_DIR/asms_unit_operations.json")" if [ "$UPLOADED_CONFIG_SHA" != "$EXPECTED_CONFIG_SHA" ]; then echo "ERROR: uploaded Flex configuration checksum mismatch." >&2 exit 1 @@ -140,11 +153,21 @@ if [ ! -e "$RELEASE_PATH" ]; then --require-robot-server \ --require-mutation \ --require-live-hardware + "$RELEASE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$RELEASE_PATH/unit-config.json" \ + --require-sila-only \ + --require-live-hardware + "$RELEASE_PATH/bin/python" -c \ + "from unitelabs.opentrons_flex.asms_unit_operations import load_manifest; from unitelabs.opentrons_flex.io import load_labware_movement_config; load_manifest('$RELEASE_PATH/asms-unit-operations.json'); load_labware_movement_config('$RELEASE_PATH/asms-unit-labware-movement.json')" printf '%s\n' "$EXPECTED_CONFIG_SHA" > "$COMPLETE_MARKER" CLEANUP_INCOMPLETE=no else echo "Release already installed and complete; reusing $RELEASE_PATH" - INSTALLED_CONFIG_SHA="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$RELEASE_PATH/config.json")" + INSTALLED_CONFIG_SHA="$(python3 -c 'import hashlib,sys; h=hashlib.sha256(); [h.update(open(p,"rb").read()) for p in sys.argv[1:]]; print(h.hexdigest())' \ + "$RELEASE_PATH/config.json" \ + "$RELEASE_PATH/unit-config.json" \ + "$RELEASE_PATH/asms-unit-labware-movement.json" \ + "$RELEASE_PATH/asms-unit-operations.json")" if [ "$INSTALLED_CONFIG_SHA" != "$EXPECTED_CONFIG_SHA" ]; then echo "ERROR: immutable release configuration checksum mismatch." >&2 exit 1 @@ -161,6 +184,10 @@ fi --require-robot-server \ --require-mutation \ --require-live-hardware +"$RELEASE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$RELEASE_PATH/unit-config.json" \ + --require-sila-only \ + --require-live-hardware if [ -L "$ACTIVE_PATH" ]; then PREVIOUS_TARGET="$(readlink -f "$ACTIVE_PATH")" diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index 55341ca..7093c46 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -215,6 +215,161 @@ An authenticated `play` with no mutation is the explicit, audited skip a named checkpoint. The two native `resume-from-recovery` action variants have the same token and audit requirement while the run is `awaiting-recovery`. +## Connector-unit fast commissioning path (Scheme B) + +Use this path to qualify the currently prepared hardware quickly without +Protocol Engine. It starts the connector with `with_robot_server=false`; the +connector is the only `OT3API` owner, port 31950 is intentionally unavailable, +and no protocol, custom labware, or run is uploaded. Each command below runs at +most one atomic operation. There is deliberately no `all` phase. + +This is the exact physical contract for the pictured deck: + +- staging-area fixtures are installed at A3, B3, C3, and D3; their raised + extensions occupy A4, B4, C4, and D4, so no labware may be placed on any of + those four extensions; +- empty Axygen plates start at A1 and B1; +- Magnetic Block GEN1 is at B2; +- the complete Flex 1000 µL tip rack is at A3, with column 1 unused; +- Temperature Module GEN2 plus reservoir is at C1; +- the exact waste plate is at C2; +- the right eight-channel 1000 µL pipette and Flex Gripper are attached. + +On the Windows operator computer, open PowerShell in the repository and use the +immutable Scheme B commit reported in the release handoff. Production lab use +must take that commit from `main`; a pre-merge qualification run may use the +explicit handoff commit in detached mode, but must not silently follow a moving +test branch: + +```powershell +git fetch --prune origin +git switch --detach +git status --short +uv sync --extra test +``` + +Deploying and installing the Linux service still requires Git Bash or WSL. The +deployment includes both connector profiles, the four gripper routes, and the +unit-operation manifest: + +```sh +gh run download \ + --repo AccelerationConsortium/opentrons-flex \ + --name flex-arm-wheels \ + --dir dist_arm_connector_unit +sh scripts/switch_mode.sh 169.254.105.239 opentrons +sh deploy.sh 169.254.105.239 dist_arm_connector_unit +sh scripts/install_connector_service.sh 169.254.105.239 +sh scripts/switch_mode.sh 169.254.105.239 connector-unit +``` + +Use the run ID reported with this branch's handoff. Do not reuse an older +`dist_arm` wheel: the robot-side health command is packaged inside the new +connector wheel. + +The unit connector deliberately binds only to robot loopback. In a second +PowerShell window, open the authenticated tunnel and keep it running for the +whole qualification session: + +```powershell +ssh -N -L 50052:127.0.0.1:50051 root@169.254.105.239 +``` + +Back in the first PowerShell window, run the read-only checks through that +tunnel. `health` permits an invalid +ledger so the service can explain its state; `preflight` additionally requires +the door closed, E-stop disengaged, no robot error, and exact ledger occupancy +`A1=asms-plate`, `B1=elution-plate`: + +```powershell +uv run --extra test python scripts/run_asms_unit_operations.py health ` + --host 127.0.0.1 --port 50052 ` + --manifest config/asms_unit_operations.json + +uv run --extra test python scripts/run_asms_unit_operations.py preflight ` + --host 127.0.0.1 --port 50052 ` + --manifest config/asms_unit_operations.json +``` + +Continue only when `preflight` reports `"ready": true`. Run the atomic mechanical +checks in this order, inspecting the physical result after every command: + +```powershell +# 1. Home only. +uv run --extra test python scripts/run_asms_unit_operations.py home --execute ` + --host 127.0.0.1 --port 50052 --manifest config/asms_unit_operations.json ` + --confirm-deck-ready ASMS-UNIT-DECK-READY ` + --confirm-staging-slots-clear ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR + +# 2. Empty A1 plate: A1 -> Magnetic Block B2 -> A1. +uv run --extra test python scripts/run_asms_unit_operations.py gripper-a1 --execute ` + --host 127.0.0.1 --port 50052 --manifest config/asms_unit_operations.json ` + --confirm-deck-ready ASMS-UNIT-DECK-READY ` + --confirm-staging-slots-clear ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR ` + --confirm-empty-plates ASMS-UNIT-EMPTY-AXYGEN-A1-B1 + +# 3. Empty B1 plate: B1 -> Magnetic Block B2 -> B1. +uv run --extra test python scripts/run_asms_unit_operations.py gripper-b1 --execute ` + --host 127.0.0.1 --port 50052 --manifest config/asms_unit_operations.json ` + --confirm-deck-ready ASMS-UNIT-DECK-READY ` + --confirm-staging-slots-clear ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR ` + --confirm-empty-plates ASMS-UNIT-EMPTY-AXYGEN-A1-B1 + +# 4. Pick up and return only tip column A3/A1. +uv run --extra test python scripts/run_asms_unit_operations.py tip --execute ` + --host 127.0.0.1 --port 50052 --manifest config/asms_unit_operations.json ` + --confirm-deck-ready ASMS-UNIT-DECK-READY ` + --confirm-staging-slots-clear ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR ` + --confirm-tip-column ASMS-UNIT-TIPS-A3-A1 + +# 5. One 20 µL/channel water transfer: reservoir A2 -> waste plate C2/A1. +uv run --extra test python scripts/run_asms_unit_operations.py liquid --execute ` + --host 127.0.0.1 --port 50052 --manifest config/asms_unit_operations.json ` + --confirm-deck-ready ASMS-UNIT-DECK-READY ` + --confirm-staging-slots-clear ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR ` + --confirm-tip-column ASMS-UNIT-TIPS-A3-A1 ` + --confirm-test-liquid ASMS-UNIT-WATER-A2-TO-C2 + +# 6. Accept 4 °C, read the response, then always deactivate the module. +uv run --extra test python scripts/run_asms_unit_operations.py temperature --execute ` + --host 127.0.0.1 --port 50052 --manifest config/asms_unit_operations.json ` + --confirm-deck-ready ASMS-UNIT-DECK-READY ` + --confirm-staging-slots-clear ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR ` + --confirm-temperature-module ASMS-UNIT-TEMP-C1-READY +``` + +Stop on scraping, a poor grip, a plate that is not fully seated, unexpected tip +state, liquid in the wrong wells, or any non-zero command exit. After any +interrupted/failed gripper move, do not delete or edit the ledger remotely: +physically reconcile the plate locations first. The fail-closed ledger is +`/var/lib/unitelabs-opentrons-flex/asms-unit-deck-state.json`. + +If the physical reconciliation confirms both empty plates are back at A1 and B1 +and B2 is empty, preserve and reset the ledger through the guarded script from +Git Bash/WSL, then restart unit mode: + +```sh +sh scripts/switch_mode.sh 169.254.105.239 opentrons +sh scripts/reconcile_asms_unit_deck.sh 169.254.105.239 \ + ASMS-UNIT-RECONCILE-A1-B1-B2-EMPTY +sh scripts/switch_mode.sh 169.254.105.239 connector-unit +``` + +The script refuses to run while the connector owns hardware, copies the old +ledger to a timestamped byte-for-byte backup, durably records authorization, +and then atomically replaces the ledger with the confirmed A1/B1 state. A +malformed ledger is still preserved; it is never silently deleted. + +Passing these six operations proves the SiLA atoms and their prepared geometry; +it does not prove AS-MS ordering, tip allocation across all 26 pickups, liquid +lineage, programmed delays, or recovery for the one-hour workflow. For the +existing complete AS-MS test, return to the Protocol Engine profile and rerun +analysis before execution: + +```sh +sh scripts/switch_mode.sh 169.254.105.239 connector-pe +``` + ## Fastest safe real-Flex sequence From macOS or Linux, `scripts/run_asms_hardware.py` provides the shortest @@ -299,8 +454,8 @@ already consumes every available fresh tip. Temperature Module GEN2, and B2 to Magnetic Block GEN1. Each staging-area fixture replaces a standard column-3 piece while preserving that working slot and extending into the matching column-4 deck slot. Keep every installed - staging-area deck slot empty for this workflow; with the observed A3 and B3 - fixtures, both A4 and B4 must be empty. This is distinct from reservoir well + staging-area deck slot empty for this workflow; with the observed fixtures in + all four rows, A4, B4, C4, and D4 must all be empty. This is distinct from reservoir well A4, which contains methanol. Confirm every other cutout matches the table above; the Flex default deck reserves A3 for trash and treats C1/B2 as ordinary slots, so physical placement alone is not sufficient. diff --git a/pyproject.toml b/pyproject.toml index 7a13c5c..b97678a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ ignore = [ "!src/**.py" = ["D", "ANN", "ARG", "S101", "EM", "BLE", "PTH", "T201", "FIX", "ERA", "E7", "S", "B"] # ignore parameter name warnings in feature files "src/unitelabs/opentrons_flex/features/**/**.py" = ["D417", "EM"] +"src/unitelabs/opentrons_flex/asms_unit_operations.py" = ["D102", "EM"] # test scripts outside the tests/ suite "test_*.py" = ["E402"] diff --git a/scripts/install.sh b/scripts/install.sh index 295ca6e..81da671 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -25,6 +25,9 @@ python3 -m venv --system-site-packages "$RELEASE_PATH" # is required even inside a venv or pip silently installs elsewhere. "$RELEASE_PATH/bin/pip" install --root / --no-index --no-deps "$ARTIFACT_DIR"/*.whl cp "$ARTIFACT_DIR/flex_config.json" "$RELEASE_PATH/config.json" +cp "$ARTIFACT_DIR/flex_unit_operations.json" "$RELEASE_PATH/unit-config.json" +cp "$ARTIFACT_DIR/asms_unit_labware_movement.json" "$RELEASE_PATH/asms-unit-labware-movement.json" +cp "$ARTIFACT_DIR/asms_unit_operations.json" "$RELEASE_PATH/asms-unit-operations.json" cp "$ARTIFACT_DIR/runtime-manifest.json" "$RELEASE_PATH/runtime-manifest.json" echo "Installed immutable release at $RELEASE_PATH" diff --git a/scripts/install_connector_service.sh b/scripts/install_connector_service.sh index 58ab8ab..9964c53 100755 --- a/scripts/install_connector_service.sh +++ b/scripts/install_connector_service.sh @@ -13,6 +13,7 @@ set -eu STATE_DIR="/var/lib/unitelabs-opentrons-flex" ACTIVE_PATH="/var/sila2_flex" ENV_FILE="$STATE_DIR/run-mutation.env" +CONFIG_SELECTOR="$STATE_DIR/connector-config.json" SERVICE_STATE="$STATE_DIR/stock-service-state" HARDWARE_SERVICES="opentrons-robot-server opentrons-status-bar opentrons-gpio-setup opentrons-status-leds" @@ -57,6 +58,7 @@ if systemctl is-active --quiet sila2-connector 2>/dev/null; then echo "ERROR: connector is already active; switch to stock mode before reinstalling its service." >&2 exit 1 fi +ln -sfn "$ACTIVE_PATH/config.json" "$CONFIG_SELECTOR" cat > /etc/systemd/system/sila2-connector.service </dev/null; then + return 1 + fi + if systemctl is-enabled --quiet "$svc" 2>/dev/null; then + return 1 + fi + done + return 0 +} + echo "Stopping stock hardware owners..." for svc in $HARDWARE_SERVICES; do systemctl stop "$svc" 2>/dev/null || true + systemctl disable "$svc" 2>/dev/null || true done +if ! stock_services_stopped_and_disabled; then + echo "ERROR: a stock hardware-owner service remained active or enabled; refusing dual OT3API ownership." >&2 + restore_stock + exit 1 +fi systemctl reset-failed sila2-connector 2>/dev/null || true if ! systemctl start sila2-connector; then @@ -137,7 +157,8 @@ i=60 while [ "$i" -gt 0 ]; do if python3 -c "import socket; s=socket.create_connection(('127.0.0.1',50051),2); s.close()" 2>/dev/null && curl --fail --silent --show-error --connect-timeout 2 --max-time 5 \ - http://127.0.0.1:31950/health >/dev/null 2>&1; then + http://127.0.0.1:31950/health >/dev/null 2>&1 && + stock_services_stopped_and_disabled; then READY=yes break fi @@ -169,10 +190,12 @@ if ! curl --fail --silent --show-error --connect-timeout 2 --max-time 10 \ exit 1 fi -for svc in $HARDWARE_SERVICES; do - systemctl disable "$svc" 2>/dev/null || true -done systemctl enable sila2-connector +if ! stock_services_stopped_and_disabled; then + echo "ERROR: stock hardware ownership reappeared after connector startup." >&2 + restore_stock + exit 1 +fi echo "Connector mode is healthy." systemctl status sila2-connector --no-pager diff --git a/scripts/reconcile_asms_unit_deck.sh b/scripts/reconcile_asms_unit_deck.sh new file mode 100755 index 0000000..f0889a6 --- /dev/null +++ b/scripts/reconcile_asms_unit_deck.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# Preserve and reset the ASMS unit deck ledger after a physical reconciliation. +set -eu + +HOST="${1:?Usage: $0 }" +OPERATOR="${2:?Usage: $0 }" +CONFIRMATION="${3:?Usage: $0 }" +EXPECTED="ASMS-UNIT-RECONCILE-A1-B1-B2-EMPTY" + +case "$OPERATOR" in + *[!A-Za-z0-9._-]*|"") + echo "ERROR: operator must contain only letters, numbers, dot, underscore, or hyphen." >&2 + exit 1 + ;; +esac +if [ "$CONFIRMATION" != "$EXPECTED" ]; then + echo "ERROR: confirmation must exactly equal $EXPECTED" >&2 + exit 1 +fi + +ssh "root@$HOST" sh -s -- "$OPERATOR" "$CONFIRMATION" <<'REMOTE' +set -eu +OPERATOR="$1" +CONFIRMATION="$2" +EXPECTED="ASMS-UNIT-RECONCILE-A1-B1-B2-EMPTY" +STATE_DIR="/var/lib/unitelabs-opentrons-flex" +LEDGER="$STATE_DIR/asms-unit-deck-state.json" +AUDIT="$STATE_DIR/asms-unit-deck-reconciliations.jsonl" + +if [ "$CONFIRMATION" != "$EXPECTED" ]; then + echo "ERROR: remote confirmation mismatch." >&2 + exit 1 +fi +mount -o remount,rw / +mkdir -p "$STATE_DIR" +LOCK_DIR="$STATE_DIR/operation.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "ERROR: another Flex deployment or mode transition is in progress ($LOCK_DIR)." >&2 + exit 1 +fi +release_operation_lock() { + if [ -n "${TEMP_LEDGER:-}" ]; then + rm -f "$TEMP_LEDGER" + fi + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap release_operation_lock 0 HUP INT TERM + +if systemctl is-active --quiet sila2-connector 2>/dev/null; then + echo "ERROR: switch to stock Opentrons mode before reconciling the connector ledger." >&2 + exit 1 +fi +if [ ! -f "$LEDGER" ]; then + echo "ERROR: ASMS unit deck ledger is missing; refusing to bootstrap an uncertain prior state." >&2 + exit 1 +fi + +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)-$$" +BACKUP="$LEDGER.reconciled-$TIMESTAMP" +if [ -e "$BACKUP" ]; then + echo "ERROR: reconciliation backup already exists: $BACKUP" >&2 + exit 1 +fi +cp -p "$LEDGER" "$BACKUP" +cmp "$LEDGER" "$BACKUP" +sync + +if ! python3 -c \ + "import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding='utf-8')), indent=2, sort_keys=True))" \ + "$BACKUP"; then + echo "WARNING: preserved ledger is malformed; continuing only because the byte-for-byte backup succeeded." >&2 +fi + +TEMP_LEDGER="$STATE_DIR/.asms-unit-deck-state.reconcile-$$.json" +umask 077 +printf '%s\n' \ + '{"version":1,"valid":true,"clean_shutdown":true,"occupancy":{"A1":"asms-plate","B1":"elution-plate"}}' \ + > "$TEMP_LEDGER" +sync + +printf '{"timestamp":"%s","operator":"%s","confirmation":"%s","preservedLedger":"%s","action":"authorized-reset-to-A1-B1"}\n' \ + "$TIMESTAMP" "$OPERATOR" "$CONFIRMATION" "$BACKUP" >> "$AUDIT" +sync + +if systemctl is-active --quiet sila2-connector 2>/dev/null; then + echo "ERROR: connector became active during reconciliation; preserved backup and audit, but did not reset ledger." >&2 + exit 1 +fi +mv "$TEMP_LEDGER" "$LEDGER" +TEMP_LEDGER="" +sync + +echo "Preserved the prior ledger at $BACKUP" +echo "Recorded the authorized reset in $AUDIT" +echo "Reset the ledger to A1=asms-plate, B1=elution-plate, and B2 empty." +REMOTE diff --git a/scripts/rollback_connector.sh b/scripts/rollback_connector.sh index 8a7d913..2f73bf1 100755 --- a/scripts/rollback_connector.sh +++ b/scripts/rollback_connector.sh @@ -115,6 +115,32 @@ case "$KIND" in echo "ERROR: recorded release is unavailable: $TARGET" >&2 exit 1 fi + COMPLETE_MARKER="$TARGET/.unitelabs-release-complete" + if [ ! -f "$COMPLETE_MARKER" ]; then + echo "ERROR: recorded release has no deployment completion marker: $TARGET" >&2 + exit 1 + fi + EXPECTED_CONFIG_SHA="$(cat "$COMPLETE_MARKER")" + case "$EXPECTED_CONFIG_SHA" in + *[!0-9a-f]*|"") + echo "ERROR: recorded release has an invalid completion marker." >&2 + exit 1 + ;; + esac + if [ "${#EXPECTED_CONFIG_SHA}" -ne 64 ]; then + echo "ERROR: recorded release completion marker is not a SHA-256 digest." >&2 + exit 1 + fi + INSTALLED_CONFIG_SHA="$(python3 -c \ + 'import hashlib,sys; h=hashlib.sha256(); [h.update(open(p,"rb").read()) for p in sys.argv[1:]]; print(h.hexdigest())' \ + "$TARGET/config.json" \ + "$TARGET/unit-config.json" \ + "$TARGET/asms-unit-labware-movement.json" \ + "$TARGET/asms-unit-operations.json")" + if [ "$INSTALLED_CONFIG_SHA" != "$EXPECTED_CONFIG_SHA" ]; then + echo "ERROR: recorded release configuration checksum mismatch." >&2 + exit 1 + fi if [ -f "$STATE_DIR/run-mutation.env" ]; then set -a . "$STATE_DIR/run-mutation.env" @@ -123,6 +149,12 @@ case "$KIND" in "$TARGET/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ --config "$TARGET/config.json" \ --require-robot-server + "$TARGET/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$TARGET/unit-config.json" \ + --require-sila-only \ + --require-live-hardware + "$TARGET/bin/python" -c \ + "from unitelabs.opentrons_flex.asms_unit_operations import load_manifest; from unitelabs.opentrons_flex.io import load_labware_movement_config; load_manifest('$TARGET/asms-unit-operations.json'); load_labware_movement_config('$TARGET/asms-unit-labware-movement.json')" activate_target "$TARGET" ;; legacy) diff --git a/scripts/run_asms_unit_operations.py b/scripts/run_asms_unit_operations.py new file mode 100644 index 0000000..47991c4 --- /dev/null +++ b/scripts/run_asms_unit_operations.py @@ -0,0 +1,7 @@ +"""Repository entry point for guarded connector-only ASMS unit operations.""" + +from unitelabs.opentrons_flex.asms_unit_operations import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/switch_mode.sh b/scripts/switch_mode.sh index a63c6cd..002c202 100755 --- a/scripts/switch_mode.sh +++ b/scripts/switch_mode.sh @@ -2,12 +2,13 @@ # Safely switch exclusive Flex hardware ownership between connector and stock. set -eu -HOST="${1:?Usage: $0 }" -MODE="${2:?Usage: $0 }" +HOST="${1:?Usage: $0 }" +MODE="${2:?Usage: $0 }" case "$MODE" in - connector|opentrons) ;; + connector) MODE=connector-pe ;; + connector-pe|connector-unit|opentrons) ;; *) - echo "ERROR: mode must be connector or opentrons" >&2 + echo "ERROR: mode must be connector-pe, connector-unit, or opentrons" >&2 exit 1 ;; esac @@ -18,6 +19,7 @@ MODE="$1" STATE_DIR="/var/lib/unitelabs-opentrons-flex" ACTIVE_PATH="/var/sila2_flex" ENV_FILE="$STATE_DIR/run-mutation.env" +CONFIG_SELECTOR="$STATE_DIR/connector-config.json" SERVICE_STATE="$STATE_DIR/stock-service-state" HARDWARE_SERVICES="opentrons-robot-server opentrons-status-bar opentrons-gpio-setup opentrons-status-leds" @@ -38,13 +40,31 @@ restore_stock_services() { fi } -stop_stock_services() { +stop_and_disable_stock_services() { for svc in $HARDWARE_SERVICES; do systemctl stop "$svc" 2>/dev/null || true + systemctl disable "$svc" 2>/dev/null || true done } -wait_for_connector() { +stock_services_stopped_and_disabled() { + for svc in $HARDWARE_SERVICES; do + if systemctl is-active --quiet "$svc" 2>/dev/null; then + return 1 + fi + if systemctl is-enabled --quiet "$svc" 2>/dev/null; then + return 1 + fi + done + return 0 +} + +unit_http_is_unavailable() { + ! curl --fail --silent --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:31950/health >/dev/null 2>&1 +} + +wait_for_pe_connector() { i=60 while [ "$i" -gt 0 ]; do if python3 -c "import socket; s=socket.create_connection(('127.0.0.1',50051),2); s.close()" 2>/dev/null && @@ -54,7 +74,8 @@ wait_for_connector() { http://127.0.0.1:31950/deck_configuration >/dev/null && curl --fail --silent --connect-timeout 2 --max-time 10 \ http://127.0.0.1:31950/openapi.json | - python3 -c "import json,sys; assert '/unitelabs/runs/{run_id}/mutations' in json.load(sys.stdin).get('paths',{})" + python3 -c "import json,sys; assert '/unitelabs/runs/{run_id}/mutations' in json.load(sys.stdin).get('paths',{})" && + stock_services_stopped_and_disabled return $? fi i=$((i - 1)) @@ -63,6 +84,25 @@ wait_for_connector() { return 1 } +wait_for_unit_connector() { + i=60 + while [ "$i" -gt 0 ]; do + if python3 -c "import socket; s=socket.create_connection(('127.0.0.1',50051),2); s.close()" 2>/dev/null && + "$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.asms_unit_operations \ + health \ + --host 127.0.0.1 \ + --port 50051 \ + --manifest "$ACTIVE_PATH/asms-unit-operations.json" >/dev/null 2>&1 && + unit_http_is_unavailable && + stock_services_stopped_and_disabled; then + return 0 + fi + i=$((i - 1)) + sleep 2 + done + return 1 +} + wait_for_stock() { i=60 while [ "$i" -gt 0 ]; do @@ -90,7 +130,10 @@ release_operation_lock() { trap release_operation_lock 0 HUP INT TERM if systemctl is-active --quiet sila2-connector 2>/dev/null; then - CURRENT=connector + case "$(readlink "$CONFIG_SELECTOR" 2>/dev/null || true)" in + */unit-config.json) CURRENT=connector-unit ;; + *) CURRENT=connector-pe ;; + esac elif systemctl is-active --quiet opentrons-robot-server 2>/dev/null; then CURRENT=opentrons else @@ -98,34 +141,74 @@ else fi echo "Current: $CURRENT -> Target: $MODE" -if [ "$MODE" = connector ]; then +if [ "$MODE" = connector-pe ] || [ "$MODE" = connector-unit ]; then if [ ! -L "$ACTIVE_PATH" ] || [ ! -x "$ACTIVE_PATH/bin/python" ]; then echo "ERROR: no activated versioned connector; run deploy.sh first." >&2 exit 1 fi - if [ ! -f "$ENV_FILE" ]; then - echo "ERROR: missing $ENV_FILE." >&2 + if ! systemctl cat sila2-connector 2>/dev/null | grep -q "$CONFIG_SELECTOR"; then + echo "ERROR: installed sila2-connector service predates selectable configs; rerun install_connector_service.sh." >&2 exit 1 fi - set -a - . "$ENV_FILE" - set +a - "$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ - --config "$ACTIVE_PATH/config.json" \ - --require-robot-server \ - --require-mutation \ - --require-live-hardware - - if [ "$CURRENT" = connector ] && wait_for_connector; then - echo "Connector mode is already healthy." - exit 0 + + if [ "$MODE" = connector-pe ]; then + if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: missing $ENV_FILE." >&2 + exit 1 + fi + set -a + . "$ENV_FILE" + set +a + "$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$ACTIVE_PATH/config.json" \ + --require-robot-server \ + --require-mutation \ + --require-live-hardware + TARGET_CONFIG="$ACTIVE_PATH/config.json" + if [ "$CURRENT" = connector-pe ] && wait_for_pe_connector; then + echo "Connector PE mode is already healthy." + exit 0 + fi + else + "$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.runtime_preflight \ + --config "$ACTIVE_PATH/unit-config.json" \ + --require-sila-only \ + --require-live-hardware + "$ACTIVE_PATH/bin/python" -c \ + "from unitelabs.opentrons_flex.asms_unit_operations import load_manifest; load_manifest('$ACTIVE_PATH/asms-unit-operations.json')" + TARGET_CONFIG="$ACTIVE_PATH/unit-config.json" + if [ "$CURRENT" = connector-unit ] && wait_for_unit_connector; then + echo "Connector unit-operation mode is already healthy." + exit 0 + fi fi systemctl stop sila2-connector 2>/dev/null || true - stop_stock_services + stop_and_disable_stock_services + if ! stock_services_stopped_and_disabled; then + echo "ERROR: a stock hardware-owner service remained active or enabled; refusing dual OT3API ownership." >&2 + restore_stock_services + exit 1 + fi + ln -sfn "$TARGET_CONFIG" "$CONFIG_SELECTOR" systemctl reset-failed sila2-connector 2>/dev/null || true - if ! systemctl start sila2-connector || ! wait_for_connector; then - echo "ERROR: connector mode failed; restoring stock Opentrons mode." >&2 + STARTED=no + if systemctl start sila2-connector; then + if [ "$MODE" = connector-pe ] && wait_for_pe_connector; then + STARTED=yes + elif [ "$MODE" = connector-unit ] && wait_for_unit_connector; then + STARTED=yes + fi + fi + if [ "$STARTED" != yes ]; then + echo "ERROR: $MODE failed; restoring stock Opentrons mode." >&2 + if [ "$MODE" = connector-unit ]; then + "$ACTIVE_PATH/bin/python" -m unitelabs.opentrons_flex.asms_unit_operations \ + health \ + --host 127.0.0.1 \ + --port 50051 \ + --manifest "$ACTIVE_PATH/asms-unit-operations.json" || true + fi systemctl status sila2-connector --no-pager || true journalctl -u sila2-connector -n 100 --no-pager || true systemctl stop sila2-connector 2>/dev/null || true @@ -137,11 +220,19 @@ if [ "$MODE" = connector ]; then fi exit 1 fi - for svc in $HARDWARE_SERVICES; do - systemctl disable "$svc" 2>/dev/null || true - done systemctl enable sila2-connector - echo "Connector mode healthy: gRPC 50051, embedded HTTP 31950." + if ! stock_services_stopped_and_disabled; then + echo "ERROR: stock hardware ownership reappeared after connector startup; restoring stock mode." >&2 + systemctl stop sila2-connector 2>/dev/null || true + systemctl disable sila2-connector 2>/dev/null || true + restore_stock_services + exit 1 + fi + if [ "$MODE" = connector-pe ]; then + echo "Connector PE mode healthy: gRPC 50051, embedded HTTP 31950." + else + echo "Connector unit-operation mode healthy: SiLA gRPC 50051 exclusively owns OT3API; HTTP is disabled." + fi exit 0 fi diff --git a/src/unitelabs/opentrons_flex/__init__.py b/src/unitelabs/opentrons_flex/__init__.py index 784abd2..de2e255 100644 --- a/src/unitelabs/opentrons_flex/__init__.py +++ b/src/unitelabs/opentrons_flex/__init__.py @@ -275,6 +275,7 @@ def _register_core_features( gripper, plans=labware_config.plans, state=labware_state, + profile_sha256=labware_config.profile_sha256, ) ) ) @@ -401,7 +402,12 @@ async def create_app(config: OpentronsFlexConfig) -> collections.abc.AsyncGenera labware_config = ( load_labware_movement_config(config.labware_movement_config) if config.labware_movement_config is not None - else LoadedLabwareMovementConfig(plans=(), initial_occupancy={}, state_file=None) + else LoadedLabwareMovementConfig( + plans=(), + initial_occupancy={}, + state_file=None, + profile_sha256="0" * 64, + ) ) if config.with_robot_server: diff --git a/src/unitelabs/opentrons_flex/asms_unit_operations.py b/src/unitelabs/opentrons_flex/asms_unit_operations.py new file mode 100644 index 0000000..f0dac73 --- /dev/null +++ b/src/unitelabs/opentrons_flex/asms_unit_operations.py @@ -0,0 +1,830 @@ +""" +Guarded SiLA-only ASMS unit-operation qualification client. + +This module intentionally does not upload a protocol or create a Protocol +Engine run. It talks only to the connector's SiLA surface and exposes one +mechanical unit operation at a time. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import contextlib +import json +import sys +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +import grpc +import grpc.aio +from sila.server import CommandConfirmation, CommandExecutionUUID +from unitelabs.cdk import SiLAServerConfig + +from . import OpentronsFlexConfig, create_app +from .features import ( + LabwareDeckState, + LabwareMovementProfile, + LabwareMovementResult, + LabwarePlanSummary, + LiquidPosition, + PipetteInfo, + PipetteMount, + TipLocation, + TipPresence, + TransferProfile, +) +from .features.gripper import GripperStatus +from .features.motion_control import MachineStatus + +_MOTION_PACKAGE = "sila2.ca.accelerationconsortium.robots.motioncontroller.v2" +_MOTION_SERVICE = f"{_MOTION_PACKAGE}.MotionController" +_PIPETTE_PACKAGE = "sila2.ca.accelerationconsortium.robots.pipettecontroller.v1" +_PIPETTE_SERVICE = f"{_PIPETTE_PACKAGE}.PipetteController" +_TIP_PACKAGE = "sila2.ca.accelerationconsortium.robots.tipcontroller.v1" +_TIP_SERVICE = f"{_TIP_PACKAGE}.TipController" +_LIQUID_PACKAGE = "sila2.ca.accelerationconsortium.robots.liquidhandlingcontroller.v1" +_LIQUID_SERVICE = f"{_LIQUID_PACKAGE}.LiquidHandlingController" +_LABWARE_PACKAGE = "sila2.ca.accelerationconsortium.robots.labwaremovementcontroller.v1" +_LABWARE_SERVICE = f"{_LABWARE_PACKAGE}.LabwareMovementController" +_GRIPPER_PACKAGE = "sila2.ca.accelerationconsortium.robots.grippercontroller.v1" +_GRIPPER_SERVICE = f"{_GRIPPER_PACKAGE}.GripperController" +_TEMPERATURE_PACKAGE = "sila2.ca.accelerationconsortium.modules.temperaturecontroller.v2" +_TEMPERATURE_SERVICE = f"{_TEMPERATURE_PACKAGE}.TemperatureController" + +_DECK_CONFIRMATION = "ASMS-UNIT-DECK-READY" +_STAGING_CONFIRMATION = "ASMS-UNIT-STAGING-A4-B4-C4-D4-CLEAR" +_PLATE_CONFIRMATION = "ASMS-UNIT-EMPTY-AXYGEN-A1-B1" +_TIP_CONFIRMATION = "ASMS-UNIT-TIPS-A3-A1" +_LIQUID_CONFIRMATION = "ASMS-UNIT-WATER-A2-TO-C2" +_TEMPERATURE_CONFIRMATION = "ASMS-UNIT-TEMP-C1-READY" +_ACTUATION_PHASES = ("home", "gripper-a1", "gripper-b1", "tip", "liquid", "temperature") +_PHASES = ("health", "preflight", *_ACTUATION_PHASES) + + +class UnitOperationError(RuntimeError): + """Operator-recoverable local qualification failure.""" + + +@dataclass(frozen=True) +class PositionConfig: + """One absolute deck coordinate in millimetres.""" + + x: float + y: float + z: float + + +@dataclass(frozen=True) +class PipettingConfig: + """Prepared pipette, tip, and liquid geometry.""" + + mount: str + tiprack_uri: str + tiprack_diameter: float + tip_length: float + tip_location: PositionConfig + source: PositionConfig + source_retract: PositionConfig + destination: PositionConfig + destination_retract: PositionConfig + volume: float + + +@dataclass(frozen=True) +class UnitOperationManifest: + """Local qualification contract; no geometry is accepted from SiLA callers.""" + + expected_staging_slots: tuple[str, ...] + movement_profile_sha256: str + expected_pipette_models: tuple[str, ...] + expected_plan_routes: dict[str, tuple[str, str, str]] + initial_occupancy: dict[str, str] + pipetting: PipettingConfig + temperature_target: float + + +class UnitClient(Protocol): + """Narrow client contract used by the guarded phase runner.""" + + async def is_simulating(self) -> bool: ... + + async def machine_status(self) -> MachineStatus: ... + + async def attached_pipettes(self) -> list[PipetteInfo]: ... + + async def gripper_status(self) -> GripperStatus: ... + + async def available_plans(self) -> list[LabwarePlanSummary]: ... + + async def deck_state(self) -> LabwareDeckState: ... + + async def movement_profile(self) -> LabwareMovementProfile: ... + + async def temperature_device_info(self) -> object: ... + + async def home(self) -> None: ... + + async def move_labware(self, plan_identifier: str) -> LabwareMovementResult: ... + + async def configure_full_nozzle(self, mount: PipetteMount, tiprack_diameter: float) -> object: ... + + async def tip_presence(self, mount: PipetteMount) -> TipPresence: ... + + async def pick_up_tip( + self, + mount: PipetteMount, + location: TipLocation, + tip_length: float, + ) -> TipPresence: ... + + async def drop_tip(self, mount: PipetteMount, location: TipLocation) -> TipPresence: ... + + async def transfer( + self, + mount: PipetteMount, + config: PipettingConfig, + ) -> None: ... + + async def set_temperature(self, target: float) -> object: ... + + async def deactivate_temperature(self) -> object: ... + + +def load_manifest(path: str | Path) -> UnitOperationManifest: + """Load and validate the locally provisioned unit-operation contract.""" + manifest_path = Path(path).expanduser() + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise UnitOperationError(f"Cannot read ASMS unit manifest {manifest_path}: {exc}") from exc + if not isinstance(data, dict) or data.get("schema_version") != 1: + raise UnitOperationError("ASMS unit manifest must be a schema_version 1 JSON object.") + + staging = _string_tuple(data.get("expected_staging_slots"), "expected_staging_slots") + if staging != ("A4", "B4", "C4", "D4"): + raise UnitOperationError("Expected staging slots must be exactly A4, B4, C4, D4 for this deck.") + movement_profile_sha256 = _text(data.get("movement_profile_sha256"), "movement_profile_sha256") + expected_profile_sha256 = "fb8fa69a3d781acd2f0edb1a6e710dec626cdf5661b5d9db46b35bab88df14f3" + if movement_profile_sha256 != expected_profile_sha256: + raise UnitOperationError("Movement profile digest does not match the pinned ASMS gripper geometry.") + pipette_models = _string_tuple(data.get("expected_pipette_models"), "expected_pipette_models") + if pipette_models != ("flex_8channel_1000", "p1000_multi_flex"): + raise UnitOperationError("Expected pipette models do not match the exact RIGHT eight-channel API aliases.") + routes_raw = _mapping(data.get("expected_plan_routes"), "expected_plan_routes") + routes: dict[str, tuple[str, str, str]] = {} + for identifier, value in routes_raw.items(): + if not isinstance(identifier, str) or not identifier: + raise UnitOperationError("Every movement plan identifier must be a non-empty string.") + route = _string_tuple(value, f"expected_plan_routes[{identifier!r}]") + if len(route) != 3: + raise UnitOperationError(f"Plan {identifier!r} must declare labware, source, and destination.") + routes[identifier] = (route[0], route[1], route[2]) + required_routes = { + "asms-v1-a1-to-b2", + "asms-v1-b2-to-a1", + "asms-v1-b1-to-b2", + "asms-v1-b2-to-b1", + } + if set(routes) != required_routes: + raise UnitOperationError(f"Movement plan set does not match the exact ASMS unit routes: {sorted(routes)}.") + + occupancy_raw = _mapping(data.get("initial_occupancy"), "initial_occupancy") + occupancy = { + _text(key, "initial_occupancy location"): _text(value, f"initial_occupancy[{key!r}]") + for key, value in occupancy_raw.items() + } + if occupancy != {"A1": "asms-plate", "B1": "elution-plate"}: + raise UnitOperationError("Initial deck ledger must contain only asms-plate at A1 and elution-plate at B1.") + + p = _mapping(data.get("pipetting"), "pipetting") + pipetting = PipettingConfig( + mount=_text(p.get("mount"), "pipetting.mount"), + tiprack_uri=_text(p.get("tiprack_uri"), "pipetting.tiprack_uri"), + tiprack_diameter=_positive_number(p.get("tiprack_diameter"), "pipetting.tiprack_diameter"), + tip_length=_positive_number(p.get("tip_length"), "pipetting.tip_length"), + tip_location=_position(p.get("tip_location"), "pipetting.tip_location"), + source=_position(p.get("source"), "pipetting.source"), + source_retract=_position(p.get("source_retract"), "pipetting.source_retract"), + destination=_position(p.get("destination"), "pipetting.destination"), + destination_retract=_position(p.get("destination_retract"), "pipetting.destination_retract"), + volume=_positive_number(p.get("volume"), "pipetting.volume"), + ) + if pipetting.mount != "RIGHT": + raise UnitOperationError("The exact ASMS unit manifest requires the RIGHT pipette mount.") + expected_pipetting = PipettingConfig( + mount="RIGHT", + tiprack_uri="opentrons/opentrons_flex_96_tiprack_1000ul/1", + tiprack_diameter=5.47, + tip_length=95.6, + tip_location=PositionConfig(342.38, 395.38, 99.0), + source=PositionConfig(23.38, 149.74, 18.85), + source_retract=PositionConfig(23.38, 149.74, 51.4), + destination=PositionConfig(178.21, 181.62, 12.22), + destination_retract=PositionConfig(178.21, 181.62, 41.7), + volume=20.0, + ) + if pipetting != expected_pipetting: + raise UnitOperationError("Pipetting geometry does not match the pinned ASMS unit-operation contract.") + target = _positive_number(data.get("temperature_target"), "temperature_target") + if target != 4.0: + raise UnitOperationError("temperature_target must be exactly 4 degrees Celsius for this unit test.") + return UnitOperationManifest( + staging, + movement_profile_sha256, + pipette_models, + routes, + occupancy, + pipetting, + target, + ) + + +def required_confirmations(phase: str) -> dict[str, str]: + """Return the exact human confirmation tokens required by one phase.""" + if phase not in _ACTUATION_PHASES: + return {} + required = { + "confirm_deck_ready": _DECK_CONFIRMATION, + "confirm_staging_slots_clear": _STAGING_CONFIRMATION, + } + if phase.startswith("gripper"): + required["confirm_empty_plates"] = _PLATE_CONFIRMATION + elif phase == "tip": + required["confirm_tip_column"] = _TIP_CONFIRMATION + elif phase == "liquid": + required["confirm_tip_column"] = _TIP_CONFIRMATION + required["confirm_test_liquid"] = _LIQUID_CONFIRMATION + elif phase == "temperature": + required["confirm_temperature_module"] = _TEMPERATURE_CONFIRMATION + return required + + +async def run_phase( + manifest: UnitOperationManifest, + client: UnitClient, + phase: str, + *, + execute: bool = False, + confirmations: dict[str, str | None] | None = None, +) -> dict[str, Any]: + """Validate or run exactly one guarded unit operation.""" + if phase not in _PHASES: + raise UnitOperationError(f"Unknown phase {phase!r}.") + if phase in _ACTUATION_PHASES: + if not execute: + raise UnitOperationError(f"{phase} is an actuation phase; pass --execute after reviewing the deck.") + supplied = confirmations or {} + for name, expected in required_confirmations(phase).items(): + if supplied.get(name) != expected: + raise UnitOperationError(f"{name} must exactly equal {expected!r}.") + + inventory = await _inventory(manifest, client, require_ready=phase != "health") + if phase == "health": + return {"phase": phase, **inventory} + if phase == "preflight": + return {"phase": phase, **inventory, "ready": True} + + if phase == "home": + await client.home() + elif phase == "gripper-a1": + await _round_trip(client, "asms-v1-a1-to-b2", "asms-v1-b2-to-a1") + elif phase == "gripper-b1": + await _round_trip(client, "asms-v1-b1-to-b2", "asms-v1-b2-to-b1") + elif phase in {"tip", "liquid"}: + await _run_tip_or_liquid(client, manifest.pipetting, transfer=phase == "liquid") + elif phase == "temperature": + await _run_temperature(client, manifest.temperature_target) + + after = await _require_machine_ready(client) + return { + "phase": phase, + "completed": True, + "machine_status": _machine_payload(after), + "operator_note": ( + "If physical state differs from this result, stop and reconcile the deck before another command." + ), + } + + +async def _inventory( + manifest: UnitOperationManifest, + client: UnitClient, + *, + require_ready: bool, +) -> dict[str, Any]: + if await client.is_simulating(): + raise UnitOperationError("The target reports simulation; live unit qualification requires the real Flex.") + + pipettes = await client.attached_pipettes() + right = next((item for item in pipettes if _enum_text(item.mount) == "RIGHT"), None) + if right is None or not right.attached: + raise UnitOperationError("No pipette is attached on the RIGHT mount.") + if right.model not in manifest.expected_pipette_models: + raise UnitOperationError( + f"RIGHT pipette model {right.model!r} is not one of {manifest.expected_pipette_models!r}." + ) + if right.channels != 8: + raise UnitOperationError(f"RIGHT pipette must have 8 channels; connector reported {right.channels}.") + if not right.min_volume <= manifest.pipetting.volume <= right.max_volume: + raise UnitOperationError( + f"Test volume {manifest.pipetting.volume} is outside RIGHT pipette range " + f"{right.min_volume}-{right.max_volume}." + ) + if require_ready and right.has_tip: + raise UnitOperationError( + "RIGHT pipette reports an attached tip. Remove it physically, restart connector-unit mode, " + "and rerun preflight before actuation." + ) + + gripper = await client.gripper_status() + if not gripper.attached: + raise UnitOperationError("The Flex gripper is not attached.") + + plans = await client.available_plans() + profile = await client.movement_profile() + if profile.sha256 != manifest.movement_profile_sha256: + raise UnitOperationError( + "Connector movement profile digest does not match the pinned local geometry. " + f"Expected {manifest.movement_profile_sha256}, found {profile.sha256}." + ) + actual_routes = { + plan.plan_identifier: ( + plan.labware_identifier, + plan.source_location, + plan.destination_location, + ) + for plan in plans + if not plan.is_lid + } + if actual_routes != manifest.expected_plan_routes: + raise UnitOperationError(f"Connector movement plans do not match the local manifest. Found {actual_routes!r}.") + + deck = await client.deck_state() + occupancy = {item.location_identifier: item.labware_identifier for item in deck.occupied_locations} + machine = await client.machine_status() + temperature_info = await client.temperature_device_info() + if require_ready: + _validate_machine_ready(machine) + if not deck.valid: + raise UnitOperationError("The durable deck ledger is invalid; physically reconcile it before actuation.") + if occupancy != manifest.initial_occupancy: + raise UnitOperationError( + f"Deck ledger does not match the prepared initial state. Expected " + f"{manifest.initial_occupancy!r}, found {occupancy!r}." + ) + + return { + "connector_health": "PASS", + "hardware_inventory": "PASS", + "pipette_model": right.model, + "pipette_has_tip": right.has_tip, + "gripper_model": gripper.model, + "temperature_module_model": str(getattr(temperature_info, "model", "")), + "temperature_module_serial": str(getattr(temperature_info, "serial_number", "")), + "plan_routes": sorted(actual_routes), + "deck_ledger_valid": deck.valid, + "deck_occupancy": occupancy, + "machine_status": _machine_payload(machine), + "staging_slots_to_keep_clear": list(manifest.expected_staging_slots), + } + + +async def _round_trip(client: UnitClient, outbound: str, inbound: str) -> None: + first = await client.move_labware(outbound) + if first.plan_identifier != outbound: + raise UnitOperationError(f"Connector returned unexpected outbound plan {first.plan_identifier!r}.") + await _require_machine_ready(client) + second = await client.move_labware(inbound) + if second.plan_identifier != inbound: + raise UnitOperationError(f"Connector returned unexpected return plan {second.plan_identifier!r}.") + + +async def _run_tip_or_liquid(client: UnitClient, config: PipettingConfig, *, transfer: bool) -> None: + mount = PipetteMount[config.mount] + await client.configure_full_nozzle(mount, config.tiprack_diameter) + if await client.tip_presence(mount) is not TipPresence.ABSENT: + raise UnitOperationError("RIGHT pipette already has a tip; reconcile it before the prepared pickup.") + location = TipLocation(config.tip_location.x, config.tip_location.y, config.tip_location.z) + picked = await client.pick_up_tip(mount, location, config.tip_length) + if picked is not TipPresence.PRESENT: + raise UnitOperationError("Tip pickup returned without a sensor-verified attached tip.") + try: + if transfer: + await client.transfer(mount, config) + finally: + dropped = await client.drop_tip(mount, location) + if dropped is not TipPresence.ABSENT: + raise UnitOperationError("Tip return finished without a sensor-verified empty pipette.") + + +async def _run_temperature(client: UnitClient, target: float) -> None: + try: + await client.set_temperature(target) + finally: + await client.deactivate_temperature() + + +async def _require_machine_ready(client: UnitClient) -> MachineStatus: + status = await client.machine_status() + _validate_machine_ready(status) + return status + + +def _validate_machine_ready(status: MachineStatus) -> None: + if status.is_error_state: + raise UnitOperationError(f"Flex reports an error state: {status.message}") + if status.estop != "DISENGAGED": + raise UnitOperationError(f"Flex E-stop is {status.estop}; disengage and re-home before actuation.") + if status.door_open: + raise UnitOperationError("Flex door is open; close it before actuation.") + + +class GrpcUnitClient: + """Raw gRPC client for the connector's generated SiLA feature surface.""" + + def __init__(self, channel: grpc.aio.Channel, protobuf: object) -> None: + self._channel = channel + self._protobuf = protobuf + + async def _unary(self, path: str, request: bytes, *, timeout: float) -> bytes: + call = self._channel.unary_unary(path) + return await asyncio.wait_for(call(request, timeout=timeout), timeout=timeout + 1.0) + + async def _property( + self, + service: str, + package: str, + method: str, + *, + timeout: float = 15.0, + ) -> object: + response = await self._unary(f"/{service}/{method}", b"", timeout=timeout) + decoded = await self._protobuf.decode(f"{package}.{method}_Responses", response) + return next(iter(decoded.values())) + + async def _command( + self, + service: str, + package: str, + method: str, + parameters: dict[str, Any] | None = None, + ) -> object: + request = await self._protobuf.encode(f"{package}.{method}_Parameters", parameters or {}) + response = await self._unary(f"/{service}/{method}", request, timeout=30.0) + decoded = await self._protobuf.decode(f"{package}.{method}_Responses", response) + return next(iter(decoded.values()), None) + + async def _observable( + self, + service: str, + package: str, + method: str, + parameters: dict[str, Any] | None = None, + *, + timeout: float = 300.0, + emergency_on_failure: bool = True, + ) -> object: + try: + return await asyncio.wait_for( + self._poll_observable(service, package, method, parameters, timeout=timeout), + timeout=timeout, + ) + except asyncio.CancelledError: + if emergency_on_failure: + await self._halt_after_uncertain_execution(method) + raise + except (asyncio.TimeoutError, TimeoutError, grpc.aio.AioRpcError) as exc: + timed_out = isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or ( + exc.code() is grpc.StatusCode.DEADLINE_EXCEEDED + ) + if not timed_out: + raise + if emergency_on_failure: + await self._halt_after_uncertain_execution(method) + raise UnitOperationError( + f"{method} exceeded its {timeout:g}-second deadline. EmergencyStop was issued; " + "physically inspect the robot and complete recovery before another operation." + ) from exc + + async def _poll_observable( + self, + service: str, + package: str, + method: str, + parameters: dict[str, Any] | None, + *, + timeout: float, + ) -> object: + request = await self._protobuf.encode(f"{package}.{method}_Parameters", parameters or {}) + confirmation_raw = await self._unary( + f"/{service}/{method}", + request, + timeout=min(15.0, timeout), + ) + confirmation = CommandConfirmation.decode(confirmation_raw) + execution_id = CommandExecutionUUID(value=confirmation.command_execution_uuid.value).encode() + deadline = asyncio.get_running_loop().time() + timeout + while True: + try: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError + response = await self._unary( + f"/{service}/{method}_Result", + execution_id, + timeout=min(5.0, remaining), + ) + decoded = await self._protobuf.decode(f"{package}.{method}_Responses", response) + return next(iter(decoded.values()), None) + except grpc.aio.AioRpcError as exc: + details = base64.b64decode(exc.details() or "") + not_ready = exc.code() is grpc.StatusCode.ABORTED and b"Result is not ready" in details + if not not_ready: + raise + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError from exc + await asyncio.sleep(0.1) + + async def _halt_after_uncertain_execution(self, method: str) -> None: + async def halt() -> None: + try: + await self._observable( + _MOTION_SERVICE, + _MOTION_PACKAGE, + "EmergencyStop", + timeout=30.0, + emergency_on_failure=False, + ) + except Exception as exc: + raise UnitOperationError( + f"{method} execution state is uncertain and EmergencyStop could not be confirmed. " + "Press the physical E-stop now and do not continue until the deck and tip state are reconciled." + ) from exc + + halt_task = asyncio.create_task(halt()) + while not halt_task.done(): + try: + await asyncio.shield(halt_task) + except asyncio.CancelledError: + continue + await halt_task + + async def is_simulating(self) -> bool: + return bool(await self._property(_MOTION_SERVICE, _MOTION_PACKAGE, "Get_IsSimulating")) + + async def machine_status(self) -> MachineStatus: + return await self._property(_MOTION_SERVICE, _MOTION_PACKAGE, "Get_MachineStatus") # type: ignore[return-value] + + async def attached_pipettes(self) -> list[PipetteInfo]: + return await self._observable( + _PIPETTE_SERVICE, + _PIPETTE_PACKAGE, + "GetAttachedPipettes", + timeout=30.0, + emergency_on_failure=False, + ) # type: ignore[return-value] + + async def gripper_status(self) -> GripperStatus: + return await self._property(_GRIPPER_SERVICE, _GRIPPER_PACKAGE, "Get_Status") # type: ignore[return-value] + + async def available_plans(self) -> list[LabwarePlanSummary]: + return await self._property(_LABWARE_SERVICE, _LABWARE_PACKAGE, "Get_AvailablePlans") # type: ignore[return-value] + + async def deck_state(self) -> LabwareDeckState: + return await self._property(_LABWARE_SERVICE, _LABWARE_PACKAGE, "Get_DeckState") # type: ignore[return-value] + + async def movement_profile(self) -> LabwareMovementProfile: + return await self._property( + _LABWARE_SERVICE, + _LABWARE_PACKAGE, + "Get_ProfileIdentity", + ) # type: ignore[return-value] + + async def temperature_device_info(self) -> object: + return await self._property(_TEMPERATURE_SERVICE, _TEMPERATURE_PACKAGE, "Get_DeviceInfo") + + async def home(self) -> None: + await self._observable(_MOTION_SERVICE, _MOTION_PACKAGE, "Home", timeout=180.0) + + async def move_labware(self, plan_identifier: str) -> LabwareMovementResult: + return await self._observable( + _LABWARE_SERVICE, + _LABWARE_PACKAGE, + "MoveLabware", + {"plan_identifier": plan_identifier}, + ) # type: ignore[return-value] + + async def configure_full_nozzle(self, mount: PipetteMount, tiprack_diameter: float) -> object: + return await self._command( + _PIPETTE_SERVICE, + _PIPETTE_PACKAGE, + "ConfigureFullNozzleLayout", + {"mount": mount, "tiprack_diameter": tiprack_diameter}, + ) + + async def tip_presence(self, mount: PipetteMount) -> TipPresence: + return await self._command( + _TIP_SERVICE, + _TIP_PACKAGE, + "GetTipPresence", + {"mount": mount}, + ) # type: ignore[return-value] + + async def pick_up_tip( + self, + mount: PipetteMount, + location: TipLocation, + tip_length: float, + ) -> TipPresence: + return await self._observable( + _TIP_SERVICE, + _TIP_PACKAGE, + "PickUpTip", + { + "mount": mount, + "location": location, + "tip_length": tip_length, + "prep_after": False, + }, + ) # type: ignore[return-value] + + async def drop_tip(self, mount: PipetteMount, location: TipLocation) -> TipPresence: + return await self._observable( + _TIP_SERVICE, + _TIP_PACKAGE, + "DropTip", + {"mount": mount, "location": location, "home_after": False}, + ) # type: ignore[return-value] + + async def transfer(self, mount: PipetteMount, config: PipettingConfig) -> None: + await self._observable( + _LIQUID_SERVICE, + _LIQUID_PACKAGE, + "Transfer", + { + "mount": mount, + "source": LiquidPosition(config.source.x, config.source.y, config.source.z), + "source_retract": LiquidPosition( + config.source_retract.x, + config.source_retract.y, + config.source_retract.z, + ), + "destination": LiquidPosition( + config.destination.x, + config.destination.y, + config.destination.z, + ), + "destination_retract": LiquidPosition( + config.destination_retract.x, + config.destination_retract.y, + config.destination_retract.z, + ), + "volume": config.volume, + "profile": TransferProfile(1.0, 1.0, 0.0, 0.0, 0, 0.0, 0, 0.0, True), + }, + ) + + async def set_temperature(self, target: float) -> object: + return await self._observable( + _TEMPERATURE_SERVICE, + _TEMPERATURE_PACKAGE, + "SetTemperature", + {"temperature": target}, + timeout=120.0, + ) + + async def deactivate_temperature(self) -> object: + return await self._observable( + _TEMPERATURE_SERVICE, + _TEMPERATURE_PACKAGE, + "Deactivate", + timeout=120.0, + ) + + +@contextlib.asynccontextmanager +async def live_client(address: str) -> AsyncIterator[GrpcUnitClient]: + """Build a local codec, then connect it to the selected live SiLA server.""" + config = OpentronsFlexConfig( + use_simulator=True, + simulated_gripper=True, + simulated_temperature_module=True, + sila_server=SiLAServerConfig(hostname="127.0.0.1", port=0, tls=False), + cloud_server_endpoint=None, + discovery=None, + ) + application = create_app(config) + connector = await application.__anext__() + channel = grpc.aio.insecure_channel(address) + try: + yield GrpcUnitClient(channel, connector.sila_server.protobuf) + finally: + await channel.close() + with contextlib.suppress(StopAsyncIteration): + await application.__anext__() + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Validate one guarded ASMS unit operation through connector-only SiLA." + ) + parser.add_argument("phase", choices=_PHASES) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=50051) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--confirm-deck-ready") + parser.add_argument("--confirm-staging-slots-clear") + parser.add_argument("--confirm-empty-plates") + parser.add_argument("--confirm-tip-column") + parser.add_argument("--confirm-test-liquid") + parser.add_argument("--confirm-temperature-module") + return parser + + +async def _main_async(args: argparse.Namespace) -> dict[str, Any]: + manifest = load_manifest(args.manifest) + confirmations = { + "confirm_deck_ready": args.confirm_deck_ready, + "confirm_staging_slots_clear": args.confirm_staging_slots_clear, + "confirm_empty_plates": args.confirm_empty_plates, + "confirm_tip_column": args.confirm_tip_column, + "confirm_test_liquid": args.confirm_test_liquid, + "confirm_temperature_module": args.confirm_temperature_module, + } + async with live_client(f"{args.host}:{args.port}") as client: + return await run_phase( + manifest, + client, + args.phase, + execute=args.execute, + confirmations=confirmations, + ) + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point with stable JSON output for the Windows lab operator.""" + args = _parser().parse_args(argv) + try: + result = asyncio.run(_main_async(args)) + except (UnitOperationError, OSError, TimeoutError, grpc.RpcError) as exc: + sys.stderr.write(f"ERROR: {exc}\n") + return 1 + sys.stdout.write(json.dumps(result, indent=2, sort_keys=True) + "\n") + return 0 + + +def _mapping(value: object, name: str) -> dict[Any, Any]: + if not isinstance(value, dict): + raise UnitOperationError(f"{name} must be a JSON object.") + return value + + +def _string_tuple(value: object, name: str) -> tuple[str, ...]: + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + raise UnitOperationError(f"{name} must be an array of non-empty strings.") + return tuple(value) + + +def _text(value: object, name: str) -> str: + if not isinstance(value, str) or not value: + raise UnitOperationError(f"{name} must be a non-empty string.") + return value + + +def _positive_number(value: object, name: str) -> float: + if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0: + raise UnitOperationError(f"{name} must be a positive number.") + return float(value) + + +def _position(value: object, name: str) -> PositionConfig: + raw = _mapping(value, name) + coordinates = [] + for axis in ("x", "y", "z"): + coordinate = raw.get(axis) + if not isinstance(coordinate, (int, float)) or isinstance(coordinate, bool): + raise UnitOperationError(f"{name}.{axis} must be a number.") + coordinates.append(float(coordinate)) + return PositionConfig(*coordinates) + + +def _enum_text(value: object) -> str: + return str(getattr(value, "value", value)) + + +def _machine_payload(status: MachineStatus) -> dict[str, Any]: + return { + "estop": status.estop, + "door_open": status.door_open, + "is_error_state": status.is_error_state, + "message": status.message, + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/unitelabs/opentrons_flex/features/__init__.py b/src/unitelabs/opentrons_flex/features/__init__.py index a139d3b..b5b1ebe 100644 --- a/src/unitelabs/opentrons_flex/features/__init__.py +++ b/src/unitelabs/opentrons_flex/features/__init__.py @@ -32,6 +32,7 @@ from .labware_movement import ( LabwareDeckState, LabwareMovementController, + LabwareMovementProfile, LabwareMovementResult, LabwarePlanSummary, LabwarePosition, @@ -67,6 +68,7 @@ "HopperDoorState", "LabwareDeckState", "LabwareMovementController", + "LabwareMovementProfile", "LabwareMovementResult", "LabwarePlanSummary", "LabwarePosition", diff --git a/src/unitelabs/opentrons_flex/features/labware_movement.py b/src/unitelabs/opentrons_flex/features/labware_movement.py index e783383..5ee7765 100644 --- a/src/unitelabs/opentrons_flex/features/labware_movement.py +++ b/src/unitelabs/opentrons_flex/features/labware_movement.py @@ -102,11 +102,18 @@ class LabwareDeckState: occupied_locations: list[OccupiedLocation] +@dataclass +class LabwareMovementProfile: + """Identity of the complete server-loaded movement geometry profile.""" + + sha256: typing.Annotated[str, constraints.Pattern(r"^[0-9a-f]{64}$")] + + class LabwareMovementController(sila.Feature): """Execute server-provisioned labware and lid plans with pickup verification.""" def __init__(self, controller: FlexLabwareMovementController) -> None: - super().__init__(originator="ca.accelerationconsortium", category="robots", version="1.0") + super().__init__(originator="ca.accelerationconsortium", category="robots", version="1.1") self._controller = controller @sila.ObservableCommand(errors=_LABWARE_ERRORS) @@ -157,6 +164,11 @@ def deck_state(self) -> LabwareDeckState: ], ) + @sila.UnobservableProperty() + def profile_identity(self) -> LabwareMovementProfile: + """Return the SHA-256 identity of all server-loaded routes and geometry.""" + return LabwareMovementProfile(sha256=self._controller.profile_sha256) + async def _move( self, plan_identifier: str, @@ -196,6 +208,7 @@ async def _move( __all__ = [ "LabwareDeckState", "LabwareMovementController", + "LabwareMovementProfile", "LabwareMovementResult", "LabwarePlanSummary", "LabwarePosition", diff --git a/src/unitelabs/opentrons_flex/io/labware_movement.py b/src/unitelabs/opentrons_flex/io/labware_movement.py index 40bfcc5..badc5d5 100644 --- a/src/unitelabs/opentrons_flex/io/labware_movement.py +++ b/src/unitelabs/opentrons_flex/io/labware_movement.py @@ -1,6 +1,7 @@ """Allowlisted Flex labware movement using the official gripper waypoint planner.""" import asyncio +import hashlib import json import math from collections.abc import Iterable, Mapping @@ -66,6 +67,7 @@ class LoadedLabwareMovementConfig: plans: tuple[LabwareMovementPlan, ...] initial_occupancy: dict[str, str] state_file: Path | None + profile_sha256: str @dataclass(frozen=True) @@ -173,7 +175,13 @@ def load_labware_movement_config(path: str | Path) -> LoadedLabwareMovementConfi state_path = Path(state_file).expanduser() if not state_path.is_absolute(): state_path = config_path.parent / state_path - return LoadedLabwareMovementConfig(plans=plans, initial_occupancy=occupancy, state_file=state_path) + canonical = json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return LoadedLabwareMovementConfig( + plans=plans, + initial_occupancy=occupancy, + state_file=state_path, + profile_sha256=hashlib.sha256(canonical).hexdigest(), + ) class FlexLabwareMovementController: @@ -185,6 +193,7 @@ def __init__( gripper: FlexGripperController, plans: Iterable[LabwareMovementPlan] = (), state: LabwareMovementState | None = None, + profile_sha256: str = "0" * 64, ) -> None: self._motion = motion self._gripper = gripper @@ -198,6 +207,7 @@ def __init__( msg = "Labware movement plan identifiers must be unique." raise ValueError(msg) self._state = state + self._profile_sha256 = profile_sha256 if plan_list and state is None: msg = "Configured labware movement plans require a durable LabwareMovementState ledger." raise ValueError(msg) @@ -332,6 +342,11 @@ def available_plans(self) -> tuple[LabwareMovementPlan, ...]: """Return locally provisioned plan metadata for SiLA discovery.""" return tuple(self._plans.values()) + @property + def profile_sha256(self) -> str: + """Return the digest of the complete server-loaded movement profile.""" + return self._profile_sha256 + @property def deck_state(self) -> tuple[bool, dict[str, str]]: """Return durable validity and a copy of location-to-labware identity.""" diff --git a/src/unitelabs/opentrons_flex/runtime_preflight.py b/src/unitelabs/opentrons_flex/runtime_preflight.py index 5965cf7..413a371 100644 --- a/src/unitelabs/opentrons_flex/runtime_preflight.py +++ b/src/unitelabs/opentrons_flex/runtime_preflight.py @@ -19,11 +19,17 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Validate the Flex connector runtime without initializing hardware.") parser.add_argument("--config", type=Path, required=True, help="Installed connector JSON configuration.") - parser.add_argument( + mode = parser.add_mutually_exclusive_group() + mode.add_argument( "--require-robot-server", action="store_true", help="Require the embedded Opentrons robot-server imports used in connector mode.", ) + mode.add_argument( + "--require-sila-only", + action="store_true", + help="Require connector-exclusive OT3API mode without the embedded robot-server.", + ) parser.add_argument( "--require-mutation", action="store_true", @@ -60,6 +66,7 @@ def main(argv: list[str] | None = None) -> int: config, connector_version=report.connector_version, require_robot_server=args.require_robot_server, + require_sila_only=args.require_sila_only, require_live_hardware=args.require_live_hardware, ) active_release_identity, release_issues = release_identity( @@ -106,11 +113,31 @@ def _configuration_issues( *, connector_version: str, require_robot_server: bool, + require_sila_only: bool, require_live_hardware: bool, ) -> tuple[str, ...]: issues = [] if require_robot_server and config.get("with_robot_server") is not True: issues.append("with_robot_server must be true for connector mode.") + if require_sila_only: + if config.get("with_robot_server") is not False: + issues.append("with_robot_server must be false for connector-unit mode.") + if config.get("run_mutation_required") is not False: + issues.append("run_mutation_required must be false for connector-unit mode.") + if config.get("run_mutation_ledger_path") is not None: + issues.append("run_mutation_ledger_path must be null for connector-unit mode.") + movement_config = config.get("labware_movement_config") + if not isinstance(movement_config, str) or not movement_config: + issues.append("labware_movement_config is required for connector-unit mode.") + elif not Path(movement_config).is_absolute(): + issues.append("labware_movement_config must be an absolute path for connector-unit mode.") + sila_config = config.get("sila_server") + hostname = sila_config.get("hostname") if isinstance(sila_config, dict) else None + if hostname != "127.0.0.1": + issues.append( + "sila_server.hostname must be 127.0.0.1 for connector-unit mode; " + "reach it only through an authenticated SSH tunnel." + ) if require_live_hardware and config.get("use_simulator") is not False: issues.append("use_simulator must be false for a real Flex deployment.") sila_config = config.get("sila_server") diff --git a/tests/integration/test_grpc_advanced_flex.py b/tests/integration/test_grpc_advanced_flex.py index 2d57c00..f7b32a4 100644 --- a/tests/integration/test_grpc_advanced_flex.py +++ b/tests/integration/test_grpc_advanced_flex.py @@ -17,6 +17,7 @@ from unitelabs.opentrons_flex.features import ( LabwareMovementController, LabwareDeckState, + LabwareMovementProfile, LabwareMovementResult, LabwarePlanSummary, LiquidHandlingController, @@ -302,6 +303,9 @@ async def test_labware_and_lid_movement_endpoints_over_grpc( plans = next(iter((await labware_client.property("Get_AvailablePlans")).values())) assert all(isinstance(plan, LabwarePlanSummary) for plan in plans) assert {plan.plan_identifier for plan in plans} == {"plate-out", "lid-out", "occupied-target"} + profile = next(iter((await labware_client.property("Get_ProfileIdentity")).values())) + assert isinstance(profile, LabwareMovementProfile) + assert profile.sha256 == "0" * 64 moved = await labware_client.observable("MoveLabware", {"plan_identifier": "plate-out"}) assert isinstance(next(iter(moved.values())), LabwareMovementResult) diff --git a/tests/scripts/test_deployment_scripts.py b/tests/scripts/test_deployment_scripts.py index 2865e1c..8fc067b 100644 --- a/tests/scripts/test_deployment_scripts.py +++ b/tests/scripts/test_deployment_scripts.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import hashlib import subprocess from pathlib import Path @@ -25,6 +26,8 @@ def _remote_body(path: str, runtime_root: Path) -> str: 'ACTIVE_PATH="/var/sila2_flex"': f'ACTIVE_PATH="{runtime_root}/var/sila2_flex"', } for original, isolated in replacements.items(): + if original not in body and original.startswith("ACTIVE_PATH="): + continue assert original in body, f"{path} no longer exposes the expected isolated path seam" body = body.replace(original, isolated) systemd_unit = "cat > /etc/systemd/system/sila2-connector.service" @@ -58,6 +61,7 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st command_log = tmp_path / "commands.log" connector_active = tmp_path / "connector-active" stock_active = tmp_path / "stock-active" + stock_enabled = tmp_path / "stock-enabled" for directory in ( current_release / "bin", @@ -69,12 +73,18 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st directory.mkdir(parents=True, exist_ok=True) active_path.symlink_to(current_release, target_is_directory=True) stock_active.touch() + stock_enabled.touch() (state_dir / "run-mutation.env").write_text( "UNITELABS_RUN_MUTATION_TOKEN=" + ("t" * 32) + "\nUNITELABS_RUN_MUTATION_ACTOR=offline-harness\n" ) (state_dir / "stock-service-state").write_text("opentrons-robot-server|yes|yes\n") for release in (current_release, previous_release): (release / "config.json").write_text("{}\n") + (release / "unit-config.json").write_text("{}\n") + (release / "asms-unit-labware-movement.json").write_text("{}\n") + (release / "asms-unit-operations.json").write_text("{}\n") + config_digest = hashlib.sha256(b"{}\n" * 4).hexdigest() + (release / ".unitelabs-release-complete").write_text(config_digest + "\n") _write_executable( release / "bin/python", """#!/bin/sh @@ -97,11 +107,15 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st fi case "$command" in cat) + selector="$FAKE_RUNTIME_ROOT/var/lib/unitelabs-opentrons-flex/connector-config.json" + printf 'ExecStart=connector --config-path %s\n' "$selector" exit 0 ;; is-enabled) - [ "$service" != "sila2-connector" ] - exit $? + case "$service" in + sila2-connector) exit 1 ;; + *) [ -f "$FAKE_STOCK_ENABLED" ] ;; + esac ;; is-active) case "$service" in @@ -119,10 +133,21 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st : > "$FAKE_STOCK_ACTIVE" fi ;; + enable) + if [ "$service" != "sila2-connector" ]; then + : > "$FAKE_STOCK_ENABLED" + fi + ;; + disable) + if [ "$service" != "sila2-connector" ]; then + rm -f "$FAKE_STOCK_ENABLED" + fi + ;; stop) if [ "$service" = "sila2-connector" ]; then rm -f "$FAKE_CONNECTOR_ACTIVE" elif [ "$service" = "opentrons-robot-server" ]; then + [ "${FAKE_STOCK_STOP_FAIL:-0}" != 1 ] || exit 1 rm -f "$FAKE_STOCK_ACTIVE" fi ;; @@ -149,7 +174,11 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st ;; */health) if [ -f "$FAKE_CONNECTOR_ACTIVE" ]; then - [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] + selector="$FAKE_RUNTIME_ROOT/var/lib/unitelabs-opentrons-flex/connector-config.json" + case "$(readlink "$selector" 2>/dev/null || true)" in + */unit-config.json) [ "${FAKE_UNIT_HTTP_OPEN:-0}" = 1 ] ;; + *) [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] ;; + esac else [ -f "$FAKE_STOCK_ACTIVE" ] && [ "${FAKE_STOCK_HEALTH:-1}" = 1 ] fi @@ -167,6 +196,9 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st printf '%s\n' "python3 $*" >> "$FAKE_COMMAND_LOG" code="${2:-}" case "$code" in + *31950*) + [ "${FAKE_HTTP_31950_OPEN:-0}" = 1 ] + ;; *socket.create_connection*) [ -f "$FAKE_CONNECTOR_ACTIVE" ] && [ "${FAKE_CONNECTOR_HEALTH:-0}" = 1 ] ;; @@ -178,6 +210,9 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st rm -f "$4" mv "$3" "$4" ;; + *hashlib*) + printf '%s\n' "$FAKE_CONFIG_SHA" + ;; *) exit 1 ;; @@ -200,10 +235,14 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st "FAKE_COMMAND_LOG": str(command_log), "FAKE_CONNECTOR_ACTIVE": str(connector_active), "FAKE_STOCK_ACTIVE": str(stock_active), + "FAKE_STOCK_ENABLED": str(stock_enabled), "FAKE_CONNECTOR_HEALTH": "1", "FAKE_STOCK_HEALTH": "1", "FAKE_PREFLIGHT_EXIT": "0", "FAKE_ACTIVATION_FAIL": "0", + "FAKE_CONFIG_SHA": config_digest, + "FAKE_STOCK_STOP_FAIL": "0", + "FAKE_UNIT_HTTP_OPEN": "0", } paths = { "active": active_path, @@ -212,15 +251,21 @@ def _offline_remote_environment(tmp_path: Path) -> tuple[dict[str, str], dict[st "current_release": current_release, "previous_release": previous_release, "previous_record": state_dir / "previous-release", + "config_selector": state_dir / "connector-config.json", "stock_active": stock_active, + "stock_enabled": stock_enabled, } return environment, paths -def _run_remote_body(path: str, environment: dict[str, str]) -> subprocess.CompletedProcess[str]: +def _run_remote_body( + path: str, + environment: dict[str, str], + *arguments: str, +) -> subprocess.CompletedProcess[str]: runtime_root = Path(environment["FAKE_RUNTIME_ROOT"]) return subprocess.run( - ["sh"], + ["sh", "-s", "--", *arguments], input=_remote_body(path, runtime_root), text=True, capture_output=True, @@ -277,6 +322,7 @@ def test_all_state_transitions_share_a_fail_closed_operation_lock() -> None: "scripts/install_connector_service.sh", "scripts/switch_mode.sh", "scripts/rollback_connector.sh", + "scripts/reconcile_asms_unit_deck.sh", ): script = _text(path) assert 'LOCK_DIR="$STATE_DIR/operation.lock"' in script @@ -284,11 +330,92 @@ def test_all_state_transitions_share_a_fail_closed_operation_lock() -> None: assert "trap " in script +def test_unit_deck_reconciliation_is_explicit_audited_and_recoverable() -> None: + script = _text("scripts/reconcile_asms_unit_deck.sh") + + assert "ASMS-UNIT-RECONCILE-A1-B1-B2-EMPTY" in script + assert "systemctl is-active --quiet sila2-connector" in script + assert "asms-unit-deck-reconciliations.jsonl" in script + assert 'cp -p "$LEDGER" "$BACKUP"' in script + assert 'mv "$TEMP_LEDGER" "$LEDGER"' in script + assert script.index('mkdir "$LOCK_DIR"') < script.index("systemctl is-active --quiet sila2-connector") + assert script.index('"action":"authorized-reset-to-A1-B1"') < script.index('mv "$TEMP_LEDGER" "$LEDGER"') + assert 'rm "$LEDGER"' not in script + + +def test_unit_deck_reconciliation_preserves_malformed_ledger_before_reset(tmp_path: Path) -> None: + environment, _paths = _offline_remote_environment(tmp_path) + runtime_root = Path(environment["FAKE_RUNTIME_ROOT"]) + state_dir = runtime_root / "var/lib/unitelabs-opentrons-flex" + ledger = state_dir / "asms-unit-deck-state.json" + ledger.write_text("{truncated") + fake_bin = Path(environment["PATH"].split(os.pathsep)[0]) + _write_executable(fake_bin / "python3", "#!/bin/sh\nexit 1\n") + + result = _run_remote_body( + "scripts/reconcile_asms_unit_deck.sh", + environment, + "offline-operator", + "ASMS-UNIT-RECONCILE-A1-B1-B2-EMPTY", + ) + + assert result.returncode == 0, result.stderr + backups = list(state_dir.glob("asms-unit-deck-state.json.reconciled-*")) + assert len(backups) == 1 + assert backups[0].read_text() == "{truncated" + assert '"valid":true' in ledger.read_text() + assert '"action":"authorized-reset-to-A1-B1"' in (state_dir / "asms-unit-deck-reconciliations.jsonl").read_text() + + def test_install_inherits_only_robot_image_bindings_and_overrides_pip_root() -> None: script = _text("scripts/install.sh") assert "venv --system-site-packages" in script assert 'pip" install --root / --no-index --no-deps' in script + assert "flex_unit_operations.json" in script + assert "asms_unit_labware_movement.json" in script + assert "asms_unit_operations.json" in script + + +def test_connector_unit_switch_uses_sila_only_config_and_health(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + + result = _run_remote_body("scripts/switch_mode.sh", environment, "connector-unit") + command_log = paths["command_log"].read_text() + + assert result.returncode == 0, result.stderr + assert paths["config_selector"].is_symlink() + assert paths["config_selector"].resolve() == paths["current_release"] / "unit-config.json" + assert "--require-sila-only --require-live-hardware" in command_log + assert "asms_unit_operations health" in command_log + assert paths["connector_active"].is_file() + assert not paths["stock_active"].exists() + assert not paths["stock_enabled"].exists() + + +def test_connector_unit_repairs_preexisting_dual_owner_state(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + paths["connector_active"].touch() + paths["config_selector"].symlink_to(paths["current_release"] / "unit-config.json") + + result = _run_remote_body("scripts/switch_mode.sh", environment, "connector-unit") + + assert result.returncode == 0, result.stderr + assert "systemctl stop opentrons-robot-server" in paths["command_log"].read_text() + assert paths["connector_active"].is_file() + assert not paths["stock_active"].exists() + + +def test_connector_unit_refuses_when_stock_owner_cannot_stop(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + environment["FAKE_STOCK_STOP_FAIL"] = "1" + + result = _run_remote_body("scripts/switch_mode.sh", environment, "connector-unit") + + assert result.returncode != 0 + assert "refusing dual OT3API ownership" in result.stderr + assert not paths["connector_active"].exists() + assert paths["stock_active"].is_file() def test_rollback_leaves_stock_robot_server_active() -> None: @@ -347,6 +474,30 @@ def test_rollback_activation_failure_keeps_current_release_active(tmp_path: Path assert paths["stock_active"].is_file() +def test_rollback_rejects_release_without_completion_marker(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + paths["previous_record"].write_text(f"release:{paths['previous_release']}\n") + (paths["previous_release"] / ".unitelabs-release-complete").unlink() + + result = _run_remote_body("scripts/rollback_connector.sh", environment) + + assert result.returncode != 0 + assert "no deployment completion marker" in result.stderr + assert paths["active"].resolve() == paths["current_release"] + + +def test_rollback_rejects_release_with_changed_configuration(tmp_path: Path) -> None: + environment, paths = _offline_remote_environment(tmp_path) + paths["previous_record"].write_text(f"release:{paths['previous_release']}\n") + environment["FAKE_CONFIG_SHA"] = "0" * 64 + + result = _run_remote_body("scripts/rollback_connector.sh", environment) + + assert result.returncode != 0 + assert "configuration checksum mismatch" in result.stderr + assert paths["active"].resolve() == paths["current_release"] + + def test_rollback_atomically_swaps_release_and_records_prior_target(tmp_path: Path) -> None: environment, paths = _offline_remote_environment(tmp_path) paths["previous_record"].write_text(f"release:{paths['previous_release']}\n") diff --git a/tests/test_asms_unit_operations.py b/tests/test_asms_unit_operations.py new file mode 100644 index 0000000..d1f87b8 --- /dev/null +++ b/tests/test_asms_unit_operations.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from opentrons.hardware_control.ot3api import OT3API +from opentrons.hardware_control.types import OT3Mount + +from unitelabs.opentrons_flex.asms_unit_operations import ( + GrpcUnitClient, + UnitOperationError, + load_manifest, + required_confirmations, + run_phase, +) +from unitelabs.opentrons_flex.features import ( + LabwareDeckState, + LabwareMovementProfile, + LabwarePlanSummary, + OccupiedLocation, + PipetteInfo, + PipetteMount, + TipPresence, +) +from unitelabs.opentrons_flex.features.gripper import GripperStatus +from unitelabs.opentrons_flex.features.motion_control import MachineStatus +from unitelabs.opentrons_flex.io import load_labware_movement_config +from unitelabs.opentrons_flex.io import ( + FlexGripperController, + FlexLabwareMovementController, + FlexMotionController, + LabwareMovementState, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def _manifest(): + return load_manifest(ROOT / "config/asms_unit_operations.json") + + +class FakeClient: + def __init__(self) -> None: + manifest = _manifest() + self.simulating = False + self.machine = MachineStatus("DISENGAGED", False, False, "ready") + self.pipettes = [ + PipetteInfo( + mount=PipetteMount.RIGHT, + attached=True, + model="p1000_multi_flex", + name="flex_8channel_1000", + pipette_id="P1000", + channels=8, + min_volume=5.0, + max_volume=1000.0, + has_tip=False, + ) + ] + self.gripper = GripperStatus(True, "gripperV1", "GRIPPER", "READY") + self.plans = [ + LabwarePlanSummary(identifier, route[0], route[1], route[2], False) + for identifier, route in manifest.expected_plan_routes.items() + ] + self.profile = LabwareMovementProfile(manifest.movement_profile_sha256) + self.deck = LabwareDeckState( + True, + [ + OccupiedLocation("A1", "asms-plate"), + OccupiedLocation("B1", "elution-plate"), + ], + ) + self.calls: list[str] = [] + + async def is_simulating(self): + return self.simulating + + async def machine_status(self): + return self.machine + + async def attached_pipettes(self): + return self.pipettes + + async def gripper_status(self): + return self.gripper + + async def available_plans(self): + return self.plans + + async def deck_state(self): + return self.deck + + async def movement_profile(self): + return self.profile + + async def temperature_device_info(self): + return SimpleNamespace(model="temperatureModuleV2", serial_number="TM-1") + + async def home(self): + self.calls.append("home") + + async def move_labware(self, plan_identifier): + self.calls.append(plan_identifier) + return SimpleNamespace(plan_identifier=plan_identifier) + + async def configure_full_nozzle(self, mount, tiprack_diameter): + self.calls.append(f"configure:{mount.value}:{tiprack_diameter}") + + async def tip_presence(self, mount): + self.calls.append(f"presence:{mount.value}") + return TipPresence.ABSENT + + async def pick_up_tip(self, mount, location, tip_length): + self.calls.append(f"pickup:{mount.value}:{tip_length}") + return TipPresence.PRESENT + + async def drop_tip(self, mount, location): + self.calls.append(f"drop:{mount.value}") + return TipPresence.ABSENT + + async def transfer(self, mount, config): + self.calls.append(f"transfer:{mount.value}:{config.volume}") + + async def set_temperature(self, target): + self.calls.append(f"temperature:{target}") + + async def deactivate_temperature(self): + self.calls.append("temperature:off") + + +def _confirmations(phase: str) -> dict[str, str]: + return required_confirmations(phase) + + +class _StallingChannel: + def unary_unary(self, _path): + async def call(_request, *, timeout): + await asyncio.Event().wait() + + return call + + +class _MinimalCodec: + async def encode(self, _message, _parameters): + return b"request" + + +def test_exact_manifest_loads_and_preserves_magnetic_block_offsets() -> None: + manifest = _manifest() + movement = load_labware_movement_config(ROOT / "config/asms_unit_labware_movement.json") + by_identifier = {plan.identifier: plan for plan in movement.plans} + + assert manifest.expected_staging_slots == ("A4", "B4", "C4", "D4") + assert manifest.movement_profile_sha256 == movement.profile_sha256 + assert manifest.pipetting.mount == "RIGHT" + assert manifest.pipetting.volume == 20.0 + assert movement.initial_occupancy == {"A1": "asms-plate", "B1": "elution-plate"} + assert by_identifier["asms-v1-a1-to-b2"].destination_grip_point.z == -1.675 + assert by_identifier["asms-v1-b2-to-a1"].source_grip_point.z == -2.675 + + +def test_manifest_rejects_incomplete_staging_topology(tmp_path: Path) -> None: + source = (ROOT / "config/asms_unit_operations.json").read_text() + path = tmp_path / "manifest.json" + path.write_text(source.replace('"C4",\n "D4"', '"C4"')) + + with pytest.raises(UnitOperationError, match="exactly A4, B4, C4, D4"): + load_manifest(path) + + +def test_manifest_rejects_changed_pipetting_geometry(tmp_path: Path) -> None: + source = (ROOT / "config/asms_unit_operations.json").read_text() + path = tmp_path / "manifest.json" + path.write_text(source.replace('"volume": 20.0', '"volume": 200.0')) + + with pytest.raises(UnitOperationError, match="pinned ASMS unit-operation contract"): + load_manifest(path) + + +@pytest.mark.asyncio +async def test_exact_gripper_geometry_is_accepted_by_pinned_ot3_bounds(tmp_path: Path) -> None: + movement = load_labware_movement_config(ROOT / "config/asms_unit_labware_movement.json") + api = await OT3API.build_hardware_simulator( + attached_instruments={ + OT3Mount.GRIPPER: { + "model": "gripperV1", + "id": "GRIPPER-SIM-1", + } + } + ) + state = LabwareMovementState(tmp_path / "deck-state.json", movement.initial_occupancy) + try: + lock = asyncio.Lock() + motion = FlexMotionController.from_api(api, lock=lock) + gripper = FlexGripperController.from_api(api, lock=lock) + controller = FlexLabwareMovementController( + motion, + gripper, + plans=movement.plans, + state=state, + ) + assert len(controller.available_plans) == 4 + finally: + state.close() + await api.clean_up() + + +@pytest.mark.asyncio +async def test_health_reports_invalid_ledger_without_allowing_preflight() -> None: + client = FakeClient() + client.deck = replace(client.deck, valid=False) + + health = await run_phase(_manifest(), client, "health") + + assert health["connector_health"] == "PASS" + assert health["deck_ledger_valid"] is False + with pytest.raises(UnitOperationError, match="deck ledger is invalid"): + await run_phase(_manifest(), client, "preflight") + + +@pytest.mark.asyncio +async def test_observable_rpc_deadline_triggers_emergency_halt() -> None: + client = GrpcUnitClient(_StallingChannel(), _MinimalCodec()) # type: ignore[arg-type] + client._halt_after_uncertain_execution = AsyncMock() # type: ignore[method-assign] + + with pytest.raises(UnitOperationError, match="EmergencyStop was issued"): + await client._observable("service", "package", "Move", timeout=0.01) + + client._halt_after_uncertain_execution.assert_awaited_once_with("Move") + + +@pytest.mark.asyncio +async def test_preflight_requires_exact_initial_occupancy() -> None: + client = FakeClient() + client.deck = LabwareDeckState(True, [OccupiedLocation("B2", "asms-plate")]) + + with pytest.raises(UnitOperationError, match="prepared initial state"): + await run_phase(_manifest(), client, "preflight") + + +@pytest.mark.asyncio +async def test_preflight_rejects_attached_tip() -> None: + client = FakeClient() + client.pipettes[0] = replace(client.pipettes[0], has_tip=True) + + health = await run_phase(_manifest(), client, "health") + + assert health["pipette_has_tip"] is True + with pytest.raises(UnitOperationError, match="attached tip"): + await run_phase(_manifest(), client, "preflight") + + +@pytest.mark.asyncio +async def test_preflight_rejects_same_routes_with_different_server_geometry() -> None: + client = FakeClient() + client.profile = LabwareMovementProfile("0" * 64) + + with pytest.raises(UnitOperationError, match="profile digest"): + await run_phase(_manifest(), client, "preflight") + + +@pytest.mark.asyncio +async def test_actuation_requires_execute_and_exact_tokens() -> None: + client = FakeClient() + + with pytest.raises(UnitOperationError, match="pass --execute"): + await run_phase(_manifest(), client, "home") + with pytest.raises(UnitOperationError, match="confirm_deck_ready"): + await run_phase(_manifest(), client, "home", execute=True) + + +@pytest.mark.asyncio +async def test_gripper_phase_runs_only_one_round_trip() -> None: + client = FakeClient() + + result = await run_phase( + _manifest(), + client, + "gripper-a1", + execute=True, + confirmations=_confirmations("gripper-a1"), + ) + + assert result["completed"] is True + assert client.calls == ["asms-v1-a1-to-b2", "asms-v1-b2-to-a1"] + + +@pytest.mark.asyncio +async def test_liquid_phase_owns_one_sensor_verified_tip_lifecycle() -> None: + client = FakeClient() + + await run_phase( + _manifest(), + client, + "liquid", + execute=True, + confirmations=_confirmations("liquid"), + ) + + assert client.calls == [ + "configure:RIGHT:5.47", + "presence:RIGHT", + "pickup:RIGHT:95.6", + "transfer:RIGHT:20.0", + "drop:RIGHT", + ] + + +@pytest.mark.asyncio +async def test_liquid_phase_attempts_tip_return_when_transfer_fails() -> None: + client = FakeClient() + + async def fail_transfer(_mount, _config): + client.calls.append("transfer:failed") + raise RuntimeError("transfer failed") + + client.transfer = fail_transfer + with pytest.raises(RuntimeError, match="transfer failed"): + await run_phase( + _manifest(), + client, + "liquid", + execute=True, + confirmations=_confirmations("liquid"), + ) + + assert client.calls[-2:] == ["transfer:failed", "drop:RIGHT"] + + +@pytest.mark.asyncio +async def test_temperature_is_deactivated_when_set_fails() -> None: + client = FakeClient() + + async def fail(_target): + raise RuntimeError("module error") + + client.set_temperature = fail + with pytest.raises(RuntimeError, match="module error"): + await run_phase( + _manifest(), + client, + "temperature", + execute=True, + confirmations=_confirmations("temperature"), + ) + + assert client.calls == ["temperature:off"] diff --git a/tests/test_runtime_preflight.py b/tests/test_runtime_preflight.py index 2267afb..9dc1ecd 100644 --- a/tests/test_runtime_preflight.py +++ b/tests/test_runtime_preflight.py @@ -86,6 +86,11 @@ def test_runtime_preflight_rejects_simulator_for_live_deployment(tmp_path, monke ) ) monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report()) + monkeypatch.setattr( + runtime_preflight, + "release_identity", + lambda **_: ({"release_id": "test", "bundle_sha256": "a" * 64}, ()), + ) result = runtime_preflight.main( [ @@ -100,6 +105,67 @@ def test_runtime_preflight_rejects_simulator_for_live_deployment(tmp_path, monke assert "use_simulator must be false" in capsys.readouterr().out +def test_runtime_preflight_passes_connector_unit_config(tmp_path, monkeypatch, capsys) -> None: + config = tmp_path / "unit-config.json" + config.write_text( + json.dumps( + { + "with_robot_server": False, + "use_simulator": False, + "run_mutation_required": False, + "run_mutation_ledger_path": None, + "labware_movement_config": "/var/sila2_flex/asms-unit-labware-movement.json", + "sila_server": {"version": "0.9.1", "hostname": "127.0.0.1"}, + } + ) + ) + monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report()) + monkeypatch.setattr( + runtime_preflight, + "release_identity", + lambda **_: ({"release_id": "test", "bundle_sha256": "a" * 64}, ()), + ) + + result = runtime_preflight.main( + [ + "--config", + str(config), + "--require-sila-only", + "--require-live-hardware", + ] + ) + + assert result == 0 + assert json.loads(capsys.readouterr().out)["configuration_issues"] == [] + + +def test_runtime_preflight_rejects_robot_server_in_connector_unit_mode(tmp_path, monkeypatch, capsys) -> None: + config = tmp_path / "unit-config.json" + config.write_text( + json.dumps( + { + "with_robot_server": True, + "use_simulator": False, + "run_mutation_required": True, + "run_mutation_ledger_path": "/var/lib/unitelabs-opentrons-flex/mutations.jsonl", + "labware_movement_config": "relative.json", + "sila_server": {"version": "0.9.1", "hostname": "0.0.0.0"}, + } + ) + ) + monkeypatch.setattr(runtime_preflight, "inspect_runtime_compatibility", lambda **_: _report()) + + result = runtime_preflight.main(["--config", str(config), "--require-sila-only"]) + + assert result == 1 + output = capsys.readouterr().out + assert "with_robot_server must be false" in output + assert "run_mutation_required must be false" in output + assert "run_mutation_ledger_path must be null" in output + assert "labware_movement_config must be an absolute path" in output + assert "sila_server.hostname must be 127.0.0.1" in output + + def test_release_identity_binds_active_venv_to_verified_bundle(tmp_path, monkeypatch) -> None: bundle_sha256 = "a" * 64 architecture = "aarch64" diff --git a/tests/test_smoketest_config.py b/tests/test_smoketest_config.py index 223fd06..ffe1ba1 100644 --- a/tests/test_smoketest_config.py +++ b/tests/test_smoketest_config.py @@ -7,6 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] SMOKETEST_CONFIG = ROOT / "config" / "smoketest_config.json" FLEX_CONFIG = ROOT / "config" / "flex_config.json" +UNIT_CONFIG = ROOT / "config" / "flex_unit_operations.json" def _load(path: Path) -> dict: @@ -45,3 +46,15 @@ def test_real_flex_config_stays_explicitly_live() -> None: assert cfg["with_robot_server"] is True assert cfg["sila_server"]["hostname"] == "0.0.0.0" assert cfg["run_mutation_required"] is True + + +def test_unit_operation_config_is_live_sila_only_and_locally_allowlisted() -> None: + """The unit profile must own real hardware without exposing Protocol Engine.""" + cfg = _load(UNIT_CONFIG) + + assert cfg["use_simulator"] is False + assert cfg["with_robot_server"] is False + assert cfg["run_mutation_required"] is False + assert cfg["run_mutation_ledger_path"] is None + assert cfg["labware_movement_config"] == "/var/sila2_flex/asms-unit-labware-movement.json" + assert cfg["sila_server"]["hostname"] == "127.0.0.1" From b634787b1c8ac9bd7c713f69c3028adfd63a7e42 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 29 Jul 2026 14:08:27 -0400 Subject: [PATCH 12/14] fix: target Flex artifacts to Python 3.12 --- .github/workflows/build-flex-arm-wheels.yml | 2 +- .github/workflows/test.yml | 10 +- CHANGELOG.md | 8 +- Dockerfile | 2 +- Dockerfile.build | 4 +- README.md | 17 +- deploy.sh | 4 +- docs/asms_flex_workflow_test.md | 4 +- pyproject.toml | 4 +- scripts/artifact_manifest.py | 47 ++ .../opentrons_flex/runtime_compat.py | 2 +- tests/scripts/test_artifact_manifest.py | 29 +- tests/scripts/test_preflight_flex.py | 6 +- tests/test_hitl_evidence.py | 2 +- tests/test_runtime_compat.py | 12 +- tests/test_runtime_contract_consistency.py | 3 +- tests/test_runtime_identity_api.py | 2 +- tests/test_runtime_preflight.py | 2 +- tests/test_workflow_manifests.py | 6 +- uv.lock | 621 ++++++++---------- 20 files changed, 394 insertions(+), 393 deletions(-) diff --git a/.github/workflows/build-flex-arm-wheels.yml b/.github/workflows/build-flex-arm-wheels.yml index 6e9d27d..5789525 100644 --- a/.github/workflows/build-flex-arm-wheels.yml +++ b/.github/workflows/build-flex-arm-wheels.yml @@ -57,7 +57,7 @@ jobs: --opentrons-version 9.0.0 \ --robot-server-version 9.0.0 \ --opentrons-source-commit 44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 \ - --python-version 3.10 \ + --python-version 3.12 \ --architecture aarch64 - name: Upload flex-arm-wheels artifact diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c7a5279..1517bfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: matrix: include: # This is the exact runtime matrix deployed to Flex. - - python-version: "3.10" + - python-version: "3.12" opentrons-version: "9.0.0" opentrons-commit: "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5" @@ -113,10 +113,10 @@ jobs: with: fetch-depth: 0 - - name: Download coverage (Python 3.10) + - name: Download coverage (Python 3.12) uses: actions/download-artifact@v4 with: - name: coverage-3.10 + name: coverage-3.12 path: . - name: Skip SonarCloud when token is unavailable @@ -184,8 +184,8 @@ jobs: version: "0.9.6" enable-cache: true - - name: Set up operator Python 3.10 - run: uv python install 3.10 + - name: Set up operator Python 3.12 + run: uv python install 3.12 - name: Install connector and test dependencies run: uv sync --all-extras diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbf548..d564a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ properties while retaining read commands for existing clients. CI now runs the vendored official SiLA v1.2 semantic XSLT in addition to XSD validation. - Version 0.9.1 aligns the deployable runtime with Flex Opentrons 9.0.0 on - Python 3.10, packages the co-versioned private robot-server stack, verifies a + Python 3.12, packages the co-versioned private robot-server stack, verifies a checksummed ARM artifact manifest built from an immutable upstream commit, and rejects cross-release imports before any hardware initialization. Deployments now use versioned immutable releases with completion markers, bounded @@ -41,9 +41,9 @@ restricted to token-authenticated, audited, non-actuating built-in-resource commands and serialized with play; protocol-backed runs and custom definitions fail closed. Stop remains available during state-provider failure, command audit is written in - bounded batches, and the ARM artifact is pinned to the validated Python - 3.10/Opentrons 9.0.0 runtime. Python 3.11+ remains intentionally outside the - supported Flex deployment matrix. + bounded batches, and the ARM artifact is pinned to the Python + 3.12/Opentrons 9.0.0 runtime observed on the target Flex. Incompatible wheel + ABI and architecture tags are rejected before upload. All notable changes to this project will be documented in this file. diff --git a/Dockerfile b/Dockerfile index 2b4edf7..4601654 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG PYTHON_VERSION=3.10 +ARG PYTHON_VERSION=3.12 FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm AS build diff --git a/Dockerfile.build b/Dockerfile.build index d2b2d17..71f6ed6 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -11,7 +11,7 @@ # --target export --output type=local,dest=dist_arm . # # The resulting dist_arm/ feeds deploy.sh. -FROM ghcr.io/astral-sh/uv:python3.10-bookworm AS wheels +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS wheels ARG OPENTRONS_VERSION=9.0.0 ARG OPENTRONS_COMMIT=44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 @@ -60,7 +60,7 @@ RUN git clone --depth 1 --branch "v${OPENTRONS_VERSION}" \ --opentrons-version "$OPENTRONS_VERSION" \ --robot-server-version "$OPENTRONS_VERSION" \ --opentrons-source-commit "$OPENTRONS_COMMIT" \ - --python-version "3.10" \ + --python-version "3.12" \ --architecture "aarch64" FROM scratch AS export diff --git a/README.md b/README.md index 1c1e791..3a342ef 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Motion is exposed per **mount** (`LEFT`, `RIGHT`, `GRIPPER`) in deck coordinates ### Verified models and environments Automated verification and the ARM deployment artifact use the real Opentrons -9.0.0 hardware simulators on Python 3.10. Controlled Protocol Engine +9.0.0 hardware simulators on Python 3.12. Controlled Protocol Engine mutation is intentionally unavailable on unvalidated Opentrons/Python matrices. The connector recognizes these official model identifiers: @@ -159,9 +159,9 @@ and routine workflow operator path runs natively on the lab Windows computer; macOS remains a compatible backup environment. The `deploy.sh` and service management scripts are POSIX tooling for the Flex's Linux environment: invoke them from CI, macOS, WSL, or Git Bash rather than native PowerShell. -The connector/runtime remains pinned to Python 3.10, while the local Unitelabs -workflow uses Python 3.12 and depends only on the shared cross-runtime acceptance -contract; `uv run --directory` keeps those environments isolated. +The connector/runtime and local Unitelabs workflow both target Python 3.12. The +workflow remains isolated and depends only on the shared acceptance contract; +`uv run --directory` keeps its environment separate from robot-only packages. ### Module feature v2 migration @@ -295,7 +295,7 @@ Setting this environment variable will allow you to run various CLI commands wit Install the connector and its dependencies into your active virtual environment: ```sh -uv sync --all-extras +uv sync --python 3.12 --all-extras ``` #### Configure the Connector @@ -411,7 +411,7 @@ SiLA feature-definition generation, and real gRPC calls through the full chain propagating over the wire. CI also creates an isolated Windows Python 3.12 workflow environment that does -not install the Python 3.10 connector. It validates the native PowerShell +not install the connector runtime. It validates the native PowerShell entrypoint and runs the `flex_workflow_offline` import, phase-wiring, commissioning-digest, and fail-closed shutdown tests with offline SDK/Prefect stubs. Deployment-script tests execute the actual remote shell bodies under a @@ -639,7 +639,8 @@ determines whether GitHub blocks merging on that job. The artifact is accepted only when `runtime-manifest.json` and `SHA256SUMS` verify every wheel, the connector/Opentrons/robot-server versions are exact, and -the declared target is Python 3.10 on aarch64. +the declared target is Python 3.12 on aarch64. Every platform-specific wheel +must also carry a compatible `cp312`/`abi3` tag and an aarch64 platform tag. ### Installing on the Flex @@ -774,7 +775,7 @@ Clone the repository and set up the development environment: ```sh git clone https://github.com/AccelerationConsortium/opentrons-flex.git cd opentrons-flex -uv sync --all-extras +uv sync --python 3.12 --all-extras uv run connector start -vvv ``` diff --git a/deploy.sh b/deploy.sh index ad0104c..c67a743 100755 --- a/deploy.sh +++ b/deploy.sh @@ -31,7 +31,7 @@ python3 "$MANIFEST_TOOL" verify "$ARTIFACT_DIR" \ --opentrons-version 9.0.0 \ --robot-server-version 9.0.0 \ --opentrons-source-commit 44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 \ - --python-version 3.10 \ + --python-version 3.12 \ --architecture aarch64 RELEASE_ID="$(python3 "$MANIFEST_TOOL" field "$ARTIFACT_DIR" releaseId)" CONFIG_SHA="$(python3 -c 'import hashlib,sys; h=hashlib.sha256(); [h.update(open(p,"rb").read()) for p in sys.argv[1:]]; print(h.hexdigest())' \ @@ -84,7 +84,7 @@ python3 "$UPLOAD_DIR/artifact_manifest.py" verify "$UPLOAD_DIR" \ --opentrons-version 9.0.0 \ --robot-server-version 9.0.0 \ --opentrons-source-commit 44b37a2f91520bf2e7245c70bf799d46c8c2d9a5 \ - --python-version 3.10 \ + --python-version 3.12 \ --architecture aarch64 \ --check-host-python \ --check-host-architecture diff --git a/docs/asms_flex_workflow_test.md b/docs/asms_flex_workflow_test.md index 7093c46..64fb3da 100644 --- a/docs/asms_flex_workflow_test.md +++ b/docs/asms_flex_workflow_test.md @@ -121,7 +121,7 @@ process arguments. It must contain at least 32 characters. Replace `operator-name` before running the command; every mutation or hold-release request must use that exact `actor`. The connector records the environment-bound identity, not an untrusted self-declared value. The service also fails closed -unless the installed Python 3.10, Opentrons, and embedded robot-server runtime is +unless the installed Python 3.12, Opentrons, and embedded robot-server runtime is the validated 9.0.0 target. Its durable ledger is `/var/lib/unitelabs-opentrons-flex/run-mutations.jsonl`. @@ -245,7 +245,7 @@ test branch: git fetch --prune origin git switch --detach git status --short -uv sync --extra test +uv sync --python 3.12 --extra test ``` Deploying and installing the Linux service still requires Git Bash or WSL. The diff --git a/pyproject.toml b/pyproject.toml index b97678a..8449a6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,11 @@ classifiers = [ "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Human Machine Interfaces", "Topic :: System :: Hardware", ] -requires-python=">=3.10,<3.11" +requires-python=">=3.12,<3.13" dependencies=[ "unitelabs-cdk~=0.9.0", "unitelabs-flex-acceptance-contract==0.1.0", diff --git a/scripts/artifact_manifest.py b/scripts/artifact_manifest.py index ab953b9..0ebbd0e 100755 --- a/scripts/artifact_manifest.py +++ b/scripts/artifact_manifest.py @@ -86,6 +86,46 @@ def _wheel_identity(path: Path) -> tuple[str, str]: return _normalize_distribution(match.group("name")), match.group("version") +def _require_wheel_target(path: Path, *, python_version: str, architecture: str) -> None: + """Reject wheels that cannot load on the declared Python/architecture.""" + version_match = re.fullmatch(r"(?P[0-9]+)\.(?P[0-9]+)", python_version) + if version_match is None: + raise RuntimeError(f"Python version must be major.minor, received {python_version!r}.") + major = int(version_match.group("major")) + minor = int(version_match.group("minor")) + match = _WHEEL_PATTERN.match(path.name) + if match is None: + raise RuntimeError(f"Unrecognized wheel filename: {path.name}") + + python_tags = match.group("python").split(".") + abi_tags = match.group("abi").split(".") + exact_cpython_tag = f"cp{major}{minor}" + compatible_python = exact_cpython_tag in python_tags or f"py{major}" in python_tags + if not compatible_python and "abi3" in abi_tags: + for tag in python_tags: + abi3_match = re.fullmatch(r"cp(?P[0-9])(?P[0-9]+)", tag) + if ( + abi3_match is not None + and int(abi3_match.group("major")) == major + and int(abi3_match.group("minor")) <= minor + ): + compatible_python = True + break + if not compatible_python: + raise RuntimeError( + f"Wheel {path.name} is not compatible with declared Python {python_version}; " + f"expected {exact_cpython_tag}, py{major}, or a compatible abi3 tag." + ) + + normalized_architecture = {"arm64": "aarch64", "amd64": "x86_64"}.get( + architecture.lower(), + architecture.lower(), + ) + platform_tags = match.group("platform").split(".") + if "any" not in platform_tags and not any(tag.endswith(f"_{normalized_architecture}") for tag in platform_tags): + raise RuntimeError(f"Wheel {path.name} is not compatible with declared architecture {normalized_architecture}.") + + def _require_wheel_version(wheels: list[dict[str, Any]], distribution: str, expected: str) -> None: normalized = _normalize_distribution(distribution) versions = sorted({item["version"] for item in wheels if item["distribution"] == normalized}) @@ -112,6 +152,7 @@ def build_manifest( wheels: list[dict[str, Any]] = [] for path in wheel_paths: + _require_wheel_target(path, python_version=python_version, architecture=architecture) distribution, wheel_version = _wheel_identity(path) wheels.append( { @@ -250,6 +291,12 @@ def verify_manifest( } if not all(isinstance(value, str) and value for value in runtime_fields.values()): raise RuntimeError("Manifest runtime identity fields must be non-empty strings.") + for record in wheel_records: + _require_wheel_target( + directory / record["filename"], + python_version=runtime_fields["pythonVersion"], + architecture=runtime_fields["architecture"], + ) _require_wheel_version(wheel_records, "unitelabs-opentrons-flex", runtime_fields["connectorVersion"]) _require_wheel_version(wheel_records, "opentrons", runtime_fields["opentronsVersion"]) _require_wheel_version(wheel_records, "opentrons-shared-data", runtime_fields["opentronsVersion"]) diff --git a/src/unitelabs/opentrons_flex/runtime_compat.py b/src/unitelabs/opentrons_flex/runtime_compat.py index 2dbbc9c..459a667 100644 --- a/src/unitelabs/opentrons_flex/runtime_compat.py +++ b/src/unitelabs/opentrons_flex/runtime_compat.py @@ -14,7 +14,7 @@ SUPPORTED_OPENTRONS_VERSION = "9.0.0" SUPPORTED_OPENTRONS_SOURCE_COMMIT = "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5" -SUPPORTED_PYTHON_VERSION = (3, 10) +SUPPORTED_PYTHON_VERSION = (3, 12) SUPPORTED_RUNTIME_PACKAGES = { "aiohttp": "3.12.14", "anyio": "4.9.0", diff --git a/tests/scripts/test_artifact_manifest.py b/tests/scripts/test_artifact_manifest.py index 9b83ad5..ae77343 100644 --- a/tests/scripts/test_artifact_manifest.py +++ b/tests/scripts/test_artifact_manifest.py @@ -33,13 +33,18 @@ def _build(directory): opentrons_version="9.0.0", robot_server_version="9.0.0", opentrons_source_commit="44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", - python_version="3.10", + python_version="3.12", architecture="aarch64", ) -def test_build_and_verify_exact_runtime_artifact(tmp_path) -> None: +def test_build_and_verify_exact_runtime_artifact(tmp_path, monkeypatch) -> None: _write_required_wheels(tmp_path) + monkeypatch.setattr( + artifact_manifest.sys, + "version_info", + type("VersionInfo", (), {"major": 3, "minor": 12})(), + ) manifest = _build(tmp_path) verified = artifact_manifest.verify_manifest( @@ -49,7 +54,7 @@ def test_build_and_verify_exact_runtime_artifact(tmp_path) -> None: "opentronsVersion": "9.0.0", "robotServerVersion": "9.0.0", "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", - "pythonVersion": "3.10", + "pythonVersion": "3.12", "architecture": "aarch64", }, check_host_python=True, @@ -57,7 +62,7 @@ def test_build_and_verify_exact_runtime_artifact(tmp_path) -> None: ) assert verified == manifest - assert manifest["releaseId"].startswith("flex-0.9.1-ot9.0.0-py3.10-aarch64-") + assert manifest["releaseId"].startswith("flex-0.9.1-ot9.0.0-py3.12-aarch64-") assert json.loads((tmp_path / artifact_manifest.MANIFEST_NAME).read_text())["bundleSha256"] assert (tmp_path / artifact_manifest.CHECKSUMS_NAME).is_file() @@ -90,6 +95,22 @@ def test_build_rejects_mixed_opentrons_runtime(tmp_path) -> None: _build(tmp_path) +def test_build_rejects_cp310_wheel_for_python_312_artifact(tmp_path) -> None: + _write_required_wheels(tmp_path) + (tmp_path / "native_dependency-1.0.0-cp310-cp310-manylinux_2_17_aarch64.whl").write_bytes(b"wrong-python-abi") + + with pytest.raises(RuntimeError, match=r"not compatible with declared Python 3\.12"): + _build(tmp_path) + + +def test_build_rejects_x86_wheel_for_aarch64_artifact(tmp_path) -> None: + _write_required_wheels(tmp_path) + (tmp_path / "native_dependency-1.0.0-cp312-cp312-manylinux_2_17_x86_64.whl").write_bytes(b"wrong-architecture") + + with pytest.raises(RuntimeError, match=r"not compatible with declared architecture aarch64"): + _build(tmp_path) + + def test_verify_rejects_manifest_that_omits_required_runtime_wheel(tmp_path) -> None: _write_required_wheels(tmp_path) _build(tmp_path) diff --git a/tests/scripts/test_preflight_flex.py b/tests/scripts/test_preflight_flex.py index e0a0a35..4985127 100644 --- a/tests/scripts/test_preflight_flex.py +++ b/tests/scripts/test_preflight_flex.py @@ -14,7 +14,7 @@ from unitelabs.opentrons_flex.runtime_compat import RUNTIME_CONTRACT_ID _RELEASE_IDENTITY = { - "release_id": "flex-0.9.1-ot9.0.0-py3.10-aarch64-bbbbbbbbbbbb", + "release_id": "flex-0.9.1-ot9.0.0-py3.12-aarch64-bbbbbbbbbbbb", "bundle_sha256": "b" * 64, } @@ -23,7 +23,7 @@ def _runtime() -> dict: return { "connector_version": "0.9.1", "opentrons_version": "9.0.0", - "python_version": "3.10.20", + "python_version": "3.12.11", "runtime_contract_id": RUNTIME_CONTRACT_ID, "mutation_ready": True, "release_identity": _RELEASE_IDENTITY, @@ -59,7 +59,7 @@ def _runtime_manifest() -> dict: "opentronsVersion": "9.0.0", "robotServerVersion": "9.0.0", "opentronsSourceCommit": "44b37a2f91520bf2e7245c70bf799d46c8c2d9a5", - "pythonVersion": "3.10", + "pythonVersion": "3.12", "architecture": "aarch64", "bundleSha256": _RELEASE_IDENTITY["bundle_sha256"], } diff --git a/tests/test_hitl_evidence.py b/tests/test_hitl_evidence.py index 44b200f..3231c58 100644 --- a/tests/test_hitl_evidence.py +++ b/tests/test_hitl_evidence.py @@ -26,7 +26,7 @@ def _report() -> dict: "runtime": { "connector_version": "0.9.1", "opentrons_version": "9.0.0", - "python_version": "3.10.20", + "python_version": "3.12.11", "runtime_contract_id": "flex-runtime-test", "release_identity": { "release_id": "flex-0.9.1-test", diff --git a/tests/test_runtime_compat.py b/tests/test_runtime_compat.py index 59274e6..c6bc823 100644 --- a/tests/test_runtime_compat.py +++ b/tests/test_runtime_compat.py @@ -19,7 +19,7 @@ def _matching_report(*, require_robot_server: bool = True) -> runtime_compat.Run "_package_version", side_effect=package_versions.__getitem__, ), - patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat.sys, "version_info", (3, 12, 11)), patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", (), ())), patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), @@ -49,7 +49,7 @@ def test_opentrons_version_mismatch_fails_before_hardware() -> None: "_package_version", side_effect=package_versions.__getitem__, ), - patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat.sys, "version_info", (3, 12, 11)), patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), ): @@ -72,7 +72,7 @@ def test_http_dependency_drift_only_blocks_embedded_robot_server_profile() -> No "_package_version", side_effect=package_versions.__getitem__, ), - patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat.sys, "version_info", (3, 12, 11)), patch.object(runtime_compat, "_inspect_robot_server", return_value=("9.0.0", "/robot_server", (), ())), patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), patch.object(runtime_compat, "_module_outside_release_issue", return_value=None), @@ -88,7 +88,7 @@ def test_http_dependency_drift_only_blocks_embedded_robot_server_profile() -> No def test_require_compatible_runtime_raises_actionable_error() -> None: incompatible = runtime_compat.RuntimeCompatibilityReport( connector_version="0.9.1", - python_version="3.11.0", + python_version="3.10.20", opentrons_version="9.0.0", robot_server_version=None, robot_server_source=None, @@ -97,7 +97,7 @@ def test_require_compatible_runtime_raises_actionable_error() -> None: private_api_checks=(), base_compatible=False, mutation_compatible=False, - issues=("Python 3.11.0 is unsupported; expected Python 3.10.x.",), + issues=("Python 3.10.20 is unsupported; expected Python 3.12.x.",), mutation_issues=("The base connector runtime is incompatible.",), ) with ( @@ -135,7 +135,7 @@ def test_opentrons_module_outside_active_release_is_rejected() -> None: "_package_version", side_effect=package_versions.__getitem__, ), - patch.object(runtime_compat.sys, "version_info", (3, 10, 20)), + patch.object(runtime_compat.sys, "version_info", (3, 12, 11)), patch.object(runtime_compat, "_missing_symbol_issue", return_value=None), patch.object( runtime_compat, diff --git a/tests/test_runtime_contract_consistency.py b/tests/test_runtime_contract_consistency.py index 4df8444..a16752a 100644 --- a/tests/test_runtime_contract_consistency.py +++ b/tests/test_runtime_contract_consistency.py @@ -29,7 +29,8 @@ def test_runtime_version_and_source_pin_are_consistent_across_release_surfaces() pyproject = _text("pyproject.toml") assert f'"opentrons=={opentrons_version}"' in pyproject - assert f'requires-python=">={python_version},<3.11"' in pyproject + next_minor = runtime_compat.SUPPORTED_PYTHON_VERSION[1] + 1 + assert f'requires-python=">={python_version},<3.{next_minor}"' in pyproject required_fragments = { "Dockerfile.build": ( diff --git a/tests/test_runtime_identity_api.py b/tests/test_runtime_identity_api.py index 831e45c..e4994d2 100644 --- a/tests/test_runtime_identity_api.py +++ b/tests/test_runtime_identity_api.py @@ -7,7 +7,7 @@ def _compatibility() -> RuntimeCompatibilityReport: return RuntimeCompatibilityReport( connector_version="0.9.1", - python_version="3.10.20", + python_version="3.12.11", opentrons_version="9.0.0", robot_server_version="9.0.0", robot_server_source="/release/robot_server/__init__.py", diff --git a/tests/test_runtime_preflight.py b/tests/test_runtime_preflight.py index 9dc1ecd..0d65ec6 100644 --- a/tests/test_runtime_preflight.py +++ b/tests/test_runtime_preflight.py @@ -9,7 +9,7 @@ def _report(*, compatible: bool = True) -> RuntimeCompatibilityReport: return RuntimeCompatibilityReport( connector_version="0.9.1", - python_version="3.10.20", + python_version="3.12.11", opentrons_version="9.0.0", robot_server_version="9.0.0", robot_server_source="/release/robot_server/__init__.py", diff --git a/tests/test_workflow_manifests.py b/tests/test_workflow_manifests.py index 7e8bcdd..c8eb55f 100644 --- a/tests/test_workflow_manifests.py +++ b/tests/test_workflow_manifests.py @@ -10,7 +10,7 @@ try: import tomllib -except ModuleNotFoundError: # pragma: no cover - Python 3.10 CI +except ModuleNotFoundError: # pragma: no cover - isolated workflow environment import tomli as tomllib from pathlib import Path @@ -120,7 +120,7 @@ def test_flex_acceptance_workflow_is_hardware_tagged() -> None: @pytest.mark.flex_workflow_offline def test_flex_workflow_uses_cross_runtime_contract_instead_of_connector_package() -> None: - """The Python 3.12 workflow must not depend on the Python 3.10 robot runtime.""" + """The workflow must not depend on connector-only robot runtime packages.""" data = _pyproject("flex-system-acceptance") dependencies = set(data["project"]["dependencies"]) @@ -164,7 +164,7 @@ def test_workflow_entrypoints_import_and_build_as_flows(monkeypatch) -> None: @pytest.mark.flex_workflow_offline def test_flex_workflow_entrypoint_imports_as_a_python_312_safe_flow(monkeypatch) -> None: - """The Flex entrypoint imports without the Python 3.10 connector runtime.""" + """The Flex entrypoint imports without the connector runtime.""" _install_workflow_runtime_stubs(monkeypatch) monkeypatch.syspath_prepend(str(WORKFLOWS / "flex-system-acceptance" / "src")) for loaded in list(sys.modules): diff --git a/uv.lock b/uv.lock index 31f3b54..9ae9589 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.10.*" +requires-python = "==3.12.*" [manifest] overrides = [{ name = "jsonschema", specifier = ">=4.25" }] @@ -21,7 +21,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -30,23 +29,23 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e39ad954107ebf213a2325038a3e7a506be3d98e1435e1f82086eec4cde2/aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2", size = 7822921, upload-time = "2025-07-10T13:05:33.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/88/f161f429f9de391eee6a5c2cffa54e2ecd5b7122ae99df247f7734dfefcb/aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248", size = 702641, upload-time = "2025-07-10T13:02:38.98Z" }, - { url = "https://files.pythonhosted.org/packages/fe/b5/24fa382a69a25d242e2baa3e56d5ea5227d1b68784521aaf3a1a8b34c9a4/aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb", size = 479005, upload-time = "2025-07-10T13:02:42.714Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/fda1bc34adbfaa950d98d934a23900918f9d63594928c70e55045838c943/aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd", size = 466781, upload-time = "2025-07-10T13:02:44.639Z" }, - { url = "https://files.pythonhosted.org/packages/36/96/3ce1ea96d3cf6928b87cfb8cdd94650367f5c2f36e686a1f5568f0f13754/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c", size = 1648841, upload-time = "2025-07-10T13:02:46.356Z" }, - { url = "https://files.pythonhosted.org/packages/be/04/ddea06cb4bc7d8db3745cf95e2c42f310aad485ca075bd685f0e4f0f6b65/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95", size = 1622896, upload-time = "2025-07-10T13:02:48.422Z" }, - { url = "https://files.pythonhosted.org/packages/73/66/63942f104d33ce6ca7871ac6c1e2ebab48b88f78b2b7680c37de60f5e8cd/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663", size = 1695302, upload-time = "2025-07-10T13:02:50.078Z" }, - { url = "https://files.pythonhosted.org/packages/20/00/aab615742b953f04b48cb378ee72ada88555b47b860b98c21c458c030a23/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1", size = 1737617, upload-time = "2025-07-10T13:02:52.123Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4f/ef6d9f77225cf27747368c37b3d69fac1f8d6f9d3d5de2d410d155639524/aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61", size = 1642282, upload-time = "2025-07-10T13:02:53.899Z" }, - { url = "https://files.pythonhosted.org/packages/37/e1/e98a43c15aa52e9219a842f18c59cbae8bbe2d50c08d298f17e9e8bafa38/aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656", size = 1582406, upload-time = "2025-07-10T13:02:55.515Z" }, - { url = "https://files.pythonhosted.org/packages/71/5c/29c6dfb49323bcdb0239bf3fc97ffcf0eaf86d3a60426a3287ec75d67721/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3", size = 1626255, upload-time = "2025-07-10T13:02:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/79/60/ec90782084090c4a6b459790cfd8d17be2c5662c9c4b2d21408b2f2dc36c/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288", size = 1637041, upload-time = "2025-07-10T13:02:59.008Z" }, - { url = "https://files.pythonhosted.org/packages/22/89/205d3ad30865c32bc472ac13f94374210745b05bd0f2856996cb34d53396/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda", size = 1612494, upload-time = "2025-07-10T13:03:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/48/ae/2f66edaa8bd6db2a4cba0386881eb92002cdc70834e2a93d1d5607132c7e/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc", size = 1692081, upload-time = "2025-07-10T13:03:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/08/3a/fa73bfc6e21407ea57f7906a816f0dc73663d9549da703be05dbd76d2dc3/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8", size = 1715318, upload-time = "2025-07-10T13:03:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/e3/b3/751124b8ceb0831c17960d06ee31a4732cb4a6a006fdbfa1153d07c52226/aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3", size = 1643660, upload-time = "2025-07-10T13:03:06.406Z" }, - { url = "https://files.pythonhosted.org/packages/81/3c/72477a1d34edb8ab8ce8013086a41526d48b64f77e381c8908d24e1c18f5/aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c", size = 428289, upload-time = "2025-07-10T13:03:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c4/8aec4ccf1b822ec78e7982bd5cf971113ecce5f773f04039c76a083116fc/aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db", size = 451328, upload-time = "2025-07-10T13:03:10.146Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0d/29026524e9336e33d9767a1e593ae2b24c2b8b09af7c2bd8193762f76b3e/aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22", size = 701055, upload-time = "2025-07-10T13:03:45.59Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b8/a5e8e583e6c8c1056f4b012b50a03c77a669c2e9bf012b7cf33d6bc4b141/aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a", size = 475670, upload-time = "2025-07-10T13:03:47.249Z" }, + { url = "https://files.pythonhosted.org/packages/29/e8/5202890c9e81a4ec2c2808dd90ffe024952e72c061729e1d49917677952f/aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff", size = 468513, upload-time = "2025-07-10T13:03:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/23/e5/d11db8c23d8923d3484a27468a40737d50f05b05eebbb6288bafcb467356/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d", size = 1715309, upload-time = "2025-07-10T13:03:51.556Z" }, + { url = "https://files.pythonhosted.org/packages/53/44/af6879ca0eff7a16b1b650b7ea4a827301737a350a464239e58aa7c387ef/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869", size = 1697961, upload-time = "2025-07-10T13:03:53.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/94/18457f043399e1ec0e59ad8674c0372f925363059c276a45a1459e17f423/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c", size = 1753055, upload-time = "2025-07-10T13:03:55.368Z" }, + { url = "https://files.pythonhosted.org/packages/26/d9/1d3744dc588fafb50ff8a6226d58f484a2242b5dd93d8038882f55474d41/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7", size = 1799211, upload-time = "2025-07-10T13:03:57.216Z" }, + { url = "https://files.pythonhosted.org/packages/73/12/2530fb2b08773f717ab2d249ca7a982ac66e32187c62d49e2c86c9bba9b4/aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660", size = 1718649, upload-time = "2025-07-10T13:03:59.469Z" }, + { url = "https://files.pythonhosted.org/packages/b9/34/8d6015a729f6571341a311061b578e8b8072ea3656b3d72329fa0faa2c7c/aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088", size = 1634452, upload-time = "2025-07-10T13:04:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/08b83ea02595a582447aeb0c1986792d0de35fe7a22fb2125d65091cbaf3/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7", size = 1695511, upload-time = "2025-07-10T13:04:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/9c7c31037a063eec13ecf1976185c65d1394ded4a5120dd5965e3473cb21/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9", size = 1716967, upload-time = "2025-07-10T13:04:06.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/02/84406e0ad1acb0fb61fd617651ab6de760b2d6a31700904bc0b33bd0894d/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3", size = 1657620, upload-time = "2025-07-10T13:04:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/07/53/da018f4013a7a179017b9a274b46b9a12cbeb387570f116964f498a6f211/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb", size = 1737179, upload-time = "2025-07-10T13:04:10.182Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/ca01c5ccfeaafb026d85fa4f43ceb23eb80ea9c1385688db0ef322c751e9/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425", size = 1765156, upload-time = "2025-07-10T13:04:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/22/32/5501ab525a47ba23c20613e568174d6c63aa09e2caa22cded5c6ea8e3ada/aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0", size = 1724766, upload-time = "2025-07-10T13:04:13.961Z" }, + { url = "https://files.pythonhosted.org/packages/06/af/28e24574801fcf1657945347ee10df3892311c2829b41232be6089e461e7/aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729", size = 422641, upload-time = "2025-07-10T13:04:16.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/d5/7ac2464aebd2eecac38dbe96148c9eb487679c512449ba5215d233755582/aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338", size = 449316, upload-time = "2025-07-10T13:04:18.289Z" }, ] [[package]] @@ -94,7 +93,6 @@ name = "anyio" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions" }, @@ -126,15 +124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -144,15 +133,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "binaryornot" version = "0.6.0" @@ -180,18 +160,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] @@ -209,22 +189,22 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -266,7 +246,6 @@ dependencies = [ { name = "questionary" }, { name = "termcolor" }, { name = "tomlkit" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/8a/4fccfa29c95536ac6dc98dc09a676cc2bce60d72f68b8e280278c6674669/commitizen-4.16.4.tar.gz", hash = "sha256:bb2fda50da381979e308d4a443d349598de2487d9e40d2e4f55a1f115ca38a8c", size = 66771, upload-time = "2026-06-22T06:32:50.06Z" } wheels = [ @@ -298,28 +277,24 @@ version = "7.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" }, - { url = "https://files.pythonhosted.org/packages/3f/26/602de8c2aec7e2e3e99ebfb8e04ba65598f746275396eea5f6794ff4673f/coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", size = 221058, upload-time = "2026-07-02T13:08:21.013Z" }, - { url = "https://files.pythonhosted.org/packages/fc/13/ebab0743138891c1d646d61e247ec29639afcbb6c4e1905e6a0f0c75291a/coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", size = 247797, upload-time = "2026-07-02T13:08:22.474Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b7/b6ffb9e042aa48dc4144a8a65529affaec8dca0685309353614a2a7386ad/coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", size = 249626, upload-time = "2026-07-02T13:08:23.803Z" }, - { url = "https://files.pythonhosted.org/packages/9c/06/243ff05b652333d8e3d060c11223efc2723b19cacf6605e433fa686ab5d4/coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", size = 251493, upload-time = "2026-07-02T13:08:25.397Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2b/867faa17030a806114dae388b32a3fa929d8cd4bf39226fbc11f6e6bb705/coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", size = 253406, upload-time = "2026-07-02T13:08:26.842Z" }, - { url = "https://files.pythonhosted.org/packages/94/c0/d789ce18f6605afc4895db75723424be2ef494282f77f61d8e5832923183/coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", size = 248512, upload-time = "2026-07-02T13:08:28.398Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/b2673c30739f4a2e06649a0a38ad8b093c4d865462dc7bab0e9524a2c3b1/coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", size = 249532, upload-time = "2026-07-02T13:08:29.731Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/acd79e9a41beabee92b623afe4f30b549916f48566271475f2907e752828/coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", size = 247537, upload-time = "2026-07-02T13:08:31.173Z" }, - { url = "https://files.pythonhosted.org/packages/12/d4/2d301c4d1b3238d7c88b70ab9d13fd53ed9505662a7ff1b46ba1e2e4e3c3/coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", size = 251348, upload-time = "2026-07-02T13:08:32.63Z" }, - { url = "https://files.pythonhosted.org/packages/35/bb/c67708b2bc00f32e12805ec23d5fa677a0a51652f449341a89f9d6b1b715/coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", size = 247806, upload-time = "2026-07-02T13:08:33.931Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6c/57c4f653c47a6e917748f8938e389e72fbcae44e3643cd906664f0477a13/coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", size = 248410, upload-time = "2026-07-02T13:08:35.189Z" }, - { url = "https://files.pythonhosted.org/packages/6c/94/bb083041aef828903668f134273f319f2bd49224962875359c52faa5497f/coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", size = 222588, upload-time = "2026-07-02T13:08:36.486Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/a09d8ee618956f626741b0734854bac4425a00e10c0565f5abca64e7e751/coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", size = 223214, upload-time = "2026-07-02T13:08:37.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli" }, -] - [[package]] name = "cruft" version = "2.16.0" @@ -328,7 +303,6 @@ dependencies = [ { name = "click" }, { name = "cookiecutter" }, { name = "gitpython" }, - { name = "toml" }, { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/7e/0e20012f2aa156e5d6a70483a8d90accb7ed4966dddb0c58987d9f6eba12/cruft-2.16.0.tar.gz", hash = "sha256:184662853fbc1c16c0137fe0e9444e0cbe95e40362f5ebb2d9fae33d9223e73f", size = 678333, upload-time = "2024-12-25T14:08:09.46Z" } @@ -342,7 +316,6 @@ version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -413,18 +386,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/11/317824213350e6bf7af8aa4238cb78bec9ca014b89fdd5e25283f1c40d98/elementpath-5.1.3-py3-none-any.whl", hash = "sha256:c46f5e0e36c149b892308843e1e394bdee17ceafbc18cfa39250403dd8475a4e", size = 260297, upload-time = "2026-06-28T11:24:00.522Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "fastapi" version = "0.100.0" @@ -454,22 +415,22 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, - { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, - { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, - { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, - { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, - { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, - { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -503,13 +464,14 @@ version = "3.5.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, - { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, - { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, ] [[package]] @@ -556,16 +518,16 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/d5/f2b159d8eec08be2a855ef698f5b6f7f9fdda022e4dd9e4f5d968affd678/grpcio-1.81.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:6f9a0c9c1cc15c112d1c053064fd032b64917062292c3d70aea280e02ae10b77", size = 6086868, upload-time = "2026-06-11T12:44:19.364Z" }, - { url = "https://files.pythonhosted.org/packages/80/41/9c95232b94b219ed8b14029d9cd000e0381cafba869c451dda60af84f4ba/grpcio-1.81.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:69ef28e54fc85397f91b8c19592b8ef3d81952080366914823bd8572a2958120", size = 12062291, upload-time = "2026-06-11T12:44:27.142Z" }, - { url = "https://files.pythonhosted.org/packages/83/8b/bd9284bdd665ddf877a3e8bc2930d1bcf6ebdbae7b0da5c783dc26bd6e33/grpcio-1.81.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15641444eca4a29358107b3dceb74c1c6305c55c822fd199b458aaea4068a7fb", size = 6635242, upload-time = "2026-06-11T12:44:30.741Z" }, - { url = "https://files.pythonhosted.org/packages/60/24/78fa025517a925f1a17da71c4ef9d5f1c6f9fa65af22dfb523c5c6317a21/grpcio-1.81.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d4b2dddfc219f54f956ccd53cf76a1d338ffe68fc7f2849ec9c7feb9927ff692", size = 7332974, upload-time = "2026-06-11T12:44:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/f7/11/402295b388dd35861007f8a26a37c2e2f284212d57bdf407c31f36043746/grpcio-1.81.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca1cc11d82677b9662082e5478b7528e2b7db7beaa6bdff42bd62789d81be399", size = 6836597, upload-time = "2026-06-11T12:44:36.108Z" }, - { url = "https://files.pythonhosted.org/packages/4d/71/37b10fd4fd579ffade6e695c14e9df5e8cba9e2365b81c131da438b67c34/grpcio-1.81.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa2ba7d2ad6df4d80127cea65e5b8d5e2c3adbf153ff4804452836328aca7c54", size = 7440660, upload-time = "2026-06-11T12:44:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d5/40203f828abc83d458b634666df6df13778032f178c03845ad5a93682388/grpcio-1.81.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:592b5fee597faa91cce2dd294dd7d9a1c83d76c4dbf877e33ec1adb866b2fbed", size = 8443171, upload-time = "2026-06-11T12:44:41.678Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2c/0ed82ea35b5ec595e10444940c1db8c0e0ef57aa46bc8797d5ff838a219e/grpcio-1.81.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62481553b1793a27e9b9c3cf9e5bd483ef045ca72462592074b46d42b0c4d9b9", size = 7868905, upload-time = "2026-06-11T12:44:44.854Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1f/dcbdc1a68a07cc2b631c3098953794f17d75f93426a019240b90ce5423d6/grpcio-1.81.1-cp310-cp310-win32.whl", hash = "sha256:bb693b1e3d9a2f3fd228e2110daf4b5aeedb36761ca1e4282f74725f6d89f611", size = 4202215, upload-time = "2026-06-11T12:44:47.165Z" }, - { url = "https://files.pythonhosted.org/packages/75/a1/d7ab9f1f42efcb7d9e6111d38be6b367737a72ea2c534e1f55c81e1b6436/grpcio-1.81.1-cp310-cp310-win_amd64.whl", hash = "sha256:88268ca418cacea64cecb0d1d600d3c6b3a8038fcba02e1e205178c5b1f47661", size = 4936582, upload-time = "2026-06-11T12:44:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, ] [[package]] @@ -686,22 +648,24 @@ version = "6.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" }, - { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" }, - { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" }, - { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" }, - { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" }, - { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" }, - { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" }, - { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" }, - { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, ] [[package]] @@ -722,17 +686,17 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] [[package]] @@ -750,44 +714,41 @@ version = "1.0.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/08/4c/17adf86a8fbb02c144c7569dc4919483c01a2ac270307e2d59e1ce394087/msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3", size = 167014, upload-time = "2024-03-02T01:19:21.299Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/c2/8ecbafd6d3178ad408989c82d6d518fec76e053bae20c0fd9f47bffe7dda/msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868", size = 157691, upload-time = "2024-03-01T12:34:50.572Z" }, - { url = "https://files.pythonhosted.org/packages/0d/7e/93373ffbe6561e719996a90b6d112604f52da3ab46e7c395db7607458553/msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c", size = 87954, upload-time = "2024-03-01T12:34:52.52Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/d000e53b067aee19d57a4f26d5bffed7890e6896538ac5f97605b0f64985/msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659", size = 84945, upload-time = "2024-03-01T12:34:55.055Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6e/3dcd4f7d8b978277393fd5b7c0abd9d2b6ef7ba8eb12834bed59158ecf5f/msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2", size = 376004, upload-time = "2024-03-01T12:34:57.709Z" }, - { url = "https://files.pythonhosted.org/packages/d9/96/a1868dd8997d65732476dfc70fef44d046c1b4dbe36ec1481ab744d87775/msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982", size = 385107, upload-time = "2024-03-01T12:35:00.529Z" }, - { url = "https://files.pythonhosted.org/packages/9b/db/8d629233bba3cbe6d7a6e0fd018ed684c5f0befea4428d4217ce066d2f20/msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa", size = 374290, upload-time = "2024-03-01T12:35:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/f0/75/553cc9ddfe59c62654dd398c16cd8ab1b3eeb145e56805f52115cbe9f5a0/msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128", size = 380759, upload-time = "2024-03-01T12:35:07.369Z" }, - { url = "https://files.pythonhosted.org/packages/7c/40/c6f31cef899b54e3f6a759204d0b152c9205aef7219c9d2279f608c421eb/msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d", size = 413943, upload-time = "2024-03-01T12:35:10.271Z" }, - { url = "https://files.pythonhosted.org/packages/b0/a8/29426f7af85406116e1cdbd21d8f02e30ef8f4afe3cfcbb43c498cbadadf/msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653", size = 385405, upload-time = "2024-03-01T12:35:13.438Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/757eeca26527ebac31d86d35bf4ba20155ee14d35c8619dd96bc80a037f3/msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee", size = 158948, upload-time = "2024-03-01T12:35:44.033Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/558899a5f90d450e988484be25be0b49c6930858d6fe44ea6f1f66502fe5/msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b", size = 88696, upload-time = "2024-03-01T12:35:46.218Z" }, + { url = "https://files.pythonhosted.org/packages/99/3e/49d430df1e9abf06bb91e9824422cd6ceead2114662417286da3ddcdd295/msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8", size = 85428, upload-time = "2024-03-01T12:35:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/84828d0c6be6b7f0770777f1a7b1f76f3a78e8b6afb5e4e9c1c9350242be/msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3", size = 396116, upload-time = "2024-03-01T12:35:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/04/2a/c833a8503be9030083f0469e7a3c74d3622a3b4eae676c3934d3ccc01036/msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc", size = 408331, upload-time = "2024-03-01T12:35:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/b988d0a8e8835f705e4bbcb6433845ff11dd50083c0aa43e607bb7b2ff96/msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58", size = 394182, upload-time = "2024-03-01T12:35:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/0d18496cbeef771db605b6a14794f9b4235d371f36b43f7223c1613969ec/msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f", size = 401226, upload-time = "2024-03-01T12:35:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/03/79/ae000bde2aee4b9f0d50c1ca1ab301ade873b59dd6968c28f918d1cf8be4/msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04", size = 432994, upload-time = "2024-03-01T12:35:59.225Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/f97bedf3ab16d38eeea0aafa3ad93cc7b9adf898218961faaea9c3c639f1/msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543", size = 410432, upload-time = "2024-03-01T12:36:01.516Z" }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, - { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, - { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, - { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, - { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, - { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, - { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, - { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -806,14 +767,14 @@ version = "1.26.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, - { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, - { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, - { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, ] [[package]] @@ -922,23 +883,23 @@ version = "0.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, - { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, - { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, - { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, - { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, - { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, - { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, - { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, - { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, - { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, - { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] @@ -975,28 +936,20 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, ] [[package]] @@ -1048,12 +1001,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1065,7 +1016,6 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner" }, { name = "pytest" }, { name = "typing-extensions" }, ] @@ -1079,7 +1029,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1174,9 +1124,9 @@ name = "pywin32" version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, - { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, ] [[package]] @@ -1185,15 +1135,16 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] [[package]] @@ -1256,20 +1207,21 @@ version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, ] [[package]] @@ -1369,13 +1321,11 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c8/56/5a8dcb01ef7b68904f2a3224343d4ab3674b5cc8f48f7cefb0701bc75ab8/SQLAlchemy-1.4.51.tar.gz", hash = "sha256:e7908c2025eb18394e32d65dd02d2e37e17d733cdbe7d78231c2b6d7eb20cdb9", size = 8521574, upload-time = "2024-01-03T01:32:12.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/bf/84be9fd1ba46f6eb1be6d125946bfbb11cdfcb10ff1d4e4d69edfe1d7996/SQLAlchemy-1.4.51-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:1a09d5bd1a40d76ad90e5570530e082ddc000e1d92de495746f6257dc08f166b", size = 1572253, upload-time = "2024-01-11T19:28:40.598Z" }, - { url = "https://files.pythonhosted.org/packages/28/c3/004e8252157c35a8955d259e779606f136a8b4e532d179a2287eca626554/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2be4e6294c53f2ec8ea36486b56390e3bcaa052bf3a9a47005687ccf376745d1", size = 1626038, upload-time = "2024-01-03T04:33:53.206Z" }, - { url = "https://files.pythonhosted.org/packages/1b/96/572636b93dfac56aa6fdb7e3154e52104178a3b258cbcbcebf9706486db8/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca484ca11c65e05639ffe80f20d45e6be81fbec7683d6c9a15cd421e6e8b340", size = 1637613, upload-time = "2024-01-03T04:24:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/f1/35/163fd75315f7989ef3e79679cd28be200beddbcbf45b746d663ce8885e06/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0535d5b57d014d06ceeaeffd816bb3a6e2dddeb670222570b8c4953e2d2ea678", size = 1637607, upload-time = "2024-01-03T04:24:44.444Z" }, - { url = "https://files.pythonhosted.org/packages/31/62/1ba10f442303734a3888e16e0c36724a4f5d323a453dcd5fed696707fc41/SQLAlchemy-1.4.51-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af55cc207865d641a57f7044e98b08b09220da3d1b13a46f26487cc2f898a072", size = 1637598, upload-time = "2024-01-03T04:24:47.108Z" }, - { url = "https://files.pythonhosted.org/packages/d5/94/57d15ad64e5f9fe9a75fbe47ebaf815a74a278ca12cd6896480b7a7c44fd/SQLAlchemy-1.4.51-cp310-cp310-win32.whl", hash = "sha256:7af40425ac535cbda129d9915edcaa002afe35d84609fd3b9d6a8c46732e02ee", size = 1589495, upload-time = "2024-01-11T19:31:01.135Z" }, - { url = "https://files.pythonhosted.org/packages/97/93/8919814c73aeb8d4f6b16edd17f2bed6a274b65ca26a40aeb3e1015c40bc/SQLAlchemy-1.4.51-cp310-cp310-win_amd64.whl", hash = "sha256:8d1d7d63e5d2f4e92a39ae1e897a5d551720179bb8d1254883e7113d3826d43c", size = 1591561, upload-time = "2024-01-11T19:29:27.222Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/315124e11e65e53b61dff71f4331d43880db3d200057b92a3f2820ee81a4/SQLAlchemy-1.4.51-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a33cb3f095e7d776ec76e79d92d83117438b6153510770fcd57b9c96f9ef623d", size = 1589179, upload-time = "2024-01-11T19:28:10.27Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f5/2adc795b64dab95485987537ec8efc38529eba0b3e543c28bab7ed60b990/SQLAlchemy-1.4.51-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cacc0b2dd7d22a918a9642fc89840a5d3cee18a0e1fe41080b1141b23b10916", size = 1628811, upload-time = "2024-01-03T04:32:27.334Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cf/429adece33702ed3d78a7705469439722adf1c0bc0ca0b644de7391e638a/SQLAlchemy-1.4.51-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:245c67c88e63f1523e9216cad6ba3107dea2d3ee19adc359597a628afcabfbcb", size = 1627860, upload-time = "2024-01-03T04:25:30.939Z" }, + { url = "https://files.pythonhosted.org/packages/39/ad/9b88cb1ea1324e48b9933b4314c8686c14f146697a6b461e97c8cfb9d4aa/SQLAlchemy-1.4.51-cp312-cp312-win32.whl", hash = "sha256:8e702e7489f39375601c7ea5a0bef207256828a2bc5986c65cb15cd0cf097a87", size = 1589871, upload-time = "2024-01-11T19:31:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b2/2129557c438c8394377015338c939685cc3bc702b2a8711b18f135b286c3/SQLAlchemy-1.4.51-cp312-cp312-win_amd64.whl", hash = "sha256:0525c4905b4b52d8ccc3c203c9d7ab2a80329ffa077d4bacf31aefda7604dc65", size = 1591823, upload-time = "2024-01-11T19:30:32.118Z" }, ] [[package]] @@ -1408,24 +1358,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "tomlkit" version = "0.15.0" @@ -1618,7 +1550,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/56/7bc5cf1d693d0c8e5d9dd66c29808691c17260b31346e4ddfbee26ba9bc2/uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907", size = 40971, upload-time = "2024-01-29T09:30:55.956Z" } wheels = [ @@ -1634,7 +1565,6 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } wheels = [ @@ -1650,19 +1580,20 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, - { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, - { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, - { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, - { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, - { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, - { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, - { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, - { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, ] [[package]] @@ -1680,16 +1611,16 @@ version = "1.17.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, - { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, - { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, - { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, - { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, - { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] @@ -1728,23 +1659,23 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/ac/cacdda1f0a90441297210bc34cf7e4ac1b7318c8030ebd83bdf6fe82f1db/yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750", size = 135466, upload-time = "2026-07-20T02:04:21.695Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a5/1b2ceace0230e40c52ab1b263148059a43a6303219b996affc68f8381836/yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2", size = 97291, upload-time = "2026-07-20T02:04:24.045Z" }, - { url = "https://files.pythonhosted.org/packages/59/1d/340d1a0db7bbce1f291afc044255ebf4ebbce2b25ab1b3f7d3d069080f5d/yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871", size = 97154, upload-time = "2026-07-20T02:04:25.761Z" }, - { url = "https://files.pythonhosted.org/packages/05/41/25596a33c2fb5098dca8dc3773b04221db64ded0b7f8f09885647d864610/yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0", size = 109196, upload-time = "2026-07-20T02:04:27.543Z" }, - { url = "https://files.pythonhosted.org/packages/f2/df/dd9f2fb8a5c6054fbefd1538d2b9b1127e612d2ee64b307a070173b57afd/yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e", size = 102556, upload-time = "2026-07-20T02:04:29.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/57/4754b9d2c8945880290ecba0864e8b0441e117bba70534fe819e3645e174/yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2", size = 117965, upload-time = "2026-07-20T02:04:30.845Z" }, - { url = "https://files.pythonhosted.org/packages/74/b5/6a9ece27d2043c3386f902dd078ab35d29ef5126b3206ebffb673283a7cb/yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621", size = 116266, upload-time = "2026-07-20T02:04:32.573Z" }, - { url = "https://files.pythonhosted.org/packages/9e/bc/a6653249f6ee59ec85dcfec008d9cbc16586dad613963bb17a91b2b993a5/yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba", size = 110758, upload-time = "2026-07-20T02:04:34.235Z" }, - { url = "https://files.pythonhosted.org/packages/65/7e/c5a12fb8208df7b981bc82256e7831ce428eeaf893f7bbe6179c57bb9252/yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950", size = 110120, upload-time = "2026-07-20T02:04:35.85Z" }, - { url = "https://files.pythonhosted.org/packages/04/6c/1b659b964626694667b3ec01bf4bcff564b73ae7c48ea1fbfe588b78b461/yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00", size = 108834, upload-time = "2026-07-20T02:04:37.67Z" }, - { url = "https://files.pythonhosted.org/packages/74/a6/bf48f55c2104e40c15b7b13fad0a5756a11552a55f01c90bc90a66ab81c3/yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed", size = 103442, upload-time = "2026-07-20T02:04:39.576Z" }, - { url = "https://files.pythonhosted.org/packages/37/ac/84b273ac133ecdce598fc1f4140a08a1bf2044048bff8106371d207d105f/yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440", size = 117413, upload-time = "2026-07-20T02:04:41.549Z" }, - { url = "https://files.pythonhosted.org/packages/a4/55/9307e03977d3b290dfa42e5d2bae7b6140808fd1786fbe70cd9d3bee53c5/yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1", size = 109498, upload-time = "2026-07-20T02:04:43.468Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/791a6f314cb4c989c19f8e3a10271f1e469c077143915e52474d80f26b4b/yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6", size = 116062, upload-time = "2026-07-20T02:04:45.319Z" }, - { url = "https://files.pythonhosted.org/packages/19/1a/ddd3807b86055010e2f99aa89b3c640effdb65696766c20597f696f48a1c/yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d", size = 110941, upload-time = "2026-07-20T02:04:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/6d/03/f34271bba042d2187508bf62aea20a14129efb5a1acfc6a2efe7544630b4/yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224", size = 97534, upload-time = "2026-07-20T02:04:48.774Z" }, - { url = "https://files.pythonhosted.org/packages/e4/02/ecc8dc31b9f355731e700f8402b8075d2ea1737dbc4baf4abf0f0fc64288/yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13", size = 93603, upload-time = "2026-07-20T02:04:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] @@ -1757,15 +1688,15 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/09/ea/34bb185645ecaa18d34e5883bffea71aa9bffbbb994634884e8b2f3ad0c4/zeroconf-0.150.0.tar.gz", hash = "sha256:a5fe7feab1de6ef5e541e0a3d07e534fd91629b813fc27281593584100f63164", size = 213634, upload-time = "2026-06-22T17:52:37.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/37/6ff505bc61353fb62e4dc766aa4f651dd6f07de18a82cc3d244953168c4a/zeroconf-0.150.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d5cfe83cfa85bf8f9bc31b1b33c20ae708d9ca76cef3ae92333e13f4147b169", size = 1683914, upload-time = "2026-06-22T18:23:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/3d192f9fd076dd73207536b4391d56e7460d43455689ffa2636af7603c2e/zeroconf-0.150.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34062fd3cb0f49d7f29fddf1d266c3c72a62bef2d6dd3829db036acb6b63473a", size = 2170530, upload-time = "2026-06-22T18:23:34.556Z" }, - { url = "https://files.pythonhosted.org/packages/90/bc/d9abf066e1d2e1487c379d44a04c0dc289b2924331dc8acb54a79bd3312a/zeroconf-0.150.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:525fa1476e5cff7cfd0194a2a1e189150d180ae4b071db632693409165d9a282", size = 1940385, upload-time = "2026-06-22T18:23:36.09Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bc/efd36f2f3e8792f756627c0be832990b73d9b5f8886c408de07dd6baa26c/zeroconf-0.150.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e66e5eaae928f6f7e21259652c7b8c20601361b1298c49bab5e8eb6e2741ae32", size = 2246664, upload-time = "2026-06-22T18:23:37.727Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a8/a8f3f7f1a0d79fe4b831767b8f115fa349dcc788079395530ac929a88fcd/zeroconf-0.150.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b3d7579f23421087abbcba52a307b7645fdf23da5f382e9d08c984bece0da80", size = 2211453, upload-time = "2026-06-22T18:23:39.435Z" }, - { url = "https://files.pythonhosted.org/packages/78/91/790a11cf73330181bc640817d6833bf260b4dd22fa497b6f8d4b84d2f282/zeroconf-0.150.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6b730d14ca9d19bbc67df8d29a05a3775a2c06f1bf987e5db20a9766e8cf558", size = 2196582, upload-time = "2026-06-22T18:23:40.881Z" }, - { url = "https://files.pythonhosted.org/packages/1a/07/29728bb1136a93a51e4c330cb8db04aa9f147c7b45f1a8bcc335da5d1d53/zeroconf-0.150.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:34cfd84ab4480008874c224905246acf450637ac826f2c211a91932f17c7f23e", size = 2000569, upload-time = "2026-06-22T18:23:42.612Z" }, - { url = "https://files.pythonhosted.org/packages/ff/15/a28375e4dab1d69e0fd1e72fb02cf88fc8d4a494d2b736f51d80711acd05/zeroconf-0.150.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e2e31aaf0e8376acd279712d85f8303331c16dfc004d4117a58e8a5e233e96a1", size = 2218148, upload-time = "2026-06-22T18:23:44.108Z" }, - { url = "https://files.pythonhosted.org/packages/de/83/93c376c8832df46a9a794fd48d59498db3d2d37a1e068fcc06b01c0b0843/zeroconf-0.150.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20a240044db62d51f6cd487226638ad50f3ebac4d06945dae4ac7c1c0008f3b3", size = 2270827, upload-time = "2026-06-22T18:23:45.691Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8a/70f89bd99f27b4d552e9df46d7399b18534e4419d98f7e0be2437c376aae/zeroconf-0.150.0-cp310-cp310-win32.whl", hash = "sha256:903c7745e2dd0e9d19d742a034651d397d4e3706e9c21b8140e22a8cd56fcc22", size = 1289259, upload-time = "2026-06-22T18:23:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/30/50/b3707d03fc6fd4bcf4c3a6d541ee507b7900b28a1779415e53319e0377d8/zeroconf-0.150.0-cp310-cp310-win_amd64.whl", hash = "sha256:fbc3c29ed9ec32db0458f1ecfb8dfc42fc608bf3c78f1b8a4d50593f595f5289", size = 1518728, upload-time = "2026-06-22T18:23:49.224Z" }, + { url = "https://files.pythonhosted.org/packages/63/a4/49a46fcf1afe8f96bec6751e4fe761ce2cd9c124f0d01748f69fa5566013/zeroconf-0.150.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1aba87b6c1cdb6859d0c2291c67dc7d3ac06612250fddd95f68b7632e1e89a79", size = 1684292, upload-time = "2026-06-22T18:24:08.468Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/520eccd6156c066efc03ff41130ce5119ec69b70de90d47b5b69282f9993/zeroconf-0.150.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17a3adc2e4e425c59a1f2a72e289706ec6655fead4d5287f23e1748663df7dc", size = 2088058, upload-time = "2026-06-22T18:24:10.084Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/0103805657b4656ff026e74ca5591189b53c49c35a86a54289c9376639ed/zeroconf-0.150.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00fa39069437e68fe7c32856f7d041f46cd57f4f5a13ca75ae8f1c49e39932be", size = 1884499, upload-time = "2026-06-22T18:24:11.691Z" }, + { url = "https://files.pythonhosted.org/packages/37/41/32b19f3bd7a615f67d0157cef6cfbeb432ea3a16a42d8fbf104706677567/zeroconf-0.150.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e50a7b3d5922ac9bb53129c4dbc381b0af2f49067670f35eb9c9b4ad341bde28", size = 2185601, upload-time = "2026-06-22T18:24:13.852Z" }, + { url = "https://files.pythonhosted.org/packages/e3/44/0fcaf6e1ffea86d685e200de3597c13322552e53aecdf35ea135c1f685b4/zeroconf-0.150.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b93e7cc90a150e6e0a706251c745f2425782cdbc3a7a8b8a12123b96d167960e", size = 2116173, upload-time = "2026-06-22T18:24:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9e/c61025e2cf9d4f436130c2db18c118452c7372c0c1076e08720ccfa36c4f/zeroconf-0.150.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cd46be1653a194ffb583012ffdf284a5042a1dacd7f44b7befc5de1e8491ba8", size = 2114605, upload-time = "2026-06-22T18:24:17.804Z" }, + { url = "https://files.pythonhosted.org/packages/87/5d/76c4f2f2253e2acbc857c521827902cd11fb82d8300088e8ee7174176d21/zeroconf-0.150.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e4f973c9f284031bcc698f1357a44900254ba7f6488c3b9a18541f0221b3f091", size = 1974248, upload-time = "2026-06-22T18:24:19.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a0/43692be8525f7908979a8d7955563f64e01489e8986b6baf3614f1fa784f/zeroconf-0.150.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:626d057f5a65b2126a6799e538ad78b329cb366c3a5fb9f69757ee23ee06ce5f", size = 2120678, upload-time = "2026-06-22T18:24:21.589Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b7/5ecbdad936a8670a15771a0d2ff54d9675ea1d53d49ce427fe4352ae97e7/zeroconf-0.150.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1ddb105722ac7dfdd7b6e0880ef52f955e679fd37a5f3247c6050c74259bda96", size = 2207205, upload-time = "2026-06-22T18:24:23.335Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/77891ee52b8d2e992fb3ac67e3178b419f57e795c631bf1445f01f61cf30/zeroconf-0.150.0-cp312-cp312-win32.whl", hash = "sha256:191972a56d69cb1c6dd472d6af9813730c1937e2e6f89c01e5373df9ab878e11", size = 1277139, upload-time = "2026-06-22T18:24:24.945Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9c/e98b18a621055833433f95342aa7cfba052695f6dcd086853d35c0ffd914/zeroconf-0.150.0-cp312-cp312-win_amd64.whl", hash = "sha256:e68cb166f6b535c42550a554ed00939256045e18df8b998f8af3a3bd50506e99", size = 1514592, upload-time = "2026-06-22T18:24:26.699Z" }, ] From 5065a6c3f5af23de3026d8992f446d9ac55380ce Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 29 Jul 2026 14:13:41 -0400 Subject: [PATCH 13/14] fix: build ARM wheels from lockfile --- CHANGELOG.md | 4 +++- Dockerfile.build | 2 +- tests/test_runtime_contract_consistency.py | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d564a74..878f8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ - Version 0.9.1 aligns the deployable runtime with Flex Opentrons 9.0.0 on Python 3.12, packages the co-versioned private robot-server stack, verifies a checksummed ARM artifact manifest built from an immutable upstream commit, and - rejects cross-release imports before any hardware initialization. Deployments + rejects cross-release imports before any hardware initialization. Runtime + wheels are resolved strictly from `uv.lock`, without a second unpinned + dependency solve. Deployments now use versioned immutable releases with completion markers, bounded no-motion/live health checks, automatic stock robot-server recovery, and explicit release rollback. The multipart upload parser is upgraded to the diff --git a/Dockerfile.build b/Dockerfile.build index 71f6ed6..b12dc7a 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -38,7 +38,7 @@ ADD scripts/artifact_manifest.py /build/scripts/artifact_manifest.py # the embedded robot-server imports, so deployment must not mix a PyPI API wheel # with whatever robot-server happens to be present in the system image. RUN uv build --wheel --out-dir /wheels . && \ - uv pip compile pyproject.toml --output-file /tmp/req.txt && \ + uv export --locked --no-dev --no-hashes --no-emit-project --output-file /tmp/req.txt && \ python -m pip wheel --no-deps -r /tmp/req.txt --wheel-dir /wheels RUN git clone --depth 1 --branch "v${OPENTRONS_VERSION}" \ diff --git a/tests/test_runtime_contract_consistency.py b/tests/test_runtime_contract_consistency.py index a16752a..70aa18a 100644 --- a/tests/test_runtime_contract_consistency.py +++ b/tests/test_runtime_contract_consistency.py @@ -65,6 +65,13 @@ def test_runtime_dependency_manifest_matches_startup_contract() -> None: assert _normalized_runtime_packages() == artifact_manifest.PINNED_RUNTIME_WHEELS +def test_arm_artifact_build_uses_the_validated_lockfile() -> None: + dockerfile = _text("Dockerfile.build") + + assert "uv export --locked --no-dev --no-hashes --no-emit-project" in dockerfile + assert "uv pip compile" not in dockerfile + + def test_flex_documentation_does_not_claim_the_retired_runtime() -> None: for path in ("README.md", "docs/asms_flex_workflow_test.md"): assert "8.8.1" not in _text(path), f"{path} still claims the retired Flex runtime" From 2a9c3004400325d7816c80065a2282a4ad6b5b70 Mon Sep 17 00:00:00 2001 From: Sissi Feng Date: Wed, 5 Aug 2026 12:51:39 -0400 Subject: [PATCH 14/14] feat(gripper): add adjustable force to ungrip + B3->B4 heavy-plate test script --- scripts/test_gripper_heavy_plate.py | 238 ++++++++++++++++++ .../opentrons_flex/features/gripper.py | 6 +- 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 scripts/test_gripper_heavy_plate.py diff --git a/scripts/test_gripper_heavy_plate.py b/scripts/test_gripper_heavy_plate.py new file mode 100644 index 0000000..a5310fe --- /dev/null +++ b/scripts/test_gripper_heavy_plate.py @@ -0,0 +1,238 @@ +"""Targeted gripper test: transport heavy plate from slot B3 to B4. + +Uses the SiLA connector's GripperFeature with adjustable grip/ungrip force +to pick up a heavy plate at B3 and place it at B4. + +Usage: + python scripts/test_gripper_heavy_plate.py --host 169.254.105.239 [--port 50051] + + FLEX_SILA_HOST=169.254.105.239 python scripts/test_gripper_heavy_plate.py +""" + +import argparse +import asyncio +import contextlib +import os +import sys + +import grpc.aio +from sila.server import CommandConfirmation, CommandExecutionUUID +from unitelabs.cdk import SiLAServerConfig +from unitelabs.opentrons_flex import OpentronsFlexConfig, create_app +from unitelabs.opentrons_flex.features.motion_control import Mount + +_MOTION_PKG = "sila2.ca.accelerationconsortium.robots.motioncontrolfeature.v2" +_MOTION_SERVICE = f"{_MOTION_PKG}.MotionControlFeature" + +_GRIPPER_PKG = "sila2.ca.accelerationconsortium.robots.grippercontroller.v1" +_GRIPPER_SERVICE = f"{_GRIPPER_PKG}.GripperController" + + +async def call_observable( + channel: grpc.aio.Channel, + pb: object, + service: str, + package: str, + method: str, + params: dict | None = None, + timeout_s: float = 60.0, +) -> dict: + """Start an observable command, poll its result, and decode the response.""" + req = await pb.encode(f"{package}.{method}_Parameters", params or {}) + start = channel.unary_unary(f"/{service}/{method}") + confirmation = CommandConfirmation.decode(await start(req)) + uuid = confirmation.command_execution_uuid.value + + result = channel.unary_unary(f"/{service}/{method}_Result") + uuid_bytes = CommandExecutionUUID(value=uuid).encode() + deadline = asyncio.get_running_loop().time() + timeout_s + + while True: + try: + resp_bytes = await result(uuid_bytes) + return await pb.decode(f"{package}.{method}_Responses", resp_bytes) + except grpc.aio.AioRpcError as exc: + # "Result is not ready" is expected while the command runs + err_msg = exc.details() or "" + if exc.code() is grpc.StatusCode.ABORTED and "Result is not ready" in err_msg: + pass + elif exc.code() is not grpc.StatusCode.ABORTED: + raise + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError(f"{method} did not finish within {timeout_s}s") from exc + await asyncio.sleep(0.5) + + +# ── Slot centre coordinates (Flex deck, absolute mm, v4 definition) ────────── +# Cutout offset + half-bounding-box (128×86 → 64, 43). +SLOT_B3 = {"x": 392.0, "y": 257.0} # B row, col 3 +SLOT_B4 = {"x": 556.0, "y": 257.0} # B row, col 4 + +SAFE_Z = 120.0 # safe travel height above deck +PICK_Z = 75.0 # Z to lower gripper onto plate (tune for your labware) + +# Grip force for heavy plates – bump to 18 N (well within 5-25 N range) +HEAVY_GRIP_FORCE_N = 18.0 +HEAVY_UNGRIP_FORCE_N = 18.0 + + +async def main(robot_ip: str, port: int): + address = f"{robot_ip}:{port}" + + print("Initializing protobuf codec locally...") + config = OpentronsFlexConfig( + use_simulator=True, + sila_server=SiLAServerConfig(hostname="127.0.0.1", port=0, tls=False), + cloud_server_endpoint=None, + discovery=None, + ) + gen = create_app(config) + connector = await gen.__anext__() + await connector.start() + pb = connector.sila_server.protobuf + + print(f"Connecting to live robot SiLA server at {address}...") + channel = grpc.aio.insecure_channel(address) + + async def get_property(service_name: str, package_name: str, name: str) -> dict: + stub = channel.unary_unary(f"/{service_name}/{name}") + resp_bytes = await stub(b"") + return await pb.decode(f"{package_name}.{name}_Responses", resp_bytes) + + async def call_command(service_name: str, package_name: str, method: str, params: dict | None = None) -> dict: + return await call_observable(channel, pb, service_name, package_name, method, params, timeout_s=60.0) + + async def check_machine_status(stage: str): + res = await get_property(_MOTION_SERVICE, _MOTION_PKG, "Get_MachineStatus") + status = next(iter(res.values())) + print( + f" [{stage}] estop={status.estop} door={status.door_open} " + f"error={status.is_error_state} msg={status.message!r}" + ) + if status.is_error_state: + print(f"FATAL: Robot reports error state: {status.message}") + sys.exit(1) + + try: + # ── 0. Status + gripper check ──────────────────────────────────────── + print("\n=== B3→B4 Heavy-Plate Gripper Test ===\n") + print(f"Grip force: {HEAVY_GRIP_FORCE_N} N") + print(f"Ungrip force: {HEAVY_UNGRIP_FORCE_N} N\n") + + await check_machine_status("start") + + res_gripper = await get_property(_GRIPPER_SERVICE, _GRIPPER_PKG, "Get_Status") + g_status = next(iter(res_gripper.values())) + print(f"Gripper attached: {g_status.attached}") + if not g_status.attached: + print("FATAL: Gripper must be attached for this test.") + sys.exit(1) + jaw = await get_property(_GRIPPER_SERVICE, _GRIPPER_PKG, "Get_JawWidth") + jw = next(iter(jaw.values())) + print(f"Initial jaw width: {jw:.1f} mm") + + # ── 1. Move gripper to B3 (above deck) ────────────────────────────── + print(f"\n--- 1. Move Gripper to B3 safe Z={SAFE_Z} ---") + await call_command( + _MOTION_SERVICE, _MOTION_PKG, "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_B3["x"], "y": SLOT_B3["y"], "z": SAFE_Z, "speed": 50.0}, + ) + await check_machine_status("B3-safe") + + # ── 2. Lower to pick height ───────────────────────────────────────── + print(f"--- 2. Lower Gripper to B3 pick Z={PICK_Z} ---") + await call_command( + _MOTION_SERVICE, _MOTION_PKG, "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_B3["x"], "y": SLOT_B3["y"], "z": PICK_Z, "speed": 30.0}, + ) + await check_machine_status("B3-pick") + + # ── 3. Ungrip (open jaw) then Grip (close on plate) ───────────────── + print(f"--- 3. Ungrip (open) then Grip at {HEAVY_GRIP_FORCE_N} N ---") + await call_command(_GRIPPER_SERVICE, _GRIPPER_PKG, "Ungrip", {"force": HEAVY_UNGRIP_FORCE_N}) + await check_machine_status("B3-ungrip") + await call_command(_GRIPPER_SERVICE, _GRIPPER_PKG, "Grip", {"force": HEAVY_GRIP_FORCE_N}) + await check_machine_status("B3-grip") + + jaw = await get_property(_GRIPPER_SERVICE, _GRIPPER_PKG, "Get_JawWidth") + jw = next(iter(jaw.values())) + print(f" Jaw width after grip: {jw:.1f} mm") + + # ── 4. Raise gripper ──────────────────────────────────────────────── + print(f"--- 4. Raise Gripper to safe Z={SAFE_Z} ---") + await call_command( + _MOTION_SERVICE, _MOTION_PKG, "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_B3["x"], "y": SLOT_B3["y"], "z": SAFE_Z, "speed": 30.0}, + ) + await check_machine_status("B3-ascend") + + # ── 5. Move gripper to B4 ─────────────────────────────────────────── + print(f"--- 5. Move Gripper to B4 safe Z={SAFE_Z} ---") + await call_command( + _MOTION_SERVICE, _MOTION_PKG, "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_B4["x"], "y": SLOT_B4["y"], "z": SAFE_Z, "speed": 50.0}, + ) + await check_machine_status("B4-safe") + + # ── 6. Lower to place height ──────────────────────────────────────── + print(f"--- 6. Lower Gripper to B4 place Z={PICK_Z} ---") + await call_command( + _MOTION_SERVICE, _MOTION_PKG, "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_B4["x"], "y": SLOT_B4["y"], "z": PICK_Z, "speed": 30.0}, + ) + await check_machine_status("B4-place") + + # ── 7. Ungrip (release plate) ─────────────────────────────────────── + print(f"--- 7. Ungrip (release) at {HEAVY_UNGRIP_FORCE_N} N ---") + await call_command(_GRIPPER_SERVICE, _GRIPPER_PKG, "Ungrip", {"force": HEAVY_UNGRIP_FORCE_N}) + await check_machine_status("B4-ungrip") + + jaw = await get_property(_GRIPPER_SERVICE, _GRIPPER_PKG, "Get_JawWidth") + jw = next(iter(jaw.values())) + print(f" Jaw width after release: {jw:.1f} mm") + + # ── 8. Raise gripper ──────────────────────────────────────────────── + print(f"--- 8. Raise Gripper to safe Z={SAFE_Z} ---") + await call_command( + _MOTION_SERVICE, _MOTION_PKG, "MoveTo", + {"mount": Mount.GRIPPER, "x": SLOT_B4["x"], "y": SLOT_B4["y"], "z": SAFE_Z, "speed": 30.0}, + ) + await check_machine_status("B4-ascend") + + print("\n=== B3→B4 Heavy-Plate Gripper Test PASSED ===") + + except Exception as exc: + print(f"\nTEST FAILED: {exc}") + sys.exit(1) + finally: + await channel.close() + await connector.stop() + with contextlib.suppress(StopAsyncIteration): + await gen.__anext__() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Test gripper heavy-plate transport B3→B4 via SiLA connector.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--host", + default=os.environ.get("FLEX_SILA_HOST"), + help="Robot SiLA server IP/hostname (or set FLEX_SILA_HOST).", + ) + parser.add_argument( + "--port", + type=int, + default=int(os.environ.get("FLEX_SILA_PORT", "50051")), + help="Robot SiLA server port.", + ) + args = parser.parse_args() + if not args.host: + parser.error("robot host is required: pass --host or set FLEX_SILA_HOST") + return args + + +if __name__ == "__main__": + _args = _parse_args() + asyncio.run(main(_args.host, _args.port)) diff --git a/src/unitelabs/opentrons_flex/features/gripper.py b/src/unitelabs/opentrons_flex/features/gripper.py index 365961f..fe3ef15 100644 --- a/src/unitelabs/opentrons_flex/features/gripper.py +++ b/src/unitelabs/opentrons_flex/features/gripper.py @@ -119,6 +119,7 @@ async def grip( ) async def ungrip( self, + force: _GripForce, *, status: sila.Status, intermediate: sila.Intermediate[OperationProgress], @@ -126,12 +127,15 @@ async def ungrip( """ Open the jaw fully to release labware. + Args: + force: Release force in Newtons (5-25 N). + Yields: Update: Current gripper progress update. """ report_progress(status, intermediate, 0.0, OperationPhase.STARTING, "Starting gripper release.") try: - await self._controller.ungrip() + await self._controller.ungrip(force_newtons=force) except asyncio.CancelledError: report_progress(status, intermediate, 1.0, OperationPhase.CANCELLED, "Gripper release cancelled.") raise