chrisbouchard / easyrepr

Python decorator to automatically generate repr strings

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Make Ellipsis/None only expand once

chrisbouchard opened this issue · comments

I would like to make ... (Ellipsis) more useful. Currently, using ... in the attribute iterable expands to all public attributes (with an option to include private attributes). This isn't very useful, because once an attribute is included directly, e.g., to make it display first, we can no longer use ... because it will repeat the attribute.

This is going to be a breaking change one way or another.

I would like to make ... a little smarter. I can think of three reasonable alternatives. Consider the following class:

class Test:
    def __init__(self, a, b, c, d, e):
        # Omitted ...
    @easyrepr
    def __repr__(self):
        return ('a', 'c', ..., 'd', ...)

Currently, the repr would be:

>>> repr(Test(1, 2, 3, 4, 5))
"Test(a=1, c=3, a=1, b=2, c=3, d=4, e=5, d=4, a=1, b=2, c=3, d=4, e=5)"

Option 1: Expand eagerly but remember what's already included.

>>> repr(Test(1, 2, 3, 4, 5))
"Test(a=1, c=3, b=2, d=4, e=5, d=4)"
  1. First a and c are included, because they are mentioned directly.
  2. Then ... is expanded to all remaining public attributes that have not been included yet.
  3. Then 'd' is included again because it is mentioned directly.
  4. Then ... is expanded a second time but to an empty list because all attributes have been included already.

Pros:

  • Eager expansion is probably easier to implement. One pass keeping a set of names that we've already seen.
  • Eager expansion is potentially simpler conceptually.

Cons:

  • If an ancestor class includes a ..., extra attributes will always go there, since the attribute list is additive.
  • If an ancestor class includes a ..., all attributes known by descendant classes will be included there because they have not been included yet. (I only just thought of this, and it's probably a deal-breaker.)

Option 2a: Expand first ... after all attributes are accounted for

>>> repr(Test(1, 2, 3, 4, 5))
"Test(a=1, c=3, b=2, e=5, d=4)"
  1. First a and c are included, because they are mentioned directly.
  2. The first ... is encountered and left in-place.
  3. Then 'd' is included again because it is mentioned directly.
  4. The second ... is encountered and left in-place.
  5. A second pass is made. The first ... is expanded to the attributes that were not included at all.
  6. The second ... is removed.

Pros:

  • Ancestor classes can use ... without automatically including all attributes from descendant classes.

Cons:

  • Requires two passes.
  • No way for descendant class to add attributes before "the rest". (This may not actually be a con?)

Option 2b: Expand last ... after all attributes are accounted for

>>> repr(Test(1, 2, 3, 4, 5))
"Test(a=1, c=3, d=4, b=2, e=5)"
  1. First a and c are included, because they are mentioned directly.
  2. The first ... is encountered and left in-place.
  3. Then 'd' is included again because it is mentioned directly.
  4. The second ... is encountered and left in-place.
  5. A second pass is made in reverse. The second ... is expanded to the attributes that were not included at all.
  6. The first ... is removed.

Pros:

  • Ancestor classes can use ... without automatically including all attributes from descendant classes.
  • Descendant class can add attributes before "the rest". (This may not actually be a pro?)

Cons:

  • Requires two passes.

Assumptions and Notes

I'm making two assumptions in all of this. First, that the combined iterable of attributes from walking the MRO should be equivalent to a single iterable of all those attributes. That's why my example __repr__ has two ...s. Second, that named virtual attributes shouldn't affect ... at all, even if their name matches to an actual attribute.

Also, it's worth mentioning that making ... "class sensitive", that is, having it only include the attributes that "belong to" the class that contains that particular easyrepr method, is not possible as far as I know. I feel like the ideal solution would be for ... to expand to all unincluded attributes of the current class as we walk the MRO, but we can't actually match up attributes to the class that added them. Attributes are all thrown into a single __dict__, including attributes added outside __init__.

There are two exceptions to this:

  • If the classes include declarations, we could reasonably infer ownership from them, but it's not guaranteed.
  • If the classes use slots, we will know which class owns the attribute by the __slots__ properties. (We don't actually support slots yet, see #10.)

However, in both cases it would still be possible have attributes in __dict__ that are unaccounted for. So, we'd have special handling for some attributes and not others. I'd prefer to have a single algorithm that applies to all attributes.

Having written all this  — and being the only one I know actually using this library right now 😀 — I think option 2b is the most reasonable.