Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion connect/cli/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,50 @@ def decorator(f):
return decorator


class LazyGroup(click.Group):
"""A group that imports a subgroup only when it is called.

Until then, `ccli --help` lists the subgroup by the short help it was added with.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.lazy_commands = {}

def add_lazy_command(self, name, short_help, entrypoint):
if name not in self.commands:
self.lazy_commands[name] = (short_help, entrypoint)

def list_commands(self, ctx):
return sorted({*super().list_commands(ctx), *self.lazy_commands})

def get_command(self, ctx, cmd_name):
if cmd_name in self.lazy_commands:
_, entrypoint = self.lazy_commands[cmd_name]
self.add_command(entrypoint.load()(), cmd_name)
del self.lazy_commands[cmd_name]
return super().get_command(ctx, cmd_name)

def format_commands(self, ctx, formatter):
commands = []
for name in self.list_commands(ctx):
if name in self.lazy_commands:
commands.append((name, self.lazy_commands[name][0]))
continue
cmd = super().get_command(ctx, name)
if cmd is not None and not cmd.hidden:
commands.append((name, cmd))
if commands:
limit = formatter.width - 6 - max(len(name) for name, _ in commands)
with formatter.section('Commands'):
formatter.write_dl(
[
(name, cmd if isinstance(cmd, str) else cmd.get_short_help_str(limit))
for name, cmd in commands
],
)


def group(name=None, **attrs):
attrs.setdefault('cls', CCliGroup)
return click.command(name, **attrs)
Expand All @@ -59,7 +103,7 @@ def print_version(ctx, param, value):
ctx.exit()


@click.group(context_settings={'help_option_names': ['-h', '--help']})
@click.group(cls=LazyGroup, context_settings={'help_option_names': ['-h', '--help']})
@click.option(
'--version',
is_flag=True,
Expand Down
23 changes: 22 additions & 1 deletion connect/cli/core/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@
from connect.cli.core.utils import iter_entry_points


# The built-in command groups by entry point: their command name and the short help that
# `ccli --help` shows. Knowing them up front, ccli imports a group only when it runs: some
# pull in heavy libraries, such as WeasyPrint for the PDF reports.
# tests/core/test_plugins.py checks this table against the groups themselves.
BUILTIN_GROUPS = {
'commerce': ('commerce', 'Manage commerce definitions.'),
'customer': ('customer', 'Export/synchronize customers.'),
'locales': ('locale', 'List all locales available.'),
'play': ('play', 'Play connect scripts.'),
'product': ('product', 'Manage product definitions.'),
'project': ('project', 'Manage project definitions.'),
'report': ('report', 'Reports execution and development.'),
'translation': ('translation', 'Manage translations.'),
}


def load_plugins(cli):
@click.group(name='plugin', short_help='Third party plugins.')
def grp_plugins():
Expand All @@ -15,7 +31,12 @@ def grp_plugins():
has_3rd_party_plugins = False

for entrypoint in iter_entry_points('connect.cli.plugins'):
if entrypoint.value.startswith('connect.cli.plugins.'):
if (
entrypoint.value.startswith('connect.cli.plugins.')
and entrypoint.name in BUILTIN_GROUPS
):
cli.add_lazy_command(*BUILTIN_GROUPS[entrypoint.name], entrypoint)
elif entrypoint.value.startswith('connect.cli.plugins.'):
command_fn = entrypoint.load()
cli.add_command(command_fn())
else:
Expand Down
77 changes: 76 additions & 1 deletion tests/core/test_plugins.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import subprocess
import sys
from importlib.metadata import EntryPoint

import click

from connect.cli.core.plugins import load_plugins
from connect.cli.core.base import LazyGroup
from connect.cli.core.plugins import BUILTIN_GROUPS, load_plugins
from connect.cli.core.utils import iter_entry_points


def test_load_plugins(mocker):
Expand Down Expand Up @@ -60,3 +64,74 @@ def test_load_no_external(mocker):

assert 'internal' in cli.commands
assert 'plugin' not in cli.commands


def test_builtin_groups_match_the_installed_plugins():
entrypoints = {
ep.name: ep
for ep in iter_entry_points('connect.cli.plugins')
if ep.value.startswith('connect.cli.plugins.')
}

assert set(entrypoints) == set(BUILTIN_GROUPS)
for ep_name, (name, short_help) in BUILTIN_GROUPS.items():
grp = entrypoints[ep_name].load()()
assert (grp.name, grp.short_help) == (name, short_help)


def test_load_plugins_adds_builtin_groups_lazily(mocker):
cli = LazyGroup()
cli.add_command(click.Command('account', short_help='Manage accounts.'))
cli.add_command(click.Command('secret', hidden=True))
grp_product = click.Group('product', short_help='Manage product definitions.')
load = mocker.patch.object(EntryPoint, 'load', return_value=lambda: grp_product)
mocker.patch(
'connect.cli.core.plugins.iter_entry_points',
return_value=iter(
[
EntryPoint('product', 'connect.cli.plugins.product.commands:get_group', None),
],
),
)

load_plugins(cli)
ctx = click.Context(cli)
help_text = cli.get_help(ctx)

assert 'product' not in cli.commands
assert cli.list_commands(ctx) == ['account', 'product', 'secret']
assert 'Manage accounts.' in help_text
assert 'Manage product definitions.' in help_text
assert 'secret' not in help_text
load.assert_not_called()

assert cli.get_command(ctx, 'product') is grp_product
assert cli.get_command(ctx, 'product') is grp_product
load.assert_called_once()
assert cli.lazy_commands == {}

cli.add_lazy_command('product', 'Manage product definitions.', None)
assert cli.lazy_commands == {}


def test_lazy_group_without_commands_has_no_commands_section():
cli = LazyGroup()

assert 'Commands' not in cli.get_help(click.Context(cli))


def test_help_imports_no_command_group():
code = (
'import sys\n'
"sys.argv = ['ccli', '--help']\n"
'from connect.cli.ccli import main\n'
'main()\n'
"print(sorted(m for m in sys.modules if m.startswith('connect.cli.plugins.')), file=sys.stderr)\n"
)

result = subprocess.run(
[sys.executable, '-c', code], capture_output=True, text=True, check=True
)

assert 'Manage product definitions.' in result.stdout
assert result.stderr.strip().splitlines()[-1] == '[]'
Loading