Include a helper for writing `__repr__` in the standard library

I have wanted this a few times and I’m curious whether others would find this useful.

For this class:

class Point:
    def __init__(self, x, y, z, color):
        self.x, self.y, self.z = x, y, z
        self.color = color

    def __repr__(self):
        return f"{type(self).__name__}({self.x!r}, {self.y!r}, {self.z!r}, color={self.color!r})"

I’m imagining replacing the __repr__ method with this:

from reprlib import make_repr

class Point:
    def __init__(self, x, y, z, color):
        self.x, self.y, self.z = x, y, z
        self.color = color

    def __repr__(self):
        return make_repr(self, self.x, self.y, self.z, color=self.color)

This would result in this repr:

>>> Point(1, 2, 3, color="purple")
Point(1, 2, 3, color='purple')

The implementation would be something like this:

def make_repr(obj, *args, **kwargs):
    arg_strings = (repr(a) for a in args)
    kwarg_strings = (
        f"{name}={value!r}"
        for name, value in kwargs.items()
    )
    combined_argument_string = ", ".join([*arg_strings, *kwarg_strings])
    return f"{type(obj).__name__}({combined_argument_string})"

Live version

Some arguments I can imagine against this:

  • This is so simple that it doesn’t belong in the standard library
  • Why not just use a dataclass (my response is “sometimes I can’t” and “note the positional arguments”)
  • A feature to allow more easily customizing dataclasses to show positional arguments in a string representation would be better
  • There’s no one obvious implementation for this and endless bike shedding might ensue: decorator implementation, returning just the comma-delimited arguments, accepting the type instead of the object, how arguments should be passed, etc.

I searched past ideas and was surprised that I didn’t find any similar ones. I assume I overlooked some past discussion, so please feel free to link it if you find one.

9 Likes
2 Likes

I’d forgotten about this PEP.

If that PEP were accepted, this could become something like:

from reprlib import make_repr

class Point:
    def __init__(self, x, y, z, color):
        self.x, self.y, self.z = x, y, z
        self.color = color

    def __pprint__(self):
        yield self.x
        yield self.y
        yield self.z
        yield "color", self.color

    def __repr__(self):
        return make_repr(self)

And make_repr would be something like:

def make_repr(obj):
    arguments = []
    for item in obj.__pprint__():
        match item:
            case tuple((name, value, default)) if value == default:
                continue
            case tuple((name, value)) | tuple((name, value, _)) if name:
                arguments.append(f"{name}={value!r}")
            case tuple((_, value)):
                arguments.append(repr(value))
            case value:
                arguments.append(repr(value))
    return f"{type(obj).__name__}({', '.join(arguments)})"
1 Like

Yes, useful idea.
I’ve got something similar in my standard utilities module.
Mine takes the type as first argument (your last bullet) and is named call_repr. Thus it can also take functions as argument, making it useful in e.g. logging and error messages.

1 Like

I don’t really think your initial example saves enough over the handwritten version? I wouldn’t go out of my way to use this function over writing the few extra characters by hand for something like this.

However, I have thought of something similar but in the context of making a replacement for how dataclasses currently generates its methods. The goal being to generate the methods lazily or more optimally[1] and in a way that can be shared by other tools that need to generate essentially identical __repr__, __eq__ and similar methods to dataclasses.

The intent here wouldn’t be to use them directly on individual classes but you could, and doing so for something equivalent to your example would look like this:

class Point:
    def __init__(self, x, y, z, color): ...
    __repr__ = auto_repr(fields=["color"], positional_fields=["x", "y", "z"])

Which is also fairly long in this case, but you could use something like this to create a __repr__ based on the __init__ signature, or based on __slots__ or any other place that may already have the list of field names you want to use in the __repr__.


  1. Dataclasses currently spends the majority of its time creating classes in generating these methods, many of which are never used at runtime and many of which are essentially identical between classes with the same number of fields. ↩︎

1 Like

For a lot of these cases, you should probably just use a daraclass

I have written a base class AutoInitRepr that uses __init_subclass__ to prepend __init__ with code that generates a _repr_str when __init__ is called. Tests included.

code
#!/usr/bin/env python3

import functools
import inspect
import unittest

