Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ dev = [
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.0.0",
"pytest-xdist>=3.8,<4",
# The spec's reporting-subscription coverage vectors are derived with IANA
# tzdata 2026f; the parity test pins zoneinfo to this release.
"tzdata==2026.6",
"freezegun==1.5.5", # isolated deferred-schema compatibility regressions
"mypy==1.20.2",
"pyright==1.1.414", # real imported-base adopter regressions
Expand Down Expand Up @@ -487,6 +490,7 @@ dev = [
# source — and a dead-weight hook that everyone bypasses with
# ``SKIP=mypy``.
"mypy==1.20.2",
"tzdata==2026.6",
]

[[tool.mypy.overrides]]
Expand Down
23 changes: 17 additions & 6 deletions src/adcp/reporting/subscriptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from __future__ import annotations

from adcp.reporting.subscriptions._clock import COVERAGE_INPUT_DETAILS, CoverageInputError
from adcp.reporting.subscriptions.capabilities import (
REPORTING_SUBSCRIPTIONS_FEATURE,
REPORTING_SUBSCRIPTIONS_SECTION,
Expand Down Expand Up @@ -73,18 +74,18 @@
decode_principal_state,
encode_principal_state,
)
from adcp.reporting.subscriptions.ledger import (
FrozenOwedSet,
SubscriptionObligationPlanner,
SubscriptionPlanResult,
subscription_obligation_id,
)
from adcp.reporting.subscriptions.facade import (
PreviewResult,
ReportingClient,
SubscribeResult,
guard_sync_principal,
)
from adcp.reporting.subscriptions.ledger import (
FrozenOwedSet,
SubscriptionObligationPlanner,
SubscriptionPlanResult,
subscription_obligation_id,
)
from adcp.reporting.subscriptions.registry import (
ReportingSubscriptionValidationError,
apply_reporting_subscriptions,
Expand All @@ -100,6 +101,11 @@
SubscriptionSelector,
resolve_subscription_offerings,
)
from adcp.reporting.subscriptions.vocabulary import (
COVERAGE_REASON_CODES,
COVERAGE_RULE_ORDER,
COVERAGE_VOCABULARY_VERSION,
)
from adcp.reporting.subscriptions.wire import (
project_offering,
project_reporting_delivery,
Expand All @@ -109,6 +115,11 @@
)

__all__ = [
"COVERAGE_REASON_CODES",
"COVERAGE_RULE_ORDER",
"COVERAGE_VOCABULARY_VERSION",
"COVERAGE_INPUT_DETAILS",
"CoverageInputError",
"PreviewResult",
"ReportingClient",
"SubscribeResult",
Expand Down
87 changes: 77 additions & 10 deletions src/adcp/reporting/subscriptions/_clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,61 @@
from functools import lru_cache
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones

from adcp.exceptions import ADCPError, RecoveryLiteral

UTC = timezone.utc

#: COVERAGE_INPUT_INCOMPLETE and COVERAGE_INPUT_INVALID details, from the
#: spec's frozen error-codes.json (vocabulary_version 1).
COVERAGE_INPUT_DETAILS = frozenset(
{
"missing_account_timezone",
"missing_source_timezone",
"missing_previous_timezone",
"missing_standard_dimensions",
"missing_status_retention_days",
"missing_version_configuration",
"missing_destination_method",
"invalid_timezone",
"invalid_period_duration",
"invalid_instant",
"invalid_input",
}
)


class CoverageInputError(ADCPError, ValueError):
"""Coverage cannot be computed from this input (spec ``error-codes.json``).

``code`` is ``COVERAGE_INPUT_INCOMPLETE`` when the input lacks a value the
rule needs and ``COVERAGE_INPUT_INVALID`` when a value is malformed;
``detail`` names the cause. ``recovery`` is ``correctable``. It is a
local computation, so ``dispatched`` is not set. It stays a
``ValueError`` for callers that already catch one.
"""

recovery: RecoveryLiteral | None = "correctable"

def __init__(self, detail: str, message: str) -> None:
if detail not in COVERAGE_INPUT_DETAILS:
raise AssertionError(f"unknown coverage input detail {detail!r}")
super().__init__(message)
self.detail = detail
self.code = (
"COVERAGE_INPUT_INCOMPLETE"
if detail.startswith("missing_")
else "COVERAGE_INPUT_INVALID"
)


_DURATION = re.compile(r"P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?")
_SLA = re.compile(r"P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?")


def utc_instant(value: datetime, *, field: str = "timestamp") -> datetime:
"""Return ``value`` in UTC, refusing naive datetimes."""
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field} must be timezone-aware")
raise CoverageInputError("invalid_instant", f"{field} must be timezone-aware")
return value.astimezone(UTC)


Expand All @@ -61,23 +107,42 @@ def _iana_zones() -> frozenset[str]:
def require_zone(name: str | None, *, field: str = "timezone") -> ZoneInfo:
"""A recognized IANA zone; numeric offsets, unknown names and keys that are
not IANA zone names (``posixrules``, ``right/UTC``, ``posix/...``) are refused."""
if not name or name[0] in "+-" or name[0].isdigit() or name not in _iana_zones():
raise ValueError(f"{field} {name!r} is not a recognized IANA timezone")
if not name:
detail = (
"missing_source_timezone"
if "source" in field
else "missing_previous_timezone"
if "previous" in field
else "missing_account_timezone"
)
raise CoverageInputError(detail, f"{field} is required. Supply the IANA zone name.")
if name[0] in "+-" or name[0].isdigit() or name not in _iana_zones():
raise CoverageInputError(
"invalid_timezone",
f"{field} {name!r} is not a recognized IANA timezone. Use an IANA zone name; "
"a numeric offset is not one.",
)
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError) as error:
raise ValueError(f"{field} {name!r} is not a recognized IANA timezone") from error
raise CoverageInputError(
"invalid_timezone", f"{field} {name!r} is not a recognized IANA timezone"
) from error


