def test_ended_commanderror_argparsing(capsys, create_message_handler): """Reports just the message to stdout.""" mh = create_message_handler() mh.init(mh.NORMAL) mh.ended_cmderror(CommandError("test controlled error", argsparsing=True)) captured = capsys.readouterr() assert captured.out == "test controlled error\n"
def __init__( self, all_parts: Dict[str, Any], *, work_dir: pathlib.Path, project_dir: pathlib.Path, ignore_local_sources: List[str], ): self._all_parts = all_parts.copy() self._project_dir = project_dir # set the cache dir for parts package management cache_dir = BaseDirectory.save_cache_path("charmcraft") try: self._lcm = LifecycleManager( {"parts": all_parts}, application_name="charmcraft", work_dir=work_dir, cache_dir=cache_dir, ignore_local_sources=ignore_local_sources, ) except PartsError as err: raise CommandError( f"Error bootstrapping lifecycle manager: {err}") from err
def run(self, parsed_args): """Run the command.""" if parsed_args.name: charm_name = parsed_args.name else: charm_name = get_name_from_metadata() if charm_name is None: raise CommandError( "Can't access name in 'metadata.yaml' file. The 'list-lib' command must " "either be executed from a valid project directory, or specify a charm " "name using the --charm-name option.") # get tips from the Store store = Store(self.config.charmhub) to_query = [{'charm_name': charm_name}] libs_tips = store.get_libraries_tips(to_query) if not libs_tips: logger.info("No libraries found for charm %s.", charm_name) return headers = ['Library name', 'API', 'Patch'] data = sorted((item.lib_name, item.api, item.patch) for item in libs_tips.values()) table = tabulate(data, headers=headers, tablefmt='plain', numalign='left') for line in table.splitlines(): logger.info(line)
def get_paths_to_include(dirpath): """Get all file/dir paths to include.""" allpaths = set() # all mandatory files, which must exist (currently only bundles.yaml is mandatory, and # it's verified before) for fname in MANDATORY_FILES: allpaths.add(dirpath / fname) # the extra files, which must be relative config = load_yaml(dirpath / 'charmcraft.yaml') or {} prime_specs = config.get('parts', {}).get('bundle', {}).get('prime', []) for spec in prime_specs: # check if it's an absolute path using POSIX's '/' (not os.path.sep, as the charm's # config is independent of where charmcraft is running) if spec[0] == '/': raise CommandError( "Extra files in prime config can not be absolute: {!r}".format( spec)) fpaths = sorted(fpath for fpath in dirpath.glob(spec) if fpath.is_file()) logger.debug("Including per prime config %r: %s.", spec, fpaths) allpaths.update(fpaths) return sorted(allpaths)
def validate_entrypoint(self, filepath): """Validate that the entrypoint exists and is executable.""" if filepath is None: filepath = self.basedir / 'src' / 'charm.py' else: filepath = filepath.expanduser().absolute() if not filepath.exists(): raise CommandError("Charm entry point was not found: {!r}".format(str(filepath))) if self.basedir not in filepath.parents: raise CommandError( "Charm entry point must be inside the project: {!r}".format(str(filepath))) if not os.access(str(filepath), os.X_OK): # access does not support pathlib in 3.5 raise CommandError( "Charm entry point must be executable: {!r}".format(str(filepath))) return filepath
def _pack_bundle(self): """Pack a bundle.""" project = self.config.project config_parts = self.config.parts.copy() bundle_part = config_parts.setdefault("bundle", {}) prime = bundle_part.setdefault("prime", []) # get the config files bundle_filepath = project.dirpath / "bundle.yaml" bundle_config = load_yaml(bundle_filepath) if bundle_config is None: raise CommandError( "Missing or invalid main bundle file: {!r}.".format(str(bundle_filepath)) ) bundle_name = bundle_config.get("name") if not bundle_name: raise CommandError( "Invalid bundle config; missing a 'name' field indicating the bundle's name in " "file {!r}.".format(str(bundle_filepath)) ) # set prime filters for fname in MANDATORY_FILES: fpath = project.dirpath / fname if not fpath.exists(): raise CommandError("Missing mandatory file: {!r}.".format(str(fpath))) prime.extend(MANDATORY_FILES) # set source for buiding bundle_part["source"] = str(project.dirpath) # run the parts lifecycle logger.debug("Parts definition: %s", config_parts) lifecycle = parts.PartsLifecycle( config_parts, work_dir=project.dirpath / build.BUILD_DIRNAME, ignore_local_sources=[bundle_name + ".zip"], ) lifecycle.run(Step.PRIME) # pack everything create_manifest(lifecycle.prime_dir, project.started_at, None, []) zipname = project.dirpath / (bundle_name + ".zip") build_zip(zipname, lifecycle.prime_dir) logger.info("Created %r.", str(zipname))
def request_urlpath_text(self, method: str, urlpath: str, *args, **kwargs) -> str: """Return a request.Response to a urlpath.""" try: return super().request(method, self.api_base_url + urlpath, *args, **kwargs).text except craft_store.errors.CraftStoreError as err: raise CommandError(str(err)) from err
def run(self, parsed_args): """Run the command.""" # decide if this will work on a charm or a bundle if self.config.type == "charm" or not self.config.project.config_provided: self._pack_charm(parsed_args) elif self.config.type == "bundle": if parsed_args.entrypoint is not None: raise CommandError( "The -e/--entry option is valid only when packing a charm") if parsed_args.requirement is not None: raise CommandError( "The -r/--requirement option is valid only when packing a charm" ) self._pack_bundle(parsed_args) else: raise CommandError("Unknown type {!r} in charmcraft.yaml".format( self.config.type))
def validate_from(self, dirpath): """Validate that the charm dir is there and yes, a directory.""" if dirpath is None: dirpath = pathlib.Path.cwd() else: dirpath = dirpath.expanduser().absolute() if not dirpath.exists(): raise CommandError( "the charm directory was not found: {!r}".format(str(dirpath))) if not dirpath.is_dir(): raise CommandError( "the charm directory is not really a directory: {!r}".format( str(dirpath))) self.basedir = dirpath return dirpath
def run(self, parsed_args): """Run the command.""" lib_name = parsed_args.name valid_all_chars = set(string.ascii_lowercase + string.digits + '_') valid_first_char = string.ascii_lowercase if set(lib_name) - valid_all_chars or not lib_name or lib_name[ 0] not in valid_first_char: raise CommandError( "Invalid library name (can be only lowercase alphanumeric " "characters and underscore, starting with alpha).") charm_name = get_name_from_metadata() if charm_name is None: raise CommandError( "Cannot access name in 'metadata.yaml' file. The 'create-lib' command needs to " "be executed in a valid project's directory.") # all libraries born with API version in 0 full_name = 'charms.{}.v0.{}'.format(charm_name, lib_name) lib_data = _get_lib_info(full_name=full_name) lib_path = lib_data.path if lib_path.exists(): raise CommandError( 'This library already exists: {}'.format(lib_path)) store = Store() lib_id = store.create_library_id(charm_name, lib_name) # create the new library file from the template env = get_templates_environment('charmlibs') template = env.get_template('new_library.py.j2') context = dict(lib_id=lib_id) try: lib_path.parent.mkdir(parents=True, exist_ok=True) lib_path.write_text(template.render(context)) except OSError as exc: raise CommandError( "Got an error when trying to write the library in {}: {!r}". format(lib_path, exc)) logger.info("Library %s created with id %s.", full_name, lib_id) logger.info( "Make sure to add the library file to your project; for example 'git add %s'.", lib_path)
def validate_bases_indices(self, bases_indices): """Validate that bases index is valid.""" if not bases_indices: return for bases_index in bases_indices: if bases_index < 0: raise CommandError( f"Bases index '{bases_index}' is invalid (must be >= 0).") if not self.config.bases: raise CommandError( "No bases configuration found, required when using --bases-index.", ) if bases_index >= len(self.config.bases): raise CommandError( f"No bases configuration found for specified index '{bases_index}'." )
def error_decorator(self, *args, **kwargs): """Handle craft-store error situations and login scenarios.""" try: return method(self, *args, **kwargs) except craft_store.errors.NotLoggedIn: emit.progress("Credentials not found. Trying to log in...") except craft_store.errors.StoreServerError as error: if error.response.status_code == 401: emit.progress("Existing credentials no longer valid. Trying to log in...") else: raise CommandError(str(error)) from error except craft_store.errors.CraftStoreError as error: raise CommandError( f"Server error while communicating to the Store: {error!s}" ) from error self.login() return method(self, *args, **kwargs)
def clean_project_environments( self, *, charm_name: str, project_path: pathlib.Path, ) -> List[str]: """Clean up any environments created for project. :param charm_name: Name of project. :param project_path: Directory of charm project. :returns: List of containers deleted. """ deleted: List[str] = [] # Nothing to do if provider is not installed. if not self.is_provider_available(): return deleted inode = project_path.stat().st_ino try: names = self.multipass.list() except multipass.MultipassError as error: raise CommandError(str(error)) from error for name in names: match_regex = f"^charmcraft-{charm_name}-{inode}-.+-.+-.+$" if re.match(match_regex, name): emit.trace(f"Deleting Multipass VM {name!r}.") try: self.multipass.delete( instance_name=name, purge=True, ) except multipass.MultipassError as error: raise CommandError(str(error)) from error deleted.append(name) else: emit.trace(f"Not deleting Multipass VM {name!r}.") return deleted
def validate_entrypoint(self, filepath): """Validate that the entrypoint exists and is executable.""" if filepath is None: return None filepath = filepath.expanduser().absolute() if not filepath.exists(): raise CommandError("Charm entry point was not found: {!r}".format( str(filepath))) if self.basedir not in filepath.parents: raise CommandError( "Charm entry point must be inside the project: {!r}".format( str(filepath))) if not os.access(filepath, os.X_OK): raise CommandError( "Charm entry point must be executable: {!r}".format( str(filepath))) return filepath
def test_main_controlled_error(): """Work raised CommandError: message handler notified properly, use indicated return code.""" simulated_exception = CommandError("boom", retcode=33) with patch("charmcraft.main.message_handler") as mh_mock: with patch("charmcraft.main.Dispatcher.run") as d_mock: d_mock.side_effect = simulated_exception retcode = main(["charmcraft", "version"]) assert retcode == 33 mh_mock.ended_cmderror.assert_called_once_with(simulated_exception)
def test_main_controlled_error(): """Work raised CommandError: message handler notified properly, use indicated return code.""" simulated_exception = CommandError('boom', retcode=33) with patch.object(logsetup, 'message_handler') as mh_mock: with patch('charmcraft.main.Dispatcher.run') as d_mock: d_mock.side_effect = simulated_exception retcode = main(['charmcraft', 'version']) assert retcode == 33 assert mh_mock.ended_cmderror.called_once_with(simulated_exception)
def run(self, parsed_args): """Run the command.""" lib_name = parsed_args.name valid_all_chars = set(string.ascii_lowercase + string.digits + '_') valid_first_char = string.ascii_lowercase if set(lib_name) - valid_all_chars or not lib_name or lib_name[0] not in valid_first_char: raise CommandError( "Invalid library name. Must only use lowercase alphanumeric " "characters and underscore, starting with alpha.") charm_name = get_name_from_metadata() if charm_name is None: raise CommandError( "Cannot find a valid charm name in metadata.yaml. Check you are in a charm " "directory with metadata.yaml.") # '-' is valid in charm names, but not in a python import # mutate the name so the path is a valid import importable_charm_name = create_importable_name(charm_name) # all libraries born with API version 0 full_name = 'charms.{}.v0.{}'.format(importable_charm_name, lib_name) lib_data = _get_lib_info(full_name=full_name) lib_path = lib_data.path if lib_path.exists(): raise CommandError('This library already exists: {}'.format(lib_path)) store = Store(self.config.charmhub) lib_id = store.create_library_id(charm_name, lib_name) # create the new library file from the template env = get_templates_environment('charmlibs') template = env.get_template('new_library.py.j2') context = dict(lib_id=lib_id) try: lib_path.parent.mkdir(parents=True, exist_ok=True) lib_path.write_text(template.render(context)) except OSError as exc: raise CommandError( "Error writing the library in {}: {!r}.".format(lib_path, exc)) logger.info("Library %s created with id %s.", full_name, lib_id) logger.info("Consider 'git add %s'.", lib_path)
def test_main_no_args(): """The setup.py entry_point function needs to work with no arguments.""" with patch('sys.argv', ['charmcraft']): with patch.object(logsetup, 'message_handler') as mh_mock: with patch('charmcraft.main.Dispatcher.run') as d_mock: d_mock.side_effect = CommandError('boom', retcode=42) retcode = main() assert retcode == 42 assert mh_mock.ended_ok.called_once()
def _discover_charm(self, charm_filepath): """Discover the charm name and file path. If received path is None, a metadata.yaml will be searched in the current directory. If path is given the name is taken from the filename. """ if charm_filepath is None: # discover the info using project's metadata, asume the file has the project's name # with a .charm extension charm_name = get_name_from_metadata() if charm_name is None: raise CommandError( "Can't access name in 'metadata.yaml' file. The 'upload' command needs to be " "executed in a valid project's directory, or point to a charm file with " "the --charm-file option.") charm_filepath = pathlib.Path(charm_name + '.charm').absolute() if not os.access(str(charm_filepath), os.R_OK): # access doesnt support pathlib in 3.5 raise CommandError( "Can't access charm file {!r}. You can indicate a charm file with " "the --charm-file option.".format(str(charm_filepath))) else: # the path is given, asume the charm name is part of the file name # XXX Facundo 2020-06-30: Actually, we need to open the ZIP file, extract the # included metadata.yaml file, and read the name from there. Issue: #77. charm_filepath = charm_filepath.expanduser() if not os.access(str(charm_filepath), os.R_OK): # access doesnt support pathlib in 3.5 raise CommandError( "Can't access the indicated charm file: {!r}".format( str(charm_filepath))) if not charm_filepath.is_file(): raise CommandError( "The indicated charm is not a file: {!r}".format( str(charm_filepath))) charm_name = charm_filepath.stem return charm_name, charm_filepath
def ensure_charmcraft_environment_is_supported(): """Assert that environment is supported. :raises CommandError: if unsupported environment. """ if (not is_charmcraft_running_in_supported_environment() and not is_charmcraft_running_in_developer_mode()): raise CommandError( "For a supported user experience, please use the Charmcraft snap. " "For more information, please see https://juju.is/docs/sdk/setting-up-charmcraft" )
def unmarshal(cls, obj: Dict[str, Any]): """Unmarshal object with necessary translations and error handling. :returns: valid CharmMetadata. :raises CommandError: On failure to unmarshal object. """ try: return cls.parse_obj(obj) except pydantic.error_wrappers.ValidationError as error: raise CommandError(format_pydantic_errors(error.errors(), file_name=CHARM_METADATA))
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 = CommandError( "fail") bundle_yaml(name="testbundle") bundle_config.set(type="bundle") (tmp_path / "README.md").write_text("test readme") with pytest.raises(CommandError): PackCommand(bundle_config).run(get_namespace(debug=True)) assert mock_launch_shell.mock_calls == [mock.call()]
def run(self): """Really run the command.""" if isinstance(self.command, HelpCommand): self.command.run(self.parsed_args, self.commands) else: if self.command.needs_config and not self.command.config.project.config_provided: raise CommandError( "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" ) self.command.run(self.parsed_args)
def run(self, target_step: Step) -> None: """Run the parts lifecycle. :param target_step: The final step to execute. :raises CommandError: On error during lifecycle ops. :raises RuntimeError: On unexpected error. """ previous_dir = os.getcwd() try: os.chdir(self._project_dir) # invalidate build if packing a charm and entrypoint changed if "charm" in self._all_parts: charm_part = self._all_parts["charm"] entrypoint = os.path.normpath(charm_part["charm-entrypoint"]) dis_entrypoint = os.path.normpath( _get_dispatch_entrypoint(self.prime_dir)) if entrypoint != dis_entrypoint: self._lcm.clean(Step.BUILD, part_names=["charm"]) self._lcm.reload_state() emit.trace( f"Executing parts lifecycle in {str(self._project_dir)!r}") actions = self._lcm.plan(target_step) emit.trace(f"Parts actions: {actions}") with self._lcm.action_executor() as aex: aex.execute(actions) except RuntimeError as err: 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}" raise CommandError(f"Parts processing error: {msg}") from err except Exception as err: raise CommandError(f"Parts processing error: {err}") from err finally: os.chdir(previous_dir)
def _discover_charm(self, charm_filepath): """Discover the charm name and file path. If received path is None, a metadata.yaml will be searched in the current directory. If path is given the name is taken from the filename. """ if charm_filepath is None: # discover the info using project's metadata, asume the file has the project's name # with a .charm extension charm_name = get_name_from_metadata() if charm_name is None: raise CommandError( "Cannot find a valid charm name in metadata.yaml to upload. Check you are in " "a charm directory with metadata.yaml, or use --charm-file=foo.charm." ) charm_filepath = pathlib.Path(charm_name + '.charm').absolute() if not os.access(str(charm_filepath), os.R_OK): # access doesnt support pathlib in 3.5 raise CommandError( "Cannot access charm at {!r}. Try --charm-file=foo.charm". format(str(charm_filepath))) else: # the path is given, asume the charm name is part of the file name # XXX Facundo 2020-06-30: Actually, we need to open the ZIP file, extract the # included metadata.yaml file, and read the name from there. Issue: #77. charm_filepath = charm_filepath.expanduser() if not os.access(str(charm_filepath), os.R_OK): # access doesnt support pathlib in 3.5 raise CommandError("Cannot access {!r}.".format( str(charm_filepath))) if not charm_filepath.is_file(): raise CommandError("{!r} is not a file.".format( str(charm_filepath))) charm_name = charm_filepath.stem return charm_name, charm_filepath
def get_destination_url(self, reference): """Get the fully qualified URL in the destination registry for a tag/digest reference.""" if not self.dst_registry.is_manifest_already_uploaded(reference): raise CommandError( "The {!r} image does not exist in the destination registry".format( reference ) ) # need to actually get the manifest, because this is what we'll end up getting the v2 one _, digest, _ = self.dst_registry.get_manifest(reference) final_fqu = self.dst_registry.get_fully_qualified_url(digest) return final_fqu
def _process_run(cmd: List[str]) -> None: """Run an external command logging its output. :raises CommandError: if execution crashes or ends with return code not zero. """ emit.progress(f"Running external command {cmd}") try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, ) except Exception as err: raise CommandError(f"Subprocess execution crashed for command {cmd}") from err for line in proc.stdout: emit.trace(f" :: {line.rstrip()}") retcode = proc.wait() if retcode: raise CommandError(f"Subprocess command {cmd} execution failed with retcode {retcode}")
def assert_response_ok(response, expected_status=200): """Assert the response is ok.""" if response.status_code != expected_status: if response.headers.get("Content-Type") in JSON_RELATED_MIMETYPES: errors = response.json().get("errors") else: errors = None raise CommandError( "Wrong status code from server (expected={}, got={}) errors={} headers={}".format( expected_status, response.status_code, errors, response.headers ) ) if response.headers.get("Content-Type") not in JSON_RELATED_MIMETYPES: return result = response.json() if "errors" in result: raise CommandError( "Response with errors from server: {}".format(result["errors"]) ) return result
def _hit(self, method, urlpath, body=None): """Issue a request to the Store.""" url = self.api_base_url + urlpath logger.debug("Hitting the store: %s %s %s", method, url, body) resp = self._auth_client.request(method, url, body) if not resp.ok: raise CommandError(self._parse_store_error(resp)) logger.debug("Store ok: %s", resp.status_code) # XXX Facundo 2020-06-30: we need to wrap this .json() call, and raise UnknownError (after # logging in debug the received raw response). This would catch weird "html" responses, # for example, without making charmcraft to badly crash. Related: issue #73. data = resp.json() return data
def handle_dependencies(self): """Handle from-directory and virtualenv dependencies.""" logger.debug("Installing dependencies") # virtualenv with other dependencies (if any) if self.requirement_paths: retcode = polite_exec(['pip3', 'list']) if retcode: raise CommandError("problems using pip") venvpath = self.buildpath / VENV_DIRNAME cmd = [ 'pip3', 'install', # base command '--target={}'.format(venvpath), # put all the resulting files in that specific dir ] if _pip_needs_system(): logger.debug("adding --system to work around pip3 defaulting to --user") cmd.append("--system") for reqspath in self.requirement_paths: cmd.append('--requirement={}'.format(reqspath)) # the dependencies file(s) retcode = polite_exec(cmd) if retcode: raise CommandError("problems installing dependencies")