class AutoInitRepr:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        original_init = cls.__init__

        @functools.wraps(original_init)
        def wrapped_init(self, *init_args, **init_kwargs):
            sig = inspect.signature(original_init)

            bound = sig.bind_partial(self, *init_args, **init_kwargs)

            args_reprs = []

            # walk through signature parameters in the order they are defined
            for name, param in sig.parameters.items():
                if name == "self":
                    continue

                if name in bound.arguments:
                    # The parameter was explicitly passed by the caller

                    value = bound.arguments[name]

                    if param.kind == inspect.Parameter.POSITIONAL_ONLY:
                        args_reprs.append(repr(value))

                    elif param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
                        # XXX POI-1: Should we 'canonicalize', ie look at
                        # whether the signature uses a '=' or not, regardless
                        # of whether the user used a '=' or not?
                        if name not in init_kwargs:
                            # passed as arg
                            args_reprs.append(repr(value))
                        else:
                            # passed as kwarg
                            args_reprs.append(f"{name}={repr(value)}")

                    elif param.kind == inspect.Parameter.KEYWORD_ONLY:
                        args_reprs.append(f"{name}={repr(value)}")

                    elif param.kind == inspect.Parameter.VAR_POSITIONAL:
                        # **args
                        # `value` is a tuple of the args
                        for arg in value:
                            args_reprs.append(repr(arg))

                    elif param.kind == inspect.Parameter.VAR_KEYWORD:
                        # **kwargs
                        # `value` is a dict of the kwargs
                        for k, v in value.items():
                            args_reprs.append(f"{k}={repr(v)}")

                    else:
                        # should never happen right?
                        raise NotImplementedError("Unknown parameter type")
                else:
                    # The parameter was omitted by the caller

                    if param.kind == inspect.Parameter.POSITIONAL_ONLY:
                        # Missing positional-only; `__init__` will fail
                        # I think it's fair to add a default of `!` to
                        # indicate 'missing' or something, rather than
                        # refusing to set `_repr_str`, incase an exception
                        # is taken care of and `_repr_str` is expected,
                        # even if borked.
                        args_reprs.append(f"!")

                    elif param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
                        if param.default == inspect.Parameter.empty:
                            # Missing positional. Same comment as above.
                            args_reprs.append(f"!")
                        else:
                            # Missing kwarg with default.
                            # XXX POI-2: Should we even add this, as the default was not
                            # explicitly passed by the user? It makes the repr str
                            # bloated with defaults that are already set.
                            args_reprs.append(f"{name}={repr(param.default)}")

                    elif param.kind == inspect.Parameter.KEYWORD_ONLY:
                        if param.default == inspect.Parameter.empty:
                            # Same comment as above. Keyword-only parameter
                            # without a default is missing, but we should
                            # fill it anyways incase __init__'s exception is caught.
                            args_reprs.append(f"{name}=!")
                        else:
                            # XXX see POI-2.
                            args_reprs.append(f"{name}={repr(param.default)}")

            self._repr_str = f"{cls.__name__}({', '.join(args_reprs)})"
            original_init(self, *init_args, **init_kwargs)

        cls.__init__ = wrapped_init

    def __repr__(self) -> str:
        return getattr(self, '_repr_str', f"{self.__class__.__name__}()")

# Testing

class Foo(AutoInitRepr):
    def __init__(self, x, /, y, z=None):
        pass

class TestFoo(unittest.TestCase):
    # Positional-only `x` passed as positional

    ## test_1: Positional-or-keyword-no-default `y` passed as positional
    def test_1(self):
        ### Positional-or-keyword-yes-default `z` default to 'None'
        self.assertEqual(repr(Foo(6, 7)), "Foo(6, 7, z=None)")

        ### Positional-or-keyword-yes-default `z` passed as positional
        self.assertEqual(repr(Foo(6, 7, 8)), "Foo(6, 7, 8)")

        ### Positional-or-keyword-yes-default `z` passed as keyword
        self.assertEqual(repr(Foo(6, 7, z = 8)), "Foo(6, 7, z=8)")

    ## test_2: Positional-or-keyword-no-default `y` passed as keyword
    def test_2(self):
        ### Positional-or-keyword-yes-default `z` default to 'None'
        self.assertEqual(repr(Foo(6, y = 7)), "Foo(6, y=7, z=None)")

        ### Positional-or-keyword-yes-default `z` passed as positional
        ### is not possible: cannot have positional after keyword

        ### Positional-or-keyword-yes-default `z` passed as keyword
        self.assertEqual(repr(Foo(6, y = 7, z = 8)), "Foo(6, y=7, z=8)")

class Bar(AutoInitRepr):
    def __init__(self, *args, name, age=40, **kwargs):
        pass

