Skip to content

Commit

Permalink
Merge pull request #10 from bluescarni/traitlets
Browse files Browse the repository at this point in the history
Traitlets-based numprops
  • Loading branch information
astrofrog committed Sep 23, 2015
2 parents 3e315cb + 09ab2e1 commit 8d29500
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 232 deletions.
4 changes: 1 addition & 3 deletions .travis.yml
Expand Up @@ -2,11 +2,9 @@ language: c

env:
matrix:
- PYTHON_VERSION=2.6 UNITPKG=false
- PYTHON_VERSION=2.7 UNITPKG=false
- PYTHON_VERSION=3.3 UNITPKG=false
- PYTHON_VERSION=3.4 UNITPKG=false
- PYTHON_VERSION=2.6 UNITPKG=true
- PYTHON_VERSION=2.7 UNITPKG=true
- PYTHON_VERSION=3.3 UNITPKG=true
- PYTHON_VERSION=3.4 UNITPKG=true
Expand All @@ -18,7 +16,7 @@ before_install:

install:
- conda install --yes pip pytest numpy
- pip install coveralls pytest-cov
- pip install coveralls pytest-cov traitlets
- if [[ $UNITPKG == true ]]; then conda install --yes astropy; fi
- if [[ $UNITPKG == true ]]; then pip install quantities pint; fi
- if [[ $PYTHON_VERSION == 2.6 ]]; then conda install --yes unittest2; fi
Expand Down
4 changes: 2 additions & 2 deletions CHANGES.md
@@ -1,9 +1,9 @@
0.2 (unreleased)
----------------

- No changes yet.
- Implement on top of the traitlets module.

0.1 (2015-07-05)
----------------

- Initial release.
- Initial release.
63 changes: 35 additions & 28 deletions README.md
Expand Up @@ -18,7 +18,7 @@ class Sphere(object):
@property
def radius(self):
return self._radius

@radius.setter
def radius(self, value):
if value <= 0:
Expand All @@ -33,13 +33,18 @@ class Sphere(object):
for each property you want to define, you can simply do:

```python
from numprops import NumericalProperty
from numprops import NumericalTrait
from traitlets import HasTraits

class Sphere(object):
class Sphere(HasTraits):

radius = NumericalProperty('radius', domain='strictly-positive', ndim=0)
radius = NumericalTrait(domain='strictly-positive', ndim=0)
```

