Build superset enums by composing members from other enums. Included members become real first-class members of the new enum, with introspection back to their origin.
from enum import Enum
from composite_enum import CompositeEnum
class Operator(Enum):
UNION = "|"
INTERSECT = "&"
DIFF = "-"
SYM_DIFF = "^"
class TokenType(CompositeEnum, includes=Operator):
IDENT = "IDENT"
STRING = "STRING"
ASSIGN = "="
LPAREN = "("
RPAREN = ")"
# TokenType has all 9 members: 4 from Operator + 5 of its own
list(TokenType)
# [UNION, INTERSECT, DIFF, SYM_DIFF, IDENT, STRING, ASSIGN, LPAREN, RPAREN]
# Included members are real members
TokenType.UNION # <TokenType.UNION: '|'>
TokenType.UNION.value # '|'
TokenType("|") # <TokenType.UNION: '|'>
TokenType["UNION"] # <TokenType.UNION: '|'>
# But they know where they came from
TokenType.UNION.source_enum # <enum 'Operator'>
TokenType.UNION.to_source() # <Operator.UNION: '|'>
TokenType.from_source(Operator.UNION) # <TokenType.UNION: '|'>
TokenType.IDENT.source_enum # None (defined directly)
TokenType.members_from(Operator) # frozenset({UNION, INTERSECT, DIFF, SYM_DIFF})pip install composite-enumgit clone https://github.com/isaacfuenmayora/composite-enum
cd composite-enumWith uv:
uv sync
uv run pytestWith pip:
pip install -e . && pip install pytest
pytestPython's Enum doesn't allow subclassing an enum that already has members.
This is intentional (docs),
but it means you can't express "TokenType is Operator plus some extra token
types" through inheritance. You end up duplicating the values and hoping
they stay in sync.
This restriction exists for good reason.
flufl.enum, the precursor to
Python's stdlib enum, supported member inheritance natively. That
feature was dropped in PEP 435 because it conflicts with members being
instances of their enum class. CPython core developer Alyssa Coghlan
later speculated
that extensible enums would require aggregating members from multiple
independent enumerations, sketching a hypothetical syntax:
class MoreColors(AggregateEnum, extends=Color):
cyan = ...
magenta = ...This was never implemented in the stdlib. composite-enum takes a
similar approach using includes instead of extends.
composite-enum solves this with a metaclass that injects source enum
members into the new enum's namespace during class creation.
The opening example covers the basics. Here's what else you can do.
class Delimiter(Enum):
COMMA = ","
SEMICOLON = ";"
class TokenType(CompositeEnum, includes=(Operator, Delimiter)):
IDENT = "IDENT"
STRING = "STRING"
ASSIGN = "="
LPAREN = "("
RPAREN = ")"
TokenType.included_enums() # (Operator, Delimiter)
# Included members appear first, in includes order, then class body
list(TokenType)
# [UNION, INTERSECT, DIFF, SYM_DIFF, COMMA, SEMICOLON, IDENT, STRING, ASSIGN, LPAREN, RPAREN]CompositeEnum can't be used alongside StrEnum or IntEnum
(Python's enum inheritance rules). Use the metaclass directly:
from enum import StrEnum # 3.11+
from composite_enum import CompositeEnumMeta
class TokenType(StrEnum, metaclass=CompositeEnumMeta, includes=Operator):
IDENT = "IDENT"
isinstance(TokenType.UNION, str) # TrueThe metaclass validates that included values match the target's data type. All introspection methods work the same either way.
The same metaclass approach works for any data type mixin, not just
StrEnum and IntEnum. Use (float, Enum), (bytes, Enum), or
any custom type:
class Voltage(Enum):
LOW = 3.3
HIGH = 5.0
class Signal(float, Enum, metaclass=CompositeEnumMeta, includes=Voltage):
GROUND = 0.0
isinstance(Signal.LOW, float) # TrueNote: Type checkers have two limitations with the
metaclass=CompositeEnumMetaapproach:
- They may flag the
includeskeyword, since they don't infer class keywords from metaclass signatures. Add# type: ignore[call-arg]to suppress this.- The instance-level attributes
source_enumandto_source()won't be visible to type checkers, because the.pyistub declares these onCompositeEnum, not on arbitrary metaclass-created classes. The class-level methods (from_source(),members_from(),included_enums(),includes_enum()) work fine on both paths since they're declared on the metaclass. SubclassingCompositeEnumis the type-checker-friendly path:from_source()narrows toSelf | Noneandmembers_from()tofrozenset[Self].Both work correctly at runtime regardless. Note that type checkers cannot resolve dynamically injected member names (e.g.
TokenType.UNION) on either path. This is a general limitation of enum metaclasses, not specific tocomposite-enum.
Composing from an already-composite enum works. source_enum points
to the immediate source, not the original:
class Base(CompositeEnum, includes=Operator):
IDENT = "IDENT"
class Extended(CompositeEnum, includes=Base):
EXTRA = "extra"
Extended.UNION.source_enum # <enum 'Base'>, not OperatorBase class for composition. Extend this instead of Enum.
The metaclass powering composition. Use directly when you need
StrEnum, IntEnum, etc. as the base type.
class TokenType(CompositeEnum, includes=Operator): # single source
class TokenType(CompositeEnum, includes=(Operator, Delimiter)): # multiple sourcesA single Enum type or a sequence of them whose members should be included.
TokenType.UNION.source_enum # <enum 'Operator'>
TokenType.IDENT.source_enum # NoneThe source enum this member was included from, or None.
TokenType.UNION.to_source() # Operator.UNION
TokenType.IDENT.to_source() # NoneConvert a composite member back to its source enum member. Returns
None for members defined directly on the composite.
TokenType.from_source(Operator.UNION) # TokenType.UNIONConvert a source enum member to its composite equivalent. Returns
None when there's no match.
TokenType.members_from(Operator)
# frozenset({TokenType.UNION, TokenType.INTERSECT, ...})Returns a frozenset of members that originated from source.
TokenType.included_enums() # (Operator, Delimiter)
TokenType.includes_enum(Operator) # TrueIntrospect which source enums were composed in.
| Base type | Python | Supported | How |
|---|---|---|---|
Enum |
3.10+ | Yes | CompositeEnum base class |
StrEnum |
3.11+ | Yes | metaclass=CompositeEnumMeta |
IntEnum |
3.10+ | Yes | metaclass=CompositeEnumMeta |
str, Enum mixin |
3.10+ | Yes | metaclass=CompositeEnumMeta |
int, Enum mixin |
3.10+ | Yes | metaclass=CompositeEnumMeta |
Flag |
any | No | Bitwise semantics across unrelated Flags are ambiguous |
IntFlag |
any | No | Same as Flag |
Source enums (the ones in includes) can be any Enum, StrEnum, or
IntEnum. Their values must be compatible with the target's data type:
| Target type | Accepted source values |
|---|---|
Enum (plain) |
Anything |
StrEnum / str, Enum |
Must be str |
IntEnum / int, Enum |
Must be int |
Implementation detail dependency. The metaclass injects members via
_EnumDict.__setitem__, which is an implementation detail of CPython's
enum module. It's been stable since Python 3.6 and is unlikely to
change, but it's not a guaranteed public API. Tested on 3.10 through
3.15.
Source members are not in the composite. Enum.__contains__
uses isinstance, so Operator.UNION in TokenType is False even
though TokenType.UNION exists with the same value. Use
TokenType.from_source(Operator.UNION) to check membership.
Reserved member names. The names source_enum, included_enums,
includes_enum, members_from, to_source, and from_source are
reserved by the metaclass. Using any of them as a member name raises TypeError at class creation.
Source methods don't transfer. Only member names and values are
composed. Methods, properties, and custom __init__ defined on a
source enum are not carried over to the composite.
Source enum aliases are preserved. If a source enum has aliases (multiple names for the same value), they transfer as aliases in the composite too:
class Source(Enum):
PRIMARY = 1
ALIAS = 1 # alias of PRIMARY
class Target(CompositeEnum, includes=Source):
EXTRA = "extra"
Target.PRIMARY # <Target.PRIMARY: 1>
Target["ALIAS"] # <Target.PRIMARY: 1> (alias, same as source)Value aliases across sources. If two included sources share a value (different name, same value), the second name becomes an alias of the first. This is standard enum behavior, not composite-specific, but it has implications for introspection:
class A(Enum):
X = 1
class B(Enum):
Y = 1
class Combined(CompositeEnum, includes=(A, B)):
Z = 2
Combined.Y # <Combined.X: 1> (Y is an alias)
Combined.from_source(B.Y) # <Combined.X: 1>
Combined.from_source(B.Y).source_enum # <enum 'A'> (not B)
Combined.from_source(B.Y).to_source() # <A.X: 1> (not B.Y)
Combined.members_from(A) # frozenset({<Combined.X: 1>})
Combined.members_from(B) # frozenset({<Combined.X: 1>}) (same member)Because Y is an alias for X, the canonical member's source_enum
always points to whichever source provided the canonical name (A),
regardless of which source you used in from_source(). Likewise,
members_from() returns the canonical member for both sources.
The metaclass overrides __prepare__ and __new__:
-
__prepare__runs before the class body executes. It creates the standard_EnumDictnamespace, then injects each source enum's members vianamespace[name] = value._EnumDict.__setitem__registers these as member candidates. This means included members appear first in iteration order. -
The class body executes next, adding its own members. If a name collides with an already-injected member,
_EnumDictraisesTypeErrorimmediately. -
__new__builds the actual enum class viasuper().__new__(), then attaches metadata for introspection.
The result is a normal stdlib Enum. Standard tools like isinstance,
pickle, match/case, and list() all work exactly as they would
with any hand-written enum. The only additions are the introspection
methods (source_enum, to_source, etc.).
-
flufl.enum is the original Python enum package (predating the stdlib) and still supports member inheritance natively. If you want true subclassing where parent and child share member identity, and you don't need to stay on the stdlib
enum,flufl.enumis actively maintained and battle-tested since 2004. -
aenum by the stdlib
enummaintainer providesextend_enum()for adding members to an existing enum at runtime. If you need to modify enums you don't control,aenumis the mature, well-established choice. -
extendable-enum takes a decorator approach:
@inheritable_enummakes an existing enum subclassable (soclass Derived(Base):works directly), while@copy_enum_memberscopies members from one enum into a new, distinct class. -
unionenum.py is a clever gist that creates union enums where members retain their original type identity rather than becoming members of the new class.
composite-enum occupies a slightly different niche: declarative
composition of one or more source enums at class-definition time, with
source tracking and type compatibility checks. If one of the above fits
your use case better, use it.
MIT