Repository: matrix-org/python-canonicaljson

Issue:
On Python 3.10, must use a sufficiently new frozendict version
andrewsh [noticed](https://matrix.to/#/!FeyNfPFYiGSPyHoALL:jki.re/$16371923830Lmnkh:shadura.me?via=matrix.org&via=sw1v.org&via=uhoreg.ca) the following

> FYI https://buildd.debian.org/status/fetch.php?pkg=matrix-synapse&arch=all&ver=1.47.0-1&stamp=1637184874&raw=0

```
I: pybuild base:237: python3.10 setup.py clean 
Traceback (most recent call last):
  File "/<<PKGBUILDDIR>>/setup.py", line 84, in <module>
    version = exec_file(("synapse", "__init__.py"))["__version__"]
  File "/<<PKGBUILDDIR>>/setup.py", line 80, in exec_file
    exec(code, result)
  File "<string>", line 44, in <module>
  File "/usr/lib/python3/dist-packages/canonicaljson.py", line 20, in <module>
    from frozendict import frozendict
  File "/usr/lib/python3/dist-packages/frozendict/__init__.py", line 16, in <module>
    class frozendict(collections.Mapping):
AttributeError: module 'collections' has no attribute 'Mapping'
E: pybuild pybuild:354: clean: plugin distutils failed with: exit code=1: python3.10 setup.py clean 
```

The above from frozendict 1.2.

This was fine in 3.9, but failed in 3.10. The Python 3.9 docs for the collection module says:

> Deprecated since version 3.3, will be removed in version 3.10: Moved Collections Abstract Base Classes to the collections.abc module. For backwards compatibility, they continue to be visible in this module through Python 3.9.

We should support Python 3.10. I'd suggest we ought to raise the required version of frozendict. I don't know if there's a way to make that conditional on 3.10? We should start by finding out the first version of frozendict that is supported by Python 3.10; I'd guess we want the first version where `frozendict` inherits from `dict`.

(Though come to think of it, it's odd that a frozendict inherits from dict, because it has a strictly smaller feature set (no-mutability).)

Comments:

Patch:
diff --git a/README.rst b/README.rst
index 9311e6c..af0c212 100644
--- a/README.rst
+++ b/README.rst
@@ -17,7 +17,7 @@ Features
 * Encodes the JSON as UTF-8.
 * Can encode ``frozendict`` immutable dictionaries.
 
-Supports Python versions 3.5 and newer.
+Supports Python versions 3.7 and newer.
 
 .. _`RFC 7159`: https://tools.ietf.org/html/rfc7159
 
diff --git a/canonicaljson.py b/canonicaljson.py
index 89913ff..654e34a 100644
--- a/canonicaljson.py
+++ b/canonicaljson.py
@@ -16,23 +16,21 @@
 # limitations under the License.
 
 import platform
+from typing import Optional, Type
 
-from frozendict import frozendict
+frozendict_type: Optional[Type]
+try:
+    from frozendict import frozendict as frozendict_type
+except ImportError:
+    frozendict_type = None  # pragma: no cover
 
 __version__ = "1.5.0"
 
 
 def _default(obj):  # pragma: no cover
-    if type(obj) is frozendict:
-        # fishing the protected dict out of the object is a bit nasty,
-        # but we don't really want the overhead of copying the dict.
-        try:
-            return obj._dict
-        except AttributeError:
-            # When the C implementation of frozendict is used,
-            # there isn't a `_dict` attribute with a dict
-            # so we resort to making a copy of the frozendict
-            return dict(obj)
+    if type(obj) is frozendict_type:
+        # If frozendict is available and used, cast `obj` into a dict
+        return dict(obj)
     raise TypeError(
         "Object of type %s is not JSON serializable" % obj.__class__.__name__
     )
diff --git a/setup.py b/setup.py
index 1c2cc94..ed59aca 100755
--- a/setup.py
+++ b/setup.py
@@ -49,8 +49,11 @@ setup(
         # simplerjson versions before 3.14.0 had a bug with some characters
         # (e.g. \u2028) if ensure_ascii was set to false.
         "simplejson>=3.14.0",
-        "frozendict>=1.0",
     ],
+    extras_require={
+        # frozendict support can be enabled using the `canonicaljson[frozendict]` syntax
+        "frozendict": ["frozendict>=1.0"],
+    },
     zip_safe=True,
     long_description=read_file(("README.rst",)),
     keywords="json",
@@ -58,7 +61,7 @@ setup(
     author_email="team@matrix.org",
     url="https://github.com/matrix-org/python-canonicaljson",
     license="Apache License, Version 2.0",
-    python_requires="~=3.5",
+    python_requires="~=3.7",
     classifiers=[
         "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Developers",
diff --git a/tox.ini b/tox.ini
index 768a346..2895be7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = packaging, pep8, py35, py36, py37, py38, py39, py310, pypy3
+envlist = packaging, pep8, py37, py38, py39, py310, pypy3
 
 [testenv]
 deps =
@@ -16,13 +16,13 @@ deps =
 commands = check-manifest
 
 [testenv:pep8]
-basepython = python3.6
+basepython = python3.7
 deps =
     flake8
 commands = flake8 .
 
 [testenv:black]
-basepython = python3.6
+basepython = python3.7
 deps =
-    black
+    black==21.9b0
 commands = python -m black --check --diff .


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 3c8180b..00de968 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -14,7 +14,7 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions/setup-python@v2
         with:
-          python-version: '3.6'
+          python-version: '3.7'
       - run: pip install tox
       - run: tox -e ${{ matrix.toxenv }}
 
@@ -22,7 +22,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy3']
+        python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.7']
 
     steps:
       - uses: actions/checkout@v2
diff --git a/test_canonicaljson.py b/test_canonicaljson.py
index 949e511..0e081c0 100644
--- a/test_canonicaljson.py
+++ b/test_canonicaljson.py
@@ -14,18 +14,18 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+
 from math import inf, nan
 
 from canonicaljson import (
     encode_canonical_json,
     encode_pretty_printed_json,
+    frozendict_type,
     iterencode_canonical_json,
     iterencode_pretty_printed_json,
     set_json_library,
 )
 
-from frozendict import frozendict
-
 import unittest
 from unittest import mock
 
@@ -108,13 +108,18 @@ class TestCanonicalJson(unittest.TestCase):
             b'{\n    "la merde amus\xc3\xa9e": "\xF0\x9F\x92\xA9"\n}',
         )
 
+    @unittest.skipIf(
+        frozendict_type is None,
+        "If `frozendict` is not available, skip test",
+    )
     def test_frozen_dict(self):
         self.assertEqual(
-            encode_canonical_json(frozendict({"a": 1})),
+            encode_canonical_json(frozendict_type({"a": 1})),
             b'{"a":1}',
         )
         self.assertEqual(
-            encode_pretty_printed_json(frozendict({"a": 1})), b'{\n    "a": 1\n}'
+            encode_pretty_printed_json(frozendict_type({"a": 1})),
+            b'{\n    "a": 1\n}',
         )
 
     def test_unknown_type(self):