Skip to content
Draft
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
34 changes: 12 additions & 22 deletions docs/caching.rst
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
Caching Behavior
================

This page explains how the SDK caches data to reduce unnecessary API calls.
This page explains how the SDK handles data caching.

Endpoint Caches
---------------
Endpoint Caching
----------------

The endpoints for studies, forms, variables and intervals maintain simple in-memory
caches. When `list()` or `async_list()` is called without any filters, the
results are stored on the endpoint instance. Subsequent calls return the cached
list instead of performing another request. Passing ``refresh=True`` bypasses the
cache and stores the fresh response.
The SDK no longer maintains any stateful in-memory cache on endpoint instances.
Every call to ``list()`` or ``async_list()`` performs a fresh API request,
ensuring that concurrent requests from different application contexts cannot
contaminate each other's data.

The caches live on the endpoint instances as dictionaries keyed by study. They
are not shared across SDK instances and are discarded when the SDK is garbage
collected.
If your application needs to reduce redundant API calls, implement caching at
the application layer using a library such as `cachetools`_, a request-level
cache middleware (e.g. ``httpx-cache``), or your framework's own cache
primitives (e.g. Django's cache framework or FastAPI dependency injection).

``get()`` methods use ``list(refresh=True)`` to ensure the most recent data is
returned. If your application needs to clear cached data manually you may call
``list(refresh=True)`` or create a new SDK instance.
.. _cachetools: https://cachetools.readthedocs.io/

Schema Cache
------------
Expand All @@ -36,11 +34,3 @@ on demand if it has not been cached yet.
For offline tests, ``imednet.testing.fake_data`` includes helpers to generate
forms, variables and records. These objects can be used with
``SchemaCache.refresh`` to validate payloads without hitting the API.

Limitations
-----------

The caching layer is purely in memory and is not thread-safe. It is intended to
minimize repeated requests in short-lived scripts or during interactive sessions.
Long running applications should periodically refresh or recreate the SDK to
avoid using stale data.
6 changes: 0 additions & 6 deletions packages/core/src/imednet/core/endpoint/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,6 @@ def MODEL(self) -> Type[T]: # noqa: N802
Defaults to "id". Override in subclasses if needed.
"""

_enable_cache: bool = False
"""
Whether this endpoint supports caching.
Defaults to False. Override in subclasses if needed.
"""

@abstractmethod
def _build_path(self, *segments: Any) -> str:
"""
Expand Down
93 changes: 5 additions & 88 deletions packages/core/src/imednet/core/endpoint/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import re
import warnings
from functools import lru_cache
from typing import Any, Callable, Dict, List, Optional, TypeVar, cast
from typing import Any, Callable, Dict, List, Optional, TypeVar
from urllib.parse import quote

from imednet.constants import DEFAULT_PAGE_SIZE
Expand Down Expand Up @@ -107,15 +107,6 @@ class GenericListGetEndpoint(
PARAM_PROCESSOR_CLS: type[ParamProcessor] = DefaultParamProcessor
STUDY_KEY_STRATEGY: Optional[StudyKeyStrategy] = None

def __init__(
self,
client: RequestorProtocol,
ctx: object | None = None,
async_client: Optional[AsyncRequestorProtocol] = None,
) -> None:
super().__init__(client, ctx, async_client)
self._cache: Optional[List[T] | Dict[str, List[T]]] = None

@property
def study_key_strategy(self) -> StudyKeyStrategy:
if self.STUDY_KEY_STRATEGY:
Expand Down Expand Up @@ -169,83 +160,21 @@ def _resolve_params(

return ParamState(study=study, params=params, other_filters=other_filters)

def _get_local_cache(self) -> Optional[List[T] | Dict[str, List[T]]]:
if not self._enable_cache:
return None

if self.requires_study_key and self._cache is None:
self._cache = {}
return self._cache

def _update_local_cache(self, result: List[T], study: str | None, has_filters: bool) -> None:
if has_filters or not self._enable_cache:
return

if self.requires_study_key:
if self._cache is None:
self._cache = {}
if isinstance(self._cache, dict) and study is not None:
self._cache[study] = result
return

self._cache = result

def _check_cache_hit(
self,
study: Optional[str],
refresh: bool,
other_filters: Dict[str, Any],
cache: Optional[List[T] | Dict[str, List[T]]],
) -> Optional[List[T]]:
if not self._enable_cache:
return None

if self.requires_study_key:
if (
isinstance(cache, dict)
and study is not None
and not other_filters
and not refresh
and study in cache
):
return cache[study]
return None

if isinstance(cache, list) and not other_filters and not refresh:
return cache
return None

def _prepare_list_request(
self,
study_key: Optional[str],
extra_params: Optional[Dict[str, Any]],
filters: Dict[str, Any],
refresh: bool,
) -> ListRequestState[T]:
param_state = self._resolve_params(study_key, extra_params, filters)
study = param_state.study
params = param_state.params
other_filters = param_state.other_filters

cache = self._get_local_cache()
cached_result = self._check_cache_hit(study, refresh, other_filters, cache)
if cached_result is not None:
return ListRequestState(
path="",
params={},
study=study,
has_filters=False,
cache=None,
cached_result=cast(List[T], cached_result),
)

path = self._get_endpoint_path(study)
return ListRequestState(
path=path,
params=params,
study=study,
has_filters=bool(other_filters),
cache=cache,
)

def _list_sync(
Expand All @@ -254,45 +183,33 @@ def _list_sync(
paginator_cls: type[Paginator],
*,
study_key: Optional[str] = None,
refresh: bool = False,
extra_params: Optional[Dict[str, Any]] = None,
**filters: Any,
) -> List[T]:
state = self._prepare_list_request(study_key, extra_params, filters, refresh)
if state.cached_result is not None:
return state.cached_result

result = ListOperation[T](
state = self._prepare_list_request(study_key, extra_params, filters)
return ListOperation[T](
path=state.path,
params=state.params,
page_size=self.PAGE_SIZE,
parse_func=self._resolve_parse_func(),
).execute_sync(client, paginator_cls)
self._update_local_cache(result, state.study, state.has_filters)
return result

async def _list_async(
self,
client: AsyncRequestorProtocol,
paginator_cls: type[AsyncPaginator],
*,
study_key: Optional[str] = None,
refresh: bool = False,
extra_params: Optional[Dict[str, Any]] = None,
**filters: Any,
) -> List[T]:
state = self._prepare_list_request(study_key, extra_params, filters, refresh)
if state.cached_result is not None:
return state.cached_result

result = await ListOperation[T](
state = self._prepare_list_request(study_key, extra_params, filters)
return await ListOperation[T](
path=state.path,
params=state.params,
page_size=self.PAGE_SIZE,
parse_func=self._resolve_parse_func(),
).execute_async(client, paginator_cls)
self._update_local_cache(result, state.study, state.has_filters)
return result

def list(self, study_key: Optional[str] = None, **filters: Any) -> List[T]:
return self._list_sync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ def execute_sync(
client,
paginator_cls,
study_key=self.study_key,
refresh=True,
**self.filters,
)
return self.validate_func(result, self.study_key, self.item_id)
Expand All @@ -99,7 +98,6 @@ async def execute_async(
client,
paginator_cls,
study_key=self.study_key,
refresh=True,
**self.filters,
)
return self.validate_func(result, self.study_key, self.item_id)
3 changes: 0 additions & 3 deletions packages/core/src/imednet/core/endpoint/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ class EndpointProtocol(Protocol):
PATH: str
MODEL: Type[JsonModel]
_id_param: str
_enable_cache: bool
requires_study_key: bool
PAGE_SIZE: int

Expand Down Expand Up @@ -61,7 +60,6 @@ def _list_sync(
paginator_cls: type[Paginator],
*,
study_key: Optional[str] = None,
refresh: bool = False,
extra_params: Optional[Dict[str, Any]] = None,
**filters: Any,
) -> List[T]:
Expand All @@ -74,7 +72,6 @@ async def _list_async(
paginator_cls: type[AsyncPaginator],
*,
study_key: Optional[str] = None,
refresh: bool = False,
extra_params: Optional[Dict[str, Any]] = None,
**filters: Any,
) -> List[T]:
Expand Down
8 changes: 1 addition & 7 deletions packages/core/src/imednet/core/endpoint/structs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, Generic, List, Optional, TypeVar
from typing import Any, Dict, Generic, Optional, TypeVar

from imednet.models.json_base import JsonModel

Expand Down Expand Up @@ -30,17 +30,11 @@ class ListRequestState(Generic[T]):
Encapsulates the state required to execute a list request.

Attributes:
cached_result: The result from cache, if any.
path: The API path for the request.
params: The query parameters.
study: The study key.
has_filters: Whether filters other than study key are present.
cache: The cache object used for this request.
"""

path: str
params: Dict[str, Any]
study: Optional[str]
has_filters: bool
cache: Any
cached_result: Optional[List[T]] = None
1 change: 0 additions & 1 deletion packages/core/src/imednet/endpoints/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,4 @@ class FormsEndpoint(EdcGenericListGetEndpoint[Form]):
MODEL = Form
_id_param = "formId"
STUDY_KEY_STRATEGY = PopStudyKeyStrategy()
_enable_cache = True
PAGE_SIZE = 500
1 change: 0 additions & 1 deletion packages/core/src/imednet/endpoints/intervals.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,4 @@ class IntervalsEndpoint(EdcGenericListGetEndpoint[Interval]):
MODEL = Interval
_id_param = "intervalId"
STUDY_KEY_STRATEGY = PopStudyKeyStrategy()
_enable_cache = True
PAGE_SIZE = 500
1 change: 0 additions & 1 deletion packages/core/src/imednet/endpoints/studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ class StudiesEndpoint(EdcGenericListGetEndpoint[Study]):
PATH = ""
MODEL = Study
_id_param = "studyKey"
_enable_cache = True
requires_study_key: bool = False
1 change: 0 additions & 1 deletion packages/core/src/imednet/endpoints/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,4 @@ class VariablesEndpoint(EdcGenericListGetEndpoint[Variable]):
MODEL = Variable
_id_param = "variableId"
STUDY_KEY_STRATEGY = PopStudyKeyStrategy()
_enable_cache = True
PAGE_SIZE = 500
8 changes: 4 additions & 4 deletions packages/core/src/imednet/validation/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async def _refresh_async(
variables: VariablesEndpoint,
study_key: Optional[str] = None,
) -> None:
vars_list = await variables.async_list(study_key=study_key, refresh=True)
vars_list = await variables.async_list(study_key=study_key)
self.populate(vars_list)

def _refresh_sync(
Expand All @@ -46,7 +46,7 @@ def _refresh_sync(
variables: VariablesEndpoint,
study_key: Optional[str] = None,
) -> None:
vars_list = variables.list(study_key=study_key, refresh=True)
vars_list = variables.list(study_key=study_key)
self.populate(vars_list)

def refresh(
Expand Down Expand Up @@ -234,7 +234,7 @@ def refresh(self, study_key: str) -> None:
This method never raises :class:`~imednet.errors.ValidationError`;
any API errors bubble up as :class:`~imednet.errors.ApiError`.
"""
variables = self._sdk.variables.list(study_key=study_key, refresh=True)
variables = self._sdk.variables.list(study_key=study_key)
self._refresh_common(variables)

def validate_record(self, study_key: str, record: Dict[str, Any]) -> None:
Expand Down Expand Up @@ -264,7 +264,7 @@ async def refresh(self, study_key: str) -> None:
This method never raises :class:`~imednet.errors.ValidationError`;
any API errors bubble up as :class:`~imednet.errors.ApiError`.
"""
variables = await self._sdk.variables.async_list(study_key=study_key, refresh=True)
variables = await self._sdk.variables.async_list(study_key=study_key)
self._refresh_common(variables)

async def validate_record(self, study_key: str, record: Dict[str, Any]) -> None:
Expand Down
1 change: 0 additions & 1 deletion tests/unit/core/test_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ def test_endpoint_abc_properties():
assert endpoint.MODEL == MockModel
assert endpoint.requires_study_key is True
assert endpoint._id_param == "id"
assert endpoint._enable_cache is False


def test_endpoint_abc_methods():
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/endpoints/test_endpoints_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,15 @@ async def fake_impl(self, client, paginator, *, study_key=None, **filters):

rec = await ep.async_get("S1", 1)

assert called == {"study_key": "S1", "filters": {"recordId": 1, "refresh": True}}
assert called == {"study_key": "S1", "filters": {"recordId": 1}}
assert isinstance(rec, Record)


@pytest.mark.asyncio
async def test_async_get_record_not_found(monkeypatch, dummy_client, context, response_factory):
ep = records.RecordsEndpoint(dummy_client, context, async_client=dummy_client)

async def fake_impl(self, client, paginator, *, study_key=None, refresh=False, **filters):
async def fake_impl(self, client, paginator, *, study_key=None, **filters):
return []

monkeypatch.setattr(records.RecordsEndpoint, "_list_async", fake_impl)
Expand Down
Loading