Source code for fsleyes_props.properties_types

#!/usr/bin/env python
#
# properties_types.py - Definitions for different property types.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""Definitions for different property types.

This module provides a number of :class:`.PropertyBase` subclasses which
define properties of different types. These classes are intended to be
added as attributes of a :class:`.HasProperties` class definition.

 .. autosummary::
    :nosignatures:

    Object
    Boolean
    Number
    Int
    Real
    Percentage
    String
    Choice
    FilePath
    List
    Colour
    ColourMap
    Bounds
    Point
    Array
"""


import os.path as op

import matplotlib        as mpl
import matplotlib.colors as mplcolors
import numpy             as np

from . import properties        as props
from . import properties_value  as propvals


[docs] class Object(props.PropertyBase): """A property which encapsulates any value. """ def __init__(self, **kwargs): """Create a ``Object`` property. If an ``equalityFunc`` is not provided, any writes to this property will be treated as if the value has changed (and any listeners will be notified). """ def defaultEquals(this, other): return False kwargs['equalityFunc'] = kwargs.get('equalityFunc', defaultEquals) props.PropertyBase.__init__(self, **kwargs)
[docs] class Boolean(props.PropertyBase): """A property which encapsulates a ``bool`` value.""" def __init__(self, **kwargs): """Create a ``Boolean`` property. If the ``default`` ``kwarg`` is not provided, a default value of ``False`` is used. """ kwargs['default'] = kwargs.get('default', False) props.PropertyBase.__init__(self, **kwargs)
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.cast`. Casts the given value to a ``bool``. """ return bool(value)
[docs] class Number(props.PropertyBase): """Base class for the :class:`Int` and :class:`Real` classes. A property which represents a number. Don't use/subclass this, use/subclass one of ``Int`` or ``Real``. """ def __init__(self, minval=None, maxval=None, clamped=False, **kwargs): """Define a :class:`Number` property. :param minval: Minimum valid value :param maxval: Maximum valid value :param clamped: If ``True``, the value will be clamped to its min/max bounds. :param kwargs: Passed through to :meth:`.PropertyBase.__init__`. If a ``default`` value is not provided, it is set to something sensible. """ default = kwargs.get('default', None) if default is None: if minval is not None and maxval is not None: default = (minval + maxval) / 2 elif minval is not None: default = minval elif maxval is not None: default = maxval else: default = 0 kwargs['default'] = default kwargs['minval'] = minval kwargs['maxval'] = maxval kwargs['clamped'] = clamped props.PropertyBase.__init__(self, **kwargs)
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. Validates the given number. Calls the :meth:`.PropertyBase.validate` method. Then, if the ``minval`` and/or ``maxval`` attributes have been set, and the given value is not within those values, a :exc:`ValueError` is raised. :param instance: The owning :class:`.HasProperties` instance (or ``None`` for unbound property values). :param attributes: Dictionary containing property attributes. :param value: The value to validate. """ props.PropertyBase.validate(self, instance, attributes, value) minval = attributes['minval'] maxval = attributes['maxval'] if minval is not None and value < minval: raise ValueError('Must be at least {}'.format(minval)) if maxval is not None and value > maxval: raise ValueError('Must be at most {}'.format(maxval))
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.cast`. If the ``clamped`` attribute is ``True`` and the ``minval`` and/or ``maxval`` have been set, this function ensures that the given value lies within the ``minval`` and ``maxval`` limits. Otherwise the value is returned unchanged. """ if value is None: return value clamped = attributes['clamped'] if not clamped: return value minval = attributes['minval'] maxval = attributes['maxval'] if minval is not None and value < minval: return minval if maxval is not None and value > maxval: return maxval return value
[docs] class Int(Number): """A :class:`Number` which encapsulates an integer.""" def __init__(self, **kwargs): """Create an ``Int`` property. """ Number.__init__(self, **kwargs)
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`Number.cast`. Casts the given value to an ``int``, and then passes the value to :meth:`Number.cast`. """ if value is None: return value return Number.cast(self, instance, attributes, int(value))
[docs] class Real(Number): """A :class:`.Number` which encapsulates a floating point number.""" def __equals(self, a, b): """Custom equality function passed to :class`.PropertyBase.__init__`. Tests for equality according to the ``precision`` passed to :meth:`__init__`. """ if any((a is None, b is None, self.__precision is None)): return a == b return abs(a - b) < self.__precision def __init__(self, precision=0.000000001, **kwargs): """Define a ``Real`` property. :param precision: Tolerance for equality testing. Set to ``None`` to use exact equality. """ self.__precision = precision Number.__init__(self, equalityFunc=self.__equals, **kwargs)
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`Number.cast`. Casts the given value to a ``float``, and then passes the value to :meth:`Number.cast`. """ if value is None: return value return Number.cast(self, instance, attributes, float(value))
[docs] class Percentage(Real): """A :class:`Real` property which represents a percentage. A ``Percentage`` property is just a ``Real`` property with a default minimum value of ``0`` and default maximum value of ``100``. """ def __init__(self, **kwargs): """Create a ``Percentage`` property.""" kwargs['minval'] = kwargs.get('minval', 0.0) kwargs['maxval'] = kwargs.get('maxval', 100.0) kwargs['default'] = kwargs.get('default', 50.0) Real.__init__(self, **kwargs)
[docs] class String(props.PropertyBase): """A property which encapsulates a string.""" def __init__(self, minlen=None, maxlen=None, **kwargs): """Cteate a ``String`` property. :param int minlen: Minimum valid string length. :param int maxlen: Maximum valid string length. """ kwargs['default'] = kwargs.get('default', None) kwargs['minlen'] = minlen kwargs['maxlen'] = maxlen props.PropertyBase.__init__(self, **kwargs)
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.cast`. Casts the given value to a string. If the given value is the empty string, it is replaced with ``None``. """ if value == '': return None else: return value
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. Passes the given value to :meth:`.PropertyBase.validate`. Then, if either the ``minlen`` or ``maxlen`` attributes have been set, and the given value has length less than ``minlen`` or greater than ``maxlen``, raises a :exc:`ValueError`. """ if value == '': value = None props.PropertyBase.validate(self, instance, attributes, value) if value is None: return if not isinstance(value, str): raise ValueError('Must be a string') minlen = attributes['minlen'] maxlen = attributes['maxlen'] if minlen is not None and len(value) < minlen: raise ValueError('Must have length at least {}'.format(minlen)) if maxlen is not None and len(value) > maxlen: raise ValueError('Must have length at most {}'.format(maxlen))
[docs] class Choice(props.PropertyBase): """A property which may only be set to one of a set of predefined values. Choices can be added/removed via the :meth:`addChoice`, :meth:`removeChoice` method, and :meth:`setChoices` methods. Existing choices can be modified with the :meth:`updateChoice` method. Individual choices can be enabled/disabled via the :meth:`enableChoice` and :meth:`disableChoice` methods. The ``choiceEnabled`` attribute contains a dictionary of ``{choice : boolean}`` mappings representing the enabled/disabled state of each choice. A set of alternate values can be provided for each choice - these alternates will be accepted when assigning to a ``Choice`` property. .. note:: If you create a ``Choice`` property with non-string choice and alternate values, you may run into problems when using :mod:`.serialise` and/or :mod:`.cli` functionality, unless you set ``allowStr`` to ``True``. """ def __init__(self, choices=None, alternates=None, allowStr=False, **kwargs): """Create a ``Choice`` property. :arg choices: List of values, the possible values that this property can take. Can alternately be a ``dict`` - see the note above. :arg alternates: A list of lists, specificying alternate acceptable values for each choice. Can also be a dict of ``{choice : [alternates]}`` mappings. All alternate values must be unique - different choices cannot have equivalent alternate values. :arg allowStr: If ``True``, string versions of any non-string choice values will be accepted - ``str`` versions of each choice are added as alternate values for that choice. Defaults to ``False``. """ if choices is None: choices = [] alternates = {} # Alternates are stored twice: # # - As a dict of { choice : [alternate] } mappings # - As a dict of { alternate : choice } mappings # # We generate the first dict here if alternates is None: alternates = {c : [] for c in choices} elif isinstance(alternates, dict): alternates = dict(alternates) elif isinstance(alternates, (list, tuple)): alternates = {c : list(a) for (c, a) in zip(choices, alternates)} # Add stringified versions of all # choices if allowStr is True if allowStr: for c in choices: strc = str(c) alts = alternates[c] if strc not in alts: alts.append(strc) # Generate the second alternates dict altLists = alternates alternates = self.__generateAlternatesDict(altLists) # Enabled flags are stored as a dict # of {choice : bool} mappings enabled = {choice: True for choice in choices} if len(choices) > 0: default = choices[0] else: default = None if len(choices) != len(altLists): raise ValueError('Alternates are required for every choice') kwargs['choices'] = list(choices) kwargs['alternates'] = dict(alternates) kwargs['altLists'] = dict(altLists) kwargs['choiceEnabled'] = enabled kwargs['allowStr'] = allowStr kwargs['default'] = kwargs.get('default', default) kwargs['allowInvalid'] = kwargs.get('allowInvalid', False) props.PropertyBase.__init__(self, **kwargs)
[docs] def setDefault(self, default, instance=None): """Sets the default choice value. """ if default not in self.getChoices(instance): raise ValueError(f'{default} is not a choice') self.setAttribute(instance, 'default', default)
[docs] def enableChoice(self, choice, instance=None): """Enables the given choice. """ choiceEnabled = dict(self.getAttribute(instance, 'choiceEnabled')) choiceEnabled[choice] = True self.setAttribute(instance, 'choiceEnabled', choiceEnabled)
[docs] def disableChoice(self, choice, instance=None): """Disables the given choice. An attempt to set the property to a disabled value will result in a :exc:`ValueError`. """ choiceEnabled = dict(self.getAttribute(instance, 'choiceEnabled')) choiceEnabled[choice] = False self.setAttribute(instance, 'choiceEnabled', choiceEnabled)
[docs] def choiceEnabled(self, choice, instance=None): """Returns ``True`` if the given choice is enabled, ``False`` otherwise. """ return self.getAttribute(instance, 'choiceEnabled')[choice]
[docs] def getChoices(self, instance=None): """Returns a list of the current choices. """ return list(self.getAttribute(instance, 'choices'))
[docs] def getAlternates(self, instance=None): """Returns a list of the current acceptable alternate values for each choice. """ choices = self.getAttribute(instance, 'choices') altLists = self.getAttribute(instance, 'altLists') return [altLists[c] for c in choices]
[docs] def updateChoice(self, choice, newChoice=None, newAlt=None, instance=None): """Updates the choice value and/or alternates for the specified choice. """ choices = list(self.getAttribute(instance, 'choices')) altLists = dict(self.getAttribute(instance, 'altLists')) idx = choices.index(choice) if newChoice is not None: choices[ idx] = newChoice altLists[newChoice] = altLists[choice] altLists.pop(choice) else: newChoice = choice if newAlt is not None: altLists[newChoice] = list(newAlt) self.__updateChoices(choices, altLists, instance)
[docs] def setChoices(self, choices, alternates=None, instance=None, newChoice=None): """Sets the list of possible choices (and their alternate values, if not None). """ if alternates is None: alternates = {c : [] for c in choices} elif isinstance(alternates, (list, tuple)): alternates = {c : a for (c, a) in zip(choices, alternates)} elif isinstance(alternates, dict): alternates = dict(alternates) # Add stringified versions of all # choices if allowStr is True if self.getAttribute(instance, 'allowStr'): for c in choices: strc = str(c) alts = alternates[c] if strc not in alts: alts.append(strc) if len(choices) != len(alternates): raise ValueError('Alternates are required for every choice') if (newChoice is not None) and (newChoice not in choices): raise ValueError(f'New choice value {newChoice} is ' f'not in new choices {choices}') self.__updateChoices(choices, alternates, instance, newChoice)
[docs] def addChoice(self, choice, alternate=None, instance=None): """Adds a new choice to the list of possible choices.""" if alternate is None: alternate = [] else: alternate = list(alternate) choices = list(self.getAttribute(instance, 'choices')) altLists = dict(self.getAttribute(instance, 'altLists')) if self.getAttribute(instance, 'allowStr'): strc = str(choice) if strc not in alternate: alternate.append(strc) choices.append(choice) altLists[choice] = list(alternate) self.__updateChoices(choices, altLists, instance)
[docs] def removeChoice(self, choice, instance=None): """Removes the specified choice from the list of possible choices. """ choices = list(self.getAttribute(instance, 'choices')) altLists = dict(self.getAttribute(instance, 'altLists')) choices .remove(choice) altLists.pop( choice) self.__updateChoices(choices, altLists, instance)
def __generateAlternatesDict(self, altLists): """Given a dictionary containing ``{choice : [alternates]}`` mappings, creates and returns a dictionary containing ``{alternate : choice}`` mappings. Raises a ``ValueError`` if there are any duplicate alternate values. """ alternates = {} for choice, altList in altLists.items(): for alt in altList: if alt in alternates: raise ValueError('Duplicate alternate value ' f'(choice: {choice}): {alt}') alternates[alt] = choice return alternates def __updateChoices(self, choices, alternates, instance=None, newChoice=None): """Used by all of the public choice modifying methods. Updates all choices, labels, and altenrates. :param choices: A list of choice values :param alternates: A dict of ``{choice : [alternates]}`` mappings. :param newChoice: New value """ propVal = self.getPropVal( instance) default = self.getAttribute(instance, 'default') oldEnabled = self.getAttribute(instance, 'choiceEnabled') newEnabled = {} # Prevent notification while # we're updating constraints if propVal is not None: oldChoice = propVal.get() notifState = propVal.getNotificationState() validState = propVal.allowInvalid() propVal.disableNotification() propVal.allowInvalid(True) for choice in choices: if choice in oldEnabled: newEnabled[choice] = oldEnabled[choice] else: newEnabled[choice] = True if default not in choices: default = choices[0] altLists = alternates alternates = self.__generateAlternatesDict(altLists) self.setAttribute(instance, 'choiceEnabled', newEnabled) self.setAttribute(instance, 'altLists', altLists) self.setAttribute(instance, 'alternates', alternates) self.setAttribute(instance, 'choices', choices) self.setAttribute(instance, 'default', default) if propVal is not None: if newChoice is not None: propVal.set(newChoice) elif oldChoice not in choices: if default in choices: propVal.set(default) elif len(choices) > 0: propVal.set(choices[0]) else: propVal.set(None) propVal.setNotificationState(notifState) propVal.allowInvalid( validState) if notifState: propVal.notifyAttributeListeners('choices', choices) if propVal.get() != oldChoice: propVal.propNotify()
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. Raises a :exc:`ValueError` if the given value is not one of the possible values for this :class:`Choice` property. """ props.PropertyBase.validate(self, instance, attributes, value) choices = self.getAttribute(instance, 'choices') enabled = self.getAttribute(instance, 'choiceEnabled') alternates = self.getAttribute(instance, 'alternates') if len(choices) == 0: return # Check to see if this is an # acceptable alternate value altValue = alternates.get(value, None) if value not in choices and altValue not in choices: raise ValueError(f'Invalid choice ({value})') if not enabled.get(value, False): raise ValueError(f'Choice is disabled ({value})')
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.cast`. Checks to see if the given value is a valid alternate value for a choice. If so, the alternate value is replaced with the choice value. """ alternates = self.getAttribute(instance, 'alternates') return alternates.get(value, value)
[docs] class FilePath(String): """A property which represents a file or directory path. There is currently no support for validating a path which may be either a file or a directory - only one or the other. """ def __init__(self, exists=False, isFile=True, suffixes=None, **kwargs): """Create a ``FilePath`` property. :param bool exists: If ``True``, the path must exist. :param bool isFile: If ``True``, the path must be a file. If ``False``, the path must be a directory. This check is only performed if ``exists`` is ``True``. :param list suffixes: List of acceptable file suffixes (only relevant if ``isFile`` is ``True``). """ if suffixes is None: suffixes = [] kwargs['exists'] = exists kwargs['isFile'] = isFile kwargs['suffixes'] = list(suffixes) String.__init__(self, **kwargs)
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. If the ``exists`` attribute is not ``True``, does nothing. Otherwise, if ``isFile`` is ``False`` and the given value is not a path to an existing directory, a :exc:`ValueError` is raised. If ``isFile`` is ``True``, and the given value is not a path to an existing file (which, if ``suffixes`` is not None, must end in one of the specified suffixes), a :exc:`ValueError` is raised. """ String.validate(self, instance, attributes, value) exists = attributes['exists'] isFile = attributes['isFile'] suffixes = attributes['suffixes'] if value is None: return if value == '': return if not exists: return if isFile: matchesSuffix = any([value.endswith(s) for s in suffixes]) # If the file doesn't exist, it's bad if not op.isfile(value): raise ValueError('Must be a file ({})'.format(value)) # if the file exists, and matches one of # the specified suffixes, then it's good if len(suffixes) == 0 or matchesSuffix: return # Otherwise it's bad else: raise ValueError( 'Must be a file ending in [{}] ({})'.format( ','.join(suffixes), value)) elif not op.isdir(value): raise ValueError('Must be a directory ({})'.format(value))
[docs] class List(props.ListPropertyBase): """A property which represents a list of items, of another property type. If you use ``List`` properties, you really should read the documentation for the :class:`.PropertyValueList`, as it contains important usage information. """ def __init__(self, listType=None, minlen=None, maxlen=None, **kwargs): """Create a ``List`` property. :param listType: A :class:`.PropertyBase` type, specifying the values allowed in the list. If ``None``, anything can be stored in the list, but no casting or validation will occur. :param int minlen: Minimum list length. :param int maxlen: Maximum list length. """ if (listType is not None) and \ (not isinstance(listType, props.PropertyBase)): raise ValueError( 'A list type (a PropertyBase instance) must be specified') kwargs['default'] = kwargs.get('default', []) kwargs['minlen'] = minlen kwargs['maxlen'] = maxlen # This needs to be removed when you update widgets_list.py self.embed = False props.ListPropertyBase.__init__(self, listType, **kwargs)
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. Checks that the given value (which should be a list) meets the ``minlen``/``maxlen`` attribute. Raises a :exc:`ValueError` if it does not. """ props.ListPropertyBase.validate(self, instance, attributes, value) minlen = attributes['minlen'] maxlen = attributes['maxlen'] if minlen is not None and len(value) < minlen: raise ValueError('Must have length at least {}'.format(minlen)) if maxlen is not None and len(value) > maxlen: raise ValueError('Must have length at most {}'.format(maxlen))
[docs] class Colour(props.PropertyBase): """A property which represents a RGBA colour, stored as four floating point values in the range ``0.0 - 1.0``. Any value which can be interpreted by matplotlib as a RGB(A) colour is accepted. If an RGB colour is provided, the alpha channel is set to 1.0. """ def __init__(self, **kwargs): """Create a ``Colour`` property. If the ``default`` ``kwarg`` is not provided, the default is set to white. """ default = kwargs.get('default', (1.0, 1.0, 1.0, 1.0)) if len(default) == 3: default = list(default) + [1.0] kwargs['default'] = default props.PropertyBase.__init__(self, **kwargs)
[docs] def validate(self, instance, attributes, value): """Checks the given ``value``, and raises a :exc:`ValueError` if it does not consist of three or four floating point numbers in the range ``(0.0 - 1.0)``. """ props.PropertyBase.validate(self, instance, attributes, value) mplcolors.to_rgba(value)
[docs] def cast(self, instance, attributes, value): """Ensures that the given ``value`` contains three or four floating point numbers, in the range ``(0.0 - 1.0)``. If the alpha channel is not provided, it is set to the current alpha value (which defaults to ``1.0``). """ if value is not None: return mplcolors.to_rgba(value) else: return value
[docs] class ColourMap(props.PropertyBase): """A property which encapsulates a :class:`matplotlib.colors.Colormap`. A ``ColourMap`` property can take any ``Colormap`` instance as its value. ColourMap values may be specified either as a ``Colormap`` instance, or as a string containing the name of a registered colour map instance. ``ColourMap`` properties also maintain an internal list of colour map names; while these names do not restrict the value that a ``ColourMap`` property can take, they are used for display purposes - a widget which is created for a ``ColourMap`` instance will only display the options returned by the :meth:`getColourMaps` method. See the :func:`widgets._ColourMap` function. It is possible to specify a colour map name ``prefix`` when creating a ``ColourMap`` property. When a prefix is set, assignments to the property, e.g. ``obj.cmap = 'red'`` will cause a colour map named ``{prefix}_red`` to be chosen over a colour map named ``red``, if the former is registered with matplotlib. This `prefix` option was added because, for historical reasons, FSLeyes defines some colour maps with the same name as built-in matplotlib colour maps. From matplotlib ~3.5 and newer, it is not possible to override built-in colour maps, so these are registered as ``fsleyes_{name}``. Furthermore, matplotlib 3.8 made it impossible to give a colour map a name which is different to the key under which it is registered. """ def __init__(self, cmaps=None, prefix=None, **kwargs): """Define a ``ColourMap`` property. """ default = kwargs.get('default', None) if cmaps is None: cmaps = [] if default is None and len(cmaps) > 0: default = cmaps[0] kwargs['default'] = default kwargs['cmaps'] = list(cmaps) self.__prefix = prefix props.PropertyBase.__init__(self, **kwargs)
[docs] def setColourMaps(self, cmaps, instance=None): """Set the colour maps for this property. :arg cmaps: a list of registered colour map names. """ default = self.getAttribute(instance, 'default') if default not in cmaps: default = cmaps[0] self.setAttribute(instance, 'cmaps' , cmaps) self.setAttribute(instance, 'default', default)
[docs] def addColourMap(self, cmap, instance=None): """Add a colour map to the list. :arg cmap: The name of a registered colour map. """ cmaps = self.getColourMaps(instance) if cmap not in cmaps: cmaps.append(cmap) self.setColourMaps(cmaps, instance)
[docs] def getColourMaps(self, instance=None): """Returns a list containing the names of registered colour maps available for this property. """ return list(self.getAttribute(instance, 'cmaps'))
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. Raises a :exc:`ValueError` if the given ``value`` is not a matplotlib :class:`.Colormap` instance. """ if not isinstance(value, mplcolors.Colormap): raise ValueError('Colour map value is not a ' 'matplotlib.colors.Colormap instance')
[docs] def cast(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.cast`. If the provided value is a string, an attempt is made to convert it to a colour map, via the ``matplotlib.colormaps`` registry. The value may either be the registered colour map name, or its ``Colormap.name`` attribute. The match is case-insensitive. """ if isinstance(value, str): # Case insensitive match against either the # registered colourmap key. We accept any # colour map registered with matplotlib, but # colour maps added to this ColourMap # property are preferentially considered. cmapKeys = list(self.getAttribute(instance, 'cmaps')) # If a prefix is set, we preferentially match # against mpl colour maps named '{prefix}{value}'. prefix = self.__prefix mplKeys = [c for c in mpl.colormaps.keys() if c not in cmapKeys] if prefix is not None: mplKeys = [k for k in mplKeys if k.startswith(prefix)] + \ [k for k in mplKeys if not k.startswith(prefix)] cmapKeys += mplKeys lCmapKeys = [s.lower() for s in cmapKeys] # Preferentially match prefixed colour maps value = value.lower() if prefix is not None: candidates = [f'{prefix}{value}', value] else: candidates = [value] for candidate in candidates: try: idx = lCmapKeys.index(candidate) break except ValueError: pass if idx is None: choices = ','.join(cmapKeys) raise ValueError(f'Unknown colour map ({value}) - ' f'valid choices are: {choices}') value = cmapKeys[idx] value = mpl.colormaps[value] return value
[docs] class BoundsValueList(propvals.PropertyValueList): """A :class:`.PropertyValueList` with values which represent bounds along a number of dimensions (up to 4). This class is used by the :class:`Bounds` property to encapsulate bounding values for an arbitrary number of dimensions. For ``N+1`` dimensions, the bounding values are stored as a list:: [lo0, hi0, lo1, hi1, ..., loN, hiN] This class just adds some convenience methods and attributes to the ``PropertyValueList`` base class. For a single dimension, a bound object has a ``lo`` value and a ``hi`` value, specifying the bounds along that dimension. To make things confusing, each dimension also has ``min`` and ``max`` attributes, which define the minimum/maximum values that the ``lo`` and ``high`` values may take for that dimension. Some dynamic attributes are available on ``BoundsValueList`` objects, allowing access to and assignment of bound values and attributes. Dimensions ``0, 1, 2, 3`` respectively map to identifiers ``x, y, z, t``. If an attempt is made to access/assign an attribute corresponding to a dimension which does not exist on a particular ``BoundsValueList`` instance (e.g. attribute ``t`` on a 3-dimensional list), an :exc:`IndexError` will be raised. Here is an example of dynamic bound attribute access:: class MyObj(props.HasProperties): myBounds = Bounds(ndims=4) obj = MyObj() # set/access lo/hi values together xlo, xhi = obj.mybounds.x obj.mybounds.z = (25, 30) # set/access lo/hi values separately obj.mybounds.xlo = 2 obj.mybounds.zhi = 50 # get the length of the bounds for a dimension ylen = obj.mybounds.ylen # set/access the minimum/maximum # constraints for a dimension obj.mybounds.xmin = 0 tmax = obj.mybounds.tmax """ def __init__(self, *args, **kwargs): """Create a ``BoundsValueList`` instance - see :meth:`.PropertyValueList.__init__`. """ propvals.PropertyValueList.__init__(self, *args, **kwargs)
[docs] def getLo(self, axis=None): """Return the low value for the given (0-indexed) axis. If ``axis`` is not specified, the low bounds for all axes are returned. """ if axis is not None: return self[axis * 2] else: return self[::2]
[docs] def getHi(self, axis=None): """Return the high value for the given (0-indexed) axis. If ``axis`` is not specified, the high bounds for all axes are returned. """ if axis is not None: return self[axis * 2 + 1] else: return self[1::2]
[docs] def getRange(self, axis): """Return the (low, high) values for the given (0-indexed) axis.""" return (self.getLo(axis), self.getHi(axis))
[docs] def getLen(self, axis): """Return the distance between the low and high values for the specified axis. """ return abs(self.getHi(axis) - self.getLo(axis))
[docs] def setLimit(self, axis, limit, value): """Sets the value for the specified axis and limit (0 == low, 1 == high). """ self[axis * 2 + limit] = value
[docs] def getLimit(self, axis, limit): """Returns the value for the specified axis and limit (0 == low, 1 == high). """ return self[axis * 2 + limit]
[docs] def setLo(self, axis, value): """Set the low value for the specified axis.""" self[axis * 2] = value
[docs] def setHi(self, axis, value): """Set the high value for the specified axis.""" self[axis * 2 + 1] = value
[docs] def setRange(self, axis, loval, hival): """Set the low and high values for the specified axis.""" self[axis * 2:axis * 2 + 2] = [loval, hival]
[docs] def getMin(self, axis): """Return the minimum value (the low limit) for the specified axis.""" return self.getPropertyValueList()[axis * 2].getAttribute('minval')
[docs] def getMax(self, axis): """Return the maximum value (the high limit) for the specified axis.""" return self.getPropertyValueList()[axis * 2 + 1].getAttribute('maxval')
[docs] def setMin(self, axis, value): """Set the minimum value for the specified axis.""" self.getPropertyValueList()[axis * 2] .setAttribute('minval', value) self.getPropertyValueList()[axis * 2 + 1].setAttribute('minval', value)
[docs] def setMax(self, axis, value): """Set the maximum value for the specified axis.""" self.getPropertyValueList()[axis * 2] .setAttribute('maxval', value) self.getPropertyValueList()[axis * 2 + 1].setAttribute('maxval', value)
[docs] def getLimits(self, axis): """Return (minimum, maximum) limit values for the specified axis.""" return (self.getMin(axis), self.getMax(axis))
[docs] def setLimits(self, axis, minval, maxval): """Set the minimum and maximum limit values for the specified axis.""" self.setMin(axis, minval) self.setMax(axis, maxval)
[docs] def inBounds(self, point): """Returns ``True`` if the given point (a sequence of numbers) lies within the bounds represented by this ``BoundsValueList``, ``False`` otherwise. """ if 2 * len(point) != len(self): raise ValueError('Invalid number of dimensions: {}'.format(point)) for ax, coord in enumerate(point): if coord < self.getLo(ax) or coord > self.getHi(ax): return False return True
def __getattr__(self, name): """Return the specified value. Raises an :exc:`AttributeError` for unrecognised attributes, or an :exc:`IndexError` if an attempt is made to access bound values values of a higher dimension than this list contains. """ lname = name.lower() # TODO this is easy to read, but # could be made much more efficient if lname == 'x': return self.getRange( 0) elif lname == 'y': return self.getRange( 1) elif lname == 'z': return self.getRange( 2) elif lname == 't': return self.getRange( 3) elif lname == 'lo': return self.getLo() elif lname == 'hi': return self.getHi() elif lname == 'xlo': return self.getLo( 0) elif lname == 'xhi': return self.getHi( 0) elif lname == 'ylo': return self.getLo( 1) elif lname == 'yhi': return self.getHi( 1) elif lname == 'zlo': return self.getLo( 2) elif lname == 'zhi': return self.getHi( 2) elif lname == 'tlo': return self.getLo( 3) elif lname == 'thi': return self.getHi( 3) elif lname == 'xlen': return self.getLen( 0) elif lname == 'ylen': return self.getLen( 1) elif lname == 'zlen': return self.getLen( 2) elif lname == 'tlen': return self.getLen( 3) elif lname == 'xmin': return self.getMin( 0) elif lname == 'ymin': return self.getMin( 1) elif lname == 'zmin': return self.getMin( 2) elif lname == 'tmin': return self.getMin( 3) elif lname == 'xmax': return self.getMax( 0) elif lname == 'ymax': return self.getMax( 1) elif lname == 'zmax': return self.getMax( 2) elif lname == 'tmax': return self.getMax( 3) elif lname == 'xlim': return self.getLimits(0) elif lname == 'ylim': return self.getLimits(1) elif lname == 'zlim': return self.getLimits(2) elif lname == 'tlim': return self.getLimits(3) raise AttributeError('{} has no attribute called {}'.format( self.__class__.__name__, name)) def __setattr__(self, name, value): """Set the specified value. Raises an :exc:`IndexError` if an attempt is made to assign bound values values of a higher dimension than this list contains. """ lname = name.lower() if lname == 'x': self.setRange( 0, *value) elif lname == 'y': self.setRange( 1, *value) elif lname == 'z': self.setRange( 2, *value) elif lname == 't': self.setRange( 3, *value) elif lname == 'xlo': self.setLo( 0, value) elif lname == 'xhi': self.setHi( 0, value) elif lname == 'ylo': self.setLo( 1, value) elif lname == 'yhi': self.setHi( 1, value) elif lname == 'zlo': self.setLo( 2, value) elif lname == 'zhi': self.setHi( 2, value) elif lname == 'tlo': self.setLo( 3, value) elif lname == 'thi': self.setHi( 3, value) elif lname == 'xmin': self.setMin( 0, value) elif lname == 'ymin': self.setMin( 1, value) elif lname == 'zmin': self.setMin( 2, value) elif lname == 'tmin': self.setMin( 3, value) elif lname == 'xmax': self.setMax( 0, value) elif lname == 'ymax': self.setMax( 1, value) elif lname == 'zmax': self.setMax( 2, value) elif lname == 'tmax': self.setMax( 3, value) elif lname == 'xlim': self.setLimits(0, *value) elif lname == 'ylim': self.setLimits(1, *value) elif lname == 'zlim': self.setLimits(2, *value) elif lname == 'tlim': self.setLimits(3, *value) else: self.__dict__[name] = value
[docs] class Bounds(List): """A property which represents numeric bounds in any number of dimensions, as long as that number is no more than 4. ``Bounds`` values are stored in a :class:`BoundsValueList`, a list of integer or floating point values, with two values (lo, hi) for each dimension. ``Bounds`` values may also have bounds of their own, i.e. minimium/maximum values that the bound values can take. These bound-limits are referred to as 'min' and 'max', and can be set via the :meth:`BoundsValueList.setMin` and :meth:`BoundsValueList.setMax` methods. The advantage to using these methods, instead of using, for example, :meth:`.PropertyValue.setAttribute`, is that if you use the latter you will have to set the attributes on both the low and the high values. """ def __init__(self, ndims=1, real=True, minDistance=None, clamped=True, minval=None, maxval=None, **kwargs): """Create a ``Bounds`` property. :arg ndims: Number of dimensions. This is (currently) not a property attribute, hence it cannot be changed. :arg real: If ``True`` (the default), the bound values are stored as :class:`Real` values; otherwise, they are stored as :class:`Int` values. :arg minDistance: Minimum distance to be maintained between the low/high values for each dimension. :arg clamped: If ``True`` (the default), the bound values are clamped to their limits. See the :class:`Number` class. :arg minval: Initial minimum value to use for all dimensions :arg maxval: Initial maximum value to use for all dimensions """ default = kwargs.get('default', None) if minDistance is None: minDistance = 0.0 if default is None: default = [0.0, minDistance] * ndims if minval is not None: default[0::2] = [minval] * ndims if maxval is not None: default[1::2] = [maxval] * ndims if ndims < 1 or ndims > 4: raise ValueError('Only bounds of one to four ' 'dimensions are supported') if len(default) != 2 * ndims: raise ValueError('{} bound values are required'.format(2 * ndims)) kwargs['default'] = default kwargs['minDistance'] = minDistance kwargs['minval'] = minval kwargs['maxval'] = maxval self._real = real self._ndims = ndims if real: listType = Real(clamped=clamped) else: listType = Int( clamped=clamped) List.__init__(self, listType=listType, minlen=ndims * 2, maxlen=ndims * 2, **kwargs) def _makePropVal(self, instance): """Overrides :meth:`.ListPropertyBase._makePropVal`. Creates and returns a ``BoundsValueList`` instead of a ``PropertyValueList``, so callers get to use the convenience methods/attributes defined in the BVL class. """ default = self.getAttribute(None, 'default', None) minval = self.getAttribute(None, 'minval', None) maxval = self.getAttribute(None, 'maxval', None) bvl = BoundsValueList( instance, name=self.getLabel(instance), values=default, itemCastFunc=self._listType.cast, itemEqualityFunc=self._listType._equalityFunc, itemValidateFunc=self._listType.validate, listValidateFunc=self.validate, listAttributes=self._defaultAttributes, itemAttributes=self._listType._defaultAttributes) for i in range(self._ndims): if minval is not None: bvl.setMin(i, minval) if maxval is not None: bvl.setMax(i, maxval) return bvl
[docs] def validate(self, instance, attributes, value): """Overrides :meth:`.PropertyBase.validate`. Raises a :exc:`ValueError` if the given value (a list of min/max values) is of the wrong length or data type, or if any of the min values are greater than the corresponding max value. """ minDistance = attributes['minDistance'] # the List.validate method will check # the value length and type for us List.validate(self, instance, attributes, value) for i in range(self._ndims): imin = value[i * 2] imax = value[i * 2 + 1] if imin > imax: raise ValueError('Minimum bound must be smaller ' 'than maximum bound (dimension {}, ' '{} - {}'.format(i, imin, imax)) if imax - imin < minDistance: raise ValueError('Minimum and maximum bounds must be at ' 'least {} apart'.format(minDistance))
[docs] class PointValueList(propvals.PropertyValueList): """A list of values which represent a point in some n-dimensional (up to 4) space. This class is used by the :class:`Point` property to encapsulate point values for between 1 and 4 dimensions. This class just adds some convenience methods and attributes to the :class:`.PropertyValueList` base class, in a similar manner to the :class:`BoundsValueList` class. The point values for each dimension may be queried/assigned via the dynamic attributes ``x, y, z, t``, which respectively map to dimensions ``0, 1, 2, 3``. When querying/assigning point values, you may use `GLSL-like swizzling <http://www.opengl.org/wiki/Data_Type_(GLSL)#Swizzling>`_. For example:: class MyObj(props.HasProperties): mypoint = props.Point(ndims=3) obj = MyObj() y,z = obj.mypoint.yz obj.mypoint.zxy = (3,6,1) """ def __init__(self, *args, **kwargs): """Create a ``PointValueList`` - see :meth:`.PropertyValueList.__init__`. """ propvals.PropertyValueList.__init__(self, *args, **kwargs)
[docs] def getPos(self, axis): """Return the point value for the specified (0-indexed) axis.""" return self[axis]
[docs] def setPos(self, axis, value): """Set the point value for the specified axis.""" self[axis] = value
[docs] def getMin(self, axis): """Get the minimum limit for the specified axis.""" return self.getPropertyValueList()[axis].getAttribute('minval')
[docs] def getMax(self, axis): """Get the maximum limit for the specified axis.""" return self.getPropertyValueList()[axis].getAttribute('maxval')
[docs] def getLimits(self, axis): """Get the (minimum, maximum) limits for the specified axis.""" return (self.getMin(axis), self.getMax(axis))
[docs] def setMin(self, axis, value): """Set the minimum limit for the specified axis.""" self.getPropertyValueList()[axis].setAttribute('minval', value)
[docs] def setMax(self, axis, value): """Set the maximum limit for the specified axis.""" self.getPropertyValueList()[axis].setAttribute('maxval', value)
[docs] def setLimits(self, axis, minval, maxval): """Set the minimum and maximum limits for the specified axis.""" self.setMin(axis, minval) self.setMax(axis, maxval)
def __getattr__(self, name): """Return the specified point value. Raises an :exc:`AttributeError` for unrecognised attributes, or an :exc:`IndexError` if a dimension which does not exist for this ``PointValueList`` is specified. """ lname = name.lower() if any([dim not in 'xyzt' for dim in lname]): raise AttributeError('{} has no attribute called {}'.format( self.__class__.__name__, name)) res = [] for dim in lname: if dim == 'x': res.append(self[0]) elif dim == 'y': res.append(self[1]) elif dim == 'z': res.append(self[2]) elif dim == 't': res.append(self[3]) if len(res) == 1: return res[0] return res def __setattr__(self, name, value): """Set the specified point value. Raises an :exc:`IndexError` if a dimension which does not exist for this ``PointValueList`` is specified. """ lname = name.lower() if any([dim not in 'xyzt' for dim in lname]): self.__dict__[name] = value return if len(lname) == 1: value = [value] if len(lname) != len(value): raise AttributeError('Improper number of values ' '({}) for attribute {}'.format( len(value), lname)) newvals = self[:] for dim, val in zip(lname, value): if dim == 'x': newvals[0] = val elif dim == 'y': newvals[1] = val elif dim == 'z': newvals[2] = val elif dim == 't': newvals[3] = val self[:] = newvals
[docs] class Point(List): """A property which represents a point in some n-dimensional (up to 4) space. ``Point`` property values are stored in a :class:`PointValueList`, a list of integer or floating point values, one for each dimension. """ def __init__(self, ndims=2, real=True, **kwargs): """Create a ``Point`` property. :param int ndims: Number of dimensions. :param bool real: If ``True`` the point values are stored as :class:`Real` values, otherwise they are stored as :class:`Int` values. """ default = kwargs.get('default', None) if default is None: default = [0] * ndims if real: default = [float(v) for v in default] if ndims < 1 or ndims > 4: raise ValueError('Only points of one to four ' 'dimensions are supported') elif len(default) != ndims: raise ValueError('{} point values are required'.format(ndims)) kwargs['default'] = default self._ndims = ndims self._real = real if real: listType = Real(clamped=True) else: listType = Int( clamped=True) List.__init__(self, listType=listType, minlen=ndims, maxlen=ndims, **kwargs) def _makePropVal(self, instance): """Overrides :meth:`.ListPropertyBase._makePropVal`. Creates and returns a ``PointValueList`` instead of a ``PropertyValueList``, so callers get to use the convenience methods/attributes defined in the PVL class. """ default = self.getAttribute(None, 'default', None) pvl = PointValueList( instance, name=self.getLabel(instance), values=default, itemCastFunc=self._listType.cast, itemEqualityFunc=self._listType._equalityFunc, itemValidateFunc=self._listType.validate, listValidateFunc=self.validate, listAttributes=self._defaultAttributes, itemAttributes=self._listType._defaultAttributes) return pvl
[docs] class ArrayProxy(propvals.PropertyValue): """A proxy class indended to encapsulate a ``numpy`` array. ``ArrayProxy`` instances are used by the :class:`Array` property type. An ``ArrayProxy`` is a :class:`.PropertyValue` which contains, and tries to act like, a ``numpy`` array. Element access andassignment, and attribute access can be performed through the ``ArrayProxy``. All element assignments which occur via an ``ArrayProxy`` instance will result in notification of all registered listeners (see the :meth:`.PropertyValue.addListener` method). A limitation of this implementation is that notification will occur even if the assignment does not change the array values. The underlying ``numpy`` array is accessible via the :meth:`getArray` method. However, changes made directly to the numpy array will bypass the :class:`.PropertyValue` notification procedure. """ def __init__(self, *args, **kwargs): """Create an ``ArrayProxy``. All arguments are passed through to the :meth:`.PropertyValue.__init__` method. """ def defaultEquals(this, other): if isinstance(this, ArrayProxy): this = this .getArray() if isinstance(other, ArrayProxy): other = other.getArray() return np.all(this == other) kwargs['equalityFunc'] = kwargs.get('equalityFunc', defaultEquals) propvals.PropertyValue.__init__(self, *args, **kwargs)
[docs] def get(self): """Overrides :meth:`.PropertyValue.get`. Returns this ``ArrayProxy``. """ if self.getArray() is None: return None else: return self
[docs] def getArray(self): """Returns a reference to the ``numpy`` array encapsulated by this ``ArrayProxy``. """ return propvals.PropertyValue.get(self)
def __getattr__(self, name): """Returns the attribute of the ``numpy`` array with the given name. """ return getattr(self.getArray(), name) def __getitem__(self, *args, **kwargs): """Calls the ``__getitem``__ method of the ``numpy`` array. """ return self.getArray().__getitem__(*args, **kwargs) def __setitem__(self, *args, **kwargs): """Calls the ``__setitem__`` method of the ``numpy`` array, and triggers notification of all registered listeners. """ array = self.getArray() array.__setitem__(*args, **kwargs) notifState = self.getNotificationState() self.disableNotification() self.set(array) self.setNotificationState(notifState) self.propNotify()
[docs] class Array(props.PropertyBase): """A property which represents a ``numpy`` array. Each array is encapsulated within an :class:`ArrayProxy` instance. """ def __init__(self, dtype=None, shape=None, resizable=True, nullable=True, **kwargs): """Create an ``Array`` property. :arg dtype: ``numpy`` data type. :arg shape: Initial shape of the array. :arg resizable: Defaults to ``True``. If ``False``, the array size will be fixed to the ``shape`` specified here. Different sized arrays will still be allowed, if the ``allowInvalid`` parameter to :meth:`.PropertyBase.__init__` is set to ``True``. :arg nullable: Defaults to ``True``. Allow the property to be set to ``None``. """ if dtype is None: dtype = np.float64 if shape is None: shape = (4, 4) kwargs['dtype'] = dtype kwargs['shape'] = shape kwargs['resizable'] = resizable kwargs['nullable'] = nullable kwargs['default'] = kwargs.get('default', np.zeros(shape, dtype)) props.PropertyBase.__init__(self, **kwargs) def _makePropVal(self, instance): """Overrides :meth`.PropertyBase._makePropVal`. Creates and returns an :class:`.ArrayProxy` for the given ``instance``. """ default = self.getAttribute(None, 'default', None) ap = ArrayProxy( instance, name=self.getLabel(instance), value=default, castFunc=self.cast, validateFunc=self.validate, allowInvalid=self._allowInvalid, **self._defaultAttributes) return ap
[docs] def cast(self, instance, attributes, value): """Overrides :meth`.PropertyBase.cast`. Casts the given value to a ``numpy`` array (with the data type that was specified in :meth:`__init__`). """ dtype = attributes['dtype'] nullable = attributes['nullable'] if nullable and (value is None): return None return np.array(value, dtype=dtype)
[docs] def validate(self, instance, attributes, value): """Overrides :meth`.PropertyBase.validate`. If the given ``value`` has the wrong data type, or this ``Array`` is not resizable and the ``value`` has the wrong shape, a :exc:`ValueError` is raised. """ props.PropertyBase.validate(self, instance, attributes, value) dtype = attributes['dtype'] shape = attributes['shape'] nullable = attributes['nullable'] resizable = attributes['resizable'] if nullable and (value is None): return if value.dtype != dtype: raise ValueError('Invalid data type: {} (should be {})'.format( value.dtype, dtype)) if (not resizable) and (value.shape != shape): raise ValueError('Invalid shape: {} (should be {})'.format( value.shape, shape))