python-duco-connectivity 0.3.0__tar.gz → 0.4.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 (27) hide show
  1. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/PKG-INFO +1 -1
  2. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/pyproject.toml +1 -1
  3. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/duco_connectivity/__init__.py +2 -0
  4. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/duco_connectivity/client.py +9 -4
  5. python_duco_connectivity-0.4.0/src/duco_connectivity/exceptions.py +53 -0
  6. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/python_duco_connectivity.egg-info/PKG-INFO +1 -1
  7. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_client.py +22 -4
  8. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_exceptions.py +29 -0
  9. python_duco_connectivity-0.3.0/src/duco_connectivity/exceptions.py +0 -24
  10. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/LICENSE +0 -0
  11. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/README.md +0 -0
  12. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/setup.cfg +0 -0
  13. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/duco_connectivity/__main__.py +0 -0
  14. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/duco_connectivity/cli.py +0 -0
  15. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/duco_connectivity/models.py +0 -0
  16. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/duco_connectivity/py.typed +0 -0
  17. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  18. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  19. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  20. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  21. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  22. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_api_reference.py +0 -0
  23. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_cli.py +0 -0
  24. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_local_sample_validation.py +0 -0
  25. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_models.py +0 -0
  26. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.0}/tests/test_pytest_live_support.py +0 -0
  27. {python_duco_connectivity-0.3.0 → python_duco_connectivity-0.4.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.3.0
3
+ Version: 0.4.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,6 +7,7 @@ from .exceptions import (
7
7
  DucoConnectionError,
8
8
  DucoError,
9
9
  DucoRateLimitError,
10
+ DucoResponseError,
10
11
  DucoWriteLimitError,
11
12
  )
12
13
  from .models import (
@@ -93,6 +94,7 @@ __all__ = [
93
94
  "DucoConnectionError",
94
95
  "DucoError",
95
96
  "DucoRateLimitError",
97
+ "DucoResponseError",
96
98
  "DucoWriteLimitError",
97
99
  "DiagComponent",
98
100
  "DiagStatus",
@@ -9,7 +9,12 @@ from urllib.parse import urlsplit
9
9
 
10
10
  import aiohttp
11
11
 
12
- from .exceptions import DucoConnectionError, DucoError, DucoWriteLimitError
12
+ from .exceptions import (
13
+ DucoConnectionError,
14
+ DucoError,
15
+ DucoResponseError,
16
+ DucoWriteLimitError,
17
+ )
13
18
  from .models import (
14
19
  ActionItem,
15
20
  ActionItemList,
@@ -215,18 +220,18 @@ class DucoClient:
215
220
  path,
216
221
  )
217
222
  if response.status == 429:
223
+ body = await response.text()
218
224
  _LOGGER.debug(
219
225
  "Write limit reached for %s %s%s",
220
226
  method,
221
227
  self._base_url,
222
228
  path,
223
229
  )
224
- raise DucoWriteLimitError()
230
+ raise DucoWriteLimitError(path=path, body=body)
225
231
 
226
232
  if response.status >= 400:
227
233
  body = await response.text()
228
- msg = f"Unexpected response {response.status} for {path}: {body}"
229
- raise DucoError(msg)
234
+ raise DucoResponseError(response.status, path, body)
230
235
 
231
236
  try:
232
237
  return await response.json(content_type=None)
@@ -0,0 +1,53 @@
1
+ """Exceptions raised by the Duco client."""
2
+
3
+
4
+ class DucoError(Exception):
5
+ """Base class for client errors."""
6
+
7
+
8
+ class DucoConnectionError(DucoError):
9
+ """Raised when the client cannot reach the box."""
10
+
11
+
12
+ class DucoResponseError(DucoError):
13
+ """Raised when the box responds with an HTTP error status."""
14
+
15
+ def __init__(
16
+ self,
17
+ status: int,
18
+ path: str,
19
+ body: str = "",
20
+ *,
21
+ message: str | None = None,
22
+ ) -> None:
23
+ self.status = status
24
+ self.path = path
25
+ self.body = body
26
+ if message is None:
27
+ detail = f"Unexpected response {status} for {path}"
28
+ if body.strip():
29
+ detail = f"{detail}: {body}"
30
+ else:
31
+ detail = message
32
+ super().__init__(detail)
33
+
34
+
35
+ class DucoWriteLimitError(DucoResponseError):
36
+ """Raised when the box rejects writes because its budget is exhausted."""
37
+
38
+ def __init__(
39
+ self,
40
+ remaining: int | None = None,
41
+ *,
42
+ path: str = "",
43
+ body: str = "",
44
+ ) -> None:
45
+ self.remaining = remaining
46
+ detail = "Duco write capacity exhausted"
47
+ if remaining is not None:
48
+ detail = f"{detail} ({remaining} writes remaining)"
49
+ super().__init__(429, path, body, message=detail)
50
+
51
+
52
+ # Backward-compatible alias for the old python-duco-client exception name.
53
+ DucoRateLimitError = DucoWriteLimitError
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -27,6 +27,7 @@ from duco_connectivity import (
27
27
  DucoClient,
28
28
  DucoConnectionError,
29
29
  DucoError,
30
+ DucoResponseError,
30
31
  DucoWriteLimitError,
31
32
  InfoGroup,
32
33
  InfoZone,
@@ -3320,16 +3321,25 @@ async def test_timeout_raises_duco_connection_error() -> None:
3320
3321
  await client.async_get_api_info()
3321
3322
 
3322
3323
 
3323
- async def test_http_error_raises_duco_error() -> None:
3324
- """HTTP >= 400 should raise DucoError."""
3324
+ async def test_http_error_raises_duco_response_error() -> None:
3325
+ """HTTP >= 400 should raise DucoResponseError with HTTP metadata."""
3325
3326
  mock_response = _response(status=500, json_payload={}, text_payload="boom")
3326
3327
 
3327
3328
  async with aiohttp.ClientSession() as session:
3328
3329
  client = DucoClient(session=session, host="192.0.2.94")
3329
3330
  with patch.object(session, "request", _request(mock_response)):
3330
- with pytest.raises(DucoError, match="Unexpected response 500"):
3331
+ with pytest.raises(
3332
+ DucoResponseError,
3333
+ match="Unexpected response 500 for /api: boom",
3334
+ ) as err_info:
3331
3335
  await client.async_get_api_info()
3332
3336
 
3337
+ err = err_info.value
3338
+ assert isinstance(err, DucoError)
3339
+ assert err.status == 500
3340
+ assert err.path == "/api"
3341
+ assert err.body == "boom"
3342
+
3333
3343
 
3334
3344
  async def test_invalid_json_raises_duco_error(api_info_data: dict[str, object]) -> None:
3335
3345
  """Non-JSON responses should raise DucoError."""
@@ -3355,10 +3365,18 @@ async def test_write_limit_error_is_raised() -> None:
3355
3365
  async with aiohttp.ClientSession() as session:
3356
3366
  client = DucoClient(session=session, host="192.0.2.94")
3357
3367
  with patch.object(session, "request", MagicMock(return_value=request_context)):
3358
- with pytest.raises(DucoWriteLimitError, match="Duco write capacity exhausted"):
3368
+ with pytest.raises(
3369
+ DucoWriteLimitError,
3370
+ match="Duco write capacity exhausted",
3371
+ ) as err_info:
3359
3372
  await client.async_set_ventilation_state(1, "MAN2")
3360
3373
 
3361
3374
  request_context.__aexit__.assert_awaited_once()
3375
+ err = err_info.value
3376
+ assert isinstance(err, DucoError)
3377
+ assert err.status == 429
3378
+ assert err.path == "/action/nodes/1"
3379
+ assert err.body == ""
3362
3380
 
3363
3381
 
3364
3382
  async def test_get_actions_returns_typed_items(
@@ -1,9 +1,12 @@
1
1
  """Tests for the public exceptions."""
2
2
 
3
+ import pytest
4
+
3
5
  from duco_connectivity import (
4
6
  DucoConnectionError,
5
7
  DucoError,
6
8
  DucoRateLimitError,
9
+ DucoResponseError,
7
10
  DucoWriteLimitError,
8
11
  )
9
12
 
@@ -20,6 +23,24 @@ def test_write_limit_error_inherits_from_base_error() -> None:
20
23
  assert isinstance(err, DucoError)
21
24
 
22
25
 
26
+ def test_response_error_stores_http_metadata() -> None:
27
+ """DucoResponseError should expose status, path, and body."""
28
+ err = DucoResponseError(404, "/info", "missing")
29
+ assert err.status == 404
30
+ assert err.path == "/info"
31
+ assert err.body == "missing"
32
+ assert str(err) == "Unexpected response 404 for /info: missing"
33
+ assert isinstance(err, DucoError)
34
+
35
+
36
+ @pytest.mark.parametrize("body", ["", " "])
37
+ def test_response_error_omits_empty_body_from_default_message(body: str) -> None:
38
+ """DucoResponseError should omit the body suffix when no body content exists."""
39
+ err = DucoResponseError(404, "/info", body)
40
+
41
+ assert str(err) == "Unexpected response 404 for /info"
42
+
43
+
23
44
  def test_write_limit_error_stores_remaining_count() -> None:
24
45
  """DucoWriteLimitError should expose the remaining count when present."""
25
46
  err = DucoWriteLimitError(remaining=10)
@@ -33,3 +54,11 @@ def test_rate_limit_error_alias_points_to_write_limit_error() -> None:
33
54
  err = DucoRateLimitError(remaining=3)
34
55
  assert isinstance(err, DucoWriteLimitError)
35
56
  assert err.remaining == 3
57
+
58
+
59
+ def test_write_limit_error_exposes_http_metadata() -> None:
60
+ """DucoWriteLimitError should expose HTTP metadata for 429 responses."""
61
+ err = DucoWriteLimitError(path="/config", body="rate limited")
62
+ assert err.status == 429
63
+ assert err.path == "/config"
64
+ assert err.body == "rate limited"
@@ -1,24 +0,0 @@
1
- """Exceptions raised by the Duco client."""
2
-
3
-
4
- class DucoError(Exception):
5
- """Base class for client errors."""
6
-
7
-
8
- class DucoConnectionError(DucoError):
9
- """Raised when the client cannot reach the box."""
10
-
11
-
12
- class DucoWriteLimitError(DucoError):
13
- """Raised when the box rejects writes because its budget is exhausted."""
14
-
15
- def __init__(self, remaining: int | None = None) -> None:
16
- self.remaining = remaining
17
- detail = "Duco write capacity exhausted"
18
- if remaining is not None:
19
- detail = f"{detail} ({remaining} writes remaining)"
20
- super().__init__(detail)
21
-
22
-
23
- # Backward-compatible alias for the old python-duco-client exception name.
24
- DucoRateLimitError = DucoWriteLimitError