Object-oriented framework for building smart applications and APIs
Project description
Objetto is an opinionated object-oriented framework for building modular applications and APIs.
Overview
Objetto allows for the creation of an Application that consists of high-level mutable structures referred to as Objects.
Objects are associated with an Application when initialized.
Objects have their schema defined by Attributes.
Objects encase an immutable subset version of themselves referred to as Data.
Objects will send an Action to themselves, their parent, and grandparents everytime a Change happens.
Objects can perform Reactions in response to Actions received from themselves, their children, and grandchildren.
Objects can be observed by external Observers such as GUI widgets.
Objects feature built-in human-readable Serialization and Deserialization capabilities.
Objects can be automatically tracked by a History, which allows for easy and selective undo/redo functionality.
How to install
You can install Objetto by using pip:
pip install objetto
Application
An Application oversees all Objects that are meant to work together. It provides different contexts for managing and keeping track of their Changes.
Objects that are part of different Applications see each other as regular values and can never be part of the same Hierarchy.
An Application can have Root Objects, which are Objects that are always available at the top of the hierarchy, and cannot be parented under other Objects.
Example: Instantiate a new Application.
>>> from objetto import Application
>>> app = Application() # instantiate a new application
Read Context
While in a Read Context, all Objects in the Application are guaranteed not to be modified.
Example: Enter a Read Context.
>>> from objetto import Application
>>> app = Application()
>>> with app.read_context():
... pass # read access only, no changes allowed
...
Write Context
While in a Write Context, Actions are only sent internally until the outermost Write Context exits without errors, after which external Observers will then receive them.
If an unhandled exception gets raised, all changes are reverted to the moment the context was entered, and external Observers will not receive any Actions. This behavior is similar to transactions in a database.
Example: Enter a Write Context.
>>> from objetto import Application
>>> app = Application()
>>> with app.write_context():
... pass # send actions to external observers only at the end, revert if errors
...
Roots
Root Objects can be declared when creating a subclass of an Application by using a root descriptor and specifying the Object type and initialization arguments.
Example: Define Root Objects when subclassing Application.
>>> from objetto import Application, Object, attribute, root
>>> class Document(Object):
... title = attribute(str)
...
>>> class CustomApplication(Application): # inherit from Application
... document = root(Document, title="untitled") # specify object type and args
...
>>> app = CustomApplication()
>>> type(app.document).__name__
'Document'
Object
Objects are the building blocks of an Application. An Object is mutable, has state, and can be a parent/child of another Object.
To define our own Object, we have to inherit from objetto.Object and use Attributes to define its schema. You need to instantiate it by passing an Application, which can later be accessed through the .app property:
Example: Make our own Object subclass and instantiate it.
>>> from objetto import Application, Object, attribute
>>> class Hobby(Object): # inherit from Object
... description = attribute(str) # example attribute called 'description'
...
>>> app = Application() # we need an application
>>> hobby = Hobby(app, description="biking") # instantiate our object
>>> hobby.app is app
True
Attribute
Attributes describe the schema of an Object. When defining one, we can specify relationship parameters between the Object that owns it and the value being stored, such as a Value Type, Hierarchy settings, History propagation, Serialization and Deserialization options, etc.
Example: Define custom Objects with multiple Attributes.
>>> from objetto import Application, Object, attribute
>>> class Hobby(Object):
... description = attribute(str) # specify value type, only takes strings
...
>>> class Person(Object):
... name = attribute(str, default="Phil") # specify a default value
... hobby = attribute(Hobby) # specify value type, only takes 'Hobby' objects
... busy = attribute(bool, serialized=False, default=False) # not serialized
...
>>> app = Application()
>>> hobby = Hobby(app, description="biking")
>>> person = Person(app, hobby=hobby)
>>> person.name
'Phil'
>>> person.name = "Gaimon"
>>> person.name
'Gaimon'
Value Type
When defining an Attribute, we can specify its Value Type. This is leveraged by the runtime type checking and by static ones such as mypy.
Defining types is also helpful to inform Objetto about the schema of our Objects, which is needed for proper Serialization and Deserialization.
Import strings are also valid (using the syntax module.submodule|Class.NestedClass), and they will be imported lazily during runtime. It’s also possible to use multiple Types by specifying them in a tuple.
The types are interpreted ‘exactly’ by default. This means they are checked and compared by identity, so instances of subclasses are not accepted. However that behavior can be changed by specifying subtypes=True when defining an Attribute.
If None is also accepted as a value, we can specify None as a valid type.
Example: Define the Value Types of Attributes.
>>> from objetto import Object, attribute
>>> class Person(Object):
... name = attribute(str) # single exact value type
... friend = attribute(("__main__|Person", None)) # import path, accepts None
... hobby = attribute("module.hobby|Hobby") # import path with module path
... points = attribute((int, float)) # multiple basic types
... _status = attribute(serialized=False) # no value type, not serialized
... _pet = attribute(
... "pets|AbstractPet", subtypes=True
... ) # accepts instances of 'AbstractPet' subclasses
Value Factory
An Attribute can conform and/or verify new values by using a Value Factory, which is simply a function or callable that takes the newly input value, does something to it, and then return the actual value that gets stored in the Object.
You can use simple functions or callable types as Value Factories, but Objetto offers some very useful pre-defined ones that can be easily configured with parameters.
Here are some of those built-in Value Factories, which can be imported from objetto.factories:
Integer
FloatingPoint
RegexMatch
RegexSub
String
Curated
Boolean
Example: Use Value Factories to conform/verify attribute values.
>>> from objetto import Object, attribute
>>> from objetto.factories import RegexMatch, Integer, Curated, String, Boolean
>>> class Person(Object):
... name = attribute(str, factory=RegexMatch(r"^[a-z ,.'-]+$")) # regex match
... age = attribute(int, factory=Integer(minimum=1)) # minimum integer
... pet = attribute(str, factory=Curated(("cat", "dog"))) # curated values
... job = attribute(str, factory=String()) # force string
... happy = attribute(bool, factory=Boolean(), default=True) # force boolean
Auxiliary Attribute
These are special Attributes that will hold multiple values instead of just one.
The most basic Auxiliary Attributes are:
list_attribute
dict_attribute
set_attribute
Example: Use Auxiliary Attributes to hold values.
>>> from objetto import Application, Object, attribute, list_attribute
>>> class Hobby(Object):
... description = attribute(str)
...
>>> class Person(Object):
... hobbies = list_attribute(Hobby) # holds multiple 'hobbies'
...
>>> app = Application()
>>> hobby_a = Hobby(app, description="biking")
>>> hobby_b = Hobby(app, description="gaming")
>>> person = Person(app, hobbies=(hobby_a, hobby_b)) # initialize with iterable
>>> person.hobbies[0] is hobby_a
True
Delegated Attribute
Attributes can have delegate methods that will get, set and/or delete the values of other Attributes in the same Object.
When defining delegates, you have to specify which Attributes they will read from as dependencies.
Example: Define a Delegated Attribute with a getter and a setter.
>>> from objetto import Application, Object, attribute
>>> class Person(Object):
... first_name = attribute(str)
... last_name = attribute(str)
... name = attribute(
... str, delegated=True, dependencies=(first_name, last_name)
... ) # delegated attribute with read dependencies
...
... @name.getter # define a getter delegate
... def name(self):
... return self.first_name + " " + self.last_name
...
... @name.setter # define a setter delegate
... def name(self, value):
... self.first_name, self.last_name = value.split()
...
>>> app = Application()
>>> person = Person(app, first_name="Katherine", last_name="Johnson")
>>> person.name
'Katherine Johnson'
>>> person.name = "Grace Hopper"
>>> person.name
'Grace Hopper'
>>> person.first_name
'Grace'
>>> person.last_name
'Hopper'
Attribute Helper
There are patterns that come up very often when defining Attributes. Instead of re-writing those patterns everytime, it’s possible to use helper functions known as Attribute Helpers to get the same effect.
Here are some examples of Attribute Helpers:
constant_attribute
protected_attribute_pair
protected_list_attribute_pair
protected_dict_attribute_pair
protected_set_attribute_pair
Example: Define a simple Attribute Helper.
>>> from objetto import Application, Object, protected_attribute_pair
>>> class Person(Object):
... _name, name = protected_attribute_pair(str, default="King") # helper
...
... def set_name(self, name):
... self._name = name.upper() # set the changeable private attribute
...
>>> app = Application()
>>> person = Person(app)
>>> person.name
'King'
>>> person.name = "bb king" # can't set non-changeable public attribute
Traceback (most recent call last):
AttributeError: attribute 'name' is read-only
>>> person.set_name("bb king") # we have to use the method instead
>>> person.name
'BB KING'
Hierarchy
An Object can have one parent and/or multiple children.
The parent-children hierarchy is central to the way Objetto works, as it provides an elegant way to structure our Application. It’s essential for features like:
Preventing cyclic references: Objects can only have one parent
Immutable Data ‘mirroring’: The Data structure will replace child Objects with their Data according to the hierarchy
Human-readable Serialization: The .serialize() and .deserialize() methods utilize the hierarchy to format their input/output
Action sending and subsequent Reactionresponse: Actions will propagate from where the Change happened all the way up the hierarchy to the topmost grandparent, triggering Reactions along the way
Automatic History propagation: Children can automatically be assigned to the same History of the parent if desired.
Example: Access ._parent and ._children properties.
>>> from objetto import Application, Object, attribute
>>> class Hobby(Object):
... description = attribute(str)
...
>>> class Person(Object):
... name = attribute(str)
... hobby = attribute(Hobby) # child=True is the default behavior
...
>>> app = Application()
>>> hobby = Hobby(app, description="animation")
>>> person = Person(app, name="Hayao Miyazaki", hobby=hobby)
>>> hobby._parent is person # 'person' is the parent of 'hobby'
True
>>> hobby in person._children # 'hobby' is a child of 'person'
True
Undo/Redo History
Objetto has built-in support for a undo/redo History. It takes care of managing its validity for internal changes by flushing itself automatically when necessary, and it is extremely easy to implement.
A history can be associated with an Object by adding a history_descriptor to the class definition. Accessing that attribute from an Object’s instance will give us the history itself.
A history will be propagated to children/grandchildren of the Object which defines it, however it’s possible to prevent that behavior by specifying history=False when we define an Attribute.
Undo/redo can be triggered by running the history’s methods .undo() and .redo().
Histories are Objects too, so they do send Actions that can be observed by Observers.
Example: Associate a History with an Object.
>>> from objetto import Application, Object, history_descriptor, attribute
>>> class Person(Object):
... history = history_descriptor() # specify a history
... name = attribute(str)
...
>>> app = Application()
>>> person = Person(app, name="Dave")
>>> person.name
'Dave'
>>> person.name = "Dave Grohl"
>>> person.name
'Dave Grohl'
>>> person.history.undo() # undo the name change
>>> person.name
'Dave'
Batch Context
An Object can enter a Batch Context, which will group multiple Changes happening to itself and/or to other Objects into one single entry in the associated History.
A special Action carrying the the name and the metadata of the batch context will be sent when entering (PRE Phase) and when exiting the context (POST Phase).
Example: Enter a Batch Context.
>>> from objetto import Application, Object, history_descriptor, attribute
>>> class Hobby(Object):
... description = attribute(str)
...
>>> class Person(Object):
... history = history_descriptor() # specify a history
... name = attribute(str)
... hobby = attribute(Hobby) # history will propagate by default
...
... def set_info(self, name, hobby_description):
... with self._batch_context("Set Person Info"): # enter batch
... self.name = name # single change
... self.hobby.description = hobby_description # single change
...
>>> app = Application()
>>> hobby = Hobby(app, description="sailing")
>>> person = Person(app, name="Albert", hobby=hobby)
>>> person.name, person.hobby.description
('Albert', 'sailing')
>>> person.set_info("Einstein", "physics") # batch change
>>> person.name, person.hobby.description
('Einstein', 'physics')
>>> person.history.undo() # single undo will revert both changes
>>> person.name, person.hobby.description
('Albert', 'sailing')
Data
Data are analog structures to Objects, but they are immutable.
Everytime an Object changes, their internal Data and all of its parent’s and grandparents’ Data get replaced with a new one that reflects those changes.
By default, every Object class/subclass with automatically generate it’s Data class based on its attributes and schema. You can access the data type of an Object through its .Data class property.
The Data instance for an Object can be accessed through its .data property.
Example: Access internal Data of an Object.
>>> from objetto import Application, Object, attribute
>>> class Hobby(Object):
... description = attribute(str)
...
>>> class Person(Object):
... hobby = attribute(Hobby)
...
>>> Person.Data.__fullname__ # access to automatically generated 'Data' class
'Person.Data'
>>> app = Application()
>>> hobby = Hobby(app, description="biking")
>>> person = Person(app, hobby=hobby)
>>> hobby_data = person.data.hobby # access 'hobby' data through 'person' data
>>> hobby_data is hobby.data
True
>>> hobby_data.description
'biking'
If you want to bind methods from the Object to the Data as well, you can use the data_method decorator.
Example: Using the data_method decorator.
>>> from objetto import Application, Object, attribute, data_method
>>> class Hobby(Object):
... description = attribute(str)
...
... @data_method
... def get_description(self):
... return "Description: {}".format(self.description)
...
>>> app = Application()
>>> hobby = Hobby(app, description="biking")
>>> hobby.get_description()
'Description: biking'
>>> hobby.data.get_description() # 'hobby' data also has the method
'Description: biking'
And finally, if you want more control, you can define a custom Data class for an Object, but this only recommended for advanced behavior. Keep in mind that the class must match the schema of the Object’s Attributes.
Example: Defining a custom Data class for an Object.
>>> from objetto import Application, Object, Data, attribute, data_attribute
>>> class Hobby(Object):
... description = attribute(str)
...
... class Data(Data):
... description = data_attribute(str, factory=lambda v, **_: v.upper())
...
>>> app = Application()
>>> hobby = Hobby(app, description="biking")
>>> hobby.description
'biking'
>>> hobby.data.description # data attribute has a custom factory
'BIKING'
It’s also possible to use Data on its own, without an encasing Object. Remember that Data instances are immutable, so the only way to produce changes is by calling methods that return a new version of the data when subclassing from an interactive Data class.
Example: Using an interactive Data on its own.
>>> from objetto import InteractiveData, data_attribute
>>> class HobbyData(InteractiveData): # inherit from InteractiveData
... description = data_attribute(str) # use data attributes
...
>>> class PersonData(InteractiveData):
... hobby = data_attribute((HobbyData, None)) # specify data types
...
>>> hobby_data = HobbyData(description="biking")
>>> new_hobby_data = hobby_data.set("description", "programming") # make new
>>> person_data = PersonData(hobby=hobby_data)
>>> person_data.hobby = None # data is immutable
Traceback (most recent call last):
AttributeError: 'PersonData' object attribute 'hobby' is read-only
Action
Every time an Object changes, it will automatically send an Action up the Hierarchy to its parent and grandparents.
The Action carries information such as:
Phase
A constant value that tells whether the change in the state is about to happen (PRE) or if the change already happened (POST).
Change
A Change describes what exactly changed in the state of an Object.
Here are some of the Changes provided by Objects:
Batch
Update
DictUpdate
ListInsert
ListDelete
ListUpdate
ListMove
SetUpdate
SetRemove
Reaction
Objects can define Reactions that will get triggered once Actions are received. Reactions are special methods of Objects that respond to Actions received from themselves, their children, and grandchildren.
Example: Define Reaction methods.
>>> from objetto import Application, Object, attribute, reaction, POST
>>> class MyObject(Object):
... value = attribute(int, default=0)
...
... @reaction
... def __on_received(self, action, phase):
... if not self._initializing and phase is POST:
... print(("LAST -", action.change.name, phase))
...
... @reaction(priority=1)
... def __on_received_first(self, action, phase):
... if not self._initializing and phase is POST:
... print(("FIRST -", action.change.name, phase))
...
>>> app = Application()
>>> my_obj = MyObject(app)
>>> my_obj.value = 42
('FIRST -', 'Update Attributes', <Phase.POST: 'POST'>)
('LAST -', 'Update Attributes', <Phase.POST: 'POST'>)
Action Observer
After all internal Reactions within an Write Context run without any errors, the Actions are then finally sent to external Action Observers so they have a chance to synchronize.
Graphical user interface widgets are a good example of Action Observers.
Example: Register an external Action Observer.
>>> from objetto import Application, Object, ActionObserver, attribute
>>> class Person(Object):
... name = attribute(str, default="Nina")
...
>>> class PersonObserver(ActionObserver):
...
... def __observe__(self, action, phase):
... print((action.change.name, phase.value))
...
>>> app = Application()
>>> person = Person(app)
>>> observer = PersonObserver()
>>> token = observer.start_observing(person)
>>> person.name = "Simone"
('Update Attributes', 'PRE')
('Update Attributes', 'POST')
Auxiliary Attribute Reaction
It is possible to specify Reactions methods/callables when defining Auxiliary Attributes. Objetto offers configurable reactions that can be used for that purpose.
Here are some of them:
UniqueAttributes
LimitChildren
Limit
Example: Ensure unique names.
>>> from objetto import Application, Object, attribute, list_attribute
>>> from objetto.reactions import UniqueAttributes
>>> class Person(Object):
... name = attribute(str)
...
>>> class Band(Object):
... musicians = list_attribute(Person, reactions=UniqueAttributes("name"))
...
>>> app = Application()
>>> person_a = Person(app, name="Paul")
>>> person_b = Person(app, name="John")
>>> band = Band(app, musicians=(person_a, person_b))
>>> person_c = Person(app, name="Paul")
>>> band.musicians.append(person_c)
Traceback (most recent call last):
ValueError: another object already has 'name' set to 'Paul'
Serialization
Objects support human-readable serialization out of the box.
Example: Serialize an Object.
>>> from objetto import Application, Object, attribute, list_attribute
>>> class Person(Object):
... name = attribute(str)
...
>>> class Band(Object):
... musicians = list_attribute(Person)
...
>>> app = Application()
>>> person_a = Person(app, name="Oscar")
>>> person_b = Person(app, name="Ray")
>>> band = Band(app, musicians=(person_a, person_b))
>>> band.serialize()
{'musicians': [{'name': 'Oscar'}, {'name': 'Ray'}]}
Deserialization
Objects support human-readable deserialization out of the box.
Example: Deserialize an Object.
>>> from objetto import Application, Object, attribute, list_attribute
>>> class Person(Object):
... name = attribute(str)
...
>>> class Band(Object):
... musicians = list_attribute(Person)
...
>>> app = Application()
>>> Band.deserialize({"musicians": [{"name": "Oscar"}, {"name": "Ray"}]}, app=app)
Band(musicians=[<Person at ...>, <Person at ...>])
Custom Serializer/Deserializer
You can specify custom serializer/deserializer functions for attributes.
Example: Serialize an Enum using lambdas.
>>> from enum import Enum
>>> from objetto import Application, Object, attribute
>>> class Hobby(Enum):
... GUITAR = 1
... BIKING = 2
...
>>> class Person(Object):
... hobby = attribute(
... Hobby,
... serializer=lambda value, **_: value.name.lower(),
... deserializer=lambda value, **_: Hobby[value.upper()],
... )
...
>>> app = Application()
>>> person = Person(app, hobby=Hobby.GUITAR)
>>> person.serialize()
{'hobby': 'guitar'}
>>> Person.deserialize({"hobby": "biking"}, app=app)
Person(hobby=<Hobby.BIKING: 2>)
Example: Serialize an Enum using provided serializer/deserializer.
>>> from enum import Enum
>>> from objetto import Application, Object, attribute
>>> from objetto.serializers import EnumSerializer
>>> from objetto.deserializers import EnumDeserializer
>>> class Hobby(Enum):
... GUITAR = 1
... BIKING = 2
...
>>> class Job(Enum):
... PROGRAMMER = 1
... TEACHER = 2
...
>>> class Person(Object):
... hobby = attribute(
... Hobby,
... serializer=EnumSerializer(),
... deserializer=EnumDeserializer(Hobby),
... )
... job = attribute(
... Job,
... serializer=EnumSerializer(by_name=True),
... deserializer=EnumDeserializer(Job, by_name=True),
... )
...
>>> app = Application()
>>> person = Person(app, hobby=Hobby.GUITAR, job=Job.PROGRAMMER)
>>> serialized = person.serialize()
>>> serialized["hobby"]
1
>>> serialized["job"]
'PROGRAMMER'
>>> Person.deserialize({"hobby": 2, "job": "TEACHER"}, app=app)
Person(hobby=<Hobby.BIKING: 2>, job=<Job.TEACHER: 2>)
… And More!
Take a look at the API documentation to learn more about Objetto.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for objetto-1.29.2-py2.py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | e11cf5cc28163f575973d165ad3a102a3cc3b399a4711db378fcf6a2b1c04b85 |
|
MD5 | e3b86b42a6d9d1628c38f4f5eb02350a |
|
BLAKE2b-256 | a595244368f33d77cd4c723ef7069910ec5f99da95d70b803c6a191fdf0b280f |