Repository: microcombustion/ctwrap

Issue:
Parallel batch jobs fail silently
While failing parallel jobs should not raise an error, some documentation needs to be generated

Discussion:

Patch:
diff --git a/ctwrap/simulation.py b/ctwrap/simulation.py
index ea81fb5..4048309 100644
--- a/ctwrap/simulation.py
+++ b/ctwrap/simulation.py
@@ -59,6 +59,7 @@ class Simulation(object):
         self._module = module
         self._output = output
         self.data = None
+        self._errored = False
 
         # ensure that module is well formed
         mod = self._load_module()
@@ -140,7 +141,15 @@ class Simulation(object):
             config = module.defaults()
         config = Parser(config)
 
-        self.data = module.run(name, **config, **kwargs)
+        try:
+            self.data = module.run(name, **config, **kwargs)
+            self._errored = False
+        except Exception as err:
+            # Convert exception to warning
+            msg = "Simulation of '{}' for '{}' failed with error message:\n{}".format(module.__name__, name, err)
+            warnings.warn(msg, RuntimeWarning)
+            self.data = {name: (type(err).__name__, str(err))}
+            self._errored = True
 
     def defaults(self) -> Dict[str, Any]:
         """Pass-through returning simulation module defaults as a dictionary"""
@@ -175,7 +184,7 @@ class Simulation(object):
             filename = Path(filepath) / filename
 
         # file check
-        fexists = Path.is_file(filename)
+        fexists = Path(filename).is_file()
 
         if fexists:
             with h5py.File(filename, 'r') as hdf:
@@ -193,7 +202,13 @@ class Simulation(object):
         if formatt in supported:
             module = self._load_module()
             if hasattr(module, 'save'):
-                module.save(filename, self.data, task, **output)
+                if self._errored:
+                    with h5py.File(filename, mode) as hdf:
+                        for group, err in self.data.items():
+                            grp = hdf.create_group(group)
+                            grp.attrs[err[0]] = err[1]
+                else:
+                    module.save(filename, self.data, task, **output)
             else:
                 raise AttributeError("{} simulation module has no method 'save' "
                                      "but output format was defined in configuration "


Test patch:
diff --git a/tests/invalid.py b/tests/invalid.py
new file mode 100644
index 0000000..9f9f760
--- /dev/null
+++ b/tests/invalid.py
@@ -0,0 +1,22 @@
+"""Invalid module - raises error"""
+
+
+def defaults():
+    """Returns dictionary containing default arguments"""
+    return {'foo': 0.2}
+
+
+def run(name, foo=.2):
+    """Simply raise error"""
+    raise RuntimeError("Hello world!")
+
+
+def save(filename, data, task=None):
+    """Does nothing for this example"""
+    pass
+
+
+if __name__ == "__main__":
+    """ Main function """
+    config = defaults()
+    out = run('main', **config)
diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py
index 367f7ea..375fef7 100644
--- a/tests/test_wrapper.py
+++ b/tests/test_wrapper.py
@@ -8,6 +8,7 @@ from pathlib import Path
 import subprocess
 import pint.quantity as pq
 import importlib
+import h5py
 
 import warnings
 # add exception as pywintypes imports a deprecated module
@@ -40,37 +41,35 @@ class TestWrap(unittest.TestCase):
     _path = None
     _strategy = 'sequence'
 
+    def setUp(self):
+        self.sim = cw.Simulation.from_module(self._module)
+        self.sh = cw.SimulationHandler.from_yaml(self._yaml, strategy=self._strategy, path=EXAMPLES)
+
     def tearDown(self):
         if self._hdf:
             [hdf.unlink() for hdf in Path(EXAMPLES).glob('*.h5')]
             [hdf.unlink() for hdf in Path(ROOT).glob('*.h5')]
 
     def test_simulation(self):
-        sim = cw.Simulation.from_module(self._module)
-        self.assertIsNone(sim.data)
-        sim.run()
-        self.assertIsInstance(sim.data, dict)
-        for key in sim.data.keys():
+        self.assertIsNone(self.sim.data)
+        self.sim.run()
+        self.assertIsInstance(self.sim.data, dict)
+        for key in self.sim.data.keys():
             self.assertIn('defaults', key)
 
     def test_handler(self):
-        sh = cw.SimulationHandler.from_yaml(self._yaml, strategy=self._strategy, path=EXAMPLES)
-        self.assertIsInstance(sh.tasks, dict)
-        self.assertIn(self._task, sh.tasks)
+        self.assertIsInstance(self.sh.tasks, dict)
+        self.assertIn(self._task, self.sh.tasks)
 
     def test_serial(self):
-        sim = cw.Simulation.from_module(self._module)
-        sh = cw.SimulationHandler.from_yaml(self._yaml, strategy=self._strategy, path=EXAMPLES)
-        self.assertTrue(sh.run_serial(sim))
+        self.assertTrue(self.sh.run_serial(self.sim))
 
         if self._hdf:
             hdf = Path(EXAMPLES) / self._hdf
             self.assertTrue(hdf.is_file())
 
     def test_parallel(self):
-        sim = cw.Simulation.from_module(self._module)
-        sh = cw.SimulationHandler.from_yaml(self._yaml, strategy=self._strategy, path=EXAMPLES)
-        self.assertTrue(sh.run_parallel(sim))
+        self.assertTrue(self.sh.run_parallel(self.sim))
 
         if self._hdf:
             hdf = Path(EXAMPLES) / self._hdf
@@ -148,5 +147,43 @@ class TestAdiabaticFlame(TestWrap):
     _strategy = None
 
 
+class TestInvalid(TestWrap):
+
+    _module = str(ROOT / 'tests' / 'invalid.py')
+    _task = 'foo_1'
+    _dict = {
+        'strategy': {'sequence': {'foo': [0, 1, 2]}},
+        'defaults': {'foo': None},
+        'output': {'name': 'invalid', 'format': 'h5'},
+        'ctwrap': '0.2.0'
+    }
+
+    def setUp(self):
+        self.sim = cw.Simulation.from_module(self._module)
+        self.sh = cw.SimulationHandler.from_dict(self._dict)
+
+    def tearDown(self):
+        h5 = Path('invalid.h5')
+        if h5.is_file():
+            with h5py.File(h5, 'r') as hdf:
+                for data in hdf.values():
+                    for attr in data.attrs:
+                        self.assertEqual(attr, 'RuntimeError')
+                        self.assertEqual(data.attrs[attr], "Hello world!")
+            h5.unlink()
+
+    def test_simulation(self):
+        with self.assertWarnsRegex(RuntimeWarning, "Hello world!"):
+            super().test_simulation()
+
+    def test_serial(self):
+        with self.assertWarnsRegex(RuntimeWarning, "Hello world!"):
+            super().test_serial()
+
+    def test_commandline(self):
+        # skip test (does not use setUp and is more involved)
+        pass
+
+
 if __name__ == "__main__":
     unittest.main()