The ``NumericalTrait`` class is implemented on top of the [traitlets](https://github.com/ipython/traitlets)
module. Any class using ``NumericalTrait`` for the definition of a property **must** derive from the
``traitlets.HasTraits`` class.

Support is also included for checking the dimensionality and shape of arrays
(which includes converting tuples and lists to arrays on-the-fly), as well as
checking the units of quantities for the
Expand All @@ -50,8 +55,9 @@ checking the units of quantities for the
Installing
----------

This package is compatible with Python 2.6, 2.7, and 3.3 and later, and
requires [numpy](http://www.numpy.org). If you are interested in doing unit validation, you will also need
This package is compatible with Python 2.7, 3.3 and later, and
requires [numpy](http://www.numpy.org) and [traitlets](https://github.com/ipython/traitlets).
If you are interested in doing unit validation, you will also need
[astropy](docs.astropy.org/en/stable/units/),
[pint](http://pint.readthedocs.org/), or
[quantities](https://pythonhosted.org/quantities/), depending on which unit
Expand All @@ -69,38 +75,39 @@ Using
-----

To create self-validating numerical properties on a class, use the
``NumericalProperty`` class:
``NumericalTrait`` class:

```python
from numprops import NumericalProperty
from traitlets import HasTraits
from numprops import NumericalTrait

class Sphere(object):
class Sphere(HasTraits):

radius = NumericalProperty('radius', domain='strictly-positive', ndim=0)
position = NumericalProperty('position', shape=(3,))
radius = NumericalTrait(domain='strictly-positive', ndim=0)
position = NumericalTrait(shape=(3,))
```

When a property is set, it will be validated:

```python
>>> s = Sphere()
>>> s.radius = 1.
>>> s.radius = -3
...
ValueError: radius should be strictly positive
TraitError: radius should be strictly positive
>>> s.radius = [1,2]
...
TypeError: radius should be a scalar value
TraitError: radius should be a scalar value
>>> s.position = (1,2,3)
>>> s.position = 3
...
TypeError: position should be a 1-d sequence
TraitError: position should be a 1-d sequence
>>> s.position = (1,2,3,4)
...
ValueError: position has incorrect length (expected 3 but found 4)
TraitError: position has incorrect length (expected 3 but found 4)
```

The following arguments to ``NumericalProperty`` are available (in addition to the property name):
The following arguments to ``NumericalTrait`` are available:

* ``ndim``: restrict the values to arrays with this number of dimension
* ``shape``: restrict the values to arrays with this shape. If specified, ``ndim`` does not need to be given.
Expand All @@ -113,14 +120,14 @@ Note that tuples and lists will automatically get converted to Numpy arrays, if
Physical units
--------------

While ``NumericalProperty`` can be used for plain scalars and Numpy arrays, it
While ``NumericalTrait`` can be used for plain scalars and Numpy arrays, it
can also be used for scalars and arrays which have associated units, with support for three
popular unit handling units:
[astropy.units](docs.astropy.org/en/stable/units/),
[pint](http://pint.readthedocs.org/), and
[quantities](https://pythonhosted.org/quantities/).

To restrict a ``NumericalProperty`` to quantities with a certain type of unit,
To restrict a ``NumericalTrait`` to quantities with a certain type of unit,
use the ``convertible_to`` option. This option takes units from any of these
three unit packages, and will ensure that any value passed has units equivalent
(but not necessarily equal) to those specified with the ``convertible_to``
Expand All @@ -145,8 +152,8 @@ The following example shows how to restrict the ``radius`` property to be an
```python
from astropy import units as u

class Sphere(object):
radius = NumericalProperty('radius', convertible_to=u.m)
class Sphere(HasTraits):
radius = NumericalTrait(convertible_to=u.m)
```

will then behave as follows:
Expand All @@ -157,7 +164,7 @@ will then behave as follows:
>>> s.radius = 4. * u.cm
>>> s.radius = 4. * u.s
...
ValueError: radius should be in units convertible to m
TraitError: radius should be in units convertible to m
```

### pint Quantity example
Expand All @@ -169,8 +176,8 @@ The following example shows how to restrict the ``radius`` property to be a
from pint import UnitRegistry
ureg = UnitRegistry()

class Sphere(object):
radius = NumericalProperty('radius', convertible_to=ureg.m)
class Sphere(HasTraits):
radius = NumericalTrait(convertible_to=ureg.m)
```

will then behave as follows:
Expand All @@ -181,7 +188,7 @@ will then behave as follows:
>>> s.radius = 4. * ureg.cm
>>> s.radius = 4. * ureg.s
...
ValueError: radius should be in units convertible to meter
TraitError: radius should be in units convertible to meter
```

### quantities Quantity example
Expand All @@ -192,8 +199,8 @@ be a [quantities](https://pythonhosted.org/quantities/) quantity in units of len
```python
import quantities as pq

class Sphere(object):
radius = NumericalProperty('radius', convertible_to=pq.m)
class Sphere(HasTraits):
radius = NumericalTrait(convertible_to=pq.m)
```

will then behave as follows:
Expand All @@ -204,7 +211,7 @@ will then behave as follows:
>>> s.radius = 4. * pq.cm
>>> s.radius = 4. * pq.s
...
ValueError: radius should be in units convertible to m
TraitError: radius should be in units convertible to m
```

Planned support
Expand Down
90 changes: 43 additions & 47 deletions numprops.py
Expand Up @@ -25,56 +25,54 @@

from __future__ import print_function

import numpy as np
from traitlets import TraitType, TraitError

from weakref import WeakKeyDictionary
import numpy as np

__version__ = '0.2.dev'
__version__ = '0.2.dev0'

ASTROPY = 'astropy'
PINT = 'pint'
QUANTITIES = 'quantities'


class NumericalProperty(object):

def __init__(self, name, ndim=None, shape=None, domain=None,
class NumericalTrait(TraitType):
info_text = 'a numerical trait, either a scalar or a vector'
def __init__(self, ndim=None, shape=None, domain=None,
default=None, convertible_to=None):
super(NumericalTrait, self).__init__()

self.name = name
self.domain = domain

if shape is not None:
if ndim is None:
ndim = len(shape)
else:
if ndim != len(shape):
raise ValueError("shape={0} and ndim={1} for property '{2}' are inconsistent".format(shape, ndim, name))

# Just store all the construction arguments.
self.ndim = ndim
self.shape = shape

self.domain = domain
# TODO: traitlets supports a `default` argument in __init__(), we should
# probably link them together once we start using this.
self.default = default
self.target_unit = convertible_to

self.data = WeakKeyDictionary()
if self.target_unit is not None:
self.unit_framework = identify_unit_framework(self.target_unit)

if convertible_to is not None:
self.unit_framework = identify_unit_framework(convertible_to)
# Check the construction arguments.
self._check_args()

def __get__(self, instance, owner):
return self.data.get(instance, self.default)
def _check_args(self):
if self.shape is not None:
if self.ndim is None:
self.ndim = len(self.shape)
else:
if self.ndim != len(self.shape):
raise TraitError("shape={0} and ndim={1} are inconsistent".format(self.shape, self.ndim))

def __set__(self, instance, value):
def validate(self, obj, value):

# We proceed by checking whether Numpy tells us the value is a
# scalar. If Numpy isscalar returns False, it could still be scalar
# but be a Quantity with units, so we then extract the numerical
# values

if np.isscalar(value):
if not np.isreal(value):
raise TypeError("{0} should be a numerical value".format(self.name))
raise TraitError("{0} should be a numerical value".format(self.name))
else:
is_scalar = True
num_value = value
Expand All @@ -84,7 +82,7 @@ def __set__(self, instance, value):
try:
num_value = np.array(value, copy=False, dtype=float)
except Exception as exc:
raise TypeError("Could not convert value of {0} to a Numpy array (Exception: {1})".format(self.name, exc))
raise TraitError("Could not convert value of {0} to a Numpy array (Exception: {1})".format(self.name, exc))

is_scalar = np.isscalar(num_value)

Expand All @@ -100,22 +98,22 @@ def __set__(self, instance, value):

if self.ndim == 0:
if not is_scalar:
raise TypeError("{0} should be a scalar value".format(self.name))
raise TraitError("{0} should be a scalar value".format(self.name))

if self.ndim > 0:
if is_scalar or num_value.ndim != self.ndim:
if self.ndim == 1:
raise TypeError("{0} should be a 1-d sequence".format(self.name))
raise TraitError("{0} should be a 1-d sequence".format(self.name))
else:
raise TypeError("{0} should be a {1:d}-d array".format(self.name, self.ndim))
raise TraitError("{0} should be a {1:d}-d array".format(self.name, self.ndim))

if self.shape is not None:

if self.shape is not None and np.any(num_value.shape != self.shape):
if self.ndim == 1:
raise ValueError("{0} has incorrect length (expected {1} but found {2})".format(self.name, self.shape[0], num_value.shape[0]))
raise TraitError("{0} has incorrect length (expected {1} but found {2})".format(self.name, self.shape[0], num_value.shape[0]))
else:
raise ValueError("{0} has incorrect shape (expected {1} but found {2})".format(self.name, self.shape, num_value.shape))
raise TraitError("{0} has incorrect shape (expected {1} but found {2})".format(self.name, self.shape, num_value.shape))

if self.target_unit is not None:
assert_unit_convertability(self.name, value, self.target_unit, self.unit_framework)
Expand All @@ -127,22 +125,21 @@ def __set__(self, instance, value):

if self.domain == 'positive':
if np.any(num_value < 0.):
raise ValueError(prefix + "{0} should be positive".format(self.name))
raise TraitError(prefix + "{0} should be positive".format(self.name))
elif self.domain == 'strictly-positive':
if np.any(num_value <= 0.):
raise ValueError(prefix + "{0} should be strictly positive".format(self.name))
raise TraitError(prefix + "{0} should be strictly positive".format(self.name))
elif self.domain == 'negative':
if np.any(num_value > 0.):
raise ValueError(prefix + "{0} should be negative".format(self.name))
raise TraitError(prefix + "{0} should be negative".format(self.name))
elif self.domain == 'strictly-negative':
if np.any(num_value >= 0.):
raise ValueError(prefix + "{0} should be strictly negative".format(self.name))
raise TraitError(prefix + "{0} should be strictly negative".format(self.name))
elif type(self.domain) in [tuple, list] and len(self.domain) == 2:
if np.any(num_value < self.domain[0]) or np.any(num_value > self.domain[-1]):
raise ValueError(prefix + "{0} should be in the range [{1:g}:{2:g}]".format(self.name, self.domain[0], self.domain[-1]))

self.data[instance] = value
raise TraitError(prefix + "{0} should be in the range [{1:g}:{2:g}]".format(self.name, self.domain[0], self.domain[-1]))

return value

try:
import astropy.units
Expand Down Expand Up @@ -198,8 +195,7 @@ def identify_unit_framework(target_unit):

return QUANTITIES

raise ValueError("Could not identify unit framework for target unit of type {0}".format(type(target_unit).__name__))

raise TraitError("Could not identify unit framework for target unit of type {0}".format(type(target_unit).__name__))


def assert_unit_convertability(name, value, target_unit, unit_framework):
Expand All @@ -226,27 +222,27 @@ def assert_unit_convertability(name, value, target_unit, unit_framework):
from astropy.units import Quantity

if not isinstance(value, Quantity):
raise TypeError("{0} should be given as an Astropy Quantity instance".format(name))
raise TraitError("{0} should be given as an Astropy Quantity instance".format(name))

if not target_unit.is_equivalent(value.unit):
raise ValueError("{0} should be in units convertible to {1}".format(name, target_unit))
raise TraitError("{0} should be in units convertible to {1}".format(name, target_unit))

elif unit_framework == PINT:

from pint.unit import UnitsContainer

if not (hasattr(value, 'units') and isinstance(value.units, UnitsContainer)):
raise TypeError("{0} should be given as a Pint Quantity instance".format(name))
raise TraitError("{0} should be given as a Pint Quantity instance".format(name))

if value.dimensionality != target_unit.dimensionality:
raise ValueError("{0} should be in units convertible to {1}".format(name, target_unit.units))
raise TraitError("{0} should be in units convertible to {1}".format(name, target_unit.units))

elif unit_framework == QUANTITIES:

from quantities import Quantity

if not isinstance(value, Quantity):
raise TypeError("{0} should be given as a quantities Quantity instance".format(name))
raise TraitError("{0} should be given as a quantities Quantity instance".format(name))

if value.dimensionality.simplified != target_unit.dimensionality.simplified:
raise ValueError("{0} should be in units convertible to {1}".format(name, target_unit.dimensionality.string))
raise TraitError("{0} should be in units convertible to {1}".format(name, target_unit.dimensionality.string))

0 comments on commit 8d29500

Please sign in to comment.