Skip to content

Conversation

t00sa
Copy link
Contributor

@t00sa t00sa commented Aug 22, 2025

Swap all RuntimeError exceptions for custom fab exceptions using a class hierarchy derived from RuntimeError to minimise disruptive changes.

These changes fall into three broad areas:

  • creation of a new set of error classes in fab.errors and an associated set of tests
  • changes to the existing regression tests to:
    • check the new message formats
    • check the exceptions - which are still caught as RuntimeError to test the impact on calling functions - match the expected instance of the new error class
  • replacement of raise RuntimeError across the code base with the new error classes

While the change is complete and the test suite runs successfully in my environment, I still have a couple of outstanding questions that need to be addressed in review:

  • are the names of the new exceptions classes acceptable? The top level class names end with Error but the derived classes do not. Ending every class name with Error would make fab more consistent with python, at the cost of longer exception names
  • are there any potential clashes or issues with the in-flight baf PR?

t00sa added 2 commits August 22, 2025 15:52
Swap all RuntimeError exceptions for custom fab exceptions using a
class hierarchy derived from RuntimeError to minimise distruptive
changes.
Some minor flake8 problems had slipped in.  This fixes them.
@t00sa t00sa requested review from MatthewHambley and hiker August 22, 2025 15:35
@t00sa t00sa moved this to In Review in Fab Development Tracker Aug 22, 2025
@t00sa t00sa self-assigned this Aug 22, 2025
@t00sa t00sa linked an issue Aug 22, 2025 that may be closed by this pull request
Copy link
Collaborator

@hiker hiker left a comment

Choose a reason for hiding this comment

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

Thanks for this huge amount of work. I didn't expect that many classes to be used :)
FWIW, in psyclone we have around half the errors defined in some submodules (which helps avoid circular dependencies to some degree).

I have added a few comments. Could you also add a section to the development.rst file about errors, e.g. indicating that only Fab* errors should be raised, no standard errors.

self.tool = tool

# 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?

# Check for name attributes rather than using isintance
# because Category and Tool ahve 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.

if hasattr(tool, "name"):
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.

"""

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.

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]

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.

"""

def __init__(self, tool: str, reason: str, revision: Optional[str] = None) -> 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.

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?

@MatthewHambley
Copy link
Collaborator

I'm about to start a review but I'll leave this comment here for immediate consideration. Are you aware of FabException in https://github.com/MetOffice/fab/blob/main/source/fab/__init__.py? Have you considered how this interacts with your change?

Copy link
Collaborator

@MatthewHambley MatthewHambley left a comment

Choose a reason for hiding this comment

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

This is an extensive change which tidies up exception handling considerable. A few questions and suggestions included in-line.

In general, we have generated API documentation but is there anything which could be added (or removed) from the static documentation in light of this change?

"""

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.

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.

self.tool = tool

# 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 have thoughts on replacing the concept of categories with a more flexible system of capability tags. How does that interact with this issue?

self.tool = tool

# 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.

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

Comment on lines +14 to +16
if TYPE_CHECKING:
from fab.tools.category import Category
from fab.tools.tool import Tool
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?

if hasattr(tool, "name"):
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.

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


captured = capsys.readouterr()
assert "error: fab file does not exist" in captured.err
assert "fab file does not exist" in captured.err
Copy link
Collaborator

Choose a reason for hiding this comment

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

Where we are testing error messages I think we should test as much as possible. i.e. prefer equality over "in".

Comment on lines +56 to +61
# Mock defines an internal name property, so special handling
# is required to reset the value to support testing
tool = Mock(_name="tool cc")
del tool.name
err = FabToolError(tool, "compiler message")
assert str(err) == "[tool cc] compiler message"
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems alarming. Is this related to Joerg's comment about peeking at private state?

"""Test FabCommandError in various configurations."""

err = FabCommandError(["ls", "-l", "/nosuch"], 1, b"", b"ls: cannot", "/")
assert str(err) == "command 'ls -l /nosuch' returned 1"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be easier to read if the sentence was restructured to put the variable length element last. e.g. "Return code 1 from command: ls -l /nosuch".

This way the eye knows where to go to for the error code and the start of the command.

assert str(err) == "[git] merge failed: conflicting source files"

err = FabSourceMergeError("git", "conflicting source files", "vn1.1")
assert str(err) == "[git] merge of vn1.1 failed: conflicting source files"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe quote marks around revision?

with raises(RuntimeError) as err:
linker.get_lib_flags("unknown")
assert str(err.value) == "Unknown library name: 'unknown'"
assert str(err.value) == "unknown library unknown"
Copy link
Collaborator

Choose a reason for hiding this comment

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

This might benefit from a redrafting. Initial upper-case and maybe quote marks around library name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: In Review
Development

Successfully merging this pull request may close these issues.

Replace RuntimeError exceptions
3 participants