Custom peltak commands¶
Note
You can find the example code for this tutorial here: here
Background¶
When the built in scripts functionality is not enough peltak allows you to write commands directly in python. You are free to keep your peltak commands as part of your project or move them to a separate directory. You always need to specify what commands are available within your project’s pelconf.yaml so from peltak’s perspective it really doesn’t matter where you store them.
The rule of thumb is to start with project specific commands being stored as part of the project repo and as you progress you might extract some of your commands into a separate packages so you can reuse them in other projects or just share with the community.
peltak internally uses click
so everything you can find in the click docs applies here as well. peltak
does provide some functionality built on top of click that makes some of the
tasks easier to implement (pretend_option
for example), and
already provides the root CLI group (peltak.commands.root_cli
) as well as
some predefined groups that you can extend.
You can attach your commands to any existing group as well as create new groups and attach them wherever you want. peltak tries to be very flexible in terms of how you structure the CLI commands for your project.
How to create custom commands¶
Hello world command¶
Let’s start with the most basic command that will just print hello world to
the terminal. This will show you all the steps you need to take to implement the
command and make it available to peltak. Let’s initialize a new
pelconf.yaml
with peltak init
:
peltak init --blank -v
Note
The generated pelconf.yaml
should look like this:
# peltak configuration file
# Visit https://novopl.github.io/peltak for more information
pelconf_version: '1'
# You can add custom project commands or 3rd party packages here.
commands:
- peltak.extra.scripts
# This directory will be added to sys.path when the config is loaded.
# Useful if do not keep the source code in th root directory.
src_dir: "."
The next step is to create the python module with the commands. It has to be
importable by peltak so needs to be inside whatever src_dir
in pelconf.yaml
is set to. Let’s create a new file called ./custom_commands.py
:
# -*- coding: utf-8 -*-
""" Custom command definitions for the project. """
from __future__ import absolute_import, unicode_literals
from peltak.commands import root_cli, click
For peltak to be able to load your command file you need to add it to your
commands:
section in pelconf.yaml
:
# peltak configuration file
# Visit https://novopl.github.io/peltak for more information
pelconf_version: '1'
# You can add custom project commands or 3rd party packages here.
commands:
- peltak.extra.scripts
- custom_commands
# This directory will be added to sys.path when the config is loaded.
# Useful if do not keep the source code in th root directory.
src_dir: "."
Now our new command is visible to peltak and we can execute it with:
$ peltak hello-world
Hello, World!
A more advanced example¶
As a more advanced example, we will re-implement the lint script defined
in the Quickstart guide. First let’s define the command itself along
with it’s options to match the files:
section so we can filter the files
that will be linted:
# -*- coding: utf-8 -*-
""" Custom command definitions for the project. """
from __future__ import absolute_import, unicode_literals
from peltak.commands import root_cli, click
@root_cli.command('hello-world')
def hello_world():
""" Hello world command. """
print('Hello, World!')
@root_cli.command('lint')
@click.argument(
'paths',
nargs=-1,
required=True,
type=click.Path(exists=True),
)
@click.option(
'-i', '--include',
multiple=True,
help='A pattern to include in linting. Can be passed multiple times. This '
'can be used to limit the results in the given paths to only files '
'that match the include patterns.',
)
@click.option(
'-e', '--exclude',
multiple=True,
help='A pattern to exclude from linting. Can be passed multiple times.',
)
@click.option(
'--commit', 'only_staged',
is_flag=True,
help='If given, only files staged for commit will be checked.',
)
@click.option(
'--ignore-untracked',
is_flag=True,
help='Include untracked files',
)
def lint(paths, include, exclude, only_staged, ignore_untracked):
""" Run code checks (pylint + mypy) """
from peltak.core import log
log.info('<0><1>{}', '-' * 60)
log.info('paths: {}', paths)
log.info('include: {}', include)
log.info('exclude: {}', exclude)
log.info('only_staged: {}', only_staged)
log.info('ignore_untracked: {}', ignore_untracked)
log.info('<0><1>{}', '-' * 60)
Warning
Try not to import anything at the top module level in the module that defines commands. Only things that are required to define the command (and it’s arguments) should be imported at top level.
This is due to the fact that all command modules need to be imported during shell completion, so the more they import globally the slower the completion gets. This might not be an issue with just one module misbehaving, but if that laziness spreads it quickly becomes noticeable and hurts user experience.
Ideally you want to have a separate module with the implementation of each command you export. The click command handlers just import that code inside the command handler. This way the import won’t happen until the command is actually executed and it keeps the command file size small for faster parsing.
Now we will create a new file with the business logic behind our lint command.
In this tutorial we will call it custom_commands_lint.py
but you can use
whatever structure you like.
# -*- coding: utf-8 -*-
""" Custom commands business logic. """
from __future__ import absolute_import, unicode_literals
# stdlib imports
from typing import Sequence
# local imports
from peltak.core import fs
from peltak.core import log
from peltak.core import shell
from peltak.core import types
def check(paths, include, exclude, only_staged, untracked):
# type: (str, Sequence[str], Sequence[str], bool, bool) -> None
""" Run mypy and pylint against the current directory."""
files = types.FilesCollection(
paths=paths,
include=['*.py'] + list(include), # We only want to lint python files.
exclude=exclude,
only_staged=only_staged,
untracked=untracked,
)
paths = fs.collect_files(files)
wrapped_paths = fs.wrap_paths(paths)
log.info("Paths: <33>{}", paths)
log.info("Wrapped paths: <33>{}", wrapped_paths)
log.info("Running <35>mypy")
shell.run('mypy --ignore-missing-imports {}'.format(wrapped_paths))
log.info("Running <35>pylint")
shell.run('pylint {}'.format(wrapped_paths))
# Used only in type hint comments
del Sequence
The last bit is to actually call our lint function from our peltak command:
def lint(paths, include, exclude, only_staged, ignore_untracked):
""" Run code checks (pylint + mypy) """
from peltak.core import log
from custom_commands_logic import check
log.info('<0><1>{}', '-' * 60)
log.info('paths: {}', paths)
log.info('include: {}', include)
log.info('exclude: {}', exclude)
log.info('only_staged: {}', only_staged)
log.info('ignore_untracked: {}', ignore_untracked)
log.info('<0><1>{}', '-' * 60)
check(
paths=paths,
include=include,
exclude=exclude,
only_staged=only_staged,
untracked=not ignore_untracked,
)