span-panel-api 3.1.0__tar.gz → 3.2.0__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.1.0 → span_panel_api-3.2.0}/.gitignore +0 -5
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/CHANGELOG.md +26 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/PKG-INFO +24 -8
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/README.md +23 -7
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/pyproject.toml +36 -4
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/__init__.py +4 -1
- span_panel_api-3.2.0/src/span_panel_api/_http.py +318 -0
- span_panel_api-3.2.0/src/span_panel_api/_ssl.py +223 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/auth.py +235 -174
- span_panel_api-3.2.0/src/span_panel_api/detection.py +96 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/models.py +29 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/protocol.py +6 -5
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/conftest.py +13 -7
- span_panel_api-3.2.0/tests/fixtures/panelbench_wire.json +684 -0
- span_panel_api-3.2.0/tests/fixtures/v2/README.md +14 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/README.md +27 -19
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/bootstrap.py +9 -4
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/schema_one.py +10 -5
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_adoption.py +25 -1
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_auth_redaction.py +86 -1
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_catalog_divergence.py +16 -38
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_homie.py +0 -14
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_packaging.py +34 -32
- span_panel_api-3.2.0/tests/test_plaintext_warning.py +241 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_public_api_unchanged.py +6 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_reference_tree_values.py +7 -7
- span_panel_api-3.2.0/tests/test_rest_transport_contract.py +188 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_migration_delta.py +11 -3
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_adapter.py +1 -1
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_circuits.py +43 -5
- span_panel_api-3.2.0/tests/test_schema_one_conformance.py +629 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_connection_health.py +2 -2
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_control_refusal.py +137 -41
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_discovery.py +1 -1
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_panel.py +11 -11
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_snapshot.py +4 -4
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_ssl_context.py +119 -1
- span_panel_api-3.2.0/tests/test_v2_status_parser.py +74 -0
- span_panel_api-3.1.0/src/span_panel_api/_http.py +0 -124
- span_panel_api-3.1.0/src/span_panel_api/_ssl.py +0 -104
- span_panel_api-3.1.0/src/span_panel_api/detection.py +0 -85
- span_panel_api-3.1.0/tests/fixtures/v2/README.md +0 -14
- span_panel_api-3.1.0/tests/reference_payloads/homie_schema.json +0 -420
- span_panel_api-3.1.0/tests/reference_payloads/parent_child_tree.json +0 -234
- span_panel_api-3.1.0/tests/test_schema_one_against_simulator.py +0 -260
- span_panel_api-3.1.0/tests/test_schema_one_conformance.py +0 -828
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/LICENSE +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.2.0}/tests/test_shared_http_client.py +0 -0
|
@@ -40,8 +40,3 @@ coverage_output.log
|
|
|
40
40
|
# of which belongs in a repository. The differential that reads them commits its
|
|
41
41
|
# *verdict* only, never the capture, and skips when the file is absent.
|
|
42
42
|
tests/fixtures/live_*.json
|
|
43
|
-
|
|
44
|
-
# Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so
|
|
45
|
-
# the provenance checks have something to compare vendored bytes against; the same
|
|
46
|
-
# layout works locally if you would rather not point .env at siblings.
|
|
47
|
-
/peers/
|
|
@@ -7,6 +7,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
7
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
|
|
8
8
|
beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
|
|
9
9
|
|
|
10
|
+
## [3.2.0]
|
|
11
|
+
|
|
12
|
+
A consumer pinned to a panel's CA cannot currently tell a panel that has moved from something impersonating one, because the two produce the same verification failure. This release splits the question.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`build_panel_ssl_context` takes `check_hostname`**, so a caller can verify that a peer holds a key the pinned CA signed without also asserting that the certificate names the address it was dialled by.
|
|
17
|
+
- **`leaf_names_host` decides the name binding on its own**, hand-written against `getpeercert()` because `ssl.match_hostname` was removed in Python 3.12, and stricter than that function was: no wildcards, no `commonName` fallback, and DNS and IP entries
|
|
18
|
+
that never stand in for one another.
|
|
19
|
+
|
|
20
|
+
## [3.1.1]
|
|
21
|
+
|
|
22
|
+
A follow-up to 3.1.0's security work, with no API change and no adapter move required.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **A rejected passphrase no longer reaches the debug log**, closing the shape of validation response that reports the field that failed in one place and the value it rejected in another, under a key that says nothing about what it holds.
|
|
27
|
+
- **A panel that fails part-way through answering is reported as unreachable**, rather than as an error from the HTTP layer that a consumer catching this library's own errors would not catch.
|
|
28
|
+
- **A response this library cannot read is reported as an API error naming the endpoint and the missing field**, instead of a raw parsing error raised out of the call.
|
|
29
|
+
- **`get_v2_status` reports whether the panel proved proximity**, which until now only the detection path had read, so the same panel answered differently depending on which call had asked.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **A warning when a panel's bootstrap traffic is unencrypted**, raised by the transport so that every call carrying a credential is covered, logged once per panel, never repeating the credential it warns about, and leaving plaintext the default it has
|
|
34
|
+
always been.
|
|
35
|
+
|
|
10
36
|
## [3.1.0]
|
|
11
37
|
|
|
12
38
|
A security release. Three things a caller could not previously find out — whether a control command was delivered, whether the panel's bootstrap traffic was encrypted, and whether the CA behind the MQTT broker is still the one that was there yesterday —
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
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
|
|
@@ -554,11 +554,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
554
554
|
|
|
555
555
|
## Reference Payloads
|
|
556
556
|
|
|
557
|
-
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree —
|
|
557
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in
|
|
558
|
+
[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
|
|
558
559
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
560
|
+
| Capture | Read from |
|
|
561
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
562
|
+
| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
|
|
563
|
+
| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
|
|
564
|
+
|
|
565
|
+
**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages:
|
|
566
|
+
|
|
567
|
+
```python
|
|
568
|
+
from importlib.resources import files
|
|
569
|
+
import json
|
|
570
|
+
|
|
571
|
+
schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data
|
|
575
|
+
files read through `importlib.resources`, not an import surface.
|
|
562
576
|
|
|
563
577
|
## Project Structure
|
|
564
578
|
|
|
@@ -589,13 +603,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
589
603
|
|
|
590
604
|
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
591
605
|
└── src/span_panel_api_schema_0/
|
|
592
|
-
|
|
606
|
+
├── reference/ # homie_schema.json — test-support package data, not read at runtime
|
|
607
|
+
└── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
593
608
|
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
594
609
|
|
|
595
610
|
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
596
|
-
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
611
|
+
├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
|
|
597
612
|
└── src/span_panel_api_schema_1/
|
|
598
|
-
|
|
613
|
+
├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
|
|
614
|
+
└── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
599
615
|
# adoption, catalog validator, spec_lock.json
|
|
600
616
|
```
|
|
601
617
|
|
|
@@ -527,11 +527,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
527
527
|
|
|
528
528
|
## Reference Payloads
|
|
529
529
|
|
|
530
|
-
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree —
|
|
530
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in
|
|
531
|
+
[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
|
|
531
532
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
533
|
+
| Capture | Read from |
|
|
534
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
535
|
+
| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
|
|
536
|
+
| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
|
|
537
|
+
|
|
538
|
+
**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages:
|
|
539
|
+
|
|
540
|
+
```python
|
|
541
|
+
from importlib.resources import files
|
|
542
|
+
import json
|
|
543
|
+
|
|
544
|
+
schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data
|
|
548
|
+
files read through `importlib.resources`, not an import surface.
|
|
535
549
|
|
|
536
550
|
## Project Structure
|
|
537
551
|
|
|
@@ -562,13 +576,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
562
576
|
|
|
563
577
|
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
564
578
|
└── src/span_panel_api_schema_0/
|
|
565
|
-
|
|
579
|
+
├── reference/ # homie_schema.json — test-support package data, not read at runtime
|
|
580
|
+
└── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
566
581
|
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
567
582
|
|
|
568
583
|
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
569
|
-
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
584
|
+
├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
|
|
570
585
|
└── src/span_panel_api_schema_1/
|
|
571
|
-
|
|
586
|
+
├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
|
|
587
|
+
└── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
572
588
|
# adoption, catalog validator, spec_lock.json
|
|
573
589
|
```
|
|
574
590
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.2.0"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -78,6 +78,12 @@ dev = [
|
|
|
78
78
|
# two distributions together, which is the configuration users will run.
|
|
79
79
|
"span-panel-api-schema-0",
|
|
80
80
|
"span-panel-api-schema-1",
|
|
81
|
+
# The producer of `span_panel_api_schema_1/reference/parent_child_tree.json`, pinned
|
|
82
|
+
# exactly because a reference capture is only evidence if what made it is
|
|
83
|
+
# known. Dependabot raises the bump on its own; the bump PR re-runs
|
|
84
|
+
# `scripts/capture_parent_child_reference.py` and the suite then says whether
|
|
85
|
+
# the wire moved.
|
|
86
|
+
"ebus-panel-sim==0.8.0",
|
|
81
87
|
"pytest>=9.0.2",
|
|
82
88
|
"pytest-asyncio>=1.3.0",
|
|
83
89
|
"pytest-cov",
|
|
@@ -102,7 +108,7 @@ dev = [
|
|
|
102
108
|
# transitive of `twine -> keyring -> secretstorage`, which is marked
|
|
103
109
|
# `sys_platform == 'linux'` -- so the whole module ran in CI and silently
|
|
104
110
|
# skipped on every macOS checkout, which reads in the summary line exactly
|
|
105
|
-
# like passing. See DEVELOPMENT.md, "A skip
|
|
111
|
+
# like passing. See DEVELOPMENT.md, "A skip is still not a pass".
|
|
106
112
|
#
|
|
107
113
|
# Floored at 50.0.0 rather than at whatever the lock happens to hold: four
|
|
108
114
|
# advisories cover the range below it (the highest first patched in 50.0.0),
|
|
@@ -153,8 +159,20 @@ include = [
|
|
|
153
159
|
|
|
154
160
|
[tool.ruff]
|
|
155
161
|
line-length = 125
|
|
162
|
+
# `scripts/` is excluded file by file rather than as a directory, so that a new
|
|
163
|
+
# script is linted by default and only the ones written before that was true are
|
|
164
|
+
# grandfathered out. `capture_parent_child_reference.py` is the one in scope: it
|
|
165
|
+
# produces a fixture the whole schema_1 suite is written against, and the rest are
|
|
166
|
+
# hand-run tools.
|
|
156
167
|
exclude = [
|
|
157
|
-
"scripts/",
|
|
168
|
+
"scripts/capture_flat_reference.py",
|
|
169
|
+
"scripts/capture_live_flat.py",
|
|
170
|
+
"scripts/coverage.py",
|
|
171
|
+
"scripts/format_markdown.py",
|
|
172
|
+
"scripts/test_live_auth.py",
|
|
173
|
+
"scripts/validate_lug_derivation/",
|
|
174
|
+
"scripts/verify_adapterless_install.py",
|
|
175
|
+
"scripts/verify_reconnect.py",
|
|
158
176
|
".*_cache/",
|
|
159
177
|
"dist/",
|
|
160
178
|
"venv/",
|
|
@@ -164,6 +182,10 @@ exclude = [
|
|
|
164
182
|
[tool.ruff.lint.per-file-ignores]
|
|
165
183
|
# Exclude tests from ALL linting checks (formatting still applies)
|
|
166
184
|
"tests/**/*.py" = ["ALL"]
|
|
185
|
+
# Its whole output is a report on stdout naming the producer, the device count and
|
|
186
|
+
# where the capture landed, which is what an operator reads to decide whether to
|
|
187
|
+
# adopt it. T20 is right for the library and wrong for a hand-run tool.
|
|
188
|
+
"scripts/capture_parent_child_reference.py" = ["T201"]
|
|
167
189
|
|
|
168
190
|
[tool.ruff.format]
|
|
169
191
|
quote-style = "double"
|
|
@@ -217,8 +239,18 @@ warn_unused_configs = true
|
|
|
217
239
|
disallow_untyped_defs = true
|
|
218
240
|
explicit_package_bases = true
|
|
219
241
|
mypy_path = "src"
|
|
242
|
+
# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
|
|
243
|
+
# checked like the library, and the hand-run tools written before that was expected
|
|
244
|
+
# are not.
|
|
220
245
|
exclude = [
|
|
221
|
-
"scripts/",
|
|
246
|
+
"scripts/capture_flat_reference.py",
|
|
247
|
+
"scripts/capture_live_flat.py",
|
|
248
|
+
"scripts/coverage.py",
|
|
249
|
+
"scripts/format_markdown.py",
|
|
250
|
+
"scripts/test_live_auth.py",
|
|
251
|
+
"scripts/validate_lug_derivation/",
|
|
252
|
+
"scripts/verify_adapterless_install.py",
|
|
253
|
+
"scripts/verify_reconnect.py",
|
|
222
254
|
"tests/",
|
|
223
255
|
"docs/",
|
|
224
256
|
".*_cache/",
|
|
@@ -6,7 +6,7 @@ supporting MQTT/Homie (v2) transport.
|
|
|
6
6
|
|
|
7
7
|
from importlib.metadata import version as _pkg_version
|
|
8
8
|
|
|
9
|
-
from ._ssl import build_panel_ssl_context, ca_fingerprint
|
|
9
|
+
from ._ssl import build_panel_ssl_context, ca_fingerprint, leaf_names_host
|
|
10
10
|
from .auth import (
|
|
11
11
|
delete_fqdn,
|
|
12
12
|
download_ca_cert,
|
|
@@ -154,6 +154,9 @@ __all__ = [ # noqa: RUF022
|
|
|
154
154
|
# both live here rather than being reimplemented on the other side.
|
|
155
155
|
"build_panel_ssl_context",
|
|
156
156
|
"ca_fingerprint",
|
|
157
|
+
# Added 2026-08-28: the hostname half of verification, split out so a
|
|
158
|
+
# caller using a relaxed context can still establish the name binding.
|
|
159
|
+
"leaf_names_host",
|
|
157
160
|
"delete_fqdn",
|
|
158
161
|
"download_ca_cert",
|
|
159
162
|
"get_fqdn",
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Shared HTTP helpers for SPAN Panel bootstrap REST calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import logging
|
|
10
|
+
import ssl
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
#: What a bootstrap URL resolves to when the caller names no port. HTTP without a
|
|
20
|
+
#: context, HTTPS with one -- so a caller that pins the panel CA and leaves the
|
|
21
|
+
#: port alone reaches the right place rather than the plaintext one.
|
|
22
|
+
DEFAULT_HTTP_PORT = 80
|
|
23
|
+
DEFAULT_HTTPS_PORT = 443
|
|
24
|
+
|
|
25
|
+
#: The one bootstrap path two modules request: the detector probes it to decide
|
|
26
|
+
#: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
|
|
27
|
+
#: for a caller that already knows it does. Named here rather than spelled out in
|
|
28
|
+
#: each, so the two cannot drift apart the way their parsers had.
|
|
29
|
+
V2_STATUS_PATH = "/api/v2/status"
|
|
30
|
+
|
|
31
|
+
#: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
|
|
32
|
+
#: through to `client.request()` so the dispatch below stays exhaustive and each
|
|
33
|
+
#: call still reaches the named httpx method.
|
|
34
|
+
type _Method = Literal["GET", "POST", "PUT", "DELETE"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _SSLCache:
|
|
39
|
+
"""Mutable container for the cached SSLContext and its async lock."""
|
|
40
|
+
|
|
41
|
+
context: ssl.SSLContext | None = None
|
|
42
|
+
lock: asyncio.Lock | None = field(default=None, repr=False)
|
|
43
|
+
|
|
44
|
+
def get_lock(self) -> asyncio.Lock:
|
|
45
|
+
"""Return the async lock, creating it lazily."""
|
|
46
|
+
if self.lock is None:
|
|
47
|
+
self.lock = asyncio.Lock()
|
|
48
|
+
return self.lock
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_ssl_cache = _SSLCache()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_url(host: str, port: int | None, path: str, ssl_context: ssl.SSLContext | None = None) -> str:
|
|
55
|
+
"""Build a bootstrap URL, choosing the scheme from whether a CA was supplied.
|
|
56
|
+
|
|
57
|
+
``port`` is ``None`` for "the caller did not say", which is the whole reason
|
|
58
|
+
it is nullable: with a plain ``int = 80`` there is no way to tell an omitted
|
|
59
|
+
port from one the caller deliberately set to 80, and the two need opposite
|
|
60
|
+
answers here.
|
|
61
|
+
|
|
62
|
+
An explicit ``port=80`` alongside an ``ssl_context`` is refused rather than
|
|
63
|
+
reinterpreted. It is not a hypothetical combination -- a consumer that
|
|
64
|
+
persisted a port before it pinned a CA produces exactly this on its first
|
|
65
|
+
HTTPS call -- and both readings are defensible: the caller may mean "HTTPS on
|
|
66
|
+
the unusual port 80" or may simply not have migrated the stored value.
|
|
67
|
+
Guessing either way is a security control that silently does something other
|
|
68
|
+
than what it was asked to.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
SpanPanelValidationError: ``ssl_context`` supplied with an explicit port 80.
|
|
72
|
+
"""
|
|
73
|
+
if ssl_context is None:
|
|
74
|
+
resolved = DEFAULT_HTTP_PORT if port is None else port
|
|
75
|
+
return f"http://{host}{path}" if resolved == DEFAULT_HTTP_PORT else f"http://{host}:{resolved}{path}"
|
|
76
|
+
|
|
77
|
+
if port == DEFAULT_HTTP_PORT:
|
|
78
|
+
raise SpanPanelValidationError(
|
|
79
|
+
f"port={DEFAULT_HTTP_PORT} was passed together with an ssl_context for {host}. "
|
|
80
|
+
f"Port {DEFAULT_HTTP_PORT} is the plaintext default and {DEFAULT_HTTPS_PORT} is the TLS one; "
|
|
81
|
+
"pass the panel's HTTPS port explicitly, or omit port to take the default."
|
|
82
|
+
)
|
|
83
|
+
resolved = DEFAULT_HTTPS_PORT if port is None else port
|
|
84
|
+
return f"https://{host}{path}" if resolved == DEFAULT_HTTPS_PORT else f"https://{host}:{resolved}{path}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _create_ssl_context() -> ssl.SSLContext:
|
|
88
|
+
"""Return a cached default SSL context, creating it in an executor on first call.
|
|
89
|
+
|
|
90
|
+
``ssl.create_default_context()`` calls ``load_verify_locations`` which
|
|
91
|
+
performs blocking file I/O on the system CA bundle. The resulting context
|
|
92
|
+
is thread-safe and reusable, so we cache it for the lifetime of the process.
|
|
93
|
+
"""
|
|
94
|
+
cached = _ssl_cache.context
|
|
95
|
+
if cached is not None:
|
|
96
|
+
return cached
|
|
97
|
+
async with _ssl_cache.get_lock():
|
|
98
|
+
# Double-check after acquiring the lock.
|
|
99
|
+
cached = _ssl_cache.context
|
|
100
|
+
if cached is not None:
|
|
101
|
+
return cached
|
|
102
|
+
# Read back through a local rather than returning the field again. The
|
|
103
|
+
# field is `SSLContext | None` and another task may clear or replace it
|
|
104
|
+
# between the assignment and the return, so returning it a second time
|
|
105
|
+
# is a read this function cannot promise is non-None -- which is what a
|
|
106
|
+
# strict checker objects to, correctly. The value that was just built is
|
|
107
|
+
# the value to hand back.
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
context = await loop.run_in_executor(None, ssl.create_default_context)
|
|
110
|
+
_ssl_cache.context = context
|
|
111
|
+
return context
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@asynccontextmanager
|
|
115
|
+
async def _get_client(
|
|
116
|
+
httpx_client: httpx.AsyncClient | None,
|
|
117
|
+
timeout: float,
|
|
118
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
119
|
+
) -> AsyncIterator[httpx.AsyncClient]:
|
|
120
|
+
"""Yield the client this call should use, honouring both arguments truthfully.
|
|
121
|
+
|
|
122
|
+
**A supplied ``ssl_context`` wins over an injected client, deliberately.**
|
|
123
|
+
httpx fixes ``verify=`` at construction, so a context cannot be applied to a
|
|
124
|
+
client somebody else built. This function used to yield an injected client
|
|
125
|
+
untouched, which meant a caller passing both would have got a plaintext-or-
|
|
126
|
+
system-trust connection while believing it had pinned the panel CA -- a
|
|
127
|
+
security control that appears to be on and is off. The only two honest
|
|
128
|
+
options are to refuse the combination or to build a dedicated client, and
|
|
129
|
+
building one keeps the pin working for the consumer that motivated it: Home
|
|
130
|
+
Assistant injects its shared client at every bootstrap call site.
|
|
131
|
+
|
|
132
|
+
The cost is named rather than hidden: these calls lose the shared connection
|
|
133
|
+
pool and the injected client's timeout and header policy, and take this
|
|
134
|
+
function's ``timeout`` instead. Acceptable because every caller here is
|
|
135
|
+
bootstrap -- registration, detection, schema, FQDN, status -- made a handful
|
|
136
|
+
of times per config entry, not a hot path. The injected client is never
|
|
137
|
+
closed by this function on any path; the dedicated one always is.
|
|
138
|
+
"""
|
|
139
|
+
if ssl_context is not None:
|
|
140
|
+
async with httpx.AsyncClient(timeout=timeout, verify=ssl_context) as client:
|
|
141
|
+
yield client
|
|
142
|
+
return
|
|
143
|
+
if httpx_client is not None:
|
|
144
|
+
yield httpx_client
|
|
145
|
+
return
|
|
146
|
+
ctx = await _create_ssl_context()
|
|
147
|
+
async with httpx.AsyncClient(timeout=timeout, verify=ctx) as client:
|
|
148
|
+
yield client
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: Panels already warned about over plaintext, so the warning is said once each.
|
|
152
|
+
#: Process-wide, once per panel host: wider than the MQTT bridge's unpinned-CA
|
|
153
|
+
#: warning, which is per bridge instance and so repeats when a config entry is
|
|
154
|
+
#: reloaded. See `_warn_plaintext_transport` for why the client object is the
|
|
155
|
+
#: wrong key.
|
|
156
|
+
_warned_plaintext_hosts: set[str] = set()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _reset_plaintext_warnings() -> None:
|
|
160
|
+
"""Test hook. Not public API."""
|
|
161
|
+
_warned_plaintext_hosts.clear()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
|
|
165
|
+
"""Say out loud, once per panel, that its bootstrap traffic is not encrypted.
|
|
166
|
+
|
|
167
|
+
In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
|
|
168
|
+
reason: a security property that is off by default is only a decision if the
|
|
169
|
+
operator can tell it is off. ``ssl_context=None`` puts the request on
|
|
170
|
+
plaintext ``http://``, and two of these calls carry credentials --
|
|
171
|
+
registration sends the panel passphrase and brings the broker password back,
|
|
172
|
+
and passphrase rotation sends a bearer token and brings the new broker
|
|
173
|
+
password back -- so anything on the path reads all of it.
|
|
174
|
+
|
|
175
|
+
**Called from the transport, not from the calls that bootstrap a client.**
|
|
176
|
+
Warning at the call sites meant each new call site had to remember to, and
|
|
177
|
+
passphrase rotation did not: the one call a consumer reaches for when
|
|
178
|
+
reauthenticating went out in the clear and said nothing. There is one
|
|
179
|
+
mechanism here so there is nothing to remember.
|
|
180
|
+
|
|
181
|
+
**Scoped to the panel, not to the request or the client object.** Per
|
|
182
|
+
request is a line somebody filters out. Per client object looks tighter and
|
|
183
|
+
is worse, because the CA download runs on every MQTT reconnect and builds a
|
|
184
|
+
fresh client each time -- so that key would produce a warning per reconnect,
|
|
185
|
+
which is precisely what the bridge's own once-per-bridge warning exists to
|
|
186
|
+
avoid. The panel is the thing the warning is actually about.
|
|
187
|
+
|
|
188
|
+
The credential itself is never named. This is a warning *about* a secret,
|
|
189
|
+
not a place to put one.
|
|
190
|
+
"""
|
|
191
|
+
if ssl_context is not None:
|
|
192
|
+
return
|
|
193
|
+
if host in _warned_plaintext_hosts:
|
|
194
|
+
return
|
|
195
|
+
_warned_plaintext_hosts.add(host)
|
|
196
|
+
_LOGGER.warning(
|
|
197
|
+
"Bootstrap traffic for %s is being sent over plaintext HTTP: no ssl_context was supplied, so "
|
|
198
|
+
"these requests and their responses -- including any credential they carry, such as the panel "
|
|
199
|
+
"passphrase and the broker password -- are readable by anything on the path between here and "
|
|
200
|
+
"the panel. Pin the panel's CA certificate and pass it as ssl_context.",
|
|
201
|
+
host,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class _Reply:
|
|
207
|
+
"""One panel answer, with the decoding every caller of it needs.
|
|
208
|
+
|
|
209
|
+
Status classification stays with the caller, because it is genuinely
|
|
210
|
+
per-endpoint: 412 means "no passphrase is set" on the rotation path and
|
|
211
|
+
nothing anywhere else, and 404 means "no FQDN configured" on one call and
|
|
212
|
+
"not a v2 panel" on another. The two steps *around* that classification are
|
|
213
|
+
the same everywhere and had been written out once per endpoint -- translating
|
|
214
|
+
a failed connection, and turning a body into an object with the fields the
|
|
215
|
+
caller is about to read. Both live here.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
host: str
|
|
219
|
+
endpoint: str
|
|
220
|
+
response: httpx.Response
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def status_code(self) -> int:
|
|
224
|
+
"""The status the panel answered with."""
|
|
225
|
+
return self.response.status_code
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def text(self) -> str:
|
|
229
|
+
"""The body as text, for the one endpoint that answers with a PEM."""
|
|
230
|
+
return self.response.text
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def headers(self) -> httpx.Headers:
|
|
234
|
+
"""The response headers, for ``Retry-After`` and content-type."""
|
|
235
|
+
return self.response.headers
|
|
236
|
+
|
|
237
|
+
def json_object(self, *required: str, on_malformed: type[SpanPanelAPIError] = SpanPanelAPIError) -> dict[str, object]:
|
|
238
|
+
"""Decode the body as a JSON object and confirm the fields about to be read.
|
|
239
|
+
|
|
240
|
+
A 200 is not a promise of a body. A panel part-way through starting
|
|
241
|
+
answers one with nothing in it; a proxy in front of one answers with an
|
|
242
|
+
HTML error page under a 200; and firmware is free to omit a field this
|
|
243
|
+
library treats as mandatory. Untranslated those surfaced as
|
|
244
|
+
``JSONDecodeError`` and ``KeyError`` -- neither of them a
|
|
245
|
+
``SpanPanelError``, so neither caught by a caller holding this library's
|
|
246
|
+
contract, and both escaping the retry clauses built on it.
|
|
247
|
+
|
|
248
|
+
``on_malformed`` exists for the one endpoint where an unreadable body is
|
|
249
|
+
"not ready yet" rather than "wrong": the schema fetch, whose caller
|
|
250
|
+
retries a booting panel. Every other endpoint here is asked once.
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
parsed = self.response.json()
|
|
254
|
+
except ValueError as exc:
|
|
255
|
+
raise on_malformed(
|
|
256
|
+
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} with a body that is not JSON",
|
|
257
|
+
status_code=self.status_code,
|
|
258
|
+
) from exc
|
|
259
|
+
if not isinstance(parsed, dict):
|
|
260
|
+
raise on_malformed(
|
|
261
|
+
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
|
|
262
|
+
f"with {type(parsed).__name__}, not a JSON object",
|
|
263
|
+
status_code=self.status_code,
|
|
264
|
+
)
|
|
265
|
+
body: dict[str, object] = parsed
|
|
266
|
+
missing = sorted(key for key in required if key not in body)
|
|
267
|
+
if missing:
|
|
268
|
+
raise on_malformed(
|
|
269
|
+
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
|
|
270
|
+
f"without the required field(s) {', '.join(missing)}",
|
|
271
|
+
status_code=self.status_code,
|
|
272
|
+
)
|
|
273
|
+
return body
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _request(
|
|
277
|
+
method: _Method,
|
|
278
|
+
host: str,
|
|
279
|
+
port: int | None,
|
|
280
|
+
path: str,
|
|
281
|
+
*,
|
|
282
|
+
timeout: float,
|
|
283
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
284
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
285
|
+
json: dict[str, str] | None = None,
|
|
286
|
+
headers: dict[str, str] | None = None,
|
|
287
|
+
) -> _Reply:
|
|
288
|
+
"""Make one bootstrap request and translate everything that is not an answer.
|
|
289
|
+
|
|
290
|
+
The whole ``httpx.TransportError`` family, not just a refused connect:
|
|
291
|
+
``ReadError`` and ``WriteError`` when a rebooting panel resets mid-request,
|
|
292
|
+
and ``RemoteProtocolError`` when its proxy closes without answering, which is
|
|
293
|
+
what a proxy restarting under load produces. ``TimeoutException`` is itself a
|
|
294
|
+
``TransportError``, so it has to be caught first to keep its own class.
|
|
295
|
+
|
|
296
|
+
The verb is dispatched to the named httpx method rather than handed to
|
|
297
|
+
``client.request()``: the URL stays the first positional argument of a
|
|
298
|
+
recognisable call, which is what makes an injected client inspectable by the
|
|
299
|
+
caller that supplied it.
|
|
300
|
+
"""
|
|
301
|
+
url = _build_url(host, port, path, ssl_context)
|
|
302
|
+
_warn_plaintext_transport(host, ssl_context)
|
|
303
|
+
try:
|
|
304
|
+
async with _get_client(httpx_client, timeout, ssl_context) as client:
|
|
305
|
+
match method:
|
|
306
|
+
case "GET":
|
|
307
|
+
response = await client.get(url, headers=headers)
|
|
308
|
+
case "POST":
|
|
309
|
+
response = await client.post(url, json=json, headers=headers)
|
|
310
|
+
case "PUT":
|
|
311
|
+
response = await client.put(url, json=json, headers=headers)
|
|
312
|
+
case "DELETE":
|
|
313
|
+
response = await client.delete(url, headers=headers)
|
|
314
|
+
except httpx.TimeoutException as exc:
|
|
315
|
+
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
|
|
316
|
+
except httpx.TransportError as exc:
|
|
317
|
+
raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
|
|
318
|
+
return _Reply(host=host, endpoint=path, response=response)
|