Skip to content
Advertisement

Global options for python-click MultiCommand

I’m implementing a classical CLI toolbox with python and I selected click as my argument parser. Adding a command should just be adding a file. From there the command is listed in the help and so on. This part is working through a click MultiCommand.

What I didn’t achieve yet are global options like loglevel or configfile. I don’t want every command to deal with the options. I think most global options create somewhat global state. How do achieve this, I’m lost. I also think that this something that could very well be covered by the official documentation.

# __init__.py
import pathlib
import click
import os
import typing


class ToolboxCLI(click.MultiCommand):
    commands_folder = pathlib.Path.joinpath(
        pathlib.Path(__file__).parent, "commands"
    ).resolve()

    def list_commands(self, ctx: click.Context) -> typing.List[str]:
        rv = []
        for filename in os.listdir(self.commands_folder):
            if filename.endswith(".py") and not filename.startswith("__init__"):
                rv.append(filename[:-3])
        rv.sort()
        return rv

    def get_command(
        self, ctx: click.Context, cmd_name: str
    ) -> typing.Optional[click.Command]:
        ns = {}
        fn = pathlib.Path.joinpath(self.commands_folder, cmd_name + ".py")
        with open(fn) as f:
            code = compile(f.read(), fn, "exec")
            eval(code, ns, ns)
        return ns["cli"]

@click.group(cls=ToolboxCLI)
@click.option("--loglevel")
def cli(loglevel):
    "Toolbox CLI "


# commands/admin.py
import click


@click.group() # <- how do i get global options for this command?
def cli():
    pass


@cli.command()
def invite():
    pass

Advertisement

Answer

click_example.py:

#!/usr/bin/env python

import os
import pathlib
import typing

import click


class ToolboxCLI(click.MultiCommand):
    commands_folder = pathlib.Path.joinpath(
        pathlib.Path(__file__).parent, "commands"
    ).resolve()

    def list_commands(self, ctx: click.Context) -> typing.List[str]:
        rv = []
        for filename in os.listdir(self.commands_folder):
            if filename.endswith(".py") and not filename.startswith("__init__"):
                rv.append(filename[:-3])
        rv.sort()
        return rv

    def get_command(
        self, ctx: click.Context, cmd_name: str
    ) -> typing.Optional[click.Command]:
        ns = {}
        fn = pathlib.Path.joinpath(self.commands_folder, cmd_name + ".py")
        with open(fn) as f:
            code = compile(f.read(), fn, "exec")
            eval(code, ns, ns)
        return ns["cli"]


@click.group(
    cls=ToolboxCLI,
    context_settings={
        # Step 1: Add allow_interspersed_args to context settings defaults
        "allow_interspersed_args": True,
    },
)
@click.option("--log-level")
def cli(log_level):
    "Toolbox CLI"


if __name__ == "__main__":
    cli()

Above: Add allow_interspersed_args so --log-level can be accessed anywhere

Note: I renamed --loglevel -> --log-level

In commands/admin_cli.py:

import click


@click.group()  # <- how do i get global options for this command?
@click.pass_context  # Step 2: Add @click.pass_context decorator for context
def cli(ctx):
    # Step 3: ctx.parent to access root scope
    print(ctx.parent.params.get("log_level"))
    pass


@cli.command()
@click.pass_context
def invite(ctx):
    pass

Use @click.pass_context and Context.parent to fetch the params of the root scope.

Setup: chmod +x ./click_example.py

Output:

❯ ./click_example.py admin_cli invite --log-level DEBUG
DEBUG

P.S. I am using something similar to this pattern in a project of mine (vcspull), see vcspull/cli/. Inside of it I pass the log level param to a setup_logger(log=None, level='INFO') function. This source is MIT licensed so you / anyone is free to use it as an example.

User contributions licensed under: CC BY-SA
2 People found this is helpful
Advertisement