Repository: HypothesisWorks/hypothesis

Issue:
Bad charmap.pickle.gz file causes user-unfriendly Hypothesis crash
# Problem

A coworker of mine ran into the following `EOFError: Ran out of input` crash, with the latest Hypothesis: see the traceback below.

# Analysis

It appears that the crash happened while reading `.hypothesis/unicodedata/8.0.0/charmap.pickle.gz`. Presumably, either the pickle or gzip data were malformed for some reason: deleting the file resolved the problem.

However, it seems like Hypothesis could handle this situation better. Two possible improvements:

1. Wrap the charmap file read with something that produces a friendlier error message, that at least mentions the file name and possible problem?
2. If reading the charmap file fails like this, simply ignore and recreate it?

Thoughts?

# Traceback

```
…/site-packages/hypothesis/core.py:439: in process_arguments_to_given
    search_strategy.validate()
…/site-packages/hypothesis/searchstrategy/strategies.py:210: in validate
    self.do_validate()
…/site-packages/hypothesis/searchstrategy/lazy.py:103: in do_validate
    w = self.wrapped_strategy
…/site-packages/hypothesis/searchstrategy/lazy.py:99: in wrapped_strategy
    for k, v in self.__kwargs.items()})
…/site-packages/hypothesis/strategies.py:386: in tuples
    if arg.is_empty:
…/site-packages/hypothesis/searchstrategy/strategies.py:112: in is_empty
    self.cached_is_empty = self.calc_is_empty()
…/site-packages/hypothesis/searchstrategy/strategies.py:323: in calc_is_empty
    return self.mapped_strategy.is_empty
…/site-packages/hypothesis/searchstrategy/strategies.py:112: in is_empty
    self.cached_is_empty = self.calc_is_empty()
…/site-packages/hypothesis/searchstrategy/lazy.py:90: in calc_is_empty
    return self.wrapped_strategy.is_empty
…/site-packages/hypothesis/searchstrategy/lazy.py:99: in wrapped_strategy
    for k, v in self.__kwargs.items()})
…/site-packages/hypothesis/strategies.py:581: in fixed_dictionaries
    if v.is_empty:
…/site-packages/hypothesis/searchstrategy/strategies.py:112: in is_empty
    self.cached_is_empty = self.calc_is_empty()
…/site-packages/hypothesis/searchstrategy/lazy.py:90: in calc_is_empty
    return self.wrapped_strategy.is_empty
…/site-packages/hypothesis/searchstrategy/lazy.py:99: in wrapped_strategy
    for k, v in self.__kwargs.items()})
…/site-packages/hypothesis/strategies.py:446: in lists
    if elements.is_empty:
…/site-packages/hypothesis/searchstrategy/strategies.py:112: in is_empty
    self.cached_is_empty = self.calc_is_empty()
…/site-packages/hypothesis/searchstrategy/strategies.py:323: in calc_is_empty
    return self.mapped_strategy.is_empty
…/site-packages/hypothesis/searchstrategy/strategies.py:112: in is_empty
    self.cached_is_empty = self.calc_is_empty()
…/site-packages/hypothesis/searchstrategy/lazy.py:90: in calc_is_empty
    return self.wrapped_strategy.is_empty
…/site-packages/hypothesis/searchstrategy/lazy.py:97: in wrapped_strategy
    *[unwrap_strategies(s) for s in self.__args],
…/site-packages/hypothesis/searchstrategy/lazy.py:97: in <listcomp>
    *[unwrap_strategies(s) for s in self.__args],
…/site-packages/hypothesis/searchstrategy/lazy.py:53: in unwrap_strategies
    result = unwrap_strategies(s.wrapped_strategy)
…/site-packages/hypothesis/searchstrategy/lazy.py:99: in wrapped_strategy
    for k, v in self.__kwargs.items()})
…/site-packages/hypothesis/strategies.py:660: in characters
    max_codepoint=max_codepoint)
…/site-packages/hypothesis/searchstrategy/strings.py:45: in __init__
    max_codepoint=max_codepoint,
…/site-packages/hypothesis/internal/charmap.py:155: in query
    catkey = _category_key(exclude_categories, include_categories)
…/site-packages/hypothesis/internal/charmap.py:116: in _category_key
    cs = categories()
…/site-packages/hypothesis/internal/charmap.py:82: in categories
    cm = charmap()
…/site-packages/hypothesis/internal/charmap.py:71: EOFError
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    def charmap():
        …
            with GzipFile(f, 'rb') as i:
>               _charmap = dict(pickle.loads(i.read()))

EOFError: Ran out of input
```


