diff --git a/neuroml/l2validators.py b/neuroml/l2validators.py new file mode 100644 index 00000000..e8f61caa --- /dev/null +++ b/neuroml/l2validators.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +"Level 2" validators: extra tests in addition to the validation tests included +in the standard/schema + +File: neuroml/l2validators.py + +Copyright 2023 NeuroML contributors +Author: Ankur Sinha +""" + +import inspect +import importlib +import typing +from abc import ABC, abstractmethod +from enum import Enum +import logging +logger = logging.getLogger(__name__) + + +class TEST_LEVEL(Enum): + WARNING = logging.WARNING + ERROR = logging.ERROR + + +class StandardTestSuper(ABC): + + """Class implementing a standard test. + + All tests should extend this class. `L2Validator` will automatically + register any classes in this module that extend this class. + """ + + test_id = "" + target_class = "" + description = "" + level = TEST_LEVEL.ERROR + + @abstractmethod + def run(self, obj): + """Implementation of test to run. + :param obj: object to run test on + :returns: True if test passes, false if not + """ + pass + + +class L2Validator(object): + + """Main validator class.""" + tests: typing.Dict[str, typing.Any] = {} + + def __init__(self): + """Register all classes that extend StandardTestSuper """ + this_module = importlib.import_module(__name__) + for name, obj in inspect.getmembers(this_module): + if inspect.isclass(obj): + if StandardTestSuper in obj.__mro__: + if obj != StandardTestSuper: + self.register_test(obj) + + @classmethod + def validate(cls, obj, class_name=None, collector=None): + """Main validate method that should include calls to all tests that are + to be run on an object + + :param obj: object to be validated + :type obj: an object to be validated + :param class_name: name of class for which tests are to be run + In most cases, this will be None, and the class name will be + obtained from the object. However, in cases where a tests has been + defined in an ancestor (BaseCell, which a Cell would inherit), one + can pass the class name of the ancestor. This can be used to run L2 + test defined for ancestors for all descendents. In fact, tests for + arbitrary classes can be run on any objects. It is for the + developer to ensure that the appropriate tests are run. + :type class_name: str + :param collector: a GdsCollector instance for messages + :type collector: neuroml.GdsCollector + :returns: True if all validation tests pass, false if not + """ + test_result = True + class_name_ = class_name if class_name else obj.__class__.__name__ + # The collector looks for a local with name "self" in the stack frame + # to figure out what the "caller" class is. + # So, set "self" to the object that is being validated here. + # self = obj + + try: + for test in cls.tests[class_name_]: + test_result = test.run(obj) + + if test_result is False: + if obj.collector: + obj.collector.add_message(f"Validation failed: {test.test_id}: {test.description}") + if test.level == logging.WARNING: + # a warning, don't mark as invalid + test_result = True + logger.warning(f"Validation failed: {obj}: {test.test_id}: {test.description}") + else: + logger.error(f"Validation failed: {obj}: {test.test_id}: {test.description}") + else: + logger.debug(f"PASSED: {obj}: {test.test_id}: {test.description}") + + except KeyError: + pass # no L2 tests have been defined + + return test_result + + @classmethod + def register_test(cls, test): + """Register a test class + + :param test: test class to register + :returns: None + + """ + try: + if test not in cls.tests[test.target_class]: + cls.tests[test.target_class].append(test) + except KeyError: + cls.tests[test.target_class] = [test] + + @classmethod + def list_tests(cls): + """List all registered tests.""" + print("Registered tests:") + for key, val in cls.tests.items(): + print(f"* {key}") + for t in val: + print(f"\t* {t.test_id}: {t.description}") + print() + + +class SegmentGroupSelfIncludes(StandardTestSuper): + + """Segment groups should not include themselves""" + test_id = "0001" + target_class = "SegmentGroup" + description = "Segment group includes itself" + level = TEST_LEVEL.ERROR + + @classmethod + def run(self, obj): + """Test runner method. + + :param obj: object to run tests on + :type object: any neuroml.* object + :returns: True if test passes, false if not. + + """ + for sginc in obj.includes: + if sginc.segment_groups == obj.id: + return False + return True diff --git a/neuroml/nml/generatedssupersuper.py b/neuroml/nml/generatedssupersuper.py index 14bf29ec..1ace97a4 100644 --- a/neuroml/nml/generatedssupersuper.py +++ b/neuroml/nml/generatedssupersuper.py @@ -11,6 +11,7 @@ import sys from .generatedscollector import GdsCollector +from ..l2validators import L2Validator class GeneratedsSuperSuper(object): @@ -20,6 +21,9 @@ class GeneratedsSuperSuper(object): Any bits that must go into every libNeuroML class should go here. """ + l2_validator = L2Validator() + collector = GdsCollector() # noqa + def add(self, obj=None, hint=None, force=False, validate=True, **kwargs): """Generic function to allow easy addition of a new member to a NeuroML object. Without arguments, when `obj=None`, it simply calls the `info()` method @@ -387,19 +391,33 @@ def validate(self, recursive=False): :rtype: None :raises ValueError: if component is invalid """ - collector = GdsCollector() # noqa + self.collector.clear_messages() valid = True for c in type(self).__mro__: if getattr(c, "validate_", None): - v = c.validate_(self, collector, recursive) + v = c.validate_(self, self.collector, recursive) valid = valid and v + # l2 tests for specific classes + v1 = self.l2_validator.validate(obj=self, + class_name=c.__name__, + collector=self.collector) + valid = valid and v1 + if valid is False: err = "Validation failed:\n" - for msg in collector.get_messages(): + for msg in self.collector.get_messages(): err += f"- {msg}\n" raise ValueError(err) + # Other validation warnings + msgs = self.collector.get_messages() + if len(msgs) > 0: + err = "Validation warnings:\n" + for msg in self.collector.get_messages(): + err += f"- {msg}\n" + print(err) + def parentinfo(self, return_format="string"): """Show the list of possible parents. diff --git a/neuroml/nml/helper_methods.py b/neuroml/nml/helper_methods.py index 8a5f2f7f..281100c8 100644 --- a/neuroml/nml/helper_methods.py +++ b/neuroml/nml/helper_methods.py @@ -1762,6 +1762,8 @@ def optimise_segment_group(self, seg_group_id): members = new_members seg_group.members = list(members) + # TODO: only deduplicates by identical objects, also deduplicate by + # contents includes = seg_group.includes new_includes = [] for i in includes: diff --git a/neuroml/test/test_l2validators.py b/neuroml/test/test_l2validators.py new file mode 100644 index 00000000..8d909702 --- /dev/null +++ b/neuroml/test/test_l2validators.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Enter one line description here. + +File: + +Copyright 2023 Ankur Sinha +Author: Ankur Sinha +""" + +try: + import unittest2 as unittest +except ImportError: + import unittest +import logging + + +from neuroml.l2validators import (L2Validator, SegmentGroupSelfIncludes, + StandardTestSuper) +from neuroml import SegmentGroup, Include +from neuroml.utils import component_factory + +class TestL2Validators(unittest.TestCase): + + """Docstring for TestL2Validators. """ + + def test_l2validator(self): + """Test L2Validtor + + tests that standard tests defined in the l2validators module are + automatically registered. + """ + + self.l2validator = None + self.l2validator = L2Validator() + self.l2validator.list_tests() + self.assertIn(SegmentGroupSelfIncludes, list(self.l2validator.tests["SegmentGroup"])) + + def test_l2validator_register(self): + """Test l2validator test registration """ + class DummyTest(StandardTestSuper): + + """A dummy test""" + test_id = "0001" + target_class = "DummyClass" + description = "DummyClass" + level = 0 + + @classmethod + def run(self, obj): + """Test runner method. + + :param obj: object to run tests on + :type object: any neuroml.* object + :returns: True if test passes, false if not. + + """ + return True + + self.l2validator = None + self.l2validator = L2Validator() + self.l2validator.register_test(DummyTest) + self.l2validator.list_tests() + self.assertIn(DummyTest, list(self.l2validator.tests["DummyClass"])) + + def test_l2validator_runs(self): + """Test l2validator test running""" + class DummyTest(StandardTestSuper): + + """A dummy test""" + test_id = "0001" + target_class = "DummyClass" + description = "DummyClass" + level = logging.ERROR + + @classmethod + def run(self, obj): + """Test runner method. + + :param obj: object to run tests on + :type object: any neuroml.* object + :returns: True if test passes, false if not. + + """ + return True + + class DummyClass(object): + + """A dummy class""" + + name = "dummy" + + self.l2validator = None + self.l2validator = L2Validator() + self.l2validator.register_test(DummyTest) + dummy = DummyClass() + + self.assertIn(DummyTest, list(self.l2validator.tests["DummyClass"])) + self.assertTrue(self.l2validator.validate(dummy)) + + def test_l2validator_runs_errors(self): + """Test l2validator test running""" + class DummyTest(StandardTestSuper): + + """A dummy test""" + test_id = "0001" + target_class = "DummyClass" + description = "DummyClass" + level = logging.ERROR + + @classmethod + def run(self, obj): + """Test runner method. + + :param obj: object to run tests on + :type object: any neuroml.* object + :returns: True if test passes, false if not. + + """ + return False + + class DummyClass(object): + + """A dummy class""" + + name = "dummy" + + self.l2validator = None + self.l2validator = L2Validator() + self.l2validator.register_test(DummyTest) + dummy = DummyClass() + + self.assertIn(DummyTest, list(self.l2validator.tests["DummyClass"])) + + # since it's an error, validation should fail + self.assertFalse(self.l2validator.validate(dummy)) + + def test_l2validator_runs_warns(self): + """Test l2validator test running""" + class DummyTest(StandardTestSuper): + + """A dummy test""" + test_id = "0001" + target_class = "DummyClass" + description = "DummyClass" + level = logging.WARNING + + @classmethod + def run(self, obj): + """Test runner method. + + :param obj: object to run tests on + :type object: any neuroml.* object + :returns: True if test passes, false if not. + + """ + return False + + class DummyClass(object): + + """A dummy class""" + + name = "dummy" + + self.l2validator = None + self.l2validator = L2Validator() + self.l2validator.register_test(DummyTest) + dummy = DummyClass() + + self.assertIn(DummyTest, list(self.l2validator.tests["DummyClass"])) + + # since it's a warning, validation should still pass + self.assertTrue(self.l2validator.validate(dummy)) + + def test_SegmentGroupSelfIncludes(self): + """test SegmentGroupSelfIncludes class""" + sg = component_factory(SegmentGroup, validate=True, id="dummy_group") + sg.l2_validator.list_tests() + with self.assertRaises(ValueError): + sg.add(Include, segment_groups="dummy_group") diff --git a/neuroml/test/test_nml.py b/neuroml/test/test_nml.py index 8f2caa24..d0c04213 100644 --- a/neuroml/test/test_nml.py +++ b/neuroml/test/test_nml.py @@ -609,20 +609,19 @@ def test_optimise_segment_group(self): segments=cell.get_segment_group("soma_0").members[0].segments)) # add group to all, explicitly - cell.get_segment_group("all").add(neuroml.Include, - segment_groups="soma_0", - force=True) - cell.get_segment_group("all").add(neuroml.Include, - segment_groups="soma_0", - force=True) - cell.get_segment_group("all").add(neuroml.Include, - segment_groups="soma_0", - force=True) + inc = neuroml.Include(segment_groups="soma_0", force=True) + + cell.get_segment_group("all").add(inc, force=True) + cell.get_segment_group("all").add(inc, force=True) + cell.get_segment_group("all").add(inc, force=True) + self.assertEqual(4, len(cell.get_segment_group("all").includes)) # should have only one included segment group # should have no segments, because the segment is included in the one # segment group already cell.optimise_segment_group("all") + for i in cell.get_segment_group("all").includes: + print(i) self.assertEqual(1, len(cell.get_segment_group("all").includes)) def test_create_unbranched_segment_group_branches(self): @@ -721,3 +720,4 @@ def test_morphinfo(self): cell.morphinfo(True) cell.biophysinfo() +