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¶
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.
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.