Object
The object.py module provides two main classes, BaseObject and MetaClass, while the mapping module provides BaseMapping. Together they take care of the consistency of objects throughout the entire code and are used to define attributes, private methods, and dunder methods.
Getting Started¶
from everysk.core.object import BaseObject
from everysk.core.fields import StrField
class MyClass(BaseObject):
name = StrField(default='test', readonly=True)
test = MyClass()
If readonly is set to True, the value cannot be changed.
Let's take a look at the main classes and its methods that are available in the object.py module.
BaseObject¶
Let's take a deeper look into the features of BaseObject class and how it can be used to create and manage objects in Python.
Silent Initialization¶
One of the features of the BaseObject class is the ability to initialize a given object without raising any exceptions.
The silent attribute is used to suppress any exceptions that may occur during the initialization of an object. When the silent attribute is set to True, the object will not raise any exceptions during the initialization process, instead it will store the exception in the _errors dictionary.
Imagine a scenario where you want to accept a given number as input, but you don't want to break the initialization in the case that the number is greater than 10. Let's see how to achieve this with the following code:
from everysk.core.object import BaseObject
class MyObject(BaseObject):
def __init__(self, number: int) -> None:
if number > 10:
raise ValueError('Number cannot be greater than 10.')
self.number = number
obj = MyObject(number=11, silent=True)
obj._errors
{'before_init': None, 'init': ValueError('Number cannot be greater than 10.'), 'after_init': None}
We start by creating a hypothetical class called MyObject which inherits from the BaseObject class. The MyObject class takes a number as input and raises a ValueError if the number is greater than 10.
We then create an instance of the MyObject class with the number attribute set to 11 and the silent attribute set to True.
Before Init¶
The __before_init__ method is called, if implemented, before the actual initialization of the class. It might be useful to perform a specific business logic or any other operation. Let's take a look to see how this works in practice.
from everysk.core.object import BaseObject
class MyCustomClass(BaseObject):
@classmethod
def __before_init__(self, **kwargs: dict) -> dict:
if 'value' in kwargs:
raise ValueError('value')
return kwargs
obj = MyCustomClass(value='Hello, World!')
ValueError: value
In the example above we are defining our custom class and implementing the __before_init__ method, note that the method must be a classmethod since the object is not yet initialized. Then we check if a value key is present in the kwargs dictionary, if it is we raise a ValueError.
As mentioned before, we also have the option to set the silent flag to True in order to prevent the exception from being raised.
obj = MyCustomClass(value='Hello, World!', silent=True)
obj._errors
{'before_init': ValueError('value'), 'init': None, 'after_init': None}
This way any exception that occurs during the __before_init__ stage will be stored in the _errors dictionary.
After Init¶
Following our thought process we arrive at the __after_init__ stage, which, as the name suggests, occurs after the initialization of our object. The __after_init__ method might come in handy when we need to perform a post-processing of attributes or a lazy initialization.
Let's see how this would be translated to code by implementing the following logic:
from everysk.core.object import BaseObject
class MyCustomClass(BaseObject):
def __init__(self, is_file_ready: bool) -> None:
self.is_file_ready = is_file_ready
def __after_init__(self) -> None:
if not self.is_file_ready:
raise ValueError('File is not ready yet.')
obj = MyCustomClass(is_file_ready=False)
ValueError: File is not ready yet.
In our example above we define our custom __after_init__ method which checks if the is_file_ready attribute is set to False, if it is we raise a ValueError. In our hypothetical scenario our file might not be ready yet, in that case we only perform our logic after the object is fully initialized.
Again, we have our disposal the silent flag, allowing us to quietly raise our exceptions without breaking the after initialization process in this case:
obj = MyCustomClass(is_file_ready=False, silent=True)
obj._errors
{'before_init': None, 'init': None, 'after_init': ValueError('File is not ready yet.')}
Convert to Dict¶
One of the most important features of the BaseObject class is the ability to convert an object into a dictionary. This can be achieved by using the to_dict() method. This method takes all the instance and class attributes available and adds them to a dictionary.
from everysk.core.object import BaseObject
class MyObject(BaseObject):
def __init__(self, value: str) -> None:
self.value = value
obj = MyObject(value='Hello, World!')
obj.to_dict()
{'value': 'Hello, World!'}
The to_dict() method also take two optional inputs. Let's take a look at each of them starting with the add_class_path flag, which is designed to add the path of the current working class.
class MyObject(BaseObject):
required: bool = False
readonly: bool = False
def __init__(self, value: str) -> None:
self.value = value
obj = MyObject(value='Hello, World!')
obj.to_dict(add_class_path=True)
{
'value': 'Hello, World!',
'required': False,
'_is_frozen': False,
'_silent': False,
'readonly': False,
'_errors': None,
'__class_path__': '__main__.MyObject'
}
As you can see in the implementation above, when the add_class_path is set to True, all the other class attributes and private attributes are also added to the dictionary.
Now let's take a look at how the recursion flag works, which, as the name suggests, will also convert any nested objects into dictionaries.
class MyObject(BaseObject):
def __init__(self, value: str, data: dict) -> None:
self.value = value
self.data = data
obj = MyObject(value='Hello, World!', data=BaseObject(key='value'))
obj.to_dict(recursion=True)
{'value': 'Hello, World!', 'data': {'key': 'value'}}
To illustrate, below is an example of how the output would look like if recursion was set to False.
obj.to_dict(recursion=False)
{'value': 'Hello, World!', 'data': <everysk.core.object.BaseObject object at 0x7f8b3c1b3d90>}
Frozen Instances¶
The BaseObject class provides a powerful way of working with objects by creating Frozen Instances, which are objects that cannot be modified after their creation.
Let's create a custom class and see how frozen instances work in practice.
from everysk.core.object import BaseObject
class MyFrozenObject(BaseObject):
class Config:
frozen: bool = True
obj = MyFrozenObject(attr1=123, attr2='abc')
If we check our __dict__ attribute we see our _is_frozen key set to True.
By creating a class called Config and setting the frozen attribute to True, we are able to create a frozen instance of the MyFrozenObject class. Now watch what happens when we run some operation on the attributes:
Same thing happens when we try to delete an attribute:
Exclude Keys¶
When working with Python objects, there are times when we might want to exclude or prevent certain keys from being used the BaseObjectConfig provides an useful frozenset attribute called exclude_keys. The attribute prevents any key that is included in the exclude_keys from being used in the object. Let's take a look at an example in practice.
from everysk.core.object import BaseObjectConfig, BaseObject
class MyObject(BaseObject):
class Config(BaseObjectConfig):
exclude_keys = frozenset(['value'])
value: str = 'abc'
attr: str = '123'
obj = MyObject()
obj.to_dict(add_class_path=True)
{
'_silent': False,
'_errors': None,
'_is_frozen': False,
'attr': '123',
'__class_path__': '__main__.MyObject'
}
To elaborate in the example above we start by defining our class which inherits from the BaseObject class. We then create a nested class called Config which inherits from the BaseObjectConfig class. Then we defined a frozenset attribute called exclude_keys with value as the key to be excluded.
Finally when we instantiate the object and convert it to a dictionary, we are able to notice that the value key is not present in the dictionary.
Using Pickle¶
The BaseObject class also provides a way of working with pickle serialization by modifying the standard pickle behavior using the __getstate__ and __setstate__ methods under the hood.
This way the pickle module is able to correctly serialize the data and also set the object back to its original state.
import pickle
from everysk.core.object import BaseObject
obj = BaseObject(public={'key': 'value'})
obj.public
{"key": "value"}
pickled_obj = pickle.dumps(obj)
pickled_obj
b'\x80\x04\x95\x1b\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x06public\x94}\x94(\x8c\x03key\x94\x8c\x05value\x94u.'
new_obj = pickle.loads(pickled_obj)
new_obj.public
{"key": "value"}
BaseMapping¶
BaseMapping lives in everysk.core.mapping and behaves like a regular Python dictionary, with one key addition: attribute access and key access are always kept in sync. Setting obj.key = value is equivalent to setting obj['key'] = value, and both representations always reflect the same value.
from everysk.core.mapping import BaseMapping
class MyMapping(BaseMapping):
name: str = None
age: int = None
obj = MyMapping(name='Alice', age=30)
obj['name'] # 'Alice'
obj.name # 'Alice'
obj['age'] # 30
obj.age # 30
Updates through either interface are reflected in both:
Type Validation¶
BaseMapping supports optional type validation. Set __validate_fields__ = True on the class to enable it. Assigning a value of the wrong type to an annotated field then raises a TypeError.
from everysk.core.mapping import BaseMapping
class MyMapping(BaseMapping):
__validate_fields__ = True
score: int = None
obj = MyMapping()
obj['score'] = 'not-a-number'
TypeError: Field score must be of type int, got str.
Invalid Keys¶
BaseMapping provides an __invalid_keys__ frozenset that marks specific key names as invalid. Use the _is_valid_key() method to check whether a key is permitted before operating on it.
from everysk.core.mapping import BaseMapping
class MyMapping(BaseMapping):
__invalid_keys__ = frozenset(['forbidden'])
obj = MyMapping()
obj._is_valid_key('forbidden') # False
obj._is_valid_key('allowed') # True
Dictionary Operations¶
BaseMapping supports the full set of standard dictionary operations.
update() — merges key/value pairs into the object:
from everysk.core.mapping import BaseMapping
obj = BaseMapping(a=1, b=2)
obj.update({'b': 20, 'c': 30})
obj
{'a': 1, 'b': 20, 'c': 30}
get() — returns the value for a key, or a default if the key is absent:
pop() — removes a key and returns its value:
items(), keys(), values() — standard dictionary view methods:
list(obj.keys()) # ['a', 'b']
list(obj.values()) # [1, 20]
list(obj.items()) # [('a', 1), ('b', 20)]
clear() — removes all keys from the object:
Merge Operators¶
BaseMapping supports the | and |= operators for merging with other mappings or plain dictionaries.
The | operator returns a new merged plain dictionary:
from everysk.core.mapping import BaseMapping
a = BaseMapping(x=1, y=2)
b = BaseMapping(y=20, z=30)
c = a | b
c
{'x': 1, 'y': 20, 'z': 30}
It also works with plain dicts:
The |= operator merges in place, keeping both attribute and key access in sync:
Iterating and Membership¶
BaseMapping supports the in operator, len(), and for iteration over its keys:
from everysk.core.mapping import BaseMapping
obj = BaseMapping(x=1, y=2, z=3)
'x' in obj # True
'w' in obj # False
len(obj) # 3
for key in obj:
print(key, obj[key])
# x 1
# y 2
# z 3
fromkeys¶
fromkeys() is the standard dict classmethod inherited by BaseMapping. It creates a new BaseMapping instance with all provided keys set to a given value (defaulting to None).
from everysk.core.mapping import BaseMapping
obj = BaseMapping(a=1, b=2, c=3)
subset = BaseMapping.fromkeys(['a', 'c'])
subset
{'a': None, 'c': None}
To use a custom default value, pass it as the second argument: