python-duco-connectivity 0.9.0__tar.gz → 0.11.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.
Files changed (26) hide show
  1. {python_duco_connectivity-0.9.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.11.0}/PKG-INFO +14 -3
  2. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/README.md +13 -2
  3. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/__init__.py +2 -0
  5. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/client.py +18 -5
  6. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/exceptions.py +10 -0
  7. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0/src/python_duco_connectivity.egg-info}/PKG-INFO +14 -3
  8. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_api_reference.py +9 -0
  9. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_client.py +32 -18
  10. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_exceptions.py +17 -0
  11. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/LICENSE +0 -0
  12. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/setup.cfg +0 -0
  13. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/__main__.py +0 -0
  14. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/cli.py +0 -0
  15. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/models.py +0 -0
  16. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/duco_connectivity/py.typed +0 -0
  17. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  18. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  19. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  20. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  21. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  22. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_cli.py +0 -0
  23. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.11.0}/tests/test_replay_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -64,11 +64,22 @@ in the development examples below.
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
66
  - typed helpers for stable `/info` fields such as heat recovery filter time
67
- - optional temperature helpers that return `None` when the box reports their
68
- endpoint as unavailable
67
+ - optional endpoint helpers with explicit unsupported-capability errors
69
68
  - typed models that stay close to the API response shape
70
69
  - preserved `raw_payload` data on typed response models for forward compatibility
71
70
 
71
+ ## Error handling
72
+
73
+ When a Duco box explicitly reports that the optional ventilation-temperature or
74
+ bypass-target endpoint is unsupported, the relevant helper raises
75
+ `DucoUnsupportedCapabilityError`. This exception is a `DucoResponseError`
76
+ subclass and preserves the HTTP status, path, and response body. A valid
77
+ ventilation-temperature response always returns a `VentilationTemperatureInfo`
78
+ model, whose individual temperature fields can be `None` when omitted. The
79
+ bypass-target helper returns a typed target model for successful parameter-
80
+ specific reads and raises `DucoError` if the requested target field is missing
81
+ from an otherwise valid `/config` response.
82
+
72
83
  Diagnostic subsystem reads now keep raw component and status strings from
73
84
  `Diag.SubSystems`, so future subsystem names or status values remain available
74
85
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -32,11 +32,22 @@ in the development examples below.
32
32
  - asynchronous communication via `aiohttp`
33
33
  - typed stable config families for the documented `/config` branches
34
34
  - typed helpers for stable `/info` fields such as heat recovery filter time
35
- - optional temperature helpers that return `None` when the box reports their
36
- endpoint as unavailable
35
+ - optional endpoint helpers with explicit unsupported-capability errors
37
36
  - typed models that stay close to the API response shape
38
37
  - preserved `raw_payload` data on typed response models for forward compatibility
39
38
 
39
+ ## Error handling
40
+
41
+ When a Duco box explicitly reports that the optional ventilation-temperature or
42
+ bypass-target endpoint is unsupported, the relevant helper raises
43
+ `DucoUnsupportedCapabilityError`. This exception is a `DucoResponseError`
44
+ subclass and preserves the HTTP status, path, and response body. A valid
45
+ ventilation-temperature response always returns a `VentilationTemperatureInfo`
46
+ model, whose individual temperature fields can be `None` when omitted. The
47
+ bypass-target helper returns a typed target model for successful parameter-
48
+ specific reads and raises `DucoError` if the requested target field is missing
49
+ from an otherwise valid `/config` response.
50
+
40
51
  Diagnostic subsystem reads now keep raw component and status strings from
41
52
  `Diag.SubSystems`, so future subsystem names or status values remain available
42
53
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,6 +8,7 @@ from .exceptions import (
8
8
  DucoError,
9
9
  DucoRateLimitError,
10
10
  DucoResponseError,
11
+ DucoUnsupportedCapabilityError,
11
12
  DucoWriteLimitError,
12
13
  )
13
14
  from .models import (
@@ -172,6 +173,7 @@ __all__ = [
172
173
  "DucoRateLimitError",
173
174
  "DucoResponseError",
174
175
  "DucoSerialNumber",
176
+ "DucoUnsupportedCapabilityError",
175
177
  "DucoVersion",
176
178
  "DucoWriteLimitError",
177
179
  "HostName",
@@ -16,6 +16,7 @@ from .exceptions import (
16
16
  DucoConnectionError,
17
17
  DucoError,
18
18
  DucoResponseError,
19
+ DucoUnsupportedCapabilityError,
19
20
  DucoWriteLimitError,
20
21
  )
21
22
  from .models import (
@@ -2242,13 +2243,17 @@ class DucoClient:
2242
2243
  path="HeatRecovery.General",
2243
2244
  )
2244
2245
 
2245
- async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo | None:
2246
+ async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo:
2246
2247
  """Return ventilation temperatures when the box exposes them, in Celsius."""
2247
2248
  try:
2248
2249
  payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2249
2250
  except DucoResponseError as err:
2250
2251
  if _is_unsupported_optional_endpoint_error(err, "/info"):
2251
- return None
2252
+ raise DucoUnsupportedCapabilityError(
2253
+ err.status,
2254
+ err.path,
2255
+ err.body,
2256
+ ) from err
2252
2257
  raise
2253
2258
 
2254
2259
  if not isinstance(payload, dict):
@@ -2290,7 +2295,7 @@ class DucoClient:
2290
2295
  async def async_get_bypass_supply_temperature_target(
2291
2296
  self,
2292
2297
  zone_id: int,
2293
- ) -> BypassSupplyTemperatureTarget | None:
2298
+ ) -> BypassSupplyTemperatureTarget:
2294
2299
  """Return a bypass supply target from `/config` in Celsius."""
2295
2300
  parameter = self._validate_bypass_zone_id(zone_id)
2296
2301
  try:
@@ -2301,9 +2306,17 @@ class DucoClient:
2301
2306
  )
2302
2307
  except DucoResponseError as err:
2303
2308
  if _is_unsupported_optional_endpoint_error(err, "/config"):
2304
- return None
2309
+ raise DucoUnsupportedCapabilityError(
2310
+ err.status,
2311
+ err.path,
2312
+ err.body,
2313
+ ) from err
2305
2314
  raise
2306
- return self._extract_bypass_supply_temperature_target(config, zone_id)
2315
+ target = self._extract_bypass_supply_temperature_target(config, zone_id)
2316
+ if target is None:
2317
+ msg = f"Expected {parameter} in /config response"
2318
+ raise DucoError(msg)
2319
+ return target
2307
2320
 
