Enumerate all possible dataclass instances (with enum and bool fields only)

Viewed 910

Basically I need following. I have a python3 dataclass or NamedTuple, with only enum and bool fields. E.g.:

from enum import Enum, auto
from typing import NamedTuple

class MyEnum(Enum):
    v1 = auto()
    v2 = auto()
    v3 = auto()


class MyStateDefinition(NamedTuple):
    a: MyEnum
    b: bool

Is here any good known solution to enumerate all possible non-equal instances of such a dataclass? (Example above has 6 possible non-equal instances).

Perhaps it is not a dataclass I should use, but something else. Or should I play with such things like dataclasses.fields directly?

I imagine it as some table generator which accepts a namedtuple or dataclass as an input parameter and produces all possible values.

table = DataTable(MyStateDefinition)
for item in table:
    # Use items somehow
    print(item.a)
    print(item.b)

Why do I need it? I just have some state definition which consists of enums and bools. I believe it could be implemented as a bitmask. But when it comes to extending your bitmask with new values, it turns out to be a nightmare. Afterall, bitmasks seem to be a non-pythonic way of doing things.

Currently I have to use an implementation of my own. But perhaps I'm reinventing the wheel.

Thanks!

2 Answers

You can do this using enums, with the data-tuples as the enum-members' value (an Enum/NamedTuple hybrid, if you will). The _ignore_ attribute is used to prevent certain names in the class namespace from being converted into enum members.

from itertools import product
from enum import Enum

class Data(Enum):
    _ignore_ = "Data", "myenum_member", "truthiness"

    @property
    def a(self):
        return self.value[0]

    @property
    def b(self):
        return self.value[1]

    def __repr__(self):
        return f'Data(a={self.a!r}, b={self.b!r})'

    Data = vars()
    for myenum_member, truthiness in product(MyEnum, (True, False)):
        Data[f'{myenum_member.name}_{truthiness}'] = (myenum_member, truthiness)

You should be able to iterate through the resulting enum class just as you desire.

This use of enums is similar to the "time period" example in the Enum HOWTO section of the docs.


Generating this kind of table dynamically

If you want to generate this kind of table dynamically, you could do something like this, (ab)using metaclasses. I've shown example usages for how you would use this DataTable class in the docstrings. (For some reason, using typing.get_type_hints in a doctest seems to cause the doctest module to error out, but the examples do work if you try them yourself in an interactive terminal.) Rather than special-casing bool, as you did in your answer, I decided to special-case typing.Literal, as it seemed like a more extensible option (and bool can just be spelled as typing.Literal[True, False]).

from __future__ import annotations
from itertools import product
from enum import Enum, EnumMeta

from typing import (
    Iterable,
    Mapping,
    cast,
    Protocol,
    get_type_hints,
    Any,
    get_args,
    get_origin,
    Literal,
    TypeVar,
    Union,
    Optional
)

D = TypeVar('D')
T = TypeVar('T')


class DataTableFactory(EnumMeta):
    """A helper class for making data tables (an implementation detail of `DataTable`)."""

    _CLS_BASES = (Enum,)

    @classmethod
    def __prepare__(  # type: ignore[override]
            metacls,
            cls_name: str,
            fields: Mapping[str, Iterable[Any]]
    ) -> dict[str, Any]:

        cls_dict = cast(
            dict[str, Any],
            super().__prepare__(cls_name, metacls._CLS_BASES)
        )

        for i, field in enumerate(fields.keys()):
            cls_dict[field] = property(fget=lambda self, i=i: self.value[i])  # type: ignore[misc]

        for p in product(*fields.values()):
            cls_dict['_'.join(map(str, p))] = p

        def __repr__(self: Enum) -> str:
            contents = ', '.join(
                f'{field}={getattr(self, field)!r}'
                for field in fields
            )
            return f'{cls_name}Member({contents})'

        cls_dict['__repr__'] = __repr__
        return cls_dict

    @classmethod
    def make_datatable(
            metacls,
            cls_name: str,
            *,
            fields: Mapping[str, Iterable[Any]],
            doc: Optional[str] = None
    ) -> type[Enum]:
        """Create a new data table"""

        cls_dict = metacls.__prepare__(cls_name, fields)
        new_cls = metacls.__new__(metacls, cls_name, metacls._CLS_BASES, cls_dict)
        new_cls.__module__ = __name__

        if doc is None:
            all_attrs = '\n'.join(
                f'    {f"{attr_name}: ":<{(max(map(len, fields)) + 3)}}one of {attr_val!r}'
                for attr_name, attr_val in fields.items()
            )

            fields_len = len(fields)

            doc = (
                f'An enum-like data table.\n\n'
                f'All members of this data table have {fields_len} '
                f'read-only attribute{"s" if fields_len > 1 else ""}:\n'
                f'{all_attrs}\n\n'
                f'----------------------------------------------------------------------'
            )

        new_cls.__doc__ = doc
        return cast(type[Enum], new_cls)

    def __repr__(cls) -> str:
        return f"<Data table '{cls.__name__}'>"

    def index_of(cls: Iterable[D], member: D) -> int:
        """Get the index of a member in the list of members."""
        return list(cls).index(member)

    def get(
            cls: Iterable[D],
            /,
            *,
            default_: Optional[T] = None,
            **kwargs: Any
    ) -> Union[D, T, None]:
        """Return instance for given arguments set.
        Return `default_` if no member matches those arguments.
        """

        it = (
            member for member in cls
            if all((getattr(member, key) == val) for key, val in kwargs.items())
        )

        return next(it, default_)

    def __dir__(cls) -> list[str]:
        # By defining __dir__, we make methods defined in this class
        # discoverable by the interactive help() function in the REPL
        return cast(list[str], super().__dir__()) + ['index_of', 'get']


