Custom Directives¶
Having built-in directives from Esmerald is great as it gives you a lot of niceties for your project but having custom directives is what really powers up your application and takes it to another level.
Important¶
Before reading this section, you should get familiar with the ways Esmerald handles the discovery of the applications.
The following examples and explanations will be using the --app and environment variables approach but the auto discovery is equally valid and works in the same way.
What is a custom directive?¶
Before jumping into that, let us go back to the roots of python.
Python was and still is heavily used as a scripting language. The scripts are isolated pieces of code and logic that can run on every machine that has python installed and execute without too much trouble or hurdle.
Quite simple, right?
So, what does this have to do with directives? Well, directives follow the same principle but applied to your own project. What if you could create your own structured scripts inside your project directly? What if you could build dependent or independent pieces of logic that could be run using your own Esmerald application resources?
This is what a directive is.
Tip
If you are familiar with Django management commands, Esmerald directives follow the same principle. There is an excelent article about those if you want to get familiar with.
Examples¶
Imagine you need to deploy a database that will contain all the information about specific user accesses and will manage roles of your application.
Now, once that database is deployed with your application, usually would would need somehow to connect to your production server and manually setup a user or run a specific script or command to create that same super user. This can be time consuming and prone to errors, right?
You can use a directive to do that same job for you.
Or what if you need to create specific operations to run in the background by some ops that does not require APIs, for example, update the role of a user? Directives solve that problem as well.
There is a world of possibilities of what you can do with directives.
Directive¶
This is the main object class for every single custom directive you want to implement. This is a special object with some defaults that you can use.
Directives were inspired by the management commands of Django with extra flavours and therefore the syntax is very similar.
Parameters¶
- --directive - The directive name (the file where the Directive was created). Check list all directives for more details in obtaining the names.
How to run¶
The syntax is very simple for a custom directive:
With the --app parameter
$ esmerald --app <LOCATION> run --directive <DIRECTIVE-NAME> <OPTIONS>
Example:
esmerald --app myproject.main:app run --directive mydirective --name esmerald
With the ESMERALD_DEFAULT_APP environment variable set
$ export ESMERALD_DEFAULT_APP=myproject.main:app
$ esmerald run --directive <DIRECTIVE-NAME> <OPTIONS>
Example:
$ export ESMERALD_DEFAULT_APP=myproject.main:app
$ esmerald run --directive mydirective --name esmerald
The run --directive
is always expecting the name of the file of your directive.
For example, you created a createsuperuser.py
file with your Directive
logic. The --directive
parameter will be run --directive createsuperuser
.
Example:
$ export ESMERALD_DEFAULT_APP=myproject.main:app
$ esmerald run --directive createsuperuser --email example@esmerald.dev
How to create a directive¶
To create a directive you must inherit from the BaseDiretive class and must call Directive
to your object.
from esmerald.core.directives import BaseDirective
Create the Directive class
import argparse
from typing import Any, Type
from esmerald.core.directives import BaseDirective
class Directive(BaseDirective):
def add_arguments(self, parser: Type["argparse.ArgumentParser"]) -> Any:
# Add argments
...
Every single custom directive created should be called Directive and must inherit from the
BaseDiretive
class.
Internally esmerald
looks for a Directive
object and verifies if is a subclass of BaseDirective
.
If one of this conditions fails, it will raise a DirectiveError
.
Where should directives be placed at?¶
All the custom directives created must be inside a directives/operations
package in order to
be discovered.
The place for the directives/operations
can be anywhere in your application and
you can have more than one as well.
Example:
.
├── Taskfile.yaml
└── myproject
├── __init__.py
├── apps
│ ├── accounts
│ │ ├── directives
│ │ │ ├── __init__.py
│ │ │ └── operations
│ │ │ ├── createsuperuser.py
│ │ │ └── __init__.py
│ ├── payroll
│ │ ├── directives
│ │ │ ├── __init__.py
│ │ │ └── operations
│ │ │ ├── run_payroll.py
│ │ │ └── __init__.py
│ ├── products
│ │ ├── directives
│ │ │ ├── __init__.py
│ │ │ └── operations
│ │ │ ├── createproduct.py
│ │ │ └── __init__.py
├── configs
│ ├── __init__.py
│ ├── development
│ │ ├── __init__.py
│ │ └── settings.py
│ ├── settings.py
│ └── testing
│ ├── __init__.py
│ └── settings.py
├── directives
│ ├── __init__.py
│ └── operations
│ ├── db_shell.py
│ └── __init__.py
├── main.py
├── serve.py
├── tests
│ ├── __init__.py
│ └── test_app.py
└── urls.py
As you can see from the previous example, we have four directives:
- createsuperuser - Inside
accounts/directives/operations
. - run_payroll - Inside
payroll/directives/operations
. - createproduct - Inside
products/directives/operations
. - db_shell - Inside
./directives/operations
.
All of them, no matter where you put the directive, are inside a directives/operations where esmerald always looks at.
Directive functions¶
handle()¶
The Diretive
logic is implemented inside a handle
function that can be either sync
or
async
.
When calling a Directive
, esmerald
will execute the handle()
and run the all the logic.
import argparse
from typing import Any, Type
from esmerald.core.directives import BaseDirective
from esmerald.core.terminal import Print
printer = Print()
class Directive(BaseDirective):
def add_arguments(self, parser: Type["argparse.ArgumentParser"]) -> Any:
# Add argments
...
def handle(self, *args: Any, **options: Any) -> Any:
# Runs the handle logic in sync mode
printer.write_success("Sync mode handle run with success!")
import argparse
from typing import Any, Type
from esmerald.core.directives import BaseDirective
from esmerald.core.terminal import Print
printer = Print()
class Directive(BaseDirective):
def add_arguments(self, parser: Type["argparse.ArgumentParser"]) -> Any:
# Add argments
...
async def handle(self, *args: Any, **options: Any) -> Any:
# Runs the handle logic in async mode
printer.write_success("Async mode handle run with success!")
As you can see, Esmerald Directives also allow async
and sync
type of functions. This can be
particularly useful for when you need to run specific tasks in async mode, for example.
add_arguments()¶
This is the place where you add any argument needed to run your custom directive. The arguments
are argparse
related arguments so the syntax should be familiar.
import argparse
from typing import Any, Type
from esmerald.core.directives import BaseDirective
from esmerald.core.terminal import Print
printer = Print()
class Directive(BaseDirective):
def add_arguments(self, parser: Type["argparse.ArgumentParser"]) -> Any:
"""Arguments needed to create a user"""
parser.add_argument("--first-name", dest="first_name", type=str, required=True)
parser.add_argument("--last-name", dest="last_name", type=str, required=True)
parser.add_argument("--username", dest="username", type=str, required=True)
parser.add_argument("--email", dest="email", type=str, required=True)
parser.add_argument("--password", dest="password", type=str, required=True)
async def handle(self, *args: Any, **options: Any) -> Any:
# Runs the handle logic in async mode
...
As you can see, the Directive has five parameters and all of them required.
esmerald --app teste.main:app run --directive mydirective --first-name Esmerald --last-name Framework --email example@esmerald.dev --username esmerald --password esmerald
Help¶
There are two helps in place for the directives. The one you run the esmerald executor (run) and the
one for the directive
.
--help¶
This command is only used for the executor help, for example:
$ esmerald run --help
-h¶
This flag is used to access the directive
help and not the run
.
$ esmerald run --directive mydirective -h
Notes¶
The only way to see the help of a directive is via -h
.
If --help
is used, it will only show the help of the run
and not the directive
itself.
Order of priority¶
This is very important to understand.
What happens if we have two custom directives with the same name?
Let us use the following structure as example:
.
├── Taskfile.yaml
└── myproject
├── __init__.py
├── apps
│ ├── accounts
│ │ ├── directives
│ │ │ ├── __init__.py
│ │ │ └── operations
│ │ │ ├── createsuperuser.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── tests.py
│ │ └── v1
│ │ ├── __init__.py
│ │ ├── schemas.py
│ │ ├── urls.py
│ │ └── controllers.py
├── configs
│ ├── __init__.py
│ ├── development
│ │ ├── __init__.py
│ │ └── settings.py
│ ├── settings.py
│ └── testing
│ ├── __init__.py
│ └── settings.py
├── directives
│ ├── __init__.py
│ └── operations
│ ├── createsuperuser.py
│ └── __init__.py
├── main.py
├── serve.py
├── tests
│ ├── __init__.py
│ └── test_app.py
└── urls.py
This example is simulating a structure of an esmerald project with two custom directives with the same name.
The first directive is inside ./directives/operations/
and the second inside
./apps/accounts/directives/operations
.
Esmerald directives work on a First Found First Executed principle and that means if you have two custom directives with the same name, esmerald will execute the first found directive with that given name.
In other words, if you want to execute the createsuperuser
from the accounts
, the first found
directive inside ./directives/operations/
shall have a different name or else it will execute
it instead of the intended from accounts
.
Execution¶
Esmerald directives use the same events as the one passed in the application.
For example, if you want to execute database operations and the database connections should be established before hand, you can do in two ways:
- Use Lifespan events and the directives will use them.
- Establish the connections (open and close) inside the Directive directly.
The pratical example uses the lifespan events to execute the operations. This way you only need one place to manage the needed application events.
A practical example¶
Let us run an example of a custom directive for your application. Since we keep mentioning the
createsuperuser
often, let us then create that same directive and apply to our Esmerald application.
For this example we will be using Saffier since it is from the same author and will allow us to do a complete end-to-end directive using the async approach.
This example is very simple in its own design.
For production you should have your models inside a models dedicated place and your registry
and database
settings somewhere in your settings
where you can access it anywhere in your code via
esmerald settings, for example.
P.S.: For the registry and database strategy with saffier, it is good to have a read the tips and tricks with saffier.
The design is up to you.
What we will be creating:
- myproject/main/main.py - The entry-point for our Esmerald application
- createsuperuser - Our directive.
In the end we simply run the directive.
We will be also using the saffier support from Esmerald models as this will make the example simpler.
The application entrypoint¶
import saffier
from saffier import Database, Registry
from esmerald import Esmerald
from esmerald.contrib.auth.saffier.base_user import AbstractUser
database = Database("postgres://postgres:password@localhost:5432/my_db")
registry = Registry(database=database)
class User(AbstractUser):
date_of_birth = saffier.DateField(null=True)
class Meta:
registry = registry
def get_application():
"""
This is optional. The function is only used for organisation purposes.
"""
app = Esmerald(
routes=[],
on_startup=[database.connect],
on_shutdown=[database.disconnect],
)
return app
app = get_application()
The connection string should be replaced with whatever is your detail.
The createsuperuser¶
Now it is time to create the directive createsuperuser
. As mentioned above,
the directive shall be inside a directives/operations
package.
import argparse
import random
import string
from typing import Any, Type
from asyncpg.exceptions import UniqueViolationError
from esmerald.core.directives import BaseDirective
from esmerald.core.terminal import Print
from ..main import User
printer = Print()
class Directive(BaseDirective):
help: str = "Creates a superuser"
def add_arguments(self, parser: Type["argparse.ArgumentParser"]) -> Any:
parser.add_argument("--first-name", dest="first_name", type=str, required=True)
parser.add_argument("--last-name", dest="last_name", type=str, required=True)
parser.add_argument("--username", dest="username", type=str, required=True)
parser.add_argument("--email", dest="email", type=str, required=True)
parser.add_argument("--password", dest="password", type=str, required=True)
def get_random_string(self, length=10):
letters = string.ascii_lowercase
result_str = "".join(random.choice(letters) for i in range(length))
return result_str
async def handle(self, *args: Any, **options: Any) -> Any:
"""
Generates a superuser
"""
first_name = options["first_name"]
last_name = options["last_name"]
username = options["username"]
email = options["email"]
password = options["password"]
try:
user = await User.query.create_superuser(
first_name=first_name,
last_name=last_name,
username=username,
email=email,
password=password,
)
except UniqueViolationError:
printer.write_error(f"User with email {email} already exists.")
return
printer.write_success(f"Superuser {user.email} created successfully.")
And this should be it. We now have a createsuperuser
and an application and now we can run
in the command line:
Using the auto discover
$ esmerald run --directive createsuperuser --first-name Esmerald --last-name Framework --email example@esmerald.dev --username esmerald --password esmerald
Using the --app or ESMERALD_DEFAULT_APP
$ esmerald --app myproject.main:app run --directive createsuperuser --first-name Esmerald --last-name Framework --email example@esmerald.dev --username esmerald --password esmerald
Or
$ export ESMERALD_DEFAULT_APP=myproject.main:app
$ esmerald run --directive createsuperuser --first-name Esmerald --last-name Framework --email example@esmerald.dev --username esmerald --password esmerald
After the command is executed, you should be able to see the superuser created in your database.