diff --git a/odml/format.py b/odml/format.py index 85e65501..74d33194 100644 --- a/odml/format.py +++ b/odml/format.py @@ -157,6 +157,7 @@ class Section(Format): 'section': 0, 'include': 0, 'property': 0, + 'sec_cardinality': 0, 'prop_cardinality': 0 } _map = { diff --git a/odml/section.py b/odml/section.py index 19ece352..8e8f018c 100644 --- a/odml/section.py +++ b/odml/section.py @@ -43,6 +43,10 @@ class BaseSection(base.Sectionable): :param oid: object id, UUID string as specified in RFC 4122. If no id is provided, an id will be generated and assigned. An id has to be unique within an odML Document. + :param sec_cardinality: Section cardinality defines how many Sub-Sections are allowed for this + Section. By default unlimited Sections can be set. + A required number of Sections can be set by assigning a tuple of the + format "(min, max)" :param prop_cardinality: Property cardinality defines how many Properties are allowed for this Section. By default unlimited Properties can be set. A required number of Properties can be set by assigning a tuple of the @@ -60,7 +64,7 @@ class BaseSection(base.Sectionable): def __init__(self, name=None, type="n.s.", parent=None, definition=None, reference=None, repository=None, link=None, include=None, oid=None, - prop_cardinality=None): + sec_cardinality=None, prop_cardinality=None): # Sets _sections Smartlist and _repository to None, so run first. super(BaseSection, self).__init__() @@ -86,6 +90,7 @@ def __init__(self, name=None, type="n.s.", parent=None, self._repository = repository self._link = link self._include = include + self._sec_cardinality = None self._prop_cardinality = None # this may fire a change event, so have the section setup then @@ -94,6 +99,7 @@ def __init__(self, name=None, type="n.s.", parent=None, # This might lead to a validation warning, since properties are set # at a later point in time. + self.sec_cardinality = sec_cardinality self.prop_cardinality = prop_cardinality for err in validation.Validation(self).errors: @@ -360,6 +366,59 @@ def get_repository(self): def repository(self, url): base.Sectionable.repository.fset(self, url) + @property + def sec_cardinality(self): + """ + The Section cardinality of a Section. It defines how many Sections + are minimally required and how many Sections should be maximally + stored. Use the 'set_sections_cardinality' method to set. + """ + return self._sec_cardinality + + @sec_cardinality.setter + def sec_cardinality(self, new_value): + """ + Sets the Sections cardinality of a Section. + + The following cardinality cases are supported: + (n, n) - default, no restriction + (d, n) - minimally d entries, no maximum + (n, d) - maximally d entries, no minimum + (d, d) - minimally d entries, maximally d entries + + Only positive integers are supported. 'None' is used to denote + no restrictions on a maximum or minimum. + + :param new_value: Can be either 'None', a positive integer, which will set + the maximum or an integer 2-tuple of the format '(min, max)'. + """ + self._sec_cardinality = format_cardinality(new_value) + + # Validate and inform user if the current cardinality is violated + self._sections_cardinality_validation() + + def set_sections_cardinality(self, min_val=None, max_val=None): + """ + Sets the Sections cardinality of a Section. + + :param min_val: Required minimal number of values elements. None denotes + no restrictions on values elements minimum. Default is None. + :param max_val: Allowed maximal number of values elements. None denotes + no restrictions on values elements maximum. Default is None. + """ + self.sec_cardinality = (min_val, max_val) + + def _sections_cardinality_validation(self): + """ + Runs a validation to check whether the sections cardinality + is respected and prints a warning message otherwise. + """ + valid = validation.Validation(self) + # Make sure to display only warnings of the current section + res = [curr for curr in valid.errors if self.id == curr.obj.id] + for err in res: + print("%s: %s" % (err.rank.capitalize(), err.msg)) + @property def prop_cardinality(self): """ diff --git a/odml/validation.py b/odml/validation.py index 7d4d71de..2107e5ac 100644 --- a/odml/validation.py +++ b/odml/validation.py @@ -53,9 +53,9 @@ def __repr__(self): class Validation(object): """ Validation provides a set of default validations that can used to validate - an odml.Document. Custom validations can be added via the 'register_handler' method. + odml objects. Custom validations can be added via the 'register_handler' method. - :param doc: odml.Document that the validation will be applied to. + :param obj: odml object the validation will be applied to. """ _handlers = {} @@ -77,19 +77,18 @@ def register_handler(klass, handler): """ Validation._handlers.setdefault(klass, set()).add(handler) - def __init__(self, obj): - self.doc = obj # may also be a section + def __init__(self, obj, validate=True, reset=False): + self.obj = obj # may also be a section self.errors = [] - self.validate(obj) - - if obj.format().name == "property": + # If initialized with reset=True, reset all handlers and + # do not run any validation yet to allow custom Validation objects. + if reset: + self._handlers = {} return - for sec in obj.itersections(recursive=True): - self.validate(sec) - for prop in sec.properties: - self.validate(prop) + if validate: + self.run_validation() def validate(self, obj): """ @@ -109,6 +108,38 @@ def error(self, validation_error): """ self.errors.append(validation_error) + def run_validation(self): + """ + Runs a clean new validation on the registered Validation object. + """ + self.errors = [] + + self.validate(self.obj) + + if self.obj.format().name == "property": + return + + for sec in self.obj.itersections(recursive=True): + self.validate(sec) + for prop in sec.properties: + self.validate(prop) + + def register_custom_handler(self, klass, handler): + """ + Adds a validation handler for an odml class. The handler is called in the + validation process for each corresponding object. + The *handler* is assumed to be a generator function yielding + all ValidationErrors it finds. + + Section handlers are only called for sections and not for the document node. + If both are required, the handler needs to be registered twice. + + :param klass: string corresponding to an odml class. Valid strings are + 'odML', 'section' and 'property'. + :param handler: validation function applied to the odml class. + """ + self._handlers.setdefault(klass, set()).add(handler) + def __getitem__(self, obj): """ Return a list of the errors for a certain object. @@ -455,59 +486,89 @@ def property_values_string_check(prop): Validation.register_handler('property', property_values_string_check) -def section_properties_cardinality(obj): +def _cardinality_validation(obj, cardinality, card_target_attr, validation_rank): """ - Checks Section properties against any set property cardinality. + Helper function that validates the cardinality of an odml object attribute. + Valid object-attribute combinations are Section-sections, Section-properties and + Property-values. - :param obj: odml.Section - :return: Yields a ValidationError warning, if a set cardinality is not met. + :param obj: an odml.Section or an odml.Property + :param cardinality: 2-int tuple containing the cardinality value + :param card_target_attr: string containing the name of the attribute the cardinality is + applied against. Supported values are: + 'sections', 'properties' or 'values' + :param validation_rank: Rank of the yielded ValidationError. + + :return: Returns a ValidationError, if a set cardinality is not met or None. """ - if obj.prop_cardinality and isinstance(obj.prop_cardinality, tuple): + err = None + if cardinality and isinstance(cardinality, tuple): - val_min = obj.prop_cardinality[0] - val_max = obj.prop_cardinality[1] + val_min = cardinality[0] + val_max = cardinality[1] - val_len = len(obj.properties) if obj.properties else 0 + card_target = getattr(obj, card_target_attr) + val_len = len(card_target) if card_target else 0 invalid_cause = "" if val_min and val_len < val_min: invalid_cause = "minimum %s" % val_min - elif val_max and (obj.properties and len(obj.properties) > val_max): + elif val_max and val_len > val_max: invalid_cause = "maximum %s" % val_max if invalid_cause: - msg = "Section properties cardinality violated" + obj_name = obj.format().name.capitalize() + msg = "%s %s cardinality violated" % (obj_name, card_target_attr) msg += " (%s values, %s found)" % (invalid_cause, val_len) - yield ValidationError(obj, msg, LABEL_WARNING) + + err = ValidationError(obj, msg, validation_rank) + + return err + + +def section_properties_cardinality(obj): + """ + Checks Section properties against any set property cardinality. + + :param obj: odml.Section + + :return: Yields a ValidationError warning, if a set cardinality is not met. + """ + err = _cardinality_validation(obj, obj.prop_cardinality, 'properties', LABEL_WARNING) + if err: + yield err Validation.register_handler("section", section_properties_cardinality) -def property_values_cardinality(prop): +def section_sections_cardinality(obj): """ - Checks Property values against any set value cardinality. + Checks Section sub-sections against any set sub-section cardinality. + + :param obj: odml.Section - :param prop: odml.Property :return: Yields a ValidationError warning, if a set cardinality is not met. """ - if prop.val_cardinality and isinstance(prop.val_cardinality, tuple): + err = _cardinality_validation(obj, obj.sec_cardinality, 'sections', LABEL_WARNING) + if err: + yield err - val_min = prop.val_cardinality[0] - val_max = prop.val_cardinality[1] - val_len = len(prop.values) if prop.values else 0 +Validation.register_handler("section", section_sections_cardinality) - invalid_cause = "" - if val_min and val_len < val_min: - invalid_cause = "minimum %s" % val_min - elif val_max and (prop.values and len(prop.values) > val_max): - invalid_cause = "maximum %s" % val_max - if invalid_cause: - msg = "Property values cardinality violated" - msg += " (%s values, %s found)" % (invalid_cause, val_len) - yield ValidationError(prop, msg, LABEL_WARNING) +def property_values_cardinality(obj): + """ + Checks Property values against any set value cardinality. + + :param obj: odml.Property + + :return: Yields a ValidationError warning, if a set cardinality is not met. + """ + err = _cardinality_validation(obj, obj.val_cardinality, 'values', LABEL_WARNING) + if err: + yield err Validation.register_handler("property", property_values_cardinality) diff --git a/test/test_section.py b/test/test_section.py index 14391fc5..ba5f0a50 100644 --- a/test/test_section.py +++ b/test/test_section.py @@ -1066,6 +1066,34 @@ def test_properties_cardinality(self): # Use general method to reduce redundancy self._test_cardinality_re_assignment(sec, 'prop_cardinality') + def test_sections_cardinality(self): + """ + Tests the basic assignment rules for Section sections cardinality + on init and re-assignment but does not test sections assignment or + the actual cardinality validation. + """ + doc = Document() + + # -- Test set cardinality on Section init + # Test empty init + sec_card_none = Section(name="sec_cardinality_none", type="test", parent=doc) + self.assertIsNone(sec_card_none.sec_cardinality) + + # Test single int max init + sec_card_max = Section(name="sec_cardinality_max", sec_cardinality=10, parent=doc) + self.assertEqual(sec_card_max.sec_cardinality, (None, 10)) + + # Test tuple init + sec_card_min = Section(name="sec_cardinality_min", sec_cardinality=(2, None), parent=doc) + self.assertEqual(sec_card_min.sec_cardinality, (2, None)) + + # -- Test Section properties cardinality re-assignment + sec = Section(name="sec", sec_cardinality=(None, 10), parent=doc) + self.assertEqual(sec.sec_cardinality, (None, 10)) + + # Use general method to reduce redundancy + self._test_cardinality_re_assignment(sec, 'sec_cardinality') + def _test_set_cardinality_method(self, obj, obj_attribute, set_cardinality_method): """ Tests the basic set convenience method of both Section properties and @@ -1119,6 +1147,13 @@ def test_set_properties_cardinality(self): # Use general method to reduce redundancy self._test_set_cardinality_method(sec, 'prop_cardinality', sec.set_properties_cardinality) + def test_set_sections_cardinality(self): + doc = Document() + sec = Section(name="sec", type="test", parent=doc) + + # Use general method to reduce redundancy + self._test_set_cardinality_method(sec, 'sec_cardinality', sec.set_sections_cardinality) + def test_link(self): pass diff --git a/test/test_section_integration.py b/test/test_section_integration.py index 91d6b6e8..9f79d920 100644 --- a/test/test_section_integration.py +++ b/test/test_section_integration.py @@ -257,6 +257,48 @@ def test_prop_cardinality(self): self._test_cardinality_load("prop_cardinality", yaml_doc, card_dict, sec_empty, sec_max, sec_min, sec_full) + def test_sec_cardinality(self): + """ + Test saving and loading of Section sections cardinality variants to + and from all supported file formats. + """ + doc = odml.Document() + + sec_empty = "card_empty" + sec_max = "card_max" + sec_min = "card_min" + sec_full = "card_full" + card_dict = { + sec_empty: None, + sec_max: (None, 10), + sec_min: (2, None), + sec_full: (1, 5) + } + + _ = odml.Section(name=sec_empty, type="test", parent=doc) + _ = odml.Section(name=sec_max, sec_cardinality=card_dict[sec_max], type="test", parent=doc) + _ = odml.Section(name=sec_min, sec_cardinality=card_dict[sec_min], type="test", parent=doc) + _ = odml.Section(name=sec_full, sec_cardinality=card_dict[sec_full], + type="test", parent=doc) + + # Test saving to and loading from an XML file + odml.save(doc, self.xml_file) + xml_doc = odml.load(self.xml_file) + self._test_cardinality_load("sec_cardinality", xml_doc, card_dict, + sec_empty, sec_max, sec_min, sec_full) + + # Test saving to and loading from a JSON file + odml.save(doc, self.json_file, "JSON") + json_doc = odml.load(self.json_file, "JSON") + self._test_cardinality_load("sec_cardinality", json_doc, card_dict, + sec_empty, sec_max, sec_min, sec_full) + + # Test saving to and loading from a YAML file + odml.save(doc, self.yaml_file, "YAML") + yaml_doc = odml.load(self.yaml_file, "YAML") + self._test_cardinality_load("sec_cardinality", yaml_doc, card_dict, + sec_empty, sec_max, sec_min, sec_full) + def test_link(self): pass diff --git a/test/test_validation.py b/test/test_validation.py index 25727cf2..7b270d48 100644 --- a/test/test_validation.py +++ b/test/test_validation.py @@ -140,6 +140,63 @@ def test_section_properties_cardinality(self): self.assertTrue(found) + def test_section_sections_cardinality(self): + msg_base = "Section sections cardinality violated" + + doc = odml.Document() + # Test no caught warning on empty cardinality + sec = odml.Section(name="sec_empty_cardinality", type="test", parent=doc) + # Check that the current section did not throw any sections cardinality warnings + for err in validate(doc).errors: + if err.obj.id == sec.id: + self.assertNotIn(msg_base, err.msg) + + # Test no warning on valid cardinality + sec = odml.Section(name="sec_valid_cardinality", sec_cardinality=(1, 2), parent=doc) + _ = odml.Section(name="sub_sec_valid_cardinality", type="test", parent=sec) + for err in validate(doc).errors: + if err.obj.id == sec.id: + self.assertNotIn(msg_base, err.msg) + + # Test maximum value cardinality validation + test_range = 3 + test_card = 2 + sec = odml.Section(name="sec_invalid_max_val", sec_cardinality=test_card, parent=doc) + for i in range(test_range): + sec_name = "sub_sec_invalid_max_val_%s" % i + _ = odml.Section(name=sec_name, type="test", parent=sec) + + test_msg = "%s (maximum %s values, %s found)" % (msg_base, test_card, len(sec.sections)) + + # Once ValidationErrors provide validation ids, the following can be simplified. + found = False + for err in validate(doc).errors: + if err.obj.id == sec.id and msg_base in err.msg: + self.assertFalse(err.is_error) + self.assertIn(test_msg, err.msg) + found = True + + self.assertTrue(found) + + # Test minimum value cardinality validation + test_card = (4, None) + + sec = odml.Section(name="sec_invalid_min_val", sec_cardinality=test_card, parent=sec) + _ = odml.Section(name="sub_sec_invalid_min_val", type="test", parent=sec) + + test_msg = "%s (minimum %s values, %s found)" % (msg_base, test_card[0], + len(sec.sections)) + + # Once ValidationErrors provide validation ids, the following can be simplified. + found = False + for err in validate(doc).errors: + if err.obj.id == sec.id and msg_base in err.msg: + self.assertFalse(err.is_error) + self.assertIn(test_msg, err.msg) + found = True + + self.assertTrue(found) + def test_section_in_terminology(self): doc = samplefile.parse("""s1[T1]""") res = validate(doc)