Repository: napari/napari

Issue:
translations._load_language (& all settings loads) needs a try/except
## 🐛 Bug

I was just running some pytests on a non-napari package, in an environment that happened to have napari installed, and started getting errors because napari is registered as a pytest plugin, but was failing to import.  Here's the full trace:

<details>

```pytb
Traceback (most recent call last):
  File "/Users/talley/miniconda3/envs/magicdev/bin/pytest", line 8, in <module>
    sys.exit(console_main())
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/config/__init__.py", line 185, in console_main
    code = main()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/config/__init__.py", line 143, in main
    config = _prepareconfig(args, plugins)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/config/__init__.py", line 318, in _prepareconfig
    config = pluginmanager.hook.pytest_cmdline_parse(
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/hooks.py", line 286, in __call__
    return self._hookexec(self, self.get_hookimpls(), kwargs)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/manager.py", line 93, in _hookexec
    return self._inner_hookexec(hook, methods, kwargs)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/manager.py", line 84, in <lambda>
    self._inner_hookexec = lambda hook, methods, kwargs: hook.multicall(
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/callers.py", line 203, in _multicall
    gen.send(outcome)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/helpconfig.py", line 100, in pytest_cmdline_parse
    config: Config = outcome.get_result()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/callers.py", line 80, in get_result
    raise ex[1].with_traceback(ex[2])
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/callers.py", line 187, in _multicall
    res = hook_impl.function(*args)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/config/__init__.py", line 1003, in pytest_cmdline_parse
    self.parse(args)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/config/__init__.py", line 1283, in parse
    self._preparse(args, addopts=addopts)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/config/__init__.py", line 1172, in _preparse
    self.pluginmanager.load_setuptools_entrypoints("pytest11")
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/pluggy/manager.py", line 299, in load_setuptools_entrypoints
    plugin = ep.load()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/importlib/metadata.py", line 77, in load
    module = import_module(match.group('module'))
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 961, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 961, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/__init__.py", line 7, in <module>
    from .viewer import Viewer  # isort:skip
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/viewer.py", line 3, in <module>
    from .components import ViewerModel
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/components/__init__.py", line 19, in <module>
    from .layerlist import LayerList
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/components/layerlist.py", line 8, in <module>
    from ..layers import Layer
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/layers/__init__.py", line 10, in <module>
    from .base import Layer
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/layers/base/__init__.py", line 1, in <module>
    from .base import Layer
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/layers/base/base.py", line 22, in <module>
    from ._base_constants import Blending
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/layers/base/_base_constants.py", line 5, in <module>
    from ...utils.translations import trans
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/_pytest/assertion/rewrite.py", line 170, in exec_module
    exec(co, module.__dict__)
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/utils/translations.py", line 476, in <module>
    _Translator._set_locale(_load_language())
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/utils/translations.py", line 468, in _load_language
    data = safe_load(fh) or {}
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/__init__.py", line 162, in safe_load
    return load(stream, SafeLoader)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/__init__.py", line 114, in load
    return loader.get_single_data()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/constructor.py", line 49, in get_single_data
    node = self.get_single_node()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 36, in get_single_node
    document = self.compose_document()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 55, in compose_document
    node = self.compose_node(None, None)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 84, in compose_node
    node = self.compose_mapping_node(anchor)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 133, in compose_mapping_node
    item_value = self.compose_node(node, item_key)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 84, in compose_node
    node = self.compose_mapping_node(anchor)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 133, in compose_mapping_node
    item_value = self.compose_node(node, item_key)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 82, in compose_node
    node = self.compose_sequence_node(anchor)
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/composer.py", line 110, in compose_sequence_node
    while not self.check_event(SequenceEndEvent):
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/parser.py", line 98, in check_event
    self.current_event = self.state()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/parser.py", line 403, in parse_indentless_sequence_entry
    if self.check_token(BlockEntryToken):
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/scanner.py", line 115, in check_token
    while self.need_more_tokens():
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/scanner.py", line 152, in need_more_tokens
    self.stale_possible_simple_keys()
  File "/Users/talley/miniconda3/envs/magicdev/lib/python3.8/site-packages/yaml/scanner.py", line 291, in stale_possible_simple_keys
    raise ScannerError("while scanning a simple key", key.mark,
yaml.scanner.ScannerError: while scanning a simple key
  in "/Users/talley/Library/Application Support/Napari/settings.yaml", line 31, column 1
could not find expected ':'
  in "/Users/talley/Library/Application Support/Napari/settings.yaml", line 32, column 19
```
</details>

but the key part is right here:
```python
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/utils/translations.py", line 476, in <module>
    _Translator._set_locale(_load_language())
  File "/Users/talley/Dropbox (HMS)/Python/forks/napari/napari/utils/translations.py", line 468, in _load_language
    data = safe_load(fh) or {}
...
yaml.scanner.ScannerError: while scanning a simple key
  in "/Users/talley/Library/Application Support/Napari/settings.yaml", line 31, column 1
could not find expected ':'
  in "/Users/talley/Library/Application Support/Napari/settings.yaml", line 32, column 19
```

we should add some tests to make sure that _every_ time we read from from settings, even if that settings file is complete nonsense, napari doesn't crash.

cc @goanpeca 


Discussion:
Will fix!

Indeed not nice!
thanks!

