launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #28568
[Merge] ~cjwatson/lpcraft:craft-cli-dispatcher into lpcraft:main
Colin Watson has proposed merging ~cjwatson/lpcraft:craft-cli-dispatcher into lpcraft:main.
Commit message:
Use the craft-cli command dispatcher
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/lpcraft/+git/lpcraft/+merge/424297
The main motivation for this is that it moves per-command argument handling to be alongside the implementation of the command. It also makes it easier to get rid of several fragile `getattr` calls.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:craft-cli-dispatcher into lpcraft:main.
diff --git a/NEWS.rst b/NEWS.rst
index da6454b..6e552fb 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -7,6 +7,8 @@ Version history
- Rewrite the release documentation.
+- Use the ``craft-cli`` command dispatcher.
+
0.0.15 (2022-06-01)
===================
diff --git a/lpcraft/commands/clean.py b/lpcraft/commands/clean.py
index 5d67579..95a19b4 100644
--- a/lpcraft/commands/clean.py
+++ b/lpcraft/commands/clean.py
@@ -1,34 +1,51 @@
# Copyright 2022 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
-from argparse import Namespace
+from argparse import ArgumentParser, Namespace
from pathlib import Path
-from craft_cli import emit
+from craft_cli import BaseCommand, emit
from lpcraft.config import Config
from lpcraft.providers import get_provider
-def clean(args: Namespace) -> int:
+class CleanCommand(BaseCommand):
"""Clean the managed environments for a project."""
- # We want to run the "clean" command only when run from
- # an lpcraft project directory.
- config_path = getattr(args, "config", Path(".launchpad.yaml"))
- Config.load(config_path)
-
- cwd = Path.cwd()
- emit.progress(
- f"Deleting the managed environments for the {cwd.name!r} project."
- )
-
- provider = get_provider()
- provider.ensure_provider_is_available()
-
- provider.clean_project_environments(
- project_name=cwd.name, project_path=cwd
- )
- emit.message(
- f"Deleted the managed environments for the {cwd.name!r} project."
- )
- return 0
+
+ name = "clean"
+ help_msg = __doc__.splitlines()[0]
+ overview = __doc__
+ common = True
+
+ def fill_parser(self, parser: ArgumentParser) -> None:
+ """Add arguments specific to this command."""
+ parser.add_argument(
+ "-c",
+ "--config",
+ type=Path,
+ default=".launchpad.yaml",
+ help="Read the configuration file from this path.",
+ )
+
+ def run(self, args: Namespace) -> int:
+ """Run the command."""
+ # We want to run the "clean" command only when run from
+ # an lpcraft project directory.
+ Config.load(args.config)
+
+ cwd = Path.cwd()
+ emit.progress(
+ f"Deleting the managed environments for the {cwd.name!r} project."
+ )
+
+ provider = get_provider()
+ provider.ensure_provider_is_available()
+
+ provider.clean_project_environments(
+ project_name=cwd.name, project_path=cwd
+ )
+ emit.message(
+ f"Deleted the managed environments for the {cwd.name!r} project."
+ )
+ return 0
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index 4a0ab82..0175b00 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -7,11 +7,11 @@ import itertools
import json
import os
import shlex
-from argparse import Namespace
+from argparse import ArgumentParser, Namespace
from pathlib import Path, PurePath
from typing import Dict, List, Optional, Set
-from craft_cli import EmitterMode, emit
+from craft_cli import BaseCommand, EmitterMode, emit
from craft_providers import Executor, lxd
from craft_providers.actions.snap_installer import install_from_store
from dotenv import dotenv_values
@@ -427,105 +427,187 @@ def _get_job_instance_name(provider: Provider, job: Job) -> str:
)
-def run(args: Namespace) -> int:
+class RunCommand(BaseCommand):
"""Run a pipeline, launching managed environments as needed."""
- # XXX jugmac00 2022-02-04: this fallback may become obsolete once we
- # use craft-cli's command dispatcher
- config_path = getattr(args, "config", Path(".launchpad.yaml"))
- config = Config.load(config_path)
- provider = get_provider()
- provider.ensure_provider_is_available()
- launched_instances = []
+ name = "run"
+ help_msg = __doc__.splitlines()[0]
+ overview = __doc__
+ common = True
+
+ def fill_parser(self, parser: ArgumentParser) -> None:
+ """Add arguments specific to this command."""
+ parser.add_argument(
+ "--output-directory",
+ type=Path,
+ help="Write output files to this directory.",
+ )
+ parser.add_argument(
+ "-c",
+ "--config",
+ type=Path,
+ default=".launchpad.yaml",
+ help="Read the configuration file from this path.",
+ )
+ parser.add_argument(
+ "--clean",
+ action="store_true",
+ default=False,
+ help=(
+ "Clean the managed environments created "
+ "for the pipeline after the running it."
+ ),
+ )
+ parser.add_argument(
+ "--apt-replace-repositories",
+ action="append",
+ help="Overwrite /etc/apt/sources.list.",
+ )
+ parser.add_argument(
+ "--set-env",
+ action="append",
+ help="Set an environment variable.",
+ )
- try:
- for stage in config.pipeline:
- stage_failed = False
- for job_name in stage:
- try:
- jobs = config.jobs.get(job_name, [])
- if not jobs:
- raise CommandError(
- f"No job definition for {job_name!r}"
- )
- for job in jobs:
- launched_instances.append(
- _get_job_instance_name(provider, job)
- )
- _run_job(
- job_name,
- job,
- provider,
- getattr(args, "output_directory", None),
- apt_replacement_repositories=getattr(
- args, "apt_replace_repositories", None
- ),
- env_from_cli=getattr(args, "set_env", None),
- )
- except CommandError as e:
- if len(stage) == 1:
- # Single-job stage, so just reraise this
- # in order to get simpler error messages.
- raise
- else:
- emit.error(e)
- stage_failed = True
- if stage_failed:
- raise CommandError(
- f"Some jobs in {stage} failed; stopping.", retcode=1
+ def run(self, args: Namespace) -> int:
+ """Run the command."""
+ config = Config.load(args.config)
+
+ provider = get_provider()
+ provider.ensure_provider_is_available()
+ launched_instances = []
+
+ try:
+ for stage in config.pipeline:
+ stage_failed = False
+ for job_name in stage:
+ try:
+ jobs = config.jobs.get(job_name, [])
+ if not jobs:
+ raise CommandError(
+ f"No job definition for {job_name!r}"
+ )
+ for job in jobs:
+ launched_instances.append(
+ _get_job_instance_name(provider, job)
+ )
+ _run_job(
+ job_name,
+ job,
+ provider,
+ args.output_directory,
+ apt_replacement_repositories=(
+ args.apt_replace_repositories
+ ),
+ env_from_cli=args.set_env,
+ )
+ except CommandError as e:
+ if len(stage) == 1:
+ # Single-job stage, so just reraise this
+ # in order to get simpler error messages.
+ raise
+ else:
+ emit.error(e)
+ stage_failed = True
+ if stage_failed:
+ raise CommandError(
+ f"Some jobs in {stage} failed; stopping.", retcode=1
+ )
+ finally:
+ if args.clean:
+ cwd = Path.cwd()
+ provider.clean_project_environments(
+ project_name=cwd.name,
+ project_path=cwd,
+ instances=launched_instances,
)
- finally:
- should_clean_environment = getattr(args, "clean", False)
-
- if should_clean_environment:
- cwd = Path.cwd()
- provider.clean_project_environments(
- project_name=cwd.name,
- project_path=cwd,
- instances=launched_instances,
- )
- return 0
+ return 0
-def run_one(args: Namespace) -> int:
+class RunOneCommand(BaseCommand):
"""Select and run a single job from a pipeline.
(This command is for use by Launchpad, and is subject to change.)
"""
- config = Config.load(args.config)
- jobs = config.jobs.get(args.job, [])
- if not jobs:
- raise CommandError(f"No job definition for {args.job!r}")
- if args.index >= len(jobs):
- raise CommandError(
- f"No job definition with index {args.index} for {args.job!r}"
+ name = "run-one"
+ help_msg = __doc__.splitlines()[0]
+ overview = __doc__
+ hidden = True
+
+ def fill_parser(self, parser: ArgumentParser) -> None:
+ """Add arguments specific to this command."""
+ parser.add_argument(
+ "--output-directory",
+ type=Path,
+ help="Write output files to this directory.",
)
- job = jobs[args.index]
-
- provider = get_provider()
- provider.ensure_provider_is_available()
-
- try:
- _run_job(
- args.job,
- job,
- provider,
- getattr(args, "output_directory", None),
- apt_replacement_repositories=getattr(
- args, "apt_replace_repositories", None
+ parser.add_argument(
+ "-c",
+ "--config",
+ type=Path,
+ default=".launchpad.yaml",
+ help="Read the configuration file from this path.",
+ )
+ parser.add_argument(
+ "--clean",
+ action="store_true",
+ default=False,
+ help=(
+ "Clean the managed environment created for the job "
+ "after running it."
),
- env_from_cli=getattr(args, "set_env", None),
)
- finally:
- should_clean_environment = getattr(args, "clean", False)
-
- if should_clean_environment:
- cwd = Path.cwd()
- provider.clean_project_environments(
- project_name=cwd.name,
- project_path=cwd,
- instances=[_get_job_instance_name(provider, job)],
+ parser.add_argument("job", help="Run only this job name.")
+ parser.add_argument(
+ "index",
+ type=int,
+ metavar="N",
+ help="Run only the Nth job with the given name (indexing from 0).",
+ )
+ parser.add_argument(
+ "--apt-replace-repositories",
+ action="append",
+ help="Overwrite /etc/apt/sources.list.",
+ )
+ parser.add_argument(
+ "--set-env",
+ action="append",
+ help="Set an environment variable.",
+ )
+
+ def run(self, args: Namespace) -> int:
+ """Run the command."""
+ config = Config.load(args.config)
+
+ jobs = config.jobs.get(args.job, [])
+ if not jobs:
+ raise CommandError(f"No job definition for {args.job!r}")
+ if args.index >= len(jobs):
+ raise CommandError(
+ f"No job definition with index {args.index} for {args.job!r}"
+ )
+ job = jobs[args.index]
+
+ provider = get_provider()
+ provider.ensure_provider_is_available()
+
+ try:
+ _run_job(
+ args.job,
+ job,
+ provider,
+ args.output_directory,
+ apt_replacement_repositories=args.apt_replace_repositories,
+ env_from_cli=args.set_env,
)
+ finally:
+ if args.clean:
+ cwd = Path.cwd()
+ provider.clean_project_environments(
+ project_name=cwd.name,
+ project_path=cwd,
+ instances=[_get_job_instance_name(provider, job)],
+ )
- return 0
+ return 0
diff --git a/lpcraft/commands/version.py b/lpcraft/commands/version.py
index 40f7ce3..94acff7 100644
--- a/lpcraft/commands/version.py
+++ b/lpcraft/commands/version.py
@@ -3,12 +3,20 @@
from argparse import Namespace
-from craft_cli import emit
+from craft_cli import BaseCommand, emit
from lpcraft._version import version_description as lpcraft_version
-def version(args: Namespace) -> int:
+class VersionCommand(BaseCommand):
"""Show lpcraft's version number."""
- emit.message(lpcraft_version)
- return 0
+
+ name = "version"
+ help_msg = __doc__.splitlines()[0]
+ overview = __doc__
+ common = True
+
+ def run(self, args: Namespace) -> int:
+ """Run the command."""
+ emit.message(lpcraft_version)
+ return 0
diff --git a/lpcraft/main.py b/lpcraft/main.py
index c9af819..83fe767 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -4,17 +4,24 @@
"""Main entry point."""
import logging
-from argparse import ArgumentParser
-from pathlib import Path
+import sys
from typing import List, Optional
-from craft_cli import CraftError, EmitterMode, emit
+from craft_cli import (
+ ArgumentParsingError,
+ CommandGroup,
+ CraftError,
+ Dispatcher,
+ EmitterMode,
+ GlobalArgument,
+ ProvideHelpException,
+ emit,
+)
from lpcraft._version import version_description as lpcraft_version
-from lpcraft.commands.clean import clean
-from lpcraft.commands.run import run, run_one
-from lpcraft.commands.version import version
-from lpcraft.errors import CommandError
+from lpcraft.commands.clean import CleanCommand
+from lpcraft.commands.run import RunCommand, RunOneCommand
+from lpcraft.commands.version import VersionCommand
def _configure_logger(name: str) -> None:
@@ -30,155 +37,56 @@ def _configure_logger(name: str) -> None:
_configure_logger("craft_providers")
+_basic_commands = [
+ CleanCommand,
+ RunCommand,
+ RunOneCommand,
+ VersionCommand,
+]
+
+
def main(argv: Optional[List[str]] = None) -> int:
"""`lpcraft` runs Launchpad CI jobs."""
- parser = ArgumentParser(description="Run Launchpad CI jobs.")
- parser.add_argument(
- "--version",
- action="store_true",
- help="Show version information and exit.",
- )
-
- verbosity_group = parser.add_mutually_exclusive_group()
- verbosity_group.add_argument(
- "-q",
- "--quiet",
- action="store_true",
- help="Only show warnings and errors, not progress.",
- )
- verbosity_group.add_argument(
- "-v",
- "--verbose",
- action="store_true",
- help="Show debug information and be more verbose.",
- )
- verbosity_group.add_argument(
- "--trace",
- action="store_true",
- help="Show all information needed to trace internal behaviour.",
- )
-
- subparsers = parser.add_subparsers()
-
- # XXX cjwatson 2021-11-15: Subcommand arguments should be defined
- # alongside the individual subcommands rather than here.
- parser_clean = subparsers.add_parser(
- "clean", description=clean.__doc__, help=clean.__doc__
- )
- parser_clean.add_argument(
- "-c",
- "--config",
- type=Path,
- default=".launchpad.yaml",
- help="Read the configuration file from this path.",
- )
- parser_clean.set_defaults(func=clean)
-
- parser_run = subparsers.add_parser(
- "run", description=run.__doc__, help=run.__doc__
- )
- parser_run.add_argument(
- "--output-directory",
- type=Path,
- help="Write output files to this directory.",
- )
- parser_run.add_argument(
- "-c",
- "--config",
- type=Path,
- default=".launchpad.yaml",
- help="Read the configuration file from this path.",
- )
- parser_run.add_argument(
- "--clean",
- action="store_true",
- help=(
- "Clean the managed environments created "
- "for the pipeline after the running it."
- ),
- )
- parser_run.add_argument(
- "--apt-replace-repositories",
- action="append",
- help="Overwrite /etc/apt/sources.list.",
- )
- parser_run.add_argument(
- "--set-env",
- action="append",
- help="Set an environment variable.",
- )
- parser_run.set_defaults(func=run)
-
- parser_run_one = subparsers.add_parser(
- "run-one", description=run_one.__doc__, help=run_one.__doc__
- )
- parser_run_one.add_argument(
- "--output-directory",
- type=Path,
- help="Write output files to this directory.",
- )
- parser_run_one.add_argument(
- "-c",
- "--config",
- type=Path,
- default=".launchpad.yaml",
- help="Read the configuration file from this path.",
- )
- parser_run_one.add_argument(
- "--clean",
- action="store_true",
- help=(
- "Clean the managed environment created for the job "
- "after running it."
- ),
- )
- parser_run_one.add_argument("job", help="Run only this job name.")
- parser_run_one.add_argument(
- "index",
- type=int,
- metavar="N",
- help="Run only the Nth job with the given name (indexing from 0).",
- )
- parser_run_one.add_argument(
- "--apt-replace-repositories",
- action="append",
- help="Overwrite /etc/apt/sources.list.",
- )
- parser_run_one.add_argument(
- "--set-env",
- action="append",
- help="Set an environment variable.",
- )
- parser_run_one.set_defaults(func=run_one)
-
- parser_version = subparsers.add_parser(
- "version", description=version.__doc__, help=version.__doc__
- )
- parser_version.set_defaults(func=version)
+ if argv is None:
+ argv = sys.argv[1:]
emit.init(EmitterMode.NORMAL, "lpcraft", f"Starting {lpcraft_version}")
+ command_groups = [CommandGroup("Basic", _basic_commands)]
+ summary = "Run Launchpad CI jobs."
+ extra_global_args = [
+ GlobalArgument(
+ "version",
+ "flag",
+ "-V",
+ "--version",
+ "Show version information and exit",
+ )
+ ]
try:
- args = parser.parse_args(argv)
- except SystemExit:
+ dispatcher = Dispatcher(
+ "lpcraft",
+ command_groups,
+ summary=summary,
+ extra_global_args=extra_global_args,
+ default_command=RunCommand,
+ )
+ global_args = dispatcher.pre_parse_args(argv)
+ if global_args["version"]:
+ emit.message(lpcraft_version)
+ emit.ended_ok()
+ return 0
+ dispatcher.load_command(None)
+ ret = dispatcher.run() or 0
+ except ArgumentParsingError as e:
+ print(e, file=sys.stderr)
emit.ended_ok()
- return 1
-
- if args.quiet:
- emit.set_mode(EmitterMode.QUIET)
- elif args.verbose:
- emit.set_mode(EmitterMode.VERBOSE)
- elif args.trace:
- emit.set_mode(EmitterMode.TRACE)
-
- if args.version:
- emit.message(lpcraft_version)
+ ret = 1
+ except ProvideHelpException as e:
+ print(e)
emit.ended_ok()
- return 0
-
- try:
- ret = int(getattr(args, "func", run)(args))
- except CommandError as e:
+ ret = 0
+ except CraftError as e:
emit.error(e)
ret = e.retcode
except KeyboardInterrupt as e:
diff --git a/lpcraft/tests/test_main.py b/lpcraft/tests/test_main.py
index 423d7ec..85aae92 100644
--- a/lpcraft/tests/test_main.py
+++ b/lpcraft/tests/test_main.py
@@ -1,6 +1,7 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
+import io
from unittest.mock import call, patch
from craft_cli import CraftError, EmitterMode
@@ -26,38 +27,44 @@ class TestMain(TestCase):
mock_emit.message.assert_called_once_with(lpcraft_version)
mock_emit.ended_ok.assert_called_once_with()
- def test_bad_arguments(self):
+ @patch("sys.stderr", new_callable=io.StringIO)
+ def test_bad_arguments(self, mock_stderr):
# main() exits appropriately if given bad arguments.
mock_emit = self.useFixture(MockPatch("lpcraft.main.emit")).mock
- mock_argparse_print_message = self.useFixture(
- MockPatch("argparse.ArgumentParser._print_message")
- ).mock
ret = main(["--nonexistent"])
self.assertEqual(1, ret)
- # using `assert_called_with` is not possible as the message is
- # different depending whether pytest or coverage is driving the tests
self.assertIn(
- "error: unrecognized arguments: --nonexistent\n",
- mock_argparse_print_message.call_args.args[0],
+ "Error: unrecognized arguments: --nonexistent\n",
+ mock_stderr.getvalue(),
)
mock_emit.ended_ok.assert_called_once_with()
- @patch("lpcraft.main.run")
+ @patch("sys.stdout", new_callable=io.StringIO)
+ def test_help(self, mock_stdout):
+ mock_emit = self.useFixture(MockPatch("lpcraft.main.emit")).mock
+
+ ret = main(["--help"])
+
+ self.assertEqual(0, ret)
+ self.assertIn("Usage:\n", mock_stdout.getvalue())
+ mock_emit.ended_ok.assert_called_once_with()
+
+ @patch("lpcraft.commands.run.RunCommand.run")
def test_keyboard_interrupt(self, mock_run):
mock_run.side_effect = KeyboardInterrupt()
with RecordingEmitterFixture() as emitter:
- ret = main()
+ ret = main([])
self.assertEqual(1, ret)
self.assertEqual(
call("error", CraftError("Interrupted.")),
- emitter.recorder.interactions[0],
+ emitter.recorder.interactions[-1],
)
- @patch("lpcraft.main.run")
+ @patch("lpcraft.commands.run.RunCommand.run")
def test_handling_unexpected_exception(self, mock_run):
self.useFixture(MockPatch("sys.argv", ["lpcraft"]))
mock_run.side_effect = RuntimeError()
@@ -70,7 +77,7 @@ class TestMain(TestCase):
call(
"error", CraftError("lpcraft internal error: RuntimeError()")
),
- emitter.recorder.interactions[0],
+ emitter.recorder.interactions[-1],
)
def test_quiet_mode(self):
@@ -83,7 +90,7 @@ class TestMain(TestCase):
# result is something like "lpcraft, version 0.0.1"
self.assertIn(
- "lpcraft, version", emitter.recorder.interactions[0].args[1]
+ "lpcraft, version", emitter.recorder.interactions[-1].args[1]
)
def test_verbose_mode(self):
@@ -96,7 +103,7 @@ class TestMain(TestCase):
# result is something like "lpcraft, version 0.0.1"
self.assertIn(
- "lpcraft, version", emitter.recorder.interactions[0].args[1]
+ "lpcraft, version", emitter.recorder.interactions[-1].args[1]
)
def test_trace_mode(self):
@@ -111,5 +118,5 @@ class TestMain(TestCase):
# result is something like "lpcraft, version 0.0.1"
self.assertIn(
- "lpcraft, version", emitter.recorder.interactions[0].args[1]
+ "lpcraft, version", emitter.recorder.interactions[-1].args[1]
)
Follow ups