enumplus 1.2.0__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {enumplus-1.2.0 → enumplus-1.2.2}/.github/ISSUE_TEMPLATE/bug_report.md +1 -1
  2. {enumplus-1.2.0 → enumplus-1.2.2}/.github/ISSUE_TEMPLATE/config.yml +2 -2
  3. {enumplus-1.2.0 → enumplus-1.2.2}/.github/workflows/release.yml +0 -1
  4. {enumplus-1.2.0 → enumplus-1.2.2}/AGENTS.md +7 -3
  5. enumplus-1.2.2/CHANGELOG.md +118 -0
  6. {enumplus-1.2.0 → enumplus-1.2.2}/CONTRIBUTING.md +4 -4
  7. {enumplus-1.2.0 → enumplus-1.2.2}/LICENSE +1 -1
  8. {enumplus-1.2.0 → enumplus-1.2.2}/PKG-INFO +22 -10
  9. {enumplus-1.2.0 → enumplus-1.2.2}/README.md +18 -6
  10. {enumplus-1.2.0 → enumplus-1.2.2}/SECURITY.md +1 -0
  11. {enumplus-1.2.0 → enumplus-1.2.2}/SUPPORT.md +4 -4
  12. {enumplus-1.2.0 → enumplus-1.2.2}/enumplus/__init__.py +7 -1
  13. {enumplus-1.2.0 → enumplus-1.2.2}/enumplus/enum.py +62 -19
  14. {enumplus-1.2.0 → enumplus-1.2.2}/enumplus/serialize.py +22 -27
  15. {enumplus-1.2.0 → enumplus-1.2.2}/pyproject.toml +4 -4
  16. enumplus-1.2.2/tests/test_edge_cases.py +1494 -0
  17. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_enum.py +3 -3
  18. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_new_features.py +35 -0
  19. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_public_api.py +9 -1
  20. enumplus-1.2.0/.github/FUNDING.yml +0 -3
  21. enumplus-1.2.0/CHANGELOG.md +0 -76
  22. enumplus-1.2.0/tests/test_edge_cases.py +0 -581
  23. {enumplus-1.2.0 → enumplus-1.2.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {enumplus-1.2.0 → enumplus-1.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {enumplus-1.2.0 → enumplus-1.2.2}/.github/workflows/ci.yml +0 -0
  26. {enumplus-1.2.0 → enumplus-1.2.2}/.gitignore +0 -0
  27. {enumplus-1.2.0 → enumplus-1.2.2}/CODE_OF_CONDUCT.md +0 -0
  28. {enumplus-1.2.0 → enumplus-1.2.2}/enumplus/py.typed +0 -0
  29. {enumplus-1.2.0 → enumplus-1.2.2}/enumplus/pydantic.py +0 -0
  30. {enumplus-1.2.0 → enumplus-1.2.2}/tests/__init__.py +0 -0
  31. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_compatibility.py +0 -0
  32. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_metadata_unpacking.py +0 -0
  33. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_ordered.py +0 -0
  34. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_pydantic.py +0 -0
  35. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_serialize.py +0 -0
  36. {enumplus-1.2.0 → enumplus-1.2.2}/tests/test_type_hints.py +0 -0
@@ -31,7 +31,7 @@ A clear and concise description of what actually happened.
31
31
  ## Environment
32
32
 
33
33
  - Python version: [e.g. 3.12.1]
34
- - enumplus version: [e.g. 1.1.0]
34
+ - enumplus version: [e.g. 1.2.1]
35
35
  - OS: [e.g. Ubuntu 22.04, Windows 11, macOS 14]
36
36
 
37
37
  ## Additional context
@@ -1,8 +1,8 @@
1
1
  blank_issues_enabled: false
2
2
  contact_links:
3
3
  - name: GitHub Discussions
4
- url: https://github.com/MathiasPaulenko/enumpy/discussions
4
+ url: https://github.com/MathiasPaulenko/enumplus/discussions
5
5
  about: Ask questions and discuss ideas with the community.
6
6
  - name: Security Policy
7
- url: https://github.com/MathiasPaulenko/enumpy/blob/main/SECURITY.md
7
+ url: https://github.com/MathiasPaulenko/enumplus/blob/main/SECURITY.md
8
8
  about: Report security vulnerabilities privately — do not open a public issue.
@@ -96,7 +96,6 @@ jobs:
96
96
  DOCS=$(git log --pretty=format:"- %s" ${RANGE} --grep="^docs" --grep="^readme" -i || true)
97
97
  TESTS=$(git log --pretty=format:"- %s" ${RANGE} --grep="^test" -i || true)
98
98
  CHORE=$(git log --pretty=format:"- %s" ${RANGE} --grep="^chore" --grep="^ci" --grep="^build" -i || true)
99
- RELEASE=$(git log --pretty=format:"- %s" ${RANGE} --grep="^release" -i || true)
100
99
 
101
100
  {
102
101
  echo "body<<EOF"
@@ -22,6 +22,10 @@ Requires Python `>=3.11`.
22
22
  - Keep class config keys such as `serialize_by_name` from becoming enum members
23
23
  or affecting `auto()` generation.
24
24
  - Support `auto()` following a metadata tuple on Python 3.11–3.14.
25
+ - `serialize_by_name` is a reserved class config key: it must be a `bool`
26
+ (`TypeError` otherwise) and can never be a member name.
27
+ - Auto-generated labels use `name.replace("_", " ").title()`. `filter(label=...)`
28
+ matches the evaluated label, not the raw metadata dict.
25
29
  - `Enum.__eq__` and `EnumMeta.__contains__` use `_safe_equal`, which coerces
26
30
  `a == b` to a `bool` without raising on shape/length mismatches (e.g. NumPy
27
31
  arrays, unhashable container values).
@@ -67,7 +71,7 @@ py -3.11 -m mypy --strict enumplus/ tests/
67
71
 
68
72
  ## Known constraints
69
73
 
70
- - The git remote is `MathiasPaulenko/enumpy`, while the package name and PyPI
71
- project are `enumplus`. Package URLs in `pyproject.toml` point to the actual
72
- remote repository; update if the repository is renamed.
74
+ - The repository was renamed from `enumpy` to `enumplus` on GitHub. All
75
+ project URLs point to `MathiasPaulenko/enumplus`; old `enumpy` links
76
+ redirect automatically.
73
77
  - `ref/` is intentionally ignored (local reference material). Do not commit it.
@@ -0,0 +1,118 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.2.2] - 2026-10-06
11
+
12
+ ### Fixed
13
+
14
+ - `from_value()`, `get()`, and `validate()` now return the member itself when
15
+ passed a member of the queried enum whose value is a foreign enum member.
16
+ Previously they raised `ValueError` (or returned the default) even though
17
+ `is_valid()` and `in` correctly accepted the member.
18
+ - `is_valid()` and `from_value()` now accept members of subclass enums, matching
19
+ the behavior of `in` (e.g. `Base.is_valid(Child.MEMBER)` is `True` for an
20
+ empty `Base`).
21
+ - `SerializableEncoder` now also serializes stdlib `enum.Enum` members, not just
22
+ enumplus members. `to_json()` and `json.dumps()` no longer raise `TypeError`
23
+ when a member's value or metadata is a stdlib enum member.
24
+
25
+ ### Changed
26
+
27
+ - Auto-generated labels now replace underscores with spaces: `IN_PROGRESS`
28
+ produces `"In Progress"` instead of `"In_Progress"`.
29
+ - `filter(label=...)` now matches against the member's evaluated label, so
30
+ callable and auto-generated labels can be filtered.
31
+ - `serialize_by_name` now raises `TypeError` when set to a non-`bool` value
32
+ instead of being silently swallowed as a member assignment.
33
+ - `OrderedEnum` comparison operators are now typed as returning
34
+ `bool | NotImplementedType`.
35
+
36
+ ## [1.2.1] - 2026-08-23
37
+
38
+ ### Fixed
39
+
40
+ - `to_dict()` and `to_json()` no longer double-evaluate callable labels — the callable is invoked exactly once and the result is reused in both the top-level `label` field and the `metadata` dict.
41
+ - `__version__` is now loaded dynamically from package metadata via `importlib.metadata`, eliminating version mismatch between `__init__.py` and `pyproject.toml`.
42
+ - `__contains__`, `from_value()`, `is_valid()`, and `get()` no longer produce false positives when passed a foreign enum member with the same value as a member of the queried enum.
43
+ - `__contains__`, `from_value()`, `is_valid()`, and `get()` now correctly handle members whose value is an enum member from another class. Previously, the foreign-enum guard rejected all foreign enum members, making it impossible to look up such members by value.
44
+ - `to_json()` now uses `SerializableEncoder` internally, so members whose value (or metadata) is an enum member from another class are serialized correctly instead of raising `TypeError`.
45
+ - `_safe_equal()` now catches all exceptions (not just `TypeError` and `ValueError`) from custom `__eq__` implementations, preventing crashes when comparing with objects that raise unexpected exceptions.
46
+ - Falsy but valid labels (`0`, `""`, `False`) are now respected instead of being silently replaced by `name.title()`. Only `None` and missing labels trigger the fallback.
47
+ - Removed dead `_serialize_value` function from `serialize.py`.
48
+ - Updated `SECURITY.md` to include `1.2.x` in supported versions.
49
+
50
+ ## [1.2.0] - 2026-08-23
51
+
52
+ ### Fixed
53
+
54
+ - `serialize_by_name` is now correctly inherited by subclass enums instead of being silently reset to `False`.
55
+
56
+ ### Changed
57
+
58
+ - Improved README with table of contents, requirements section, and dynamic CI badge.
59
+ - Added `pydantic` optional dependency extra for Pydantic v2 integration.
60
+ - Added concurrency groups to CI and release workflows to cancel superseded runs.
61
+ - Updated issue templates with correct version examples.
62
+ - Updated CONTRIBUTING with cross-version testing instructions and repository name clarification.
63
+
64
+ ## [1.1.0] - 2025-01-15
65
+
66
+ ### Added
67
+
68
+ - Case-insensitive lookup via `case_insensitive=True` on `from_value()` and `from_name()`.
69
+ - `serialize_by_name` class config for Pydantic v2 validation and serialization by member name.
70
+ - `to_dict()` method for serializing an enum to a nested dictionary.
71
+ - `get()` method for dict-style lookup with a default.
72
+ - `map()` method for mapping each member to a value.
73
+ - `keys()` method as an alias of `names()`.
74
+ - `get_initial()` and `get_final()` for declaration-order access.
75
+ - Callable labels for i18n / translatable labels.
76
+ - `@dataclass_transform()` on the metaclass for type-checker metadata attribute support.
77
+ - `py.typed` marker for PEP 561 typed-package support.
78
+ - Python 3.14 support and CI coverage.
79
+
80
+ ### Fixed
81
+
82
+ - `auto()` now works correctly after metadata tuples on Python 3.11–3.14.
83
+ - Class config (`serialize_by_name`) no longer registers as an enum member or corrupts `auto()` state.
84
+ - `__eq__`, `__hash__`, `__contains__`, `from_value`, `is_valid`, and `filter` are now safe for unhashable values and non-boolean comparison results.
85
+ - `from_json` validates input type and JSON shape (`TypeError` for wrong type, `ValueError` for invalid JSON).
86
+ - `from_name` raises `TypeError` for non-string input instead of `KeyError`.
87
+ - `to_dict()` and `to_json()` no longer invoke non-label callable metadata values.
88
+ - Removed redundant import in `SerializableEncoder`.
89
+
90
+ ### Changed
91
+
92
+ - Improved package metadata: classifiers, project URLs, and dev dependency lower bounds.
93
+ - Release workflow uses `${{ github.repository }}` for dynamic changelog URLs.
94
+ - CONTRIBUTING and SUPPORT docs updated with correct commands.
95
+
96
+ ## [1.0.0] - 2024-12-01
97
+
98
+ ### Added
99
+
100
+ - `Enum` base class as a drop-in replacement for `enum.Enum`.
101
+ - `OrderedEnum` with declaration-order comparison operators.
102
+ - Per-member labels with automatic `name.title()` fallback.
103
+ - Per-member metadata via `(value, dict)` tuple syntax.
104
+ - Metadata attribute access via `__getattr__`.
105
+ - `choices()`, `from_value()`, `from_name()`, `is_valid()`, `validate()`.
106
+ - `values()`, `names()`, `labels()`.
107
+ - `filter()` by metadata key-value pairs.
108
+ - `to_json()` / `from_json()` serialization.
109
+ - `SerializableEncoder` for `json.dumps`.
110
+ - Pydantic v2 integration via `__get_pydantic_core_schema__`.
111
+ - Full test suite, CI, and release workflows.
112
+
113
+ [Unreleased]: https://github.com/MathiasPaulenko/enumplus/compare/v1.2.2...HEAD
114
+ [1.2.2]: https://github.com/MathiasPaulenko/enumplus/releases/tag/v1.2.2
115
+ [1.2.1]: https://github.com/MathiasPaulenko/enumplus/releases/tag/v1.2.1
116
+ [1.2.0]: https://github.com/MathiasPaulenko/enumplus/releases/tag/v1.2.0
117
+ [1.1.0]: https://github.com/MathiasPaulenko/enumplus/releases/tag/v1.1.0
118
+ [1.0.0]: https://github.com/MathiasPaulenko/enumplus/releases/tag/v1.0.0
@@ -7,8 +7,8 @@ Thank you for your interest in contributing to enumplus! This document describes
7
7
  1. Fork the repository on GitHub.
8
8
  2. Clone your fork locally:
9
9
  ```bash
10
- git clone https://github.com/<your-username>/enumpy.git
11
- cd enumpy
10
+ git clone https://github.com/<your-username>/enumplus.git
11
+ cd enumplus
12
12
  ```
13
13
  3. Create a virtual environment and install dev dependencies:
14
14
  ```bash
@@ -17,7 +17,7 @@ Thank you for your interest in contributing to enumplus! This document describes
17
17
  pip install -e ".[dev]"
18
18
  ```
19
19
 
20
- > **Note:** The git repository is named `enumpy`, while the package name is `enumplus`. This is expected — the repository name predates the package rename.
20
+ > **Note:** The repository was previously named `enumpy`; it has been renamed to `enumplus` to match the package name. Old links redirect automatically.
21
21
 
22
22
  ## Development Workflow
23
23
 
@@ -73,7 +73,7 @@ py -3.11 -m mypy --strict enumplus/ tests/
73
73
 
74
74
  ## Reporting Issues
75
75
 
76
- - Use [GitHub Issues](https://github.com/MathiasPaulenko/enumpy/issues) to report bugs or request features.
76
+ - Use [GitHub Issues](https://github.com/MathiasPaulenko/enumplus/issues) to report bugs or request features.
77
77
  - Use the provided issue templates for consistency.
78
78
 
79
79
  ## Code of Conduct
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Mathias Paulenko
3
+ Copyright (c) 2024-2026 Mathias Paulenko
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: enumplus
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Enhanced Python enums with metadata, serialization, and choices
5
5
  Project-URL: Homepage, https://pypi.org/project/enumplus/
6
- Project-URL: Repository, https://github.com/MathiasPaulenko/enumpy
7
- Project-URL: Issues, https://github.com/MathiasPaulenko/enumpy/issues
8
- Project-URL: Changelog, https://github.com/MathiasPaulenko/enumpy/blob/main/CHANGELOG.md
6
+ Project-URL: Repository, https://github.com/MathiasPaulenko/enumplus
7
+ Project-URL: Issues, https://github.com/MathiasPaulenko/enumplus/issues
8
+ Project-URL: Changelog, https://github.com/MathiasPaulenko/enumplus/blob/main/CHANGELOG.md
9
9
  Author-email: Mathias Paulenko <mathias.paulenko@outlook.com>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
@@ -36,7 +36,7 @@ Description-Content-Type: text/markdown
36
36
  ![PyPI](https://img.shields.io/pypi/v/enumplus)
37
37
  ![Python](https://img.shields.io/badge/python-3.11+-blue)
38
38
  ![License](https://img.shields.io/badge/license-MIT-green)
39
- ![Tests](https://github.com/MathiasPaulenko/enumpy/actions/workflows/ci.yml/badge.svg)
39
+ ![Tests](https://github.com/MathiasPaulenko/enumplus/actions/workflows/ci.yml/badge.svg)
40
40
 
41
41
  Python's `enum.Enum` is basic. `enumplus` adds display names, metadata, JSON
42
42
  serialization, `choices()`, and value-based comparison — all with zero runtime
@@ -125,12 +125,12 @@ print("red" in Color) # True
125
125
 
126
126
  ### Display Names (label)
127
127
 
128
- Every member gets a human-readable label, auto-generated from the member name or set explicitly via metadata.
128
+ Every member gets a human-readable label, auto-generated from the member name or set explicitly via metadata. Auto-generated labels replace underscores with spaces (`IN_PROGRESS` becomes `"In Progress"`).
129
129
 
130
130
  ```python
131
131
  class Status(Enum):
132
- PENDING = "pending" # label: "Pending"
133
- IN_PROGRESS = ("in_progress", {"label": "In Progress"})
132
+ PENDING = "pending" # label: "Pending"
133
+ IN_PROGRESS = "in_progress" # label: "In Progress"
134
134
 
135
135
  print(Status.PENDING.label) # "Pending"
136
136
  print(Status.IN_PROGRESS.label) # "In Progress"
@@ -220,7 +220,7 @@ Color.keys() # ["RED", "GREEN"]
220
220
 
221
221
  ### filter()
222
222
 
223
- Filter members by metadata key-value pairs (AND logic).
223
+ Filter members by metadata key-value pairs (AND logic). `label` is special: it matches against the evaluated label (including auto-generated and callable labels), not the raw metadata dict.
224
224
 
225
225
  ```python
226
226
  class Color(Enum):
@@ -230,6 +230,7 @@ class Color(Enum):
230
230
  Color.filter(category="warm") # [Color.RED]
231
231
  Color.filter(hex="#FF0000", category="warm") # [Color.RED]
232
232
  Color.filter() # [Color.RED, Color.GREEN]
233
+ Color.filter(label="Red") # [Color.RED]
233
234
  ```
234
235
 
235
236
  ### Comparison with values (==)
@@ -433,7 +434,7 @@ print(Color.labels()) # ["Rojo", "Verde"]
433
434
  print(Color.choices()) # [("red", "Rojo"), ("green", "Verde")]
434
435
  ```
435
436
 
436
- Callable labels work everywhere: `choices()`, `to_dict()`, `to_json()`, `labels()`, and `str()`.
437
+ Callable labels work everywhere: `choices()`, `to_dict()`, `to_json()`, `labels()`, `filter(label=...)`, and `str()`.
437
438
 
438
439
  ### Type hints in metadata
439
440
 
@@ -465,6 +466,17 @@ from enumplus import Enum
465
466
 
466
467
  All existing enum code continues to work — `Enum["RED"]`, `Enum("red")`, `list(Enum)`, `len(Enum)`, `@unique`, `auto()`, `isinstance` checks, everything.
467
468
 
469
+ A few deliberate differences from `enum.Enum` to keep in mind:
470
+
471
+ - `str(member)` returns the label, not `"ClassName.MEMBER"` (`repr()` shows
472
+ `<ClassName.MEMBER: value>`), and `member == value` compares by value.
473
+ - `serialize_by_name` is a reserved class attribute for Pydantic config — it
474
+ can't be used as a member name and must be a `bool`.
475
+ - Metadata keys that collide with real attributes (`name`, `value`, `label`,
476
+ `metadata`) are only reachable via `member.metadata`, not as attributes.
477
+ - Metadata declared on an alias is ignored — an alias shares the canonical
478
+ member's label and metadata.
479
+
468
480
  ## Changelog
469
481
 
470
482
  See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
@@ -3,7 +3,7 @@
3
3
  ![PyPI](https://img.shields.io/pypi/v/enumplus)
4
4
  ![Python](https://img.shields.io/badge/python-3.11+-blue)
5
5
  ![License](https://img.shields.io/badge/license-MIT-green)
6
- ![Tests](https://github.com/MathiasPaulenko/enumpy/actions/workflows/ci.yml/badge.svg)
6
+ ![Tests](https://github.com/MathiasPaulenko/enumplus/actions/workflows/ci.yml/badge.svg)
7
7
 
8
8
  Python's `enum.Enum` is basic. `enumplus` adds display names, metadata, JSON
9
9
  serialization, `choices()`, and value-based comparison — all with zero runtime
@@ -92,12 +92,12 @@ print("red" in Color) # True
92
92
 
93
93
  ### Display Names (label)
94
94
 
95
- Every member gets a human-readable label, auto-generated from the member name or set explicitly via metadata.
95
+ Every member gets a human-readable label, auto-generated from the member name or set explicitly via metadata. Auto-generated labels replace underscores with spaces (`IN_PROGRESS` becomes `"In Progress"`).
96
96
 
97
97
  ```python
98
98
  class Status(Enum):
99
- PENDING = "pending" # label: "Pending"
100
- IN_PROGRESS = ("in_progress", {"label": "In Progress"})
99
+ PENDING = "pending" # label: "Pending"
100
+ IN_PROGRESS = "in_progress" # label: "In Progress"
101
101
 
102
102
  print(Status.PENDING.label) # "Pending"
103
103
  print(Status.IN_PROGRESS.label) # "In Progress"
@@ -187,7 +187,7 @@ Color.keys() # ["RED", "GREEN"]
187
187
 
188
188
  ### filter()
189
189
 
190
- Filter members by metadata key-value pairs (AND logic).
190
+ Filter members by metadata key-value pairs (AND logic). `label` is special: it matches against the evaluated label (including auto-generated and callable labels), not the raw metadata dict.
191
191
 
192
192
  ```python
193
193
  class Color(Enum):
@@ -197,6 +197,7 @@ class Color(Enum):
197
197
  Color.filter(category="warm") # [Color.RED]
198
198
  Color.filter(hex="#FF0000", category="warm") # [Color.RED]
199
199
  Color.filter() # [Color.RED, Color.GREEN]
200
+ Color.filter(label="Red") # [Color.RED]
200
201
  ```
201
202
 
202
203
  ### Comparison with values (==)
@@ -400,7 +401,7 @@ print(Color.labels()) # ["Rojo", "Verde"]
400
401
  print(Color.choices()) # [("red", "Rojo"), ("green", "Verde")]
401
402
  ```
402
403
 
403
- Callable labels work everywhere: `choices()`, `to_dict()`, `to_json()`, `labels()`, and `str()`.
404
+ Callable labels work everywhere: `choices()`, `to_dict()`, `to_json()`, `labels()`, `filter(label=...)`, and `str()`.
404
405
 
405
406
  ### Type hints in metadata
406
407
 
@@ -432,6 +433,17 @@ from enumplus import Enum
432
433
 
433
434
  All existing enum code continues to work — `Enum["RED"]`, `Enum("red")`, `list(Enum)`, `len(Enum)`, `@unique`, `auto()`, `isinstance` checks, everything.
434
435
 
436
+ A few deliberate differences from `enum.Enum` to keep in mind:
437
+
438
+ - `str(member)` returns the label, not `"ClassName.MEMBER"` (`repr()` shows
439
+ `<ClassName.MEMBER: value>`), and `member == value` compares by value.
440
+ - `serialize_by_name` is a reserved class attribute for Pydantic config — it
441
+ can't be used as a member name and must be a `bool`.
442
+ - Metadata keys that collide with real attributes (`name`, `value`, `label`,
443
+ `metadata`) are only reachable via `member.metadata`, not as attributes.
444
+ - Metadata declared on an alias is ignored — an alias shares the canonical
445
+ member's label and metadata.
446
+
435
447
  ## Changelog
436
448
 
437
449
  See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
@@ -4,6 +4,7 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|--------------------|
7
+ | 1.2.x | :white_check_mark: |
7
8
  | 1.1.x | :white_check_mark: |
8
9
  | < 1.1 | :x: |
9
10
 
@@ -3,12 +3,12 @@
3
3
  ## Getting Help
4
4
 
5
5
  - **Documentation**: Start with the [README](README.md) for usage examples and API reference.
6
- - **Issues**: Search [existing issues](https://github.com/MathiasPaulenko/enumpy/issues) to see if your question has already been answered.
7
- - **Discussions**: Use [GitHub Discussions](https://github.com/MathiasPaulenko/enumpy/discussions) for questions, ideas, and general discussion.
6
+ - **Issues**: Search [existing issues](https://github.com/MathiasPaulenko/enumplus/issues) to see if your question has already been answered.
7
+ - **Discussions**: Use [GitHub Discussions](https://github.com/MathiasPaulenko/enumplus/discussions) for questions, ideas, and general discussion.
8
8
 
9
9
  ## Reporting a Bug
10
10
 
11
- Open a [bug report issue](https://github.com/MathiasPaulenko/enumpy/issues/new?template=bug_report.md).
11
+ Open a [bug report issue](https://github.com/MathiasPaulenko/enumplus/issues/new?template=bug_report.md).
12
12
  Include:
13
13
 
14
14
  - Python version
@@ -18,7 +18,7 @@ Include:
18
18
 
19
19
  ## Requesting a Feature
20
20
 
21
- Open a [feature request issue](https://github.com/MathiasPaulenko/enumpy/issues/new?template=feature_request.md).
21
+ Open a [feature request issue](https://github.com/MathiasPaulenko/enumplus/issues/new?template=feature_request.md).
22
22
 
23
23
  ## Security Issues
24
24
 
@@ -4,9 +4,15 @@ Drop-in replacement for ``enum.Enum`` with labels, metadata, serialization,
4
4
  and Pydantic v2 integration.
5
5
  """
6
6
 
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as _pkg_version
9
+
7
10
  from enumplus.enum import Enum, OrderedEnum
8
11
  from enumplus.serialize import SerializableEncoder
9
12
 
10
- __version__ = "1.1.0"
13
+ try:
14
+ __version__ = _pkg_version("enumplus")
15
+ except PackageNotFoundError: # pragma: no cover
16
+ __version__ = "0.0.0.dev0"
11
17
 
12
18
  __all__ = ["Enum", "OrderedEnum", "SerializableEncoder", "__version__"]
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import enum
4
4
  import sys
5
+ from types import NotImplementedType
5
6
  from typing import Any, Self, TypeVar, dataclass_transform, overload
6
7
 
7
8
  if sys.version_info >= (3, 13):
@@ -39,6 +40,11 @@ class _EnumPlusDict(EnumDict):
39
40
 
40
41
  def __setitem__(self, key: str, value: Any) -> None:
41
42
  if key in self._CONFIG_KEYS:
43
+ if not isinstance(value, bool):
44
+ raise TypeError(
45
+ f"{key} is a reserved class config key and must be a bool, "
46
+ f"got {type(value).__name__}"
47
+ )
42
48
  self._class_config[key] = value
43
49
  # Store as a class attribute, but do not register as a member and
44
50
  # do not affect _last_values used by auto().
@@ -58,20 +64,21 @@ class _EnumPlusDict(EnumDict):
58
64
 
59
65
 
60
66
  def _safe_equal(a: Any, b: Any) -> bool:
61
- """Return ``a == b`` as a bool, swallowing shape/length comparison errors.
67
+ """Return ``a == b`` as a bool, swallowing comparison errors.
62
68
 
63
69
  Handles values whose ``__eq__`` may raise (e.g. NumPy arrays with shape
64
- mismatches) or return non-boolean objects (e.g. arrays, ``NotImplemented``).
70
+ mismatches, custom objects with side-effecting ``__eq__``) or return
71
+ non-boolean objects (e.g. arrays, ``NotImplemented``).
65
72
  """
66
73
  try:
67
74
  result = a == b
68
- except (TypeError, ValueError):
75
+ except Exception:
69
76
  return False
70
77
  if result is NotImplemented:
71
78
  return False
72
79
  try:
73
80
  return bool(result)
74
- except (TypeError, ValueError):
81
+ except Exception:
75
82
  return False
76
83
 
77
84
 
@@ -147,8 +154,8 @@ class EnumMeta(enum.EnumMeta):
147
154
  member._metadata_ = metadata
148
155
 
149
156
  label = metadata.get("label")
150
- if not label:
151
- member._label_ = member.name.title()
157
+ if label is None:
158
+ member._label_ = member.name.replace("_", " ").title()
152
159
  else:
153
160
  member._label_ = label
154
161
 
@@ -160,6 +167,13 @@ class EnumMeta(enum.EnumMeta):
160
167
  if isinstance(item, cls):
161
168
  return True
162
169
  member: Any
170
+ if isinstance(item, enum.Enum):
171
+ # Foreign enum member: only match if a member's value IS this
172
+ # exact enum member (identity), not just equal by value.
173
+ for member in cls:
174
+ if member.value is item:
175
+ return True
176
+ return False
163
177
  for member in cls:
164
178
  if _safe_equal(member.value, item):
165
179
  return True
@@ -185,7 +199,7 @@ class Enum(enum.Enum, metaclass=EnumMeta):
185
199
  - Pydantic v2 integration
186
200
  """
187
201
 
188
- _label_: str
202
+ _label_: Any
189
203
  _metadata_: dict[str, Any]
190
204
  _index_: int
191
205
 
@@ -194,7 +208,7 @@ class Enum(enum.Enum, metaclass=EnumMeta):
194
208
  """Human-readable label for this member.
195
209
 
196
210
  If the label was set to a callable, it is evaluated on every access.
197
- Falls back to ``name.title()`` when no label is set.
211
+ Falls back to ``name.replace("_", " ").title()`` when no label is set.
198
212
  """
199
213
  label = self._label_
200
214
  if callable(label):
@@ -272,6 +286,18 @@ class Enum(enum.Enum, metaclass=EnumMeta):
272
286
  ValueError: If no match is found and no default is provided.
273
287
  """
274
288
  member: Any
289
+ if isinstance(value, cls):
290
+ return value
291
+ if isinstance(value, enum.Enum):
292
+ # Foreign enum member: only match if a member's value IS this
293
+ # exact enum member (identity), not just equal by value.
294
+ for member in cls:
295
+ if member.value is value:
296
+ return member
297
+ if default is not _SENTINEL:
298
+ return default
299
+ raise ValueError(f"{value!r} is not a valid {cls.__name__} value")
300
+
275
301
  if case_insensitive and isinstance(value, str):
276
302
  lowered = value.lower()
277
303
  for member in cls:
@@ -336,6 +362,15 @@ class Enum(enum.Enum, metaclass=EnumMeta):
336
362
  @classmethod
337
363
  def is_valid(cls, value: Any) -> bool:
338
364
  """Return ``True`` if ``value`` is a valid member or member value."""
365
+ if isinstance(value, cls):
366
+ return True
367
+ if isinstance(value, enum.Enum):
368
+ # Foreign enum member: only match if a member's value IS this
369
+ # exact enum member (identity), not just equal by value.
370
+ for member in cls:
371
+ if member.value is value:
372
+ return True
373
+ return False
339
374
  for member in cls:
340
375
  if member is value or _safe_equal(member.value, value):
341
376
  return True
@@ -424,13 +459,17 @@ class Enum(enum.Enum, metaclass=EnumMeta):
424
459
  """Serialize the enum to a nested dict with ``value``, ``label``, ``metadata``."""
425
460
  result: dict[str, dict[str, Any]] = {}
426
461
  for member in cls:
462
+ label = member.label
463
+ metadata: dict[str, Any] = {}
464
+ for k, v in member._metadata_.items():
465
+ if k == "label" and callable(v):
466
+ metadata[k] = label
467
+ else:
468
+ metadata[k] = v
427
469
  result[member.name] = {
428
470
  "value": member.value,
429
- "label": member.label,
430
- "metadata": {
431
- k: (v() if callable(v) and k == "label" else v)
432
- for k, v in member._metadata_.items()
433
- },
471
+ "label": label,
472
+ "metadata": metadata,
434
473
  }
435
474
  return result
436
475
 
@@ -438,14 +477,18 @@ class Enum(enum.Enum, metaclass=EnumMeta):
438
477
  def filter(cls, **kwargs: Any) -> list[Self]:
439
478
  """Filter members by metadata key-value pairs (AND logic).
440
479
 
441
- With no kwargs, returns all members.
480
+ ``label`` is matched against the member's evaluated label, so callable
481
+ labels work too. With no kwargs, returns all members.
442
482
  """
443
483
  if not kwargs:
444
484
  return list(cls)
445
485
  result: list[Self] = []
446
486
  for member in cls:
447
487
  if all(
448
- key in member._metadata_ and _safe_equal(member._metadata_[key], value)
488
+ _safe_equal(member.label, value)
489
+ if key == "label"
490
+ else key in member._metadata_
491
+ and _safe_equal(member._metadata_[key], value)
449
492
  for key, value in kwargs.items()
450
493
  ):
451
494
  result.append(member)
@@ -486,22 +529,22 @@ class OrderedEnum(Enum):
486
529
  Also works with ``sorted()``, ``min()``, and ``max()``.
487
530
  """
488
531
 
489
- def __lt__(self, other: object) -> bool:
532
+ def __lt__(self, other: object) -> bool | NotImplementedType:
490
533
  if not isinstance(other, type(self)):
491
534
  return NotImplemented
492
535
  return self._index_ < other._index_
493
536
 
494
- def __le__(self, other: object) -> bool:
537
+ def __le__(self, other: object) -> bool | NotImplementedType:
495
538
  if not isinstance(other, type(self)):
496
539
  return NotImplemented
497
540
  return self._index_ <= other._index_
498
541
 
499
- def __gt__(self, other: object) -> bool:
542
+ def __gt__(self, other: object) -> bool | NotImplementedType:
500
543
  if not isinstance(other, type(self)):
501
544
  return NotImplemented
502
545
  return self._index_ > other._index_
503
546
 
504
- def __ge__(self, other: object) -> bool:
547
+ def __ge__(self, other: object) -> bool | NotImplementedType:
505
548
  if not isinstance(other, type(self)):
506
549
  return NotImplemented
507
550
  return self._index_ >= other._index_