Skip to main content

A collection of command line helper scripts wrapping tools used during Python development.

Project description

🧰  Delfino  🧰

Plugable Click command finder/loader/executor.

CircleCI Codecov GitHub tag (latest SemVer) Maintenance GitHub last commit PyPI - Python Version Downloads

Table of content

What is Delfino

Delfino is a wrapper around Click command line scripts. It automatically discovers instances of Click commands and offers them for execution. However, the biggest power comes from the possibility of creating plugins, which can be distributed as standard Python packages.

Plugins

Plugins can greatly reduce code duplication and/or promote your own standards in multiple places. For example, you can create a plugin wrapping common linting tools that you use on your projects, including their default configuration. Keeping the rules and creating new projects with the same style suddenly becomes a matter of installing one Python library.

Each plugin can contain one or more Click commands that are automatically discovered and exposed by Delfino. See delfino-demo for a minimal plugin, which provide a demo command printing out a message.

Existing plugins:

Plugin name Description
delfino-demo A minimal plugin example for Delfino. Contains one command printing a message.
delfino-core Commands wrapping tools used during every day development (linting, testing, dependencies update).
delfino-docker Docker build helper script.

Installation

  • pip: pip install delfino
  • Poetry: poetry add --group=dev delfino
  • Pipenv: pipenv install -d delfino

or

  • pip: pip install delfino[completion]
  • Poetry: poetry add --group=dev delfino[completion]
  • Pipenv: pipenv install -d delfino[completion]

to enable auto-completion.

Configuration

All configuration is expected to live in the pyproject.toml file.

Enabling a plugin

For security reasons, plugins are disabled by default. To enable a plugin, you have to include it in the pyproject.toml file:

[tool.delfino.plugins.<PLUGIN_NAME>]

Enabling/disabling commands

By default, all commands are enabled. Use enable_commands or disable_commands to show only a subset of commands. If both used, disabled commands are subtracted from the set of enabled commands.

# [tool.delfino.plugins.<PLUGIN_NAME_A>]
# enable_commands = [<COMMAND_NAME>]
# disable_commands = [<COMMAND_NAME>]

# [tool.delfino.plugins.<PLUGIN_NAME_B>]
# enable_commands = [<COMMAND_NAME>]
# disable_commands = [<COMMAND_NAME>]

Usage

Run delfino --help to see all available commands and their usage.

Development

Delfino is a simple wrapper around Click commands. Any Click command will be accepted by Delfino.

Commands discovery

Delfino looks for any click.Command sub-class in the following locations:

  • commands folder in the root of the project (next to the pyproject.toml file). This location is useful for commands that don't need to be replicated in multiple locations/projects.
  • python module import path (<IMPORT_PATH>) specified by entry_point of a plugin:
    [tool.poetry.plugins] # Optional super table
    
    [tool.poetry.plugins."delfino.plugin"]
    "delfino-<PLUGIN_NAME>" = "<IMPORT_PATH>"
    

Any files starting with an underscore, except for __init__.py, will be ignored.

Warning Folders are NOT inspected recursively. If you place any commands into nested folders, they will not be loaded by Delfino.

Minimal command

  1. Create a commands folder:
    mkdir commands
    
  2. Create a commands/__init__.py file, with the following content:
    import click
    
    @click.command()
    def command_test():
        """Tests commands placed in the `commands` folder are loaded."""
        print("✨ This command works! ✨")
    
  3. See if Delfino loads the command. Open a terminal and in the root of the project, call: delfino --help. You should see something like this:
    Usage: delfino [OPTIONS] COMMAND [ARGS]...
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      ...
      command-test            Tests commands placed in the `commands` folder...
      ...
    
  4. Run the command with delfino command-test

Minimal plugin

If you'd like to use one or more commands in multiple places, you can create a plugin. A plugin is just a regular Python package with specific entry point telling Delfino it should use it. It can also be distributed as any other Python packages, for example via Pypi.

The quickest way to create one is to use a Delfino plugin cookiecutter template, which asks you several questions and sets up the whole project.

Alternatively, you can get inspired by the demo plugin or any of the other existing plugins.

Advanced usage

Auto-completion

You can either attempt to install completions automatically with:

delfino --install-completion

or generate it with:

delfino --show-completion

and manually put it in the relevant RC file.

The auto-completion implementation is dynamic so that every time it is invoked, it uses the current project. Each project can have different commands or disable certain commands it doesn't use. And dynamic auto-completion makes sure only the currently available commands will be suggested.

The downside of this approach is that evaluating what is available each time is slower than a static list of commands.

Running external programs

It is up to you how you want to execute external processes as part of commands (if you need to at all). A common way in Python is to use subprocess.run. Delfino comes with its own run implementation, which wraps and simplifies subprocess.run for the most common use cases:

  • Normalizing subprocess.run arguments - you can pass in either a string or a list. Either way, subprocess.run will be executed correctly.
  • Handling errors from the execution via the on_error argument. Giving the option to either ignore the errors and continue (PASS), not continue and clean exit (EXIT) or not continue and abort with error code (ABORT).
  • Setting environment variables.
  • Logging what is being executed in the debug level.

