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",
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user