Discussion:
I’d lean towards option (2) – exposing details of this file to the user seems like a mistake. It’s created by Hypothesis, we shouldn’t tell the user about it unless things really fall over.
Definitely prefer option 2; having looked at the source we can always handle this with at most a once-per-run performance hit (regenerating the content).  I'll offer this as a good first patch for someone at the PyConAU sprints tomorrow; and otherwise get to it soon myself.

Resolving patch:
diff --git a/src/hypothesis/internal/charmap.py b/src/hypothesis/internal/charmap.py
--- a/src/hypothesis/internal/charmap.py
+++ b/src/hypothesis/internal/charmap.py
@@ -19,12 +19,13 @@
 
 import os
 import sys
+import gzip
 import pickle
 import tempfile
 import unicodedata
 
 from hypothesis.configuration import tmpdir, storage_directory
-from hypothesis.internal.compat import GzipFile, FileExistsError, hunichr
+from hypothesis.internal.compat import hunichr
 
 
 def charmap_file():
@@ -39,9 +40,16 @@ def charmap_file():
 
 def charmap():
     global _charmap
+    # Best-effort caching in the face of missing files and/or unwritable
+    # filesystems is fairly simple: check if loaded, else try loading,
+    # else calculate and try writing the cache.
     if _charmap is None:
         f = charmap_file()
-        if not os.path.exists(f):
+        try:
+            with gzip.GzipFile(f, 'rb') as i:
+                _charmap = dict(pickle.load(i))
+
+        except Exception:
             tmp_charmap = {}
             for i in range(0, sys.maxunicode + 1):
                 cat = unicodedata.category(hunichr(i))
@@ -50,25 +58,20 @@ def charmap():
                     rs[-1][-1] += 1
                 else:
                     rs.append([i, i])
-            # We explicitly set the mtime to an arbitrary value so as to get
-            # a stable format for our charmap.
-            data = sorted(
-                (k, tuple((map(tuple, v))))
-                for k, v in tmp_charmap.items())
-
-            # Write the Unicode table atomically
-            fd, tmpfile = tempfile.mkstemp(dir=tmpdir())
-            os.close(fd)
-            with GzipFile(tmpfile, 'wb', mtime=1) as o:
-                o.write(pickle.dumps(data, pickle.HIGHEST_PROTOCOL))
+            _charmap = {k: tuple((map(tuple, v)))
+                        for k, v in tmp_charmap.items()}
+
             try:
+                # Write the Unicode table atomically
+                fd, tmpfile = tempfile.mkstemp(dir=tmpdir())
+                os.close(fd)
+                # Explicitly set the mtime to get reproducible output
+                with gzip.GzipFile(tmpfile, 'wb', mtime=1) as o:
+                    pickle.dump(sorted(_charmap.items()), o,
+                                pickle.HIGHEST_PROTOCOL)
                 os.rename(tmpfile, f)
-            except FileExistsError:  # pragma: no cover
-                # This exception is only raised on Windows, and coverage is
-                # measured on Linux.
+            except Exception:  # pragma: no cover
                 pass
-        with GzipFile(f, 'rb') as i:
-            _charmap = dict(pickle.loads(i.read()))
     assert _charmap is not None
     return _charmap
 
diff --git a/src/hypothesis/internal/compat.py b/src/hypothesis/internal/compat.py
--- a/src/hypothesis/internal/compat.py
+++ b/src/hypothesis/internal/compat.py
@@ -26,7 +26,6 @@
 import codecs
 import platform
 import importlib
-from gzip import GzipFile
 from base64 import b64encode
 from decimal import Context, Decimal, Inexact
 from hashlib import sha1


Test patch:
diff --git a/tests/cover/test_charmap.py b/tests/cover/test_charmap.py
--- a/tests/cover/test_charmap.py
+++ b/tests/cover/test_charmap.py
@@ -21,8 +21,6 @@
 import sys
 import unicodedata
 
-import pytest
-
 import hypothesis.strategies as st
 import hypothesis.internal.charmap as cm
 from hypothesis import given, assume
@@ -138,8 +136,17 @@ def broken(*args, **kwargs):
     cm._charmap = None
     monkeypatch.setattr(os.path, 'exists', lambda p: False)
     monkeypatch.setattr(os, 'rename', broken)
-    with pytest.raises(ValueError):
-        cm.charmap()
 
-    with pytest.raises(ValueError):
-        cm.charmap()
+    cm.charmap()
+    cm.charmap()
+
+
+def test_regenerate_broken_charmap_file():
+    cm.charmap()
+    file_loc = cm.charmap_file()
+
+    with open(file_loc, 'wb'):
+        pass
+
+    cm._charmap = None
+    cm.charmap()