Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add needs_project property #340

Merged
merged 2 commits into from
May 8, 2024
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
2 changes: 1 addition & 1 deletion craft_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ def run(self) -> int: # noqa: PLR0912 (too many branches)
self._pre_run(dispatcher)

managed_mode = command.run_managed(dispatcher.parsed_args())
if managed_mode or command.always_load_project:
if managed_mode or command.needs_project(dispatcher.parsed_args()):
self.services.project = self.get_project(
platform=platform, build_for=build_for
)
Expand Down
6 changes: 5 additions & 1 deletion craft_application/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@

from craft_application.commands.base import AppCommand, ExtensibleCommand
from craft_application.commands import lifecycle
from craft_application.commands.lifecycle import get_lifecycle_command_group
from craft_application.commands.lifecycle import (
get_lifecycle_command_group,
LifecycleCommand,
)
from craft_application.commands.other import get_other_command_group

__all__ = [
"AppCommand",
"ExtensibleCommand",
"lifecycle",
"LifecycleCommand",
"get_lifecycle_command_group",
"get_other_command_group",
]
14 changes: 14 additions & 0 deletions craft_application/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ def __init__(self, config: dict[str, Any] | None) -> None:
self._app: application.AppMetadata = config["app"]
self._services: service_factory.ServiceFactory = config["services"]

def needs_project(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (unused argument is for subclasses)
) -> bool:
"""Property to determine if the command needs a project loaded.

Defaults to `self.always_load_project`. Subclasses can override this property

:param parsed_args: Parsed arguments for the command.

:returns: True if the command needs a project loaded, False otherwise.
"""
return self.always_load_project

