Repository: jsonpickle/jsonpickle (python)

Issue:
Doesn't respect @property
Hey! When using ``jsonpickle.encode(Object)``, it doesn't serialize the methods marked with @property. For example:

```python
@property
def name(self):
    return 'John Smith'
```

Should yield the following json:
```json
{
  "name": "John Smith"
}

Pull request:
Move to CPython 3.7+
Also closes #297.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 606a7cd..0bcc7b8 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -10,6 +10,8 @@ v3.0.0
     * Drop support for CPython<3.7. CPython 3.6 and below have reached EOL
       and no longer receieve security updates. (#375)
     * Remove jsonlib and yajl backends (py2 only)
+    * Add ``include_properties`` option to the pickler. This should only
+      be used if analyzing generated json outside of Python. (#297) (+387)
 
 v2.2.0
 ======
diff --git a/Makefile b/Makefile
index caa6eec..614da8c 100644
--- a/Makefile
+++ b/Makefile
@@ -44,7 +44,7 @@ TOXCMD += --develop --skip-missing-interpreters
 ifdef multi
     TOXCMD += --parallel $(NUM_JOBS)
     TOXCMD += -e
-    TOXCMD += 'clean,py{27,36,37,38,39},py{27,38,39}-sa{12,13},py{27,38,39}-libs'
+    TOXCMD += 'clean,py{37,38,39,310},py{38,39,310}-sa{12,13},py{38,39,310}-libs'
     # Disable coverage when running in parallel
     TOXCMD += -- --no-cov
 endif
@@ -55,7 +55,7 @@ ifdef V
 endif
 
 # Coverage
-COVERAGE_ENV ?= py39
+COVERAGE_ENV ?= py310
 
 # Data
 ARTIFACTS := build
@@ -106,5 +106,5 @@ clean::
 .PHONY: clean
 
 format::
-	$(BLACK) --skip-string-normalization --target-version py27 $(PYTHON_DIRS)
+	$(BLACK) --skip-string-normalization --target-version py310 $(PYTHON_DIRS)
 .PHONY: format
diff --git a/jsonpickle/compat.py b/jsonpickle/compat.py
index 43e3744..320c64d 100644
--- a/jsonpickle/compat.py
+++ b/jsonpickle/compat.py
@@ -1,19 +1,14 @@
 from __future__ import absolute_import, division, unicode_literals
 
-import base64
+import queue  # noqa
 import sys
-import types
+from collections.abc import Iterator as abc_iterator  # noqa
 
 PY_MAJOR = sys.version_info[0]
 
 class_types = (type,)
 iterator_types = (type(iter('')),)
 
-import builtins
-import queue
-from base64 import decodebytes, encodebytes
-from collections.abc import Iterator as abc_iterator
-
 string_types = (str,)
 numeric_types = (int, float)
 ustr = str
diff --git a/jsonpickle/pickler.py b/jsonpickle/pickler.py
index 1b761f2..f7062f3 100644
--- a/jsonpickle/pickler.py
+++ b/jsonpickle/pickler.py
@@ -10,7 +10,6 @@ import decimal
 import inspect
 import itertools
 import sys
-import types
 import warnings
 from itertools import chain, islice
 
@@ -307,13 +306,6 @@ class Pickler(object):
             self.reset()
         return self._flatten(obj)
 
-    def _flatten_file(self, obj):
-        """
-        Special case file objects
-        """
-        assert not PY3 and isinstance(obj, types.FileType)
-        return None
-
     def _flatten_bytestring(self, obj):
         return {self._bytes_tag: self._bytes_encoder(obj)}
 
@@ -381,66 +373,94 @@ class Pickler(object):
 
         return data
 
-    def _get_flattener(self, obj):
-        if type(obj) in (list, dict):
-            if self._mkref(obj):
-                return (
-                    self._list_recurse if type(obj) is list else self._flatten_dict_obj
-                )
-            else:
-                self._push()
-                return self._getref
+    def _getstate(self, obj, data):
+        state = self._flatten(obj)
+        if self.unpicklable:
+            data[tags.STATE] = state
+        else:
+            data = state
+        return data
 
-        # We handle tuples and sets by encoding them in a "(tuple|set)dict"
-        elif type(obj) in (tuple, set):
-            if not self.unpicklable:
-                return self._list_recurse
-            return lambda obj: {
-                tags.TUPLE
-                if type(obj) is tuple
-                else tags.SET: [self._flatten(v) for v in obj]
-            }
+    def _flatten_key_value_pair(self, k, v, data):
+        """Flatten a key/value pair into the passed-in dictionary."""
+        if not util.is_picklable(k, v):
+            return data
 
-        elif util.is_object(obj):
-            return self._ref_obj_instance
+        if k is None:
+            k = 'null'  # for compatibility with common json encoders
 
-        elif util.is_type(obj):
-            return _mktyperef
+        if self.numeric_keys and isinstance(k, numeric_types):
+            pass
+        elif not isinstance(k, string_types):
+            try:
+                k = repr(k)
+            except Exception:
+                k = compat.ustr(k)
 
-        elif util.is_module_function(obj):
-            return self._flatten_function
+        data[k] = self._flatten(v)
+        return data
 
-        # instance methods, lambdas, old style classes...
-        self._pickle_warning(obj)
-        return None
+    def _flatten_obj_attrs(self, obj, attrs, data):
+        flatten = self._flatten_key_value_pair
+        ok = False
+        for k in attrs:
+            try:
+                if not k.startswith('__'):
+                    value = getattr(obj, k)
+                else:
+                    value = getattr(obj, f"_{obj.__class__.__name__}{k}")
+                flatten(k, value, data)
+            except AttributeError:
+                # The attribute may have been deleted
+                continue
+            ok = True
+        return ok
 
-    def _ref_obj_instance(self, obj):
-        """Reference an existing object or flatten if new"""
-        if self.unpicklable:
-            if self._mkref(obj):
-                # We've never seen this object so return its
-                # json representation.
-                return self._flatten_obj_instance(obj)
-            # We've seen this object before so place an object
-            # reference tag in the data. This avoids infinite recursion
-            # when processing cyclical objects.
-            return self._getref(obj)
-        else:
-            max_reached = self._max_reached()
-            in_cycle = _in_cycle(obj, self._objs, max_reached, False)
-            if in_cycle:
-                # A circular becomes None.
-                return None
+    def _flatten_properties(self, obj, data, allslots=None):
+        if allslots is None:
+            # setting a list as a default argument can lead to some weird errors
+            allslots = []
 
-            self._mkref(obj)
-            return self._flatten_obj_instance(obj)
+        # convert to set in case there are a lot of slots
+        allslots_set = set(itertools.chain.from_iterable(allslots))
+
+        # i don't like lambdas
+        def valid_property(x):
+            return not x[0].startswith("__") and x[0] not in allslots_set
+
+        properties = [
+            x[0] for x in inspect.getmembers(obj.__class__) if valid_property(x)
+        ]
+
+        properties_dict = {}
+        for p_name in properties:
+            p_val = getattr(obj, p_name)
+            if util.is_not_class(p_val):
+                properties_dict[p_name] = p_val
+            else:
+                properties_dict[p_name] = self._flatten(p_val)
+
+        data[tags.PROPERTY] = properties_dict
+
+        return data
+
+    def _flatten_newstyle_with_slots(self, obj, data):
+        """Return a json-friendly dict for new-style objects with __slots__."""
+        allslots = [
+            _wrap_string_slot(getattr(cls, '__slots__', tuple()))
+            for cls in obj.__class__.mro()
+        ]
+
+        # add properties to the attribute list
+        if self.include_properties:
+            data = self._flatten_properties(obj, data, allslots)
+
+        if not self._flatten_obj_attrs(obj, chain(*allslots), data):
+            attrs = [
+                x for x in dir(obj) if not x.startswith('__') and not x.endswith('__')
+            ]
+            self._flatten_obj_attrs(obj, attrs, data)
 
-    def _getstate(self, obj, data):
-        state = self._flatten(obj)
-        if self.unpicklable:
-            data[tags.STATE] = state
-        else:
-            data = state
         return data
 
     def _flatten_obj_instance(self, obj):
@@ -474,6 +494,9 @@ class Pickler(object):
 
         reduce_val = None
 
+        if self.include_properties:
+            data = self._flatten_properties(obj, data)
+
         if self.unpicklable:
             if has_reduce and not has_reduce_ex:
                 try:
@@ -608,6 +631,27 @@ class Pickler(object):
         self._pickle_warning(obj)
         return None
 
+    def _ref_obj_instance(self, obj):
+        """Reference an existing object or flatten if new"""
+        if self.unpicklable:
+            if self._mkref(obj):
+                # We've never seen this object so return its
+                # json representation.
+                return self._flatten_obj_instance(obj)
+            # We've seen this object before so place an object
+            # reference tag in the data. This avoids infinite recursion
+            # when processing cyclical objects.
+            return self._getref(obj)
+        else:
+            max_reached = self._max_reached()
+            in_cycle = _in_cycle(obj, self._objs, max_reached, False)
+            if in_cycle:
+                # A circular becomes None.
+                return None
+
+            self._mkref(obj)
+            return self._flatten_obj_instance(obj)
+
     def _escape_key(self, k):
         return tags.JSON_KEY + encode(
             k,
@@ -702,86 +746,38 @@ class Pickler(object):
 
         return data
 
-    def _flatten_obj_attrs(self, obj, attrs, data):
-        flatten = self._flatten_key_value_pair
-        ok = False
-        for k in attrs:
-            try:
-                if not k.startswith('__'):
-                    value = getattr(obj, k)
-                else:
-                    value = getattr(obj, f"_{obj.__class__.__name__}{k}")
-                flatten(k, value, data)
-            except AttributeError:
-                # The attribute may have been deleted
-                continue
-            ok = True
-        return ok
-
-    def _flatten_properties(self, obj, data, allslots=None):
-        if allslots is None:
-            # setting a list as a default argument can lead to some weird errors
-            allslots = []
-
-        properties = []
-
-        # convert to set in case there are a lot of slots
-        allslots_set = set(itertools.chain.from_iterable(allslots))
-
-        for cls in obj.__class__.mro():
-
-            # i don't like lambdas
-            def valid_property(x):
-                return not x[0].startswith("__") and x[0] not in allslots_set
-
-            # this could be a list comprehension but split it for readability
-            for cls in obj.__class__.mro():
-                properties += [
-                    x[0] for x in inspect.getmembers(cls) if valid_property(x)
-                ]
-
-        # deduplicate strings
-        data[tags.PROPERTY] = list(dict.fromkeys(properties))
-
-        return data
-
-    def _flatten_newstyle_with_slots(self, obj, data):
-        """Return a json-friendly dict for new-style objects with __slots__."""
-        allslots = [
-            _wrap_string_slot(getattr(cls, '__slots__', tuple()))
-            for cls in obj.__class__.mro()
-        ]
-
-        # add properties to the attribute list
-        if self.include_properties:
-            data = self._flatten_properties(obj, data, allslots)
-
-        if not self._flatten_obj_attrs(obj, chain(*allslots), data):
-            attrs = [
-                x for x in dir(obj) if not x.startswith('__') and not x.endswith('__')
-            ]
-            self._flatten_obj_attrs(obj, attrs, data)
+    def _get_flattener(self, obj):
+        if type(obj) in (list, dict):
+            if self._mkref(obj):
+                return (
+                    self._list_recurse if type(obj) is list else self._flatten_dict_obj
+                )
+            else:
+                self._push()
+                return self._getref
 
-        return data
+        # We handle tuples and sets by encoding them in a "(tuple|set)dict"
+        elif type(obj) in (tuple, set):
+            if not self.unpicklable:
+                return self._list_recurse
+            return lambda obj: {
+                tags.TUPLE
+                if type(obj) is tuple
+                else tags.SET: [self._flatten(v) for v in obj]
+            }
 
-    def _flatten_key_value_pair(self, k, v, data):
-        """Flatten a key/value pair into the passed-in dictionary."""
-        if not util.is_picklable(k, v):
-            return data
+        elif util.is_object(obj):
+            return self._ref_obj_instance
 
-        if k is None:
-            k = 'null'  # for compatibility with common json encoders
+        elif util.is_type(obj):
+            return _mktyperef
 
-        if self.numeric_keys and isinstance(k, numeric_types):
-            pass
-        elif not isinstance(k, string_types):
-            try:
-                k = repr(k)
-            except Exception:
-                k = compat.ustr(k)
+        elif util.is_module_function(obj):
+            return self._flatten_function
 
-        data[k] = self._flatten(v)
-        return data
+        # instance methods, lambdas, old style classes...
+        self._pickle_warning(obj)
+        return None
 
     def _flatten_sequence_obj(self, obj, data):
         """Return a json-friendly dict for a sequence subclass."""
diff --git a/jsonpickle/unpickler.py b/jsonpickle/unpickler.py
index 5bebcda..1bb3e69 100644
--- a/jsonpickle/unpickler.py
+++ b/jsonpickle/unpickler.py
@@ -808,6 +808,9 @@ class Unpickler(object):
                 self._namestack.pop()
         return data
 
+    def _restore_tuple(self, obj):
+        return tuple([self._restore(v) for v in obj[tags.TUPLE]])
+
     def _restore_tags(self, obj):
         try:
             if not tags.RESERVED <= set(obj) and not type(obj) in (list, dict):
@@ -855,6 +858,3 @@ class Unpickler(object):
                 return x
 
         return restore
-
-    def _restore_tuple(self, obj):
-        return tuple([self._restore(v) for v in obj[tags.TUPLE]])
diff --git a/jsonpickle/util.py b/jsonpickle/util.py
index 6e6f108..92ae3cd 100644
--- a/jsonpickle/util.py
+++ b/jsonpickle/util.py
@@ -20,13 +20,7 @@ import time
 import types
 
 from . import compat, tags
-from .compat import (
-    abc_iterator,
-    class_types,
-    iterator_types,
-    numeric_types,
-)
-
+from .compat import abc_iterator, class_types, iterator_types, numeric_types
 
 SEQUENCES = (list, set, tuple)
 SEQUENCES_SET = {list, set, tuple}
@@ -40,8 +34,6 @@ FUNCTION_TYPES = {
 }
 NON_REDUCIBLE_TYPES = (
     {
-        int,
-        float,
         list,
         dict,
         set,
@@ -52,6 +44,13 @@ NON_REDUCIBLE_TYPES = (
     | PRIMITIVES
     | FUNCTION_TYPES
 )
+NON_CLASS_TYPES = {
+    list,
+    dict,
+    set,
+    tuple,
+    bytes,
+} | PRIMITIVES
 
 
 def is_type(obj):
@@ -135,6 +134,13 @@ def is_object(obj):
     )
 
 
+def is_not_class(obj):
+    """Determines if the object is not a class or a class instance.
+    Used for serializing properties.
+    """
+    return type(obj) in NON_CLASS_TYPES
+
+
 def is_primitive(obj):
     """Helper method to see if the object is a basic data type. Unicode strings,
     integers, longs, floats, booleans, and None are considered primitive


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c8c4753..9ee8223 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,7 +11,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10"]
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
         os: [ubuntu-20.04]
         env:
           - TOXENV: libs
diff --git a/tests/jsonpickle_test.py b/tests/jsonpickle_test.py
index 7235755..a9b5f5d 100644
--- a/tests/jsonpickle_test.py
+++ b/tests/jsonpickle_test.py
@@ -31,7 +31,7 @@ class ListLike:
         return self.internal_list[i]
 
 
-class Thing(object):
+class Thing:
     def __init__(self, name):
         self.name = name
         self.child = None
@@ -39,14 +39,20 @@ class Thing(object):
     def __repr__(self):
         return 'Thing("%s")' % self.name
 
+    def __iter__(self):
+        for attr in [
+            x for x in getattr(self.__class__, "__dict__") if not x.startswith("__")
+        ]:
+            yield attr, getattr(self, attr)
+
 
-class Capture(object):
+class Capture:
     def __init__(self, *args, **kwargs):
         self.args = args
         self.kwargs = kwargs
 
 
-class ThingWithProps(object):
+class ThingWithProps:
     def _get_identity(self):
         keys = [self.dogs, self.monkies, self.name]
         return hash('-'.join([str(key) for key in keys]))
@@ -98,9 +104,9 @@ class UserDict(dict):
         self.valid = False
 
 
-class Outer(object):
-    class Middle(object):
-        class Inner(object):
+class Outer:
+    class Middle:
+        class Inner:
             pass
 
 
@@ -112,6 +118,52 @@ class MySlots:
         self.__beta = 1
 
 
+class MyPropertiesSlots:
+    __slots__ = ("alpha", "arr")
+
+    def __init__(self):
+        self.alpha = 1
+        self.arr = [1, 2, 3]
+
+    @property
+    def arr_len(self):
+        return len(self.arr)
+
+    @property
+    def other_object(self):
+        return Thing("Test")
+
+    def __eq__(self, other):
+        return (
+            self.alpha == other.alpha
+            and self.arr == other.arr
+            and self.arr_len == other.arr_len
+            and list(self.other_object) == list(other.other_object)
+        )
+
+
+class MyPropertiesDict:
+    def __init__(self):
+        self.alpha = 1
+        self.arr = [1, 2, 3]
+
+    @property
+    def arr_len(self):
+        return len(self.arr)
+
+    @property
+    def other_object(self):
+        return Thing("Test")
+
+    def __eq__(self, other):
+        return (
+            self.alpha == other.alpha
+            and self.arr == other.arr
+            and self.arr_len == other.arr_len
+            and list(self.other_object) == list(other.other_object)
+        )
+
+
 def on_missing_callback(class_name):
     # not actually a runtime problem but it doesn't matter
     warnings.warn("The unpickler couldn't find %s" % class_name, RuntimeWarning)
@@ -489,6 +541,18 @@ class PicklingTestCase(unittest.TestCase):
         beta = getattr(obj, "_" + obj.__class__.__name__ + "__beta", "(missing beta)")
         self.assertEqual(alpha, beta)
 
+    def test_include_properties_slots(self):
+        obj = MyPropertiesSlots()
+        dumped = jsonpickle.dumps(obj, include_properties=True)
+        self.assertTrue("py/property" in dumped)
+        self.assertEqual(obj, jsonpickle.loads(dumped))
+
+    def test_include_properties_dict(self):
+        obj = MyPropertiesDict()
+        dumped = jsonpickle.dumps(obj, include_properties=True)
+        self.assertTrue("py/property" in dumped)
+        self.assertEqual(obj, jsonpickle.loads(dumped))
+
 
 class JSONPickleTestCase(SkippableTest):
     def setUp(self):