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,
    )