classification
Title: Add abc.Mapping to dataclass
Type: enhancement Stage:
Components: Library (Lib) Versions: Python 3.10
process
Status: open Resolution:
Dependencies: Superseder:
Assigned To: eric.smith Nosy List: abrosimov.a.a, eric.smith
Priority: normal Keywords:

Created on 2020-12-25 16:32 by abrosimov.a.a, last changed 2020-12-29 19:49 by abrosimov.a.a.

Messages (11)
msg383752 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-25 16:32
I want to add `abc.Mapping` extension to `dataclasses.dataclass`.

Motivation:

1. `asdict` makes a deep copy of the `dataclass` object. If I only want to iterate over the `field` attributes, I don't want to do a deep copy.
2. `dict(my_dataclass)` can be used as a `dict` representation of `my_dataclass` class without deep copying.
3. `myfunc(**my_dataclass)` looks better and is faster then `myfunc(**asdict(my_dataclass))`.
4. `len(my_dataclass) == len(asdict(my_dataclass))` is expected behavior.
5. `my_dataclass.my_field is my_dataclass['my_field']` is expected behavior.


Looks like a prototype:

from collections.abc import Mapping
from dataclasses import dataclass, fields, _FIELDS, _FIELD


@dataclass  # `(mapping=True)` creates such a class:
class MyDataclass(Mapping):
    a: int = 1
    b: int = 2

    # In `dataclasses._process_class`:
    # if `mapping` is `True`.
    # Make sure 'get', 'items', 'keys', 'values' is not in `MyDataclass` fields.

    def __iter__(self):
        return (f.name for f in fields(self))

    def __getitem__(self, key):
        fields = getattr(self, _FIELDS)
        f = fields[key]
        if f._field_type is not _FIELD:
            raise KeyError(f"'{key}' is not a field of the dataclass.")
        return getattr(self, f.name)

    def __len__(self):
        return len(fields(self))


my_dataclass = MyDataclass(b=3)
print(my_dataclass['a'])
print(my_dataclass['b'])
print(dict(my_dataclass))
print(dict(**my_dataclass))

Stdout:
1
3
{'a': 1, 'b': 3}
{'a': 1, 'b': 3}


Realisation:

Updating the `dataclasses.py`: `dataclass`, `_process_class`, `_DataclassParams`.
Set `mapping` argument to default `False`.


Can this enhancement be accepted?
msg383755 - (view) Author: Eric V. Smith (eric.smith) * (Python committer) Date: 2020-12-25 17:23
You'd need to return a different class in order to add the collections.abc.Mapping base class. Currently, dataclasses by design always return the same class that's passed in.

I'd suggest adding this as a stand-alone decorator.
msg383758 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-25 18:57
Thanks for the answer, I agree.
The implementation should be like this?


from collections.abc import Mapping
from dataclasses import dataclass, fields, _FIELDS, _FIELD

class _DataclassMappingMixin(Mapping):
    def __iter__(self):
        return (f.name for f in fields(self))

    def __getitem__(self, key):
        fields = getattr(self, _FIELDS)
        f = fields[key]
        if f._field_type is not _FIELD:
            raise KeyError(f"'{key}' is not a dataclass field.")
        return getattr(self, f.name)

    def __len__(self):
        return len(fields(self))


def dataclass_mapping(cls=None, **kwargs):
    def apply_dataclass(cls):
        dataclass_wrap = dataclass(**kwargs)
        return dataclass_wrap(cls)

    def check_mapping_attrs(cls):
        mapping_attrs = (i for i in dir(_DataclassMappingMixin) if i[0] != '_')
        for key in mapping_attrs:
            if hasattr(cls, key):
                raise AttributeError(f"'{key}' is the Mapping reserved attribute.")

    def apply_mapping(cls):
        return type(cls.__name__ + 'Mapping',
                    (cls, _DataclassMappingMixin),
                    {})

    def wrap(cls):
        check_mapping_attrs(cls)
        cls_dataclass = apply_dataclass(cls)
        return apply_mapping(cls_dataclass)

    # See if we're being called as @dataclass or @dataclass().
    if cls is None:
        # We're called with parens.
        return wrap

    # We're called as @dataclass without parens.
    return wrap(cls)


