Repository: qutebrowser/qutebrowser

Issue:
Crash with invalid dictionary filenames
Looks like this doesn't get caught properly on init:

```
16:39:44 DEBUG    init       app:_init_modules:468 Initializing websettings...
16:39:45 ERROR    misc       crashsignal:exception_hook:216 Uncaught exception
Traceback (most recent call last):
  File "/bin/qutebrowser", line 11, in <module>
    load_entry_point('qutebrowser==1.1.1', 'gui_scripts', 'qutebrowser')()
  File "/usr/lib/python3.6/site-packages/qutebrowser/qutebrowser.py", line 188, in main
    return app.run(args)
  File "/usr/lib/python3.6/site-packages/qutebrowser/app.py", line 137, in run
    init(args, crash_handler)
  File "/usr/lib/python3.6/site-packages/qutebrowser/app.py", line 163, in init
    _init_modules(args, crash_handler)
  File "/usr/lib/python3.6/site-packages/qutebrowser/app.py", line 469, in _init_modules
    websettings.init(args)
  File "/usr/lib/python3.6/site-packages/qutebrowser/config/websettings.py", line 215, in init
    webenginesettings.init(args)
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/webenginesettings.py", line 297, in init
    websettings.init_mappings(MAPPINGS)
  File "/usr/lib/python3.6/site-packages/qutebrowser/config/websettings.py", line 198, in init_mappings
    mapping.set(value)
  File "/usr/lib/python3.6/site-packages/qutebrowser/config/websettings.py", line 72, in set
    self._set(value, settings=settings)
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/webenginesettings.py", line 154, in _set
    filenames = [self._find_installed(code) for code in value]
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/webenginesettings.py", line 154, in <listcomp>
    filenames = [self._find_installed(code) for code in value]
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/webenginesettings.py", line 143, in _find_installed
    local_filename = spell.local_filename(code)
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/spell.py", line 64, in local_filename
    all_installed = local_files(code)
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/spell.py", line 51, in local_files
    for matching_dict in sorted(matching_dicts, key=version, reverse=True):
  File "/usr/lib/python3.6/site-packages/qutebrowser/browser/webengine/spell.py", line 36, in version
    .format(filename))
ValueError: the given dictionary file name is malformed: /usr/share/qt/qtwebengine_dictionaries/en-US.bdic
```

https://crashes.qutebrowser.org/view/63cd0d83

cc @elshize 


Discussion:
I'll look into this after I return from my vacation ;)
Sure - enjoy the vacation! :+1: 
When exactly do you get this error? I can't reproduce it. What are the spell settings that are set?
I get it at start after a `sudo touch /usr/share/qt/qtwebengine_dictionaries/en-US.bdic` with `spellcheck.languages = ["en-US"]`.
Okay, got it. I'll make sure to catch that. But should we still treat dictionaries without a version as invalid? (If not, then there's a problem of selecting the latest one if one has and one does not have a version.)
I do think they should be treated as invalid, but qutebrowser should show some error instead of crashing.

Resolving patch:
diff --git a/qutebrowser/browser/webengine/spell.py b/qutebrowser/browser/webengine/spell.py
--- a/qutebrowser/browser/webengine/spell.py
+++ b/qutebrowser/browser/webengine/spell.py
@@ -24,16 +24,18 @@
 import re
 
 from PyQt5.QtCore import QLibraryInfo
-from qutebrowser.utils import log
+from qutebrowser.utils import log, message
+
+dict_version_re = re.compile(r".+-(?P<version>[0-9]+-[0-9]+?)\.bdic")
 
 
 def version(filename):
     """Extract the version number from the dictionary file name."""
-    version_re = re.compile(r".+-(?P<version>[0-9]+-[0-9]+?)\.bdic")
-    match = version_re.fullmatch(filename)
+    match = dict_version_re.match(filename)
     if match is None:
-        raise ValueError('the given dictionary file name is malformed: {}'
-                         .format(filename))
+        message.warning(
+            "Found a dictionary with a malformed name: {}".format(filename))
+        return None
     return tuple(int(n) for n in match.group('version').split('-'))
 
 
@@ -44,15 +46,23 @@ def dictionary_dir():
 
 
 def local_files(code):
-    """Return all installed dictionaries for the given code."""
+    """Return all installed dictionaries for the given code.
+
+    The returned dictionaries are sorted by version, therefore the latest will
+    be the first element. The list will be empty if no dictionaries are found.
+    """
     pathname = os.path.join(dictionary_dir(), '{}*.bdic'.format(code))
     matching_dicts = glob.glob(pathname)
