Repository: SINTEF/dlite (language: python)

Issue:
Add unit support in property access and assignment
Issue suggested by @terence-coudert.

Suggested interface for assignment:
```
inst.stress = "3 MPa"  # this will convert the str to a Quantity and store the value in the right unit
inst.stress = 3   # this is already implemented
inst.stress = ([3.0, 4.0, 5.0], "MPa")  # this convert the tuple into a Quantity
inst.stress = Quantity(70, "GPa")
```

Suggested interface for access:
```
inst.stress.to("MPa") -> 70e3
```



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 64693110e..6cd4d95aa 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,16 @@ In this case we didn't initialised the age
 >>> homes.age = 34  # Assign the age
 ```
 
+If you have [Pint] installed, you can also specify or access the age
+as a quantity with unit
+```python
+>>> homes.q.age = "34year"
+>>> homes.q.age
+<Quantity(34, 'year')>
+>>> homes.q.age.to("century").m
+0.34
+```
+
 We can view (a JSON representation of) the instance with
 
 ```python
@@ -213,3 +223,4 @@ DLite is developed with the hope that it will be a delight to work with.
 [documentation]: https://sintef.github.io/dlite/index.html
 [SOFT]: https://www.sintef.no/en/publications/publication/1553408/
 [UUID]: https://en.wikipedia.org/wiki/Universally_unique_identifier
+[Pint]: https://pint.readthedocs.io/
diff --git a/bindings/python/CMakeLists.txt b/bindings/python/CMakeLists.txt
index 979877d51..54d8e3811 100644
--- a/bindings/python/CMakeLists.txt
+++ b/bindings/python/CMakeLists.txt
@@ -8,6 +8,7 @@ set(py_sources
   mappings.py
   datamodel.py
   rdf.py
+  quantity.py
   testutils.py
   )
 
diff --git a/bindings/python/dlite-entity-python.i b/bindings/python/dlite-entity-python.i
index f907613b7..c6bf3d159 100644
--- a/bindings/python/dlite-entity-python.i
+++ b/bindings/python/dlite-entity-python.i
@@ -97,7 +97,7 @@ class Metadata(Instance):
         lst = [p for p in self.properties["properties"] if p.name == name]
         if lst:
             return lst[0]
-        raise _dlite.DLiteError(
+        raise _dlite.DLiteKeyError(
             f"Metadata {self.uri} has no such property: {name}")
 
     def dimnames(self):
@@ -161,7 +161,7 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
         inst = _dlite.get_instance(id, metaid, check_storages)
 
     if inst is None:
-        raise _dlite.DLiteError(f"no such instance: {id}")
+        raise _dlite.DLiteMissingInstanceError(f"no such instance: {id}")
     return instance_cast(inst)
 
 %}
@@ -300,7 +300,6 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
 /* --------
  * Instance
  * -------- */
