Python roles module¶
Library for Role based development
Roles
provides a Pythonic implementation of the DCI (Data Context Interaction) paradigm
(http://www.artima.com/articles/dci_vision.html).
Roles allow you to assign and revoke behaviour on a per-instance basis. This defines the big difference with mixins, which are assigned at class level.
A role has a special meaning in a context (imagine you want to do a money transfer: in this context you’ll need 2 accounts, a source and a destination account). The roles module provides a simple implementation for defining contexts.
Roles can be assigned and revoked. Multiple roles can be applied to an instance. Revocation can happen in any particular order.
- Homepage:
- Sources:
- Downloads:
Contents:
Usage¶
Using Roles¶
>>> from roles import RoleType
As a basic example, consider a data class:
>>> class Person:
... def __init__(self, name):
... self.name = name
>>> person = Person("John")
The instance should participate in a collaboration in which it fulfills a particular role:
>>> class Carpenter(metaclass=RoleType):
... def chop(self):
... return "chop, chop"
Assign the role to the person:
>>> Carpenter(person)
<__main__.Person+Carpenter object at 0x...>
>>> person
<__main__.Person+Carpenter object at 0x...>
The person is still a Person:
>>> isinstance(person, Person)
True
… and can do carpenter things:
>>> person.chop()
'chop, chop'
To revoke a role from an instance do
>>> Carpenter.revoke(person)
<__main__.Person object at 0x...>
It is much more convenient, though, to apply roles only in a specific context:
>>> from roles.context import context
>>> class WoodChopping:
... def __init__(self, person):
... self.person = person
... self.chopping_context = context(self, person=Carpenter)
...
... def __call__(self):
... with self.chopping_context:
... return self.person.chop()
...
>>> ctx = WoodChopping(person)
>>> ctx()
'chop, chop'
Using Context¶
Roles make a lot of sense when used in a context. A classic example is the money transfer example. Here two accounts are used and an amount of money is transfered from one account to the other. So, one account playes the role of source account and the other plays the role of target account.
>>> from roles.context import context
Say we have this simple account class:
>>> class Account:
...
... def __init__(self, amount):
... self.balance = amount
... super(Account, self).__init__()
...
... def withdraw(self, amount):
... self.balance -= amount
...
... def deposit(self, amount):
... self.balance += amount
If you want to transfer money from one account to another, we’re normally calling one the source account and the other the destination account. Let’s make that clear in code:
>>> class MoneySource(metaclass=RoleType):
...
... def transfer(self, amount):
... if self.balance >= amount:
... self.withdraw(amount)
... context.sink.receive(amount)
...
>>> class MoneySink(metaclass=RoleType):
...
... def receive(self, amount):
... self.deposit(amount)
During the act of a money transfer (what do you think about when money is transfered?), some amount is transfered from one account to the other. That is made explicit in a context.
A context contains the objects that are required for some action and assign them the roles they will play during the enactment. Roles have no meaning outside a context, since the logic executed is specific to this use case. Advantage of using a context is that it is not required to pass role objects around. Also contexts can be used as intermediate data store (for the duration of the context): note how the money source is finding its destination through the context.
>>> class TransferMoney:
...
... def __init__(self, source, sink):
... self.source = source
... self.sink = sink
... self.transfer_context = context(self,
... source=MoneySource,
... sink=MoneySink)
...
... def transfer_money(self, amount):
... """
... The interaction.
... """
... with self.transfer_context as ctx:
... ctx.source.transfer(amount)
Another way is to make the method itself act as context (well, the first argument is considered the context object:
>>> from roles.context import in_context
>>> class TransferMoney:
...
... def __init__(self, source, sink):
... self.source = source
... self.sink = sink
...
... @in_context
... def transfer_money(self, amount):
... """
... The interaction.
... """
... with MoneySource.played_by(self.source),\
... MoneySink.played_by(self.sink):
... self.source.transfer(amount)
User API¶
RoleType metaclass¶
- class roles.RoleType¶
RoleType
is a metaclass that provides role support to classes. The initialization process has been altered to provide addition and removal of roles.It starts with a normal class:
>>> class Person: ... def __init__(self, name): self.name = name ... def am(self): print(self.name, 'is')
Apart from that a few roles can be defined. Simple objects with a default
__init__()
(no arguments) and theRoleType
as metaclass:>>> class Carpenter(metaclass=RoleType): ... def chop(self): print(self.name, 'chops') >>> class Biker(metaclass=RoleType): ... def bike(self): print(self.name, 'bikes')
Now, by default an object has no roles (in this case our person).
>>> person = Person('Joe')
Roles can be added by calling the
assign()
method:>>> Carpenter.assign(person) <roles.role.Person+Carpenter object at 0x...>
Or by calling the role on the subject:
>>> Carpenter(person) <roles.role.Person+Carpenter object at 0x...>
The persons methods can be invoked:
>>> person.am() Joe is
As well as the role’s methods:
>>> person.chop() Joe chops
The default behaviour is to apply the role directly to the instance.
>>> person <roles.role.Person+Carpenter object at 0x...>
The module contains a function
clone()
that can be provided to theasign()
method to create proxy instances (the default function is calledinstance()
and can also be found in this module):>>> biker = Biker.assign(person, method=clone) >>> biker <roles.role.Person+Carpenter+Biker object at 0x...> >>> biker is person False
Objects can contain multiple roles:
>>> biker = Biker.assign(person) >>> biker <roles.role.Person+Carpenter+Biker object at 0x...> >>> biker.__class__.__bases__ (<class 'roles.role.Person'>, <class 'roles.role.Carpenter'>, <class 'roles.role.Biker'>)
Note that a new class is assigned, with the roles applied (roles first).
Roles can be revoked:
>>> Carpenter.revoke(biker) <roles.role.Person+Biker object at 0x...> >>> biker.__class__.__bases__ (<class 'roles.role.Person'>, <class 'roles.role.Biker'>)
Revoking a non-existant role has no effect:
>>> Carpenter.revoke(biker) <roles.role.Person+Biker object at 0x...>
Roles do not allow for overriding methods.
>>> class Incognito(metaclass=RoleType): ... def am(self): return 'under cover' >>> Incognito(Person) Traceback (most recent call last): ... TypeError: Can not apply role when overriding methods: am
Caching
One more thing: role classes are cached. This means that if I want to assign a role to a different instance, the same role class is applied:
>>> person = Person('Joe') >>> someone = Person('Jane') >>> Biker(someone).__class__ is Biker(person).__class__ True
Changing role application
If for some reason the role should not be directly applied to the instance, another application method can be assigned.
Here is an example that uses the
clone
method:>>> person = Person('Joe') >>> person.__class__ <class 'roles.role.Person'> >>> biker = Biker(person, method=clone) >>> biker <roles.role.Person+Biker object at 0x...> >>> person.__class__ <class 'roles.role.Person'> >>> biker.bike() Joe bikes
- assign(subj: ~roles.role.T, method: ~typing.Callable[[~typing.Type[~roles.role.R], ~roles.role.T], ~roles.role.R] = <function instance>) Union[T, R] ¶
Call is invoked when a role should be assigned to an object.
- played_by(subj: T) Iterator[Union[T, R]] ¶
Shorthand for using roles in with statements.
>>> class Biker(metaclass=RoleType): ... def bike(self): return 'bike, bike' >>> class Person: ... pass >>> john = Person() >>> with Biker.played_by(john): ... john.bike() 'bike, bike'
- revoke(subj: ~roles.role.R, method: ~typing.Callable[[~typing.Type[~roles.role.T], ~roles.role.R], ~roles.role.T] = <function instance>) T ¶
Retract the role from subj.
By default the
instance
strategy is used.
Using roles in a context¶
Roles are played in a context. The roles.context
module provides a
means to access the context from within your roles. Use this to make your
role’s code simpler and more readable.
- roles.context.context(ctxobj, **bindings)¶
The default application wide context stack.
Put a new context class on the context stack. This functionality should be called with the context class as first argument.
>>> class SomeContext: ... pass # define some methods, define some roles ... def execute(self): ... with context(self): ... pass # do something
Roles can be fetched from the context by calling
context.name
. Just like that.You can provide additional bindings to be performed:
>>> from roles.role import RoleType
>>> class SomeRole(metaclass=RoleType): ... pass
>>> class SomeContext: ... def __init__(self, data_object): ... self.data_object = data_object ... def execute(self): ... with context(self, data_object=SomeRole): ... pass # do something
Those bindings are applied when the context is entered (in this case immediately).
- roles.context.in_context(func)¶
Decorator for running methods in context.
The context is the object (self).
Ways to assign roles¶
There are basically 3 ways to assign a role to an instance. The first one is to manipulate the instance’s class (this is the default) and the second is to proxy the object by referencing the same instance dict (Borg pattern). The this (last resort) is to create an adapter on top of the data instance.
Generic roles¶
As an (non-DCI) extension it is possible to create role implementations tailored for specific classes.
Although this may clutter the clear and readable ways provided by DCI, for specific tasks it may help. Use it wisely.
Django support¶
Django is a popular web framework written in Python. Using DCI (or just roles) in Django is quite easy, but since Django is using some meta classes of its own, a few things have to be taken into account.
First of all, roles that need to be applied to model classes (which is most
obvious) should use roles.django.ModelRoleType
as metaclass.
>>> from roles.django import ModelRoleType
Then roles can be applied in the regular way.
>>> class MoneySource(metaclass=ModelRoleType):
...
... def transfer(self, amount):
... if self.balance >= amount:
... self.withdraw(amount)
... context.sink.receive(amount)
Since the roles module changes the class names (it adds the roles that are applied at a certain time), those can not be used directly for storing. For now, saving objects should be done outside the role context.
>>> class TransferMoney:
...
... def __init__(self, source, sink):
... self.source = source
... self.sink = sink
...
... def transfer_money(self, amount):
... """
... The interaction.
... """
... with context(self,
... source=MoneySource,
... sink=MoneySink):
... # Let roles interact, in context
... self.source.transfer(amount)
... # Do storage when roles are removed
... self.source.save()
... self.sink.save()
Private API¶
For those interested in (non-public) API.
Utility classes¶
- roles.role.class_fields(cls: Type, exclude: Sequence[str] = ('__doc__', '__module__', '__dict__', '__weakref__', '__slots__')) Set[str] ¶
Get all fields declared in a class, including superclasses.
Don’t forget to clear the cache if fields are added to a class or role!
Context internals¶
- class roles.context.CurrentContextManager¶
The default application wide context stack.
Put a new context class on the context stack. This functionality should be called with the context class as first argument.
>>> class SomeContext: ... pass # define some methods, define some roles ... def execute(self): ... with context(self): ... pass # do something
Roles can be fetched from the context by calling
context.name
. Just like that.You can provide additional bindings to be performed:
>>> from roles.role import RoleType
>>> class SomeRole(metaclass=RoleType): ... pass
>>> class SomeContext: ... def __init__(self, data_object): ... self.data_object = data_object ... def execute(self): ... with context(self, data_object=SomeRole): ... pass # do something
Those bindings are applied when the context is entered (in this case immediately).
Change History¶
1.0.0
Python 3.8+ only
Removed
roles.factory
, use functools.singledispatch` insteadAdded type annotations
Publish docs on readthedocs.io
Build with Poetry
CI on Github Actions
0.10
Allow saving domain instances with roles applied. Thanks to Ben Scherrey and Chokchai Phatharamalai
Deal with
__slots__
Can assign roles bindings to be used in the Context
0.9
It is no longer allowed to let roles override methods.
Added module roles.django
Added Django application example (django_dci module)
context is thread safe
added adapter method for role assignment
0.8
Removed @rolecontext. Seems not such a good idea.
separated code in multiple modules
Added DCI context management classes.
Added warnings for using the factory functionality in a DCI context
0.7.0
Added @rolecontext decorator to ensure roles are applied on function invocation.
0.6.0
RoleType.played_by for easy use with with statement.
removed roles function and psyco optimizations.
bug fixes and performance updates
0.5.0
Support for contexts (with statement).
revoke on factories now works as expected.
0.4.0
Make the way a role is applied to an object pluggable. This means you can either apply the role to the original instance or create a clone, using the original instance dict.
0.3.0
Module works for Python 2.6 as well as Python 3.x. Doctests still run under 2.6.
0.2.0
Added psyco_optimize() for optimizing code with psyco.
0.1.0
Initial release: roles.py