Resolving patch:
diff --git a/napari/utils/_base.py b/napari/utils/_base.py
--- a/napari/utils/_base.py
+++ b/napari/utils/_base.py
@@ -5,7 +5,10 @@
 the translator before the settings manager is created.
 """
 
+from appdirs import user_config_dir
+
 _FILENAME = "settings.yaml"
 _APPNAME = "Napari"
 _APPAUTHOR = "Napari"
 _DEFAULT_LOCALE = "en"
+_DEFAULT_CONFIG_PATH = user_config_dir(_APPNAME, _APPAUTHOR, _FILENAME)
diff --git a/napari/utils/settings/_manager.py b/napari/utils/settings/_manager.py
--- a/napari/utils/settings/_manager.py
+++ b/napari/utils/settings/_manager.py
@@ -118,8 +118,19 @@ def _load(self):
             self._settings[section] = setting()
 
         if path.is_file():
-            with open(path) as fh:
-                data = safe_load(fh.read()) or {}
+            try:
+                with open(path) as fh:
+                    data = safe_load(fh.read()) or {}
+            except Exception as err:
+                import warnings
+
+                warnings.warn(
+                    "The content of the napari settings file could "
+                    "not be read.\n\nThe default settings will be used "
+                    "and the content of the file will be replaced the "
+                    f"next time settings are changed.\n\nError: \n{err}"
+                )
+                data = {}
 
             # Check with models
             for section, model_data in data.items():
diff --git a/napari/utils/translations.py b/napari/utils/translations.py
--- a/napari/utils/translations.py
+++ b/napari/utils/translations.py
@@ -8,10 +8,9 @@
 import sys
 from pathlib import Path
 
-from appdirs import user_config_dir
 from yaml import safe_load
 
-from ._base import _APPAUTHOR, _APPNAME, _DEFAULT_LOCALE, _FILENAME
+from ._base import _DEFAULT_CONFIG_PATH, _DEFAULT_LOCALE
 
 # Entry points
 NAPARI_LANGUAGEPACK_ENTRY = "napari.languagepack"
@@ -457,15 +456,40 @@ def load(cls, domain: str = "napari") -> TranslationBundle:
         return trans
 
 
-def _load_language() -> str:
-    """Load language from configuration file directly."""
-    locale = _DEFAULT_LOCALE
-    default_config_path = Path(
-        user_config_dir(_APPNAME, _APPAUTHOR, _FILENAME)
-    )
+def _load_language(
+    default_config_path: str = _DEFAULT_CONFIG_PATH,
+    locale: str = _DEFAULT_LOCALE,
+) -> str:
+    """
+    Load language from configuration file directly.
+
+    Parameters
+    ----------
+    default_config_path : str or Path
+        The default configuration path, optional
+    locale : str
+        The default locale used to display options, optional
+
+    Returns
+    -------
+    str
+        The language locale set by napari.
+    """
+    default_config_path = Path(default_config_path)
     if default_config_path.exists():
         with open(default_config_path) as fh:
-            data = safe_load(fh) or {}
+            try:
+                data = safe_load(fh) or {}
+            except Exception as err:
+                import warnings
+
+                warnings.warn(
+                    "The `language` setting defined in the napari "
+                    "configuration file could not be read. The "
+                    f"default language will be used.\n\nError: \n{err}"
+                )
+
+                data = {}
 
         locale = data.get("application", {}).get("language", locale)
 


Test patch:
diff --git a/napari/utils/_tests/test_translations.py b/napari/utils/_tests/test_translations.py
--- a/napari/utils/_tests/test_translations.py
+++ b/napari/utils/_tests/test_translations.py
@@ -8,6 +8,7 @@
 from napari.utils.translations import (
     _get_display_name,
     _is_valid_locale,
+    _load_language,
     get_language_packs,
     translator,
 )
@@ -183,3 +184,28 @@ def test_locale_np_runs(trans):
     # Test plural context method shorthand
     result = trans._np(context, string, plural, n)
     assert result == plural
+
+
+def test_load_language_valid(tmp_path):
+    # This is valid content
+    data = """
+application:
+  language: es_ES
+"""
+    temp_config_path = tmp_path / "tempconfig.yml"
+    with open(temp_config_path, "w") as fh:
+        fh.write(data)
+
+    result = _load_language(temp_config_path)
+    assert result == "es_ES"
+
+
+def test_load_language_invalid(tmp_path):
+    # This is invalid content
+    data = ":"
+    temp_config_path = tmp_path / "tempconfig.yml"
+    with open(temp_config_path, "w") as fh:
+        fh.write(data)
+
+    with pytest.warns(UserWarning):
+        _load_language(temp_config_path)
diff --git a/napari/utils/settings/_tests/test_settings.py b/napari/utils/settings/_tests/test_settings.py
--- a/napari/utils/settings/_tests/test_settings.py
+++ b/napari/utils/settings/_tests/test_settings.py
@@ -138,3 +138,13 @@ def test_custom_theme_settings(settings):
 
 def test_settings_string(settings):
     assert 'application:\n' in str(settings)
+
+
+def test_settings_load_invalid_content(tmp_path):
+    # This is invalid content
+    data = ":"
+    with open(tmp_path / SettingsManager._FILENAME, "w") as fh:
+        fh.write(data)
+
+    with pytest.warns(UserWarning):
+        SettingsManager(tmp_path)