-    files = []
-    for matching_dict in sorted(matching_dicts, key=version, reverse=True):
-        filename = os.path.basename(matching_dict)
-        log.config.debug('Found file for dict {}: {}'.format(code, filename))
-        files.append(filename)
-    return files
+    versioned_dicts = []
+    for matching_dict in matching_dicts:
+        parsed_version = version(matching_dict)
+        if parsed_version is not None:
+            filename = os.path.basename(matching_dict)
+            log.config.debug('Found file for dict {}: {}'
+                             .format(code, filename))
+            versioned_dicts.append((parsed_version, filename))
+    return [filename for version, filename
+            in sorted(versioned_dicts, reverse=True)]
 
 
 def local_filename(code):


Test patch:
diff --git a/tests/unit/browser/webengine/test_spell.py b/tests/unit/browser/webengine/test_spell.py
--- a/tests/unit/browser/webengine/test_spell.py
+++ b/tests/unit/browser/webengine/test_spell.py
@@ -17,31 +17,69 @@
 # You should have received a copy of the GNU General Public License
 # along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
 
-import pytest
+"""Tests for qutebrowser.browser.webengine.spell module."""
+
+import logging
+import os
+
+from PyQt5.QtCore import QLibraryInfo
 from qutebrowser.browser.webengine import spell
+from qutebrowser.utils import usertypes
 
 
-def test_version():
+def test_version(message_mock, caplog):
+    """Tests parsing dictionary version from its file name."""
     assert spell.version('en-US-8-0.bdic') == (8, 0)
     assert spell.version('pl-PL-3-0.bdic') == (3, 0)
-    with pytest.raises(ValueError):
-        spell.version('malformed_filename')
+    with caplog.at_level(logging.WARNING):
+        assert spell.version('malformed_filename') is None
+    msg = message_mock.getmsg(usertypes.MessageLevel.warning)
+    expected = ("Found a dictionary with a malformed name: malformed_filename")
+    assert msg.text == expected
+
 
+def test_dictionary_dir(monkeypatch):
+    monkeypatch.setattr(QLibraryInfo, 'location', lambda _: 'datapath')
+    assert spell.dictionary_dir() == os.path.join('datapath',
+                                                  'qtwebengine_dictionaries')
 
-def test_local_filename_dictionary_does_not_exist(tmpdir, monkeypatch):
+
+def test_local_filename_dictionary_does_not_exist(monkeypatch):
+    """Tests retrieving local filename when the dir doesn't exits."""
     monkeypatch.setattr(
         spell, 'dictionary_dir', lambda: '/some-non-existing-dir')
     assert not spell.local_filename('en-US')
 
 
 def test_local_filename_dictionary_not_installed(tmpdir, monkeypatch):
+    """Tests retrieving local filename when the dict not installed."""
     monkeypatch.setattr(spell, 'dictionary_dir', lambda: str(tmpdir))
     assert not spell.local_filename('en-US')
 
 
+def test_local_filename_not_installed_malformed(tmpdir, monkeypatch, caplog):
+    """Tests retrieving local filename when the only file is malformed."""
+    monkeypatch.setattr(spell, 'dictionary_dir', lambda: str(tmpdir))
+    (tmpdir / 'en-US.bdic').ensure()
+    with caplog.at_level(logging.WARNING):
+        assert not spell.local_filename('en-US')
+
+
 def test_local_filename_dictionary_installed(tmpdir, monkeypatch):
+    """Tests retrieving local filename when the dict installed."""
     monkeypatch.setattr(spell, 'dictionary_dir', lambda: str(tmpdir))
     for lang_file in ['en-US-11-0.bdic', 'en-US-7-1.bdic', 'pl-PL-3-0.bdic']:
         (tmpdir / lang_file).ensure()
     assert spell.local_filename('en-US') == 'en-US-11-0'
     assert spell.local_filename('pl-PL') == 'pl-PL-3-0'
+
+
+def test_local_filename_installed_malformed(tmpdir, monkeypatch, caplog):
+    """Tests retrieving local filename when the dict installed.
+
+    In this usecase, another existing file is malformed."""
+    monkeypatch.setattr(spell, 'dictionary_dir', lambda: str(tmpdir))
+    for lang_file in ['en-US-11-0.bdic', 'en-US-7-1.bdic', 'en-US.bdic']:
+        (tmpdir / lang_file).ensure()
+    with caplog.at_level(logging.WARNING):
+        assert spell.local_filename('en-US') == 'en-US-11-0'