2

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

1 Answer 1

2

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.

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks @Tony N , your answer and your example are really helpful. I didn't notice before that __init__.py:cli AND commands/admin.py:cli are called. It even works without setting allow_interspersed_args=True - the generated help texts then don't tell you about the global options though.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.