From 8e50deba535902523148b01f225302ed120fccb2 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 10:12:33 +0200 Subject: [PATCH 1/9] [section] Add sec cardinality on init --- odml/section.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/odml/section.py b/odml/section.py index 19ece352..bfc44bfe 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 From 4f3115804f248ad84026b695c26efb26bc2924ff Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 10:13:43 +0200 Subject: [PATCH 2/9] [section] Add sec card accessort methods --- odml/section.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/odml/section.py b/odml/section.py index bfc44bfe..d7af541f 100644 --- a/odml/section.py +++ b/odml/section.py @@ -99,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: @@ -365,6 +366,34 @@ 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) + @property def prop_cardinality(self): """ From 7569aed5db9a09844e40a2520e48395645cf3963 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 10:14:19 +0200 Subject: [PATCH 3/9] [section] Add sec card validation Also adds a set Section section cardinality convenience method. --- odml/section.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/odml/section.py b/odml/section.py index d7af541f..8e8f018c 100644 --- a/odml/section.py +++ b/odml/section.py @@ -394,6 +394,31 @@ def sec_cardinality(self, new_value): """ 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): """ From cd7eef35448dd3b96d65c3707d15664afe4f2541 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 10:15:50 +0200 Subject: [PATCH 4/9] [validation] Add sec sec cardinality validation --- odml/validation.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/odml/validation.py b/odml/validation.py index 7d4d71de..5698a3a6 100644 --- a/odml/validation.py +++ b/odml/validation.py @@ -484,6 +484,35 @@ def section_properties_cardinality(obj): Validation.register_handler("section", section_properties_cardinality) +def section_sections_cardinality(obj): + """ + Checks Section sub-sections against any set sub-section cardinality. + + :param obj: odml.Section + :return: Yields a ValidationError warning, if a set cardinality is not met. + """ + if obj.sec_cardinality and isinstance(obj.sec_cardinality, tuple): + + val_min = obj.sec_cardinality[0] + val_max = obj.sec_cardinality[1] + + val_len = len(obj.sections) if obj.sections else 0 + + invalid_cause = "" + if val_min and val_len < val_min: + invalid_cause = "minimum %s" % val_min + elif val_max and (obj.sections and len(obj.sections) > val_max): + invalid_cause = "maximum %s" % val_max + + if invalid_cause: + msg = "Section sub-section cardinality violated" + msg += " (%s values, %s found)" % (invalid_cause, val_len) + yield ValidationError(obj, msg, LABEL_WARNING) + + +Validation.register_handler("section", section_sections_cardinality) + + def property_values_cardinality(prop): """ Checks Property values against any set value cardinality. From 416bc27a200bdb78a337e99300e9ce22e755b9c1 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 10:23:38 +0200 Subject: [PATCH 5/9] [format] Add Section.sec_cardinality attribute --- odml/format.py | 1 + 1 file changed, 1 insertion(+) 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 = { From f6fefe07298113f2c77cbffe2ddf1a13500eaae0 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 11:18:08 +0200 Subject: [PATCH 6/9] [validation] Add general cardinality function --- odml/validation.py | 146 +++++++++++++++++++++++++++------------------ 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/odml/validation.py b/odml/validation.py index 5698a3a6..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,88 +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) -Validation.register_handler("section", section_properties_cardinality) + return err -def section_sections_cardinality(obj): +def section_properties_cardinality(obj): """ - Checks Section sub-sections against any set sub-section cardinality. + Checks Section properties against any set property cardinality. :param obj: odml.Section + :return: Yields a ValidationError warning, if a set cardinality is not met. """ - if obj.sec_cardinality and isinstance(obj.sec_cardinality, tuple): + err = _cardinality_validation(obj, obj.prop_cardinality, 'properties', LABEL_WARNING) + if err: + yield err - val_min = obj.sec_cardinality[0] - val_max = obj.sec_cardinality[1] - val_len = len(obj.sections) if obj.sections else 0 +Validation.register_handler("section", section_properties_cardinality) - invalid_cause = "" - if val_min and val_len < val_min: - invalid_cause = "minimum %s" % val_min - elif val_max and (obj.sections and len(obj.sections) > val_max): - invalid_cause = "maximum %s" % val_max - if invalid_cause: - msg = "Section sub-section cardinality violated" - msg += " (%s values, %s found)" % (invalid_cause, val_len) - yield ValidationError(obj, msg, LABEL_WARNING) +def section_sections_cardinality(obj): + """ + Checks Section sub-sections against any set sub-section cardinality. + + :param obj: odml.Section + + :return: Yields a ValidationError warning, if a set cardinality is not met. + """ + err = _cardinality_validation(obj, obj.sec_cardinality, 'sections', LABEL_WARNING) + if err: + yield err Validation.register_handler("section", section_sections_cardinality) -def property_values_cardinality(prop): +def property_values_cardinality(obj): """ Checks Property values against any set value cardinality. - :param prop: odml.Property + :param obj: odml.Property + :return: Yields a ValidationError warning, if a set cardinality is not met. """ - if prop.val_cardinality and isinstance(prop.val_cardinality, tuple): - - val_min = prop.val_cardinality[0] - val_max = prop.val_cardinality[1] - - val_len = len(prop.values) if prop.values else 0 - - 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) + err = _cardinality_validation(obj, obj.val_cardinality, 'values', LABEL_WARNING) + if err: + yield err Validation.register_handler("property", property_values_cardinality) From 4253932302c3819da1c191b2f8b2253eac7425b9 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 11:37:45 +0200 Subject: [PATCH 7/9] [test/section] Add sec cardinality tests Adds test for both sec_cardinality setter and the set_sections_cardinality convenience method. --- test/test_section.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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 From 5b431ff65bf8fd817af1b533da61323e9432d48d Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 11:42:10 +0200 Subject: [PATCH 8/9] [test/section_integration] Add sec card test Add tests for saving and loading of the added Section.sec_cardinality attribute. --- test/test_section_integration.py | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) 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 From bbecc62cb82756457cf4fe48368e90202e58f597 Mon Sep 17 00:00:00 2001 From: "M. Sonntag" Date: Thu, 16 Apr 2020 11:49:22 +0200 Subject: [PATCH 9/9] [test/validation] Add section sec card test Adds tests for the Section sub-section cardinality validation. Once the ValidationError obj provides an id attribute, the tests should be refactored. --- test/test_validation.py | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) 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)