Gaudi
A helper library for Clean Architectures in Python
Full documentation available at http://gaudilib.readthedocs.io/en/latest/
About Gaudi
Gaudi (pronounced /ˈɡaʊdi/) is a library that provides some helper structures to build projects based on a clean architecture in Python. The very nature of a clean architecture is the opposite of a framework, so Gaudi provides the minimum amount of classes and methods to avoid boring repetition in your code.
Gaudi is opinionated, as some of the structures provided enforce minimum conventions, like requests being dictionary-like objects and responses having a category and a content. It is also extensible, however, as you are free to change the behaviour of the components at any time.
As you are following the clean architecture model you are free to build the internal protocols and objects like you prefer. Using Gaudi saves you some typing and enforces a minimum of conventions in your project. You are free to use just some of the components of Gaudi without breaking the clean architecture model.
Origin
In 2016 I wrote Clean architectures in Python: a step-by-step example, a detailed analysis of a clean architecture written in Python from scratch following a pure TDD methodology. Since then I created many successful projects following this model, but I quickly realised that there was a core of code that I copied from project to project (the shared
module in the original article). So I decided to try to clean it up and to publish it as a library.
The name is an homage to Antoni Gaudí a genius that gave the world some of the most beautiful architectural works ever conceived by men.
Development
Gaudi is an helper library for clean architectures, so it provides the very minimum amount of code to avoid repetitions among projects. This means that the library shouldn't grow too much in the future. There will be bug fixes and maybe some new helpers if there are good use cases (no pun intended) for them. I'm however ready to be surprised, so it might be that there are many other aspects of the clean architecture that can be automated while keeping the nature of the whole methodology: clean separation between layers.
Feel free to submit issues or pull requests or to get in touch if you have ideas about Gaudi. Maybe you can see what I can't! And thanks for using Gaudi and the Clean Architecture model!
Installation
Gaudi is available for Python 3 through pip. Just create a virtual environment and run
pip install gaudi
Domain models
Gaudi provides the gaudi.domain_model.DomainModel
abstract base class to register you models.
from gaudi.domain_model import DomainModel
class Board:
pass
DomainModel.register(Board)
This allows you to categorise your classes as domain models and to check them with isinstance()
.
For the time being this is not used in the rest of the library.
Response objects
Gaudi provides a single class that represents a response, gaudi.response_object.Response
. It provides two factory TODO classes to build success responses (gaudi.response_object.ResponseSuccess
) and failure responses (gaudi.response_object.ResponseFailure
).
A Response
is initialised with a boolean_value
, a category
, and a content
(default None
)
class Response:
def __init__(self, boolean_value, category, content=None):
The boolean_value
is the truth value of the response in boolean comparisons like response id True
or response is False
.
The category
value is used to categorise your responses if you need a complex workflow when receiving them. It accepts any type of value, but I recommend to use a string.
Last, the content
value is the content of the response. This is up to your implementation, it might be a string, a dictionary, or whatever you need to pass as part of the response.
Successful responses
To create a successful response you can just run the following code
r = Response(True, category, content)
where category
and content
are the values you want to put in the response. You can omit content
if you don't have anything to return
r = Response(True, category)
Gaudi, however, provides the ResponseSuccess
class that simplifies the process, while giving a visual hint of what is going on
r = ResponseSuccess.create(category, content)
or, if the response is empty
r = ResponseSuccess.create(category)
As a further simplification, if you are not categorising your responses you might rely on the default category gaudi.response_object.DEFAULT_SUCCESS
and use the create_default_success
method
r = ResponseSuccess.create_default_success(content)
or, if the response is empty
r = ResponseSuccess.create_default_success()
Unsuccessful responses
To create unsuccessful response you can just run the following code
r = Response(False, category, content)
where category
and content
are the values you want to put in the response. You can omit content
if you don't have anything to return
r = Response(False, category)
As happened for successful responses, Gaudi provides the ResponseFailure
class that simplifies the process
r = ResponseFailure.create(category, content)
or, if the response is empty
r = ResponseFailure.create(category)
When designing a system based on a clean architecture, you can categorise most of the errors as coming from use cases, parameters, or exceptions.
Errors generated by use cases are usually errors that you foresee in your business logic and are explicitly created in the use cases. For these errors you can rely on the gaudi.response_object.USE_CASE_ERROR
category and use create_use_case_error
r = ResponseFailure.create_use_case_error(content)
or, if the response is empty
r = ResponseFailure.create_use_case_error()
Another type of error is the one that originates from wrong parameters (either missing ones or parameters with wrong values or types). For these errors you can use the gaudi.response_object.PARAMETERS_ERROR
category given by create_parameters_error
r = ResponseFailure.create_parameters_error(content)
or, if the response is empty
r = ResponseFailure.create_parameters_error()
The last type of error is used for exceptions, or errors that cannot be foreseen when designing the clean architecture. This definition may encompass both system errors that cannot be considered when writing an algorithm (the disk is full) and errors that you simply forgot to consider in the algorithm, that are thus not properly handled. These errors can have the standard gaudi.response_object.EXCEPTION_ERROR
category used by create_exception_error
r = ResponseFailure.create_exception_error(content)
or, if the response is empty
r = ResponseFailure.create_exception_error()
Use cases
Use cases are represented by the gaudi.use_case.UseCase
class. To define a use case you can inherit from this class in the traditional way
from gaudi import use_case as uc
class InitialiseSystemUseCase(uc.UseCase):
The __init__
method of UseCase
accepts two parameters, exception_on_failure
and no_traceback
.
When the use case returns an unsuccessful response you might choose to raise a specific Python exception instead of returning the original error. The exception_on_failure
argument is the exception class that will be returned, initialised with the unsuccessful response category and content. Pay attention that this is not a properly formatted unsuccessful response but a real Python exception.
By default, if the use case raises an exception (for example if you try to access an element of an empty list) the returned response contains the exception traceback. If you pass the no_traceback
argument as True
the returned response will contain only the exception name and content.
Process the request
You have to put the use case logic in the process_request
method, that receives the incoming request as the only parameter. The request is supposed to be an object with a dictionary-like interface.
class InitialiseSystemUseCase(uc.UseCase):
def process_request(self, request):
Inside this function you can process the incoming data contained in the request and eventually return a Response
(see the previous section).
from gaudi import use_case as uc
from gaudi import response_object as res
from mysystem import System
class InitialiseSystemUseCase(uc.UseCase):
def process_request(self, request):
c = request['cpus']
s = System(cpus_number=c)
return res.ResponseSuccess.create_default_success({
'system': s,
})
As already explained, any Python exception occurring inside the process_request
function will result in an unsuccessful Response
with category EXCEPTION_ERROR
.
Processing the request parameters
When you create the use case you can define a class attribute called _parameters
, which is a list of mandatory parameters. The presence of these parameters is checked inside the incoming request, and if a parameter is missing an unsuccessful Response
is returned by the use case, with the PARAMETERS_ERROR
category and an apt content that signals the error.
Parameters can be specified as simple strings or as dictionaries. As strings you are supposed to pass the parameter name, and the only check that Gaudi does is to verify that the parameter is present in the request. These are pure mandatory parameters.
class InitialiseSystemUseCase(uc.UseCase):
_parameters = ['cpus']
def process_request(self, request):
If you specify a parameter as a dictionary you have to include the name
key, which value is the name of the parameter as it is supposed to be contained in the request. You can also include a default
key, which value is the default value of the parameter.
class InitialiseSystemUseCase(uc.UseCase):
_parameters = [
{
'name': 'cpus',
'default': 4
}
]
def process_request(self, request):
Use case execution
The UseCase
class provides an execute
method that actually runs the use case. This method performs the following actions:
- It initialises an empty request
- It runs through all the parameters and sets the default value of the ones that provide it
- It updates the request with the one provided as an argument
- It checks if all the parameters mentioned in the
_parameters
class attribute are contained in the request. - If something goes wrong here it returns a
PARAMETERS_ERROR
unsuccessful response - It runs the
process_request
method that you provided - If a Python exception is raised during this step it returns an
EXCEPTION_ERROR
unsuccessful response - If the response is unsuccessful and
exception_on_failure
is set it raises the given exception - In all the other cases it returns the response (successful or unsuccessful)
An example of code that runs a use case is the following
use_case = InitialiseSystemUseCase()
res = use_case.execute({
'cpus': 5
})
According to the previous definition of InitialiseSystemUseCase
this will run the code s = System(cpus_number=5)
and return a successful Response
with content {'system': s}
.
Instead the following code
use_case = InitialiseSystemUseCase()
res = use_case.execute()
will return an unsuccessful Response
categorised as PARAMETERS_ERROR
, as cpus
is listed among the mandatory parameters of the InitialiseSystemUseCase
use case.
Helper classes
Initialising a use case and executing it will be a pretty common pattern in your code. To provide shortcuts for this pattern all the classes that inherit from UseCase
are registered into the UseCaseMeta
metaclass and accessible through the UseCaseRegister
class.
ucr = UseCaseRegister()
use_case = ucr.InitialiseSystem
this code puts in the use_case
variable the class InitialiseSystemUseCase
. Pay attention that use cases are registered without the suffix UseCase
. Note also that the returned value is the class and not an instance of it.
The UseCaseCreator
class retrieves the use case and initialises it
ucc = UseCaseCreator()
use_case = ucc.InitialiseSystem
Here, use_case
is an instance of InitialiseSystemUseCase
. UseCaseCreator
can be initialised with some parameters that are used then to initialise all the use cases
ucc = UseCaseCreator(exception_on_failure=ValueError)
use_case = ucc.InitialiseSystem
Now use_case
is an instance of InitialiseSystemUseCase
that has been created with exception_on_failure=ValueError
.
The last helper class is UseCaseExecutor
that behaves exactly like UseCaseCreator
but returns the execute
method of the use case. Thus, it can be used to run the use case in one single line of code
uce = UseCaseExecutor()
res = uce.InitialiseSystem({
'cpus': 5
})
ATTENTION The registration and the helper classes work if you imported the modules containing your use cases. So you have either to import them at the beginning of each file or to import them once and for all in the __init__.py
file of your module.