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" },