Skip to content

Latest commit

 

History

26 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

composite-enum

CI PyPI Python Checked with pyright License: MIT

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})

Install

pip install composite-enum

Development

git clone https://github.com/isaacfuenmayora/composite-enum
cd composite-enum

With uv:

uv sync
uv run pytest

With pip:

pip install -e . && pip install pytest
pytest

Why

Python'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.

Usage

The opening example covers the basics. Here's what else you can do.

Multiple sources

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]

With StrEnum / IntEnum

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)  # True

The 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)  # True

Note: Type checkers have two limitations with the metaclass=CompositeEnumMeta approach:

  1. They may flag the includes keyword, since they don't infer class keywords from metaclass signatures. Add # type: ignore[call-arg] to suppress this.
  2. The instance-level attributes source_enum and to_source() won't be visible to type checkers, because the .pyi stub declares these on CompositeEnum, 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. Subclassing CompositeEnum is the type-checker-friendly path: from_source() narrows to Self | None and members_from() to frozenset[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 to composite-enum.

Nested composition

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 Operator

API Reference

CompositeEnum

Base class for composition. Extend this instead of Enum.

CompositeEnumMeta

The metaclass powering composition. Use directly when you need StrEnum, IntEnum, etc. as the base type.

includes (class keyword)

class TokenType(CompositeEnum, includes=Operator):               # single source
class TokenType(CompositeEnum, includes=(Operator, Delimiter)):  # multiple sources

A single Enum type or a sequence of them whose members should be included.

member.source_enum

TokenType.UNION.source_enum  # <enum 'Operator'>
TokenType.IDENT.source_enum  # None

The source enum this member was included from, or None.

member.to_source()

TokenType.UNION.to_source()  # Operator.UNION
TokenType.IDENT.to_source()  # None

Convert a composite member back to its source enum member. Returns None for members defined directly on the composite.

cls.from_source(member)

TokenType.from_source(Operator.UNION) # TokenType.UNION

Convert a source enum member to its composite equivalent. Returns None when there's no match.

cls.members_from(source)

TokenType.members_from(Operator)
# frozenset({TokenType.UNION, TokenType.INTERSECT, ...})

Returns a frozenset of members that originated from source.

cls.included_enums() / cls.includes_enum(source)

TokenType.included_enums()        # (Operator, Delimiter)
TokenType.includes_enum(Operator) # True

Introspect which source enums were composed in.

Supported Enum Types

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 enum types

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

Caveats

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.

How It Works

The metaclass overrides __prepare__ and __new__:

  1. __prepare__ runs before the class body executes. It creates the standard _EnumDict namespace, then injects each source enum's members via namespace[name] = value. _EnumDict.__setitem__ registers these as member candidates. This means included members appear first in iteration order.

  2. The class body executes next, adding its own members. If a name collides with an already-injected member, _EnumDict raises TypeError immediately.

  3. __new__ builds the actual enum class via super().__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.).

Alternatives

  • 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.enum is actively maintained and battle-tested since 2004.

  • aenum by the stdlib enum maintainer provides extend_enum() for adding members to an existing enum at runtime. If you need to modify enums you don't control, aenum is the mature, well-established choice.

  • 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.

  • 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.

License

MIT

About

Compose Python enums from other enums with full introspection

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages