feat: add temporal context and contextual help

This commit is contained in:
2026-08-05 00:03:31 +02:00
parent 982ef636b8
commit add7a99f6d
43 changed files with 1878 additions and 167 deletions
+182
View File
@@ -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",
]
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import or_
from govoplan_core.core.temporal import (
TemporalContextError,
TemporalDataContext,
current_temporal_data_context,
)
def apply_temporal_revision_filter(
query: Any,
model: type[Any],
*,
context: TemporalDataContext | None = None,
valid_from: str | None = "valid_from",
valid_to: str | None = "valid_to",
recorded_at: str | None = "recorded_at",
superseded_at: str | None = "superseded_at",
) -> Any:
"""Apply latest/as-recorded and valid-time clauses to a revision query."""
resolved = context or current_temporal_data_context()
clauses: list[Any] = []
superseded_column = _optional_column(model, superseded_at)
recorded_column = _optional_column(model, recorded_at)
if resolved.recorded_at is None:
if superseded_column is not None:
clauses.append(superseded_column.is_(None))
else:
if recorded_column is None or superseded_column is None:
raise TemporalContextError(
f"{model.__name__} does not expose recorded/superseded revision time."
)
clauses.extend(
(
recorded_column <= resolved.recorded_at,
or_(
superseded_column.is_(None),
superseded_column > resolved.recorded_at,
),
)
)
instant = resolved.validity_instant
if instant is not None:
valid_from_column = _optional_column(model, valid_from)
valid_to_column = _optional_column(model, valid_to)
if valid_from_column is not None:
clauses.append(
or_(valid_from_column.is_(None), valid_from_column <= instant)
)
if valid_to_column is not None:
clauses.append(or_(valid_to_column.is_(None), valid_to_column > instant))
return query.filter(*clauses) if clauses else query
def _optional_column(model: type[Any], name: str | None) -> Any | None:
if name is None:
return None
column = getattr(model, name, None)
if column is None:
raise TemporalContextError(
f"{model.__name__} has no temporal column named {name!r}."
)
return column
__all__ = ["apply_temporal_revision_filter"]
@@ -7,7 +7,15 @@ from fastapi import Request
from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache"
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language")
JSON_ETAG_VARY_HEADERS = (
"Authorization",
"Cookie",
"X-API-Key",
"Accept-Language",
"X-Govoplan-Validity-Mode",
"X-Govoplan-Valid-At",
"X-Govoplan-Recorded-At",
)
async def conditional_json_get_middleware(
+2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
from govoplan_core.server.temporal import temporal_data_context_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
@@ -169,6 +170,7 @@ def create_govoplan_app(
return response
app.middleware("http")(conditional_json_get_middleware)
app.middleware("http")(temporal_data_context_middleware)
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
+25
View File
@@ -139,6 +139,29 @@ def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]
]
def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, object]]:
contexts: list[dict[str, object]] = []
seen: set[str] = set()
for topic in manifest.documentation:
raw_contexts = topic.metadata.get("help_contexts", ())
if isinstance(raw_contexts, str) or not isinstance(raw_contexts, (list, tuple, set)):
continue
for raw_context in raw_contexts:
context_id = str(raw_context).strip()
if not context_id or context_id in seen:
continue
seen.add(context_id)
contexts.append(
{
"id": context_id,
"topic_id": topic.id,
"title": topic.title,
"documentation_types": list(topic.documentation_types),
}
)
return contexts
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
frontend = manifest.frontend
if frontend is None:
@@ -240,6 +263,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
for key, value in manifest_interface_catalog(manifest).items()
if key != "declarations"
},
"help_contexts": _documentation_help_contexts(manifest),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
}
@@ -274,6 +298,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"help_contexts": _documentation_help_contexts(manifest),
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from govoplan_core.core.temporal import (
RECORDED_AT_HEADER,
TEMPORAL_EVALUATED_AT_HEADER,
TEMPORAL_VARY_HEADERS,
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
TemporalContextError,
TemporalDataContext,
bind_temporal_data_context,
current_temporal_data_context,
parse_temporal_data_context,
reset_temporal_data_context,
)
async def temporal_data_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
try:
context = parse_temporal_data_context(
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
valid_at=request.headers.get(VALID_AT_HEADER),
recorded_at=request.headers.get(RECORDED_AT_HEADER),
evaluated_at=datetime.now(UTC),
)
except TemporalContextError as exc:
return JSONResponse(status_code=400, content={"detail": str(exc)})
request.state.govoplan_temporal_data_context = context
token = bind_temporal_data_context(context)
try:
response = await call_next(request)
finally:
reset_temporal_data_context(token)
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
context.evaluated_at
)
if context.valid_at is not None:
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
if context.recorded_at is not None:
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
_merge_vary(response, TEMPORAL_VARY_HEADERS)
return response
def get_temporal_data_context(request: Request) -> TemporalDataContext:
context = getattr(request.state, "govoplan_temporal_data_context", None)
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
current = {
item.strip().lower(): item.strip()
for item in response.headers.get("Vary", "").split(",")
if item.strip()
}
for name in names:
current.setdefault(name.lower(), name)
response.headers["Vary"] = ", ".join(current.values())
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]