span-panel-api 3.0.0b6__tar.gz → 3.0.0b8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/CHANGELOG.md +22 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/PKG-INFO +1 -1
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/pyproject.toml +1 -1
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/auth.py +22 -2
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/factory.py +13 -3
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/client.py +48 -3
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_auth_and_homie_helpers.py +44 -2
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_redispatch_on_reconnect.py +66 -1
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_conformance.py +51 -12
- span_panel_api-3.0.0b8/tests/test_shared_http_client.py +121 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/.gitignore +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/LICENSE +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/README.md +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/README.md +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/conftest.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_adoption.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_packaging.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_public_api_unchanged.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_against_simulator.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_zero_adapter.py +0 -0
|
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
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).
|
|
6
6
|
|
|
7
|
+
## [3.0.0b8]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A panel answering `502` while it reboots no longer costs the automatic reload.** When a panel upgrades its firmware it drops MQTT, comes back, and serves HTTP a little later — and a booting device brings its network stack and reverse proxy up before
|
|
12
|
+
the application behind them, so the schema fetch is answered with `502` rather than refused. The retry that exists for exactly this handled "cannot reach" and "timed out" but not "answered, with 502", so the first attempt raised straight out of the loop,
|
|
13
|
+
out of the fire-and-forget task that called it, and the parser was never swapped. Caught on two Home Assistant instances watching one panel through the same live upgrade: both logged `Task exception was never retrieved`, both stayed on the old parser,
|
|
14
|
+
and neither recovered without a manual reload. `get_homie_schema` now raises `SpanPanelServerError` for any 5xx — "not ready yet", distinct from a 4xx that will not fix itself — and the retry treats it as retryable.
|
|
15
|
+
- **The wait is now the length of a real reboot.** Five attempts backing off to 8s gave up after about 23 seconds. The observed upgrade took four minutes from MQTT dropping to the broker returning, with HTTP still answering 502 at that point. Twelve
|
|
16
|
+
attempts backing off to 30s covers it.
|
|
17
|
+
- **Nothing escapes the redispatch task any more.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation — the failure the redispatch exists to prevent, reached by
|
|
18
|
+
another route. It is now logged at ERROR naming the consequence and the remedy, because a reload is the user's only move and nothing else was going to tell them.
|
|
19
|
+
|
|
20
|
+
## [3.0.0b7]
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **`SpanMqttClient` accepts an `httpx_client`, and so does `create_span_client`.** Four config-flow-facing entry points already took an injected client; the runtime path was the one that did not, so every schema read built a throwaway — including the
|
|
25
|
+
retry loop that runs during a firmware upgrade, which built one per attempt at exactly the moment the panel was mid-reboot. Optional and defaulted, so nothing outside Home Assistant changes. The ownership rule is the one the existing entry points already
|
|
26
|
+
state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given. Home Assistant's shared client carries httpx's default timeout rather than
|
|
27
|
+
this library's 10 s, and that is the caller exercising the policy it owns rather than a setting being lost.
|
|
28
|
+
|
|
7
29
|
## [3.0.0b6]
|
|
8
30
|
|
|
9
31
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.0b8
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -15,7 +15,13 @@ import uuid
|
|
|
15
15
|
import httpx
|
|
16
16
|
|
|
17
17
|
from ._http import _build_url, _get_client
|
|
18
|
-
from .exceptions import
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
SpanPanelAPIError,
|
|
20
|
+
SpanPanelAuthError,
|
|
21
|
+
SpanPanelConnectionError,
|
|
22
|
+
SpanPanelServerError,
|
|
23
|
+
SpanPanelTimeoutError,
|
|
24
|
+
)
|
|
19
25
|
from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
20
26
|
|
|
21
27
|
|
|
@@ -187,8 +193,22 @@ async def get_homie_schema(
|
|
|
187
193
|
except httpx.TimeoutException as exc:
|
|
188
194
|
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
|
|
189
195
|
|
|
196
|
+
if response.status_code >= 500:
|
|
197
|
+
# A rebooting panel answers 502 from its front end while the application
|
|
198
|
+
# behind it is still starting. That is "not ready yet", not "wrong" --
|
|
199
|
+
# and it is the ordinary shape of a firmware upgrade, because a device
|
|
200
|
+
# brings its network stack and proxy up before its application. Raised as
|
|
201
|
+
# a distinct class so a caller can retry it and fail fast on a 4xx, which
|
|
202
|
+
# will not fix itself.
|
|
203
|
+
raise SpanPanelServerError(
|
|
204
|
+
f"Panel not ready: HTTP {response.status_code} fetching the Homie schema",
|
|
205
|
+
status_code=response.status_code,
|
|
206
|
+
)
|
|
190
207
|
if response.status_code != 200:
|
|
191
|
-
raise SpanPanelAPIError(
|
|
208
|
+
raise SpanPanelAPIError(
|
|
209
|
+
f"Failed to fetch Homie schema: HTTP {response.status_code}",
|
|
210
|
+
status_code=response.status_code,
|
|
211
|
+
)
|
|
192
212
|
|
|
193
213
|
data: dict[str, object] = response.json()
|
|
194
214
|
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
11
12
|
|
|
12
13
|
from .adapters import resolve_adapter
|
|
13
14
|
from .auth import get_homie_schema, register_v2
|
|
@@ -17,6 +18,9 @@ from .exceptions import SpanPanelAuthError
|
|
|
17
18
|
from .mqtt.client import SpanMqttClient
|
|
18
19
|
from .mqtt.models import MqttClientConfig
|
|
19
20
|
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
20
24
|
_LOGGER = logging.getLogger(__name__)
|
|
21
25
|
|
|
22
26
|
_V2_CLIENT_NAME = "span-panel-api"
|
|
@@ -28,6 +32,7 @@ async def create_span_client(
|
|
|
28
32
|
mqtt_config: MqttClientConfig | None = None,
|
|
29
33
|
serial_number: str | None = None,
|
|
30
34
|
port: int = 80,
|
|
35
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
31
36
|
) -> SpanMqttClient:
|
|
32
37
|
"""Create a SPAN Panel MQTT client.
|
|
33
38
|
|
|
@@ -37,6 +42,10 @@ async def create_span_client(
|
|
|
37
42
|
mqtt_config: Pre-built MQTT broker configuration.
|
|
38
43
|
serial_number: Panel serial number (extracted from detection/registration if omitted).
|
|
39
44
|
port: HTTP port of the panel bootstrap API used for registration and detection.
|
|
45
|
+
httpx_client: Optional shared ``httpx.AsyncClient``, used for every request this
|
|
46
|
+
makes and handed to the client it builds. Not closed here; its timeouts and
|
|
47
|
+
limits are the caller's, which is why the per-call ``timeout`` defaults are
|
|
48
|
+
ignored when one is given.
|
|
40
49
|
|
|
41
50
|
Returns:
|
|
42
51
|
A connected-ready SpanMqttClient instance.
|
|
@@ -54,7 +63,7 @@ async def create_span_client(
|
|
|
54
63
|
if mqtt_config is None:
|
|
55
64
|
if passphrase is None:
|
|
56
65
|
raise SpanPanelAuthError("Neither mqtt_config nor passphrase provided")
|
|
57
|
-
auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port)
|
|
66
|
+
auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port, httpx_client=httpx_client)
|
|
58
67
|
mqtt_config = MqttClientConfig(
|
|
59
68
|
broker_host=auth_response.ebus_broker_host,
|
|
60
69
|
username=auth_response.ebus_broker_username,
|
|
@@ -68,7 +77,7 @@ async def create_span_client(
|
|
|
68
77
|
|
|
69
78
|
if serial_number is None:
|
|
70
79
|
# Try to detect from panel status
|
|
71
|
-
result = await detect_api_version(host, port=port)
|
|
80
|
+
result = await detect_api_version(host, port=port, httpx_client=httpx_client)
|
|
72
81
|
if result.status_info is not None:
|
|
73
82
|
serial_number = result.status_info.serial_number
|
|
74
83
|
|
|
@@ -80,7 +89,7 @@ async def create_span_client(
|
|
|
80
89
|
# flat-versus-parent/child signal, mirroring MQTT's `info/data-model-version`
|
|
81
90
|
# — so the parser is chosen before a single message is consumed, rather than
|
|
82
91
|
# a wrong parser being discovered by its output.
|
|
83
|
-
schema = await get_homie_schema(host, port=port)
|
|
92
|
+
schema = await get_homie_schema(host, port=port, httpx_client=httpx_client)
|
|
84
93
|
adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version)
|
|
85
94
|
# In a thread: resolution reads distribution metadata and imports the adapter
|
|
86
95
|
# package, and this is the first call in the process to do either. See
|
|
@@ -96,6 +105,7 @@ async def create_span_client(
|
|
|
96
105
|
data_model_version=schema.data_model_version,
|
|
97
106
|
schema_dispatch_reason=dispatch_reason,
|
|
98
107
|
schema=schema,
|
|
108
|
+
httpx_client=httpx_client,
|
|
99
109
|
)
|
|
100
110
|
await client.connect()
|
|
101
111
|
return client
|
|
@@ -13,6 +13,7 @@ import contextlib
|
|
|
13
13
|
from importlib.metadata import version
|
|
14
14
|
import logging
|
|
15
15
|
import time
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
16
17
|
|
|
17
18
|
from span_panel_api.schema_drift import log_schema_drift
|
|
18
19
|
|
|
@@ -34,6 +35,9 @@ from .connection import AsyncMqttBridge
|
|
|
34
35
|
from .const import MQTT_READY_TIMEOUT_S
|
|
35
36
|
from .models import MqttClientConfig
|
|
36
37
|
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
37
41
|
_LOGGER = logging.getLogger(__name__)
|
|
38
42
|
|
|
39
43
|
# How long to wait for circuit name properties after device ready.
|
|
@@ -74,6 +78,7 @@ class SpanMqttClient:
|
|
|
74
78
|
data_model_version: str | None = None,
|
|
75
79
|
schema_dispatch_reason: str | None = None,
|
|
76
80
|
schema: V2HomieSchema | None = None,
|
|
81
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
77
82
|
) -> None:
|
|
78
83
|
self._host = host
|
|
79
84
|
self._serial_number = serial_number
|
|
@@ -81,6 +86,12 @@ class SpanMqttClient:
|
|
|
81
86
|
self._snapshot_interval = snapshot_interval
|
|
82
87
|
self._panel_http_port = panel_http_port
|
|
83
88
|
self._adapter_factory = adapter_factory
|
|
89
|
+
# Shared by the caller, owned by the caller: never closed here, and its
|
|
90
|
+
# policy -- timeouts, limits, headers -- is whatever the caller set. That
|
|
91
|
+
# is the same rule the four config-flow entry points already state, and
|
|
92
|
+
# the reason this exists at all is that the runtime path was the one place
|
|
93
|
+
# left without it. See `_get_client`.
|
|
94
|
+
self._httpx_client = httpx_client
|
|
84
95
|
|
|
85
96
|
self._bridge: AsyncMqttBridge | None = None
|
|
86
97
|
self._adapter: SchemaAdapter | None = None
|
|
@@ -287,7 +298,11 @@ class SpanMqttClient:
|
|
|
287
298
|
# would be a second call to the same unauthenticated endpoint for a
|
|
288
299
|
# value that cannot have changed. A directly-constructed client has no
|
|
289
300
|
# schema yet, so it fetches here and dispatches in _build_adapter.
|
|
290
|
-
schema =
|
|
301
|
+
schema = (
|
|
302
|
+
self._schema
|
|
303
|
+
if self._schema is not None
|
|
304
|
+
else await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
|
|
305
|
+
)
|
|
291
306
|
self._schema = schema
|
|
292
307
|
await self._preload_adapter(schema)
|
|
293
308
|
adapter = self._build_adapter(schema)
|
|
@@ -807,8 +822,21 @@ class SpanMqttClient:
|
|
|
807
822
|
last: Exception | None = None
|
|
808
823
|
for _ in range(_REDISPATCH_RETRY_ATTEMPTS):
|
|
809
824
|
try:
|
|
810
|
-
return await get_homie_schema(self._host, port=self._panel_http_port)
|
|
811
|
-
except (
|
|
825
|
+
return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
|
|
826
|
+
except (
|
|
827
|
+
SpanPanelConnectionError,
|
|
828
|
+
SpanPanelTimeoutError,
|
|
829
|
+
# The third way HTTP lags the broker, and the one a real upgrade
|
|
830
|
+
# actually produced: the panel answers, with 502. Its front end is
|
|
831
|
+
# up while the application behind it is still starting, which is
|
|
832
|
+
# the ordinary order for a booting device. Omitting this meant the
|
|
833
|
+
# first attempt raised straight out of this loop, out of the
|
|
834
|
+
# fire-and-forget task that called it, and the parser was never
|
|
835
|
+
# swapped -- observed on two Home Assistant instances watching one
|
|
836
|
+
# panel through the same upgrade, neither of which recovered
|
|
837
|
+
# without a manual reload.
|
|
838
|
+
SpanPanelServerError,
|
|
839
|
+
) as exc:
|
|
812
840
|
last = exc
|
|
813
841
|
await asyncio.sleep(delay)
|
|
814
842
|
delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S)
|
|
@@ -847,6 +875,23 @@ class SpanMqttClient:
|
|
|
847
875
|
"""
|
|
848
876
|
try:
|
|
849
877
|
await self._redispatch_once()
|
|
878
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
879
|
+
# Nothing may escape here. This runs as a fire-and-forget task, so an
|
|
880
|
+
# escaping exception becomes "Task exception was never retrieved" in
|
|
881
|
+
# the log and the parser silently stays on the old generation --
|
|
882
|
+
# which is the failure this whole method exists to prevent, arrived at
|
|
883
|
+
# by a different route. That is not hypothetical: a 502 from a
|
|
884
|
+
# rebooting panel did exactly this on two live installs.
|
|
885
|
+
#
|
|
886
|
+
# Logged at ERROR with the consequence spelled out, because the user's
|
|
887
|
+
# remedy is a reload and nothing else will tell them so.
|
|
888
|
+
_LOGGER.error(
|
|
889
|
+
"Could not follow the panel's schema-generation change; the %r parser is "
|
|
890
|
+
"unchanged and its data will read as missing. Reload the integration once "
|
|
891
|
+
"the panel is fully back up.",
|
|
892
|
+
self._data_model_version,
|
|
893
|
+
exc_info=True,
|
|
894
|
+
)
|
|
850
895
|
finally:
|
|
851
896
|
# Released only when the swap is finished, not when the fetch is.
|
|
852
897
|
# Clearing it after the fetch left a window that the slowest step in
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
from unittest.mock import AsyncMock, patch
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
import pytest
|
|
@@ -11,7 +11,12 @@ import pytest
|
|
|
11
11
|
from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
|
|
12
12
|
from span_panel_api_schema_0.consumer import HomieDeviceConsumer, _parse_int
|
|
13
13
|
from span_panel_api.auth import _int, download_ca_cert, get_homie_schema
|
|
14
|
-
from span_panel_api.exceptions import
|
|
14
|
+
from span_panel_api.exceptions import (
|
|
15
|
+
SpanPanelAPIError,
|
|
16
|
+
SpanPanelConnectionError,
|
|
17
|
+
SpanPanelServerError,
|
|
18
|
+
SpanPanelTimeoutError,
|
|
19
|
+
)
|
|
15
20
|
|
|
16
21
|
# ---------------------------------------------------------------------------
|
|
17
22
|
# auth._int edge cases (lines 29-31)
|
|
@@ -34,6 +39,17 @@ class TestIntHelper:
|
|
|
34
39
|
# ---------------------------------------------------------------------------
|
|
35
40
|
|
|
36
41
|
|
|
42
|
+
def _mock_response(method: str, status_code: int) -> AsyncMock:
|
|
43
|
+
"""A client whose request completes and returns `status_code`."""
|
|
44
|
+
response = MagicMock()
|
|
45
|
+
response.status_code = status_code
|
|
46
|
+
mock = AsyncMock()
|
|
47
|
+
setattr(mock, method, AsyncMock(return_value=response))
|
|
48
|
+
mock.__aenter__ = AsyncMock(return_value=mock)
|
|
49
|
+
mock.__aexit__ = AsyncMock(return_value=False)
|
|
50
|
+
return mock
|
|
51
|
+
|
|
52
|
+
|
|
37
53
|
def _mock_client(method: str, side_effect: Exception) -> AsyncMock:
|
|
38
54
|
mock = AsyncMock()
|
|
39
55
|
setattr(mock, method, AsyncMock(side_effect=side_effect))
|
|
@@ -78,6 +94,32 @@ class TestGetHomieSchemaErrors:
|
|
|
78
94
|
with pytest.raises(SpanPanelTimeoutError):
|
|
79
95
|
await get_homie_schema("192.168.1.1")
|
|
80
96
|
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
|
99
|
+
async def test_a_server_status_is_not_ready_rather_than_wrong(self, status: int) -> None:
|
|
100
|
+
"""A rebooting panel answers from its front end while the app behind it starts.
|
|
101
|
+
|
|
102
|
+
Raised as `SpanPanelServerError` so a caller can tell "not yet" from
|
|
103
|
+
"no". The redispatch retry depends on this distinction: a live firmware
|
|
104
|
+
upgrade produced 502 here, the retry loop did not catch the general
|
|
105
|
+
`SpanPanelAPIError` it used to be, and the parser was never swapped.
|
|
106
|
+
"""
|
|
107
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as cls:
|
|
108
|
+
cls.return_value = _mock_response("get", status)
|
|
109
|
+
with pytest.raises(SpanPanelServerError) as caught:
|
|
110
|
+
await get_homie_schema("192.168.1.1")
|
|
111
|
+
assert caught.value.status_code == status
|
|
112
|
+
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
@pytest.mark.parametrize("status", [401, 404])
|
|
115
|
+
async def test_a_client_status_is_not_retryable(self, status: int) -> None:
|
|
116
|
+
"""These do not fix themselves, so they must not look like "not ready yet"."""
|
|
117
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as cls:
|
|
118
|
+
cls.return_value = _mock_response("get", status)
|
|
119
|
+
with pytest.raises(SpanPanelAPIError) as caught:
|
|
120
|
+
await get_homie_schema("192.168.1.1")
|
|
121
|
+
assert not isinstance(caught.value, SpanPanelServerError)
|
|
122
|
+
|
|
81
123
|
|
|
82
124
|
# ---------------------------------------------------------------------------
|
|
83
125
|
# homie._parse_int failure path (lines 51-52)
|
|
@@ -30,7 +30,7 @@ from unittest.mock import patch
|
|
|
30
30
|
|
|
31
31
|
import pytest
|
|
32
32
|
|
|
33
|
-
from span_panel_api.exceptions import SpanPanelConnectionError
|
|
33
|
+
from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError
|
|
34
34
|
from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient
|
|
35
35
|
from span_panel_api.mqtt.models import MqttClientConfig
|
|
36
36
|
|
|
@@ -299,3 +299,68 @@ async def test_a_raising_consumer_does_not_break_the_swap() -> None:
|
|
|
299
299
|
|
|
300
300
|
assert client.data_model_version == "1.0", "the swap must stand"
|
|
301
301
|
assert reached == ["second"], "one raising subscriber must not starve the others"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.asyncio
|
|
305
|
+
async def test_a_rebooting_panel_answering_502_is_waited_for_not_abandoned() -> None:
|
|
306
|
+
"""The failure that cost a live firmware upgrade its automatic reload.
|
|
307
|
+
|
|
308
|
+
A panel accepts MQTT before it serves HTTP, and the retry loop above exists
|
|
309
|
+
for that. But there are three ways HTTP lags the broker, and this loop
|
|
310
|
+
originally handled two: it caught "cannot reach" and "timed out" and not
|
|
311
|
+
"answered, with 502". A booting device brings its network stack and reverse
|
|
312
|
+
proxy up before the application behind them, so 502 is the *ordinary* shape,
|
|
313
|
+
not an exotic one.
|
|
314
|
+
|
|
315
|
+
Because `SpanPanelServerError` was not caught, the very first attempt raised
|
|
316
|
+
straight out of the loop, out of the fire-and-forget task that called it, and
|
|
317
|
+
the parser was never swapped. Observed on two Home Assistant instances
|
|
318
|
+
watching one panel through the same upgrade: both logged `Task exception was
|
|
319
|
+
never retrieved`, both stayed on the flat parser, and neither recovered
|
|
320
|
+
without a manual reload.
|
|
321
|
+
"""
|
|
322
|
+
client, _ = _client(None)
|
|
323
|
+
before = client.adapter
|
|
324
|
+
attempts = 0
|
|
325
|
+
|
|
326
|
+
def _five_oh_two_then_ready(*_a: object, **_k: object) -> _Schema:
|
|
327
|
+
nonlocal attempts
|
|
328
|
+
attempts += 1
|
|
329
|
+
if attempts < 3:
|
|
330
|
+
raise SpanPanelServerError("Panel not ready: HTTP 502 fetching the Homie schema", 502)
|
|
331
|
+
return _Schema("1.0")
|
|
332
|
+
|
|
333
|
+
with (
|
|
334
|
+
patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_five_oh_two_then_ready),
|
|
335
|
+
patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0),
|
|
336
|
+
patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0),
|
|
337
|
+
):
|
|
338
|
+
await _panel_publishes_version(client, "1.0")
|
|
339
|
+
|
|
340
|
+
assert attempts >= 3, "a 502 must be retried rather than ending the attempt"
|
|
341
|
+
assert client.adapter is not before, "the parser must swap once the panel answers"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.mark.asyncio
|
|
345
|
+
async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_traceback(
|
|
346
|
+
caplog: pytest.LogCaptureFixture,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Nothing may escape the fire-and-forget task.
|
|
349
|
+
|
|
350
|
+
An escaping exception surfaces as "Task exception was never retrieved" and
|
|
351
|
+
the parser silently stays on the old generation -- the exact failure this
|
|
352
|
+
method exists to prevent, reached by a different route. The user's remedy is
|
|
353
|
+
a reload, and nothing else is going to tell them so.
|
|
354
|
+
"""
|
|
355
|
+
client, _ = _client(None)
|
|
356
|
+
before = client.adapter
|
|
357
|
+
|
|
358
|
+
with patch(
|
|
359
|
+
"span_panel_api.mqtt.client.get_homie_schema",
|
|
360
|
+
side_effect=RuntimeError("something nobody predicted"),
|
|
361
|
+
):
|
|
362
|
+
await _panel_publishes_version(client, "1.0")
|
|
363
|
+
|
|
364
|
+
assert client.adapter is before
|
|
365
|
+
assert "Reload the integration" in caplog.text
|
|
366
|
+
assert "something nobody predicted" in caplog.text
|
|
@@ -37,6 +37,7 @@ import ast
|
|
|
37
37
|
import importlib
|
|
38
38
|
import json
|
|
39
39
|
import os
|
|
40
|
+
import subprocess
|
|
40
41
|
from pathlib import Path
|
|
41
42
|
import re
|
|
42
43
|
from typing import NoReturn
|
|
@@ -572,26 +573,64 @@ def test_nothing_is_recorded_as_unexercised_once_the_simulator_publishes_it() ->
|
|
|
572
573
|
|
|
573
574
|
|
|
574
575
|
def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None:
|
|
575
|
-
"""
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
576
|
+
"""Are the bytes we vendored the bytes we claim they are?
|
|
577
|
+
|
|
578
|
+
Read out of git **at `synced_commit`** rather than from the checkout's working
|
|
579
|
+
tree, so the answer does not depend on what that clone happens to be sitting
|
|
580
|
+
on. This used to read the working tree while its own docstring claimed
|
|
581
|
+
otherwise, and `synced_commit` appeared only in the failure message. That is
|
|
582
|
+
wrong three ways, and one of them is the dangerous one:
|
|
583
|
+
|
|
584
|
+
* it **fails** when the clone has moved *ahead* of the pin, which is ordinary
|
|
585
|
+
currency drift and not a defect here -- observed the day the specification
|
|
586
|
+
went to `power-flows` 0.3;
|
|
587
|
+
* it **fails spuriously** with the clone on an unrelated branch;
|
|
588
|
+
* it **passes falsely** with a clone itself stale at the pinned commit while
|
|
589
|
+
the specification has moved on.
|
|
590
|
+
|
|
591
|
+
**Integrity, deliberately not currency.** Whether upstream has moved past our
|
|
592
|
+
pin is a separate question whose answer is normally "yes, a little", and it
|
|
593
|
+
must not fail a build. Conflating the two is what made this unreliable.
|
|
594
|
+
Currency is not checked by anything automatic here, and wants a scheduled job
|
|
595
|
+
rather than a gate.
|
|
596
|
+
|
|
597
|
+
The upstream reference producer fixed the same defect in its own copy of this
|
|
598
|
+
check (`distribution-enclosure-simulator` #47), which is where the framing
|
|
599
|
+
comes from.
|
|
580
600
|
"""
|
|
581
601
|
spec = _checkout(
|
|
582
602
|
"EBUS_SPEC_DIR",
|
|
583
603
|
"a specification checkout to verify vendored bytes",
|
|
584
604
|
expect="capabilities",
|
|
585
605
|
)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
606
|
+
commit = _lock()["synced_commit"]
|
|
607
|
+
differing: list[str] = []
|
|
608
|
+
for path in sorted(_CATALOGS.glob("*.json")):
|
|
609
|
+
blob = subprocess.run(
|
|
610
|
+
["git", "-C", str(spec), "show", f"{commit}:capabilities/{path.name}"],
|
|
611
|
+
capture_output=True,
|
|
612
|
+
# Stripped, because `-C` does not beat them. Git hooks export `GIT_DIR`
|
|
613
|
+
# and `GIT_INDEX_FILE` pointing at the repository being committed to,
|
|
614
|
+
# and an exported `GIT_DIR` wins over directory discovery -- so under
|
|
615
|
+
# pre-commit this read the *consumer's* object store, could not find a
|
|
616
|
+
# specification commit there, and failed with a fetch instruction for a
|
|
617
|
+
# commit the clone already had. Caught by the hook that causes it.
|
|
618
|
+
env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")},
|
|
619
|
+
)
|
|
620
|
+
if blob.returncode != 0:
|
|
621
|
+
# A clone that cannot resolve the pin fails rather than skipping: a
|
|
622
|
+
# silent skip reads exactly like a pass on the one check that proves
|
|
623
|
+
# the vendored bytes are what the lockfile says.
|
|
624
|
+
pytest.fail(
|
|
625
|
+
f"{spec} cannot resolve {commit} (needed to read capabilities/{path.name}). "
|
|
626
|
+
f"Fetch it: git -C {spec} fetch origin {commit}"
|
|
627
|
+
)
|
|
628
|
+
if blob.stdout != path.read_bytes():
|
|
629
|
+
differing.append(path.name)
|
|
591
630
|
|
|
592
631
|
assert not differing, (
|
|
593
|
-
f"vendored catalogs differ from
|
|
594
|
-
"
|
|
632
|
+
f"vendored catalogs differ from the specification at {commit}: {differing}. "
|
|
633
|
+
"These are byte copies, so this is a vendoring defect rather than upstream having moved."
|
|
595
634
|
)
|
|
596
635
|
|
|
597
636
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The runtime path uses the caller's HTTP client, not one of its own.
|
|
2
|
+
|
|
3
|
+
Four config-flow-facing entry points already take an injected
|
|
4
|
+
`httpx.AsyncClient`; `SpanMqttClient` was the one runtime entry point without
|
|
5
|
+
it, so every schema fetch built a throwaway client -- including the retry loop
|
|
6
|
+
that runs during a firmware upgrade, which builds one per attempt at exactly the
|
|
7
|
+
moment the panel is mid-reboot.
|
|
8
|
+
|
|
9
|
+
Home Assistant is the caller that cares. It owns a shared client, closes it at
|
|
10
|
+
shutdown, and the integration's own `quality_scale.yaml` claims
|
|
11
|
+
`inject-websession: done` -- a claim that was true of the config flow and not of
|
|
12
|
+
anything that ran afterwards.
|
|
13
|
+
|
|
14
|
+
**Ownership is the whole contract.** A client handed in is never closed here, and
|
|
15
|
+
its policy is the caller's: timeouts, limits and headers are whatever the caller
|
|
16
|
+
configured, which is why the per-call `timeout` arguments are documented as
|
|
17
|
+
ignored when a client is injected. Home Assistant's shared client carries
|
|
18
|
+
httpx's default timeout rather than this library's, and that is the caller
|
|
19
|
+
exercising the policy it owns rather than a setting being lost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextlib
|
|
26
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from span_panel_api.mqtt import MqttClientConfig
|
|
31
|
+
from span_panel_api.mqtt.client import SpanMqttClient
|
|
32
|
+
|
|
33
|
+
SERIAL = "sp3-242424-001"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Schema:
|
|
37
|
+
def __init__(self, version: str | None) -> None:
|
|
38
|
+
self.data_model_version = version
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _client(injected: object | None) -> SpanMqttClient:
|
|
42
|
+
return SpanMqttClient(
|
|
43
|
+
host="192.168.1.1",
|
|
44
|
+
serial_number=SERIAL,
|
|
45
|
+
broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"),
|
|
46
|
+
httpx_client=injected, # type: ignore[arg-type]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_the_connect_fetch_uses_the_injected_client() -> None:
|
|
52
|
+
"""The first schema read of a session, and the one every install makes."""
|
|
53
|
+
sentinel = MagicMock(name="shared-client")
|
|
54
|
+
client = _client(sentinel)
|
|
55
|
+
|
|
56
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
57
|
+
with (
|
|
58
|
+
patch("span_panel_api.mqtt.client.get_homie_schema", fetch),
|
|
59
|
+
patch.object(client, "_preload_adapter", AsyncMock()),
|
|
60
|
+
patch.object(client, "_build_adapter", MagicMock()),
|
|
61
|
+
patch.object(client, "_connect_bridge", AsyncMock(), create=True),
|
|
62
|
+
):
|
|
63
|
+
# Connect goes on to bring up the MQTT bridge, which has nothing to do
|
|
64
|
+
# with this assertion and no broker to reach. The fetch is what is under
|
|
65
|
+
# test, and the await-count assertion below is what keeps that from
|
|
66
|
+
# passing vacuously if it never happened at all.
|
|
67
|
+
with contextlib.suppress(Exception):
|
|
68
|
+
await client.connect()
|
|
69
|
+
|
|
70
|
+
assert fetch.await_count >= 1
|
|
71
|
+
assert fetch.await_args.kwargs["httpx_client"] is sentinel
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_the_upgrade_refetch_uses_the_injected_client() -> None:
|
|
76
|
+
"""The path that mattered most, because it builds one client per retry attempt.
|
|
77
|
+
|
|
78
|
+
A panel accepts MQTT before it serves HTTP, so this loop can run several times
|
|
79
|
+
in a row while the panel finishes booting -- each one previously a fresh
|
|
80
|
+
client, a fresh connection pool, thrown away on the next attempt.
|
|
81
|
+
"""
|
|
82
|
+
sentinel = MagicMock(name="shared-client")
|
|
83
|
+
client = _client(sentinel)
|
|
84
|
+
client._loop = asyncio.get_running_loop()
|
|
85
|
+
|
|
86
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
87
|
+
with patch("span_panel_api.mqtt.client.get_homie_schema", fetch):
|
|
88
|
+
assert await client._fetch_schema_with_retry() is not None
|
|
89
|
+
|
|
90
|
+
assert fetch.await_args.kwargs["httpx_client"] is sentinel
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_an_injected_client_is_never_closed_here() -> None:
|
|
95
|
+
"""It belongs to the caller, and the caller may still be using it.
|
|
96
|
+
|
|
97
|
+
Home Assistant hands out one shared client to every integration and closes it
|
|
98
|
+
at shutdown; closing it from here would take the others down with it. HA
|
|
99
|
+
guards its own copy, but a library that relies on the caller guarding it is
|
|
100
|
+
relying on the caller.
|
|
101
|
+
"""
|
|
102
|
+
sentinel = MagicMock(name="shared-client")
|
|
103
|
+
sentinel.aclose = AsyncMock()
|
|
104
|
+
client = _client(sentinel)
|
|
105
|
+
|
|
106
|
+
await client.close()
|
|
107
|
+
|
|
108
|
+
sentinel.aclose.assert_not_awaited()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_no_injected_client_still_works() -> None:
|
|
113
|
+
"""The default has to stay the default: this library is not Home Assistant's alone."""
|
|
114
|
+
client = _client(None)
|
|
115
|
+
client._loop = asyncio.get_running_loop()
|
|
116
|
+
|
|
117
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
118
|
+
with patch("span_panel_api.mqtt.client.get_homie_schema", fetch):
|
|
119
|
+
assert await client._fetch_schema_with_retry() is not None
|
|
120
|
+
|
|
121
|
+
assert fetch.await_args.kwargs["httpx_client"] is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/README.md
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/src/span_panel_api/reference_payloads/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/panel.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/soe.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/simulation_fixtures/status.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_generation_cross_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_against_simulator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b6 → span_panel_api-3.0.0b8}/tests/test_schema_one_connection_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|