Can you modify an object's field every time another field is modified?

Question:

I have a dataclass that looks like this

from dataclasses import dataclass, field

@dataclass
class Data:
    name: str | None = None
    file_friendly_name: str | None = field(default=None, init=False)

    def __post_init__(self):
        # If name, automatically create the file_friendly_name
        if self.name:
            self.file_friendly_name = "".join(
                i for i in self.name if i not in "/:*?<>|"
            )

If user passes name on instantiation, file_friendly_name is automatically created.

Is there a way to do it so that every time name is updated/changed, file_friendly_name also changes?

e.g.

data = Data()
data.name = 'foo/bar'
print(data.file_friendly_name) # want: 'foobar'

data = Data(name='foo/bar')
data.name = 'new?name'
print(data.file_friendly_name) # want: 'newname'

Update based on answers:

  1. I’ve tried setting _name: str and creating name using getters/setters. But I don’t like how when you do print(Data()) it shows _name as an attribute. I’d like that not to happen.
  2. I like setting file_friendly_name as a property. But then you can’t see that as an attribute when you do print(Data()). This is less of an issue but still not ideal.

Can it just show name and file_friendly_name as attributes when doing print(Data())?

Asked By: codeananda

||

Answers:

I’d suggest defining file_friendly_name as @property instead.

from dataclasses import dataclass, fields

@dataclass
class Data:
    name: str | None = None
    
    @property
    def file_friendly_name(self) -> str | None:
        if self.name is not None:
            return "".join(
                i for i in self.name if i not in "/:*?<>|"
            )
        else:
            return None

    def __repr__(self):
        fields_str = [f'{field.name}={getattr(self, field.name)!r}'
                      for field in fields(self)]
        fields_str.append(f'file_friendly_name={self.file_friendly_name}')
        fields_res = ', '.join(fields_str)
        return f'{type(self).__name__}({fields_res})'
Answered By: Yevhen Kuzmovych

Similiarly to @Yevhen’s suggestion, but using setter on property you can trigger a specific function when setting to an attribute. You can then check if class has related private attribute to tell if you are definining it right now or it already exists.

from dataclasses import dataclass, field

def methodToTrigger():
    print("Triggered method")

@dataclass
class Data:
    name: str = None

    def __post_init__(self):
        # If name, automatically create the file_friendly_name
        if self.name:
            self.file_friendly_name = "".join(
                i for i in self.name if i not in "/:*?<>|"
            )
    @property
    def file_friendly_name(self):
        return self._file_friendly_name

    @file_friendly_name.setter
    def file_friendly_name(self, value):
        if not hasattr(self, "_file_friendly_name"):
            methodToTrigger()
        self._file_friendly_name = value

d = Data(name = "asdf")

print(d.file_friendly_name)
Answered By: matszwecja

Indeed, there is a way!

Code

from dataclasses import dataclass, field

@dataclass
class Data:
    _name: str | None = None
    file_friendly_name: str | None = field(default=None, init=False)

    def __post_init__(self):
        # If _name is not None, automatically create the file_friendly_name
        if self._name is not None:
            self.file_friendly_name = "".join(
                i for i in self._name if i not in "/:*?<>|"
            )

    @property
    def name(self) -> str | None:
        return self._name

    @name.setter
    def name(self, new_val: str | None) -> None:
        if self._name == new_val:
            return
        self._name = new_val
        if self._name is None:
            self.file_friendly_name = None
        else:
            self.file_friendly_name = "".join(
                i for i in self._name if i not in "/:*?<>|"
            )

Explanation

Since you asked for a way to actually update the file_friendly_name field whenever name changes, I’ve changed the name field into a property which reads from private attribute _name. Now it’s _name which is assessed in __post_init__.

Then I’ve created a "setter" for the name property. This setter will be called every time name is updated. Note that there’s no sort of Data.name.setattr(...) boilerplate-y nonsense, given that we’re in Python-land. When I say "updated", I mean whenever you do

>>> d = Data("Zev")
>>> d.name = "**Zev**"

that setter will be invoked and the name and file_friendly_name fields will be updated accordingly:

>>> d.file_friendly_name
'Zev'
>>> d.name
'**Zev**'

>>> data = Data()
>>> data.name = 'foo/bar'
>>> print(data.file_friendly_name) 
'foobar'

>>> data = Data('foo/bar')
>>> data.name = 'new?name'
>>> print(data.file_friendly_name) 
'newname'

Pretty Printing

One small drawback of this is that printing data shows our private field:

>>> print(data)
Data(_name='new?name', file_friendly_name='newname')

However, you can work around this by defining your own __repr__ method:

def __repr__(self) -> str:
    return f"Data(name='{self._name}', file_friendly_name='{self.file_friendly_name}')"
>>> print(data)
Data(name='new?name', file_friendly_name='newname')

Making name work in the constructor

Finally, if you’d like your name keyword argument back for constructing Data instances, you can add your own constructor to it. We’ll DRY up the code this requires while we’re at it:

def __init__(self, name: str | None = None):
    self._name = name
    if self._name is not None:
        self.file_friendly_name = self.make_file_friendly_name(self._name)

def __post_init__(self):
    # If _name is not None, automatically create the file_friendly_name
    if self._name is not None:
        self.file_friendly_name = self.make_file_friendly_name(self._name)

@name.setter
def name(self, new_val: str | None) -> None:
    if self._name == new_val:
        return
    self._name = new_val
    if self._name is None:
        self.file_friendly_name = None
    else:
        self.file_friendly_name = self.make_file_friendly_name(self._name) #   revised
    

@staticmethod
def make_file_friendly_name(name: str) -> str:
    return "".join(
        i for i in name if i not in "\/:*?<>|"
    )

After this, the sample code works as expected:

>>> data = Data()
>>> data.name = 'foo/bar'
>>> print(data.file_friendly_name) 
'foobar'

>>> data = Data(name='foo/bar')
>>> data.name = 'new?name'
>>> print(data.file_friendly_name) 
'newname'
Answered By: Zev Averbach