Skip to main content

Object-oriented framework for building smart applications and APIs

Project description

https://github.com/brunonicko/objetto/workflows/MyPy/badge.svg https://github.com/brunonicko/objetto/workflows/Lint/badge.svg https://github.com/brunonicko/objetto/workflows/Tests/badge.svg https://readthedocs.org/projects/objetto/badge/?version=stable https://img.shields.io/github/license/brunonicko/objetto?color=light-green https://static.pepy.tech/personalized-badge/objetto?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=Downloads https://img.shields.io/pypi/pyversions/objetto?color=light-green&style=flat

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.

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:

  • The description of the Change (change)

  • A reference to the Object receiving the Action (receiver)

  • A reference to the Object where the change originated from (sender)

  • A list of relative indexes/keys from the receiver to the sender (locations)

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

objetto-1.29.2.tar.gz (155.8 kB view hashes)

Uploaded Source

Built Distribution

objetto-1.29.2-py2.py3-none-any.whl (170.1 kB view hashes)

Uploaded Python 2 Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page