-
 %extend _DLiteInstance {
 
   int __len__(void) {
@@ -362,7 +361,10 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
         for p in self.meta['properties']:
             if p.name == name:
                 return p
-        raise ValueError(f'No property "{name}" in "{self.uri}"')
+        raise ValueError(
+            f'No property "{name}" in '
+            f'"{self.uri if self.uri else self.meta.uri}"'
+        )
 
     meta = property(get_meta, doc="Reference to the metadata of this instance.")
     dimensions = property(
@@ -575,33 +577,40 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
         elif isinstance(dest, str):
             self.save_to_url(dest)
         else:
-            raise _dlite.DLiteError('Arguments do not match any call signature')
+            raise _dlite.DLiteTypeError(
+                'Arguments to save() do not match any of the call signatures'
+            )
 
     def __getitem__(self, ind):
         if isinstance(ind, int):
             value = self.get_property_by_index(ind)
         elif self.has_property(ind):
-            value = self.get_property(ind)
+            value = _get_property(self, ind)
         elif isinstance(ind, int):
-            raise IndexError('instance property index out of range: %d' % ind)
+            raise _dlite.DLiteIndexError(
+                'instance property index out of range: %d' % ind
+            )
         else:
-            raise KeyError('no such property: %s' % ind)
+            raise _dlite.DLiteKeyError('no such property: %s' % ind)
         if isinstance(value, np.ndarray) and self.is_frozen():
             value.flags.writeable = False  # ensure immutability
         return value
 
     def __setitem__(self, ind, value):
         if self.is_frozen():
-            raise _dlite.DLiteError(
-                'frozen instance does not support item assignment')
+            raise _dlite.DLiteUnsupportedError(
+                f'frozen instance does not support assignment of property '
+                f'{ind}')
         if isinstance(ind, int):
             self.set_property_by_index(ind, value)
         elif self.has_property(ind):
-            self.set_property(ind, value)
+            _set_property(self, ind, value)
         elif isinstance(ind, int):
-            raise IndexError('instance property index out of range: %d' % ind)
+            raise _dlite.DLiteIndexError(
+                'instance property index out of range: %d' % ind
+            )
         else:
-            raise KeyError('no such property: %s' % ind)
+            raise _dlite.DLiteKeyError('no such property: %s' % ind)
 
     def __contains__(self, item):
         return item in self.properties.keys()
@@ -617,7 +626,9 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
         elif self.has_dimension(name):
             value = self.get_dimension_size(name)
         else:
-            raise AttributeError('Instance object has no attribute %r' % name)
+            raise _dlite.DLiteAttributeError(
+                'Instance object has no attribute %r' % name
+            )
         if isinstance(value, np.ndarray) and self.is_frozen():
             value.flags.writeable = False  # ensure immutability
         return value
@@ -626,8 +637,10 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
         if name == 'this':
             object.__setattr__(self, name, value)
         elif self.is_frozen():
-            raise _dlite.DLiteError(
-                'frozen instance does not support attribute assignment')
+            raise _dlite.DLiteUnsupportedError(
+                f"frozen instance does not support assignment of property "
+                f"'{name}'"
+            )
         elif _has_property(self, name):
             _set_property(self, name, value)
         else:
@@ -673,7 +686,6 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
             iterfun(self),
         )
 
-
     def asdict(self, soft7=True, uuid=True, single=True):
         """Returns a dict representation of self.
 
@@ -783,5 +795,17 @@ def get_instance(id: str, metaid: str = None, check_storages: bool = True) -> "I
             DeprecationWarning, stacklevel=2)
         return self.copy()
 
+    @property
+    def q(self):
+        """ to work with quantities """
+        from dlite.quantity import get_quantity_helper
+        return get_quantity_helper(self)
+
+    def get_quantity(self, name):
+        return self.q[name]
+
+    def set_quantity(self, name, value, unit):
+        self.q[name] = (value, unit)
+
 %}
 }
diff --git a/bindings/python/quantity.py b/bindings/python/quantity.py
new file mode 100755
index 000000000..8ce31cd1c
--- /dev/null
+++ b/bindings/python/quantity.py
@@ -0,0 +1,182 @@
+
+""" Define the singleton QuantityHelper to work with pint Quantity and dlite 
+    instance properties.
+"""
+
+from typing import Any
+import numpy as np
+HAS_PINT = True
+try:
+    import pint
+except Exception:
+    HAS_PINT = False
+
+DLITE_QUANTITY_TYPES = [
+    'int', 'float', 'double', 'uint',
+    'int8', 'int16', 'int32', 'int64',
+    'uint8', 'uint16', 'uint32', 'uint64',
+    'float32', 'float64', 'float80', 'float96', 'float128'
+]
+quantity_helper = None
+
+
+class QuantityHelper:
+
+    def __init__(self):
+        self.__dict__['_instance'] = None
+        self.__dict__['_registry'] = None
+
+    def _get_property(self, name: str):
+        """ Return the property if it exists and if it can be a quantity """
+        p = self._instance.get_property_descr(name)
+        if p.type in DLITE_QUANTITY_TYPES:
+            return p
+        else:
+            raise TypeError(
+                f'The type "{p.type}" of property "{name}" is not a type for'
+                ' a number, cannot convert the property value to a quantity.'
+            )
+
+    def _get_unit_as_string(self, unit: Any) -> str:
+        """ Returns the given unit as a string """
+        unit_string = ''
+        if isinstance(unit, str):
+            unit_string = unit.strip()
+        elif isinstance(unit, self._registry.Unit):
+            unit_string = str(unit)
+        return unit_string.replace('%', 'percent')
+
+    def __getitem__(self, name: str) -> pint.Quantity:
+        p = self._get_property(name)
+        u = self._get_unit_as_string(p.unit)
+        return self.quantity(self._instance[name], u)
+
+    def __setitem__(self, name: str, value: Any):
+        p = self._get_property(name)
+        q = self.parse(value)
+        self._instance[name] = q.m if q.units.dimensionless else q.to(p.unit).m
+
+    def __getattr__(self, name: str):
+        if name in self.__dict__:
+            return self.__dict__[name]
+        else:
+            return self[name]
+
+    def __setattr__(self, name: str, value: Any):
+        if name in self.__dict__:
+            self.__dict__[name] = value
+        else:
+            self[name] = value
+
+    def __call__(self, inst) -> 'QuantityHelper':
+        self._instance = inst
+        self._registry = pint.get_application_registry()
+        if not hasattr(self._registry.get(), 'percent'):
+            self._registry.get().define('percent = 0.01*count')
+        return self
+
+    def names(self):
+        """ Return the names of each property which has a quantity type """
+        prop = self._instance.meta['properties']
+        return [p.name for p in prop if p.type in DLITE_QUANTITY_TYPES]
+
+    def units(self):
+        """ Return the units of each property which has a quantity type """
+        prop = self._instance.meta['properties']
+        return [p.unit for p in prop if p.type in DLITE_QUANTITY_TYPES]
+
+    def values(self):
+        """ Return the quantity of each property which has a quantity type """
+        return [self[name] for name in self.names()]
+
+    def items(self):
+        """ Return a zip iterator (name, quantity) """
+        names = self.names()
+        values = [self[name] for name in names]
+        return zip(names, values)
+
+    def get(self, *names):
+        """ Return the quantity of each given property name """
+        if names:
+            if len(names) == 1:
+                return self[names[0]]
+            else:
+                return [self[name] for name in names]
+        return None
+
+    @property
+    def unit_registry(self) -> pint.UnitRegistry:
+        """ Returns the current pint UnitRegistry object """
+        return self._registry.get()
+
+    def quantity(self, magnitude, unit) -> pint.Quantity:
+        """ Return a pint.Quantity object """
+        return self._registry.Quantity(magnitude, unit)
+
+    def parse(self, value: Any) -> pint.Quantity:
+        """ Parse the given value and return a pint.Quantity object """
+        if isinstance(value, (pint.Quantity, self._registry.Quantity)):
+            return value
+        elif isinstance(value, tuple):
+            if len(value) >= 2:
+                return self.quantity(value[0], value[1])
+            else:
+                return self.quantity(value[0], '')
+        elif isinstance(value, str):
+            return self.parse_expression(value)
+        elif value is None:
+            return self.quantity(np.nan, '')
+        else:
+            return self.quantity(value, '')
+
+    def parse_expression(self, value: str) -> pint.Quantity:
+        """ Parse an expression (str) and return a pint.Quantity object """
+        result = None
+        if value:
+            value_str = self._get_unit_as_string(value)
+            ph = pint.util.ParserHelper.from_string(value_str)
+            if len(ph._d) > 0:
+                un = '*'.join([f'({k}**{v})' for k, v in ph._d.items()])
+                try:
+                    unit = self.unit_registry.parse_units(un)
+                except Exception:
+                    unit = None
+                if unit is not None:
+                    result = self.quantity(ph.scale, un)
+            else:
+                result = self.quantity(ph.scale, '')
+        return self.quantity(np.nan, '') if result is None else result
+
+    def update(self, **kwargs):
+        for key, val in kwargs.items():
+            self[key] = val
+
+    def to_dict(self, names=None, value_type='quantity', fmt=''):
+        if not isinstance(names, (list, tuple, dict)):
+            prop = self._instance.meta['properties']
+            names = [p.name for p in prop if p.type in DLITE_QUANTITY_TYPES]
+        values = {}
+        for name in names:
+            q = self[name]
+            if value_type.startswith('q'):
+                values[name] = q
+            elif value_type.startswith('m'):
+                values[name] = q.m
+            elif value_type.startswith('s'):
+                if fmt:
+                    q.default_format = fmt
+                values[name] = f'{q}'
+        return values
+
+
+def get_quantity_helper(instance):
+    global quantity_helper
+    if HAS_PINT:
+        if quantity_helper is None:
+            quantity_helper = QuantityHelper()
+        return quantity_helper(instance)
+    else:
+        raise RuntimeError(
+            'you must install "pint" to work with quantities, '
+            'try: pip install pint'
+        )
diff --git a/doc/getting_started/tutorial.md b/doc/getting_started/tutorial.md
index c1a9f2bed..4495c475a 100644
--- a/doc/getting_started/tutorial.md
+++ b/doc/getting_started/tutorial.md
@@ -230,3 +230,119 @@ for key in mapping:
 
 
 More examples of how to use DLite are available in the [examples](https://github.com/SINTEF/dlite/tree/master/examples) directory.
+
+
+## Working with physical quantities
+
+As you can read in the previous sections, a property of a dlite entity can be
+defined with a **unit** (a unit of measurement). The dlite.Instance object can
+set (or get) their properties from (or to) physical quanities (the product of a
+numerical value and a unit of measurement). The dlite library uses the physical
+quantities defined and implemented by the Python package
+[pint](https://pint.readthedocs.io/en/stable/getting/overview.html).
+
+> You must install pint if you want to to work with physical quantities,
+> try to run the command ```pip install pint```, see the page
+> [pint installation](https://pint.readthedocs.io/en/stable/getting/index.html#installation).
+
+This section illustrates how to work with physical quantities when manipulating
+the dlite **instance** objects. The code example will use the following data model:
+
+```yaml
+uri: http://onto-ns.com/meta/0.1/BodyMassIndex
+description: The body mass index of a person.
+properties:
+  name:
+    type: string
+    description: Name of the person.
+  age:
+    type: float32
+    unit: year
+    description: Age of the person.
+  height:
+    type: float
+    unit: cm
+    description: Height of the person.
+  weight:
+    type: float
+    unit: kg
+    description: Weight of the person.
+  bmi:
+    type: float
+    unit: kg/m^2
+    description: Body mass index.
+  category:
+    type: string
+    description: Underweight, normal, or overweight.
+```
+
+The code below will create one BodyMassIndex instance object (variable ```person```) and assign the properties of the ```person``` using physical quantities. You can use the method ```person.set_quantity``` to set a property with a quantity or you can use the shortcut ```person.q``` like shown below:
+
+```python
+BodyMassIndex = dlite.get_instance("http://onto-ns.com/meta/0.1/BodyMassIndex")
+# create a BodyMassIndex instance
+person = BodyMassIndex()
+# assign the name of the person
+person.name = "John Doe"
+# assign the age of the person without a physical quantity
+person.age = 5
+assert person.age == 5.0
+# assign the age of the person with a physical quantity
+person.set_quantity("age", 3.0, "years")
+assert person.age == 3.0
+person.q.age = (1461, "days")
+assert person.age == 4.0
+# assign the height and the weight
+person.q.height = "1.72m"
+person.q.weight = "63kg"
+assert person.height == 172.0
+assert person.weight == 63.0
+# compute the BMI
+person.q.bmi = person.q.weight.to("kg") / person.q.height.to("m") ** 2
+assert np.round(person.get_quantity("bmi").magnitude) == 21.0
+# the following line will give the same result as the line above
+person.q.bmi = person.q.weight / person.q.height ** 2
+# the following line will raise a TypeError exception, because the property
+# "category" with the type "string" cannot be converted into a quantity.
+category = person.q.category
+# assign the category
+person.category = "normal"
+if person.bmi < 18.5:
+    person.category = = "underweight"
+elif person.bmi >= 25.0:
+    person.category = "overweight"
+```
+
+> If you need in your program to use a specific units registry, use the function
+> [pint.set_application_registry](https://pint.readthedocs.io/en/0.10.1/tutorial.html#using-pint-in-your-projects)
+
+The Python property ```q``` of the dlite.Instance objects as some other methods:
+
+```python
+assert person.q.names() == ["age", "height", "weight", "bmi"]
+assert person.q.units() == ["year", "cm", "kg", "kg/m^2"]
+for name, quantity in persion.q.items():
+    quantity.defaut_format = "~"  # format the unit with a compact format
+    print(f"{name} = {quantity}")
+```
+
+> see [quantity formatting](https://pint.readthedocs.io/en/stable/user/formatting.html)
+
+You should not keep the Python property ```q``` as a local variable, it will result with wrong assigment if you work with several instances.
+
+```python
+# >>> don't do that
+p1 = person1.q  
+p2 = person2.q
+# this will assign the weight to person2, and not to person1
+p1.weight = "34kg"  
+# <<< 
+# you must write this code
+person1.q.weight = "34kg"
+person2.q.weight = "32kg"
+# for more complex formula, you can get the quantities in local variables
+w1, h1 = person1.q.get("weight", "height")
+w2, h2, a2 = person2.q.get("weight", "height", "age")
+```
+
+The first line of the code above prepare the [singleton](https://www.geeksforgeeks.org/singleton-pattern-in-python-a-complete-guide/) dlite.quantity.QuantityHelper for the person1 and return the singleton. The second line do the same as the first line, so the variable ```p2``` is the singleton and is prepared to work on ```person2```.
\ No newline at end of file


Test patch:
diff --git a/bindings/python/tests/entities/PersonQuantity.json b/bindings/python/tests/entities/PersonQuantity.json
new file mode 100644
index 000000000..a1c411d8a
--- /dev/null
+++ b/bindings/python/tests/entities/PersonQuantity.json
@@ -0,0 +1,36 @@
+{
+  "uri": "http://onto-ns.com/meta/0.1/PersonQuantity",
+  "description": "A example to work with quantity.",
+  "dimensions": {
+    "N": "Number of points."
+  },
+  "properties": {
+    "name": {
+      "type": "string",
+      "description": "Name."
+    },
+    "age": {
+      "type": "float",
+      "unit": "years",
+      "description": "Age of person."
+    },
+    "length": {
+      "type": "float",
+      "unit": "cm",
+      "description": "Length."
+    },
+    "temperature": {
+      "type": "float",
+      "unit": "degC",
+      "description": "Temperature."
+    },
+    "stress": {
+      "type": "float",
+      "unit": "MPa",
+      "shape": [
+        "N"
+      ],
+      "description": "Stress curve."
+    }
+  }
+}
\ No newline at end of file
diff --git a/bindings/python/tests/test_entity.py b/bindings/python/tests/test_entity.py
index 87aa4ad03..4378b5cb9 100755
--- a/bindings/python/tests/test_entity.py
+++ b/bindings/python/tests/test_entity.py
@@ -325,6 +325,25 @@
 assert PersonOld.dimnames() == PersonNew.dimnames()
 
 
+# For issue #743
+Item = dlite.get_instance("http://onto-ns.com/meta/0.1/Item")
+item = Item(dimensions={"nf": 3}, properties={"name": "A", "f": [1, 2, 3]})
+try:
+    import pint
+except ModuleNotFoundError:
+    pass
+else:
+    item.q.f = pint.Quantity([0.1, 0.2, 0.3], "kHz")
+    assert item.f.tolist() == [100., 200., 300.]
+    item.q.f = ([0.5, 0.2, 0.3], "kHz")
+    assert item.f.tolist() == [500., 200., 300.]
+
+    item.set_quantity("f", [0, 3600, 3.6], "hour**-1")
+    assert item.f.tolist() == [0., 1., 0.001]
+    assert item.q.f.m.tolist() == [0., 1., 0.001]
+    assert item.q.f.to("1/hour").m.tolist() == [0, 3600, 3.6]
+
+    
 # For issue #750 - test instance_cast()
 with raises(dlite.DLiteTypeError):
     dlite.instance_cast(inst, dlite.Metadata)
diff --git a/bindings/python/tests/test_quantity.py b/bindings/python/tests/test_quantity.py
new file mode 100755
index 000000000..e15ca5240
--- /dev/null
+++ b/bindings/python/tests/test_quantity.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+""" Unit Tests on the quantity module """
+try:
+    import pint
+except ImportError:
+    exit(44)  # skip test
+
+from pathlib import Path
+import numpy as np
+import dlite
+from dlite.testutils import raises
+from dlite.quantity import get_quantity_helper
+
+thisdir = Path(__file__).absolute().parent
+entitydir = thisdir / "entities"
+dlite.storage_path.append(f'{entitydir}/*.json')
+
+Person = dlite.get_instance("http://onto-ns.com/meta/0.1/PersonQuantity")
+
+
+def assertQuantity(q, m, u):
+    if np.isnan(m):
+        assert np.isnan(q.m)
+    else:
+        assert q.m == m
+    assert q.u == pint.Unit(u)
+
+
+def assertParse(h, value, m, u):
+    assertQuantity(h.parse(value), m, u)
+
+
+# Test the quantity helper methods: names, units, values, items
+inst = Person(dimensions={'N': 3})
+names = inst.q.names()
+assert ['age', 'length', 'temperature', 'stress'] == names
+assert ['years', 'cm', 'degC', 'MPa'] == inst.q.units()
+inst.q.update(
+    temperature='20 degC',
+    length=(22.3, 'mm'),
+    age='1461 days',
+    stress=([1, 2, 3], 'GPa')
+)
+values = inst.q.values()
+assertQuantity(values[0], 4.0, 'year')
+assertQuantity(values[1], 2.23, 'cm')
+assertQuantity(values[2], 20.0, 'degC')
+assert np.allclose(values[3].m, [1000.0, 2000.0, 3000.0])
+assert values[3].u == pint.Unit('MPa')
+items = list(inst.q.items())
+names = [item[0] for item in items]
+assert ['age', 'length', 'temperature', 'stress'] == names
+
+# Test the quantity helper for dlite instances
+q = inst.q
+# raise a ValueError if property does not exists
+with raises(ValueError):
+    q.unknown
+# raise a TypeError if the property has not quantity type
+with raises(TypeError):
+    inst.q.name
+# modify the value of the property "temperature"
+inst.temperature = 37.0
+# get temperature as quantity
+assertQuantity(q.temperature, 37.0, 'degC')
+assertQuantity(inst.get_quantity('temperature'), 37.0, 'degC')
+# use the quantity to scale the magnitude
+assertQuantity(q.temperature.to('K'), 273.15 + 37.0, 'K')
+
+# Test to set quantity into dlite instance
+inst.name = 'Hello'
+assert inst.name == 'Hello'
+with raises(TypeError):
+    inst.q.name = 3
+inst.q.temperature = '20 degC'
+assertQuantity(inst.q.temperature, 20, 'degC')
+inst.q['length'] = (10, 'mm')
+assertQuantity(inst.q.length, 1, 'cm')
+inst.q.age = inst.q.quantity(1461, 'days')
+assertQuantity(inst.q.age, 4, 'years')
+surface = inst.q.length ** 2
+assertQuantity(surface, 1, 'cm²')
+force = inst.q.quantity([1, 2, 3], 'kN')
+inst.q.stress = force / surface
+assert np.allclose([10, 20, 30], inst.q.stress.m)
+assert pint.Unit('MPa') == inst.q.stress.u
+inst.set_quantity('age', 365.25, 'days')
+assertQuantity(inst.get_quantity('age'), 1.0, 'year')
+
+# test parse expression
+p = get_quantity_helper(None)
+assertParse(p, '15', 15, '')
+assertParse(p, '273.15K', 273.15, 'K')
+assertParse(p, 'degC', 1.0, 'degC')
+assertParse(p, 'degC/cm', 1.0, 'degC/cm')
+assertParse(p, '', np.nan, '')
+assertParse(p, None, np.nan, '')
+assertParse(p, 'None', np.nan, '')
+assertParse(p, 'none', np.nan, '')
+assertParse(p, 'Text', np.nan, '')
+assertParse(p, 'degC/28', 1 / 28, 'degC')
+assertParse(p, '3.0', 3.0, '')
+assertParse(p, '10cm', 10, 'cm')
+assertParse(p, (10.0, 'km'), 10, 'km')
+assertParse(p, 10.0, 10.0, '')
+arr = [1.2, 2.3, 3.4]
+q = p.quantity(arr, 'nm')
+assert np.allclose(arr, q.m)
+assert q.u == pint.Unit('nm')
+
+# test unit "percent"
+assertParse(p, '1.1', 1.1, '')
+assertParse(p, '11%', 11, 'percent')
+q = p.parse('11%').to('')
+assert q.m == 0.11
+q = p.parse('11%').to('percent')
+assert q.m == 11
+q = p.parse('101 percent').to('')
+assert q.m == 1.01
+q = p.parse('101 1/%').to('')
+assert q.m == 10100.0
diff --git a/bindings/python/tests/test_transaction.py b/bindings/python/tests/test_transaction.py
index 87509d422..88c75b824 100644
--- a/bindings/python/tests/test_transaction.py
+++ b/bindings/python/tests/test_transaction.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 
 import dlite
+from dlite.testutils import raises
 
 
 # Configure paths
@@ -40,19 +41,12 @@
 
 frozen = person.get_snapshot(1)
 assert frozen.is_frozen() == True
-try:
+
+with raises(dlite.DLiteUnsupportedError):
     frozen.age = 77
-except dlite.DLiteError as exc:
-    assert str(exc) == "frozen instance does not support attribute assignment"
-else:
-    raise Exception("frozen instance should not accept attribute assignment")
 
-try:
+with raises(ValueError):
     frozen.skills[0] = "skiing"
-except ValueError:
-    pass
-else:
-    raise Exception("frozen property array should not accept assignment")
 
 # Create branch
 inst = person.copy()