From de96d0bd753097cf96c2e727067d5a7cd473de75 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 18 May 2026 14:56:43 -0700 Subject: [PATCH] fix(mqtt): rebuild paho client on persistent reconnect failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncMqttBridge.connect() pins the panel's private CA into the paho client once and reuses it across every reconnect attempt. If the panel rotates its CA — most plausibly during a firmware upgrade — every subsequent _client.reconnect() raises ssl.SSLCertVerificationError, which the broad OSError clause silently swallows, and the bridge cannot recover without a manual config-entry reload (the symptom in SpanPanel/span#242). Add _rebuild_client(): on the next reconnect attempt after threshold or any SSL error, re-fetch the CA, construct a fresh paho client via the new _make_paho_client() factory, fire the new pre-rebuild callback so SpanMqttClient can reset its Homie accumulator, tear down the old client, and submit the initial connect via the executor. Restores the previous client on any failure (panel temporarily unreachable, CA endpoint returns 502, etc.) so the loop continues retrying. The counter resets after every rebuild attempt, success or fail, so rebuilds keep firing at the threshold cadence throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. SpanPanelAPIError is now in the bridge's CA-fetch exception list so a 502 from /api/v2/certificate/ca during rebuild doesn't kill the task. Bumps to 2.6.4. Full design at SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md. --- CHANGELOG.md | 23 ++ pyproject.toml | 3 +- src/span_panel_api/mqtt/client.py | 34 ++ src/span_panel_api/mqtt/connection.py | 217 +++++++++-- src/span_panel_api/mqtt/const.py | 6 + tests/test_mqtt_connect_flow.py | 542 +++++++++++++++++++++++++- uv.lock | 20 +- 7 files changed, 811 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 625ab93..58882ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.4] - 05/2026 + +### Fixed + +- **MQTT reconnect now self-heals after persistent failure** — `AsyncMqttBridge._reconnect_loop` rebuilds the paho client from scratch (re-fetching the panel CA, constructing a fresh client, resetting the Homie accumulator) after + `MQTT_FULL_REBUILD_AFTER_FAILURES` (3) consecutive failures, or immediately on any `ssl.SSLError`. The previous behavior pinned the panel's CA certificate into the paho client once at `connect()` time and re-used it across all reconnect attempts; if the + panel rotated its private CA — most plausibly during a firmware upgrade — every subsequent reconnect raised `ssl.SSLCertVerificationError` (caught by the broad `OSError` clause and silently retried) and the bridge could not recover without a config-entry + reload. The rebuild mirrors what a manual reload does without going through HA's `config_entry` teardown, so entities stay registered and the integration's grace-period logic continues to apply unchanged. The threshold-cadence design (counter reset on + every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. See + `SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md` for the full design. + +### Added + +- **`AsyncMqttBridge._rebuild_client()`** — internal recovery method invoked by the reconnect loop on persistent failure. Re-fetches the panel CA via `download_ca_cert()`, builds a fresh paho client via the new `_make_paho_client()` factory, fires the + optional pre-rebuild callback so consumers can reset their own state, tears down the old client, and submits the initial connect via the executor. Restores the previous client on any failure. +- **`AsyncMqttBridge.set_pre_rebuild_callback()`** — internal API for `SpanMqttClient` to register a hook that fires before each rebuild. Used to reset the Homie accumulator so retained messages on the new subscription start from a clean slate. +- **`MQTT_FULL_REBUILD_AFTER_FAILURES`** constant in `mqtt/const.py`. + +### Changed + +- **`SpanPanelAPIError` now in the bridge's CA-fetch exception list** — a `download_ca_cert()` failure during rebuild (e.g. panel returns HTTP 502 mid-outage) is caught, logged at WARNING, and the loop continues retrying with the previous client instead of + letting the reconnect task die. + ## [2.6.2] - 04/2026 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 2a66bed..8765872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "2.6.2" +version = "2.6.4" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -129,7 +129,6 @@ omit = [ "*/tests/*", "*/.venv/*", "*/venv/*", - "src/span_panel_api/mqtt/connection.py", ] [tool.coverage.report] diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 2d0e89a..af52623 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -63,6 +63,10 @@ def __init__( self._field_metadata: dict[str, FieldMetadata] | None = None self._schema_hash: str | None = None self._previous_schema_types: HomieSchemaTypes | None = None + # Cached at connect() so the pre-rebuild hook can reconstruct the + # Homie accumulator with the same panel size after a transport-level + # rebuild. Schema cannot change within a session, so caching is safe. + self._panel_size: int | None = None def _require_homie(self) -> HomieDeviceConsumer: """Return the HomieDeviceConsumer, raising if not yet connected.""" @@ -115,6 +119,7 @@ async def connect(self) -> None: # Fetch schema to determine panel size and build field metadata schema = await get_homie_schema(self._host, port=self._panel_http_port) + self._panel_size = schema.panel_size self._accumulator = HomiePropertyAccumulator(self._serial_number) self._homie = HomieDeviceConsumer(self._accumulator, schema.panel_size) @@ -157,6 +162,11 @@ async def connect(self) -> None: # Wire message handler self._bridge.set_message_callback(self._on_message) self._bridge.set_connection_callback(self._on_connection_change) + # Pre-rebuild hook: reset Homie accumulator before the bridge swaps + # paho clients, so retained messages on the new subscription start + # from a clean slate (no stale `$state=disconnected` cached from + # the original outage). + self._bridge.set_pre_rebuild_callback(self._on_pre_rebuild) # Connect to broker _LOGGER.debug("MQTT: Connecting to broker...") @@ -369,6 +379,30 @@ def _on_connection_change(self, connected: bool) -> None: except Exception: # pylint: disable=broad-exception-caught _LOGGER.warning("Connection callback raised", exc_info=True) + def _on_pre_rebuild(self) -> None: + """Reset Homie accumulator state before the bridge rebuilds its paho client. + + Called synchronously from the bridge's `_rebuild_client` before the + old paho client is torn down and the new one is wired up. Discards + any stale `$state=disconnected` cached during the outage so the + new subscription's retained messages repopulate from a clean slate. + + Schema-derived state (`_field_metadata`, `_schema_hash`, + `_previous_schema_types`) is intentionally preserved — the Homie + schema cannot change within a session, so the cache remains valid + and a refetch would just add cost. If the panel reboots and the + schema actually changed, the existing drift-detection log fires on + the next session's `connect()`. + """ + if self._panel_size is None: + # Pre-rebuild fired before connect() cached the panel size. + # Treat as a no-op — there is no accumulator state to reset + # because connect() never completed. + return + _LOGGER.debug("Pre-rebuild — resetting Homie accumulator") + self._accumulator = HomiePropertyAccumulator(self._serial_number) + self._homie = HomieDeviceConsumer(self._accumulator, self._panel_size) + async def _wait_for_circuit_names(self, timeout: float) -> None: """Wait for all circuit-like nodes to have a ``name`` property. diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 6b581e7..212c81e 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -23,10 +23,11 @@ from paho.mqtt.reasoncodes import ReasonCode from ..auth import download_ca_cert -from ..exceptions import SpanPanelConnectionError, SpanPanelTimeoutError +from ..exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError from .async_client import AsyncMQTTClient from .const import ( MQTT_CONNECT_TIMEOUT_S, + MQTT_FULL_REBUILD_AFTER_FAILURES, MQTT_KEEPALIVE_S, MQTT_RECONNECT_BACKOFF_MULTIPLIER, MQTT_RECONNECT_MAX_DELAY_S, @@ -97,6 +98,7 @@ def __init__( self._message_callback: Callable[[str, str], None] | None = None self._connection_callback: Callable[[bool], None] | None = None + self._pre_rebuild_callback: Callable[[], None] | None = None def is_connected(self) -> bool: """Return whether the MQTT client is currently connected.""" @@ -110,6 +112,44 @@ def set_connection_callback(self, callback: Callable[[bool], None]) -> None: """Set callback for connection state changes: callback(is_connected).""" self._connection_callback = callback + def set_pre_rebuild_callback(self, callback: Callable[[], None]) -> None: + """Set callback invoked just before the bridge rebuilds its paho client. + + Used by SpanMqttClient to reset its Homie accumulator so any stale + in-memory state (e.g. cached `$state=disconnected`) is discarded + before the new client subscribes and retained messages flow in. + + Callback runs synchronously inside `_rebuild_client` before the old + paho client is torn down. Exceptions are caught and logged so a + misbehaving subscriber cannot prevent the rebuild. + """ + self._pre_rebuild_callback = callback + + def _make_paho_client(self, ssl_context: ssl.SSLContext | None) -> AsyncMQTTClient: + """Build and wire a fresh paho client. + + Shared by connect() (initial connect) and _rebuild_client() (in-loop + rebuild). Keeps the callback wiring in one place so a rebuild is + provably symmetric with initial connect. + """ + client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + transport=self._transport, + reconnect_on_failure=False, + ) + client.setup() + client.username_pw_set(self._username, self._password) + # Wire socket callbacks (async versions by default) + client.on_socket_close = self._async_on_socket_close + client.on_socket_unregister_write = self._async_on_socket_unregister_write + # Wire MQTT callbacks (run directly on event loop — no thread dispatch) + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + client.on_message = self._on_message + if ssl_context is not None: + client.tls_set_context(ssl_context) + return client + async def connect(self) -> None: """Connect to the MQTT broker. @@ -142,26 +182,7 @@ async def connect(self) -> None: except (ssl.SSLError, ValueError) as exc: raise SpanPanelConnectionError(f"Failed to build SSL context for {self._panel_host}") from exc - self._client = AsyncMQTTClient( - callback_api_version=CallbackAPIVersion.VERSION2, - transport=self._transport, - reconnect_on_failure=False, - ) - self._client.setup() - - self._client.username_pw_set(self._username, self._password) - - # Wire socket callbacks (async versions by default) - self._client.on_socket_close = self._async_on_socket_close - self._client.on_socket_unregister_write = self._async_on_socket_unregister_write - - # Wire MQTT callbacks (run directly on event loop — no thread dispatch) - self._client.on_connect = self._on_connect - self._client.on_disconnect = self._on_disconnect - self._client.on_message = self._on_message - - if ssl_context is not None: - self._client.tls_set_context(ssl_context) + self._client = self._make_paho_client(ssl_context) # Connect in executor (blocking: DNS, TCP, TLS handshake). # During executor connect, socket callbacks bridge to the event @@ -395,9 +416,132 @@ def _on_message( # -- Reconnection ------------------------------------------------------- + async def _rebuild_client(self) -> bool: + """Tear down the paho client and rebuild it from scratch. + + Replicates what a manual integration reload does without going + through HA's config_entry teardown. Re-fetches the panel CA, + builds a fresh paho client with the same callbacks, fires the + pre-rebuild callback so SpanMqttClient can reset its accumulator, + and submits an initial connect via the executor. + + Returns True when the new client was built and the initial connect + was successfully submitted. Returns False on any failure (panel + unreachable, CA endpoint down, executor connect raised) — the + previous client is left in place and the reconnect loop continues + retrying with it. + + Recovery target: CA rotation (firmware upgrade), stale paho client + internal state, stuck Homie accumulator. See the design doc at + SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md. + """ + if self._loop is None: + return False + + old_client = self._client + + # Fetch fresh CA (TLS bridges only). Failure is non-fatal — old + # client stays in place and the loop retries on the next tick. + ssl_context: ssl.SSLContext | None = None + if self._use_tls: + try: + ca_pem = await download_ca_cert(self._panel_host, port=self._panel_http_port) + ssl_context = _build_ssl_context(ca_pem) + except ( + OSError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelAPIError, + ssl.SSLError, + ValueError, + ) as exc: + _LOGGER.warning("Client rebuild — CA fetch failed: %s", exc) + return False + + # Fire pre-rebuild hook before we touch any state. SpanMqttClient + # uses this to discard its stale Homie accumulator so retained + # messages on the new subscription start from a clean slate. + if self._pre_rebuild_callback is not None: + try: + self._pre_rebuild_callback() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Pre-rebuild callback raised", exc_info=True) + + # Everything past this point is wrapped in a broad catch so that + # unexpected failures (paho construction errors, etc.) cannot kill + # the reconnect task. The whole point of self-heal is that the + # loop survives — we never want the recovery path itself to be a + # source of unrecoverable failure. + try: + # Best-effort teardown of the old paho client. paho's disconnect() + # is synchronous and only severs the socket; the object itself is + # no longer used. + if old_client is not None: + try: + old_client.disconnect() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.debug("Old paho client disconnect raised", exc_info=True) + + # Build fresh client and assign it BEFORE the executor await so + # that a CONNACK arriving during the await sees the right client. + # Without this, the _on_connect → re-subscribe path would route + # through self._client which would still be the (disconnected) + # old_client, and the new client's subscription would never run. + new_client = self._make_paho_client(ssl_context) + new_client.on_socket_open = self._on_socket_open_sync + new_client.on_socket_register_write = self._on_socket_register_write_sync + self._client = new_client + + def _blocking_connect() -> None: + new_client.connect( + host=self._host, + port=self._port, + keepalive=MQTT_KEEPALIVE_S, + ) + + try: + await self._loop.run_in_executor(None, _blocking_connect) + except asyncio.CancelledError: + # Bridge teardown or _on_connect cancelled us mid-rebuild. + # Restore the previous client reference so post-teardown + # state stays consistent, then re-raise — CancelledError + # must propagate to keep the loop's cancel semantics intact. + self._client = old_client + raise + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.warning("Client rebuild — initial connect failed: %s", exc) + # Restore the previous client so the loop keeps retrying + # with what it had. The new client's socket was never opened. + self._client = old_client + return False + finally: + new_client.on_socket_open = self._async_on_socket_open + new_client.on_socket_register_write = self._async_on_socket_register_write + + _LOGGER.info("MQTT client rebuilt for reconnect (TLS=%s)", self._use_tls) + return True + except Exception as exc: # pylint: disable=broad-exception-caught + # _make_paho_client raised, or some other unforeseen failure + # after the CA was fetched. Reconnect loop MUST survive — log + # with traceback for triage and leave whatever client reference + # is current in place. CancelledError is BaseException in 3.8+ + # so it bypasses this clause and propagates naturally. + _LOGGER.warning("Client rebuild — unexpected error: %s", exc, exc_info=True) + return False + async def _reconnect_loop(self) -> None: - """Reconnect with exponential backoff.""" + """Reconnect with exponential backoff. + + Every MQTT_FULL_REBUILD_AFTER_FAILURES consecutive non-SSL failures + (or on any ssl.SSLError), rebuild the paho client from scratch — + re-fetching the panel CA and resetting any stale in-memory state. + Mirrors what a manual integration reload does without going through + HA's config_entry teardown. The counter resets after every rebuild + attempt (success or fail) and on `_connected == True`, so the + cadence holds throughout extended outages. + """ delay = MQTT_RECONNECT_MIN_DELAY_S + failures_since_rebuild_attempt = 0 while self._should_reconnect: if not self._connected and self._client is not None: try: @@ -407,22 +551,37 @@ async def _reconnect_loop(self) -> None: self._client.on_socket_open = self._on_socket_open_sync self._client.on_socket_register_write = self._on_socket_register_write_sync await self._loop.run_in_executor(None, self._client.reconnect) + except ssl.SSLError as exc: + # TLS verification failure — most likely a CA rotation + # (firmware upgrade). ssl.SSLError must be caught before + # OSError because it is an OSError subclass. + _LOGGER.warning("Reconnect TLS failure (%s), rebuilding client", exc) + await self._rebuild_client() + failures_since_rebuild_attempt = 0 except (OSError, TimeoutError) as exc: - # Expected transient failures — panel offline, DNS miss, socket - # timeout, refused connection. The exception type and errno are - # the full diagnostic; paho/stdlib stack frames add no signal. - # ssl.SSLError is an OSError subclass and falls in here too; - # SSL misconfiguration would have failed at setup, so a - # reconnect-time SSL error is handled as a transient failure. + # Expected transient failures — panel offline, DNS miss, + # socket timeout, refused connection. paho also wraps + # some TLS handshake errors as generic OSError on the + # executor connect path; the rebuild after threshold + # catches those. + failures_since_rebuild_attempt += 1 _LOGGER.warning("Reconnect failed (%s), retrying in %ss", exc, delay) + if failures_since_rebuild_attempt >= MQTT_FULL_REBUILD_AFTER_FAILURES: + await self._rebuild_client() + failures_since_rebuild_attempt = 0 except Exception: # pylint: disable=broad-exception-caught # Unknown territory — keep the traceback so support tickets - # are actionable. Never let the reconnect loop die. + # are actionable. Never let the reconnect loop die. No + # rebuild here — unknown errors should not be masked + # behind a recovery action whose effect we cannot predict. + failures_since_rebuild_attempt += 1 _LOGGER.warning("Reconnect failed, retrying in %ss", delay, exc_info=True) finally: if self._client is not None: self._client.on_socket_open = self._async_on_socket_open self._client.on_socket_register_write = self._async_on_socket_register_write + else: + failures_since_rebuild_attempt = 0 await asyncio.sleep(delay) delay = min( delay * MQTT_RECONNECT_BACKOFF_MULTIPLIER, diff --git a/src/span_panel_api/mqtt/const.py b/src/span_panel_api/mqtt/const.py index baf5092..b5bc893 100644 --- a/src/span_panel_api/mqtt/const.py +++ b/src/span_panel_api/mqtt/const.py @@ -48,6 +48,12 @@ MQTT_RECONNECT_MAX_DELAY_S = 60.0 MQTT_RECONNECT_BACKOFF_MULTIPLIER = 2 +# Every this many consecutive reconnect failures (any reason), rebuild the paho client from scratch +# and re-fetch the panel CA. Mirrors the recovery effect of a manual integration reload without +# going through HA's config_entry teardown. Resets after every rebuild attempt so the cadence holds +# throughout extended outages. +MQTT_FULL_REBUILD_AFTER_FAILURES = 3 + # Lugs direction values LUGS_UPSTREAM = "UPSTREAM" LUGS_DOWNSTREAM = "DOWNSTREAM" diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 7536348..8ac7371 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -14,10 +14,10 @@ from paho.mqtt.client import ConnectFlags, DisconnectFlags, MQTTMessage from paho.mqtt.reasoncodes import ReasonCode -from span_panel_api.exceptions import SpanPanelConnectionError +from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelConnectionError from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge -from span_panel_api.mqtt.const import MQTT_RECONNECT_MIN_DELAY_S +from span_panel_api.mqtt.const import MQTT_FULL_REBUILD_AFTER_FAILURES, MQTT_RECONNECT_MIN_DELAY_S from span_panel_api.mqtt.models import MqttClientConfig from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL @@ -417,3 +417,541 @@ async def test_reconnect_resubscribes(self, mqtt_client_mock: MagicMock) -> None mqtt_client_mock.subscribe.assert_called_once() await client.close() + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — rebuild path (CA refresh / stale-state recovery) +# --------------------------------------------------------------------------- + + +async def _trigger_reconnect_loop(bridge: AsyncMqttBridge, mqtt_client_mock: MagicMock) -> None: + """Drive the bridge into its reconnect loop via an _on_disconnect edge.""" + bridge._on_disconnect( + mqtt_client_mock, + None, + DisconnectFlags(is_disconnect_packet_from_server=True), + ReasonCode(packetType=2, aName="Success"), + None, + ) + assert bridge._reconnect_task is not None + + +class TestBridgeReconnectRebuild: + """Verify the reconnect loop's CA-refresh / client-rebuild path.""" + + @pytest.mark.asyncio + async def test_ssl_error_triggers_immediate_rebuild( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A single ssl.SSLError on reconnect should fire a rebuild without + waiting for the OSError threshold.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + # CA fetch count from initial connect — verify subsequent rebuild + # increments it. + from span_panel_api.mqtt import connection as conn_mod + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + mqtt_client_mock.reconnect.side_effect = [ssl.SSLError("verify failed"), 0] + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.2) + + # Rebuild fetched a fresh CA exactly once. + assert conn_mod.download_ca_cert.call_count == download_calls_before + 1 # type: ignore[attr-defined] + # Old client got disconnected during rebuild. + mqtt_client_mock.disconnect.assert_called() + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_oserror_threshold_triggers_rebuild( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Repeated OSError reconnect failures fire rebuild only after the + configured threshold, not on the first or second failure.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + # Three OSErrors, then success on the fourth call. + mqtt_client_mock.reconnect.side_effect = [OSError("EOF"), OSError("EOF"), OSError("EOF"), 0] + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + # Allow several loop iterations. + await asyncio.sleep(0.6) + + # Rebuild fired exactly once across the threshold-many failures. + assert conn_mod.download_ca_cert.call_count == download_calls_before + 1 # type: ignore[attr-defined] + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_repeated_ssl_errors_each_trigger_rebuild( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """SSL errors are not throttled by a once-per-outage flag — each + independent SSL failure triggers its own rebuild attempt.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + # Sustained outage: every reconnect raises SSL, AND the rebuild's + # initial connect also fails (panel unreachable at connect time). + # This keeps the loop running so multiple SSL errors can accumulate. + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + mqtt_client_mock.connect.side_effect = OSError("connection refused") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.3) + + # Each SSL error fires its own rebuild attempt (counted via CA fetch). + # Lower bound 2 because with min_delay=0.01 and 0.3s window we get + # plenty of iterations; assert > 1 to prove the no-throttle behavior. + rebuilds = conn_mod.download_ca_cert.call_count - download_calls_before # type: ignore[attr-defined] + assert rebuilds >= 3, f"expected each SSL to trigger rebuild, got {rebuilds}" + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_extended_outage_cadence(self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + """During an extended OSError outage, rebuilds keep firing every + MQTT_FULL_REBUILD_AFTER_FAILURES failures — not just once per outage.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.005) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + # Sustained outage: every reconnect raises OSError and the rebuild's + # initial connect also fails (panel unreachable throughout). + mqtt_client_mock.reconnect.side_effect = OSError("EOF") + mqtt_client_mock.connect.side_effect = OSError("connection refused") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.5) + + # We expect at least 2 rebuilds (across 3*2 = 6+ failures). + rebuilds = conn_mod.download_ca_cert.call_count - download_calls_before # type: ignore[attr-defined] + assert rebuilds >= 2, f"expected >=2 rebuilds during extended outage, got {rebuilds}" + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_failed_rebuild_preserves_old_client( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If download_ca_cert raises SpanPanelAPIError (e.g. HTTP 502), + the rebuild bails out and the previous paho client is preserved.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + original_client = bridge._client + assert original_client is not None + + from span_panel_api.mqtt import connection as conn_mod + + # CA endpoint returns 502 — rebuild must not crash the loop. + monkeypatch.setattr(conn_mod, "download_ca_cert", AsyncMock(side_effect=SpanPanelAPIError("HTTP 502"))) + + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.1) + + # The old client reference is preserved — rebuild failed before tearing it down. + assert bridge._client is original_client + # Bridge teardown intent stays consistent — reconnect loop did not die. + assert bridge._should_reconnect is True + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_failed_rebuild_resets_counter(self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + """After a failed rebuild attempt, the counter resets so the next + rebuild fires only after another threshold-many failures, not on + the immediate next iteration.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + # First two CA fetches raise 502, then succeed. + ca_mock = AsyncMock( + side_effect=[ + SpanPanelAPIError("HTTP 502"), + SpanPanelAPIError("HTTP 502"), + "FAKE-PEM", + ] + ) + monkeypatch.setattr(conn_mod, "download_ca_cert", ca_mock) + + # Drive a stream of OSErrors. The threshold should fire rebuild every + # MQTT_FULL_REBUILD_AFTER_FAILURES failures, and each attempt — even + # if it fails at CA fetch — must reset the counter so we don't try + # again on the very next iteration. Rebuild's connect must also fail + # so the third (successful) CA fetch doesn't end the outage. + mqtt_client_mock.reconnect.side_effect = OSError("EOF") + mqtt_client_mock.connect.side_effect = OSError("connection refused") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.5) + + # We should see at most one rebuild attempt per + # MQTT_FULL_REBUILD_AFTER_FAILURES iterations — not one per iteration. + # With ~50 iterations available in 0.5s and threshold=3, we expect + # roughly 16 rebuild attempts maximum, definitely not 50. + attempts = ca_mock.call_count + max_iterations = int(0.5 / 0.01) + assert ( + attempts <= max_iterations // MQTT_FULL_REBUILD_AFTER_FAILURES + 2 + ), f"expected throttling — got {attempts} rebuild attempts in {max_iterations} iterations" + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_no_ca_fetch_when_tls_disabled(self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + """Non-TLS bridges skip the CA fetch entirely on rebuild but still + rebuild the paho client (covering the stale-paho-state case).""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = AsyncMqttBridge( + host="broker.local", + port=1883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + use_tls=False, + ) + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + # SSL error wouldn't happen on a plain-TCP bridge, but the threshold + # path still fires on persistent OSError. Rebuild's connect also + # fails to extend the outage. + mqtt_client_mock.reconnect.side_effect = OSError("EOF") + mqtt_client_mock.connect.side_effect = OSError("connection refused") + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.2) + + # CA never fetched on a non-TLS bridge, even though the rebuild path ran. + assert conn_mod.download_ca_cert.call_count == download_calls_before # type: ignore[attr-defined] + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_pre_rebuild_callback_fires_before_old_client_torn_down( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The pre-rebuild callback must fire before the bridge calls + disconnect() on the old paho client, so SpanMqttClient can reset + its accumulator while the original client is still wired.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + observed_order: list[str] = [] + + def pre_rebuild_hook() -> None: + # mqtt_client_mock.disconnect is the old-client teardown call. + observed_order.append( + "pre_rebuild_then_disconnect" + if mqtt_client_mock.disconnect.call_count == 0 + else "pre_rebuild_after_disconnect" + ) + + bridge.set_pre_rebuild_callback(pre_rebuild_hook) + + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.1) + + assert observed_order == ["pre_rebuild_then_disconnect"] + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_pre_rebuild_callback_exception_does_not_break_rebuild( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A misbehaving pre-rebuild callback must not abort the rebuild.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + bridge.set_pre_rebuild_callback(lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + + from span_panel_api.mqtt import connection as conn_mod + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.1) + + # Rebuild proceeded despite callback raising. + assert conn_mod.download_ca_cert.call_count == download_calls_before + 1 # type: ignore[attr-defined] + + await bridge.disconnect() + + +# --------------------------------------------------------------------------- +# SpanMqttClient — accumulator reset on bridge rebuild +# --------------------------------------------------------------------------- + + +class TestSpanMqttClientAccumulatorReset: + """Verify the pre-rebuild hook resets Homie state while preserving schema.""" + + @pytest.mark.asyncio + async def test_pre_rebuild_resets_accumulator(self, mqtt_client_mock: MagicMock) -> None: + """`_on_pre_rebuild` replaces accumulator and consumer with fresh instances.""" + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + original_accumulator = client._accumulator + original_homie = client._homie + assert original_accumulator is not None + assert original_homie is not None + # Accumulator is in a ready-ish state from the simulated Homie messages. + assert original_homie.is_ready() is True + + # Trigger the pre-rebuild hook directly — same call the bridge makes. + client._on_pre_rebuild() + + # New accumulator / consumer instances, fresh state. + assert client._accumulator is not original_accumulator + assert client._homie is not original_homie + assert client._homie is not None + assert client._homie.is_ready() is False + + await client.close() + + @pytest.mark.asyncio + async def test_pre_rebuild_preserves_schema_state(self, mqtt_client_mock: MagicMock) -> None: + """Schema-derived state must survive across pre-rebuild — schema cannot change in-session.""" + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + schema_hash_before = client._schema_hash + schema_types_before = client._previous_schema_types + field_metadata_before = client._field_metadata + panel_size_before = client._panel_size + + client._on_pre_rebuild() + + assert client._schema_hash == schema_hash_before + assert client._previous_schema_types == schema_types_before + assert client._field_metadata == field_metadata_before + assert client._panel_size == panel_size_before + + await client.close() + + @pytest.mark.asyncio + async def test_pre_rebuild_before_connect_is_noop(self) -> None: + """If pre-rebuild somehow fires before connect() completes, the + handler must not raise — there is no accumulator state to reset.""" + client = _make_span_client() + # _panel_size is None because connect() never ran. + client._on_pre_rebuild() + # No exception, no state changes. + assert client._accumulator is None + assert client._homie is None + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — rebuild path: hardening / edge cases +# --------------------------------------------------------------------------- + + +class TestBridgeRebuildHardening: + """Edge-case coverage that guards against the reconnect task dying.""" + + @pytest.mark.asyncio + async def test_rebuild_returns_false_when_loop_is_none(self, mqtt_client_mock: MagicMock) -> None: + """_rebuild_client must short-circuit if the bridge has no loop yet + (e.g., called against a freshly-constructed but never-connected bridge).""" + bridge = _make_bridge() + # Skip connect() — bridge._loop is None. + result = await bridge._rebuild_client() + assert result is False + # No side effects: no client, no CA fetch, no warnings. + assert bridge._client is None + + @pytest.mark.asyncio + async def test_make_paho_client_raising_does_not_kill_loop( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If _make_paho_client raises during rebuild, the reconnect loop + must survive — the broad exception catch returns False so the + outer loop keeps spinning across multiple rebuild attempts.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + # _make_paho_client always raises during this outage — simulates an + # unexpected paho construction failure that would otherwise leak. + def always_boom(ssl_context: ssl.SSLContext | None) -> object: + raise RuntimeError("simulated paho construction failure") + + monkeypatch.setattr(bridge, "_make_paho_client", always_boom) + + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.15) + + # The loop survived multiple iterations of (SSL error → rebuild attempt → + # _make_paho_client raises). If the broad catch were missing, the very + # first failure would have killed the task and download_ca_cert would + # have been called exactly once. We expect at least 2 attempts. + rebuild_attempts = conn_mod.download_ca_cert.call_count - download_calls_before # type: ignore[attr-defined] + assert rebuild_attempts >= 2, f"reconnect loop died after _make_paho_client error: only {rebuild_attempts} attempts" + # Task is still alive and bridge teardown semantics intact. + assert bridge._reconnect_task is not None + assert not bridge._reconnect_task.done(), "reconnect loop died on _make_paho_client error" + assert bridge._should_reconnect is True + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_unknown_exception_does_not_trigger_rebuild( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The design explicitly says unknown exceptions in the reconnect path + must NOT trigger a rebuild — recovery actions should not be applied + to error classes whose effect we cannot predict.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + download_calls_before = conn_mod.download_ca_cert.call_count # type: ignore[attr-defined] + + # A non-OSError, non-SSL exception falls through to the unknown branch. + class WeirdProtocolError(Exception): + pass + + mqtt_client_mock.reconnect.side_effect = WeirdProtocolError("???") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.2) + + # Many failures should have accumulated but no rebuild fires — + # download_ca_cert call count is unchanged. + assert conn_mod.download_ca_cert.call_count == download_calls_before # type: ignore[attr-defined] + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_pre_rebuild_callback_not_fired_when_ca_fetch_fails( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If the CA fetch fails, the rebuild returns False *before* firing + the pre-rebuild callback. The accumulator should not be reset for a + rebuild that never happened.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + + from span_panel_api.mqtt import connection as conn_mod + + callback_fired = {"n": 0} + + def pre_rebuild_hook() -> None: + callback_fired["n"] += 1 + + bridge.set_pre_rebuild_callback(pre_rebuild_hook) + + # CA fetch fails for all attempts during this outage. + monkeypatch.setattr(conn_mod, "download_ca_cert", AsyncMock(side_effect=SpanPanelAPIError("HTTP 502"))) + + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.15) + + # Pre-rebuild callback must not fire — the rebuild bailed at CA fetch. + assert callback_fired["n"] == 0 + + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_client_assigned_before_executor_await( + self, mqtt_client_mock: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The bridge's `_client` reference must point at the new client + before the executor await — so a CONNACK arriving during the await + sees the right client when callbacks dispatch to bridge.subscribe.""" + monkeypatch.setattr("span_panel_api.mqtt.connection.MQTT_RECONNECT_MIN_DELAY_S", 0.01) + bridge = _make_bridge() + await bridge.connect() + original_client = bridge._client + + # Capture the state of self._client at the moment _blocking_connect runs. + # Since mqtt_client_mock is the same MagicMock instance for old and new + # client, we cannot distinguish identity — but we can confirm the + # assignment happens before the executor by checking that bridge._client + # is set when the mock's connect side_effect fires. + observed_client_at_connect: list[object | None] = [] + + original_connect = mqtt_client_mock.connect.side_effect + + def capturing_connect(*args: object, **kwargs: object) -> int: + observed_client_at_connect.append(bridge._client) + assert callable(original_connect) + return original_connect(*args, **kwargs) # type: ignore[no-any-return] + + mqtt_client_mock.connect.side_effect = capturing_connect + + mqtt_client_mock.reconnect.side_effect = ssl.SSLError("verify failed") + + await _trigger_reconnect_loop(bridge, mqtt_client_mock) + await asyncio.sleep(0.15) + + # The mock connect fired at least once with bridge._client already + # set (not None and not pointing somewhere else). + assert observed_client_at_connect, "rebuild path never invoked connect" + for observed in observed_client_at_connect: + assert observed is not None, "bridge._client was None at connect time" + + # After the rebuild, the original client reference is still the same + # mock (singleton behavior of MagicMock.return_value). + assert bridge._client is original_client # same MagicMock instance + + await bridge.disconnect() diff --git a/uv.lock b/uv.lock index 226372b..361c34d 100644 --- a/uv.lock +++ b/uv.lock @@ -130,31 +130,43 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8 wheels = [ { 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/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { 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/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, @@ -426,27 +438,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, @@ -1292,7 +1310,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "2.6.2" +version = "2.6.4" source = { editable = "." } dependencies = [ { name = "httpx" },