Repository: IDSIA/sacred
Pull request #367 (2018-10-10 10:15:20)

Improved error messages/base exceptions
This PR implements the basic improved exceptions from #348 without the fancy stuff. This means, there are no suggestions and no tracking of where the configuration values come from.


Patch:
diff --git a/sacred/config/captured_function.py b/sacred/config/captured_function.py
index 3353f2f..095f378 100644
--- a/sacred/config/captured_function.py
+++ b/sacred/config/captured_function.py
@@ -9,6 +9,7 @@ import wrapt
 from sacred.config.custom_containers import FallbackDict
 from sacred.config.signature import Signature
 from sacred.randomness import create_rnd, get_seed
+from sacred.utils import ConfigError
 
 
 def create_captured_function(function, prefix=None):
@@ -43,7 +44,8 @@ def captured_function(wrapped, instance, args, kwargs):
         wrapped.logger.debug("Started")
         start_time = time.time()
     # =================== run actual function =================================
-    result = wrapped(*args, **kwargs)
+    with ConfigError.track(wrapped):
+        result = wrapped(*args, **kwargs)
     # =========================================================================
     if wrapped.logger is not None:
         stop_time = time.time()
diff --git a/sacred/config/signature.py b/sacred/config/signature.py
index 732e8fc..1e6b0ea 100644
--- a/sacred/config/signature.py
+++ b/sacred/config/signature.py
@@ -6,6 +6,7 @@ import inspect
 from collections import OrderedDict
 import sys
 
+from sacred.utils import MissingConfigError
 
 if sys.version_info[0] < 3:  # python2
     def get_argspec(f):
@@ -156,5 +157,7 @@ class Signature(object):
         free_params = self.get_free_parameters(args, kwargs, bound)
         missing_args = [m for m in free_params if m not in self.kwargs]
         if missing_args:
-            raise TypeError("{} is missing value(s) for {}".format(
-                self.name, missing_args))
+            raise MissingConfigError(
+                '{} is missing value(s):'.format(self.name),
+                missing_configs=missing_args
+            )
diff --git a/sacred/experiment.py b/sacred/experiment.py
index 0b045d7..bd93f56 100755
--- a/sacred/experiment.py
+++ b/sacred/experiment.py
@@ -4,9 +4,8 @@
 from __future__ import division, print_function, unicode_literals
 
 import inspect
-import sys
-
 import os.path
+import sys
 from collections import OrderedDict
 
 from docopt import docopt, printable_usage
