From 430939dfea0aeca58342f6e38889c7a259a6ec43 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 11 Jun 2024 15:19:03 -0400 Subject: [PATCH] chore: remove unused pack command This pack command existed for comparison with the new one, but is no longer necessary. --- charmcraft/commands/pack.py | 285 ---------- charmcraft/main.py | 3 +- tests/commands/test_pack.py | 1021 ----------------------------------- 3 files changed, 1 insertion(+), 1308 deletions(-) delete mode 100644 charmcraft/commands/pack.py delete mode 100644 tests/commands/test_pack.py diff --git a/charmcraft/commands/pack.py b/charmcraft/commands/pack.py deleted file mode 100644 index f985856d4..000000000 --- a/charmcraft/commands/pack.py +++ /dev/null @@ -1,285 +0,0 @@ -# Copyright 2020-2023 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# For further info, check https://github.com/canonical/charmcraft - -"""Infrastructure for the 'pack' command.""" -import argparse -import pathlib - -import yaml -from craft_cli import ArgumentParsingError, CraftError, emit - -from charmcraft import const, env, instrum, package -from charmcraft.cmdbase import BaseCommand -from charmcraft.utils import find_charm_sources, get_charm_name_from_path, load_yaml - -# the minimum set of files in a bundle -MANDATORY_FILES = [const.BUNDLE_FILENAME, "README.md"] - -_overview = """ -Build and pack a charm operator package or a bundle. - -You can `juju deploy` the resulting `.charm` or bundle's `.zip` -file directly, or upload it to Charmhub with `charmcraft upload`. - -For the charm you must be inside a charm directory with a valid -`metadata.yaml`, `requirements.txt` including the `ops` package -for the Python operator framework, and an operator entrypoint, -usually `src/charm.py`. See `charmcraft init` to create a -template charm directory structure. - -For the bundle you must already have a `bundle.yaml` (can be -generated by Juju) and a README.md file. -""" - - -class PackCommand(BaseCommand): - """Build the bundle or the charm. - - It uses the 'type' key in the configuration to decide which. - """ - - name = "pack" - help_msg = "Build the charm or bundle" - overview = _overview - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument( - "--debug", - action="store_true", - help="Launch shell in build environment upon failure", - ) - parser.add_argument( - "--destructive-mode", - action="store_true", - help=( - "Pack charm using current host which may result in breaking " - "changes to system configuration" - ), - ) - parser.add_argument( - "--shell", - action="store_true", - help="Launch shell in build environment in lieu of packing", - ) - parser.add_argument( - "--shell-after", - action="store_true", - help="Launch shell in build environment after packing", - ) - parser.add_argument( - "--bases-index", - action="append", - type=int, - help="Index of 'bases' configuration to build (can be used multiple " - "times); zero-based, defaults to all", - ) - parser.add_argument( - "--force", - action="store_true", - help="Force packing even after finding lint errors", - ) - parser.add_argument( - "--measure", - type=pathlib.Path, - help="Dump measurements to the specified file", - ) - include_charm_group = parser.add_mutually_exclusive_group() - include_charm_group.add_argument( - "--include-all-charms", - action="store_true", - help="For bundles, pack all charms whose source is inside the bundle directory", - ) - include_charm_group.add_argument( - "--include-charm", - action="append", - type=pathlib.Path, - help="For bundles, pack the charm in the referenced path. Can be used multiple times", - ) - parser.add_argument( - "--output-bundle", - type=pathlib.Path, - help="Write the bundle configuration to this path", - ) - parser.add_argument( - "--project-dir", - "-p", - type=pathlib.Path, - help="Specify the project's directory (defaults to current)", - ) - - 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: - raise ArgumentParsingError( - "--include-all-charms can only be used when packing a bundle. " - f"Currently trying to pack: {self.config.project.dirpath}" - ) - if parsed_args.include_charm: - raise ArgumentParsingError( - "--include-charm can only be used when packing a bundle. " - f"Currently trying to pack: {self.config.project.dirpath}" - ) - if parsed_args.output_bundle: - raise ArgumentParsingError( - "--output-bundle can only be used when packing a bundle. " - f"Currently trying to pack: {self.config.project.dirpath}" - ) - self._check_config(bases=True) - 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 / const.BUNDLE_FILENAME - bundle = load_yaml(bundle_filepath) - if bundle is None: - raise CraftError(f"Missing or invalid main bundle file: {str(bundle_filepath)!r}.") - if parsed_args.include_all_charms: - charm_names = bundle.get("applications", {}).keys() - charms = find_charm_sources(self.config.project.dirpath, charm_names) - elif parsed_args.include_charm: - charms: dict[str, pathlib.Path] = {} - for path in parsed_args.include_charm: - if not path.is_absolute(): - path = self.config.project.dirpath / path - name = get_charm_name_from_path(path) - charms[name] = path - else: - charms = {} - 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(f"Unknown type {self.config.type!r} in charmcraft.yaml") - - if parsed_args.measure: - instrum.dump(parsed_args.measure) - - def _validate_bases_indices(self, bases_indices): - """Validate that bases index is valid.""" - if bases_indices is None: - return - - msg = "Bases index '{}' is invalid (must be >= 0 and fit in configured bases)." - len_configured_bases = len(self.config.bases) - for bases_index in bases_indices: - if bases_index < 0: - raise CraftError(msg.format(bases_index)) - if bases_index >= len_configured_bases: - raise CraftError(msg.format(bases_index)) - - def _pack_charm(self, parsed_args, builder: package.Builder) -> None: - """Pack a charm.""" - self._validate_bases_indices(parsed_args.bases_index) - - # build - emit.progress("Packing the charm.") - charms = builder.run( - parsed_args.bases_index, - destructive_mode=parsed_args.destructive_mode, - ) - - # avoid showing results when run inside a container (the outer charmcraft - # is responsible for the final message to the user) - if env.is_charmcraft_running_in_managed_mode(): - return - - if parsed_args.format: - info = {"charms": charms} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message("Charms packed:") - for charm in charms: - emit.message(f" {charm}") - - def _pack_bundle( - self, - parsed_args: argparse.Namespace, - charms: dict[str, pathlib.Path], - builder: package.Builder, - overwrite_bundle: bool = False, - ) -> None: - """Pack a bundle.""" - emit.progress("Packing the bundle.") - project = self.config.project - - if self.config.parts: - config_parts = self.config.parts.copy() - else: - # "parts" not declared, create an implicit "bundle" part - config_parts = {"bundle": {"plugin": "bundle"}} - - # a part named "bundle" using plugin "bundle" is special and has - # predefined values set automatically. - bundle_part = config_parts.get("bundle") - if bundle_part and bundle_part.get("plugin") == "bundle": - # set prime filters - for fname in MANDATORY_FILES: - fpath = project.dirpath / fname - if not fpath.exists(): - 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 bundle_part.get("source"): - bundle_part["source"] = str(project.dirpath) - - # run the parts lifecycle - emit.debug(f"Parts definition: {config_parts}") - - try: - 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 - - if parsed_args.format: - info = {"bundles": [str(b) for b in output_files.bundles]} - if output_files.charms: - info["charms"] = [str(c) for c in output_files.charms] - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message(f"Created {str(output_files.bundles[0])!r}.") - - if parsed_args.shell_after: - package.launch_shell() diff --git a/charmcraft/main.py b/charmcraft/main.py index d6871a7df..cd3f158b9 100644 --- a/charmcraft/main.py +++ b/charmcraft/main.py @@ -33,7 +33,7 @@ ) from charmcraft import config, const, env, utils -from charmcraft.commands import pack, store, version +from charmcraft.commands import store, version from charmcraft.parts import setup_parts # set up all the libs' loggers in DEBUG level so their content is grabbed by craft-cli's Emitter @@ -57,7 +57,6 @@ # central place and not distributed in several classes/files. Also note that order here is # important when listing commands and showing help. _basic_commands = [ - pack.PackCommand, version.VersionCommand, ] _charmhub_commands = [ diff --git a/tests/commands/test_pack.py b/tests/commands/test_pack.py deleted file mode 100644 index 7b86cbdc0..000000000 --- a/tests/commands/test_pack.py +++ /dev/null @@ -1,1021 +0,0 @@ -# Copyright 2020-2023 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# For further info, check https://github.com/canonical/charmcraft - -import json -import pathlib -import sys -import zipfile -from argparse import Namespace -from textwrap import dedent -from typing import Any -from unittest import mock -from unittest.mock import MagicMock, call, patch - -import pytest -import yaml -from craft_cli import ArgumentParsingError, CraftError - -from charmcraft import const, 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 -from charmcraft.config import load -from charmcraft.models.charmcraft import BasesConfiguration - - -def get_namespace( - *, - bases_index=None, - debug=False, - destructive_mode=False, - force=None, - shell=False, - shell_after=False, - format=None, - measure=None, - include_all_charms: bool = False, - include_charm: list[pathlib.Path] | None = None, - output_bundle: pathlib.Path | None = None, -): - if bases_index is None: - bases_index = [] - - return Namespace( - bases_index=bases_index, - debug=debug, - destructive_mode=destructive_mode, - force=force, - shell=shell, - shell_after=shell_after, - format=format, - measure=measure, - include_all_charms=include_all_charms, - include_charm=include_charm, - output_bundle=output_bundle, - ) - - -# default namespace -noargs = get_namespace() - - -@pytest.fixture() -def bundle_yaml(tmp_path): - """Create an empty bundle.yaml, with the option to set values to it.""" - bundle_path = tmp_path / const.BUNDLE_FILENAME - bundle_path.write_text("{}") - content = {} - - def func(*, name, base_content: dict[str, Any] | None = None): - if base_content: - content.update(base_content) - content["name"] = name - encoded = yaml.dump(content) - bundle_path.write_text(encoded) - return encoded - - return func - - -@pytest.fixture() -def mock_parts(): - with patch("charmcraft.parts") as mock_parts: - pack.package.parts = mock_parts - yield mock_parts - pack.package.parts = parts - - -@pytest.fixture() -def mock_launch_shell(): - with patch("charmcraft.package.launch_shell") as mock_shell: - yield mock_shell - - -# region Tests for bad CLI parameters -@pytest.mark.parametrize( - ("namespace", "message_start", "project_type"), - [ - pytest.param( - get_namespace(include_all_charms=True), - "--include-all-charms can only be used when packing a bundle. Currently trying ", - "charm", - id="include_all_charms_on_charm", - ), - pytest.param( - get_namespace(include_charm=[pathlib.Path("a")]), - "--include-charm can only be used when packing a bundle. Currently trying to pack: ", - "charm", - id="include_charm_on_charm", - ), - pytest.param( - get_namespace(output_bundle=pathlib.Path("output.yaml")), - "--output-bundle can only be used when packing a bundle. Currently trying to pack: ", - "charm", - id="output_bundle_on_charm", - ), - ], -) -def test_invalid_arguments(config, namespace, message_start, project_type): - config.set(type=project_type) - cmd = PackCommand(config) - - with pytest.raises(ArgumentParsingError) as exc_info: - cmd.run(namespace) - - assert exc_info.value.args[0].startswith(message_start) - - -# endregion -# -- tests for the project type decissor - - -def test_resolve_charm_type(config): - """The config indicates the project is a charm.""" - config.set(type="charm") - cmd = PackCommand(config) - - with patch.object(cmd, "_pack_charm") as mock_obj: - cmd.run(noargs) - mock_obj.assert_called_with(noargs, mock.ANY) - - -def test_resolve_bundle_type(config): - """The config indicates the project is a bundle.""" - config.set(type="bundle") - cmd = PackCommand(config) - - with patch.object(pack, "load_yaml") as mock_yaml: - mock_yaml.return_value = {} - with patch.object(cmd, "_pack_bundle") as mock_pack: - cmd.run(noargs) - mock_pack.assert_called_with(noargs, {}, mock.ANY) - - -def test_resolve_dump_measure_if_indicated(config, tmp_path): - """Dumps measurement if the user requested it.""" - config.set(type="charm") - cmd = PackCommand(config) - - measure_filepath = tmp_path / "testmeasures.json" - args = get_namespace(measure=measure_filepath) - with patch.object(cmd, "_pack_charm"): - cmd.run(args) - - # check the measurement file is created, and check that the root measurement - # is the whole pack run - assert measure_filepath.exists() - dumped = json.loads(measure_filepath.read_text()) - (root_measurement,) = ( - measurement - for measurement in dumped.values() - if "parent" in measurement and measurement["parent"] is None - ) - assert root_measurement["msg"] == "Whole pack run" - - -# -- tests for main bundle building process - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -@pytest.mark.parametrize( - "namespace_kwargs", - [ - {}, - {"include_all_charms": True}, - ], -) -def test_bundle_simple_successful_build( - tmp_path, emitter, bundle_yaml, bundle_config, formatted, namespace_kwargs -): - """A simple happy story.""" - # mandatory files (other than the automatically provided manifest) - content = bundle_yaml(name="testbundle") - bundle_config.set(type="bundle") - (tmp_path / "README.md").write_text("test readme") - - # build! - args = get_namespace(format=formatted, **namespace_kwargs) - PackCommand(bundle_config).run(args) - - # check - zipname = tmp_path / "testbundle.zip" - zf = zipfile.ZipFile(zipname) - assert const.CHARMCRAFT_FILENAME not in [x.filename for x in zf.infolist()] - assert zf.read(const.BUNDLE_FILENAME) == content.encode("ascii") - assert zf.read("README.md") == b"test readme" - - if formatted: - emitter.assert_json_output({"bundles": [str(zipname)]}) - else: - expected = f"Created '{zipname}'." - emitter.assert_message(expected) - - # check the manifest is present and with particular values that depend on given info - manifest = yaml.safe_load(zf.read(const.MANIFEST_FILENAME)) - assert manifest["charmcraft-started-at"] == bundle_config.project.started_at.isoformat() + "Z" - - # verify that the manifest was not leftover in user's project - assert not (tmp_path / const.MANIFEST_FILENAME).exists() - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("parsed_args", "charms"), - [ - pytest.param( - get_namespace(include_all_charms=True), - {"test": "charms/test"}, - id="include_all_charms", - ), - pytest.param( - get_namespace(include_charm=[pathlib.Path("charms/test")]), - {"test": "charms/test"}, - id="include_all_charms", - ), - ], -) -def test_bundle_recursive_pack_setup( - tmp_path, - mocker, - build_charm_directory, - emitter, - bundle_yaml, - bundle_config, - parsed_args, - charms, -): - charms_content = {"applications": charms, "name": "testbundle"} - bundle_yaml(name="testbundle", base_content=charms_content) - (tmp_path / "README.md").touch() - packer = PackCommand(bundle_config) - 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, expected_charms, mock.ANY) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("parsed_args", "charms"), - [ - pytest.param( - get_namespace(include_all_charms=True), - {"test": "charms/test"}, - id="include_all_charms", - ), - pytest.param(get_namespace(), {"test": "charms/test"}, id="include_all_charms"), - ], -) -def test_bundle_non_recursive_pack_setup( - tmp_path, mocker, emitter, bundle_yaml, bundle_config, parsed_args, charms -): - charms_content = {"applications": charms, "name": "testbundle"} - bundle_yaml(name="testbundle", base_content=charms_content) - (tmp_path / "README.md").touch() - packer = PackCommand(bundle_config) - mock_pack = mocker.patch.object(packer, "_pack_bundle") - - packer.run(parsed_args) - - mock_pack.assert_called_once_with(parsed_args, {}, mock.ANY) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_bundle_missing_bundle_file(tmp_path, bundle_config): - """Can not build a bundle without bundle.yaml.""" - # build without a bundle.yaml! - with pytest.raises(CraftError) as cm: - PackCommand(bundle_config).run(noargs) - assert str(cm.value) == ( - f"Missing or invalid main bundle file: '{tmp_path / const.BUNDLE_FILENAME}'." - ) - - -def test_bundle_missing_other_mandatory_file(tmp_path, bundle_config, bundle_yaml): - """Can not build a bundle without any of the mandatory files.""" - bundle_yaml(name="testbundle") - bundle_config.set(type="bundle") - - # build without a README! - with pytest.raises(CraftError) as cm: - PackCommand(bundle_config).run(noargs) - assert str(cm.value) == "Missing mandatory file: {!r}.".format(str(tmp_path / "README.md")) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -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: - PackCommand(bundle_config).run(noargs) - assert str(cm.value) == ( - "Invalid bundle config; " - f"missing a 'name' field indicating the bundle's name in file '{tmp_path / const.BUNDLE_FILENAME}'." - ) - - -def test_bundle_debug_no_error( - tmp_path, bundle_yaml, bundle_config, mock_parts, mock_launch_shell -): - bundle_yaml(name="testbundle") - bundle_config.set(type="bundle") - (tmp_path / "README.md").write_text("test readme") - - PackCommand(bundle_config).run(get_namespace(debug=True)) - - assert mock_launch_shell.mock_calls == [] - - -def test_bundle_debug_with_error( - tmp_path, bundle_yaml, bundle_config, mock_parts, mock_launch_shell -): - mock_parts.PartsLifecycle.return_value.run.side_effect = CraftError("fail") - bundle_yaml(name="testbundle") - bundle_config.set(type="bundle") - (tmp_path / "README.md").write_text("test readme") - - with pytest.raises(CraftError): - PackCommand(bundle_config).run(get_namespace(debug=True)) - - assert mock_launch_shell.mock_calls == [mock.call()] - - -def test_bundle_shell(tmp_path, bundle_yaml, bundle_config, mock_parts, mock_launch_shell): - bundle_yaml(name="testbundle") - bundle_config.set(type="bundle") - (tmp_path / "README.md").write_text("test readme") - - PackCommand(bundle_config).run(get_namespace(shell=True)) - - assert mock_launch_shell.mock_calls == [mock.call()] - - -def test_bundle_shell_after(tmp_path, bundle_yaml, bundle_config, mock_parts, mock_launch_shell): - bundle_yaml(name="testbundle") - bundle_config.set(type="bundle") - (tmp_path / "README.md").write_text("test readme") - - PackCommand(bundle_config).run(get_namespace(shell_after=True)) - - assert mock_launch_shell.mock_calls == [mock.call()] - - -# -- tests for implicit bundle part - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - name: test-charm-name-from-charmcraft-yaml - type: bundle - summary: test-summary - description: test-description - """ - ), - None, - ], - ], -) -def test_bundle_parts_not_defined( - tmp_path, - monkeypatch, - mock_parts, - bundle_yaml, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Parts are not defined. - - When the "parts" section does not exist, create an implicit "bundle" part and - populate it with the default bundle building parameters. - """ - bundle_yaml(name="testbundle") - (tmp_path / "README.md").write_text("test readme") - - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - - 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": [ - const.BUNDLE_FILENAME, - "README.md", - ], - } - }, - work_dir=pathlib.Path("/root"), - project_dir=tmp_path, - project_name="testbundle", - ignore_local_sources=["testbundle.zip"], - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - bundle: - prime: - - my_extra_file.txt - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - name: test-charm-name-from-charmcraft-yaml - type: bundle - summary: test-summary - description: test-description - parts: - bundle: - prime: - - my_extra_file.txt - """ - ), - None, - ], - ], -) -def test_bundle_parts_with_bundle_part( - tmp_path, - monkeypatch, - mock_parts, - bundle_yaml, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Parts are declared with a charm part with implicit plugin. - - When the "parts" section exists in chamcraft.yaml and a part named "bundle" - is defined with implicit plugin (or explicit "bundle" plugin), populate it - with the defaults for bundle building. - """ - bundle_yaml(name="testbundle") - (tmp_path / "README.md").write_text("test readme") - - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - 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( - { - "bundle": { - "plugin": "bundle", - "source": str(tmp_path), - "prime": [ - "my_extra_file.txt", - const.BUNDLE_FILENAME, - "README.md", - ], - } - }, - work_dir=pathlib.Path("/root"), - project_dir=tmp_path, - project_name="testbundle", - ignore_local_sources=["testbundle.zip"], - ) - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - foo: - plugin: nil - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - name: test-charm-name-from-charmcraft-yaml - type: bundle - summary: test-summary - description: test-description - parts: - foo: - plugin: nil - """ - ), - None, - ], - ], -) -def test_bundle_parts_without_bundle_part( - tmp_path, - monkeypatch, - mock_parts, - bundle_yaml, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Parts are declared without a bundle part. - - When the "parts" section exists in chamcraft.yaml and a part named "bundle" - is not defined, process parts normally and don't invoke the bundle plugin. - """ - bundle_yaml(name="testbundle") - (tmp_path / "README.md").write_text("test readme") - - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - 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( - { - "foo": { - "plugin": "nil", - } - }, - work_dir=pathlib.Path("/root"), - project_dir=tmp_path, - project_name="testbundle", - ignore_local_sources=["testbundle.zip"], - ) - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - bundle: - plugin: nil - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - name: test-charm-name-from-charmcraft-yaml - type: bundle - summary: test-summary - description: test-description - parts: - bundle: - plugin: nil - """ - ), - None, - ], - ], -) -def test_bundle_parts_with_bundle_part_with_plugin( - tmp_path, - monkeypatch, - mock_parts, - bundle_yaml, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Parts are declared with a bundle part that uses a different plugin. - - When the "parts" section exists in chamcraft.yaml and a part named "bundle" - is defined with a plugin that's not "bundle", handle it as a regular part - without populating fields for bundle building. - """ - bundle_yaml(name="testbundle") - (tmp_path / "README.md").write_text("test readme") - - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - 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( - { - "bundle": { - "plugin": "nil", - } - }, - work_dir=pathlib.Path("/root"), - project_dir=tmp_path, - project_name="testbundle", - ignore_local_sources=["testbundle.zip"], - ) - ] - ) - - -# -- tests for get paths helper - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_mandatory_ok(tmp_path, bundle_yaml, bundle_config): - """Simple successful case getting all mandatory files.""" - bundle_yaml(name="testbundle") - test_mandatory = ["foo.txt", "bar.bin"] - test_file1 = tmp_path / "foo.txt" - test_file1.touch() - test_file2 = tmp_path / "bar.bin" - test_file2.touch() - - with patch.object(pack, "MANDATORY_FILES", test_mandatory): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - assert "foo.txt" in zipped_files - assert "bar.bin" in zipped_files - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_ok(tmp_path, bundle_yaml, bundle_config): - """Extra files were indicated ok.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["f2.txt", "f1.txt"]) - testfile1 = tmp_path / "f1.txt" - testfile1.touch() - testfile2 = tmp_path / "f2.txt" - testfile2.touch() - - with patch.object(pack, "MANDATORY_FILES", []): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - assert "f1.txt" in zipped_files - assert "f2.txt" in zipped_files - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_missing(tmp_path, bundle_yaml, bundle_config): - """Extra files were indicated but not found.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["f2.txt", "f1.txt"]) - testfile1 = tmp_path / "f1.txt" - testfile1.touch() - - with patch.object(pack, "MANDATORY_FILES", []): - with pytest.raises(CraftError) as err: - PackCommand(bundle_config).run(noargs) - assert str(err.value) == ( - f"Parts processing error: Failed to copy '{tmp_path}/build/stage/f2.txt': " - "no such file or directory." - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_long_path(tmp_path, bundle_yaml, bundle_config): - """An extra file can be deep in directories.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["foo/bar/baz/extra.txt"]) - testfile = tmp_path / "foo" / "bar" / "baz" / "extra.txt" - testfile.parent.mkdir(parents=True) - testfile.touch() - - with patch.object(pack, "MANDATORY_FILES", []): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - assert "foo/bar/baz/extra.txt" in zipped_files - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_wildcards_ok(tmp_path, bundle_yaml, bundle_config): - """Use wildcards to specify several files ok.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["*.txt"]) - testfile1 = tmp_path / "f1.txt" - testfile1.touch() - testfile2 = tmp_path / "f2.bin" - testfile2.touch() - testfile3 = tmp_path / "f3.txt" - testfile3.touch() - - with patch.object(pack, "MANDATORY_FILES", []): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - assert "f1.txt" in zipped_files - assert "f2.bin" not in zipped_files - assert "f3.txt" in zipped_files - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_wildcards_not_found(tmp_path, bundle_yaml, bundle_config): - """Use wildcards to specify several files but nothing found.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["*.txt"]) - - # non-existent files are not included if using a wildcard - with patch.object(pack, "MANDATORY_FILES", []): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - assert zipped_files == [const.MANIFEST_FILENAME] - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_globstar(tmp_path, bundle_yaml, bundle_config): - """Double star means whatever directories are in the path.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["lib/**/*"]) - srcpaths = ( - ("lib/foo/f1.txt", True), - ("lib/foo/deep/fx.txt", True), - ("lib/bar/f2.txt", True), - ("lib/f3.txt", True), - ("extra/lib/f.txt", False), - ("libs/fs.txt", False), - ) - - for srcpath, _ in srcpaths: - testfile = tmp_path / pathlib.Path(srcpath) - testfile.parent.mkdir(parents=True, exist_ok=True) - testfile.touch() - - with patch.object(pack, "MANDATORY_FILES", []): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - for srcpath, expected in srcpaths: - assert (srcpath in zipped_files) == expected - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_prime_extra_globstar_specific_files(tmp_path, bundle_yaml, bundle_config): - """Combination of both mechanisms.""" - bundle_yaml(name="testbundle") - bundle_config.set(prime=["lib/**/*.txt"]) - srcpaths = ( - ("lib/foo/f1.txt", True), - ("lib/foo/f1.nop", False), - ("lib/foo/deep/fx.txt", True), - ("lib/foo/deep/fx.nop", False), - ("lib/bar/f2.txt", True), - ("lib/bar/f2.nop", False), - ("lib/f3.txt", True), - ("lib/f3.nop", False), - ("extra/lib/f.txt", False), - ("libs/fs.nop", False), - ) - - for srcpath, _ in srcpaths: - testfile = tmp_path / pathlib.Path(srcpath) - testfile.parent.mkdir(parents=True, exist_ok=True) - testfile.touch() - - with patch.object(pack, "MANDATORY_FILES", []): - PackCommand(bundle_config).run(noargs) - - zf = zipfile.ZipFile(tmp_path / "testbundle.zip") - zipped_files = [x.filename for x in zf.infolist()] - for srcpath, expected in srcpaths: - assert (srcpath in zipped_files) == expected - - -# tests for the main charm building process - - -def test_charm_builder_infrastructure_called(config, tmp_path): - """Check that build.Builder is properly called.""" - measure_filepath = tmp_path / "measurements.json" - args = get_namespace( - bases_index=[], - debug=True, - destructive_mode=True, - force=True, - shell=True, - shell_after=True, - measure=measure_filepath, - ) - config.set(type="charm") - with patch("charmcraft.package.Builder") as builder_class_mock: - builder_class_mock.return_value = builder_instance_mock = MagicMock() - PackCommand(config).run(args) - builder_class_mock.assert_called_with( - config=config, - debug=True, - force=True, - shell_after=True, - shell=True, - measure=measure_filepath, - ) - builder_instance_mock.run.assert_called_with([], destructive_mode=True) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_charm_pack_output_simple(config, emitter, formatted): - """Output when packing one charm.""" - args = get_namespace(format=formatted) - config.set(type="charm") - - builder_instance_mock = MagicMock() - builder_instance_mock.run.return_value = ["mystuff.charm"] - with patch("charmcraft.package.Builder") as builder_class_mock: - builder_class_mock.return_value = builder_instance_mock - PackCommand(config).run(args) - - if formatted: - emitter.assert_json_output({"charms": ["mystuff.charm"]}) - else: - emitter.assert_messages( - [ - "Charms packed:", - " mystuff.charm", - ] - ) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_charm_pack_output_multiple(config, emitter, formatted): - """Output when packing multiple charm.""" - args = get_namespace(format=formatted) - config.set(type="charm") - - builder_instance_mock = MagicMock() - builder_instance_mock.run.return_value = ["mystuff1.charm", "mystuff2.charm"] - with patch("charmcraft.package.Builder") as builder_class_mock: - builder_class_mock.return_value = builder_instance_mock - PackCommand(config).run(args) - - if formatted: - emitter.assert_json_output({"charms": ["mystuff1.charm", "mystuff2.charm"]}) - else: - emitter.assert_messages( - [ - "Charms packed:", - " mystuff1.charm", - " mystuff2.charm", - ] - ) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_charm_pack_output_managed_mode(config, emitter, formatted, monkeypatch): - """Output when packing charms under managed mode.""" - args = get_namespace(format=formatted) - config.set(type="charm") - - builder_instance_mock = MagicMock() - builder_instance_mock.run.return_value = ["mystuff.charm"] - with patch("charmcraft.env.is_charmcraft_running_in_managed_mode", return_value=True): - with patch("charmcraft.package.Builder") as builder_class_mock: - builder_class_mock.return_value = builder_instance_mock - PackCommand(config).run(args) - - for emitter_call in emitter.interactions: - assert emitter_call.args[0] != "message" - - -@pytest.mark.parametrize( - ("bases_indices", "bad_index"), - [ - (None, None), # not used, it's fine - ([], None), # empty, it's fine - ([0], None), # first one - ([1], None), # second one - ([1, 0], None), # a sequence - ([-1], -1), # not negative! - ([0, -1], -1), # also negative, after a valid one - ([1, 0, -1], -1), # other sequence - ([3, 1], 3), # too big - ], -) -def test_validator_bases_index_invalid(bases_indices, bad_index, config): - """Validate the bases indices given in the command line.""" - config.set( - bases=[ - BasesConfiguration( - **{"build-on": [get_host_as_base()], "run-on": [get_host_as_base()]} - ), - BasesConfiguration( - **{"build-on": [get_host_as_base()], "run-on": [get_host_as_base()]} - ), - ] - ) - cmd = PackCommand(config) - - if bad_index is None: - # success case - cmd._validate_bases_indices(bases_indices) - else: - with pytest.raises(CraftError) as exc_cm: - cmd._validate_bases_indices(bases_indices) - expected_msg = ( - f"Bases index '{bad_index}' is invalid (must be >= 0 and fit in configured bases)." - ) - assert str(exc_cm.value) == expected_msg