The CLI applications we write usually tend to follow the same pattern:
All commands require logging/sentry to have been initialized
All commands require being wrapped to handle uncaught exceptions
All commands require one or more of: config, one or more database connections, or one or more API clients of some sort.
Often we want some way to manage verbosity
Often we want some way to run idempotent, equivalents of commands, without committing any changes they might make.
The mechanism strapp exposes to facilitate these requirements and is the
The two primary goals were:
Enable the production of the various objects cli command invocations might require lazily, such that any command which did not require i.e. config, did not load config.
Reduce the boilerplate required to either inject or construct those objects.
Again note: if at any time, the patterns expected by Strapp dont work in a given situation, resolver methods always return Click-native primitives which can used normally, using normal click patterns.
With that being said, a typical click project using Strapp tends to look like so:
pyproject.toml / setup.py src/ projectname/ cli/ __init__.py base.py commandset1.py commandset2.py ... the rest of the project
We then use the
setup.py to produce an entrypoint script.
[tool.poetry.scripts] projectname = "projectname.cli:run"
In order to avoid circular imports when making use of the resolver in dependent subcommands, we imperitively add the commands to the base cli, after everything has been constructed.
# flake8: noqa from platform_actions.cli import base, commandset1, commandset2 base.cli.add_command(commandset1.commandset1) base.cli.add_command(commandset2.commandset2) def run(): base.cli()
base.py, we produce callables for all the resolvable resources, and instantiate the resolver.
import click import strapp.click import strapp.sqlalchemy import projectname from configly import Config def config(): return Config.from_yaml("config.yml") def api_client(config): return projectname.api_client.APIClient(config.api_client) def postgres(config, dry_run): return strapp.sqlalchemy.create_session(config.postgres, dry_run=dry_run) def redshift(config, dry_run): return strapp.sqlalchemy.create_session(config.redshift, dry_run=dry_run) resolver = strapp.click.Resolver( config=config, postgres=postgres, redshift=redshift, api_client=api_client, ) @resolver.group() @click.option("--dry-run", is_flag=True) @click.option("-v", "--verbose", count=True, default=0) def cli(config: Config, dry_run, verbose): resolver.register_values(dry_run=dry_run, verbosity=verbose)
cli base group is the ideal spot to integrate with Logging.
And finally, commandset1/2 can be structured however they please. We tend to follow a pattern like:
import click from projectname.cli.base import resolver import projectname @resolver.group() def commandset1(): pass @resolver.command(commandset1, help='subcommand') @click.option('--some-option') def subcommand(postgres, redshift, api_client): projectname.do_something(postgres, redshift, api_client)
While it doesn’t make a difference from a Strapp perspective, keeping a strict barrier between the click cli structure and the actual code which performs the actions of the cli tends to make testing much easier, tests just need to produce test-stubs for the arguments rather than needing to interact with click’s testing facilities.
We also include a testing module to reduce to boilerplate associated with testing cli commands.