class TypedStructProto(Protocol):
    """In order to satisfy this interface, a type must have an __annotations__ dict."""
    __annotations__: dict[str, Union[Iterable[Any], type[Literal[True]]]]


class DataTableMeta(type):
    """Metaclass for `DataTable`."""
    __call__ = DataTableFactory.make_datatable  # type: ignore[assignment]


class DataTable(metaclass=DataTableMeta):
    """A mechanism to create 'data table enumerations' -- not really a class at all!

    Example usage
    -------------
    >>> Cars = DataTable('Cars', fields={'make': ('Toyota', 'Audi'), 'colour': ('Red', 'Blue')})
    >>> Cars
    <Data table 'Cars'>
    >>> list(Cars)
    [CarsMember(make=Toyota, colour=Red), CarsMember(make=Toyota, colour=Blue), CarsMember(make=Audi, colour=Red), CarsMember(make=Audi, colour=Blue)]
    >>> Cars.get(make='Audi', colour='Red')
    CarsMember(make=Audi, colour=Red)
    >>> Cars.index_of(_)
    2
    """

    @classmethod
    def from_struct(cls, cls_name: str, *, struct: type[TypedStructProto], doc: Optional[str] = None) -> type[Enum]:
        """Make a DataTable from a "typed struct" -- e.g. a dataclass, NamedTuple or TypedDict.

        Example usage (works the same way with dataclasses and TypedDicts)
        -------------------------------------------------------------------
        >>> from enum import Enum, auto
        >>> from typing import NamedTuple, Literal
        >>> class E(Enum):
        ...     v1 = auto()
        ...     v2 = auto()
        ...     v3 = auto()
        ...
        >>> class BoolsEndEnums(NamedTuple):
        ...     a: E
        ...     b: Literal[True, False]
        ...
        >>> BoolsEndEnumsTable = DataTable.from_struct('BoolsEndEnumsTable', struct=BoolsEndEnums)
        >>> list(BoolsEndEnumsTable)
        [BoolsEndEnumsTableMember(a=E.v1, b=True), BoolsEndEnumsTableMember(a=E.v1, b=False), BoolsEndEnumsTableMember(a=E.v2, b=True), BoolsEndEnumsTableMember(a=E.v2, b=False), BoolsEndEnumsTableMember(a=E.v3, b=True), BoolsEndEnumsTableMember(a=E.v3, b=False)]
        """

        fields = get_type_hints(struct)

        for field_name, field_val in fields.items():
            if get_origin(field_val) is Literal:
                fields[field_name] = get_args(field_val)

        return cast(type[Enum], cls(cls_name, fields=fields, doc=doc))  # type: ignore[call-arg]

I've had to do some "interesting" things with the type hints, but MyPy is sort of happy with all this.

Also posting implementation of my own. Not ideal, I had to use some protected members.

Usage:

from typing import NamedTuple
from datatable import DataTable

class BoolsEndEnums(NamedTuple):
    a: E
    b: bool


tbl = DataTable(BoolsEndEnums)

item = tbl[0]

print(item.a) # a is v1
print(item.b) # b is False

See test_datatable.py, _test_cls for more usage examples.

datatable.py

import collections
import dataclasses
from collections import Iterable
from enum import Enum
from typing import Union, Any, Tuple, Iterator, get_type_hints, NamedTuple


