Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions source/fab/artefacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ def replace(self, artefact: Union[str, ArtefactSet],

art_set = self[artefact]
if not isinstance(art_set, set):
raise RuntimeError(f"Replacing artefacts in dictionary "
f"'{artefact}' is not supported.")
name = artefact if isinstance(artefact, str) else artefact.name
raise ValueError(f"{name} is not mutable")
art_set.difference_update(set(remove_files))
art_set.update(add_files)

Expand Down
344 changes: 344 additions & 0 deletions source/fab/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
##############################################################################
# (c) Crown copyright Met Office. All rights reserved.
# For further details please refer to the file COPYRIGHT
# which you should have received as part of this distribution
##############################################################################
"""
Custom exception classes designed to replace generic RuntimeError
exceptions originally used in fab.
"""

from pathlib import Path
from typing import List, Optional, TYPE_CHECKING, Union

if TYPE_CHECKING:
from fab.tools.category import Category
from fab.tools.tool import Tool
Comment on lines +14 to +16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't like this. Is it due to the circular dependency you mention later?



class FabError(RuntimeError):
"""Base class for all fab specific exceptions.

:param message: reason for the exception
"""

def __init__(self, message: str) -> None:
self.message = message

def __str__(self) -> str:
return self.message


class FabToolError(FabError):
"""Base class for fab tool and toolbox exceptions.

:param tool: name of the current tool or category
:param message: reason for the exception
"""

def __init__(self, tool: Union["Category", "Tool", str], message: str) -> None:
self.tool = tool
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm - a coding style question: I prefer all attributes of a class to be 'private' (i.e. starting with _, and then add getter and/or setter as required). The big advantage (imho) of this is that the setter/getter will be documented, so a user can check out the supported methods in the documentation.
We recently investigated the performance impact of a getter (see stfc/PSyclone#3082), and found a 3 to 4% improvement when avoiding the getter. That's for getters that are being called 100.000s of time, I don't think we will notice a performance disadvantage of this in Fab.

I am ok leaving it as is (esp. since afaik it's not a pep8 requirement), but would prefer a clarification in the coding style :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I too prefer private variables and getters/setters. It allows for validation on change of implementation without altering the API.

The @property modifier is available if the () of a function offends.


# Check for name attributes rather than using isintance
# because Category and Tool ahve issues with circular
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think Category has a problem by itself, so I think that can be moved out of the TYPE_CHECKING test above (and fix the type here from string to be Category) - at least it worked for me).

Then I find the name tool confusing, since it can also be a category? Would it be worth storing both (tool and category, and leaving one of them None?? That might be useful for messages that a tool has the wrong category somewhere??). Or just call it tool_or_category?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have thoughts on replacing the concept of categories with a more flexible system of capability tags. How does that interact with this issue?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# because Category and Tool ahve issues with circular
# because Category and Tool have issues with circular

# dependencies
if hasattr(tool, "name"):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With Category imported directly, you can use some isinstance tests here. Otherwise I'd like a comment explaining what class you are accessing.

self.name = tool.name
elif hasattr(tool, "_name"):
self.name = tool._name
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what class this is, but please add a setter, don't access what is intended to be a private attribute.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A getter in this case? Agree that we shouldn't be accessing nominally private state.

else:
self.name = tool
super().__init__(f"[{self.name}] {message}")


class FabToolMismatch(FabToolError):
"""Tool and category mismatch.

Error when a tool category does not match the expected setting.

:param tool: name of the current tool
:param category: name of the current category
:param expectedd: name of the correct category
"""

def __init__(
self,
tool: Union["Category", "Tool", str],
category: Union["Category", "Tool", type, str],
Comment on lines +66 to +67
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can argument Tool have a value of type Category and argument category have value of type Tool? Also, why allow strings?

expected: str,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this an expected Category?

) -> None:
self.category = category
self.expected = expected

super().__init__(tool, f"got type {category} instead of {expected}")


class FabToolInvalidVersion(FabToolError):
"""Version format problem.

Error when version information cannot be extracted from a specific
tool. Where a version pattern is available, report this as part
of the error.

:param tool: name of the current tool
:param value: output from the query command
:param expected: optional format of version string
"""

def __init__(
self,
tool: Union["Category", "Tool", str],
value: str,
expected: Optional[str] = None,
Comment on lines +90 to +92
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should tool have type Tool? is expected a regular expression?

) -> None:
self.value = value
self.expected = expected

message = f"invalid version {repr(self.value)}"
if expected is not None:
message += f" should be {repr(expected)}"

super().__init__(tool, message)


class FabToolPsycloneAPI(FabToolError):
"""PSyclone API and target problem.

Error when the specified PSyclone API, which can be empty to
indicate DSL mode, and the associated target do not match.
Comment on lines +107 to +108
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is how PSyclone works but we might prefer to be explicit about the API.


:param api: the current API or empty for DSL mode
:param target: the name of the target
:param present: optionally whether the target is present or
absent. Used to format the error message. Defaults to False.
"""

def __init__(
self, api: Union[str, None], target: str, present: Optional[bool] = False
) -> None:
self.target = target
self.present = present
self.api = api

message = "called "
if api:
message += f"with {api} API "
else:
message += "without API "
if present:
message += "and with "
else:
message += "but not with "
message += f"{target}"

super().__init__("psyclone", message)


class FabToolNotAvailable(FabToolError):
"""An unavailable tool has been requested.

Error where a tool which is not available in a particular suite of
tools has been requested.

:param tool: name of the current tool
:param suite: optional name of the current tool suite
"""

def __init__(
self, tool: Union["Category", "Tool", str], suite: Optional[str] = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool of type Tool?

) -> None:
message = "not available"
if suite:
message += f" in suite {suite}"
super().__init__(tool, message)


class FabToolInvalidSetting(FabToolError):
"""An invalid tool setting has been requested.

Error where an invalid setting, e.g. MPI, has been requested for a
particular tool.

:param setting_type: name of the invalid setting
:param tool: the tool to which setting applies
:param additional: optional additional information
"""

# FIXME: improve these here and in tool_repository
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we will eventually remove the need for this failure mode in that the API will not allow impossible requests to be made. Clearly not an issue for this change.


def __init__(
self,
setting_type: str,
tool: Union["Category", "Tool", str],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool has type Tool?

additional: Optional[str] = None,
) -> None:
self.setting_type = setting_type

message = f"invalid {setting_type}"
if additional:
message += f" {additional}"

super().__init__(tool, message)


class FabUnknownLibraryError(FabError):
"""An unknown library has been requested.

Error where an library which is not known to the current Linker
instance is requested.

:param library: the name of the unknown library
"""

def __init__(self, library: str) -> None:
self.library = library
super().__init__(f"unknown library {library}")


class FabCommandError(FabError):
"""An error was encountered running a subcommand.

Error where a subcommand run by the fab framework returned with a
non-zero exit code. The exit code plus any data from the stdout
and stderr streams are retained to allow them to be passed back up
the calling stack.

:param command: the command being run
:param code: the command return code from the OS
:param output: output stream from the command
:param error: error stream from the command
"""

def __init__(
self,
command: str,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subprocess module also allows commands to be specified as a list of strings. Check how it is done when this exception is raised - we might prefer to accept it straight rather than having to convert it.

In fact, looking below, I see you cover this eventuality so reflect it in the type hinting.

code: int,
output: Union[str, bytes, None],
error: Union[str, bytes, None],
cwd: Optional[Union[str, Path]] = None,
) -> None:
if isinstance(command, list):
self.command: str = " ".join(command)
else:
self.command = str(command)
self.code = int(code)
Comment on lines +223 to +224
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both these things are being cast to the type they are already. Is there a reason for that?

self.output = self._decode(output)
self.error = self._decode(error)
self.cwd = cwd
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two types are accepted but we should store just one. I suggest Path.

super().__init__(f"command {repr(self.command)} returned {code}")

def _decode(self, value: Union[str, bytes, None]) -> str:
"""Convert from bytes to a string as necessary."""
if value is None:
return ""
if isinstance(value, bytes):
return value.decode()
return value


class FabCommandNotFound(FabError):
"""Target command could not be found by subprocess.

Error where the target command passed to a subprocess call could
not be found.

:param command: the target command. For clarity, only the first
item is used in the error message but the entire command is
preserved for inspection by the caller.
"""

def __init__(self, command: str) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typing error: Union[list[str].str]

self.command = command
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Homogenise argument to either string or list of strings. Latter would remove the need for the test to extract target.

if isinstance(command, list):
self.target: str = command[0]
elif isinstance(command, str):
self.target = command.split()[0]
else:
raise ValueError(f"invalid command: {command}")

super().__init__(f"unable to execute {self.target}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer quotes like '{self.target}' around the string. This helps identify white-space errors (e.g. when a user provides say touch abc as command, since we are running without shell, that line would result in unable to execute touch abc, which kind of hides the error that there is a space in the binary name.



class FabMultiCommandError(FabError):
"""Unpack multiple exceptions into a single one.

Error which combines all potential exceptions raised by a
multiprocessing section into a single exception class for
subsequent inspection.

This feature is is required because versions of python prior to
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This feature is is required because versions of python prior to
This feature is required because versions of python prior to

3.11 do not support ExceptionGroups.
Comment on lines +269 to +270
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe mark this as Todo: so someone can be prompted to remove it once 3.10 is no longer supported.


:param errors: a list ot exceptions
:param label: an identifier for the multiprocessing section
"""

def __init__(
self, errors: List[Union[str, Exception]], label: Optional[str] = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this exception consolidates exceptions, why is it accepting strings?

If that is useful, should it create an exception to hold the string so self.errors holds only exceptions?

) -> None:
self.errors = errors
self.label = label or "during multiprocessing"

message = f"{len(errors)} exception"
message += " " if len(errors) == 1 else "s "
message += f"{self.label}"

super().__init__(message)


class FabSourceError(FabError):
"""Base class for source code management exceptions."""


class FabSourceNoFilesError(FabSourceError):
"""No source files were found.

Error where no source files have been once any filtering rules
have been applied.

"""

def __init__(self) -> None:
super().__init__("no source files found after filtering")


class FabSourceMergeError(FabSourceError):
"""Version control merge has failed.

Error where the underlying version control system has failed to
automatically merge source code changes, e.g. because of a source
conflict that requires manual resolution.

:param tool: name of the version control system
:param reason: reason/error output from the version control system
indicating why the merge failed
:param revision: optional name of the specific revision being
targeted
"""

def __init__(self, tool: str, reason: str, revision: Optional[str] = None) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool has type Tool?

self.tool = tool
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was confused at first, but then realised that this is not based on FabToolError. Nothing we can do about it, but maybe add a comment to explain this?

self.reason = reason

message = f"[{tool}] merge "
if revision:
message += f"of {revision} "
message += f"failed: {reason}"

super().__init__(message)


class FabSourceFetchError(FabSourceError):
"""An attempt to fetch source files has failed.

Error where a specific set of files could not be fetched,
e.g. from a location containing prebuild files.

:params source: location of the source files
:param reason: reason for the failure
"""

def __init__(self, source: str, reason: str) -> None:
self.source = source
self.reason = reason
super().__init__(f"could not fetch {source}: {reason}")
7 changes: 3 additions & 4 deletions source/fab/steps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from fab.metrics import send_metric
from fab.util import by_type, TimerLogger
from fab.errors import FabMultiCommandError

from functools import wraps


Expand Down Expand Up @@ -96,7 +98,4 @@ def check_for_errors(results: Iterable[Union[str, Exception]],

exceptions = list(by_type(results, Exception))
if exceptions:
formatted_errors = "\n\n".join(map(str, exceptions))
raise RuntimeError(
f"{formatted_errors}\n\n{len(exceptions)} error(s) found {caller_label}"
)
raise FabMultiCommandError(exceptions, caller_label)
Loading