Skip to content

Commit

Permalink
Same content as rev b5c043b
Browse files Browse the repository at this point in the history
  • Loading branch information
thetorpedodog committed Nov 3, 2021
1 parent 035280e commit 1bca768
Show file tree
Hide file tree
Showing 4 changed files with 67 additions and 49 deletions.
2 changes: 2 additions & 0 deletions changelog.d/847.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Attrs classes are now fully compatible with cloudpickle.
`#847 <https://github.com/python-attrs/attrs/issues/847>`_
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
EXTRAS_REQUIRE = {
"docs": ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"],
"tests_no_zope": [
# Regression test to ensure cloudpickle compat doesn't break.
# For regression test to ensure cloudpickle compat doesn't break.
"cloudpickle",
# 5.0 introduced toml; parallel was broken until 5.0.2
"coverage[toml]>=5.0.2",
Expand Down
109 changes: 61 additions & 48 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,21 @@

from operator import itemgetter

# We need to import _compat itself in addition to the _compat members to avoid
# having the thread-local in the globals here.
from . import _compat, _config, setters
from ._compat import (
HAS_F_STRINGS,
PY2,
PY310,
PYPY,
isclass,
iteritems,
metadata_proxy,
new_class,
ordered_dict,
set_closure_cell,
)
from .exceptions import (
DefaultAlreadySetError,
FrozenInstanceError,
Expand All @@ -18,7 +32,7 @@
)


if not _compat.PY2:
if not PY2:
import typing


Expand All @@ -40,7 +54,7 @@
# (when slots=True)
_hash_cache_field = "_attrs_cached_hash"

_empty_metadata_singleton = _compat.metadata_proxy({})
_empty_metadata_singleton = metadata_proxy({})

# Unique object for unequivocal getattr() defaults.
_sentinel = object()
Expand Down Expand Up @@ -90,7 +104,7 @@ class _CacheHashWrapper(int):
See GH #613 for more details.
"""

if _compat.PY2:
if PY2:
# For some reason `type(None)` isn't callable in Python 2, but we don't
# actually need a constructor for None objects, we just need any
# available function that returns None.
Expand Down Expand Up @@ -508,9 +522,9 @@ def _transform_attrs(
anns = _get_annotations(cls)

if these is not None:
ca_list = [(name, ca) for name, ca in _compat.iteritems(these)]
ca_list = [(name, ca) for name, ca in iteritems(these)]

if not isinstance(these, _compat.ordered_dict):
if not isinstance(these, ordered_dict):
ca_list.sort(key=_counter_getter)
elif auto_attribs is True:
ca_names = {
Expand Down Expand Up @@ -600,7 +614,7 @@ def _transform_attrs(
return _Attributes((AttrsClass(attrs), base_attrs, base_attr_map))


if _compat.PYPY:
if PYPY:

def _frozen_setattrs(self, name, value):
"""
Expand Down Expand Up @@ -782,7 +796,7 @@ def _create_slots_class(self):
"""
cd = {
k: v
for k, v in _compat.iteritems(self._cls_dict)
for k, v in iteritems(self._cls_dict)
if k not in tuple(self._attr_names) + ("__dict__", "__weakref__")
}

Expand Down Expand Up @@ -837,7 +851,7 @@ def _create_slots_class(self):
# we collect them here and update the class dict
reused_slots = {
slot: slot_descriptor
for slot, slot_descriptor in _compat.iteritems(existing_slots)
for slot, slot_descriptor in iteritems(existing_slots)
if slot in slot_names
}
slot_names = [name for name in slot_names if name not in reused_slots]
Expand Down Expand Up @@ -880,7 +894,7 @@ def _create_slots_class(self):
pass
else:
if match:
_compat.set_closure_cell(cell, cls)
set_closure_cell(cell, cls)

return cls

Expand Down Expand Up @@ -1462,7 +1476,7 @@ def attrs(
.. versionchanged:: 21.1.0 *cmp* undeprecated
.. versionadded:: 21.3.0 *match_args*
"""
if auto_detect and _compat.PY2:
if auto_detect and PY2:
raise PythonTooOldError(
"auto_detect only works on Python 3 and later."
)
Expand Down Expand Up @@ -1578,7 +1592,7 @@ def wrap(cls):
)

if (
_compat.PY310
PY310
and match_args
and not _has_own_attribute(cls, "__match_args__")
):
Expand All @@ -1601,7 +1615,7 @@ def wrap(cls):
"""


if _compat.PY2:
if PY2:

def _has_frozen_base_class(cls):
"""
Expand Down Expand Up @@ -1653,7 +1667,7 @@ def _make_hash(cls, attrs, frozen, cache_hash):
if not cache_hash:
hash_def += "):"
else:
if not _compat.PY2:
if not PY2:
hash_def += ", *"

hash_def += (
Expand Down Expand Up @@ -1851,7 +1865,7 @@ def _add_eq(cls, attrs=None):
return cls


if _compat.HAS_F_STRINGS:
if HAS_F_STRINGS:

def _make_repr(attrs, ns, cls):
unique_filename = "repr"
Expand Down Expand Up @@ -1893,24 +1907,23 @@ def _make_repr(attrs, ns, cls):
else:
cls_name_fragment = ns + ".{self.__class__.__name__}"

lines = []
lines.append("def __repr__(self):")
lines.append(" try:")
lines.append(" working_set = _compat.repr_context.working_set")
lines.append(" except AttributeError:")
lines.append(" working_set = {id(self),}")
lines.append(" _compat.repr_context.working_set = working_set")
lines.append(" else:")
lines.append(" if id(self) in working_set:")
lines.append(" return '...'")
lines.append(" else:")
lines.append(" working_set.add(id(self))")
lines.append(" try:")
lines.append(
" return f'%s(%s)'" % (cls_name_fragment, repr_fragment)
)
lines.append(" finally:")
lines.append(" working_set.remove(id(self))")
lines = [
"def __repr__(self):",
" try:",
" already_repring = _compat.repr_context.already_repring",
" except AttributeError:",
" already_repring = {id(self),}",
" _compat.repr_context.already_repring = already_repring",
" else:",
" if id(self) in already_repring:",
" return '...'",
" else:",
" already_repring.add(id(self))",
" try:",
" return f'%s(%s)'" % (cls_name_fragment, repr_fragment),
" finally:",
" already_repring.remove(id(self))",
]

return _make_method(
"__repr__", "\n".join(lines), unique_filename, globs=globs
Expand Down Expand Up @@ -1939,12 +1952,12 @@ def __repr__(self):
Automatically created by attrs.
"""
try:
working_set = _compat.repr_context.working_set
already_repring = _compat.repr_context.already_repring
except AttributeError:
working_set = set()
_compat.repr_context.working_set = working_set
already_repring = set()
_compat.repr_context.already_repring = already_repring

if id(self) in working_set:
if id(self) in already_repring:
return "..."
real_cls = self.__class__
if ns is None:
Expand All @@ -1964,7 +1977,7 @@ def __repr__(self):
# for the duration of this call, it's safe to depend on id(...)
# stability, and not need to track the instance and therefore
# worry about properties like weakref- or hash-ability.
working_set.add(id(self))
already_repring.add(id(self))
try:
result = [class_name, "("]
first = True
Expand All @@ -1978,7 +1991,7 @@ def __repr__(self):
)
return "".join(result) + ")"
finally:
working_set.remove(id(self))
already_repring.remove(id(self))

return __repr__

Expand Down Expand Up @@ -2012,7 +2025,7 @@ def fields(cls):
.. versionchanged:: 16.2.0 Returned tuple allows accessing the fields
by name.
"""
if not _compat.isclass(cls):
if not isclass(cls):
raise TypeError("Passed object must be a class.")
attrs = getattr(cls, "__attrs_attrs__", None)
if attrs is None:
Expand Down Expand Up @@ -2040,14 +2053,14 @@ def fields_dict(cls):
.. versionadded:: 18.1.0
"""
if not _compat.isclass(cls):
if not isclass(cls):
raise TypeError("Passed object must be a class.")
attrs = getattr(cls, "__attrs_attrs__", None)
if attrs is None:
raise NotAnAttrsClassError(
"{cls!r} is not an attrs-decorated class.".format(cls=cls)
)
return _compat.ordered_dict(((a.name, a) for a in attrs))
return ordered_dict(((a.name, a) for a in attrs))


def validate(inst):
Expand Down Expand Up @@ -2200,7 +2213,7 @@ def _assign_with_converter(attr_name, value_var, has_on_setattr):
)


if _compat.PY2:
if PY2:

def _unpack_kw_only_py2(attr_name, default=None):
"""
Expand Down Expand Up @@ -2474,7 +2487,7 @@ def fmt_setter_with_converter(
if a.init is True:
if a.type is not None and a.converter is None:
annotations[arg_name] = a.type
elif a.converter is not None and not _compat.PY2:
elif a.converter is not None and not PY2:
# Try to get the type from the converter.
sig = None
try:
Expand Down Expand Up @@ -2531,7 +2544,7 @@ def fmt_setter_with_converter(

args = ", ".join(args)
if kw_only_args:
if _compat.PY2:
if PY2:
lines = _unpack_kw_only_lines_py2(kw_only_args) + lines

args += "%s**_kw_only" % (", " if args else "",) # leading comma
Expand Down Expand Up @@ -2650,7 +2663,7 @@ def __init__(
bound_setattr(
"metadata",
(
_compat.metadata_proxy(metadata)
metadata_proxy(metadata)
if metadata
else _empty_metadata_singleton
),
Expand Down Expand Up @@ -2745,7 +2758,7 @@ def _setattrs(self, name_values_pairs):
else:
bound_setattr(
name,
_compat.metadata_proxy(value)
metadata_proxy(value)
if value
else _empty_metadata_singleton,
)
Expand Down Expand Up @@ -3021,7 +3034,7 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
if user_init is not None:
body["__init__"] = user_init

type_ = _compat.new_class(name, bases, {}, lambda ns: ns.update(body))
type_ = new_class(name, bases, {}, lambda ns: ns.update(body))

# For pickling to work, the __module__ variable needs to be set to the
# frame where the class is created. Bypass this step in environments where
Expand Down Expand Up @@ -3108,7 +3121,7 @@ def pipe_converter(val):

return val

if not _compat.PY2:
if not PY2:
if not converters:
# If the converter list is empty, pipe_converter is the identity.
A = typing.TypeVar("A")
Expand Down
3 changes: 3 additions & 0 deletions tests/test_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class TestCloudpickleCompat(object):

@given(simple_classes())
def test_repr(self, cls):
"""
attrs instances can be pickled and un-pickled with cloudpickle.
"""
inst = cls()
# Exact values aren't a concern so long as neither direction
# raises an exception.
Expand Down

0 comments on commit 1bca768

Please sign in to comment.