Defining new Types with Properties

PCells and views typically have one or more properties that define their content. However, properties can be used for type-safe parameterization of any user-defined type (Python class), by subclassing this type from StrongPropertyInitializer (which means as much as an object with strong checking and validation on its properties).

StrongPropertyInitializer

Base class for any class that can use properties.

An example (of a user defined type not related to IC design):

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]))
    quantity = PositiveNumberProperty()

    def __str__(self):
        return "Order for {0} pieces of {1}".format(self.quantity, self.fruit_type)

Objects of this type can now be simply created using keyword arguments:

>>> order = FruitOrder(fruit_type="apple", quantity=200)
>>> print(order)
'Order for 200 pieces of apple'

The advantages of using properties are

  • Strong checking: it is possible to add type and value checking to individual properties, but also add validation for a combination of properties. So it is possible to restrict the type of the values, or the range of allowed values.

  • Simple to write: with only one line of code you can specify a new property.

  • Documentation: all properties are automatically included in the class documentation with a description of their restrictions, and optional user documentation.

  • Initialization: It is not needed to write an __init__ method in your class purely for the initialization of properties. Objects can be created using keyword arguments for the properties and these will be automatically assigned.

  • Complex default behavior: If is possible to define default values to properties, so they are not required, and even calculate default values dynamically, based on the values of other properties.

  • Access control: Properties can be read-only. This is useful when you incorporate more intelligence in classes and you want to calculate values automatically.

Most properties are of the type i3.DefinitionProperty. In this section we will only discuss the use of such properties. Other types of properties, such as i3.FunctionProperty and i3.FunctionNameProperty are similar to the built-in python property, but with the possibility to add restrictions. Here we will only discuss the use of DefinitionProperties.

An overview of all predefined DefinitionProperties and restrictions is provided in the :ref:’Properties Reference <properties-reference>’. A quick-start for working with properties is provided in the Properties Guide.

Adding Restrictions

By adding a restriction to a DefinitionProperty, a new value will be checked before it is assigned. This can be type checking (e.g. string, numbers, or specific user-defined types) or limiting the value to a specific range of list of values.

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]))
    quantity = PositiveNumberProperty()

    def __str__(self):
        return "Order for {0} pieces of {1}".format(self.quantity, self.fruit_type)

The restriction of the property fruit_type limits it to one of the three values "apple", "pear" and "cherry". When another value is assigned, an error will be thrown. RestrictValueList is just one of the many predefined restrictions available in Ipkiss. The complete list is available in the Properties Reference.

If the restriction is commonly used, it makes sense to define it into a variable or constant:

RESTRICT_FRUIT = RestrictValueList(allowed_values=["apple", "pear", "cherry"])

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RESTRICT_FRUIT)
    quantity = PositiveNumberProperty()

    def __str__(self):
        return "Order for {0} pieces of {1}".format(self.quantity, self.fruit_type)

Also, many predefined properties already have a restriction built-in. For example, i3.NumberProperty limits the type of the value to a number and the allowed range to numbers larger than 0.

Combining restrictions

For more complex restrictions, it is often needed to combine two or more simple rules. This can be done intuitively using the python bitwise boolean operators & (and), | (or) and ~ (not).

  • AND: The operator & combines two restrictions into a new restrictions where the condition of both have to be met.

    RESTRICT_POSITIVE_NUMBER = RESTRICT_NUMBER & RESTRICT_POSITIVE
    
  • OR: The operator | combines two restrictions into a new restrictions where the condition of either have to be met.

    RESTRICT_NUMBER = RestrictType(int) | RestrictType(float)
    
  • NOT: The operator ~ creates a new restriction for objects where the condition of the original restriction is not met:

    RESTRICT_NONNEGATIVE = ~RESTRICT_NEGATIVE
    

Adding restrictions over multiple properties

There might be situations where the restrictions of two properties depend on one another. In that case, it is not possible to specify the restriction in the property itself, but it has to be included in the main class. For instance, consider the situation of a fruit order where the maximum weight should not be exceeded:

fruit_weights = {"apple": 5,
                 "pear": 4,
                 "cherry": 1}

max_weight = 15

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]))
    quantity = PositiveNumberProperty()

    def validate_properties(self):
        f = fruit_weights[self.fruit_type] # weight of individual piece
        total_weight = f * self.quantity
        if  total_weight > max_weight: # total weight
            raise PropertyValidationError("Maximum weight exceeded: {}".format(total_weight),
                                          error_var_values={"fruit_type": self.fruit_type, "quantity": self.quantity})
        return True

    def __str__(self):
        return "Order for {0} pieces of {1}".format(self.quantity, self.fruit_type)

In this example, we raise an error when the maximum weight has been exceeded, and we list the properties and their values which are responsible for this.

