From 56d62d2a6ada9afb6d12007988dc4843c54b99d2 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 4 Mar 2026 21:53:46 -0800 Subject: [PATCH 1/3] implement functional interface, bump version and update changelog #156 --- doc/source/changelog.rst | 5 + doc/source/howto.rst | 28 ++ doc/source/reference.rst | 2 +- justfile | 14 - pyproject.toml | 2 +- src/enum_properties/__init__.py | 141 +++++++++- src/enum_properties/__init__.pyi | 4 + tests/examples/howto_functional.py | 45 ++++ tests/examples/test_examples.py | 4 + tests/test_functional.py | 419 +++++++++++++++++++++++++++++ uv.lock | 2 +- 11 files changed, 647 insertions(+), 19 deletions(-) create mode 100644 tests/examples/howto_functional.py create mode 100644 tests/test_functional.py diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index bc41528..0348d94 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,6 +2,11 @@ Change Log ========== +v2.6.0 (2026-03-04) +=================== + +* Implemented `Support the Enum functional API. `_ + v2.5.1 (2026-02-09) =================== diff --git a/doc/source/howto.rst b/doc/source/howto.rst index ed5c901..d66017b 100644 --- a/doc/source/howto.rst +++ b/doc/source/howto.rst @@ -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 +`_ 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 diff --git a/doc/source/reference.rst b/doc/source/reference.rst index 5094d80..7f7db26 100644 --- a/doc/source/reference.rst +++ b/doc/source/reference.rst @@ -14,4 +14,4 @@ Module :undoc-members: :show-inheritance: :private-members: - :special-members: __first_class_members__ + :special-members: __first_class_members__, __call__ diff --git a/justfile b/justfile index e92f0e0..d2fdecf 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ad23cdc..c9a5cdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/enum_properties/__init__.py b/src/enum_properties/__init__.py index f284680..1231871 100644 --- a/src/enum_properties/__init__.py +++ b/src/enum_properties/__init__.py @@ -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) @@ -405,6 +405,143 @@ 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: + items = list(names) + + # 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] """ diff --git a/src/enum_properties/__init__.pyi b/src/enum_properties/__init__.pyi index 2b7c9bb..bf7f434 100644 --- a/src/enum_properties/__init__.pyi +++ b/src/enum_properties/__init__.pyi @@ -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]: ... @@ -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 @@ -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): diff --git a/tests/examples/howto_functional.py b/tests/examples/howto_functional.py new file mode 100644 index 0000000..0b62992 --- /dev/null +++ b/tests/examples/howto_functional.py @@ -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] diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py index b9ec3da..1853106 100644 --- a/tests/examples/test_examples.py +++ b/tests/examples/test_examples.py @@ -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 diff --git a/tests/test_functional.py b/tests/test_functional.py new file mode 100644 index 0000000..6831201 --- /dev/null +++ b/tests/test_functional.py @@ -0,0 +1,419 @@ +""" +Tests for the functional (dynamic) API with properties. + +EnumProperties("Name", {"A": ("a", True), ...}, properties=("prop",)) +""" + +import sys +import typing as t +from unittest import TestCase +from unittest.mock import patch + +from enum_properties import ( + EnumProperties, + FlagProperties, + IntEnumProperties, + IntFlagProperties, + StrEnumProperties, + p, + s, +) + + +class TestFunctionalAPI(TestCase): + def test_basic_non_symmetric(self): + """String property names create non-symmetric properties.""" + AnEnum = EnumProperties( + "AnEnum", + {"A": ("a", True), "B": ("b", False)}, + properties=("prop",), + ) + self.assertEqual(AnEnum.A.value, "a") + self.assertEqual(AnEnum.B.value, "b") + self.assertTrue(AnEnum.A.prop) + self.assertFalse(AnEnum.B.prop) + + # Non-symmetric: cannot look up by property value + with self.assertRaises((ValueError, KeyError)): + AnEnum(True) + + def test_multiple_non_symmetric_properties(self): + """Multiple string property specs work in order.""" + Color = EnumProperties( + "Color", + { + "RED": (1, "Roja", "ff0000"), + "GREEN": (2, "Verde", "00ff00"), + "BLUE": (3, "Azul", "0000ff"), + }, + properties=("spanish", "hex"), + ) + self.assertEqual(Color.RED.value, 1) + self.assertEqual(Color.RED.spanish, "Roja") + self.assertEqual(Color.RED.hex, "ff0000") + self.assertEqual(Color.GREEN.spanish, "Verde") + self.assertEqual(Color.BLUE.hex, "0000ff") + + def test_symmetric_via_s_type(self): + """s() types create symmetric properties.""" + Color = EnumProperties( + "Color", + { + "RED": (1, (1, 0, 0)), + "GREEN": (2, (0, 1, 0)), + "BLUE": (3, (0, 0, 1)), + }, + properties=(s("rgb"),), + ) + self.assertEqual(Color.RED.rgb, (1, 0, 0)) + self.assertIs(Color((1, 0, 0)), Color.RED) + self.assertIs(Color((0, 1, 0)), Color.GREEN) + self.assertIs(Color((0, 0, 1)), Color.BLUE) + + def test_case_fold_symmetric(self): + """s() with case_fold=True creates case-insensitive symmetric lookup.""" + Color = EnumProperties( + "Color", + { + "RED": (1, "ff0000"), + "GREEN": (2, "00ff00"), + "BLUE": (3, "0000ff"), + }, + properties=(s("hex", case_fold=True),), + ) + self.assertEqual(Color.RED.hex, "ff0000") + self.assertIs(Color("ff0000"), Color.RED) + self.assertIs(Color("FF0000"), Color.RED) + self.assertIs(Color("00ff00"), Color.GREEN) + self.assertIs(Color("0000FF"), Color.BLUE) + + def test_mixed_symmetric_and_plain(self): + """Mix of string and s() properties works correctly.""" + Color = EnumProperties( + "Color", + { + "RED": (1, "Roja", (1, 0, 0)), + "GREEN": (2, "Verde", (0, 1, 0)), + "BLUE": (3, "Azul", (0, 0, 1)), + }, + properties=("spanish", s("rgb")), + ) + self.assertEqual(Color.RED.spanish, "Roja") + self.assertEqual(Color.RED.rgb, (1, 0, 0)) + # rgb is symmetric + self.assertIs(Color((1, 0, 0)), Color.RED) + # spanish is not symmetric + with self.assertRaises((ValueError, KeyError)): + Color("Roja") + + def test_p_type_in_properties(self): + """Explicit p() type in properties works like a string name.""" + AnEnum = EnumProperties( + "AnEnum", + {"X": ("x", 10), "Y": ("y", 20)}, + properties=(p("score"),), + ) + self.assertEqual(AnEnum.X.score, 10) + self.assertEqual(AnEnum.Y.score, 20) + + def test_names_as_mapping(self): + """Any Mapping (not just dict) is accepted for names.""" + from collections import OrderedDict + from types import MappingProxyType + + for mapping in ( + OrderedDict([("A", ("a", 1)), ("B", ("b", 2))]), + MappingProxyType({"A": ("a", 1), "B": ("b", 2)}), + ): + with self.subTest(mapping_type=type(mapping).__name__): + AnEnum = EnumProperties("AnEnum", mapping, properties=("num",)) + self.assertEqual(AnEnum.A.value, "a") + self.assertEqual(AnEnum.A.num, 1) + self.assertEqual(AnEnum.B.num, 2) + + def test_list_of_pairs_format(self): + """names as a list of (name, value) pairs is supported.""" + AnEnum = EnumProperties( + "AnEnum", + [("A", ("a", 1)), ("B", ("b", 2))], + properties=("num",), + ) + self.assertEqual(AnEnum.A.value, "a") + self.assertEqual(AnEnum.A.num, 1) + self.assertEqual(AnEnum.B.num, 2) + + def test_int_enum_properties(self): + """IntEnumProperties works with the functional API.""" + Status = IntEnumProperties( + "Status", + {"PENDING": (1, "Pending"), "DONE": (2, "Done")}, + properties=("label",), + ) + self.assertEqual(Status.PENDING, 1) + self.assertEqual(Status.PENDING.label, "Pending") + self.assertEqual(Status.DONE.label, "Done") + + def test_str_enum_properties(self): + """StrEnumProperties works with the functional API.""" + Color = StrEnumProperties( + "Color", + {"RED": ("red", "#f00"), "BLUE": ("blue", "#00f")}, + properties=("hex",), + ) + Color.hex: t.ClassVar[str] # type: ignore[no-untyped-def] + self.assertEqual(Color.RED, "red") + self.assertEqual(Color.RED.hex, "#f00") + + def test_module_set(self): + """The created enum's __module__ is set to the calling module.""" + AnEnum = EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties=("num",), + ) + self.assertEqual(AnEnum.__module__, __name__) + + def test_module_explicit(self): + """Explicit module= overrides automatic module detection.""" + AnEnum = EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties=("num",), + module="my.module", + ) + self.assertEqual(AnEnum.__module__, "my.module") + + def test_qualname(self): + """Explicit qualname= is applied to the created class.""" + AnEnum = EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties=("num",), + qualname="SomeOuter.AnEnum", + ) + self.assertEqual(AnEnum.__qualname__, "SomeOuter.AnEnum") + + def test_invalid_property_spec_raises(self): + """Non-string, non-p/s property raises TypeError.""" + with self.assertRaises(TypeError): + EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties=(42,), + ) + + def test_no_names_with_properties_raises(self): + """Passing properties= without names= is not allowed.""" + with self.assertRaises(TypeError): + EnumProperties("AnEnum", properties=("prop",)) + + def test_normal_lookup_unaffected(self): + """Ordinary value lookup still works on a class created normally.""" + + class Color(EnumProperties): + label: str + + RED = 1, "Red" + GREEN = 2, "Green" + + self.assertIs(Color(1), Color.RED) + self.assertIs(Color(2), Color.GREEN) + + def test_functional_members_list(self): + """All members are accessible via iteration.""" + AnEnum = EnumProperties( + "AnEnum", + {"A": ("a", 1), "B": ("b", 2), "C": ("c", 3)}, + properties=("num",), + ) + self.assertEqual([m.name for m in AnEnum], ["A", "B", "C"]) + self.assertEqual([m.num for m in AnEnum], [1, 2, 3]) + + def test_symmetric_name_lookup(self): + """name is always symmetric on EnumProperties subclasses.""" + AnEnum = EnumProperties( + "AnEnum", + {"ALPHA": ("a", True), "BETA": ("b", False)}, + properties=("flag",), + ) + self.assertIs(AnEnum("ALPHA"), AnEnum.ALPHA) + self.assertIs(AnEnum("BETA"), AnEnum.BETA) + + def test_flag_properties(self): + """FlagProperties works with the functional API.""" + Perm = FlagProperties( + "Perm", + { + "R": (1, "read"), + "W": (2, "write"), + "X": (4, "execute"), + "RWX": (7, "all"), + }, + properties=(s("label", case_fold=True),), + ) + self.assertEqual(Perm.R.label, "read") + self.assertEqual(Perm.W.label, "write") + self.assertEqual(Perm.X.label, "execute") + self.assertEqual(Perm.RWX.label, "all") + + # Symmetric lookup by label + self.assertIs(Perm("read"), Perm.R) + self.assertIs(Perm("READ"), Perm.R) + self.assertIs(Perm("write"), Perm.W) + self.assertIs(Perm("all"), Perm.RWX) + + # Composite flag construction from iterable of members + self.assertIs(Perm([Perm.R, Perm.W, Perm.X]), Perm.RWX) + self.assertIs(Perm({"read", "write", "execute"}), Perm.RWX) + + # DecomposeMixin: iteration and flagged + self.assertCountEqual(list(Perm.R | Perm.W), [Perm.R, Perm.W]) + self.assertCountEqual(Perm.RWX.flagged, [Perm.R, Perm.W, Perm.X]) + self.assertEqual(len(Perm.R | Perm.W), 2) + self.assertEqual(len(Perm.RWX), 3) + self.assertEqual(len(Perm.R), 1) + + def test_int_flag_properties(self): + """IntFlagProperties works with the functional API.""" + Perm = IntFlagProperties( + "Perm", + { + "R": (1, "read"), + "W": (2, "write"), + "X": (4, "execute"), + }, + properties=(s("label", case_fold=True),), + ) + self.assertEqual(Perm.R.label, "read") + self.assertEqual(Perm.W.label, "write") + self.assertEqual(Perm.X.label, "execute") + + # IntFlag: numeric equality + self.assertEqual(Perm.R, 1) + self.assertEqual(Perm.W, 2) + self.assertEqual(Perm.X, 4) + + # Symmetric lookup + self.assertIs(Perm("read"), Perm.R) + self.assertIs(Perm("WRITE"), Perm.W) + + # Composite via OR + rwx = Perm.R | Perm.W | Perm.X + self.assertEqual(rwx, 7) + self.assertCountEqual(rwx.flagged, [Perm.R, Perm.W, Perm.X]) + + def test_flag_non_symmetric_property(self): + """FlagProperties with a non-symmetric property works correctly.""" + Perm = FlagProperties( + "Perm", + { + "R": (1, "read", "r"), + "W": (2, "write", "w"), + "X": (4, "execute", "x"), + }, + properties=(s("label", case_fold=True), "short"), + ) + self.assertEqual(Perm.R.short, "r") + self.assertEqual(Perm.W.short, "w") + self.assertEqual(Perm.X.short, "x") + + # label is symmetric, short is not + self.assertIs(Perm("read"), Perm.R) + # Python 3.10's Flag._missing_ raises TypeError for non-integer values + with self.assertRaises((ValueError, KeyError, TypeError)): + Perm("r") + + # ------------------------------------------------------------------ + # Branch-coverage tests for __call__ + # ------------------------------------------------------------------ + + def test_standard_functional_api_no_properties(self): + """Standard functional API (names but no properties=) is unchanged.""" + # Exercises the super().__call__(value, names, ...) delegation (line 454) + AnEnum = EnumProperties("AnEnum", {"A": 1, "B": 2}) + self.assertEqual(AnEnum.A.value, 1) + self.assertEqual(AnEnum.B.value, 2) + # name is still symmetric by default + self.assertIs(AnEnum("A"), AnEnum.A) + + def test_properties_as_string_raises(self): + """Passing a bare string for properties= raises TypeError with a hint.""" + with self.assertRaises(TypeError, msg="Did you mean properties=("): + EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties="label", # accidentally a string, not a tuple + ) + + def test_invalid_property_not_subclass_raises(self): + """A class that is not a _Prop subclass raises TypeError (line 476->481).""" + with self.assertRaises(TypeError): + EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties=(int,), # int is a type but NOT a _Prop subclass + ) + + def test_type_mixin(self): + """type= kwarg produces a class that also inherits from the mixin (line 491).""" + IntColor = EnumProperties( + "IntColor", + {"RED": (1, "red"), "GREEN": (2, "green")}, + type=int, + properties=("label",), + ) + # inherits from int: numeric equality + self.assertEqual(IntColor.RED, 1) + self.assertEqual(IntColor.GREEN, 2) + self.assertEqual(IntColor.RED.label, "red") + self.assertTrue(issubclass(IntColor, int)) + + def test_names_as_string(self): + """names as a space/comma-separated string auto-assigns values (line 500).""" + # No properties needed here; the string form produces plain names + AnEnum = EnumProperties("AnEnum", "A B C") + self.assertEqual([m.name for m in AnEnum], ["A", "B", "C"]) + self.assertEqual([m.value for m in AnEnum], [1, 2, 3]) + + def test_names_as_string_with_properties(self): + """names as string + properties raises because values can't carry props.""" + # The string form produces scalar values, not tuples, so add_member_and_properties + # will raise. + with self.assertRaises(ValueError): + EnumProperties("AnEnum", "A B C", properties=("label",)) + + def test_names_as_empty_list(self): + """Empty list of names produces an enum with no members (line 505).""" + AnEnum = EnumProperties("AnEnum", [], properties=("label",)) + self.assertEqual(list(AnEnum), []) + + def test_names_as_plain_list_of_strings(self): + """List of plain strings auto-assigns sequential start values (line 508).""" + AnEnum = EnumProperties("AnEnum", ["X", "Y", "Z"], start=10) + self.assertEqual(AnEnum.X.value, 10) + self.assertEqual(AnEnum.Y.value, 11) + self.assertEqual(AnEnum.Z.value, 12) + + def test_names_as_generic_iterable(self): + """A generator (non-list/tuple/dict) is consumed as (name, value) pairs (line 514).""" + pairs = ( + (name, (i, label)) + for i, (name, label) in enumerate([("A", "alpha"), ("B", "beta")], start=1) + ) + AnEnum = EnumProperties("AnEnum", pairs, properties=("label",)) + self.assertEqual(AnEnum.A.value, 1) + self.assertEqual(AnEnum.A.label, "alpha") + self.assertEqual(AnEnum.B.label, "beta") + + def test_module_not_set_when_getframe_fails(self): + """When sys._getframe raises, module stays None and __module__ is unchanged + (lines 530-531, 533->536).""" + with patch.object(sys, "_getframe", side_effect=ValueError("no frame")): + AnEnum = EnumProperties( + "AnEnum", + {"A": ("a", 1)}, + properties=("num",), + ) + # __module__ will be whatever Python set during class construction — + # the key assertion is that we didn't crash and module wasn't set by us. + self.assertNotEqual(AnEnum.__module__, __name__) diff --git a/uv.lock b/uv.lock index 3338a7e..13edbd8 100644 --- a/uv.lock +++ b/uv.lock @@ -499,7 +499,7 @@ wheels = [ [[package]] name = "enum-properties" -version = "2.5.1" +version = "2.6.0" source = { editable = "." } [package.dev-dependencies] From ead2805746e7f2cc3ae1b4c495ebf70ce4a87cdf Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 4 Mar 2026 22:23:18 -0800 Subject: [PATCH 2/3] add badges to docs, add typed classifier --- README.md | 1 + doc/source/index.rst | 43 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea20d28..fe7a1bc 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/doc/source/index.rst b/doc/source/index.rst index b0f2eee..c672783 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -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 `_ is a lightweight extension to Python's :class:`enum.Enum` class. Example: diff --git a/pyproject.toml b/pyproject.toml index c9a5cdf..e14b754 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] From b69f06343d55cafd10e478125c9d3f8b676dfe59 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Wed, 4 Mar 2026 22:38:08 -0800 Subject: [PATCH 3/3] allow names to be iterable of strings --- src/enum_properties/__init__.py | 11 ++++++++++- tests/test_functional.py | 19 +++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/enum_properties/__init__.py b/src/enum_properties/__init__.py index 1231871..63b5ac3 100644 --- a/src/enum_properties/__init__.py +++ b/src/enum_properties/__init__.py @@ -518,7 +518,16 @@ def __call__( # type: ignore[override] # pyright: ignore[reportIncompatibleMet elif isinstance(names, Mapping): items = list(names.items()) else: - items = list(names) + # 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_. diff --git a/tests/test_functional.py b/tests/test_functional.py index 6831201..889391b 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -5,7 +5,6 @@ """ import sys -import typing as t from unittest import TestCase from unittest.mock import patch @@ -160,7 +159,6 @@ def test_str_enum_properties(self): {"RED": ("red", "#f00"), "BLUE": ("blue", "#00f")}, properties=("hex",), ) - Color.hex: t.ClassVar[str] # type: ignore[no-untyped-def] self.assertEqual(Color.RED, "red") self.assertEqual(Color.RED.hex, "#f00") @@ -338,7 +336,7 @@ def test_standard_functional_api_no_properties(self): def test_properties_as_string_raises(self): """Passing a bare string for properties= raises TypeError with a hint.""" - with self.assertRaises(TypeError, msg="Did you mean properties=("): + with self.assertRaisesRegex(TypeError, r"Did you mean properties=\("): EnumProperties( "AnEnum", {"A": ("a", 1)}, @@ -377,8 +375,8 @@ def test_names_as_string(self): def test_names_as_string_with_properties(self): """names as string + properties raises because values can't carry props.""" - # The string form produces scalar values, not tuples, so add_member_and_properties - # will raise. + # The string form produces scalar values, not tuples, so property + # extraction will raise. with self.assertRaises(ValueError): EnumProperties("AnEnum", "A B C", properties=("label",)) @@ -395,7 +393,7 @@ def test_names_as_plain_list_of_strings(self): self.assertEqual(AnEnum.Z.value, 12) def test_names_as_generic_iterable(self): - """A generator (non-list/tuple/dict) is consumed as (name, value) pairs (line 514).""" + """A generator of (name, value) pairs is consumed correctly.""" pairs = ( (name, (i, label)) for i, (name, label) in enumerate([("A", "alpha"), ("B", "beta")], start=1) @@ -405,6 +403,15 @@ def test_names_as_generic_iterable(self): self.assertEqual(AnEnum.A.label, "alpha") self.assertEqual(AnEnum.B.label, "beta") + def test_names_as_string_generator_with_properties_raises(self): + """Generator of plain string names + properties= raises (no property data).""" + with self.assertRaises(ValueError): + EnumProperties( + "AnEnum", + (n for n in ["A", "B"]), + properties=("label",), + ) + def test_module_not_set_when_getframe_fails(self): """When sys._getframe raises, module stays None and __module__ is unchanged (lines 530-531, 533->536)."""