Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation of a faster repr #819

Merged
merged 17 commits into from May 26, 2021
1 change: 1 addition & 0 deletions changelog.d/819.changes.rst
@@ -0,0 +1 @@
The generated ``__repr__`` is significantly faster on Pythons with F-strings.
5 changes: 5 additions & 0 deletions src/attr/_compat.py
Expand Up @@ -8,6 +8,11 @@

PY2 = sys.version_info[0] == 2
PYPY = platform.python_implementation() == "PyPy"
HAS_F_STRINGS = (
sys.version_info[:2] >= (3, 7)
if not PYPY
else sys.version_info[:2] >= (3, 6)
)
PY310 = sys.version_info[:2] >= (3, 10)


Expand Down
175 changes: 124 additions & 51 deletions src/attr/_make.py
Expand Up @@ -12,6 +12,7 @@

from . import _config, setters
from ._compat import (
HAS_F_STRINGS,
PY2,
PY310,
PYPY,
Expand Down Expand Up @@ -888,7 +889,7 @@ def _create_slots_class(self):

def add_repr(self, ns):
self._cls_dict["__repr__"] = self._add_method_dunders(
_make_repr(self._attrs, ns=ns)
_make_repr(self._attrs, ns, self._cls)
)
return self

Expand Down Expand Up @@ -1873,64 +1874,136 @@ def _add_eq(cls, attrs=None):

_already_repring = threading.local()

if HAS_F_STRINGS:

def _make_repr(attrs, ns, cls):
unique_filename = "repr"
# Figure out which attributes to include, and which function to use to
# format them. The a.repr value can be either bool or a custom
# callable.
attr_names_with_reprs = tuple(
(a.name, (repr if a.repr is True else a.repr), a.init)
for a in attrs
if a.repr is not False
)
globs = {
name + "_repr": r
for name, r, _ in attr_names_with_reprs
if r != repr
}
globs["_already_repring"] = _already_repring
globs["AttributeError"] = AttributeError
globs["NOTHING"] = NOTHING
attribute_fragments = []
for name, r, i in attr_names_with_reprs:
accessor = (
"self." + name
if i
else 'getattr(self, "' + name + '", NOTHING)'
)
fragment = (
"%s={%s!r}" % (name, accessor)
if r == repr
else "%s={%s_repr(%s)}" % (name, name, accessor)
)
attribute_fragments.append(fragment)
repr_fragment = ", ".join(attribute_fragments)

def _make_repr(attrs, ns):
"""
Make a repr method that includes relevant *attrs*, adding *ns* to the full
name.
"""
if ns is None:
cls_name_fragment = (
'{self.__class__.__qualname__.rsplit(">.", 1)[-1]}'
)
else:
cls_name_fragment = ns + ".{self.__class__.__name__}"

lines = []
lines.append("def __repr__(self):")
lines.append(" try:")
lines.append(" working_set = _already_repring.working_set")
lines.append(" except AttributeError:")
lines.append(" working_set = {id(self),}")
lines.append(" _already_repring.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))")

# Figure out which attributes to include, and which function to use to
# format them. The a.repr value can be either bool or a custom callable.
attr_names_with_reprs = tuple(
(a.name, repr if a.repr is True else a.repr)
for a in attrs
if a.repr is not False
)
return _make_method(
"__repr__", "\n".join(lines), unique_filename, globs=globs
)

def __repr__(self):

else:

def _make_repr(attrs, ns, _):
"""
Automatically created by attrs.
Make a repr method that includes relevant *attrs*, adding *ns* to the
full name.
"""
try:
working_set = _already_repring.working_set
except AttributeError:
working_set = set()
_already_repring.working_set = working_set

if id(self) in working_set:
return "..."
real_cls = self.__class__
if ns is None:
qualname = getattr(real_cls, "__qualname__", None)
if qualname is not None:
class_name = qualname.rsplit(">.", 1)[-1]
else:
class_name = real_cls.__name__
else:
class_name = ns + "." + real_cls.__name__
# Figure out which attributes to include, and which function to use to
# format them. The a.repr value can be either bool or a custom
# callable.
attr_names_with_reprs = tuple(
(a.name, repr if a.repr is True else a.repr)
for a in attrs
if a.repr is not False
)

# Since 'self' remains on the stack (i.e.: strongly referenced) 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))
try:
result = [class_name, "("]
first = True
for name, attr_repr in attr_names_with_reprs:
if first:
first = False
def __repr__(self):
"""
Automatically created by attrs.
"""
try:
working_set = _already_repring.working_set
except AttributeError:
working_set = set()
_already_repring.working_set = working_set

if id(self) in working_set:
return "..."
real_cls = self.__class__
if ns is None:
qualname = getattr(real_cls, "__qualname__", None)
if qualname is not None: # pragma: no cover
# This case only happens on Python 3.5 and 3.6. We exclude
# it from coverage, because we don't want to slow down our
# test suite by running them under coverage too for this
# one line.
class_name = qualname.rsplit(">.", 1)[-1]
else:
result.append(", ")
result.extend(
(name, "=", attr_repr(getattr(self, name, NOTHING)))
)
return "".join(result) + ")"
finally:
working_set.remove(id(self))
class_name = real_cls.__name__
else:
class_name = ns + "." + real_cls.__name__

# Since 'self' remains on the stack (i.e.: strongly referenced)
# 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))
try:
result = [class_name, "("]
first = True
for name, attr_repr in attr_names_with_reprs:
if first:
first = False
else:
result.append(", ")
result.extend(
(name, "=", attr_repr(getattr(self, name, NOTHING)))
)
return "".join(result) + ")"
finally:
working_set.remove(id(self))

return __repr__
return __repr__


def _add_repr(cls, ns=None, attrs=None):
Expand All @@ -1940,7 +2013,7 @@ def _add_repr(cls, ns=None, attrs=None):
if attrs is None:
attrs = cls.__attrs_attrs__

cls.__repr__ = _make_repr(attrs, ns)
cls.__repr__ = _make_repr(attrs, ns, cls)
return cls


Expand Down