def load(filestream: TextIO) -> Dict[str, Any]: """Load and parse a YAML-formatted file. :param filename: The YAML file to load. :raises SnapcraftError: if loading didn't succeed. :raises LegacyFallback: if the project's base is not core22. """ try: data = yaml.safe_load(filestream) build_base = utils.get_effective_base( base=data.get("base"), build_base=data.get("build-base"), project_type=data.get("type"), name=data.get("name"), ) if build_base is None: raise errors.LegacyFallback("no base defined") if build_base != "core22": raise errors.LegacyFallback("base is not core22") except yaml.error.YAMLError as err: raise errors.SnapcraftError( f"snapcraft.yaml parsing error: {err!s}") from err filestream.seek(0) try: return yaml.load(filestream, Loader=_SafeLoader) except yaml.error.YAMLError as err: raise errors.SnapcraftError( f"snapcraft.yaml parsing error: {err!s}") from err
def request(self, *args, **kwargs) -> requests.Response: """Request using the BaseClient and wrap responses that require action. Actionable items are those that could prompt a login or registration. """ try: return self.store_client.request(*args, **kwargs) except craft_store.errors.StoreServerError as store_error: if (store_error.response.status_code == requests.codes.unauthorized # pylint: disable=no-member ): if os.getenv(constants.ENVIRONMENT_STORE_CREDENTIALS): raise errors.SnapcraftError( "Provided credentials are no longer valid for the Snap Store.", resolution="Regenerate them and try again.", ) from store_error emit.message("You are required to re-login before continuing") self.store_client.logout() else: raise except craft_store.errors.CredentialsUnavailable: emit.message("You are required to login before continuing") self.login() return self.store_client.request(*args, **kwargs)
def get_snap_tool(command_name: str) -> str: """Return the path of a command found in the snap. If snapcraft is not running as a snap, shutil.which() is used to resolve the command using PATH. :param command_name: the name of the command to resolve a path for. :return: Path to command :raises SnapcraftError: if command_name was not found. """ if os.environ.get("SNAP_NAME") != "snapcraft": return get_host_tool(command_name) snap_path = os.getenv("SNAP") if snap_path is None: raise RuntimeError( "The SNAP environment variable is not defined, but SNAP_NAME is?") command_path = _find_command_path_in_root(snap_path, command_name) if command_path is None: raise errors.SnapcraftError( f"Cannot find snap tool {command_name!r}", resolution="Please report this error to the Snapcraft maintainers.", ) return command_path
def _get_dom(path: str) -> lxml.etree.ElementTree: try: return lxml.etree.parse(path) except OSError as err: raise errors.SnapcraftError(str(err)) from err except lxml.etree.ParseError as err: raise errors.MetadataExtractionError(path, str(err)) from err
def version_id(self) -> str: """Return the OS version ID. :raises SnapcraftError: If no version ID can be determined. """ with contextlib.suppress(KeyError): return self._os_release["VERSION_ID"] raise errors.SnapcraftError("Unable to determine host OS version ID")
def name(self) -> str: """Return the OS name. :raises SnapcraftError: If no name can be determined. """ with contextlib.suppress(KeyError): return self._os_release["NAME"] raise errors.SnapcraftError("Unable to determine host OS name")
def pack_snap( directory: Path, *, output: Optional[str], compression: Optional[str] = None, name: Optional[str] = None, version: Optional[str] = None, target_arch: Optional[str] = None, ) -> str: """Pack snap contents with `snap pack`. `output` may either be a directory, a file path, or just a file name. - directory: write snap to directory with default snap name - file path: write snap to specified directory with specified snap name - file name: write snap to cwd with specified snap name If name, version, and target architecture are not specified, then snap will use its default naming convention. :param directory: Directory to pack. :param output: Snap file name or directory. :param compression: Compression type to use, None for defaults. :param name: Name of snap project. :param version: Version of snap project. :param target_arch: Target architecture the snap project is built to. """ emit.debug(f"pack_snap: output={output!r}, compression={compression!r}") # TODO remove workaround once LP: #1950465 is fixed _verify_snap(directory) # create command formatted as `snap pack <options> <snap-dir> <output-dir>` command: List[Union[str, Path]] = ["snap", "pack"] output_file = _get_filename(output, name, version, target_arch) if output_file is not None: command.extend(["--filename", output_file]) if compression is not None: command.extend(["--compression", compression]) command.append(directory) command.append(_get_directory(output)) emit.progress("Creating snap package...") emit.debug(f"Pack command: {command}") try: proc = subprocess.run(command, capture_output=True, check=True, universal_newlines=True) except subprocess.CalledProcessError as err: msg = f"Cannot pack snap file: {err!s}" if err.stderr: msg += f" ({err.stderr.strip()!s})" raise errors.SnapcraftError(msg) snap_filename = Path(str(proc.stdout).partition(":")[2].strip()).name return snap_filename
def _update_project_variables(project: Project, project_vars: Dict[str, str]): """Update project fields with values set during lifecycle processing.""" try: if project_vars["version"]: project.version = project_vars["version"] if project_vars["grade"]: project.grade = project_vars["grade"] # type: ignore except pydantic.ValidationError as err: _raise_formatted_validation_error(err) raise errors.SnapcraftError(f"error setting variable: {err}")
def update_project_metadata( project: Project, *, project_vars: Dict[str, str], metadata_list: List[ExtractedMetadata], assets_dir: Path, prime_dir: Path, ) -> None: """Set project fields using corresponding adopted entries. Fields are validated on assignment by pydantic. :param project: The project to update. :param project_vars: The variables updated during lifecycle execution. :param metadata_list: List containing parsed information from metadata files. :raises SnapcraftError: If project update failed. """ _update_project_variables(project, project_vars) for metadata in metadata_list: # Data specified in the project yaml has precedence over extracted data if metadata.title and not project.title: project.title = metadata.title if metadata.summary and not project.summary: project.summary = metadata.summary if metadata.description and not project.description: project.description = metadata.description if metadata.version and not project.version: project.version = metadata.version if metadata.grade and not project.grade: project.grade = metadata.grade # type: ignore emit.debug(f"project icon: {project.icon!r}") emit.debug(f"metadata icon: {metadata.icon!r}") if not project.icon: _update_project_icon(project, metadata=metadata, assets_dir=assets_dir) _update_project_app_desktop_file(project, metadata=metadata, assets_dir=assets_dir, prime_dir=prime_dir) # Fields that must not end empty for field in MANDATORY_ADOPTABLE_FIELDS: if not getattr(project, field): raise errors.SnapcraftError( f"Field {field!r} was not adopted from metadata")
def run_project_checks(project: Project, *, assets_dir: Path) -> None: """Execute consistency checks for project and project files. The checks done here are meant to be light, and not rely on the build environment. """ # Assets dir shouldn't contain unexpected files. if assets_dir.is_dir(): _check_snap_dir(assets_dir) # Icon should refer to project file, verify it exists. if project.icon and not Path(project.icon).exists(): raise errors.SnapcraftError( f"Specified icon {project.icon!r} does not exist.")
def login( self, *, permissions: Sequence[str], description: str, ttl: int, packages: Optional[Sequence[craft_store.endpoints.Package]] = None, channels: Optional[Sequence[str]] = None, **kwargs, ) -> str: raise errors.SnapcraftError( "Cannot login with existing legacy credentials in use", resolution="Run 'snapcraft logout' first to clear them", )
def get_snap_project() -> _SnapProject: """Find the snapcraft.yaml to load. :raises SnapcraftError: if the project yaml file cannot be found. """ for snap_project in _SNAP_PROJECT_FILES: if snap_project.project_file.exists(): return snap_project raise errors.SnapcraftError( "Could not find snap/snapcraft.yaml. Are you sure you are in the " "right directory?", resolution="To start a new project, use `snapcraft init`", )
def _verify_snap(directory: Path) -> None: emit.debug("pack_snap: check skeleton") try: subprocess.run( ["snap", "pack", "--check-skeleton", directory], capture_output=True, check=True, universal_newlines=True, ) except subprocess.CalledProcessError as err: msg = f"Cannot pack snap file: {err!s}" if err.stderr: msg += f" ({err.stderr.strip()!s})" raise errors.SnapcraftError(msg)
def _raise_formatted_validation_error(err: pydantic.ValidationError): error_list = err.errors() if len(error_list) != 1: return error = error_list[0] loc = error.get("loc") msg = error.get("msg") if not (loc and msg) or not isinstance(loc, tuple): return varname = ".".join((x for x in loc if isinstance(x, str))) raise errors.SnapcraftError(f"error setting {varname}: {msg}")
def process_yaml(project_file: Path) -> Dict[str, Any]: """Process the yaml from project file. :raises SnapcraftError: if the project yaml file cannot be loaded. """ try: with open(project_file, encoding="utf-8") as yaml_file: yaml_data = yaml_utils.load(yaml_file) except OSError as err: msg = err.strerror if err.filename: msg = f"{msg}: {err.filename!r}." raise errors.SnapcraftError(msg) from err return apply_yaml(yaml_data)
def read(prime_dir: Path) -> SnapMetadata: """Read snap metadata file. :param prime_dir: The directory containing the snap payload. :return: The populated snap metadata. """ snap_yaml = prime_dir / "meta" / "snap.yaml" try: with snap_yaml.open(encoding="utf-8") as file: data = yaml.safe_load(file) except OSError as error: raise errors.SnapcraftError( f"Cannot read snap metadata: {error}") from error return SnapMetadata.unmarshal(data)
def _validate_command_chain( command_chain: List[str], *, name: str, prime_dir: Path ) -> None: """Verify if each item in the command chain is executble.""" for item in command_chain: executable_path = prime_dir / item # command-chain entries must always be relative to the root of # the snap, i.e. PATH is not used. if not _is_executable(executable_path): raise errors.SnapcraftError( f"Failed to generate snap metadata: The command-chain item {item!r} " f"defined in {name} does not exist or is not executable.", resolution=f"Ensure that {item!r} is relative to the prime directory.", )
def version_codename(self) -> str: """Return the OS version codename. This first tries to use the VERSION_CODENAME. If that's missing, it tries to use the VERSION_ID to figure out the codename on its own. :raises SnapcraftError: If no version codename can be determined. """ with contextlib.suppress(KeyError): return self._os_release["VERSION_CODENAME"] with contextlib.suppress(KeyError): return _ID_TO_UBUNTU_CODENAME[self._os_release["VERSION_ID"]] raise errors.SnapcraftError( "Unable to determine host OS version codename")
def get_host_tool(command_name: str) -> str: """Return the full path of the given host tool. :param command_name: the name of the command to resolve a path for. :return: Path to command :raises SnapcraftError: if command_name was not found. """ tool = shutil.which(command_name) if not tool: raise errors.SnapcraftError( f"A tool snapcraft depends on could not be found: {command_name!r}", resolution= "Ensure the tool is installed and available, and try again.", ) return tool
def run(command_name: str, parsed_args: "argparse.Namespace") -> None: """Run the parts lifecycle. :raises SnapcraftError: if the step name is invalid, or the project yaml file cannot be loaded. :raises LegacyFallback: if the project's base is not core22. """ emit.debug(f"command: {command_name}, arguments: {parsed_args}") snap_project = get_snap_project() yaml_data = process_yaml(snap_project.project_file) start_time = datetime.now() build_plan = get_build_plan(yaml_data, parsed_args) if parsed_args.provider: raise errors.SnapcraftError("Option --provider is not supported.") # Register our own plugins and callbacks plugins.register() callbacks.register_prologue(_set_global_environment) callbacks.register_pre_step(_set_step_environment) build_count = utils.get_parallel_build_count() for build_on, build_for in build_plan: emit.verbose(f"Running on {build_on} for {build_for}") yaml_data_for_arch = apply_yaml(yaml_data, build_on, build_for) parse_info = _extract_parse_info(yaml_data_for_arch) _expand_environment( yaml_data_for_arch, parallel_build_count=build_count, target_arch=build_for, ) project = Project.unmarshal(yaml_data_for_arch) try: _run_command( command_name, project=project, parse_info=parse_info, parallel_build_count=build_count, assets_dir=snap_project.assets_dir, start_time=start_time, parsed_args=parsed_args, ) except PermissionError as err: raise errors.FilePermissionError(err.filename, reason=err.strerror)
def _get_architecture() -> str: snap_arch = os.getenv("SNAP_ARCH") # The first scenario is the general case as snapcraft will be running from the snap. if snap_arch is not None: try: miniconda_arch = _MINICONDA_ARCH_FROM_SNAP_ARCH[snap_arch] except KeyError as key_error: raise errors.SnapcraftError( f"Architecture not supported for conda plugin: {snap_arch!r}" ) from key_error # But there may be times when running from a virtualenv while doing development. else: machine = platform.machine() architecture = platform.architecture()[0] miniconda_arch = _MINICONDA_ARCH_FROM_PLATFORM[machine][architecture] return miniconda_arch
def prompt(prompt_text: str, *, hide: bool = False) -> str: """Prompt and return the entered string. :param prompt_text: string used for the prompt. :param hide: hide user input if True. """ if is_managed_mode(): raise RuntimeError("prompting not yet supported in managed-mode") if not sys.stdin.isatty(): raise errors.SnapcraftError("prompting not possible with no tty") if hide: method = getpass else: method = input # type: ignore with emit.pause(): return str(method(prompt_text))
def process_yaml(project_file: Path) -> Dict[str, Any]: """Process yaml data from file into a dictionary. :param project_file: Path to project. :raises SnapcraftError: if the project yaml file cannot be loaded. :return: The processed YAML data. """ try: with open(project_file, encoding="utf-8") as yaml_file: yaml_data = yaml_utils.load(yaml_file) except OSError as err: msg = err.strerror if err.filename: msg = f"{msg}: {err.filename!r}." raise errors.SnapcraftError(msg) from err return yaml_data
def run(self, parsed_args): client = store.StoreClientCLI() # Account info request to retrieve the snap-id account_info = client.get_account_info() try: snap_id = account_info["snaps"][store.constants.DEFAULT_SERIES][ parsed_args.name]["snap-id"] except KeyError as key_error: emit.debug(f"{key_error!r} no found in {account_info!r}") raise errors.SnapcraftError( f"{parsed_args.name!r} not found or not owned by this account" ) from key_error client.close( snap_id=snap_id, channel=parsed_args.channel, ) emit.message( f"Channel {parsed_args.channel!r} for {parsed_args.name!r} is now closed" )
def write( project: Project, prime_dir: Path, *, arch: str, parts: Dict[str, Any], image_information: str, start_time: datetime, primed_stage_packages: List[str], ): """Create a manifest.yaml file.""" snap_dir = prime_dir / "snap" snap_dir.mkdir(parents=True, exist_ok=True) osrel = os_release.OsRelease() version = utils.process_version(project.version) try: image_info = json.loads(image_information) except json.decoder.JSONDecodeError as err: raise errors.SnapcraftError( f"Image information decode error at {err.lineno}:{err.colno}: " f"{err.doc!r}: {err.msg}") from err manifest = Manifest( # Snapcraft annotations snapcraft_version=__version__, snapcraft_started_at=start_time.isoformat("T") + "Z", snapcraft_os_release_id=osrel.name().lower(), snapcraft_os_release_version_id=osrel.version_id().lower(), # Project fields name=project.name, version=version, summary=project.summary, # type: ignore description=project.description, # type: ignore base=project.base, grade=project.grade or "stable", confinement=project.confinement, apps=project.apps, parts=parts, # Architecture architectures=[arch], # Image info image_info=image_info, # Build environment build_packages=[], build_snaps=[], primed_stage_packages=primed_stage_packages, ) yaml_data = manifest.yaml( by_alias=True, exclude_none=True, exclude_unset=True, allow_unicode=True, sort_keys=False, width=1000, ) manifest_yaml = snap_dir / "manifest.yaml" manifest_yaml.write_text(yaml_data)
def login( self, *, ttl: int = int(timedelta(days=365).total_seconds()), acls: Optional[Sequence[str]] = None, packages: Optional[Sequence[str]] = None, channels: Optional[Sequence[str]] = None, ) -> str: """Login to the Snap Store and prompt if required.""" if os.getenv(constants.ENVIRONMENT_STORE_CREDENTIALS): raise errors.SnapcraftError( f"Cannot login with {constants.ENVIRONMENT_STORE_CREDENTIALS!r} set.", resolution= f"Unset {constants.ENVIRONMENT_STORE_CREDENTIALS!r} and try again.", ) kwargs: Dict[str, Any] = {} if use_candid() is False: kwargs["email"], kwargs["password"] = _prompt_login() if packages is None: packages = [] _packages = [ craft_store.endpoints.Package(package_name=p, package_type="snap") for p in packages ] if acls is None: acls = [ "package_access", "package_manage", "package_metrics", "package_push", "package_register", "package_release", "package_update", ] description = f"snapcraft@{_get_hostname()}" try: credentials = self.store_client.login( ttl=ttl, permissions=acls, channels=channels, packages=_packages, description=description, **kwargs, ) except craft_store.errors.StoreServerError as store_error: if "twofactor-required" not in store_error.error_list: raise kwargs["otp"] = utils.prompt("Second-factor auth: ") credentials = self.store_client.login( ttl=ttl, permissions=acls, channels=channels, packages=_packages, description=description, **kwargs, ) return credentials
def notify_upload( self, *, snap_name: str, upload_id: str, snap_file_size: int, built_at: Optional[str], channels: Optional[Sequence[str]], ) -> int: """Notify an upload to the Snap Store. :param snap_name: name of the snap :param upload_id: the upload_id to register with the Snap Store :param snap_file_size: the file size of the uploaded snap :param built_at: the build timestamp for this build :param channels: the channels to release to after being accepted into the Snap Store :returns: the snap's processed revision """ data = { "name": snap_name, "series": constants.DEFAULT_SERIES, "updown_id": upload_id, "binary_filesize": snap_file_size, "source_uploaded": False, } if built_at is not None: data["built_at"] = built_at if channels is not None: data["channels"] = channels response = self.request( "POST", self._base_url + "/dev/api/snap-push/", json=data, headers={ "Accept": "application/json", }, ) status_url = response.json()["status_details_url"] while True: response = self.request("GET", status_url) status = response.json() human_status = _HUMAN_STATUS.get(status["code"], status["code"]) emit.progress(f"Status: {human_status}") if status.get("processed", False): if status.get("errors"): error_messages = [ e["message"] for e in status["errors"] if "message" in e ] error_string = "\n".join( [f"- {e}" for e in error_messages]) raise errors.SnapcraftError( f"Issues while processing snap:\n{error_string}") break time.sleep(_POLL_DELAY) return status["revision"]
def setup_assets( project: Project, *, assets_dir: Path, project_dir: Path, prime_dir: Path ) -> None: """Copy assets to the appropriate locations in the snap filesystem. :param project: The snap project file. :param assets_dir: The directory containing snap project assets. :param project_dir: The project root directory. :param prime_dir: The directory containing the content to be snapped. """ meta_dir = prime_dir / "meta" gui_dir = meta_dir / "gui" gui_dir.mkdir(parents=True, exist_ok=True) _write_snap_directory(assets_dir=assets_dir, prime_dir=prime_dir, meta_dir=meta_dir) if project.hooks: for hook_name, hook in project.hooks.items(): if hook.command_chain: _validate_command_chain( hook.command_chain, name=f"hook {hook_name!r}", prime_dir=prime_dir ) ensure_hook(meta_dir / "hooks" / hook_name) if project.type == "gadget": gadget_yaml = project_dir / "gadget.yaml" if not gadget_yaml.exists(): raise errors.SnapcraftError("gadget.yaml is required for gadget snaps") _copy_file(gadget_yaml, meta_dir / "gadget.yaml") if project.type == "kernel": kernel_yaml = project_dir / "kernel.yaml" if kernel_yaml.exists(): _copy_file(kernel_yaml, meta_dir / "kernel.yaml") if not project.apps: return icon_path = _finalize_icon( project.icon, assets_dir=assets_dir, gui_dir=gui_dir, prime_dir=prime_dir ) relative_icon_path: Optional[str] = None if icon_path is not None: if prime_dir in icon_path.parents: icon_path = icon_path.relative_to(prime_dir) relative_icon_path = str(icon_path) emit.debug(f"relative icon path: {relative_icon_path!r}") for app_name, app in project.apps.items(): _validate_command_chain( app.command_chain, name=f"app {app_name!r}", prime_dir=prime_dir ) if app.desktop: desktop_file = DesktopFile( snap_name=project.name, app_name=app_name, filename=app.desktop, prime_dir=prime_dir, ) desktop_file.write(gui_dir=gui_dir, icon_path=relative_icon_path)