11
\$\begingroup\$

Preface

At some point I was tired of writing & supporting __repr__ methods, so I've decided to write it once and reuse everywhere in my classes.

Since I'm trying to write classes as simple as possible, most of them ends in something like

class MyClass:
     def __init__(self, param, other_param, ...):
         self.param = param
         self.other_param = other_param
         ...

i.e. initializer parameters names corresponds to fields names, so we can get initializer arguments by simply getattr'ing corresponding fields.

Main ideas are:

  1. I want to have informative string which includes parameters involved in instance creation. For simple cases it should be possible to copy string & paste in some place (e.g. REPL session) and have similar object definition with as less work as possible. This helps a lot during debugging sessions, logging, especially in failed test cases with randomly generated data.

    Great examples are Counter & OrderedDict from collections standard library:

    >>> from collections import Counter
    >>> Counter(range(2))
    Counter({0: 1, 1: 1})
    >>> Counter(range(2)) == Counter({0: 1, 1: 1})
    True
    >>> from collections import OrderedDict
    >>> OrderedDict.fromkeys(range(2))
    OrderedDict([(0, None), (1, None)])
    >>> OrderedDict.fromkeys(range(2)) == OrderedDict([(0, None), (1, None)])
    True
    
  2. Once signature change, __repr__ should handle this automatically for simple cases like renaming/removing/changing order of parameters.

Attempt

Note: following code requires Python3.5+ due to yield from statement

import inspect
from collections import (OrderedDict,
                         abc)
from typing import (Any,
                    Callable,
                    Iterable,
                    TypeVar,
                    Union)

Domain = TypeVar('Domain')
Range = TypeVar('Range')
Map = Callable[[Domain], Range]

Constructor = Callable[..., Domain]
Initializer = Callable[..., None]


def generate_repr(constructor_or_initializer: Union[Constructor, Initializer],
                  *,
                  field_seeker: Callable[[Domain, str], Any] = getattr
                  ) -> Map[Domain, str]:
    signature = inspect.signature(constructor_or_initializer)
    parameters = OrderedDict(signature.parameters)
    # remove `self`
    parameters.popitem(0)
    to_positional_argument_string = repr
    to_keyword_argument_string = '{}={!r}'.format

    def __repr__(self: Domain) -> str:
        return (type(self).__qualname__
                + '(' + ', '.join(to_arguments_strings(self)) + ')')

    def to_arguments_strings(object_: Domain) -> Iterable[str]:
        for parameter_name, parameter in parameters.items():
            field = field_seeker(object_, parameter_name)
            if parameter.kind == inspect._VAR_POSITIONAL:
                if isinstance(field, abc.Iterator):
                    yield '...'
                else:
                    yield from map(to_positional_argument_string, field)
            elif parameter.kind == inspect._VAR_KEYWORD:
                yield from map(to_keyword_argument_string,
                               field.keys(), field.values())
            elif parameter.kind in {inspect._POSITIONAL_ONLY,
                                    inspect._POSITIONAL_OR_KEYWORD}:
                yield to_positional_argument_string(field)
            else:
                yield to_keyword_argument_string(parameter_name, field)

    return __repr__

Test

Let's define our class like

class A:
    def __init__(self, positional, *variadic_positional, keyword_only,
                 **variadic_keyword):
        self.positional = positional
        self.variadic_positional = variadic_positional
        self.keyword_only = keyword_only
        self.variadic_keyword = variadic_keyword

    __repr__ = generate_repr(__init__)

After that

>>> A(1, 2, 3, keyword_only='some', a={'sample': 42}, b={1, 2})
A(1, 2, 3, keyword_only='some', b={1, 2}, a={'sample': 42})
\$\endgroup\$
1

3 Answers 3

1
\$\begingroup\$

Honestly it looks like most of your code can't be simplified. However I found:

  1. to_positional_argument_string is a mouth full, and people would understand your code easier if you just use repr.
  2. to_keyword_argument_string is another mouth full and so you could call it kw_repr.
  3. You may want to add a comment explaining what isinstance(field, abc.Iterator) is for. This is because at first it confused me.
  4. [Controversial] You can change your code so that you don't need to be passed constructor_or_initializer. To do this you'd create parameters when __repr__ is first used.
\$\endgroup\$
4
  • \$\begingroup\$ about self: this is incorrect, because for __new__ (which can be used for immutable types) first parameter is cls, not self \$\endgroup\$ Commented Feb 20, 2019 at 15:30
  • \$\begingroup\$ about "major unusable bug": this can be simply handled by passing custom field_seeker, no need in extra mapping parameter \$\endgroup\$ Commented Feb 20, 2019 at 15:30
  • \$\begingroup\$ @AzatIbrakov It can actually be anything you want it to be, but yes you're correct in that case. \$\endgroup\$
    – Peilonrayz
    Commented Feb 20, 2019 at 15:32
  • \$\begingroup\$ @AzatIbrakov Ah yes it can. That would make field_seeker quite unsightly imo. \$\endgroup\$
    – Peilonrayz
    Commented Feb 20, 2019 at 15:35
