Skip to content

Commit 99886da

Browse files
dcjclaude
andcommitted
Add Ruff formatting and linting
Adds [tool.ruff] config (line-length 100, py310 target, E/W/F/I/B/UP/SIM rules), ruff to dev deps, a lint CI workflow, and a pre-commit config. Initial format pass applied across src/ and tests/. Fixes surfaced by ruff check: - mqtt.py: move MqttClient annotation under TYPE_CHECKING (was F821 undefined at runtime due to deferred import) - mqtt.py, webhook.py: raise ImportError ... from err (B904) - test_discovery.py: drop unused `as adv` binding (F841) - test_ven.py: combine nested with-statements (SIM117) Closes OA3C-1t4. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ea7f328 commit 99886da

17 files changed

Lines changed: 224 additions & 134 deletions

.github/workflows/lint.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

pyproject.toml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ mqtt = ["ebus-mqtt-client>=0.1.0"]
3333
webhooks = ["flask>=3.0.0"]
3434
mdns = ["zeroconf>=0.131.0"]
3535
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"]
3737

3838
[project.urls]
3939
Homepage = "https://grid-coordination.energy"
@@ -51,3 +51,26 @@ exclude = [
5151
".beads/",
5252
"examples/",
5353
]
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

src/openadr3_client/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
from openadr3_client.base import BaseClient
2-
from openadr3_client.ven import VenClient, extract_topics
32
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
113
from openadr3_client.discovery import (
124
DiscoveredVTN,
135
DiscoveryMode,
146
advertise_vtn,
157
discover_vtns,
168
)
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
1717

1818
__all__ = [
1919
# Clients

src/openadr3_client/base.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,15 @@ def start(self) -> BaseClient:
7373
if self._api:
7474
log.info(
7575
"%s already started: url=%s",
76-
type(self).__name__, self._resolved_url,
76+
type(self).__name__,
77+
self._resolved_url,
7778
)
7879
return self
7980

8081
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,
8285
)
8386

8487
if not self.token:
@@ -98,7 +101,9 @@ def start(self) -> BaseClient:
98101
)
99102
log.info(
100103
"%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,
102107
)
103108
return self
104109

@@ -120,9 +125,7 @@ def __exit__(self, *args: Any) -> None:
120125
def api(self) -> OpenADRClient:
121126
"""The underlying OpenADRClient. Raises if not started."""
122127
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.")
126129
return self._api
127130

128131
# -- __getattr__ delegation --
@@ -136,6 +139,4 @@ def __getattr__(self, name: str) -> Any:
136139
return getattr(api, name)
137140
except AttributeError:
138141
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}'")

src/openadr3_client/discovery.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import logging
1313
import threading
14-
from dataclasses import dataclass, field
14+
from dataclasses import dataclass
1515
from enum import Enum
1616
from typing import Any
1717

@@ -71,8 +71,10 @@ def from_service_info(cls, info: Any) -> DiscoveredVTN:
7171
"""Build from a ``zeroconf.ServiceInfo`` object."""
7272
props = _parse_txt_properties(info.properties or {})
7373
# 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")
7678
)
7779
return cls(
7880
name=info.name,
@@ -91,6 +93,7 @@ def _import_zeroconf():
9193
"""Lazy-import zeroconf with a helpful error message."""
9294
try:
9395
import zeroconf # noqa: F811
96+
9497
return zeroconf
9598
except ImportError:
9699
raise ImportError(
@@ -157,19 +160,15 @@ def resolve_url(
157160

158161
if mode == DiscoveryMode.REQUIRE_LOCAL:
159162
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")
163164
return discovered_url
164165

165166
if mode == DiscoveryMode.PREFER_LOCAL:
166167
if discovered_url:
167168
return discovered_url
168169
if configured_url:
169170
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")
173172

174173
# LOCAL_WITH_FALLBACK
175174
if discovered_url:
@@ -241,9 +240,7 @@ def advertise_vtn(
241240
server=f"{host}.",
242241
port=port,
243242
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)],
247244
)
248245

249246
zc = Zeroconf()

src/openadr3_client/mqtt.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99

1010
import json
1111
import logging
12-
import re
1312
import threading
1413
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
1717
from urllib.parse import urlparse
1818

1919
from openadr3.entities import coerce_notification, is_notification
2020

21+
if TYPE_CHECKING:
22+
from ebus_mqtt_client import MqttClient
23+
2124
log = logging.getLogger(__name__)
2225

2326

@@ -54,9 +57,7 @@ def _parse_payload(raw: bytes, topic: str) -> Any:
5457
return s
5558

5659
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})
6061
return parsed
6162

6263