Defining Default Values

In many cases, it is possible to define a default value for the property. That way, the property is no longer required, i.e. the user does not need to specify the value himself when creating the object.

Adding a default value can be done in different ways.

  • Specifying it directly as an argument to the DefinitionProperty.

  • Calculating it dynamically using a _default_xyz method.

In both cases, the default value is ignored if the value of the property is manually set.

Specify the default value directly

The simplest way is to specify the default value when the property is being defined in the class:

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                default="apple")
    quantity = PositiveNumberProperty(default=4)

    def __str__(self):
        return "Order for {0} pieces of {1}".format(self.quantity, self.fruit_type)

 order1 = FruitOrder()                     # 4 apples
 order2 = FruitOrder(quantity=10)          # 10 apples
 order3 = FruitOrder(fruit_type="pear")    # 4 pears

Note that the default value should meet the restrictions imposed on the property. The following will not work

quantity = PositiveNumberProperty(default=0)

because 0 is not a positive number.

Calculate the default value dynamically

In many cases, the default value of one property depends on the value of one or more other properties, and a calculation has to be performed. This can be done by adding a _default_xyz method to the class, with xyz being the name of the property.

For instance, say that we want to calculate the default quantity of fruit dynamically so it remains within the maximum allowed weight.

fruit_weights = {"apple": 5,
                 "pear": 4,
                 "cherry": 1}

max_weight = 15

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]))
    quantity = PositiveNumberProperty()

    def _default_quantity(self):
        w = fruit_weights[self.fruit_type] # weight of a single piece
        n = floor(max_weight/w)
        return max((n, 1))

Again, the calculated value should match the restriction, otherwise an error will be thrown.

The name of the default method should always be _default_ followed by the name of the property (case sensitive). If for some reason this name cannot be used, it is possible to specify another name using the fdef_name parameter:

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]))
    quantity = PositiveNumberProperty(fdef_name="calculate_quantity")

    def calculate_quantity(self):
        w = fruit_weights[self.fruit_type] # weight of a single piece
        n = floor(max_weight/w)
        return max((n, 1))

Note

Note that a _default_xyz method takes precedence over a default value specified as an argument.

Overriding defaults

When a default value is specified in a parent class, but you want to change the default value in the child class, it is possible to override the default:

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                default="apple")
    quantity = PositiveNumberProperty(default=4)

class AppleOrder(FruitOrder):
    quantity = PositiveNumberProperty(default=6)

    def _default_fruit_type(self):
        return "apple"

In the class AppleOrder the default for quantity is overruled from 4 to 6 by redefining the property.

The default for fruit_type is also overruled, but instead of redefining the property we simply added a method _default_fruit_type that returns the new default value. The method takes precedence over the value specified in the DefinitionProperty statement.

Resetting defaults

When you manually assign a value to a property, its default values are ignored, whether they are specified in the property definition or through a _default_xyz method. However, you can reset the property to its default value:

>>> order1 = FruitOrder()  # 4 apples
>>> order1.fruit_type  # default value
"apple"
>>> order1.quantity # the default value is calculated
3
>>> order1.fruit_type = "cherry"  #manual override
>>> order1.quantity # default value is recalculated
15
>>> order1.quantity = 8 # manual override
>>> order1.reset("fruit_type")  # resets 'fruit_type' to default ("apple")
>>> order1.fruit_type
"apple"
>>> order1.quantity   # still manually set
8
>>> order1.reset() # reset all properties to default
>>> order1.quantity
3

The reset method does not affect properties that do not have a default value.

Allowing None as a valid value

In some cases, it is useful that the user can indicate that he does not want to set a value to a property. In that case, he can use the Python built-in None as a ‘sentinel’.

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                allow_none=True)
    quantity = PositiveNumberProperty(default=4)

    def __str__(self):
        if self.fruit_type is None:
            return "I don't like fruit"
        else:
            return "Order for {0} pieces of {1}".format(self.quantity, self.fruit_type)

Be careful with this option: if other properties depend on this property (e.g. to perform a calculation), it should always be checked whether the value is None.

The fact that None does not meet the restriction imposed by RestrictValueList is not a problem. The allow_none argument overrules that restriction. Also, by using allow_none, the default value for this property becomes None, equivalent to specifying default=None in the same statement.

Caching

When _default_xyz methods are used, the values are calculated on the fly. This also means that the values are updated when other properties have changed.

For efficiency reasons, the calculated values are cached, and they are only recalculated when for some reason the state of the object has changed. This means that the _default_xyz method will not be recalculated every time the value of the property is looked up.

So with the class above:

