├── tests ├── __init__.py ├── examples │ ├── __init__.py │ ├── hash_equiv_def.py │ ├── howto_hash_equiv.py │ ├── howto_flag_boundaries.py │ ├── howto_verify_unique.py │ ├── howto_specialized_list.py │ ├── howto_specialized_missing.py │ ├── howto_flags_no_iterable.py │ ├── howto_add_props.py │ ├── howto_specialized_default.py │ ├── howto_metaclass.py │ ├── howto_dataclass.py │ ├── howto_symmetric_builtins.py │ ├── howto_symmetric_metaclass.py │ ├── specialization_example.py │ ├── howto_hash_equiv_def.py │ ├── howto_specialized.py │ ├── howto_members_and_aliases.py │ ├── color_example.py │ ├── howto_symmetry.py │ ├── howto_symmetric_decorator.py │ ├── howto_nested_classes.py │ ├── howto_nested_classes_313.py │ ├── howto_symmetric_overload.py │ ├── howto_dataclass_integration.py │ ├── howto_legacy.py │ ├── symmetric_example.py │ ├── address.py │ ├── howto_flag.py │ ├── mapbox.py │ └── test_examples.py ├── legacy │ ├── __init__.py │ ├── test_multi_primitives.py │ ├── test_type_hints.py │ ├── test_pickle.py │ ├── test_none_coercion.py │ ├── test_perf.py │ ├── test_interface_eq.py │ ├── test_specialize.py │ ├── test_nestedclass.py │ └── test_flags.py ├── annotations │ ├── test_future_annotations.py │ ├── test_interface_eq.py │ ├── test_pickle.py │ ├── test_type_hints.py │ ├── test_perf.py │ ├── test_none_coercion.py │ ├── test_symmetric.py │ ├── test_aliases.py │ ├── test_specialize.py │ ├── test_nestedclass.py │ └── test_flags.py ├── pickle_enums.py └── pickle_enums_annotations.py ├── src └── enum_properties │ └── py.typed ├── .codecov.yml ├── doc ├── source │ ├── reference.rst │ ├── _static │ │ └── style.css │ ├── index.rst │ ├── conf.py │ ├── tutorial.rst │ ├── changelog.rst │ └── howto.rst └── .readthedocs.yaml ├── .pre-commit-config.yaml ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── scorecard.yml │ ├── release.yml │ └── test.yml ├── SECURITY.md ├── LICENSE ├── .gitignore ├── CONTRIBUTING.md ├── pyproject.toml ├── README.md ├── CODE_OF_CONDUCT.md └── justfile /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/enum_properties/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/legacy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/hash_equiv_def.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/annotations/test_future_annotations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from tests.annotations.test import * 3 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/codecov/codecov-python/issues/136 2 | coverage: 3 | fixes: 4 | - "__init__.py::src/enum_properties/__init__.py" 5 | -------------------------------------------------------------------------------- /tests/examples/howto_hash_equiv.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class MyIntEnum(IntEnum): 5 | 6 | ONE = 1 7 | TWO = 2 8 | THREE = 3 9 | 10 | 11 | assert {1: 'Found me!'}[MyIntEnum.ONE] == 'Found me!' 12 | -------------------------------------------------------------------------------- /doc/source/reference.rst: -------------------------------------------------------------------------------- 1 | .. _reference: 2 | 3 | ========= 4 | Reference 5 | ========= 6 | 7 | .. _meta: 8 | 9 | Module 10 | ------ 11 | 12 | .. automodule:: enum_properties 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | :private-members: 17 | :special-members: __first_class_members__ 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: lint 5 | name: Lint 6 | entry: just lint 7 | language: system 8 | pass_filenames: false 9 | - id: format 10 | name: Format 11 | entry: just format 12 | language: system 13 | pass_filenames: false 14 | -------------------------------------------------------------------------------- /tests/examples/howto_flag_boundaries.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import IntFlagProperties, Symmetric 3 | from enum import STRICT 4 | 5 | 6 | class Perm(IntFlagProperties, boundary=STRICT): 7 | 8 | label: t.Annotated[str, Symmetric(case_fold=True)] 9 | 10 | R = 1, 'read' 11 | W = 2, 'write' 12 | X = 4, 'execute' 13 | RWX = 7, 'all' 14 | 15 | 16 | Perm(8) # raises ValueError 17 | -------------------------------------------------------------------------------- /tests/examples/howto_verify_unique.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties, Symmetric 3 | from enum import verify, UNIQUE 4 | 5 | 6 | @verify(UNIQUE) 7 | class Color(EnumProperties): 8 | 9 | label: t.Annotated[str, Symmetric()] 10 | 11 | RED = 1, 'red' 12 | GREEN = 2, 'green' 13 | BLUE = 3, 'blue' 14 | 15 | # ValueError: aliases found in : blue -> BLUE, 16 | # green -> GREEN, red -> RED 17 | -------------------------------------------------------------------------------- /tests/examples/howto_specialized_list.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties, specialize 2 | 3 | 4 | class SpecializedEnum(EnumProperties): 5 | 6 | ONE = 1 7 | TWO = 2 8 | THREE = 3 9 | 10 | @specialize(TWO, THREE) 11 | def method(self): 12 | return 'shared()' 13 | 14 | assert not hasattr(SpecializedEnum.ONE, 'method') 15 | assert SpecializedEnum.TWO.method() == 'shared()' 16 | assert SpecializedEnum.THREE.method() == 'shared()' 17 | -------------------------------------------------------------------------------- /tests/examples/howto_specialized_missing.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties, specialize 2 | 3 | 4 | class SpecializedEnum(EnumProperties): 5 | 6 | ONE = 1 7 | TWO = 2 8 | THREE = 3 9 | 10 | @specialize(THREE) 11 | def method(self): 12 | return 'method_three()' 13 | 14 | assert not hasattr(SpecializedEnum.ONE, 'method') 15 | assert not hasattr(SpecializedEnum.TWO, 'method') 16 | assert SpecializedEnum.THREE.method() == 'method_three()' 17 | -------------------------------------------------------------------------------- /tests/examples/howto_flags_no_iterable.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import enum 3 | from enum_properties import EnumPropertiesMeta, SymmetricMixin, Symmetric 4 | 5 | 6 | class Perm( 7 | SymmetricMixin, 8 | enum.IntFlag, 9 | metaclass=EnumPropertiesMeta 10 | ): 11 | label: t.Annotated[str, Symmetric(case_fold=True)] 12 | 13 | R = 1, 'read' 14 | W = 2, 'write' 15 | X = 4, 'execute' 16 | RWX = 7, 'all' 17 | 18 | 19 | assert (Perm.R | Perm.W).flagged == [Perm.R, Perm.W] 20 | -------------------------------------------------------------------------------- /tests/examples/howto_add_props.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties 3 | from enum import auto 4 | 5 | 6 | class Color(EnumProperties): 7 | 8 | rgb: t.Tuple[int, int, int] 9 | hex: str 10 | 11 | # name value rgb hex 12 | RED = auto(), (1, 0, 0), 'ff0000' 13 | GREEN = auto(), (0, 1, 0), '00ff00' 14 | BLUE = auto(), (0, 0, 1), '0000ff' 15 | 16 | 17 | # The property values are accessible by name on the enumeration values: 18 | assert Color.RED.hex == 'ff0000' 19 | -------------------------------------------------------------------------------- /tests/examples/howto_specialized_default.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties, specialize 2 | 3 | 4 | class SpecializedEnum(EnumProperties): 5 | 6 | ONE = 1 7 | TWO = 2 8 | THREE = 3 9 | 10 | def method(self): 11 | return 'generic()' 12 | 13 | @specialize(THREE) 14 | def method(self): 15 | return 'method_three()' 16 | 17 | 18 | assert SpecializedEnum.ONE.method() == 'generic()' 19 | assert SpecializedEnum.TWO.method() == 'generic()' 20 | assert SpecializedEnum.THREE.method() == 'method_three()' 21 | -------------------------------------------------------------------------------- /tests/examples/howto_metaclass.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumPropertiesMeta 3 | from enum import Enum, auto 4 | 5 | 6 | class Color(Enum, metaclass=EnumPropertiesMeta): 7 | 8 | rgb: t.Tuple[int, int, int] 9 | hex: str 10 | 11 | # name value rgb hex 12 | RED = auto(), (1, 0, 0), 'ff0000' 13 | GREEN = auto(), (0, 1, 0), '00ff00' 14 | BLUE = auto(), (0, 0, 1), '0000ff' 15 | 16 | 17 | # The property values are accessible by name on the enumeration values: 18 | assert Color.RED.hex == 'ff0000' 19 | -------------------------------------------------------------------------------- /tests/examples/howto_dataclass.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import Enum 3 | 4 | 5 | @dataclass 6 | class CreatureDataMixin: 7 | size: str 8 | legs: int 9 | tail: bool = field(repr=False, default=True) 10 | 11 | 12 | class Creature(CreatureDataMixin, Enum): 13 | BEETLE = 'small', 6 14 | DOG = 'medium', 4 15 | 16 | 17 | # you can now access the dataclass fields on the enumeration values 18 | # as with enum properties: 19 | assert Creature.BEETLE.size == 'small' 20 | assert Creature.BEETLE.legs == 6 21 | assert Creature.BEETLE.tail is True 22 | -------------------------------------------------------------------------------- /tests/examples/howto_symmetric_builtins.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum import auto 3 | from enum_properties import EnumProperties, Symmetric 4 | 5 | 6 | class Color(EnumProperties): 7 | 8 | name: t.Annotated[str, Symmetric(case_fold=True)] 9 | rgb: t.Annotated[t.Tuple[int, int, int], Symmetric()] 10 | hex: t.Annotated[str, Symmetric(case_fold=True)] 11 | 12 | # name value rgb hex 13 | RED = auto(), (1, 0, 0), 'ff0000' 14 | GREEN = auto(), (0, 1, 0), '00ff00' 15 | BLUE = auto(), (0, 0, 1), '0000ff' 16 | 17 | 18 | # now we can do this: 19 | assert Color('red') is Color.RED 20 | -------------------------------------------------------------------------------- /tests/examples/howto_symmetric_metaclass.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumPropertiesMeta, SymmetricMixin, Symmetric 3 | from enum import Enum, auto 4 | 5 | 6 | class Color(SymmetricMixin, Enum, metaclass=EnumPropertiesMeta): 7 | 8 | rgb: t.Annotated[t.Tuple[int, int, int], Symmetric()] 9 | hex: t.Annotated[str, Symmetric(case_fold=True)] 10 | 11 | # name value rgb hex 12 | RED = auto(), (1, 0, 0), '0xff0000' 13 | GREEN = auto(), (0, 1, 0), '0x00ff00' 14 | BLUE = auto(), (0, 0, 1), '0x0000ff' 15 | 16 | 17 | assert Color.RED is Color((1, 0, 0)) is Color('0xFF0000') is Color('0xff0000') 18 | -------------------------------------------------------------------------------- /tests/legacy/test_multi_primitives.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from enum_properties import EnumProperties 4 | 5 | 6 | class TestMultiPrimitives(TestCase): 7 | def test_multiple_primitive_types(self): 8 | from datetime import date 9 | 10 | class MyEnum(EnumProperties): 11 | V1 = None 12 | V2 = 5 13 | V3 = "label" 14 | V4 = date(year=1970, month=1, day=1) 15 | 16 | self.assertEqual(MyEnum.V1, None) 17 | self.assertEqual(MyEnum.V2, 5) 18 | self.assertEqual(MyEnum.V3, "label") 19 | self.assertEqual(MyEnum.V4, date(year=1970, month=1, day=1)) 20 | -------------------------------------------------------------------------------- /doc/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.12" 13 | jobs: 14 | post_install: 15 | - pip install uv 16 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: doc/source/conf.py 21 | 22 | formats: 23 | - pdf 24 | -------------------------------------------------------------------------------- /tests/examples/specialization_example.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties as Enum, specialize 2 | 3 | 4 | class SpecializedEnum(Enum): 5 | 6 | ONE = 1 7 | TWO = 2 8 | THREE = 3 9 | 10 | @specialize(ONE) 11 | def method(self): 12 | return 'method_one()' 13 | 14 | @specialize(TWO) 15 | def method(self): 16 | return 'method_two()' 17 | 18 | @specialize(THREE) 19 | def method(self): 20 | return 'method_three()' 21 | 22 | assert SpecializedEnum.ONE.method() == 'method_one()' 23 | assert SpecializedEnum.TWO.method() == 'method_two()' 24 | assert SpecializedEnum.THREE.method() == 'method_three()' 25 | -------------------------------------------------------------------------------- /tests/examples/howto_hash_equiv_def.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumPropertiesMeta, SymmetricMixin, Symmetric 3 | from enum import Enum 4 | 5 | 6 | class Color( 7 | SymmetricMixin, 8 | tuple, 9 | Enum, 10 | metaclass=EnumPropertiesMeta 11 | ): 12 | hex: t.Annotated[str, Symmetric(case_fold=True)] 13 | 14 | # name value (rgb) hex 15 | RED = (1, 0, 0), '0xff0000' 16 | GREEN = (0, 1, 0), '0x00ff00' 17 | BLUE = (0, 0, 1), '0x0000ff' 18 | 19 | def __hash__(self): # you must add this! 20 | return tuple.__hash__(self) 21 | 22 | 23 | assert {(1, 0, 0): 'Found me!'}[Color.RED] == 'Found me!' 24 | -------------------------------------------------------------------------------- /tests/examples/howto_specialized.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties, specialize 2 | 3 | 4 | class SpecializedEnum(EnumProperties): 5 | 6 | ONE = 1 7 | TWO = 2 8 | THREE = 3 9 | 10 | @specialize(ONE) 11 | def method(self): 12 | return 'method_one()' 13 | 14 | @specialize(TWO) 15 | def method(self): 16 | return 'method_two()' 17 | 18 | @specialize(THREE) 19 | def method(self): 20 | return 'method_three()' 21 | 22 | 23 | assert SpecializedEnum.ONE.method() == 'method_one()' 24 | assert SpecializedEnum.TWO.method() == 'method_two()' 25 | assert SpecializedEnum.THREE.method() == 'method_three()' 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # GitHub Actions 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | commit-message: 14 | prefix: ⬆ 15 | # Python 16 | - package-ecosystem: "pip" 17 | directory: "/" 18 | schedule: 19 | interval: "daily" 20 | commit-message: 21 | prefix: ⬆ 22 | -------------------------------------------------------------------------------- /tests/examples/howto_members_and_aliases.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties, Symmetric 3 | 4 | 5 | class MyEnum(EnumProperties): 6 | 7 | label: t.Annotated[str, Symmetric()] 8 | 9 | A = 1, "a" 10 | B = 2, "b" 11 | C = 3, "c" 12 | ALIAS_TO_A = A, "a" 13 | 14 | 15 | # __first_class_members__ contains members and aliases 16 | assert MyEnum.__first_class_members__ == ["A", "B", "C", "ALIAS_TO_A"] 17 | 18 | # __members__ contains all members, including aliases and symmetric aliases 19 | assert set(MyEnum.__members__.keys()) == {"A", "B", "C", "ALIAS_TO_A", "a", "b", "c"} 20 | 21 | # iterating contains only non-alias members 22 | assert list(MyEnum) == [MyEnum.A, MyEnum.B, MyEnum.C] 23 | -------------------------------------------------------------------------------- /tests/examples/color_example.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties 3 | from enum import auto 4 | 5 | 6 | class Color(EnumProperties): 7 | 8 | rgb: t.Tuple[int, int, int] 9 | hex: str 10 | 11 | # name value rgb hex 12 | RED = auto(), (1, 0, 0), 'ff0000' 13 | GREEN = auto(), (0, 1, 0), '00ff00' 14 | BLUE = auto(), (0, 0, 1), '0000ff' 15 | 16 | # the type hints on the Enum class become properties on 17 | # each value, matching the order in which they are specified 18 | 19 | 20 | assert Color.RED.rgb == (1, 0, 0) 21 | assert Color.GREEN.rgb == (0, 1, 0) 22 | assert Color.BLUE.rgb == (0, 0, 1) 23 | 24 | assert Color.RED.hex == 'ff0000' 25 | assert Color.GREEN.hex == '00ff00' 26 | assert Color.BLUE.hex == '0000ff' 27 | -------------------------------------------------------------------------------- /tests/examples/howto_symmetry.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties, Symmetric 3 | from enum import auto 4 | 5 | 6 | class Color(EnumProperties): 7 | 8 | rgb: t.Annotated[t.Tuple[int, int, int], Symmetric()] 9 | hex: t.Annotated[str, Symmetric(case_fold=True)] 10 | 11 | # name value rgb hex 12 | RED = auto(), (1, 0, 0), '0xff0000' 13 | GREEN = auto(), (0, 1, 0), '0x00ff00' 14 | BLUE = auto(), (0, 0, 1), '0x0000ff' 15 | 16 | 17 | assert Color.RED is Color((1, 0, 0)) is Color('0xFF0000') is Color('0xff0000') 18 | 19 | 20 | # str(hex(16711680)) == '0xff0000' 21 | assert Color.RED is Color(hex(16711680)) == hex(16711680) 22 | assert Color.RED == (1, 0, 0) 23 | assert Color.RED != (0, 1, 0) 24 | assert Color.RED == '0xFF0000' 25 | -------------------------------------------------------------------------------- /tests/examples/howto_symmetric_decorator.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum import auto 3 | from enum_properties import EnumProperties, symmetric 4 | 5 | 6 | class Color(EnumProperties): 7 | 8 | rgb: t.Tuple[int, int, int] 9 | hex: str 10 | 11 | # name value rgb hex 12 | RED = auto(), (1, 0, 0), 'ff0000' 13 | GREEN = auto(), (0, 1, 0), '00ff00' 14 | BLUE = auto(), (0, 0, 1), '0000ff' 15 | 16 | @symmetric() 17 | @property 18 | def integer(self) -> int: 19 | return int(self.hex, 16) 20 | 21 | @symmetric() 22 | @property 23 | def binary(self) -> str: 24 | return bin(self.integer)[2:] 25 | 26 | 27 | # now we can do this: 28 | assert Color(Color.RED.binary) is Color.RED 29 | assert Color(Color.RED.integer) is Color.RED 30 | -------------------------------------------------------------------------------- /tests/examples/howto_nested_classes.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties 2 | 3 | 4 | class MyEnum(EnumProperties): 5 | 6 | label: str 7 | 8 | class Type1: 9 | pass 10 | 11 | class Type2: 12 | pass 13 | 14 | class Type3: 15 | pass 16 | 17 | VALUE1 = Type1, 'label1' 18 | VALUE2 = Type2, 'label2' 19 | VALUE3 = Type3, 'label3' 20 | 21 | 22 | # only the expected values become enumeration values 23 | assert MyEnum.Type1 == MyEnum.VALUE1 24 | assert MyEnum.Type2 == MyEnum.VALUE2 25 | assert MyEnum.Type3 == MyEnum.VALUE3 26 | assert len(MyEnum) == 3, len(MyEnum) 27 | 28 | # nested classes behave as expected 29 | assert MyEnum.Type1().__class__ is MyEnum.Type1 30 | assert MyEnum.Type2().__class__ is MyEnum.Type2 31 | assert MyEnum.Type3().__class__ is MyEnum.Type3 32 | -------------------------------------------------------------------------------- /tests/pickle_enums.py: -------------------------------------------------------------------------------- 1 | from enum import auto 2 | 3 | from enum_properties import EnumProperties, FlagProperties, IntFlagProperties, p, s 4 | 5 | 6 | class IntPerm( 7 | IntFlagProperties, 8 | s("label", case_fold=True), 9 | ): 10 | R = 1, "read" 11 | W = 2, "write" 12 | X = 4, "execute" 13 | RWX = 7, "all" 14 | 15 | 16 | class Perm( 17 | FlagProperties, 18 | s("label", case_fold=True), 19 | ): 20 | R = auto(), "read" 21 | W = auto(), "write" 22 | X = auto(), "execute" 23 | RWX = R | W | X, "all" 24 | 25 | 26 | class PriorityEx(EnumProperties, s("prop1"), s("prop2", case_fold=True)): 27 | ONE = 0, "1", [3, 4] 28 | TWO = 1, "2", [3, "4"] 29 | THREE = 2, "3", [3, 4] 30 | 31 | 32 | class Color(EnumProperties, p("rgb"), p("hex")): 33 | RED = auto(), (1, 0, 0), "ff0000" 34 | GREEN = auto(), (0, 1, 0), "00ff00" 35 | BLUE = auto(), (0, 0, 1), "0000ff" 36 | -------------------------------------------------------------------------------- /tests/examples/howto_nested_classes_313.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties 2 | from enum import nonmember, member 3 | 4 | 5 | class MyEnum(EnumProperties): 6 | 7 | @nonmember 8 | class Type1: 9 | pass 10 | 11 | @nonmember 12 | class Type2: 13 | pass 14 | 15 | @nonmember 16 | class Type3: 17 | pass 18 | 19 | label: str 20 | 21 | VALUE1 = member(Type1), 'label1' 22 | VALUE2 = member(Type2), 'label2' 23 | VALUE3 = member(Type3), 'label3' 24 | 25 | 26 | # only the expected values become enumeration values 27 | assert MyEnum.Type1 == MyEnum.VALUE1 28 | assert MyEnum.Type2 == MyEnum.VALUE2 29 | assert MyEnum.Type3 == MyEnum.VALUE3 30 | assert len(MyEnum) == 3, len(MyEnum) 31 | 32 | # nested classes behave as expected 33 | assert MyEnum.Type1().__class__ is MyEnum.Type1 34 | assert MyEnum.Type2().__class__ is MyEnum.Type2 35 | assert MyEnum.Type3().__class__ is MyEnum.Type3 36 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | [![CodeQL](https://github.com/bckohan/enum-properties/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)](https://github.com/bckohan/enum-properties/actions/workflows/github-code-scanning/codeql?query=branch:main) 4 | [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/bckohan/enum-properties/badge)](https://securityscorecards.dev/viewer/?uri=github.com/bckohan/enum-properties) 5 | 6 | ## Supported Versions 7 | 8 | Only the latest version [![PyPI version](https://badge.fury.io/py/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties) is supported. 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you think you have found a vulnerability, and even if you are not sure, please [report it to us in private](https://github.com/bckohan/enum-properties/security/advisories/new). We will review it and get back to you. Please refrain from public discussions of the issue. 13 | -------------------------------------------------------------------------------- /tests/examples/howto_symmetric_overload.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import IntEnumProperties, Symmetric 3 | 4 | 5 | class PriorityEx(IntEnumProperties): 6 | 7 | prop1: t.Annotated[str, Symmetric()] 8 | prop2: t.Annotated[t.List[int | str], Symmetric(case_fold=True)] 9 | 10 | # <-------- Higher Precedence 11 | # name value prop1 prop2 # ^ 12 | ONE = 0, '1', [3, 4] # | 13 | TWO = 1, '2', [3, '4'] # Higher 14 | THREE = 2, '3', [3, 4] # Precedence 15 | 16 | 17 | assert PriorityEx(0) is PriorityEx.ONE # order left to right 18 | assert PriorityEx('1') is PriorityEx.ONE # type specificity 19 | assert PriorityEx(3) is PriorityEx.ONE # type specificity/order 20 | assert PriorityEx('3') is PriorityEx.THREE # type specificity 21 | assert PriorityEx(4) is PriorityEx.ONE # order left to right 22 | assert PriorityEx('4') is PriorityEx.TWO # type specificity 23 | -------------------------------------------------------------------------------- /tests/examples/howto_dataclass_integration.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import dataclass, field 3 | from enum_properties import EnumProperties, Symmetric 4 | 5 | 6 | @dataclass 7 | class CreatureDataMixin: 8 | size: str 9 | legs: int 10 | tail: bool = field(repr=False, default=True) 11 | 12 | 13 | class Creature(CreatureDataMixin, EnumProperties): 14 | 15 | kingdom: t.Annotated[str, Symmetric()] 16 | 17 | BEETLE = ('small', 6, False), 'insect' 18 | DOG = ('medium', 4), 'animal' 19 | 20 | 21 | # you can now access the dataclass fields on the enumeration values 22 | # as with enum properties: 23 | assert Creature.BEETLE.size == 'small' 24 | assert Creature.BEETLE.legs == 6 25 | assert Creature.BEETLE.tail is False 26 | assert Creature.BEETLE.kingdom == 'insect' 27 | 28 | # adding symmetric properties onto a dataclass enum can help with 29 | # marshalling external data into the enum classes! 30 | assert Creature('insect') is Creature.BEETLE 31 | -------------------------------------------------------------------------------- /tests/annotations/test_interface_eq.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from typing import Annotated 3 | 4 | from enum_properties import Symmetric 5 | 6 | 7 | class TestInterfaceEquivalency(TestCase): 8 | """ 9 | Enums should hash the same as their values. 10 | """ 11 | 12 | def test_hashing_example(self): 13 | from enum import Enum 14 | 15 | from enum_properties import EnumPropertiesMeta, SymmetricMixin 16 | 17 | class Color( 18 | SymmetricMixin, 19 | tuple, 20 | Enum, 21 | metaclass=EnumPropertiesMeta, 22 | ): 23 | hex: Annotated[str, Symmetric(case_fold=True)] 24 | 25 | # name value rgb hex 26 | RED = (1, 0, 0), "0xff0000" 27 | GREEN = (0, 1, 0), "0x00ff00" 28 | BLUE = (0, 0, 1), "0x0000ff" 29 | 30 | def __hash__(self): 31 | return tuple.__hash__(self) 32 | 33 | assert {(1, 0, 0): "Found me!"}[Color.RED] == "Found me!" 34 | -------------------------------------------------------------------------------- /tests/examples/howto_legacy.py: -------------------------------------------------------------------------------- 1 | from enum_properties import EnumProperties, p, s 2 | from enum import auto 3 | 4 | 5 | # we use p and s values to define properties in the order they appear in the value tuple 6 | class Color(EnumProperties, p('rgb'), s('hex')): 7 | 8 | extra: int # this does not become a property 9 | 10 | # non-value tuple properties are marked symmetric using the _symmetric_builtins_ 11 | # class attribute 12 | _symmetric_builtins_ = [s("name", case_fold=True), "binary"] 13 | 14 | # name value rgb hex 15 | RED = auto(), (1, 0, 0), 'ff0000' 16 | GREEN = auto(), (0, 1, 0), '00ff00' 17 | BLUE = auto(), (0, 0, 1), '0000ff' 18 | 19 | @property 20 | def binary(self) -> str: 21 | return bin(int(self.hex, 16))[2:] 22 | 23 | 24 | assert Color('red') is Color.RED 25 | assert Color('111111110000000000000000') is Color.RED 26 | 27 | assert Color.RED.rgb == (1, 0, 0) 28 | assert Color.RED.hex == 'ff0000' 29 | assert Color.RED.binary == '111111110000000000000000' 30 | -------------------------------------------------------------------------------- /tests/examples/symmetric_example.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties, Symmetric 3 | from enum import auto 4 | 5 | 6 | class Color(EnumProperties): 7 | 8 | rgb: t.Annotated[t.Tuple[int, int, int], Symmetric()] 9 | hex: t.Annotated[str, Symmetric(case_fold=True)] 10 | 11 | RED = auto(), (1, 0, 0), 'ff0000' 12 | GREEN = auto(), (0, 1, 0), '00ff00' 13 | BLUE = auto(), (0, 0, 1), '0000ff' 14 | 15 | # Enumeration instances may be instantiated from any Symmetric property 16 | # values. Use case_fold for case insensitive matching 17 | 18 | 19 | assert Color((1, 0, 0)) is Color.RED 20 | assert Color((0, 1, 0)) is Color.GREEN 21 | assert Color((0, 0, 1)) is Color.BLUE 22 | 23 | assert Color('ff0000') is Color.RED 24 | assert Color('FF0000') is Color.RED # case_fold makes mapping case insensitive 25 | assert Color('00ff00') is Color.GREEN 26 | assert Color('00FF00') is Color.GREEN 27 | assert Color('0000ff') is Color.BLUE 28 | assert Color('0000FF') is Color.BLUE 29 | 30 | assert Color.RED.hex == 'ff0000' 31 | -------------------------------------------------------------------------------- /tests/examples/address.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties as Enum, Symmetric 3 | 4 | 5 | class AddressRoute(Enum): 6 | 7 | # name is a builtin property of Enum, we can override its case insensitivity 8 | name: t.Annotated[str, Symmetric(case_fold=True)] 9 | 10 | abbr: t.Annotated[str, Symmetric(case_fold=True)] 11 | alt: t.Annotated[t.List[str], Symmetric(case_fold=True)] 12 | 13 | # name value abbr alt 14 | ALLEY = 1, 'ALY', ['ALLEE', 'ALLY'] 15 | AVENUE = 2, 'AVE', ['AV', 'AVEN', 'AVENU', 'AVN', 'AVNUE'] 16 | CIRCLE = 3, 'CIR', ['CIRC', 'CIRCL', 'CRCL', 'CRCLE'] 17 | 18 | # ... other types elided for brevity 19 | 20 | 21 | assert ( 22 | AddressRoute('avenue') # the name is case insensitive and symmetric 23 | is 24 | AddressRoute('AVE') # the abbr property is also symmetric 25 | is 26 | AddressRoute('Aven') # values in the alt property are symmetric 27 | is 28 | AddressRoute.AVENUE # all of the above resolve to the same enum instance 29 | ) 30 | -------------------------------------------------------------------------------- /tests/pickle_enums_annotations.py: -------------------------------------------------------------------------------- 1 | from enum import auto 2 | import typing as t 3 | from enum_properties import EnumProperties, FlagProperties, IntFlagProperties, Symmetric 4 | 5 | 6 | class IntPerm(IntFlagProperties): 7 | label: t.Annotated[str, Symmetric(case_fold=True)] 8 | 9 | R = 1, "read" 10 | W = 2, "write" 11 | X = 4, "execute" 12 | RWX = 7, "all" 13 | 14 | 15 | class Perm(FlagProperties): 16 | label: t.Annotated[str, Symmetric(case_fold=True)] 17 | 18 | R = auto(), "read" 19 | W = auto(), "write" 20 | X = auto(), "execute" 21 | RWX = R | W | X, "all" 22 | 23 | 24 | class PriorityEx(EnumProperties): 25 | prop1: t.Annotated[str, Symmetric()] 26 | prop2: t.Annotated[str, Symmetric(case_fold=True)] 27 | 28 | ONE = 0, "1", [3, 4] 29 | TWO = 1, "2", [3, "4"] 30 | THREE = 2, "3", [3, 4] 31 | 32 | 33 | class Color(EnumProperties): 34 | rgb: t.Tuple[int, int, int] 35 | hex: str 36 | 37 | RED = auto(), (1, 0, 0), "ff0000" 38 | GREEN = auto(), (0, 1, 0), "00ff00" 39 | BLUE = auto(), (0, 0, 1), "0000ff" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Brian Kohan 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. 22 | -------------------------------------------------------------------------------- /doc/source/_static/style.css: -------------------------------------------------------------------------------- 1 | 2 | section#reference div.highlight pre { 3 | color: #b30000; 4 | display: block; /* ensures it's treated as a block */ 5 | margin-left: auto; /* auto margins center block elements */ 6 | margin-right: auto; 7 | width: fit-content; 8 | } 9 | body[data-theme="light"] section#reference div.highlight, 10 | body[data-theme="light"] section#reference div.highlight pre { 11 | background-color: #f8f8f8; 12 | } 13 | 14 | body[data-theme="dark"] section#reference div.highlight, 15 | body[data-theme="dark"] section#reference div.highlight pre { 16 | background-color: #202020; 17 | } 18 | 19 | /* AUTO → system prefers DARK (acts like dark unless user forced light) */ 20 | @media (prefers-color-scheme: dark) { 21 | body:not([data-theme="light"]) #reference .highlight, 22 | body:not([data-theme="light"]) #reference .highlight pre { 23 | background-color: #202020; 24 | } 25 | } 26 | 27 | /* AUTO → system prefers LIGHT (acts like light unless user forced dark) */ 28 | @media (prefers-color-scheme: light) { 29 | body:not([data-theme="dark"]) #reference .highlight, 30 | body:not([data-theme="dark"]) #reference .highlight pre { 31 | background-color: #f8f8f8; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/legacy/test_type_hints.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from unittest import TestCase 3 | 4 | from enum_properties import IntEnumProperties, p, s 5 | 6 | 7 | class TestTypeHints(TestCase): 8 | def test_type_hints(self): 9 | from typing import get_type_hints 10 | 11 | class MyEnum(IntEnumProperties, s("label"), p("idx")): 12 | label: str 13 | idx: int 14 | 15 | ITEM1 = 1, "item1", 0 16 | ITEM2 = 2, "item2", 1 17 | ITEM3 = 3, "item3", 2 18 | 19 | self.assertEqual(MyEnum.ITEM1, 1) 20 | self.assertEqual(MyEnum.ITEM1.value, 1) 21 | self.assertEqual(MyEnum.ITEM1.label, "item1") 22 | self.assertEqual(MyEnum.ITEM1.idx, 0) 23 | self.assertEqual(get_type_hints(MyEnum.ITEM1), {"label": str, "idx": int}) 24 | 25 | self.assertEqual(MyEnum.ITEM2, 2) 26 | self.assertEqual(MyEnum.ITEM2.value, 2) 27 | self.assertEqual(MyEnum.ITEM2.label, "item2") 28 | self.assertEqual(MyEnum.ITEM2.idx, 1) 29 | self.assertEqual(get_type_hints(MyEnum.ITEM2), {"label": str, "idx": int}) 30 | 31 | self.assertEqual(MyEnum.ITEM3, 3) 32 | self.assertEqual(MyEnum.ITEM3.value, 3) 33 | self.assertEqual(MyEnum.ITEM3.label, "item3") 34 | self.assertEqual(MyEnum.ITEM3.idx, 2) 35 | self.assertEqual(get_type_hints(MyEnum.ITEM3), {"label": str, "idx": int}) 36 | -------------------------------------------------------------------------------- /tests/examples/howto_flag.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import IntFlagProperties, Symmetric 3 | 4 | class Perm(IntFlagProperties): 5 | 6 | label: t.Annotated[str, Symmetric(case_fold=True)] 7 | 8 | R = 1, 'read' 9 | W = 2, 'write' 10 | X = 4, 'execute' 11 | RWX = 7, 'all' 12 | 13 | 14 | # properties for combined flags, that are not listed will not exist 15 | assert not hasattr((Perm.R | Perm.W), "label") 16 | 17 | # but combined flags can be specified and given properties 18 | assert (Perm.R | Perm.W | Perm.X) is Perm.RWX 19 | assert (Perm.R | Perm.W | Perm.X).label == 'all' 20 | 21 | # list the active flags: 22 | assert (Perm.R | Perm.W).flagged == [Perm.R, Perm.W] 23 | assert (Perm.R | Perm.W | Perm.X).flagged == [Perm.R, Perm.W, Perm.X] 24 | 25 | 26 | assert Perm([Perm.R, Perm.W, Perm.X]) is Perm.RWX 27 | assert Perm({'read', 'write', 'execute'}) is Perm.RWX 28 | assert Perm(perm for perm in (1, 'write', Perm.X)) is Perm.RWX 29 | 30 | # iterate through active flags 31 | assert [perm for perm in Perm.RWX] == [Perm.R, Perm.W, Perm.X] 32 | 33 | # flagged property returns list of flags 34 | assert (Perm.R | Perm.W).flagged == [Perm.R, Perm.W] 35 | 36 | # instantiate a Flag off an empty iterable 37 | assert Perm(0) == Perm([]) 38 | 39 | # check number of active flags: 40 | assert len(Perm(0)) == 0 41 | assert len(Perm.RWX) == 3 42 | assert len(Perm.R | Perm.X) == 2 43 | assert len(Perm.R & Perm.X) == 0 44 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Enum Properties 3 | ======================= 4 | 5 | Add properties to Python enumeration values in a simple declarative syntax. 6 | `enum-properties `_ is a lightweight extension to 7 | Python's :class:`enum.Enum` class. Example: 8 | 9 | .. literalinclude:: ../../tests/examples/color_example.py 10 | 11 | 12 | Properties may also be symmetrically mapped to enumeration values using 13 | Symmetric type annotations: 14 | 15 | .. literalinclude:: ../../tests/examples/symmetric_example.py 16 | 17 | 18 | Member functions may also be specialized to each enumeration value, using the ``@specialize`` 19 | decorator: 20 | 21 | .. literalinclude:: ../../tests/examples/specialization_example.py 22 | 23 | 24 | Please report bugs and discuss features on the 25 | `issues page `_. 26 | 27 | `Contributions `_ are 28 | encouraged! 29 | 30 | `Full documentation at read the docs. `_ 31 | 32 | Installation 33 | ------------ 34 | 35 | 1. Clone enum-properties from `GitHub `_ or install a 36 | release off `pypi `_: 37 | 38 | .. code:: bash 39 | 40 | pip install enum-properties 41 | 42 | .. toctree:: 43 | :maxdepth: 2 44 | :caption: Contents: 45 | 46 | howto 47 | tutorial 48 | reference 49 | changelog 50 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | pull_request: 9 | workflow_call: 10 | workflow_dispatch: 11 | inputs: 12 | debug: 13 | description: 'Open ssh debug session.' 14 | required: true 15 | default: false 16 | type: boolean 17 | 18 | jobs: 19 | 20 | linting: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | # run static analysis on bleeding and trailing edges 25 | python-version: [ '3.9', '3.14' ] 26 | 27 | steps: 28 | - uses: actions/checkout@v6 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v6 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | allow-prereleases: true 34 | - name: Install uv 35 | uses: astral-sh/setup-uv@v7 36 | with: 37 | enable-cache: true 38 | - name: Install Just 39 | uses: extractions/setup-just@v3 40 | - name: Install Dependencies 41 | run: | 42 | just setup ${{ steps.sp.outputs.python-path }} 43 | just install-docs 44 | - name: Install Emacs 45 | if: ${{ github.event.inputs.debug == 'true' }} 46 | run: | 47 | sudo apt install emacs 48 | - name: Setup tmate session 49 | if: ${{ github.event.inputs.debug == 'true' }} 50 | uses: mxschmitt/action-tmate@v3.23 51 | with: 52 | detached: true 53 | timeout-minutes: 60 54 | - name: Run Static Analysis 55 | run: | 56 | just check-lint 57 | just check-format 58 | just check-types 59 | just check-package 60 | just check-readme 61 | -------------------------------------------------------------------------------- /tests/legacy/test_pickle.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from io import BytesIO 3 | from unittest import TestCase 4 | 5 | 6 | class TestPickle(TestCase): 7 | def do_pickle_test(self, ipt): 8 | buffer = BytesIO() 9 | pickle.dump(ipt, buffer, pickle.HIGHEST_PROTOCOL) 10 | buffer.seek(0) 11 | opt = pickle.load(buffer) 12 | return ipt is opt 13 | 14 | def test_pickle(self): 15 | from tests.pickle_enums import Color, PriorityEx 16 | 17 | self.assertTrue(self.do_pickle_test(PriorityEx.ONE)) 18 | self.assertTrue(self.do_pickle_test(PriorityEx.TWO)) 19 | self.assertTrue(self.do_pickle_test(PriorityEx.THREE)) 20 | 21 | self.assertTrue(self.do_pickle_test(PriorityEx("1"))) 22 | self.assertTrue(self.do_pickle_test(PriorityEx("2"))) 23 | self.assertTrue(self.do_pickle_test(PriorityEx(3))) 24 | 25 | self.assertTrue(self.do_pickle_test(Color.RED)) 26 | self.assertTrue(self.do_pickle_test(Color.GREEN)) 27 | self.assertTrue(self.do_pickle_test(Color.BLUE)) 28 | 29 | def test_flag_pickle(self): 30 | from tests.pickle_enums import IntPerm, Perm 31 | 32 | self.assertTrue(self.do_pickle_test(Perm.R)) 33 | self.assertTrue(self.do_pickle_test(Perm.W)) 34 | self.assertTrue(self.do_pickle_test(Perm.X)) 35 | self.assertTrue(self.do_pickle_test(Perm.RWX)) 36 | self.assertTrue(self.do_pickle_test(Perm.R | Perm.W | Perm.X)) 37 | self.assertTrue(self.do_pickle_test(Perm.R | Perm.W)) 38 | 39 | self.assertTrue(self.do_pickle_test(IntPerm.R)) 40 | self.assertTrue(self.do_pickle_test(IntPerm.W)) 41 | self.assertTrue(self.do_pickle_test(IntPerm.X)) 42 | self.assertTrue(self.do_pickle_test(IntPerm.RWX)) 43 | self.assertTrue(self.do_pickle_test(IntPerm.R | IntPerm.W | IntPerm.X)) 44 | self.assertTrue(self.do_pickle_test(IntPerm.W | IntPerm.X)) 45 | -------------------------------------------------------------------------------- /tests/annotations/test_pickle.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from io import BytesIO 3 | from unittest import TestCase 4 | 5 | 6 | class TestPickle(TestCase): 7 | def do_pickle_test(self, ipt): 8 | buffer = BytesIO() 9 | pickle.dump(ipt, buffer, pickle.HIGHEST_PROTOCOL) 10 | buffer.seek(0) 11 | opt = pickle.load(buffer) 12 | return ipt is opt 13 | 14 | def test_pickle(self): 15 | from tests.pickle_enums_annotations import Color, PriorityEx 16 | 17 | self.assertTrue(self.do_pickle_test(PriorityEx.ONE)) 18 | self.assertTrue(self.do_pickle_test(PriorityEx.TWO)) 19 | self.assertTrue(self.do_pickle_test(PriorityEx.THREE)) 20 | 21 | self.assertTrue(self.do_pickle_test(PriorityEx("1"))) 22 | self.assertTrue(self.do_pickle_test(PriorityEx("2"))) 23 | self.assertTrue(self.do_pickle_test(PriorityEx(3))) 24 | 25 | self.assertTrue(self.do_pickle_test(Color.RED)) 26 | self.assertTrue(self.do_pickle_test(Color.GREEN)) 27 | self.assertTrue(self.do_pickle_test(Color.BLUE)) 28 | 29 | def test_flag_pickle(self): 30 | from tests.pickle_enums_annotations import IntPerm, Perm 31 | 32 | self.assertTrue(self.do_pickle_test(Perm.R)) 33 | self.assertTrue(self.do_pickle_test(Perm.W)) 34 | self.assertTrue(self.do_pickle_test(Perm.X)) 35 | self.assertTrue(self.do_pickle_test(Perm.RWX)) 36 | self.assertTrue(self.do_pickle_test(Perm.R | Perm.W | Perm.X)) 37 | self.assertTrue(self.do_pickle_test(Perm.R | Perm.W)) 38 | 39 | self.assertTrue(self.do_pickle_test(IntPerm.R)) 40 | self.assertTrue(self.do_pickle_test(IntPerm.W)) 41 | self.assertTrue(self.do_pickle_test(IntPerm.X)) 42 | self.assertTrue(self.do_pickle_test(IntPerm.RWX)) 43 | self.assertTrue(self.do_pickle_test(IntPerm.R | IntPerm.W | IntPerm.X)) 44 | self.assertTrue(self.do_pickle_test(IntPerm.W | IntPerm.X)) 45 | -------------------------------------------------------------------------------- /tests/examples/mapbox.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from enum_properties import EnumProperties as Enum, s, Symmetric, symmetric 3 | 4 | 5 | class MapBoxStyle(Enum): 6 | """ 7 | https://docs.mapbox.com/api/maps/styles/ 8 | """ 9 | 10 | # we may also mark name symmetric by including a type hint for it 11 | name: t.Annotated[str, Symmetric(case_fold=True)] 12 | 13 | # type hints specify our additional enum instance properties 14 | label: t.Annotated[str, Symmetric(case_fold=True)] 15 | version: int 16 | 17 | # name value label version 18 | STREETS = 'streets', 'Streets', 11 19 | OUTDOORS = 'outdoors', 'Outdoors', 11 20 | LIGHT = 'light', 'Light', 10 21 | DARK = 'dark', 'Dark', 10 22 | SATELLITE = 'satellite', 'Satellite', 9 23 | SATELLITE_STREETS = 'satellite-streets', 'Satellite Streets', 11 24 | NAVIGATION_DAY = 'navigation-day', 'Navigation Day', 1 25 | NAVIGATION_NIGHT = 'navigation-night', 'Navigation Night', 1 26 | 27 | # we can define a normal property to produce property values based 28 | # off other properties! We can even use the symmetric decorator to make it symmetric 29 | @symmetric() 30 | @property 31 | def uri(self) -> str: 32 | return f'mapbox://styles/mapbox/{self.value}-v{self.version}' 33 | 34 | def __str__(self): 35 | return self.uri 36 | 37 | 38 | assert MapBoxStyle.LIGHT.uri == 'mapbox://styles/mapbox/light-v10' 39 | 40 | # uri's are symmetric 41 | assert MapBoxStyle('mapbox://styles/mapbox/light-v10') is MapBoxStyle.LIGHT 42 | 43 | # so are labels (also case insensitive) 44 | assert MapBoxStyle('satellite streets') is MapBoxStyle.SATELLITE_STREETS 45 | 46 | # when used in API calls (coerced to strings) - they "do the right thing" 47 | assert str(MapBoxStyle.DARK) == 'mapbox://styles/mapbox/dark-v10' 48 | -------------------------------------------------------------------------------- /tests/annotations/test_type_hints.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | from typing import Annotated 4 | 5 | from enum_properties import ( 6 | IntEnumProperties, 7 | Symmetric, 8 | ) 9 | 10 | 11 | class TestTypeHints(TestCase): 12 | def test_type_hints(self): 13 | from typing import List, get_type_hints 14 | 15 | class MyEnum(IntEnumProperties): 16 | label: Annotated[str, Symmetric()] 17 | idx: int 18 | 19 | ITEM1 = 1, "item1", 0 20 | ITEM2 = 2, "item2", 1 21 | ITEM3 = 3, "item3", 2 22 | 23 | self.assertEqual(MyEnum.ITEM1, 1) 24 | self.assertEqual(MyEnum.ITEM1.value, 1) 25 | self.assertEqual(MyEnum.ITEM1.label, "item1") 26 | self.assertEqual(MyEnum.ITEM1.idx, 0) 27 | 28 | if sys.version_info >= (3, 9): 29 | self.assertEqual(get_type_hints(MyEnum.ITEM1), {"label": str, "idx": int}) 30 | else: 31 | self.assertEqual(get_type_hints(MyEnum.ITEM1)["label"].__origin__, str) 32 | self.assertEqual(get_type_hints(MyEnum.ITEM1)["idx"], int) 33 | 34 | self.assertEqual(MyEnum.ITEM2, 2) 35 | self.assertEqual(MyEnum.ITEM2.value, 2) 36 | self.assertEqual(MyEnum.ITEM2.label, "item2") 37 | self.assertEqual(MyEnum.ITEM2.idx, 1) 38 | 39 | if sys.version_info >= (3, 9): 40 | self.assertEqual(get_type_hints(MyEnum.ITEM2), {"label": str, "idx": int}) 41 | else: 42 | self.assertEqual(get_type_hints(MyEnum.ITEM2)["label"].__origin__, str) 43 | self.assertEqual(get_type_hints(MyEnum.ITEM2)["idx"], int) 44 | 45 | self.assertEqual(MyEnum.ITEM3, 3) 46 | self.assertEqual(MyEnum.ITEM3.value, 3) 47 | self.assertEqual(MyEnum.ITEM3.label, "item3") 48 | self.assertEqual(MyEnum.ITEM3.idx, 2) 49 | 50 | if sys.version_info >= (3, 9): 51 | self.assertEqual(get_type_hints(MyEnum.ITEM3), {"label": str, "idx": int}) 52 | else: 53 | self.assertEqual(get_type_hints(MyEnum.ITEM3)["label"].__origin__, str) 54 | self.assertEqual(get_type_hints(MyEnum.ITEM3)["idx"], int) 55 | -------------------------------------------------------------------------------- /tests/legacy/test_none_coercion.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from enum_properties import ( 4 | EnumProperties, 5 | s, 6 | ) 7 | 8 | 9 | class NoneCoercionTests(TestCase): 10 | def test_string_to_none_coercion_disabled(self): 11 | class EnumWithNones(EnumProperties, s("prop", match_none=True)): 12 | VALUE1 = 1, None 13 | VALUE2 = 2, "label" 14 | 15 | self.assertRaises(ValueError, EnumWithNones, "None") 16 | 17 | class EnumWithNones( 18 | EnumProperties, s("prop", case_fold=True, match_none=False) 19 | ): 20 | VALUE1 = 1, None 21 | VALUE2 = 2, "label" 22 | 23 | self.assertRaises(ValueError, EnumWithNones, "None") 24 | 25 | class EnumWithNones(EnumProperties): 26 | VALUE1 = None 27 | VALUE2 = "label" 28 | 29 | self.assertRaises(ValueError, EnumWithNones, "None") 30 | self.assertEqual(EnumWithNones(None), EnumWithNones.VALUE1) 31 | 32 | def test_none_to_string_coercion_disabled(self): 33 | class EnumWithNones(EnumProperties, s("prop", match_none=True)): 34 | VALUE1 = 1, "None" 35 | VALUE2 = 2, "label" 36 | 37 | self.assertRaises(ValueError, EnumWithNones, None) 38 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 39 | 40 | class EnumWithNones(EnumProperties, s("prop", case_fold=True, match_none=True)): 41 | VALUE1 = 1, "None" 42 | VALUE2 = 2, "label" 43 | 44 | self.assertRaises(ValueError, EnumWithNones, None) 45 | self.assertEqual(EnumWithNones("none"), EnumWithNones.VALUE1) 46 | 47 | class EnumWithNones(EnumProperties, s("prop", match_none=False)): 48 | VALUE1 = 1, "None" 49 | VALUE2 = 2, "label" 50 | 51 | self.assertRaises(ValueError, EnumWithNones, None) 52 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 53 | 54 | class EnumWithNones(EnumProperties): 55 | VALUE1 = "None" 56 | VALUE2 = "label" 57 | 58 | self.assertRaises(ValueError, EnumWithNones, None) 59 | self.assertRaises(KeyError, lambda x: EnumWithNones[x], None) 60 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 61 | -------------------------------------------------------------------------------- /tests/annotations/test_perf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | 4 | 5 | class PerformanceAndMemoryChecks(TestCase): 6 | from tests.big_enum_annotations import ISOCountry 7 | 8 | def test_check_big_enum_size(self): 9 | """ 10 | Memory benchmarks: 11 | 12 | v1.3.3 ISOCountry: 151966 bytes 13 | v1.4.0 ISOCountry: 105046 bytes 14 | """ 15 | 16 | seen = {} 17 | total_size = 0 18 | for name, attr in vars(self.ISOCountry).items(): 19 | total_size += sys.getsizeof(attr) 20 | seen[(id(attr))] = (name, sys.getsizeof(attr)) 21 | 22 | for val in self.ISOCountry: 23 | for name, attr in vars(self.ISOCountry).items(): 24 | if id(attr) not in seen: # pragma: no cover 25 | total_size += sys.getsizeof(attr) 26 | seen[(id(attr))] = (name, sys.getsizeof(attr)) 27 | 28 | print("Total Memory footprint of ISOCountry: {} bytes".format(total_size)) 29 | 30 | def test_property_access_time(self): 31 | """ 32 | Access benchmarks: 33 | 34 | v1.3.3 ISOCountry: ~1.05 seconds (macbook M1 Pro) 35 | v1.4.0 ISOCountry: ~0.196 seconds (macbook M1 Pro) (5.3x faster) 36 | """ 37 | 38 | # use perf counter to time the length of a for loop execution 39 | from time import perf_counter 40 | 41 | for_loop_time = perf_counter() 42 | for i in range(1000000): 43 | self.ISOCountry.US.full_name 44 | 45 | for_loop_time = perf_counter() - for_loop_time 46 | print("for loop time: {}".format(for_loop_time)) 47 | 48 | def test_symmetric_mapping(self): 49 | """ 50 | Symmetric mapping benchmarks 51 | 52 | v1.4.0 ISOCountry: ~3 seconds (macbook M1 Pro) 53 | v1.4.1 ISOCountry: ~ seconds (macbook M1 Pro) (x faster) 54 | """ 55 | self.assertEqual( 56 | self.ISOCountry(self.ISOCountry.US.full_name.lower()), self.ISOCountry.US 57 | ) 58 | from time import perf_counter 59 | 60 | for_loop_time = perf_counter() 61 | for i in range(1000000): 62 | self.ISOCountry(self.ISOCountry.US.full_name.lower()) 63 | 64 | for_loop_time = perf_counter() - for_loop_time 65 | print("for loop time: {}".format(for_loop_time)) 66 | -------------------------------------------------------------------------------- /tests/legacy/test_perf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | 4 | 5 | class Unhashable: 6 | pass 7 | 8 | 9 | class PerformanceAndMemoryChecks(TestCase): 10 | from tests.big_enum import ISOCountry 11 | 12 | def test_check_big_enum_size(self): 13 | """ 14 | Memory benchmarks: 15 | 16 | v1.3.3 ISOCountry: 151966 bytes 17 | v1.4.0 ISOCountry: 105046 bytes 18 | """ 19 | 20 | seen = {} 21 | total_size = 0 22 | for name, attr in vars(self.ISOCountry).items(): 23 | total_size += sys.getsizeof(attr) 24 | seen[(id(attr))] = (name, sys.getsizeof(attr)) 25 | 26 | for val in self.ISOCountry: 27 | for name, attr in vars(self.ISOCountry).items(): 28 | if id(attr) not in seen: # pragma: no cover 29 | total_size += sys.getsizeof(attr) 30 | seen[(id(attr))] = (name, sys.getsizeof(attr)) 31 | 32 | print("Total Memory footprint of ISOCountry: {} bytes".format(total_size)) 33 | 34 | def test_property_access_time(self): 35 | """ 36 | Access benchmarks: 37 | 38 | v1.3.3 ISOCountry: ~1.05 seconds (macbook M1 Pro) 39 | v1.4.0 ISOCountry: ~0.196 seconds (macbook M1 Pro) (5.3x faster) 40 | """ 41 | 42 | # use perf counter to time the length of a for loop execution 43 | from time import perf_counter 44 | 45 | for_loop_time = perf_counter() 46 | for i in range(1000000): 47 | self.ISOCountry.US.full_name 48 | 49 | for_loop_time = perf_counter() - for_loop_time 50 | print("for loop time: {}".format(for_loop_time)) 51 | 52 | def test_symmetric_mapping(self): 53 | """ 54 | Symmetric mapping benchmarks 55 | 56 | v1.4.0 ISOCountry: ~3 seconds (macbook M1 Pro) 57 | v1.4.1 ISOCountry: ~ seconds (macbook M1 Pro) (x faster) 58 | """ 59 | self.assertEqual( 60 | self.ISOCountry(self.ISOCountry.US.full_name.lower()), self.ISOCountry.US 61 | ) 62 | from time import perf_counter 63 | 64 | for_loop_time = perf_counter() 65 | for i in range(1000000): 66 | self.ISOCountry(self.ISOCountry.US.full_name.lower()) 67 | 68 | for_loop_time = perf_counter() - for_loop_time 69 | print("for loop time: {}".format(for_loop_time)) 70 | -------------------------------------------------------------------------------- /tests/annotations/test_none_coercion.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from unittest import TestCase 3 | from typing import Annotated 4 | 5 | from enum_properties import ( 6 | EnumProperties, 7 | Symmetric, 8 | ) 9 | 10 | 11 | class NoneCoercionTests(TestCase): 12 | def test_string_to_none_coercion_disabled(self): 13 | class EnumWithNones(EnumProperties): 14 | prop: Annotated[t.Optional[str], Symmetric(match_none=True)] 15 | 16 | VALUE1 = 1, None 17 | VALUE2 = 2, "label" 18 | 19 | self.assertRaises(ValueError, EnumWithNones, "None") 20 | 21 | class EnumWithNones(EnumProperties): 22 | prop: Annotated[t.Optional[str], Symmetric(case_fold=True, match_none=True)] 23 | 24 | VALUE1 = 1, None 25 | VALUE2 = 2, "label" 26 | 27 | self.assertRaises(ValueError, EnumWithNones, "None") 28 | 29 | class EnumWithNones(EnumProperties): 30 | VALUE1 = None 31 | VALUE2 = "label" 32 | 33 | self.assertRaises(ValueError, EnumWithNones, "None") 34 | self.assertEqual(EnumWithNones(None), EnumWithNones.VALUE1) 35 | 36 | def test_none_to_string_coercion_disabled(self): 37 | class EnumWithNones(EnumProperties): 38 | prop: Annotated[str, Symmetric(match_none=True)] 39 | 40 | VALUE1 = 1, "None" 41 | VALUE2 = 2, "label" 42 | 43 | self.assertRaises(ValueError, EnumWithNones, None) 44 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 45 | 46 | class EnumWithNones(EnumProperties): 47 | prop: Annotated[str, Symmetric(case_fold=True, match_none=True)] 48 | 49 | VALUE1 = 1, "None" 50 | VALUE2 = 2, "label" 51 | 52 | self.assertRaises(ValueError, EnumWithNones, None) 53 | self.assertEqual(EnumWithNones("none"), EnumWithNones.VALUE1) 54 | 55 | class EnumWithNones(EnumProperties): 56 | prop: Annotated[str, Symmetric(match_none=True)] 57 | 58 | VALUE1 = 1, "None" 59 | VALUE2 = 2, "label" 60 | 61 | self.assertRaises(ValueError, EnumWithNones, None) 62 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 63 | 64 | class EnumWithNones(EnumProperties): 65 | VALUE1 = "None" 66 | VALUE2 = "label" 67 | 68 | self.assertRaises(ValueError, EnumWithNones, None) 69 | self.assertRaises(KeyError, lambda x: EnumWithNones[x], None) 70 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | .python_version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | /poetry.lock 132 | /test.db 133 | .DS_Store 134 | .idea 135 | .ruff_cache 136 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | name: OpenSSF Scorecard 2 | on: 3 | # For Branch-Protection check. Only the default branch is supported. See 4 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 5 | branch_protection_rule: 6 | # To guarantee Maintained check is occasionally updated. See 7 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 8 | push: 9 | branches: [ main ] 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | name: Scorecard analysis 16 | runs-on: ubuntu-latest 17 | permissions: 18 | security-events: write 19 | id-token: write 20 | 21 | steps: 22 | - name: "Checkout code" 23 | uses: actions/checkout@v6 24 | with: 25 | persist-credentials: false 26 | 27 | - name: "Run analysis" 28 | uses: ossf/scorecard-action@v2.4.3 29 | with: 30 | results_file: results.sarif 31 | results_format: sarif 32 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 33 | # - you want to enable the Branch-Protection check on a *public* repository, or 34 | # - you are installing Scorecard on a *private* repository 35 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 36 | repo_token: ${{ secrets.SCORECARD_TOKEN }} 37 | 38 | # Public repositories: 39 | # - Publish results to OpenSSF REST API for easy access by consumers 40 | # - Allows the repository to include the Scorecard badge. 41 | # - See https://github.com/ossf/scorecard-action#publishing-results. 42 | # For private repositories: 43 | # - `publish_results` will always be set to `false`, regardless 44 | # of the value entered here. 45 | publish_results: true 46 | 47 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 48 | # format to the repository Actions tab. 49 | - name: "Upload artifact" 50 | uses: actions/upload-artifact@v6 51 | with: 52 | name: SARIF file 53 | path: results.sarif 54 | retention-days: 5 55 | 56 | # Upload the results to GitHub's code scanning dashboard (optional). 57 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 58 | - name: "Upload to code-scanning" 59 | uses: github/codeql-action/upload-sarif@v4 60 | with: 61 | sarif_file: results.sarif 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are encouraged! Please use the issue page to submit feature requests or bug reports. Issues with attached PRs will be given priority and have a much higher likelihood of acceptance. Please also open an issue and associate it with any submitted PRs. The aim is to keep this library as lightweight as possible. Only features with broad based use cases will be considered. 4 | 5 | We are actively seeking additional maintainers. If you're interested, please contact [me](https://github.com/bckohan). 6 | 7 | 8 | ## Installation 9 | 10 | ### Install Just 11 | 12 | We provide a platform independent justfile with recipes for all the development tasks. You should [install just](https://just.systems/man/en/installation.html) if it is not on your system already. 13 | 14 | `enum-properties` uses [uv](https://docs.astral.sh/uv) for environment, package and dependency management: 15 | 16 | ```bash 17 | just install-uv 18 | ``` 19 | 20 | Next, initialize and install the development environment: 21 | 22 | ```bash 23 | just setup 24 | just install 25 | ``` 26 | 27 | ## Documentation 28 | 29 | `enum-properties` documentation is generated using [Sphinx](https://www.sphinx-doc.org). Any new feature PRs must provide updated documentation for the features added. To build the docs run: 30 | 31 | ```bash 32 | just install-docs 33 | just docs 34 | ``` 35 | 36 | Or to serve a live automatic rebuilding on localhost: 37 | 38 | ```bash 39 | just docs-live 40 | ``` 41 | 42 | 43 | ## Static Analysis 44 | 45 | `enum-properties` uses [ruff](https://docs.astral.sh/ruff) for python linting and formatting. [mypy](http://mypy-lang.org) and [pyright](https://github.com/microsoft/pyright) are used for static type checking. Before any PR is accepted the following must be run, and static analysis tools should not produce any errors or warnings. Disabling certain errors or warnings where justified is acceptable: 46 | 47 | ```bash 48 | just check 49 | ``` 50 | 51 | ## Running Tests 52 | 53 | `enum-properties` uses [pytest](https://docs.pytest.org/) to define and run tests. All the tests are housed under ``tests/``. Before a PR is accepted, all tests must be passing and the code coverage must be at 100%. A small number of exempted error handling branches are acceptable. 54 | 55 | To run the full suite: 56 | 57 | ```bash 58 | just test 59 | ``` 60 | 61 | To run a single test, or group of tests in a class: 62 | 63 | ```bash 64 | just test ::ClassName::FunctionName 65 | ``` 66 | 67 | For instance to run all tests in TestFlags, and then just the 68 | test_int_flag example test you would do: 69 | 70 | ```bash 71 | just test tests/annotations/test_flags.py::TestFlags 72 | just test tests/annotations/test_flags.py::TestFlags::test_int_flag 73 | ``` 74 | 75 | 76 | ## Issuing Releases 77 | 78 | Update the versions in pyproject.toml and src/enum_properties/__init__.py then run: 79 | 80 | ```bash 81 | just release x.x.x 82 | ``` 83 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import sys 3 | from pathlib import Path 4 | from sphinx.ext.autodoc import between 5 | 6 | sys.path.append(str(Path(__file__).parent.parent.parent)) 7 | import enum_properties 8 | 9 | # Configuration file for the Sphinx documentation builder. 10 | # 11 | # This file only contains a selection of the most common options. For a full 12 | # list see the documentation: 13 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 14 | 15 | # -- Path setup -------------------------------------------------------------- 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | # 21 | # import os 22 | # import sys 23 | # sys.path.insert(0, os.path.abspath('.')) 24 | 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | project = enum_properties.__title__ 29 | copyright = enum_properties.__copyright__ 30 | author = enum_properties.__author__ 31 | release = enum_properties.__version__ 32 | 33 | 34 | # -- General configuration --------------------------------------------------- 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = [ 40 | 'sphinx.ext.autodoc', 41 | 'sphinx.ext.todo', 42 | 'sphinx.ext.intersphinx', 43 | "sphinx_tabs.tabs" 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # List of patterns, relative to source directory, that match files and 50 | # directories to ignore when looking for source files. 51 | # This pattern also affects html_static_path and html_extra_path. 52 | exclude_patterns = [] 53 | 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | 57 | # The theme to use for HTML and HTML Help pages. See the documentation for 58 | # a list of builtin themes. 59 | # 60 | html_theme = 'furo' 61 | html_theme_options = { 62 | "source_repository": "https://github.com/bckohan/enum-properties/", 63 | "source_branch": "main", 64 | "source_directory": "doc/source", 65 | } 66 | 67 | # Add any paths that contain custom static files (such as style sheets) here, 68 | # relative to this directory. They are copied after the builtin static files, 69 | # so a file named "default.css" will overwrite the builtin "default.css". 70 | html_static_path = ["_static"] 71 | html_css_files = [ 72 | "style.css", 73 | ] 74 | 75 | todo_include_todos = True 76 | 77 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} 78 | 79 | 80 | def setup(app): 81 | # Register a sphinx.ext.autodoc.between listener to ignore everything 82 | # between lines that contain the word IGNORE 83 | app.connect( 84 | 'autodoc-process-docstring', 85 | between('^.*[*]{79}.*$', exclude=True) 86 | ) 87 | return app 88 | -------------------------------------------------------------------------------- /tests/annotations/test_symmetric.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from enum_properties import EnumProperties, symmetric 3 | import sys 4 | 5 | if sys.version_info[0:2] >= (3, 11): 6 | from enum import property as enum_property 7 | else: 8 | from types import DynamicClassAttribute as enum_property 9 | 10 | 11 | class TestSymmetricDecorator(TestCase): 12 | """ 13 | Test the specialize decorator 14 | """ 15 | 16 | def test_symmetric_decorator_case_fold(self): 17 | class SymEnum(EnumProperties): 18 | ONE = 1 19 | TWO = 2 20 | THREE = 3 21 | 22 | @symmetric(case_fold=True) 23 | @property 24 | def label(self): 25 | return self.name 26 | 27 | self.assertEqual(SymEnum.ONE.label, "ONE") 28 | self.assertEqual(SymEnum.TWO.label, "TWO") 29 | self.assertEqual(SymEnum.THREE.label, "THREE") 30 | 31 | self.assertTrue(SymEnum("one") is SymEnum.ONE) 32 | self.assertTrue(SymEnum("tWo") is SymEnum.TWO) 33 | self.assertTrue(SymEnum("THRee") is SymEnum.THREE) 34 | 35 | def test_symmetric_decorator_none(self): 36 | class SymEnum(EnumProperties): 37 | ONE = 1 38 | TWO = 2 39 | THREE = 3 40 | 41 | @symmetric() 42 | @property 43 | def label(self): 44 | if self.value == 1: 45 | return None 46 | return self.name 47 | 48 | self.assertTrue(SymEnum.ONE.label is None) 49 | self.assertEqual(SymEnum.TWO.label, "TWO") 50 | self.assertEqual(SymEnum.THREE.label, "THREE") 51 | 52 | self.assertRaises(ValueError, SymEnum, None) 53 | self.assertRaises(ValueError, SymEnum, "tWo") 54 | self.assertTrue(SymEnum("TWO") is SymEnum.TWO) 55 | self.assertTrue(SymEnum("THREE") is SymEnum.THREE) 56 | 57 | class SymNoneEnum(EnumProperties): 58 | ONE = 1 59 | TWO = 2 60 | THREE = 3 61 | 62 | @symmetric(match_none=True) 63 | @property 64 | def label(self): 65 | if self.value == 1: 66 | return None 67 | return self.name 68 | 69 | self.assertTrue(SymNoneEnum(None) is SymNoneEnum.ONE) 70 | 71 | def test_symmetric_decorator_function(self): 72 | class SymEnum(EnumProperties): 73 | ONE = 1 74 | TWO = 2 75 | THREE = 3 76 | 77 | # lol - should work with anything 78 | @symmetric() 79 | def label(self): 80 | return self.name 81 | 82 | self.assertEqual(SymEnum.ONE.label(), "ONE") 83 | self.assertEqual(SymEnum.TWO.label(), "TWO") 84 | self.assertEqual(SymEnum.THREE.label(), "THREE") 85 | 86 | self.assertTrue(SymEnum(SymEnum.ONE.label) is SymEnum.ONE) 87 | self.assertTrue(SymEnum(SymEnum.TWO.label) is SymEnum.TWO) 88 | self.assertTrue(SymEnum(SymEnum.THREE.label) is SymEnum.THREE) 89 | 90 | def test_make_enum_properties_symmetric(self): 91 | class SymEnum(EnumProperties): 92 | ONE = 1 93 | TWO = 2 94 | THREE = 3 95 | 96 | @symmetric(case_fold=True) 97 | @enum_property 98 | def label(self): 99 | return self.name 100 | 101 | self.assertEqual(SymEnum.ONE.label, "ONE") 102 | self.assertEqual(SymEnum.TWO.label, "TWO") 103 | self.assertEqual(SymEnum.THREE.label, "THREE") 104 | 105 | self.assertTrue(SymEnum("one") is SymEnum.ONE) 106 | self.assertTrue(SymEnum("tWo") is SymEnum.TWO) 107 | self.assertTrue(SymEnum("THRee") is SymEnum.THREE) 108 | -------------------------------------------------------------------------------- /tests/examples/test_examples.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pytest 3 | import warnings 4 | 5 | 6 | if sys.version_info < (3, 9): 7 | # because we import typing.Annotated for brevity in the examples 8 | # TODO remove after drop 3.8 support 9 | pytest.skip("requires Python 3.9 or higher", allow_module_level=True) 10 | 11 | 12 | def test_basic_color_example(): 13 | from tests.examples import color_example 14 | 15 | 16 | def test_symmetric_example(): 17 | from tests.examples import symmetric_example 18 | 19 | 20 | def test_specialization_example(): 21 | from tests.examples import specialization_example 22 | 23 | 24 | def test_address_tutorial(): 25 | from tests.examples import address 26 | 27 | 28 | def test_mapbox_tutorial(): 29 | from tests.examples import mapbox 30 | 31 | 32 | def test_howto_add_props(): 33 | from tests.examples import howto_add_props 34 | 35 | 36 | def test_howto_metaclass(): 37 | from tests.examples import howto_metaclass 38 | 39 | 40 | def test_howto_symmetry(): 41 | from tests.examples import howto_symmetry 42 | 43 | 44 | @pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10 or higher") 45 | def test_howto_symmetric_overload(): 46 | from tests.examples import howto_symmetric_overload 47 | 48 | 49 | @pytest.mark.skipif(sys.version_info < (3, 11), reason="requires Python 3.11 or higher") 50 | def test_howto_verify_unique(): 51 | with pytest.raises(ValueError): 52 | from tests.examples import howto_verify_unique 53 | 54 | 55 | def test_howto_symmetric_builtins(): 56 | from tests.examples import howto_symmetric_builtins 57 | 58 | 59 | def test_howto_symmetric_decorator(): 60 | from tests.examples import howto_symmetric_decorator 61 | 62 | 63 | def test_howto_specialized(): 64 | from tests.examples import howto_specialized 65 | 66 | 67 | def test_howto_specialized_default(): 68 | from tests.examples import howto_specialized_default 69 | 70 | 71 | def test_howto_specialized_missing(): 72 | from tests.examples import howto_specialized_missing 73 | 74 | 75 | def test_howto_specialized_list(): 76 | from tests.examples import howto_specialized_list 77 | 78 | 79 | def test_howto_flag(): 80 | from tests.examples import howto_flag 81 | 82 | 83 | def test_howto_flag_no_iterable(): 84 | with pytest.raises(AttributeError): 85 | from tests.examples import howto_flags_no_iterable 86 | 87 | 88 | @pytest.mark.skipif(sys.version_info < (3, 11), reason="requires Python 3.11 or higher") 89 | def test_howto_flag_boundaries(): 90 | with pytest.raises(ValueError): 91 | from tests.examples import howto_flag_boundaries 92 | 93 | 94 | @pytest.mark.skipif(sys.version_info < (3, 11), reason="requires Python 3.11 or higher") 95 | def test_howto_nested_classes(): 96 | with warnings.catch_warnings(): 97 | warnings.simplefilter("ignore", DeprecationWarning) 98 | from tests.examples import howto_nested_classes_313 99 | 100 | 101 | @pytest.mark.skipif(sys.version_info >= (3, 13), reason="requires Python < 3.13") 102 | def test_howto_nested_classes_313(): 103 | with warnings.catch_warnings(): 104 | warnings.simplefilter("ignore", DeprecationWarning) 105 | from tests.examples import howto_nested_classes 106 | 107 | 108 | def test_howto_dataclass(): 109 | from tests.examples import howto_dataclass 110 | 111 | 112 | def test_howto_dataclass_integration(): 113 | from tests.examples import howto_dataclass_integration 114 | 115 | 116 | def test_howto_hash_equiv(): 117 | from tests.examples import howto_hash_equiv 118 | 119 | 120 | def test_howto_hash_equiv_def(): 121 | from tests.examples import howto_hash_equiv_def 122 | 123 | 124 | def test_howto_legacy(): 125 | from tests.examples import howto_legacy 126 | 127 | 128 | def test_howto_members_and_aliases(): 129 | from tests.examples import howto_members_and_aliases 130 | -------------------------------------------------------------------------------- /doc/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | ======== 4 | Tutorial 5 | ======== 6 | 7 | Enumerations in Python can provide rich class based interfaces, well suited to many scenarios. Two 8 | real world examples are presented here that leverage enum properties to encapsulate more 9 | information and get our enums to do more work. 10 | 11 | .. _tutorial_address_route: 12 | 13 | Address Route Type 14 | __________________ 15 | 16 | The USPS maintains a `list `_ of valid route types 17 | for United States addresses. We'd like to construct an address model that is searchable based on 18 | route type. A natural (and space efficient!) choice to represent the route is an enumeration. The 19 | USPS also maintains a list of official and common abbreviations for address routes. We'd like to 20 | encapsulate this information and parsing logic directly into our enumeration. We might implement 21 | it like so: 22 | 23 | .. literalinclude:: ../../tests/examples/address.py 24 | :lines: 1-19 25 | 26 | The builtin ``name`` property is the long-form official name for the route. By default enum 27 | properties does not make the ``name`` property case insensitive, so we override the default 28 | behavior by specifying a type hint for it. We also add a case insensitive abbreviation property and 29 | alt property. The alt property is a list of common alternative abbreviations. Now we can 30 | instantiate our enum from any valid route name, abbreviation or common alternative like so: 31 | 32 | .. literalinclude:: ../../tests/examples/address.py 33 | :lines: 21-29 34 | 35 | We use an integer literal as our enumeration values to save space if these enumerations need to be 36 | persisted in a datastore by value. By specifying them directly instead of using ``auto()`` we 37 | reserve the ability to add additional route types in alphabetical order without accidentally 38 | invalidating any persisted data. 39 | 40 | .. _tutorial_mapbox_style: 41 | 42 | MapBox Style 43 | ____________ 44 | 45 | `Mapbox `_ is a popular web mapping platform. It comes with a handful of 46 | default map styles. An enumeration is a natural choice to represent these styles but the styles are 47 | complicated by the fact that they are versioned and that when used as a parameter in the mapbox API 48 | they are in a URI format that is overly verbose for a human friendly user interface. 49 | 50 | Each mapbox style enumeration is therefore composed of 4 primary properties. A name slug used in 51 | the URI, a human friendly label for the style, a version number for the style and the full URI 52 | specification of the style. We might implement our style enumeration like so: 53 | 54 | .. literalinclude:: ../../tests/examples/mapbox.py 55 | :lines: 1-36 56 | 57 | We've used the style's name slug as the value of the enumeration. If storage was an issue 58 | (e.g. database) we could have separated this out into a separate property called ``slug`` and used 59 | a small integer or single character as the enumeration value. We've also added a symmetric case 60 | insensitive human friendly label for each style and a version property. 61 | 62 | The version numbers will increment over time, but we're only concerned with the most recent 63 | versions, so we'll increment their values in this enumeration as they change. If this enumeration 64 | is persisted by value, any version number updates exist only in code and will be picked up as those 65 | persisted values are re-instantiated as ``MapBoxStyle`` enumerations. 66 | 67 | The last property we've added is the ``uri`` property. We've added it as concrete property on the 68 | class because it can be created from the slug and version. We could have specified it in the value 69 | tuple but that would be very verbose and less 70 | `DRY `_. To make this property symmetric we 71 | decorate it with :py:func:`~enum_properties.symmetric`. 72 | 73 | We can use our enumeration like so: 74 | 75 | .. literalinclude:: ../../tests/examples/mapbox.py 76 | :lines: 38-47 77 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "enum-properties" 7 | version = "2.4.1" 8 | description = "Add properties and method specializations to Python enumeration values with a simple declarative syntax." 9 | requires-python = ">=3.9,<4.0" 10 | authors = [ 11 | {name = "Brian Kohan", email = "bckohan@gmail.com"} 12 | ] 13 | license = "MIT" 14 | license-files = [ "LICENSE" ] 15 | repository = "https://github.com/bckohan/enum-properties" 16 | homepage = "https://enum-properties.readthedocs.io" 17 | readme = "README.md" 18 | keywords = ["enum", "properties", "defines", "field", "dataclass", "dataclasses"] 19 | classifiers = [ 20 | "Environment :: Console", 21 | "Operating System :: OS Independent", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Development Status :: 5 - Production/Stable", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | "Natural Language :: English", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Programming Language :: Python :: 3.14", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: Software Development :: Libraries :: Python Modules" 36 | ] 37 | 38 | [project.urls] 39 | "Homepage" = "https://github.com/bckohan/enum-properties" 40 | "Documentation" = "https://enum-properties.readthedocs.io" 41 | "Repository" = "https://github.com/bckohan/enum-properties" 42 | "Issues" = "https://github.com/bckohan/enum-properties/issues" 43 | "Changelog" = "https://enum-properties.readthedocs.io/en/latest/changelog.html" 44 | "Code_of_Conduct" = "https://github.com/bckohan/enum-properties/blob/main/CODE_OF_CONDUCT.md" 45 | 46 | [tool.hatch.build.targets.wheel] 47 | packages = ["src/enum_properties"] 48 | 49 | 50 | [dependency-groups] 51 | dev = [ 52 | "coverage>=7.6.1", 53 | "importlib-metadata>=8.5.0", 54 | "ipdb>=0.13.13", 55 | "mypy>=1.14.1", 56 | "packaging>=24.2", 57 | "pre-commit>=3.5.0", 58 | "pyright>=1.1.396", 59 | "pytest>=8.3.5", 60 | "pytest-cov>=5.0.0", 61 | "ruff>=0.9.9", 62 | ] 63 | docs = [ 64 | "doc8>=1.1.2", 65 | "furo>=2024.8.6", 66 | "readme-renderer[md]>=43.0", 67 | "sphinx>=7.1.2", 68 | "sphinx-autobuild>=2021.3.14", 69 | "sphinx-tabs>=3.4.7", 70 | ] 71 | 72 | 73 | [tool.mypy] 74 | # The mypy configurations: http://bit.ly/2zEl9WI 75 | allow_redefinition = false 76 | check_untyped_defs = true 77 | disallow_untyped_decorators = false 78 | disallow_any_explicit = false 79 | disallow_any_generics = false 80 | disallow_untyped_calls = true 81 | ignore_errors = false 82 | ignore_missing_imports = true 83 | implicit_reexport = false 84 | strict_optional = true 85 | strict_equality = true 86 | local_partial_types = true 87 | no_implicit_optional = true 88 | warn_unused_ignores = true 89 | warn_redundant_casts = true 90 | warn_unused_configs = true 91 | warn_unreachable = true 92 | warn_no_return = true 93 | exclude = "tests" 94 | mypy_path = "src" 95 | modules = "enum_properties" 96 | 97 | 98 | [tool.doc8] 99 | ignore-path = "doc/_build" 100 | max-line-length = 100 101 | sphinx = true 102 | 103 | 104 | [tool.pytest.ini_options] 105 | python_files = "test*.py" 106 | norecursedirs = "*.egg .eggs dist build docs .tox .git __pycache__" 107 | 108 | addopts = [ 109 | "--strict-markers", 110 | "--cov=enum_properties", 111 | "--cov-branch" 112 | ] 113 | [tool.ruff] 114 | line-length = 88 115 | exclude = [ 116 | "doc", 117 | "dist", 118 | "examples", 119 | "tests/resources/bad_code.py" 120 | ] 121 | 122 | [tool.ruff.lint] 123 | exclude = [ 124 | "tests/**/*", 125 | ] 126 | 127 | [tool.pyright] 128 | exclude = ["tests/**/*"] 129 | include = [ 130 | "src/enum_properties" 131 | ] 132 | 133 | [tool.coverage.run] 134 | omit = ["tests/**/*py"] 135 | branch = true 136 | source = ["enum_properties"] 137 | concurrency = ["multiprocessing"] 138 | parallel = true 139 | relative_files = true 140 | command_line = "-m pytest --cov=enum_properties" 141 | 142 | [tool.coverage.paths] 143 | source = ["enum_properties"] 144 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Publish Release 3 | 4 | permissions: read-all 5 | 6 | concurrency: 7 | # stop previous release runs if tag is recreated 8 | group: release-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | on: 12 | push: 13 | tags: 14 | - 'v*' # only publish on version tags (e.g. v1.0.0) 15 | 16 | jobs: 17 | 18 | lint: 19 | permissions: 20 | contents: read 21 | actions: write 22 | uses: ./.github/workflows/lint.yml 23 | secrets: inherit 24 | 25 | test: 26 | permissions: 27 | contents: read 28 | actions: write 29 | uses: ./.github/workflows/test.yml 30 | secrets: inherit 31 | 32 | build: 33 | name: Build Package 34 | runs-on: ubuntu-latest 35 | permissions: 36 | contents: read 37 | actions: write 38 | outputs: 39 | PACKAGE_NAME: ${{ steps.set-package.outputs.package_name }} 40 | RELEASE_VERSION: ${{ steps.set-package.outputs.release_version }} 41 | steps: 42 | - uses: actions/checkout@v6 43 | - name: Set up Python 44 | uses: actions/setup-python@v6 45 | with: 46 | python-version: ">=3.11" # for tomlib 47 | - name: Install uv 48 | uses: astral-sh/setup-uv@v7 49 | with: 50 | enable-cache: true 51 | - name: Setup Just 52 | uses: extractions/setup-just@v3 53 | - name: Verify Tag 54 | run: | 55 | TAG_NAME=${GITHUB_REF#refs/tags/} 56 | echo "Verifying tag $TAG_NAME..." 57 | # if a tag was deleted and recreated we may have the old one cached 58 | # be sure that we're publishing the current tag! 59 | git fetch --force origin refs/tags/$TAG_NAME:refs/tags/$TAG_NAME 60 | 61 | # verify signature 62 | curl -sL https://github.com/${{ github.actor }}.gpg | gpg --import 63 | git tag -v "$TAG_NAME" 64 | 65 | # verify version 66 | RELEASE_VERSION=$(just validate_version $TAG_NAME) 67 | 68 | # export the release version 69 | echo "RELEASE_VERSION=${RELEASE_VERSION}" >> $GITHUB_ENV 70 | - name: Build the binary wheel and a source tarball 71 | run: just build 72 | - name: Store the distribution packages 73 | uses: actions/upload-artifact@v6 74 | with: 75 | name: python-package-distributions 76 | path: dist/ 77 | - name: Set Package Name 78 | id: set-package 79 | run: 80 | PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])") 81 | echo "PACKAGE_NAME=${PACKAGE_NAME}" >> $GITHUB_ENV 82 | 83 | publish-to-pypi: 84 | name: Publish to PyPI 85 | needs: 86 | - lint 87 | - test 88 | - build 89 | runs-on: ubuntu-latest 90 | environment: 91 | name: pypi 92 | url: https://pypi.org/p/${{ needs.build.outputs.PACKAGE_NAME }} 93 | permissions: 94 | id-token: write # IMPORTANT: mandatory for trusted publishing 95 | steps: 96 | - name: Download all the dists 97 | uses: actions/download-artifact@v7 98 | with: 99 | name: python-package-distributions 100 | path: dist/ 101 | - name: Publish distribution 📦 to PyPI 102 | uses: pypa/gh-action-pypi-publish@release/v1.13 103 | 104 | github-release: 105 | name: Publish GitHub Release 106 | runs-on: ubuntu-latest 107 | needs: 108 | - lint 109 | - test 110 | - build 111 | permissions: 112 | contents: write # IMPORTANT: mandatory for making GitHub Releases 113 | id-token: write # IMPORTANT: mandatory for sigstore 114 | 115 | steps: 116 | - name: Download all the dists 117 | uses: actions/download-artifact@v7 118 | with: 119 | name: python-package-distributions 120 | path: dist/ 121 | - name: Sign the dists with Sigstore 122 | uses: sigstore/gh-action-sigstore-python@v3.2.0 123 | with: 124 | inputs: >- 125 | ./dist/*.tar.gz 126 | ./dist/*.whl 127 | - name: Create GitHub Release 128 | env: 129 | GITHUB_TOKEN: ${{ github.token }} 130 | run: >- 131 | gh release create 132 | '${{ github.ref_name }}' 133 | --repo '${{ github.repository }}' 134 | --generate-notes 135 | --prerelease 136 | - name: Upload artifact signatures to GitHub Release 137 | env: 138 | GITHUB_TOKEN: ${{ github.token }} 139 | # Upload to GitHub Release using the `gh` CLI. 140 | # `dist/` contains the built packages, and the 141 | # sigstore-produced signatures and certificates. 142 | run: >- 143 | gh release upload 144 | '${{ github.ref_name }}' dist/** 145 | --repo '${{ github.repository }}' 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enum Properties 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 5 | [![PyPI version](https://badge.fury.io/py/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties/) 6 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties/) 7 | [![PyPI status](https://img.shields.io/pypi/status/enum-properties.svg)](https://pypi.python.org/pypi/enum-properties) 8 | [![Documentation Status](https://readthedocs.org/projects/enum-properties/badge/?version=latest)](http://enum-properties.readthedocs.io/?badge=latest/) 9 | [![Code Cov](https://codecov.io/gh/bckohan/enum-properties/branch/main/graph/badge.svg?token=0IZOKN2DYL)](https://codecov.io/gh/bckohan/enum-properties) 10 | [![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) 11 | [![Lint Status](https://github.com/bckohan/enum-properties/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/bckohan/enum-properties/actions/workflows/lint.yml?query=branch:main) 12 | 13 | Add properties to Python enumeration values with a simple declarative syntax. [Enum Properties](https://enum-properties.readthedocs.io/en/latest) is a lightweight extension to [Python's Enum class](https://docs.python.org/3/library/enum.html). Example: 14 | 15 | ```python 16 | import typing as t 17 | from enum_properties import EnumProperties 18 | from enum import auto 19 | 20 | 21 | class Color(EnumProperties): 22 | 23 | rgb: t.Tuple[int, int, int] 24 | hex: str 25 | 26 | # name value rgb hex 27 | RED = auto(), (1, 0, 0), 'ff0000' 28 | GREEN = auto(), (0, 1, 0), '00ff00' 29 | BLUE = auto(), (0, 0, 1), '0000ff' 30 | 31 | # the type hints on the Enum class become properties on 32 | # each value, matching the order in which they are specified 33 | 34 | 35 | assert Color.RED.rgb == (1, 0, 0) 36 | assert Color.GREEN.rgb == (0, 1, 0) 37 | assert Color.BLUE.rgb == (0, 0, 1) 38 | 39 | assert Color.RED.hex == 'ff0000' 40 | assert Color.GREEN.hex == '00ff00' 41 | assert Color.BLUE.hex == '0000ff' 42 | ``` 43 | 44 | Properties may also be symmetrically mapped to enumeration values using annotated type hints: 45 | 46 | ```python 47 | import typing as t 48 | from enum_properties import EnumProperties, Symmetric 49 | from enum import auto 50 | 51 | 52 | class Color(EnumProperties): 53 | 54 | rgb: t.Annotated[t.Tuple[int, int, int], Symmetric()] 55 | hex: t.Annotated[str, Symmetric(case_fold=True)] 56 | 57 | RED = auto(), (1, 0, 0), 'ff0000' 58 | GREEN = auto(), (0, 1, 0), '00ff00' 59 | BLUE = auto(), (0, 0, 1), '0000ff' 60 | 61 | # Enumeration instances may be instantiated from any Symmetric property 62 | # values. Use case_fold for case insensitive matching 63 | 64 | 65 | assert Color((1, 0, 0)) is Color.RED 66 | assert Color((0, 1, 0)) is Color.GREEN 67 | assert Color((0, 0, 1)) is Color.BLUE 68 | 69 | assert Color('ff0000') is Color.RED 70 | assert Color('FF0000') is Color.RED # case_fold makes mapping case insensitive 71 | assert Color('00ff00') is Color.GREEN 72 | assert Color('00FF00') is Color.GREEN 73 | assert Color('0000ff') is Color.BLUE 74 | assert Color('0000FF') is Color.BLUE 75 | 76 | assert Color.RED.hex == 'ff0000' 77 | ``` 78 | 79 | Member functions may also be specialized to each enumeration value, using the ``@specialize`` decorator. 80 | 81 | ```python 82 | from enum_properties import EnumProperties as Enum, specialize 83 | 84 | 85 | class SpecializedEnum(Enum): 86 | 87 | ONE = 1 88 | TWO = 2 89 | THREE = 3 90 | 91 | @specialize(ONE) 92 | def method(self): 93 | return 'method_one()' 94 | 95 | @specialize(TWO) 96 | def method(self): 97 | return 'method_two()' 98 | 99 | @specialize(THREE) 100 | def method(self): 101 | return 'method_three()' 102 | 103 | assert SpecializedEnum.ONE.method() == 'method_one()' 104 | assert SpecializedEnum.TWO.method() == 'method_two()' 105 | assert SpecializedEnum.THREE.method() == 'method_three()' 106 | ``` 107 | 108 | Please report bugs and discuss features on the [issues page](https://github.com/bckohan/enum-properties/issues). 109 | 110 | [Contributions](https://github.com/bckohan/enum-properties/blob/main/CONTRIBUTING.rst) are encouraged! 111 | 112 | [Full documentation at read the docs.](https://enum-properties.readthedocs.io/en/latest) 113 | 114 | ## Installation 115 | 116 | 1. Clone enum-properties from [GitHub](https://github.com/bckohan/enum-properties) or install a release off [PyPI](https://pypi.org/project/enum-properties/): 117 | 118 | ```bash 119 | pip install enum-properties 120 | ``` 121 | -------------------------------------------------------------------------------- /tests/legacy/test_interface_eq.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, IntEnum 2 | from unittest import TestCase 3 | 4 | from enum_properties import ( 5 | IntEnumProperties, 6 | IntFlagProperties, 7 | StrEnumProperties, 8 | ) 9 | 10 | 11 | class TestInterfaceEquivalency(TestCase): 12 | """ 13 | Enums should hash the same as their values. 14 | """ 15 | 16 | def test_operators(self): 17 | class MyIntEnum(Enum): 18 | ONE = 1 19 | TWO = 2 20 | THREE = 3 21 | 22 | self.assertNotEqual(MyIntEnum.ONE, 1) 23 | 24 | class MyIntEnum(IntEnum): 25 | ONE = 1 26 | TWO = 2 27 | THREE = 3 28 | 29 | self.assertTrue(MyIntEnum.ONE == 1) 30 | self.assertTrue(MyIntEnum.ONE != 2) 31 | 32 | self.assertTrue(1 == MyIntEnum.ONE) 33 | self.assertTrue(2 != MyIntEnum.ONE) 34 | 35 | self.assertTrue(MyIntEnum.ONE < 2) 36 | self.assertTrue(2 < MyIntEnum.THREE) 37 | self.assertTrue(MyIntEnum.ONE < 2) 38 | self.assertTrue(2 < MyIntEnum.THREE) 39 | self.assertTrue(MyIntEnum.TWO <= 2) 40 | self.assertTrue(2 <= MyIntEnum.TWO) 41 | 42 | self.assertTrue(MyIntEnum.TWO > 1) 43 | self.assertTrue(3 > MyIntEnum.TWO) 44 | self.assertTrue(MyIntEnum.TWO > 1) 45 | self.assertTrue(2 > MyIntEnum.ONE) 46 | self.assertTrue(MyIntEnum.THREE >= 2) 47 | self.assertTrue(3 >= MyIntEnum.TWO) 48 | 49 | self.assertTrue(MyIntEnum.TWO % 2 == 0) 50 | self.assertTrue(MyIntEnum.TWO % 3 == 2) 51 | 52 | class MyIntEnum(IntEnumProperties): 53 | ONE = 1 54 | TWO = 2 55 | THREE = 3 56 | 57 | self.assertTrue(MyIntEnum.ONE == 1) 58 | self.assertTrue(MyIntEnum.ONE != 2) 59 | 60 | self.assertTrue(1 == MyIntEnum.ONE) 61 | self.assertTrue(2 != MyIntEnum.ONE) 62 | 63 | self.assertTrue(MyIntEnum.ONE < 2) 64 | self.assertTrue(2 < MyIntEnum.THREE) 65 | self.assertTrue(MyIntEnum.ONE < 2) 66 | self.assertTrue(2 < MyIntEnum.THREE) 67 | self.assertTrue(MyIntEnum.TWO <= 2) 68 | self.assertTrue(2 <= MyIntEnum.TWO) 69 | 70 | self.assertTrue(MyIntEnum.TWO > 1) 71 | self.assertTrue(3 > MyIntEnum.TWO) 72 | self.assertTrue(MyIntEnum.TWO > 1) 73 | self.assertTrue(2 > MyIntEnum.ONE) 74 | self.assertTrue(MyIntEnum.THREE >= 2) 75 | self.assertTrue(3 >= MyIntEnum.TWO) 76 | 77 | self.assertTrue(MyIntEnum.TWO % 2 == 0) 78 | self.assertTrue(MyIntEnum.TWO % 3 == 2) 79 | 80 | def test_hashing_issue_53(self): 81 | class MyIntEnum(IntEnum): 82 | ONE = 1 83 | TWO = 2 84 | THREE = 3 85 | 86 | class MyStrEnum(str, Enum): 87 | A = "a" 88 | B = "b" 89 | C = "c" 90 | 91 | # self.assertEqual(hash(MyIntEnum.ONE), hash(1)) 92 | # self.assertEqual(hash(MyStrEnum.C), hash('c')) 93 | 94 | test_dict = {1: "One", 2: "Two", MyIntEnum.THREE: "Three"} 95 | 96 | self.assertIn(MyIntEnum.ONE, test_dict) 97 | self.assertEqual(test_dict[MyIntEnum.ONE], "One") 98 | self.assertIn(3, test_dict) 99 | 100 | test_dict = {"a": "A", "b": "B", MyStrEnum.C: "C"} 101 | 102 | self.assertIn(MyStrEnum.B, test_dict) 103 | self.assertEqual(test_dict[MyStrEnum.A], "A") 104 | self.assertIn("c", test_dict) 105 | 106 | self.assertIn(MyIntEnum.ONE, {1, 2, 3}) 107 | self.assertIn(MyStrEnum.A, {"a", "b", "c"}) 108 | self.assertIn(1, {MyIntEnum.ONE, MyIntEnum.TWO, MyIntEnum.THREE}) 109 | self.assertIn(3, {MyIntEnum.ONE, MyIntEnum.TWO, MyIntEnum.THREE}) 110 | self.assertIn("a", {MyStrEnum.A, MyStrEnum.B, MyStrEnum.C}) 111 | 112 | class MyIntEnum(IntEnumProperties): 113 | ONE = 1 114 | TWO = 2 115 | THREE = 3 116 | 117 | class MyStrEnum(StrEnumProperties): 118 | A = "a" 119 | B = "b" 120 | C = "c" 121 | 122 | test_dict = {1: "One", 2: "Two", MyIntEnum.THREE: "Three"} 123 | 124 | self.assertIn(MyIntEnum.ONE, test_dict) 125 | self.assertEqual(test_dict[MyIntEnum.ONE], "One") 126 | self.assertIn(3, test_dict) 127 | 128 | test_dict = {"a": "A", "b": "B", MyStrEnum.C: "C"} 129 | 130 | self.assertIn(MyStrEnum.B, test_dict) 131 | self.assertEqual(test_dict[MyStrEnum.A], "A") 132 | self.assertIn("c", test_dict) 133 | 134 | self.assertIn(MyIntEnum.ONE, {1, 2, 3}) 135 | self.assertIn(MyStrEnum.A, {"a", "b", "c"}) 136 | self.assertIn(1, {MyIntEnum.ONE, MyIntEnum.TWO, MyIntEnum.THREE}) 137 | self.assertIn(3, {MyIntEnum.ONE, MyIntEnum.TWO, MyIntEnum.THREE}) 138 | self.assertIn("a", {MyStrEnum.A, MyStrEnum.B, MyStrEnum.C}) 139 | 140 | class MyIntFlagEnum(IntFlagProperties): 141 | ONE = 1 142 | TWO = 2 143 | THREE = 4 144 | 145 | test_dict = {1: "One", 2: "Two", MyIntFlagEnum.THREE: "Three"} 146 | 147 | self.assertIn(MyIntFlagEnum.ONE, test_dict) 148 | self.assertEqual(test_dict[MyIntFlagEnum.ONE], "One") 149 | self.assertIn(4, test_dict) 150 | 151 | def test_hashing_example(self): 152 | from enum import Enum 153 | 154 | from enum_properties import EnumPropertiesMeta, SymmetricMixin, s 155 | 156 | class Color( 157 | SymmetricMixin, 158 | tuple, 159 | Enum, 160 | s("hex", case_fold=True), 161 | metaclass=EnumPropertiesMeta, 162 | ): 163 | # name value rgb hex 164 | RED = (1, 0, 0), "0xff0000" 165 | GREEN = (0, 1, 0), "0x00ff00" 166 | BLUE = (0, 0, 1), "0x0000ff" 167 | 168 | def __hash__(self): 169 | return tuple.__hash__(self) 170 | 171 | assert {(1, 0, 0): "Found me!"}[Color.RED] == "Found me!" 172 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /tests/legacy/test_specialize.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import sys 3 | from unittest import TestCase 4 | 5 | from enum_properties import ( 6 | EnumProperties, 7 | s, 8 | specialize, 9 | ) 10 | 11 | 12 | class TestSpecialize(TestCase): 13 | """ 14 | Test the specialize decorator 15 | """ 16 | 17 | def test_specialize(self): 18 | class SpecializedEnum(EnumProperties): 19 | ONE = 1 20 | TWO = 2 21 | THREE = 3 22 | 23 | @specialize(ONE) 24 | def method(self): 25 | return "method_one()" 26 | 27 | @specialize(TWO) 28 | def method(self): 29 | return "method_two()" 30 | 31 | @specialize(THREE) 32 | def method(self): 33 | return "method_three()" 34 | 35 | self.assertEqual(SpecializedEnum.ONE.method(), "method_one()") 36 | self.assertEqual(SpecializedEnum.TWO.method(), "method_two()") 37 | self.assertEqual(SpecializedEnum.THREE.method(), "method_three()") 38 | 39 | def test_specialize_default(self): 40 | class SpecializedEnum(EnumProperties, s("label")): 41 | ONE = 1, "one" 42 | TWO = 2, "two" 43 | THREE = 3, "three" 44 | 45 | def test(self): 46 | return "test_default()" 47 | 48 | @specialize(THREE) 49 | def test(self): 50 | return "test_three()" 51 | 52 | self.assertEqual(SpecializedEnum.ONE.test(), "test_default()") 53 | self.assertEqual(SpecializedEnum.TWO.test(), "test_default()") 54 | self.assertEqual(SpecializedEnum.THREE.test(), "test_three()") 55 | self.assertEqual(SpecializedEnum("three").test(), "test_three()") 56 | 57 | def test_specialize_no_default(self): 58 | class SpecializedEnum(EnumProperties, s("label")): 59 | ONE = 1, "one" 60 | TWO = 2, "two" 61 | THREE = 3, "three" 62 | 63 | @specialize(TWO) 64 | def test(self): 65 | return "test_two()" 66 | 67 | @specialize(THREE) 68 | def test(self): 69 | return "test_three()" 70 | 71 | self.assertFalse(hasattr(SpecializedEnum.ONE, "test")) 72 | self.assertFalse(hasattr(SpecializedEnum("one"), "test")) 73 | self.assertEqual(SpecializedEnum.TWO.test(), "test_two()") 74 | self.assertEqual(SpecializedEnum["TWO"].test(), "test_two()") 75 | self.assertEqual(SpecializedEnum.THREE.test(), "test_three()") 76 | 77 | def test_specialize_class_method(self): 78 | class SpecializedEnum(EnumProperties, s("label")): 79 | ONE = 1, "one" 80 | TWO = 2, "two" 81 | THREE = 3, "three" 82 | 83 | @specialize(ONE) 84 | @classmethod 85 | def test(cls): 86 | return (1, cls) 87 | 88 | @specialize(TWO) 89 | @classmethod 90 | def test(cls): 91 | return (2, cls) 92 | 93 | @specialize(THREE) 94 | @classmethod 95 | def test(cls): 96 | return (3, cls) 97 | 98 | self.assertEqual(SpecializedEnum.ONE.test(), (1, SpecializedEnum)) 99 | self.assertEqual(SpecializedEnum.TWO.test(), (2, SpecializedEnum)) 100 | self.assertEqual(SpecializedEnum.THREE.test(), (3, SpecializedEnum)) 101 | self.assertEqual(SpecializedEnum("two").test(), (2, SpecializedEnum)) 102 | 103 | def test_specialize_static_method(self): 104 | class SpecializedEnum(EnumProperties, s("label")): 105 | ONE = 1, "one" 106 | TWO = 2, "two" 107 | THREE = 3, "three" 108 | 109 | @specialize(ONE) 110 | @staticmethod 111 | def test(): 112 | return "test_one()" 113 | 114 | @specialize(TWO) 115 | @staticmethod 116 | def test(): 117 | return "test_two()" 118 | 119 | @specialize(THREE) 120 | @staticmethod 121 | def test(): 122 | return "test_three()" 123 | 124 | self.assertEqual(SpecializedEnum.ONE.test(), "test_one()") 125 | self.assertEqual(SpecializedEnum.TWO.test(), "test_two()") 126 | self.assertEqual(SpecializedEnum.THREE.test(), "test_three()") 127 | self.assertEqual(SpecializedEnum("two").test(), "test_two()") 128 | 129 | def test_specialize_arguments(self): 130 | class SpecializedEnum(EnumProperties, s("label")): 131 | ONE = 1, "one" 132 | TWO = 2, "two" 133 | THREE = 3, "three" 134 | 135 | @specialize(ONE) 136 | def test(self, count=1): 137 | return self.label * count 138 | 139 | @specialize(TWO) 140 | def test(self, count=2): 141 | return self.label * count 142 | 143 | @specialize(THREE) 144 | def test(self, count=3): 145 | return self.label * count 146 | 147 | self.assertEqual(SpecializedEnum.ONE.test(), "one") 148 | self.assertEqual(SpecializedEnum.TWO.test(), "twotwo") 149 | self.assertEqual(SpecializedEnum.THREE.test(), "threethreethree") 150 | self.assertEqual(SpecializedEnum("two").test(count=1), "two") 151 | 152 | def test_specialize_multiple_lists(self): 153 | class SpecializedEnum(EnumProperties, s("label")): 154 | ONE = 1, "one" 155 | TWO = 2, "two" 156 | THREE = 3, "three" 157 | 158 | @specialize(ONE) 159 | def test(self, count=1): 160 | return self.label * count 161 | 162 | @specialize(TWO, THREE) 163 | def test(self, count=2): 164 | return self.label * count 165 | 166 | self.assertEqual(SpecializedEnum.ONE.test(), "one") 167 | self.assertEqual(SpecializedEnum.TWO.test(), "twotwo") 168 | self.assertEqual(SpecializedEnum.THREE.test(), "threethree") 169 | -------------------------------------------------------------------------------- /tests/annotations/test_aliases.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import enum 3 | from unittest import TestCase 4 | from typing import Annotated 5 | 6 | from enum_properties import ( 7 | EnumProperties, 8 | FlagProperties, 9 | Symmetric, 10 | specialize, 11 | symmetric, 12 | ) 13 | 14 | 15 | class TestAliases(TestCase): 16 | def test_enum_alias(self): 17 | class EnumWithAliases(EnumProperties): 18 | label: Annotated[str, Symmetric(case_fold=True)] 19 | 20 | A = 1, "a" 21 | B = 2, "b" 22 | C = 3, "c" 23 | X = C, "x" 24 | Y = B, "y" 25 | Z = A, "z" 26 | 27 | self.assertEqual( 28 | EnumWithAliases.__first_class_members__, ["A", "B", "C", "X", "Y", "Z"] 29 | ) 30 | 31 | class EnumWithAliasesComplex(EnumProperties): 32 | label: Annotated[str, Symmetric(case_fold=True)] 33 | 34 | A = 1, "a" 35 | B = 2, "b" 36 | C = 3, "c" 37 | X = C, "x" 38 | Y = B, "y" 39 | Z = A, "z" 40 | 41 | @symmetric(case_fold=True) 42 | def x3(self) -> str: 43 | return self.label * 3 44 | 45 | @property 46 | def prop(self) -> str: 47 | return self.label * 5 48 | 49 | def method(self) -> str: 50 | return self.label * 7 51 | 52 | @specialize(A) 53 | def method(self) -> str: 54 | return self.label * 8 55 | 56 | if sys.version_info[:2] >= (3, 11): 57 | 58 | @enum.nonmember 59 | class Nested: 60 | pass 61 | else: 62 | 63 | class Nested: 64 | pass 65 | 66 | self.assertEqual( 67 | EnumWithAliasesComplex.__first_class_members__, 68 | ["A", "B", "C", "X", "Y", "Z"], 69 | ) 70 | 71 | class EnumWithAliasesOverride1(EnumProperties): 72 | label: Annotated[str, Symmetric(case_fold=True)] 73 | 74 | A = 1, "a" 75 | B = 2, "b" 76 | C = 3, "c" 77 | X = C, "x" 78 | Y = B, "y" 79 | Z = A, "z" 80 | 81 | __first_class_members__ = ["A", "B", "C", "X", "Y"] 82 | 83 | self.assertEqual( 84 | EnumWithAliasesOverride1.__first_class_members__, ["A", "B", "C", "X", "Y"] 85 | ) 86 | 87 | class EnumWithAliasesOverride2(EnumProperties): 88 | label: Annotated[str, Symmetric(case_fold=True)] 89 | 90 | __first_class_members__ = ["A", "B", "C", "X"] 91 | 92 | A = 1, "a" 93 | B = 2, "b" 94 | C = 3, "c" 95 | X = C, "x" 96 | Y = B, "y" 97 | Z = A, "z" 98 | 99 | self.assertEqual( 100 | EnumWithAliasesOverride2.__first_class_members__, ["A", "B", "C", "X"] 101 | ) 102 | 103 | def test_flag_alias(self): 104 | class FlagWithAliases(FlagProperties): 105 | label: Annotated[str, Symmetric(case_fold=True)] 106 | 107 | A = 1 << 0, "a" 108 | B = 1 << 1, "b" 109 | C = 1 << 2, "c" 110 | X = C, "x" 111 | Y = B, "y" 112 | Z = A, "z" 113 | 114 | AB = A | B, "ab" 115 | AC = A | C, "ac" 116 | BC = B | C, "bc" 117 | ABC = A | B | C, "abc" 118 | 119 | self.assertEqual( 120 | FlagWithAliases.__first_class_members__, 121 | ["A", "B", "C", "X", "Y", "Z", "AB", "AC", "BC", "ABC"], 122 | ) 123 | 124 | class FlagWithAliasesComplex(FlagProperties): 125 | label: Annotated[str, Symmetric(case_fold=True)] 126 | 127 | A = 1 << 0, "a" 128 | B = 1 << 1, "b" 129 | C = 1 << 2, "c" 130 | X = C, "x" 131 | Y = B, "y" 132 | Z = A, "z" 133 | 134 | AB = A | B, "ab" 135 | AC = A | C, "ac" 136 | BC = B | C, "bc" 137 | ABC = A | B | C, "abc" 138 | 139 | @symmetric(case_fold=True) 140 | def x3(self) -> str: 141 | return self.label * 3 142 | 143 | @property 144 | def prop(self) -> str: 145 | return self.label * 5 146 | 147 | def method(self) -> str: 148 | return self.label * 7 149 | 150 | @specialize(A) 151 | def method(self) -> str: 152 | return self.label * 8 153 | 154 | if sys.version_info[:2] >= (3, 11): 155 | 156 | @enum.nonmember 157 | class Nested: 158 | pass 159 | else: 160 | 161 | class Nested: 162 | pass 163 | 164 | self.assertEqual( 165 | FlagWithAliasesComplex.__first_class_members__, 166 | ["A", "B", "C", "X", "Y", "Z", "AB", "AC", "BC", "ABC"], 167 | ) 168 | 169 | class FlagWithAliasesOverride1(FlagProperties): 170 | label: Annotated[str, Symmetric(case_fold=True)] 171 | 172 | __first_class_members__ = ["A", "B", "C", "X", "Y", "ABC"] 173 | 174 | A = 1 << 0, "a" 175 | B = 1 << 1, "b" 176 | C = 1 << 2, "c" 177 | X = C, "x" 178 | Y = B, "y" 179 | Z = A, "z" 180 | 181 | AB = A | B, "ab" 182 | AC = A | C, "ac" 183 | BC = B | C, "bc" 184 | ABC = A | B | C, "abc" 185 | 186 | self.assertEqual( 187 | FlagWithAliasesOverride1.__first_class_members__, 188 | ["A", "B", "C", "X", "Y", "ABC"], 189 | ) 190 | 191 | class FlagWithAliasesOverride2(FlagProperties): 192 | label: Annotated[str, Symmetric(case_fold=True)] 193 | 194 | A = 1 << 0, "a" 195 | B = 1 << 1, "b" 196 | C = 1 << 2, "c" 197 | X = C, "x" 198 | Y = B, "y" 199 | Z = A, "z" 200 | 201 | AB = A | B, "ab" 202 | AC = A | C, "ac" 203 | BC = B | C, "bc" 204 | ABC = A | B | C, "abc" 205 | 206 | __first_class_members__ = ["A", "B", "C", "X", "Y", "ABC"] 207 | 208 | self.assertEqual( 209 | FlagWithAliasesOverride2.__first_class_members__, 210 | ["A", "B", "C", "X", "Y", "ABC"], 211 | ) 212 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | pull_request: 9 | workflow_call: 10 | workflow_dispatch: 11 | inputs: 12 | debug: 13 | description: 'Open ssh debug session.' 14 | required: true 15 | default: false 16 | type: boolean 17 | clear_cache: 18 | description: 'Clear GitHub Actions cache.' 19 | required: true 20 | default: false 21 | type: boolean 22 | jobs: 23 | linux: 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | actions: write 28 | strategy: 29 | matrix: 30 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 31 | env: 32 | COVERAGE_FILE: linux-py${{ matrix.python-version }}.coverage 33 | 34 | steps: 35 | - name: Clear GitHub Actions cache 36 | if: ${{ github.event.inputs.clear_cache == 'true' }} 37 | run: sudo rm -rf /opt/hostedtoolcache 38 | 39 | - uses: actions/checkout@v6 40 | - name: Set up Python ${{ matrix.python-version }} 41 | id: sp 42 | uses: actions/setup-python@v6 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | allow-prereleases: true 46 | - name: Install Just 47 | uses: extractions/setup-just@v3 48 | - name: Install uv 49 | uses: astral-sh/setup-uv@v7 50 | with: 51 | enable-cache: true 52 | - name: Install Dependencies 53 | run: | 54 | just setup ${{ steps.sp.outputs.python-path }} 55 | just install-docs 56 | - name: Install Emacs 57 | if: ${{ github.event.inputs.debug == 'true' }} 58 | run: | 59 | sudo apt install emacs 60 | - name: Setup tmate session 61 | if: ${{ github.event.inputs.debug == 'true' }} 62 | uses: mxschmitt/action-tmate@v3.23 63 | with: 64 | detached: true 65 | - name: Install Dependencies 66 | run: | 67 | just setup ${{ steps.sp.outputs.python-path }} 68 | just install 69 | - name: Run Unit Tests 70 | run: | 71 | just test-all 72 | 73 | - name: Store coverage files 74 | uses: actions/upload-artifact@v6 75 | with: 76 | name: ${{ env.COVERAGE_FILE }} 77 | path: ${{ env.COVERAGE_FILE }} 78 | 79 | macos: 80 | runs-on: macos-latest 81 | permissions: 82 | contents: read 83 | actions: write 84 | strategy: 85 | matrix: 86 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 87 | env: 88 | COVERAGE_FILE: macos-py${{ matrix.python-version }}.coverage 89 | 90 | steps: 91 | - name: Clear GitHub Actions cache 92 | if: ${{ github.event.inputs.clear_cache == 'true' }} 93 | run: sudo rm -rf /Users/runner/hostedtoolcache 94 | 95 | - uses: actions/checkout@v6 96 | - name: Set up Python ${{ matrix.python-version }} 97 | id: sp 98 | uses: actions/setup-python@v6 99 | with: 100 | python-version: ${{ matrix.python-version }} 101 | allow-prereleases: true 102 | - name: Install Just 103 | uses: extractions/setup-just@v3 104 | - name: Install uv 105 | uses: astral-sh/setup-uv@v7 106 | with: 107 | enable-cache: true 108 | - name: Install Dependencies 109 | run: | 110 | just setup ${{ steps.sp.outputs.python-path }} 111 | just install-docs 112 | - name: install-emacs-macos 113 | if: ${{ github.event.inputs.debug == 'true' }} 114 | run: | 115 | brew install emacs 116 | - name: Setup tmate session 117 | if: ${{ github.event.inputs.debug == 'true' }} 118 | uses: mxschmitt/action-tmate@v3.23 119 | with: 120 | detached: true 121 | - name: Install Dependencies 122 | run: | 123 | just setup ${{ steps.sp.outputs.python-path }} 124 | just install 125 | - name: Run Unit Tests 126 | run: | 127 | just test-all 128 | 129 | - name: Store coverage files 130 | uses: actions/upload-artifact@v6 131 | with: 132 | name: ${{ env.COVERAGE_FILE }} 133 | path: ${{ env.COVERAGE_FILE }} 134 | 135 | windows: 136 | runs-on: windows-latest 137 | permissions: 138 | contents: read 139 | actions: write 140 | strategy: 141 | matrix: 142 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 143 | env: 144 | COVERAGE_FILE: windows-py${{ matrix.python-version }}.coverage 145 | 146 | steps: 147 | - name: Clear GitHub Actions cache 148 | if: ${{ github.event.inputs.clear_cache == 'true' }} 149 | run: Remove-Item -Recurse -Force C:\hostedtoolcache 150 | - uses: actions/checkout@v6 151 | - name: Set up Python ${{ matrix.python-version }} 152 | id: sp 153 | uses: actions/setup-python@v6 154 | with: 155 | python-version: ${{ matrix.python-version }} 156 | allow-prereleases: true 157 | - name: Install Just 158 | uses: extractions/setup-just@v3 159 | - name: Install uv 160 | uses: astral-sh/setup-uv@v7 161 | with: 162 | enable-cache: true 163 | - name: Install Dependencies 164 | run: | 165 | just setup ${{ steps.sp.outputs.python-path }} 166 | just install-docs 167 | - name: install-vim-windows 168 | if: ${{ github.event.inputs.debug == 'true' }} 169 | uses: rhysd/action-setup-vim@v1 170 | - name: Setup tmate session 171 | if: ${{ github.event.inputs.debug == 'true' }} 172 | uses: mxschmitt/action-tmate@v3.23 173 | with: 174 | detached: true 175 | - name: Install Dependencies 176 | run: | 177 | just setup ${{ steps.sp.outputs.python-path }} 178 | just install 179 | - name: Run Unit Tests 180 | run: | 181 | just test-all 182 | 183 | - name: Store coverage files 184 | uses: actions/upload-artifact@v6 185 | with: 186 | name: ${{ env.COVERAGE_FILE }} 187 | path: ${{ env.COVERAGE_FILE }} 188 | 189 | coverage-combine: 190 | needs: [linux, macos, windows] 191 | runs-on: ubuntu-latest 192 | 193 | steps: 194 | - uses: actions/checkout@v6 195 | - uses: actions/setup-python@v6 196 | id: sp 197 | with: 198 | python-version: '3.12' 199 | 200 | - name: Install uv 201 | uses: astral-sh/setup-uv@v7 202 | with: 203 | enable-cache: true 204 | - name: Setup Just 205 | uses: extractions/setup-just@v3 206 | - name: Install Release Dependencies 207 | run: | 208 | just setup ${{ steps.sp.outputs.python-path }} 209 | just install 210 | 211 | - name: Get coverage files 212 | uses: actions/download-artifact@v7 213 | with: 214 | pattern: "*.coverage" 215 | merge-multiple: true 216 | - run: ls -la *.coverage 217 | - run: just coverage 218 | 219 | - name: Upload coverage to Codecov 220 | uses: codecov/codecov-action@v5 221 | with: 222 | token: ${{ secrets.CODECOV_TOKEN }} 223 | file: ./coverage.xml 224 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 2 | set unstable := true 3 | set script-interpreter := ['uv', 'run', '--script'] 4 | 5 | export PYTHONPATH := source_directory() 6 | 7 | [private] 8 | default: 9 | @just --list --list-submodules 10 | 11 | # install the uv package manager 12 | [linux] 13 | [macos] 14 | install-uv: 15 | curl -LsSf https://astral.sh/uv/install.sh | sh 16 | 17 | # install the uv package manager 18 | [windows] 19 | install-uv: 20 | powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 21 | 22 | # setup the venv and pre-commit hooks 23 | setup python="python": 24 | uv venv -p {{ python }} 25 | @just run pre-commit install 26 | 27 | # install git pre-commit hooks 28 | install-precommit: 29 | @just run pre-commit install 30 | 31 | # update and install development dependencies 32 | install *OPTS: 33 | uv sync {{ OPTS }} 34 | @just run pre-commit install 35 | 36 | # install documentation dependencies 37 | install-docs: 38 | uv sync --group docs --all-extras 39 | 40 | [script] 41 | _lock-python: 42 | import tomlkit 43 | import sys 44 | f='pyproject.toml' 45 | d=tomlkit.parse(open(f).read()) 46 | d['project']['requires-python']='=={}'.format(sys.version.split()[0]) 47 | open(f,'w').write(tomlkit.dumps(d)) 48 | 49 | # lock to specific python and versions of given dependencies 50 | test-lock +PACKAGES: _lock-python 51 | uv add {{ PACKAGES }} 52 | 53 | # run static type checking 54 | check-types: 55 | @just run mypy 56 | @just run pyright 57 | 58 | # run package checks 59 | check-package: 60 | @just run pip check 61 | 62 | # remove doc build artifacts 63 | [script] 64 | clean-docs: 65 | import shutil 66 | shutil.rmtree('./doc/build', ignore_errors=True) 67 | 68 | # remove the virtual environment 69 | [script] 70 | clean-env: 71 | import shutil 72 | import sys 73 | shutil.rmtree(".venv", ignore_errors=True) 74 | 75 | # remove all git ignored files 76 | clean-git-ignored: 77 | git clean -fdX 78 | 79 | # remove all non repository artifacts 80 | clean: clean-docs clean-env clean-git-ignored 81 | 82 | # build html documentation 83 | build-docs-html: install-docs 84 | @just run sphinx-build --fresh-env --builder html --doctree-dir ./doc/build/doctrees ./doc/source ./doc/build/html 85 | 86 | # build pdf documentation 87 | build-docs-pdf: install-docs 88 | @just run sphinx-build --fresh-env --builder latexpdf --doctree-dir ./doc/build/doctrees ./doc/source ./doc/build/pdf 89 | 90 | # build the docs 91 | build-docs: build-docs-html 92 | 93 | # build docs and package 94 | build: build-docs-html 95 | uv build 96 | 97 | # open the html documentation 98 | [script] 99 | open-docs: 100 | import os 101 | import webbrowser 102 | webbrowser.open(f'file://{os.getcwd()}/doc/build/html/index.html') 103 | 104 | # build and open the documentation 105 | docs: install-docs build-docs-html open-docs 106 | 107 | # serve the documentation, with auto-reload 108 | docs-live: install-docs 109 | @just run sphinx-autobuild doc/source doc/build --open-browser --watch src --port 8000 --delay 1 110 | 111 | _link_check: 112 | -uv run sphinx-build -b linkcheck -Q -D linkcheck_timeout=10 ./doc/source ./doc/build 113 | 114 | # check the documentation links for broken links 115 | [script] 116 | check-docs-links: _link_check 117 | import os 118 | import sys 119 | import json 120 | from pathlib import Path 121 | # The json output isn't valid, so we have to fix it before we can process. 122 | data = json.loads(f"[{','.join((Path(os.getcwd()) / 'doc/build/output.json').read_text().splitlines())}]") 123 | broken_links = [link for link in data if link["status"] not in {"working", "redirected", "unchecked", "ignored"}] 124 | if broken_links: 125 | for link in broken_links: 126 | print(f"[{link['status']}] {link['filename']}:{link['lineno']} -> {link['uri']}", file=sys.stderr) 127 | sys.exit(1) 128 | 129 | # lint the documentation 130 | check-docs: 131 | @just run doc8 --ignore-path ./doc/build --max-line-length 100 -q ./doc 132 | 133 | # fetch the intersphinx references for the given package 134 | [script] 135 | fetch-refs LIB: install-docs 136 | import os 137 | from pathlib import Path 138 | import logging as _logging 139 | import sys 140 | import runpy 141 | from sphinx.ext.intersphinx import inspect_main 142 | _logging.basicConfig() 143 | 144 | libs = runpy.run_path(Path(os.getcwd()) / "doc/source/conf.py").get("intersphinx_mapping") 145 | url = libs.get("{{ LIB }}", None) 146 | if not url: 147 | sys.exit(f"Unrecognized {{ LIB }}, must be one of: {', '.join(libs.keys())}") 148 | if url[1] is None: 149 | url = f"{url[0].rstrip('/')}/objects.inv" 150 | else: 151 | url = url[1] 152 | 153 | raise SystemExit(inspect_main([url])) 154 | 155 | # lint the code 156 | check-lint: 157 | @just run ruff check --select I 158 | @just run ruff check 159 | 160 | # check if the code needs formatting 161 | check-format: 162 | @just run ruff format --check 163 | 164 | # check that the readme renders 165 | check-readme: 166 | @just run -m readme_renderer ./README.md -o /tmp/README.html 167 | 168 | # sort the python imports 169 | sort-imports: 170 | @just run ruff check --fix --select I 171 | 172 | # format the code and sort imports 173 | format: sort-imports 174 | just --fmt --unstable 175 | @just run ruff format 176 | 177 | # sort the imports and fix linting issues 178 | lint: sort-imports 179 | @just run ruff check --fix 180 | 181 | # fix formatting, linting issues and import sorting 182 | fix: lint format 183 | 184 | # run all static checks 185 | check: check-lint check-format check-types check-package check-docs check-docs-links check-readme 186 | 187 | # run all tests 188 | test-all: 189 | @just run pytest --cov-append 190 | 191 | # run tests 192 | test *TESTS: 193 | @just run pytest --cov-append {{ TESTS }} 194 | 195 | # run the pre-commit checks 196 | precommit: 197 | @just run pre-commit 198 | 199 | # generate the test coverage report 200 | coverage: 201 | @just run coverage combine --keep *.coverage 202 | @just run coverage report 203 | @just run coverage xml 204 | 205 | # run the command in the virtual environment 206 | run +ARGS: 207 | uv run {{ ARGS }} 208 | 209 | # validate the given version string against the lib version 210 | [script] 211 | validate_version VERSION: 212 | import re 213 | import tomllib 214 | import enum_properties 215 | from packaging.version import Version 216 | raw_version = "{{ VERSION }}".lstrip("v") 217 | version_obj = Version(raw_version) 218 | # the version should be normalized 219 | assert str(version_obj) == raw_version 220 | # make sure all places the version appears agree 221 | assert raw_version == tomllib.load(open('pyproject.toml', 'rb'))['project']['version'] 222 | assert raw_version == enum_properties.__version__ 223 | print(raw_version) 224 | 225 | # issue a relase for the given semver string (e.g. 2.1.0) 226 | release VERSION: 227 | @just validate_version v{{ VERSION }} 228 | git tag -s v{{ VERSION }} -m "{{ VERSION }} Release" 229 | git push origin v{{ VERSION }} 230 | -------------------------------------------------------------------------------- /tests/annotations/test_specialize.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from unittest import TestCase 3 | from typing import Annotated 4 | 5 | from enum_properties import ( 6 | EnumProperties, 7 | Symmetric, 8 | specialize, 9 | ) 10 | 11 | 12 | class TestSpecialize(TestCase): 13 | """ 14 | Test the specialize decorator 15 | """ 16 | 17 | def test_specialize_default(self): 18 | class SpecializedEnum(EnumProperties): 19 | label: Annotated[str, Symmetric()] 20 | 21 | ONE = 1, "one" 22 | TWO = 2, "two" 23 | THREE = 3, "three" 24 | 25 | def test(self): 26 | return "test_default()" 27 | 28 | @specialize(THREE) 29 | def test(self): 30 | return "test_three()" 31 | 32 | self.assertEqual(SpecializedEnum.ONE.test(), "test_default()") 33 | self.assertEqual(SpecializedEnum.TWO.test(), "test_default()") 34 | self.assertEqual(SpecializedEnum.THREE.test(), "test_three()") 35 | self.assertEqual(SpecializedEnum("three").test(), "test_three()") 36 | 37 | def test_specialize_no_default(self): 38 | class SpecializedEnum(EnumProperties): 39 | label: Annotated[str, Symmetric()] 40 | 41 | ONE = 1, "one" 42 | TWO = 2, "two" 43 | THREE = 3, "three" 44 | 45 | @specialize(TWO) 46 | def test(self): 47 | return "test_two()" 48 | 49 | @specialize(THREE) 50 | def test(self): 51 | return "test_three()" 52 | 53 | self.assertFalse(hasattr(SpecializedEnum.ONE, "test")) 54 | self.assertFalse(hasattr(SpecializedEnum("one"), "test")) 55 | self.assertEqual(SpecializedEnum.TWO.test(), "test_two()") 56 | self.assertEqual(SpecializedEnum["TWO"].test(), "test_two()") 57 | self.assertEqual(SpecializedEnum.THREE.test(), "test_three()") 58 | 59 | def test_specialize_class_method(self): 60 | class SpecializedEnum(EnumProperties): 61 | label: Annotated[str, Symmetric()] 62 | 63 | ONE = 1, "one" 64 | TWO = 2, "two" 65 | THREE = 3, "three" 66 | 67 | @specialize(ONE) 68 | @classmethod 69 | def test(cls): 70 | return (1, cls) 71 | 72 | @specialize(TWO) 73 | @classmethod 74 | def test(cls): 75 | return (2, cls) 76 | 77 | @specialize(THREE) 78 | @classmethod 79 | def test(cls): 80 | return (3, cls) 81 | 82 | self.assertEqual(SpecializedEnum.ONE.test(), (1, SpecializedEnum)) 83 | self.assertEqual(SpecializedEnum.TWO.test(), (2, SpecializedEnum)) 84 | self.assertEqual(SpecializedEnum.THREE.test(), (3, SpecializedEnum)) 85 | self.assertEqual(SpecializedEnum("two").test(), (2, SpecializedEnum)) 86 | 87 | def test_specialize_static_method(self): 88 | class SpecializedEnum(EnumProperties): 89 | label: Annotated[str, Symmetric()] 90 | 91 | ONE = 1, "one" 92 | TWO = 2, "two" 93 | THREE = 3, "three" 94 | 95 | @specialize(ONE) 96 | @staticmethod 97 | def test(): 98 | return "test_one()" 99 | 100 | @specialize(TWO) 101 | @staticmethod 102 | def test(): 103 | return "test_two()" 104 | 105 | @specialize(THREE) 106 | @staticmethod 107 | def test(): 108 | return "test_three()" 109 | 110 | self.assertEqual(SpecializedEnum.ONE.test(), "test_one()") 111 | self.assertEqual(SpecializedEnum.TWO.test(), "test_two()") 112 | self.assertEqual(SpecializedEnum.THREE.test(), "test_three()") 113 | self.assertEqual(SpecializedEnum("two").test(), "test_two()") 114 | 115 | def test_specialize_arguments(self): 116 | class SpecializedEnum(EnumProperties): 117 | label: Annotated[str, Symmetric()] 118 | 119 | ONE = 1, "one" 120 | TWO = 2, "two" 121 | THREE = 3, "three" 122 | 123 | @specialize(ONE) 124 | def test(self, count=1): 125 | return self.label * count 126 | 127 | @specialize(TWO) 128 | def test(self, count=2): 129 | return self.label * count 130 | 131 | @specialize(THREE) 132 | def test(self, count=3): 133 | return self.label * count 134 | 135 | self.assertEqual(SpecializedEnum.ONE.test(), "one") 136 | self.assertEqual(SpecializedEnum.TWO.test(), "twotwo") 137 | self.assertEqual(SpecializedEnum.THREE.test(), "threethreethree") 138 | self.assertEqual(SpecializedEnum("two").test(count=1), "two") 139 | 140 | def test_specialize_multiple_lists(self): 141 | class SpecializedEnum(EnumProperties): 142 | label: Annotated[str, Symmetric()] 143 | 144 | ONE = 1, "one" 145 | TWO = 2, "two" 146 | THREE = 3, "three" 147 | 148 | @specialize(ONE) 149 | def test(self, count=1): 150 | return self.label * count 151 | 152 | @specialize(TWO, THREE) 153 | def test(self, count=2): 154 | return self.label * count 155 | 156 | self.assertEqual(SpecializedEnum.ONE.test(), "one") 157 | self.assertEqual(SpecializedEnum.TWO.test(), "twotwo") 158 | self.assertEqual(SpecializedEnum.THREE.test(), "threethree") 159 | 160 | 161 | class NoneCoercionTests(TestCase): 162 | def test_string_to_none_coercion_disabled(self): 163 | class EnumWithNones(EnumProperties): 164 | prop: Annotated[t.Optional[str], Symmetric(match_none=True)] 165 | 166 | VALUE1 = 1, None 167 | VALUE2 = 2, "label" 168 | 169 | self.assertRaises(ValueError, EnumWithNones, "None") 170 | 171 | class EnumWithNones(EnumProperties): 172 | prop: Annotated[t.Optional[str], Symmetric(case_fold=True, match_none=True)] 173 | 174 | VALUE1 = 1, None 175 | VALUE2 = 2, "label" 176 | 177 | self.assertRaises(ValueError, EnumWithNones, "None") 178 | 179 | class EnumWithNones(EnumProperties): 180 | VALUE1 = None 181 | VALUE2 = "label" 182 | 183 | self.assertRaises(ValueError, EnumWithNones, "None") 184 | self.assertEqual(EnumWithNones(None), EnumWithNones.VALUE1) 185 | 186 | def test_none_to_string_coercion_disabled(self): 187 | class EnumWithNones(EnumProperties): 188 | prop: Annotated[str, Symmetric(match_none=True)] 189 | 190 | VALUE1 = 1, "None" 191 | VALUE2 = 2, "label" 192 | 193 | self.assertRaises(ValueError, EnumWithNones, None) 194 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 195 | 196 | class EnumWithNones(EnumProperties): 197 | prop: Annotated[str, Symmetric(case_fold=True, match_none=True)] 198 | 199 | VALUE1 = 1, "None" 200 | VALUE2 = 2, "label" 201 | 202 | self.assertRaises(ValueError, EnumWithNones, None) 203 | self.assertEqual(EnumWithNones("none"), EnumWithNones.VALUE1) 204 | 205 | class EnumWithNones(EnumProperties): 206 | prop: Annotated[str, Symmetric(match_none=True)] 207 | 208 | VALUE1 = 1, "None" 209 | VALUE2 = 2, "label" 210 | 211 | self.assertRaises(ValueError, EnumWithNones, None) 212 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 213 | 214 | class EnumWithNones(EnumProperties): 215 | VALUE1 = "None" 216 | VALUE2 = "label" 217 | 218 | self.assertRaises(ValueError, EnumWithNones, None) 219 | self.assertRaises(KeyError, lambda x: EnumWithNones[x], None) 220 | self.assertEqual(EnumWithNones("None"), EnumWithNones.VALUE1) 221 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Change Log 3 | ========== 4 | 5 | v2.4.1 (2025-11-09) 6 | =================== 7 | 8 | * Fixed `StrEnumProperties auto() resolves differently across py 3.11 boundary `_ 9 | 10 | v2.4.0 (2025-09-21) 11 | =================== 12 | 13 | * Implemented `Support Python 3.14, drop 3.8 support `_ 14 | 15 | .. warning:: 16 | 17 | Python 3.14 lazily loads type annotations when they are accessed. Since annotations are used to 18 | define the properties a significant re-ordering of class creation is required on Python 3.14+. 19 | All tests are passing and this version should be interface compatible with all previous 20 | versions. However, if you encounter any issues please report them. 21 | 22 | 23 | v2.3.0 (2025-03-29) 24 | =================== 25 | 26 | * Implemented `Need a way to distinguish symmetric properties from first class aliases. `_ 27 | 28 | v2.2.5 (2025-03-22) 29 | =================== 30 | 31 | * Fix readme badges. 32 | 33 | 34 | v2.2.4 (2025-03-17) 35 | =================== 36 | 37 | * Add intersphinx reference points to howto and tutorial doc sections. 38 | 39 | 40 | v2.2.3 (2025-03-14) 41 | =================== 42 | 43 | * Documentation correction. 44 | 45 | 46 | v2.2.2 (2025-03-07) 47 | =================== 48 | 49 | * Tutorial correction. 50 | 51 | 52 | v2.2.1 (2025-03-07) 53 | =================== 54 | 55 | * Changelog correction. 56 | 57 | 58 | v2.2.0 (2025-03-07) 59 | =================== 60 | 61 | * Fixed `StrEnumProperties is missing from __all__ `_ 62 | * Implemented `Test all example code in docs. `_ 63 | * Fixed `Symmetric dataclass is missing from __all__ `_ 64 | * Implemented `Decorator to make properties symmetric `_ 65 | * Implemented `Move to intersphinx references for stdlib types `_ 66 | * Implemented `Update docs to diataxis organization `_ 67 | * Implemented `More precise case_fold documentation. `_ 68 | 69 | 70 | v2.1.0 (2025-03-06) 71 | =================== 72 | 73 | * Implemented `Add macos and windows runners to CI `_ 74 | * Implemented `Move to trusted publisher releases. `_ 75 | * Implemented `Move from poetry to uv, add standard justfile management interface `_ 76 | 77 | 78 | v2.0.1 (2024-09-02) 79 | =================== 80 | 81 | * Misc readme/doc updates. 82 | * Fixed `Break tests into smaller files. `_ 83 | 84 | v2.0.0 (2024-09-02) 85 | =================== 86 | 87 | * Implemented `Allow properties to be specified through type hints alone without s/p value inheritance `_ 88 | 89 | v1.8.1 (2024-08-29) 90 | =================== 91 | 92 | * Fixed `Add missing py.typed `_ 93 | 94 | v1.8.0 (2024-08-26) 95 | =================== 96 | 97 | * Implemented `Drop support for Python 3.7 `_ 98 | * Implemented `Support Python 3.13 `_ 99 | * Implemented `Move to ruff for linting and formatting. `_ 100 | * Documented `Support type hinting for properties `_ 101 | 102 | v1.7.0 (2023-10-02) 103 | =================== 104 | 105 | * Implemented `Add a StrEnumProperties type to match StrEnum. `_ 106 | * Fixed `Hash equivalency between values and enums is broken. `_ 107 | * Implemented `Test mixed primitive type values. `_ 108 | 109 | v1.6.0 (2023-08-22) 110 | ==================== 111 | 112 | * Implemented `Support dataclasses in enums along with Python 3.12 `_ 113 | 114 | v1.5.2 (2023-05-06) 115 | =================== 116 | 117 | * Fixed `_missing_ allows exceptions through that are not ValueError, TypeError or KeyError `_ 118 | 119 | v1.5.1 (2023-04-17) 120 | =================== 121 | 122 | * Fixed `Symmetric string 'none' values enable coercion from None despite match_none=False `_ 123 | 124 | v1.5.0 (2023-04-17) 125 | =================== 126 | 127 | There is one minimally impactful breaking change in the 1.5.0 release: 128 | 129 | * Symmetric properties that are None will not map back to the enumeration value 130 | by default. To replicate the previous behavior, pass True as the `match_none` 131 | argument when instantiating the property with s(). 132 | 133 | The 1.5.0 release includes two feature improvements: 134 | 135 | * Implemented `Configurable behavior for matching none on symmetric fields `_ 136 | * Implemented `Allow @specialize to accept a list of enumeration values. `_ 137 | 138 | v1.4.0 (2023-04-08) 139 | =================== 140 | 141 | There are some breaking changes in the 1.4.0 release: 142 | 143 | * The `enum_properties` attribute that lists property names has been changed to 144 | the sunder name `_properties_`. 145 | 146 | * Properties on combinations of flag enumerations that are not specified in 147 | the members list instead of being None, no longer exist. Accessing them will 148 | result in an AttributeError. 149 | 150 | The 1.4.0 release includes some significant performance improvements. Property 151 | access speed has been improved by over 5x and the memory footprint has 152 | been reduced by about 1/3. 153 | 154 | * Fixed `All utility members added by EnumProperties should be sunder names. `_ 155 | * Fixed `auto() broken for flag enums that declare combinations as members of the enum. `_ 156 | * Implemented `Performance improvements `_ 157 | * Implemented `Provide a decorator to provide function overrides per enum value. `_ 158 | * Fixed `Address python 3.11+ deprecation warnings. `_ 159 | * Fixed `New flag behavior modifiers break IntFlagProperties in python 3.11+ `_ 160 | 161 | 162 | v1.3.3 (2023-02-15) 163 | =================== 164 | 165 | * Fixed `LICENSE included in source package. `_ 166 | 167 | 168 | v1.3.2 (2023-02-15) 169 | =================== 170 | 171 | * Fixed `Nested classes are incompatible with EnumProperties. `_ 172 | 173 | 174 | v1.3.1 (2022-10-25) 175 | =================== 176 | 177 | * Fixed `Remove errant print statement `_ 178 | 179 | 180 | v1.3.0 (2022-10-25) 181 | =================== 182 | 183 | * Fixed `Initialize Flag enum with empty iterable should resolve to Flag(0) - no selections. `_ 184 | * Added `Support for python 3.11. `_ 185 | * Implemented `Generally allow composite flag enumerations to be treated as iterables of active flags. `_ 186 | 187 | v1.2.2 (2022-10-25) 188 | =================== 189 | 190 | * Implemented `Add convenience property to decompose Flag enumeration values `_ 191 | 192 | v1.2.1 (2022-10-25) 193 | =================== 194 | 195 | * Implemented `Allow Flag Enumerations to be created from iterables `_ 196 | 197 | v1.2.0 (2022-08-17) 198 | =================== 199 | 200 | * Implemented `Drop support for < Python3.6 `_ 201 | * Fixed `Add types and support for Flag and IntFlag `_ 202 | 203 | v1.1.1 (2022-07-24) 204 | =================== 205 | 206 | * Fixed `SymmetricMixin objects are not hashable `_ 207 | 208 | v1.1.0 (2022-07-23) 209 | =================== 210 | 211 | * Implemented `Provide equality comparisons for symmetric property values `_ 212 | 213 | v1.0.2 (2022-07-19) 214 | =================== 215 | 216 | * Fixed `Consolidate source files `_ 217 | 218 | v1.0.1 (2022-07-18) 219 | =================== 220 | 221 | * Include readme in package 222 | 223 | v1.0.0 (2022-07-18) 224 | =================== 225 | 226 | * Initial Release (production/stable) 227 | -------------------------------------------------------------------------------- /tests/annotations/test_nestedclass.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import sys 3 | from enum import auto 4 | from unittest import TestCase 5 | from typing import Annotated 6 | import warnings 7 | 8 | from enum_properties import ( 9 | EnumProperties, 10 | Symmetric, 11 | ) 12 | 13 | 14 | def transparent(func): 15 | return func # pragma: no cover 16 | 17 | 18 | nonmember, member = ( 19 | (enum.nonmember, enum.member) 20 | if sys.version_info >= (3, 11) 21 | else (transparent, transparent) 22 | ) 23 | 24 | 25 | class TestNestedClassOnEnum(TestCase): 26 | """ 27 | Should be able to nest classes on Enumerations! 28 | """ 29 | 30 | def test_enum_can_be_types(self): 31 | class Type1: 32 | pass 33 | 34 | class Type2: 35 | pass 36 | 37 | class Type3: 38 | pass 39 | 40 | class TestEnum(EnumProperties): 41 | label: Annotated[str, Symmetric()] 42 | 43 | VALUE1 = Type1, "value1" 44 | VALUE2 = Type2, "value2" 45 | VALUE3 = Type3, "value3" 46 | 47 | self.assertEqual( 48 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 49 | ) 50 | 51 | self.assertEqual( 52 | [en.label for en in TestEnum], 53 | [TestEnum.VALUE1.label, TestEnum.VALUE2.label, TestEnum.VALUE3.label], 54 | ) 55 | self.assertEqual( 56 | [en for en in TestEnum], 57 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 58 | ) 59 | self.assertEqual( 60 | [en for en in TestEnum], [TestEnum(Type1), TestEnum(Type2), TestEnum(Type3)] 61 | ) 62 | self.assertEqual([en.value for en in TestEnum], [Type1, Type2, Type3]) 63 | 64 | def test_nested_classes(self): 65 | class TestEnum(EnumProperties): 66 | label: Annotated[str, Symmetric()] 67 | 68 | VALUE1 = auto(), "value1" 69 | VALUE2 = auto(), "value2" 70 | VALUE3 = auto(), "value3" 71 | 72 | def function(self): 73 | return self.value 74 | 75 | @classmethod 76 | def default(cls): 77 | return cls.VALUE1 78 | 79 | @staticmethod 80 | def static_property(): 81 | return "static_prop" 82 | 83 | @nonmember 84 | class NestedClass: 85 | @property 86 | def prop(self): 87 | return "nested" 88 | 89 | self.assertEqual(TestEnum.VALUE1.function(), TestEnum.VALUE1.value) 90 | self.assertEqual(TestEnum.VALUE2.function(), TestEnum.VALUE2.value) 91 | self.assertEqual(TestEnum.VALUE3.function(), TestEnum.VALUE3.value) 92 | self.assertEqual(TestEnum.default(), TestEnum.VALUE1) 93 | self.assertEqual(TestEnum.static_property(), "static_prop") 94 | self.assertEqual(TestEnum.NestedClass().prop, "nested") 95 | self.assertEqual( 96 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 97 | ) 98 | self.assertEqual( 99 | [en.label for en in TestEnum], 100 | [TestEnum.VALUE1.label, TestEnum.VALUE2.label, TestEnum.VALUE3.label], 101 | ) 102 | self.assertEqual( 103 | [en for en in TestEnum], 104 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 105 | ) 106 | 107 | def test_nested_classes_as_values(self): 108 | class TestEnum(EnumProperties): 109 | label: Annotated[str, Symmetric()] 110 | 111 | @nonmember 112 | class Type1: 113 | pass 114 | 115 | @nonmember 116 | class Type2: 117 | pass 118 | 119 | @nonmember 120 | class Type3: 121 | pass 122 | 123 | VALUE1 = member(Type1), "value1" 124 | VALUE2 = member(Type2), "value2" 125 | VALUE3 = member(Type3), "value3" 126 | 127 | self.assertEqual( 128 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 129 | ) 130 | 131 | self.assertEqual( 132 | [en.label for en in TestEnum], 133 | [TestEnum.VALUE1.label, TestEnum.VALUE2.label, TestEnum.VALUE3.label], 134 | ) 135 | self.assertEqual( 136 | [en for en in TestEnum], 137 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 138 | ) 139 | self.assertEqual( 140 | [en for en in TestEnum], 141 | [ 142 | TestEnum(TestEnum.Type1), 143 | TestEnum(TestEnum.Type2), 144 | TestEnum(TestEnum.Type3), 145 | ], 146 | ) 147 | self.assertEqual( 148 | [en.value for en in TestEnum], 149 | [TestEnum.Type1, TestEnum.Type2, TestEnum.Type3], 150 | ) 151 | 152 | def test_example(self): 153 | class MyEnum(EnumProperties): 154 | label: str 155 | 156 | @nonmember 157 | class Type1: 158 | pass 159 | 160 | @nonmember 161 | class Type2: 162 | pass 163 | 164 | @nonmember 165 | class Type3: 166 | pass 167 | 168 | VALUE1 = member(Type1), "label1" 169 | VALUE2 = member(Type2), "label2" 170 | VALUE3 = member(Type3), "label3" 171 | 172 | # nested classes are usable like normal 173 | self.assertEqual(MyEnum.Type1, MyEnum.VALUE1.value) 174 | self.assertEqual(MyEnum.Type2, MyEnum.VALUE2.value) 175 | self.assertEqual(MyEnum.Type3, MyEnum.VALUE3.value) 176 | self.assertEqual(len(MyEnum), 3) 177 | self.assertTrue(MyEnum.Type1().__class__ is MyEnum.Type1) 178 | self.assertTrue(MyEnum.Type2().__class__ is MyEnum.Type2) 179 | self.assertTrue(MyEnum.Type3().__class__ is MyEnum.Type3) 180 | 181 | if sys.version_info >= (3, 11): # pragma: no cover 182 | 183 | def test_nonmember_decorator(self): 184 | class MyEnum(EnumProperties): 185 | @nonmember 186 | class Type1: 187 | pass 188 | 189 | @nonmember 190 | class Type2: 191 | pass 192 | 193 | @nonmember 194 | class Type3: 195 | pass 196 | 197 | label: str 198 | 199 | VALUE1 = member(Type1), "label1" 200 | VALUE2 = member(Type2), "label2" 201 | VALUE3 = member(Type3), "label3" 202 | VALUE4 = nonmember((Type3, "label4")) 203 | 204 | # nested classes are usable like normal 205 | self.assertEqual(MyEnum.Type1, MyEnum.VALUE1.value) 206 | self.assertEqual(MyEnum.Type2, MyEnum.VALUE2.value) 207 | self.assertEqual(MyEnum.Type3, MyEnum.VALUE3.value) 208 | self.assertEqual(len(MyEnum), 3) 209 | self.assertEqual(MyEnum.VALUE4, (MyEnum.Type3, "label4")) 210 | self.assertTrue(MyEnum.Type1().__class__ is MyEnum.Type1) 211 | self.assertTrue(MyEnum.Type2().__class__ is MyEnum.Type2) 212 | self.assertTrue(MyEnum.Type3().__class__ is MyEnum.Type3) 213 | 214 | def test_member_decorator(self): 215 | with self.assertRaises(ValueError): 216 | 217 | class MyEnum(EnumProperties): 218 | label: str 219 | 220 | @member 221 | class Type1: 222 | pass 223 | 224 | @member 225 | class Type2: 226 | pass 227 | 228 | @member 229 | class Type3: 230 | pass 231 | 232 | VALUE1 = Type1, "label1" 233 | VALUE2 = Type2, "label2" 234 | VALUE3 = Type3, "label3" 235 | 236 | def test_3_13_member_compat(self): 237 | class MyEnum(EnumProperties): 238 | @member # this is transparent on < 3.11 239 | class Type1: 240 | pass 241 | 242 | @member # this is transparent on < 3.11 243 | class Type2: 244 | pass 245 | 246 | @member # this is transparent on < 3.11 247 | class Type3: 248 | pass 249 | 250 | VALUE1 = Type1, "label1" 251 | VALUE2 = Type2, "label2" 252 | VALUE3 = Type3, "label3" 253 | 254 | self.assertEqual(MyEnum.Type1.value, MyEnum.VALUE1.value[0]) 255 | self.assertEqual(MyEnum.Type2.value, MyEnum.VALUE2.value[0]) 256 | self.assertEqual(MyEnum.Type3.value, MyEnum.VALUE3.value[0]) 257 | self.assertEqual(len(MyEnum), 6) 258 | self.assertTrue(MyEnum.Type1.value().__class__ is MyEnum.Type1.value) 259 | self.assertTrue(MyEnum.Type2.value().__class__ is MyEnum.Type2.value) 260 | self.assertTrue(MyEnum.Type3.value().__class__ is MyEnum.Type3.value) 261 | 262 | if sys.version_info < (3, 13): # pragma: no cover 263 | 264 | def test_unmarked_nested_classes(self): 265 | with warnings.catch_warnings(): 266 | warnings.simplefilter("ignore", DeprecationWarning) 267 | 268 | class TestEnum(EnumProperties): 269 | label: Annotated[str, Symmetric()] 270 | 271 | VALUE1 = auto(), "value1" 272 | VALUE2 = auto(), "value2" 273 | VALUE3 = auto(), "value3" 274 | 275 | class NestedClass: 276 | @property 277 | def prop(self): 278 | return "nested" 279 | 280 | self.assertEqual(TestEnum.NestedClass().prop, "nested") 281 | self.assertEqual( 282 | [en for en in TestEnum], 283 | [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3], 284 | ) 285 | self.assertEqual( 286 | [en.label for en in TestEnum], 287 | [ 288 | TestEnum.VALUE1.label, 289 | TestEnum.VALUE2.label, 290 | TestEnum.VALUE3.label, 291 | ], 292 | ) 293 | self.assertEqual( 294 | [en for en in TestEnum], 295 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 296 | ) 297 | -------------------------------------------------------------------------------- /tests/legacy/test_nestedclass.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import sys 3 | from enum import auto 4 | from unittest import TestCase 5 | import warnings 6 | from enum_properties import ( 7 | EnumProperties, 8 | p, 9 | s, 10 | ) 11 | 12 | 13 | def transparent(func): 14 | return func # pragma: no cover 15 | 16 | 17 | nonmember, member = ( 18 | (enum.nonmember, enum.member) 19 | if sys.version_info >= (3, 11) 20 | else (transparent, transparent) 21 | ) 22 | 23 | 24 | class TestNestedClassOnEnum(TestCase): 25 | """ 26 | Should be able to nest classes on Enumerations! 27 | """ 28 | 29 | def test_enum_can_be_types(self): 30 | class Type1: 31 | pass 32 | 33 | class Type2: 34 | pass 35 | 36 | class Type3: 37 | pass 38 | 39 | class TestEnum(EnumProperties, s("label")): 40 | VALUE1 = Type1, "value1" 41 | VALUE2 = Type2, "value2" 42 | VALUE3 = Type3, "value3" 43 | 44 | self.assertEqual( 45 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 46 | ) 47 | 48 | self.assertEqual( 49 | [en.label for en in TestEnum], 50 | [TestEnum.VALUE1.label, TestEnum.VALUE2.label, TestEnum.VALUE3.label], 51 | ) 52 | self.assertEqual( 53 | [en for en in TestEnum], 54 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 55 | ) 56 | self.assertEqual( 57 | [en for en in TestEnum], [TestEnum(Type1), TestEnum(Type2), TestEnum(Type3)] 58 | ) 59 | self.assertEqual([en.value for en in TestEnum], [Type1, Type2, Type3]) 60 | 61 | def test_nested_classes(self): 62 | class TestEnum(EnumProperties, s("label")): 63 | VALUE1 = auto(), "value1" 64 | VALUE2 = auto(), "value2" 65 | VALUE3 = auto(), "value3" 66 | 67 | def function(self): 68 | return self.value 69 | 70 | @classmethod 71 | def default(cls): 72 | return cls.VALUE1 73 | 74 | @staticmethod 75 | def static_property(): 76 | return "static_prop" 77 | 78 | @nonmember 79 | class NestedClass: 80 | @property 81 | def prop(self): 82 | return "nested" 83 | 84 | self.assertEqual(TestEnum.VALUE1.function(), TestEnum.VALUE1.value) 85 | self.assertEqual(TestEnum.VALUE2.function(), TestEnum.VALUE2.value) 86 | self.assertEqual(TestEnum.VALUE3.function(), TestEnum.VALUE3.value) 87 | self.assertEqual(TestEnum.default(), TestEnum.VALUE1) 88 | self.assertEqual(TestEnum.static_property(), "static_prop") 89 | self.assertEqual(TestEnum.NestedClass().prop, "nested") 90 | self.assertEqual( 91 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 92 | ) 93 | self.assertEqual( 94 | [en.label for en in TestEnum], 95 | [TestEnum.VALUE1.label, TestEnum.VALUE2.label, TestEnum.VALUE3.label], 96 | ) 97 | self.assertEqual( 98 | [en for en in TestEnum], 99 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 100 | ) 101 | 102 | def test_nested_classes_as_values(self): 103 | class TestEnum(EnumProperties, s("label")): 104 | @nonmember 105 | class Type1: 106 | pass 107 | 108 | @nonmember 109 | class Type2: 110 | pass 111 | 112 | @nonmember 113 | class Type3: 114 | pass 115 | 116 | VALUE1 = member(Type1), "value1" 117 | VALUE2 = member(Type2), "value2" 118 | VALUE3 = member(Type3), "value3" 119 | 120 | self.assertEqual( 121 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 122 | ) 123 | 124 | self.assertEqual( 125 | [en.label for en in TestEnum], 126 | [TestEnum.VALUE1.label, TestEnum.VALUE2.label, TestEnum.VALUE3.label], 127 | ) 128 | self.assertEqual( 129 | [en for en in TestEnum], 130 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 131 | ) 132 | self.assertEqual( 133 | [en for en in TestEnum], 134 | [ 135 | TestEnum(TestEnum.Type1), 136 | TestEnum(TestEnum.Type2), 137 | TestEnum(TestEnum.Type3), 138 | ], 139 | ) 140 | self.assertEqual( 141 | [en.value for en in TestEnum], 142 | [TestEnum.Type1, TestEnum.Type2, TestEnum.Type3], 143 | ) 144 | 145 | def test_nested_classes_as_values_no_props(self): 146 | class TestEnum(EnumProperties): 147 | @nonmember 148 | class Type1: 149 | pass 150 | 151 | @nonmember 152 | class Type2: 153 | pass 154 | 155 | @nonmember 156 | class Type3: 157 | pass 158 | 159 | VALUE1 = member(Type1) 160 | VALUE2 = member(Type2) 161 | VALUE3 = member(Type3) 162 | 163 | self.assertEqual( 164 | [en for en in TestEnum], [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3] 165 | ) 166 | self.assertEqual( 167 | [en for en in TestEnum], 168 | [ 169 | TestEnum(TestEnum.Type1), 170 | TestEnum(TestEnum.Type2), 171 | TestEnum(TestEnum.Type3), 172 | ], 173 | ) 174 | self.assertEqual( 175 | [en.value for en in TestEnum], 176 | [TestEnum.Type1, TestEnum.Type2, TestEnum.Type3], 177 | ) 178 | 179 | def test_example(self): 180 | class MyEnum(EnumProperties, p("label")): 181 | @nonmember 182 | class Type1: 183 | pass 184 | 185 | @nonmember 186 | class Type2: 187 | pass 188 | 189 | @nonmember 190 | class Type3: 191 | pass 192 | 193 | VALUE1 = member(Type1), "label1" 194 | VALUE2 = member(Type2), "label2" 195 | VALUE3 = member(Type3), "label3" 196 | 197 | # nested classes are usable like normal 198 | self.assertEqual(MyEnum.Type1, MyEnum.VALUE1.value) 199 | self.assertEqual(MyEnum.Type2, MyEnum.VALUE2.value) 200 | self.assertEqual(MyEnum.Type3, MyEnum.VALUE3.value) 201 | self.assertEqual(len(MyEnum), 3) 202 | self.assertTrue(MyEnum.Type1().__class__ is MyEnum.Type1) 203 | self.assertTrue(MyEnum.Type2().__class__ is MyEnum.Type2) 204 | self.assertTrue(MyEnum.Type3().__class__ is MyEnum.Type3) 205 | 206 | if sys.version_info >= (3, 11): # pragma: no cover 207 | 208 | def test_nonmember_decorator(self): 209 | class MyEnum(EnumProperties, p("label")): 210 | @nonmember 211 | class Type1: 212 | pass 213 | 214 | @nonmember 215 | class Type2: 216 | pass 217 | 218 | @nonmember 219 | class Type3: 220 | pass 221 | 222 | VALUE1 = member(Type1), "label1" 223 | VALUE2 = member(Type2), "label2" 224 | VALUE3 = member(Type3), "label3" 225 | VALUE4 = nonmember((Type3, "label4")) 226 | 227 | # nested classes are usable like normal 228 | self.assertEqual(MyEnum.Type1, MyEnum.VALUE1.value) 229 | self.assertEqual(MyEnum.Type2, MyEnum.VALUE2.value) 230 | self.assertEqual(MyEnum.Type3, MyEnum.VALUE3.value) 231 | self.assertEqual(len(MyEnum), 3) 232 | self.assertEqual(MyEnum.VALUE4, (MyEnum.Type3, "label4")) 233 | self.assertTrue(MyEnum.Type1().__class__ is MyEnum.Type1) 234 | self.assertTrue(MyEnum.Type2().__class__ is MyEnum.Type2) 235 | self.assertTrue(MyEnum.Type3().__class__ is MyEnum.Type3) 236 | 237 | def test_member_decorator(self): 238 | with self.assertRaises(ValueError): 239 | 240 | class MyEnum(EnumProperties, p("label")): 241 | @member 242 | class Type1: 243 | pass 244 | 245 | @member 246 | class Type2: 247 | pass 248 | 249 | @member 250 | class Type3: 251 | pass 252 | 253 | VALUE1 = Type1, "label1" 254 | VALUE2 = Type2, "label2" 255 | VALUE3 = Type3, "label3" 256 | 257 | def test_3_13_member_compat(self): 258 | class MyEnum(EnumProperties): 259 | @member # this is transparent on < 3.11 260 | class Type1: 261 | pass 262 | 263 | @member # this is transparent on < 3.11 264 | class Type2: 265 | pass 266 | 267 | @member # this is transparent on < 3.11 268 | class Type3: 269 | pass 270 | 271 | VALUE1 = Type1, "label1" 272 | VALUE2 = Type2, "label2" 273 | VALUE3 = Type3, "label3" 274 | 275 | self.assertEqual(MyEnum.Type1.value, MyEnum.VALUE1.value[0]) 276 | self.assertEqual(MyEnum.Type2.value, MyEnum.VALUE2.value[0]) 277 | self.assertEqual(MyEnum.Type3.value, MyEnum.VALUE3.value[0]) 278 | self.assertEqual(len(MyEnum), 6) 279 | self.assertTrue(MyEnum.Type1.value().__class__ is MyEnum.Type1.value) 280 | self.assertTrue(MyEnum.Type2.value().__class__ is MyEnum.Type2.value) 281 | self.assertTrue(MyEnum.Type3.value().__class__ is MyEnum.Type3.value) 282 | 283 | if sys.version_info < (3, 13): # pragma: no cover 284 | 285 | def test_unmarked_nested_classes(self): 286 | with warnings.catch_warnings(): 287 | warnings.simplefilter("ignore", DeprecationWarning) 288 | 289 | class TestEnum(EnumProperties, s("label")): 290 | VALUE1 = auto(), "value1" 291 | VALUE2 = auto(), "value2" 292 | VALUE3 = auto(), "value3" 293 | 294 | class NestedClass: 295 | @property 296 | def prop(self): 297 | return "nested" 298 | 299 | self.assertEqual(TestEnum.NestedClass().prop, "nested") 300 | self.assertEqual( 301 | [en for en in TestEnum], 302 | [TestEnum.VALUE1, TestEnum.VALUE2, TestEnum.VALUE3], 303 | ) 304 | self.assertEqual( 305 | [en.label for en in TestEnum], 306 | [ 307 | TestEnum.VALUE1.label, 308 | TestEnum.VALUE2.label, 309 | TestEnum.VALUE3.label, 310 | ], 311 | ) 312 | self.assertEqual( 313 | [en for en in TestEnum], 314 | [TestEnum("value1"), TestEnum("value2"), TestEnum("value3")], 315 | ) 316 | -------------------------------------------------------------------------------- /doc/source/howto.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | ====== 4 | How To 5 | ====== 6 | 7 | .. _howto_add_properties: 8 | 9 | Add Properties to an Enum 10 | ------------------------- 11 | 12 | To add properties to an enumeration you must inherit from 13 | :py:class:`~enum_properties.EnumProperties` or :py:class:`~enum_properties.IntEnumProperties` 14 | instead of :class:`enum.Enum` and :class:`enum.IntEnum`, list property values in a tuple with each 15 | enumeration value and let :py:class:`~enum_properties.EnumProperties` know that your properties 16 | exist and what their names are by adding type hints to the Enum class definition before the 17 | enumeration values. **The type hints must be in the same order as property values are listed in the 18 | value tuples:** 19 | 20 | .. warning:: 21 | 22 | A :class:`ValueError` will be thrown if the length of any value tuple does not 23 | match the number of expected properties. If a given enumeration value does 24 | not have a property, None should be used. 25 | 26 | For example: 27 | 28 | .. literalinclude:: ../../tests/examples/howto_add_props.py 29 | 30 | .. tip:: 31 | 32 | The property type hints must be specified before the enumeration values to become properties. 33 | If you would like a type hint on your enumeration that are not properties, you may specify the 34 | hint after the value definitions. 35 | 36 | Use a metaclass instead 37 | ~~~~~~~~~~~~~~~~~~~~~~~ 38 | 39 | :py:class:`~enum_properties.EnumProperties` inherits from enum and all other standard python 40 | enumeration functionality will work. The :py:class:`~enum_properties.EnumProperties` base class is 41 | equivalent to: 42 | 43 | .. literalinclude:: ../../tests/examples/howto_metaclass.py 44 | :lines: 2-7 45 | 46 | .. _howto_symmetric_properties: 47 | 48 | Get Enums from their properties 49 | ------------------------------- 50 | 51 | For some enumerations it will make sense to be able to fetch an enumeration value instance 52 | from one of the property values. **This is called property symmetry**. To mark a property as 53 | symmetric, annotate your type hint with :py:class:`~enum_properties.Symmetric`: 54 | 55 | .. literalinclude:: ../../tests/examples/howto_symmetry.py 56 | 57 | .. tip:: 58 | 59 | Symmetric string properties are by default case sensitive. To mark a property as case 60 | insensitive, use the ``case_fold=True`` parameter on the :py:class:`~enum_properties.Symmetric` 61 | dataclass. 62 | 63 | ``case_fold`` will more than just make matching case insensitive. It will store the string using 64 | the `unicode standard Normalization Form Compatibility Decomposition (NFKD) algorithm 65 | `_. This breaks down characters into their canonical components. 66 | For example, accented characters like "é" are decomposed to "e". This is particularly useful when 67 | you want to compare strings or search text in a way that ignores differences in case and 68 | accent/diacritic representations. 69 | 70 | For futher reading, here's `more than you ever wanted to know about unicode 71 | `_. 72 | 73 | .. tip:: 74 | 75 | By default, none values for symmetric properties will not be symmetric. To change this behavior 76 | pass: ``match_none=True`` to :py:class:`~enum_properties.Symmetric`. 77 | 78 | .. warning:: 79 | 80 | Any object may be a property value, but symmetric property values must be hashable. A 81 | :class:`ValueError` will be thrown if they are not. 82 | 83 | An exception to this is that symmetric property values may be a list or set of hashable values. 84 | Each value in the list will be symmetric to the enumeration value. Tuples are hashable and are 85 | treated as singular property values. See the ``AddressRoute`` example in :ref:`examples`. 86 | 87 | :py:class:`~enum_properties.SymmetricMixin` tries very hard to resolve enumeration values from 88 | objects. Type coercion to all potential value types will be attempted before giving up. For 89 | instance, if we have a hex object that is coercible to a string hex value we could instantiate our 90 | Color enumeration from it and perform equality comparisons: 91 | 92 | .. literalinclude:: ../../tests/examples/howto_symmetry.py 93 | :lines: 20-24 94 | 95 | 96 | .. warning:: 97 | 98 | Using symmetric properties with @verify(UNIQUE) will raise an error: 99 | 100 | .. literalinclude:: ../../tests/examples/howto_verify_unique.py 101 | 102 | 103 | Use a metaclass instead 104 | ~~~~~~~~~~~~~~~~~~~~~~~ 105 | 106 | Symmetric property support is added through the :py:class:`~enum_properties.SymmetricMixin` class 107 | which is included in the :py:class:`~enum_properties.EnumProperties` base class. If you are using 108 | the metaclass you must also inherit from :py:class:`~enum_properties.SymmetricMixin`: 109 | 110 | .. literalinclude:: ../../tests/examples/howto_symmetric_metaclass.py 111 | :lines: 2-7 112 | 113 | 114 | .. _howto_symmetric_precedence: 115 | 116 | Handle Symmetric Overloads 117 | -------------------------- 118 | 119 | Symmetric properties need not be unique. Resolution by value is deterministic based on the 120 | following priority order: 121 | 122 | 1) Type Specificity 123 | Any value that matches a property value without a type coercion will take precedence over 124 | values that match after type coercion. 125 | 2) Left to right. 126 | Any value with a smaller tuple index will override any value with a larger tuple index 127 | 3) Nested left to right. 128 | Any value in a list or set of symmetric values will override values with larger indexes in 129 | corresponding property values. 130 | 131 | .. literalinclude:: ../../tests/examples/howto_symmetric_overload.py 132 | 133 | 134 | Mark ``name`` as Symmetric 135 | -------------------------- 136 | 137 | When extending from :class:`enum.Enum` or other enumeration base classes, some builtin properties 138 | are available. `name` is available on all standard :class:`enum.Enum` classes. By default 139 | :py:class:`~enum_properties.EnumProperties` will make `name` case sensitive symmetric. To override 140 | this behavior, you may add a type hint for ``name`` before your added property type hints. For 141 | example to make name case insensitive we might: 142 | 143 | .. literalinclude:: ../../tests/examples/howto_symmetric_builtins.py 144 | 145 | 146 | .. _howto_symmetric_decorator: 147 | 148 | Mark @properties as Symmetric 149 | ----------------------------- 150 | 151 | The :py:func:`~enum_properties.symmetric` decorator may be used to mark @properties as symmetric or other members not specified 152 | in the Enum value tuple as symmetric. For example: 153 | 154 | .. literalinclude:: ../../tests/examples/howto_symmetric_decorator.py 155 | 156 | 157 | .. _howto_specialize_members: 158 | 159 | Specializing Member Functions 160 | ----------------------------- 161 | 162 | Provide specialized implementations of member functions using the specialize decorator. For 163 | example: 164 | 165 | .. literalinclude:: ../../tests/examples/howto_specialized.py 166 | 167 | The :py:meth:`~enum_properties.specialize` decorator works on ``@classmethods`` and 168 | ``@staticmethods`` as well, but it must be the outermost decorator. 169 | 170 | The undecorated method will apply to all members that lack a specialization: 171 | 172 | .. literalinclude:: ../../tests/examples/howto_specialized_default.py 173 | 174 | If no undecorated method or specialization for a value is found that value will 175 | lack the method. 176 | 177 | .. literalinclude:: ../../tests/examples/howto_specialized_missing.py 178 | 179 | :py:meth:`~enum_properties.specialize` will also accept a list so that multiple enumeration values 180 | can share the same specialization. 181 | 182 | .. literalinclude:: ../../tests/examples/howto_specialized_list.py 183 | 184 | 185 | .. _howto_flags: 186 | 187 | Flags 188 | ----- 189 | 190 | :class:`enum.IntFlag` and :class:`enum.Flag` types that support properties are also provided by the 191 | :py:class:`~enum_properties.IntFlagProperties` and :py:class:`~enum_properties.FlagProperties` 192 | classes. For example: 193 | 194 | .. literalinclude:: ../../tests/examples/howto_flag.py 195 | :lines: 1-24 196 | 197 | Flag enumerations can also be created from iterables and generators containing values or symmetric 198 | values. 199 | 200 | .. literalinclude:: ../../tests/examples/howto_flag.py 201 | :lines: 26-43 202 | 203 | .. note:: 204 | 205 | Iterable instantiation on flags is added using the :py:class:`~enum_properties.DecomposeMixin`. 206 | To create a flag enumeration without the iterable extensions we can simply declare it manually 207 | without the mixin: 208 | 209 | .. literalinclude:: ../../tests/examples/howto_flags_no_iterable.py 210 | :lines: 1-10 211 | 212 | 213 | As of Python 3.11 `boundary values `_ 214 | are supported on flags. Boundary specifiers must be supplied as named arguments: 215 | 216 | .. literalinclude:: ../../tests/examples/howto_flag_boundaries.py 217 | 218 | 219 | .. _howto_nested_class_values: 220 | 221 | Use Nested Classes as Enums 222 | --------------------------- 223 | 224 | You can use nested classes as enumeration values. The tricky part is keeping them from becoming 225 | values themselves. 226 | 227 | On enums that inherit from :class:`enum.Enum` in python < 3.13 nested classes become 228 | enumeration values because types may be values and a quirk of Python makes it 229 | difficult to determine if a type on a class is declared as a nested class 230 | during __new__. For enums with properties we can distinguish declared classes 231 | because values must be tuples. 232 | 233 | Note that on 3.13 and above you must use the nonmember/member decorators. Also note that 234 | the position of ``label`` is important. 235 | 236 | .. tabs:: 237 | 238 | .. tab:: <3.13 239 | 240 | .. literalinclude:: ../../tests/examples/howto_nested_classes.py 241 | 242 | .. tab:: >=3.11 (required >=3.13) 243 | 244 | .. literalinclude:: ../../tests/examples/howto_nested_classes_313.py 245 | 246 | 247 | .. _howto_dataclass_enums: 248 | 249 | What about dataclass Enums? 250 | --------------------------- 251 | 252 | As of Python 3.12, `Enum values can be 253 | `_ :mod:`python:dataclasses`. At 254 | first glance this enables some behavior that is similar to adding properties. For example: 255 | 256 | .. literalinclude:: ../../tests/examples/howto_dataclass.py 257 | 258 | **We still recommend EnumProperties as the preferred way to add additional attributes to a Python 259 | enumeration for the following reasons:** 260 | 261 | - The value of ``BEETLE`` and ``DOG`` in the above example are instances 262 | of the ``CreatureDataMixin`` dataclass. This can complicate interfacing 263 | with other systems (like databases) where it is more natural for the 264 | enumeration value to be a small primitive type like a character or 265 | integer. 266 | 267 | - The dataclass method requires two classes where a single ``EnumProperties`` 268 | class will suffice. 269 | 270 | - :mod:`python:dataclasses` are not hashable by default which can complicate equality 271 | testing and marshalling external data into enumeration values. 272 | 273 | - Many code bases that use duck typing and that work with Enums expect the ``value`` 274 | attribute to be a a plain old data type and therefore serializable. 275 | 276 | .. note:: 277 | 278 | EnumProperties also integrates with Enum's dataclass support! 279 | For example we can add a symmetric property to the Creature 280 | enumeration like so (**note the tuple encapsulating the dataclass fields**): 281 | 282 | .. literalinclude:: ../../tests/examples/howto_dataclass_integration.py 283 | 284 | 285 | .. _howto_members_and_aliases: 286 | 287 | Get members and aliases 288 | ----------------------- 289 | 290 | Symmetric properties are added to the :attr:`~enum.EnumType.__members__` attribute, 291 | and alias members do not appear in :attr:`~enum.Enum._member_names_`. To get a list 292 | of first class members and aliases use 293 | :attr:`~enum_properties.EnumProperties.__first_class_members__`. This class member may 294 | also be overridden if you wish to customize this behavior for users. 295 | 296 | .. literalinclude:: ../../tests/examples/howto_members_and_aliases.py 297 | 298 | .. _howto_hash_equivalency: 299 | 300 | Define hash equivalent enums 301 | ---------------------------- 302 | 303 | The :class:`enum.Enum` types that inherit from primitive types :class:`int` and :class:`str` are 304 | hash equivalent to their primitive types. This means that they can be used interchangeably in 305 | collections that use hashing: 306 | 307 | .. literalinclude:: ../../tests/examples/howto_hash_equiv.py 308 | 309 | :py:class:`~enum_properties.IntEnumProperties`, :py:class:`~enum_properties.StrEnumProperties` and 310 | :py:class:`~enum_properties.IntFlagProperties` also honor this hash equivalency. When defining your 311 | own symmetric enumeration types if you want to keep hash equivalency to the value type you will 312 | you will have to implement this yourself. For example, if you wanted your color enumeration to also 313 | be an rgb tuple: 314 | 315 | .. literalinclude:: ../../tests/examples/howto_hash_equiv_def.py 316 | 317 | .. _howto_legacy_api: 318 | 319 | Use the legacy (1.x) API 320 | ------------------------ 321 | 322 | The legacy (1.x) way of specifying properties using :py:meth:`~enum_properties.p` and 323 | :py:meth:`~enum_properties.s` value inheritance is still supported. If any properties 324 | are defined this way it will take precedence over type hinting and the type hints will 325 | not be interpreted as properties. For example: 326 | 327 | .. literalinclude:: ../../tests/examples/howto_legacy.py 328 | -------------------------------------------------------------------------------- /tests/annotations/test_flags.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import auto 3 | from unittest import TestCase 4 | from typing import Annotated 5 | 6 | from enum_properties import ( 7 | EnumProperties, 8 | FlagProperties, 9 | IntEnumProperties, 10 | IntFlagProperties, 11 | Symmetric, 12 | specialize, 13 | ) 14 | 15 | 16 | class TestFlags(TestCase): 17 | def test_int_flag(self): 18 | class Perm(IntFlagProperties): 19 | label: Annotated[str, Symmetric(case_fold=True)] 20 | 21 | R = 1, "read" 22 | W = 2, "write" 23 | X = 4, "execute" 24 | RWX = 7, "all" 25 | 26 | @property 27 | def custom_prop(self): 28 | return self.label.upper() 29 | 30 | self.assertEqual(Perm.R.label, "read") 31 | self.assertEqual(Perm.W.label, "write") 32 | self.assertEqual(Perm.X.label, "execute") 33 | self.assertEqual(Perm.RWX.label, "all") 34 | 35 | self.assertTrue(Perm.R is Perm("read")) 36 | self.assertTrue(Perm.W is Perm("write")) 37 | self.assertTrue(Perm.X is Perm("execute")) 38 | self.assertTrue(Perm.RWX is Perm("all")) 39 | 40 | self.assertEqual(Perm.W.custom_prop, "WRITE") 41 | self.assertEqual(Perm.RWX.custom_prop, "ALL") 42 | 43 | self.assertTrue((Perm.R | Perm.W | Perm.X) is Perm("RWX")) 44 | self.assertTrue(Perm([Perm.R, Perm.W, Perm.X]) is Perm("RWX")) 45 | self.assertTrue(Perm({"read", "write", "execute"}) is Perm("RWX")) 46 | self.assertTrue(Perm((val for val in (Perm.R, "write", 4))) is Perm("RWX")) 47 | 48 | self.assertEqual((Perm.R | Perm.W | Perm.X).label, "all") 49 | self.assertEqual((Perm("READ") | Perm("write") | Perm("X")).label, "all") 50 | 51 | self.assertFalse(hasattr((Perm.R | Perm.W), "label")) 52 | self.assertFalse(hasattr((Perm.W | Perm.X), "label")) 53 | self.assertFalse(hasattr((Perm.R | Perm.X), "label")) 54 | 55 | self.assertFalse(bool(Perm.R & Perm.X)) 56 | self.assertFalse(hasattr((Perm.R & Perm.X), "label")) 57 | 58 | self.assertCountEqual((Perm.R | Perm.W).flagged, [Perm.R, Perm.W]) 59 | self.assertCountEqual(Perm.RWX.flagged, [Perm.R, Perm.W, Perm.X]) 60 | self.assertEqual(Perm.R.flagged, [Perm.R]) 61 | self.assertEqual((Perm.R & Perm.X).flagged, []) 62 | 63 | self.assertEqual(len((Perm.R | Perm.W)), 2) 64 | self.assertEqual(len(Perm.RWX), 3) 65 | self.assertEqual(len(Perm.R), 1) 66 | self.assertEqual(len((Perm.R & Perm.X)), 0) 67 | 68 | self.assertEqual(Perm([]), Perm(0)) 69 | self.assertEqual(Perm({}), Perm(0)) 70 | self.assertEqual(Perm((item for item in [])), Perm(0)) 71 | 72 | if sys.version_info >= (3, 11): # pragma: no cover 73 | from enum import show_flag_values 74 | 75 | self.assertEqual(show_flag_values(Perm.R | Perm.X), [1, 4]) 76 | self.assertEqual(show_flag_values(Perm.RWX), [1, 2, 4]) 77 | 78 | def test_flag(self): 79 | class Perm(FlagProperties): 80 | label: Annotated[str, Symmetric(case_fold=True)] 81 | 82 | R = auto(), "read" 83 | W = auto(), "write" 84 | X = auto(), "execute" 85 | RWX = R | W | X, "all" 86 | 87 | @property 88 | def custom_prop(self): 89 | return self.label.upper() 90 | 91 | self.assertEqual(Perm.R.label, "read") 92 | self.assertEqual(Perm.W.label, "write") 93 | self.assertEqual(Perm.X.label, "execute") 94 | 95 | self.assertEqual(Perm.RWX.label, "all") 96 | 97 | self.assertTrue(Perm.R is Perm("read")) 98 | self.assertTrue(Perm.W is Perm("write")) 99 | self.assertTrue(Perm.X is Perm("execute")) 100 | self.assertTrue(Perm.RWX is Perm("all")) 101 | 102 | self.assertEqual(Perm.W.custom_prop, "WRITE") 103 | self.assertEqual(Perm.RWX.custom_prop, "ALL") 104 | 105 | self.assertTrue((Perm.R | Perm.W | Perm.X) is Perm("RWX")) 106 | self.assertTrue(Perm([Perm.R, Perm.W, Perm.X]) is Perm("RWX")) 107 | self.assertTrue(Perm({"read", "write", "execute"}) is Perm("RWX")) 108 | self.assertTrue(Perm((val for val in (Perm.R, "write", 4))) is Perm("RWX")) 109 | 110 | self.assertEqual((Perm.R | Perm.W | Perm.X).label, "all") 111 | self.assertEqual((Perm("READ") | Perm("write") | Perm("X")).label, "all") 112 | 113 | self.assertFalse(hasattr((Perm.R | Perm.W), "label")) 114 | self.assertFalse(hasattr((Perm.W | Perm.X), "label")) 115 | self.assertFalse(hasattr((Perm.R | Perm.X), "label")) 116 | 117 | self.assertFalse(bool(Perm.R & Perm.X)) 118 | self.assertFalse(hasattr((Perm.R & Perm.X), "label")) 119 | 120 | self.assertCountEqual((Perm.R | Perm.W).flagged, [Perm.R, Perm.W]) 121 | self.assertCountEqual(Perm.RWX.flagged, [Perm.R, Perm.W, Perm.X]) 122 | self.assertEqual(Perm.R.flagged, [Perm.R]) 123 | self.assertEqual((Perm.R & Perm.X).flagged, []) 124 | 125 | self.assertEqual(len((Perm.R | Perm.W)), 2) 126 | self.assertEqual(len(Perm.RWX), 3) 127 | self.assertEqual(len(Perm.R), 1) 128 | self.assertEqual(len((Perm.R & Perm.X)), 0) 129 | 130 | self.assertEqual(Perm([]), Perm(0)) 131 | self.assertEqual(Perm({}), Perm(0)) 132 | self.assertEqual(Perm((item for item in [])), Perm(0)) 133 | 134 | self.assertCountEqual([perm for perm in Perm.RWX], [Perm.R, Perm.W, Perm.X]) 135 | 136 | self.assertCountEqual([perm for perm in (Perm.R | Perm.X)], [Perm.R, Perm.X]) 137 | 138 | self.assertCountEqual([perm for perm in Perm.R], [Perm.R]) 139 | 140 | self.assertCountEqual([perm for perm in (Perm.R & Perm.X)], []) 141 | 142 | def test_flag_def_order(self): 143 | from enum import IntFlag 144 | 145 | class PermNative(IntFlag): 146 | R = auto() 147 | W = auto() 148 | RW = R | W 149 | X = auto() 150 | RWX = R | W | X 151 | 152 | self.assertEqual(PermNative.R.value, 1) 153 | self.assertEqual(PermNative.W.value, 2) 154 | self.assertEqual(PermNative.RW.value, 3) 155 | self.assertEqual(PermNative.X.value, 4) 156 | self.assertEqual(PermNative.RWX.value, 7) 157 | 158 | class PermProperties(FlagProperties): 159 | label: Annotated[str, Symmetric(case_fold=True)] 160 | 161 | R = auto(), "read" 162 | W = auto(), "write" 163 | RW = R | W, "read/write" 164 | X = auto(), "execute" 165 | RWX = R | W | X, "all" 166 | 167 | self.assertEqual(PermProperties.R.value, 1) 168 | self.assertEqual(PermProperties.W.value, 2) 169 | self.assertEqual(PermProperties.RW.value, 3) 170 | self.assertEqual(PermProperties.X.value, 4) 171 | self.assertEqual(PermProperties.RWX.value, 7) 172 | 173 | self.assertEqual((PermProperties.R | PermProperties.W).label, "read/write") 174 | self.assertFalse(hasattr((PermProperties.W | PermProperties.X), "label")) 175 | self.assertFalse(hasattr((PermProperties.R | PermProperties.X), "label")) 176 | self.assertEqual( 177 | (PermProperties.R | PermProperties.W | PermProperties.X).label, "all" 178 | ) 179 | self.assertEqual(PermProperties.R.label, "read") 180 | self.assertEqual(PermProperties.W.label, "write") 181 | 182 | self.assertEqual(PermProperties.RW, PermProperties("read/write")) 183 | self.assertEqual(PermProperties.RW, PermProperties(["read", "write"])) 184 | self.assertEqual( 185 | (PermProperties.W | PermProperties.X), PermProperties(["write", "execute"]) 186 | ) 187 | self.assertEqual(PermProperties.R, PermProperties("read")) 188 | self.assertEqual(PermProperties.W, PermProperties("write")) 189 | self.assertEqual(PermProperties.X, PermProperties("execute")) 190 | self.assertEqual(PermProperties.RWX, PermProperties("all")) 191 | 192 | class IntPermProperties(IntFlagProperties): 193 | label: Annotated[str, Symmetric(case_fold=True)] 194 | 195 | R = auto(), "read" 196 | W = auto(), "write" 197 | RW = R | W, "read/write" 198 | X = auto(), "execute" 199 | RWX = R | W | X, "all" 200 | 201 | self.assertEqual(IntPermProperties.R.value, 1) 202 | self.assertEqual(IntPermProperties.W.value, 2) 203 | self.assertEqual(IntPermProperties.RW.value, 3) 204 | self.assertEqual(IntPermProperties.X.value, 4) 205 | self.assertEqual(IntPermProperties.RWX.value, 7) 206 | 207 | self.assertEqual( 208 | (IntPermProperties.R | IntPermProperties.W).label, "read/write" 209 | ) 210 | self.assertFalse(hasattr((IntPermProperties.W | IntPermProperties.X), "label")) 211 | self.assertFalse(hasattr((IntPermProperties.R | IntPermProperties.X), "label")) 212 | self.assertEqual( 213 | (IntPermProperties.R | IntPermProperties.W | IntPermProperties.X).label, 214 | "all", 215 | ) 216 | self.assertEqual(IntPermProperties.R.label, "read") 217 | self.assertEqual(IntPermProperties.W.label, "write") 218 | 219 | self.assertEqual(IntPermProperties.RW, IntPermProperties("read/write")) 220 | self.assertEqual(IntPermProperties.RW, IntPermProperties(["read", "write"])) 221 | self.assertEqual( 222 | (IntPermProperties.W | IntPermProperties.X), 223 | IntPermProperties(["write", "execute"]), 224 | ) 225 | self.assertEqual(IntPermProperties.R, IntPermProperties("read")) 226 | self.assertEqual(IntPermProperties.W, IntPermProperties("write")) 227 | self.assertEqual(IntPermProperties.X, IntPermProperties("execute")) 228 | self.assertEqual(IntPermProperties.RWX, IntPermProperties("all")) 229 | 230 | if sys.version_info >= (3, 11): # pragma: no cover 231 | 232 | def test_flag_boundary_enum(self): 233 | """ 234 | Test the boundary functionality introduced in 3.11 235 | """ 236 | from enum import CONFORM, EJECT, KEEP, STRICT 237 | 238 | class StrictFlag(IntFlagProperties, boundary=STRICT): 239 | label: str 240 | 241 | RED = auto(), "red" 242 | GREEN = auto(), "green" 243 | BLUE = auto(), "blue" 244 | 245 | with self.assertRaises(ValueError): 246 | StrictFlag(2**2 + 2**4) 247 | 248 | self.assertEqual(StrictFlag.BLUE.label, "blue") 249 | self.assertEqual(StrictFlag.RED.label, "red") 250 | self.assertEqual(StrictFlag.GREEN.label, "green") 251 | self.assertFalse(hasattr((StrictFlag.BLUE | StrictFlag.RED), "label")) 252 | 253 | class ConformFlag(FlagProperties, boundary=CONFORM): 254 | label: Annotated[str, Symmetric()] 255 | 256 | RED = auto(), "red" 257 | GREEN = auto(), "green" 258 | BLUE = auto(), "blue" 259 | 260 | self.assertEqual(ConformFlag.BLUE, ConformFlag(2**2 + 2**4)) 261 | self.assertEqual(ConformFlag(2**2 + 2**4).label, "blue") 262 | self.assertEqual(ConformFlag(2**2 + 2**4).label, ConformFlag("blue")) 263 | 264 | class EjectFlag(IntFlagProperties, boundary=EJECT): 265 | label: Annotated[str, Symmetric()] 266 | 267 | RED = auto(), "red" 268 | GREEN = auto(), "green" 269 | BLUE = auto(), "blue" 270 | 271 | self.assertEqual(EjectFlag(2**2 + 2**4), 20) 272 | self.assertFalse(hasattr(EjectFlag(2**2 + 2**4), "label")) 273 | self.assertEqual(EjectFlag.GREEN, EjectFlag("green")) 274 | self.assertEqual( 275 | (EjectFlag.GREEN | EjectFlag.BLUE), EjectFlag(["blue", "green"]) 276 | ) 277 | 278 | class KeepFlag(FlagProperties, boundary=KEEP): 279 | label: Annotated[str, Symmetric()] 280 | hex: int 281 | 282 | RED = auto(), "red", 0xFF0000 283 | GREEN = auto(), "green", 0x00FF00 284 | BLUE = auto(), "blue", 0x0000FF 285 | 286 | self.assertEqual(KeepFlag(2**2 + 2**4).value, 20) 287 | self.assertTrue(KeepFlag.BLUE in KeepFlag(2**2 + 2**4)) 288 | self.assertFalse(hasattr(KeepFlag(2**2 + 2**4), "label")) 289 | self.assertEqual([flg.label for flg in KeepFlag(2**2 + 2**4)], ["blue"]) 290 | 291 | if sys.version_info >= (3, 11): # pragma: no cover 292 | 293 | def test_enum_verify(self): 294 | from enum import CONTINUOUS, NAMED_FLAGS, UNIQUE, verify 295 | 296 | with self.assertRaises(ValueError): 297 | 298 | @verify(UNIQUE) 299 | class Color(EnumProperties): 300 | label: Annotated[str, Symmetric()] 301 | 302 | RED = 1, "red" 303 | GREEN = 2, "green" 304 | BLUE = 3, "blue" 305 | CRIMSON = 1, "crimson" 306 | 307 | @verify(UNIQUE) 308 | class Color(EnumProperties): 309 | label: str 310 | 311 | RED = 1, "red" 312 | GREEN = 2, "green" 313 | BLUE = 3, "blue" 314 | CRIMSON = 4, "crimson" 315 | 316 | self.assertEqual(Color.GREEN.label, "green") 317 | self.assertEqual(Color.CRIMSON.label, "crimson") 318 | 319 | with self.assertRaises(ValueError): 320 | # this throws an error if label is symmetric! 321 | @verify(UNIQUE) 322 | class Color(EnumProperties): 323 | label: Annotated[str, Symmetric()] 324 | 325 | RED = 1, "red" 326 | GREEN = 2, "green" 327 | BLUE = 3, "blue" 328 | CRIMSON = 4, "crimson" 329 | 330 | with self.assertRaises(ValueError): 331 | 332 | @verify(CONTINUOUS) 333 | class Color(IntEnumProperties): 334 | label: Annotated[str, Symmetric()] 335 | 336 | RED = 1, "red" 337 | GREEN = 2, "green" 338 | BLUE = 5, "blue" 339 | 340 | @verify(CONTINUOUS) 341 | class Color(IntEnumProperties): 342 | label: Annotated[str, Symmetric()] 343 | 344 | RED = 1, "red" 345 | GREEN = 2, "green" 346 | BLUE = 3, "blue" 347 | 348 | self.assertEqual(Color.BLUE.label, "blue") 349 | self.assertEqual(Color.RED, Color("red")) 350 | 351 | with self.assertRaises(ValueError): 352 | 353 | @verify(NAMED_FLAGS) 354 | class Color(IntFlagProperties): 355 | label: Annotated[str, Symmetric()] 356 | 357 | RED = 1, "red" 358 | GREEN = 2, "green" 359 | BLUE = 4, "blue" 360 | WHITE = 15, "white" 361 | NEON = 31, "neon" 362 | 363 | @verify(NAMED_FLAGS) 364 | class Color(IntFlagProperties): 365 | label: Annotated[str, Symmetric()] 366 | 367 | RED = 1, "red" 368 | GREEN = 2, "green" 369 | BLUE = 4, "blue" 370 | WHITE = 16, "white" 371 | NEON = 32, "neon" 372 | 373 | self.assertEqual(Color.BLUE | Color.NEON, Color(["blue", "neon"])) 374 | 375 | if sys.version_info >= (3, 11): # pragma: no cover 376 | 377 | def test_enum_property(self): 378 | from enum import property as enum_property 379 | 380 | class Color(EnumProperties): 381 | label: Annotated[str, Symmetric()] 382 | 383 | RED = 1, "red" 384 | GREEN = 2, "green" 385 | BLUE = 3, "blue" 386 | 387 | @enum_property 388 | def blue(self): 389 | return "whatever" 390 | 391 | self.assertEqual(Color.BLUE.blue, "whatever") 392 | 393 | # attempting to assign an enum_property to a class as an existing 394 | # property name should raise an AttributeError 395 | with self.assertRaises(AttributeError): 396 | 397 | class Color(EnumProperties): 398 | label: Annotated[str, Symmetric()] 399 | 400 | RED = 1, "red" 401 | GREEN = 2, "green" 402 | BLUE = 3, "blue" 403 | 404 | @enum_property 405 | def label(self): 406 | return "label" 407 | 408 | if sys.version_info >= (3, 12): # pragma: no cover 409 | 410 | def test_enum_dataclass_support(self): 411 | """ 412 | In 3.12, Enum added support for dataclass inheritance which offers similar 413 | functionality to enum-properties. This tests evaluates how these step on 414 | each other's toes. 415 | 416 | From the std lib docs example: 417 | """ 418 | from dataclasses import dataclass, field 419 | 420 | @dataclass 421 | class CreatureDataMixin: 422 | size: str 423 | legs: int 424 | tail: bool = field(repr=False, default=True) 425 | 426 | @dataclass(eq=True, frozen=True) 427 | class CreatureDataHashableMixin: 428 | size: str 429 | legs: int 430 | tail: bool = field(repr=False, default=True) 431 | 432 | class CreatureHybrid(CreatureDataMixin, EnumProperties): 433 | kingdom: Annotated[str, Symmetric()] 434 | 435 | BEETLE = "small", 6, False, "insect" 436 | DOG = ( 437 | ( 438 | "medium", 439 | 4, 440 | ), 441 | "mammal", 442 | ) 443 | 444 | self.assertEqual(CreatureHybrid.BEETLE.size, "small") 445 | self.assertEqual(CreatureHybrid.BEETLE.legs, 6) 446 | self.assertEqual(CreatureHybrid.BEETLE.tail, False) 447 | self.assertEqual(CreatureHybrid.BEETLE.kingdom, "insect") 448 | 449 | self.assertEqual(CreatureHybrid.DOG.size, "medium") 450 | self.assertEqual(CreatureHybrid.DOG.legs, 4) 451 | self.assertEqual(CreatureHybrid.DOG.tail, True) 452 | self.assertEqual(CreatureHybrid.DOG.kingdom, "mammal") 453 | 454 | self.assertEqual(CreatureHybrid("mammal"), CreatureHybrid.DOG) 455 | self.assertEqual(CreatureHybrid("insect"), CreatureHybrid.BEETLE) 456 | 457 | class CreatureHybridSpecialized(CreatureDataMixin, EnumProperties): 458 | kingdom: Annotated[str, Symmetric()] 459 | 460 | BEETLE = "small", 6, "insect" 461 | DOG = "medium", 4, False, "mammal" 462 | 463 | @specialize(BEETLE) 464 | def function(self): 465 | return "function(beetle)" 466 | 467 | @specialize(DOG) 468 | def function(self): 469 | return "function(dog)" 470 | 471 | self.assertEqual(CreatureHybridSpecialized.BEETLE.size, "small") 472 | self.assertEqual(CreatureHybridSpecialized.BEETLE.legs, 6) 473 | self.assertEqual(CreatureHybridSpecialized.BEETLE.tail, True) 474 | self.assertEqual(CreatureHybridSpecialized.BEETLE.kingdom, "insect") 475 | 476 | self.assertEqual(CreatureHybridSpecialized.DOG.size, "medium") 477 | self.assertEqual(CreatureHybridSpecialized.DOG.legs, 4) 478 | self.assertEqual(CreatureHybridSpecialized.DOG.tail, False) 479 | self.assertEqual(CreatureHybridSpecialized.DOG.kingdom, "mammal") 480 | 481 | self.assertEqual( 482 | CreatureHybridSpecialized("mammal"), CreatureHybridSpecialized.DOG 483 | ) 484 | self.assertEqual( 485 | CreatureHybridSpecialized("insect"), CreatureHybridSpecialized.BEETLE 486 | ) 487 | 488 | self.assertEqual(CreatureHybridSpecialized.DOG.function(), "function(dog)") 489 | self.assertEqual( 490 | CreatureHybridSpecialized.BEETLE.function(), "function(beetle)" 491 | ) 492 | 493 | class CreatureHybridSpecialized(CreatureDataHashableMixin, EnumProperties): 494 | kingdom: Annotated[str, Symmetric()] 495 | 496 | BEETLE = "small", 6, "insect" 497 | DOG = ( 498 | ( 499 | "medium", 500 | 4, 501 | ), 502 | "mammal", 503 | ) 504 | 505 | @specialize(BEETLE) 506 | def function(self): 507 | return "function(beetle)" 508 | 509 | @specialize(DOG) 510 | def function(self): 511 | return "function(dog)" 512 | 513 | self.assertEqual(CreatureHybridSpecialized.BEETLE.size, "small") 514 | self.assertEqual(CreatureHybridSpecialized.BEETLE.legs, 6) 515 | self.assertEqual(CreatureHybridSpecialized.BEETLE.tail, True) 516 | self.assertEqual(CreatureHybridSpecialized.BEETLE.kingdom, "insect") 517 | 518 | self.assertEqual(CreatureHybridSpecialized.DOG.size, "medium") 519 | self.assertEqual(CreatureHybridSpecialized.DOG.legs, 4) 520 | self.assertEqual(CreatureHybridSpecialized.DOG.tail, True) 521 | self.assertEqual(CreatureHybridSpecialized.DOG.kingdom, "mammal") 522 | 523 | self.assertEqual( 524 | CreatureHybridSpecialized("mammal"), CreatureHybridSpecialized.DOG 525 | ) 526 | self.assertEqual( 527 | CreatureHybridSpecialized("insect"), CreatureHybridSpecialized.BEETLE 528 | ) 529 | 530 | self.assertEqual(CreatureHybridSpecialized.DOG.function(), "function(dog)") 531 | self.assertEqual( 532 | CreatureHybridSpecialized.BEETLE.function(), "function(beetle)" 533 | ) 534 | 535 | 536 | class TestGiantFlags(TestCase): 537 | def test_over64_flags(self): 538 | class BigFlags(IntFlagProperties): 539 | label: str 540 | 541 | ONE = 2**0, "one" 542 | MIDDLE = 2**64, "middle" 543 | MIXED = ONE | MIDDLE, "mixed" 544 | LAST = 2**128, "last" 545 | 546 | self.assertEqual((BigFlags.ONE | BigFlags.LAST).value, 2**128 + 1) 547 | self.assertEqual((BigFlags.MIDDLE | BigFlags.LAST).value, 2**128 + 2**64) 548 | self.assertEqual((BigFlags.MIDDLE | BigFlags.ONE).label, "mixed") 549 | -------------------------------------------------------------------------------- /tests/legacy/test_flags.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import auto 3 | from unittest import TestCase 4 | 5 | from enum_properties import ( 6 | EnumProperties, 7 | FlagProperties, 8 | IntEnumProperties, 9 | IntFlagProperties, 10 | p, 11 | s, 12 | specialize, 13 | ) 14 | 15 | 16 | class TestFlags(TestCase): 17 | def test_int_flag(self): 18 | class Perm( 19 | IntFlagProperties, 20 | s("label", case_fold=True), 21 | ): 22 | R = 1, "read" 23 | W = 2, "write" 24 | X = 4, "execute" 25 | RWX = 7, "all" 26 | 27 | @property 28 | def custom_prop(self): 29 | return self.label.upper() 30 | 31 | self.assertEqual(Perm.R.label, "read") 32 | self.assertEqual(Perm.W.label, "write") 33 | self.assertEqual(Perm.X.label, "execute") 34 | self.assertEqual(Perm.RWX.label, "all") 35 | 36 | self.assertTrue(Perm.R is Perm("read")) 37 | self.assertTrue(Perm.W is Perm("write")) 38 | self.assertTrue(Perm.X is Perm("execute")) 39 | self.assertTrue(Perm.RWX is Perm("all")) 40 | 41 | self.assertEqual(Perm.W.custom_prop, "WRITE") 42 | self.assertEqual(Perm.RWX.custom_prop, "ALL") 43 | 44 | self.assertTrue((Perm.R | Perm.W | Perm.X) is Perm("RWX")) 45 | self.assertTrue(Perm([Perm.R, Perm.W, Perm.X]) is Perm("RWX")) 46 | self.assertTrue(Perm({"read", "write", "execute"}) is Perm("RWX")) 47 | self.assertTrue(Perm((val for val in (Perm.R, "write", 4))) is Perm("RWX")) 48 | 49 | self.assertEqual((Perm.R | Perm.W | Perm.X).label, "all") 50 | self.assertEqual((Perm("READ") | Perm("write") | Perm("X")).label, "all") 51 | 52 | self.assertFalse(hasattr((Perm.R | Perm.W), "label")) 53 | self.assertFalse(hasattr((Perm.W | Perm.X), "label")) 54 | self.assertFalse(hasattr((Perm.R | Perm.X), "label")) 55 | 56 | self.assertFalse(bool(Perm.R & Perm.X)) 57 | self.assertFalse(hasattr((Perm.R & Perm.X), "label")) 58 | 59 | self.assertCountEqual((Perm.R | Perm.W).flagged, [Perm.R, Perm.W]) 60 | self.assertCountEqual(Perm.RWX.flagged, [Perm.R, Perm.W, Perm.X]) 61 | self.assertEqual(Perm.R.flagged, [Perm.R]) 62 | self.assertEqual((Perm.R & Perm.X).flagged, []) 63 | 64 | self.assertEqual(len((Perm.R | Perm.W)), 2) 65 | self.assertEqual(len(Perm.RWX), 3) 66 | self.assertEqual(len(Perm.R), 1) 67 | self.assertEqual(len((Perm.R & Perm.X)), 0) 68 | 69 | self.assertEqual(Perm([]), Perm(0)) 70 | self.assertEqual(Perm({}), Perm(0)) 71 | self.assertEqual(Perm((item for item in [])), Perm(0)) 72 | 73 | if sys.version_info >= (3, 11): # pragma: no cover 74 | from enum import show_flag_values 75 | 76 | self.assertEqual(show_flag_values(Perm.R | Perm.X), [1, 4]) 77 | self.assertEqual(show_flag_values(Perm.RWX), [1, 2, 4]) 78 | 79 | def test_flag(self): 80 | class Perm( 81 | FlagProperties, 82 | s("label", case_fold=True), 83 | ): 84 | R = auto(), "read" 85 | W = auto(), "write" 86 | X = auto(), "execute" 87 | RWX = R | W | X, "all" 88 | 89 | @property 90 | def custom_prop(self): 91 | return self.label.upper() 92 | 93 | self.assertEqual(Perm.R.label, "read") 94 | self.assertEqual(Perm.W.label, "write") 95 | self.assertEqual(Perm.X.label, "execute") 96 | 97 | self.assertEqual(Perm.RWX.label, "all") 98 | 99 | self.assertTrue(Perm.R is Perm("read")) 100 | self.assertTrue(Perm.W is Perm("write")) 101 | self.assertTrue(Perm.X is Perm("execute")) 102 | self.assertTrue(Perm.RWX is Perm("all")) 103 | 104 | self.assertEqual(Perm.W.custom_prop, "WRITE") 105 | self.assertEqual(Perm.RWX.custom_prop, "ALL") 106 | 107 | self.assertTrue((Perm.R | Perm.W | Perm.X) is Perm("RWX")) 108 | self.assertTrue(Perm([Perm.R, Perm.W, Perm.X]) is Perm("RWX")) 109 | self.assertTrue(Perm({"read", "write", "execute"}) is Perm("RWX")) 110 | self.assertTrue(Perm((val for val in (Perm.R, "write", 4))) is Perm("RWX")) 111 | 112 | self.assertEqual((Perm.R | Perm.W | Perm.X).label, "all") 113 | self.assertEqual((Perm("READ") | Perm("write") | Perm("X")).label, "all") 114 | 115 | self.assertFalse(hasattr((Perm.R | Perm.W), "label")) 116 | self.assertFalse(hasattr((Perm.W | Perm.X), "label")) 117 | self.assertFalse(hasattr((Perm.R | Perm.X), "label")) 118 | 119 | self.assertFalse(bool(Perm.R & Perm.X)) 120 | self.assertFalse(hasattr((Perm.R & Perm.X), "label")) 121 | 122 | self.assertCountEqual((Perm.R | Perm.W).flagged, [Perm.R, Perm.W]) 123 | self.assertCountEqual(Perm.RWX.flagged, [Perm.R, Perm.W, Perm.X]) 124 | self.assertEqual(Perm.R.flagged, [Perm.R]) 125 | self.assertEqual((Perm.R & Perm.X).flagged, []) 126 | 127 | self.assertEqual(len((Perm.R | Perm.W)), 2) 128 | self.assertEqual(len(Perm.RWX), 3) 129 | self.assertEqual(len(Perm.R), 1) 130 | self.assertEqual(len((Perm.R & Perm.X)), 0) 131 | 132 | self.assertEqual(Perm([]), Perm(0)) 133 | self.assertEqual(Perm({}), Perm(0)) 134 | self.assertEqual(Perm((item for item in [])), Perm(0)) 135 | 136 | self.assertCountEqual([perm for perm in Perm.RWX], [Perm.R, Perm.W, Perm.X]) 137 | 138 | self.assertCountEqual([perm for perm in (Perm.R | Perm.X)], [Perm.R, Perm.X]) 139 | 140 | self.assertCountEqual([perm for perm in Perm.R], [Perm.R]) 141 | 142 | self.assertCountEqual([perm for perm in (Perm.R & Perm.X)], []) 143 | 144 | def test_flag_def_order(self): 145 | from enum import IntFlag 146 | 147 | class PermNative(IntFlag): 148 | R = auto() 149 | W = auto() 150 | RW = R | W 151 | X = auto() 152 | RWX = R | W | X 153 | 154 | self.assertEqual(PermNative.R.value, 1) 155 | self.assertEqual(PermNative.W.value, 2) 156 | self.assertEqual(PermNative.RW.value, 3) 157 | self.assertEqual(PermNative.X.value, 4) 158 | self.assertEqual(PermNative.RWX.value, 7) 159 | 160 | class PermProperties(FlagProperties, s("label", case_fold=True)): 161 | R = auto(), "read" 162 | W = auto(), "write" 163 | RW = R | W, "read/write" 164 | X = auto(), "execute" 165 | RWX = R | W | X, "all" 166 | 167 | self.assertEqual(PermProperties.R.value, 1) 168 | self.assertEqual(PermProperties.W.value, 2) 169 | self.assertEqual(PermProperties.RW.value, 3) 170 | self.assertEqual(PermProperties.X.value, 4) 171 | self.assertEqual(PermProperties.RWX.value, 7) 172 | 173 | self.assertEqual((PermProperties.R | PermProperties.W).label, "read/write") 174 | self.assertFalse(hasattr((PermProperties.W | PermProperties.X), "label")) 175 | self.assertFalse(hasattr((PermProperties.R | PermProperties.X), "label")) 176 | self.assertEqual( 177 | (PermProperties.R | PermProperties.W | PermProperties.X).label, "all" 178 | ) 179 | self.assertEqual(PermProperties.R.label, "read") 180 | self.assertEqual(PermProperties.W.label, "write") 181 | 182 | self.assertEqual(PermProperties.RW, PermProperties("read/write")) 183 | self.assertEqual(PermProperties.RW, PermProperties(["read", "write"])) 184 | self.assertEqual( 185 | (PermProperties.W | PermProperties.X), PermProperties(["write", "execute"]) 186 | ) 187 | self.assertEqual(PermProperties.R, PermProperties("read")) 188 | self.assertEqual(PermProperties.W, PermProperties("write")) 189 | self.assertEqual(PermProperties.X, PermProperties("execute")) 190 | self.assertEqual(PermProperties.RWX, PermProperties("all")) 191 | 192 | class IntPermProperties(IntFlagProperties, s("label", case_fold=True)): 193 | R = auto(), "read" 194 | W = auto(), "write" 195 | RW = R | W, "read/write" 196 | X = auto(), "execute" 197 | RWX = R | W | X, "all" 198 | 199 | self.assertEqual(IntPermProperties.R.value, 1) 200 | self.assertEqual(IntPermProperties.W.value, 2) 201 | self.assertEqual(IntPermProperties.RW.value, 3) 202 | self.assertEqual(IntPermProperties.X.value, 4) 203 | self.assertEqual(IntPermProperties.RWX.value, 7) 204 | 205 | self.assertEqual( 206 | (IntPermProperties.R | IntPermProperties.W).label, "read/write" 207 | ) 208 | self.assertFalse(hasattr((IntPermProperties.W | IntPermProperties.X), "label")) 209 | self.assertFalse(hasattr((IntPermProperties.R | IntPermProperties.X), "label")) 210 | self.assertEqual( 211 | (IntPermProperties.R | IntPermProperties.W | IntPermProperties.X).label, 212 | "all", 213 | ) 214 | self.assertEqual(IntPermProperties.R.label, "read") 215 | self.assertEqual(IntPermProperties.W.label, "write") 216 | 217 | self.assertEqual(IntPermProperties.RW, IntPermProperties("read/write")) 218 | self.assertEqual(IntPermProperties.RW, IntPermProperties(["read", "write"])) 219 | self.assertEqual( 220 | (IntPermProperties.W | IntPermProperties.X), 221 | IntPermProperties(["write", "execute"]), 222 | ) 223 | self.assertEqual(IntPermProperties.R, IntPermProperties("read")) 224 | self.assertEqual(IntPermProperties.W, IntPermProperties("write")) 225 | self.assertEqual(IntPermProperties.X, IntPermProperties("execute")) 226 | self.assertEqual(IntPermProperties.RWX, IntPermProperties("all")) 227 | 228 | if sys.version_info >= (3, 11): # pragma: no cover 229 | 230 | def test_flag_boundary_enum(self): 231 | """ 232 | Test the boundary functionality introduced in 3.11 233 | """ 234 | from enum import CONFORM, EJECT, KEEP, STRICT 235 | 236 | class StrictFlag(IntFlagProperties, p("label"), boundary=STRICT): 237 | RED = auto(), "red" 238 | GREEN = auto(), "green" 239 | BLUE = auto(), "blue" 240 | 241 | with self.assertRaises(ValueError): 242 | StrictFlag(2**2 + 2**4) 243 | 244 | self.assertEqual(StrictFlag.BLUE.label, "blue") 245 | self.assertEqual(StrictFlag.RED.label, "red") 246 | self.assertEqual(StrictFlag.GREEN.label, "green") 247 | self.assertFalse(hasattr((StrictFlag.BLUE | StrictFlag.RED), "label")) 248 | 249 | class ConformFlag(FlagProperties, s("label"), boundary=CONFORM): 250 | RED = auto(), "red" 251 | GREEN = auto(), "green" 252 | BLUE = auto(), "blue" 253 | 254 | self.assertEqual(ConformFlag.BLUE, ConformFlag(2**2 + 2**4)) 255 | self.assertEqual(ConformFlag(2**2 + 2**4).label, "blue") 256 | self.assertEqual(ConformFlag(2**2 + 2**4).label, ConformFlag("blue")) 257 | 258 | class EjectFlag(IntFlagProperties, s("label"), boundary=EJECT): 259 | RED = auto(), "red" 260 | GREEN = auto(), "green" 261 | BLUE = auto(), "blue" 262 | 263 | self.assertEqual(EjectFlag(2**2 + 2**4), 20) 264 | self.assertFalse(hasattr(EjectFlag(2**2 + 2**4), "label")) 265 | self.assertEqual(EjectFlag.GREEN, EjectFlag("green")) 266 | self.assertEqual( 267 | (EjectFlag.GREEN | EjectFlag.BLUE), EjectFlag(["blue", "green"]) 268 | ) 269 | 270 | class KeepFlag(FlagProperties, s("label"), p("hex"), boundary=KEEP): 271 | RED = auto(), "red", 0xFF0000 272 | GREEN = auto(), "green", 0x00FF00 273 | BLUE = auto(), "blue", 0x0000FF 274 | 275 | self.assertEqual(KeepFlag(2**2 + 2**4).value, 20) 276 | self.assertTrue(KeepFlag.BLUE in KeepFlag(2**2 + 2**4)) 277 | self.assertFalse(hasattr(KeepFlag(2**2 + 2**4), "label")) 278 | self.assertEqual([flg.label for flg in KeepFlag(2**2 + 2**4)], ["blue"]) 279 | 280 | if sys.version_info >= (3, 11): # pragma: no cover 281 | 282 | def test_enum_verify(self): 283 | from enum import CONTINUOUS, NAMED_FLAGS, UNIQUE, verify 284 | 285 | with self.assertRaises(ValueError): 286 | 287 | @verify(UNIQUE) 288 | class Color(EnumProperties, s("label")): 289 | RED = 1, "red" 290 | GREEN = 2, "green" 291 | BLUE = 3, "blue" 292 | CRIMSON = 1, "crimson" 293 | 294 | @verify(UNIQUE) 295 | class Color(EnumProperties, p("label")): 296 | RED = 1, "red" 297 | GREEN = 2, "green" 298 | BLUE = 3, "blue" 299 | CRIMSON = 4, "crimson" 300 | 301 | self.assertEqual(Color.GREEN.label, "green") 302 | self.assertEqual(Color.CRIMSON.label, "crimson") 303 | 304 | with self.assertRaises(ValueError): 305 | # this throws an error if label is symmetric! 306 | @verify(UNIQUE) 307 | class Color(EnumProperties, s("label")): 308 | RED = 1, "red" 309 | GREEN = 2, "green" 310 | BLUE = 3, "blue" 311 | CRIMSON = 4, "crimson" 312 | 313 | with self.assertRaises(ValueError): 314 | 315 | @verify(CONTINUOUS) 316 | class Color(IntEnumProperties, s("label")): 317 | RED = 1, "red" 318 | GREEN = 2, "green" 319 | BLUE = 5, "blue" 320 | 321 | @verify(CONTINUOUS) 322 | class Color(IntEnumProperties, s("label")): 323 | RED = 1, "red" 324 | GREEN = 2, "green" 325 | BLUE = 3, "blue" 326 | 327 | self.assertEqual(Color.BLUE.label, "blue") 328 | self.assertEqual(Color.RED, Color("red")) 329 | 330 | with self.assertRaises(ValueError): 331 | 332 | @verify(NAMED_FLAGS) 333 | class Color(IntFlagProperties, s("label")): 334 | RED = 1, "red" 335 | GREEN = 2, "green" 336 | BLUE = 4, "blue" 337 | WHITE = 15, "white" 338 | NEON = 31, "neon" 339 | 340 | @verify(NAMED_FLAGS) 341 | class Color(IntFlagProperties, s("label")): 342 | RED = 1, "red" 343 | GREEN = 2, "green" 344 | BLUE = 4, "blue" 345 | WHITE = 16, "white" 346 | NEON = 32, "neon" 347 | 348 | self.assertEqual(Color.BLUE | Color.NEON, Color(["blue", "neon"])) 349 | 350 | if sys.version_info >= (3, 11): # pragma: no cover 351 | 352 | def test_enum_property(self): 353 | from enum import property as enum_property 354 | 355 | class Color(EnumProperties, s("label")): 356 | RED = 1, "red" 357 | GREEN = 2, "green" 358 | BLUE = 3, "blue" 359 | 360 | @enum_property 361 | def blue(self): 362 | return "whatever" 363 | 364 | self.assertEqual(Color.BLUE.blue, "whatever") 365 | 366 | # attempting to assign an enum_property to a class as an existing 367 | # property name should raise an AttributeError 368 | with self.assertRaises(AttributeError): 369 | 370 | class Color(EnumProperties, s("label")): 371 | RED = 1, "red" 372 | GREEN = 2, "green" 373 | BLUE = 3, "blue" 374 | 375 | @enum_property 376 | def label(self): 377 | return "label" 378 | 379 | if sys.version_info >= (3, 12): # pragma: no cover 380 | 381 | def test_enum_dataclass_support(self): 382 | """ 383 | In 3.12, Enum added support for dataclass inheritance which offers similar functionality 384 | to enum-properties. This tests evaluates how these step on each other's toes. 385 | 386 | From the std lib docs example: 387 | """ 388 | from dataclasses import dataclass, field 389 | 390 | @dataclass 391 | class CreatureDataMixin: 392 | size: str 393 | legs: int 394 | tail: bool = field(repr=False, default=True) 395 | 396 | @dataclass(eq=True, frozen=True) 397 | class CreatureDataHashableMixin: 398 | size: str 399 | legs: int 400 | tail: bool = field(repr=False, default=True) 401 | 402 | class Creature(CreatureDataMixin, EnumProperties): 403 | BEETLE = ("small", 6) 404 | DOG = ("medium", 4) 405 | 406 | self.assertEqual(Creature.BEETLE.size, "small") 407 | self.assertEqual(Creature.BEETLE.legs, 6) 408 | self.assertEqual(Creature.BEETLE.tail, True) 409 | 410 | self.assertEqual(Creature.DOG.size, "medium") 411 | self.assertEqual(Creature.DOG.legs, 4) 412 | self.assertEqual(Creature.DOG.tail, True) 413 | 414 | class CreatureEP(CreatureDataMixin, EnumProperties): 415 | BEETLE = ("small", 6) 416 | DOG = ("medium", 4, False) 417 | 418 | self.assertEqual(CreatureEP.BEETLE.size, "small") 419 | self.assertEqual(CreatureEP.BEETLE.legs, 6) 420 | self.assertEqual(CreatureEP.BEETLE.tail, True) 421 | 422 | self.assertEqual(CreatureEP.DOG.size, "medium") 423 | self.assertEqual(CreatureEP.DOG.legs, 4) 424 | self.assertEqual(CreatureEP.DOG.tail, False) 425 | 426 | class CreatureHybrid(CreatureDataMixin, EnumProperties, s("kingdom")): 427 | BEETLE = ("small", 6, False), "insect" 428 | DOG = ( 429 | ( 430 | "medium", 431 | 4, 432 | ), 433 | "mammal", 434 | ) 435 | 436 | self.assertEqual(CreatureHybrid.BEETLE.size, "small") 437 | self.assertEqual(CreatureHybrid.BEETLE.legs, 6) 438 | self.assertEqual(CreatureHybrid.BEETLE.tail, False) 439 | self.assertEqual(CreatureHybrid.BEETLE.kingdom, "insect") 440 | 441 | self.assertEqual(CreatureHybrid.DOG.size, "medium") 442 | self.assertEqual(CreatureHybrid.DOG.legs, 4) 443 | self.assertEqual(CreatureHybrid.DOG.tail, True) 444 | self.assertEqual(CreatureHybrid.DOG.kingdom, "mammal") 445 | 446 | self.assertEqual(CreatureHybrid("mammal"), CreatureHybrid.DOG) 447 | self.assertEqual(CreatureHybrid("insect"), CreatureHybrid.BEETLE) 448 | 449 | class CreatureHybridSpecialized( 450 | CreatureDataMixin, EnumProperties, s("kingdom") 451 | ): 452 | BEETLE = "small", 6, "insect" 453 | DOG = "medium", 4, False, "mammal" 454 | 455 | @specialize(BEETLE) 456 | def function(self): 457 | return "function(beetle)" 458 | 459 | @specialize(DOG) 460 | def function(self): 461 | return "function(dog)" 462 | 463 | self.assertEqual(CreatureHybridSpecialized.BEETLE.size, "small") 464 | self.assertEqual(CreatureHybridSpecialized.BEETLE.legs, 6) 465 | self.assertEqual(CreatureHybridSpecialized.BEETLE.tail, True) 466 | self.assertEqual(CreatureHybridSpecialized.BEETLE.kingdom, "insect") 467 | 468 | self.assertEqual(CreatureHybridSpecialized.DOG.size, "medium") 469 | self.assertEqual(CreatureHybridSpecialized.DOG.legs, 4) 470 | self.assertEqual(CreatureHybridSpecialized.DOG.tail, False) 471 | self.assertEqual(CreatureHybridSpecialized.DOG.kingdom, "mammal") 472 | 473 | self.assertEqual( 474 | CreatureHybridSpecialized("mammal"), CreatureHybridSpecialized.DOG 475 | ) 476 | self.assertEqual( 477 | CreatureHybridSpecialized("insect"), CreatureHybridSpecialized.BEETLE 478 | ) 479 | 480 | self.assertEqual(CreatureHybridSpecialized.DOG.function(), "function(dog)") 481 | self.assertEqual( 482 | CreatureHybridSpecialized.BEETLE.function(), "function(beetle)" 483 | ) 484 | 485 | class CreatureHybridSpecialized( 486 | CreatureDataHashableMixin, EnumProperties, s("kingdom") 487 | ): 488 | BEETLE = "small", 6, "insect" 489 | DOG = ( 490 | ( 491 | "medium", 492 | 4, 493 | ), 494 | "mammal", 495 | ) 496 | 497 | @specialize(BEETLE) 498 | def function(self): 499 | return "function(beetle)" 500 | 501 | @specialize(DOG) 502 | def function(self): 503 | return "function(dog)" 504 | 505 | self.assertEqual(CreatureHybridSpecialized.BEETLE.size, "small") 506 | self.assertEqual(CreatureHybridSpecialized.BEETLE.legs, 6) 507 | self.assertEqual(CreatureHybridSpecialized.BEETLE.tail, True) 508 | self.assertEqual(CreatureHybridSpecialized.BEETLE.kingdom, "insect") 509 | 510 | self.assertEqual(CreatureHybridSpecialized.DOG.size, "medium") 511 | self.assertEqual(CreatureHybridSpecialized.DOG.legs, 4) 512 | self.assertEqual(CreatureHybridSpecialized.DOG.tail, True) 513 | self.assertEqual(CreatureHybridSpecialized.DOG.kingdom, "mammal") 514 | 515 | self.assertEqual( 516 | CreatureHybridSpecialized("mammal"), CreatureHybridSpecialized.DOG 517 | ) 518 | self.assertEqual( 519 | CreatureHybridSpecialized("insect"), CreatureHybridSpecialized.BEETLE 520 | ) 521 | 522 | self.assertEqual(CreatureHybridSpecialized.DOG.function(), "function(dog)") 523 | self.assertEqual( 524 | CreatureHybridSpecialized.BEETLE.function(), "function(beetle)" 525 | ) 526 | 527 | 528 | class TestGiantFlags(TestCase): 529 | def test_over64_flags(self): 530 | class BigFlags(IntFlagProperties, p("label")): 531 | ONE = 2**0, "one" 532 | MIDDLE = 2**64, "middle" 533 | MIXED = ONE | MIDDLE, "mixed" 534 | LAST = 2**128, "last" 535 | 536 | self.assertEqual((BigFlags.ONE | BigFlags.LAST).value, 2**128 + 1) 537 | self.assertEqual((BigFlags.MIDDLE | BigFlags.LAST).value, 2**128 + 2**64) 538 | self.assertEqual((BigFlags.MIDDLE | BigFlags.ONE).label, "mixed") 539 | --------------------------------------------------------------------------------