@@ -96,11 +97,11 @@ def connect(self) -> None:
9697
"""Connect to the MQTT broker."""
9798
try:
9899
from ebus_mqtt_client import MqttClient
99-
except ImportError:
100+
except ImportError as err:
100101
raise ImportError(
101102
"ebus-mqtt-client is required for MQTT support. "
102103
"Install it with: pip install python-oa3-client[mqtt]"
103-
)
104+
) from err
104105

105106
host, port, use_tls = normalize_broker_uri(self.broker_url)
106107
self._client = MqttClient(

src/openadr3_client/notifications.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
from __future__ import annotations
88

99
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
1112

1213
from openadr3_client.mqtt import MQTTConnection, MQTTMessage
13-
from openadr3_client.webhook import WebhookReceiver, WebhookMessage
14+
from openadr3_client.webhook import WebhookMessage, WebhookReceiver
1415

1516
log = logging.getLogger(__name__)
1617

src/openadr3_client/ven.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import Any, Callable
6+
from collections.abc import Callable
7+
from typing import Any
78

89
import httpx
9-
10-
from openadr3.api import success, body
10+
from openadr3.api import success
1111

1212
from openadr3_client.base import BaseClient
1313
from openadr3_client.notifications import (
@@ -83,16 +83,16 @@ def register(self, ven_name: str) -> VenClient:
8383
vid = existing["id"]
8484
log.info("VEN found, reusing: name=%s id=%s", ven_name, vid)
8585
else:
86-
resp = self.api.create_ven({
87-
"objectType": "VEN_VEN_REQUEST",
88-
"venName": ven_name,
89-
})
86+
resp = self.api.create_ven(
87+
{
88+
"objectType": "VEN_VEN_REQUEST",
89+
"venName": ven_name,
90+
}
91+
)
9092
resp.raise_for_status()
9193
vid = resp.json().get("id")
9294
if not vid:
93-
raise RuntimeError(
94-
f"VEN registration failed: {resp.status_code} {resp.text}"
95-
)
95+
raise RuntimeError(f"VEN registration failed: {resp.status_code} {resp.text}")
9696
log.info("VEN registered: name=%s id=%s", ven_name, vid)
9797
self._ven_id = vid
9898
self._ven_name = ven_name
@@ -135,9 +135,7 @@ def vtn_supports_mqtt(self) -> bool:
135135
return False
136136
# VTN-RI returns a list of notifier dicts with "transport" field
137137
if isinstance(notifiers, list):
138-
return any(
139-
n.get("transport", "").upper() == "MQTT" for n in notifiers
140-
)
138+
return any(n.get("transport", "").upper() == "MQTT" for n in notifiers)
141139
# Or it might be a dict with transport info
142140
return "mqtt" in str(notifiers).lower()
143141

@@ -213,16 +211,20 @@ def subscribe(
213211
all_topics.extend(topics)
214212
elif isinstance(channel, WebhookChannel):
215213
# 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-
})
214+
self.api.create_subscription(
215+
{
216+
"clientName": self._ven_name or "ven-client",
217+
"programID": program_id,
218+
"objectOperations": [
219+
{
220+
"objects": objects,
221+
"operations": operations,
222+
"callbackUrl": channel.callback_url,
223+
"bearerToken": channel._receiver.bearer_token,
224+
}
225+
],
226+
}
227+
)
226228

227229
return all_topics
228230

src/openadr3_client/webhook.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111

1212
import json
1313
import logging
14+
import socket
1415
import threading
1516
import time
17+
from collections.abc import Callable
1618
from dataclasses import dataclass
17-
from typing import Any, Callable
18-
19-
import socket
19+
from typing import Any
2020

2121
from openadr3.entities import coerce_notification, is_notification
2222

@@ -66,9 +66,7 @@ def _parse_webhook_payload(raw: bytes, path: str) -> Any:
6666
return s
6767

6868
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})
7270
return parsed
7371

7472

@@ -120,12 +118,12 @@ def callback_url(self) -> str:
120118
def start(self) -> None:
121119
"""Start the webhook server in a background thread."""
122120
try:
123-
from flask import Flask, request, abort
124-
except ImportError:
121+
from flask import Flask, abort, request
122+
except ImportError as err:
125123
raise ImportError(
126124
"Flask is required for webhook support. "
127125
"Install it with: pip install python-oa3-client[webhooks]"
128-
)
126+
) from err
129127

130128
app = Flask(__name__)
131129
# Suppress Flask/werkzeug request logging
@@ -177,7 +175,9 @@ def health():
177175
self._server_thread.start()
178176
log.info(
179177
"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,
181181
)
182182

183183
def stop(self) -> None:

0 commit comments

Comments
 (0)