>>> order1 = FruitOrder()  # 4 apples
>>> order1.fruit_type
"apple"
>>> order1.quantity # the value is calculated for the first time and cached
3
>>> order1.quantity # the value is not recalculated but taken from the cache
3
>>> order1.fruit_type = "cherry"  #the cache is now cleared
>>> order1.quantity # the value is recalculated and stored in the cache
15

This is important to know. You should not use _default_xyz methods to perform actions on the objects, but only to calculate the values of the properties. You cannot predict when these methods will be actually executed.

Switching off cache invalidation

In some rare cases, you only want to evaluate the _default_xyz method only once. This can occur when you want to assign a unique name or identifier and you don’t want that value to change unless the user manually overrides it.

In that case, you should switch off the cache invalidation, because otherwise the value will be recalculated every time another property is changed. This can be done with the cache_invalidation parameter:

class FruitOrder(StrongPropertyInitializer):
    order_id = PositiveIntProperty(cache_invalidation=False)
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                default="apple")
    quantity = PositiveNumberProperty(default=4)

    def _default_order_id(self):
        return random.randint(1, 1000000)

Now the property order_id will not change anymore, even when fruit_type or quantity is changed or reset by the user.

Locking Properties

Sometimes the value of (automatically calculated) properties should not be changed by the user. This often happens when subclassing a ‘smarter’ version of a more generic class. Making properties read-only can be done in 3 ways:

  • Using the locked keyword when defining the property.

  • Using LockedProperty while subclassing.

  • Using the @lock_properties() decorator.

The locked parameter

When defining a property that should be read-only, the argument locked can be used. For instance, if we want the quantity of a fruit order to be automatically calculated but not overriden by the user, we can use:

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]))
    quantity = PositiveNumberProperty(locked=True)

    def _default_quantity(self):
        w = fruit_weights[self.fruit_type] # weight of a single piece
        n = floor(max_weight/w)
        return max((n, 1))

Properties that are locked should have a default value, either specified as a keyword or with a _default_xyz method.

LockedProperty

A common case for making properties read only is when creating a smarter subclass of an existing class. In such a case, we don’t want to redefine the properties manually, because these might have elaborate restrictions and preprocessors, and it is bad practice to duplicate this code.

LockedProperty will override a property from a parent class, including all its restrictions, but make it read-only:

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                default="apple")
    quantity = PositiveNumberProperty(default=4)

class CherryOrder(FruitOrder):
    fruit_type = LockedProperty()

    def _default_fruit_type(self):
        return "cherry"

In CherryOrder we made the fruit_type read-only, and used _default_fruit_type to change the default.

The @lock_properties() decorator

lock_properties

Makes all properties of a StrongPropertyInitializer subclass read-only.

There are also use cases where all the properties of a class need to be locked, and it is not always known in advance what all those properties are (e.g. when subclassing a third-party class that could be changed later on by the third party).

The @lock_properties() decorator makes all properties of a class read-only:

class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                default="apple")
    quantity = PositiveNumberProperty(default=4)

@lock_properties()
class SixAppleOrder(FruitOrder):
    quantity = PositiveNumberProperty(default=6)

    def _default_fruit_type(self):
        return "apple"

Preprocessors

Preprocessors can convert or modify a value of a property before it is assigned. This could be type casting, or limiting a value to a certain range.

Preprocessors are added to a property using the preprocess parameter.

class ProcessorLowerCase(PropertyProcessor):
  def process(self, value, obj=None):
      if isinstance(value, str):
          return str.lower(value)
      else:
          return value


class FruitOrder(StrongPropertyInitializer):
    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                preprocess=ProcessorLowerCase,
                                default="apple")
    quantity = PositiveNumberProperty(default=4)

Our new preprocessor will now convert our assigned value to lower case, but only if it is a string:

>>> order1 = FruitOrder()  # 4 apples
>>> order1.fruit_type  # default value
"apple"
>>> order1.fruit_type = "PEAR"
>>> order1.fruit_type
"pear"

A list of available predefined preprocessors can be found in the Properties Reference. These include type casing, rounding, and range limiting.

Concatenating Preprocessors

Preprocessors can be concatenated: For instance, a first preprocessor could convert a value to a string, and the second could then convert it to lower case. For this, we use the operator +:

LowerCaseStringPreprocessor = ProcessorTypeCast(str) + ProcessorLowerCase()

Documenting Properties

All properties are automatically included in the documentation of the class, including the information about their restrictions.

In case the name of the property is nor sufficiently clear (e.g. which units are used) then it is good practice to add a documentation string to the class and the property:

class FruitOrder(StrongPropertyInitializer):
    """ An order for fruit. """

    fruit_type = StringProperty(restriction=RestrictValueList(allowed_values=["apple", "pear", "cherry"]),
                                default="apple")
    quantity = PositiveNumberProperty(default=4,
                                      doc="How many pieces of fruit?")