diff --git a/odml/format.py b/odml/format.py index 47825a2f..08113f88 100644 --- a/odml/format.py +++ b/odml/format.py @@ -117,7 +117,8 @@ class Property(Format): 'uncertainty': 0, 'reference': 0, 'type': 0, - 'value_origin': 0 + 'value_origin': 0, + 'val_cardinality': 0 } _map = { 'dependencyvalue': 'dependency_value', diff --git a/odml/info.json b/odml/info.json index 747c5fa9..8f1cd9c4 100644 --- a/odml/info.json +++ b/odml/info.json @@ -1,5 +1,5 @@ { - "VERSION": "1.4.5", + "VERSION": "1.5.0", "FORMAT_VERSION": "1.1", "AUTHOR": "Hagen Fritsch, Jan Grewe, Christian Kellner, Achilleas Koutsou, Michael Sonntag, Lyuba Zehl", "COPYRIGHT": "(c) 2011-2020, German Neuroinformatics Node", diff --git a/odml/property.py b/odml/property.py index 6c5caf0e..08a69508 100644 --- a/odml/property.py +++ b/odml/property.py @@ -82,6 +82,10 @@ class BaseProperty(base.BaseObject): :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 val_cardinality: Value cardinality defines how many values are allowed for this Property. + By default unlimited values can be set. + A required number of values can be set by assigning a tuple of the + format "(min, max)". :param value: Legacy code to the 'values' attribute. If 'values' is provided, any data provided via 'value' will be ignored. """ @@ -91,7 +95,7 @@ class BaseProperty(base.BaseObject): def __init__(self, name=None, values=None, parent=None, unit=None, uncertainty=None, reference=None, definition=None, dependency=None, dependency_value=None, dtype=None, - value_origin=None, oid=None, value=None): + value_origin=None, oid=None, val_cardinality=None, value=None): try: if oid is not None: @@ -115,6 +119,7 @@ def __init__(self, name=None, values=None, parent=None, unit=None, self._definition = definition self._dependency = dependency self._dependency_value = dependency_value + self._val_cardinality = None self._dtype = None if dtypes.valid_type(dtype): @@ -129,6 +134,10 @@ def __init__(self, name=None, values=None, parent=None, unit=None, self.parent = parent + # Cardinality should always be set after values have been added + # since it is always tested against values when it is set. + self.val_cardinality = val_cardinality + for err in validation.Validation(self).errors: if err.is_error: msg = "\n\t- %s %s: %s" % (err.obj, err.rank, err.msg) @@ -401,6 +410,11 @@ def values(self, new_value): raise ValueError(msg) self._values = [dtypes.get(v, self.dtype) for v in new_value] + # Validate and inform user if the current values cardinality is violated + valid = validation.Validation(self) + for err in valid.errors: + print("%s: %s" % (err.rank.capitalize(), err.msg)) + @property def value_origin(self): """ @@ -507,6 +521,88 @@ def dependency_value(self, new_value): new_value = None self._dependency_value = new_value + @property + def val_cardinality(self): + """ + The value cardinality of a Property. It defines how many values + are minimally required and how many values should be maximally + stored. Use 'values_set_cardinality' to set. + """ + return self._val_cardinality + + @val_cardinality.setter + def val_cardinality(self, new_value): + """ + Sets the values cardinality of a Property. + + 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)'. + """ + invalid_input = False + exc_msg = "Can only assign positive single int or int-tuples of the format '(min, max)'" + + # Empty values reset the cardinality to None. + if not new_value or new_value == (None, None): + self._val_cardinality = None + + # Providing a single integer sets the maximum value in a tuple. + elif isinstance(new_value, int) and new_value > 0: + self._val_cardinality = (None, new_value) + + # Only integer 2-tuples of the format '(min, max)' are supported to set the cardinality + elif isinstance(new_value, tuple) and len(new_value) == 2: + v_min = new_value[0] + v_max = new_value[1] + + min_int = isinstance(v_min, int) and v_min >= 0 + max_int = isinstance(v_max, int) and v_max >= 0 + + if max_int and min_int and v_max > v_min: + self._val_cardinality = (v_min, v_max) + + elif max_int and not v_min: + self._val_cardinality = (None, v_max) + + elif min_int and not v_max: + self._val_cardinality = (v_min, None) + + else: + invalid_input = True + + # Use helpful exception message in the following case: + if max_int and min_int and v_max < v_min: + exc_msg = "Minimum larger than maximum (min=%s, max=%s)" % (v_min, v_max) + else: + invalid_input = True + + if not invalid_input: + # Validate and inform user if the current values cardinality is violated + valid = validation.Validation(self) + for err in valid.errors: + print("%s: %s" % (err.rank.capitalize(), err.msg)) + else: + raise ValueError(exc_msg) + + def set_values_cardinality(self, min_val=None, max_val=None): + """ + Sets the values cardinality of a Property. + + :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.val_cardinality = (min_val, max_val) + def remove(self, value): """ Remove a value from this property. Only the first encountered diff --git a/odml/tools/dict_parser.py b/odml/tools/dict_parser.py index 4b22783c..947667cd 100644 --- a/odml/tools/dict_parser.py +++ b/odml/tools/dict_parser.py @@ -8,6 +8,47 @@ from .parser_utils import InvalidVersionException, ParserException, odml_tuple_export +def parse_cardinality(vals): + """ + Parses an odml specific cardinality from an input value. + + If the input content is valid, returns an appropriate tuple. + Returns None if the input is empty or the content cannot be + properly parsed. + + :param vals: list or tuple + :return: None or 2-tuple + """ + if not vals: + return None + + if isinstance(vals, (list, tuple)) and len(vals) == 2: + min_val = vals[0] + max_val = vals[1] + + if min_val is None or str(min_val).strip() == "None": + min_val = None + + if max_val is None or str(max_val).strip() == "None": + max_val = None + + min_int = isinstance(min_val, int) and min_val >= 0 + max_int = isinstance(max_val, int) and max_val >= 0 + + if min_int and max_int and max_val > min_val: + return min_val, max_val + + if min_int and not max_val: + return min_val, None + + if max_int and not min_val: + return None, max_val + + # We were not able to properly parse the current cardinality, so add + # an appropriate Error/Warning once the reader 'ignore_errors' option has been implemented. + return None + + class DictWriter: """ A writer to parse an odml.Document to a Python dictionary object equivalent. @@ -255,8 +296,12 @@ def parse_properties(self, props_list): for i in _property: attr = self.is_valid_attribute(i, odmlfmt.Property) if attr: + content = _property[attr] + if attr.endswith("_cardinality"): + content = parse_cardinality(content) + # Make sure to always use the correct odml format attribute name - prop_attrs[odmlfmt.Property.map(attr)] = _property[attr] + prop_attrs[odmlfmt.Property.map(attr)] = content prop = odmlfmt.Property.create(**prop_attrs) odml_props.append(prop) diff --git a/odml/tools/xmlparser.py b/odml/tools/xmlparser.py index 7e0bcad0..e23359af 100644 --- a/odml/tools/xmlparser.py +++ b/odml/tools/xmlparser.py @@ -40,6 +40,43 @@ """ +def parse_cardinality(val): + """ + Parses an odml specific cardinality from a string. + + If the string content is valid, returns an appropriate tuple. + Returns None if the string is empty or the content cannot be + properly parsed. + + :param val: string + :return: None or 2-tuple + """ + if not val: + return None + + # Remove parenthesis and split on comma + parsed_vals = val.strip()[1:-1].split(",") + if len(parsed_vals) == 2: + min_val = parsed_vals[0].strip() + max_val = parsed_vals[1].strip() + + min_int = min_val.isdigit() and int(min_val) >= 0 + max_int = max_val.isdigit() and int(max_val) >= 0 + + if min_int and max_int and int(max_val) > int(min_val): + return int(min_val), int(max_val) + + if min_int and max_val == "None": + return int(min_val), None + + if max_int and min_val == "None": + return None, int(max_val) + + # Todo we were not able to properly parse the current cardinality + # add an appropriate Error/Warning + return None + + def to_csv(val): """ Modifies odML values for serialization to strings and files. @@ -410,6 +447,9 @@ def parse_tag(self, root, fmt, insert_children=True): if tag == "values" and curr_text: content = from_csv(node.text) arguments[tag] = content + # Special handling of cardinality + elif tag.endswith("_cardinality") and curr_text: + arguments[tag] = parse_cardinality(node.text) else: arguments[tag] = curr_text else: diff --git a/odml/validation.py b/odml/validation.py index eb2fd05d..7f8b9386 100644 --- a/odml/validation.py +++ b/odml/validation.py @@ -454,3 +454,32 @@ def property_values_string_check(prop): Validation.register_handler('property', property_values_string_check) + + +def property_values_cardinality(prop): + """ + Checks Property values against any set value cardinality. + + :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): + + 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) + + +Validation.register_handler("property", property_values_cardinality) diff --git a/test/test_dumper.py b/test/test_dumper.py index 40476b6a..24db6b50 100644 --- a/test/test_dumper.py +++ b/test/test_dumper.py @@ -1,21 +1,18 @@ import unittest import sys -import odml - try: from StringIO import StringIO except ImportError: from io import StringIO +import odml + +from odml.tools.dumper import dump_doc + class TestTypes(unittest.TestCase): def setUp(self): - # Capture the output printed by the functions to STDOUT, and use it for - # testing purposes. - self.captured_stdout = StringIO() - sys.stdout = self.captured_stdout - s_type = "type" self.doc = odml.Document(author='Rave', version='1.0') @@ -38,10 +35,19 @@ def setUp(self): self.doc.append(s1) def test_dump_doc(self): + # Capture the output printed by the functions to STDOUT, and use it for + # testing purposes. It needs to be reset after the capture. + captured_stdout = StringIO() + sys.stdout = captured_stdout + # This test dumps the whole document and checks it word by word. - # If possible, maybe some better way of testing this ? - odml.tools.dumper.dump_doc(self.doc) - output = [x.strip() for x in self.captured_stdout.getvalue().split('\n') if x] + # If possible, maybe some better way of testing this? + dump_doc(self.doc) + output = [x.strip() for x in captured_stdout.getvalue().split('\n') if x] + + # Reset stdout + sys.stdout = sys.__stdout__ + expected_output = [] expected_output.append("*Cell (type='type')") expected_output.append(":Type (values=Rechargeable, dtype='string')") @@ -50,10 +56,7 @@ def test_dump_doc(self): expected_output.append("*Electrode (type='type')") expected_output.append(":Material (values=Nickel, dtype='string')") expected_output.append(":Models (values=[AA,AAA], dtype='string')") + self.assertEqual(len(output), len(expected_output)) for i in range(len(output)): self.assertEqual(output[i], expected_output[i]) - - # Discard the document output from stdout stream - self.captured_stdout.seek(0) - self.captured_stdout.truncate(0) diff --git a/test/test_property.py b/test/test_property.py index ac9dd96d..371c8bc8 100644 --- a/test/test_property.py +++ b/test/test_property.py @@ -794,6 +794,111 @@ def test_export_leaf(self): self.assertEqual(len(ex2['first'].sections), 1) self.assertEqual(len(ex2['first']['second'].properties), 1) + def test_values_cardinality(self): + doc = Document() + sec = Section(name="sec", type="type", parent=doc) + + # -- Test set cardinality on Property init + # Test empty init + prop_card_none = Property(name="prop_cardinality_empty", parent=sec) + self.assertIsNone(prop_card_none.val_cardinality) + + # Test single int max init + prop_card_max = Property(name="prop_cardinality_max", val_cardinality=10, parent=sec) + self.assertEqual(prop_card_max.val_cardinality, (None, 10)) + + # Test tuple init + prop_card_min = Property(name="prop_cardinality_min", val_cardinality=(2, None), parent=sec) + self.assertEqual(prop_card_min.val_cardinality, (2, None)) + + # -- Test Property cardinality re-assignment + prop = Property(name="prop", val_cardinality=(None, 10), parent=sec) + self.assertEqual(prop.val_cardinality, (None, 10)) + + # Test Property cardinality reset + for non_val in [None, "", [], ()]: + prop.val_cardinality = non_val + self.assertIsNone(prop.val_cardinality) + prop.val_cardinality = 1 + + # Test Property cardinality single int max assignment + prop.val_cardinality = 10 + self.assertEqual(prop.val_cardinality, (None, 10)) + + # Test Property cardinality tuple max assignment + prop.val_cardinality = (None, 5) + self.assertEqual(prop.val_cardinality, (None, 5)) + + # Test Property cardinality tuple min assignment + prop.val_cardinality = (5, None) + self.assertEqual(prop.val_cardinality, (5, None)) + + # Test Property cardinality min/max assignment + prop.val_cardinality = (1, 5) + self.assertEqual(prop.val_cardinality, (1, 5)) + + # -- Test Property cardinality assignment failures + with self.assertRaises(ValueError): + prop.val_cardinality = "a" + + with self.assertRaises(ValueError): + prop.val_cardinality = -1 + + with self.assertRaises(ValueError): + prop.val_cardinality = (1, "b") + + with self.assertRaises(ValueError): + prop.val_cardinality = (1, 2, 3) + + with self.assertRaises(ValueError): + prop.val_cardinality = (-1, 1) + + with self.assertRaises(ValueError): + prop.val_cardinality = (1, -5) + + with self.assertRaises(ValueError): + prop.val_cardinality = (5, 1) + + def test_set_values_cardinality(self): + doc = Document() + sec = Section(name="sec", type="sec_type", parent=doc) + + prop = Property(name="prop", val_cardinality=1, parent=sec) + + # Test Property values cardinality min assignment + prop.set_values_cardinality(1) + self.assertEqual(prop.val_cardinality, (1, None)) + + # Test Property values cardinality keyword min assignment + prop.set_values_cardinality(min_val=2) + self.assertEqual(prop.val_cardinality, (2, None)) + + # Test Property values cardinality max assignment + prop.set_values_cardinality(None, 1) + self.assertEqual(prop.val_cardinality, (None, 1)) + + # Test Property values cardinality keyword max assignment + prop.set_values_cardinality(max_val=2) + self.assertEqual(prop.val_cardinality, (None, 2)) + + # Test Property values cardinality min max assignment + prop.set_values_cardinality(1, 2) + self.assertEqual(prop.val_cardinality, (1, 2)) + + # Test Property values cardinality keyword min max assignment + prop.set_values_cardinality(min_val=2, max_val=5) + self.assertEqual(prop.val_cardinality, (2, 5)) + + # Test Property values cardinality empty reset + prop.set_values_cardinality() + self.assertIsNone(prop.val_cardinality) + + # Test Property values cardinality keyword empty reset + prop.set_values_cardinality(1) + self.assertIsNotNone(prop.val_cardinality) + prop.set_values_cardinality(min_val=None, max_val=None) + self.assertIsNone(prop.val_cardinality) + if __name__ == "__main__": print("TestProperty") diff --git a/test/test_property_integration.py b/test/test_property_integration.py index b19766e5..985b454a 100644 --- a/test/test_property_integration.py +++ b/test/test_property_integration.py @@ -131,3 +131,71 @@ def test_simple_attributes(self): self.assertEqual(yprop.definition, p_def) self.assertEqual(yprop.dependency, p_dep) self.assertEqual(yprop.dependency_value, p_dep_val) + + def test_cardinality(self): + """ + Test saving and loading of property values cardinality variants to + and from all supported file formats. + """ + doc = odml.Document() + sec = odml.Section(name="sec", type="sometype", parent=doc) + + prop_empty = "prop_cardinality_empty" + prop_max = "prop_cardinality_max" + prop_max_card = (None, 10) + prop_min = "prop_cardinality_min" + prop_min_card = (2, None) + prop_full = "prop_full" + prop_full_card = (1, 5) + + _ = odml.Property(name=prop_empty, parent=sec) + _ = odml.Property(name=prop_max, val_cardinality=prop_max_card, parent=sec) + _ = odml.Property(name=prop_min, val_cardinality=prop_min_card, parent=sec) + _ = odml.Property(name=prop_full, val_cardinality=prop_full_card, parent=sec) + + # Test saving to and loading from an XML file + odml.save(doc, self.xml_file) + xml_doc = odml.load(self.xml_file) + xml_prop = xml_doc.sections["sec"].properties[prop_empty] + self.assertIsNone(xml_prop.val_cardinality) + + xml_prop = xml_doc.sections["sec"].properties[prop_max] + self.assertEqual(xml_prop.val_cardinality, prop_max_card) + + xml_prop = xml_doc.sections["sec"].properties[prop_min] + self.assertEqual(xml_prop.val_cardinality, prop_min_card) + + xml_prop = xml_doc.sections["sec"].properties[prop_full] + self.assertEqual(xml_prop.val_cardinality, prop_full_card) + + # Test saving to and loading from a JSON file + odml.save(doc, self.json_file, "JSON") + json_doc = odml.load(self.json_file, "JSON") + + json_prop = json_doc.sections["sec"].properties[prop_empty] + self.assertIsNone(json_prop.val_cardinality) + + json_prop = json_doc.sections["sec"].properties[prop_max] + self.assertEqual(json_prop.val_cardinality, prop_max_card) + + json_prop = json_doc.sections["sec"].properties[prop_min] + self.assertEqual(json_prop.val_cardinality, prop_min_card) + + json_prop = json_doc.sections["sec"].properties[prop_full] + self.assertEqual(json_prop.val_cardinality, prop_full_card) + + # Test saving to and loading from a YAML file + odml.save(doc, self.yaml_file, "YAML") + yaml_doc = odml.load(self.yaml_file, "YAML") + + yaml_prop = yaml_doc.sections["sec"].properties[prop_empty] + self.assertIsNone(yaml_prop.val_cardinality) + + yaml_prop = yaml_doc.sections["sec"].properties[prop_max] + self.assertEqual(yaml_prop.val_cardinality, prop_max_card) + + yaml_prop = yaml_doc.sections["sec"].properties[prop_min] + self.assertEqual(yaml_prop.val_cardinality, prop_min_card) + + yaml_prop = yaml_doc.sections["sec"].properties[prop_full] + self.assertEqual(yaml_prop.val_cardinality, prop_full_card) diff --git a/test/test_validation.py b/test/test_validation.py index 1dc8a330..e31cccff 100644 --- a/test/test_validation.py +++ b/test/test_validation.py @@ -41,6 +41,49 @@ def assertError(self, res, err, filter_rep=True, filter_map=False): return self.assertEqual(errs, err) + def test_property_values_cardinality(self): + doc = odml.Document() + sec = odml.Section(name="sec", type="sec_type", parent=doc) + + # Test no caught warning on empty cardinality + prop = odml.Property(name="prop_empty_cardinality", values=[1, 2, 3, 4], parent=sec) + # Check that the current property is not in the list of validation warnings or errors + for err in validate(doc).errors: + self.assertNotEqual(err.obj.id, prop.id) + + # Test no warning on valid cardinality + prop = odml.Property(name="prop_valid_cardinality", values=[1, 2, 3, 4], + val_cardinality=(2, 10), parent=sec) + for err in validate(doc).errors: + self.assertNotEqual(err.obj.id, prop.id) + + # Test minimum value cardinality validation + test_val = [1, 2, 3] + test_card = 2 + + prop = odml.Property(name="prop_invalid_max_val", values=test_val, + val_cardinality=test_card, parent=sec) + + test_msg_base = "Property values cardinality violated" + test_msg = "%s (maximum %s values, %s found)" % (test_msg_base, test_card, len(prop.values)) + for err in validate(doc).errors: + if err.obj.id == prop.id: + self.assertFalse(err.is_error) + self.assertIn(test_msg, err.msg) + + # Test maximum value cardinality validation + test_val = "I am a nice text to test" + test_card = (4, None) + + prop = odml.Property(name="prop_invalid_min_val", values=test_val, + val_cardinality=test_card, parent=sec) + + test_msg = "%s (minimum %s values, %s found)" % (test_msg_base, test_card[0], len(prop.values)) + for err in validate(doc).errors: + if err.obj.id == prop.id: + self.assertFalse(err.is_error) + self.assertIn(test_msg, err.msg) + def test_section_type(self): doc = samplefile.parse("""s1[undefined]""") res = validate(doc) diff --git a/test/test_validation_integration.py b/test/test_validation_integration.py new file mode 100644 index 00000000..1e978be3 --- /dev/null +++ b/test/test_validation_integration.py @@ -0,0 +1,117 @@ +""" +This file tests built-in odml validations. +""" + +import sys +import unittest + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +import odml + + +class TestValidationIntegration(unittest.TestCase): + + def setUp(self): + # Redirect stdout to test messages + self.stdout_orig = sys.stdout + self.capture = StringIO() + sys.stdout = self.capture + + self.msg_base = "Property values cardinality violated" + + def tearDown(self): + # Reset stdout; resetting using 'sys.__stdout__' fails on windows + sys.stdout = self.stdout_orig + self.capture.close() + + def _get_captured_output(self): + out = [txt.strip() for txt in self.capture.getvalue().split('\n') if txt] + + # Buffer reset + self.capture.seek(0) + self.capture.truncate() + + return out + + def test_property_values_cardinality(self): + # -- Test assignment validation warnings + doc = odml.Document() + sec = odml.Section(name="sec", type="sec_type", parent=doc) + + # -- Test cardinality validation warnings on Property init + # Test warning when setting invalid minimum + _ = odml.Property(name="prop_card_min", values=[1], val_cardinality=(2, None), parent=sec) + output = self._get_captured_output() + test_msg = "%s (minimum %s values, %s found)" % (self.msg_base, 2, 1) + self.assertEqual(len(output), 1) + self.assertIn(test_msg, output[0]) + + # Test warning when setting invalid maximum + _ = odml.Property(name="prop_card_max", values=[1, 2, 3], val_cardinality=2, parent=sec) + output = self._get_captured_output() + test_msg = "%s (maximum %s values, %s found)" % (self.msg_base, 2, 3) + self.assertEqual(len(output), 1) + self.assertIn(test_msg, output[0]) + + # Test no warning on valid init + prop_card = odml.Property(name="prop_card", values=[1, 2], + val_cardinality=(1, 5), parent=sec) + output = self._get_captured_output() + self.assertEqual(output, []) + + # -- Test cardinality validation warnings on cardinality updates + # Test warning when setting minimally required values cardinality + prop_card.val_cardinality = (3, None) + output = self._get_captured_output() + test_msg = "%s (minimum %s values, %s found)" % (self.msg_base, 3, 2) + self.assertEqual(len(output), 1) + self.assertIn(test_msg, output[0]) + + # Test warning when setting maximally required values cardinality + prop_card.values = [1, 2, 3] + prop_card.val_cardinality = 2 + output = self._get_captured_output() + test_msg = "%s (maximum %s values, %s found)" % (self.msg_base, 2, 3) + self.assertEqual(len(output), 1) + self.assertIn(test_msg, output[0]) + + # Test no warning on valid cardinality + prop_card.val_cardinality = (1, 10) + output = self._get_captured_output() + self.assertEqual(output, []) + + # Test no warning when setting cardinality to None + prop_card.val_cardinality = None + output = self._get_captured_output() + self.assertEqual(output, []) + + # -- Test cardinality validation warnings on values updates + # Test warning when violating minimally required values cardinality + prop_card.val_cardinality = (3, None) + prop_card.values = [1, 2] + output = self._get_captured_output() + test_msg = "%s (minimum %s values, %s found)" % (self.msg_base, 3, 2) + self.assertEqual(len(output), 1) + self.assertIn(test_msg, output[0]) + + # Test warning when violating maximally required values cardinality + prop_card.val_cardinality = (None, 2) + prop_card.values = [1, 2, 3] + output = self._get_captured_output() + test_msg = "%s (maximum %s values, %s found)" % (self.msg_base, 2, 3) + self.assertEqual(len(output), 1) + self.assertIn(test_msg, output[0]) + + # Test no warning when setting correct number of values + prop_card.values = [1, 2] + output = self._get_captured_output() + self.assertEqual(output, []) + + # Test no warning when setting values to None + prop_card.values = None + output = self._get_captured_output() + self.assertEqual(output, [])