Repository: glotzerlab/signac (python)

Issue:
Bug in JSON error checking
<!-- Please replace the text in the individual sections below. -->

### Description

The rapidjson package does not have a `decoder` submodule. Since we don't have a corresponding compatibility layer, things like `json.decoder.JSONDecodeError` fail.

### To reproduce

```
$ signac init json_decoder
$ signac find '{"x": True}'
27958648a9e57fcd66ae5e31ff3359e9
$ signac find '{"x": True}'  # This is invalid JSON, the expected behavior is to raise an error indicating that the JSON is invalid
Error: module 'signac.core.json' has no attribute 'decoder'  # The actual output
```


Pull request:
Added JSONDecodeError for rapidjson >= 0.7.1 with fallback to ValueError.
## Motivation and Context
Resolves #172.

## Types of Changes
- [x] Bug fix

## Checklist:
- [x] I am familiar with the [**Contributing Guidelines**](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md).
- [x] I agree with the terms of the [**Contributor Agreement**](https://github.com/glotzerlab/signac/blob/master/ContributorAgreement.md).
- [x] My name is on the [list of contributors](https://github.com/glotzerlab/signac/blob/master/contributors.txt).
- [x] My code follows the [code style guideline](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md#code-style) of this project.
- [x] The changes introduced by this pull request are covered by existing or newly introduced tests.

If necessary:
- [ ] I have updated the [changelog](https://github.com/glotzerlab/signac/blob/master/changelog.txt).

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 4c584fb7..3056353c 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -65,7 +65,7 @@ jobs:
           command: |
             . venv/bin/activate
             coverage run -m unittest discover tests/ -v
-            pip install python-rapidjson==0.7 && coverage run -m unittest discover tests/ -v
+            pip install python-rapidjson==0.7.1 && coverage run -m unittest discover tests/ -v
             coverage report -i
             codecov
 
diff --git a/signac/contrib/filterparse.py b/signac/contrib/filterparse.py
index af8f0529..1ec15bbe 100644
--- a/signac/contrib/filterparse.py
+++ b/signac/contrib/filterparse.py
@@ -33,7 +33,7 @@ def _is_regex(q):
 def _parse_json(q):
     try:
         return json.loads(q)
-    except json.decoder.JSONDecodeError:
+    except json.JSONDecodeError:
         _print_err("Failed to parse query argument. "
                    "Ensure that '{}' is valid JSON!".format(q))
         raise
diff --git a/signac/core/json.py b/signac/core/json.py
index fccdefd5..3de9943d 100644
--- a/signac/core/json.py
+++ b/signac/core/json.py
@@ -16,6 +16,12 @@ except ImportError:
 try:
     import rapidjson as json
     from rapidjson import Encoder
+    try:
+        # Defined for rapidjson >= 0.7.1
+        from rapidjson import JSONDecodeError
+    except ImportError:
+        # rapidjson < 0.7.1 raises a ValueError
+        JSONDecodeError = ValueError
 
     class JSONEncoder(Encoder):
         encode = Encoder.__call__
@@ -24,6 +30,11 @@ try:
 except ImportError:
     import json
     from json import JSONEncoder
+    try:
+        from json.decoder import JSONDecodeError
+    except ImportError:
+        # JSONDecodeError doesn't exist for Python 2
+        JSONDecodeError = ValueError
 
     logger.debug(msg.format('json'))
 
@@ -63,4 +74,4 @@ def dumps(o, sort_keys=False, indent=None):
     return CustomJSONEncoder(sort_keys=sort_keys, indent=indent).encode(o)
 
 
-__all__ = ['loads', 'dumps']
+__all__ = ['loads', 'dumps', 'JSONDecodeError']


Test patch:
diff --git a/tests/test_find_command_line_interface.py b/tests/test_find_command_line_interface.py
index a2db94a1..19f35eea 100644
--- a/tests/test_find_command_line_interface.py
+++ b/tests/test_find_command_line_interface.py
@@ -1,12 +1,17 @@
-# Copyright (c) 2017 The Regents of the University of Michigan
+# Copyright (c) 2019 The Regents of the University of Michigan
 # All rights reserved.
 # This software is licensed under the BSD 3-Clause License.
+import io
 import os
+import sys
 import json
 import unittest
 from itertools import chain
+from contextlib import contextmanager
 
+from signac.common import six
 from signac.contrib.filterparse import parse_filter_arg
+from signac.core.json import JSONDecodeError
 
 FILTERS = [
     {'a': 0},
@@ -65,6 +70,29 @@ ARRAY_EXPRESSIONS = [
 ]
 
 
+class StringIO(io.StringIO):
+    "PY27 compatibility layer."
+
+    def write(self, s):
+        if six.PY2:
+            super(StringIO, self).write(unicode(s))  # noqa
+        else:
+            super(StringIO, self).write(s)
+
+
+@contextmanager
+def redirect_stderr(new_target=None):
+    "Temporarily redirect all output to stderr to new_target."
+    if new_target is None:
+        new_target = StringIO()
+    old_target = sys.stderr
+    try:
+        sys.stderr = new_target
+        yield
+    finally:
+        sys.stderr = old_target
+
+
 class FindCommandLineInterfaceTest(unittest.TestCase):
 
     @staticmethod
@@ -92,6 +120,11 @@ class FindCommandLineInterfaceTest(unittest.TestCase):
         for expr in chain(ARITHMETIC_EXPRESSIONS, ARRAY_EXPRESSIONS):
             self.assertEqual(self._parse(['a', json.dumps(expr)]), {'a': expr})
 
+    def test_invalid_json(self):
+        with redirect_stderr():
+            with self.assertRaises(JSONDecodeError):
+                parse_filter_arg(['{"x": True}'])
+
 
 if __name__ == '__main__':
     unittest.main()