diff --git a/superscore/backends/core.py b/superscore/backends/core.py index 953b58e..444b686 100644 --- a/superscore/backends/core.py +++ b/superscore/backends/core.py @@ -1,22 +1,12 @@ """ Base superscore data storage backend interface """ -import re -from collections.abc import Container, Generator -from typing import NamedTuple, Union +from collections.abc import Generator +from typing import Union from uuid import UUID from superscore.model import Entry, Root -from superscore.type_hints import AnyEpicsType - -SearchTermValue = Union[AnyEpicsType, Container[AnyEpicsType], tuple[AnyEpicsType, ...]] -SearchTermType = tuple[str, str, SearchTermValue] - - -class SearchTerm(NamedTuple): - attr: str - operator: str - value: SearchTermValue +from superscore.search_term import SearchTermType class _Backend: @@ -67,41 +57,6 @@ def search(self, *search_terms: SearchTermType) -> Generator[Entry, None, None]: """ raise NotImplementedError - @staticmethod - def compare(op: str, data: AnyEpicsType, target: SearchTermValue) -> bool: - """ - Return whether data and target satisfy the op comparator, typically during application - of a search filter. Possible values of op are detailed in _Backend.search - - Parameters - ---------- - op: str - one of the comparators that all backends must support, detailed in _Backend.search - data: AnyEpicsType | Tuple[AnyEpicsType] - data from an Entry that is being used to decide whether the Entry passes a filter - target: AnyEpicsType | Tuple[AnyEpicsType] - the filter value - - Returns - ------- - bool - whether data and target satisfy the op condition - """ - if op == "eq": - return data == target - elif op == "lt": - return data <= target - elif op == "gt": - return data >= target - elif op == "in": - return data in target - elif op == "like": - if isinstance(data, UUID): - data = str(data) - return re.search(target, data) - else: - raise ValueError(f"SearchTerm does not support operator \"{op}\"") - @property def root(self) -> Root: """Return the Root Entry in this backend""" diff --git a/superscore/backends/filestore.py b/superscore/backends/filestore.py index e04d649..9aa201c 100644 --- a/superscore/backends/filestore.py +++ b/superscore/backends/filestore.py @@ -17,6 +17,7 @@ from superscore.errors import BackendError from superscore.model import Entry, Root from superscore.utils import build_abs_path +from superscore.visitor import SearchVisitor logger = logging.getLogger(__name__) @@ -285,22 +286,10 @@ def search(self, *search_terms: SearchTermType) -> Generator[Entry, None, None]: Keys are attributes on `Entry` subclasses, or special keywords. Values can be a single value or a tuple of values depending on operator. """ - with self._load_and_store_context() as db: - for entry in db.values(): - conditions = [] - for attr, op, target in search_terms: - # TODO: search for child pvs? - if attr == "entry_type": - conditions.append(isinstance(entry, target)) - else: - try: - # check entry attribute by name - value = getattr(entry, attr) - conditions.append(self.compare(op, value, target)) - except AttributeError: - conditions.append(False) - if all(conditions): - yield entry + visitor = SearchVisitor(self, *search_terms) + root = self.root + visitor.visit(root) + yield from visitor.matches @contextlib.contextmanager def _load_and_store_context(self) -> Generator[Dict[UUID, Any], None, None]: diff --git a/superscore/backends/test.py b/superscore/backends/test.py index 71b9d13..86564c6 100644 --- a/superscore/backends/test.py +++ b/superscore/backends/test.py @@ -9,6 +9,7 @@ from superscore.errors import (BackendError, EntryExistsError, EntryNotFoundError) from superscore.model import Entry, Nestable, Root +from superscore.visitor import SearchVisitor class TestBackend(_Backend): @@ -75,18 +76,7 @@ def root(self) -> Root: return self._root def search(self, *search_terms: SearchTermType): - for entry in self._entry_cache.values(): - conditions = [] - for attr, op, target in search_terms: - # TODO: search for child pvs? - if attr == "entry_type": - conditions.append(isinstance(entry, target)) - else: - try: - # check entry attribute by name - value = getattr(entry, attr) - conditions.append(self.compare(op, value, target)) - except AttributeError: - conditions.append(False) - if all(conditions): - yield entry + visitor = SearchVisitor(self, *search_terms) + root = self.root + visitor.visit(root) + yield from visitor.matches diff --git a/superscore/client.py b/superscore/client.py index 907716d..476f449 100644 --- a/superscore/client.py +++ b/superscore/client.py @@ -7,13 +7,14 @@ from uuid import UUID from superscore.backends import get_backend -from superscore.backends.core import SearchTerm, SearchTermType, _Backend +from superscore.backends.core import _Backend from superscore.compare import DiffItem, EntryDiff, walk_find_diff from superscore.control_layers import ControlLayer, EpicsData from superscore.control_layers.status import TaskStatus from superscore.errors import CommunicationError from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, Setpoint, Snapshot) +from superscore.search_term import SearchTerm, SearchTermType from superscore.utils import build_abs_path logger = logging.getLogger(__name__) diff --git a/superscore/model.py b/superscore/model.py index a689762..d0cb4ae 100644 --- a/superscore/model.py +++ b/superscore/model.py @@ -111,6 +111,11 @@ def validate(self, toplevel: bool = True) -> bool: readback_is_valid = self.readback is None or self.readback.validate(toplevel=False) return readback_is_valid and super().validate(toplevel=toplevel) + def accept(self, visitor) -> None: + visitor.visitParameter(self) + if self.readback is not None: + visitor.visit(self.readback) + @dataclass class Setpoint(Entry): @@ -142,6 +147,11 @@ def validate(self, toplevel: bool = True) -> bool: readback_is_valid = self.readback is None or self.readback.validate(toplevel=False) return readback_is_valid and super().validate(toplevel=toplevel) + def accept(self, visitor) -> None: + visitor.visitSetpoint(self) + if self.readback is not None: + visitor.visit(self.readback) + @dataclass class Readback(Entry): @@ -183,6 +193,9 @@ def from_parameter( timeout=timeout, ) + def accept(self, visitor) -> None: + visitor.visitReadback(self) + class Nestable: """Mix-in class that provides methods for nested container Entries""" @@ -237,6 +250,11 @@ def swap_to_uuids(self) -> List[Entry]: self.children = new_children return ref_list + def accept(self, visitor) -> None: + visitor.visitCollection(self) + for entry in self.children: + visitor.visit(entry) + @dataclass class Snapshot(Nestable, Entry): @@ -274,9 +292,19 @@ def swap_to_uuids(self) -> List[Union[Entry, UUID]]: return ref_list + def accept(self, visitor) -> None: + visitor.visitSnapshot(self) + for entry in self.children: + visitor.visit(entry) + @dataclass class Root: """Top level structure holding ``Entry``'s. Denotes the top of the tree""" - meta_id: UUID = _root_uuid + uuid: UUID = _root_uuid entries: List[Entry] = field(default_factory=list) + + def accept(self, visitor) -> None: + visitor.visitRoot(self) + for entry in self.entries: + visitor.visit(entry) diff --git a/superscore/search_term.py b/superscore/search_term.py new file mode 100644 index 0000000..43684f6 --- /dev/null +++ b/superscore/search_term.py @@ -0,0 +1,12 @@ +from typing import Container, NamedTuple, Union + +from superscore.type_hints import AnyEpicsType + +SearchTermValue = Union[AnyEpicsType, Container[AnyEpicsType], tuple[AnyEpicsType, ...]] +SearchTermType = tuple[str, str, SearchTermValue] + + +class SearchTerm(NamedTuple): + attr: str + operator: str + value: SearchTermValue diff --git a/superscore/tests/db/filestore.json b/superscore/tests/db/filestore.json index 8562893..b053a90 100644 --- a/superscore/tests/db/filestore.json +++ b/superscore/tests/db/filestore.json @@ -1,5 +1,5 @@ { - "meta_id": "a28cd77d-cc92-46cc-90cb-758f0f36f041", + "uuid": "a28cd77d-cc92-46cc-90cb-758f0f36f041", "entries": [ { "Parameter": { diff --git a/superscore/tests/test_backend.py b/superscore/tests/test_backend.py index 557dbf2..1e5e0ab 100644 --- a/superscore/tests/test_backend.py +++ b/superscore/tests/test_backend.py @@ -3,10 +3,11 @@ import pytest -from superscore.backends.core import SearchTerm, _Backend +from superscore.backends.core import _Backend from superscore.errors import (BackendError, EntryExistsError, EntryNotFoundError) from superscore.model import Collection, Parameter, Snapshot +from superscore.search_term import SearchTerm class TestTestBackend: diff --git a/superscore/tests/test_client.py b/superscore/tests/test_client.py index 22024fb..38c4f2a 100644 --- a/superscore/tests/test_client.py +++ b/superscore/tests/test_client.py @@ -5,7 +5,6 @@ import pytest -from superscore.backends.core import SearchTerm from superscore.backends.filestore import FilestoreBackend from superscore.backends.test import TestBackend from superscore.client import Client @@ -13,6 +12,7 @@ from superscore.errors import CommunicationError from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, Root, Setpoint) +from superscore.search_term import SearchTerm from superscore.tests.conftest import MockTaskStatus, nest_depth SAMPLE_CFG = Path(__file__).parent / 'config.cfg' @@ -211,8 +211,24 @@ def test_fill_depth(fill_depth: int): @pytest.mark.parametrize("filestore_backend", [("linac_with_comparison_snapshot",)], indirect=True) -def test_parametrized_filestore(sample_client: Client): - assert len(list(sample_client.search())) > 0 +def test_search_entries_by_ancestor(sample_client: Client): + entries = tuple(sample_client.search( + ("entry_type", "eq", Setpoint), + ("pv_name", "eq", "LASR:GUNB:TEST1"), + )) + assert len(entries) == 2 + entries = tuple(sample_client.search( + ("entry_type", "eq", Setpoint), + ("pv_name", "eq", "LASR:GUNB:TEST1"), + ("ancestor", "eq", UUID("06282731-33ea-4270-ba14-098872e627dc")), # top-level snapshot + )) + assert len(entries) == 1 + entries = tuple(sample_client.search( + ("entry_type", "eq", Setpoint), + ("pv_name", "eq", "LASR:GUNB:TEST1"), + ("ancestor", "eq", UUID("2f709b4b-79da-4a8b-8693-eed2c389cb3a")), # direct parent + )) + assert len(entries) == 1 def test_parametrized_filestore_empty(sample_client: Client): diff --git a/superscore/tests/test_compare.py b/superscore/tests/test_compare.py index 9b72827..4a99376 100644 --- a/superscore/tests/test_compare.py +++ b/superscore/tests/test_compare.py @@ -4,12 +4,12 @@ import pytest -from superscore.backends.core import SearchTerm from superscore.client import Client from superscore.compare import (AttributePath, DiffItem, EntryDiff, walk_find_diff) from superscore.model import (Collection, Entry, Parameter, Readback, Setpoint, Severity, Snapshot, Status) +from superscore.search_term import SearchTerm def simplify_path(path: AttributePath) -> AttributePath: diff --git a/superscore/visitor.py b/superscore/visitor.py new file mode 100644 index 0000000..279c53b --- /dev/null +++ b/superscore/visitor.py @@ -0,0 +1,133 @@ +"""""" +import re +from typing import Iterable, Union +from uuid import UUID + +from superscore.model import (Collection, Entry, Parameter, Readback, Root, + Setpoint, Snapshot) +from superscore.search_term import SearchTerm, SearchTermValue +from superscore.type_hints import AnyEpicsType + + +class EntryVisitor: + """""" + def __init__(self, backend): + self.backend = backend + + def visit(self, entry: Union[Entry, Root, UUID]) -> None: + if isinstance(entry, UUID): + entry = self.backend.get_entry(entry) + entry.accept(self) + return + + def visitParameter(self, parameter: Parameter) -> None: + raise NotImplementedError + + def visitSetpoint(self, setpoint: Setpoint) -> None: + raise NotImplementedError + + def visitReadback(self, readback: Readback) -> None: + raise NotImplementedError + + def visitCollection(self, collection: Collection) -> None: + raise NotImplementedError + + def visitSnapshot(self, snapshot: Snapshot) -> None: + raise NotImplementedError + + def visitRoot(self, root: Root) -> None: + raise NotImplementedError + + +class FillUUIDVisitor(EntryVisitor): + pass + + +class SnapVisitor(EntryVisitor): + pass + + +class SearchVisitor(EntryVisitor): + def __init__(self, backend, *search_terms: Iterable[SearchTerm]): + super().__init__(backend) + self.search_terms = search_terms + self.path = [] + self.matches = [] + + def _check_match(self, entry: Union[Entry, Root]) -> bool: + conditions = [] + for attr, op, target in self.search_terms: + # TODO: search for child pvs? + if attr == "entry_type": + conditions.append(isinstance(entry, target)) + elif attr == "ancestor": + conditions.append(any([entry.uuid == target for entry in self.path])) + else: + try: + # check entry attribute by name + value = getattr(entry, attr) + conditions.append(self.compare(op, value, target)) + except AttributeError: + conditions.append(False) + if all(conditions): + self.matches.append(entry) + + def visit(self, entry: Union[Entry, Root, UUID]) -> None: + if isinstance(entry, UUID): + entry = self.backend.get_entry(entry) + self.path.append(entry) + super().visit(entry) + self.path.pop() + + def visitParameter(self, parameter: Parameter) -> None: + self._check_match(parameter) + + def visitSetpoint(self, setpoint: Setpoint) -> None: + self._check_match(setpoint) + + def visitReadback(self, readback: Readback) -> None: + self._check_match(readback) + + def visitCollection(self, collection: Collection) -> None: + self._check_match(collection) + + def visitSnapshot(self, snapshot: Snapshot) -> None: + self._check_match(snapshot) + + def visitRoot(self, root: Root) -> None: + return + + @staticmethod + def compare(op: str, data: AnyEpicsType, target: SearchTermValue) -> bool: + """ + Return whether data and target satisfy the op comparator, typically during application + of a search filter. Possible values of op are detailed in _Backend.search + + Parameters + ---------- + op: str + one of the comparators that all backends must support, detailed in _Backend.search + data: AnyEpicsType | Tuple[AnyEpicsType] + data from an Entry that is being used to decide whether the Entry passes a filter + target: AnyEpicsType | Tuple[AnyEpicsType] + the filter value + + Returns + ------- + bool + whether data and target satisfy the op condition + """ + if op == "eq": + return data == target + elif op == "lt": + return data <= target + elif op == "gt": + return data >= target + elif op == "in": + return data in target + elif op == "like": + if isinstance(data, UUID): + data = str(data) + return re.search(target, data) + else: + raise ValueError(f"SearchTerm does not support operator \"{op}\"") diff --git a/superscore/widgets/page/search.py b/superscore/widgets/page/search.py index 7b80ac8..839812e 100644 --- a/superscore/widgets/page/search.py +++ b/superscore/widgets/page/search.py @@ -8,9 +8,9 @@ from dateutil import tz from qtpy import QtCore, QtWidgets -from superscore.backends.core import SearchTerm from superscore.client import Client from superscore.model import Collection, Entry, Readback, Setpoint, Snapshot +from superscore.search_term import SearchTerm from superscore.type_hints import OpenPageSlot from superscore.widgets import ICON_MAP from superscore.widgets.core import Display diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index e8a4332..f3ba8b2 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -15,13 +15,13 @@ import qtawesome as qta from qtpy import QtCore, QtGui, QtWidgets -from superscore.backends.core import SearchTerm from superscore.client import Client from superscore.control_layers import EpicsData from superscore.errors import EntryNotFoundError from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, Root, Setpoint, Severity, Snapshot, Status) from superscore.qt_helpers import QDataclassBridge +from superscore.search_term import SearchTerm from superscore.type_hints import OpenPageSlot from superscore.widgets import ICON_MAP