python-oa3-client 0.1.0__tar.gz → 0.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.
Files changed (24) hide show
  1. python_oa3_client-0.2.0/.github/workflows/lint.yml +18 -0
  2. python_oa3_client-0.2.0/.pre-commit-config.yaml +7 -0
  3. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/PKG-INFO +3 -2
  4. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/pyproject.toml +26 -3
  5. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/__init__.py +8 -8
  6. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/base.py +10 -9
  7. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/discovery.py +9 -12
  8. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/mqtt.py +9 -8
  9. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/notifications.py +3 -2
  10. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/ven.py +46 -31
  11. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/webhook.py +10 -10
  12. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_base.py +1 -1
  13. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_bl.py +1 -1
  14. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_discovery.py +36 -17
  15. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_mqtt.py +2 -5
  16. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_notifications.py +16 -3
  17. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_ven.py +53 -26
  18. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/tests/test_webhook.py +2 -3
  19. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/.github/workflows/publish.yml +0 -0
  20. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/.gitignore +0 -0
  21. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/LICENSE +0 -0
  22. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/README.md +0 -0
  23. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/doc/ven-bl-client-guide.md +0 -0
  24. {python_oa3_client-0.1.0 → python_oa3_client-0.2.0}/src/openadr3_client/bl.py +0 -0
@@ -0,0 +1,18 @@
1
+ name: Lint
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ ruff:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
13
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install "ruff>=0.15.0"
17
+ - run: ruff check src tests
18
+ - run: ruff format --check src tests
@@ -0,0 +1,7 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.15.6
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-oa3-client
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: OpenADR 3 companion client with VEN registration, MQTT notifications, and lifecycle management
5
- Project-URL: Homepage, https://github.com/grid-coordination/python-oa3-client
5
+ Project-URL: Homepage, https://grid-coordination.energy
6
6
  Project-URL: Repository, https://github.com/grid-coordination/python-oa3-client
7
7
  Project-URL: Issues, https://github.com/grid-coordination/python-oa3-client/issues
8
8
  Author: Grid-Coordination Contributors
@@ -28,6 +28,7 @@ Provides-Extra: dev
28
28
  Requires-Dist: ebus-mqtt-client>=0.1.0; extra == 'dev'
29
29
  Requires-Dist: flask>=3.0.0; extra == 'dev'
30
30
  Requires-Dist: pytest; extra == 'dev'
31
+ Requires-Dist: ruff>=0.15.0; extra == 'dev'
31
32
  Requires-Dist: zeroconf>=0.131.0; extra == 'dev'
32
33
  Provides-Extra: mdns
33
34
  Requires-Dist: zeroconf>=0.131.0; extra == 'mdns'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-oa3-client"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "OpenADR 3 companion client with VEN registration, MQTT notifications, and lifecycle management"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -33,10 +33,10 @@ mqtt = ["ebus-mqtt-client>=0.1.0"]
33
33
  webhooks = ["flask>=3.0.0"]
34
34
  mdns = ["zeroconf>=0.131.0"]
35
35
  all = ["ebus-mqtt-client>=0.1.0", "flask>=3.0.0", "zeroconf>=0.131.0"]
36
- dev = ["pytest", "ebus-mqtt-client>=0.1.0", "flask>=3.0.0", "zeroconf>=0.131.0"]
36
+ dev = ["pytest", "ruff>=0.15.0", "ebus-mqtt-client>=0.1.0", "flask>=3.0.0", "zeroconf>=0.131.0"]
37
37
 
38
38
  [project.urls]
39
- Homepage = "https://github.com/grid-coordination/python-oa3-client"
39
+ Homepage = "https://grid-coordination.energy"
40
40
  Repository = "https://github.com/grid-coordination/python-oa3-client"
41
41
  Issues = "https://github.com/grid-coordination/python-oa3-client/issues"
42
42
 
@@ -51,3 +51,26 @@ exclude = [
51
51
  ".beads/",
52
52
  "examples/",
53
53
  ]
54
+
55
+ [tool.ruff]
56
+ line-length = 100
57
+ target-version = "py310"
58
+ extend-exclude = [".beads", "examples"]
59
+
60
+ [tool.ruff.lint]
61
+ select = [
62
+ "E", # pycodestyle errors
63
+ "W", # pycodestyle warnings
64
+ "F", # pyflakes
65
+ "I", # isort
66
+ "B", # flake8-bugbear
67
+ "UP", # pyupgrade
68
+ "SIM", # flake8-simplify
69
+ ]
70
+ ignore = [
71
+ "E501", # line-too-long (handled by formatter)
72
+ "B008", # do not perform function calls in argument defaults
73
+ ]
74
+
75
+ [tool.ruff.lint.per-file-ignores]
76
+ "tests/*" = ["B011"] # allow assert False in tests
@@ -1,19 +1,19 @@
1
1
  from openadr3_client.base import BaseClient
