diff --git a/charmcraft/commands/pack.py b/charmcraft/commands/pack.py index c329a577f..6c5971f5a 100644 --- a/charmcraft/commands/pack.py +++ b/charmcraft/commands/pack.py @@ -16,28 +16,18 @@ """Infrastructure for the 'pack' command.""" import argparse -import functools -import os import pathlib -import shutil -import subprocess -import tempfile -from typing import Any, Collection, Dict, List, Mapping +from typing import Dict, List import yaml from craft_cli import emit, CraftError, ArgumentParsingError -from charmcraft import env, parts, instrum, const, package +from charmcraft import env, instrum, package from charmcraft.cmdbase import BaseCommand -from charmcraft.errors import DuplicateCharmsError -from charmcraft.metafiles.manifest import create_manifest -from charmcraft.parts import Step from charmcraft.utils import ( load_yaml, - build_zip, find_charm_sources, get_charm_name_from_path, - humanize_list, ) # the minimum set of files in a bundle @@ -135,6 +125,16 @@ def fill_parser(self, parser): def run(self, parsed_args: argparse.Namespace) -> None: """Run the command.""" self._check_config(config_file=True) + + builder = package.Builder( + config=self.config, + force=parsed_args.force, + debug=parsed_args.debug, + shell=parsed_args.shell, + shell_after=parsed_args.shell_after, + measure=parsed_args.measure, + ) + # decide if this will work on a charm or a bundle if self.config.type == "charm": if parsed_args.include_all_charms: @@ -153,8 +153,12 @@ def run(self, parsed_args: argparse.Namespace) -> None: f"Currently trying to pack: {self.config.project.dirpath}" ) self._check_config(bases=True) - pack_method = self._pack_charm + with instrum.Timer("Whole pack run"): + self._pack_charm(parsed_args, builder) elif self.config.type == "bundle": + if parsed_args.shell: + package.launch_shell() + return bundle_filepath = self.config.project.dirpath / "bundle.yaml" bundle = load_yaml(bundle_filepath) if bundle is None: @@ -171,22 +175,14 @@ def run(self, parsed_args: argparse.Namespace) -> None: charms[name] = path else: charms = {} - if charms: - pack_method = functools.partial( - self._recursive_pack_bundle, bundle_config=bundle, charms=charms - ) - else: - pack_method = functools.partial(self._pack_bundle, bundle_config=bundle) + with instrum.Timer("Whole pack run"): + self._pack_bundle(parsed_args, charms, builder) + if parsed_args.output_bundle: + with parsed_args.output_bundle.open("wt") as file: + yaml.safe_dump(bundle, file) else: raise CraftError("Unknown type {!r} in charmcraft.yaml".format(self.config.type)) - with instrum.Timer("Whole pack run"): - pack_method(parsed_args) - - if parsed_args.output_bundle: - with parsed_args.output_bundle.open("wt") as file: - yaml.safe_dump(bundle, file) - if parsed_args.measure: instrum.dump(parsed_args.measure) @@ -203,20 +199,12 @@ def _validate_bases_indices(self, bases_indices): if bases_index >= len_configured_bases: raise CraftError(msg.format(bases_index)) - def _pack_charm(self, parsed_args) -> List[pathlib.Path]: + def _pack_charm(self, parsed_args, builder: package.Builder) -> List[pathlib.Path]: """Pack a charm.""" self._validate_bases_indices(parsed_args.bases_index) # build emit.progress("Packing the charm.") - builder = package.Builder( - config=self.config, - force=parsed_args.force, - debug=parsed_args.debug, - shell=parsed_args.shell, - shell_after=parsed_args.shell_after, - measure=parsed_args.measure, - ) charms = builder.run( parsed_args.bases_index, destructive_mode=parsed_args.destructive_mode, @@ -238,15 +226,12 @@ def _pack_charm(self, parsed_args) -> List[pathlib.Path]: def _pack_bundle( self, parsed_args: argparse.Namespace, - bundle_config: Dict[str, Any], + charms: Dict[str, pathlib.Path], + builder: package.Builder, overwrite_bundle: bool = False, ) -> List[pathlib.Path]: """Pack a bundle.""" emit.progress("Packing the bundle.") - if parsed_args.shell: - package.launch_shell() - return [] - project = self.config.project if self.config.parts: @@ -259,150 +244,41 @@ def _pack_bundle( # predefined values set automatically. bundle_part = config_parts.get("bundle") if bundle_part and bundle_part.get("plugin") == "bundle": - special_bundle_part = bundle_part - else: - special_bundle_part = None - - # get the config files - bundle_filepath = project.dirpath / "bundle.yaml" - bundle_name = bundle_config.get("name") - if not bundle_name: - raise CraftError( - "Invalid bundle config; missing a 'name' field indicating the bundle's name in " - "file {!r}.".format(str(bundle_filepath)) - ) - - if special_bundle_part: # set prime filters for fname in MANDATORY_FILES: fpath = project.dirpath / fname if not fpath.exists(): - raise CraftError("Missing mandatory file: {!r}.".format(str(fpath))) - prime = special_bundle_part.setdefault("prime", []) + raise CraftError(f"Missing mandatory file: {str(fpath)!r}.") + prime = bundle_part.setdefault("prime", []) prime.extend(MANDATORY_FILES) # set source if empty or not declared in charm part - if not special_bundle_part.get("source"): - special_bundle_part["source"] = str(project.dirpath) - - if env.is_charmcraft_running_in_managed_mode(): - work_dir = env.get_managed_environment_home_path() - else: - work_dir = project.dirpath / const.BUILD_DIRNAME + if not bundle_part.get("source"): + bundle_part["source"] = str(project.dirpath) # run the parts lifecycle emit.debug(f"Parts definition: {config_parts}") - lifecycle = parts.PartsLifecycle( - config_parts, - work_dir=work_dir, - project_dir=project.dirpath, - project_name=bundle_name, - ignore_local_sources=[bundle_name + ".zip"], - ) + try: - lifecycle.run(Step.PRIME) + output_files = builder.pack_bundle( + charms=charms, + base_indeces=parsed_args.bases_index or [], + destructive_mode=parsed_args.destructive_mode, + overwrite=overwrite_bundle, + ) except (RuntimeError, CraftError) as error: if parsed_args.debug: emit.debug(f"Error when running PRIME step: {error}") package.launch_shell() raise - # pack everything - create_manifest(lifecycle.prime_dir, project.started_at, None, []) - zipname = project.dirpath / (bundle_name + ".zip") - if overwrite_bundle: - primed_bundle_path = lifecycle.prime_dir / "bundle.yaml" - with primed_bundle_path.open("w") as bundle_file: - yaml.safe_dump(bundle_config, bundle_file) - build_zip(zipname, lifecycle.prime_dir) - if parsed_args.format: - info = {"bundles": [str(zipname)]} + info = {"bundles": [str(output_files[-1])]} emit.message(self.format_content(parsed_args.format, info)) else: - emit.message(f"Created {str(zipname)!r}.") + emit.message(f"Created {str(output_files[-1])!r}.") if parsed_args.shell_after: package.launch_shell() - return [zipname] - - def _recursive_pack_bundle( - self, - parsed_args: argparse.Namespace, - bundle_config: Dict[str, Any], - charms: Mapping[str, pathlib.Path], - ) -> List[pathlib.Path]: - bundle_charms = bundle_config.get("applications", {}) - command_args = _get_charm_pack_args(["charmcraft", "pack", "--verbose"], parsed_args) - charms = _subprocess_pack_charms(charms, command_args) - for name, value in bundle_charms.items(): - if name in charms: - value["charm"] = charms[name] - bundle_path = self._pack_bundle( - parsed_args, bundle_config=bundle_config, overwrite_bundle=True - ) - return [*bundle_charms.values(), bundle_path] - - -def _get_charm_pack_args(base_args: List[str], parsed_args: argparse.Namespace) -> List[str]: - """Get the parameters for packing a child charm from the current process parameters. - - :param base_args: The base arguments, including the executable, to use. - :param parsed_args: The parsed arguments for this process. - """ - command_args = base_args.copy() - if parsed_args.destructive_mode: - command_args.append("--destructive-mode") - if parsed_args.bases_index: - for base in parsed_args.bases_index: - command_args.append(f"--bases-index={base}") - if parsed_args.force: - command_args.append("--force") - return command_args - - -def _subprocess_pack_charms( - charms: Mapping[str, pathlib.Path], - command_args: Collection[str], -) -> Dict[str, pathlib.Path]: - """Pack the given charms for a bundle in subprocesses. - - :param command_args: The initial arguments - :param charms: A mapping of charm name to charm path - :returns: A mapping of charm names to the generated charm. - """ - if charms: - charm_str = humanize_list(charms.keys(), "and") - emit.progress(f"Packing charms: {charm_str}...") - cwd = pathlib.Path(os.getcwd()).resolve() - generated_charms = {} - with tempfile.TemporaryDirectory(prefix="charmcraft-bundle-", dir=cwd) as temp_dir: - temp_dir = pathlib.Path(temp_dir) - try: - # Put all the charms in this temporary directory. - os.chdir(temp_dir) - for charm, project_dir in charms.items(): - full_command = [*command_args, f"--project-dir={project_dir}"] - with emit.open_stream(f"Packing charm {charm}...") as stream: - subprocess.check_call(full_command, stdout=stream, stderr=stream) - duplicate_charms = {} - for charm_file in temp_dir.glob("*.charm"): - charm_name = charm_file.name.partition("_")[0] - if charm_name not in charms: - emit.debug(f"Unknown charm file generated: {charm_file.name}") - continue - if charm_name in generated_charms: - if charm_name not in duplicate_charms: - duplicate_charms[charm_name] = temp_dir.glob(f"{charm_name}_*.charm") - continue - generated_charms[charm_name] = charm_file - if duplicate_charms: - raise DuplicateCharmsError(duplicate_charms) - for charm, charm_file in generated_charms.items(): - destination = cwd / charm_file.name - destination.unlink(missing_ok=True) - generated_charms[charm] = shutil.move(charm_file, destination) - finally: - os.chdir(cwd) - return generated_charms + return output_files diff --git a/charmcraft/package.py b/charmcraft/package.py index 10fd8732e..6a79b639e 100644 --- a/charmcraft/package.py +++ b/charmcraft/package.py @@ -17,18 +17,21 @@ import os import pathlib +import shutil import subprocess +import tempfile import zipfile -from typing import List, Optional +from typing import List, Optional, Mapping, Collection, Dict +import yaml from craft_cli import emit, CraftError from craft_providers.bases import get_base_alias import charmcraft.env import charmcraft.linters -import charmcraft.parts import charmcraft.providers import charmcraft.instrum +from charmcraft import parts, env, const, errors from charmcraft.metafiles.config import create_config_yaml from charmcraft.metafiles.actions import create_actions_yaml from charmcraft.metafiles.manifest import create_manifest @@ -42,8 +45,7 @@ from charmcraft.metafiles.metadata import create_metadata_yaml from charmcraft.commands.store.charmlibs import collect_charmlib_pydeps from charmcraft.models.charmcraft import Base, BasesConfiguration -from charmcraft.parts import Step -from charmcraft.utils import get_host_architecture +from charmcraft.utils import get_host_architecture, load_yaml, build_zip, humanize_list def _format_run_on_base(base: Base) -> str: @@ -89,7 +91,10 @@ def __init__(self, *, config, force, debug, shell, shell_after, measure): self.charmdir = config.project.dirpath self.buildpath = self.charmdir / BUILD_DIRNAME self.config = config - self._parts = self.config.parts.copy() + if self.config.parts: + self._parts = self.config.parts.copy() + else: + self._parts = None # a part named "charm" using plugin "charm" is special and has # predefined values set automatically. @@ -169,7 +174,7 @@ def build_charm(self, bases_config: BasesConfiguration) -> str: ignore_local_sources=["*.charm"], ) with charmcraft.instrum.Timer("Lifecycle run"): - lifecycle.run(Step.PRIME) + lifecycle.run(parts.Step.PRIME) # skip creation yaml files if using reactive, reactive will create them # in a incompatible way @@ -418,3 +423,123 @@ def handle_package(self, prime_dir, bases_config: BasesConfiguration): zipfh.close() return zipname + + def _get_charm_pack_args(self, base_indeces: List[str], destructive_mode: bool) -> List[str]: + """Get the arguments for a charmcraft pack subprocess to run.""" + args = ["charmcraft", "pack", "--verbose"] + if destructive_mode: + args.append("--destructive-mode") + for base in base_indeces: + args.append(f"--bases-index={base}") + if self.force_packing: + args.append("--force") + return args + + def pack_bundle( + self, + *, + charms: Dict[str, pathlib.Path], + base_indeces: List[str], + destructive_mode: bool, + overwrite: bool = False, + ) -> List[pathlib.Path]: + """Pack a bundle.""" + if self._parts is None: + self._parts = {"bundle": {"plugin": "bundle"}} + + if env.is_charmcraft_running_in_managed_mode(): + work_dir = env.get_managed_environment_home_path() + else: + work_dir = self.config.project.dirpath / const.BUILD_DIRNAME + + # get the config files + bundle_filepath = self.config.project.dirpath / "bundle.yaml" + bundle = load_yaml(bundle_filepath) + bundle_name = bundle.get("name") + if not bundle_name: + raise CraftError( + "Invalid bundle config; missing a 'name' field indicating the bundle's name in " + "file {!r}.".format(str(bundle_filepath)) + ) + + if charms: + bundle_charms = bundle.get("applications", {}) + command_args = self._get_charm_pack_args(base_indeces, destructive_mode) + charms = _subprocess_pack_charms(charms, command_args) + for name, value in bundle_charms.items(): + if name in charms: + value["charm"] = charms[name] + else: + charms = {} + + lifecycle = parts.PartsLifecycle( + self._parts, + work_dir=work_dir, + project_dir=self.config.project.dirpath, + project_name=bundle_name, + ignore_local_sources=[bundle_name + ".zip"], + ) + + lifecycle.run(parts.Step.PRIME) + + # pack everything + create_manifest( + lifecycle.prime_dir, + self.config.project.started_at, + bases_config=None, + linting_results=[], + ) + zipname = self.config.project.dirpath / (bundle_name + ".zip") + if overwrite: + primed_bundle_path = lifecycle.prime_dir / "bundle.yaml" + with primed_bundle_path.open("w") as bundle_file: + yaml.safe_dump(bundle, bundle_file) + build_zip(zipname, lifecycle.prime_dir) + + return [*charms.values(), zipname] + + +def _subprocess_pack_charms( + charms: Mapping[str, pathlib.Path], + command_args: Collection[str], +) -> Dict[str, pathlib.Path]: + """Pack the given charms for a bundle in subprocesses. + + :param command_args: The initial arguments + :param charms: A mapping of charm name to charm path + :returns: A mapping of charm names to the generated charm. + """ + if charms: + charm_str = humanize_list(charms.keys(), "and") + emit.progress(f"Packing charms: {charm_str}...") + cwd = pathlib.Path(os.getcwd()).resolve() + generated_charms = {} + with tempfile.TemporaryDirectory(prefix="charmcraft-bundle-", dir=cwd) as temp_dir: + temp_dir = pathlib.Path(temp_dir) + try: + # Put all the charms in this temporary directory. + os.chdir(temp_dir) + for charm, project_dir in charms.items(): + full_command = [*command_args, f"--project-dir={project_dir}"] + with emit.open_stream(f"Packing charm {charm}...") as stream: + subprocess.check_call(full_command, stdout=stream, stderr=stream) + duplicate_charms = {} + for charm_file in temp_dir.glob("*.charm"): + charm_name = charm_file.name.partition("_")[0] + if charm_name not in charms: + emit.debug(f"Unknown charm file generated: {charm_file.name}") + continue + if charm_name in generated_charms: + if charm_name not in duplicate_charms: + duplicate_charms[charm_name] = temp_dir.glob(f"{charm_name}_*.charm") + continue + generated_charms[charm_name] = charm_file + if duplicate_charms: + raise errors.DuplicateCharmsError(duplicate_charms) + for charm, charm_file in generated_charms.items(): + destination = cwd / charm_file.name + destination.unlink(missing_ok=True) + generated_charms[charm] = shutil.move(charm_file, destination) + finally: + os.chdir(cwd) + return generated_charms diff --git a/tests/commands/test_pack.py b/tests/commands/test_pack.py index d43d0c81e..2128016c5 100644 --- a/tests/commands/test_pack.py +++ b/tests/commands/test_pack.py @@ -28,10 +28,11 @@ import yaml from craft_cli import CraftError, ArgumentParsingError +from charmcraft import parts from charmcraft.bases import get_host_as_base from charmcraft.cmdbase import JSON_FORMAT from charmcraft.commands import pack -from charmcraft.commands.pack import PackCommand, _get_charm_pack_args, _subprocess_pack_charms +from charmcraft.commands.pack import PackCommand from charmcraft.models.charmcraft import BasesConfiguration from charmcraft.config import load @@ -92,8 +93,10 @@ def func(*, name, base_content: Optional[Dict[str, Any]] = None): @pytest.fixture def mock_parts(): - with patch("charmcraft.commands.pack.parts") as mock_parts: + with patch("charmcraft.parts") as mock_parts: + pack.package.parts = mock_parts yield mock_parts + pack.package.parts = parts @pytest.fixture @@ -145,9 +148,9 @@ def test_resolve_charm_type(config): config.set(type="charm") cmd = PackCommand(config) - with patch.object(cmd, "_pack_charm") as mock: + with patch.object(cmd, "_pack_charm") as mock_obj: cmd.run(noargs) - mock.assert_called_with(noargs) + mock_obj.assert_called_with(noargs, mock.ANY) def test_resolve_bundle_type(config): @@ -157,9 +160,9 @@ def test_resolve_bundle_type(config): with patch.object(pack, "load_yaml") as mock_yaml: mock_yaml.return_value = {} - with patch.object(cmd, "_pack_bundle") as mock: + with patch.object(cmd, "_pack_bundle") as mock_pack: cmd.run(noargs) - mock.assert_called_with(noargs, bundle_config={}) + mock_pack.assert_called_with(noargs, {}, mock.ANY) def test_resolve_dump_measure_if_indicated(config, tmp_path): @@ -260,14 +263,12 @@ def test_bundle_recursive_pack_setup( bundle_yaml(name="testbundle", base_content=charms_content) (tmp_path / "README.md").touch() packer = PackCommand(bundle_config) - mock_pack = mocker.patch.object(packer, "_recursive_pack_bundle") + mock_pack = mocker.patch.object(packer, "_pack_bundle") expected_charms = build_charm_directory(tmp_path, fake_charms=charms) packer.run(parsed_args) - mock_pack.assert_called_once_with( - parsed_args, bundle_config=charms_content, charms=expected_charms - ) + mock_pack.assert_called_once_with(parsed_args, expected_charms, mock.ANY) @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") @@ -293,7 +294,7 @@ def test_bundle_non_recursive_pack_setup( packer.run(parsed_args) - mock_pack.assert_called_once_with(parsed_args, bundle_config=charms_content) + mock_pack.assert_called_once_with(parsed_args, {}, mock.ANY) @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") @@ -322,6 +323,7 @@ def test_bundle_missing_other_mandatory_file(tmp_path, bundle_config, bundle_yam def test_bundle_missing_name_in_bundle(tmp_path, bundle_yaml, bundle_config): """Can not build a bundle without name.""" bundle_config.set(type="bundle") + (tmp_path / "README.md").touch() # build! with pytest.raises(CraftError) as cm: @@ -417,6 +419,7 @@ def test_bundle_shell_after(tmp_path, bundle_yaml, bundle_config, mock_parts, mo def test_bundle_parts_not_defined( tmp_path, monkeypatch, + mock_parts, bundle_yaml, prepare_charmcraft_yaml, prepare_metadata_yaml, @@ -437,29 +440,26 @@ def test_bundle_parts_not_defined( config = load(tmp_path) monkeypatch.setenv("CHARMCRAFT_MANAGED_MODE", "1") - with patch("charmcraft.parts.PartsLifecycle", autospec=True) as mock_lifecycle: - mock_lifecycle.side_effect = SystemExit() - with pytest.raises(SystemExit): - PackCommand(config).run(get_namespace(shell_after=True)) - mock_lifecycle.assert_has_calls( - [ - call( - { - "bundle": { - "plugin": "bundle", - "source": str(tmp_path), - "prime": [ - "bundle.yaml", - "README.md", - ], - } - }, - work_dir=pathlib.Path("/root"), - project_dir=tmp_path, - project_name="testbundle", - ignore_local_sources=["testbundle.zip"], - ) - ] + + mock_parts.PartsLifecycle.side_effect = SystemExit() + with pytest.raises(SystemExit): + PackCommand(config).run(get_namespace(shell_after=True)) + + mock_parts.PartsLifecycle.assert_called_once_with( + { + "bundle": { + "plugin": "bundle", + "source": str(tmp_path), + "prime": [ + "bundle.yaml", + "README.md", + ], + } + }, + work_dir=pathlib.Path("/root"), + project_dir=tmp_path, + project_name="testbundle", + ignore_local_sources=["testbundle.zip"], ) @@ -505,6 +505,7 @@ def test_bundle_parts_not_defined( def test_bundle_parts_with_bundle_part( tmp_path, monkeypatch, + mock_parts, bundle_yaml, prepare_charmcraft_yaml, prepare_metadata_yaml, @@ -526,11 +527,11 @@ def test_bundle_parts_with_bundle_part( config = load(tmp_path) monkeypatch.setenv("CHARMCRAFT_MANAGED_MODE", "1") - with patch("charmcraft.parts.PartsLifecycle", autospec=True) as mock_lifecycle: - mock_lifecycle.side_effect = SystemExit() - with pytest.raises(SystemExit): - PackCommand(config).run(get_namespace(shell_after=True)) - mock_lifecycle.assert_has_calls( + mock_parts.PartsLifecycle.side_effect = SystemExit() + + with pytest.raises(SystemExit): + PackCommand(config).run(get_namespace(shell_after=True)) + mock_parts.PartsLifecycle.assert_has_calls( [ call( { @@ -593,6 +594,7 @@ def test_bundle_parts_with_bundle_part( def test_bundle_parts_without_bundle_part( tmp_path, monkeypatch, + mock_parts, bundle_yaml, prepare_charmcraft_yaml, prepare_metadata_yaml, @@ -613,11 +615,10 @@ def test_bundle_parts_without_bundle_part( config = load(tmp_path) monkeypatch.setenv("CHARMCRAFT_MANAGED_MODE", "1") - with patch("charmcraft.parts.PartsLifecycle", autospec=True) as mock_lifecycle: - mock_lifecycle.side_effect = SystemExit() - with pytest.raises(SystemExit): - PackCommand(config).run(get_namespace(shell_after=True)) - mock_lifecycle.assert_has_calls( + mock_parts.PartsLifecycle.side_effect = SystemExit() + with pytest.raises(SystemExit): + PackCommand(config).run(get_namespace(shell_after=True)) + mock_parts.PartsLifecycle.assert_has_calls( [ call( { @@ -674,6 +675,7 @@ def test_bundle_parts_without_bundle_part( def test_bundle_parts_with_bundle_part_with_plugin( tmp_path, monkeypatch, + mock_parts, bundle_yaml, prepare_charmcraft_yaml, prepare_metadata_yaml, @@ -695,11 +697,10 @@ def test_bundle_parts_with_bundle_part_with_plugin( config = load(tmp_path) monkeypatch.setenv("CHARMCRAFT_MANAGED_MODE", "1") - with patch("charmcraft.parts.PartsLifecycle", autospec=True) as mock_lifecycle: - mock_lifecycle.side_effect = SystemExit() - with pytest.raises(SystemExit): - PackCommand(config).run(get_namespace(shell_after=True)) - mock_lifecycle.assert_has_calls( + mock_parts.PartsLifecycle.side_effect = SystemExit() + with pytest.raises(SystemExit): + PackCommand(config).run(get_namespace(shell_after=True)) + mock_parts.PartsLifecycle.assert_has_calls( [ call( { @@ -1020,108 +1021,3 @@ def test_validator_bases_index_invalid(bases_indices, bad_index, config): f"Bases index '{bad_index}' is invalid (must be >= 0 and fit in configured bases)." ) assert str(exc_cm.value) == expected_msg - - -# region Unit tests for private functions -@pytest.mark.parametrize( - "base_args,parsed_args,expected", - [ - pytest.param([], get_namespace(), [], id="empty"), - pytest.param(["cmd", "--option"], get_namespace(), ["cmd", "--option"], id="base_only"), - pytest.param( - [], get_namespace(destructive_mode=True), ["--destructive-mode"], id="destructive_mode" - ), - pytest.param([], get_namespace(bases_index=[1]), ["--bases-index=1"], id="bases_index"), - pytest.param([], get_namespace(force=True), ["--force"], id="force"), - pytest.param( - ["charmcraft", "pack", "--verbose"], - get_namespace(destructive_mode=True, bases_index=[1, 2, 3], force=True), - [ - "charmcraft", - "pack", - "--verbose", - "--destructive-mode", - "--bases-index=1", - "--bases-index=2", - "--bases-index=3", - "--force", - ], - id="all_on", - ), - pytest.param( - [], - get_namespace( - debug=True, - shell=True, - shell_after=True, - format="json", - measure="file", - include_all_charms=True, - include_charm=[pathlib.Path("/dev/null")], - output_bundle=pathlib.Path("/dev/null"), - ), - [], - id="unforwarded_params", - ), - ], -) -def test_get_charm_pack_args(base_args, parsed_args, expected): - assert _get_charm_pack_args(base_args, parsed_args) == expected - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - "charms,command_args,charm_files,expected_calls,expected", - [ - pytest.param({}, [], [], [], {}, id="empty"), - pytest.param( - {"test": pathlib.Path("charms/test")}, - ["pack_cmd"], - [], - [ - mock.call( - ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY - ) - ], - {}, - id="no_outputs", - ), - pytest.param( - {"test": pathlib.Path("charms/test")}, - ["pack_cmd"], - ["test_amd64.charm"], - [ - mock.call( - ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY - ) - ], - {"test": pathlib.Path("test_amd64.charm").resolve()}, - id="one_correct_charm", - ), - pytest.param( - {"test": pathlib.Path("charms/test")}, - ["pack_cmd"], - ["test_amd64.charm", "where-did-this-come-from_riscv.charm"], - [ - mock.call( - ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY - ) - ], - {"test": pathlib.Path("test_amd64.charm").resolve()}, - id="one_correct_charm", - ), - ], -) -def test_subprocess_pack_charms_success( - mocker, check, charms, charm_files, command_args, expected_calls, expected -): - mock_check_call = mocker.patch("subprocess.check_call") - mock_check_call.side_effect = lambda *_, **__: [pathlib.Path(f).touch() for f in charm_files] - - actual = _subprocess_pack_charms(charms, command_args) - - check.equal(actual, expected) - check.equal(mock_check_call.mock_calls, expected_calls) - - -# endregion diff --git a/tests/test_package.py b/tests/test_package.py index 70e21ef15..b179f84c9 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -33,7 +33,12 @@ from charmcraft.charm_builder import relativise from charmcraft.bases import get_host_as_base from charmcraft.const import BUILD_DIRNAME -from charmcraft.package import Builder, format_charm_file_name, launch_shell +from charmcraft.package import ( + Builder, + format_charm_file_name, + launch_shell, + _subprocess_pack_charms, +) from charmcraft.models.charmcraft import Base, BasesConfiguration from charmcraft.config import load from charmcraft.providers import get_base_configuration @@ -1718,6 +1723,21 @@ def test_show_linters_lint_errors_forced(basic_project, emitter, config): ) +@pytest.mark.parametrize("force", [True, False]) +@pytest.mark.parametrize("destructive_mode", [True, False]) +@pytest.mark.parametrize("base_indeces", [[], [1], [1, 2, 3, 4, 5]]) +def test_get_charm_pack_args(config, force, base_indeces, destructive_mode): + builder = get_builder(config, force=force) + + actual = builder._get_charm_pack_args(base_indeces, destructive_mode) + + assert actual[:3] == ["charmcraft", "pack", "--verbose"] + assert ("--force" in actual) == force + assert ("--destructive-mode" in actual) == destructive_mode + for index in base_indeces: + assert f"--bases-index={index}" in actual + + # --- tests for relativise helper @@ -1831,3 +1851,58 @@ def fake_run(command, check, cwd): with mock.patch("subprocess.run", fake_run): launch_shell() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") +@pytest.mark.parametrize( + "charms,command_args,charm_files,expected_calls,expected", + [ + pytest.param({}, [], [], [], {}, id="empty"), + pytest.param( + {"test": pathlib.Path("charms/test")}, + ["pack_cmd"], + [], + [ + mock.call( + ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY + ) + ], + {}, + id="no_outputs", + ), + pytest.param( + {"test": pathlib.Path("charms/test")}, + ["pack_cmd"], + ["test_amd64.charm"], + [ + mock.call( + ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY + ) + ], + {"test": pathlib.Path("test_amd64.charm").resolve()}, + id="one_correct_charm", + ), + pytest.param( + {"test": pathlib.Path("charms/test")}, + ["pack_cmd"], + ["test_amd64.charm", "where-did-this-come-from_riscv.charm"], + [ + mock.call( + ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY + ) + ], + {"test": pathlib.Path("test_amd64.charm").resolve()}, + id="one_correct_charm", + ), + ], +) +def test_subprocess_pack_charms_success( + mocker, check, charms, charm_files, command_args, expected_calls, expected +): + mock_check_call = mocker.patch("subprocess.check_call") + mock_check_call.side_effect = lambda *_, **__: [pathlib.Path(f).touch() for f in charm_files] + + actual = _subprocess_pack_charms(charms, command_args) + + check.equal(actual, expected) + check.equal(mock_check_call.mock_calls, expected_calls)