Skip to content

Commit

Permalink
Merge pull request #47 from nipreps/fix/error-desc
Browse files Browse the repository at this point in the history
ENH: Improve error description, add handler for NodeExecutionError
  • Loading branch information
mgxd authored Nov 16, 2023
2 parents 18b0b38 + 2d8067a commit 2161099
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 58 deletions.
6 changes: 6 additions & 0 deletions migas/error/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from migas.error.base import inspect_error, strip_filenames

__all__ = [
"inspect_error",
"strip_filenames",
]
56 changes: 56 additions & 0 deletions migas/error/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

import re
import sys


def inspect_error(error_funcs: dict | None = None) -> dict:
# Catch handled errors as well
etype, err, etb = sys.exc_info()

if (etype, err, etb) == (None, None, None):
# Python 3.12, new method
# MG: Cannot reproduce behavior while testing with 3.12.0
# if hasattr(sys, 'last_exc'):
# etype, evalue, etb = sys.last_exc

# < 3.11
if hasattr(sys, 'last_type'):
etype = sys.last_type
err = sys.last_value
etb = sys.last_traceback

if err and etype:
evalue = err.args[0]
ename = etype.__name__

if isinstance(error_funcs, dict) and ename in error_funcs:
func = error_funcs[ename]
kwargs = func(etype, evalue, etb)

elif ename in ('KeyboardInterrupt', 'BdbQuit'):
kwargs = {
'status': 'S',
'status_desc': 'Suspended',
}

else:
kwargs = {
'status': 'F',
'status_desc': 'Errored',
'error_type': ename,
'error_desc': evalue,
}
else:
kwargs = {
'status': 'C',
'status_desc': 'Completed',
}
return kwargs


def strip_filenames(text: str) -> str:
paths = set(re.findall(r'(?:/[^/]+)[/\w\.-]*', text))
for path in paths:
text = text.replace(path, '<redacted>')
return text
31 changes: 31 additions & 0 deletions migas/error/nipype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import re
from types import TracebackType

from migas.error import strip_filenames


def node_execution_error(etype: type, evalue: str, etb: TracebackType) -> dict:
strpval = evalue.replace('\n', ' ').replace('\t', ' ').strip()
node, cmd, stdout, stderr, tb = None, None, None, None, None

if m := re.search(r'(?P<node>(?<=Exception raised while executing Node )\w+)', strpval):
node = m.group('node').strip()

if m := re.search(r'(?P<cmdline>(?<=Cmdline:).*(?=Stdout:))', strpval):
cmd = strip_filenames(m.group('cmdline')).strip()

if m := re.search(r'(?P<stdout>(?<=Stdout:).*(?=Stderr:))', strpval):
stdout = strip_filenames(m.group('stdout')).strip()

if m := re.search(r'(?P<stderr>(?<=Stderr:).*(?=Traceback:))', strpval):
stderr = strip_filenames(m.group('stderr')).strip()

if m := re.search(r'(?P<tb>(?<=Traceback:).*)', strpval):
tb = strip_filenames(m.group('tb')).strip()

return {
'status': 'F',
'status_desc': f'Exception raised from node {node or "<?>"}',
'error_type': 'NodeExecutionError',
'error_desc': tb or "No traceback available",
}
23 changes: 11 additions & 12 deletions migas/tests/test_helpers.py → migas/error/tests/test_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@

import pytest

import migas


class CustomException(Exception):
...


def sample_error_func(etype: Exception, evalue: str, etb: str):
def sample_error_func(etype: type, evalue: str, etb: str):
ename = etype.__name__
if ename == "CustomException":
return {
Expand All @@ -20,23 +18,24 @@ def sample_error_func(etype: Exception, evalue: str, etb: str):
}


