Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Section properties cardinality #383

Merged
merged 10 commits into from
Apr 16, 2020
3 changes: 2 additions & 1 deletion odml/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ class Section(Format):
'repository': 0,
'section': 0,
'include': 0,
'property': 0
'property': 0,
'prop_cardinality': 0
}
_map = {
'section': 'sections',
Expand Down
70 changes: 67 additions & 3 deletions odml/section.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .property import BaseProperty
# it MUST however not be used to create any Property objects
from .tools.doc_inherit import inherit_docstring, allow_inherit_docstring
from .util import format_cardinality


@allow_inherit_docstring
Expand All @@ -40,8 +41,12 @@ class BaseSection(base.Sectionable):
:param link: Specifies a soft link, i.e. a path within the document.
:param include: Specifies an arbitrary URL. Can only be used if *link* is not set.
: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.
an id will be generated and assigned. An id has to be unique
within an odML Document.
: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
format "(min, max)".
"""

type = None
Expand All @@ -54,7 +59,8 @@ 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):
repository=None, link=None, include=None, oid=None,
prop_cardinality=None):

# Sets _sections Smartlist and _repository to None, so run first.
super(BaseSection, self).__init__()
Expand All @@ -80,11 +86,16 @@ def __init__(self, name=None, type="n.s.", parent=None,
self._repository = repository
self._link = link
self._include = include
self._prop_cardinality = None

# this may fire a change event, so have the section setup then
self.type = type
self.parent = parent

# This might lead to a validation warning, since properties are set
# at a later point in time.
self.prop_cardinality = prop_cardinality

for err in validation.Validation(self).errors:
if err.is_error:
use_name = err.obj.name if err.obj.id != err.obj.name else None
Expand Down Expand Up @@ -349,6 +360,59 @@ def get_repository(self):
def repository(self, url):
base.Sectionable.repository.fset(self, url)

@property
def prop_cardinality(self):
"""
The Property cardinality of a Section. It defines how many Properties
are minimally required and how many Properties should be maximally
stored. Use the 'set_properties_cardinality' method to set.
"""
return self._prop_cardinality

@prop_cardinality.setter
def prop_cardinality(self, new_value):
"""
Sets the Properties 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._prop_cardinality = format_cardinality(new_value)

# Validate and inform user if the current cardinality is violated
self._properties_cardinality_validation()

def set_properties_cardinality(self, min_val=None, max_val=None):
"""
Sets the Properties 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.prop_cardinality = (min_val, max_val)

def _properties_cardinality_validation(self):
"""
Runs a validation to check whether the properties 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))

@inherit_docstring
def get_terminology_equivalent(self):
repo = self.get_repository()
Expand Down
19 changes: 16 additions & 3 deletions odml/tools/dict_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,11 @@ def get_sections(self, section_list):
section_dict[attr] = sections
else:
tag = getattr(section, attr)

if tag:
# Tuples have to be serialized as lists to avoid
# nasty python code annotations when writing to yaml.
if tag and isinstance(tag, tuple):
section_dict[i] = list(tag)
elif tag:
# Always use the arguments key attribute name when saving
section_dict[i] = tag

Expand Down Expand Up @@ -143,6 +146,8 @@ def get_properties(props_list):

if hasattr(prop, attr):
tag = getattr(prop, attr)
# Tuples have to be serialized as lists to avoid
# nasty python code annotations when writing to yaml.
if isinstance(tag, tuple):
prop_dict[attr] = list(tag)
elif (tag == []) or tag: # Even if 'values' is empty, allow '[]'
Expand Down Expand Up @@ -266,8 +271,14 @@ def parse_sections(self, section_list):
elif attr == 'sections':
children_secs = self.parse_sections(section['sections'])
elif attr:
# Tuples had to be serialized as lists to support the yaml format.
# Now convert cardinality lists back to tuples.
content = section[attr]
if attr.endswith("_cardinality"):
content = parse_cardinality(content)

# Make sure to always use the correct odml format attribute name
sec_attrs[odmlfmt.Section.map(attr)] = section[attr]
sec_attrs[odmlfmt.Section.map(attr)] = content

sec = odmlfmt.Section.create(**sec_attrs)
for prop in sec_props:
Expand Down Expand Up @@ -297,6 +308,8 @@ def parse_properties(self, props_list):
attr = self.is_valid_attribute(i, odmlfmt.Property)
if attr:
content = _property[attr]
# Tuples had to be serialized as lists to support the yaml format.
# Now convert cardinality lists back to tuples.
if attr.endswith("_cardinality"):
content = parse_cardinality(content)

Expand Down
29 changes: 29 additions & 0 deletions odml/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,35 @@ def property_values_string_check(prop):
Validation.register_handler('property', property_values_string_check)


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.
"""
if obj.prop_cardinality and isinstance(obj.prop_cardinality, tuple):

val_min = obj.prop_cardinality[0]
val_max = obj.prop_cardinality[1]

val_len = len(obj.properties) if obj.properties 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):
invalid_cause = "maximum %s" % val_max

if invalid_cause:
msg = "Section properties cardinality violated"
msg += " (%s values, %s found)" % (invalid_cause, val_len)
yield ValidationError(obj, msg, LABEL_WARNING)


Validation.register_handler("section", section_properties_cardinality)


def property_values_cardinality(prop):
"""
Checks Property values against any set value cardinality.
Expand Down
141 changes: 141 additions & 0 deletions test/test_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,147 @@ def test_export_leaf(self):
self.assertEqual(len(ex3['first'].sections), 1)
self.assertEqual(len(ex3['first']['third']), 0)

