__pre_init__ functionalty in python?

Viewed 3028

I would like to make string comparison case insensitive. For that, I would like to create an immutable class with just one string field. In the constructor, I would like to call lower() before assigning the value to the field.

I would like to use as much as possible of standard classes like namedtuple or dataclass. Using the post_init function (see e.g. How to use the __post_init__ method in Dataclasses in Python) feels like a hack. It also makes me wonder is the field still frozen after I changed it in the post_init function.

However, I can't find a pre_init function. Is there a better way?

4 Answers

Turns out that dataclasses doesn't provide the functionality I was looking for. Attrs however does:

from attr import attrs, attrib


@attrs(frozen=True)
class Name:
    name: str = attrib(converter=str.lower)

Clarification: If I'm interpreting the question correctly, you specifically want to work with a class annotated with the stdlib dataclasses.dataclass(frozen=True). Specifically, for your case, you might have something like this:

from dataclasses import dataclass


@dataclass(frozen=True)
class HasStringField:
    some_string: str


instance = HasStringField("SOME_STRING")
instance.some_string  # value: "SOME_STRING"

My understanding is that you want to implement something such that the value in instance.some_string above is actually "some_string".


Answer: There is no "built-in" way to do what you want, but there are two options that come to mind: using object.__setattr__ in __post_init__ or else using an alternative decorator that provides you with a way to create a custom constructor that still calls through to the constructor created by dataclass.

Option 1: using object.__setattr__ in __post_init__

@dataclasses.dataclass(frozen=True)
class HasStringField:
    some_string: str

    def __post_init__(self):
        object.__setattr__(self, "some_string", self.some_string.lower())


inst = HasStringField("SOME_STRING")
inst.some_string  # value: "some_string"

Option 2: custom decorator

The dataclass_with_default_init decorator in an answer to a similar question provides one such decorator.

Using the dataclass_with_default_init decorator, you'd do:

# Import / implement the decorator

@dataclass_with_default_init(frozen=True)
class HasStringField:
    some_string: str

    def __init__(self, some_string: str):
        self.__default_init__(some_string=some_string.lower())


instance = HasStringField("SOME_STRING")
instance.some_string  # value: "some_string"

...and everything else about this class would remain as if you had used the dataclass decorator instead.

This should be one of the simplest ways, the field you mention is the internal data (inherited from the UserString class):

from collections import UserString

class LowerStr(UserString):
    def __init__(self, value):
        super().__init__(value)
        self.data = self.data.lower()

s1 = LowerStr("ABC")
print("s1: {}".format(s1))
print("s1 class: {}".format(s1.__class__))
print("s1 class mro: {}".format(s1.__class__.mro()))

Output:

s1: abc
s1 class: <class '__main__.LowerStr'>
s1 class mro: [<class '__main__.LowerStr'>, <class 'collections.UserString'>, <class 'collections.abc.Sequence'>, <class 'collections.abc.Reversible'>, <class 'collections.abc.Collection'>, <class 'collections.abc.Sized'>, <class 'collections.abc.Iterable'>, <class 'collections.abc.Container'>, <class 'object'>]

This gives you all the methods from str and you can customize them at will.

If you prefer to subclass, so there is no internal data attribute:

class InsensitiveStr(str):
    def __new__(cls, value):
        if isinstance(value, str):
            return super().__new__(cls, value.lower())
        else:
            raise ValueError

s1 = InsensitiveStr("ABC")
print(s1)
print(type(s1))
print(s1.__class__)
print(s1.__class__.__mro__)

Note the differences in mro.

Outputs

abc
<class '__main__.InsensitiveStr'>
<class '__main__.InsensitiveStr'>
(<class '__main__.InsensitiveStr'>, <class 'str'>, <class 'object'>)

Note: I still think the best approach is to use the __post_init__ / object.__set_attribute__ mentioned above or to use an external library like https://www.attrs.org/en/stable/)

One pattern I have seen reading the python code for dataclasses itself is to use a function with the same name but in snake case to do these sorts of changes. e.g.:

@dataclass(frozen=True)
class HasStringField:
  some_string: str


def has_string_field(some_string: str) -> HasStringField:
  return HasStringField(some_string.lower())

Then you always import and use has_string_field() rather than HasStringField(). (An example of this pattern is dataclasses field vs Field):

It has the drawback of being verbose and making the class invariant's bypassable.

Related