From 667cf616b69595414f8c9fd1dd739c2c52b3a35d Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sun, 1 Feb 2026 09:14:01 +0100 Subject: [PATCH 1/2] [TASK] use filter to process values from state cache then state cache can contain more information. --- src/accml_lib/core/bl/delta_backend.py | 27 ++- .../core/interfaces/backend/filter.py | 10 + tests/test_core/test_delta_backend.py | 198 ++++++++++++++++++ 3 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/accml_lib/core/interfaces/backend/filter.py create mode 100644 tests/test_core/test_delta_backend.py diff --git a/src/accml_lib/core/bl/delta_backend.py b/src/accml_lib/core/bl/delta_backend.py index 7688743..2644952 100644 --- a/src/accml_lib/core/bl/delta_backend.py +++ b/src/accml_lib/core/bl/delta_backend.py @@ -1,6 +1,7 @@ import logging from ..interfaces.backend.backend import BackendRW, BackendR +from ..interfaces.backend.filter import FilterInterface from ..model.utils.command import ReadCommand logger = logging.getLogger("accml") @@ -45,12 +46,18 @@ def delta_property(prop_id: str) -> (bool, str): return False, prop_id +class NOOPFilter(FilterInterface): + def process(self, input): + return input + + class DeltaBackendRProxy(BackendR): """handle delta properties""" - def __init__(self, *, backend: BackendR, cache: StateCache): + def __init__(self, *, backend: BackendR, cache: StateCache, filter:FilterInterface=NOOPFilter()): self.backend = backend self.cache = cache + self.filter = filter def __repr__(self): return f"{self.__class__.__name__}(backend={self.backend}, cache={self.cache})" @@ -80,16 +87,20 @@ def _calculate_delta_read(self, rcmd: ReadCommand, value): """ For overloading in derived classes e.g. for processing ophyd-async data """ - ref = self.cache.get(rcmd, None) - assert ref is not None - return value - ref + ref_cached = self.cache.get(rcmd, None) + assert ref_cached is not None + # Todo: check that both have the same name if so (for ophyd async e.g) + ref = self.filter.process(ref_cached) + v = self.filter.process(value) + r = v - ref + return r class DeltaBackendRWProxy(DeltaBackendRProxy, BackendRW): """handle delta properties""" - def __init__(self, backend: BackendRW, cache: StateCache): - super().__init__(backend=backend, cache=cache) + def __init__(self, backend: BackendRW, cache: StateCache, filter: FilterInterface=NOOPFilter()): + super().__init__(backend=backend, cache=cache, filter=filter) self.backend = backend async def set(self, dev_id: str, prop_id: str, value: object): @@ -98,7 +109,7 @@ async def set(self, dev_id: str, prop_id: str, value: object): return await self.backend.set(dev_id=dev_id, prop_id=prop_id, value=value) rcmd = ReadCommand(id=dev_id, property=orig_prop_id) - ref = self.cache.get(rcmd, None) + ref = self.filter.process(self.cache.get(rcmd, None)) if not ref: r = await self.backend.read(dev_id=dev_id, prop_id=orig_prop_id) # Todo: refactor the classes here so this does not need @@ -113,6 +124,6 @@ def _calculate_delta_set(self, rcmd: ReadCommand, value): """ For overloading in derived classes e.g. for processing ophyd-async data """ - ref = self.cache.get(rcmd, None) + ref = self.filter.process(self.cache.get(rcmd, None)) assert ref is not None, f"No reference stored for {rcmd}" return value + ref diff --git a/src/accml_lib/core/interfaces/backend/filter.py b/src/accml_lib/core/interfaces/backend/filter.py new file mode 100644 index 0000000..31b09ad --- /dev/null +++ b/src/accml_lib/core/interfaces/backend/filter.py @@ -0,0 +1,10 @@ +from abc import ABCMeta, abstractmethod +from typing import TypeVar + +T = TypeVar("T") + + +class FilterInterface(metaclass=ABCMeta): + @abstractmethod + def process(self, input: T) -> T: + raise NotImplementedError("use derived class instead") \ No newline at end of file diff --git a/tests/test_core/test_delta_backend.py b/tests/test_core/test_delta_backend.py new file mode 100644 index 0000000..b5ed078 --- /dev/null +++ b/tests/test_core/test_delta_backend.py @@ -0,0 +1,198 @@ +import asyncio +import dataclasses +import pytest +import types + +from accml_lib.core.bl import delta_backend +from accml_lib.core.bl.delta_backend import StateCache, NOOPFilter +from accml_lib.core.interfaces.backend.filter import FilterInterface +from accml_lib.core.model.utils.command import ReadCommand + +# pytest-asyncio is required for the async tests +pytest_plugins = ("pytest_asyncio",) + + +# ------------------------- +# Adjust this import if your code is in a different module +# ------------------------- +# from backend import ( +# BackendR, +# BackendRW, +# DeltaBackendRProxy, +# DeltaBackendRWProxy, +# NOOPFilter, +# FilterInterface, +# ReadCommand, +# StateCache, +# delta_property, +# ) +# + +backend_mod = delta_backend + +class BlockingFilter(FilterInterface): + """Filter that returns None for some values to simulate filtering out cache entries.""" + def __init__(self, block=False): + self.block = block + + def process(self, input): + if self.block: + return None + return input + + +class FakeBackend(backend_mod.BackendRW): + """Simple fake backend to capture calls and return preset values.""" + + def __init__(self): + self.calls = [] + # map of (dev_id, prop_id) -> value for read + self.read_map = {} + # last set recorded as tuple (dev_id, prop_id, value) + self.last_set = None + + def get_natural_view_name(self): + return "fake" + + async def trigger(self, dev_id: str, prop_id: str): + self.calls.append(("trigger", dev_id, prop_id)) + return f"triggered:{dev_id}:{prop_id}" + + async def read(self, dev_id: str, prop_id: str) -> object: + self.calls.append(("read", dev_id, prop_id)) + # default to 0 if not set explicitly + return self.read_map.get((dev_id, prop_id), 0) + + async def set(self, dev_id: str, prop_id: str, value: object): + self.calls.append(("set", dev_id, prop_id, value)) + self.last_set = (dev_id, prop_id, value) + return f"set:{dev_id}:{prop_id}:{value}" + + +# ------------------------- +# Monkeypatch helpers for delta_property +# ------------------------- +def delta_prop_flag(prefix="de.ta_"): + """ + Returns a function suitable to monkeypatch backend_mod.delta_property + The function marks properties starting with prefix as delta properties, + returning (True, original_prop_id_without_prefix). + """ + def _dp(prop_id: str): + if prop_id.startswith(prefix): + return True, prop_id[len(prefix) :] + return False, prop_id + return _dp + + +# ------------------------- +# Tests +# ------------------------- + +@pytest.mark.asyncio +async def test_trigger_forwards_to_backend(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + proxy = backend_mod.DeltaBackendRProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + # monkeypatch delta_property so no property is considered delta + monkeypatch.setattr(backend_mod, "delta_property", lambda pid: (False, pid)) + + res = await proxy.trigger(dev_id="dev1", prop_id="p1") + assert res == "triggered:dev1:p1" + assert ("trigger", "dev1", "p1") in fake.calls + + +@pytest.mark.asyncio +async def test_read_non_delta_fetches_backend(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + fake.read_map[("devA", "pos")] = 42 + proxy = backend_mod.DeltaBackendRProxy(backend=fake, cache=cache) + + # no delta + monkeypatch.setattr(backend_mod, "delta_property", lambda pid: (False, pid)) + val = await proxy.read(dev_id="devA", prop_id="pos") + assert val == 42 + assert ("read", "devA", "pos") in fake.calls + + +@pytest.mark.asyncio +async def test_read_delta_uses_cache_and_returns_difference(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + # backend returns 100 for original property 'pos' + fake.read_map[("devA", "pos")] = 100 + + proxy = backend_mod.DeltaBackendRProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + # mark properties starting with 'de.ta_' as delta + monkeypatch.setattr(backend_mod, "delta_property", delta_prop_flag("de.ta_")) + + # First read: cache empty -> proxy should store reference and then compute value - ref + # In your code the sequence is: read backend, cache.set(rcmd, r), then _calculate_delta_read uses ref + res = await proxy.read(dev_id="devA", prop_id="de.ta_pos") + + # Because cache initially had no ref, implementation sets the reference to backend value (100) + # and then returns value - ref. Given code uses ref from cache (must be not None) and returns value - ref. + # Here that leads to 0. + assert res == 0 + + # ensure cache now has the stored ref + rcmd = ReadCommand(id="devA", property="pos") + # But our StateCache keys are our own ReadCommand class; proxy uses ReadCommand from your module. + # To keep test robust, check FakeBackend calls to ensure read happened. + assert ("read", "devA", "pos") in fake.calls + + +@pytest.mark.asyncio +async def test_set_delta_reads_cache_if_missing_and_calls_backend_with_total(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + # backend returns 10 for original property 'val' + fake.read_map[("devX", "val")] = 10 + + proxy = backend_mod.DeltaBackendRWProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + # mark properties with 'de.ta_' as delta + monkeypatch.setattr(backend_mod, "delta_property", delta_prop_flag("de.ta_")) + + # call set on delta property with desired delta = 5 should result in backend.set(..., value=15) + result = await proxy.set(dev_id="devX", prop_id="de.ta_val", value=5) + # verify the backend received set with orig_prop_id and total value + assert fake.last_set == ("devX", "val", 15) + assert ("read", "devX", "val") in fake.calls + assert ("set", "devX", "val", 15) in fake.calls + + +@pytest.mark.asyncio +async def test_set_non_delta_just_forwards(monkeypatch): + cache = StateCache(name="test_cache") + fake = FakeBackend() + proxy = backend_mod.DeltaBackendRWProxy(backend=fake, cache=cache, filter=NOOPFilter()) + + monkeypatch.setattr(backend_mod, "delta_property", lambda pid: (False, pid)) + await proxy.set(dev_id="d1", prop_id="p1", value=77) + + assert fake.last_set == ("d1", "p1", 77) + assert ("set", "d1", "p1", 77) in fake.calls + + +@pytest.mark.asyncio +async def test_filter_blocks_cache_and_raises_on_calculation(monkeypatch): + """ + If a filter blocks (returns None) then DeltaBackend*Proxy should behave consistently. + The code path currently asserts ref is not None in _calculate_delta_set/_calculate_delta_read, + so this test documents expected behavior (AssertionError). + """ + cache = StateCache(name="test_cache") + fake = FakeBackend() + proxy = backend_mod.DeltaBackendRWProxy(backend=fake, cache=cache, filter=BlockingFilter(block=True)) + + monkeypatch.setattr(backend_mod, "delta_property", delta_prop_flag("de.ta_")) + # backend returns 7 + fake.read_map[("dev1", "v")] = 7 + + with pytest.raises(AssertionError): + # this should raise because filter returns None and code asserts ref is not None + await proxy.set(dev_id="dev1", prop_id="de.ta_v", value=3) From 2f4f2af363bb4663777fcf5536a7f37669dfd418 Mon Sep 17 00:00:00 2001 From: Pierre Schnizer Date: Sun, 1 Feb 2026 11:57:28 +0100 Subject: [PATCH 2/2] [FIX] only apply filter after value retrieved --- src/accml_lib/core/bl/delta_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accml_lib/core/bl/delta_backend.py b/src/accml_lib/core/bl/delta_backend.py index 2644952..7d11afc 100644 --- a/src/accml_lib/core/bl/delta_backend.py +++ b/src/accml_lib/core/bl/delta_backend.py @@ -109,7 +109,7 @@ async def set(self, dev_id: str, prop_id: str, value: object): return await self.backend.set(dev_id=dev_id, prop_id=prop_id, value=value) rcmd = ReadCommand(id=dev_id, property=orig_prop_id) - ref = self.filter.process(self.cache.get(rcmd, None)) + ref = self.cache.get(rcmd, None) if not ref: r = await self.backend.read(dev_id=dev_id, prop_id=orig_prop_id) # Todo: refactor the classes here so this does not need