Skip to content

Commit 4fcfcc9

Browse files
authored
Merge pull request #374 from mpsonntag/card
Property values cardinality implementation
2 parents d9d5223 + 79e5acf commit 4fcfcc9

11 files changed

+565
-18
lines changed

odml/format.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ class Property(Format):
117117
'uncertainty': 0,
118118
'reference': 0,
119119
'type': 0,
120-
'value_origin': 0
120+
'value_origin': 0,
121+
'val_cardinality': 0
121122
}
122123
_map = {
123124
'dependencyvalue': 'dependency_value',

odml/info.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"VERSION": "1.4.5",
2+
"VERSION": "1.5.0",
33
"FORMAT_VERSION": "1.1",
44
"AUTHOR": "Hagen Fritsch, Jan Grewe, Christian Kellner, Achilleas Koutsou, Michael Sonntag, Lyuba Zehl",
55
"COPYRIGHT": "(c) 2011-2020, German Neuroinformatics Node",

odml/property.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ class BaseProperty(base.BaseObject):
8282
:param oid: object id, UUID string as specified in RFC 4122. If no id is provided,
8383
an id will be generated and assigned. An id has to be unique
8484
within an odML Document.
85+
:param val_cardinality: Value cardinality defines how many values are allowed for this Property.
86+
By default unlimited values can be set.
87+
A required number of values can be set by assigning a tuple of the
88+
format "(min, max)".
8589
:param value: Legacy code to the 'values' attribute. If 'values' is provided,
8690
any data provided via 'value' will be ignored.
8791
"""
@@ -91,7 +95,7 @@ class BaseProperty(base.BaseObject):
9195
def __init__(self, name=None, values=None, parent=None, unit=None,
9296
uncertainty=None, reference=None, definition=None,
9397
dependency=None, dependency_value=None, dtype=None,
94-
value_origin=None, oid=None, value=None):
98+
value_origin=None, oid=None, val_cardinality=None, value=None):
9599

96100
try:
97101
if oid is not None:
@@ -115,6 +119,7 @@ def __init__(self, name=None, values=None, parent=None, unit=None,
115119
self._definition = definition
116120
self._dependency = dependency
117121
self._dependency_value = dependency_value
122+
self._val_cardinality = None
118123

119124
self._dtype = None
120125
if dtypes.valid_type(dtype):
@@ -129,6 +134,10 @@ def __init__(self, name=None, values=None, parent=None, unit=None,
129134

130135
self.parent = parent
131136

137+
# Cardinality should always be set after values have been added
138+
# since it is always tested against values when it is set.
139+
self.val_cardinality = val_cardinality
140+
132141
for err in validation.Validation(self).errors:
133142
if err.is_error:
134143
msg = "\n\t- %s %s: %s" % (err.obj, err.rank, err.msg)
@@ -401,6 +410,11 @@ def values(self, new_value):
401410
raise ValueError(msg)
402411
self._values = [dtypes.get(v, self.dtype) for v in new_value]
403412

413+
# Validate and inform user if the current values cardinality is violated
414+
valid = validation.Validation(self)
415+
for err in valid.errors:
416+
print("%s: %s" % (err.rank.capitalize(), err.msg))
417+
404418
@property
405419
def value_origin(self):
406420
"""
@@ -507,6 +521,88 @@ def dependency_value(self, new_value):
507521
new_value = None
508522
self._dependency_value = new_value
509523

524+
@property
525+
def val_cardinality(self):
526+
"""
527+
The value cardinality of a Property. It defines how many values
528+
are minimally required and how many values should be maximally
529+
stored. Use 'values_set_cardinality' to set.
530+
"""
531+
return self._val_cardinality
532+
533+
@val_cardinality.setter
534+
def val_cardinality(self, new_value):
535+
"""
536+
Sets the values cardinality of a Property.
537+
538+
The following cardinality cases are supported:
539+
(n, n) - default, no restriction
540+
(d, n) - minimally d entries, no maximum
541+
(n, d) - maximally d entries, no minimum
542+
(d, d) - minimally d entries, maximally d entries
543+
544+
Only positive integers are supported. 'None' is used to denote
545+
no restrictions on a maximum or minimum.
546+
547+
:param new_value: Can be either 'None', a positive integer, which will set
548+
the maximum or an integer 2-tuple of the format '(min, max)'.
549+
"""
550+
invalid_input = False
551+
exc_msg = "Can only assign positive single int or int-tuples of the format '(min, max)'"
552+
553+
# Empty values reset the cardinality to None.
554+
if not new_value or new_value == (None, None):
555+
self._val_cardinality = None
556+
557+
# Providing a single integer sets the maximum value in a tuple.
558+
elif isinstance(new_value, int) and new_value > 0:
559+
self._val_cardinality = (None, new_value)
560+
561+
# Only integer 2-tuples of the format '(min, max)' are supported to set the cardinality
562+
elif isinstance(new_value, tuple) and len(new_value) == 2:
563+
v_min = new_value[0]
564+
v_max = new_value[1]
565+
566+
min_int = isinstance(v_min, int) and v_min >= 0
567+
max_int = isinstance(v_max, int) and v_max >= 0
568+
569+
if max_int and min_int and v_max > v_min:
570+
self._val_cardinality = (v_min, v_max)
571+
572+
elif max_int and not v_min:
573+
self._val_cardinality = (None, v_max)
574+
575+
elif min_int and not v_max:
576+
self._val_cardinality = (v_min, None)
577+
578+
else:
579+
invalid_input = True
580+
581+
# Use helpful exception message in the following case:
582+
if max_int and min_int and v_max < v_min:
583+
exc_msg = "Minimum larger than maximum (min=%s, max=%s)" % (v_min, v_max)
584+
else:
585+
invalid_input = True
586+
587+
if not invalid_input:
588+
# Validate and inform user if the current values cardinality is violated
589+
valid = validation.Validation(self)
590+
for err in valid.errors:
591+
print("%s: %s" % (err.rank.capitalize(), err.msg))
592+
else:
593+
raise ValueError(exc_msg)
594+
595+
def set_values_cardinality(self, min_val=None, max_val=None):
596+
"""
597+
Sets the values cardinality of a Property.
598+
599+
:param min_val: Required minimal number of values elements. None denotes
600+
no restrictions on values elements minimum. Default is None.
601+
:param max_val: Allowed maximal number of values elements. None denotes
602+
no restrictions on values elements maximum. Default is None.
603+
"""
604+
self.val_cardinality = (min_val, max_val)
605+
510606
def remove(self, value):
511607
"""
512608
Remove a value from this property. Only the first encountered

odml/tools/dict_parser.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,47 @@
88
from .parser_utils import InvalidVersionException, ParserException, odml_tuple_export
99

1010

11+
def parse_cardinality(vals):
12+
"""
13+
Parses an odml specific cardinality from an input value.
14+
15+
If the input content is valid, returns an appropriate tuple.
16+
Returns None if the input is empty or the content cannot be
17+
properly parsed.
18+
19+
:param vals: list or tuple
20+
:return: None or 2-tuple
21+
"""
22+
if not vals:
23+
return None
24+
25+
if isinstance(vals, (list, tuple)) and len(vals) == 2:
26+
min_val = vals[0]
27+
max_val = vals[1]
28+
29+
if min_val is None or str(min_val).strip() == "None":
30+
min_val = None
31+
32+
if max_val is None or str(max_val).strip() == "None":
33+
max_val = None
34+
35+
min_int = isinstance(min_val, int) and min_val >= 0
36+
max_int = isinstance(max_val, int) and max_val >= 0
37+
38+
if min_int and max_int and max_val > min_val:
39+
return min_val, max_val
40+
41+
if min_int and not max_val:
42+
return min_val, None
43+
44+
if max_int and not min_val:
45+
return None, max_val
46+
47+
# We were not able to properly parse the current cardinality, so add
48+
# an appropriate Error/Warning once the reader 'ignore_errors' option has been implemented.
49+
return None
50+
51+
1152
class DictWriter:
1253
"""
1354
A writer to parse an odml.Document to a Python dictionary object equivalent.
@@ -255,8 +296,12 @@ def parse_properties(self, props_list):
255296
for i in _property:
256297
attr = self.is_valid_attribute(i, odmlfmt.Property)
257298
if attr:
299+
content = _property[attr]
300+
if attr.endswith("_cardinality"):
301+
content = parse_cardinality(content)
302+
258303
# Make sure to always use the correct odml format attribute name
259-
prop_attrs[odmlfmt.Property.map(attr)] = _property[attr]
304+
prop_attrs[odmlfmt.Property.map(attr)] = content
260305

261306
prop = odmlfmt.Property.create(**prop_attrs)
262307
odml_props.append(prop)

odml/tools/xmlparser.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,43 @@
4040
"""
4141

4242

43+
def parse_cardinality(val):
44+
"""
45+
Parses an odml specific cardinality from a string.
46+
47+
If the string content is valid, returns an appropriate tuple.
48+
Returns None if the string is empty or the content cannot be
49+
properly parsed.
50+
51+
:param val: string
52+
:return: None or 2-tuple
53+
"""
54+
if not val:
55+
return None
56+
57+
# Remove parenthesis and split on comma
58+
parsed_vals = val.strip()[1:-1].split(",")
59+
if len(parsed_vals) == 2:
60+
min_val = parsed_vals[0].strip()
61+
max_val = parsed_vals[1].strip()
62+
63+
min_int = min_val.isdigit() and int(min_val) >= 0
64+
max_int = max_val.isdigit() and int(max_val) >= 0
65+
66+
if min_int and max_int and int(max_val) > int(min_val):
67+
return int(min_val), int(max_val)
68+
69+
if min_int and max_val == "None":
70+
return int(min_val), None
71+
72+
if max_int and min_val == "None":
73+
return None, int(max_val)
74+
75+
# Todo we were not able to properly parse the current cardinality
76+
# add an appropriate Error/Warning
77+
return None
78+
79+
4380
def to_csv(val):
4481
"""
4582
Modifies odML values for serialization to strings and files.
@@ -410,6 +447,9 @@ def parse_tag(self, root, fmt, insert_children=True):
410447
if tag == "values" and curr_text:
411448
content = from_csv(node.text)
412449
arguments[tag] = content
450+
# Special handling of cardinality
451+
elif tag.endswith("_cardinality") and curr_text:
452+
arguments[tag] = parse_cardinality(node.text)
413453
else:
414454
arguments[tag] = curr_text
415455
else:

odml/validation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,32 @@ def property_values_string_check(prop):
454454

455455

456456
Validation.register_handler('property', property_values_string_check)
457+
458+
459+
def property_values_cardinality(prop):
460+
"""
461+
Checks Property values against any set value cardinality.
462+
463+
:param prop: odml.Property
464+
:return: Yields a ValidationError warning, if a set cardinality is not met.
465+
"""
466+
if prop.val_cardinality and isinstance(prop.val_cardinality, tuple):
467+
468+
val_min = prop.val_cardinality[0]
469+
val_max = prop.val_cardinality[1]
470+
471+
val_len = len(prop.values) if prop.values else 0
472+
473+
invalid_cause = ""
474+
if val_min and val_len < val_min:
475+
invalid_cause = "minimum %s" % val_min
476+
elif val_max and (prop.values and len(prop.values) > val_max):
477+
invalid_cause = "maximum %s" % val_max
478+
479+
if invalid_cause:
480+
msg = "Property values cardinality violated"
481+
msg += " (%s values, %s found)" % (invalid_cause, val_len)
482+
yield ValidationError(prop, msg, LABEL_WARNING)
483+
484+
485+
Validation.register_handler("property", property_values_cardinality)

test/test_dumper.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import unittest
22
import sys
3-
import odml
4-
53
try:
64
from StringIO import StringIO
75
except ImportError:
86
from io import StringIO
97

8+
import odml
9+
10+
from odml.tools.dumper import dump_doc
11+
1012

1113
class TestTypes(unittest.TestCase):
1214

1315
def setUp(self):
14-
# Capture the output printed by the functions to STDOUT, and use it for
15-
# testing purposes.
16-
self.captured_stdout = StringIO()
17-
sys.stdout = self.captured_stdout
18-
1916
s_type = "type"
2017

2118
self.doc = odml.Document(author='Rave', version='1.0')
@@ -38,10 +35,19 @@ def setUp(self):
3835
self.doc.append(s1)
3936

4037
def test_dump_doc(self):
38+
# Capture the output printed by the functions to STDOUT, and use it for
39+
# testing purposes. It needs to be reset after the capture.
40+
captured_stdout = StringIO()
41+
sys.stdout = captured_stdout
42+
4143
# This test dumps the whole document and checks it word by word.
42-
# If possible, maybe some better way of testing this ?
43-
odml.tools.dumper.dump_doc(self.doc)
44-
output = [x.strip() for x in self.captured_stdout.getvalue().split('\n') if x]
44+
# If possible, maybe some better way of testing this?
45+
dump_doc(self.doc)
46+
output = [x.strip() for x in captured_stdout.getvalue().split('\n') if x]
47+
48+
# Reset stdout
49+
sys.stdout = sys.__stdout__
50+
4551
expected_output = []
4652
expected_output.append("*Cell (type='type')")
4753
expected_output.append(":Type (values=Rechargeable, dtype='string')")
@@ -50,10 +56,7 @@ def test_dump_doc(self):
5056
expected_output.append("*Electrode (type='type')")
5157
expected_output.append(":Material (values=Nickel, dtype='string')")
5258
expected_output.append(":Models (values=[AA,AAA], dtype='string')")
59+
5360
self.assertEqual(len(output), len(expected_output))
5461
for i in range(len(output)):
5562
self.assertEqual(output[i], expected_output[i])
56-
57-
# Discard the document output from stdout stream
58-
self.captured_stdout.seek(0)
59-
self.captured_stdout.truncate(0)

0 commit comments

Comments
 (0)