def is_cls_namedtuple(cls):
    return issubclass(cls, tuple) and hasattr(cls, "_fields")


class DataTable(Iterable):
    def __init__(self, data_cls):
        self._table = []
        self._index = {}
        self._rindex = {}
        self._named_tuple_cls = None

        fields = None

        if dataclasses.is_dataclass(data_cls):
            fields = [f.name for f in dataclasses.fields(data_cls)]
            self._named_tuple_cls = collections.namedtuple(
                f"{data_cls.__name__}_immutable",
                fields
            )
        elif is_cls_namedtuple(data_cls):
            self._named_tuple_cls = data_cls
            fields = data_cls._fields
        else:
            raise ValueError(
                "Only dataclasses and NamedTuple subclasses are supported."
            )

        hints = get_type_hints(data_cls)

        self._build_table([], [(f, hints[f]) for f in fields])

    def index_of(self, instance):
        """
        Returns record index of given instance in table.
        :param instance:
        :return:
        """
        index = self._as_index(instance)
        return self._rindex.get(index)

    def get(self, **kw):
        """
        Returns instance for given arguments set
        :param kw:
        :return:
        """
        index = self._as_index(kw)
        return self._table[self._rindex[index]]

    def __len__(self):
        return len(self._table)

    def __getitem__(self, i: Union[int, slice]):
        return self._table[i]

    def __iter__(self) -> Iterator:
        return self._table.__iter__()

    def _build_table(self, defined_fields, remained_fields):
        if not remained_fields:
            instance = self._named_tuple_cls(**dict(defined_fields))
            item_id = len(self._table)
            self._index[item_id] = instance
            self._rindex[self._as_index(defined_fields)] = item_id
            self._table.append(instance)
            return

        next_name, next_type = remained_fields[0]
        remained_fields = remained_fields[1:]

        if issubclass(next_type, Enum):
            for v in next_type:
                self._build_table(
                    defined_fields + [(next_name, v)],
                    remained_fields
                )
            return

        if next_type is bool:
            self._build_table(
                defined_fields + [(next_name, False)],
                remained_fields
            )
            self._build_table(
                defined_fields + [(next_name, True)],
                remained_fields
            )
            return

        raise ValueError(f"Got unexpected dataclass field type: {next_type}")

    @staticmethod
    def _as_index(v: Union[Any, Tuple[str, Any]]):
        items = None
        if dataclasses.is_dataclass(v):
            items = dataclasses.asdict(v).items()
        elif is_cls_namedtuple(type(v)):
            items = v._asdict().items()
        elif isinstance(v, dict):
            items = v.items()
        else:
            assert isinstance(v, collections.Sequence)
            items = v

        return tuple(sorted(items, key=lambda x: x[0]))

test_datatable.py

import dataclasses
from enum import Enum, auto
from typing import NamedTuple

import pytest

from dataclass_utils import DataTable


class E(Enum):
    v1 = auto()
    v2 = auto()
    v3 = auto()


@dataclasses.dataclass
class BoolsEndEnums:
    a: E
    b: bool


class BoolsEndEnumsNamedTuple(NamedTuple):
    a: E
    b: bool


@dataclasses.dataclass
class HugeSetOfValues:
    a: int
    b: bool


class NotSupportedCls:
    pass


def _test_cls(cls):
    tbl = DataTable(cls)

    first = cls(E.v1, False)
    last = cls(E.v3, True)

    expected_num_entries = 6

    assert tbl.index_of(first) == 0
    assert tbl.index_of(last) == (expected_num_entries - 1)
    assert len(tbl) == expected_num_entries

    actual_third = tbl.get(a=E.v2, b=False)
    assert actual_third.a == E.v2
    assert actual_third.b is False

    actual_forth = tbl[3]
    assert actual_forth.a == E.v2
    assert actual_forth.b is True

    items = [item for item in tbl]

    actual_fifth = items[4]
    assert actual_fifth.a == E.v3
    assert actual_fifth.b is False

    # Test that we can't change result
    with pytest.raises(AttributeError):
        tbl[0].a = E.v2


def test_dataclass():
    _test_cls(BoolsEndEnums)


def test_namedtuple():
    _test_cls(BoolsEndEnumsNamedTuple)


def test_datatable_neg():
    """
    Generic negative tests
    """
    with pytest.raises(ValueError):
        DataTable(HugeSetOfValues)

    with pytest.raises(ValueError):
        DataTable(NotSupportedCls)

Related