def test_no_bases_is_ok_for_bundles(emitter, create_config, tmp_path): """Do not send a deprecation message if it is a bundle.""" create_config(""" type: bundle """) load(tmp_path) assert not emitter.interactions
def check_schema_error(expected_msg): """The real checker. Note this compares for multiple messages, as for Python 3.5 we don't have control on which of the verifications it will fail. After 3.5 is dropped this could be changed to receive only one message and compare with equality below, not inclusion. """ with pytest.raises(CommandError) as cm: load(tmp_path) assert str(cm.value) == expected_msg
def test_load_managed_mode_directory(create_config, monkeypatch, tmp_path): """Validate managed-mode default directory is /root/project.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("CHARMCRAFT_MANAGED_MODE", "1") # Patch out Config (and Project) to prevent directory validation checks. with patch("charmcraft.config.Config"): with patch("charmcraft.config.Project") as mock_project: with patch("charmcraft.config.load_yaml"): load(None) assert mock_project.call_args.kwargs["dirpath"] == pathlib.Path( "/root/project")
def test_load_optional_charmcraft_bad_directory(tmp_path): """Specify a missing directory.""" missing_directory = tmp_path / "missing" config = load(missing_directory) assert config.type is None assert config.project.dirpath == missing_directory assert not config.project.config_provided
def test_bases_minimal_short_form(create_config): tmp_path = create_config(""" type: charm bases: - name: test-name channel: test-channel """) config = load(tmp_path) assert config.bases == [ BasesConfiguration( **{ "build-on": [ Base( name="test-name", channel="test-channel", architectures=[get_host_architecture()], ), ], "run-on": [ Base( name="test-name", channel="test-channel", architectures=[get_host_architecture()], ), ], }) ]
def test_load_specific_directory_ok(create_config): """Init the config using charmcraft.yaml in a specific directory.""" tmp_path = create_config(""" type: charm """) config = load(tmp_path) assert config.type == "charm" assert config.project.dirpath == tmp_path
def test_schema_analysis_missing(create_config, tmp_path): """No analysis configuration leads to some defaults in place.""" create_config(""" type: charm # mandatory """) config = load(tmp_path) assert config.analysis.ignore.attributes == [] assert config.analysis.ignore.linters == []
def test_multiple_long_form_bases(create_config): tmp_path = create_config(""" type: charm bases: - build-on: - name: test-build-name-1 channel: test-build-channel-1 run-on: - name: test-run-name-1 channel: test-run-channel-1 architectures: [amd64, arm64] - build-on: - name: test-build-name-2 channel: test-build-channel-2 run-on: - name: test-run-name-2 channel: test-run-channel-2 architectures: [amd64, arm64] """) config = load(tmp_path) assert config.bases == [ BasesConfiguration( **{ "build-on": [ Base( name="test-build-name-1", channel="test-build-channel-1", architectures=[get_host_architecture()], ), ], "run-on": [ Base( name="test-run-name-1", channel="test-run-channel-1", architectures=["amd64", "arm64"], ), ], }), BasesConfiguration( **{ "build-on": [ Base( name="test-build-name-2", channel="test-build-channel-2", architectures=[get_host_architecture()], ), ], "run-on": [ Base( name="test-run-name-2", channel="test-run-channel-2", architectures=["amd64", "arm64"], ), ], }), ]
def test_load_current_directory(create_config, monkeypatch): """Init the config using charmcraft.yaml in current directory.""" tmp_path = create_config(""" type: charm """) monkeypatch.chdir(tmp_path) config = load(None) assert config.type == 'charm' assert config.project.dirpath == tmp_path assert config.project.config_provided
def test_load_specific_directory_expanded(create_config, monkeypatch): """Ensure that the given directory is user-expanded.""" tmp_path = create_config(""" type: charm """) # fake HOME so the '~' indication is verified to work monkeypatch.setitem(os.environ, "HOME", str(tmp_path)) config = load("~") assert config.type == "charm" assert config.project.dirpath == tmp_path
def test_schema_analysis_full_struct_just_empty(create_config, tmp_path): """Complete analysis structure, empty.""" create_config(""" type: charm # mandatory analysis: ignore: attributes: [] linters: [] """) config = load(tmp_path) assert config.analysis.ignore.attributes == [] assert config.analysis.ignore.linters == []
def test_load_current_directory(create_config, monkeypatch): """Init the config using charmcraft.yaml in current directory.""" tmp_path = create_config(""" type: charm """) monkeypatch.chdir(tmp_path) with patch("datetime.datetime") as mock: mock.utcnow.return_value = "test_timestamp" config = load(None) assert config.type == "charm" assert config.project.dirpath == tmp_path assert config.project.config_provided assert config.project.started_at == "test_timestamp"
def test_load_specific_directory_resolved(create_config, monkeypatch): """Ensure that the given directory is resolved to always show the whole path.""" tmp_path = create_config(""" type: charm """) # change to some dir, and reference the config dir relatively subdir = tmp_path / "subdir" subdir.mkdir() monkeypatch.chdir(subdir) config = load("../") assert config.type == "charm" assert config.project.dirpath == tmp_path
def test_schema_analysis_ignore_linters(create_config, tmp_path, create_checker): """Some linters are correctly ignored.""" create_checker("check_ok_1", linters.CheckType.lint) create_checker("check_ok_2", linters.CheckType.lint) create_config(""" type: charm # mandatory analysis: ignore: linters: [check_ok_1, check_ok_2] """) config = load(tmp_path) assert config.analysis.ignore.attributes == [] assert config.analysis.ignore.linters == ["check_ok_1", "check_ok_2"]
def test_charmhub_underscore_backwards_compatibility(create_config, tmp_path, emitter): """Support underscore in these attributes for a while.""" create_config(""" type: charm # mandatory charmhub: storage_url: https://server1.com api_url: https://server2.com registry_url: https://server3.com """) cfg = load(tmp_path) assert cfg.charmhub.storage_url == "https://server1.com" assert cfg.charmhub.api_url == "https://server2.com" assert cfg.charmhub.registry_url == "https://server3.com" deprecation_msg = "DEPRECATED: Configuration keywords are now separated using dashes." emitter.assert_message(deprecation_msg, intermediate=True)
def test_bundle_parts_with_bundle_part(tmp_path, monkeypatch, bundle_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") charmcraft_file = tmp_path / "charmcraft.yaml" charmcraft_file.write_text( dedent(""" type: bundle parts: bundle: prime: - my_extra_file.txt """)) 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": [ "my_extra_file.txt", "bundle.yaml", "README.md", ], } }, work_dir=pathlib.Path("/root"), project_dir=tmp_path, project_name="testbundle", ignore_local_sources=["testbundle.zip"], ) ])
def test_no_bases_defaults_to_ubuntu_20_04_with_dn03(emitter, create_config, tmp_path): create_config(""" type: charm """) config = load(tmp_path) assert config.bases == [ BasesConfiguration( **{ "build-on": [Base(name="ubuntu", channel="20.04")], "run-on": [Base(name="ubuntu", channel="20.04")], }) ] emitter.assert_message("DEPRECATED: Bases configuration is now required.", intermediate=True)
def test_charmhub_underscore_backwards_compatibility(create_config, tmp_path, caplog): """Support underscore in these attributes for a while.""" caplog.set_level(logging.WARNING, logger="charmcraft") create_config(""" type: charm # mandatory charmhub: storage_url: https://server1.com api_url: https://server2.com registry_url: https://server3.com """) cfg = load(tmp_path) assert cfg.charmhub.storage_url == "https://server1.com" assert cfg.charmhub.api_url == "https://server2.com" assert cfg.charmhub.registry_url == "https://server3.com" deprecation_msg = "DEPRECATED: Configuration keywords are now separated using dashes." assert deprecation_msg in [rec.message for rec in caplog.records]
def test_no_bases_defaults_to_ubuntu_20_04_with_dn03(caplog, create_config, tmp_path): caplog.set_level(logging.WARNING, logger="charmcraft") create_config(""" type: charm """) config = load(tmp_path) assert config.bases == [ BasesConfiguration( **{ "build-on": [Base(name="ubuntu", channel="20.04")], "run-on": [Base(name="ubuntu", channel="20.04")], }) ] assert "DEPRECATED: Bases configuration is now required." in [ rec.message for rec in caplog.records ]
def test_bundle_parts_with_bundle_part_with_plugin(tmp_path, monkeypatch, bundle_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") charmcraft_file = tmp_path / "charmcraft.yaml" charmcraft_file.write_text( dedent(""" type: bundle parts: bundle: plugin: nil """)) 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": "nil", }}, work_dir=pathlib.Path("/root"), project_dir=tmp_path, project_name="testbundle", ignore_local_sources=["testbundle.zip"], ) ])
def test_bundle_parts_not_defined(tmp_path, monkeypatch, bundle_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") charmcraft_file = tmp_path / "charmcraft.yaml" charmcraft_file.write_text("type: bundle") 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"], ) ])
def test_bundle_parts_without_bundle_part(tmp_path, monkeypatch, bundle_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") charmcraft_file = tmp_path / "charmcraft.yaml" charmcraft_file.write_text( dedent(""" type: bundle parts: foo: plugin: nil """)) 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( {"foo": { "plugin": "nil", }}, work_dir=pathlib.Path("/root"), project_dir=tmp_path, ignore_local_sources=["testbundle.zip"], ) ])
def main(argv=None): """Provide the main entry point.""" if env.is_charmcraft_running_in_managed_mode(): logpath = env.get_managed_environment_log_path() else: logpath = None emit.init( EmitterMode.NORMAL, "charmcraft", f"Starting charmcraft version {__version__}", log_filepath=logpath, ) if argv is None: argv = sys.argv extra_global_options = [ GlobalArgument( "project_dir", "option", "-p", "--project-dir", "Specify the project's directory (defaults to current)", ), ] # process try: setup_parts() # load the dispatcher and put everything in motion dispatcher = Dispatcher( "charmcraft", COMMAND_GROUPS, summary=GENERAL_SUMMARY, extra_global_args=extra_global_options, ) global_args = dispatcher.pre_parse_args(argv[1:]) loaded_config = config.load(global_args["project_dir"]) command = dispatcher.load_command(loaded_config) if command.needs_config and not loaded_config.project.config_provided: raise ArgumentParsingError( "The specified command needs a valid 'charmcraft.yaml' configuration file (in " "the current directory or where specified with --project-dir option); see " "the reference: https://discourse.charmhub.io/t/charmcraft-configuration/4138" ) emit.trace(_get_system_details()) retcode = dispatcher.run() except ArgumentParsingError as err: print(err, file=sys.stderr) # to stderr, as argparse normally does emit.ended_ok() retcode = 1 except ProvideHelpException as err: print(err, file=sys.stderr) # to stderr, as argparse normally does emit.ended_ok() retcode = 0 except CraftError as err: emit.error(err) retcode = err.retcode except errors.CraftStoreError as err: error = CraftError(f"craft-store error: {err}") emit.error(error) retcode = 1 except KeyboardInterrupt as exc: error = CraftError("Interrupted.") error.__cause__ = exc emit.error(error) retcode = 1 except Exception as err: error = CraftError(f"charmcraft internal error: {err!r}") error.__cause__ = err emit.error(error) retcode = 1 else: emit.ended_ok() if retcode is None: retcode = 0 return retcode
def test_load_optional_charmcraft_missing(tmp_path): """Specify a directory where the file is missing.""" config = load(tmp_path) assert config.project.dirpath == tmp_path assert not config.project.config_provided
def _pre_parse_args(self, sysargs): """Pre-parse sys args. Several steps: - extract the global options and detects the possible command and its args - validate global options and apply them - validate that command is correct (NOT loading and parsing its arguments) """ # get all arguments (default to what's specified) and those per options, to filter sysargs global_args = {} arg_per_option = {} options_with_equal = [] for arg in GLOBAL_ARGS: arg_per_option[arg.short_option] = arg arg_per_option[arg.long_option] = arg if arg.type == 'flag': default = False elif arg.type == 'option': default = None options_with_equal.append(arg.long_option + '=') else: raise ValueError("Bad GLOBAL_ARGS structure.") global_args[arg.name] = default filtered_sysargs = [] sysargs = iter(sysargs) options_with_equal = tuple(options_with_equal) for sysarg in sysargs: if sysarg in arg_per_option: arg = arg_per_option[sysarg] if arg.type == 'flag': value = True else: try: value = next(sysargs) except StopIteration: raise CommandError( "The 'project-dir' option expects one argument.") global_args[arg.name] = value elif sysarg.startswith(options_with_equal): option, value = sysarg.split('=', 1) if not value: raise CommandError( "The 'project-dir' option expects one argument.") arg = arg_per_option[option] global_args[arg.name] = value else: filtered_sysargs.append(sysarg) # control and use quiet/verbose options if global_args['quiet'] and global_args['verbose']: raise CommandError( "The 'verbose' and 'quiet' options are mutually exclusive.") if global_args['quiet']: message_handler.set_mode(message_handler.QUIET) elif global_args['verbose']: message_handler.set_mode(message_handler.VERBOSE) logger.debug("Raw pre-parsed sysargs: args=%s filtered=%s", global_args, filtered_sysargs) # if help requested, transform the parameters to make that explicit if global_args['help']: command = HelpCommand.name cmd_args = filtered_sysargs elif filtered_sysargs: command = filtered_sysargs[0] cmd_args = filtered_sysargs[1:] if command not in self.commands: msg = "no such command {!r}".format(command) help_text = helptexts.get_usage_message('charmcraft', msg) raise CommandError(help_text, argsparsing=True) else: # no command! help_text = get_general_help() raise CommandError(help_text, argsparsing=True) # load the system's config charmcraft_config = config.load(global_args['project_dir']) logger.debug("General parsed sysargs: command=%r args=%s", command, cmd_args) return command, cmd_args, charmcraft_config
def check_schema_error(expected_msg): """The real checker.""" with pytest.raises(CraftError) as cm: load(tmp_path) assert str(cm.value) == dedent(expected_msg)