Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[![PyPI version](https://badge.fury.io/py/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties/)
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties/)
[![PyPI status](https://img.shields.io/pypi/status/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties)
[![PyPI - Types](https://img.shields.io/pypi/types/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties)
[![Documentation Status](https://readthedocs.org/projects/enum-properties/badge/?version=latest)](http://enum-properties.readthedocs.io/?badge=latest/)
[![Code Cov](https://codecov.io/gh/bckohan/enum-properties/branch/main/graph/badge.svg?token=0IZOKN2DYL)](https://codecov.io/gh/bckohan/enum-properties)
[![Test Status](https://github.com/bckohan/enum-properties/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/bckohan/enum-properties/actions/workflows/test.yml?query=branch:main)
Expand Down
5 changes: 5 additions & 0 deletions doc/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
Change Log
==========

v2.6.0 (2026-03-04)
===================

* Implemented `Support the Enum functional API. <https://github.com/bckohan/enum-properties/issues/156>`_

v2.5.1 (2026-02-09)
===================

Expand Down
28 changes: 28 additions & 0 deletions doc/source/howto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,34 @@ be an rgb tuple:

.. literalinclude:: ../../tests/examples/howto_hash_equiv_def.py

.. _howto_functional_api:

Use the Functional (Dynamic) API
---------------------------------

Python's standard :class:`enum.Enum` supports a `functional API
<https://docs.python.org/3/howto/enum.html#functional-api>`_ that creates
enumeration classes dynamically at runtime. :py:class:`~enum_properties.EnumProperties`
extends this with a ``properties`` keyword argument that names the extra
fields packed into each member's value tuple.

Each entry in ``properties`` can be:

- A **string** – creates a plain (non-symmetric) property with that name,
equivalent to :py:func:`~enum_properties.p`.
- A :py:func:`~enum_properties.p` or :py:func:`~enum_properties.s` **type** –
used directly, which lets you configure symmetry and ``case_fold`` options.

.. literalinclude:: ../../tests/examples/howto_functional.py
:lines: 1-26

:py:class:`~enum_properties.FlagProperties` and
:py:class:`~enum_properties.IntFlagProperties` are also supported:

.. literalinclude:: ../../tests/examples/howto_functional.py
:lines: 30-


.. _howto_legacy_api:

Use the legacy (1.x) API
Expand Down
43 changes: 43 additions & 0 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@
Enum Properties
=======================

|MIT license| |Ruff| |PyPI version fury.io| |PyPI pyversions| |PyPI status|
|PyPi Typed| |Documentation Status| |Code Cov| |Test Status| |Lint Status|


|OpenSSF Scorecard| |OpenSSF Best Practices|

.. |MIT license| image:: https://img.shields.io/badge/License-MIT-blue.svg
:target: https://lbesson.mit-license.org/

.. |Ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
:target: https://docs.astral.sh/ruff

.. |PyPI version fury.io| image:: https://badge.fury.io/py/enum-properties.svg
:target: https://pypi.python.org/pypi/enum-properties/

.. |PyPI pyversions| image:: https://img.shields.io/pypi/pyversions/enum-properties.svg
:target: https://pypi.python.org/pypi/enum-properties/

.. |PyPI status| image:: https://img.shields.io/pypi/status/enum-properties.svg
:target: https://pypi.python.org/pypi/enum-properties

.. |PyPI Typed| image:: https://img.shields.io/pypi/types/enum-properties.svg
:target: https://pypi.python.org/pypi/enum-properties

.. |Documentation Status| image:: https://readthedocs.org/projects/enum-properties/badge/?version=latest
:target: http://enum-properties.readthedocs.io/?badge=latest/

.. |Code Cov| image:: https://codecov.io/gh/bckohan/enum-properties/branch/main/graph/badge.svg?token=0IZOKN2DYL
:target: https://codecov.io/gh/bckohan/enum-properties

.. |Test Status| image:: https://github.com/bckohan/enum-properties/actions/workflows/test.yml/badge.svg?branch=main
:target: https://github.com/bckohan/enum-properties/actions/workflows/test.yml

.. |Lint Status| image:: https://github.com/bckohan/enum-properties/actions/workflows/lint.yml/badge.svg
:target: https://github.com/bckohan/enum-properties/actions/workflows/lint.yml

.. |OpenSSF Scorecard| image:: https://api.securityscorecards.dev/projects/github.com/bckohan/enum-properties/badge
:target: https://securityscorecards.dev/viewer/?uri=github.com/bckohan/enum-properties

.. |OpenSSF Best Practices| image:: https://www.bestpractices.dev/projects/12046/badge
:target: https://www.bestpractices.dev/projects/12046


Add properties to Python enumeration values in a simple declarative syntax.
`enum-properties <https://pypi.python.org/pypi/enum-properties>`_ is a lightweight extension to
Python's :class:`enum.Enum` class. Example:
Expand Down
2 changes: 1 addition & 1 deletion doc/source/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ Module
:undoc-members:
:show-inheritance:
:private-members:
:special-members: __first_class_members__
:special-members: __first_class_members__, __call__
14 changes: 0 additions & 14 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,6 @@ install *OPTS:
_install-docs:
uv sync --no-default-groups --group docs --all-extras

[script]
_lock-python:
import tomlkit
import sys
f='pyproject.toml'
d=tomlkit.parse(open(f).read())
d['project']['requires-python']='=={}'.format(sys.version.split()[0])
open(f,'w').write(tomlkit.dumps(d))

# lock to specific python and versions of given dependencies
test-lock +PACKAGES: _lock-python
uv add --no-sync {{ PACKAGES }}
uv sync --reinstall --no-default-groups --no-install-project

# run static type checking with mypy
check-types-mypy *RUN_ARGS:
@just run --no-default-groups --all-extras --group typing {{ RUN_ARGS }} mypy
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "enum-properties"
version = "2.5.1"
version = "2.6.0"
description = "Add properties and method specializations to Python enumeration values with a simple declarative syntax."
requires-python = ">=3.10,<4.0"
authors = [
Expand All @@ -31,7 +31,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules"
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed"
]

[project.urls]
Expand Down
150 changes: 148 additions & 2 deletions src/enum_properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
import sys
import typing as t
import unicodedata
from collections.abc import Generator, Hashable, Iterable
from collections.abc import Generator, Hashable, Iterable, Mapping
from dataclasses import dataclass
from functools import cached_property

VERSION = (2, 5, 1)
VERSION = (2, 6, 0)

__title__ = "Enum Properties"
__version__ = ".".join(str(i) for i in VERSION)
Expand Down Expand Up @@ -405,6 +405,152 @@ class MyEnum(SymmetricMixin, enum.Enum, metaclass=EnumPropertiesMeta):
_properties_: list[_Prop]
__first_class_members__: list[str]

def __call__( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodOverride]
cls,
value,
names=None,
*,
module=None,
qualname=None,
type=None,
start=1,
properties=None,
**kwargs,
):
"""
Overrides :meth:`enum.EnumMeta.__call__` to support the functional API
with a ``properties`` argument. When ``names`` is provided along with
``properties``, a new enum class is created with the given properties.

``properties`` may be an iterable of:

- :class:`str` – creates a non-symmetric property with that name
- :func:`p` or :func:`s` type – used directly (preserves case-fold /
match-none options on symmetric properties)

Example::

AnEnum = EnumProperties(
"AnEnum",
{"A": ("a", True), "B": ("b", False)},
properties=("prop",),
)

:param value: Class name when using the functional API, otherwise the
member value to look up.
:param names: Member definitions (dict, list of pairs, or string).
:param module: Module name for the new class.
:param qualname: Qualified name for the new class.
:param type: An optional mixin type for the new class.
:param start: Starting value for auto-generated member values.
:param properties: Property specifications for the functional API.
"""
if names is None:
if properties is not None:
raise TypeError("'properties' argument requires 'names' argument")
# Normal member-value lookup – delegate entirely to EnumMeta.
return super().__call__(value, **kwargs)

if properties is None:
# Standard functional API without properties.
return super().__call__(
value,
names,
module=module,
qualname=qualname,
type=type,
start=start,
**kwargs,
)

# ------------------------------------------------------------------
# Functional API *with* properties
# ------------------------------------------------------------------
metacls = cls.__class__ # EnumPropertiesMeta

# Parse each property specification into a p()/s() *type*.
if isinstance(properties, str):
raise TypeError(
f"'properties' must be an iterable of strings or p()/s() types, "
f"not str. Did you mean properties=({properties!r},)?"
)
prop_types = []
for prop in properties:
if isinstance(prop, str):
prop_types.append(p(prop))
else:
try:
if issubclass(prop, _Prop):
prop_types.append(prop)
continue
except TypeError:
pass
raise TypeError(
f"Invalid property specification: {prop!r}. "
"Expected a string, p(), or s() property."
)

# Build the base-class tuple. Property types are prepended so that
# __prepare__ picks them up and records them in _ep_properties_.
if type is None:
bases: tuple[t.Any, ...] = (cls,)
else:
bases = (type, cls)
full_bases = tuple(prop_types) + bases

# Let __prepare__ build the classdict (it strips prop_types from bases
# and populates _ep_properties_).
classdict = metacls.__prepare__(value, full_bases, **kwargs)

# Parse *names* into a list of (member_name, value) pairs.
if isinstance(names, str):
names = names.replace(",", " ").split()

items: list[tuple[str, t.Any]]
if isinstance(names, (list, tuple)):
if not names:
items = []
elif isinstance(names[0], str):
# Plain list of names – generate sequential values.
items = [(name, start + i) for i, name in enumerate(names)]
else:
items = [tuple(item) for item in names] # type: ignore[assignment]
elif isinstance(names, Mapping):
items = list(names.items())
else:
# Non-sequence iterables (e.g. generators). Match Enum functional
# API: if this is an iterable of names, generate sequential values;
# otherwise, treat elements as (name, value) pairs.
raw_items = list(names)
if not raw_items:
items = []
elif all(isinstance(n, str) for n in raw_items):
items = [(name, start + i) for i, name in enumerate(raw_items)]
else:
items = [tuple(item) for item in raw_items] # type: ignore[assignment]

# Populate the classdict; _PropertyEnumDict.__setitem__ strips property
# values from each tuple and records them in _ep_properties_.
for name, val in items:
classdict[name] = val

# Construct the enum class. Pass *bases* (without prop_types) because
# __new__ also filters _Prop subclasses, and __prepare__ already
# recorded the properties.
enum_class = metacls.__new__(metacls, value, bases, classdict, **kwargs)
enum_class.__qualname__ = qualname or value

if module is None:
try:
module = sys._getframe(1).f_globals["__name__"]
except (AttributeError, ValueError, KeyError):
pass

if module is not None:
enum_class.__module__ = module

return enum_class

@classmethod
def __prepare__(metacls, cls, bases, **kwds): # type: ignore[override]
"""
Expand Down
4 changes: 4 additions & 0 deletions src/enum_properties/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class _SProp(_Prop):
case_fold: bool
match_none: bool

_PropertySpec: TypeAlias = str | type[_Prop]

def s(
prop_name: str, case_fold: bool = False, match_none: bool = False
) -> type[_SProp]: ...
Expand Down Expand Up @@ -82,6 +84,7 @@ class EnumPropertiesMeta(enum.EnumMeta):
type: type | None = None,
start: int = 1,
boundary: enum.FlagBoundary | None = None,
properties: Iterable[_PropertySpec] | None = None,
) -> type[enum.Enum]: ...
else:
@overload
Expand All @@ -94,6 +97,7 @@ class EnumPropertiesMeta(enum.EnumMeta):
qualname: str | None = None,
type: type | None = None,
start: int = 1,
properties: Iterable[_PropertySpec] | None = None,
) -> type[enum.Enum]: ...

if sys.version_info >= (3, 12):
Expand Down
45 changes: 45 additions & 0 deletions tests/examples/howto_functional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import typing as t
from enum_properties import EnumProperties, IntEnumProperties, FlagProperties, p, s


# The functional API lets you build enumeration classes dynamically at runtime.
# Pass a ``properties`` argument with the member definitions to name the properties.
# String entries become plain (non-symmetric) properties; p() and s() types give
# more control, including symmetry.

Color = EnumProperties(
'Color',
{
'RED': (1, 'Roja', 'ff0000'),
'GREEN': (2, 'Verde', '00ff00'),
'BLUE': (3, 'Azul', '0000ff'),
},
properties=('spanish', s('hex', case_fold=True)),
)

assert Color.RED.spanish == 'Roja'
assert Color.RED.hex == 'ff0000'

# hex is symmetric – look up by hex string (case-insensitive)
assert Color('ff0000') is Color.RED
assert Color('FF0000') is Color.RED
assert Color('00ff00') is Color.GREEN


# FlagProperties also supports the functional API.

Perm = FlagProperties(
'Perm',
{
'R': (1, 'read'),
'W': (2, 'write'),
'X': (4, 'execute'),
'RWX': (7, 'all'),
},
properties=(s('label', case_fold=True),),
)

assert Perm.R.label == 'read'
assert Perm('READ') is Perm.R
assert (Perm.R | Perm.W | Perm.X) is Perm.RWX
assert Perm.RWX.flagged == [Perm.R, Perm.W, Perm.X]
4 changes: 4 additions & 0 deletions tests/examples/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,7 @@ def test_howto_legacy():

def test_howto_members_and_aliases():
from tests.examples import howto_members_and_aliases


def test_howto_functional():
from tests.examples import howto_functional
Loading
Loading