class TestBar(unittest.TestCase):
    # Keyword-only-no-default `name` passed as keyword

    ## test_1*: Keyword-only-yes-default `age` default to '40'
    def test_1A(self):
        ### test_1A: No *args

        #### No **kwargs
        self.assertEqual(repr(Bar(name = "Alice")), "Bar(name='Alice', age=40)")

        #### Some **kwargs after `name`
        self.assertEqual(repr(Bar(name = "Alice", alive = True)), "Bar(name='Alice', age=40, alive=True)")

        #### Some **kwargs before `name`
        self.assertEqual(repr(Bar(alive = True, name = "Alice")), "Bar(name='Alice', age=40, alive=True)")

    def test_1B(self):
        ### test_1B: Some *args

        #### No **kwargs
        self.assertEqual(repr(Bar(1,       name = "Alice")), "Bar(1, name='Alice', age=40)")
        self.assertEqual(repr(Bar(1, 2,    name = "Alice")), "Bar(1, 2, name='Alice', age=40)")
        self.assertEqual(repr(Bar(1, 2, 3, name = "Alice")), "Bar(1, 2, 3, name='Alice', age=40)")

        #### Some **kwargs after `name`
        self.assertEqual(repr(Bar(4, 5, 6, name = "Alice", alive = True)), "Bar(4, 5, 6, name='Alice', age=40, alive=True)")

        #### Some **kwargs before `name`
        self.assertEqual(repr(Bar(4, 5, 6, alive = True, name = "Alice")), "Bar(4, 5, 6, name='Alice', age=40, alive=True)")

    ## test_2*: Keyword-only-yes-default `age` passed as keyword
    def test_2A(self):
        ### test_2A: No *args

        #### No **kwargs
        ##### All permutations of ordering for passed:
        ##### keyword-only-no-default `name` and keyword-only-yes-default `age`
        self.assertEqual(repr(Bar(name = "Beatrice", age = 32)), "Bar(name='Beatrice', age=32)")
        self.assertEqual(repr(Bar(age = 32, name = "Beatrice")), "Bar(name='Beatrice', age=32)")

        #### Some **kwargs
        ##### All permutations of ordering for passed:
        ##### keyword-only-no-default `name` and keyword-only-yes-default `age` and kwarg `alive`
        self.assertEqual(repr(Bar(name = "Beatrice", age = 32, alive = True)), "Bar(name='Beatrice', age=32, alive=True)")
        self.assertEqual(repr(Bar(age = 32, name = "Beatrice", alive = True)), "Bar(name='Beatrice', age=32, alive=True)")
        self.assertEqual(repr(Bar(alive = True, age = 32, name = "Beatrice")), "Bar(name='Beatrice', age=32, alive=True)")
        self.assertEqual(repr(Bar(age = 32, alive = True, name = "Beatrice")), "Bar(name='Beatrice', age=32, alive=True)")
        self.assertEqual(repr(Bar(name = "Beatrice", alive = True, age = 32)), "Bar(name='Beatrice', age=32, alive=True)")
        self.assertEqual(repr(Bar(alive = True, name = "Beatrice", age = 32)), "Bar(name='Beatrice', age=32, alive=True)")

    def test_2B(self):
        ### test_2B: Some *args
        ### XXX bored at this point; tests are probably not exhausted here
        self.assertEqual(repr(Bar(4, name = "Beatrice", okay = "yes")), "Bar(4, name='Beatrice', age=40, okay='yes')")

class Baz(AutoInitRepr):
    # Tried to be exotic with Baz's signature
    def __init__(self, v, *args, lorem, dolor="sit", **kwargs):
        pass

class TestBaz(unittest.TestCase):
    # Keyword-only-no-default `lorem` passed as keyword

    def test_all(self):
        # Positional-or-keyword-no-default `v` passed as positional
        self.assertEqual(repr(Baz("book", 7, 8, 9, lorem="ipsum")), "Baz('book', 7, 8, 9, lorem='ipsum', dolor='sit')")

        # Positional-or-keyword-no-default `v` passed as keyword before empty *args before `lorem`
        self.assertEqual(repr(Baz(v="book", lorem="ipsum")), "Baz(v='book', lorem='ipsum', dolor='sit')")

        # Positional-or-keyword-no-default `v` passed as keyword after `lorem` after empty *args
        self.assertEqual(repr(Baz(lorem="ipsum", v="book")), "Baz(v='book', lorem='ipsum', dolor='sit')")

        # Very back to front order here: in the signature
        # `eof` in kwargs, `dolor` has default, `lorem` no default, `v` before *args
        # but repr string comes out fine
        self.assertEqual(repr(Baz(eof=True, dolor="$", lorem="ipsum", v="book")), "Baz(v='book', lorem='ipsum', dolor='$', eof=True)")

if __name__ == "__main__":
    unittest.main()

The only main downside therefore is if one changes member variables that were assigned to in __init__:

class MyClass(AutoInitRepr):
    def __init__(self, x):
        self.x = x

c = MyClass(42)
c.x = 33

print(repr(c)) # MyClass(42)

which is why I called it AutoInitRepr. If one is looking for more of a ‘show the member variables’ repr, then that sounds dataclass-y.

PS: No vibecoding this time (my previous reply, which I self-tagged as Gemini assisted, was removed; first and only time vibecoding here :sweat_smile:). The new version is worth it too with the human-hand-written attention to detail and tests. @davidism Thanks.