def run_managed(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (the unused argument is for subclasses)
Expand Down
104 changes: 74 additions & 30 deletions craft_application/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

def get_lifecycle_command_group() -> CommandGroup:
"""Return the lifecycle related command group."""
commands: list[type[_LifecycleCommand]] = [
commands: list[type[_BaseLifecycleCommand]] = [
CleanCommand,
PullCommand,
OverlayCommand,
Expand All @@ -50,30 +50,21 @@ def get_lifecycle_command_group() -> CommandGroup:
)


class _LifecycleCommand(base.ExtensibleCommand):
"""Lifecycle-related commands."""
class _BaseLifecycleCommand(base.ExtensibleCommand):
"""Base class for lifecycle-related commands.
mr-cal marked this conversation as resolved.
Show resolved Hide resolved

All lifecycle commands must know where to execute (locally or in a build
environment) but do not have to provide shell access into the environment.
"""

@override
def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> None:
emit.trace(f"lifecycle command: {self.name!r}, arguments: {parsed_args!r}")


class LifecyclePartsCommand(_LifecycleCommand):
"""A lifecycle command that uses parts."""

# All lifecycle-related commands need a project to work
always_load_project = True

@override
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
super()._fill_parser(parser) # type: ignore[arg-type]
parser.add_argument(
"parts",
metavar="part-name",
type=str,
nargs="*",
help="Optional list of parts to process",
)

group = parser.add_mutually_exclusive_group()
group.add_argument(
"--destructive-mode",
Expand Down Expand Up @@ -121,8 +112,12 @@ def run_managed(self, parsed_args: argparse.Namespace) -> bool:
return True


class LifecycleStepCommand(LifecyclePartsCommand):
"""An actual lifecycle step."""
class LifecycleCommand(_BaseLifecycleCommand):
"""A command that will run the lifecycle and can shell into the environment.

LifecycleCommands do not require a part. For example 'pack' will run
the lifecycle but cannot be run on a specific part.
"""

@override
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -202,10 +197,7 @@ def _run(
shell_after = True

try:
self._services.lifecycle.run(
step_name=step_name,
part_names=parsed_args.parts,
)
self._run_lifecycle(parsed_args, step_name)
except Exception as err:
if debug:
emit.progress(str(err), permanent=True)
Expand All @@ -215,12 +207,48 @@ def _run(
if shell_after:
_launch_shell()

def _run_lifecycle(
self,
parsed_args: argparse.Namespace, # noqa: ARG002 (unused argument is for subclasses)
step_name: str | None = None,
) -> None:
"""Run the lifecycle."""
self._services.lifecycle.run(step_name=step_name)

@staticmethod
def _should_add_shell_args() -> bool:
return True


class PullCommand(LifecycleStepCommand):
class LifecyclePartsCommand(LifecycleCommand):
"""A command that can run the lifecycle for a particular part."""

# All lifecycle-related commands need a project to work
always_load_project = True

@override
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
super()._fill_parser(parser) # type: ignore[arg-type]
parser.add_argument(
"parts",
metavar="part-name",
type=str,
nargs="*",
help="Optional list of parts to process",
)

@override
def _run_lifecycle(
self, parsed_args: argparse.Namespace, step_name: str | None = None
) -> None:
"""Run the lifecycle, optionally for a part or list of parts."""
self._services.lifecycle.run(
step_name=step_name,
part_names=parsed_args.parts,
)


class PullCommand(LifecyclePartsCommand):
"""Command to pull parts."""

name = "pull"
Expand All @@ -234,7 +262,7 @@ class PullCommand(LifecycleStepCommand):
)


class OverlayCommand(LifecycleStepCommand):
class OverlayCommand(LifecyclePartsCommand):
"""Command to overlay parts."""

name = "overlay"
Expand All @@ -247,7 +275,7 @@ class OverlayCommand(LifecycleStepCommand):
)


class BuildCommand(LifecycleStepCommand):
class BuildCommand(LifecyclePartsCommand):
"""Command to build parts."""

name = "build"
Expand All @@ -260,7 +288,7 @@ class BuildCommand(LifecycleStepCommand):
)


class StageCommand(LifecycleStepCommand):
class StageCommand(LifecyclePartsCommand):
"""Command to stage parts."""

name = "stage"
Expand All @@ -274,7 +302,7 @@ class StageCommand(LifecycleStepCommand):
)


class PrimeCommand(LifecycleStepCommand):
class PrimeCommand(LifecyclePartsCommand):
"""Command to prime parts."""

name = "prime"
Expand Down Expand Up @@ -355,9 +383,10 @@ def _should_add_shell_args() -> bool:
return False


class CleanCommand(LifecyclePartsCommand):
class CleanCommand(_BaseLifecycleCommand):
"""Command to remove part assets."""

always_load_project = True
name = "clean"
help_msg = "Remove a part's assets"
overview = textwrap.dedent(
Expand All @@ -368,7 +397,22 @@ class CleanCommand(LifecyclePartsCommand):
)

@override
def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> None:
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
super()._fill_parser(parser) # type: ignore[arg-type]
parser.add_argument(
"parts",
metavar="part-name",
type=str,
nargs="*",
help="Optional list of parts to process",
)

@override
def _run(
self,
parsed_args: argparse.Namespace,
**kwargs: Any,
) -> None:
"""Run the clean command.

The project's work directory will be cleaned if:
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/commands/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ def test_without_config(emitter):
assert not hasattr(command, "_services")


@pytest.mark.parametrize("always_load_project", [True, False])
def test_needs_project(fake_command, always_load_project):
"""`needs_project()` defaults to `always_load_project`."""
fake_command.always_load_project = always_load_project

assert fake_command.needs_project(argparse.Namespace()) is always_load_project


# region Tests for ExtensibleCommand
@pytest.fixture()
def fake_extensible_cls():
Expand Down
33 changes: 22 additions & 11 deletions tests/unit/commands/test_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This file is part of craft-application.
#
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
Expand All @@ -23,8 +23,8 @@
from craft_application.commands.lifecycle import (
BuildCommand,
CleanCommand,
LifecycleCommand,
LifecyclePartsCommand,
LifecycleStepCommand,
OverlayCommand,
PackCommand,
PrimeCommand,
Expand Down Expand Up @@ -107,22 +107,33 @@ def test_get_lifecycle_command_group(enable_overlay, commands):


@pytest.mark.parametrize(("build_env_dict", "build_env_args"), BUILD_ENV_COMMANDS)
@pytest.mark.parametrize("parts_args", PARTS_LISTS)
def test_parts_command_fill_parser(
@pytest.mark.parametrize(("debug_dict", "debug_args"), DEBUG_PARAMS)
@pytest.mark.parametrize(("shell_dict", "shell_args"), SHELL_PARAMS)
def test_lifecycle_command_fill_parser(
app_metadata,
fake_services,
build_env_dict,
build_env_args,
parts_args,
debug_dict,
debug_args,
shell_dict,
shell_args,
):
cls = get_fake_command_class(LifecyclePartsCommand, managed=True)
cls = get_fake_command_class(LifecycleCommand, managed=True)
parser = argparse.ArgumentParser("parts_command")
command = cls({"app": app_metadata, "services": fake_services})
expected = {
"platform": None,
"build_for": None,
**shell_dict,
**debug_dict,
**build_env_dict,
}

command.fill_parser(parser)

args_dict = vars(parser.parse_args([*parts_args, *build_env_args]))
assert args_dict == {"parts": parts_args, **build_env_dict}
args_dict = vars(parser.parse_args([*build_env_args, *debug_args, *shell_args]))
assert args_dict == expected


@pytest.mark.parametrize("parts", PARTS_LISTS)
Expand Down Expand Up @@ -192,7 +203,7 @@ def test_step_command_fill_parser(
shell_args,
shell_dict,
):
cls = get_fake_command_class(LifecycleStepCommand, managed=True)
cls = get_fake_command_class(LifecyclePartsCommand, managed=True)
parser = argparse.ArgumentParser("step_command")
expected = {
"parts": parts_args,
Expand All @@ -217,7 +228,7 @@ def test_step_command_fill_parser(
def test_step_command_get_managed_cmd(
app_metadata, fake_services, parts, emitter_verbosity, shell_params, shell_opts
):
cls = get_fake_command_class(LifecycleStepCommand, managed=True)
cls = get_fake_command_class(LifecyclePartsCommand, managed=True)

expected = [
app_metadata.name,
Expand All @@ -239,7 +250,7 @@ def test_step_command_get_managed_cmd(
@pytest.mark.parametrize("step_name", STEP_NAMES)
@pytest.mark.parametrize("parts", PARTS_LISTS)
def test_step_command_run_explicit_step(app_metadata, mock_services, parts, step_name):
cls = get_fake_command_class(LifecycleStepCommand, managed=True)
cls = get_fake_command_class(LifecyclePartsCommand, managed=True)

parsed_args = argparse.Namespace(parts=parts)
command = cls({"app": app_metadata, "services": mock_services})
Expand Down
Loading