Skip to content

Commit 58e825a

Browse files
bokelleyclaude
andauthored
feat(reporting): compute owed reporting-subscription periods and their capabilities (#1696)
* feat(reporting): compute owed reporting-subscription periods and their capabilities Add adcp.reporting.subscriptions, the shared half of AdCP 3.3 principal-level reporting subscriptions (media_buy.reporting_subscriptions): - expected_subscription_periods(): the coverage rule from core/reporting-subscription.json as a pure function over plain data. It owes each confirmed buy and resolved offering the complete periods that start at or after the latest of the version's accepted_at, the buy's confirmed_at and the account's authorized_at, and that overlap the flight as of each period's scope_resolved_at. It applies cutovers for versions, deactivation, resolution changes and account timezone changes, removes exceptions and gaps over [from, until), and handles revocation and re-grant with and without backfill_on_regrant. Results are ExpectedReportingPeriod values, so they feed evaluate_reporting_ledger and the consumer-status planner unchanged. - resolve_subscription_offerings(): exact / finest / all selection with ties to the lowest offering_id in byte order. - subscription_capabilities() and advertise_reporting_subscriptions(): configuration_tasks, the deprecated configuration_task only when sync_accounts is listed, the subscriptions block, the feature id and the principal section. Civil-time schedules carry the normative local-midnight origin explicitly, so they do not depend on the ledger's legacy anchor default. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(reporting): derive subscription periods exactly as the TypeScript SDK does Align expected_subscription_periods with adcontextprotocol/adcp-client#3505 so that both SDKs owe identical periods. The spec leaves each of these choices open: - A dedicated period calendar. Years, months and days are local civil arithmetic with the day clamped from the origin. Hours, minutes and seconds are exact elapsed time. Calendar months (P1M billing cycles) are supported, and numeric UTC offsets are refused. - Cutovers are paired per buy over the union of its packages. A continuing offering hands off at E of its own outgoing calendar. A newly resolved one starts at the latest E of everything the previous regime resolved. - A period_timezone override moves account_timezone and account-resolved schedules into its zone, while utc and fixed-zone schedules match only their own zone. - Versions may carry in-place inactive intervals and their destination method. The coverage entry's timezone_cutovers are applied. - A re-grant continues authority only before unauthorized_at plus status_retention_days, and a re-grant without retention is an error. The TypeScript SDK's 46-case table is vendored and run as a parity test. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(reporting): adopt the reporting-subscription rules settled in adcp#8585 adcontextprotocol/adcp#8585 settles the ambiguities both SDKs hit. - Offerings publish standard_dimensions and unmapped_dimensions. SubscriptionOffering.from_offering reads them from the wire, and sellers use with_subscription_dimensions() to publish them. subscription_capabilities() refuses an offering without them. - Coverage consumes the readback's inactive_intervals. - timezone_cutovers[] carry previous_timezone and an optional offering_id, and gaps[] carry an optional offering_id. An entry with an offering_id applies to that offering only, and previous_timezone supplies the zone before the first cutover. - period_timezone follows the normative text: a utc offering matches only UTC or Etc/UTC, a source_timezone offering only its published zone (never an account-resolved one), and an account_timezone offering takes the override. The beta.1 models close these objects, so _reporting_subscription_preview adds the fields to the generated classes until a 3.3.0-beta.2 bundle carries them. The vendored TypeScript parity table keeps a scope valid under maxProperties 1 in three cases, matching the fix pending upstream. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(reporting): take each offering's prior zone from its own timezone cutover Follow adcp#8585: the account's timezone is its current zone, as list_accounts shows it. Before a timezone change, an offering's account-timezone periods use that change's previous_timezone, considering only the changes that apply to the offering. An offering with no applicable change uses the current zone throughout. A timezone change without previous_timezone is now refused, and so is a wire offering without standard_dimensions. Previously the first cutover's previous_timezone became the base zone for every offering, so a cutover scoped to one offering could shift another offering's boundaries. Re-vendor the TypeScript parity table at f8b2fddd (54 cases, with the scope fix now upstream). Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(reporting): serve adcp#8585 subscription fields only to 3.3.0-beta.2 callers The server validates responses against the release it serves, and the pinned 3.3.0-beta.1 schemas close the objects adcp#8585 extends. The new wire module gates the new fields on 3.3.0-beta.2 or later, as the TypeScript SDK does: - standard_dimensions and unmapped_dimensions on offerings; - inactive_intervals on the readback; - previous_timezone and offering_id on timezone_cutovers[] and gaps[]. For older callers, per-offering gaps collapse into one account-wide gap spanning them, and per-offering cutovers into one entry at the latest effective_at. The parity test now also validates every subscription in the TypeScript table against core/reporting-subscription.json. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(reporting): address the coverage-rule review and adopt adcp#8588 periods Fixes from the expert review of the coverage rule: - An empty flight (stopped at or before it started) overlaps no period, so a buy canceled before its flight owes nothing. - Each regime carries its per-offering handoff. E for a continuing or new offering is the latest of the outgoing calendar's boundary and the outgoing regime's own handoff, so two cutovers inside one period never owe a period twice. A timezone change passed at T or at its E now gives the same result. - The extensible reporting-dimension vocabulary parses on selectors, offerings and the capability block; an unknown selector value matches nothing. - Zone names must be IANA zones (posixrules and right/... are refused), and offerings with a year or month delivery_sla are refused at construction. - The beta.1 projection keeps distinct timezone changes distinct and drops prior_versions[].configuration. The feature id is refused without the principal section. Stale docstrings are corrected. adcp#8588 (pending for 3.3.0-beta.2): - Sub-daily periods under a local alignment restart at each local midnight on wall-clock multiples, so a DST day has 23 or 25 hourly periods. The duration must divide 24 hours and cannot mix date and time components. - prior_versions[].configuration is consumed, and require_prior_configurations makes a missing one an error. A version accepted while inactive has an empty window. The parity runner now checks row order, expected_obligations and the error class. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(reporting): accept undated continuation re-grants and re-vendor the parity table Re-vendor the TypeScript coverage table at a99597a4: 79 cases, adding - empty flights; - two cutovers inside one period; - sub-daily local periods; - DST gaps and folds at midnight; - a skipped civil date; - PT24H versus P1D; - month-end clamps; - three-grant chains; - simultaneous changes; - unknown dimensions. All pass under the strict runner. Match TS on one rule the table pins: a grant whose authorized_at is at or before the previous loss continues the chain at an unknown instant. This happens because adcp#8585 keeps authorized_at at the chain start after a re-grant inside retention. The buyer removes no gap of its own; the seller's gaps[] decide. Grants are read in input order. The schema check accepts dimension values outside the beta.1 enum, because the vocabulary is x-extensible. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(reporting): owe the period containing confirmed_at in full, per adcp#8593 Owner decision for 3.3.0-beta.2: - The start bound is the first boundary at or after the later of the version's accepted_at and the account's authorized_at. - A buy is then owed every period that ends after its confirmed_at, so the confirmation period is owed in full. - An empty flight still owes nothing. The rule lives in the one function that holds it, _first_owed_start. Re-vendor the TypeScript parity table at f8b48c0c. It has 81 cases, conformed to the same decision, and adds an ASAP buy confirmed mid-day and a monthly billing buy confirmed mid-month. All pass under the strict runner. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 50fe9e8 commit 58e825a

14 files changed

Lines changed: 18343 additions & 0 deletions

‎src/adcp/reporting/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@
161161
"service",
162162
"service_production",
163163
"source",
164+
"subscriptions",
164165
"testing",
165166
}
166167
)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""AdCP 3.3 principal-level reporting subscriptions (experimental).
2+
3+
``media_buy.reporting_subscriptions`` lets a caller register reporting once on
4+
its principal through ``sync_principal`` instead of once per account through
5+
``sync_accounts``. This package holds the parts both sides share:
6+
7+
* :func:`expected_subscription_periods` -- the pure coverage rule. A buyer runs
8+
it from its own records to learn exactly which periods it is owed; the
9+
seller runtime runs it over the same facts to decide which obligations it
10+
owes.
11+
* :func:`resolve_subscription_offerings` -- deterministic selector resolution
12+
(``exact`` / ``finest`` / ``all``, ties to the lowest ``offering_id``).
13+
* :func:`subscription_capabilities` -- the capability fields a seller that
14+
serves subscriptions must advertise.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from adcp.reporting.subscriptions.capabilities import (
20+
REPORTING_SUBSCRIPTIONS_FEATURE,
21+
REPORTING_SUBSCRIPTIONS_SECTION,
22+
ReportingSubscriptionSupport,
23+
advertise_reporting_subscriptions,
24+
reporting_configuration_tasks,
25+
subscription_capabilities,
26+
with_subscription_dimensions,
27+
)
28+
from adcp.reporting.subscriptions.coverage import (
29+
AccountTimezoneChange,
30+
ExpectedSubscriptionPeriod,
31+
SubscriptionAuthorization,
32+
SubscriptionBuy,
33+
SubscriptionBuyFlight,
34+
SubscriptionBuyPackage,
35+
SubscriptionException,
36+
SubscriptionVersionWindow,
37+
expected_subscription_periods,
38+
subscription_covers_account,
39+
subscription_exceptions,
40+
subscription_version_windows,
41+
)
42+
from adcp.reporting.subscriptions.resolution import (
43+
STANDARD_REPORTING_DIMENSIONS,
44+
SubscriptionDestination,
45+
SubscriptionMatch,
46+
SubscriptionOffering,
47+
SubscriptionResolution,
48+
SubscriptionSelector,
49+
resolve_subscription_offerings,
50+
)
51+
from adcp.reporting.subscriptions.wire import (
52+
project_offering,
53+
project_reporting_delivery,
54+
project_subscription_coverage,
55+
project_subscription_state,
56+
serves_settled_subscription_fields,
57+
)
58+
59+
__all__ = [
60+
"REPORTING_SUBSCRIPTIONS_FEATURE",
61+
"REPORTING_SUBSCRIPTIONS_SECTION",
62+
"STANDARD_REPORTING_DIMENSIONS",
63+
"AccountTimezoneChange",
64+
"ExpectedSubscriptionPeriod",
65+
"ReportingSubscriptionSupport",
66+
"SubscriptionAuthorization",
67+
"SubscriptionBuy",
68+
"SubscriptionBuyFlight",
69+
"SubscriptionBuyPackage",
70+
"SubscriptionDestination",
71+
"SubscriptionException",
72+
"SubscriptionMatch",
73+
"SubscriptionOffering",
74+
"SubscriptionResolution",
75+
"SubscriptionSelector",
76+
"SubscriptionVersionWindow",
77+
"advertise_reporting_subscriptions",
78+
"expected_subscription_periods",
79+
"reporting_configuration_tasks",
80+
"resolve_subscription_offerings",
81+
"subscription_capabilities",
82+
"subscription_covers_account",
83+
"subscription_exceptions",
84+
"subscription_version_windows",
85+
"with_subscription_dimensions",
86+
"project_offering",
87+
"project_reporting_delivery",
88+
"project_subscription_coverage",
89+
"project_subscription_state",
90+
"serves_settled_subscription_fields",
91+
]
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
"""The period calendar shared by subscription coverage on both sides of the wire.
2+
3+
``core/reporting-schedule.json`` ``period_generation``: every boundary is
4+
calculated directly from the schedule's origin and the interval ordinal by
5+
multiplying each ISO 8601 duration component by the ordinal and applying
6+
years, months, days, hours, minutes, then seconds.
7+
8+
* Origins: ``utc`` counts from 1970-01-01T00:00:00Z; ``account_timezone`` and
9+
``source_timezone`` from local midnight on 1970-01-01 in their zone;
10+
``billing_cycle`` from its explicit ``period_anchor`` expressed in its zone.
11+
* Years, months and days are local civil arithmetic in the zone. The day is
12+
clamped from the origin's day (a March 31 anchor gives February 28, then
13+
March 31 again). A nonexistent local time advances by the gap; an ambiguous
14+
one takes the earlier instant.
15+
* Hours, minutes and seconds are exact elapsed time added after the civil part,
16+
so ``PT24H`` differs from ``P1D`` across a DST change. The spec does not say
17+
whether time components are civil; this matches the TypeScript SDK
18+
(adcontextprotocol/adcp-client#3505), so both SDKs derive identical periods.
19+
* A zero-length period (a skipped civil date) is never a period.
20+
* Sub-daily local periods (adcp#8588): under ``account_timezone``,
21+
``source_timezone`` or ``billing_cycle`` alignment, a duration of hours,
22+
minutes and seconds only MUST divide 24 hours, and its boundaries are the
23+
instants whose local wall-clock time is a whole multiple of the duration
24+
after local midnight, counted afresh each local day. A nonexistent wall time
25+
yields no boundary and a repeated one yields one boundary per offset, so a
26+
23-hour DST day has 23 hourly periods and a 25-hour day 25. Local durations
27+
cannot mix date and time components.
28+
29+
The ledger's ``ReportingScheduleSpec`` treats every component as civil time and
30+
defaults an anchorless civil schedule to the UTC epoch in local time; this
31+
calendar does neither.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import calendar as _calendar
37+
import re
38+
from collections.abc import Iterator
39+
from dataclasses import dataclass, field
40+
from datetime import date, datetime, time, timedelta, timezone
41+
from functools import lru_cache
42+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
43+
44+
UTC = timezone.utc
45+
_DURATION = re.compile(r"P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?")
46+
_SLA = re.compile(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?")
47+
48+
49+
def utc_instant(value: datetime, *, field: str = "timestamp") -> datetime:
50+
"""Return ``value`` in UTC, refusing naive datetimes."""
51+
if value.tzinfo is None or value.utcoffset() is None:
52+
raise ValueError(f"{field} must be timezone-aware")
53+
return value.astimezone(UTC)
54+
55+
56+
@lru_cache(maxsize=1)
57+
def _iana_zones() -> frozenset[str]:
58+
return frozenset(available_timezones())
59+
60+
61+
def require_zone(name: str | None, *, field: str = "timezone") -> ZoneInfo:
62+
"""A recognized IANA zone; numeric offsets, unknown names and keys that are
63+
not IANA zone names (``posixrules``, ``right/UTC``, ``posix/...``) are refused."""
64+
if not name or name[0] in "+-" or name[0].isdigit() or name not in _iana_zones():
65+
raise ValueError(f"{field} {name!r} is not a recognized IANA timezone")
66+
try:
67+
return ZoneInfo(name)
68+
except (ZoneInfoNotFoundError, ValueError) as error:
69+
raise ValueError(f"{field} {name!r} is not a recognized IANA timezone") from error
70+
71+
72+
def parse_period_duration(value: str) -> tuple[int, int, int, int]:
73+
"""``(years, months, days, seconds)`` of a strictly positive duration."""
74+
match = _DURATION.fullmatch(value)
75+
if match is None or value in {"P", "PT"} or value.endswith("T"):
76+
raise ValueError(f"{value!r} is not an ISO 8601 period duration")
77+
years, months, days, hours, minutes, seconds = (int(item or 0) for item in match.groups())
78+
exact = hours * 3600 + minutes * 60 + seconds
79+
if not (years or months or days or exact):
80+
raise ValueError("period_duration must be positive")
81+
return years, months, days, exact
82+
83+
84+
def parse_delivery_sla(value: str) -> timedelta:
85+
"""A non-negative ``delivery_sla`` as exact elapsed time (days are 24 hours).
86+
87+
A ``delivery_sla`` with years or months, which the offering schema allows,
88+
has no agreed arithmetic across SDKs and is refused.
89+
"""
90+
match = _SLA.fullmatch(value)
91+
if match is None or value in {"P", "PT"} or value.endswith("T"):
92+
raise ValueError(
93+
f"delivery_sla {value!r} is not supported: use days, hours, minutes and "
94+
"seconds only (years and months have no agreed arithmetic)"
95+
)
96+
days, hours, minutes, seconds = (int(item or 0) for item in match.groups())
97+
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
98+
99+
100+
def _resolve(local: datetime, zone: ZoneInfo) -> datetime:
101+
"""Local civil time to an instant: a gap advances, a fold takes the earlier."""
102+
return local.replace(tzinfo=zone, fold=0).astimezone(UTC)
103+
104+
105+
def _add_months(local: datetime, months: int, origin_day: int) -> datetime:
106+
index = local.year * 12 + (local.month - 1) + months
107+
year, month = divmod(index, 12)
108+
month += 1
109+
day = min(origin_day, _calendar.monthrange(year, month)[1])
110+
return local.replace(year=year, month=month, day=day)
111+
112+
113+
@dataclass(frozen=True)
114+
class PeriodCalendar:
115+
"""Ordered half-open periods of one schedule in one zone."""
116+
117+
period_duration: str
118+
zone_name: str
119+
origin: datetime # naive local civil time
120+
delivery_sla: timedelta = timedelta(0)
121+
#: Local alignment (anything but ``utc``): sub-daily durations restart at
122+
#: each local midnight (adcp#8588).
123+
local: bool = False
124+
_parts: tuple[int, int, int, int] = field(init=False, repr=False, compare=False)
125+
_zone: ZoneInfo = field(init=False, repr=False, compare=False)
126+
_approx: float = field(init=False, repr=False, compare=False)
127+
128+
def __post_init__(self) -> None:
129+
parts = parse_period_duration(self.period_duration)
130+
object.__setattr__(self, "_parts", parts)
131+
object.__setattr__(self, "_zone", require_zone(self.zone_name))
132+
if self.origin.tzinfo is not None:
133+
raise ValueError("origin is local civil time and must be naive")
134+
years, months, days, exact = parts
135+
if self.local and exact and (years or months or days):
136+
raise ValueError(
137+
f"period_duration {self.period_duration!r} mixes date and time components, "
138+
"which a local alignment does not allow"
139+
)
140+
if self.local and exact and not (years or months or days) and 86400 % exact:
141+
raise ValueError(
142+
f"period_duration {self.period_duration!r} must divide 24 hours "
143+
"under a local alignment"
144+
)
145+
approx = years * 365.2425 * 86400 + months * 30.436875 * 86400 + days * 86400 + exact
146+
object.__setattr__(self, "_approx", approx)
147+
148+
@classmethod
149+
def for_schedule(
150+
cls,
151+
*,
152+
period_duration: str,
153+
delivery_sla: str,
154+
zone_name: str,
155+
anchor: datetime | None = None,
156+
local: bool = False,
157+
) -> PeriodCalendar:
158+
"""``anchor`` is an instant (``billing_cycle``); otherwise local midnight 1970-01-01.
159+
160+
``local`` marks an ``account_timezone``, ``source_timezone`` or
161+
``billing_cycle`` schedule.
162+
"""
163+
zone = require_zone(zone_name)
164+
origin = (
165+
utc_instant(anchor, field="period_anchor").astimezone(zone).replace(tzinfo=None)
166+
if anchor is not None
167+
else datetime(1970, 1, 1)
168+
)
169+
return cls(
170+
period_duration, zone_name, origin, parse_delivery_sla(delivery_sla), local=local
171+
)
172+
173+
@property
174+
def _subdaily_local(self) -> bool:
175+
years, months, days, exact = self._parts
176+
return self.local and bool(exact) and not (years or months or days)
177+
178+
def _day_boundaries(self, day: date) -> list[datetime]:
179+
"""Every boundary on one local date: wall-clock multiples after local midnight."""
180+
step = self._parts[3]
181+
found: set[datetime] = set()
182+
midnight = datetime.combine(day, time())
183+
for index in range(86400 // step):
184+
wall = midnight + timedelta(seconds=index * step)
185+
for fold in (0, 1):
186+
instant = wall.replace(tzinfo=self._zone, fold=fold).astimezone(UTC)
187+
if instant.astimezone(self._zone).replace(tzinfo=None) == wall:
188+
found.add(instant)
189+
return sorted(found)
190+
191+
def _boundaries_from(self, instant: datetime) -> Iterator[datetime]:
192+
"""Sub-daily local boundaries at or after ``instant``, in order."""
193+
at = utc_instant(instant)
194+
day = at.astimezone(self._zone).date() - timedelta(days=1)
195+
while True:
196+
for boundary in self._day_boundaries(day):
197+
if boundary >= at:
198+
yield boundary
199+
day += timedelta(days=1)
200+
201+
def boundary(self, ordinal: int) -> datetime:
202+
years, months, days, exact = self._parts
203+
local = self.origin
204+
if years or months:
205+
local = _add_months(local, (years * 12 + months) * ordinal, self.origin.day)
206+
if days:
207+
local = local + timedelta(days=days * ordinal)
208+
return _resolve(local, self._zone) + timedelta(seconds=exact * ordinal)
209+
210+
def bounds(self, ordinal: int) -> tuple[datetime, datetime]:
211+
return self.boundary(ordinal), self.boundary(ordinal + 1)
212+
213+
def first_ordinal_at_or_after(self, instant: datetime) -> int:
214+
at = utc_instant(instant)
215+
origin = self.boundary(0)
216+
ordinal = int((at - origin).total_seconds() // self._approx)
217+
while self.boundary(ordinal) < at:
218+
ordinal += 1
219+
while self.boundary(ordinal - 1) >= at:
220+
ordinal -= 1
221+
return ordinal
222+
223+
def ceil(self, instant: datetime) -> datetime:
224+
"""The first boundary at or after ``instant`` (E for a cutover at it)."""
225+
if self._subdaily_local:
226+
return next(self._boundaries_from(instant))
227+
return self.boundary(self.first_ordinal_at_or_after(instant))
228+
229+
def floor(self, instant: datetime) -> datetime:
230+
"""The last boundary at or before ``instant``."""
231+
at = utc_instant(instant)
232+
if self._subdaily_local:
233+
day = at.astimezone(self._zone).date() + timedelta(days=1)
234+
while True:
235+
earlier = [item for item in self._day_boundaries(day) if item <= at]
236+
if earlier:
237+
return earlier[-1]
238+
day -= timedelta(days=1)
239+
ordinal = self.first_ordinal_at_or_after(at)
240+
start = self.boundary(ordinal)
241+
return start if start == at else self.boundary(ordinal - 1)
242+
243+
def periods(
244+
self, *, starts_from: datetime, starts_before: datetime
245+
) -> Iterator[tuple[datetime, datetime]]:
246+
"""Every nonempty period whose start lies in ``[starts_from, starts_before)``."""
247+
upper = utc_instant(starts_before)
248+
if self._subdaily_local:
249+
boundaries = self._boundaries_from(starts_from)
250+
start = next(boundaries)
251+
while start < upper:
252+
end = next(boundaries)
253+
yield start, end
254+
start = end
255+
return
256+
ordinal = self.first_ordinal_at_or_after(starts_from)
257+
start = self.boundary(ordinal)
258+
while start < upper:
259+
end = self.boundary(ordinal + 1)
260+
if end > start:
261+
yield start, end
262+
ordinal += 1
263+
start = end
264+
265+
266+
def iso_z(value: datetime) -> str:
267+
"""RFC 3339 UTC with a ``Z`` suffix and no fractional seconds when whole."""
268+
instant = utc_instant(value)
269+
if instant.microsecond:
270+
return instant.isoformat().replace("+00:00", "Z")
271+
return instant.strftime("%Y-%m-%dT%H:%M:%SZ")
272+
273+
274+
__all__ = [
275+
"PeriodCalendar",
276+
"iso_z",
277+
"parse_delivery_sla",
278+
"parse_period_duration",
279+
"require_zone",
280+
"utc_instant",
281+
]

0 commit comments

Comments
 (0)