@pytest.mark.parametrize('error_funcs,error,status,error_desc', [
@pytest.mark.parametrize('error_funcs,error_type,status,error_desc', [
(None, None, 'C', None),
(None, KeyboardInterrupt, 'S', None),
(None, KeyError, 'F', 'KeyError: \'foo\''),
(None, FileNotFoundError, 'F', "i'm a teapot"),
({'CustomException': sample_error_func}, CustomException, 'F', 'Custom Error!'),
])
def test_inspect_error(monkeypatch, error_funcs, error, status, error_desc):
def test_inspect_error(monkeypatch, error_funcs, error_type, status, error_desc):

# do not actually call the server
if error is not None:
monkeypatch.setattr(sys, 'last_type', error, raising=False)
monkeypatch.setattr(sys, 'last_value', error_desc, raising=False)
if error_type:
error = error_type(error_desc)
monkeypatch.setattr(sys, 'last_type', error_type, raising=False)
monkeypatch.setattr(sys, 'last_value', error, raising=False)
monkeypatch.setattr(sys, 'last_traceback', 'Traceback...', raising=False)

from migas.helpers import _inspect_error
res = _inspect_error(error_funcs)
from migas.error import inspect_error
res = inspect_error(error_funcs)

assert res.get('status') == status
if error_desc is not None:
if error_desc:
assert res.get('error_desc') == error_desc
65 changes: 65 additions & 0 deletions migas/error/tests/test_nipype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import sys

from migas.error.nipype import node_execution_error

ERROR_TEXT = """
nipype.pipeline.engine.nodes.NodeExecutionError: Exception raised while executing Node failingnode.
Cmdline:
mri_convert --out_type mgz --input_volume /tmp/sample/file.txt --output_volume /tmp/wf/conv1/README_out.mgz
Stdout:
mri_convert --out_type mgz --input_volume /tmp/sample/file.txt --output_volume /tmp/wf/conv1/README_out.mgz
ERROR: cannot determine file type for /tmp/sample/file.txt
Stderr:
Traceback:
Traceback (most recent call last):
File "/code/nipype/nipype/interfaces/base/core.py", line 454, in aggregate_outputs
setattr(outputs, key, val)
File "/code/nipype/nipype/interfaces/base/traits_extension.py", line 425, in validate
value = super(MultiObject, self).validate(objekt, name, newvalue)
File "/code/.pyenv/versions/nipreps/lib/python3.10/site-packages/traits/trait_types.py", line 2699, in validate
return TraitListObject(self, object, name, value)
File "/code/.pyenv/versions/nipreps/lib/python3.10/site-packages/traits/trait_list_object.py", line 582, in __init__
super().__init__(
File "/code/.pyenv/versions/nipreps/lib/python3.10/site-packages/traits/trait_list_object.py", line 213, in __init__
super().__init__(self.item_validator(item) for item in iterable)
File "/code/.pyenv/versions/nipreps/lib/python3.10/site-packages/traits/trait_list_object.py", line 213, in <genexpr>
super().__init__(self.item_validator(item) for item in iterable)
File "/code/.pyenv/versions/nipreps/lib/python3.10/site-packages/traits/trait_list_object.py", line 865, in _item_validator
return trait_validator(object, self.name, value)
File "/code/nipype/nipype/interfaces/base/traits_extension.py", line 330, in validate
value = super(File, self).validate(objekt, name, value, return_pathlike=True)
File "/code/nipype/nipype/interfaces/base/traits_extension.py", line 135, in validate
self.error(objekt, name, str(value))
File "/code/.pyenv/versions/nipreps/lib/python3.10/site-packages/traits/base_trait_handler.py", line 74, in error
raise TraitError(
traits.trait_errors.TraitError: Each element of the 'out_file' trait of a MRIConvertOutputSpec instance must be a pathlike object or string representing an existing file, but a value of '/tmp/wf/conv1/README_out.mgz' <class 'str'> was specified.
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/code/nipype/nipype/interfaces/base/core.py", line 401, in run
outputs = self.aggregate_outputs(runtime)
File "/code/nipype/nipype/interfaces/base/core.py", line 461, in aggregate_outputs
raise FileNotFoundError(msg)
FileNotFoundError: No such file or directory '/tmp/wf/conv1/README_out.mgz' for output 'out_file' of a MRIConvert interface
"""


class NodeExecutionError(Exception):
...


TB = None
try:
raise RuntimeError("Testing")
except Exception:
_, _, TB = sys.exc_info()


def test_node_execution_error():
kwargs = node_execution_error(NodeExecutionError, ERROR_TEXT, TB)
assert kwargs['status'] == 'F'
assert kwargs['error_type'] == 'NodeExecutionError'
assert 'FileNotFoundError' in kwargs['error_desc']
60 changes: 14 additions & 46 deletions migas/helpers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from __future__ import annotations

import atexit
import sys

from migas.operations import add_project
from migas.error import inspect_error


def track_exit(project: str, version: str, error_funcs: dict | None = None, **kwargs) -> None:
"""
Registers a final breadcrumb to be sent upon process termination.
This supplements `migas.operations.add_breadcrumb` by inferring the final process status
on whether exception information is available. If so, rough exception information is relayed
to the server.
Additional customization is supported by using the `error_funcs` parameters, which accepts
a dictionary consisting of <error-name, function-to-handle-error> key/value pairs. Note that
expected outputs of the function are keyword arguments for `migas.operations.add_breadcrumb`.
"""
atexit.register(_final_breadcrumb, project, version, error_funcs, **kwargs)

def _final_breadcrumb(
Expand All @@ -15,49 +26,6 @@ def _final_breadcrumb(
error_funcs: dict | None = None,
**ping_kwargs,
) -> dict:
status = _inspect_error(error_funcs)
kwargs = {**ping_kwargs, **status}
status_kwargs = inspect_error(error_funcs)
kwargs = {**ping_kwargs, **status_kwargs}
return add_project(project, version, **kwargs)


def _inspect_error(error_funcs: dict | None) -> dict:
etype, evalue, etb = None, None, None

# Python 3.12, new method
# MG: Cannot reproduce behavior while testing with 3.12.0
# if hasattr(sys, 'last_exc'):
# etype, evalue, etb = sys.last_exc

# < 3.11
if hasattr(sys, 'last_type'):
etype = sys.last_type
evalue = sys.last_value
etb = sys.last_traceback

if etype:
ename = etype.__name__

if isinstance(error_funcs, dict) and ename in error_funcs:
func = error_funcs[ename]
kwargs = func(etype, evalue, etb)

elif ename in ('KeyboardInterrupt', 'BdbQuit'):
kwargs = {
'status': 'S',
'status_desc': 'Suspended',
}

else:
kwargs = {
'status': 'F',
'status_desc': 'Errored',
'error_type': ename,
'error_desc': evalue,
}
else:
kwargs = {
'status': 'C',
'status_desc': 'Completed',
}

return kwargs

0 comments on commit 2161099

Please sign in to comment.