@dataclass_mapping
class MyDataclass:
    a: int = 1
    b: int = 2


my_dataclass = MyDataclass(b='3')
print(my_dataclass.__class__.__name__)
print(my_dataclass['a'])
print(my_dataclass['b'])
print(dict(my_dataclass))
print(dict(**my_dataclass))
msg383762 - (view) Author: Eric V. Smith (eric.smith) * (Python committer) Date: 2020-12-25 21:16
Something like that. You'd have to write some tests and try it out.
msg383764 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-25 21:23
An alternative way:

from collections.abc import Mapping
from dataclasses import dataclass, fields, _FIELDS, _FIELD

class DataclassMappingMixin(Mapping):
    def __iter__(self):
        return (f.name for f in fields(self))

    def __getitem__(self, key):
        field = getattr(self, _FIELDS)[key]
        if field._field_type is not _FIELD:
            raise KeyError(f"'{key}' is not a dataclass field.")
        return getattr(self, field.name)

    def __len__(self):
        return len(fields(self))


@dataclass
class MyDataclass(DataclassMappingMixin):
    a: int = 1
    b: int = 2


my_dataclass = MyDataclass(a='3')
print(my_dataclass.__class__.__mro__)
print(my_dataclass.__class__.__name__)
print(my_dataclass['a'])
print(my_dataclass['b'])
print(dict(my_dataclass))
print(dict(**my_dataclass))
print(fields(my_dataclass))


Result:
(<class '__main__.MyDataclass'>,
 <class '__main__.DataclassMappingMixin'>,
 <class 'collections.abc.Mapping'>,
 <class 'collections.abc.Collection'>,
 <class 'collections.abc.Sized'>,
 <class 'collections.abc.Iterable'>,
 <class 'collections.abc.Container'>,
 <class 'object'>)
MyDataclass
3
2
{'a': '3', 'b': 2}
{'a': '3', 'b': 2}
(Field(name='a',type=<class 'int'>, ...),
 Field(name='b',type=<class 'int'>, ...))
msg383766 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-25 21:39
I think the second option looks better.
More pythonic.
No need to create new classes
No typing hacks.
Mixin can be easily expanded.

Yes, I will do refactoring, typing, documentation and tests in PR.
msg383767 - (view) Author: Eric V. Smith (eric.smith) * (Python committer) Date: 2020-12-25 21:42
I don't think this belongs in dataclasses itself, at least not until it's been vetted widely. You might want to put it on PyPI first as a standalone project.
msg383770 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-25 22:03
This Mixin only works with dataclass objects. And uses the private functionality of the dataclasses. So dataclasses.py is the right place for this. I think I can do enough tests.

And I think that this is too little for a standalone project.
msg383771 - (view) Author: Eric V. Smith (eric.smith) * (Python committer) Date: 2020-12-25 22:05
I'm just warning you that I probably won't accept it. I haven't heard of any demand for this feature.

You might want to bring it up on python-ideas if you want to generate support for the proposal.
msg383774 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-25 22:20
Thanks for the good offer, I will definitely use it.
msg384026 - (view) Author: Anton Abrosimov (abrosimov.a.a) * Date: 2020-12-29 19:49
Link to python-ideas thread:
https://mail.python.org/archives/list/python-ideas@python.org/thread/XNXCUJVNOOVPAPL6LF627EOCBUUUX2DG/
History
Date User Action Args
2020-12-29 19:49:18abrosimov.a.asetmessages: + msg384026
2020-12-25 22:20:46abrosimov.a.asetmessages: + msg383774
2020-12-25 22:05:14eric.smithsetmessages: + msg383771
2020-12-25 22:03:10abrosimov.a.asetmessages: + msg383770
2020-12-25 21:42:57eric.smithsetmessages: + msg383767
2020-12-25 21:39:47abrosimov.a.asetmessages: + msg383766
2020-12-25 21:23:56abrosimov.a.asetmessages: + msg383764
2020-12-25 21:16:24eric.smithsetmessages: + msg383762
2020-12-25 18:57:35abrosimov.a.asetmessages: + msg383758
2020-12-25 17:23:40eric.smithsetassignee: eric.smith

messages: + msg383755
nosy: + eric.smith
2020-12-25 16:32:43abrosimov.a.acreate