Skip to content

Commit ff14189

Browse files
dcjclaude
andcommitted
Document and test pendulum.DateTime propagation through channels
The client itself does no datetime parsing — all time handling is delegated to python-oa3. tests/test_time_propagation.py locks in end-to-end behavior through MqttChannel and WebhookChannel parse paths: pendulum.DateTime is surfaced, and the wire offset (Z, +00:00, -07:00, +05:30) is preserved without normalization. README gains a "Time and timezones" section pointing at python-oa3 as the canonical specification, with a parity note for clj-oa3-client. Closes OA3C-xdt. Wave 2 of the cross-repo ZonedDateTime migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5dc2fc commit ff14189

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,27 @@ When VenClient stops (via `stop()` or context manager exit), all channels are st
339339
| `time` | `float` | Unix timestamp |
340340
| `raw_payload` | `bytes` | Original request body |
341341

342+
## Time and timezones
343+
344+
`python-oa3-client` does no datetime parsing of its own — all time handling is
345+
delegated to [openadr3](https://github.com/grid-coordination/python-oa3),
346+
which is the reference compliant implementation of the cross-implementation
347+
zone-handling rule.
348+
349+
- Datetime fields on coerced entities (e.g. `event.created`,
350+
`interval_period.start`) and on coerced notification payloads (delivered
351+
by `MqttChannel` and `WebhookChannel`) are surfaced as zone-aware
352+
`pendulum.DateTime`.
353+
- The wire string's UTC offset is the **source of truth** and is preserved
354+
end-to-end. `Z`, `+00:00`, `-07:00`, `+05:30` round-trip exactly — no
355+
normalization. See [python-oa3 README — Time and Timezones](https://github.com/grid-coordination/python-oa3#time-and-timezones)
356+
for the canonical specification and round-trip table.
357+
- Cross-implementation parity with `clj-oa3-client` (which surfaces
358+
`java.time.ZonedDateTime` under the same rule).
359+
360+
Propagation through this client's MQTT and webhook parse paths is locked in
361+
by `tests/test_time_propagation.py`.
362+
342363
## Direct API access
343364

344365
All `OpenADRClient` methods are available on both VenClient and BlClient via `__getattr__`:

tests/test_time_propagation.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Regression tests for pendulum.DateTime propagation through notification channels.
2+
3+
The client itself does no datetime parsing — it forwards payloads to
4+
``openadr3.entities.coerce_notification`` (from python-oa3). These tests
5+
lock in that behavior so a regression upstream or in our parse glue would
6+
be caught here.
7+
8+
The OpenADR 3 wire-offset rule: the wire string's offset is preserved
9+
end-to-end and is NOT normalized. ``Z``, ``+00:00``, ``-07:00``, ``+05:30``
10+
all round-trip exactly.
11+
"""
12+
13+
import json
14+
15+
import pendulum
16+
17+
from openadr3_client.mqtt import _parse_payload
18+
from openadr3_client.webhook import _parse_webhook_payload
19+
20+
21+
def _notification_with_offset(offset: str) -> bytes:
22+
return json.dumps(
23+
{
24+
"objectType": "PROGRAM",
25+
"operation": "POST",
26+
"object": {
27+
"objectType": "PROGRAM",
28+
"id": "p1",
29+
"programName": "test",
30+
"createdDateTime": f"2024-06-15T10:30:00{offset}",
31+
"modificationDateTime": f"2024-06-15T10:30:00{offset}",
32+
},
33+
}
34+
).encode()
35+
36+
37+
class TestPendulumDateTimePropagation:
38+
"""Wire-offset preservation across MQTT and webhook coercion paths."""
39+
40+
def test_mqtt_payload_yields_pendulum_datetime(self):
41+
msg = _parse_payload(_notification_with_offset("Z"), "programs/create")
42+
assert isinstance(msg.object.created, pendulum.DateTime)
43+
assert isinstance(msg.object.modified, pendulum.DateTime)
44+
45+
def test_webhook_payload_yields_pendulum_datetime(self):
46+
msg = _parse_webhook_payload(_notification_with_offset("Z"), "/notifications")
47+
assert isinstance(msg.object.created, pendulum.DateTime)
48+
49+
def test_mqtt_negative_offset_preserved(self):
50+
msg = _parse_payload(_notification_with_offset("-07:00"), "programs/create")
51+
assert msg.object.created.utcoffset().total_seconds() == -7 * 3600
52+
53+
def test_webhook_half_hour_offset_preserved(self):
54+
msg = _parse_webhook_payload(_notification_with_offset("+05:30"), "/notifications")
55+
assert msg.object.created.utcoffset().total_seconds() == 5.5 * 3600
56+
57+
def test_mqtt_z_not_normalized_to_plus_zero(self):
58+
# Z and +00:00 are distinct wire forms; the parser must preserve which
59+
# one came in. Round-trip the parsed DateTime back to ISO 8601 and
60+
# confirm it ends with "Z".
61+
msg = _parse_payload(_notification_with_offset("Z"), "programs/create")
62+
assert msg.object.created.to_iso8601_string().endswith("Z")
63+
64+
def test_webhook_plus_zero_not_normalized_to_z(self):
65+
msg = _parse_webhook_payload(_notification_with_offset("+00:00"), "/notifications")
66+
assert msg.object.created.to_iso8601_string().endswith("+00:00")

0 commit comments

Comments
 (0)