"""Study metadata inspector for UAT specification generation."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, TypedDict, cast
from pydantic import Field
from imednet.spi.models import Form, ImednetBaseModel, Interval, Site, Variable
if TYPE_CHECKING:
from imednet.spi.facade import AsyncImednetFacade, ImednetFacade
[docs]class StudySnapshot(ImednetBaseModel):
"""Point-in-time snapshot of a study's structural metadata."""
study_key: str
captured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
forms: list[Form]
variables: list[Variable]
intervals: list[Interval]
sites: list[Site]
forms_by_key: dict[str, Form] = Field(default_factory=dict)
variables_by_form: dict[str, list[Variable]] = Field(default_factory=dict)
intervals_by_name: dict[str, Interval] = Field(default_factory=dict)
[docs] def model_post_init(self, __context: object) -> None:
"""Initialize derived lookup dictionaries after model initialization."""
self.forms_by_key = {
form.form_key: form for form in self.forms if form.form_key is not None
}
self.variables_by_form = {}
for variable in self.variables:
if variable.form_key is not None:
self.variables_by_form.setdefault(variable.form_key, []).append(variable)
self.intervals_by_name = {
interval.interval_name: interval
for interval in self.intervals
if interval.interval_name is not None
}
[docs] def active_sites(self) -> list[Site]:
"""Return sites that are actively enrolling."""
return [site for site in self.sites if site.site_enrollment_status == "Active"]
[docs]class StudySchemaInspector:
"""Fetch and index all structural metadata for an iMednet study.
Supports both sync and async SDK clients. When an async client is used,
metadata calls are executed concurrently via ``asyncio.gather``.
The in-memory cache is per inspector instance. It can be shared by concurrent
tasks in a single event loop, but is not thread-safe across threads/processes;
use external synchronization or separate inspector instances for that access pattern.
"""
[docs] def __init__(self, sdk: ImednetFacade | AsyncImednetFacade) -> None:
"""Initialize the study schema inspector.
Args:
sdk: An instance of the synchronous or asynchronous iMednet SDK facade.
"""
self._sdk = sdk
self._cache: dict[str, StudySnapshot] = {}
[docs] def inspect(self, study_key: str, *, force_refresh: bool = False) -> StudySnapshot:
"""Synchronously fetch and return a study snapshot."""
if not force_refresh and study_key in self._cache:
return self._cache[study_key]
sdk = cast("ImednetFacade", self._sdk)
snapshot = StudySnapshot(
study_key=study_key,
forms=sdk.get_forms(study_key),
variables=sdk.get_variables(study_key),
intervals=sdk.get_intervals(study_key),
sites=sdk.get_sites(study_key),
)
self._cache[study_key] = snapshot
return snapshot
[docs] async def async_inspect(self, study_key: str, *, force_refresh: bool = False) -> StudySnapshot:
"""Asynchronously fetch metadata concurrently and return a study snapshot."""
if not force_refresh and study_key in self._cache:
return self._cache[study_key]
sdk = cast("AsyncImednetFacade", self._sdk)
async def fetch_forms() -> list[Any]:
"""Asynchronous fetch for forms."""
return await sdk.async_get_forms(study_key)
async def fetch_variables() -> list[Any]:
"""Asynchronous fetch for variables."""
return await sdk.async_get_variables(study_key)
async def fetch_intervals() -> list[Any]:
"""Asynchronous fetch for intervals."""
return await sdk.async_get_intervals(study_key)
async def fetch_sites() -> list[Any]:
"""Asynchronous fetch for sites."""
return await sdk.async_get_sites(study_key)
forms, variables, intervals, sites = await asyncio.gather(
fetch_forms(),
fetch_variables(),
fetch_intervals(),
fetch_sites(),
)
snapshot = StudySnapshot(
study_key=study_key,
forms=forms,
variables=variables,
intervals=intervals,
sites=sites,
)
self._cache[study_key] = snapshot
return snapshot
[docs] def clear_cache(self, study_key: str | None = None) -> None:
"""Clear snapshot cache for one study key or all keys."""
if study_key is None:
self._cache.clear()
return
self._cache.pop(study_key, None)