# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2021-2023 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Craft parts errors."""
import contextlib
import pathlib
from collections.abc import Iterable
from io import StringIO
from typing import TYPE_CHECKING
from overrides import override
if TYPE_CHECKING:
from pydantic.error_wrappers import ErrorDict, Loc
[docs]
class PartsError(Exception):
"""Unexpected error.
:param brief: Brief description of error.
:param details: Detailed information.
:param resolution: Recommendation, if any.
:param doc_slug:
Reusable documentation slug for consumers adopting
the Craft Parts documentation.
"""
def __init__(
self,
brief: str,
details: str | None = None,
resolution: str | None = None,
doc_slug: str | None = None,
) -> None:
self.brief = brief
self._details = details
self.resolution = resolution
self.doc_slug = doc_slug
def __str__(self) -> str:
components = [self.brief]
if self.details:
components.append(self.details)
if self.resolution:
components.append(self.resolution)
return "\n".join(components)
def __repr__(self) -> str:
return f"{self.__class__.__name__}(brief={self.brief!r}, details={self.details!r}, resolution={self.resolution!r}, doc_slug={self.doc_slug!r})"
@property
def details(self) -> str | None:
"""Further details on the error."""
return self._details
[docs]
class FeatureError(PartsError):
"""A feature is not configured as expected."""
def __init__(self, message: str, details: str | None = None) -> None:
self.message = message
brief = message
resolution = "This operation cannot be executed."
super().__init__(brief=brief, details=details, resolution=resolution)
[docs]
class PartDependencyCycle(PartsError):
"""A dependency cycle has been detected in the parts definition."""
def __init__(self) -> None:
brief = "A circular dependency chain was detected."
resolution = "Review the parts definition to remove dependency cycles."
super().__init__(brief=brief, resolution=resolution)
[docs]
class InvalidApplicationName(PartsError):
"""The application name contains invalid characters.
:param name: The invalid application name.
"""
def __init__(self, name: str) -> None:
self.name = name
brief = f"Application name {name!r} is invalid."
resolution = (
"Valid application names contain letters, underscores or numbers, "
"and must start with a letter."
)
super().__init__(brief=brief, resolution=resolution)
[docs]
class InvalidPartName(PartsError):
"""An operation was requested on a part that's not in the parts specification.
:param part_name: The invalid part name.
"""
def __init__(self, part_name: str) -> None:
self.part_name = part_name
brief = f"A part named {part_name!r} is not defined in the parts list."
resolution = "Review the parts definition and make sure it's correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class InvalidArchitecture(PartsError):
"""The machine architecture is not supported.
:param arch_name: The unsupported architecture name.
"""
def __init__(self, arch_name: str) -> None:
self.arch_name = arch_name
brief = f"Architecture {arch_name!r} is not supported."
resolution = "Make sure the architecture name is correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class PartSpecificationError(PartsError):
"""A part was not correctly specified.
:param part_name: The name of the part being processed.
:param message: The error message.
"""
def __init__(self, *, part_name: str, message: str) -> None:
self.part_name = part_name
self.message = message
brief = f"Part {part_name!r} validation failed."
details = message
resolution = f"Review part {part_name!r} and make sure it's correct."
super().__init__(brief=brief, details=details, resolution=resolution)
[docs]
@classmethod
def from_validation_error(
cls, *, part_name: str, error_list: list["ErrorDict"]
) -> "PartSpecificationError":
"""Create a PartSpecificationError from a pydantic error list.
:param part_name: The name of the part being processed.
:param error_list: A list of dictionaries containing pydantic error definitions.
"""
formatted_errors: list[str] = []
for error in error_list:
loc = error.get("loc")
msg = error.get("msg")
if not (loc and msg) or not isinstance(loc, tuple):
continue
field = cls._format_loc(loc)
if msg == "field required":
formatted_errors.append(f"- field {field!r} is required")
elif msg == "extra fields not permitted":
formatted_errors.append(f"- extra field {field!r} not permitted")
else:
formatted_errors.append(f"- {msg} in field {field!r}")
return cls(part_name=part_name, message="\n".join(formatted_errors))
@classmethod
def _format_loc(cls, loc: "Loc") -> str:
"""Format location."""
loc_parts = []
for loc_part in loc:
if isinstance(loc_part, str):
loc_parts.append(loc_part)
elif isinstance(loc_part, int):
# Integer indicates an index. Go back and fix up previous part.
previous_part = loc_parts.pop()
previous_part += f"[{loc_part}]"
loc_parts.append(previous_part)
else:
raise TypeError(f"unhandled loc: {loc_part}")
loc_str = ".".join(loc_parts)
# Filter out internal __root__ detail.
return loc_str.replace(".__root__", "")
[docs]
class CopyTreeError(PartsError):
"""Failed to copy or link a file tree.
:param message: The error message.
"""
def __init__(self, message: str) -> None:
self.message = message
brief = f"Failed to copy or link file tree: {message}."
resolution = "Make sure paths and permissions are correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class CopyFileNotFound(PartsError):
"""An attempt was made to copy a file that doesn't exist.
:param name: The file name.
"""
def __init__(self, name: str) -> None:
self.name = name
brief = f"Failed to copy {name!r}: no such file or directory."
super().__init__(brief=brief)
[docs]
class XAttributeError(PartsError):
"""Failed to read or write an extended attribute.
:param action: The action being performed.
:param key: The extended attribute key.
:param path: The file path.
:param is_write: Whether this is an attribute write operation.
"""
def __init__(
self, key: str, path: str, is_write: bool = False # noqa: FBT001, FBT002
) -> None:
self.key = key
self.path = path
self.is_write = is_write
action = "write" if is_write else "read"
brief = f"Unable to {action} extended attribute."
details = f"Failed to {action} attribute {key!r} on {path!r}."
resolution = "Make sure your filesystem supports extended attributes."
super().__init__(brief=brief, details=details, resolution=resolution)
[docs]
class UndefinedPlugin(PartsError):
"""The part didn't define a plugin and the part name is not a valid plugin name.
:param part_name: The name of the part with no plugin definition.
"""
def __init__(self, *, part_name: str) -> None:
self.part_name = part_name
brief = f"Plugin not defined for part {part_name!r}."
resolution = f"Review part {part_name!r} and make sure it's correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class InvalidPlugin(PartsError):
"""A request was made to use a plugin that's not registered.
:param plugin_name: The invalid plugin name."
:param part_name: The name of the part defining the invalid plugin.
"""
def __init__(self, plugin_name: str, *, part_name: str) -> None:
self.plugin_name = plugin_name
self.part_name = part_name
brief = f"Plugin {plugin_name!r} in part {part_name!r} is not registered."
resolution = f"Review part {part_name!r} and make sure it's correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class PluginNotStrict(PartsError):
"""A request was made to use a plugin that's not strict.
:param plugin_name: The plugin name.
:param part_name: The name of the part defining the plugin.
"""
def __init__(self, plugin_name: str, *, part_name: str) -> None:
self.plugin_name = plugin_name
self.part_name = part_name
brief = f"Plugin {plugin_name!r} in part {part_name!r} cannot be used."
details = (
"Only plugins that are capable of building in strict mode are allowed."
)
super().__init__(brief=brief, details=details)
[docs]
class OsReleaseIdError(PartsError):
"""Failed to determine the host operating system identification string."""
def __init__(self) -> None:
brief = "Unable to determine the host operating system ID."
super().__init__(brief=brief)
[docs]
class OsReleaseNameError(PartsError):
"""Failed to determine the host operating system name."""
def __init__(self) -> None:
brief = "Unable to determine the host operating system name."
super().__init__(brief=brief)
[docs]
class OsReleaseVersionIdError(PartsError):
"""Failed to determine the host operating system version."""
def __init__(self) -> None:
brief = "Unable to determine the host operating system version ID."
super().__init__(brief=brief)
[docs]
class OsReleaseCodenameError(PartsError):
"""Failed to determine the host operating system version codename."""
def __init__(self) -> None:
brief = "Unable to determine the host operating system codename."
super().__init__(brief=brief)
[docs]
class FilesetError(PartsError):
"""An invalid fileset operation was performed.
:param name: The name of the fileset.
:param message: The error message.
"""
def __init__(self, *, name: str, message: str) -> None:
self.name = name
self.message = message
brief = f"{name!r} fileset error: {message}."
resolution = "Review the parts definition and make sure it's correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class FilesetConflict(PartsError):
"""Inconsistent stage to prime filtering.
:param conflicting_files: A set containing the conflicting file names.
"""
def __init__(self, conflicting_files: set[str]) -> None:
self.conflicting_files = conflicting_files
brief = "Failed to filter files: inconsistent 'stage' and 'prime' filesets."
details = (
f"The following files have been excluded in the 'stage' fileset, "
f"but included by the 'prime' fileset: {conflicting_files!r}."
)
resolution = (
"Make sure that the files included in 'prime' are also included in 'stage'."
)
super().__init__(brief=brief, details=details, resolution=resolution)
[docs]
class FileOrganizeError(PartsError):
"""Failed to organize a file layout.
:param part_name: The name of the part being processed.
:param message: The error message.
"""
def __init__(self, *, part_name: str, message: str) -> None:
self.part_name = part_name
self.message = message
brief = f"Failed to organize part {part_name!r}: {message}."
super().__init__(brief=brief)
[docs]
class PartFilesConflict(PartsError):
"""Different parts list the same files with different contents.
:param part_name: The name of the part being processed.
:param other_part_name: The name of the conflicting part.
:param conflicting_files: The list of conflicting files.
:param partition: Optional name of the partition where the conflict occurred.
"""
def __init__(
self,
*,
part_name: str,
other_part_name: str,
conflicting_files: list[str],
partition: str | None = None,
) -> None:
self.part_name = part_name
self.other_part_name = other_part_name
self.conflicting_files = conflicting_files
self.partition = partition
indented_conflicting_files = (f" {i}" for i in conflicting_files)
file_paths = "\n".join(sorted(indented_conflicting_files))
partition_info = f" for the {partition!r} partition" if partition else ""
brief = (
"Failed to stage: parts list the same file "
"with different contents or permissions."
)
details = (
f"Parts {part_name!r} and {other_part_name!r} list the following "
f"files{partition_info}, but with different contents or permissions:\n"
f"{file_paths}"
)
super().__init__(brief=brief, details=details)
[docs]
class StageFilesConflict(PartsError):
"""Files from a part conflict with files already being staged.
:param part_name: The name of the part being processed.
:param conflicting_files: The list of confictling files.
"""
def __init__(self, *, part_name: str, conflicting_files: list[str]) -> None:
self.part_name = part_name
self.conflicting_files = conflicting_files
indented_conflicting_files = (f" {i}" for i in conflicting_files)
file_paths = "\n".join(sorted(indented_conflicting_files))
brief = "Failed to stage: part files conflict with files already being staged."
details = (
f"The following files in part {part_name!r} are already being staged "
f"with different content:\n"
f"{file_paths}"
)
super().__init__(brief=brief, details=details)
[docs]
class PluginEnvironmentValidationError(PartsError):
"""Plugin environment validation failed at runtime.
:param part_name: The name of the part being processed.
"""
def __init__(self, *, part_name: str, reason: str) -> None:
self.part_name = part_name
self.reason = reason
brief = f"Environment validation failed for part {part_name!r}: {reason}."
super().__init__(brief=brief)
[docs]
class PluginPullError(PartsError):
"""Plugin pull script failed at runtime.
:param part_name: The name of the part being processed.
"""
def __init__(self, *, part_name: str) -> None:
self.part_name = part_name
brief = f"Failed to run the pull script for part {part_name!r}."
super().__init__(brief=brief)
[docs]
class PluginBuildError(PartsError):
"""Plugin build script failed at runtime.
:param part_name: The name of the part being processed.
:param plugin_name: The name of the plugin being processed.
"""
def __init__(
self, *, part_name: str, plugin_name: str, stderr: bytes | None = None
) -> None:
self.part_name = part_name
self.plugin_name = plugin_name
self.stderr = stderr
brief = f"Failed to run the build script for part {part_name!r}."
resolution = f"Check the build output and verify the project can work with the {plugin_name!r} plugin."
super().__init__(
brief=brief, resolution=resolution, doc_slug="/reference/plugins.html"
)
@property
@override
def details(self) -> str | None:
"""Further details on the error.
Discards all trace lines that come before the last-executed script line
"""
with contextlib.closing(StringIO()) as details_io:
if self.stderr is None:
return None
stderr = self.stderr.decode("utf-8", errors="replace")
stderr_lines = stderr.split("\n")
# Find the final command captured in the logs
last_command = None
for idx, line in enumerate(reversed(stderr_lines)):
if line.startswith("+"):
last_command = len(stderr_lines) - idx - 1
break
else:
# Fallback to printing the whole log
last_command = 0
for line in stderr_lines[last_command:]:
if line:
details_io.write(f"\n:: {line}")
return details_io.getvalue()
[docs]
class PluginCleanError(PartsError):
"""Script to clean strict build preparation failed at runtime.
:param part_name: The name of the part being processed.
"""
def __init__(self, *, part_name: str) -> None:
self.part_name = part_name
brief = f"Failed to run the clean script for part {part_name!r}."
super().__init__(brief=brief)
[docs]
class InvalidControlAPICall(PartsError):
"""A control API call was made with invalid parameters.
:param part_name: The name of the part being processed.
:param scriptlet_name: The name of the scriptlet that originated the call.
:param message: The error message.
"""
def __init__(self, *, part_name: str, scriptlet_name: str, message: str) -> None:
self.part_name = part_name
self.scriptlet_name = scriptlet_name
self.message = message
brief = (
f"{scriptlet_name!r} in part {part_name!r} executed an invalid control "
f"API call: {message}."
)
resolution = "Review the scriptlet and make sure it's correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class ScriptletRunError(PartsError):
"""A scriptlet execution failed.
:param part_name: The name of the part being processed.
:param scriptlet_name: The name of the scriptlet that failed to execute.
:param exit_code: The execution error code.
"""
def __init__(self, *, part_name: str, scriptlet_name: str, exit_code: int) -> None:
self.part_name = part_name
self.scriptlet_name = scriptlet_name
self.exit_code = exit_code
brief = (
f"{scriptlet_name!r} in part {part_name!r} failed with code {exit_code}."
)
resolution = "Review the scriptlet and make sure it's correct."
super().__init__(brief=brief, resolution=resolution)
[docs]
class CallbackRegistrationError(PartsError):
"""Error in callback function registration.
:param message: the error message.
"""
def __init__(self, message: str) -> None:
self.message = message
brief = f"Callback registration error: {message}."
super().__init__(brief=brief)
[docs]
class StagePackageNotFound(PartsError):
"""Failed to install a stage package.
:param part_name: The name of the part being processed.
:param package_name: The name of the package.
"""
def __init__(self, *, part_name: str, package_name: str) -> None:
self.part_name = part_name
self.package_name = package_name
brief = f"Stage package not found in part {part_name!r}: {package_name}."
super().__init__(brief=brief)
[docs]
class OverlayPackageNotFound(PartsError):
"""Failed to install an overlay package.
:param part_name: The name of the part being processed.
:param message: the error message.
"""
def __init__(self, *, part_name: str, package_name: str) -> None:
self.part_name = part_name
self.package_name = package_name
brief = f"Overlay package not found in part {part_name!r}: {package_name}."
super().__init__(brief=brief)
[docs]
class InvalidAction(PartsError):
"""An attempt was made to execute an action with invalid parameters.
:param message: The error message.
"""
def __init__(self, message: str) -> None:
self.message = message
brief = f"Action is invalid: {message}."
super().__init__(brief=brief)
[docs]
class OverlayPermissionError(PartsError):
"""A project using overlays was processed by a non-privileged user."""
def __init__(self) -> None:
brief = "Using the overlay step requires superuser privileges."
super().__init__(brief=brief)
[docs]
class DebError(PartsError):
"""A "deb"-related command failed."""
def __init__(
self, deb_path: pathlib.Path, command: list[str], exit_code: int
) -> None:
brief = (
f"Failed when handling {deb_path}: "
f"command {command!r} exited with code {exit_code}."
)
resolution = "Make sure the deb file is correctly specified."
super().__init__(brief=brief, resolution=resolution)
[docs]
class PartitionError(PartsError):
"""Errors related to partitions."""
def __init__(
self,
brief: str,
*,
details: str | None = None,
resolution: str | None = None,
) -> None:
super().__init__(brief=brief, details=details, resolution=resolution)
[docs]
class PartitionUsageError(PartitionError):
"""Error for a list of invalid partition usages.
:param error_list: Iterable of strings describing the invalid usages.
:param partitions: Iterable of the names of valid partitions.
:param brief: Override brief message.
"""
def __init__(
self,
error_list: Iterable[str],
partitions: Iterable[str] | None,
brief: str | None = None,
) -> None:
valid_partitions = (
f"\nValid partitions: {', '.join(partitions)}" if partitions else ""
)
super().__init__(
brief=brief or "Invalid usage of partitions",
details="\n".join(error_list) + valid_partitions,
resolution="Correct the invalid partition name(s) and try again.",
)
[docs]
class PartitionUsageWarning(PartitionError, Warning):
"""Warnings for possibly invalid usages of partitions.
:param warning_list: Iterable of strings describing the misuses.
"""
def __init__(self, warning_list: Iterable[str]) -> None:
super().__init__(
brief="Possible misuse of partitions",
details=(
"The following entries begin with a valid partition name but are "
"not wrapped in parentheses. These entries will go into the "
"default partition.\n" + "\n".join(warning_list)
),
resolution=(
"Wrap the partition name in parentheses, for example "
"'default/file' should be written as '(default)/file'"
),
)
Warning.__init__(self)
[docs]
class PartitionNotFound(PartitionUsageError):
"""A partition has been specified that does not exist.
:param partition_name: The name of the partition that does not exist.
:param partitions: Iterable of the names of valid partitions.
"""
def __init__(self, partition_name: str, partitions: Iterable[str]) -> None:
# Allow callers catching this exception easy access to the partition name
self.partition_name = partition_name
super().__init__(
brief=f"Requested partition does not exist: {partition_name!r}",
partitions=partitions,
error_list=[],
)