From 61871f270419cca0bb378e87dc39a3297cc41754 Mon Sep 17 00:00:00 2001 From: Devan Agrawal Date: Fri, 10 May 2024 14:45:59 -0700 Subject: [PATCH] ENH: Add validation methods for all Entries Entry.validate includes an apischema serialization roundtrip to enforce type checks Nestable validation includes cycle detection and making sure Nestables aren't empty. --- superscore/backends/test.py | 6 ++-- superscore/model.py | 58 +++++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/superscore/backends/test.py b/superscore/backends/test.py index 7cc3fc0..f9ae603 100644 --- a/superscore/backends/test.py +++ b/superscore/backends/test.py @@ -7,7 +7,7 @@ from superscore.backends.core import _Backend from superscore.errors import (BackendError, EntryExistsError, EntryNotFoundError) -from superscore.model import Collection, Entry, Snapshot +from superscore.model import Entry, Nestable class TestBackend(_Backend): @@ -28,7 +28,7 @@ def get_entry(self, uuid: UUID) -> Entry: entry = stack.pop() if entry.uuid == uuid: return entry - if isinstance(entry, Collection) or isinstance(entry, Snapshot): + if isinstance(entry, Nestable): stack.extend(entry.children) raise EntryNotFoundError(f"Entry {entry.uuid} could not be found") @@ -45,4 +45,4 @@ def delete_entry(self, to_delete: Entry) -> None: children.remove(entry) elif entry.uuid == to_delete.uuid: raise BackendError(f"Can't delete: entry {to_delete.uuid} is out of sync with the version in the backend") - stack.extend([entry.children for entry in children if isinstance(entry, Collection) or isinstance(entry, Snapshot)]) + stack.extend([entry.children for entry in children if isinstance(entry, Nestable)]) diff --git a/superscore/model.py b/superscore/model.py index 425acaf..cd878d3 100644 --- a/superscore/model.py +++ b/superscore/model.py @@ -1,6 +1,7 @@ """Classes for representing data""" from __future__ import annotations +import apischema import logging from dataclasses import dataclass, field from datetime import datetime @@ -59,6 +60,20 @@ class Entry: description: str = '' creation_time: datetime = field(default_factory=utcnow) + def validate(self) -> bool: + """ + Ensures Entry is properly formed. + + Raises apischema.ValidationError if type checking fails during + deserialization + """ + try: + serial = apischema.serialize(self) + apischema.deserialize(type(self), serial) + return True + except apischema.ValidationError: + return False + @dataclass class Parameter(Entry): @@ -69,6 +84,10 @@ class Parameter(Entry): readback: Optional[Parameter] = None read_only: bool = False + def validate(self) -> bool: + readback_is_valid = self.readback is None or self.readback.validate() + return readback_is_valid and super().validate() + @dataclass class Value(Entry): @@ -84,6 +103,10 @@ class Setpoint(Value): """A Value that can be written to the EPICS environment""" readback: Optional[Readback] = None + def validate(self) -> bool: + readback_is_valid = self.readback is None or self.readback.validate() + return readback_is_valid and super().validate() + @dataclass class Readback(Value): @@ -102,8 +125,39 @@ class Readback(Value): timeout: Optional[float] = None +class Nestable: + """Abstract class that provides methods for nested container Entries""" + def validate(self): + """ + Validates structure of Entry tree, then uses validate_nodes to validate + individual Entries. This separation avoids redundant work by only performing + tree-level validation once. Overrides Entry.validate(). + """ + return not self.has_cycle() and super().validate() and self.validate_nodes() + + def validate_nodes(self): + """ + Validates self and all children at the Entry level. See Nestable.validate() for + more information. + """ + not_empty = len(self.children) > 0 + all_children_are_valid = all(child.validate_nodes() if isinstance(child, Nestable) else child.validate() for child in self.children) + return not_empty and all_children_are_valid + + def has_cycle(self, parents=set()) -> bool: + if self.uuid in parents: + return True + else: + parents.add(self.uuid) + for child in self.children: + if isinstance(child, type(self)) and child.has_cycle(parents=parents): + return True + parents.remove(self.uuid) + return False + + @dataclass -class Collection(Entry): +class Collection(Nestable, Entry): """Nestable group of Parameters and Collections""" meta_pvs: ClassVar[List[Parameter]] = [] all_tags: ClassVar[Set[Tag]] = set() @@ -114,7 +168,7 @@ class Collection(Entry): @dataclass -class Snapshot(Entry): +class Snapshot(Nestable, Entry): """ Nestable group of Values and Snapshots. Effectively a data-filled Collection """