attrs icon indicating copy to clipboard operation
attrs copied to clipboard

Post-Init-Only arguments

Open hynek opened this issue 7 years ago • 23 comments

So data classes have the concept of init-only variables:

@dataclass
class C:
    i: int
    j: int = None
    database: InitVar[DatabaseType] = None

    def __post_init__(self, database):
        if self.j is None and database is not None:
            self.j = database.lookup('j')

c = C(10, database=my_database)

Thanks to converters I think the need in attrs is much lower, but we should keep an eye on the popularity of it.

I think I could live with something like:

@attr.s
class C:
   x = attr.ib()
   cache = attr.post_init_arg(default=None)

   def __attrs_post_init__(self, cache):
       # ...

I don’t think it should be shoehorned into attr.ib().


To be clear: the example above is totally possible with attrs, it's just a bit more clunky.

hynek avatar Feb 01 '18 09:02 hynek

But ah, if you declare it like that static tools will think it's a field by default, no?

My gut feeling is additional args to init don't really deserve to be in the class body like that.

Tinche avatar Feb 01 '18 10:02 Tinche

Fuck yeah, you’re totally right.

It should be an argument to @attr.s.

hynek avatar Feb 01 '18 10:02 hynek

i would like to point out that "named constructors" are commonly used for such setups allowing more clear spelling

@attr.s
class C:
    i = attr.ib()
    j: int = attr.ib()

    @classmethod
    def from_database(cls, i, database):
           j = database.lookup('j')
          return cls(i, j)

so depending on the actual needs and intended use-cases it may be entirely sensible to just leave it to the syntax

RonnyPfannschmidt avatar Feb 05 '18 09:02 RonnyPfannschmidt

Yeah, I love named classmethods as named constructors. That’s one of the reasons why I’m not all in on the idea. But I’d like to watch how it’s embraced. It doesn’t help to die alone on a hill even if you’re right.

hynek avatar Feb 05 '18 09:02 hynek

I think this would be amazing and it's one of the cool things about dataclasses that attr's doesn't have

joeblackwaslike avatar May 07 '18 02:05 joeblackwaslike

I just came across this discussion while at a loss trying to figure out a right way to use attrs in a non-attrs hierarchy.

I think that the following simple addition would strictly extend the domain of application of attrs without impacting anyone who does not need it.

A new option (say, extra_args) to @attr.s, as in:

@attr.s(extra_args=True)
class Child(NonAttrsClass):
    a = attr.ib()
    b = attr.ib()

    def __attrs_post_init__(self, base_attr):
        super().__init__(self, base_attr)

would cause the following __init__ to be generated:

def __init__(self, a, b, *args, **kwargs):
    ...deal with a and b...
    self.__attrs_post_init__(*args, **kwargs)

This would already cover a great deal of situations where attrs falls short of providing a satisfactory solution.

One could even argue that the option is superfluous: instead, __init__ could just raise if any of args or kwargs is non-empty and __attrs_post_init__ is missing.

pbourguignon avatar Oct 28 '19 21:10 pbourguignon

This is solvable with a custom __init__ as per #393:

@attr.s(extra_args=True)
class Child(NonAttrsClass):
    a = attr.ib()
    b = attr.ib()

    def __init__(self, a, b, c):
        self. __attrs_init__(self, a, b)
        # ... do something with c ...

Which also has the advantage of not having an opinion in attrs about how to solve this: if the default behavior doesn't work for you, write your own constructor.

wsanchez avatar Oct 29 '19 04:10 wsanchez

Thanks for the prompt reply, this is bringing me much further already. Much appreciated!

This solution has one drawback however: it makes me repeat myself. Each attribute is now listed three times (declaration, __init__ signature, __attrs_init__ signature) instead of one, although attrs knows what it needs already. The above proposal builds upon a feature of Python syntax that overcomes just that in a very neat manner.

Also, I don't see the above proposal as an opinionated solution to the specific problem of non-attrs class hierarchies (in particular, it does not allow to invoke the base class constructor before attrs-generated __init__ runs), but rather as a way to pass more information to the constructor than just attribute initialization values, without attrs taking any responsibility for this additional bit. Or to put it differently, it could be a very generic way of passing arguments to __attrs_post_init__ without bloating the class namespace.

pbourguignon avatar Oct 29 '19 09:10 pbourguignon

Yeah… I'm just wonder out loud about how many variations of how one could change __init__'s handling of args we want to build in versus just allowing one to take control for any and all corner cases.

wsanchez avatar Oct 29 '19 16:10 wsanchez

Back to @pbourguignon's proposal, would it be feasible to just notice extra arguments in the signature for __attrs_post_init__? As in:

@attr.s()
class Child(NonAttrsClass):
    a: int
    b: str

    def __attrs_post_init__(self, x: bytes, y: bool = False):
        super().__init__(self, x, y=y)

