vi-api-client 6.1.1__py3-none-any.whl
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.
- vi_api_client/__init__.py +74 -0
- vi_api_client/_adapter.py +321 -0
- vi_api_client/_feature_diff.py +250 -0
- vi_api_client/_types.py +12 -0
- vi_api_client/auth.py +365 -0
- vi_api_client/cli.py +1368 -0
- vi_api_client/client.py +1004 -0
- vi_api_client/const.py +20 -0
- vi_api_client/credentials.py +136 -0
- vi_api_client/exceptions.py +112 -0
- vi_api_client/fixture_client.py +169 -0
- vi_api_client/fixtures/Vitocal200S.json +3968 -0
- vi_api_client/fixtures/Vitocal222S.json +6258 -0
- vi_api_client/fixtures/Vitocal250A.json +6909 -0
- vi_api_client/fixtures/Vitocal333G-with-Vitovent300F.json +4833 -0
- vi_api_client/fixtures/Vitocharge03.json +896 -0
- vi_api_client/fixtures/Vitodens200W.json +4640 -0
- vi_api_client/fixtures/Vitopure350.json +1087 -0
- vi_api_client/fixtures/discovery.json +62 -0
- vi_api_client/fixtures/event_history.json +44 -0
- vi_api_client/fixtures/event_history_final_page.json +6 -0
- vi_api_client/models.py +536 -0
- vi_api_client/parsing.py +592 -0
- vi_api_client/privacy.py +172 -0
- vi_api_client/py.typed +1 -0
- vi_api_client/utils.py +138 -0
- vi_api_client/validation.py +52 -0
- vi_api_client-6.1.1.dist-info/METADATA +264 -0
- vi_api_client-6.1.1.dist-info/RECORD +33 -0
- vi_api_client-6.1.1.dist-info/WHEEL +5 -0
- vi_api_client-6.1.1.dist-info/entry_points.txt +2 -0
- vi_api_client-6.1.1.dist-info/licenses/LICENSE +21 -0
- vi_api_client-6.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Viessmann API Client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ._types import FeatureValue, JsonValue, ValidationDetail
|
|
6
|
+
from .auth import AbstractAuth, OAuth
|
|
7
|
+
from .client import ViClient
|
|
8
|
+
from .const import DEFAULT_SCOPES, ENDPOINT_AUTHORIZE, ENDPOINT_TOKEN
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
ViAuthError,
|
|
11
|
+
ViConnectionError,
|
|
12
|
+
ViError,
|
|
13
|
+
ViNotFoundError,
|
|
14
|
+
ViRateLimitError,
|
|
15
|
+
ViResponseError,
|
|
16
|
+
ViServerInternalError,
|
|
17
|
+
ViValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .fixture_client import FixtureViClient
|
|
20
|
+
from .models import (
|
|
21
|
+
CommandResponse,
|
|
22
|
+
Device,
|
|
23
|
+
EventHistoryPage,
|
|
24
|
+
Feature,
|
|
25
|
+
FeatureControl,
|
|
26
|
+
Gateway,
|
|
27
|
+
GatewayDeviceRefreshResult,
|
|
28
|
+
Installation,
|
|
29
|
+
InstallationEvent,
|
|
30
|
+
ScheduleConstraints,
|
|
31
|
+
)
|
|
32
|
+
from .privacy import redact_device, redact_feature, redact_sensitive
|
|
33
|
+
from .utils import format_feature
|
|
34
|
+
from .validation import validate_json_value
|
|
35
|
+
|
|
36
|
+
# Library best practice: consume the package logger without requiring
|
|
37
|
+
# consumer-side logging configuration.
|
|
38
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DEFAULT_SCOPES",
|
|
42
|
+
"ENDPOINT_AUTHORIZE",
|
|
43
|
+
"ENDPOINT_TOKEN",
|
|
44
|
+
"AbstractAuth",
|
|
45
|
+
"CommandResponse",
|
|
46
|
+
"Device",
|
|
47
|
+
"EventHistoryPage",
|
|
48
|
+
"Feature",
|
|
49
|
+
"FeatureControl",
|
|
50
|
+
"FeatureValue",
|
|
51
|
+
"FixtureViClient",
|
|
52
|
+
"Gateway",
|
|
53
|
+
"GatewayDeviceRefreshResult",
|
|
54
|
+
"Installation",
|
|
55
|
+
"InstallationEvent",
|
|
56
|
+
"JsonValue",
|
|
57
|
+
"OAuth",
|
|
58
|
+
"ScheduleConstraints",
|
|
59
|
+
"ValidationDetail",
|
|
60
|
+
"ViAuthError",
|
|
61
|
+
"ViClient",
|
|
62
|
+
"ViConnectionError",
|
|
63
|
+
"ViError",
|
|
64
|
+
"ViNotFoundError",
|
|
65
|
+
"ViRateLimitError",
|
|
66
|
+
"ViResponseError",
|
|
67
|
+
"ViServerInternalError",
|
|
68
|
+
"ViValidationError",
|
|
69
|
+
"format_feature",
|
|
70
|
+
"redact_device",
|
|
71
|
+
"redact_feature",
|
|
72
|
+
"redact_sensitive",
|
|
73
|
+
"validate_json_value",
|
|
74
|
+
]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Private API adapters for live and fixture-backed client workflows."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from typing import Any, Protocol, cast
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from ._types import JsonValue, ValidationDetail
|
|
14
|
+
from .auth import AbstractAuth
|
|
15
|
+
from .const import (
|
|
16
|
+
API_BASE_URL,
|
|
17
|
+
ENDPOINT_EVENT_HISTORY,
|
|
18
|
+
ENDPOINT_FEATURES,
|
|
19
|
+
ENDPOINT_GATEWAYS,
|
|
20
|
+
ENDPOINT_INSTALLATIONS,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
ViAuthError,
|
|
24
|
+
ViConnectionError,
|
|
25
|
+
ViError,
|
|
26
|
+
ViNotFoundError,
|
|
27
|
+
ViRateLimitError,
|
|
28
|
+
ViResponseError,
|
|
29
|
+
ViServerInternalError,
|
|
30
|
+
ViValidationError,
|
|
31
|
+
)
|
|
32
|
+
from .models import Device, FeatureControl
|
|
33
|
+
from .privacy import redact_sensitive
|
|
34
|
+
from .validation import validate_json_value
|
|
35
|
+
|
|
36
|
+
_LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Failures while reading a response body after its headers arrived: the
|
|
39
|
+
# request was not completed, unlike a complete body that is not valid JSON.
|
|
40
|
+
_BODY_READ_ERRORS = (
|
|
41
|
+
TimeoutError,
|
|
42
|
+
aiohttp.ClientConnectionError,
|
|
43
|
+
aiohttp.ClientPayloadError,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DiscoveryAdapter(Protocol):
|
|
48
|
+
"""Return raw API responses; `ViClient` turns them into models.
|
|
49
|
+
|
|
50
|
+
Kept separate from `CommandAdapter` so fixture adapters and test doubles
|
|
51
|
+
implement only the role they exercise; `LiveAdapter` fulfills both.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
async def get_installations(self) -> object: ...
|
|
55
|
+
|
|
56
|
+
async def get_gateways(self) -> object: ...
|
|
57
|
+
|
|
58
|
+
async def get_devices(
|
|
59
|
+
self, installation_id: str, gateway_serial: str
|
|
60
|
+
) -> object: ...
|
|
61
|
+
|
|
62
|
+
async def get_features(
|
|
63
|
+
self, device: Device, payload: dict[str, bool]
|
|
64
|
+
) -> object: ...
|
|
65
|
+
|
|
66
|
+
async def get_gateway_features(
|
|
67
|
+
self, installation_id: str, gateway_serial: str, payload: dict[str, bool]
|
|
68
|
+
) -> object: ...
|
|
69
|
+
|
|
70
|
+
async def get_event_history(
|
|
71
|
+
self, installation_id: str, params: dict[str, int | str]
|
|
72
|
+
) -> object: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CommandAdapter(Protocol):
|
|
76
|
+
"""Execute feature commands without constructing domain objects.
|
|
77
|
+
|
|
78
|
+
Kept separate from `DiscoveryAdapter` so command-focused test doubles do
|
|
79
|
+
not need to stub discovery methods.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
async def execute_command(
|
|
83
|
+
self, control: FeatureControl, parameters: dict[str, JsonValue]
|
|
84
|
+
) -> object: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class LiveAdapter:
|
|
88
|
+
"""Return raw live API responses through an authenticated request provider."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, auth: AbstractAuth) -> None:
|
|
91
|
+
"""Initialize the adapter with the request provider."""
|
|
92
|
+
self._auth = auth
|
|
93
|
+
|
|
94
|
+
async def get_installations(self) -> object:
|
|
95
|
+
"""Return the raw installations API response."""
|
|
96
|
+
return await self._request("GET", ENDPOINT_INSTALLATIONS)
|
|
97
|
+
|
|
98
|
+
async def get_gateways(self) -> object:
|
|
99
|
+
"""Return the raw gateways API response."""
|
|
100
|
+
return await self._request("GET", ENDPOINT_GATEWAYS)
|
|
101
|
+
|
|
102
|
+
async def get_devices(self, installation_id: str, gateway_serial: str) -> object:
|
|
103
|
+
"""Return the raw devices API response."""
|
|
104
|
+
return await self._request(
|
|
105
|
+
"GET",
|
|
106
|
+
f"{ENDPOINT_INSTALLATIONS}/{installation_id}/gateways/"
|
|
107
|
+
f"{gateway_serial}/devices",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def get_features(self, device: Device, payload: dict[str, bool]) -> object:
|
|
111
|
+
"""Return the raw feature API response for one device."""
|
|
112
|
+
return await self._request(
|
|
113
|
+
"POST",
|
|
114
|
+
f"{ENDPOINT_FEATURES}/{device.installation_id}/gateways/"
|
|
115
|
+
f"{device.gateway_serial}/devices/{device.id}/features/filter",
|
|
116
|
+
json=payload,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def get_gateway_features(
|
|
120
|
+
self, installation_id: str, gateway_serial: str, payload: dict[str, bool]
|
|
121
|
+
) -> object:
|
|
122
|
+
"""Return the raw gateway-scoped feature API response."""
|
|
123
|
+
return await self._request(
|
|
124
|
+
"POST",
|
|
125
|
+
f"{ENDPOINT_FEATURES}/{installation_id}/gateways/"
|
|
126
|
+
f"{gateway_serial}/features/filter",
|
|
127
|
+
json=payload,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def get_event_history(
|
|
131
|
+
self, installation_id: str, params: dict[str, int | str]
|
|
132
|
+
) -> object:
|
|
133
|
+
"""Return one raw event history API response."""
|
|
134
|
+
return await self._request(
|
|
135
|
+
"GET",
|
|
136
|
+
f"{ENDPOINT_EVENT_HISTORY}/{installation_id}/events",
|
|
137
|
+
params=params,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def execute_command(
|
|
141
|
+
self, control: FeatureControl, parameters: dict[str, JsonValue]
|
|
142
|
+
) -> object:
|
|
143
|
+
"""Return the raw command API response."""
|
|
144
|
+
return await self._request("POST", control.uri, json=parameters)
|
|
145
|
+
|
|
146
|
+
async def _request(
|
|
147
|
+
self,
|
|
148
|
+
method: str,
|
|
149
|
+
url: str,
|
|
150
|
+
*,
|
|
151
|
+
params: Mapping[str, int | str] | None = None,
|
|
152
|
+
json: Mapping[str, object] | None = None,
|
|
153
|
+
) -> object:
|
|
154
|
+
"""Return the decoded JSON body of a successful response.
|
|
155
|
+
|
|
156
|
+
The body is untrusted JSON of any shape; callers validate it.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ViConnectionError: If the response body cannot be read completely.
|
|
160
|
+
ViResponseError: If the URL is outside the Vi API or a successful
|
|
161
|
+
response is not valid JSON.
|
|
162
|
+
"""
|
|
163
|
+
full_url = _api_url(url)
|
|
164
|
+
_LOGGER.debug("Request: %s %s", method, redact_sensitive(full_url))
|
|
165
|
+
async with await self._auth.request(
|
|
166
|
+
method, full_url, params=params, json=json
|
|
167
|
+
) as response:
|
|
168
|
+
await _raise_for_status(response)
|
|
169
|
+
try:
|
|
170
|
+
return await response.json()
|
|
171
|
+
except _BODY_READ_ERRORS as error:
|
|
172
|
+
raise ViConnectionError(f"Network error: {error}") from error
|
|
173
|
+
except (aiohttp.ClientError, ValueError) as error:
|
|
174
|
+
raise ViResponseError(
|
|
175
|
+
"Successful API response was not valid JSON"
|
|
176
|
+
) from error
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _api_url(url: str) -> str:
|
|
180
|
+
"""Return the absolute Vi API URL for an endpoint path or command URI.
|
|
181
|
+
|
|
182
|
+
Command URIs come from API responses, and every request carries the
|
|
183
|
+
bearer token, so URLs outside the Vi API are refused rather than sent.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ViResponseError: If the URL does not resolve inside the Vi API.
|
|
187
|
+
"""
|
|
188
|
+
full_url = urljoin(API_BASE_URL, url)
|
|
189
|
+
if not full_url.startswith(f"{API_BASE_URL}/"):
|
|
190
|
+
raise ViResponseError(
|
|
191
|
+
f"Refusing request outside the Vi API: {redact_sensitive(full_url)}"
|
|
192
|
+
)
|
|
193
|
+
return full_url
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def _raise_for_status(response: aiohttp.ClientResponse) -> None:
|
|
197
|
+
"""Raise the matching library error for an unsuccessful API response.
|
|
198
|
+
|
|
199
|
+
The HTTP status decides the exception class. The JSON error body only
|
|
200
|
+
enriches it, so a missing or non-JSON body (such as a proxy error page)
|
|
201
|
+
falls back to HTTP-level defaults instead of masking the status.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
ViAuthError: For 401 and 403.
|
|
205
|
+
ViNotFoundError: For 404.
|
|
206
|
+
ViRateLimitError: For 429, with parsed Retry-After guidance.
|
|
207
|
+
ViValidationError: For 400 and 422, with validated details.
|
|
208
|
+
ViServerInternalError: For 5xx.
|
|
209
|
+
ViError: For any other unsuccessful status.
|
|
210
|
+
"""
|
|
211
|
+
status = response.status
|
|
212
|
+
if status < 400:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
error_message = f"HTTP {status}"
|
|
216
|
+
vi_error_id: str | None = None
|
|
217
|
+
error_type: str | None = None
|
|
218
|
+
validation_details: list[ValidationDetail] = []
|
|
219
|
+
try:
|
|
220
|
+
data = await response.json()
|
|
221
|
+
except TimeoutError, aiohttp.ClientError, ValueError:
|
|
222
|
+
data = None
|
|
223
|
+
if isinstance(data, dict):
|
|
224
|
+
# The untyped aiohttp JSON boundary yields an unknown container shape;
|
|
225
|
+
# each known error field is re-narrowed and validated below.
|
|
226
|
+
error_body = cast("dict[str, Any]", data)
|
|
227
|
+
vi_error_id = _text_or_none(error_body.get("viErrorId"))
|
|
228
|
+
error_type = _text_or_none(error_body.get("errorType"))
|
|
229
|
+
message = _text_or_none(error_body.get("message"))
|
|
230
|
+
if message is not None:
|
|
231
|
+
error_message = message
|
|
232
|
+
validation_details = _parse_validation_details(
|
|
233
|
+
error_body.get("validationErrors")
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# The raised library exception carries all error details, so the log
|
|
237
|
+
# stays at debug level to avoid double-reporting normal control flow.
|
|
238
|
+
_LOGGER.debug(
|
|
239
|
+
"API Error %s (%s): %s (ID: %s)",
|
|
240
|
+
status,
|
|
241
|
+
error_type,
|
|
242
|
+
error_message,
|
|
243
|
+
vi_error_id,
|
|
244
|
+
)
|
|
245
|
+
if status in (401, 403):
|
|
246
|
+
description = "Unauthorized" if status == 401 else "Forbidden"
|
|
247
|
+
raise ViAuthError(f"{description}: {error_message}", vi_error_id, error_type)
|
|
248
|
+
if status == 404:
|
|
249
|
+
raise ViNotFoundError(f"Not Found: {error_message}", vi_error_id, error_type)
|
|
250
|
+
if status == 429:
|
|
251
|
+
raise ViRateLimitError(
|
|
252
|
+
"Rate Limit Exceeded",
|
|
253
|
+
vi_error_id,
|
|
254
|
+
error_type,
|
|
255
|
+
retry_after=_parse_retry_after(response.headers.get("Retry-After")),
|
|
256
|
+
)
|
|
257
|
+
# Vi reports unreachable devices as 400 DEVICE_COMMUNICATION_ERROR;
|
|
258
|
+
# ViClient.refresh_gateway_devices relies on this mapping for its
|
|
259
|
+
# per-device fallback.
|
|
260
|
+
if status in (400, 422):
|
|
261
|
+
raise ViValidationError(
|
|
262
|
+
error_message, vi_error_id, validation_details, error_type
|
|
263
|
+
)
|
|
264
|
+
if status >= 500:
|
|
265
|
+
raise ViServerInternalError(
|
|
266
|
+
f"Server Error {status}: {error_message}", vi_error_id, error_type
|
|
267
|
+
)
|
|
268
|
+
raise ViError(f"Unknown Error {status}: {error_message}", vi_error_id, error_type)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _text_or_none(value: object) -> str | None:
|
|
272
|
+
"""Return the value if it is text, otherwise None."""
|
|
273
|
+
return value if isinstance(value, str) else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_validation_details(value: object) -> list[ValidationDetail]:
|
|
277
|
+
"""Return validated validation details, dropping unusable collections.
|
|
278
|
+
|
|
279
|
+
Every exposed detail is a string-keyed JSON object and unknown detail
|
|
280
|
+
fields remain allowed. A collection that violates the shape — or that
|
|
281
|
+
JSON cannot represent — is dropped entirely rather than masking the
|
|
282
|
+
HTTP error being reported.
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
validated = validate_json_value(value, path="API validationErrors")
|
|
286
|
+
except ViResponseError:
|
|
287
|
+
return []
|
|
288
|
+
if not isinstance(validated, list) or not all(
|
|
289
|
+
isinstance(entry, dict) for entry in validated
|
|
290
|
+
):
|
|
291
|
+
return []
|
|
292
|
+
# Every entry was runtime-checked as a validated JSON object above.
|
|
293
|
+
return cast("list[ValidationDetail]", validated)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
297
|
+
"""Return a non-negative retry duration from an HTTP Retry-After value.
|
|
298
|
+
|
|
299
|
+
The header carries either delay seconds or an HTTP-date (RFC 9110). A
|
|
300
|
+
date without a time zone is not a valid HTTP-date and cannot be compared
|
|
301
|
+
with the current UTC time, so it yields None like any unusable value. A
|
|
302
|
+
date in the past yields 0.
|
|
303
|
+
"""
|
|
304
|
+
if value is None:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
numeric_delay = float(value)
|
|
309
|
+
except ValueError:
|
|
310
|
+
pass
|
|
311
|
+
else:
|
|
312
|
+
is_valid_delay = math.isfinite(numeric_delay) and numeric_delay >= 0
|
|
313
|
+
return numeric_delay if is_valid_delay else None
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
retry_at = parsedate_to_datetime(value)
|
|
317
|
+
except IndexError, TypeError, ValueError:
|
|
318
|
+
return None
|
|
319
|
+
if retry_at.tzinfo is None:
|
|
320
|
+
return None
|
|
321
|
+
return max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Compare two raw API feature documents for the diff-features CLI command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
_ARROW = "→"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FeatureDiff:
|
|
13
|
+
"""The differences between a left and a right set of raw API features.
|
|
14
|
+
|
|
15
|
+
``structure`` and ``state`` map each changed feature name to its change
|
|
16
|
+
lines, sorted by feature name.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
left_count: int
|
|
20
|
+
right_count: int
|
|
21
|
+
added: list[str] = field(default_factory=list[str])
|
|
22
|
+
removed: list[str] = field(default_factory=list[str])
|
|
23
|
+
structure: dict[str, list[str]] = field(default_factory=dict[str, list[str]])
|
|
24
|
+
state: dict[str, list[str]] = field(default_factory=dict[str, list[str]])
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def has_differences(self) -> bool:
|
|
28
|
+
"""Return whether any section lists a difference."""
|
|
29
|
+
return bool(self.added or self.removed or self.structure or self.state)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def feature_entries(document: object) -> dict[str, Mapping[str, Any]]:
|
|
33
|
+
"""Return a feature document's raw API features keyed by feature name.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
document: A fixture file or device export with a ``data`` list.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If the document has no ``data`` list of features with
|
|
40
|
+
unique, non-empty names.
|
|
41
|
+
"""
|
|
42
|
+
data = _as_mapping(document).get("data")
|
|
43
|
+
if not isinstance(data, list):
|
|
44
|
+
raise ValueError("A feature document must be an object with a 'data' list")
|
|
45
|
+
entries: dict[str, Mapping[str, Any]] = {}
|
|
46
|
+
for item in cast("list[object]", data):
|
|
47
|
+
entry = _as_mapping(item)
|
|
48
|
+
name = entry.get("feature")
|
|
49
|
+
if not isinstance(name, str) or not name:
|
|
50
|
+
raise ValueError("Every feature needs a non-empty 'feature' name")
|
|
51
|
+
if name in entries:
|
|
52
|
+
raise ValueError(f"Duplicate feature in document: {name}")
|
|
53
|
+
entries[name] = entry
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def diff_features(
|
|
58
|
+
left: Mapping[str, Mapping[str, Any]], right: Mapping[str, Mapping[str, Any]]
|
|
59
|
+
) -> FeatureDiff:
|
|
60
|
+
"""Compare raw API features by name without comparing their values.
|
|
61
|
+
|
|
62
|
+
Properties and commands are compared only when the feature is enabled on
|
|
63
|
+
both sides: the API returns a disabled feature without them, which is a
|
|
64
|
+
state difference rather than a structure difference.
|
|
65
|
+
"""
|
|
66
|
+
diff = FeatureDiff(
|
|
67
|
+
left_count=len(left),
|
|
68
|
+
right_count=len(right),
|
|
69
|
+
added=sorted(set(right) - set(left)),
|
|
70
|
+
removed=sorted(set(left) - set(right)),
|
|
71
|
+
)
|
|
72
|
+
for name in sorted(set(left) & set(right)):
|
|
73
|
+
structure = _structure_changes(left[name], right[name])
|
|
74
|
+
if structure:
|
|
75
|
+
diff.structure[name] = structure
|
|
76
|
+
state = _state_changes(left[name], right[name])
|
|
77
|
+
if state:
|
|
78
|
+
diff.state[name] = state
|
|
79
|
+
return diff
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_feature_diff(
|
|
83
|
+
diff: FeatureDiff, left_label: str, right_label: str
|
|
84
|
+
) -> list[str]:
|
|
85
|
+
"""Return the text report of a feature comparison."""
|
|
86
|
+
lines = [
|
|
87
|
+
f"Comparing {left_label} ({diff.left_count} features) "
|
|
88
|
+
f"with {right_label} ({diff.right_count} features)"
|
|
89
|
+
]
|
|
90
|
+
if not diff.has_differences:
|
|
91
|
+
lines.append("No differences.")
|
|
92
|
+
return lines
|
|
93
|
+
if diff.added:
|
|
94
|
+
lines.extend(["", f"Added ({len(diff.added)}):"])
|
|
95
|
+
lines.extend(f" + {name}" for name in diff.added)
|
|
96
|
+
if diff.removed:
|
|
97
|
+
lines.extend(["", f"Removed ({len(diff.removed)}):"])
|
|
98
|
+
lines.extend(f" - {name}" for name in diff.removed)
|
|
99
|
+
for title, changes in (
|
|
100
|
+
("Structure changed", diff.structure),
|
|
101
|
+
("State changed", diff.state),
|
|
102
|
+
):
|
|
103
|
+
if changes:
|
|
104
|
+
lines.extend(["", f"{title} ({len(changes)}):"])
|
|
105
|
+
for name, change_lines in changes.items():
|
|
106
|
+
lines.append(f" {name}")
|
|
107
|
+
lines.extend(f" {line}" for line in change_lines)
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _structure_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
|
|
112
|
+
"""Return the property, command, and deprecation changes of one feature."""
|
|
113
|
+
changes: list[str] = []
|
|
114
|
+
if left.get("isEnabled") is True and right.get("isEnabled") is True:
|
|
115
|
+
changes.extend(
|
|
116
|
+
_property_changes(
|
|
117
|
+
_mapping(left, "properties"), _mapping(right, "properties")
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
changes.extend(
|
|
121
|
+
_command_changes(_mapping(left, "commands"), _mapping(right, "commands"))
|
|
122
|
+
)
|
|
123
|
+
if left.get("deprecated") != right.get("deprecated"):
|
|
124
|
+
changes.append(
|
|
125
|
+
f"deprecated: {_render(left.get('deprecated'))} {_ARROW} "
|
|
126
|
+
f"{_render(right.get('deprecated'))}"
|
|
127
|
+
)
|
|
128
|
+
return changes
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _command_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
|
|
132
|
+
"""Return added and removed commands and their parameter changes."""
|
|
133
|
+
changes = [f"+ command {name}" for name in sorted(set(right) - set(left))]
|
|
134
|
+
changes.extend(f"- command {name}" for name in sorted(set(left) - set(right)))
|
|
135
|
+
for name in sorted(set(left) & set(right)):
|
|
136
|
+
changes.extend(
|
|
137
|
+
_parameter_changes(
|
|
138
|
+
name,
|
|
139
|
+
_mapping(_as_mapping(left[name]), "params"),
|
|
140
|
+
_mapping(_as_mapping(right[name]), "params"),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return changes
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _property_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
|
|
147
|
+
"""Return added and removed properties and their type and unit changes."""
|
|
148
|
+
changes = [f"+ property {name}" for name in sorted(set(right) - set(left))]
|
|
149
|
+
changes.extend(f"- property {name}" for name in sorted(set(left) - set(right)))
|
|
150
|
+
for name in sorted(set(left) & set(right)):
|
|
151
|
+
left_property = _as_mapping(left[name])
|
|
152
|
+
right_property = _as_mapping(right[name])
|
|
153
|
+
changes.extend(
|
|
154
|
+
_field_changes(
|
|
155
|
+
f"property {name}", left_property, right_property, ("type", "unit")
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return changes
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parameter_changes(
|
|
162
|
+
command: str, left: Mapping[str, Any], right: Mapping[str, Any]
|
|
163
|
+
) -> list[str]:
|
|
164
|
+
"""Return added and removed parameters and their contract changes."""
|
|
165
|
+
changes = [f"+ {command}.{name}" for name in sorted(set(right) - set(left))]
|
|
166
|
+
changes.extend(f"- {command}.{name}" for name in sorted(set(left) - set(right)))
|
|
167
|
+
for name in sorted(set(left) & set(right)):
|
|
168
|
+
label = f"{command}.{name}"
|
|
169
|
+
left_parameter = _as_mapping(left[name])
|
|
170
|
+
right_parameter = _as_mapping(right[name])
|
|
171
|
+
changes.extend(
|
|
172
|
+
_field_changes(label, left_parameter, right_parameter, ("type", "required"))
|
|
173
|
+
)
|
|
174
|
+
left_constraints = _mapping(left_parameter, "constraints")
|
|
175
|
+
right_constraints = _mapping(right_parameter, "constraints")
|
|
176
|
+
changes.extend(
|
|
177
|
+
_field_changes(
|
|
178
|
+
label,
|
|
179
|
+
left_constraints,
|
|
180
|
+
right_constraints,
|
|
181
|
+
sorted(set(left_constraints) | set(right_constraints)),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return changes
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _state_changes(left: Mapping[str, Any], right: Mapping[str, Any]) -> list[str]:
|
|
188
|
+
"""Return the enabled, ready, and command executability changes."""
|
|
189
|
+
changes = _field_changes("", left, right, ("isEnabled", "isReady"))
|
|
190
|
+
left_commands = _mapping(left, "commands")
|
|
191
|
+
right_commands = _mapping(right, "commands")
|
|
192
|
+
for name in sorted(set(left_commands) & set(right_commands)):
|
|
193
|
+
changes.extend(
|
|
194
|
+
_field_changes(
|
|
195
|
+
f"{name}.",
|
|
196
|
+
_as_mapping(left_commands[name]),
|
|
197
|
+
_as_mapping(right_commands[name]),
|
|
198
|
+
("isExecutable",),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return changes
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _field_changes(
|
|
205
|
+
label: str,
|
|
206
|
+
left: Mapping[str, Any],
|
|
207
|
+
right: Mapping[str, Any],
|
|
208
|
+
fields: Sequence[str],
|
|
209
|
+
) -> list[str]:
|
|
210
|
+
"""Return one ``label field: old → new`` line per differing field.
|
|
211
|
+
|
|
212
|
+
A label ending in ``.`` is joined to the field name without a space.
|
|
213
|
+
"""
|
|
214
|
+
changes: list[str] = []
|
|
215
|
+
for field_name in fields:
|
|
216
|
+
left_value = left.get(field_name)
|
|
217
|
+
right_value = right.get(field_name)
|
|
218
|
+
if left_value == right_value:
|
|
219
|
+
continue
|
|
220
|
+
prefix = (
|
|
221
|
+
f"{label}{field_name}"
|
|
222
|
+
if label.endswith(".") or not label
|
|
223
|
+
else (f"{label} {field_name}")
|
|
224
|
+
)
|
|
225
|
+
if field_name not in left:
|
|
226
|
+
changes.append(f"{prefix}: + {_render(right_value)}")
|
|
227
|
+
elif field_name not in right:
|
|
228
|
+
changes.append(f"{prefix}: - {_render(left_value)}")
|
|
229
|
+
else:
|
|
230
|
+
changes.append(
|
|
231
|
+
f"{prefix}: {_render(left_value)} {_ARROW} {_render(right_value)}"
|
|
232
|
+
)
|
|
233
|
+
return changes
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _mapping(entry: Mapping[str, Any], key: str) -> Mapping[str, Any]:
|
|
237
|
+
"""Return a nested object of a raw entry, or an empty one if it is missing."""
|
|
238
|
+
return _as_mapping(entry.get(key))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _as_mapping(value: object) -> Mapping[str, Any]:
|
|
242
|
+
"""Return a raw JSON object, or an empty one for any other value."""
|
|
243
|
+
return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else {}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render(value: object) -> str:
|
|
247
|
+
"""Return a compact JSON rendering of a raw value, ``none`` when absent."""
|
|
248
|
+
if value is None:
|
|
249
|
+
return "none"
|
|
250
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
vi_api_client/_types.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Public dynamic value contracts shared across client API boundaries."""
|
|
2
|
+
|
|
3
|
+
type JsonValue = (
|
|
4
|
+
bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
|
|
5
|
+
)
|
|
6
|
+
"""A recursively JSON-compatible value retaining standard Python shapes."""
|
|
7
|
+
|
|
8
|
+
type FeatureValue = JsonValue
|
|
9
|
+
"""The dynamic value reported by a device feature."""
|
|
10
|
+
|
|
11
|
+
type ValidationDetail = dict[str, JsonValue]
|
|
12
|
+
"""One dictionary-shaped API validation detail."""
|