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.