2
- from openadr3_client.ven import VenClient, extract_topics
3
2
  from openadr3_client.bl import BlClient
4
- from openadr3_client.notifications import (
5
- MqttChannel,
6
- NotificationChannel,
7
- WebhookChannel,
8
- )
9
- from openadr3_client.mqtt import MQTTConnection, MQTTMessage, normalize_broker_uri
10
- from openadr3_client.webhook import WebhookReceiver, WebhookMessage, detect_lan_ip
11
3
  from openadr3_client.discovery import (
12
4
  DiscoveredVTN,
13
5
  DiscoveryMode,
14
6
  advertise_vtn,
15
7
  discover_vtns,
16
8
  )
9
+ from openadr3_client.mqtt import MQTTConnection, MQTTMessage, normalize_broker_uri
10
+ from openadr3_client.notifications import (
11
+ MqttChannel,
12
+ NotificationChannel,
13
+ WebhookChannel,
14
+ )
15
+ from openadr3_client.ven import VenClient, extract_topics
16
+ from openadr3_client.webhook import WebhookMessage, WebhookReceiver, detect_lan_ip
17
17
 
18
18
  __all__ = [
19
19
  # Clients
@@ -73,12 +73,15 @@ class BaseClient:
73
73
  if self._api:
74
74
  log.info(
75
75
  "%s already started: url=%s",
76
- type(self).__name__, self._resolved_url,
76
+ type(self).__name__,
77
+ self._resolved_url,
77
78
  )
78
79
  return self
79
80
 
80
81
  self._resolved_url = resolve_url(
81
- self.discovery_mode, self.url, self.discovery_timeout,
82
+ self.discovery_mode,
83
+ self.url,
84
+ self.discovery_timeout,
82
85
  )
83
86
 
84
87
  if not self.token:
@@ -98,7 +101,9 @@ class BaseClient:
98
101
  )
99
102
  log.info(
100
103
  "%s started: type=%s url=%s",
101
- type(self).__name__, self._client_type, self._resolved_url,
104
+ type(self).__name__,
105
+ self._client_type,
106
+ self._resolved_url,
102
107
  )
103
108
  return self
104
109
 
@@ -120,9 +125,7 @@ class BaseClient:
120
125
  def api(self) -> OpenADRClient:
121
126
  """The underlying OpenADRClient. Raises if not started."""
122
127
  if not self._api:
123
- raise RuntimeError(
124
- f"{type(self).__name__} not started. Call start() first."
125
- )
128
+ raise RuntimeError(f"{type(self).__name__} not started. Call start() first.")
126
129
  return self._api
127
130
 
128
131
  # -- __getattr__ delegation --
@@ -136,6 +139,4 @@ class BaseClient:
136
139
  return getattr(api, name)
137
140
  except AttributeError:
138
141
  pass
139
- raise AttributeError(
140
- f"'{type(self).__name__}' object has no attribute '{name}'"
141
- )
142
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import logging
13
13
  import threading
14
- from dataclasses import dataclass, field
14
+ from dataclasses import dataclass
15
15
  from enum import Enum
16
16
  from typing import Any
17
17
 
@@ -71,8 +71,10 @@ class DiscoveredVTN:
71
71
  """Build from a ``zeroconf.ServiceInfo`` object."""
72
72
  props = _parse_txt_properties(info.properties or {})
73
73
  # Prefer .server (the .local hostname) over parsed addresses
