composite-enum 0.1.0__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
15
+ continue-on-error: ${{ matrix.python-version == '3.15' }}
16
+ steps:
17
+ - uses: actions/checkout@v7.0.1
18
+ - uses: actions/setup-python@v7
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ allow-prereleases: true
22
+ - run: pip install -e . && pip install pytest
23
+ - run: pytest -v
@@ -0,0 +1,20 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v7.0.1
15
+ - uses: actions/setup-python@v7
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,43 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+ *.egg
14
+
15
+ # Unit test / coverage reports
16
+ .pytest_cache/
17
+ .tox/
18
+ .nox/
19
+ .coverage
20
+ .coverage.*
21
+ htmlcov/
22
+ coverage.xml
23
+
24
+ # Environments
25
+ .env
26
+ .venv
27
+ venv/
28
+ env/
29
+
30
+ # Type checkers
31
+ .mypy_cache/
32
+ .pytype/
33
+ .pyre/
34
+
35
+ # Linting
36
+ .ruff_cache/
37
+
38
+ # PyPI configuration
39
+ .pypirc
40
+
41
+ # IDE
42
+ .idea/
43
+ .vscode/
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [0.1.0] - 2026-09-08
9
+
10
+ ### Added
11
+
12
+ - `CompositeEnum` base class for composing members from other enums
13
+ - `CompositeEnumMeta` metaclass for use with `StrEnum`, `IntEnum`, and custom data type mixins
14
+ - Introspection API: `source_enum`, `to_source()`, `from_source()`, `members_from()`, `included_enums()`, `includes_enum()`
15
+ - Support for nested composition (composing from an already-composite enum)
16
+ - Data type validation (e.g., rejects `int` values into a `StrEnum` target)
17
+ - `Flag` / `IntFlag` rejection with clear error message
18
+ - PEP 561 `py.typed` marker for type checker support
@@ -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.
@@ -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
+ [![CI](https://github.com/isaacfuenmayora/composite-enum/actions/workflows/ci.yml/badge.svg)](https://github.com/isaacfuenmayora/composite-enum/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/composite-enum)](https://pypi.org/project/composite-enum/)
30
+ [![Python](https://img.shields.io/pypi/pyversions/composite-enum)](https://pypi.org/project/composite-enum/)
31
+ [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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