Example:

# commands/__init__.py

import click
from delfino.execution import run, OnError

@click.command()
def test():
    run("pytest tests", on_error=OnError.ABORT)

Optional dependencies

If you put several commands into one plugin, you can make some dependencies of some commands optional. This is useful when a command is not always used, and you don't want to install unnecessary dependencies. Instead, you can check if a dependency is installed only when the command is executed with delfino.validation.assert_pip_package_installed:

# commands/__init__.py

import click
from delfino.validation import assert_pip_package_installed

try:
    from git import Repo
except ImportError:
    pass

@click.command()
def git_active_branch():
    assert_pip_package_installed("gitpython")
    print(Repo(".").active_branch)

In the example above, if gitpython is not installed, delfino will show the command but will fail with suggestion to install gitpython only when the command is executed. You can also add git_active_branch into disable_commands config in places where you don't intend to use it.

This way you can greatly reduce the number of dependencies a plugin brings into a project without a need to have many small plugins.

Project settings

You can store an arbitrary object in the Click context as click.Context.obj. Delfino utilizes this object to store an instance of AppContext, which provides access to project related information. If you need to, you can still attach arbitrary attributes to this object later.

You can pass this object to your commands by decorating them with click.pass_obj:

# commands/__init__.py

import click
from delfino.models.app_context import AppContext

@click.command()
@click.pass_obj
def print_app_version(obj: AppContext):
    print(obj.pyproject_toml.tool.poetry.version)

Plugin settings

Plugin settings are expected to live in the pyproject.toml file. To prevent naming conflicts, each plugin must put its settings under tool.delfino.plugins.<PLUGIN_NAME>. It also allows Delfino to pass these settings directly to commands from these plugins.

Delfino loads, parses, validates and stores plugin settings in AppContext.plugin_config. If not specified otherwise (see below), it will be an instance of PluginConfig, with any extra keys unvalidated and in JSON-like Python objects.

You can add additional validation to your plugin settings by sub-classing the PluginConfig , defining expected keys, default values and/or validation. Delfino utilizes pydantic to create data classes.

Delfino also needs to know, which class to use for the validation. To do that, switch to delfino.decorators.pass_app_context instead of click.pass_obj:

# pyproject.toml

[tool.delfino.plugins.delfino_login_plugin]
username = "user"
# commands/__init__.py

import click
from delfino.models.pyproject_toml import PluginConfig
from delfino.models.app_context import AppContext
from delfino.decorators import pass_app_context


class LoginPluginConfig(PluginConfig):
    login: str


@click.command()
@pass_app_context(LoginPluginConfig)
def login(app_context: AppContext[LoginPluginConfig]):
    print(app_context.plugin_config.login)

The AppContext class is generic. Defining the PluginConfigType (such as AppContext[LoginPluginConfig] in the example above) enables introspection and type checks.

Project specific overrides

It is likely your projects will require slight divergence to the defaults you encode in your scripts. The following sections cover the most common use cases.

Pass-through arguments

You can pass additional arguments to downstream tools by decorating commands with the decorators.pass_args decorator:

# commands/__init__.py

from typing import Tuple

import click
from delfino.decorators import pass_args
from delfino.execution import run, OnError

@click.command()
@pass_args
def test(passed_args: Tuple[str, ...]):
    run(["pytest", "tests", *passed_args], on_error=OnError.ABORT)

Then additional arguments can be passed either via command line after --:

delfino test -- --capture=no

Or via configuration in the pyproject.toml file:

[tool.delfino.plugins.<PLUGIN>.test]
pass_args = ['--capture=no']

Either way, both will result in executing pytest tests --capture=no.

Files override

You can override files passed to downstream tools by decorating commands with the decorators.files_folders_option decorator:

# commands/__init__.py

from typing import Tuple

import click
from delfino.decorators import files_folders_option
from delfino.execution import run, OnError

@click.command()
@files_folders_option
def test(files_folders: Tuple[str, ...]):
    if not files_folders:
        files_folders = ("tests/unit", "tests/integration")
    run(["pytest", *files_folders], on_error=OnError.ABORT)

Then the default "tests/unit", "tests/integration" folders can be overridden either via command line options -f/--file/--folder:

delfino test -f tests/other

Or via configuration in the pyproject.toml file:

[tool.delfino.plugins.<PLUGIN>.test]
files_folders = ['tests/other']

Either way, both will result in executing pytest tests/other.

Grouping commands

Often it is useful to run several commands as a group with a different command name. Click supports calling other commands with click.Context.forward or click.Context.invoke.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

delfino-1.1.1.tar.gz (27.0 kB view hashes)

Uploaded Source

Built Distribution

delfino-1.1.1-py3-none-any.whl (26.7 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page