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).
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
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?")