@@ -20,7 +19,8 @@ from sacred.commands import (help_for_command, print_config,
 from sacred.config.signature import Signature
 from sacred.ingredient import Ingredient
 from sacred.initialize import create_run
-from sacred.utils import print_filtered_stacktrace, ensure_wellformed_argv
+from sacred.utils import print_filtered_stacktrace, ensure_wellformed_argv, \
+    SacredError, format_sacred_error
 
 __all__ = ('Experiment',)
 
@@ -261,16 +261,33 @@ class Experiment(Ingredient):
 
         try:
             return self.run(cmd_name, config_updates, named_configs, {}, args)
-        except Exception:
-            if not self.current_run or self.current_run.debug:
+        except Exception as e:
+            if self.current_run:
+                debug = self.current_run.debug
+            else:
+                # The usual command line options are applied after the run
+                # object is built completely. Some exceptions (e.g.
+                # ConfigAddedError) are raised before this. In these cases,
+                # the debug flag must be checked manually.
+                debug = args.get('--debug', False)
+
+            if debug:
+                # Debug: Don't change behaviour, just re-raise exception
                 raise
-            elif self.current_run.pdb:
+            elif self.current_run and self.current_run.pdb:
+                # Print exception and attach pdb debugger
                 import traceback
                 import pdb
                 traceback.print_exception(*sys.exc_info())
                 pdb.post_mortem()
             else:
-                print_filtered_stacktrace()
+                # Handle pretty printing of exceptions. This includes
+                # filtering the stacktrace and printing the usage, as
+                # specified by the exceptions attributes
+                if isinstance(e, SacredError):
+                    print(format_sacred_error(e, short_usage), file=sys.stderr)
+                else:
+                    print_filtered_stacktrace()
                 exit(1)
 
     def open_resource(self, filename, mode='r'):
@@ -446,12 +463,12 @@ class Experiment(Ingredient):
     def _check_command(self, cmd_name):
         commands = dict(self.gather_commands())
         if cmd_name is not None and cmd_name not in commands:
-            return 'Error: Command "{}" not found. Available commands are: '\
+            return 'Error: Command "{}" not found. Available commands are: ' \
                    '{}'.format(cmd_name, ", ".join(commands.keys()))
 
         if cmd_name is None:
-            return 'Error: No command found to be run. Specify a command'\
-                   ' or define main function. Available commands'\
+            return 'Error: No command found to be run. Specify a command' \
+                   ' or define main function. Available commands' \
                    ' are: {}'.format(", ".join(commands.keys()))
 
     def _handle_help(self, args, usage):
diff --git a/sacred/ingredient.py b/sacred/ingredient.py
index 31ea131..c16bcde 100644
--- a/sacred/ingredient.py
+++ b/sacred/ingredient.py
@@ -377,11 +377,12 @@ class Ingredient(object):
             If a circular structure among ingredients was detected.
         """
         if self._is_traversing:
-            raise CircularDependencyError()
+            raise CircularDependencyError(ingredients=[self])
         else:
             self._is_traversing = True
         yield self, 0
-        for ingredient in self.ingredients:
-            for ingred, depth in ingredient.traverse_ingredients():
-                yield ingred, depth + 1
+        with CircularDependencyError.track(self):
+            for ingredient in self.ingredients:
+                for ingred, depth in ingredient.traverse_ingredients():
+                    yield ingred, depth + 1
         self._is_traversing = False
diff --git a/sacred/initialize.py b/sacred/initialize.py
index 7f24e9d..9ff2243 100755
--- a/sacred/initialize.py
+++ b/sacred/initialize.py
@@ -15,7 +15,8 @@ from sacred.run import Run
 from sacred.utils import (convert_to_nested_dict, create_basic_stream_logger,
                           get_by_dotted_path, is_prefix, rel_path,
                           iterate_flattened, set_by_dotted_path,
-                          recursive_update, iter_prefixes, join_paths)
+                          recursive_update, iter_prefixes, join_paths,
+                          NamedConfigNotFoundError, ConfigAddedError)
 
 
 class Scaffold(object):
@@ -85,6 +86,10 @@ class Scaffold(object):
         if os.path.exists(config_name):
             nc = ConfigDict(load_config_file(config_name))
         else:
+            if config_name not in self.named_configs:
+                raise NamedConfigNotFoundError(
+                    named_config=config_name,
+                    available_named_configs=tuple(self.named_configs.keys()))
             nc = self.named_configs[config_name]
 
         cfg = nc(fixed=self.get_config_updates_recursive(),
@@ -170,8 +175,9 @@ class Scaffold(object):
     def _warn_about_suspicious_changes(self):
         for add in sorted(self.config_mods.added):
             if not set(iter_prefixes(add)).intersection(self.captured_args):
-                raise KeyError('Added a new config entry "{}" that is not used'
-                               ' anywhere'.format(add))
+                if self.path:
+                    add = join_paths(self.path, add)
+                raise ConfigAddedError(add, config=self.config)
             else:
                 self.logger.warning('Added new config entry: "%s"' % add)
 
diff --git a/sacred/utils.py b/sacred/utils.py
index e56728f..6d2ef75 100755
--- a/sacred/utils.py
+++ b/sacred/utils.py
@@ -3,6 +3,7 @@
 from __future__ import division, print_function, unicode_literals
 
 import collections
+import contextlib
 import inspect
 import logging
 import os.path
@@ -16,7 +17,6 @@ from functools import partial
 
 import wrapt
 
-
 __all__ = ["NO_LOGGER", "PYTHON_IDENTIFIER", "CircularDependencyError",
            "ObserverError", "SacredInterrupt", "TimeoutInterrupt",
            "create_basic_stream_logger", "recursive_update",
@@ -48,7 +48,6 @@ else:
     FileNotFoundError = FileNotFoundError
     from io import StringIO
 
-
 NO_LOGGER = logging.getLogger('ignore')
 NO_LOGGER.disabled = 1
 
@@ -57,10 +56,6 @@ PATHCHANGE = object()
 PYTHON_IDENTIFIER = re.compile("^[a-zA-Z_][_a-zA-Z0-9]*$")
 
 
-class CircularDependencyError(Exception):
-    """The ingredients of the current experiment form a circular dependency."""
-
-
 class ObserverError(Exception):
     """Error that an observer raises but that should not make the run fail."""
 
@@ -87,6 +82,189 @@ class TimeoutInterrupt(SacredInterrupt):
     STATUS = "TIMEOUT"
 
 
+class SacredError(Exception):
+    def __init__(self, message, print_traceback=True, filter_traceback=True,
+                 print_usage=False):
+        super(SacredError, self).__init__(message)
+        self.print_traceback = print_traceback
+        self.filter_traceback = filter_traceback
+        self.print_usage = print_usage
+
+
+class CircularDependencyError(SacredError):
+    """The ingredients of the current experiment form a circular dependency."""
+
+    @classmethod
+    @contextlib.contextmanager
+    def track(cls, ingredient):
+        try:
+            yield
+        except CircularDependencyError as e:
+            if not e.__circular_dependency_handled__:
+                if ingredient in e.__ingredients__:
+                    e.__circular_dependency_handled__ = True
+                e.__ingredients__.append(ingredient)
+            raise e
+
+    def __init__(self, message='Circular dependency detected:',
+                 ingredients=None, print_traceback=True,
+                 filter_traceback=True, print_usage=False):
+        super(CircularDependencyError, self).__init__(
+            message,
+            print_traceback=print_traceback,
+            filter_traceback=filter_traceback,
+            print_usage=print_usage
+        )
+
+        if ingredients is None:
+            ingredients = []
+        self.__ingredients__ = ingredients
+        self.__circular_dependency_handled__ = False
+
+    def __str__(self):
+        return (super(CircularDependencyError, self).__str__() + '->'.join(
+            [i.path for i in reversed(self.__ingredients__)]))
+
+
+class ConfigError(SacredError):
+    """There was an error in the configuration. Pretty prints the conflicting
+    configuration values."""
+
+    def __init__(self, message, conflicting_configs=(),
+                 print_conflicting_configs=True,
+                 print_traceback=True,
+                 filter_traceback=True, print_usage=False,
+                 config=None):
+        super(ConfigError, self).__init__(message,
+                                          print_traceback=print_traceback,
+                                          filter_traceback=filter_traceback,
+                                          print_usage=print_usage)
+        self.print_conflicting_configs = print_conflicting_configs
+
+        if isinstance(conflicting_configs, str):
+            conflicting_configs = (conflicting_configs,)
+
+        self.__conflicting_configs__ = conflicting_configs
+        self.__prefix_handled__ = False
+
+        if config is None:
+            config = {}
+        self.__config__ = config
+
+    @classmethod
+    @contextlib.contextmanager
+    def track(cls, wrapped):
+        try:
+            yield
+        except ConfigError as e:
+            if not e.__prefix_handled__:
+                if wrapped.prefix:
+                    e.__conflicting_configs__ = (
+                        join_paths(wrapped.prefix, str(c)) for c in
+                        e.__conflicting_configs__
+                    )
+                e.__config__ = wrapped.config
+                e.__prefix_handled__ = True
+            raise e
+
+    def __str__(self):
+        s = super(ConfigError, self).__str__()
+        if self.print_conflicting_configs:
+            # Add a list formatted as below to the string s:
+            #
+            # Conflicting configuration values:
+            #   a=3
+            #   b.c=4
+            s += '\nConflicting configuration values:'
+            for conflicting_config in self.__conflicting_configs__:
+                s += '\n  {}={}'.format(conflicting_config,
+                                        get_by_dotted_path(self.__config__,
+                                                           conflicting_config))
+        return s
+
+
+class InvalidConfigError(ConfigError):
+    """
+    Exception that can be raised in the user code if an error in the
+    configuration values is detected.
+
+    Examples:
+        >>> # Experiment definitions ...
+        ... @ex.automain
+        ... def main(a, b):
+        ...     if a != b['a']:
+        ...         raise InvalidConfigError(
+        ...                     'Need to be equal',
+        ...                     conflicting_configs=('a', 'b.a'))
+    """
+    pass
+
+
+class MissingConfigError(SacredError):
+    """A config value that is needed by a captured function is not present in
+    the provided config."""
+
+    def __init__(self, message='Configuration values are missing:',
+                 missing_configs=(),
+                 print_traceback=False, filter_traceback=True,
+                 print_usage=True):
+        message = '{}: {}'.format(message, missing_configs)
+        super(MissingConfigError, self).__init__(
+            message, print_traceback=print_traceback,
+            filter_traceback=filter_traceback, print_usage=print_usage
+        )
+
+
+class NamedConfigNotFoundError(SacredError):
+    """A named config is not found."""
+
+    def __init__(self, named_config, message='Named config not found:',
+                 available_named_configs=(),
+                 print_traceback=False,
+                 filter_traceback=True, print_usage=False):
+        message = '{} "{}". Available config values are: {}'.format(
+            message, named_config, available_named_configs)
+        super(NamedConfigNotFoundError, self).__init__(
+            message,
+            print_traceback=print_traceback,
+            filter_traceback=filter_traceback,
+            print_usage=print_usage)
+
+
+class ConfigAddedError(ConfigError):
+    SPECIAL_ARGS = {'_log', '_config', '_seed', '__doc__', 'config_filename',
+                    '_run'}
+    """Special args that show up in the captured args but can never be set
+    by the user"""
+
+    def __init__(self, conflicting_configs,
+                 message='Added new config entry that is not used anywhere',
+                 captured_args=(),
+                 print_conflicting_configs=True, print_traceback=False,
+                 filter_traceback=True, print_usage=False,
+                 print_suggestions=True,
+                 config=None):
+        super(ConfigAddedError, self).__init__(
+            message,
+            conflicting_configs=conflicting_configs,
+            print_conflicting_configs=print_conflicting_configs,
+            print_traceback=print_traceback,
+            filter_traceback=filter_traceback,
+            print_usage=print_usage,
+            config=config
+        )
+        self.captured_args = captured_args
+        self.print_suggestions = print_suggestions
+
+    def __str__(self):
+        s = super(ConfigAddedError, self).__str__()
+        if self.print_suggestions:
+            possible_keys = set(self.captured_args) - self.SPECIAL_ARGS
+            if possible_keys:
+                s += '\nPossible config keys are: {}'.format(possible_keys)
+        return s
+
+
 def create_basic_stream_logger():
     logger = logging.getLogger('')
     logger.setLevel(logging.INFO)
@@ -272,35 +450,54 @@ def _is_sacred_frame(frame):
     return frame.f_globals["__name__"].split('.')[0] == 'sacred'
 
 
-def print_filtered_stacktrace():
+def print_filtered_stacktrace(filter_traceback=True):
+    print(format_filtered_stacktrace(filter_traceback), file=sys.stderr)
+
+
+def format_filtered_stacktrace(filter_traceback=True):
     exc_type, exc_value, exc_traceback = sys.exc_info()
     # determine if last exception is from sacred
     current_tb = exc_traceback
     while current_tb.tb_next is not None:
         current_tb = current_tb.tb_next
-    if _is_sacred_frame(current_tb.tb_frame):
-        header = ["Exception originated from within Sacred.\n"
-                  "Traceback (most recent calls):\n"]
-        texts = tb.format_exception(exc_type, exc_value, current_tb)
-        print(''.join(header + texts[1:]).strip(), file=sys.stderr)
-    else:
-        if sys.version_info >= (3, 3):
-            tb_exception =\
-                tb.TracebackException(exc_type, exc_value, exc_traceback,
-                                      limit=None)
-            for line in filtered_traceback_format(tb_exception):
-                print(line, file=sys.stderr, end="")
+
+    if filter_traceback:
+        if _is_sacred_frame(current_tb.tb_frame):
+            header = ["Exception originated from within Sacred.\n"
+                      "Traceback (most recent calls):\n"]
+            texts = tb.format_exception(exc_type, exc_value, current_tb)
+            return ''.join(header + texts[1:]).strip()
         else:
-            print("Traceback (most recent calls WITHOUT Sacred internals):",
-                  file=sys.stderr)
-            current_tb = exc_traceback
-            while current_tb is not None:
-                if not _is_sacred_frame(current_tb.tb_frame):
-                    tb.print_tb(current_tb, 1)
-                current_tb = current_tb.tb_next
-            print("\n".join(tb.format_exception_only(exc_type,
-                                                     exc_value)).strip(),
-                  file=sys.stderr)
+            if sys.version_info >= (3, 5):
+                tb_exception = \
+                    tb.TracebackException(exc_type, exc_value, exc_traceback,
+                                          limit=None)
+                return ''.join(filtered_traceback_format(tb_exception))
+            else:
+                s = "Traceback (most recent calls WITHOUT Sacred internals):"
+                current_tb = exc_traceback
+                while current_tb is not None:
+                    if not _is_sacred_frame(current_tb.tb_frame):
+                        tb.print_tb(current_tb, 1)
+                    current_tb = current_tb.tb_next
+                s += "\n".join(tb.format_exception_only(exc_type,
+                                                        exc_value)).strip()
+                return s
+    else:
+        return '\n'.join(
+            tb.format_exception(exc_type, exc_value, exc_traceback))
+
+
+def format_sacred_error(e, short_usage):
+    lines = []
+    if e.print_usage:
+        lines.append(short_usage)
+    if e.print_traceback:
+        lines.append(format_filtered_stacktrace(e.filter_traceback))
+    else:
+        import traceback as tb
+        lines.append('\n'.join(tb.format_exception_only(type(e), e)))
+    return '\n'.join(lines)
 
 
 def filtered_traceback_format(tb_exception, chain=True):


Test patch:
diff --git a/tests/test_config/test_signature.py b/tests/test_config/test_signature.py
index 3f1d61b..cb60822 100644
--- a/tests/test_config/test_signature.py
+++ b/tests/test_config/test_signature.py
@@ -5,10 +5,12 @@ from __future__ import division, print_function
 import re
 
 import pytest
+
 from sacred.config.signature import Signature
+# #############  function definitions to test on ##############################
+from sacred.utils import MissingConfigError
 
 
-# #############  function definitions to test on ##############################
 def foo():
     return
 
@@ -279,16 +281,16 @@ def test_construct_arguments_overwrites_defaults():
 def test_construct_arguments_raises_if_args_unfilled():
     s = Signature(bariza)
     missing = re.compile(".*missing.*")
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([], {}, {})
     assert missing.match(excinfo.value.args[0])
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([1, 2], {}, {})
     assert missing.match(excinfo.value.args[0])
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([1], {'b': 3}, {})
     assert missing.match(excinfo.value.args[0])
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([1], {'c': 5}, {})
     assert missing.match(excinfo.value.args[0])
 
diff --git a/tests/test_config/test_signature_py3.py b/tests/test_config/test_signature_py3.py
index c787278..01bf6d6 100644
--- a/tests/test_config/test_signature_py3.py
+++ b/tests/test_config/test_signature_py3.py
@@ -5,10 +5,12 @@ from __future__ import division, print_function
 import re
 
 import pytest
+
 from sacred.config.signature import Signature
+# #############  function definitions to test on ##############################
+from sacred.utils import MissingConfigError
 
 
-# #############  function definitions to test on ##############################
 def foo() -> None:
     return
 
@@ -195,16 +197,16 @@ def test_construct_arguments_overwrites_defaults():
 def test_construct_arguments_raises_if_args_unfilled():
     s = Signature(bariza)
     missing = re.compile(".*missing.*")
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([], {}, {})
     assert missing.match(excinfo.value.args[0])
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([1, 2], {}, {})
     assert missing.match(excinfo.value.args[0])
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([1], {'b': 3}, {})
     assert missing.match(excinfo.value.args[0])
-    with pytest.raises(TypeError) as excinfo:
+    with pytest.raises(MissingConfigError) as excinfo:
         s.construct_arguments([1], {'c': 5}, {})
     assert missing.match(excinfo.value.args[0])
 
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
index 0000000..2e4e61a
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python
+# coding=utf-8
+from __future__ import division, print_function, unicode_literals
+
+import re
+
+from sacred import Ingredient, Experiment
+from sacred.utils import CircularDependencyError, ConfigAddedError, \
+    MissingConfigError, NamedConfigNotFoundError, format_filtered_stacktrace, \
+    format_sacred_error, SacredError
+
+"""Global Docstring"""
+
+import pytest
+
+
+def test_circular_dependency_raises():
+    # create experiment with circular dependency
+    ing = Ingredient('ing')
+    ex = Experiment('exp', ingredients=[ing])
+    ex.main(lambda: None)
+    ing.ingredients.append(ex)
+
+    # run and see if it raises
+    with pytest.raises(CircularDependencyError, match='exp->ing->exp'):
+        ex.run()
+
+
+def test_config_added_raises():
+    ex = Experiment('exp')
+    ex.main(lambda: None)
+
+    with pytest.raises(
+            ConfigAddedError,
+            match=r'Added new config entry that is not used anywhere.*\n'
+                  r'\s*Conflicting configuration values:\n'
+                  r'\s*a=42'):
+        ex.run(config_updates={'a': 42})
+
+
+def test_missing_config_raises():
+    ex = Experiment('exp')
+    ex.main(lambda a: None)
+    with pytest.raises(MissingConfigError):
+        ex.run()
+
+
+def test_named_config_not_found_raises():
+    ex = Experiment('exp')
+    ex.main(lambda: None)
+    with pytest.raises(NamedConfigNotFoundError,
+                       match='Named config not found: "not_there". '
+                             'Available config values are:'):
+        ex.run(named_configs=('not_there',))
+
+
+def test_format_filtered_stacktrace_true():
+    ex = Experiment('exp')
+
+    @ex.capture
+    def f():
+        raise Exception()
+
+    try:
+        f()
+    except:
+        st = format_filtered_stacktrace(filter_traceback=True)
+        assert 'captured_function' not in st
+        assert 'WITHOUT Sacred internals' in st
+
+
+def test_format_filtered_stacktrace_false():
+    ex = Experiment('exp')
+
+    @ex.capture
+    def f():
+        raise Exception()
+
+    try:
+        f()
+    except:
+        st = format_filtered_stacktrace(filter_traceback=False)
+        assert 'captured_function' in st
+
+
+@pytest.mark.parametrize(
+    'print_traceback,filter_traceback,print_usage,expected', [
+        (False, False, False, '.*SacredError: message'),
+        (True, False, False, r'Traceback \(most recent call last\):\n*'
+                             r'\s*File ".*", line \d*, in '
+                             r'test_format_sacred_error\n*'
+                             r'.*\n*'
+                             r'.*SacredError: message'),
+        (False, True, False, r'.*SacredError: message'),
+        (False, False, True, r'usage\n.*SacredError: message'),
+        (True, True, False, r'Traceback \(most recent calls WITHOUT Sacred '
+                            r'internals\):\n*'
+                            r'(\n|.)*'
+                            r'.*SacredError: message')
+    ])
+def test_format_sacred_error(print_traceback, filter_traceback, print_usage,
+                             expected):
+    try:
+        raise SacredError('message', print_traceback, filter_traceback,
+                          print_usage)
+    except SacredError as e:
+        st = format_sacred_error(e, 'usage')
+        print(st)
+        assert re.match(expected, st, re.MULTILINE)
diff --git a/tests/test_experiment.py b/tests/test_experiment.py
index b4f78a9..1bd39b0 100755
--- a/tests/test_experiment.py
+++ b/tests/test_experiment.py
@@ -11,7 +11,7 @@ import pytest
 import sys
 
 from sacred.experiment import Experiment
-from sacred.utils import apply_backspaces_and_linefeeds
+from sacred.utils import apply_backspaces_and_linefeeds, ConfigAddedError
 
 
 @pytest.fixture
@@ -82,7 +82,7 @@ def test_fails_on_unused_config_updates(ex):
     assert ex.run(config_updates={'c': 9}).result == 3
 
     # unused config updates raise
-    with pytest.raises(KeyError):
+    with pytest.raises(ConfigAddedError):
         ex.run(config_updates={'d': 3})
 
 
@@ -104,7 +104,7 @@ def test_fails_on_nested_unused_config_updates(ex):
     assert ex.run(config_updates={'d': {'e': 7}}).result == 1
 
     # unused nested config updates raise
-    with pytest.raises(KeyError):
+    with pytest.raises(ConfigAddedError):
         ex.run(config_updates={'d': {'f': 3}})
 
 
@@ -124,7 +124,7 @@ def test_considers_captured_functions_for_fail_on_unused_config(ex):
     assert ex.run(config_updates={'a': 7}).result == 7
     assert ex.run(config_updates={'b': 3}).result == 4
 
-    with pytest.raises(KeyError):
+    with pytest.raises(ConfigAddedError):
         ex.run(config_updates={'c': 3})
 
 
@@ -143,10 +143,10 @@ def test_considers_prefix_for_fail_on_unused_config(ex):
 
     assert ex.run(config_updates={'a': {'b': 3}}).result == 3
 
-    with pytest.raises(KeyError):
+    with pytest.raises(ConfigAddedError):
         ex.run(config_updates={'b': 5})
 
-    with pytest.raises(KeyError):
+    with pytest.raises(ConfigAddedError):
         ex.run(config_updates={'a': {'c': 5}})