def _test_cardinality_re_assignment(self, obj, obj_attribute):
"""
Tests the basic set of both Section properties and sub-sections cardinality.

:param obj: odml Section
:param obj_attribute: string with the cardinality attribute that is supposed to be tested.
Should be either 'prop_cardinality' or 'sec_cardinality'.
"""
oat = obj_attribute

# Test Section prop/sec cardinality reset
for non_val in [None, "", [], (), {}]:
setattr(obj, oat, non_val)
self.assertIsNone(getattr(obj, oat))
setattr(obj, oat, 1)

# Test Section prop/sec cardinality single int max assignment
setattr(obj, oat, 10)
self.assertEqual(getattr(obj, oat), (None, 10))

# Test Section prop/sec cardinality tuple max assignment
setattr(obj, oat, (None, 5))
self.assertEqual(getattr(obj, oat), (None, 5))

# Test Section prop/sec cardinality tuple min assignment
setattr(obj, oat, (5, None))
self.assertEqual(getattr(obj, oat), (5, None))

# Test Section prop/sec cardinality min/max assignment
setattr(obj, oat, (1, 5))
self.assertEqual(getattr(obj, oat), (1, 5))

# -- Test Section prop/sec cardinality assignment failures
with self.assertRaises(ValueError):
setattr(obj, oat, "a")

with self.assertRaises(ValueError):
setattr(obj, oat, -1)

with self.assertRaises(ValueError):
setattr(obj, oat, (1, "b"))

with self.assertRaises(ValueError):
setattr(obj, oat, (1, 2, 3))

with self.assertRaises(ValueError):
setattr(obj, oat, [1, 2, 3])

with self.assertRaises(ValueError):
setattr(obj, oat, {1: 2, 3: 4})

with self.assertRaises(ValueError):
setattr(obj, oat, (-1, 1))

with self.assertRaises(ValueError):
setattr(obj, oat, (1, -5))

with self.assertRaises(ValueError):
setattr(obj, oat, (5, 1))

def test_properties_cardinality(self):
"""
Tests the basic assignment rules for Section Properties cardinality
on init and re-assignment but does not test properties assignment or
the actual cardinality validation.
"""
doc = Document()

# -- Test set cardinality on Section init
# Test empty init
sec_prop_card_none = Section(name="sec_prop_card_none", type="test", parent=doc)
self.assertIsNone(sec_prop_card_none.prop_cardinality)

# Test single int max init
sec_card_max = Section(name="prop_cardinality_max", prop_cardinality=10, parent=doc)
self.assertEqual(sec_card_max.prop_cardinality, (None, 10))

# Test tuple init
sec_card_min = Section(name="prop_cardinality_min", prop_cardinality=(2, None), parent=doc)
self.assertEqual(sec_card_min.prop_cardinality, (2, None))

# -- Test Section properties cardinality re-assignment
sec = Section(name="prop", prop_cardinality=(None, 10), parent=doc)
self.assertEqual(sec.prop_cardinality, (None, 10))

# Use general method to reduce redundancy
self._test_cardinality_re_assignment(sec, 'prop_cardinality')

def _test_set_cardinality_method(self, obj, obj_attribute, set_cardinality_method):
"""
Tests the basic set convenience method of both Section properties and
sub-sections cardinality.

:param obj: odml Section
:param obj_attribute: string with the cardinality attribute that is supposed to be tested.
Should be either 'prop_cardinality' or 'sec_cardinality'.
:param set_cardinality_method: The convenience method used to set the cardinality.
"""
oba = obj_attribute

# Test Section prop/sec cardinality min assignment
set_cardinality_method(1)
self.assertEqual(getattr(obj, oba), (1, None))

# Test Section prop/sec cardinality keyword min assignment
set_cardinality_method(min_val=2)
self.assertEqual(getattr(obj, oba), (2, None))

# Test Section prop/sec cardinality max assignment
set_cardinality_method(None, 1)
self.assertEqual(getattr(obj, oba), (None, 1))

# Test Section prop/sec cardinality keyword max assignment
set_cardinality_method(max_val=2)
self.assertEqual(getattr(obj, oba), (None, 2))

# Test Section prop/sec cardinality min max assignment
set_cardinality_method(1, 2)
self.assertEqual(getattr(obj, oba), (1, 2))

# Test Section prop/sec cardinality keyword min max assignment
set_cardinality_method(min_val=2, max_val=5)
self.assertEqual(getattr(obj, oba), (2, 5))

# Test Section prop/sec cardinality empty reset
set_cardinality_method()
self.assertIsNone(getattr(obj, oba))

# Test Section prop/sec cardinality keyword empty reset
set_cardinality_method(1)
self.assertIsNotNone(getattr(obj, oba))
set_cardinality_method(min_val=None, max_val=None)
self.assertIsNone(getattr(obj, oba))

def test_set_properties_cardinality(self):
doc = Document()
sec = Section(name="sec", type="test", parent=doc)

# Use general method to reduce redundancy
self._test_set_cardinality_method(sec, 'prop_cardinality', sec.set_properties_cardinality)

def test_link(self):
pass

Expand Down
Loading