span-panel-api 3.0.0b6__tar.gz → 3.0.0b7__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.
Files changed (85) hide show
  1. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/CHANGELOG.md +9 -0
  2. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/PKG-INFO +1 -1
  3. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/pyproject.toml +1 -1
  4. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/factory.py +13 -3
  5. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/mqtt/client.py +17 -2
  6. span_panel_api-3.0.0b7/tests/test_shared_http_client.py +121 -0
  7. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/.gitignore +0 -0
  8. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/LICENSE +0 -0
  9. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/README.md +0 -0
  10. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/__init__.py +0 -0
  11. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/_http.py +0 -0
  12. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/adapters.py +0 -0
  13. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/auth.py +0 -0
  14. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/const.py +0 -0
  15. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/detection.py +0 -0
  16. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/dispatch.py +0 -0
  17. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/exceptions.py +0 -0
  18. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/models.py +0 -0
  19. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/mqtt/__init__.py +0 -0
  20. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/mqtt/async_client.py +0 -0
  21. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/mqtt/connection.py +0 -0
  22. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/mqtt/const.py +0 -0
  23. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/mqtt/models.py +0 -0
  24. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/phase_validation.py +0 -0
  25. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/protocol.py +0 -0
  26. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/py.typed +0 -0
  27. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/reference_payloads/README.md +0 -0
  28. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/reference_payloads/__init__.py +0 -0
  29. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
  30. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/src/span_panel_api/schema_drift.py +0 -0
  31. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/conftest.py +0 -0
  32. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  33. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  34. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  35. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/flat_wire.json +0 -0
  36. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  37. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/v2/README.md +0 -0
  38. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/fixtures/v2/status.json +0 -0
  39. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/simulation_fixtures/circuits.response.txt +0 -0
  40. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/simulation_fixtures/panel.response.txt +0 -0
  41. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/simulation_fixtures/soe.response.txt +0 -0
  42. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/simulation_fixtures/status.response.txt +0 -0
  43. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_accumulator.py +0 -0
  44. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_adapters_discovery.py +0 -0
  45. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_adopted_control.py +0 -0
  46. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_adoption.py +0 -0
  47. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_async_mqtt_client.py +0 -0
  48. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_auth_and_homie_helpers.py +0 -0
  49. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_catalog_divergence.py +0 -0
  50. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_detection_auth.py +0 -0
  51. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_exceptions.py +0 -0
  52. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_factory_dispatch.py +0 -0
  53. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_field_metadata.py +0 -0
  54. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_live_flat_differential.py +0 -0
  55. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_mqtt_bridge.py +0 -0
  56. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_mqtt_client_connection.py +0 -0
  57. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_mqtt_connect_flow.py +0 -0
  58. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_mqtt_debounce.py +0 -0
  59. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_mqtt_homie.py +0 -0
  60. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_packaging.py +0 -0
  61. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_phase_validation_configs.py +0 -0
  62. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_phase_validation_errors.py +0 -0
  63. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_protocol_conformance.py +0 -0
  64. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_protocol_models.py +0 -0
  65. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_public_api_unchanged.py +0 -0
  66. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_redispatch_on_reconnect.py +0 -0
  67. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_reference_tree_values.py +0 -0
  68. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_generation_cross_check.py +0 -0
  69. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_migration_delta.py +0 -0
  70. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_adapter.py +0 -0
  71. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_against_simulator.py +0 -0
  72. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_charge_limit.py +0 -0
  73. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_circuits.py +0 -0
  74. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_conformance.py +0 -0
  75. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_connection_health.py +0 -0
  76. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_devices.py +0 -0
  77. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_discovery.py +0 -0
  78. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_panel.py +0 -0
  79. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_pcs.py +0 -0
  80. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_service_entrance.py +0 -0
  81. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_shed_forecast.py +0 -0
  82. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_snapshot.py +0 -0
  83. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_one_transport.py +0 -0
  84. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_provenance.py +0 -0
  85. {span_panel_api-3.0.0b6 → span_panel_api-3.0.0b7}/tests/test_schema_zero_adapter.py +0 -0
@@ -4,6 +4,15 @@ 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.0b7]
8
+
9
+ ### Changed
10
+
11
+ - **`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
12
+ 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
13
+ 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
14
+ this library's 10 s, and that is the caller exercising the policy it owns rather than a setting being lost.
15
+
7
16
  ## [3.0.0b6]
8
17
 
9
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.0.0b6
3
+ Version: 3.0.0b7
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.0.0b6"
3
+ version = "3.0.0b7"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -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 = self._schema if self._schema is not None else await get_homie_schema(self._host, port=self._panel_http_port)
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,7 +822,7 @@ 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)
825
+ return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client)
811
826
  except (SpanPanelConnectionError, SpanPanelTimeoutError) as exc:
812
827
  last = exc
813
828
  await asyncio.sleep(delay)
@@ -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