composite-enum 0.1.0__tar.gz → 0.1.1__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.
- composite_enum-0.1.1/.github/workflows/ci.yml +53 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/PKG-INFO +48 -20
- {composite_enum-0.1.0 → composite_enum-0.1.1}/README.md +47 -19
- {composite_enum-0.1.0 → composite_enum-0.1.1}/composite_enum/_meta.pyi +4 -1
- {composite_enum-0.1.0 → composite_enum-0.1.1}/pyproject.toml +7 -2
- composite_enum-0.1.1/tests/test_typecheck.py +1314 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/uv.lock +28 -2
- composite_enum-0.1.0/.github/workflows/ci.yml +0 -23
- {composite_enum-0.1.0 → composite_enum-0.1.1}/.github/workflows/publish.yml +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/.gitignore +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/CHANGELOG.md +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/LICENSE +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/composite_enum/__init__.py +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/composite_enum/_meta.py +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/composite_enum/py.typed +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/tests/__init__.py +0 -0
- {composite_enum-0.1.0 → composite_enum-0.1.1}/tests/test_composite.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
15
|
+
continue-on-error: ${{ matrix.python-version == '3.15' }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v7.0.1
|
|
18
|
+
- uses: actions/setup-python@v7
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
allow-prereleases: true
|
|
22
|
+
- run: pip install -e . && pip install pytest pytest-xdist
|
|
23
|
+
- run: pytest tests/test_composite.py -v -n auto
|
|
24
|
+
|
|
25
|
+
typecheck:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
strategy:
|
|
28
|
+
matrix:
|
|
29
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
|
|
30
|
+
continue-on-error: ${{ matrix.python-version == '3.15' }}
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v7.0.1
|
|
33
|
+
- uses: actions/setup-python@v7
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ matrix.python-version }}
|
|
36
|
+
allow-prereleases: true
|
|
37
|
+
- run: pip install -e . && pip install pytest pytest-xdist pyright mypy basedpyright pyrefly ty
|
|
38
|
+
- run: pytest tests/test_typecheck.py -v -n auto
|
|
39
|
+
|
|
40
|
+
tripwire:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
continue-on-error: true
|
|
43
|
+
strategy:
|
|
44
|
+
matrix:
|
|
45
|
+
python-version: ["3.12", "3.13", "3.14", "3.15"]
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v7.0.1
|
|
48
|
+
- uses: actions/setup-python@v7
|
|
49
|
+
with:
|
|
50
|
+
python-version: ${{ matrix.python-version }}
|
|
51
|
+
allow-prereleases: true
|
|
52
|
+
- run: pip install -e . && pip install pytest
|
|
53
|
+
- run: pytest tests/test_typecheck.py -v -m tripwire
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: composite-enum
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Build superset enums by composing members from other enums
|
|
5
5
|
Project-URL: Homepage, https://github.com/isaacfuenmayora/composite-enum
|
|
6
6
|
Project-URL: Repository, https://github.com/isaacfuenmayora/composite-enum
|
|
@@ -184,25 +184,9 @@ class Signal(float, Enum, metaclass=CompositeEnumMeta, includes=Voltage):
|
|
|
184
184
|
isinstance(Signal.LOW, float) # True
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
> **Note:**
|
|
188
|
-
>
|
|
189
|
-
>
|
|
190
|
-
> 1. They may flag the `includes` keyword, since they don't infer class
|
|
191
|
-
> keywords from metaclass signatures. Add `# type: ignore[call-arg]`
|
|
192
|
-
> to suppress this.
|
|
193
|
-
> 2. The instance-level attributes `source_enum` and `to_source()` won't
|
|
194
|
-
> be visible to type checkers, because the `.pyi` stub declares these
|
|
195
|
-
> on `CompositeEnum`, not on arbitrary metaclass-created classes. The
|
|
196
|
-
> class-level methods (`from_source()`, `members_from()`,
|
|
197
|
-
> `included_enums()`, `includes_enum()`) work fine on both paths since
|
|
198
|
-
> they're declared on the metaclass. Subclassing `CompositeEnum` is the
|
|
199
|
-
> type-checker-friendly path: `from_source()` narrows to `Self | None`
|
|
200
|
-
> and `members_from()` to `frozenset[Self]`.
|
|
201
|
-
>
|
|
202
|
-
> Both work correctly at runtime regardless. Note that type checkers
|
|
203
|
-
> cannot resolve dynamically injected member names (e.g.
|
|
204
|
-
> `TokenType.UNION`) on either path. This is a general limitation of
|
|
205
|
-
> enum metaclasses, not specific to `composite-enum`.
|
|
187
|
+
> **Note:** The metaclass-only path has type checker limitations.
|
|
188
|
+
> See [Type Checker Compatibility](#type-checker-compatibility) below.
|
|
189
|
+
> Subclassing `CompositeEnum` is the type-checker-friendly path.
|
|
206
190
|
|
|
207
191
|
### Nested composition
|
|
208
192
|
|
|
@@ -308,6 +292,50 @@ Source enums (the ones in `includes`) can be any `Enum`, `StrEnum`, or
|
|
|
308
292
|
| `StrEnum` / `str, Enum` | Must be `str` |
|
|
309
293
|
| `IntEnum` / `int, Enum` | Must be `int` |
|
|
310
294
|
|
|
295
|
+
## Type Checker Compatibility
|
|
296
|
+
|
|
297
|
+
`composite-enum` ships a hand-written `.pyi` stub with a `__getattr__`
|
|
298
|
+
on the metaclass so that type checkers see dynamically injected members
|
|
299
|
+
(e.g. `TokenType.UNION`) typed as the correct enum subclass. The
|
|
300
|
+
trade-off is typos like `TokenType.TYPO` are silently accepted. Any
|
|
301
|
+
attribute access is allowed.
|
|
302
|
+
|
|
303
|
+
The tables below summarize how each type checker behaves with the
|
|
304
|
+
`_meta.pyi` stub. All features work correctly at runtime regardless.
|
|
305
|
+
The pyrefly column reflects `strict` mode, its default `basic` preset
|
|
306
|
+
silently accepts everything.
|
|
307
|
+
|
|
308
|
+
### `CompositeEnum` base class
|
|
309
|
+
|
|
310
|
+
| Feature | pyright | mypy | basedpyright | pyrefly | ty | pyre | pytype |
|
|
311
|
+
|---|---|---|---|---|---|---|---|
|
|
312
|
+
| `includes=` keyword | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
313
|
+
| Injected member typed correctly | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅¹ |
|
|
314
|
+
| Typo detection (`TT.NOPE`) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
|
315
|
+
| `source_enum` / `to_source()` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅¹ |
|
|
316
|
+
| `from_source()` → `Self` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌¹ |
|
|
317
|
+
| `members_from()` → `frozenset[Self]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌¹ |
|
|
318
|
+
| `includes=42` rejected | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
|
319
|
+
|
|
320
|
+
### `metaclass=CompositeEnumMeta` (IntEnum, StrEnum, etc.)
|
|
321
|
+
|
|
322
|
+
| Feature | pyright | mypy | basedpyright | pyrefly | ty | pyre | pytype |
|
|
323
|
+
|---|---|---|---|---|---|---|---|
|
|
324
|
+
| `includes=` keyword | ✅ | ✅ | ✅ | ✅ | ✅ | ❌² | ✅ |
|
|
325
|
+
| Injected member typed correctly | ✅ | ✅ | ✅ | ✅ | ✅ | —² | ✅¹ |
|
|
326
|
+
| Typo detection (`TT.NOPE`) | ❌ | ❌ | ❌ | ❌ | ❌ | —² | ❌ |
|
|
327
|
+
| `source_enum` / `to_source()` | ❌³ | ❌³ | ❌³ | ❌³ | ❌³ | —² | ❌³ |
|
|
328
|
+
| `from_source()` → `Enum`⁴ | ✅ | ✅ | ✅ | ✅ | ✅ | —² | ❌¹ |
|
|
329
|
+
| `members_from()` → `frozenset[Enum]`⁴ | ✅ | ✅ | ✅ | ✅ | ✅ | —² | ❌¹ |
|
|
330
|
+
| `includes=42` rejected | ❌ | ❌ | ❌ | ❌ | ✅ | —² | ❌ |
|
|
331
|
+
|
|
332
|
+
**Notes:**
|
|
333
|
+
|
|
334
|
+
1. pytype resolves composite classes to `Any` via the metaclass, so access succeeds vacuously with no real type narrowing.
|
|
335
|
+
2. Pyre checks `__init_subclass__`, not the metaclass `__new__`, so it rejects the `includes=` keyword on metaclass-only classes. Suppress with `# pyre-ignore[28]` on the class definition.
|
|
336
|
+
3. `source_enum` and `to_source()` are injected at runtime by `CompositeEnumMeta.__new__` and work on both paths. The `.pyi` stub declares them on `CompositeEnum` only, so type checkers can't see them on the metaclass-only path. Suppress with `# type: ignore[attr-defined]` (mypy) or `# pyright: ignore[reportAttributeAccessIssue]`.
|
|
337
|
+
4. On the `CompositeEnum` path, `from_source()` returns `Self | None` and `members_from()` returns `frozenset[Self]`. On the metaclass path they return `Enum | None` and `frozenset[Enum]` instead . The return type is the base `Enum` rather than the concrete subclass. Cast or `# type: ignore[assignment]` when assigning to the concrete type.
|
|
338
|
+
|
|
311
339
|
## Caveats
|
|
312
340
|
|
|
313
341
|
**Implementation detail dependency.** The metaclass injects members via
|
|
@@ -159,25 +159,9 @@ class Signal(float, Enum, metaclass=CompositeEnumMeta, includes=Voltage):
|
|
|
159
159
|
isinstance(Signal.LOW, float) # True
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
> **Note:**
|
|
163
|
-
>
|
|
164
|
-
>
|
|
165
|
-
> 1. They may flag the `includes` keyword, since they don't infer class
|
|
166
|
-
> keywords from metaclass signatures. Add `# type: ignore[call-arg]`
|
|
167
|
-
> to suppress this.
|
|
168
|
-
> 2. The instance-level attributes `source_enum` and `to_source()` won't
|
|
169
|
-
> be visible to type checkers, because the `.pyi` stub declares these
|
|
170
|
-
> on `CompositeEnum`, not on arbitrary metaclass-created classes. The
|
|
171
|
-
> class-level methods (`from_source()`, `members_from()`,
|
|
172
|
-
> `included_enums()`, `includes_enum()`) work fine on both paths since
|
|
173
|
-
> they're declared on the metaclass. Subclassing `CompositeEnum` is the
|
|
174
|
-
> type-checker-friendly path: `from_source()` narrows to `Self | None`
|
|
175
|
-
> and `members_from()` to `frozenset[Self]`.
|
|
176
|
-
>
|
|
177
|
-
> Both work correctly at runtime regardless. Note that type checkers
|
|
178
|
-
> cannot resolve dynamically injected member names (e.g.
|
|
179
|
-
> `TokenType.UNION`) on either path. This is a general limitation of
|
|
180
|
-
> enum metaclasses, not specific to `composite-enum`.
|
|
162
|
+
> **Note:** The metaclass-only path has type checker limitations.
|
|
163
|
+
> See [Type Checker Compatibility](#type-checker-compatibility) below.
|
|
164
|
+
> Subclassing `CompositeEnum` is the type-checker-friendly path.
|
|
181
165
|
|
|
182
166
|
### Nested composition
|
|
183
167
|
|
|
@@ -283,6 +267,50 @@ Source enums (the ones in `includes`) can be any `Enum`, `StrEnum`, or
|
|
|
283
267
|
| `StrEnum` / `str, Enum` | Must be `str` |
|
|
284
268
|
| `IntEnum` / `int, Enum` | Must be `int` |
|
|
285
269
|
|
|
270
|
+
## Type Checker Compatibility
|
|
271
|
+
|
|
272
|
+
`composite-enum` ships a hand-written `.pyi` stub with a `__getattr__`
|
|
273
|
+
on the metaclass so that type checkers see dynamically injected members
|
|
274
|
+
(e.g. `TokenType.UNION`) typed as the correct enum subclass. The
|
|
275
|
+
trade-off is typos like `TokenType.TYPO` are silently accepted. Any
|
|
276
|
+
attribute access is allowed.
|
|
277
|
+
|
|
278
|
+
The tables below summarize how each type checker behaves with the
|
|
279
|
+
`_meta.pyi` stub. All features work correctly at runtime regardless.
|
|
280
|
+
The pyrefly column reflects `strict` mode, its default `basic` preset
|
|
281
|
+
silently accepts everything.
|
|
282
|
+
|
|
283
|
+
### `CompositeEnum` base class
|
|
284
|
+
|
|
285
|
+
| Feature | pyright | mypy | basedpyright | pyrefly | ty | pyre | pytype |
|
|
286
|
+
|---|---|---|---|---|---|---|---|
|
|
287
|
+
| `includes=` keyword | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
288
|
+
| Injected member typed correctly | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅¹ |
|
|
289
|
+
| Typo detection (`TT.NOPE`) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
|
290
|
+
| `source_enum` / `to_source()` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅¹ |
|
|
291
|
+
| `from_source()` → `Self` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌¹ |
|
|
292
|
+
| `members_from()` → `frozenset[Self]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌¹ |
|
|
293
|
+
| `includes=42` rejected | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
|
294
|
+
|
|
295
|
+
### `metaclass=CompositeEnumMeta` (IntEnum, StrEnum, etc.)
|
|
296
|
+
|
|
297
|
+
| Feature | pyright | mypy | basedpyright | pyrefly | ty | pyre | pytype |
|
|
298
|
+
|---|---|---|---|---|---|---|---|
|
|
299
|
+
| `includes=` keyword | ✅ | ✅ | ✅ | ✅ | ✅ | ❌² | ✅ |
|
|
300
|
+
| Injected member typed correctly | ✅ | ✅ | ✅ | ✅ | ✅ | —² | ✅¹ |
|
|
301
|
+
| Typo detection (`TT.NOPE`) | ❌ | ❌ | ❌ | ❌ | ❌ | —² | ❌ |
|
|
302
|
+
| `source_enum` / `to_source()` | ❌³ | ❌³ | ❌³ | ❌³ | ❌³ | —² | ❌³ |
|
|
303
|
+
| `from_source()` → `Enum`⁴ | ✅ | ✅ | ✅ | ✅ | ✅ | —² | ❌¹ |
|
|
304
|
+
| `members_from()` → `frozenset[Enum]`⁴ | ✅ | ✅ | ✅ | ✅ | ✅ | —² | ❌¹ |
|
|
305
|
+
| `includes=42` rejected | ❌ | ❌ | ❌ | ❌ | ✅ | —² | ❌ |
|
|
306
|
+
|
|
307
|
+
**Notes:**
|
|
308
|
+
|
|
309
|
+
1. pytype resolves composite classes to `Any` via the metaclass, so access succeeds vacuously with no real type narrowing.
|
|
310
|
+
2. Pyre checks `__init_subclass__`, not the metaclass `__new__`, so it rejects the `includes=` keyword on metaclass-only classes. Suppress with `# pyre-ignore[28]` on the class definition.
|
|
311
|
+
3. `source_enum` and `to_source()` are injected at runtime by `CompositeEnumMeta.__new__` and work on both paths. The `.pyi` stub declares them on `CompositeEnum` only, so type checkers can't see them on the metaclass-only path. Suppress with `# type: ignore[attr-defined]` (mypy) or `# pyright: ignore[reportAttributeAccessIssue]`.
|
|
312
|
+
4. On the `CompositeEnum` path, `from_source()` returns `Self | None` and `members_from()` returns `frozenset[Self]`. On the metaclass path they return `Enum | None` and `frozenset[Enum]` instead . The return type is the base `Enum` rather than the concrete subclass. Cast or `# type: ignore[assignment]` when assigning to the concrete type.
|
|
313
|
+
|
|
286
314
|
## Caveats
|
|
287
315
|
|
|
288
316
|
**Implementation detail dependency.** The metaclass injects members via
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from collections.abc import Mapping, Sequence
|
|
2
2
|
from enum import Enum, EnumMeta
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
4
|
|
|
5
5
|
from typing_extensions import Self
|
|
6
6
|
|
|
7
|
+
_T = TypeVar("_T", bound=Enum)
|
|
8
|
+
|
|
7
9
|
class CompositeEnumMeta(EnumMeta):
|
|
8
10
|
_composite_source_map_: Mapping[str, type[Enum]]
|
|
9
11
|
_composite_includes_: tuple[type[Enum], ...]
|
|
@@ -16,6 +18,7 @@ class CompositeEnumMeta(EnumMeta):
|
|
|
16
18
|
includes: type[Enum] | Sequence[type[Enum]] = (),
|
|
17
19
|
**kwds: Any,
|
|
18
20
|
) -> CompositeEnumMeta: ...
|
|
21
|
+
def __getattr__(cls: type[_T], name: str) -> _T: ... # type: ignore
|
|
19
22
|
def included_enums(cls) -> tuple[type[Enum], ...]: ...
|
|
20
23
|
def includes_enum(cls, source: type[Enum]) -> bool: ...
|
|
21
24
|
def members_from(cls, source: type[Enum]) -> frozenset[Enum]: ...
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "composite-enum"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Build superset enums by composing members from other enums"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -28,7 +28,10 @@ classifiers = [
|
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
[dependency-groups]
|
|
31
|
-
dev = [
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest >= 9.1",
|
|
33
|
+
"pytest-xdist>=3.8.0",
|
|
34
|
+
]
|
|
32
35
|
|
|
33
36
|
[project.urls]
|
|
34
37
|
Homepage = "https://github.com/isaacfuenmayora/composite-enum"
|
|
@@ -37,6 +40,8 @@ Issues = "https://github.com/isaacfuenmayora/composite-enum/issues"
|
|
|
37
40
|
|
|
38
41
|
[tool.pytest.ini_options]
|
|
39
42
|
testpaths = ["tests"]
|
|
43
|
+
addopts = "-m 'not tripwire'"
|
|
44
|
+
markers = ["tripwire: tests that detect external tool changes (non-blocking)"]
|
|
40
45
|
|
|
41
46
|
[tool.ruff]
|
|
42
47
|
target-version = "py310"
|