Skip to content

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

myproject/main.py
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.

myproject/directives/operations/createsuperuser.py
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.