2

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())?

Zev Averbach
  • 1,044
  • 1
  • 11
  • 25
codeananda
  • 939
  • 1
  • 10
  • 16
  • codeananda as @matszwecja mentions below, it looks like your title and description are two different things. Did you originally want something to trigger every time you add an attribute to a class/instance? – Zev Averbach Feb 01 '23 at 13:46

3 Answers3

4

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})'
Yevhen Kuzmovych
  • 10,940
  • 7
  • 28
  • 48
  • this doesn't change the "file_friendly_name" field, it actually _recalculates_ the `file_friendly_name` property every time that property is invoked. in some contexts this extra computation can be problematic when a simple field access is within reach. – Zev Averbach Feb 01 '23 at 13:56
  • This is nice. But if I do `print(Data())` it doesn't show `file_friendly_name` as one of the params. Is there a way to do that as well? Come to think of it, it's probably not necessary (since the user is unlikely to use `file_friendly_name`... but still curious – codeananda Feb 01 '23 at 14:00
  • 1
    @codeananda You could override `__str__` method to include `file_friendly_name` in the output – Yevhen Kuzmovych Feb 01 '23 at 14:01
  • Great idea! Can you add that to your answer? – codeananda Feb 01 '23 at 14:05
  • 1
    @codeananda Updated. You could also generalize it for any number of fields with `dataclasses.fields(self)`. – Yevhen Kuzmovych Feb 01 '23 at 14:11
  • @YevhenKuzmovych I don't quite follow how to do that. Do I not still need to manually write out which fields I want to overwrite? Can you also add that to answer? – codeananda Feb 01 '23 at 14:17
  • @codeananda Updated. Now you can add fields to `Data` and they'll all be printed – Yevhen Kuzmovych Feb 01 '23 at 14:27
  • 2
    @ZevAverbach "30% slower than it needs to be" - that is simply incorrect. Sure, you do get faster getter on the property, but you need to perform an additional method call each time you try to change the value of `name`. If setter calls heavily outweigh getter calls your solution will be actually slower, not to mention the added memory of storing derived data along with the original `name`. As long as performance does not become an actual issue, I'd weigh heavily towards @Yevhen's solution, as it's lighter on memory. – matszwecja Feb 01 '23 at 15:20
  • 2
    @matszwecja i've removed this claim, thanks for pushing back on it: indeed, when using the exact examples from OP the performance isn't very different at all between our solutions. – Zev Averbach Feb 01 '23 at 15:33
  • @YevhenKuzmovych this works for `print` but not for `pprint`... any idea how to fix that? – codeananda Feb 02 '23 at 16:22
  • @codeananda replace `__str__` with `__repr__`. – Yevhen Kuzmovych Feb 02 '23 at 17:04
3

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'
Zev Averbach
  • 1,044
  • 1
  • 11
  • 25
  • 1
    Nice solution! But changing `name` from `Zev` to `**Zev**` shouldn't change the `file_friendly_name` :) Pretty sure, your code is correct, but the usage example isn't reliable. Another small issue is that `Data(name='foo/bar')` would fail as `Data` doesn't have `name` in defined fields. – Yevhen Kuzmovych Feb 01 '23 at 13:45
  • 1
    @YevhenKuzmovych thanks for the comment, but in fact OP requested 'Is there a way to do it so that every time name is updated/changed, file_friendly_name also changes?' So indeed, he wants `file_friendly_name` to change whenever `name` changes, it's the primary request! As for your second note, on the contrary my code works perfectly because the property `name` has a setter. EDIT: indeed, you have to leave out the keyword when instantiating `Data`, you're correct about that. A few extra lines would allow `name` as a keyword argument to the constructor, though! – Zev Averbach Feb 01 '23 at 13:48
  • @ZevAverbach I tried this method before but didn't like how if you do `print(data)` it shows `_name` as one of the attributes. Is there a way to get around that? Ideally it would just show `name` and `file_friendly_name` – codeananda Feb 01 '23 at 13:57
  • 1
    @codeananda good question: indeed, there is. I'll add it to my answer, and you may want to add it to your question as well. – Zev Averbach Feb 01 '23 at 13:57
  • having updated my answer, `name` is again accepted as a keyword argument to `Data()` – Zev Averbach Feb 01 '23 at 14:20
2

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)
matszwecja
  • 6,357
  • 2
  • 10
  • 17
  • @YevhenKuzmovych I actually slightly misread the question. My answer is OP asked for (mostly in the title), yours is what he actually needs :P (classic X/Y problem) – matszwecja Feb 01 '23 at 13:39
  • this is still very helpful! Do you have a suggestion for how I could rephrase the title to more match my question? – codeananda Feb 01 '23 at 14:15