feat: add temporal context and contextual help
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
|
||||
TemporalValidityMode = Literal["current", "at", "all"]
|
||||
VALIDITY_MODES = frozenset({"current", "at", "all"})
|
||||
|
||||
VALIDITY_MODE_HEADER = "X-Govoplan-Validity-Mode"
|
||||
VALID_AT_HEADER = "X-Govoplan-Valid-At"
|
||||
RECORDED_AT_HEADER = "X-Govoplan-Recorded-At"
|
||||
TEMPORAL_EVALUATED_AT_HEADER = "X-Govoplan-Temporal-Evaluated-At"
|
||||
TEMPORAL_VARY_HEADERS = (
|
||||
VALIDITY_MODE_HEADER,
|
||||
VALID_AT_HEADER,
|
||||
RECORDED_AT_HEADER,
|
||||
)
|
||||
|
||||
|
||||
class TemporalContextError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemporalDataContext:
|
||||
"""Bitemporal read context.
|
||||
|
||||
Valid time answers when a fact applies. Recorded time answers which version
|
||||
of that fact was known to the system. Authorization remains outside this
|
||||
context and is always evaluated under the current security state.
|
||||
"""
|
||||
|
||||
validity_mode: TemporalValidityMode = "current"
|
||||
valid_at: datetime | None = None
|
||||
recorded_at: datetime | None = None
|
||||
evaluated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.validity_mode not in VALIDITY_MODES:
|
||||
raise TemporalContextError(
|
||||
f"Unsupported temporal validity mode: {self.validity_mode!r}."
|
||||
)
|
||||
for name in ("valid_at", "recorded_at", "evaluated_at"):
|
||||
value = getattr(self, name)
|
||||
if value is not None and value.tzinfo is None:
|
||||
raise TemporalContextError(f"Temporal {name} must include a timezone.")
|
||||
if self.validity_mode == "at" and self.valid_at is None:
|
||||
raise TemporalContextError("Validity mode 'at' requires valid_at.")
|
||||
if self.validity_mode != "at" and self.valid_at is not None:
|
||||
raise TemporalContextError(
|
||||
"valid_at is only permitted when validity mode is 'at'."
|
||||
)
|
||||
|
||||
@property
|
||||
def validity_instant(self) -> datetime | None:
|
||||
if self.validity_mode == "all":
|
||||
return None
|
||||
if self.validity_mode == "at":
|
||||
return self.valid_at
|
||||
return self.evaluated_at
|
||||
|
||||
@property
|
||||
def is_default(self) -> bool:
|
||||
return self.validity_mode == "current" and self.recorded_at is None
|
||||
|
||||
def to_dict(self) -> dict[str, str | None]:
|
||||
return {
|
||||
"validity_mode": self.validity_mode,
|
||||
"valid_at": _datetime_text(self.valid_at),
|
||||
"recorded_at": _datetime_text(self.recorded_at),
|
||||
"evaluated_at": _datetime_text(self.evaluated_at),
|
||||
}
|
||||
|
||||
|
||||
_temporal_context: ContextVar[TemporalDataContext | None] = ContextVar(
|
||||
"govoplan_temporal_data_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def parse_temporal_data_context(
|
||||
*,
|
||||
validity_mode: str | None = None,
|
||||
valid_at: str | None = None,
|
||||
recorded_at: str | None = None,
|
||||
evaluated_at: datetime | None = None,
|
||||
) -> TemporalDataContext:
|
||||
clean_mode = (validity_mode or "current").strip().lower()
|
||||
if clean_mode not in VALIDITY_MODES:
|
||||
raise TemporalContextError(
|
||||
"Temporal validity mode must be one of: current, at, all."
|
||||
)
|
||||
return TemporalDataContext(
|
||||
validity_mode=clean_mode, # type: ignore[arg-type]
|
||||
valid_at=_parse_datetime(valid_at, "valid_at"),
|
||||
recorded_at=_parse_datetime(recorded_at, "recorded_at"),
|
||||
evaluated_at=evaluated_at or datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def current_temporal_data_context() -> TemporalDataContext:
|
||||
return _temporal_context.get() or TemporalDataContext()
|
||||
|
||||
|
||||
def bind_temporal_data_context(
|
||||
context: TemporalDataContext,
|
||||
) -> Token[TemporalDataContext | None]:
|
||||
return _temporal_context.set(context)
|
||||
|
||||
|
||||
def reset_temporal_data_context(token: Token[TemporalDataContext | None]) -> None:
|
||||
_temporal_context.reset(token)
|
||||
|
||||
|
||||
def temporal_revision_matches(
|
||||
context: TemporalDataContext,
|
||||
*,
|
||||
valid_from: datetime | None = None,
|
||||
valid_to: datetime | None = None,
|
||||
revision_recorded_at: datetime | None = None,
|
||||
superseded_at: datetime | None = None,
|
||||
) -> bool:
|
||||
cutoff = context.recorded_at
|
||||
if cutoff is None:
|
||||
if superseded_at is not None:
|
||||
return False
|
||||
else:
|
||||
if revision_recorded_at is None or revision_recorded_at > cutoff:
|
||||
return False
|
||||
if superseded_at is not None and superseded_at <= cutoff:
|
||||
return False
|
||||
|
||||
instant = context.validity_instant
|
||||
if instant is None:
|
||||
return True
|
||||
return (valid_from is None or valid_from <= instant) and (
|
||||
valid_to is None or valid_to > instant
|
||||
)
|
||||
|
||||
|
||||
def _parse_datetime(value: str | None, name: str) -> datetime | None:
|
||||
clean = str(value or "").strip()
|
||||
if not clean:
|
||||
return None
|
||||
if len(clean) > 64:
|
||||
raise TemporalContextError(f"Temporal {name} is too long.")
|
||||
normalized = f"{clean[:-1]}+00:00" if clean.endswith(("Z", "z")) else clean
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError as exc:
|
||||
raise TemporalContextError(
|
||||
f"Temporal {name} must be an ISO 8601 timestamp."
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise TemporalContextError(f"Temporal {name} must include a timezone.")
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RECORDED_AT_HEADER",
|
||||
"TEMPORAL_EVALUATED_AT_HEADER",
|
||||
"TEMPORAL_VARY_HEADERS",
|
||||
"VALIDITY_MODE_HEADER",
|
||||
"VALID_AT_HEADER",
|
||||
"TemporalContextError",
|
||||
"TemporalDataContext",
|
||||
"TemporalValidityMode",
|
||||
"bind_temporal_data_context",
|
||||
"current_temporal_data_context",
|
||||
"parse_temporal_data_context",
|
||||
"reset_temporal_data_context",
|
||||
"temporal_revision_matches",
|
||||
]
|
||||
Reference in New Issue
Block a user