From 6df0d4b3a39ee8ea10bab341e0ba89f185233c22 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Mon, 2 Oct 2023 14:46:41 -0300 Subject: [PATCH 1/9] spread: don't delete base instances With the move to craft-application, the base instances got renamed from base-instance-rockcraft to base-instance-buildd, so update the regex. --- spread.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spread.yaml b/spread.yaml index 4b26cc4c0..50f8b114d 100644 --- a/spread.yaml +++ b/spread.yaml @@ -94,7 +94,7 @@ restore-each: | if lxc project info rockcraft > /dev/null 2>&1 ; then for instance in $(lxc --project=rockcraft list -c n --format csv); do # Don't remove the base instance, we want to re-use it between tests - if ! [[ $instance =~ ^base-instance-rockcraft* ]]; then + if ! [[ $instance =~ ^base-instance-* ]]; then lxc --project=rockcraft delete --force "$instance" fi done From 88d26d3941ad55d4d984237c073b60a4441f810d Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Mon, 2 Oct 2023 12:13:45 -0300 Subject: [PATCH 2/9] refactor: use upstream for all commands --- .../code/convert-to-pebble-layer/task.yaml | 2 +- docs/how-to/code/install-slice/task.yaml | 2 +- docs/tutorials/code/chisel/task.yaml | 2 +- .../code/migrate-to-chiselled-rock/task.yaml | 2 +- docs/tutorials/code/pyfiglet/task.yaml | 2 +- rockcraft/__main__.py | 3 +- rockcraft/application.py | 93 ------------------- rockcraft/cli.py | 85 ++++------------- rockcraft/commands/extensions.py | 7 +- rockcraft/commands/init.py | 5 +- tests/spread/general/invalid-name/task.yaml | 2 +- tests/spread/general/overlay-logs/task.yaml | 2 +- .../general/plugin-python-error/task.yaml | 2 +- tests/unit/conftest.py | 1 + tests/unit/test_application.py | 16 ---- 15 files changed, 35 insertions(+), 191 deletions(-) diff --git a/docs/how-to/code/convert-to-pebble-layer/task.yaml b/docs/how-to/code/convert-to-pebble-layer/task.yaml index 42ced9b61..ee2ba0a1f 100644 --- a/docs/how-to/code/convert-to-pebble-layer/task.yaml +++ b/docs/how-to/code/convert-to-pebble-layer/task.yaml @@ -9,7 +9,7 @@ summary: test the "How to convert a popular entrypoint to a Pebble layer" guide execute: | # [docs:pack] - rockcraft + rockcraft pack # [docs:pack-end] # [docs:skopeo] diff --git a/docs/how-to/code/install-slice/task.yaml b/docs/how-to/code/install-slice/task.yaml index 43d1fc8a7..e729d9cb4 100644 --- a/docs/how-to/code/install-slice/task.yaml +++ b/docs/how-to/code/install-slice/task.yaml @@ -39,7 +39,7 @@ execute: | mv rockcraft.yaml.backup rockcraft.yaml # [docs:pack] - rockcraft + rockcraft pack # [docs:pack-end] # [docs:skopeo-copy] diff --git a/docs/tutorials/code/chisel/task.yaml b/docs/tutorials/code/chisel/task.yaml index 393443237..a8c0d13bd 100644 --- a/docs/tutorials/code/chisel/task.yaml +++ b/docs/tutorials/code/chisel/task.yaml @@ -13,7 +13,7 @@ execute: | # [docs:install-rockcraft-end] # [docs:build-rock] - rockcraft + rockcraft pack # [docs:build-rock-end] test -f chisel-openssl_0.0.1_amd64.rock diff --git a/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml b/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml index 1419e5f24..5999a015c 100644 --- a/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml +++ b/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml @@ -33,7 +33,7 @@ execute: | # [docs:run-docker-image-end] # [docs:build-rock] - rockcraft --verbosity debug + rockcraft pack --verbosity debug # [docs:build-rock-end] test -f dotnet-runtime_chiselled_amd64.rock diff --git a/docs/tutorials/code/pyfiglet/task.yaml b/docs/tutorials/code/pyfiglet/task.yaml index ba5c4c2b3..4d592bc43 100644 --- a/docs/tutorials/code/pyfiglet/task.yaml +++ b/docs/tutorials/code/pyfiglet/task.yaml @@ -15,7 +15,7 @@ execute: | cp ../rockcraft.yaml . # [docs:build-rock] - rockcraft + rockcraft pack # [docs:build-rock-end] # [docs:skopeo-copy] diff --git a/rockcraft/__main__.py b/rockcraft/__main__.py index 26e9e00ee..e232a932c 100644 --- a/rockcraft/__main__.py +++ b/rockcraft/__main__.py @@ -16,8 +16,9 @@ # along with this program. If not, see . """Main module.""" +import sys from .cli import run if __name__ == "__main__": - run() + sys.exit(run()) diff --git a/rockcraft/application.py b/rockcraft/application.py index 49f59517b..7871bb93f 100644 --- a/rockcraft/application.py +++ b/rockcraft/application.py @@ -19,16 +19,10 @@ from __future__ import annotations import functools -import os import pathlib -import signal -import sys import craft_cli from craft_application import Application, AppMetadata, util -from craft_application.application import _Dispatcher -from craft_application.commands import get_lifecycle_command_group -from craft_cli import CommandGroup from overrides import override from rockcraft import models @@ -42,27 +36,9 @@ ) -class FallbackRunError(RuntimeError): - """Run should be handled by the legacy code.""" - - class Rockcraft(Application): """Rockcraft application definition.""" - @property - @override - def command_groups(self) -> list[craft_cli.CommandGroup]: - """Filter supported command from CraftApplication.""" - all_lifecycle_commands = get_lifecycle_command_group().commands - migrated_commands = {"pack"} - - return [ - CommandGroup( - "Lifecycle", - [c for c in all_lifecycle_commands if c.name in migrated_commands], - ) - ] - @functools.cached_property def project(self) -> models.Project: """Get this application's Project metadata.""" @@ -70,75 +46,6 @@ def project(self) -> models.Project: craft_cli.emit.debug(f"Loading project file '{project_file!s}'") return models.Project.from_yaml_file(project_file, work_dir=self._work_dir) - @override - def _get_dispatcher(self) -> _Dispatcher: - """Overridden to raise a FallbackRunError() for unhandled commands. - - Should be removed after we finish migrating all commands. - """ - craft_cli.emit.init( - mode=craft_cli.EmitterMode.BRIEF, - appname=self.app.name, - greeting=f"Starting {self.app.name}", - log_filepath=self.log_path, - streaming_brief=True, - ) - - dispatcher = _Dispatcher( - self.app.name, - self.command_groups, - summary=str(self.app.summary), - extra_global_args=self._global_arguments, - ) - - try: - craft_cli.emit.trace("pre-parsing arguments...") - # Workaround for the fact that craft_cli requires a command. - # https://github.com/canonical/craft-cli/issues/141 - if "--version" in sys.argv or "-V" in sys.argv: - try: - global_args = dispatcher.pre_parse_args(["pull", *sys.argv[1:]]) - except craft_cli.ArgumentParsingError: - global_args = dispatcher.pre_parse_args(sys.argv[1:]) - else: - global_args = dispatcher.pre_parse_args(sys.argv[1:]) - - if global_args.get("version"): - craft_cli.emit.ended_ok() - print(f"{self.app.name} {self.app.version}") - sys.exit(0) - # Try loading the command to shake out possible ArgumentParsingErrors - # from commands with options that we can't handle yet (like - # --destructive-mode) - dispatcher.load_command( - { - "app": self.app, - "services": self.services, - } - ) - except (craft_cli.ProvideHelpException, craft_cli.ArgumentParsingError) as err: - # Difference from base's behavior: fallback to the legacy run - # if we get an unknown command, or an invalid option, or a request - # for help. - raise FallbackRunError() from err - except KeyboardInterrupt as err: - self._emit_error(craft_cli.CraftError("Interrupted."), cause=err) - sys.exit(128 + signal.SIGINT) - except Exception as err: # pylint: disable=broad-except - self._emit_error( - craft_cli.CraftError( - f"Internal error while loading {self.app.name}: {err!r}" - ) - ) - if os.getenv("CRAFT_DEBUG") == "1": - raise - sys.exit(70) # EX_SOFTWARE from sysexits.h - - craft_cli.emit.trace("Preparing application...") - self.configure(global_args) - - return dispatcher - @override def _configure_services(self, platform: str | None, build_for: str | None) -> None: if build_for is None: diff --git a/rockcraft/cli.py b/rockcraft/cli.py index 8f99ce760..3ebb30b1d 100644 --- a/rockcraft/cli.py +++ b/rockcraft/cli.py @@ -17,19 +17,13 @@ """Command-line application entry point.""" import logging -import sys -from typing import Optional import craft_cli -from craft_cli import ArgumentParsingError, ProvideHelpException, emit -from craft_parts import PartsError -from craft_providers import ProviderError -from rockcraft import __version__, errors, plugins +from rockcraft import plugins from . import commands from .services import RockcraftServiceFactory -from .utils import is_managed_mode COMMAND_GROUPS = [ craft_cli.CommandGroup( @@ -67,12 +61,12 @@ ] -def run() -> None: +def run() -> int: """Command-line interface entrypoint.""" # pylint: disable=import-outside-toplevel # Import these here so that the script that generates the docs for the # commands doesn't need to know *too much* of the application. - from .application import APP_METADATA, FallbackRunError, Rockcraft + from .application import APP_METADATA, Rockcraft # Register our own plugins plugins.register() @@ -89,64 +83,19 @@ def run() -> None: app = Rockcraft(app=APP_METADATA, services=services) - try: - app.run() - except FallbackRunError: - legacy_run() - - -def _emit_error(error: craft_cli.CraftError, cause: Optional[Exception] = None) -> None: - """Emit the error in a centralized way so we can alter it consistently.""" - # set the cause, if any - if cause is not None: - error.__cause__ = cause - - # if running inside a managed instance, do not report the internal logpath - if is_managed_mode(): - error.logpath_report = False - - # finally, emit - emit.error(error) - - -def legacy_run() -> None: - """Run the CLI.""" - dispatcher = craft_cli.Dispatcher( - "rockcraft", - COMMAND_GROUPS, - summary="A tool to create OCI images", - extra_global_args=GLOBAL_ARGS, - default_command=commands.PackCommand, + app.add_command_group( + "Other", + [ + commands.InitCommand, + ], + ) + app.add_command_group( + "Extensions", + [ + commands.ExtensionsCommand, + commands.ListExtensionsCommand, + commands.ExpandExtensionsCommand, + ], ) - try: - global_args = dispatcher.pre_parse_args(sys.argv[1:]) - if global_args.get("version"): - emit.message(f"rockcraft {__version__}") - else: - dispatcher.load_command(None) - dispatcher.run() - emit.ended_ok() - except ProvideHelpException as err: - print(err, file=sys.stderr) # to stderr, as argparse normally does - emit.ended_ok() - except ArgumentParsingError as err: - print(err, file=sys.stderr) # to stderr, as argparse normally does - emit.ended_ok() - sys.exit(1) - except errors.RockcraftError as err: - _emit_error(err) - sys.exit(1) - except PartsError as err: - _emit_error( - craft_cli.CraftError( - err.brief, details=err.details, resolution=err.resolution - ) - ) - sys.exit(1) - except ProviderError as err: - _emit_error(craft_cli.CraftError(f"craft-providers error: {err}")) - sys.exit(1) - except Exception as err: # pylint: disable=broad-except - _emit_error(craft_cli.CraftError(f"rockcraft internal error: {err!r}")) - sys.exit(1) + return app.run() diff --git a/rockcraft/commands/extensions.py b/rockcraft/commands/extensions.py index 19b2b1e23..c2c93142c 100644 --- a/rockcraft/commands/extensions.py +++ b/rockcraft/commands/extensions.py @@ -23,7 +23,8 @@ from typing import Dict, List import tabulate -from craft_cli import BaseCommand, emit +from craft_application.commands import AppCommand +from craft_cli import emit from overrides import overrides from pydantic import BaseModel @@ -45,7 +46,7 @@ def marshal(self) -> Dict[str, str]: } -class ListExtensionsCommand(BaseCommand, abc.ABC): +class ListExtensionsCommand(AppCommand, abc.ABC): """List available extensions for all supported bases.""" name = "list-extensions" @@ -82,7 +83,7 @@ class ExtensionsCommand(ListExtensionsCommand, abc.ABC): hidden = True -class ExpandExtensionsCommand(BaseCommand, abc.ABC): +class ExpandExtensionsCommand(AppCommand, abc.ABC): """Expand the extensions in the snapcraft.yaml file.""" name = "expand-extensions" diff --git a/rockcraft/commands/init.py b/rockcraft/commands/init.py index b11719857..9b158549a 100644 --- a/rockcraft/commands/init.py +++ b/rockcraft/commands/init.py @@ -19,7 +19,8 @@ from pathlib import Path from typing import TYPE_CHECKING -from craft_cli import BaseCommand, emit +from craft_application.commands import AppCommand +from craft_cli import emit from overrides import overrides from rockcraft import errors @@ -48,7 +49,7 @@ def init(rockcraft_yaml_content: str) -> None: emit.progress(f"Created {rockcraft_yaml_path}.") -class InitCommand(BaseCommand): +class InitCommand(AppCommand): """Initialize a rockcraft project.""" name = "init" diff --git a/tests/spread/general/invalid-name/task.yaml b/tests/spread/general/invalid-name/task.yaml index 368891f8c..c44d99b56 100644 --- a/tests/spread/general/invalid-name/task.yaml +++ b/tests/spread/general/invalid-name/task.yaml @@ -4,5 +4,5 @@ execute: | for name in a_a a@a a--a aa- do sed "s/placeholder-name/$name/" rockcraft.orig.yaml > rockcraft.yaml - rockcraft 2>&1 >/dev/null | MATCH "Invalid name for ROCK" + rockcraft pack 2>&1 >/dev/null | MATCH "Invalid name for ROCK" done \ No newline at end of file diff --git a/tests/spread/general/overlay-logs/task.yaml b/tests/spread/general/overlay-logs/task.yaml index fb72f9b30..dcb395be7 100644 --- a/tests/spread/general/overlay-logs/task.yaml +++ b/tests/spread/general/overlay-logs/task.yaml @@ -5,7 +5,7 @@ execute: | error_pattern="E: Unable to locate package idontexist" # Check that the message is shown to the "terminal" - rockcraft --verbosity=debug 2>&1 >/dev/null | MATCH "$error_pattern" + rockcraft pack --verbosity=debug 2>&1 >/dev/null | MATCH "$error_pattern" # Check that the message was actually written to the logfile. rockcraft_log_file=$(find /root/.local/state/rockcraft/log/ -name 'rockcraft*.log' | sort -n | tail -n1) diff --git a/tests/spread/general/plugin-python-error/task.yaml b/tests/spread/general/plugin-python-error/task.yaml index 6f4c0f087..2b30f1de9 100644 --- a/tests/spread/general/plugin-python-error/task.yaml +++ b/tests/spread/general/plugin-python-error/task.yaml @@ -4,4 +4,4 @@ environment: BASE/bare: "bare" execute: | sed "s/placeholder-base/$BASE/" rockcraft.orig.yaml > rockcraft.yaml - rockcraft -v 2>&1 >/dev/null | MATCH ":: No suitable Python interpreter found, giving up." + rockcraft pack -v 2>&1 >/dev/null | MATCH ":: No suitable Python interpreter found, giving up." diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 146e3c0cd..b4bc6dba1 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -166,6 +166,7 @@ def provider_service(default_project, default_factory, tmp_path): app=APP_METADATA, project=default_project, services=default_factory, + work_dir=tmp_path, ) diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index c55ac6291..cace8e6a3 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -15,22 +15,6 @@ # along with this program. If not, see . from pathlib import Path -from craft_application.commands.lifecycle import PackCommand - - -def test_application_commands(default_application): - commands = default_application.command_groups - - assert len(commands) == 1 - - group = commands[0] - assert group.name == "Lifecycle" - - # Only the Pack command is supported currently. - assert len(group.commands) == 1 - assert group.commands[0] is PackCommand - - ENVIRONMENT_YAML = """\ name: environment-test version: 2.0 From 63368c7359343e00a6a285fc369c1734cc6acb48 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Mon, 2 Oct 2023 18:21:12 -0300 Subject: [PATCH 3/9] refactor: drop lifecycle commands This removes all commands in rockcraft.commands.lifecycle, in preference of the default commands from craft-application. --- rockcraft/cli.py | 37 ------- rockcraft/commands/__init__.py | 16 --- rockcraft/commands/lifecycle.py | 176 -------------------------------- tests/unit/test_cli.py | 175 +------------------------------ 4 files changed, 1 insertion(+), 403 deletions(-) delete mode 100644 rockcraft/commands/lifecycle.py diff --git a/rockcraft/cli.py b/rockcraft/cli.py index 3ebb30b1d..7cf0aac81 100644 --- a/rockcraft/cli.py +++ b/rockcraft/cli.py @@ -18,48 +18,11 @@ import logging -import craft_cli - from rockcraft import plugins from . import commands from .services import RockcraftServiceFactory -COMMAND_GROUPS = [ - craft_cli.CommandGroup( - "Lifecycle", - [ - commands.CleanCommand, - commands.PullCommand, - commands.OverlayCommand, - commands.BuildCommand, - commands.StageCommand, - commands.PrimeCommand, - commands.PackCommand, - ], - ), - craft_cli.CommandGroup( - "Extensions", - [ - commands.ExtensionsCommand, - commands.ListExtensionsCommand, - commands.ExpandExtensionsCommand, - ], - ), - craft_cli.CommandGroup( - "Other", - [ - commands.InitCommand, - ], - ), -] - -GLOBAL_ARGS = [ - craft_cli.GlobalArgument( - "version", "flag", "-V", "--version", "Show the application version and exit" - ) -] - def run() -> int: """Command-line interface entrypoint.""" diff --git a/rockcraft/commands/__init__.py b/rockcraft/commands/__init__.py index 9a028ed38..6f85a155c 100644 --- a/rockcraft/commands/__init__.py +++ b/rockcraft/commands/__init__.py @@ -22,25 +22,9 @@ ListExtensionsCommand, ) from .init import InitCommand -from .lifecycle import ( - BuildCommand, - CleanCommand, - OverlayCommand, - PackCommand, - PrimeCommand, - PullCommand, - StageCommand, -) __all__ = [ "InitCommand", - "CleanCommand", - "PullCommand", - "OverlayCommand", - "BuildCommand", - "StageCommand", - "PrimeCommand", - "PackCommand", "ExpandExtensionsCommand", "ExtensionsCommand", "ListExtensionsCommand", diff --git a/rockcraft/commands/lifecycle.py b/rockcraft/commands/lifecycle.py deleted file mode 100644 index 953617b50..000000000 --- a/rockcraft/commands/lifecycle.py +++ /dev/null @@ -1,176 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Rockcraft lifecycle commands.""" - -import abc -import textwrap -from typing import TYPE_CHECKING - -from craft_cli import BaseCommand, emit -from overrides import overrides - -from rockcraft import lifecycle - -if TYPE_CHECKING: - import argparse - - -class _LifecycleCommand(BaseCommand, abc.ABC): - """Lifecycle-related commands.""" - - @overrides - def run(self, parsed_args: "argparse.Namespace") -> None: - """Run the command.""" - if not self.name: - raise RuntimeError("command name not specified") - - emit.trace(f"lifecycle command: {self.name!r}, arguments: {parsed_args!r}") - lifecycle.run(self.name, parsed_args) - - def fill_parser(self, parser: "argparse.ArgumentParser") -> None: - super().fill_parser(parser) # type: ignore - parser.add_argument( - "--debug", - action="store_true", - help="Shell into the environment if the build fails", - ) - parser.add_argument( - "--destructive-mode", - action="store_true", - help="Build in the current host", - ) - - -class _LifecycleStepCommand(_LifecycleCommand): - """Lifecycle step commands.""" - - @overrides - def fill_parser(self, parser: "argparse.ArgumentParser") -> None: - super().fill_parser(parser) # type: ignore - 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( - "--shell", - action="store_true", - help="Shell into the environment in lieu of the step to run.", - ) - group.add_argument( - "--shell-after", - action="store_true", - help="Shell into the environment after the step has run.", - ) - - -class CleanCommand(_LifecycleStepCommand): - """Command to remove part assets.""" - - name = "clean" - help_msg = "Remove a part's assets" - overview = textwrap.dedent( - """ - Clean up artifacts belonging to parts. If no parts are specified, - remove the ROCK packing environment. - """ - ) - - -class PullCommand(_LifecycleStepCommand): - """Command to pull parts.""" - - name = "pull" - help_msg = "Download or retrieve artifacts defined for a part" - overview = textwrap.dedent( - """ - Download or retrieve artifacts defined for a part. If part names - are specified only those parts will be pulled, otherwise all parts - will be pulled. - """ - ) - - -class OverlayCommand(_LifecycleStepCommand): - """Command to overlay parts.""" - - name = "overlay" - help_msg = "Create part layers over the base filesystem." - overview = textwrap.dedent( - """ - Execute operations defined for each part on a layer over the base - filesystem, potentially modifying its contents. - """ - ) - - -class BuildCommand(_LifecycleStepCommand): - """Command to build parts.""" - - name = "build" - help_msg = "Build artifacts defined for a part" - overview = textwrap.dedent( - """ - Build artifacts defined for a part. If part names are specified only - those parts will be built, otherwise all parts will be built. - """ - ) - - -class StageCommand(_LifecycleStepCommand): - """Command to stage parts.""" - - name = "stage" - help_msg = "Stage built artifacts into a common staging area" - overview = textwrap.dedent( - """ - Stage built artifacts into a common staging area. If part names are - specified only those parts will be staged. The default is to stage - all parts. - """ - ) - - -class PrimeCommand(_LifecycleStepCommand): - """Command to prime parts.""" - - name = "prime" - help_msg = "Prime artifacts defined for a part" - overview = textwrap.dedent( - """ - Prepare the final payload to be packed as a ROCK, performing additional - processing and adding metadata files. If part names are specified only - those parts will be primed. The default is to prime all parts. - """ - ) - - -class PackCommand(_LifecycleCommand): - """Command to pack the final artifact.""" - - name = "pack" - help_msg = "Create the ROCK" - overview = textwrap.dedent( - """ - Process parts and create the ROCK as an OCI archive file containing - the project payload with the provided metadata. - """ - ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 166526d9e..95bacfecd 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -15,29 +15,18 @@ # along with this program. If not, see . import sys -from argparse import Namespace from pathlib import Path from unittest.mock import DEFAULT, call, patch import pytest import yaml -from craft_cli import CraftError, ProvideHelpException, emit -from craft_providers import ProviderError +from craft_cli import emit from rockcraft import cli, services from rockcraft.application import Rockcraft -from rockcraft.errors import RockcraftError from rockcraft.models import project -@pytest.fixture -def lifecycle_pack_mock(): - """Mock for ui.pack.""" - patcher = patch("rockcraft.lifecycle.pack") - yield patcher.start() - patcher.stop() - - @pytest.fixture def lifecycle_init_mock(): """Mock for ui.init.""" @@ -46,111 +35,6 @@ def lifecycle_init_mock(): patcher.stop() -def create_namespace( - *, parts=None, shell=False, shell_after=False, debug=False, destructive_mode=False -) -> Namespace: - """Shortcut to create a Namespace object with the correct default values.""" - return Namespace( - parts=parts or [], - shell=shell, - shell_after=shell_after, - debug=debug, - destructive_mode=destructive_mode, - ) - - -@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"]) -def test_run_command(mocker, cmd): - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - mocker.patch.object(sys, "argv", ["rockcraft", cmd]) - cli.run() - - assert run_mock.mock_calls == [call(cmd, create_namespace())] - assert mock_ended_ok.mock_calls == [call()] - - -@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"]) -def test_run_command_parts(mocker, cmd): - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - mocker.patch.object(sys, "argv", ["rockcraft", cmd, "part1", "part2"]) - cli.run() - - assert run_mock.mock_calls == [ - call( - cmd, - create_namespace(parts=["part1", "part2"]), - ) - ] - assert mock_ended_ok.mock_calls == [call()] - - -@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"]) -def test_run_command_shell(mocker, cmd): - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--shell"]) - cli.run() - - assert run_mock.mock_calls == [call(cmd, create_namespace(shell=True))] - assert mock_ended_ok.mock_calls == [call()] - - -@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"]) -def test_run_command_shell_after(mocker, cmd): - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--shell-after"]) - cli.run() - - assert run_mock.mock_calls == [call(cmd, create_namespace(shell_after=True))] - assert mock_ended_ok.mock_calls == [call()] - - -@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"]) -def test_run_command_debug(mocker, cmd): - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--debug"]) - cli.run() - - assert run_mock.mock_calls == [call(cmd, create_namespace(debug=True))] - assert mock_ended_ok.mock_calls == [call()] - - -@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"]) -def test_run_command_destructive_mode(mocker, cmd): - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--destructive-mode"]) - cli.run() - - assert run_mock.mock_calls == [call(cmd, create_namespace(destructive_mode=True))] - assert mock_ended_ok.mock_calls == [call()] - - -@pytest.mark.parametrize("destructive_opt", [True, False]) -@pytest.mark.parametrize("debug_opt", [True, False]) -def test_run_pack(mocker, debug_opt, destructive_opt): - if not destructive_opt: - pytest.skip("regular 'rockcraft pack' is tested in 'test_run_pack_services'.") - run_mock = mocker.patch("rockcraft.lifecycle.run") - mock_ended_ok = mocker.spy(emit, "ended_ok") - command_line = ["rockcraft", "pack"] - if debug_opt: - command_line.append("--debug") - if destructive_opt: - command_line.append("--destructive-mode") - mocker.patch.object(sys, "argv", command_line) - cli.run() - - assert run_mock.mock_calls == [ - call("pack", Namespace(debug=debug_opt, destructive_mode=destructive_opt)) - ] - assert mock_ended_ok.mock_calls == [call()] - - def test_run_pack_services(mocker, monkeypatch, tmp_path): # Pretend it's running inside the managed instance monkeypatch.setenv("CRAFT_MANAGED_MODE", "1") @@ -207,60 +91,3 @@ def test_run_init(mocker, lifecycle_init_mock): call(cli.commands.InitCommand._INIT_TEMPLATE_YAML) # pylint: disable=W0212 ] assert mock_ended_ok.mock_calls == [call()] - - -def test_run_arg_parse_error(capsys, mocker): - """Catch ArgumentParsingError and exit cleanly.""" - mocker.patch.object(sys, "argv", ["rockcraft", "invalid-command"]) - mock_emit = mocker.patch("rockcraft.cli.emit") - mock_exit = mocker.patch("rockcraft.cli.sys.exit") - - cli.run() - - mock_emit.ended_ok.assert_called_once() - mock_exit.assert_called_once() - - out, err = capsys.readouterr() - assert not out - assert "Error: no such command 'invalid-command'" in err - - -def test_run_arg_provider_help_exception(capsys, mocker): - """Catch ProviderHelpException and exit cleanly.""" - mocker.patch( - "craft_cli.Dispatcher.pre_parse_args", - side_effect=ProvideHelpException("test help message"), - ) - mock_emit = mocker.patch("rockcraft.cli.emit") - - cli.run() - - mock_emit.ended_ok.assert_called_once() - - out, err = capsys.readouterr() - assert not out - assert err == "test help message\n" - - -@pytest.mark.parametrize( - "input_error, output_error", - [ - (RockcraftError("test error"), CraftError("test error")), - (ProviderError("test error"), CraftError("craft-providers error: test error")), - ( - Exception("test error"), - CraftError("rockcraft internal error: Exception('test error')"), - ), - ], -) -def test_run_with_error(mocker, input_error, output_error): - """Application errors should be caught for a clean exit.""" - mocker.patch("craft_cli.Dispatcher.run", side_effect=input_error) - mocker.patch.object(sys, "argv", ["rockcraft", "pack", "--destructive"]) - mock_emit = mocker.patch("rockcraft.cli.emit") - mock_exit = mocker.patch("rockcraft.cli.sys.exit") - - cli.run() - - mock_exit.assert_called_once() - mock_emit.error.assert_called_once_with(output_error) From ef95d857157ecc9c3ae51f89c43ad78117c70aea Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Mon, 2 Oct 2023 18:41:02 -0300 Subject: [PATCH 4/9] refactor: drop `rockcraft.lifecycle` The unit tests are dropped too. Most of them test basic funcionality that is already test in craft-application, and the remaining rockcraft-specific features (like package repositories) are already tested in tests.unit.services.test_lifecycle. --- rockcraft/environment.py | 57 ++++ rockcraft/lifecycle.py | 347 --------------------- rockcraft/models/project.py | 4 +- rockcraft/services/package.py | 99 +++++- tests/integration/test_lifecycle.py | 7 +- tests/unit/services/test_package.py | 4 +- tests/unit/test_lifecycle.py | 465 ---------------------------- 7 files changed, 158 insertions(+), 825 deletions(-) create mode 100644 rockcraft/environment.py delete mode 100644 rockcraft/lifecycle.py delete mode 100644 tests/unit/test_lifecycle.py diff --git a/rockcraft/environment.py b/rockcraft/environment.py new file mode 100644 index 000000000..da6c26061 --- /dev/null +++ b/rockcraft/environment.py @@ -0,0 +1,57 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Utilities to expand variables in project environments.""" + +from __future__ import annotations + +import pathlib +from typing import Any + +import craft_parts + + +def expand_environment( + project_yaml: dict[str, Any], + *, + project_vars: dict[str, Any], + work_dir: pathlib.Path, +) -> None: + """Expand global variables in the provided dictionary values. + + :param project_yaml: A dictionary containing the rockcraft.yaml's contents. + :param project_var: A dictionary with the project-specific variables. + :param work_dir: The working directory. + """ + info = craft_parts.ProjectInfo( + application_name="rockcraft", # not used in environment expansion + cache_dir=pathlib.Path(), # not used in environment expansion + project_name=project_yaml.get("name", ""), + project_dirs=craft_parts.ProjectDirs(work_dir=work_dir), + project_vars=project_vars, + ) + _set_global_environment(info) + + craft_parts.expand_environment(project_yaml, info=info) + + +def _set_global_environment(info: craft_parts.ProjectInfo) -> None: + """Set global environment variables.""" + info.global_environment.update( + { + "CRAFT_PROJECT_VERSION": info.get_project_var("version", raw_read=True), + } + ) diff --git a/rockcraft/lifecycle.py b/rockcraft/lifecycle.py deleted file mode 100644 index 46626db9c..000000000 --- a/rockcraft/lifecycle.py +++ /dev/null @@ -1,347 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021-2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -"""Lifecycle integration.""" - -import datetime -import subprocess -from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict - -from craft_cli import emit -from craft_parts import ProjectDirs, ProjectInfo, expand_environment -from craft_providers import ProviderError - -from . import oci, providers, utils -from .models.project import Project, load_project -from .parts import PartsLifecycle -from .usernames import SUPPORTED_GLOBAL_USERNAMES - -if TYPE_CHECKING: - import argparse - - -def run(command_name: str, parsed_args: "argparse.Namespace") -> None: - """Run the parts lifecycle.""" - # pylint: disable=too-many-locals - emit.trace(f"command: {command_name}, arguments: {parsed_args}") - - project_yaml = load_project(Path("rockcraft.yaml")) - destructive_mode = getattr(parsed_args, "destructive_mode", False) - - part_names = getattr(parsed_args, "parts", None) - managed_mode = utils.is_managed_mode() - - if not managed_mode and not destructive_mode: - if command_name == "clean" and not part_names: - clean_provider( - project_name=project_yaml["name"], project_path=Path().absolute() - ) - else: - run_in_provider(Project.unmarshal(project_yaml), command_name, parsed_args) - return - - if managed_mode: - work_dir = utils.get_managed_environment_home_path() - else: - work_dir = Path().absolute() - - project_vars = {"version": project_yaml["version"]} - # Expand the environment so that the global variables can be interpolated - _expand_environment( - project_yaml, - project_vars=project_vars, - work_dir=work_dir, - ) - - # pylint: disable=no-member - project = Project.unmarshal(project_yaml) - - image_dir = work_dir / "images" - bundle_dir = work_dir / "bundles" - - if project.package_repositories is None: - package_repositories = [] - else: - package_repositories = project.package_repositories - - # Obtain base image and extract it to use as our overlay base - # TODO: check if image was already downloaded, etc. - emit.progress(f"Retrieving base {project.base}") - - for platform_entry, platform in project.platforms.items(): - build_for = ( - platform["build_for"][0] if platform.get("build_for") else platform_entry - ) - build_for_variant = platform.get("build_for_variant") - - if project.base == "bare": - base_image, source_image = oci.Image.new_oci_image( - f"{project.base}:latest", - image_dir=image_dir, - arch=build_for, - variant=build_for_variant, - ) - else: - base_image, source_image = oci.Image.from_docker_registry( - project.base, - image_dir=image_dir, - arch=build_for, - variant=build_for_variant, - ) - emit.progress(f"Retrieved base {project.base} for {build_for}", permanent=True) - - emit.progress(f"Extracting {base_image.image_name}") - rootfs = base_image.extract_to(bundle_dir) - emit.progress(f"Extracted {base_image.image_name}", permanent=True) - - # TODO: check if destination image already exists, etc. - project_base_image = base_image.copy_to( - f"{project.name}:rockcraft-base", image_dir=image_dir - ) - - base_digest = project_base_image.digest(source_image) - step_name = "prime" if command_name == "pack" else command_name - - lifecycle = PartsLifecycle( - project.parts, - project_name=project.name, - project_vars=project_vars, - work_dir=work_dir, - part_names=part_names, - base_layer_dir=rootfs, - base_layer_hash=base_digest, - base=project.base, - package_repositories=package_repositories, - ) - - if command_name == "clean": - lifecycle.clean() - return - - lifecycle.run( - step_name, - shell=getattr(parsed_args, "shell", False), - shell_after=getattr(parsed_args, "shell_after", False), - debug=getattr(parsed_args, "debug", False), - ) - - if command_name == "pack": - _pack( - prime_dir=lifecycle.prime_dir, - project=project, - project_base_image=project_base_image, - base_digest=base_digest, - rock_suffix=platform_entry, - build_for=build_for, - base_layer_dir=rootfs, - ) - - -def _pack( - *, - prime_dir: Path, - project: Project, - project_base_image: oci.Image, - base_digest: bytes, - rock_suffix: str, - build_for: str, - base_layer_dir: Path, -) -> str: - """Create the rock image for a given architecture. - - :param lifecycle: - The lifecycle object containing the primed payload for the rock. - :param project_base_image: - The Image for the base over which the payload was primed. - :param base_digest: - The digest of the base image, to add to the new image's metadata. - :param rock_suffix: - The suffix to append to the image's filename, after the name and version. - :param build_for: - The architecture of the built rock, to add as metadata. - :param base_layer_dir: - The directory where the rock's base image was extracted. - """ - emit.progress("Creating new layer") - new_image = project_base_image.add_layer( - tag=project.version, - new_layer_dir=prime_dir, - base_layer_dir=base_layer_dir, - ) - emit.progress("Created new layer") - - if project.run_user: - emit.progress(f"Creating new user {project.run_user}") - new_image.add_user( - prime_dir=prime_dir, - base_layer_dir=base_layer_dir, - tag=project.version, - username=project.run_user, - uid=SUPPORTED_GLOBAL_USERNAMES[project.run_user]["uid"], - ) - - emit.progress(f"Setting the default OCI user to be {project.run_user}") - new_image.set_default_user(project.run_user) - - emit.progress("Adding Pebble entrypoint") - - new_image.set_entrypoint() - - services = project.dict(exclude_none=True, by_alias=True).get("services", {}) - - checks = project.dict(exclude_none=True, by_alias=True).get("checks", {}) - - if services or checks: - new_image.set_pebble_layer( - services=services, - checks=checks, - name=project.name, - tag=project.version, - summary=project.summary, - description=project.description, - base_layer_dir=base_layer_dir, - ) - - if project.environment: - new_image.set_environment(project.environment) - - # Set annotations and metadata, both dynamic and the ones based on user-provided properties - # Also include the "created" timestamp, just before packing the image - emit.progress("Adding metadata") - oci_annotations, rock_metadata = project.generate_metadata( - datetime.datetime.now(datetime.timezone.utc).isoformat(), base_digest - ) - rock_metadata["architecture"] = build_for - # TODO: add variant to rock_metadata too - # if build_for_variant: - # rock_metadata["variant"] = build_for_variant - new_image.set_annotations(oci_annotations) - new_image.set_control_data(rock_metadata) - emit.progress("Metadata added") - - emit.progress("Exporting to OCI archive") - archive_name = f"{project.name}_{project.version}_{rock_suffix}.rock" - new_image.to_oci_archive(tag=project.version, filename=archive_name) - emit.progress(f"Exported to OCI archive '{archive_name}'", permanent=True) - - return archive_name - - -def run_in_provider( - project: Project, command_name: str, parsed_args: "argparse.Namespace" -) -> None: - """Run lifecycle command in provider instance.""" - provider = providers.get_provider() - providers.ensure_provider_is_available(provider) - - cmd = ["rockcraft", command_name] - - if hasattr(parsed_args, "parts"): - cmd.extend(parsed_args.parts) - - mode = emit.get_mode().name.lower() - cmd.append(f"--verbosity={mode}") - - if getattr(parsed_args, "shell", False): - cmd.append("--shell") - if getattr(parsed_args, "shell_after", False): - cmd.append("--shell-after") - if getattr(parsed_args, "debug", False): - cmd.append("--debug") - - host_project_path = Path().absolute() - instance_project_path = utils.get_managed_environment_project_path() - instance_name = providers.get_instance_name( - project_name=project.name, project_path=host_project_path - ) - build_base = providers.ROCKCRAFT_BASE_TO_PROVIDER_BASE[str(project.build_base)] - - base_configuration = providers.get_base_configuration( - alias=build_base, - project_name=project.name, - project_path=host_project_path, - ) - - emit.progress("Launching instance...") - with provider.launched_environment( - project_name=project.name, - project_path=host_project_path, - base_configuration=base_configuration, - instance_name=instance_name, - ) as instance: - try: - with emit.pause(): - instance.mount( - host_source=host_project_path, target=instance_project_path - ) - instance.execute_run(cmd, check=True, cwd=instance_project_path) - except subprocess.CalledProcessError as err: - raise ProviderError( - f"Failed to execute {command_name} in instance." - ) from err - finally: - providers.capture_logs_from_instance(instance) - - -def clean_provider(project_name: str, project_path: Path) -> None: - """Clean the provider environment. - - :param project_name: name of the project - :param project_path: path of the project - """ - emit.progress("Cleaning build provider") - provider = providers.get_provider() - instance_name = providers.get_instance_name( - project_name=project_name, project_path=project_path - ) - emit.debug(f"Cleaning instance {instance_name}") - provider.clean_project_environments(instance_name=instance_name) - emit.progress("Cleaned build provider", permanent=True) - - -def _set_global_environment(info: ProjectInfo) -> None: - """Set global environment variables.""" - info.global_environment.update( - { - "CRAFT_PROJECT_VERSION": info.get_project_var("version", raw_read=True), - } - ) - - -def _expand_environment( - project_yaml: Dict[str, Any], - *, - project_vars: Dict[str, Any], - work_dir: Path, -) -> None: - """Expand global variables in the provided dictionary values. - - :param project_yaml: A dictionary containing the rockcraft.yaml's contents. - :param project_var: A dictionary with the project-specific variables. - :param work_dir: The working directory. - """ - info = ProjectInfo( - application_name="rockcraft", # not used in environment expansion - cache_dir=Path(), # not used in environment expansion - project_name=project_yaml.get("name", ""), - project_dirs=ProjectDirs(work_dir=work_dir), - project_vars=project_vars, - ) - _set_global_environment(info) - - expand_environment(project_yaml, info=info) diff --git a/rockcraft/models/project.py b/rockcraft/models/project.py index 7b4420571..d49c82cde 100644 --- a/rockcraft/models/project.py +++ b/rockcraft/models/project.py @@ -48,6 +48,7 @@ from overrides import override from pydantic_yaml import YamlModelMixin +from rockcraft.environment import expand_environment from rockcraft.errors import ProjectLoadError, ProjectValidationError from rockcraft.extensions import apply_extensions from rockcraft.parts import part_has_overlay, validate_part @@ -493,13 +494,12 @@ def from_yaml_file( ) -> "Project": """Instantiate this model from a YAML file.""" # pylint: disable=import-outside-toplevel - from rockcraft.lifecycle import _expand_environment data = load_project(path) if work_dir is not None: project_vars = {"version": data["version"]} - _expand_environment( + expand_environment( data, project_vars=project_vars, work_dir=work_dir, diff --git a/rockcraft/services/package.py b/rockcraft/services/package.py index 795b0673d..e4edd45e0 100644 --- a/rockcraft/services/package.py +++ b/rockcraft/services/package.py @@ -18,15 +18,18 @@ from __future__ import annotations +import datetime import pathlib import typing from typing import cast from craft_application import AppMetadata, PackageService, models, util +from craft_cli import emit from overrides import override -from rockcraft import errors +from rockcraft import errors, oci from rockcraft.models import Project +from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES if typing.TYPE_CHECKING: from rockcraft.services import RockcraftServiceFactory @@ -55,10 +58,6 @@ def pack(self, prime_dir: pathlib.Path, dest: pathlib.Path) -> list[pathlib.Path :param dest: Directory into which to write the package(s). :returns: A list of paths to created packages. """ - # pylint: disable=import-outside-toplevel - # This import stays here until we refactor 'lifecycle' completely out. - from rockcraft.lifecycle import _pack - # This inner import is necessary to resolve a cyclic import from rockcraft.services import RockcraftServiceFactory @@ -113,3 +112,93 @@ def metadata(self) -> models.BaseMetadata: """Get the metadata model for this project.""" # nop (no metadata file for Rockcraft) return models.BaseMetadata() + + +def _pack( + *, + prime_dir: pathlib.Path, + project: Project, + project_base_image: oci.Image, + base_digest: bytes, + rock_suffix: str, + build_for: str, + base_layer_dir: pathlib.Path, +) -> str: + """Create the rock image for a given architecture. + + :param lifecycle: + The lifecycle object containing the primed payload for the rock. + :param project_base_image: + The Image for the base over which the payload was primed. + :param base_digest: + The digest of the base image, to add to the new image's metadata. + :param rock_suffix: + The suffix to append to the image's filename, after the name and version. + :param build_for: + The architecture of the built rock, to add as metadata. + :param base_layer_dir: + The directory where the rock's base image was extracted. + """ + emit.progress("Creating new layer") + new_image = project_base_image.add_layer( + tag=project.version, + new_layer_dir=prime_dir, + base_layer_dir=base_layer_dir, + ) + emit.progress("Created new layer") + + if project.run_user: + emit.progress(f"Creating new user {project.run_user}") + new_image.add_user( + prime_dir=prime_dir, + base_layer_dir=base_layer_dir, + tag=project.version, + username=project.run_user, + uid=SUPPORTED_GLOBAL_USERNAMES[project.run_user]["uid"], + ) + + emit.progress(f"Setting the default OCI user to be {project.run_user}") + new_image.set_default_user(project.run_user) + + emit.progress("Adding Pebble entrypoint") + + new_image.set_entrypoint() + + services = project.dict(exclude_none=True, by_alias=True).get("services", {}) + + checks = project.dict(exclude_none=True, by_alias=True).get("checks", {}) + + if services or checks: + new_image.set_pebble_layer( + services=services, + checks=checks, + name=project.name, + tag=project.version, + summary=project.summary, + description=project.description, + base_layer_dir=base_layer_dir, + ) + + if project.environment: + new_image.set_environment(project.environment) + + # Set annotations and metadata, both dynamic and the ones based on user-provided properties + # Also include the "created" timestamp, just before packing the image + emit.progress("Adding metadata") + oci_annotations, rock_metadata = project.generate_metadata( + datetime.datetime.now(datetime.timezone.utc).isoformat(), base_digest + ) + rock_metadata["architecture"] = build_for + # TODO: add variant to rock_metadata too + # if build_for_variant: + # rock_metadata["variant"] = build_for_variant + new_image.set_annotations(oci_annotations) + new_image.set_control_data(rock_metadata) + emit.progress("Metadata added") + + emit.progress("Exporting to OCI archive") + archive_name = f"{project.name}_{project.version}_{rock_suffix}.rock" + new_image.to_oci_archive(tag=project.version, filename=archive_name) + emit.progress(f"Exported to OCI archive '{archive_name}'", permanent=True) + + return archive_name diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_lifecycle.py index 3ba39a559..36cae400f 100644 --- a/tests/integration/test_lifecycle.py +++ b/tests/integration/test_lifecycle.py @@ -13,13 +13,12 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import argparse from pathlib import Path import pytest import yaml -from rockcraft import lifecycle, oci +from rockcraft import oci from tests.util import jammy_only pytestmark = [jammy_only, pytest.mark.usefixtures("reset_callbacks")] @@ -65,8 +64,8 @@ def test_global_environment(new_dir, monkeypatch, mocker, reset_callbacks): Path("rockcraft.yaml").write_text(ROCKCRAFT_YAML) - args = argparse.Namespace(destructive_mode=True) - lifecycle.run("stage", args) + # args = argparse.Namespace(destructive_mode=True) + # lifecycle.run("stage", args) variables_yaml = Path(new_dir) / "stage/variables.yaml" assert variables_yaml.is_file() diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index 4b340c1ea..9d3bfdc92 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pathlib import Path -from rockcraft import lifecycle +from rockcraft.services import package def test_pack(package_service, default_factory, default_image_info, mocker): @@ -24,7 +24,7 @@ def test_pack(package_service, default_factory, default_image_info, mocker): mock_obtain_image = mocker.patch.object( image_service, "obtain_image", return_value=default_image_info ) - mock_inner_pack = mocker.patch.object(lifecycle, "_pack") + mock_inner_pack = mocker.patch.object(package, "_pack") package_service.pack(prime_dir=Path("prime"), dest=Path()) diff --git a/tests/unit/test_lifecycle.py b/tests/unit/test_lifecycle.py deleted file mode 100644 index 692119a1d..000000000 --- a/tests/unit/test_lifecycle.py +++ /dev/null @@ -1,465 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import argparse -from pathlib import Path -from unittest.mock import Mock, call - -import pytest -from craft_cli import EmitterMode, emit -from craft_providers.bases import BuilddBaseAlias - -import tests -from rockcraft import lifecycle - -pytestmark = pytest.mark.usefixtures("reset_callbacks") - - -@pytest.fixture -def mock_project_yaml(mocker): - _project_yaml = { - "name": "test-name", - "platforms": { - "test-platform": { - "build_on": ["test-build-on"], - "build_for": ["test-build-for"], - } - }, - "parts": {"foo": {"plugin": "nil"}}, - "package_repositories": [], - "version": "1.1.1", - } - yield _project_yaml - - -@pytest.fixture -def mock_project(mocker): - _mock_project = mocker.Mock() - _mock_project.name = "test-name" - _mock_project.platforms = { - "test-platform": { - "build_on": ["test-build-on"], - "build_for": ["test-build-for"], - } - } - _mock_project.parts = {"foo": {"plugin": "nil"}} - _mock_project.package_repositories = [] - _mock_project.version = "1.1.1" - yield _mock_project - - -@pytest.fixture() -def mock_provider(mocker, mock_instance, fake_provider): - _mock_provider = Mock(wraps=fake_provider) - mocker.patch( - "rockcraft.lifecycle.providers.get_provider", return_value=_mock_provider - ) - yield _mock_provider - - -@pytest.fixture() -def mock_get_instance_name(mocker): - yield mocker.patch( - "rockcraft.lifecycle.providers.get_instance_name", - return_value="test-instance-name", - ) - - -@pytest.mark.parametrize( - "command_name", ["pull", "overlay", "build", "stage", "prime", "pack"] -) -def test_run_run_in_provider(command_name, mocker, mock_project_yaml, mock_project): - """Verify `run()` calls `run_in_provider()` when not in managed or destructive - mode. - """ - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False) - mock_run_in_provider = mocker.patch("rockcraft.lifecycle.run_in_provider") - lifecycle.run(command_name=command_name, parsed_args=argparse.Namespace()) - - mock_run_in_provider.assert_called_once_with( - mock_project, command_name, argparse.Namespace() - ) - - -def test_run_clean_provider(mocker, mock_project_yaml): - """Verify `run()` calls `clean_provider()` when not in managed or destructive - mode. - """ - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False) - mock_clean_provider = mocker.patch("rockcraft.lifecycle.clean_provider") - lifecycle.run(command_name="clean", parsed_args=argparse.Namespace()) - - mock_clean_provider.assert_called_once_with( - project_name="test-name", project_path=Path().absolute() - ) - - -# region Tests for running inside the provider -@tests.linux_only -def test_run_clean_part(mocker, mock_project_yaml, mock_project, new_dir): - """Verify cleaning a specific part.""" - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=new_dir, - ) - mocker.patch( - "rockcraft.lifecycle.oci.Image.from_docker_registry", - return_value=(Mock(), Mock()), - ) - clean_mock = mocker.patch("rockcraft.parts.PartsLifecycle.clean") - - mock_project.parts = { - "foo": { - "plugin": "nil", - } - } - - lifecycle.run(command_name="clean", parsed_args=argparse.Namespace(parts=["foo"])) - - assert clean_mock.mock_calls == [call()] - - -@tests.linux_only -def test_run_clean_destructive_mode(mocker, mock_project_yaml, mock_project, new_dir): - """Verify cleaning in destructive mode.""" - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=new_dir, - ) - mocker.patch( - "rockcraft.lifecycle.oci.Image.from_docker_registry", - return_value=(Mock(), Mock()), - ) - clean_mock = mocker.patch("rockcraft.parts.PartsLifecycle.clean") - - mock_project.parts = { - "foo": { - "plugin": "nil", - } - } - - lifecycle.run( - command_name="clean", - parsed_args=argparse.Namespace(parts=["foo"], destructive_mode=True), - ) - - assert clean_mock.mock_calls == [call()] - - -@tests.linux_only -def test_run_destructive_mode(mocker, mock_project_yaml, mock_project, new_dir): - """Verify running in destructive mode.""" - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=new_dir, - ) - mocker.patch( - "rockcraft.lifecycle.oci.Image.from_docker_registry", - return_value=(Mock(), Mock()), - ) - run_mock = mocker.patch("rockcraft.parts.PartsLifecycle.run") - pack_mock = mocker.patch("rockcraft.lifecycle._pack") - - mock_project.parts = { - "foo": { - "plugin": "nil", - } - } - - lifecycle.run( - command_name="pack", - parsed_args=argparse.Namespace(parts=["foo"], destructive_mode=True), - ) - - assert run_mock.called - assert pack_mock.called - - -@tests.linux_only -@pytest.mark.parametrize( - "repos", - [ - [], - [{"type": "apt", "ppa": "ppa/ppa"}], - ], -) -def test_install_repositories(mocker, mock_project_yaml, mock_project, tmp_path, repos): - mock_project.package_repositories = repos - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=tmp_path, - ) - mocker.patch( - "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock()) - ) - mock_lifecycle_class = mocker.patch("rockcraft.lifecycle.PartsLifecycle") - - lifecycle.run(command_name="build", parsed_args=argparse.Namespace()) - - mock_lifecycle_class.assert_called_once_with( - mock_project.parts, - work_dir=tmp_path, - part_names=mocker.ANY, - base_layer_dir=mocker.ANY, - base_layer_hash=mocker.ANY, - package_repositories=repos, - base=mocker.ANY, - project_vars=mocker.ANY, - project_name=mocker.ANY, - ) - - -def test_install_repositories_none_conversion( - mocker, mock_project_yaml, mock_project, tmp_path -): - mock_project.package_repositories = None - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=tmp_path, - ) - mocker.patch( - "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock()) - ) - mock_lifecycle_class = mocker.patch("rockcraft.lifecycle.PartsLifecycle") - - lifecycle.run(command_name="build", parsed_args=argparse.Namespace()) - - mock_lifecycle_class.assert_called_once_with( - mock_project.parts, - work_dir=tmp_path, - part_names=mocker.ANY, - base_layer_dir=mocker.ANY, - base_layer_hash=mocker.ANY, - package_repositories=[], - base=mocker.ANY, - project_vars=mocker.ANY, - project_name=mocker.ANY, - ) - - -def test_project_variables(mocker, mock_project_yaml, mock_project, tmp_path): - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project) - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=tmp_path, - ) - mocker.patch( - "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock()) - ) - - # Set the project's name and version - mock_project.name = "fake-name" - # The version is set from the loaded YAML - mock_project_yaml["version"] = "1.2.3" - - mock_lifecycle_class = mocker.patch("rockcraft.lifecycle.PartsLifecycle") - - lifecycle.run(command_name="build", parsed_args=argparse.Namespace()) - mock_lifecycle_class.assert_called_once_with( - mock_project.parts, - work_dir=tmp_path, - part_names=mocker.ANY, - base_layer_dir=mocker.ANY, - base_layer_hash=mocker.ANY, - package_repositories=mocker.ANY, - base=mocker.ANY, - project_vars={"version": "1.2.3"}, - project_name="fake-name", - ) - - -def test_project_variables_expansion(mocker, mock_project_yaml, mock_project, tmp_path): - mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml) - mocker.patch("rockcraft.parts.PartsLifecycle.run") - mocker.patch("rockcraft.lifecycle._pack") - mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True) - mocker.patch( - "rockcraft.lifecycle.utils.get_managed_environment_home_path", - return_value=tmp_path, - ) - mocker.patch( - "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock()) - ) - unmarshal_mock = mocker.patch( - "rockcraft.lifecycle.Project.unmarshal", return_value=mock_project - ) - - # Add a global environment variable to the loaded YAML - mock_project_yaml["parts"]["foo"]["source"] = "v$CRAFT_PROJECT_VERSION" - # The expected expansion shall replace the above var with the project version - expanded_project_yaml = mock_project_yaml.copy() - expanded_project_yaml["parts"]["foo"]["source"] = f"v{mock_project_yaml['version']}" - - lifecycle.run( - command_name="pack", - parsed_args=argparse.Namespace(parts=["foo"], destructive_mode=True), - ) - - unmarshal_mock.assert_called_once_with(expanded_project_yaml) - - -# endregion -# region Tests for managing the provider -@pytest.mark.parametrize( - "emit_mode,verbosity", - [ - (EmitterMode.VERBOSE, ["--verbosity=verbose"]), - (EmitterMode.QUIET, ["--verbosity=quiet"]), - (EmitterMode.DEBUG, ["--verbosity=debug"]), - (EmitterMode.TRACE, ["--verbosity=trace"]), - (EmitterMode.BRIEF, ["--verbosity=brief"]), - ], -) -@pytest.mark.parametrize( - "rockcraft_base, provider_base", - [ - ("ubuntu:18.04", BuilddBaseAlias.BIONIC), - ("ubuntu:20.04", BuilddBaseAlias.FOCAL), - ("ubuntu:22.04", BuilddBaseAlias.JAMMY), - ], -) -def test_lifecycle_run_in_provider( - mock_get_instance_name, - mock_instance, - mock_provider, - mock_project, - mocker, - emit_mode, - verbosity, - rockcraft_base, - provider_base, -): - # mock provider calls - mock_base_configuration = Mock() - mock_get_base_configuration = mocker.patch( - "rockcraft.lifecycle.providers.get_base_configuration", - return_value=mock_base_configuration, - ) - mock_capture_logs_from_instance = mocker.patch( - "rockcraft.lifecycle.providers.capture_logs_from_instance" - ) - mock_ensure_provider_is_available = mocker.patch( - "rockcraft.lifecycle.providers.ensure_provider_is_available" - ) - mock_project.build_base = rockcraft_base - - cwd = Path().absolute() - - # set emitter mode - emit.set_mode(emit_mode) - - lifecycle.run_in_provider( - project=mock_project, - command_name="test", - parsed_args=argparse.Namespace(), - ) - - mock_ensure_provider_is_available.assert_called_once_with(mock_provider) - mock_get_instance_name.assert_called_once_with( - project_name="test-name", - project_path=cwd, - ) - mock_get_base_configuration.assert_called_once_with( - alias=provider_base, - project_name="test-name", - project_path=cwd, - ) - mock_provider.launched_environment.assert_called_once_with( - project_name="test-name", - project_path=cwd, - base_configuration=mock_base_configuration, - instance_name="test-instance-name", - ) - mock_instance.mount.assert_called_once_with( - host_source=cwd, target=Path("/root/project") - ) - mock_instance.execute_run.assert_called_once_with( - ["rockcraft", "test"] + verbosity, - check=True, - cwd=Path("/root/project"), - ) - mock_capture_logs_from_instance.assert_called_once_with(mock_instance) - - -def test_lifecycle_run_in_provider_debug( - mock_get_instance_name, - mock_instance, - mock_provider, - mock_project, - mocker, -): - # mock provider calls - mock_base_configuration = Mock() - mocker.patch( - "rockcraft.lifecycle.providers.get_base_configuration", - return_value=mock_base_configuration, - ) - mocker.patch("rockcraft.lifecycle.providers.capture_logs_from_instance") - mocker.patch("rockcraft.lifecycle.providers.ensure_provider_is_available") - mock_project.build_base = "ubuntu:22.04" - - lifecycle.run_in_provider( - project=mock_project, - command_name="test", - parsed_args=argparse.Namespace(debug=True), - ) - # Check that `rockcraft` was called with `--debug`. - mock_instance.execute_run.assert_called_once_with( - ["rockcraft", "test", "--verbosity=quiet", "--debug"], - check=True, - cwd=Path("/root/project"), - ) - - -def test_clean_provider(emitter, mock_get_instance_name, mock_provider, tmpdir): - lifecycle.clean_provider(project_name="test-project", project_path=tmpdir) - - mock_get_instance_name.assert_called_once_with( - project_name="test-project", project_path=tmpdir - ) - mock_provider.clean_project_environments.assert_called_once_with( - instance_name="test-instance-name" - ) - emitter.assert_interactions( - [ - call("progress", "Cleaning build provider"), - call("debug", "Cleaning instance test-instance-name"), - call("progress", "Cleaned build provider", permanent=True), - ] - ) - - -# endregion From ba99978d75956d178073f1dd4ef29cf7b76648f1 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Mon, 2 Oct 2023 18:46:20 -0300 Subject: [PATCH 5/9] refactor: drop most of `rockcraft.parts` --- rockcraft/__init__.py | 5 + rockcraft/parts.py | 251 +------------- .../integration/plugins/test_python_plugin.py | 1 - tests/integration/test_oci.py | 1 - tests/unit/test_parts.py | 317 ------------------ 5 files changed, 6 insertions(+), 569 deletions(-) delete mode 100644 tests/unit/test_parts.py diff --git a/rockcraft/__init__.py b/rockcraft/__init__.py index 0bc225918..f9057c75f 100644 --- a/rockcraft/__init__.py +++ b/rockcraft/__init__.py @@ -16,4 +16,9 @@ """The craft tool to create ROCKs.""" +from craft_parts import Features + __version__ = "0.0.1.dev1" + + +Features(enable_overlay=True) diff --git a/rockcraft/parts.py b/rockcraft/parts.py index a2ab62cb0..36ad00b16 100644 --- a/rockcraft/parts.py +++ b/rockcraft/parts.py @@ -15,258 +15,9 @@ # along with this program. If not, see . """Craft-parts lifecycle.""" -import contextlib -import pathlib -import subprocess -from typing import Any, Dict, List, Optional +from typing import Any, Dict import craft_parts -from craft_archives import repo -from craft_cli import emit -from craft_parts import ActionType, Step, callbacks -from craft_parts.errors import CallbackRegistrationError -from xdg import BaseDirectory # type: ignore - -from rockcraft.errors import PartsLifecycleError - -_LIFECYCLE_STEPS = { - "pull": Step.PULL, - "overlay": Step.OVERLAY, - "build": Step.BUILD, - "stage": Step.STAGE, - "prime": Step.PRIME, -} - - -craft_parts.Features(enable_overlay=True) - - -class PartsLifecycle: - """Create and manage the parts lifecycle. - - :param all_parts: A dictionary containing the parts defined in the project. - :param work_dir: The working directory for parts processing. - :param base_layer_dir: The path to the extracted base root filesystem. - :param base_layer_hash: The base image digest. - - :raises PartsLifecycleError: On error initializing the parts lifecycle. - """ - - # _OVERLAY_CALLBACK_REGISTERED = False - - def __init__( - self, - all_parts: Dict[str, Any], - *, - project_name: str, - work_dir: pathlib.Path, - part_names: Optional[List[str]], - base_layer_dir: pathlib.Path, - base_layer_hash: bytes, - base: str, - package_repositories: Optional[List[Dict[str, Any]]] = None, - project_vars: Optional[Dict[str, str]] = None, - ): - self._part_names = part_names - self._package_repositories = package_repositories or [] - - emit.progress("Initializing parts lifecycle") - - with contextlib.suppress(CallbackRegistrationError): - callbacks.register_configure_overlay(_install_overlay_repositories) - - # set the cache dir for parts package management - cache_dir = BaseDirectory.save_cache_path("rockcraft") - - try: - self._lcm = craft_parts.LifecycleManager( - {"parts": all_parts}, - application_name="rockcraft", - work_dir=work_dir, - cache_dir=cache_dir, - base_layer_dir=base_layer_dir, - base_layer_hash=base_layer_hash, - ignore_local_sources=["*.rock"], - base=base, - package_repositories=self._package_repositories, - project_name=project_name, - project_vars=project_vars, - ) - except craft_parts.PartsError as err: - raise PartsLifecycleError.from_parts_error(err) from err - - def clean(self) -> None: - """Remove lifecycle artifacts.""" - if self._part_names: - message = "Cleaning parts: " + ", ".join(self._part_names) - else: - message = "Cleaning all parts" - - emit.progress(message) - self._lcm.clean(part_names=self._part_names) - - @property - def prime_dir(self) -> pathlib.Path: - """Return the parts prime directory path.""" - return self._lcm.project_info.prime_dir - - @property - def project_info(self) -> craft_parts.ProjectInfo: - """Return the parts project info.""" - return self._lcm.project_info - - def run( - self, - step_name: str, - *, - shell: bool = False, - shell_after: bool = False, - debug: bool = False, - ) -> None: - """Run the parts lifecycle. - - :param step_name: The final step to execute. - :param shell: Execute a shell instead of the target step. - :param shell_after: Execute a shell after the target step. - :param debug: Execute a shell on failure. - - :raises PartsLifecycleError: On error during lifecycle. - :raises RuntimeError: On unexpected error. - """ - # pylint: disable=too-many-branches,too-many-statements - target_step = _LIFECYCLE_STEPS.get(step_name) - if not target_step: - raise RuntimeError(f"Invalid target step {step_name!r}") - - if shell: - # convert shell to shell_after for the previous step - previous_steps = target_step.previous_steps() - target_step = previous_steps[-1] if previous_steps else None - shell_after = True - - try: - if target_step: - actions = self._lcm.plan(target_step, part_names=self._part_names) - else: - actions = [] - - if self._package_repositories: - emit.progress("Installing package repositories") - self._install_package_repositories() - - emit.progress("Executing parts lifecycle") - - with self._lcm.action_executor() as aex: - for action in actions: - message = _action_message(action) - emit.progress(f"Executing parts lifecycle: {message}") - with emit.open_stream("Executing action") as stream: - aex.execute(action, stdout=stream, stderr=stream) - emit.progress(f"Executed: {message}", permanent=True) - - if shell_after: - launch_shell() - - emit.progress("Executed parts lifecycle", permanent=True) - except craft_parts.PartsError as err: - if debug: - emit.progress(str(err), permanent=True) - launch_shell() - raise PartsLifecycleError.from_parts_error(err) from err - except RuntimeError as err: - if debug: - emit.progress(str(err), permanent=True) - launch_shell() - raise RuntimeError(f"Parts processing internal error: {err}") from err - except OSError as err: - msg = err.strerror - if err.filename: - msg = f"{err.filename}: {msg}" - if debug: - emit.progress(msg, permanent=True) - launch_shell() - raise PartsLifecycleError(msg) from err - except Exception as err: - if debug: - emit.progress(str(err), permanent=True) - launch_shell() - raise PartsLifecycleError(str(err)) from err - - def _install_package_repositories(self) -> None: - """Install package repositories in the environment.""" - if not self._package_repositories: - emit.debug("No package repositories specified, none to install.") - return - - refresh_required = repo.install( - self._package_repositories, key_assets=pathlib.Path("/dev/null") - ) - if refresh_required: - emit.progress("Refreshing repositories") - self._lcm.refresh_packages_list() - - emit.progress("Package repositories installed", permanent=True) - - -def launch_shell(*, cwd: Optional[pathlib.Path] = None) -> None: - """Launch a user shell for debugging environment. - - :param cwd: Working directory to start user in. - """ - emit.progress("Launching shell on build environment...", permanent=True) - with emit.pause(): - subprocess.run(["bash"], check=False, cwd=cwd) - - -def _install_overlay_repositories(overlay_dir, project_info): - if project_info.base != "bare": - package_repositories = project_info.package_repositories - repo.install_in_root( - project_repositories=package_repositories, - root=overlay_dir, - key_assets=pathlib.Path("/dev/null"), - ) - - -def _action_message(action: craft_parts.Action) -> str: - msg = { - Step.PULL: { - ActionType.RUN: "pull", - ActionType.RERUN: "repull", - ActionType.SKIP: "skip pull", - ActionType.UPDATE: "update sources for", - }, - Step.OVERLAY: { - ActionType.RUN: "overlay", - ActionType.RERUN: "re-overlay", - ActionType.SKIP: "skip overlay", - ActionType.UPDATE: "update overlay for", - ActionType.REAPPLY: "reapply", - }, - Step.BUILD: { - ActionType.RUN: "build", - ActionType.RERUN: "rebuild", - ActionType.SKIP: "skip build", - ActionType.UPDATE: "update build for", - }, - Step.STAGE: { - ActionType.RUN: "stage", - ActionType.RERUN: "restage", - ActionType.SKIP: "skip stage", - }, - Step.PRIME: { - ActionType.RUN: "prime", - ActionType.RERUN: "re-prime", - ActionType.SKIP: "skip prime", - }, - } - - message = f"{msg[action.step][action.action_type]} {action.part_name}" - - if action.reason: - message += f" ({action.reason})" - - return message def validate_part(data: Dict[str, Any]) -> None: diff --git a/tests/integration/plugins/test_python_plugin.py b/tests/integration/plugins/test_python_plugin.py index 00dd78311..f814f92b7 100644 --- a/tests/integration/plugins/test_python_plugin.py +++ b/tests/integration/plugins/test_python_plugin.py @@ -25,7 +25,6 @@ from rockcraft import errors, plugins from rockcraft.models.project import Project -from rockcraft.parts import PartsLifecycle from rockcraft.plugins.python_plugin import SITECUSTOMIZE_TEMPLATE from tests.util import ubuntu_only diff --git a/tests/integration/test_oci.py b/tests/integration/test_oci.py index 72109472f..4e42b8c7e 100644 --- a/tests/integration/test_oci.py +++ b/tests/integration/test_oci.py @@ -21,7 +21,6 @@ from typing import Callable, List, Tuple from rockcraft import oci -from rockcraft.parts import PartsLifecycle from tests.util import jammy_only pytestmark = jammy_only diff --git a/tests/unit/test_parts.py b/tests/unit/test_parts.py deleted file mode 100644 index ac89ac55c..000000000 --- a/tests/unit/test_parts.py +++ /dev/null @@ -1,317 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021-2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from pathlib import Path -from typing import cast -from unittest.mock import call - -import craft_cli -import pytest -from craft_parts import Action, PartsError, Step, callbacks - -import tests -from rockcraft import parts -from rockcraft.errors import PartsLifecycleError, RockcraftError - - -@pytest.fixture -def parts_data(): - yield { - "foo": { - "plugin": "nil", - } - } - - -@pytest.fixture -def create_lifecycle(new_dir): - def _inner_create(**kwargs) -> parts.PartsLifecycle: - kwargs.setdefault("base_layer_hash", b"digest") - kwargs.setdefault("base", "unused") - kwargs.setdefault("project_name", "my-project") - kwargs.setdefault("part_names", None) - kwargs.setdefault("base_layer_dir", new_dir) - kwargs.setdefault("work_dir", new_dir) - - return parts.PartsLifecycle( - **kwargs, - ) - - return _inner_create - - -@tests.linux_only -def test_parts_lifecycle_prime_dir(create_lifecycle): - parts_data = { - "foo": { - "plugin": "nil", - } - } - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("/some/workdir")) - assert lifecycle.prime_dir == Path("/some/workdir/prime") - - -@tests.linux_only -def test_parts_lifecycle_project_info(create_lifecycle): - parts_data = { - "foo": { - "plugin": "nil", - } - } - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("/some/workdir")) - assert lifecycle.project_info.work_dir == Path("/some/workdir") - assert lifecycle.project_info.base == "unused" - - -@tests.linux_only -def test_parts_lifecycle_run(create_lifecycle): - parts_data = { - "foo": { - "plugin": "dump", - "source": "dir1", - } - } - - Path("dir1").mkdir() - Path("dir1/foo.txt").touch() - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path(".")) - lifecycle.run("prime") - - assert Path(lifecycle.prime_dir, "foo.txt").is_file() - - -@pytest.mark.parametrize("repos", [[{"type": "apt", "ppa": "mozillateam/ppa"}]]) -@tests.linux_only -def test_run_installs_package_repositories( - mocker, parts_data, tmp_path, repos, create_lifecycle -): - lifecycle = create_lifecycle( - all_parts=parts_data, - work_dir=tmp_path, - base_layer_dir=tmp_path, - package_repositories=repos, - ) - lcm = mocker.patch.object(lifecycle, "_lcm") - lcm.plan.return_value = [] - mocker.patch("craft_archives.repo.install", return_value=False) - progress_spy = mocker.spy(craft_cli.emit, "progress") - - lifecycle.run("pull") - - progress_spy.assert_has_calls( - [ - call("Installing package repositories"), - call("Package repositories installed", permanent=True), - ] - ) - - -@pytest.mark.parametrize( - "step_name,expected_last_step", - [ - ("pull", None), - ("overlay", Step.PULL), - ("build", Step.OVERLAY), - ("stage", Step.BUILD), - ("prime", Step.STAGE), - ], -) -@tests.linux_only -def test_parts_lifecycle_run_shell( - mocker, step_name, expected_last_step, parts_data, create_lifecycle -): - """Check if the last step executed before shell is the previous step.""" - last_step = None - - def _fake_execute(_, action: Action, **kwargs): # pylint: disable=unused-argument - nonlocal last_step - last_step = action.step - - mocker.patch("craft_parts.executor.Executor.execute", new=_fake_execute) - shell_mock = mocker.patch("subprocess.run") - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path(".")) - lifecycle.run(step_name, shell=True) - - assert last_step == expected_last_step - assert shell_mock.mock_calls == [call(["bash"], check=False, cwd=None)] - - -@pytest.mark.parametrize( - "step_name,expected_last_step", - [ - ("pull", Step.PULL), - ("overlay", Step.OVERLAY), - ("build", Step.BUILD), - ("stage", Step.STAGE), - ("prime", Step.PRIME), - ], -) -@tests.linux_only -def test_parts_lifecycle_run_shell_after( - mocker, step_name, expected_last_step, create_lifecycle -): - """Check if the last step executed before shell is the current step.""" - last_step = None - - def _fake_execute(_, action: Action, **kwargs): # pylint: disable=unused-argument - nonlocal last_step - last_step = action.step - - mocker.patch("craft_parts.executor.Executor.execute", new=_fake_execute) - shell_mock = mocker.patch("subprocess.run") - - parts_data = { - "foo": { - "plugin": "nil", - } - } - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path(".")) - lifecycle.run(step_name, shell_after=True) - - assert last_step == expected_last_step - assert shell_mock.mock_calls == [call(["bash"], check=False, cwd=None)] - - -@pytest.mark.parametrize( - "internal_exception,expected_exception_type", - [ - (PartsError("Unexpected error"), PartsLifecycleError), - (RuntimeError("Unexpected error"), RuntimeError), - (FileNotFoundError(2, "Unexpected error"), PartsLifecycleError), - (Exception("Unexpected error"), PartsLifecycleError), - ], -) -@tests.linux_only -def test_parts_lifecycle_run_debug( - mocker, internal_exception, expected_exception_type, create_lifecycle -): - """Check that when "debug" is True, a shell is launched when an error is raised.""" - - mocker.patch( - "craft_parts.executor.Executor.execute", - side_effect=internal_exception, - ) - shell_mock = mocker.patch("rockcraft.parts.launch_shell") - - parts_data = { - "foo": { - "plugin": "nil", - } - } - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path(".")) - with pytest.raises(expected_exception_type, match="Unexpected error"): - lifecycle.run("prime", debug=True) - - shell_mock.assert_called_once_with() - - -def assert_errors_match( - rockcraft_error: RockcraftError, parts_error: PartsError -) -> None: - """Assert that the RockcraftError's fields match those on the PartsError.""" - assert str(rockcraft_error) == parts_error.brief - assert rockcraft_error.details == parts_error.details - assert rockcraft_error.resolution == parts_error.resolution - - -@tests.linux_only -def test_parts_lifecycle_init_error(create_lifecycle): - parts_data = { - "foo": { - "invalid": True, - } - } - - with pytest.raises(parts.PartsLifecycleError) as exc: - create_lifecycle(all_parts=parts_data, work_dir=Path(".")) - - rockcraft_error = exc.value - parts_error = cast(PartsError, rockcraft_error.__cause__) - - assert_errors_match(rockcraft_error, parts_error) - - -@tests.linux_only -def test_parts_lifecycle_run_error(create_lifecycle): - parts_data = { - "foo": { - "plugin": "nil", - } - } - - lifecycle = create_lifecycle( - all_parts=parts_data, work_dir=Path("."), part_names=["fake_part"] - ) - - with pytest.raises(parts.PartsLifecycleError) as exc: - # This fails because `part_names` references a part that doesn't exist - lifecycle.run(step_name="pull") - - rockcraft_error = exc.value - parts_error = cast(PartsError, rockcraft_error.__cause__) - - assert_errors_match(rockcraft_error, parts_error) - - -@tests.linux_only -def test_parts_lifecycle_clean(create_lifecycle, emitter): - parts_data = { - "foo": { - "plugin": "nil", - } - } - - lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path(".")) - lifecycle.clean() - emitter.assert_progress("Cleaning all parts") - - -@tests.linux_only -def test_parts_lifecycle_clean_parts(create_lifecycle, emitter): - parts_data = { - "foo": { - "plugin": "nil", - }, - "bar": { - "plugin": "nil", - }, - } - - lifecycle = create_lifecycle(all_parts=parts_data, part_names=["foo"]) - lifecycle.clean() - emitter.assert_progress("Cleaning parts: foo") - - -@tests.linux_only -def test_parts_register_overlay_callback(mocker, create_lifecycle): - """Test that PartsLifecycle registers the overlay callback.""" - # pylint: disable=protected-access - register_spy = mocker.spy(callbacks, "register_configure_overlay") - - # Check that creating the lifecycle registers the callback - assert not register_spy.called - create_lifecycle(all_parts={}) - register_spy.assert_called_once_with(parts._install_overlay_repositories) - - # Check that creating another lifecycle does *not* raise an error from re- - # registering the callback. - create_lifecycle(all_parts={}) From 9c496317a2306f4db04645a1d3e9572ef01cb004 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Mon, 2 Oct 2023 18:53:25 -0300 Subject: [PATCH 6/9] refactor: drop `rockcraft.providers` --- rockcraft/providers.py | 196 ----------------------- tests/unit/test_providers.py | 296 ----------------------------------- 2 files changed, 492 deletions(-) delete mode 100644 rockcraft/providers.py delete mode 100644 tests/unit/test_providers.py diff --git a/rockcraft/providers.py b/rockcraft/providers.py deleted file mode 100644 index f5cf88210..000000000 --- a/rockcraft/providers.py +++ /dev/null @@ -1,196 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -"""Rockcraft-specific code to interface with craft-providers.""" - -import os -import sys -from pathlib import Path -from typing import Dict, Optional - -from craft_cli import emit -from craft_providers import Provider, ProviderError, bases, executor -from craft_providers.actions.snap_installer import Snap -from craft_providers.lxd import LXDProvider -from craft_providers.multipass import MultipassProvider - -from .utils import ( - confirm_with_user, - get_managed_environment_log_path, - get_managed_environment_snap_channel, -) - -ROCKCRAFT_BASE_TO_PROVIDER_BASE = { - "ubuntu:18.04": bases.BuilddBaseAlias.BIONIC, - "ubuntu:20.04": bases.BuilddBaseAlias.FOCAL, - "ubuntu:22.04": bases.BuilddBaseAlias.JAMMY, -} - - -def get_command_environment() -> Dict[str, Optional[str]]: - """Construct the required environment.""" - env = bases.buildd.default_command_environment() - env["CRAFT_MANAGED_MODE"] = "1" - - # Pass-through host environment that target may need. - for env_key in ["http_proxy", "https_proxy", "no_proxy"]: - if env_key in os.environ: - env[env_key] = os.environ[env_key] - - return env - - -def get_instance_name(*, project_name: str, project_path: Path) -> str: - """Formulate the name for an instance using each of the given parameters. - - Incorporate each of the parameters into the name to come up with a - predictable naming schema that avoids name collisions across multiple - projects. - - :param project_name: Name of the project. - :param project_path: Directory of the project. - """ - return "-".join( - [ - "rockcraft", - project_name, - str(project_path.stat().st_ino), - ] - ) - - -def capture_logs_from_instance(instance: executor.Executor) -> None: - """Capture and emit rockcraft logs from an instance. - - :param instance: instance to retrieve logs from - """ - source_log_path = get_managed_environment_log_path() - with instance.temporarily_pull_file( - source=source_log_path, missing_ok=True - ) as log_path: - if log_path: - emit.debug("Logs retrieved from managed instance:") - with open(log_path, "r") as log_file: - for line in log_file: - emit.debug(":: " + line.rstrip()) - else: - emit.debug( - f"Could not find log file {source_log_path.as_posix()} in instance." - ) - - -def get_base_configuration( - *, alias: bases.BuilddBaseAlias, project_name: str, project_path: Path -) -> bases.BuilddBase: - """Create a BuilddBase configuration for rockcraft.""" - instance_name = get_instance_name( - project_name=project_name, - project_path=project_path, - ) - - # injecting a snap on a non-linux system is not supported, so default to - # install rockcraft from the store's stable channel - snap_channel = get_managed_environment_snap_channel() - if sys.platform != "linux" and not snap_channel: - snap_channel = "stable" - - return bases.BuilddBase( - alias=alias, - compatibility_tag=f"rockcraft-{bases.BuilddBase.compatibility_tag}.0", - environment=get_command_environment(), - hostname=instance_name, - snaps=[ - Snap( - name="rockcraft", - channel=snap_channel, - classic=True, - ) - ], - packages=["gpg", "dirmngr"], - ) - - -def ensure_provider_is_available(provider: Provider) -> None: - """Ensure provider is installed, running, and properly configured. - - If the provider is not installed, the user is prompted to install it. - - :param instance: the provider to ensure is available - - :raises ProviderError: if provider is unknown, not available, or if the user - chooses not to install the provider. - """ - if isinstance(provider, LXDProvider): - if not LXDProvider.is_provider_installed() and not confirm_with_user( - "LXD is required but not installed. Do you wish to install LXD and configure " - "it with the defaults?", - default=False, - ): - raise ProviderError( - "LXD is required, but not installed. Visit https://snapcraft.io/lxd " - "for instructions on how to install the LXD snap for your distribution", - ) - LXDProvider.ensure_provider_is_available() - elif isinstance(provider, MultipassProvider): - if not MultipassProvider.is_provider_installed() and not confirm_with_user( - "Multipass is required but not installed. Do you wish to install Multipass" - " and configure it with the defaults?", - default=False, - ): - raise ProviderError( - "Multipass is required, but not installed. Visit https://multipass.run/" - "for instructions on installing Multipass for your operating system." - ) - MultipassProvider.ensure_provider_is_available() - else: - raise ProviderError("cannot install unknown provider") - - -def get_provider() -> Provider: - """Get the configured or appropriate provider for the host OS. - - To determine the appropriate provider, - (1) get the provider from the environment variable `ROCKCRAFT_PROVIDER` - (2) default to platform default (LXD on Linux, otherwise Multipass) - - :return: Provider instance. - """ - env_provider = os.getenv("ROCKCRAFT_PROVIDER") - - # (1) get the provider from the environment variable `ROCKCRAFT_PROVIDER` - if env_provider: - emit.debug( - f"Using provider {env_provider!r} from environmental variable " - "'ROCKCRAFT_PROVIDER'" - ) - chosen_provider = env_provider - - # (2) default to platform default (LXD on Linux, otherwise Multipass) - elif sys.platform == "linux": - emit.debug("Using default provider 'lxd' on linux system") - chosen_provider = "lxd" - else: - emit.debug("Using default provider 'multipass' on non-linux system") - chosen_provider = "multipass" - - # return the chosen provider - if chosen_provider == "lxd": - return LXDProvider(lxd_project="rockcraft") - if chosen_provider == "multipass": - return MultipassProvider() - - raise ValueError(f"unsupported provider specified: {chosen_provider!r}") diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py deleted file mode 100644 index 6922f8fed..000000000 --- a/tests/unit/test_providers.py +++ /dev/null @@ -1,296 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from pathlib import Path -from unittest.mock import MagicMock, Mock, call - -import pytest -from craft_providers import ProviderError, bases -from craft_providers.actions.snap_installer import Snap -from craft_providers.lxd import LXDProvider -from craft_providers.multipass import MultipassProvider - -from rockcraft import providers - - -def test_get_command_environment_minimal(monkeypatch): - monkeypatch.setenv("IGNORE_ME", "or-im-failing") - monkeypatch.setenv("PATH", "not-using-host-path") - - assert providers.get_command_environment() == { - "CRAFT_MANAGED_MODE": "1", - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin", - } - - -def test_get_command_environment_all_opts(monkeypatch): - monkeypatch.setenv("IGNORE_ME", "or-im-failing") - monkeypatch.setenv("PATH", "not-using-host-path") - monkeypatch.setenv("http_proxy", "test-http-proxy") - monkeypatch.setenv("https_proxy", "test-https-proxy") - monkeypatch.setenv("no_proxy", "test-no-proxy") - - assert providers.get_command_environment() == { - "CRAFT_MANAGED_MODE": "1", - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin", - "http_proxy": "test-http-proxy", - "https_proxy": "test-https-proxy", - "no_proxy": "test-no-proxy", - } - - -def test_get_instance_name(tmp_path): - assert ( - providers.get_instance_name( - project_name="my-project-name", - project_path=tmp_path, - ) - == f"rockcraft-my-project-name-{tmp_path.stat().st_ino}" - ) - - -def test_capture_logs_from_instance(mocker, emitter, mock_instance, new_dir): - """Verify logs from an instance are retrieved and emitted.""" - fake_log = Path(new_dir / "fake.file") - fake_log_data = "some\nlog data\nhere" - fake_log.write_text(fake_log_data, encoding="utf-8") - - mock_instance.temporarily_pull_file = MagicMock() - mock_instance.temporarily_pull_file.return_value = fake_log - - providers.capture_logs_from_instance(mock_instance) - - assert mock_instance.mock_calls == [ - call.temporarily_pull_file(source=Path("/tmp/rockcraft.log"), missing_ok=True) - ] - expected = [ - call("debug", "Logs retrieved from managed instance:"), - call("debug", ":: some"), - call("debug", ":: log data"), - call("debug", ":: here"), - ] - emitter.assert_interactions(expected) - - -def test_capture_log_from_instance_not_found(mocker, emitter, mock_instance, new_dir): - """Verify a missing log file is handled properly.""" - mock_instance.temporarily_pull_file = MagicMock(return_value=None) - mock_instance.temporarily_pull_file.return_value = ( - mock_instance.temporarily_pull_file - ) - mock_instance.temporarily_pull_file.__enter__ = Mock(return_value=None) - - providers.capture_logs_from_instance(mock_instance) - - emitter.assert_debug("Could not find log file /tmp/rockcraft.log in instance.") - mock_instance.temporarily_pull_file.assert_called_with( - source=Path("/tmp/rockcraft.log"), missing_ok=True - ) - - -@pytest.mark.parametrize( - "platform, snap_channel, expected_snap_channel", - [ - ("linux", None, None), - ("linux", "edge", "edge"), - ("darwin", "edge", "edge"), - # default to stable on non-linux system - ("darwin", None, "stable"), - ], -) -@pytest.mark.parametrize( - "alias", - [ - bases.BuilddBaseAlias.BIONIC, - bases.BuilddBaseAlias.FOCAL, - bases.BuilddBaseAlias.JAMMY, - ], -) -def test_get_base_configuration( - platform, - snap_channel, - expected_snap_channel, - alias, - tmp_path, - mocker, -): - """Verify the rockcraft snap is installed from the correct channel.""" - mocker.patch("sys.platform", platform) - mocker.patch( - "rockcraft.providers.get_managed_environment_snap_channel", - return_value=snap_channel, - ) - mocker.patch( - "rockcraft.providers.get_command_environment", - return_value="test-env", - ) - mocker.patch( - "rockcraft.providers.get_instance_name", - return_value="test-instance-name", - ) - mock_buildd_base = mocker.patch("rockcraft.providers.bases.BuilddBase") - mock_buildd_base.compatibility_tag = "buildd-base-v0" - - providers.get_base_configuration( - alias=alias, project_name="test-name", project_path=tmp_path - ) - - mock_buildd_base.assert_called_with( - alias=alias, - compatibility_tag="rockcraft-buildd-base-v0.0", - environment="test-env", - hostname="test-instance-name", - snaps=[Snap(name="rockcraft", channel=expected_snap_channel, classic=True)], - packages=["gpg", "dirmngr"], - ) - - -@pytest.mark.parametrize( - "is_provider_installed, confirm_with_user", - [(True, True), (True, False), (False, True)], -) -def test_ensure_provider_is_available_lxd( - is_provider_installed, confirm_with_user, mocker -): - """Verify LXD is ensured to be available when LXD is installed or the user chooses - to install LXD.""" - mock_lxd_provider = Mock(spec=LXDProvider) - mocker.patch( - "rockcraft.providers.LXDProvider.is_provider_installed", - return_value=is_provider_installed, - ) - mocker.patch( - "rockcraft.providers.confirm_with_user", - return_value=confirm_with_user, - ) - mock_ensure_provider_is_available = mocker.patch( - "rockcraft.providers.ensure_provider_is_available" - ) - - providers.ensure_provider_is_available(mock_lxd_provider) - - mock_ensure_provider_is_available.assert_called_once() - - -def test_ensure_provider_is_available_lxd_error(mocker): - """Raise an error if the user does not choose to install LXD.""" - mock_lxd_provider = Mock(spec=LXDProvider) - mocker.patch( - "rockcraft.providers.LXDProvider.is_provider_installed", - return_value=False, - ) - mocker.patch("rockcraft.providers.confirm_with_user", return_value=False) - - with pytest.raises(ProviderError) as error: - providers.ensure_provider_is_available(mock_lxd_provider) - - assert error.value.brief == ( - "LXD is required, but not installed. Visit https://snapcraft.io/lxd for " - "instructions on how to install the LXD snap for your distribution" - ) - - -@pytest.mark.parametrize( - "is_provider_installed, confirm_with_user", - [(True, True), (True, False), (False, True)], -) -def test_ensure_provider_is_available_multipass( - is_provider_installed, confirm_with_user, mocker -): - """Verify Multipass is ensured to be available when Multipass is installed or the - user chooses to install Multipass.""" - mock_multipass_provider = Mock(spec=MultipassProvider) - mocker.patch( - "rockcraft.providers.MultipassProvider.is_provider_installed", - return_value=is_provider_installed, - ) - mocker.patch( - "rockcraft.providers.confirm_with_user", - return_value=confirm_with_user, - ) - mock_ensure_provider_is_available = mocker.patch( - "rockcraft.providers.ensure_provider_is_available" - ) - - providers.ensure_provider_is_available(mock_multipass_provider) - - mock_ensure_provider_is_available.assert_called_once() - - -def test_ensure_provider_is_available_multipass_error(mocker): - """Raise an error if the user does not choose to install Multipass.""" - mock_multipass_provider = Mock(spec=MultipassProvider) - mocker.patch( - "rockcraft.providers.MultipassProvider.is_provider_installed", - return_value=False, - ) - mocker.patch("rockcraft.providers.confirm_with_user", return_value=False) - - with pytest.raises(ProviderError) as error: - providers.ensure_provider_is_available(mock_multipass_provider) - - assert error.value.brief == ( - "Multipass is required, but not installed. Visit https://multipass.run/for " - "instructions on installing Multipass for your operating system." - ) - - -def test_ensure_provider_is_available_unknown_error(): - """Raise an error if the provider type is unknown.""" - mock_multipass_provider = Mock() - - with pytest.raises(ProviderError) as error: - providers.ensure_provider_is_available(mock_multipass_provider) - - assert error.value.brief == "cannot install unknown provider" - - -def test_get_provider_default_lxd(emitter, mocker): - """Verify lxd is the default provider when running on a linux system.""" - mocker.patch("sys.platform", "linux") - provider = providers.get_provider() - assert isinstance(provider, LXDProvider) - assert provider.lxd_project == "rockcraft" - emitter.assert_debug("Using default provider 'lxd' on linux system") - - -@pytest.mark.parametrize("system", ["darwin", "win32", "other-system"]) -def test_get_provider_default_multipass(emitter, mocker, system): - """Verify multipass is the default provider when running on a non-linux system.""" - mocker.patch("sys.platform", system) - assert isinstance(providers.get_provider(), MultipassProvider) - emitter.assert_debug("Using default provider 'multipass' on non-linux system") - - -def test_get_provider_environmental_variable(monkeypatch): - """Verify the provider can be set by an environmental variable.""" - monkeypatch.setenv("ROCKCRAFT_PROVIDER", "lxd") - provider = providers.get_provider() - assert isinstance(provider, LXDProvider) - assert provider.lxd_project == "rockcraft" - - monkeypatch.setenv("ROCKCRAFT_PROVIDER", "multipass") - assert isinstance(providers.get_provider(), MultipassProvider) - - -def test_get_provider_error(monkeypatch): - """Raise a ValueError when an invalid provider is passed.""" - monkeypatch.setenv("ROCKCRAFT_PROVIDER", "invalid-provider") - - with pytest.raises(ValueError) as error: - providers.get_provider() - - assert str(error.value) == "unsupported provider specified: 'invalid-provider'" From 0a7536fcde3f3f3c405024480fefdb880632e571 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Tue, 3 Oct 2023 20:58:24 -0300 Subject: [PATCH 7/9] refactor: migrate integration tests This is a bit more involved because the previous tests were heavily tied to the previous code, so it's done as a separate commit. There is no change in functionality, just some cleaning-up and updating of the code. --- tests/conftest.py | 135 ++++++++++++++++++ tests/integration/conftest.py | 15 ++ .../integration/plugins/test_python_plugin.py | 34 +++-- tests/integration/services/__init__.py | 0 .../test_lifecycle.py} | 29 ++-- ...{test_lifecycle.py => test_application.py} | 30 +++- tests/integration/test_oci.py | 70 ++++----- tests/testing/__init__.py | 0 tests/testing/lifecycle.py | 61 ++++++++ tests/testing/project.py | 41 ++++++ tests/unit/conftest.py | 112 --------------- 11 files changed, 344 insertions(+), 183 deletions(-) create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/services/__init__.py rename tests/integration/{test_parts.py => services/test_lifecycle.py} (85%) rename tests/integration/{test_lifecycle.py => test_application.py} (73%) create mode 100644 tests/testing/__init__.py create mode 100644 tests/testing/lifecycle.py create mode 100644 tests/testing/project.py diff --git a/tests/conftest.py b/tests/conftest.py index 61db670b4..22d39c9d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import os +from pathlib import Path import pytest import xdg # type: ignore @@ -100,3 +101,137 @@ def assert_recorded(self, expected): def assert_recorded_raw(self, expected): """Verify that the given messages (with specific level) were recorded consecutively.""" self._check(expected, self.raw) + + +@pytest.fixture +def extra_project_params(): + """Configuration fixture for the Project used by the default services.""" + return {} + + +@pytest.fixture +def default_project(extra_project_params): + from craft_application.models import VersionStr + + from rockcraft.models.project import NameStr, Project + + parts = extra_project_params.pop("parts", {}) + + return Project( + name=NameStr("default"), + version=VersionStr("1.0"), + summary="default project", + description="default project", + base="ubuntu:22.04", + parts=parts, + license="MIT", + platforms={"amd64": None}, + **extra_project_params, + ) + + +@pytest.fixture +def default_factory(default_project): + from rockcraft.application import APP_METADATA + from rockcraft.services import RockcraftServiceFactory + + factory = RockcraftServiceFactory( + app=APP_METADATA, + project=default_project, + ) + factory.set_kwargs("image", work_dir=Path("work"), build_for="amd64") + return factory + + +@pytest.fixture +def default_image_info(): + from rockcraft import oci + from rockcraft.services.image import ImageInfo + + return ImageInfo( + base_image=oci.Image(image_name="fake_image", path=Path()), + base_layer_dir=Path(), + base_digest=b"deadbeef", + ) + + +@pytest.fixture +def default_application(default_factory, default_project): + from rockcraft.application import APP_METADATA, Rockcraft + + return Rockcraft(APP_METADATA, default_factory) + + +@pytest.fixture +def image_service(default_project, default_factory, tmp_path): + from rockcraft.application import APP_METADATA + from rockcraft.services import RockcraftImageService + + return RockcraftImageService( + app=APP_METADATA, + project=default_project, + services=default_factory, + work_dir=tmp_path, + build_for="amd64", + ) + + +@pytest.fixture +def provider_service(default_project, default_factory, tmp_path): + from rockcraft.application import APP_METADATA + from rockcraft.services import RockcraftProviderService + + return RockcraftProviderService( + app=APP_METADATA, + project=default_project, + services=default_factory, + work_dir=tmp_path, + ) + + +@pytest.fixture +def package_service(default_project, default_factory): + from rockcraft.application import APP_METADATA + from rockcraft.services import RockcraftPackageService + + return RockcraftPackageService( + app=APP_METADATA, + project=default_project, + services=default_factory, + platform="amd64", + build_for="amd64", + ) + + +@pytest.fixture +def lifecycle_service(default_project, default_factory): + from rockcraft.application import APP_METADATA + from rockcraft.services import RockcraftLifecycleService + + return RockcraftLifecycleService( + app=APP_METADATA, + project=default_project, + services=default_factory, + work_dir=Path("work/"), + cache_dir=Path("cache/"), + build_for="amd64", + ) + + +@pytest.fixture +def mock_obtain_image(default_factory, mocker): + """Mock and return the "obtain_image()" method of the default image service.""" + image_service = default_factory.image + return mocker.patch.object(image_service, "obtain_image") + + +@pytest.fixture +def run_lifecycle(mocker): + """Helper to call testing.run_mocked_lifecycle().""" + + def _inner(**kwargs): + from tests.testing.lifecycle import run_mocked_lifecycle + + return run_mocked_lifecycle(mocker=mocker, **kwargs) + + return _inner diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 000000000..eee9de05a --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,15 @@ +# This file is part of Rockcraft. +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . diff --git a/tests/integration/plugins/test_python_plugin.py b/tests/integration/plugins/test_python_plugin.py index f814f92b7..862350d74 100644 --- a/tests/integration/plugins/test_python_plugin.py +++ b/tests/integration/plugins/test_python_plugin.py @@ -19,13 +19,15 @@ from pathlib import Path import pytest +from craft_application import errors from craft_cli import EmitterMode, emit from craft_parts.errors import OsReleaseVersionIdError from craft_parts.utils.os_utils import OsRelease -from rockcraft import errors, plugins +from rockcraft import plugins from rockcraft.models.project import Project from rockcraft.plugins.python_plugin import SITECUSTOMIZE_TEMPLATE +from tests.testing.project import create_project from tests.util import ubuntu_only pytestmark = ubuntu_only @@ -45,9 +47,8 @@ def setup_python_test(monkeypatch): plugins.register() -def run_lifecycle(base: str, work_dir: Path, extra_part_props=None) -> None: +def create_python_project(base, extra_part_props=None) -> Project: source = Path(__file__).parent / "python_source" - extra = extra_part_props or {} parts = { @@ -59,18 +60,11 @@ def run_lifecycle(base: str, work_dir: Path, extra_part_props=None) -> None: } } - lifecycle = PartsLifecycle( - all_parts=parts, - work_dir=work_dir, - part_names=None, - base_layer_dir=Path("unused"), - base_layer_hash=b"deadbeef", + return create_project( base=base, - project_name="python-project", + parts=parts, ) - lifecycle.run("stage") - @dataclass class ExpectedValues: @@ -106,8 +100,9 @@ class ExpectedValues: @pytest.mark.parametrize("base", tuple(UBUNTU_BASES)) -def test_python_plugin_ubuntu(base, tmp_path): - run_lifecycle(base, tmp_path) +def test_python_plugin_ubuntu(base, tmp_path, run_lifecycle): + project = create_python_project(base=base) + run_lifecycle(project=project, work_dir=tmp_path) bin_dir = tmp_path / "stage/bin" @@ -133,8 +128,9 @@ def test_python_plugin_ubuntu(base, tmp_path): assert not pyvenv_cfg.is_file() -def test_python_plugin_bare(tmp_path): - run_lifecycle("bare", tmp_path) +def test_python_plugin_bare(tmp_path, run_lifecycle): + project = create_python_project(base="bare") + run_lifecycle(project=project, work_dir=tmp_path) bin_dir = tmp_path / "stage/bin" @@ -167,7 +163,7 @@ def test_python_plugin_bare(tmp_path): assert not pyvenv_cfg.is_file() -def test_python_plugin_invalid_interpreter(tmp_path): +def test_python_plugin_invalid_interpreter(tmp_path, run_lifecycle): """Check that an invalid value for PARTS_PYTHON_INTERPRETER fails the build""" log_filepath = tmp_path / "log.txt" emit.init(EmitterMode.VERBOSE, "rockcraft", "rockcraft", log_filepath=log_filepath) @@ -176,8 +172,10 @@ def test_python_plugin_invalid_interpreter(tmp_path): "build-environment": [{"PARTS_PYTHON_INTERPRETER": "/full/path/python3"}] } + project = create_python_project(base="bare", extra_part_props=extra_part) + with pytest.raises(errors.PartsLifecycleError): - run_lifecycle("bare", tmp_path, extra_part_props=extra_part) + run_lifecycle(project=project, work_dir=tmp_path) emit.ended_ok() diff --git a/tests/integration/services/__init__.py b/tests/integration/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_parts.py b/tests/integration/services/test_lifecycle.py similarity index 85% rename from tests/integration/test_parts.py rename to tests/integration/services/test_lifecycle.py index 49bc167a8..00b5df135 100644 --- a/tests/integration/test_parts.py +++ b/tests/integration/services/test_lifecycle.py @@ -19,7 +19,8 @@ import pytest from craft_parts import overlays -from rockcraft.parts import PartsLifecycle +from rockcraft.services import lifecycle +from tests.testing.project import create_project from tests.util import jammy_only pytestmark = [jammy_only, pytest.mark.usefixtures("reset_callbacks")] @@ -27,7 +28,7 @@ # pyright: reportPrivateImportUsage=false -def test_package_repositories_in_overlay(new_dir, mocker): +def test_package_repositories_in_overlay(new_dir, mocker, run_lifecycle): # Mock overlay-related calls that need root; we won't be actually installing # any packages, just checking that the repositories are correctly installed # in the overlay. @@ -57,23 +58,23 @@ def test_package_repositories_in_overlay(new_dir, mocker): {"type": "apt", "ppa": "mozillateam/ppa", "priority": "always"} ] - lifecycle = PartsLifecycle( - all_parts=parts, - work_dir=work_dir, - part_names=None, - base_layer_dir=base_layer_dir, - base_layer_hash=b"deadbeef", - base="fake-ubuntu", - package_repositories=package_repositories, - project_name="package-repos", - ) # Mock the installation of package repositories in the base system, as that # is undesired and will fail without root. mocker.patch.object(lifecycle, "_install_package_repositories") - lifecycle.run("prime") + project = create_project( + base="ubuntu:22.04", + parts=parts, + package_repositories=package_repositories, + ) + lifecycle_service = run_lifecycle( + project=project, + work_dir=work_dir, + base_layer_dir=base_layer_dir, + ) + parts_lifecycle = lifecycle_service._lcm - overlay_apt = lifecycle.project_info.overlay_dir / "packages/etc/apt" + overlay_apt = parts_lifecycle.project_info.overlay_dir / "packages/etc/apt" assert overlay_apt.is_dir() # Checking that the files are present should be enough diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_application.py similarity index 73% rename from tests/integration/test_lifecycle.py rename to tests/integration/test_application.py index 36cae400f..a2c887d72 100644 --- a/tests/integration/test_lifecycle.py +++ b/tests/integration/test_application.py @@ -13,12 +13,15 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sys from pathlib import Path import pytest import yaml -from rockcraft import oci +from rockcraft.application import APP_METADATA, Rockcraft +from rockcraft.services import RockcraftServiceFactory +from rockcraft.services.image import ImageInfo, RockcraftImageService from tests.util import jammy_only pytestmark = [jammy_only, pytest.mark.usefixtures("reset_callbacks")] @@ -51,21 +54,34 @@ """ -def test_global_environment(new_dir, monkeypatch, mocker, reset_callbacks): +def test_global_environment( + new_dir, + monkeypatch, + mocker, +): """Test our additions to the global environment that is available to the build process.""" - # Mock some OCI operations that are not relevant to this test. rootfs = Path(new_dir) / "rootfs" rootfs.mkdir() fake_digest = b"deadbeef" - mocker.patch.object(oci.Image, "extract_to", return_value=rootfs) - mocker.patch.object(oci.Image, "digest", return_value=fake_digest) + + image_info = ImageInfo( + base_image=mocker.MagicMock(), base_layer_dir=rootfs, base_digest=fake_digest + ) + mocker.patch.object(RockcraftImageService, "obtain_image", return_value=image_info) Path("rockcraft.yaml").write_text(ROCKCRAFT_YAML) - # args = argparse.Namespace(destructive_mode=True) - # lifecycle.run("stage", args) + monkeypatch.setattr(sys, "argv", ["rockcraft", "prime", "--destructive-mode"]) + + services = RockcraftServiceFactory( + # type: ignore # type: ignore[call-arg] + app=APP_METADATA, + ) + + app = Rockcraft(app=APP_METADATA, services=services) + app.run() variables_yaml = Path(new_dir) / "stage/variables.yaml" assert variables_yaml.is_file() diff --git a/tests/integration/test_oci.py b/tests/integration/test_oci.py index 4e42b8c7e..8f9a64161 100644 --- a/tests/integration/test_oci.py +++ b/tests/integration/test_oci.py @@ -20,7 +20,10 @@ from pathlib import Path from typing import Callable, List, Tuple +import pytest + from rockcraft import oci +from rockcraft.services.image import ImageInfo from tests.util import jammy_only pytestmark = jammy_only @@ -108,10 +111,6 @@ def populate_base_layer(base_layer_dir): new_layer_dir = Path("new") new_layer_dir.mkdir() - # Create the following structure to use as a new layer: - # /bin/new_bin_file - # /lib/new_lib_file - # /tmp/new_tmp_file for target in targets + ["tmp"]: new_target_dir = new_layer_dir / target new_target_dir.mkdir() @@ -129,7 +128,28 @@ def populate_base_layer(base_layer_dir): ] -def test_add_layer_with_overlay(new_dir, mocker): +@pytest.fixture +def extra_project_params(): + """Fixture used to configure the Project used by the default test services.""" + return { + "parts": { + "with-overlay": { + "plugin": "nil", + "override-build": "touch ${CRAFT_PART_INSTALL}/file_from_override_build", + "overlay-script": textwrap.dedent( + """ + cd ${CRAFT_OVERLAY} + unlink bin + mkdir bin + touch bin/file_from_overlay_script + """ + ), + } + } + } + + +def test_add_layer_with_overlay(new_dir, mocker, lifecycle_service, mock_obtain_image): """Test "overwriting" directories in the base layer via overlays.""" def populate_base_layer(base_layer_dir): @@ -138,42 +158,28 @@ def populate_base_layer(base_layer_dir): image, base_layer_dir = create_base_image(Path(new_dir), populate_base_layer) - parts = { - "with-overlay": { - "plugin": "nil", - "override-build": "touch ${CRAFT_PART_INSTALL}/file_from_override_build", - "overlay-script": textwrap.dedent( - """ - cd ${CRAFT_OVERLAY} - unlink bin - mkdir bin - touch bin/file_from_overlay_script - """ - ), - } - } - work_dir = Path() + image_info = ImageInfo( + base_image=image, + base_layer_dir=base_layer_dir, + base_digest=b"deadbeef", + ) + mock_obtain_image.return_value = image_info # Mock os.geteuid() because currently craft-parts doesn't allow overlays # without superuser privileges. mock_geteuid = mocker.patch.object(os, "geteuid", return_value=0) - lifecycle = PartsLifecycle( - all_parts=parts, - work_dir=work_dir, - part_names=None, - base_layer_dir=base_layer_dir, - base_layer_hash=b"deadbeef", - base="unused", - project_name="overlay", - ) - + # Setup the service, to create the LifecycleManager. + lifecycle_service.setup() assert mock_geteuid.called - lifecycle.run("prime") + # Run the lifecycle. + lifecycle_service.run("prime") new_image = image.add_layer( - tag="new", new_layer_dir=lifecycle.prime_dir, base_layer_dir=base_layer_dir + tag="new", + new_layer_dir=lifecycle_service.prime_dir, + base_layer_dir=base_layer_dir, ) assert get_names_in_layer(new_image) == [ diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/testing/lifecycle.py b/tests/testing/lifecycle.py new file mode 100644 index 000000000..2ae21a5db --- /dev/null +++ b/tests/testing/lifecycle.py @@ -0,0 +1,61 @@ +# This file is part of Rockcraft. +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +"""Project-related utility functions for running lifecycles.""" +from __future__ import annotations + +import pathlib +from typing import cast + +from rockcraft.application import APP_METADATA +from rockcraft.models import Project +from rockcraft.services import RockcraftLifecycleService, RockcraftServiceFactory +from rockcraft.services.image import ImageInfo + + +def run_mocked_lifecycle( + *, + project: Project, + work_dir: pathlib.Path, + mocker, + base_layer_dir: pathlib.Path | None = None, +) -> RockcraftLifecycleService: + """Run a project's lifecycle with a mocked base image.""" + + factory = RockcraftServiceFactory(APP_METADATA) + factory.set_kwargs( + "lifecycle", + work_dir=work_dir, + cache_dir=work_dir / "cache_dir", + build_for="amd64", + ) + factory.set_kwargs( + "image", + work_dir=work_dir, + build_for="amd64", + ) + factory.project = project + + image_info = ImageInfo( + base_image=mocker.MagicMock(), + base_layer_dir=base_layer_dir or pathlib.Path("unused"), + base_digest=b"deadbeef", + ) + mocker.patch.object(factory.image, "obtain_image", return_value=image_info) + + lifecycle_service = factory.lifecycle + lifecycle_service.run("stage") + + return cast(RockcraftLifecycleService, lifecycle_service) diff --git a/tests/testing/project.py b/tests/testing/project.py new file mode 100644 index 000000000..551bf2885 --- /dev/null +++ b/tests/testing/project.py @@ -0,0 +1,41 @@ +# This file is part of Rockcraft. +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +"""Project-related utility functions for testing.""" +from rockcraft.models import Project + + +def create_project(**kwargs) -> Project: + """Utility function to create test Projects with defaults.""" + base = kwargs.get("base", "ubuntu:22.04") + + build_base = kwargs.get("build_base") + if not build_base: + build_base = base if base != "bare" else "ubuntu:22.04" + + return Project.unmarshal( + { + "name": kwargs.get("name", "default"), + "version": kwargs.get("version", "1.0"), + "summary": kwargs.get("summary", "default project"), + "description": kwargs.get("description", "default project"), + "base": base, + "build-base": build_base, + "parts": kwargs.get("parts", {}), + "license": kwargs.get("license", "MIT"), + "platforms": kwargs.get("platforms", {"amd64": None}), + "package-repositories": kwargs.get("package_repositories", None), + } + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b4bc6dba1..c02d00700 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -85,115 +85,3 @@ def launched_environment( yield mock_instance return FakeProvider() - - -@pytest.fixture -def extra_project_params(): - return {} - - -@pytest.fixture -def default_project(extra_project_params): - from craft_application.models import VersionStr - - from rockcraft.models.project import NameStr, Project - - return Project( - name=NameStr("default"), - version=VersionStr("1.0"), - summary="default project", - description="default project", - base="ubuntu:22.04", - parts={}, - license="MIT", - platforms={"amd64": None}, - **extra_project_params, - ) - - -@pytest.fixture -def default_factory(default_project): - from rockcraft.application import APP_METADATA - from rockcraft.services import RockcraftServiceFactory - - factory = RockcraftServiceFactory( - app=APP_METADATA, - project=default_project, - ) - factory.set_kwargs("image", work_dir=Path("work"), build_for="amd64") - return factory - - -@pytest.fixture -def default_image_info(): - from rockcraft import oci - from rockcraft.services.image import ImageInfo - - return ImageInfo( - base_image=oci.Image(image_name="fake_image", path=Path()), - base_layer_dir=Path(), - base_digest=b"deadbeef", - ) - - -@pytest.fixture -def default_application(default_factory, default_project): - from rockcraft.application import APP_METADATA, Rockcraft - - return Rockcraft(APP_METADATA, default_factory) - - -@pytest.fixture -def image_service(default_project, default_factory, tmp_path): - from rockcraft.application import APP_METADATA - from rockcraft.services import RockcraftImageService - - return RockcraftImageService( - app=APP_METADATA, - project=default_project, - services=default_factory, - work_dir=tmp_path, - build_for="amd64", - ) - - -@pytest.fixture -def provider_service(default_project, default_factory, tmp_path): - from rockcraft.application import APP_METADATA - from rockcraft.services import RockcraftProviderService - - return RockcraftProviderService( - app=APP_METADATA, - project=default_project, - services=default_factory, - work_dir=tmp_path, - ) - - -@pytest.fixture -def package_service(default_project, default_factory): - from rockcraft.application import APP_METADATA - from rockcraft.services import RockcraftPackageService - - return RockcraftPackageService( - app=APP_METADATA, - project=default_project, - services=default_factory, - platform="amd64", - build_for="amd64", - ) - - -@pytest.fixture -def lifecycle_service(default_project, default_factory): - from rockcraft.application import APP_METADATA - from rockcraft.services import RockcraftLifecycleService - - return RockcraftLifecycleService( - app=APP_METADATA, - project=default_project, - services=default_factory, - work_dir=Path("work/"), - cache_dir=Path("cache/"), - build_for="amd64", - ) From 139eeac21ce41047384fc33b7f73e5e0ff3f3d2a Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Wed, 4 Oct 2023 10:16:54 -0300 Subject: [PATCH 8/9] refactor: update gen_cli_docs to craft-application --- rockcraft/cli.py | 18 ++++++++++++------ tools/docs/gen_cli_docs.py | 16 +++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/rockcraft/cli.py b/rockcraft/cli.py index 7cf0aac81..d8976d058 100644 --- a/rockcraft/cli.py +++ b/rockcraft/cli.py @@ -26,11 +26,6 @@ def run() -> int: """Command-line interface entrypoint.""" - # pylint: disable=import-outside-toplevel - # Import these here so that the script that generates the docs for the - # commands doesn't need to know *too much* of the application. - from .application import APP_METADATA, Rockcraft - # Register our own plugins plugins.register() @@ -39,6 +34,17 @@ def run() -> int: logger = logging.getLogger(lib_name) logger.setLevel(logging.DEBUG) + app = _create_app() + + return app.run() + + +def _create_app(): + # pylint: disable=import-outside-toplevel + # Import these here so that the script that generates the docs for the + # commands doesn't need to know *too much* of the application. + from .application import APP_METADATA, Rockcraft + services = RockcraftServiceFactory( # type: ignore # type: ignore[call-arg] app=APP_METADATA, @@ -61,4 +67,4 @@ def run() -> int: ], ) - return app.run() + return app diff --git a/tools/docs/gen_cli_docs.py b/tools/docs/gen_cli_docs.py index 2773e6560..eb106bbb4 100755 --- a/tools/docs/gen_cli_docs.py +++ b/tools/docs/gen_cli_docs.py @@ -71,12 +71,14 @@ def main(docs_dir): commands_ref_dir.mkdir() # Create a dispatcher like Rockcraft does to get access to the same options. + app = cli._create_app() + command_groups = app.command_groups + dispatcher = Dispatcher( - "rockcraft", - cli.COMMAND_GROUPS, - summary="A tool to create OCI images", - extra_global_args=cli.GLOBAL_ARGS, - default_command=cli.commands.PackCommand, + app.app.name, + command_groups, + summary=str(app.app.summary), + extra_global_args=app._global_arguments, ) help_builder = dispatcher._help_builder @@ -88,13 +90,13 @@ def main(docs_dir): toc = [] - for group in cli.COMMAND_GROUPS: + for group in command_groups: group_name = group.name.lower() + "-commands" + os.extsep + "rst" group_path = commands_ref_dir / group_name g = group_path.open("w") for cmd_class in sorted(group.commands, key=lambda c: c.name): - cmd = cmd_class({}) + cmd = cmd_class({"app": {}, "services": {}}) p = _CustomArgumentParser(help_builder) cmd.fill_parser(p) From 3d0bf47693adc9ca2107c545466fe2c8330aca31 Mon Sep 17 00:00:00 2001 From: Tiago Nobrega Date: Wed, 4 Oct 2023 10:21:33 -0300 Subject: [PATCH 9/9] chore: fix linting issues from refactorings --- Makefile | 2 +- rockcraft/services/package.py | 1 + tests/integration/services/test_lifecycle.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7892a2f33..9fd8c6c8d 100644 --- a/Makefile +++ b/Makefile @@ -117,7 +117,7 @@ test-pydocstyle: .PHONY: test-pylint test-pylint: pylint rockcraft - pylint tests --disable=invalid-name,missing-module-docstring,missing-function-docstring,redefined-outer-name,too-many-arguments,too-many-public-methods,no-member + pylint tests --disable=invalid-name,missing-module-docstring,missing-function-docstring,redefined-outer-name,too-many-arguments,too-many-public-methods,no-member,import-outside-toplevel .PHONY: test-pyright test-pyright: diff --git a/rockcraft/services/package.py b/rockcraft/services/package.py index e4edd45e0..66cda0045 100644 --- a/rockcraft/services/package.py +++ b/rockcraft/services/package.py @@ -59,6 +59,7 @@ def pack(self, prime_dir: pathlib.Path, dest: pathlib.Path) -> list[pathlib.Path :returns: A list of paths to created packages. """ # This inner import is necessary to resolve a cyclic import + # pylint: disable=import-outside-toplevel from rockcraft.services import RockcraftServiceFactory services = cast(RockcraftServiceFactory, self._services) diff --git a/tests/integration/services/test_lifecycle.py b/tests/integration/services/test_lifecycle.py index 00b5df135..eca213d1f 100644 --- a/tests/integration/services/test_lifecycle.py +++ b/tests/integration/services/test_lifecycle.py @@ -72,6 +72,7 @@ def test_package_repositories_in_overlay(new_dir, mocker, run_lifecycle): work_dir=work_dir, base_layer_dir=base_layer_dir, ) + # pylint: disable=protected-access parts_lifecycle = lifecycle_service._lcm overlay_apt = parts_lifecycle.project_info.overlay_dir / "packages/etc/apt"