python-attrs/attrs

Best way of configuring a non-None field which accepts None in __init__?

Arcitec opened this issue · 4 comments

Hi! I wonder if anyone is able to help me with a small question. I normally wouldn't ask on a repo, but I've pored through the docs and Google trying to find some good idea for solving this.

It should be a pretty common use case, so I suspect that there is some way that I've just missed.

Imagine the following class.

class Node:
  kind: str
  name: str

  def __init__(self, kind: str, name: str | None = None) -> None:
    self.kind = kind
    self.name = name if name is not None else kind.lower()

It guarantees that both class attributes are strings (never None) after construction. But it allows None in the initializer, in which case the class itself takes care of filling in the value in that situation.

What is the attrs equivalent of this?

The most important thing is that all class fields must be annotated as str, (so that mypy and IDEs are happy knowing that they will never be None), so therefore I cannot "just annotate as str | None and then use attrs post-init to fix it later if it became set to None".

Because having loose field type annotations would mean that I need if foo.name is not None checks everywhere else to satisfy mypy.

Therefore I wonder what the best way is to convert the above code into attrs, and preserving the str types? I can't figure it out from the (excellent 👌) documentation.


Edit: I discovered the converter=attrs.converters.default_if_none("Foo"), technique now, but that actually leads mypy to believe that the __init__ argument can be Any. So it's still not solved. The goal is an init that takes str | None and converts to a str on the class property. Hmm.

Okay, I finally figured it out. It was well documented, I just missed these pages (since I had been too focused on reading the actual API documentation, not the example pages):

https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization

https://www.attrs.org/en/stable/init.html#custom-init

The proper technique is to create a custom __init__, and then do the variable transformations there, and THEN pass it into the internal __attrs_init__, which will do the final validation (things like running validators, converters etc will happen in there).

Here is the working code:

@attrs.frozen
class Node:
    kind: str
    name: str

    def __init__(self, kind: str, name: str | None = None) -> None:
        if name is None:
            name = kind.lower()
        self.__attrs_init__(kind, name)


print(Node("INT"))
print(Node("INT", "custom_name"))

Result:

Node(kind='INT', name='int')
Node(kind='INT', name='custom_name')

The constructor thereby allows either str or None for the name-field. And most importantly, the IDE knows that .kind and .name are ALWAYS str type, so I don't need if-statements everywhere when working with the resulting data class. :) Sweet!

You can also use a converter, especially with the https://www.attrs.org/en/stable/api.html#attrs.converters.default_if_none helper. But sadly, the typing support is a bit spotty in tooling.

@hynek Thank you for everything. This project is amazing.

It's making life so much easier than dataclasses. It's seriously amazing how powerful attrs is. Thank you.

Regarding the converter, yeah I tried that solution before I found custom __init__. But I definitely noticed the spotty typing support after doing that. It made mypy think that the __init__ takes name: Any instead of name: str | None. Which I guess is because they coded mypy to assume "if there's an attrs converter, it takes anything".

That mypy type handling was not strict enough for me, so I went with the custom __init__ method. I'm really glad how it turned out. The IDE autocompletion is perfect now. :)

 

The only thing I kinda wish I could change is the fact that I am doing name = kind.lower() without verifying that kind is a string yet, since my init runs before attrs init. So if someone uses it incorrectly, they get less-helpful errors like AttributeError: 'NoneType' object has no attribute 'lower'.

I prefer the attrs init errors which says things like "expected str, found None type" etc.

Do you have any ideas for that? Is there some way I can manually trigger the kind attrs field validator in my custom init?

Something like this:

@attrs.frozen
class Node:
    kind: str = attrs.field(validator=attrs.validators.instance_of(str))
    name: str = attrs.field(validator=attrs.validators.instance_of(str))

    def __init__(self, kind: str, name: str | None = None) -> None:
        if name is None:
            # do something here to trigger attrs "kind" validator here before we do `.lower`
            name = kind.lower()
        self.__attrs_init__(kind, name)

If that (commented line) is possible, I'd love to do it to get better error messages. But it's a pretty minor thing, since mypy static analysis already catches incorrect calls of non-str kind parameters.

 

Edit: I found https://www.attrs.org/en/stable/api.html#attrs.validate which is almost right, but validates the actual attributes and checks all of them. I was looking for a way to just run the kind property validator.

In the end, I came up with a simpler workaround:

        if name is None and isinstance(kind, str):
            name = kind.lower()

This achieves the desired result through pure luck. If kind is an invalid value (such as None), it doesn't try setting name. And then both get put into the attrs init which checks both and realizes that kind is invalid and throws the error. Likewise if a custom non-string name comes in.

So this workaround works. It could have been nice to have a "run 1 specific validator" API but I guess most situations can be worked around like I just did. :)

I'm afraid you've moved into the grey zone between attrs and cattrs. I would suggest to write an own converter that checks the type first.