Which could be use used thusly:

child = Child(1, "foo", b"")

assert child.a == 1
assert child.b == "foo"
assert child.x == b""
assert not child.y

wsanchez avatar Oct 29 '19 16:10 wsanchez

I think there is a possibility to keep it stupid simple by generally changing the attrs-generated __init__ as follows:

def __init__(self, attr1, attr2, *args, **kwargs):
    ...do what it already does...
    if hasattr(self, __attrs_post_init__):
        self.__attrs_post_init__(*args, **kwargs)  # This raises already on invalid/missing arguments
    elif len(args) + len(kwargs) > 0:
        raise ArgumentError("Extra arguments passed to __init__, but not used.")

This seems generic enough so I wouldn't expect further variation to be called for. It still does not cover the case where a base class needs to be initialized before attrs-generated __init__ is executed, but that one just calls for a custom __init__.

On the other hand, the solution you suggest has the added benefit of __init__ signature being complete. But that comes at some additional development cost.

pbourguignon avatar Oct 29 '19 18:10 pbourguignon

As for your question:

would it be feasible to just notice extra arguments in the signature for __attrs_post_init__

I'd say inspect has all you need for that.

pbourguignon avatar Oct 29 '19 18:10 pbourguignon

On the other hand, the solution you suggest has the added benefit of __init__ signature being complete.

I think that's pretty important for typing.

wsanchez avatar Oct 29 '19 18:10 wsanchez

On the other hand, the solution you suggest has the added benefit of init signature being complete.

I think that's pretty important for typing.

Yes, it is, and definitely calls for the more elaborate solution you outlined. I think I'll give it a go.

pbourguignon avatar Oct 29 '19 19:10 pbourguignon

@pbourguignon just wondering, did you make any progress on this? I would definitely use this feature if it existed! The __post_init__ hook is a really compelling part of the dataclass implementation -- but right now I'm stuck writing grosser classes by hand thanks to another limitation of dataclasses that is solved in attrs...

sinback avatar Jan 22 '20 17:01 sinback

@sinback Sorry, no, not yet. I settled on a custom init solution for my current needs.

pbourguignon avatar Jan 24 '20 13:01 pbourguignon

@hynek - two years after the "on hold" status - have your thoughts changed about this feature? I was recently looking for it.

indigoviolet avatar Sep 08 '20 00:09 indigoviolet

I'm squarely in the "named constructor" / "classmethod constructor". I find the amount of complexity this would introduce (especially taking into account subclassing) just isn't worth it.

hynek avatar Sep 08 '20 10:09 hynek

For those interested, I had the same problem where __attrs_post_init__ needed a context. So I wrote some helpers and a @post_init decorator (source code) that generates the new __init__() by merging the parameters of __post_init__() and __init__(), as suggested by wsanchez.

The decorator saves writing a custom __init__() and properly handles type annotations and defaults of __init__() and __post_init__(). It does not handle inheritance; based on dataclasses handling of InitVar, it might be sufficient to traverse the reversed mro and merge the __post_init__ signature on top the the current result, but I had no need for that.

This might not the best solution for my particular use-case, but it might be useful to others.

QuentinSoubeyran avatar Oct 04 '20 15:10 QuentinSoubeyran

I'm squarely in the "named constructor" / "classmethod constructor". I find the amount of complexity this would introduce (especially taking into account subclassing) just isn't worth it.

I agree that this approach seems like a clean solution in many cases. The problem I have with this approach is that most of my arguments need to be passed from the classmethod constructor to the default __init__, so I have to either repeat all the arguments (which defeats most of the benefit of using attrs), or use **kwargs, which loses type checking.

sagittarian avatar Dec 19 '22 07:12 sagittarian

I don't disagree, I just haven't seen a proposal where the outcome would justify the induced complexity. :-/

hynek avatar Dec 23 '22 08:12 hynek

I'd like to note that even with the handwritten approach init only arguments have horrendous maintenance costs over time

In pytest the overly tricky collection node constructors are a major showstopper for direly needed refactorings we want to land since well before 2016

RonnyPfannschmidt avatar Dec 23 '22 09:12 RonnyPfannschmidt

I'm not entirely sure how attrs could even best support inheritance -- it seems dataclasses just kinda throws its hands up and hopes for the best

>>> from dataclasses import dataclass, InitVar
>>> @dataclass
... class C:
...     x: InitVar[int]
...     def __post_init__(self, x):
...         print(x)
...
>>> @dataclass
... class D(C):
...     x: int
...
>>> D(42)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 4, in __init__
TypeError: C.__post_init__() missing 1 required positional argument: 'x'

A5rocks avatar Feb 20 '23 22:02 A5rocks