Skip to content

Custom Directives

Having built-in directives from Lilya 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 Lilya 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 Lilya application resources?

This is what a directive is.

Tip

If you are familiar with Django management commands, Lilya 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.

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

$ lilya --app <LOCATION> run --directive <DIRECTIVE-NAME> <OPTIONS>

Example:

lilya --app myproject.main:app run --directive mydirective --name lilya

With the LILYA_DEFAULT_APP environment variable set

$ export LILYA_DEFAULT_APP=myproject.main:app
$ lilya run --directive <DIRECTIVE-NAME> <OPTIONS>

Example:

$ export LILYA_DEFAULT_APP=myproject.main:app
$ lilya run --directive mydirective --name lilya

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 LILYA_DEFAULT_APP=myproject.main:app
$ lilya run --directive createsuperuser --email example@lilya.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 lilya.cli.base import BaseDirective

Create the Directive class

import argparse
from typing import Any, Type

from lilya.cli.base 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 lilya 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 lilya 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, lilya will execute the handle() and run the all the logic.

import argparse
from typing import Any, Type

from lilya.cli.base import BaseDirective
from lilya.cli.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 lilya.cli.base import BaseDirective
from lilya.cli.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, Lilya 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 lilya.cli.base import BaseDirective
from lilya.cli.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.

lilya --app teste.main:app run --directive mydirective --first-name Lilya --last-name Toolkit --email example@lilya.dev --username lilya --password lilya

Help

There are two helps in place for the directives. The one you run the lilya executor (run) and the one for the directive.

--help

This command is only used for the executor help, for example:

$ lilya run --help

-h

This flag is used to access the directive help and not the run.

$ lilya 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 a lilya project with two custom directives with the same name.

The first directive is inside ./directives/operations/ and the second inside ./apps/accounts/directives/operations.

Lilya directives work on a First Found First Executed principle and that means if you have two custom directives with the same name, lilya 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

Lilya 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 Lilya application.

For this example we will be using Saffier as it 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 lilya 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 Lilya application
  • createsuperuser - Our directive.

In the end we simply run the directive.

We will be also using the Saffier for the DB models as this will make the example simpler.

The application entrypoint

myproject/main.py
from typing import Any

import saffier
from saffier import Database, Registry

from lilya.apps import Lilya

database = Database("postgres://postgres:password@localhost:5432/my_db")
registry = Registry(database=database)


class User(saffier.Model):
    """
    Base model used for a custom user of any application.
    """

    first_name = saffier.CharField(max_length=150)
    last_name = saffier.CharField(max_length=150)
    username = saffier.CharField(max_length=150, unique=True)
    email = saffier.EmailField(max_length=120, unique=True)
    password = saffier.CharField(max_length=128)
    last_login = saffier.DateTimeField(null=True)
    is_active = saffier.BooleanField(default=True)
    is_staff = saffier.BooleanField(default=False)
    is_superuser = saffier.BooleanField(default=False)

    class Meta:
        registry = registry

    @classmethod
    async def create_superuser(
        cls,
        username: str,
        email: str,
        password: str,
        **extra_fields: Any,
    ):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        return await cls._create_user(username, email, password, **extra_fields)

    @classmethod
    async def _create_user(cls, username: str, email: str, password: str, **extra_fields: Any):
        """
        Create and save a user with the given username, email, and password.
        """
        if not username:
            raise ValueError("The given username must be set")
        user = await cls.query.create(
            username=username, email=email, password=password, **extra_fields
        )
        return user


def get_application():
    """
    This is optional. The function is only used for organisation purposes.
    """

    app = Lilya(
        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 lilya.cli.base import BaseDirective
from lilya.cli.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

$ lilya run --directive createsuperuser --first-name Lilya --last-name Framework --email example@lilya.dev --username lilya --password lilya

Using the --app or LILYA_DEFAULT_APP

$ lilya --app myproject.main:app run --directive createsuperuser --first-name Lilya --last-name Framework --email example@lilya.dev --username lilya --password lilya

Or

$ export LILYA_DEFAULT_APP=myproject.main:app
$ lilya run --directive createsuperuser --first-name Lilya --last-name Framework --email example@lilya.dev --username lilya --password lilya

After the command is executed, you should be able to see the superuser created in your database.