74
- host = info.server.rstrip(".") if info.server else (
75
- info.parsed_addresses()[0] if info.parsed_addresses() else "localhost"
74
+ host = (
75
+ info.server.rstrip(".")
76
+ if info.server
77
+ else (info.parsed_addresses()[0] if info.parsed_addresses() else "localhost")
76
78
  )
77
79
  return cls(
78
80
  name=info.name,
@@ -91,6 +93,7 @@ def _import_zeroconf():
91
93
  """Lazy-import zeroconf with a helpful error message."""
92
94
  try:
93
95
  import zeroconf # noqa: F811
96
+
94
97
  return zeroconf
95
98
  except ImportError:
96
99
  raise ImportError(
@@ -157,9 +160,7 @@ def resolve_url(
157
160
 
158
161
  if mode == DiscoveryMode.REQUIRE_LOCAL:
159
162
  if not discovered_url:
160
- raise RuntimeError(
161
- "discovery='require_local' but no VTN found via mDNS"
162
- )
163
+ raise RuntimeError("discovery='require_local' but no VTN found via mDNS")
163
164
  return discovered_url
164
165
 
165
166
  if mode == DiscoveryMode.PREFER_LOCAL:
@@ -167,9 +168,7 @@ def resolve_url(
167
168
  return discovered_url
168
169
  if configured_url:
169
170
  return configured_url
170
- raise RuntimeError(
171
- "discovery='prefer_local': no VTN found via mDNS and no url configured"
172
- )
171
+ raise RuntimeError("discovery='prefer_local': no VTN found via mDNS and no url configured")
173
172
 
174
173
  # LOCAL_WITH_FALLBACK
175
174
  if discovered_url:
@@ -241,9 +240,7 @@ def advertise_vtn(
241
240
  server=f"{host}.",
242
241
  port=port,
243
242
  properties=properties,
244
- addresses=[socket.inet_aton(
245
- "127.0.0.1" if host in ("localhost", "127.0.0.1") else host
246
- )],
243
+ addresses=[socket.inet_aton("127.0.0.1" if host in ("localhost", "127.0.0.1") else host)],
247
244
  )
248
245
 
249
246
  zc = Zeroconf()
@@ -9,15 +9,18 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import logging
12
- import re
13
12
  import threading
14
13
  import time
15
- from dataclasses import dataclass, field
16
- from typing import Any, Callable
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
17
  from urllib.parse import urlparse
18
18
 
19
19
  from openadr3.entities import coerce_notification, is_notification
20
20
 
21
+ if TYPE_CHECKING:
22
+ from ebus_mqtt_client import MqttClient
23
+
21
24
  log = logging.getLogger(__name__)
22
25
 
23
26
 
@@ -54,9 +57,7 @@ def _parse_payload(raw: bytes, topic: str) -> Any:
54
57
  return s
55
58
 
56
59
  if isinstance(parsed, dict) and is_notification(parsed):
57
- return coerce_notification(
58
- parsed, {"openadr/channel": "mqtt", "openadr/topic": topic}
59
- )
60
+ return coerce_notification(parsed, {"openadr/channel": "mqtt", "openadr/topic": topic})
60
61
  return parsed
61
62
 
62
63
 
@@ -96,11 +97,11 @@ class MQTTConnection:
96
97
  """Connect to the MQTT broker."""
97
98
  try:
98
99
  from ebus_mqtt_client import MqttClient
99
- except ImportError:
100
+ except ImportError as err:
100
101
  raise ImportError(
101
102
  "ebus-mqtt-client is required for MQTT support. "
102
103
  "Install it with: pip install python-oa3-client[mqtt]"
103
- )
104
+ ) from err
104
105
 
105
106
  host, port, use_tls = normalize_broker_uri(self.broker_url)
106
107
  self._client = MqttClient(
@@ -7,10 +7,11 @@ the lower-level MQTTConnection and WebhookReceiver.
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
- from typing import Any, Callable, Protocol, runtime_checkable
10
+ from collections.abc import Callable
11
+ from typing import Any, Protocol, runtime_checkable
11
12
 
12
13
  from openadr3_client.mqtt import MQTTConnection, MQTTMessage
13
- from openadr3_client.webhook import WebhookReceiver, WebhookMessage
14
+ from openadr3_client.webhook import WebhookMessage, WebhookReceiver
14
15
 
15
16
  log = logging.getLogger(__name__)
16
17
 
@@ -3,11 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from typing import Any, Callable
6
+ from collections.abc import Callable
7
+ from typing import Any
7
8
 
8
9
  import httpx
9
-
10
- from openadr3.api import success, body
10
+ from openadr3.api import success
11
+ from openadr3.entities import coerce
12
+ from openadr3.entities.models import Event, Program
11
13
 
12
14
  from openadr3_client.base import BaseClient
13
15
  from openadr3_client.notifications import (
@@ -83,16 +85,16 @@ class VenClient(BaseClient):
83
85
  vid = existing["id"]
84
86
  log.info("VEN found, reusing: name=%s id=%s", ven_name, vid)
85
87
  else:
86
- resp = self.api.create_ven({
87
- "objectType": "VEN_VEN_REQUEST",
88
- "venName": ven_name,
89
- })
88
+ resp = self.api.create_ven(
89
+ {
90
+ "objectType": "VEN_VEN_REQUEST",
91
+ "venName": ven_name,
92
+ }
93
+ )
90
94
  resp.raise_for_status()
91
95
  vid = resp.json().get("id")
92
96
  if not vid:
93
- raise RuntimeError(
94
- f"VEN registration failed: {resp.status_code} {resp.text}"
95
- )
97
+ raise RuntimeError(f"VEN registration failed: {resp.status_code} {resp.text}")
96
98
  log.info("VEN registered: name=%s id=%s", ven_name, vid)
97
99
  self._ven_id = vid
98
100
  self._ven_name = ven_name
@@ -100,12 +102,23 @@ class VenClient(BaseClient):
100
102
 
101
103
  # -- Program lookup --
102
104
 
103
- def find_program_by_name(self, name: str) -> dict[str, Any] | None:
104
- """Query VTN for a program by programName. Caches the ID on success."""
105
- result = self.api.find_program_by_name(name)
106
- if result and "id" in result:
107
- self._program_cache[name] = result["id"]
108
- return result
105
+ def find_program_by_name(self, name: str) -> Program | None:
106
+ """Query VTN for a program by programName. Caches the ID on success.
107
+
108
+ Returns a coerced Program model (or None), matching the shape of
109
+ programs() and poll_events() which also return coerced models.
110
+ """
111
+ raw = self.api.find_program_by_name(name)
112
+ if not raw:
113
+ return None
114
+ program = coerce(raw)
115
+ if not isinstance(program, Program):
116
+ raise TypeError(
117
+ f"Expected Program from find_program_by_name, got {type(program).__name__}"
118
+ )
119
+ if program.id:
120
+ self._program_cache[name] = program.id
121
+ return program
109
122
 
110
123
  def resolve_program_id(self, name: str) -> str:
111
124
  """Cached name→ID lookup. Queries VTN if not cached.
@@ -135,9 +148,7 @@ class VenClient(BaseClient):
135
148
  return False
136
149
  # VTN-RI returns a list of notifier dicts with "transport" field
137
150
  if isinstance(notifiers, list):
138
- return any(
139
- n.get("transport", "").upper() == "MQTT" for n in notifiers
140
- )
151
+ return any(n.get("transport", "").upper() == "MQTT" for n in notifiers)
141
152
  # Or it might be a dict with transport info
142
153
  return "mqtt" in str(notifiers).lower()
143
154
 
@@ -213,23 +224,27 @@ class VenClient(BaseClient):
213
224
  all_topics.extend(topics)
214
225
  elif isinstance(channel, WebhookChannel):
215
226
  # Create a VTN subscription pointing to the webhook
216
- self.api.create_subscription({
217
- "clientName": self._ven_name or "ven-client",
218
- "programID": program_id,
219
- "objectOperations": [{
220
- "objects": objects,
221
- "operations": operations,
222
- "callbackUrl": channel.callback_url,
223
- "bearerToken": channel._receiver.bearer_token,
224
- }],
225
- })
227
+ self.api.create_subscription(
228
+ {
229
+ "clientName": self._ven_name or "ven-client",
230
+ "programID": program_id,
231
+ "objectOperations": [
232
+ {
233
+ "objects": objects,
234
+ "operations": operations,
235
+ "callbackUrl": channel.callback_url,
236
+ "bearerToken": channel._receiver.bearer_token,
237
+ }
238
+ ],
239
+ }
240
+ )
226
241
 
227
242
  return all_topics
228
243
 
229
244
  # -- Poll events --
230
245
 
231
- def poll_events(self, program_name: str) -> list:
232
- """GET events filtered by program name."""
246
+ def poll_events(self, program_name: str) -> list[Event]:
247
+ """GET events filtered by program name. Returns coerced Event models."""
233
248
  program_id = self.resolve_program_id(program_name)
234
249
  return self.api.events(programID=program_id)
235
250
 
@@ -11,12 +11,12 @@ from __future__ import annotations
11
11
 
12
12
  import json
13
13
  import logging
14
+ import socket
14
15
  import threading
15
16
  import time
17
+ from collections.abc import Callable
16
18
  from dataclasses import dataclass
17
- from typing import Any, Callable
18
-
19
- import socket
19
+ from typing import Any
20
20
 
21
21
  from openadr3.entities import coerce_notification, is_notification
22
22
 
@@ -66,9 +66,7 @@ def _parse_webhook_payload(raw: bytes, path: str) -> Any:
66
66
  return s
67
67
 
68
68
  if isinstance(parsed, dict) and is_notification(parsed):
69
- return coerce_notification(
70
- parsed, {"openadr/channel": "webhook", "openadr/path": path}
71
- )
69
+ return coerce_notification(parsed, {"openadr/channel": "webhook", "openadr/path": path})
72
70
  return parsed
73
71
 
74
72
 
@@ -120,12 +118,12 @@ class WebhookReceiver:
120
118
  def start(self) -> None:
121
119
  """Start the webhook server in a background thread."""
122
120
  try:
123
- from flask import Flask, request, abort
124
- except ImportError:
121
+ from flask import Flask, abort, request
122
+ except ImportError as err:
125
123
  raise ImportError(
126
124
  "Flask is required for webhook support. "
127
125
  "Install it with: pip install python-oa3-client[webhooks]"
128
- )
126
+ ) from err
129
127
 
130
128
  app = Flask(__name__)
131
129
  # Suppress Flask/werkzeug request logging
@@ -177,7 +175,9 @@ class WebhookReceiver:
177
175
  self._server_thread.start()
178
176
  log.info(
179
177
  "Webhook server started: %s (bind=%s:%d)",
180
- self.callback_url, self.host, self.port,
178
+ self.callback_url,
179
+ self.host,
180
+ self.port,
181
181
  )
182
182
 
183
183
  def stop(self) -> None:
@@ -1,6 +1,6 @@
1
1
  """Tests for openadr3_client.base — lifecycle, __getattr__, auth."""
2
2
 
3
- from unittest.mock import patch, MagicMock
3
+ from unittest.mock import MagicMock, patch
4
4
 
5
5
  import pytest
6
6
 
@@ -1,6 +1,6 @@
1
1
  """Tests for openadr3_client.bl — BlClient."""
2
2
 
3
- from unittest.mock import patch, MagicMock
3
+ from unittest.mock import MagicMock, patch
4
4
 
5
5
  from openadr3_client.bl import BlClient
6
6
 
@@ -1,20 +1,19 @@
1
1
  """Tests for openadr3_client.discovery — mDNS/DNS-SD VTN discovery."""
2
2
 
3
- from unittest.mock import patch, MagicMock, PropertyMock
3
+ from unittest.mock import MagicMock, patch
4
4
 
5
5
  import pytest
6
6
 
7
7
  from openadr3_client.discovery import (
8
+ SERVICE_TYPE,
8
9
  DiscoveredVTN,
9
10
  DiscoveryMode,
10
- SERVICE_TYPE,
11
11
  _parse_txt_properties,
12
+ advertise_vtn,
12
13
  discover_vtns,
13
14
  resolve_url,
14
- advertise_vtn,
15
15
  )
16
16
 
17
-
18
17
  # -- _parse_txt_properties --
19
18
 
20
19
 
@@ -50,14 +49,18 @@ class TestParseTxtProperties:
50
49
  class TestDiscoveredVTN:
51
50
  def test_url_from_local_url(self):
52
51
  vtn = DiscoveredVTN(
53
- name="test", host="vtn.local", port=8080,
52
+ name="test",
53
+ host="vtn.local",
54
+ port=8080,
54
55
  local_url="http://vtn.local:8080/openadr3",
55
56
  )
56
57
  assert vtn.url == "http://vtn.local:8080/openadr3"
57
58
 
58
59
  def test_url_strips_trailing_slash(self):
59
60
  vtn = DiscoveredVTN(
60
- name="test", host="vtn.local", port=8080,
61
+ name="test",
62
+ host="vtn.local",
63
+ port=8080,
61
64
  local_url="http://vtn.local:8080/openadr3/",
62
65
  )
63
66
  assert vtn.url == "http://vtn.local:8080/openadr3"
@@ -68,7 +71,9 @@ class TestDiscoveredVTN:
68
71
 
69
72
  def test_url_constructed_with_base_path(self):
70
73
  vtn = DiscoveredVTN(
71
- name="test", host="vtn.local", port=8080,
74
+ name="test",
75
+ host="vtn.local",
76
+ port=8080,
72
77
  base_path="/openadr3",
73
78
  )
74
79
  assert vtn.url == "http://vtn.local:8080/openadr3"
@@ -197,8 +202,9 @@ class TestResolveUrl:
197
202
 
198
203
  @patch("openadr3_client.discovery.discover_vtns")
199
204
  def test_require_local_found(self, mock_discover):
200
- vtn = DiscoveredVTN(name="v", host="vtn.local", port=8080,
201
- local_url="http://vtn.local:8080")
205
+ vtn = DiscoveredVTN(
206
+ name="v", host="vtn.local", port=8080, local_url="http://vtn.local:8080"
207
+ )
202
208
  mock_discover.return_value = [vtn]
203
209
  assert resolve_url("require_local", None) == "http://vtn.local:8080"
204
210
 
@@ -210,8 +216,9 @@ class TestResolveUrl:
210
216
 
211
217
  @patch("openadr3_client.discovery.discover_vtns")
212
218
  def test_prefer_local_found(self, mock_discover):
213
- vtn = DiscoveredVTN(name="v", host="vtn.local", port=8080,
214
- local_url="http://vtn.local:8080")
219
+ vtn = DiscoveredVTN(
220
+ name="v", host="vtn.local", port=8080, local_url="http://vtn.local:8080"
221
+ )
215
222
  mock_discover.return_value = [vtn]
216
223
  assert resolve_url("prefer_local", "http://cloud.vtn.com") == "http://vtn.local:8080"
217
224
 
@@ -228,8 +235,9 @@ class TestResolveUrl:
228
235
 
229
236
  @patch("openadr3_client.discovery.discover_vtns")
230
237
  def test_local_with_fallback_found(self, mock_discover):
231
- vtn = DiscoveredVTN(name="v", host="vtn.local", port=8080,
232
- local_url="http://vtn.local:8080")
238
+ vtn = DiscoveredVTN(
239
+ name="v", host="vtn.local", port=8080, local_url="http://vtn.local:8080"
240
+ )
233
241
  mock_discover.return_value = [vtn]
234
242
  assert resolve_url("local_with_fallback", "http://cloud.vtn.com") == "http://vtn.local:8080"
235
243
 
@@ -269,7 +277,7 @@ class TestAdvertiseVtn:
269
277
  mock_zc_instance = MagicMock()
270
278
  mock_zc_mod.Zeroconf.return_value = mock_zc_instance
271
279
 
272
- with advertise_vtn("127.0.0.1", 8080) as adv:
280
+ with advertise_vtn("127.0.0.1", 8080):
273
281
  mock_zc_instance.register_service.assert_called_once()
274
282
  mock_zc_instance.unregister_service.assert_called_once()
275
283
  mock_zc_instance.close.assert_called_once()
@@ -282,7 +290,8 @@ class TestAdvertiseVtn:
282
290
  mock_zc_mod.Zeroconf.return_value = mock_zc_instance
283
291
 
284
292
  advertise_vtn(
285
- "127.0.0.1", 8080,
293
+ "127.0.0.1",
294
+ 8080,
286
295
  base_path="/openadr3",
287
296
  version="3.1.0",
288
297
  program_names="prog1,prog2",
@@ -302,22 +311,26 @@ class TestAdvertiseVtn:
302
311
  class TestBaseClientDiscovery:
303
312
  def test_default_discovery_never_requires_url(self):
304
313
  from openadr3_client.base import BaseClient
314
+
305
315
  with pytest.raises(ValueError, match="url is required"):
306
316
  BaseClient(token="tok")
307
317
 
308
318
  def test_local_with_fallback_requires_url(self):
309
319
  from openadr3_client.base import BaseClient
320
+
310
321
  with pytest.raises(ValueError, match="url is required.*local_with_fallback"):
311
322
  BaseClient(token="tok", discovery="local_with_fallback")
312
323
 
313
324
  def test_require_local_no_url_ok(self):
314
325
  from openadr3_client.base import BaseClient
326
+
315
327
  c = BaseClient(token="tok", discovery="require_local")
316
328
  assert c.url is None
317
329
  assert c.discovery_mode == DiscoveryMode.REQUIRE_LOCAL
318
330
 
319
331
  def test_prefer_local_no_url_ok(self):
320
332
  from openadr3_client.base import BaseClient
333
+
321
334
  c = BaseClient(token="tok", discovery="prefer_local")
322
335
  assert c.url is None
323
336
 
@@ -325,13 +338,16 @@ class TestBaseClientDiscovery:
325
338
  @patch("openadr3_client.base.create_ven_client")
326
339
  def test_start_uses_resolved_url(self, mock_create, mock_resolve):
327
340
  from openadr3_client.base import BaseClient
341
+
328
342
  mock_create.return_value = MagicMock()
329
343
 
330
344
  c = BaseClient(token="tok", discovery="require_local")
331
345
  c.start()
332
346
 
333
347
  mock_resolve.assert_called_once_with(
334
- DiscoveryMode.REQUIRE_LOCAL, None, 3.0,
348
+ DiscoveryMode.REQUIRE_LOCAL,
349
+ None,
350
+ 3.0,
335
351
  )
336
352
  mock_create.assert_called_once()
337
353
  assert mock_create.call_args[1]["base_url"] == "http://discovered:8080"
@@ -341,11 +357,14 @@ class TestBaseClientDiscovery:
341
357
  @patch("openadr3_client.base.create_ven_client")
342
358
  def test_start_never_mode_passes_url(self, mock_create, mock_resolve):
343
359
  from openadr3_client.base import BaseClient
360
+
344
361
  mock_create.return_value = MagicMock()
345
362
 
346
363
  c = BaseClient(url="http://test", token="tok")
347
364
  c.start()
348
365
 
349
366
  mock_resolve.assert_called_once_with(
350
- DiscoveryMode.NEVER, "http://test", 3.0,
367
+ DiscoveryMode.NEVER,
368
+ "http://test",
369
+ 3.0,
351
370
  )
@@ -1,17 +1,14 @@
1
1
  """Tests for openadr3_client.mqtt."""
2
2
 
3
3
  import json
4
- import time
5
- import threading
6
- from unittest.mock import patch, MagicMock
4
+ from unittest.mock import MagicMock, patch
7
5
 
8
6
  import pytest
9
7
 
10
8
  from openadr3_client.mqtt import (
11
9
  MQTTConnection,
12
- MQTTMessage,
13
- normalize_broker_uri,
14
10
  _parse_payload,
11
+ normalize_broker_uri,
15
12
  )
16
13
 
17
14
 
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch
4
4
 
5
5
  from openadr3_client.notifications import (
6
6
  MqttChannel,
7
- NotificationChannel,
8
7
  WebhookChannel,
9
8
  )
10
9
 
@@ -12,12 +11,26 @@ from openadr3_client.notifications import (
12
11
  class TestNotificationChannelProtocol:
13
12
  def test_mqtt_channel_has_protocol_methods(self):
14
13
  """MqttChannel has all NotificationChannel methods."""
15
- for name in ("start", "stop", "subscribe_topics", "messages", "await_messages", "clear_messages"):
14
+ for name in (
15
+ "start",
16
+ "stop",
17
+ "subscribe_topics",
18
+ "messages",
19
+ "await_messages",
20
+ "clear_messages",
21
+ ):
16
22
  assert hasattr(MqttChannel, name), f"MqttChannel missing {name}"
17
23
 
18
24
  def test_webhook_channel_has_protocol_methods(self):
19
25
  """WebhookChannel has all NotificationChannel methods."""
20
- for name in ("start", "stop", "subscribe_topics", "messages", "await_messages", "clear_messages"):
26
+ for name in (
27
+ "start",
28
+ "stop",
29
+ "subscribe_topics",
30
+ "messages",
31
+ "await_messages",
32
+ "clear_messages",
33
+ ):
21
34
  assert hasattr(WebhookChannel, name), f"WebhookChannel missing {name}"
22
35
 
23
36
 
@@ -1,9 +1,10 @@
1
1
  """Tests for openadr3_client.ven — VenClient registration, program lookup, subscribe."""
2
2
 
3
- from unittest.mock import patch, MagicMock
3
+ from unittest.mock import MagicMock, patch
4
4
 
5
5
  import httpx
6
6
  import pytest
7
+ from openadr3.entities.models import Program
7
8
 
8
9
  from openadr3_client.ven import VenClient, extract_topics
9
10
 
@@ -54,16 +55,20 @@ class TestVenClientRegistration:
54
55
  def test_register_new_ven(self, mock_create):
55
56
  mock_api = MagicMock()
56
57
  mock_api.find_ven_by_name.return_value = None
57
- mock_api.create_ven.return_value = _make_response(201, {"id": "new-ven-456", "venName": "my-ven"})
58
+ mock_api.create_ven.return_value = _make_response(
59
+ 201, {"id": "new-ven-456", "venName": "my-ven"}
60
+ )
58
61
  mock_create.return_value = mock_api
59
62
 
60
63
  with VenClient(url="http://test", token="tok") as ven:
61
64
  ven.register("my-ven")
62
65
  assert ven.ven_id == "new-ven-456"
63
- mock_api.create_ven.assert_called_once_with({
64
- "objectType": "VEN_VEN_REQUEST",
65
- "venName": "my-ven",
66
- })
66
+ mock_api.create_ven.assert_called_once_with(
67
+ {
68
+ "objectType": "VEN_VEN_REQUEST",
69
+ "venName": "my-ven",
70
+ }
71
+ )
67
72
 
68
73
  def test_require_ven_id_not_registered(self):
69
74
  ven = VenClient(url="http://test", token="tok")
@@ -76,13 +81,17 @@ class TestVenClientProgramLookup:
76
81
  def test_find_program_by_name(self, mock_create):
77
82
  mock_api = MagicMock()
78
83
  mock_api.find_program_by_name.return_value = {
79
- "id": "prog-1", "programName": "pricing"
84
+ "objectType": "PROGRAM",
85
+ "id": "prog-1",
86
+ "programName": "pricing",
80
87
  }
81
88
  mock_create.return_value = mock_api
82
89
 
83
90
  with VenClient(url="http://test", token="tok") as ven:
84
91
  result = ven.find_program_by_name("pricing")
85
- assert result["id"] == "prog-1"
92
+ assert isinstance(result, Program)
93
+ assert result.id == "prog-1"
94
+ assert result.program_name == "pricing"
86
95
  assert ven._program_cache["pricing"] == "prog-1"
87
96
 
88
97
  @patch("openadr3_client.base.create_ven_client")
@@ -111,7 +120,9 @@ class TestVenClientProgramLookup:
111
120
  def test_resolve_program_id_queries(self, mock_create):
112
121
  mock_api = MagicMock()
113
122
  mock_api.find_program_by_name.return_value = {
114
- "id": "prog-2", "programName": "dr-program"
123
+ "objectType": "PROGRAM",
124
+ "id": "prog-2",
125
+ "programName": "dr-program",
115
126
  }
116
127
  mock_create.return_value = mock_api
117
128
 
@@ -125,18 +136,23 @@ class TestVenClientProgramLookup:
125
136
  mock_api.find_program_by_name.return_value = None
126
137
  mock_create.return_value = mock_api
127
138
 
128
- with VenClient(url="http://test", token="tok") as ven:
129
- with pytest.raises(KeyError, match="Program not found"):
130
- ven.resolve_program_id("missing")
139
+ with (
140
+ VenClient(url="http://test", token="tok") as ven,
141
+ pytest.raises(KeyError, match="Program not found"),
142
+ ):
143
+ ven.resolve_program_id("missing")
131
144
 
132
145
 
133
146
  class TestVenClientNotifiers:
134
147
  @patch("openadr3_client.base.create_ven_client")
135
148
  def test_discover_notifiers(self, mock_create):
136
149
  mock_api = MagicMock()
137
- mock_api.get_notifiers.return_value = _make_response(200, [
138
- {"transport": "MQTT", "url": "mqtt://broker:1883"},
139
- ])
150
+ mock_api.get_notifiers.return_value = _make_response(
151
+ 200,
152
+ [
153
+ {"transport": "MQTT", "url": "mqtt://broker:1883"},
154
+ ],
155
+ )
140
156
  mock_create.return_value = mock_api
141
157
 
142
158
  with VenClient(url="http://test", token="tok") as ven:
@@ -147,9 +163,12 @@ class TestVenClientNotifiers:
147
163
  @patch("openadr3_client.base.create_ven_client")
148
164
  def test_vtn_supports_mqtt_true(self, mock_create):
149
165
  mock_api = MagicMock()
150
- mock_api.get_notifiers.return_value = _make_response(200, [
151
- {"transport": "MQTT", "url": "mqtt://broker:1883"},
152
- ])
166
+ mock_api.get_notifiers.return_value = _make_response(
167
+ 200,
168
+ [
169
+ {"transport": "MQTT", "url": "mqtt://broker:1883"},
170
+ ],
171
+ )
153
172
  mock_create.return_value = mock_api
154
173
 
155
174
  with VenClient(url="http://test", token="tok") as ven:
@@ -158,9 +177,12 @@ class TestVenClientNotifiers:
158
177
  @patch("openadr3_client.base.create_ven_client")
159
178
  def test_vtn_supports_mqtt_false(self, mock_create):
160
179
  mock_api = MagicMock()
161
- mock_api.get_notifiers.return_value = _make_response(200, [
162
- {"transport": "HTTP", "url": "http://example.com"},
163
- ])
180
+ mock_api.get_notifiers.return_value = _make_response(
181
+ 200,
182
+ [
183
+ {"transport": "HTTP", "url": "http://example.com"},
184
+ ],
185
+ )
164
186
  mock_create.return_value = mock_api
165
187
 
166
188
  with VenClient(url="http://test", token="tok") as ven:
@@ -200,9 +222,11 @@ class TestVenClientMqttTopics:
200
222
  @patch("openadr3_client.base.create_ven_client")
201
223
  def test_ven_scoped_not_registered(self, mock_create):
202
224
  mock_create.return_value = MagicMock()
203
- with VenClient(url="http://test", token="tok") as ven:
204
- with pytest.raises(RuntimeError, match="VEN not registered"):
205
- ven.get_mqtt_topics_ven()
225
+ with (
226
+ VenClient(url="http://test", token="tok") as ven,
227
+ pytest.raises(RuntimeError, match="VEN not registered"),
228
+ ):
229
+ ven.get_mqtt_topics_ven()
206
230
 
207
231
 
208
232
  class TestVenClientGetattr:
@@ -228,7 +252,9 @@ class TestVenClientSubscribe:
228
252
  def test_subscribe_mqtt(self, mock_create):
229
253
  mock_api = MagicMock()
230
254
  mock_api.find_program_by_name.return_value = {
231
- "id": "prog-1", "programName": "pricing"
255
+ "objectType": "PROGRAM",
256
+ "id": "prog-1",
257
+ "programName": "pricing",
232
258
  }
233
259
  mock_api.get_mqtt_topics_program_events.return_value = _make_response(
234
260
  200, {"topics": {"a": "openadr3/programs/prog-1/events"}}
@@ -236,9 +262,10 @@ class TestVenClientSubscribe:
236
262
  mock_create.return_value = mock_api
237
263
 
238
264
  with VenClient(url="http://test", token="tok") as ven:
239
- from openadr3_client.notifications import MqttChannel
240
265
  from unittest.mock import patch as p
241
266
 
267
+ from openadr3_client.notifications import MqttChannel
268
+
242
269
  with p.object(MqttChannel, "subscribe_topics") as mock_sub:
243
270
  ch = MqttChannel.__new__(MqttChannel)
244
271
  ch._conn = MagicMock()
@@ -1,15 +1,14 @@
1
1
  """Tests for openadr3_client.webhook."""
2
2
 
3
3
  import json
4
- import time
5
4
  import threading
5
+ import time
6
6
 
7
- import pytest
8
7
  import httpx
8
+ import pytest
9
9
 
10
10
  from openadr3_client.webhook import (
11
11
  WebhookReceiver,
12
- WebhookMessage,
13
12
  _parse_webhook_payload,
14
13
  detect_lan_ip,
15
14
  )