def parse_period_duration(value: str) -> tuple[int, int, int, int]:
"""``(years, months, days, seconds)`` of a strictly positive duration."""
match = _DURATION.fullmatch(value)
if match is None or value in {"P", "PT"} or value.endswith("T"):
raise ValueError(f"{value!r} is not an ISO 8601 period duration")
raise CoverageInputError(
"invalid_period_duration",
f"{value!r} is not a schedule period_duration. Use Y, M and D date and H, M "
"and S time components; there is no W designator (a week is P7D).",
)
years, months, days, hours, minutes, seconds = (int(item or 0) for item in match.groups())
exact = hours * 3600 + minutes * 60 + seconds
if not (years or months or days or exact):
raise ValueError("period_duration must be positive")
raise CoverageInputError("invalid_period_duration", "period_duration must be positive")
return years, months, days, exact


Expand Down Expand Up @@ -133,14 +198,16 @@ def __post_init__(self) -> None:
raise ValueError("origin is local civil time and must be naive")
years, months, days, exact = parts
if self.local and exact and (years or months or days):
raise ValueError(
raise CoverageInputError(
"invalid_period_duration",
f"period_duration {self.period_duration!r} mixes date and time components, "
"which a local alignment does not allow"
"which a local alignment does not allow",
)
if self.local and exact and not (years or months or days) and 86400 % exact:
raise ValueError(
raise CoverageInputError(
"invalid_period_duration",
f"period_duration {self.period_duration!r} must divide 24 hours "
"under a local alignment"
"under a local alignment",
)
approx = years * 365.2425 * 86400 + months * 30.436875 * 86400 + days * 86400 + exact
object.__setattr__(self, "_approx", approx)
Expand Down
17 changes: 17 additions & 0 deletions src/adcp/reporting/subscriptions/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,17 @@ class ReportingSubscriptionError(ADCPError):
it. See :func:`adcp.exceptions.is_pre_dispatch_error`.
would_deactivate: The subscription ids a refused write would have
deactivated (``SUBSCRIPTION_WOULD_DEACTIVATE``).
would_revoke: The destination ids it would have revoked
(``DESTINATION_WOULD_REVOKE``).
delivery_config_id, account_id: The clashing delivery config and its
account (``SUBSCRIPTION_ID_CLASH``).
nearest: The offerings failing on the fewest attributes, each with
the attributes it fails (``SUBSCRIPTION_SELECTOR_UNMATCHED``).
readback: The seller's readback (``SUBSCRIPTION_READBACK_MISMATCH``).

Codes, recovery classes and context names follow the spec's frozen
``error-codes.json`` (vocabulary_version 1).

The message ends with the fix.
"""

Expand All @@ -97,6 +106,10 @@ def __init__(
recovery: RecoveryLiteral | None = None,
dispatched: Literal[False] | None = None,
would_deactivate: Sequence[str] = (),
would_revoke: Sequence[str] = (),
delivery_config_id: str | None = None,
account_id: str | None = None,
nearest: Sequence[tuple[str, tuple[str, ...]]] = (),
readback: ReportingSubscriptionReadback | None = None,
) -> None:
super().__init__(message)
Expand All @@ -111,6 +124,10 @@ def __init__(
if dispatched is False:
self.dispatched = False
self.would_deactivate = tuple(would_deactivate)
self.would_revoke = tuple(would_revoke)
self.delivery_config_id = delivery_config_id
self.account_id = account_id
self.nearest = tuple(nearest)
self.readback = readback


Expand Down
Loading
Loading