0
\$\begingroup\$

I don't know about you but I prefer decorators. I have two solutions:

  1. for any immutable.
  2. (similar to yours except there is is no yield from or other wizardry :o) for any objects that respect your convention:
def __init__(self, <args>): self.<args> = <args>

The thing is (in my mind at least), that if you make a module, we don't care how complicated it is on the inside, but what matters is how easy it is to use it. Which is why I prefer decorators, they are non-intrusive by nature.

custom_module1.py: (for any immutable)

def repr_maker(cls):
    class Representer(cls):
        def __init__(self, *args, **kwargs):
            self._args = args
            self._kwargs = kwargs

        def __repr__(self):
            return cls.__name__ + '(' + ', '.join(
                [repr(i) for i in self._args]
                + [i + '=' + repr(j) for i, j in self._kwargs.items()]
            ) + ')'
    return Representer

custom_module2.py: (for any object that respect your convention)

def repr_maker(cls):
    def represent(self):
        params = list(inspect.signature(cls.__init__).parameters.items())
        return cls.__name__ + '(' + ', '.join(
            [repr(getattr(self, name))
             for name, param in params
             if (param.kind.name == 'POSITIONAL_OR_KEYWORD'
                 and name != 'self')]
            + [', '.join(repr(value) for value in getattr(self, name))
               for name, param in params
               if param.kind.name == 'VAR_POSITIONAL']
            + [name + '=' + repr(getattr(self, name))
               for name, param in params
               if param.kind.name == 'KEYWORD_ONLY']
            + [', '.join(kw + '=' + repr(value)
                         for kw, value in getattr(self, name).items())
               for name, param in params
               if param.kind.name == 'VAR_KEYWORD']
        ) + ')'
    cls.__repr__ = represent
    return cls

my_program.py

from custom_module import repr_maker

@repr_maker
class A:
    def __init__(self, positional, *variadic_positional, keyword_only,
                 **variadic_keyword):
        self.positional = positional
        self.variadic_positional = variadic_positional
        self.keyword_only = keyword_only
        self.variadic_keyword = variadic_keyword


print(repr(A(1, 2, 3, 4, 5, keyword_only=10, babar="le plus beau des éléphants")))
# A(1, 2, 3, 4, 5, keyword_only=10, babar='le plus beau des éléphants')

It is clean, reusable and does not pollute the actual code with extra stuff.

\$\endgroup\$
5
  • 1
    \$\begingroup\$ As pointed out in the comments to my answer this is intended to work with __new__ too. Yours, currently, only works with __init__. \$\endgroup\$
    – Peilonrayz
    Commented Feb 20, 2019 at 18:24
  • \$\begingroup\$ I am not familiar with __new__ but it seems to me that it would be the same thing. \$\endgroup\$
    – Benoît P
    Commented Feb 20, 2019 at 20:02
  • \$\begingroup\$ I edited my function, this looks like the simplest solution, you could also pass an argument to the decorator but then you have to nest functions even more. \$\endgroup\$
    – Benoît P
    Commented Feb 20, 2019 at 20:31
  • \$\begingroup\$ Just to note __new__ can be created without the args and kwargs parameters. And still be the intended target. Infact, as pointed out in A those are just standard names, which can be replaced with anything. I'd also like to point out, that this deviates quite a bit from the question in that there is no field_seeker. If you look at my edits you'll see it's a rather useful aspect. \$\endgroup\$
    – Peilonrayz
    Commented Feb 20, 2019 at 20:39
  • 1
    \$\begingroup\$ Yes, 'args' and 'kwargs' are the default, meaning that the only way it has changed is if you overload __new__. I will agree this is ugly, I will revert it and let's let leave this answer there. I stay convinced that decorators are the way to go here even if my particular implementation didn't meet your needs. \$\endgroup\$
    – Benoît P
    Commented Feb 20, 2019 at 21:02
0
\$\begingroup\$

Since I'm trying to write classes as simple as possible, most of them ends in something like [...] initializer parameters names corresponds to fields names, so we can get initializer arguments by simply getattr'ing corresponding fields.

This feels exactly like what dataclasses were added for. The advantage for your use case is that support for __repr__ is built-in.

\$\endgroup\$
2
  • \$\begingroup\$ there is no dataclasses module in Python3.5 (and can't be due to variable annotations syntax support), which is my main option now \$\endgroup\$ Commented Feb 22, 2019 at 12:44
  • \$\begingroup\$ @AzatIbrakov Since you mentionned Python 3.5+, I thought I’d give it a try. Didn't know it was 3.5 exactly. \$\endgroup\$ Commented Feb 22, 2019 at 12:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.