2308
2321
  async def async_set_bypass_supply_temperature_target(
2309
2322
  self,
@@ -32,6 +32,16 @@ class DucoResponseError(DucoError):
32
32
  super().__init__(detail)
33
33
 
34
34
 
35
+ class DucoUnsupportedCapabilityError(DucoResponseError):
36
+ """Raised when the box does not support an optional capability."""
37
+
38
+ def __init__(self, status: int, path: str, body: str = "") -> None:
39
+ detail = f"Unsupported capability for {path}"
40
+ if body.strip():
41
+ detail = f"{detail}: {body}"
42
+ super().__init__(status, path, body, message=detail)
43
+
44
+
35
45
  class DucoWriteLimitError(DucoResponseError):
36
46
  """Raised when the box rejects writes because its budget is exhausted."""
37
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -64,11 +64,22 @@ in the development examples below.
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
66
  - typed helpers for stable `/info` fields such as heat recovery filter time
67
- - optional temperature helpers that return `None` when the box reports their
68
- endpoint as unavailable
67
+ - optional endpoint helpers with explicit unsupported-capability errors
69
68
  - typed models that stay close to the API response shape
70
69
  - preserved `raw_payload` data on typed response models for forward compatibility
71
70
 
71
+ ## Error handling
72
+
73
+ When a Duco box explicitly reports that the optional ventilation-temperature or
74
+ bypass-target endpoint is unsupported, the relevant helper raises
75
+ `DucoUnsupportedCapabilityError`. This exception is a `DucoResponseError`
76
+ subclass and preserves the HTTP status, path, and response body. A valid
77
+ ventilation-temperature response always returns a `VentilationTemperatureInfo`
78
+ model, whose individual temperature fields can be `None` when omitted. The
79
+ bypass-target helper returns a typed target model for successful parameter-
80
+ specific reads and raises `DucoError` if the requested target field is missing
81
+ from an otherwise valid `/config` response.
82
+
72
83
  Diagnostic subsystem reads now keep raw component and status strings from
73
84
  `Diag.SubSystems`, so future subsystem names or status values remain available
74
85
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -85,6 +85,15 @@ def test_api_reference_contains_expected_sections() -> None:
85
85
  assert "selectors.md" in content
86
86
 
87
87
 
88
+ def test_api_reference_documents_optional_capability_contracts() -> None:
89
+ """Optional endpoint return and exception contracts should stay explicit."""
90
+ content = render_api_reference()
91
+
92
+ assert "optional endpoint as unsupported" in content
93
+ assert "without the requested target field in the response" in content
94
+ assert "optional target endpoint is unsupported" in content
95
+
96
+
88
97
  def test_api_reference_hides_compatibility_only_method_aliases() -> None:
89
98
  """Compatibility-only client aliases should not be published as documented methods."""
90
99
  content = render_api_reference()
@@ -47,6 +47,7 @@ from duco_connectivity import (
47
47
  DucoError,
48
48
  DucoResponseError,
49
49
  DucoSerialNumber,
50
+ DucoUnsupportedCapabilityError,
50
51
  DucoVersion,
51
52
  DucoWriteLimitError,
52
53
  HostName,
@@ -3666,6 +3667,7 @@ async def test_get_ventilation_temperature_info_is_parsed(
3666
3667
  with patch.object(session, "request", _request(mock_response)):
3667
3668
  payload = await client.async_get_ventilation_temperature_info()
3668
3669
 
3670
+ assert_type(payload, VentilationTemperatureInfo)
3669
3671
  assert payload == VentilationTemperatureInfo(
3670
3672
  temp_oda=17.5,
3671
3673
  temp_sup=18.0,
@@ -3688,8 +3690,8 @@ async def test_get_ventilation_temperature_info_returns_empty_model_when_sensor_
3688
3690
  assert payload.raw_payload == {}
3689
3691
 
3690
3692
 
3691
- async def test_get_ventilation_temperature_info_returns_none_when_module_unsupported() -> None:
3692
- """Unsupported ventilation modules should be treated as an absent optional capability."""
3693
+ async def test_get_ventilation_temperature_info_raises_for_unsupported_module() -> None:
3694
+ """Unsupported ventilation modules should raise a typed capability error."""
3693
3695
  mock_response = _response(
3694
3696
  status=400,
3695
3697
  text_payload='{"Code":3,"Result":"FAILED"}',
@@ -3697,10 +3699,14 @@ async def test_get_ventilation_temperature_info_returns_none_when_module_unsuppo
3697
3699
 
3698
3700
  async with aiohttp.ClientSession() as session:
3699
3701
  client = DucoClient(session=session, host="192.0.2.94")
3700
- with patch.object(session, "request", _request(mock_response)):
3701
- payload = await client.async_get_ventilation_temperature_info()
3702
+ with (
3703
+ patch.object(session, "request", _request(mock_response)),
3704
+ pytest.raises(DucoUnsupportedCapabilityError) as err_info,
3705
+ ):
3706
+ await client.async_get_ventilation_temperature_info()
3702
3707
 
3703
- assert payload is None
3708
+ assert err_info.value.status == 400
3709
+ assert err_info.value.path == "/info"
3704
3710
 
3705
3711
 
3706
3712
  async def test_get_ventilation_temperature_info_reraises_other_client_errors() -> None:
@@ -3788,20 +3794,24 @@ async def test_get_bypass_supply_temperature_target_returns_converted_values(
3788
3794
  }
3789
3795
 
3790
3796
 
3791
- async def test_get_bypass_supply_temperature_target_returns_none_when_not_reported() -> None:
3792
- """Missing bypass targets should return None instead of failing."""
3797
+ async def test_get_bypass_supply_temperature_target_raises_when_not_reported() -> None:
3798
+ """Missing bypass targets should fail for parameter-specific helper reads."""
3793
3799
  mock_response = _response(json_payload={"HeatRecovery": {"Bypass": {}}})
3794
3800
 
3795
3801
  async with aiohttp.ClientSession() as session:
3796
3802
  client = DucoClient(session=session, host="192.0.2.94")
3797
- with patch.object(session, "request", _request(mock_response)):
3798
- payload = await client.async_get_bypass_supply_temperature_target(1)
3799
-
3800
- assert payload is None
3803
+ with (
3804
+ patch.object(session, "request", _request(mock_response)),
3805
+ pytest.raises(
3806
+ DucoError,
3807
+ match=re.escape("Expected TempSupTgtZone1 in /config response"),
3808
+ ),
3809
+ ):
3810
+ await client.async_get_bypass_supply_temperature_target(1)
3801
3811
 
3802
3812
 
3803
- async def test_get_bypass_supply_temperature_target_returns_none_when_unsupported() -> None:
3804
- """Unsupported bypass targets should be treated as an absent optional capability."""
3813
+ async def test_get_bypass_supply_temperature_target_raises_when_unsupported() -> None:
3814
+ """Unsupported bypass targets should raise a typed capability error."""
3805
3815
  mock_response = _response(
3806
3816
  status=400,
3807
3817
  text_payload='{"Code":3,"Result":"FAILED"}',
@@ -3809,10 +3819,14 @@ async def test_get_bypass_supply_temperature_target_returns_none_when_unsupporte
3809
3819
 
3810
3820
  async with aiohttp.ClientSession() as session:
3811
3821
  client = DucoClient(session=session, host="192.0.2.94")
3812
- with patch.object(session, "request", _request(mock_response)):
3813
- payload = await client.async_get_bypass_supply_temperature_target(1)
3822
+ with (
3823
+ patch.object(session, "request", _request(mock_response)),
3824
+ pytest.raises(DucoUnsupportedCapabilityError) as err_info,
3825
+ ):
3826
+ await client.async_get_bypass_supply_temperature_target(1)
3814
3827
 
3815
- assert payload is None
3828
+ assert err_info.value.status == 400
3829
+ assert err_info.value.path == "/config"
3816
3830
 
3817
3831
 
3818
3832
  async def test_get_bypass_supply_temperature_target_reraises_other_client_errors() -> None:
@@ -3927,8 +3941,8 @@ async def test_get_time_filter_remaining_returns_none_when_not_reported() -> Non
3927
3941
  assert remaining is None
3928
3942
 
3929
3943
 
3930
- async def test_get_time_filter_remaining_returns_none_when_module_unsupported() -> None:
3931
- """Heat recovery filter time should stay optional for boxes without that module."""
3944
+ async def test_get_time_filter_remaining_returns_none_for_unsupported_module() -> None:
3945
+ """Unsupported heat recovery modules should remain an optional value."""
3932
3946
  mock_response = _response(
3933
3947
  status=400,
3934
3948
  text_payload='{"Code":3,"Result":"FAILED"}',
@@ -7,6 +7,7 @@ from duco_connectivity import (
7
7
  DucoError,
8
8
  DucoRateLimitError,
9
9
  DucoResponseError,
10
+ DucoUnsupportedCapabilityError,
10
11
  DucoWriteLimitError,
11
12
  )
12
13
 
@@ -41,6 +42,22 @@ def test_response_error_omits_empty_body_from_default_message(body: str) -> None
41
42
  assert str(err) == "Unexpected response 404 for /info"
42
43
 
43
44
 
45
+ def test_unsupported_capability_error_preserves_response_context() -> None:
46
+ """DucoUnsupportedCapabilityError should retain HTTP response context."""
47
+ err = DucoUnsupportedCapabilityError(400, "/info", "unsupported")
48
+ assert isinstance(err, DucoResponseError)
49
+ assert err.status == 400
50
+ assert err.path == "/info"
51
+ assert err.body == "unsupported"
52
+ assert str(err) == "Unsupported capability for /info: unsupported"
53
+
54
+
55
+ def test_unsupported_capability_error_omits_empty_body_from_message() -> None:
56
+ """DucoUnsupportedCapabilityError should omit an empty body from its message."""
57
+ err = DucoUnsupportedCapabilityError(400, "/info", " ")
58
+ assert str(err) == "Unsupported capability for /info"
59
+
60
+
44
61
  def test_write_limit_error_stores_remaining_count() -> None:
45
62
  """DucoWriteLimitError should expose the remaining count when present."""
46
63
  err = DucoWriteLimitError(remaining=10)