composite-enum 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- composite_enum/__init__.py +24 -0
- composite_enum/_meta.py +185 -0
- composite_enum/_meta.pyi +41 -0
- composite_enum/py.typed +0 -0
- composite_enum-0.1.0.dist-info/METADATA +425 -0
- composite_enum-0.1.0.dist-info/RECORD +8 -0
- composite_enum-0.1.0.dist-info/WHEEL +4 -0
- composite_enum-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Compose enum types by including members from other enums.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from composite_enum import CompositeEnum
|
|
6
|
+
|
|
7
|
+
class Operator(Enum):
|
|
8
|
+
UNION = "|"
|
|
9
|
+
INTERSECT = "&"
|
|
10
|
+
|
|
11
|
+
class TokenType(CompositeEnum, includes=Operator):
|
|
12
|
+
IDENT = "IDENT"
|
|
13
|
+
ASSIGN = "="
|
|
14
|
+
|
|
15
|
+
assert TokenType.UNION.value == "|"
|
|
16
|
+
assert TokenType.UNION.source_enum is Operator
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
|
|
21
|
+
from composite_enum._meta import CompositeEnum, CompositeEnumMeta
|
|
22
|
+
|
|
23
|
+
__all__ = ["CompositeEnum", "CompositeEnumMeta"]
|
|
24
|
+
__version__ = version("composite-enum")
|
composite_enum/_meta.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Metaclass and base class for composing enum types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from enum import Enum, EnumMeta, Flag
|
|
7
|
+
from functools import cache
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_data_type(bases: tuple[type, ...]) -> type | None:
|
|
13
|
+
"""Return the mixin data type (str, int, …) from enum bases, or None."""
|
|
14
|
+
for base in bases:
|
|
15
|
+
for cls in base.__mro__:
|
|
16
|
+
if cls is object or isinstance(cls, EnumMeta):
|
|
17
|
+
continue
|
|
18
|
+
return cls
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_flag(source: type[Enum]) -> None:
|
|
23
|
+
if issubclass(source, Flag):
|
|
24
|
+
raise TypeError(
|
|
25
|
+
f"{source.__name__} is a Flag enum. Flag composition is not "
|
|
26
|
+
f"supported because bitwise semantics across unrelated Flag "
|
|
27
|
+
f"enums are ambiguous. Use plain Enum, StrEnum, or IntEnum."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_data_type(
|
|
32
|
+
source: type[Enum],
|
|
33
|
+
member: Enum,
|
|
34
|
+
expected: type | None,
|
|
35
|
+
target_name: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
if expected is not None and not isinstance(member.value, expected):
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"{source.__name__}.{member.name} has value {member.value!r} "
|
|
40
|
+
f"({type(member.value).__name__}), but {target_name} requires "
|
|
41
|
+
f"{expected.__name__} values"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_duplicate_sources(includes: tuple[type[Enum], ...]) -> None:
|
|
46
|
+
seen: set[type[Enum]] = set()
|
|
47
|
+
for source in includes:
|
|
48
|
+
if source in seen:
|
|
49
|
+
raise TypeError(f"duplicate source enum in includes: {source.__name__}")
|
|
50
|
+
seen.add(source)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_includes(
|
|
54
|
+
includes: type[Enum] | Sequence[type[Enum]],
|
|
55
|
+
) -> tuple[type[Enum], ...]:
|
|
56
|
+
"""Wrap a bare enum class or sequence into a tuple, rejecting strings."""
|
|
57
|
+
if isinstance(includes, Sequence) and not isinstance(includes, str):
|
|
58
|
+
return tuple(includes)
|
|
59
|
+
return (includes,) # type: ignore[return-value]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_RESERVED_NAMES = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"source_enum",
|
|
65
|
+
"included_enums",
|
|
66
|
+
"includes_enum",
|
|
67
|
+
"members_from",
|
|
68
|
+
"to_source",
|
|
69
|
+
"from_source",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_source_enum(self: Enum) -> type[Enum] | None:
|
|
75
|
+
"""The source enum this member was included from, or None."""
|
|
76
|
+
return self.__class__._composite_source_map_.get(self.name) # type: ignore[attr-defined]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_source(self: Enum) -> Enum | None:
|
|
80
|
+
"""Convert this composite member back to its source enum member, or None."""
|
|
81
|
+
source_map = self.__class__._composite_source_map_ # type: ignore[attr-defined]
|
|
82
|
+
source: type[Enum] | None = source_map.get(self.name)
|
|
83
|
+
if source is None:
|
|
84
|
+
return None
|
|
85
|
+
return source[self.name]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CompositeEnumMeta(EnumMeta):
|
|
89
|
+
"""Metaclass that composes members from other enums into a new one."""
|
|
90
|
+
|
|
91
|
+
_composite_source_map_: Mapping[str, type[Enum]]
|
|
92
|
+
_composite_includes_: tuple[type[Enum], ...]
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def __prepare__(
|
|
96
|
+
mcls,
|
|
97
|
+
name: str,
|
|
98
|
+
bases: tuple[type, ...],
|
|
99
|
+
includes: type[Enum] | Sequence[type[Enum]] = (),
|
|
100
|
+
**kwds: Any,
|
|
101
|
+
):
|
|
102
|
+
namespace = super().__prepare__(name, bases, **kwds)
|
|
103
|
+
includes = _normalize_includes(includes)
|
|
104
|
+
_check_duplicate_sources(includes)
|
|
105
|
+
|
|
106
|
+
data_type = _get_data_type(bases)
|
|
107
|
+
seen: dict[str, type[Enum]] = {}
|
|
108
|
+
|
|
109
|
+
for source in includes:
|
|
110
|
+
if not isinstance(source, EnumMeta):
|
|
111
|
+
raise TypeError(
|
|
112
|
+
f"includes expects Enum types, got "
|
|
113
|
+
f"{type(source).__name__}: {source!r}"
|
|
114
|
+
)
|
|
115
|
+
_check_flag(source)
|
|
116
|
+
|
|
117
|
+
for member_name, member in source.__members__.items():
|
|
118
|
+
if member_name in seen:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Name '{member_name}' exists in both "
|
|
121
|
+
f"{seen[member_name].__name__} and {source.__name__}"
|
|
122
|
+
)
|
|
123
|
+
_check_data_type(source, member, data_type, name)
|
|
124
|
+
|
|
125
|
+
seen[member_name] = source
|
|
126
|
+
# _EnumDict.__setitem__ registers this as an enum member candidate.
|
|
127
|
+
namespace[member_name] = member.value
|
|
128
|
+
|
|
129
|
+
return namespace
|
|
130
|
+
|
|
131
|
+
def __new__(
|
|
132
|
+
mcls,
|
|
133
|
+
name: str,
|
|
134
|
+
bases: tuple[type, ...],
|
|
135
|
+
namespace: dict[str, Any],
|
|
136
|
+
includes: type[Enum] | Sequence[type[Enum]] = (),
|
|
137
|
+
**kwds: Any,
|
|
138
|
+
):
|
|
139
|
+
cls = super().__new__(mcls, name, bases, namespace, **kwds) # type: ignore[arg-type]
|
|
140
|
+
|
|
141
|
+
for member_name in cls.__members__:
|
|
142
|
+
if member_name in _RESERVED_NAMES:
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"'{member_name}' is reserved by CompositeEnumMeta "
|
|
145
|
+
f"and cannot be used as a member name"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
includes = _normalize_includes(includes)
|
|
149
|
+
|
|
150
|
+
source_map: dict[str, type[Enum]] = {}
|
|
151
|
+
for source in includes:
|
|
152
|
+
for member_name in source.__members__:
|
|
153
|
+
source_map[member_name] = source
|
|
154
|
+
|
|
155
|
+
cls._composite_source_map_ = MappingProxyType(source_map)
|
|
156
|
+
cls._composite_includes_ = includes
|
|
157
|
+
cls.source_enum = property(_get_source_enum) # type: ignore[attr-defined]
|
|
158
|
+
cls.to_source = _to_source # type: ignore[attr-defined]
|
|
159
|
+
return cls
|
|
160
|
+
|
|
161
|
+
def included_enums(cls) -> tuple[type[Enum], ...]:
|
|
162
|
+
"""Return the source enums this composite was built from."""
|
|
163
|
+
return getattr(cls, "_composite_includes_", ())
|
|
164
|
+
|
|
165
|
+
def includes_enum(cls, source: type[Enum]) -> bool:
|
|
166
|
+
"""Check if this composite includes members from *source*."""
|
|
167
|
+
return source in cls.included_enums()
|
|
168
|
+
|
|
169
|
+
@cache
|
|
170
|
+
def members_from(cls, source: type[Enum]) -> frozenset[Enum]:
|
|
171
|
+
"""Return the subset of members that originated from *source*."""
|
|
172
|
+
source_map = getattr(cls, "_composite_source_map_", {})
|
|
173
|
+
return frozenset(cls[name] for name, src in source_map.items() if src is source) # type: ignore[arg-type]
|
|
174
|
+
|
|
175
|
+
def from_source(cls, member: Enum) -> Enum | None:
|
|
176
|
+
"""Convert a source enum member to its composite equivalent, or None."""
|
|
177
|
+
source_map = getattr(cls, "_composite_source_map_", {})
|
|
178
|
+
source = source_map.get(member.name)
|
|
179
|
+
if source is None or source is not type(member):
|
|
180
|
+
return None
|
|
181
|
+
return cls[member.name] # type: ignore[return-value]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class CompositeEnum(Enum, metaclass=CompositeEnumMeta):
|
|
185
|
+
"""Enum base class with composition support."""
|
composite_enum/_meta.pyi
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from enum import Enum, EnumMeta
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from typing_extensions import Self
|
|
6
|
+
|
|
7
|
+
class CompositeEnumMeta(EnumMeta):
|
|
8
|
+
_composite_source_map_: Mapping[str, type[Enum]]
|
|
9
|
+
_composite_includes_: tuple[type[Enum], ...]
|
|
10
|
+
|
|
11
|
+
def __new__(
|
|
12
|
+
mcls,
|
|
13
|
+
name: str,
|
|
14
|
+
bases: tuple[type, ...],
|
|
15
|
+
namespace: dict[str, Any],
|
|
16
|
+
includes: type[Enum] | Sequence[type[Enum]] = (),
|
|
17
|
+
**kwds: Any,
|
|
18
|
+
) -> CompositeEnumMeta: ...
|
|
19
|
+
def included_enums(cls) -> tuple[type[Enum], ...]: ...
|
|
20
|
+
def includes_enum(cls, source: type[Enum]) -> bool: ...
|
|
21
|
+
def members_from(cls, source: type[Enum]) -> frozenset[Enum]: ...
|
|
22
|
+
def from_source(cls, member: Enum) -> Enum | None: ...
|
|
23
|
+
|
|
24
|
+
class CompositeEnum(Enum, metaclass=CompositeEnumMeta):
|
|
25
|
+
def __init_subclass__(
|
|
26
|
+
cls,
|
|
27
|
+
*,
|
|
28
|
+
includes: type[Enum] | Sequence[type[Enum]] = (),
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> None: ...
|
|
31
|
+
@property
|
|
32
|
+
def source_enum(self) -> type[Enum] | None: ...
|
|
33
|
+
def to_source(self) -> Enum | None: ...
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_source(cls, member: Enum) -> Self | None: ... # type: ignore[override]
|
|
36
|
+
@classmethod
|
|
37
|
+
def members_from(cls, source: type[Enum]) -> frozenset[Self]: ... # type: ignore[override]
|
|
38
|
+
@classmethod
|
|
39
|
+
def included_enums(cls) -> tuple[type[Enum], ...]: ...
|
|
40
|
+
@classmethod
|
|
41
|
+
def includes_enum(cls, source: type[Enum]) -> bool: ...
|
composite_enum/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: composite-enum
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build superset enums by composing members from other enums
|
|
5
|
+
Project-URL: Homepage, https://github.com/isaacfuenmayora/composite-enum
|
|
6
|
+
Project-URL: Repository, https://github.com/isaacfuenmayora/composite-enum
|
|
7
|
+
Project-URL: Issues, https://github.com/isaacfuenmayora/composite-enum/issues
|
|
8
|
+
Author-email: Isaac Fuenmayor <contact@hireisaac.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: composition,enum,metaclass
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# composite-enum
|
|
27
|
+
|
|
28
|
+
[](https://github.com/isaacfuenmayora/composite-enum/actions/workflows/ci.yml)
|
|
29
|
+
[](https://pypi.org/project/composite-enum/)
|
|
30
|
+
[](https://pypi.org/project/composite-enum/)
|
|
31
|
+
[](https://microsoft.github.io/pyright/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
Build superset enums by composing members from other enums. Included
|
|
35
|
+
members become real first-class members of the new enum, with introspection
|
|
36
|
+
back to their origin.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from composite_enum import CompositeEnum
|
|
41
|
+
|
|
42
|
+
class Operator(Enum):
|
|
43
|
+
UNION = "|"
|
|
44
|
+
INTERSECT = "&"
|
|
45
|
+
DIFF = "-"
|
|
46
|
+
SYM_DIFF = "^"
|
|
47
|
+
|
|
48
|
+
class TokenType(CompositeEnum, includes=Operator):
|
|
49
|
+
IDENT = "IDENT"
|
|
50
|
+
STRING = "STRING"
|
|
51
|
+
ASSIGN = "="
|
|
52
|
+
LPAREN = "("
|
|
53
|
+
RPAREN = ")"
|
|
54
|
+
|
|
55
|
+
# TokenType has all 9 members: 4 from Operator + 5 of its own
|
|
56
|
+
list(TokenType)
|
|
57
|
+
# [UNION, INTERSECT, DIFF, SYM_DIFF, IDENT, STRING, ASSIGN, LPAREN, RPAREN]
|
|
58
|
+
|
|
59
|
+
# Included members are real members
|
|
60
|
+
TokenType.UNION # <TokenType.UNION: '|'>
|
|
61
|
+
TokenType.UNION.value # '|'
|
|
62
|
+
TokenType("|") # <TokenType.UNION: '|'>
|
|
63
|
+
TokenType["UNION"] # <TokenType.UNION: '|'>
|
|
64
|
+
|
|
65
|
+
# But they know where they came from
|
|
66
|
+
TokenType.UNION.source_enum # <enum 'Operator'>
|
|
67
|
+
TokenType.UNION.to_source() # <Operator.UNION: '|'>
|
|
68
|
+
TokenType.from_source(Operator.UNION) # <TokenType.UNION: '|'>
|
|
69
|
+
TokenType.IDENT.source_enum # None (defined directly)
|
|
70
|
+
TokenType.members_from(Operator) # frozenset({UNION, INTERSECT, DIFF, SYM_DIFF})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install composite-enum
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/isaacfuenmayora/composite-enum
|
|
83
|
+
cd composite-enum
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
With uv:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv sync
|
|
90
|
+
uv run pytest
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With pip:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install -e . && pip install pytest
|
|
97
|
+
pytest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Why
|
|
101
|
+
|
|
102
|
+
Python's `Enum` doesn't allow subclassing an enum that already has members.
|
|
103
|
+
This is intentional ([docs](https://docs.python.org/3/howto/enum.html#restricted-enum-subclassing)),
|
|
104
|
+
but it means you can't express "TokenType is Operator plus some extra token
|
|
105
|
+
types" through inheritance. You end up duplicating the values and hoping
|
|
106
|
+
they stay in sync.
|
|
107
|
+
|
|
108
|
+
This restriction exists for good reason.
|
|
109
|
+
[`flufl.enum`](https://gitlab.com/flufl/flufl.enum), the precursor to
|
|
110
|
+
Python's stdlib `enum`, supported member inheritance natively. That
|
|
111
|
+
feature was dropped in [PEP 435](https://peps.python.org/pep-0435/) because it conflicts with members being
|
|
112
|
+
instances of their enum class. CPython core developer Alyssa Coghlan
|
|
113
|
+
[later speculated](https://python-notes.curiousefficiency.org/en/latest/python3/enum_creation.html#support-for-alternate-declaration-syntaxes)
|
|
114
|
+
that extensible enums would require aggregating members from multiple
|
|
115
|
+
independent enumerations, sketching a hypothetical syntax:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
class MoreColors(AggregateEnum, extends=Color):
|
|
119
|
+
cyan = ...
|
|
120
|
+
magenta = ...
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This was never implemented in the stdlib. `composite-enum` takes a
|
|
124
|
+
similar approach using `includes` instead of `extends`.
|
|
125
|
+
|
|
126
|
+
`composite-enum` solves this with a metaclass that injects source enum
|
|
127
|
+
members into the new enum's namespace during class creation.
|
|
128
|
+
|
|
129
|
+
## Usage
|
|
130
|
+
|
|
131
|
+
The opening example covers the basics. Here's what else you can do.
|
|
132
|
+
|
|
133
|
+
### Multiple sources
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
class Delimiter(Enum):
|
|
137
|
+
COMMA = ","
|
|
138
|
+
SEMICOLON = ";"
|
|
139
|
+
|
|
140
|
+
class TokenType(CompositeEnum, includes=(Operator, Delimiter)):
|
|
141
|
+
IDENT = "IDENT"
|
|
142
|
+
STRING = "STRING"
|
|
143
|
+
ASSIGN = "="
|
|
144
|
+
LPAREN = "("
|
|
145
|
+
RPAREN = ")"
|
|
146
|
+
|
|
147
|
+
TokenType.included_enums() # (Operator, Delimiter)
|
|
148
|
+
|
|
149
|
+
# Included members appear first, in includes order, then class body
|
|
150
|
+
list(TokenType)
|
|
151
|
+
# [UNION, INTERSECT, DIFF, SYM_DIFF, COMMA, SEMICOLON, IDENT, STRING, ASSIGN, LPAREN, RPAREN]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### With StrEnum / IntEnum
|
|
155
|
+
|
|
156
|
+
`CompositeEnum` can't be used alongside `StrEnum` or `IntEnum`
|
|
157
|
+
(Python's enum inheritance rules). Use the metaclass directly:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from enum import StrEnum # 3.11+
|
|
161
|
+
from composite_enum import CompositeEnumMeta
|
|
162
|
+
|
|
163
|
+
class TokenType(StrEnum, metaclass=CompositeEnumMeta, includes=Operator):
|
|
164
|
+
IDENT = "IDENT"
|
|
165
|
+
|
|
166
|
+
isinstance(TokenType.UNION, str) # True
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The metaclass validates that included values match the target's data
|
|
170
|
+
type. All introspection methods work the same either way.
|
|
171
|
+
|
|
172
|
+
The same metaclass approach works for any data type mixin, not just
|
|
173
|
+
`StrEnum` and `IntEnum`. Use `(float, Enum)`, `(bytes, Enum)`, or
|
|
174
|
+
any custom type:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
class Voltage(Enum):
|
|
178
|
+
LOW = 3.3
|
|
179
|
+
HIGH = 5.0
|
|
180
|
+
|
|
181
|
+
class Signal(float, Enum, metaclass=CompositeEnumMeta, includes=Voltage):
|
|
182
|
+
GROUND = 0.0
|
|
183
|
+
|
|
184
|
+
isinstance(Signal.LOW, float) # True
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
> **Note:** Type checkers have two limitations with the
|
|
188
|
+
> `metaclass=CompositeEnumMeta` approach:
|
|
189
|
+
>
|
|
190
|
+
> 1. They may flag the `includes` keyword, since they don't infer class
|
|
191
|
+
> keywords from metaclass signatures. Add `# type: ignore[call-arg]`
|
|
192
|
+
> to suppress this.
|
|
193
|
+
> 2. The instance-level attributes `source_enum` and `to_source()` won't
|
|
194
|
+
> be visible to type checkers, because the `.pyi` stub declares these
|
|
195
|
+
> on `CompositeEnum`, not on arbitrary metaclass-created classes. The
|
|
196
|
+
> class-level methods (`from_source()`, `members_from()`,
|
|
197
|
+
> `included_enums()`, `includes_enum()`) work fine on both paths since
|
|
198
|
+
> they're declared on the metaclass. Subclassing `CompositeEnum` is the
|
|
199
|
+
> type-checker-friendly path: `from_source()` narrows to `Self | None`
|
|
200
|
+
> and `members_from()` to `frozenset[Self]`.
|
|
201
|
+
>
|
|
202
|
+
> Both work correctly at runtime regardless. Note that type checkers
|
|
203
|
+
> cannot resolve dynamically injected member names (e.g.
|
|
204
|
+
> `TokenType.UNION`) on either path. This is a general limitation of
|
|
205
|
+
> enum metaclasses, not specific to `composite-enum`.
|
|
206
|
+
|
|
207
|
+
### Nested composition
|
|
208
|
+
|
|
209
|
+
Composing from an already-composite enum works. `source_enum` points
|
|
210
|
+
to the immediate source, not the original:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
class Base(CompositeEnum, includes=Operator):
|
|
214
|
+
IDENT = "IDENT"
|
|
215
|
+
|
|
216
|
+
class Extended(CompositeEnum, includes=Base):
|
|
217
|
+
EXTRA = "extra"
|
|
218
|
+
|
|
219
|
+
Extended.UNION.source_enum # <enum 'Base'>, not Operator
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## API Reference
|
|
223
|
+
|
|
224
|
+
### `CompositeEnum`
|
|
225
|
+
|
|
226
|
+
Base class for composition. Extend this instead of `Enum`.
|
|
227
|
+
|
|
228
|
+
### `CompositeEnumMeta`
|
|
229
|
+
|
|
230
|
+
The metaclass powering composition. Use directly when you need
|
|
231
|
+
`StrEnum`, `IntEnum`, etc. as the base type.
|
|
232
|
+
|
|
233
|
+
#### `includes` (class keyword)
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
class TokenType(CompositeEnum, includes=Operator): # single source
|
|
237
|
+
class TokenType(CompositeEnum, includes=(Operator, Delimiter)): # multiple sources
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A single `Enum` type or a sequence of them whose members should be included.
|
|
241
|
+
|
|
242
|
+
#### `member.source_enum`
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
TokenType.UNION.source_enum # <enum 'Operator'>
|
|
246
|
+
TokenType.IDENT.source_enum # None
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The source enum this member was included from, or `None`.
|
|
250
|
+
|
|
251
|
+
#### `member.to_source()`
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
TokenType.UNION.to_source() # Operator.UNION
|
|
255
|
+
TokenType.IDENT.to_source() # None
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Convert a composite member back to its source enum member. Returns
|
|
259
|
+
`None` for members defined directly on the composite.
|
|
260
|
+
|
|
261
|
+
#### `cls.from_source(member)`
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
TokenType.from_source(Operator.UNION) # TokenType.UNION
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Convert a source enum member to its composite equivalent. Returns
|
|
268
|
+
`None` when there's no match.
|
|
269
|
+
|
|
270
|
+
#### `cls.members_from(source)`
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
TokenType.members_from(Operator)
|
|
274
|
+
# frozenset({TokenType.UNION, TokenType.INTERSECT, ...})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Returns a `frozenset` of members that originated from `source`.
|
|
278
|
+
|
|
279
|
+
#### `cls.included_enums()` / `cls.includes_enum(source)`
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
TokenType.included_enums() # (Operator, Delimiter)
|
|
283
|
+
TokenType.includes_enum(Operator) # True
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Introspect which source enums were composed in.
|
|
287
|
+
|
|
288
|
+
## Supported Enum Types
|
|
289
|
+
|
|
290
|
+
| Base type | Python | Supported | How |
|
|
291
|
+
|---|---|---|---|
|
|
292
|
+
| `Enum` | 3.10+ | Yes | `CompositeEnum` base class |
|
|
293
|
+
| `StrEnum` | 3.11+ | Yes | `metaclass=CompositeEnumMeta` |
|
|
294
|
+
| `IntEnum` | 3.10+ | Yes | `metaclass=CompositeEnumMeta` |
|
|
295
|
+
| `str, Enum` mixin | 3.10+ | Yes | `metaclass=CompositeEnumMeta` |
|
|
296
|
+
| `int, Enum` mixin | 3.10+ | Yes | `metaclass=CompositeEnumMeta` |
|
|
297
|
+
| `Flag` | any | No | Bitwise semantics across unrelated Flags are ambiguous |
|
|
298
|
+
| `IntFlag` | any | No | Same as Flag |
|
|
299
|
+
|
|
300
|
+
### Source enum types
|
|
301
|
+
|
|
302
|
+
Source enums (the ones in `includes`) can be any `Enum`, `StrEnum`, or
|
|
303
|
+
`IntEnum`. Their values must be compatible with the target's data type:
|
|
304
|
+
|
|
305
|
+
| Target type | Accepted source values |
|
|
306
|
+
|---|---|
|
|
307
|
+
| `Enum` (plain) | Anything |
|
|
308
|
+
| `StrEnum` / `str, Enum` | Must be `str` |
|
|
309
|
+
| `IntEnum` / `int, Enum` | Must be `int` |
|
|
310
|
+
|
|
311
|
+
## Caveats
|
|
312
|
+
|
|
313
|
+
**Implementation detail dependency.** The metaclass injects members via
|
|
314
|
+
`_EnumDict.__setitem__`, which is an implementation detail of CPython's
|
|
315
|
+
enum module. It's been stable since Python 3.6 and is unlikely to
|
|
316
|
+
change, but it's not a guaranteed public API. Tested on 3.10 through
|
|
317
|
+
3.15.
|
|
318
|
+
|
|
319
|
+
**Source members are not `in` the composite.** `Enum.__contains__`
|
|
320
|
+
uses `isinstance`, so `Operator.UNION in TokenType` is `False` even
|
|
321
|
+
though `TokenType.UNION` exists with the same value. Use
|
|
322
|
+
`TokenType.from_source(Operator.UNION)` to check membership.
|
|
323
|
+
|
|
324
|
+
**Reserved member names.** The names `source_enum`, `included_enums`,
|
|
325
|
+
`includes_enum`, `members_from`, `to_source`, and `from_source` are
|
|
326
|
+
reserved by the metaclass. Using any of them as a member name raises `TypeError` at class creation.
|
|
327
|
+
|
|
328
|
+
**Source methods don't transfer.** Only member names and values are
|
|
329
|
+
composed. Methods, properties, and custom `__init__` defined on a
|
|
330
|
+
source enum are not carried over to the composite.
|
|
331
|
+
|
|
332
|
+
**Source enum aliases are preserved.** If a source enum has aliases
|
|
333
|
+
(multiple names for the same value), they transfer as aliases in the
|
|
334
|
+
composite too:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
class Source(Enum):
|
|
338
|
+
PRIMARY = 1
|
|
339
|
+
ALIAS = 1 # alias of PRIMARY
|
|
340
|
+
|
|
341
|
+
class Target(CompositeEnum, includes=Source):
|
|
342
|
+
EXTRA = "extra"
|
|
343
|
+
|
|
344
|
+
Target.PRIMARY # <Target.PRIMARY: 1>
|
|
345
|
+
Target["ALIAS"] # <Target.PRIMARY: 1> (alias, same as source)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Value aliases across sources.** If two included sources share a value
|
|
349
|
+
(different name, same value), the second name becomes an alias of the
|
|
350
|
+
first. This is standard enum behavior, not composite-specific, but it
|
|
351
|
+
has implications for introspection:
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
class A(Enum):
|
|
355
|
+
X = 1
|
|
356
|
+
|
|
357
|
+
class B(Enum):
|
|
358
|
+
Y = 1
|
|
359
|
+
|
|
360
|
+
class Combined(CompositeEnum, includes=(A, B)):
|
|
361
|
+
Z = 2
|
|
362
|
+
|
|
363
|
+
Combined.Y # <Combined.X: 1> (Y is an alias)
|
|
364
|
+
Combined.from_source(B.Y) # <Combined.X: 1>
|
|
365
|
+
Combined.from_source(B.Y).source_enum # <enum 'A'> (not B)
|
|
366
|
+
Combined.from_source(B.Y).to_source() # <A.X: 1> (not B.Y)
|
|
367
|
+
Combined.members_from(A) # frozenset({<Combined.X: 1>})
|
|
368
|
+
Combined.members_from(B) # frozenset({<Combined.X: 1>}) (same member)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Because `Y` is an alias for `X`, the canonical member's `source_enum`
|
|
372
|
+
always points to whichever source provided the canonical name (`A`),
|
|
373
|
+
regardless of which source you used in `from_source()`. Likewise,
|
|
374
|
+
`members_from()` returns the canonical member for both sources.
|
|
375
|
+
|
|
376
|
+
## How It Works
|
|
377
|
+
|
|
378
|
+
The metaclass overrides `__prepare__` and `__new__`:
|
|
379
|
+
|
|
380
|
+
1. **`__prepare__`** runs before the class body executes. It creates the
|
|
381
|
+
standard `_EnumDict` namespace, then injects each source enum's
|
|
382
|
+
members via `namespace[name] = value`. `_EnumDict.__setitem__`
|
|
383
|
+
registers these as member candidates. This means included members
|
|
384
|
+
appear first in iteration order.
|
|
385
|
+
|
|
386
|
+
2. The **class body** executes next, adding its own members. If a name
|
|
387
|
+
collides with an already-injected member, `_EnumDict` raises
|
|
388
|
+
`TypeError` immediately.
|
|
389
|
+
|
|
390
|
+
3. **`__new__`** builds the actual enum class via `super().__new__()`,
|
|
391
|
+
then attaches metadata for introspection.
|
|
392
|
+
|
|
393
|
+
The result is a normal stdlib `Enum`. Standard tools like `isinstance`,
|
|
394
|
+
`pickle`, `match/case`, and `list()` all work exactly as they would
|
|
395
|
+
with any hand-written enum. The only additions are the introspection
|
|
396
|
+
methods (`source_enum`, `to_source`, etc.).
|
|
397
|
+
|
|
398
|
+
## Alternatives
|
|
399
|
+
|
|
400
|
+
- **[flufl.enum](https://fluflenum.readthedocs.io/en/stable/using.html#extending-an-enumeration-through-subclassing)**
|
|
401
|
+
is the original Python enum package (predating the stdlib) and still
|
|
402
|
+
supports member inheritance natively. If you want true subclassing
|
|
403
|
+
where parent and child share member identity, and you don't need to
|
|
404
|
+
stay on the stdlib `enum`, `flufl.enum` is actively maintained and
|
|
405
|
+
battle-tested since 2004.
|
|
406
|
+
|
|
407
|
+
- **[aenum](https://github.com/ethanfurman/aenum)** by the stdlib `enum`
|
|
408
|
+
maintainer provides `extend_enum()` for adding members to an existing enum
|
|
409
|
+
at runtime. If you need to modify enums you don't control,
|
|
410
|
+
`aenum` is the mature, well-established choice.
|
|
411
|
+
|
|
412
|
+
- **[extendable-enum](https://pypi.org/project/extendable-enum/)** takes a decorator approach: `@inheritable_enum` makes an existing enum subclassable (so `class Derived(Base):` works directly), while `@copy_enum_members` copies members from one enum into a new, distinct class.
|
|
413
|
+
|
|
414
|
+
- **[unionenum.py](https://gist.github.com/plammens/ab1a2f236b5c6d748f193eb12eefa6dd)**
|
|
415
|
+
is a clever gist that creates union enums where members retain their
|
|
416
|
+
original type identity rather than becoming members of the new class.
|
|
417
|
+
|
|
418
|
+
`composite-enum` occupies a slightly different niche: declarative
|
|
419
|
+
composition of one or more source enums at class-definition time, with
|
|
420
|
+
source tracking and type compatibility checks. If one of the above fits
|
|
421
|
+
your use case better, use it.
|
|
422
|
+
|
|
423
|
+
## License
|
|
424
|
+
|
|
425
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
composite_enum/__init__.py,sha256=60FxfZ_SSyBjRVOFziEPHF_s8-fS-zCzVRpa4iIkhsI,581
|
|
2
|
+
composite_enum/_meta.py,sha256=pipggRW6ld34CDmHQXX4RQTAUHxWsa8VecX1OFYpJj0,6478
|
|
3
|
+
composite_enum/_meta.pyi,sha256=Mh8X53Ig_7v_Z8QiUPot7s75EqG-TvE5Pnz_tO0Bjpo,1439
|
|
4
|
+
composite_enum/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
composite_enum-0.1.0.dist-info/METADATA,sha256=4d7H7hzpSSiqXWhIfs5DDwPcvD2ee17E5Ng8qme3JQ8,14662
|
|
6
|
+
composite_enum-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
composite_enum-0.1.0.dist-info/licenses/LICENSE,sha256=PeXp253EG6h9NhmAnB6YJhPna90wJRJN5dA7jE0gMNA,1072
|
|
8
|
+
composite_enum-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Fuenmayor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|