You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Python dataclasses has been something this library has always eyed keenly. A lot of the work and discussion was done earlier in #2698, and a number of things have changed since that issue was created, which has once again opened the door for us to now actually switch to dataclasses.
Reiterating the main benefits we get from the switch:
No more slots and __init__ boilerplate
Letting dataclass machinery handle equality comparison for us
Drastically less maintenance overhead, even when writing tests, since we don't need slots and equality tests.
Implementation
I'll go over things which were discussed in the issue and whether they are still applicable:
Minor details
Immutability: The solution we went for is rolling our own version of freezing, via a _frozen attribute. Dataclasses have their own version via the frozen=True argument. Here are the pros/cons to using that:
➖ IISC, dataclasses does not have a way to temporarily unfreeze an object, which means using object.__setattr__ as a workaround, specially in tests.
➕ Initializing new classes is at 3-10x faster1 if we use the dataclass approach. I had codex write a script for that. The results were surprising since I was expecting the opposite..
Personally, I'm slightly in favour of using the dataclass frozen=True approach because of the "free" performance gain.
Equality comparison: Dataclasses also has its own comparison method, using compare=True, which removes the need for our manual _id_attrs logic. I currently don't see any reason why we shouldn't use their approach. The only annoyance is a little more boilerplate to write if you don't want to include a field for equality comparsion. You have to do a field(eq=False). With frozen=True, the __hash__ method will also be handled for us and will check the attributes we want it to check.
__repr__: Dataclasses can also do this for us, but now that we have our own approach, which customizes the look by dropping None values and empty containers, we can just stick to using that.
__slots__: One of the showstoppers previously, we no longer have that issue since we only support 3.10+.
__match_args__: Since we don't have a use for them, we should disable them globally for all TelegramObjects.
Some class constants will need to be annotated with ClassVar, specially the Final ones.
We should mention that dataclasses.asdict and replace is not explicitly supported by our API and users should use to_dict.
Replacing __slots__ with fields() when wanting to get all attributes, this means almost all of our internal TO methods and even tests would need to change.
Major details
Handling of Init only fields and transform like fields:
At the moment, we have a couple of classes which call other functions in the __init__, like calling to_timedelta, or parse_sequence_arg. For these kind of classes, I propose using the metadata field in Field. This metadata will contain the function for transformation.
TO.__post_init__ will be defined on the base class which will automatically run every time after the regular __init__. It's at this time we will iterate through any metadata fields and apply the transformations.
An additional use of the metadata field is to use it for deprecated arguments / attributes, e.g. duration can be an InitVar, and then we can use a regular @property to raise warnings. The metadata field can be used to store the value in _duration for example.
Side: We can't type hint the argument and attribute differently in dataclasses, so I propose to make the Sequence accepting arguments as tuple, since users are more likely to access attributes rather than initializing a class with them.
i. The best solution imo is still making the defaulted inherited field as keyword only. This means that _BaseMedium, _BaseThumbedMedium, etc would make all of its default fields as kw only, which in turn changes the signature of classes which inherit them.
This is of course a breaking change without a deprecation period, but the stability policy does outline an extreme case where we can't provide compatibility. Moreover, most of these classes are not typically instantiated by users.
ii. Non-breaking: Override the __init__ generated from the dataclass, define a static list of positional arguments expected, and then check the passed arguments to check if they were passed positionally in the right order. I had GPT 5.5 do this, but I felt it was too black magicky and was over ~100 lines. It could work, but it can also be a maintainence burden if it's buggy.
iii. Inline inherited arguments: This is basically removing the internal classes and all of its attributes and copying them over to the subclasses. That way we can control what order the class is instantiated with. Not a big fan of this either..
There could be 2nd order effects which I haven't forseen so far, but this should be the bulk of the changes.
Python dataclasses has been something this library has always eyed keenly. A lot of the work and discussion was done earlier in #2698, and a number of things have changed since that issue was created, which has once again opened the door for us to now actually switch to dataclasses.
Reiterating the main benefits we get from the switch:
__init__boilerplateImplementation
I'll go over things which were discussed in the issue and whether they are still applicable:
Minor details
Immutability: The solution we went for is rolling our own version of freezing, via a
_frozenattribute. Dataclasses have their own version via thefrozen=Trueargument. Here are the pros/cons to using that:object.__setattr__as a workaround, specially in tests.__setattr__and__delattr__does not work. python/cpython#105936, though this has been fixed in later versions of python 3.13 and above? If users encounter this somehow below 3.13, we may just have to ask them to upgrade, but the scenario in that issue should not happen in our library.Personally, I'm slightly in favour of using the dataclass frozen=True approach because of the "free" performance gain.
Equality comparison: Dataclasses also has its own comparison method, using
compare=True, which removes the need for our manual_id_attrslogic. I currently don't see any reason why we shouldn't use their approach. The only annoyance is a little more boilerplate to write if you don't want to include a field for equality comparsion. You have to do afield(eq=False). Withfrozen=True, the__hash__method will also be handled for us and will check the attributes we want it to check.__repr__: Dataclasses can also do this for us, but now that we have our own approach, which customizes the look by dropping None values and empty containers, we can just stick to using that.__slots__: One of the showstoppers previously, we no longer have that issue since we only support 3.10+.__match_args__: Since we don't have a use for them, we should disable them globally for all TelegramObjects.Some class constants will need to be annotated with
ClassVar, specially theFinalones.We should mention that
dataclasses.asdictandreplaceis not explicitly supported by our API and users should useto_dict.Replacing
__slots__withfields()when wanting to get all attributes, this means almost all of our internal TO methods and even tests would need to change.Major details
At the moment, we have a couple of classes which call other functions in the
__init__, like callingto_timedelta, orparse_sequence_arg. For these kind of classes, I propose using themetadatafield inField. This metadata will contain the function for transformation.TO.__post_init__will be defined on the base class which will automatically run every time after the regular__init__. It's at this time we will iterate through any metadata fields and apply the transformations.An additional use of the metadata field is to use it for deprecated arguments / attributes, e.g.
durationcan be an InitVar, and then we can use a regular@propertyto raise warnings. The metadata field can be used to store the value in_durationfor example.Side: We can't type hint the argument and attribute differently in dataclasses, so I propose to make the
Sequenceaccepting arguments astuple, since users are more likely to access attributes rather than initializing a class with them.Inheritance: This is still a problem as mentioned in [DISCUSSION] Dataclasses #2698 (comment).
Possible solutions:
i. The best solution imo is still making the defaulted inherited field as keyword only. This means that
_BaseMedium,_BaseThumbedMedium, etc would make all of its default fields as kw only, which in turn changes the signature of classes which inherit them.This is of course a breaking change without a deprecation period, but the stability policy does outline an extreme case where we can't provide compatibility. Moreover, most of these classes are not typically instantiated by users.
ii. Non-breaking: Override the
__init__generated from the dataclass, define a static list of positional arguments expected, and then check the passed arguments to check if they were passed positionally in the right order. I had GPT 5.5 do this, but I felt it was too black magicky and was over ~100 lines. It could work, but it can also be a maintainence burden if it's buggy.iii. Inline inherited arguments: This is basically removing the internal classes and all of its attributes and copying them over to the subclasses. That way we can control what order the class is instantiated with. Not a big fan of this either..
There could be 2nd order effects which I haven't forseen so far, but this should be the bulk of the changes.
What does the dev team feel about this?
Footnotes
Benched on my uv installed python 3.14.6. ↩