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.
@@ -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.0
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:** Type checkers have two limitations with the
188
- > `metaclass=CompositeEnumMeta` approach:
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:** Type checkers have two limitations with the
163
- > `metaclass=CompositeEnumMeta` approach:
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.0"
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 = ["pytest >= 8.0"]
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"