code-is-magic-markers 0.2.0__tar.gz → 0.3.0__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 (31) hide show
  1. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/.github/workflows/ci.yml +9 -9
  2. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/.github/workflows/release.yml +10 -10
  3. code_is_magic_markers-0.3.0/CHANGELOG.md +61 -0
  4. code_is_magic_markers-0.3.0/CONTRIBUTING.md +105 -0
  5. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/PKG-INFO +76 -1
  6. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/README.md +75 -0
  7. code_is_magic_markers-0.3.0/examples/type_check_example.py +181 -0
  8. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/pyproject.toml +1 -1
  9. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/__init__.py +16 -0
  10. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/_types.py +9 -3
  11. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/descriptors.py +23 -1
  12. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/groups.py +26 -12
  13. code_is_magic_markers-0.3.0/src/markers/groups.pyi +19 -0
  14. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/marker.py +16 -3
  15. code_is_magic_markers-0.3.0/src/markers/marker.pyi +30 -0
  16. code_is_magic_markers-0.2.0/CHANGELOG.md +0 -28
  17. code_is_magic_markers-0.2.0/CONTRIBUTING.md +0 -75
  18. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/.github/pull_request_template.md +0 -0
  19. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/.gitignore +0 -0
  20. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/LICENSE +0 -0
  21. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/Makefile +0 -0
  22. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/examples/example.py +0 -0
  23. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/examples/real_usage.py +0 -0
  24. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/core.py +0 -0
  25. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/py.typed +0 -0
  26. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/src/markers/registry.py +0 -0
  27. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/tests/__init__.py +0 -0
  28. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/tests/test_examples.py +0 -0
  29. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/tests/test_markers.py +0 -0
  30. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/tox.ini +0 -0
  31. {code_is_magic_markers-0.2.0 → code_is_magic_markers-0.3.0}/uv.lock +0 -0
@@ -17,9 +17,9 @@ jobs:
17
17
  name: Lint
18
18
  runs-on: ubuntu-latest
19
19
  steps:
20
- - uses: actions/checkout@v5
20
+ - uses: actions/checkout@v5.0.1
21
21
 
22
- - uses: astral-sh/setup-uv@v4
22
+ - uses: astral-sh/setup-uv@v8.0.0
23
23
  with:
24
24
  python-version: "3.14"
25
25
 
@@ -36,9 +36,9 @@ jobs:
36
36
  name: Type check
37
37
  runs-on: ubuntu-latest
38
38
  steps:
39
- - uses: actions/checkout@v5
39
+ - uses: actions/checkout@v5.0.1
40
40
 
41
- - uses: astral-sh/setup-uv@v4
41
+ - uses: astral-sh/setup-uv@v8.0.0
42
42
  with:
43
43
  python-version: "3.14"
44
44
 
@@ -56,9 +56,9 @@ jobs:
56
56
  matrix:
57
57
  python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
58
58
  steps:
59
- - uses: actions/checkout@v5
59
+ - uses: actions/checkout@v5.0.1
60
60
 
61
- - uses: astral-sh/setup-uv@v4
61
+ - uses: astral-sh/setup-uv@v8.0.0
62
62
  with:
63
63
  python-version: ${{ matrix.python-version }}
64
64
 
@@ -73,9 +73,9 @@ jobs:
73
73
  runs-on: ubuntu-latest
74
74
  needs: [test]
75
75
  steps:
76
- - uses: actions/checkout@v5
76
+ - uses: actions/checkout@v5.0.1
77
77
 
78
- - uses: astral-sh/setup-uv@v4
78
+ - uses: astral-sh/setup-uv@v8.0.0
79
79
  with:
80
80
  python-version: "3.14"
81
81
 
@@ -86,7 +86,7 @@ jobs:
86
86
  run: uv run pytest tests/ --cov=markers --cov-report=xml --cov-report=term-missing
87
87
 
88
88
  - name: Upload coverage
89
- uses: codecov/codecov-action@v5
89
+ uses: codecov/codecov-action@v6.0.0
90
90
  with:
91
91
  file: coverage.xml
92
92
  fail_ci_if_error: false
@@ -14,9 +14,9 @@ jobs:
14
14
  name: Pre-release checks
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
- - uses: actions/checkout@v5
17
+ - uses: actions/checkout@v5.0.1
18
18
 
19
- - uses: astral-sh/setup-uv@v4
19
+ - uses: astral-sh/setup-uv@v8.0.0
20
20
 
21
21
  - name: Install dependencies
22
22
  run: uv sync --extra dev
@@ -35,15 +35,15 @@ jobs:
35
35
  runs-on: ubuntu-latest
36
36
  needs: [check]
37
37
  steps:
38
- - uses: actions/checkout@v5
38
+ - uses: actions/checkout@v5.0.1
39
39
 
40
- - uses: astral-sh/setup-uv@v4
40
+ - uses: astral-sh/setup-uv@v8.0.0
41
41
 
42
42
  - name: Build sdist and wheel
43
43
  run: uv build
44
44
 
45
45
  - name: Upload build artifacts
46
- uses: actions/upload-artifact@v5
46
+ uses: actions/upload-artifact@v7.0.1
47
47
  with:
48
48
  name: dist
49
49
  path: dist/
@@ -54,7 +54,7 @@ jobs:
54
54
  needs: [build]
55
55
  environment: testpypi
56
56
  steps:
57
- - uses: actions/download-artifact@v5
57
+ - uses: actions/download-artifact@v8.0.1
58
58
  with:
59
59
  name: dist
60
60
  path: dist/
@@ -70,7 +70,7 @@ jobs:
70
70
  needs: [publish-testpypi]
71
71
  environment: pypi
72
72
  steps:
73
- - uses: actions/download-artifact@v5
73
+ - uses: actions/download-artifact@v8.0.1
74
74
  with:
75
75
  name: dist
76
76
  path: dist/
@@ -83,15 +83,15 @@ jobs:
83
83
  runs-on: ubuntu-latest
84
84
  needs: [publish-pypi]
85
85
  steps:
86
- - uses: actions/checkout@v5
86
+ - uses: actions/checkout@v5.0.1
87
87
 
88
- - uses: actions/download-artifact@v5
88
+ - uses: actions/download-artifact@v8.0.1
89
89
  with:
90
90
  name: dist
91
91
  path: dist/
92
92
 
93
93
  - name: Create GitHub Release
94
- uses: softprops/action-gh-release@v2
94
+ uses: softprops/action-gh-release@v3.0.0
95
95
  with:
96
96
  files: dist/*
97
97
  generate_release_notes: true
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [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
+ ## [0.3.0] - 2026-04-13
9
+
10
+ ### Added
11
+
12
+ - PEP 681 `dataclass_transform` support — marker constructor parameters (`MaxLen(limit=100)`) are fully validated by type checkers (Pylance, Pyright, mypy). Typos, wrong types, and missing required params are caught statically.
13
+ - Type stubs (`marker.pyi`, `groups.pyi`) for full IDE integration
14
+ - `BaseMixinMeta` metaclass for generated mixin classes
15
+ - `.fields`, `.methods`, `.members` are now fully typed as `dict[str, MemberInfo]` on any class using a group mixin
16
+ - Decorator usage (`@OnSave(priority=10)`) preserves the decorated function's type signature
17
+ - Type checking example (`examples/type_check_example.py`) demonstrating all typed patterns
18
+ - "Type checking" section in README documenting what's typed and escape hatches for dynamic descriptors
19
+
20
+ ### Changed
21
+
22
+ - `MarkerInstance.__call__` signature uses `TypeVar` to preserve decorated function types
23
+ - `MarkerGroupMeta.__new__` return type narrowed to `MarkerGroupMeta` (was `type`)
24
+ - `MarkerGroup.mixin` typed as `type[BaseMixin]` (was `type`)
25
+ - Generated mixin classes now use `BaseMixinMeta` metaclass (was `type`)
26
+ - Removed `from __future__ import annotations` from `groups.py` for better type checker resolution
27
+
28
+ ## [0.2.0] - 2026-04-13
29
+
30
+ ### Added
31
+
32
+ - Python 3.14 support (PEP 749 deferred annotations)
33
+ - Python 3.14 in CI test matrix, tox env list, and pyproject.toml classifiers
34
+
35
+ ### Changed
36
+
37
+ - `MarkerMeta.__new__` restructured to read annotations from the created class rather than the namespace dict, for compatibility with Python 3.14.4+ where `__annotations__` is no longer populated in the namespace
38
+ - `Collector.collect` uses `annotationlib.get_annotations(format=STRING)` on Python 3.14+ instead of reading `__annotations__` directly
39
+ - CI lint, typecheck, and coverage jobs now default to Python 3.14
40
+
41
+ ## [0.1.1] - 2026-04-13
42
+
43
+ ### Changed
44
+
45
+ - Expanded README with motivation, detailed guides for all concepts, caching/invalidation docs, reusable mixins pattern, and complete API reference tables
46
+
47
+ ## [0.1.0] - 2025-04-12
48
+
49
+ ### Added
50
+
51
+ - `Marker` base class — subclass to define markers with optional pydantic schema
52
+ - `MarkerGroup` — bundle related markers into a `.mixin` for clean inheritance
53
+ - `Registry` — track subclasses with `.subclasses()` and `.all` aggregation
54
+ - `MarkerInstance` — validated marker usage, works as both `Annotated[]` metadata and method decorator
55
+ - `MemberInfo` — unified metadata for fields and methods with `has`/`get`/`get_all` queries
56
+ - MRO-walking `Collector` with weakref-based per-class caching
57
+ - Intermediate marker base classes for shared schema fields
58
+ - `AllProxy` for cross-subclass collection via `Registry.all`
59
+ - PEP 561 `py.typed` marker
60
+ - 70 tests covering all features
61
+ - ORM-style real-world usage example with SQL generation
@@ -0,0 +1,105 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ git clone https://github.com/Richard-Lynch/markers.git
7
+ cd markers
8
+ make venv
9
+ make deps
10
+ ```
11
+
12
+ ## Development workflow
13
+
14
+ ### Run tests
15
+
16
+ ```bash
17
+ # Quick run
18
+ make test
19
+
20
+ # With coverage
21
+ make test:cov
22
+
23
+ # Full matrix (Python 3.10–3.14)
24
+ tox
25
+ ```
26
+
27
+ ### Lint and format
28
+
29
+ ```bash
30
+ # Check
31
+ make lint
32
+
33
+ # Auto-fix
34
+ make format
35
+ ```
36
+
37
+ ### Type check
38
+
39
+ ```bash
40
+ make typecheck
41
+ ```
42
+
43
+ ### Run all checks
44
+
45
+ ```bash
46
+ make check
47
+ ```
48
+
49
+ ## Versioning
50
+
51
+ This project follows [Semantic Versioning](https://semver.org/):
52
+
53
+ - **Patch** (0.1.x): Bug fixes, documentation, CI changes
54
+ - **Minor** (0.x.0): New features, new Python version support, backwards-compatible changes
55
+ - **Major** (x.0.0): Breaking API changes
56
+
57
+ ## Release process
58
+
59
+ `main` is protected — all changes go through pull requests.
60
+
61
+ 1. Create a release branch: `git checkout -b release/v0.x.y`
62
+ 2. Update version in `pyproject.toml`
63
+ 3. Update `CHANGELOG.md`
64
+ 4. Open a PR, get CI green, merge
65
+ 5. Tag main: `git checkout main && git pull && git tag v0.x.y && git push origin v0.x.y`
66
+
67
+ The GitHub Actions release workflow will automatically:
68
+ - Run all checks
69
+ - Build sdist + wheel
70
+ - Publish to TestPyPI
71
+ - Publish to PyPI
72
+ - Create a GitHub Release with auto-generated notes
73
+
74
+ ## Project structure
75
+
76
+ ```
77
+ src/markers/
78
+ ├── __init__.py # Public API exports
79
+ ├── _types.py # MarkerInstance, MemberInfo, MemberKind, MISSING
80
+ ├── core.py # Collector — MRO walking + caching
81
+ ├── descriptors.py # BaseMixin, BaseMixinMeta + descriptor classes
82
+ ├── marker.py # Marker + MarkerMeta
83
+ ├── marker.pyi # Type stub — dataclass_transform for marker kwargs
84
+ ├── groups.py # MarkerGroup + MarkerGroupMeta
85
+ ├── groups.pyi # Type stub — mixin base class typing
86
+ ├── registry.py # Registry + AllProxy
87
+ └── py.typed # PEP 561 marker
88
+ ```
89
+
90
+ ## Type stubs
91
+
92
+ The library uses `.pyi` stubs for `marker.py` and `groups.py` to provide
93
+ type information that can't be expressed inline:
94
+
95
+ - **`marker.pyi`** — Uses PEP 681 `dataclass_transform` on `MarkerMeta` so
96
+ type checkers validate marker constructor kwargs against the schema
97
+ annotations on each `Marker` subclass. Also exposes `__call__` and
98
+ `__getattr__` on `Marker` instances for decorator and attribute access.
99
+
100
+ - **`groups.pyi`** — Declares `MarkerGroup.mixin = BaseMixin` so type checkers
101
+ accept `class User(DB.mixin):` as valid and know the class inherits
102
+ `BaseMixin`'s typed `.fields`, `.methods`, `.members` descriptors.
103
+
104
+ When modifying `marker.py` or `groups.py`, check that the corresponding
105
+ `.pyi` stub stays in sync.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-is-magic-markers
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Lightweight class introspection toolkit — define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors.
5
5
  Project-URL: Homepage, https://github.com/Richard-Lynch/markers
6
6
  Project-URL: Repository, https://github.com/Richard-Lynch/markers
@@ -372,6 +372,81 @@ inst.analyzer # 'english'
372
372
  | `inst.marker_name` | `str` | The marker type name |
373
373
  | `inst.<field>` | `Any` | Access any schema field as an attribute |
374
374
 
375
+ ## Type checking
376
+
377
+ The library ships with PEP 561 `py.typed` support and type stubs for full IDE integration (Pylance, Pyright, mypy).
378
+
379
+ ### What's typed automatically
380
+
381
+ **Marker constructors** — parameters are fully validated by type checkers via [PEP 681 `dataclass_transform`](https://peps.python.org/pep-0681/). Typos, wrong types, and missing required params are all caught:
382
+
383
+ ```python
384
+ class MaxLen(Marker):
385
+ mark = "max_length"
386
+ limit: int
387
+
388
+ MaxLen(limit=100) # ok
389
+ MaxLen(limit="oops") # type error: str is not int
390
+ MaxLen() # type error: missing 'limit'
391
+ MaxLen(limti=100) # type error: no param 'limti'
392
+ ```
393
+
394
+ **BaseMixin descriptors** — `.fields`, `.methods`, `.members` are typed as `dict[str, MemberInfo]` on any class using a group mixin. Full dict operations (`.items()`, `.keys()`, `.values()`, indexing) work without casts:
395
+
396
+ ```python
397
+ class User(DB.mixin):
398
+ id: Annotated[int, DB.PrimaryKey()]
399
+
400
+ for name, info in User.fields.items(): # (str, MemberInfo) — fully typed
401
+ print(info.is_field, info.has("primary_key"))
402
+ ```
403
+
404
+ **Decorator signatures** — `@OnSave(priority=10)` preserves the decorated function's type. Type checkers see the original return type, not `MarkerInstance`:
405
+
406
+ ```python
407
+ @Lifecycle.OnSave(priority=10)
408
+ def validate(self) -> list[str]: ...
409
+
410
+ reveal_type(User().validate()) # list[str]
411
+ ```
412
+
413
+ **Registry `.all` proxy** — `.all.fields`, `.all.methods`, `.all.members`, and `.all.<marker_name>` are typed as `dict[str, list[MemberInfo]]`.
414
+
415
+ **`Marker.collect()`** — always returns `dict[str, MemberInfo]`, fully typed:
416
+
417
+ ```python
418
+ PrimaryKey.collect(User) # dict[str, MemberInfo] — no type: ignore needed
419
+ ```
420
+
421
+ ### What's not typed automatically
422
+
423
+ **Marker-specific descriptors** like `.primary_key`, `.required`, `.indexed` are added dynamically by `MarkerGroupMeta` and are not visible to type checkers. Two options:
424
+
425
+ **Option A** — Use `Marker.collect()` (no annotations needed):
426
+
427
+ ```python
428
+ # Instead of User.primary_key, use:
429
+ PrimaryKey.collect(User) # fully typed: dict[str, MemberInfo]
430
+ ```
431
+
432
+ **Option B** — Add explicit `ClassVar` annotations (opt-in per class):
433
+
434
+ ```python
435
+ from typing import TYPE_CHECKING, ClassVar
436
+
437
+ class User(DB.mixin):
438
+ if TYPE_CHECKING:
439
+ primary_key: ClassVar[dict[str, MemberInfo]]
440
+ indexed: ClassVar[dict[str, MemberInfo]]
441
+
442
+ id: Annotated[int, DB.PrimaryKey()]
443
+ email: Annotated[str, DB.Indexed(unique=True)]
444
+
445
+ User.primary_key # now typed as dict[str, MemberInfo]
446
+ ```
447
+
448
+ The `TYPE_CHECKING` guard ensures these annotations don't affect runtime behavior — the actual values come from the dynamically-installed `MarkerDescriptor` instances.
449
+
375
450
  ## How collection works
376
451
 
377
452
  When you access a descriptor like `User.fields` or `User.required`, the library:
@@ -340,6 +340,81 @@ inst.analyzer # 'english'
340
340
  | `inst.marker_name` | `str` | The marker type name |
341
341
  | `inst.<field>` | `Any` | Access any schema field as an attribute |
342
342
 
343
+ ## Type checking
344
+
345
+ The library ships with PEP 561 `py.typed` support and type stubs for full IDE integration (Pylance, Pyright, mypy).
346
+
347
+ ### What's typed automatically
348
+
349
+ **Marker constructors** — parameters are fully validated by type checkers via [PEP 681 `dataclass_transform`](https://peps.python.org/pep-0681/). Typos, wrong types, and missing required params are all caught:
350
+
351
+ ```python
352
+ class MaxLen(Marker):
353
+ mark = "max_length"
354
+ limit: int
355
+
356
+ MaxLen(limit=100) # ok
357
+ MaxLen(limit="oops") # type error: str is not int
358
+ MaxLen() # type error: missing 'limit'
359
+ MaxLen(limti=100) # type error: no param 'limti'
360
+ ```
361
+
362
+ **BaseMixin descriptors** — `.fields`, `.methods`, `.members` are typed as `dict[str, MemberInfo]` on any class using a group mixin. Full dict operations (`.items()`, `.keys()`, `.values()`, indexing) work without casts:
363
+
364
+ ```python
365
+ class User(DB.mixin):
366
+ id: Annotated[int, DB.PrimaryKey()]
367
+
368
+ for name, info in User.fields.items(): # (str, MemberInfo) — fully typed
369
+ print(info.is_field, info.has("primary_key"))
370
+ ```
371
+
372
+ **Decorator signatures** — `@OnSave(priority=10)` preserves the decorated function's type. Type checkers see the original return type, not `MarkerInstance`:
373
+
374
+ ```python
375
+ @Lifecycle.OnSave(priority=10)
376
+ def validate(self) -> list[str]: ...
377
+
378
+ reveal_type(User().validate()) # list[str]
379
+ ```
380
+
381
+ **Registry `.all` proxy** — `.all.fields`, `.all.methods`, `.all.members`, and `.all.<marker_name>` are typed as `dict[str, list[MemberInfo]]`.
382
+
383
+ **`Marker.collect()`** — always returns `dict[str, MemberInfo]`, fully typed:
384
+
385
+ ```python
386
+ PrimaryKey.collect(User) # dict[str, MemberInfo] — no type: ignore needed
387
+ ```
388
+
389
+ ### What's not typed automatically
390
+
391
+ **Marker-specific descriptors** like `.primary_key`, `.required`, `.indexed` are added dynamically by `MarkerGroupMeta` and are not visible to type checkers. Two options:
392
+
393
+ **Option A** — Use `Marker.collect()` (no annotations needed):
394
+
395
+ ```python
396
+ # Instead of User.primary_key, use:
397
+ PrimaryKey.collect(User) # fully typed: dict[str, MemberInfo]
398
+ ```
399
+
400
+ **Option B** — Add explicit `ClassVar` annotations (opt-in per class):
401
+
402
+ ```python
403
+ from typing import TYPE_CHECKING, ClassVar
404
+
405
+ class User(DB.mixin):
406
+ if TYPE_CHECKING:
407
+ primary_key: ClassVar[dict[str, MemberInfo]]
408
+ indexed: ClassVar[dict[str, MemberInfo]]
409
+
410
+ id: Annotated[int, DB.PrimaryKey()]
411
+ email: Annotated[str, DB.Indexed(unique=True)]
412
+
413
+ User.primary_key # now typed as dict[str, MemberInfo]
414
+ ```
415
+
416
+ The `TYPE_CHECKING` guard ensures these annotations don't affect runtime behavior — the actual values come from the dynamically-installed `MarkerDescriptor` instances.
417
+
343
418
  ## How collection works
344
419
 
345
420
  When you access a descriptor like `User.fields` or `User.required`, the library:
@@ -0,0 +1,181 @@
1
+ """
2
+ Example for validating type checker behavior with markers.
3
+
4
+ Open this file in your IDE (Pylance/Pyright) and verify:
5
+ - .fields, .members, .methods are fully typed as dict[str, MemberInfo]
6
+ - .items(), .keys(), .values() all work on descriptor results
7
+ - Decorated methods preserve their signatures
8
+ - Registry .all proxy returns dict[str, list[MemberInfo]]
9
+
10
+ Marker-specific descriptors (.primary_key, .required, etc.) are
11
+ dynamic — type checkers can't see them without help. Two options:
12
+
13
+ 1. Use Marker.collect(cls) — fully typed, no annotations needed.
14
+ 2. Add ClassVar annotations under TYPE_CHECKING on your class.
15
+ """
16
+
17
+ from typing import TYPE_CHECKING, Annotated, ClassVar
18
+
19
+ from markers import Marker, MarkerGroup, MemberInfo, Registry
20
+
21
+
22
+ # -- Markers --
23
+
24
+
25
+ class Required(Marker):
26
+ pass
27
+
28
+
29
+ class MaxLen(Marker):
30
+ mark = "max_length"
31
+ limit: int
32
+
33
+
34
+ class PrimaryKey(Marker):
35
+ mark = "primary_key"
36
+ auto_increment: bool = True
37
+
38
+
39
+ class Indexed(Marker):
40
+ unique: bool = False
41
+
42
+
43
+ class Transition(Marker):
44
+ source: list[str]
45
+ target: str
46
+
47
+
48
+ # -- Groups --
49
+
50
+
51
+ class Validation(MarkerGroup):
52
+ Required = Required
53
+ MaxLen = MaxLen
54
+
55
+
56
+ class DB(MarkerGroup):
57
+ PrimaryKey = PrimaryKey
58
+ Indexed = Indexed
59
+
60
+
61
+ class SM(MarkerGroup):
62
+ Transition = Transition
63
+
64
+
65
+ # ============================================================
66
+ # 1. BaseMixin descriptors: always typed
67
+ # ============================================================
68
+
69
+ class User(DB.mixin):
70
+ id: Annotated[int, DB.PrimaryKey()]
71
+ email: Annotated[str, DB.Indexed(unique=True)]
72
+
73
+
74
+ # .fields, .methods, .members — all dict[str, MemberInfo]
75
+ fields: dict[str, MemberInfo] = User.fields
76
+ methods: dict[str, MemberInfo] = User.methods
77
+ members: dict[str, MemberInfo] = User.members
78
+
79
+ # .items() yields (str, MemberInfo)
80
+ for field_name, info in User.fields.items():
81
+ print(f"{field_name}: is_field={info.is_field}, has_default={info.has_default}")
82
+
83
+ for field_name in User.fields.keys():
84
+ print(field_name)
85
+
86
+ for info in User.fields.values():
87
+ print(info.name, info.kind)
88
+
89
+
90
+ # ============================================================
91
+ # 2. MemberInfo methods
92
+ # ============================================================
93
+
94
+ id_info = User.fields["id"]
95
+ print(id_info.has("primary_key")) # bool
96
+ print(id_info.get("primary_key")) # MarkerInstance | None
97
+ print(id_info.get_all("primary_key")) # list[MarkerInstance]
98
+
99
+
100
+ # ============================================================
101
+ # 3. Marker-specific descriptors: option A — Marker.collect()
102
+ # Fully typed, no annotations needed.
103
+ # ============================================================
104
+
105
+ pk: dict[str, MemberInfo] = PrimaryKey.collect(User)
106
+ idx: dict[str, MemberInfo] = Indexed.collect(User)
107
+
108
+ for field_name, info in PrimaryKey.collect(User).items():
109
+ marker = info.get("primary_key")
110
+ print(f"{field_name}: has pk = {marker is not None}")
111
+
112
+
113
+ # ============================================================
114
+ # 4. Marker-specific descriptors: option B — ClassVar annotations
115
+ # Opt-in explicit typing for descriptor access on the class.
116
+ # ============================================================
117
+
118
+ class Product(DB.mixin):
119
+ if TYPE_CHECKING:
120
+ primary_key: ClassVar[dict[str, MemberInfo]]
121
+ indexed: ClassVar[dict[str, MemberInfo]]
122
+
123
+ id: Annotated[int, DB.PrimaryKey()]
124
+ sku: Annotated[str, DB.Indexed(unique=True)]
125
+
126
+
127
+ # Now type checkers know about these:
128
+ product_pk: dict[str, MemberInfo] = Product.primary_key
129
+ product_idx: dict[str, MemberInfo] = Product.indexed
130
+
131
+ for field_name, info in Product.primary_key.items():
132
+ print(f"{field_name}: {info.name}")
133
+
134
+
135
+ # ============================================================
136
+ # 5. Decorator preserves function type
137
+ # ============================================================
138
+
139
+ class Task(SM.mixin):
140
+ title: Annotated[str, SM.Transition(source=["backlog"], target="todo")]
141
+
142
+ @SM.Transition(source=["backlog"], target="todo")
143
+ def move_to_todo(self) -> str:
144
+ return "moved"
145
+
146
+ @SM.Transition(source=["todo", "in_progress"], target="done")
147
+ def complete(self) -> bool:
148
+ return True
149
+
150
+
151
+ task = Task()
152
+
153
+ # These preserve the original return types
154
+ result_str: str = task.move_to_todo()
155
+ result_bool: bool = task.complete()
156
+
157
+
158
+ # ============================================================
159
+ # 6. Registry .all proxy: dict[str, list[MemberInfo]]
160
+ # ============================================================
161
+
162
+ class Entity(DB.mixin, Registry):
163
+ id: Annotated[int, DB.PrimaryKey()]
164
+
165
+
166
+ class Customer(Entity):
167
+ tier: Annotated[str, DB.Indexed()] = "free"
168
+
169
+
170
+ class Order(Entity):
171
+ total: Annotated[float, DB.Indexed(unique=True)]
172
+
173
+
174
+ all_fields: dict[str, list[MemberInfo]] = Entity.all.fields
175
+
176
+ for member_name, infos in Entity.all.fields.items():
177
+ owners = [i.owner.__name__ for i in infos if i.owner]
178
+ print(f"{member_name}: {owners}")
179
+
180
+ for cls in Entity.subclasses():
181
+ print(cls.__name__)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "code-is-magic-markers"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Lightweight class introspection toolkit — define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -42,6 +42,22 @@ Supporting types:
42
42
  MemberInfo Metadata about a field or method. Use .has()/.get()/.get_all().
43
43
  MemberKind Enum: FIELD or METHOD.
44
44
  MISSING Sentinel for fields with no default value.
45
+
46
+ Type checking:
47
+ The library ships with PEP 561 ``py.typed`` support and type stubs.
48
+
49
+ - Marker constructor kwargs are **fully validated** by type checkers
50
+ (e.g. ``MaxLen(limit="oops")`` is a type error). This is powered by
51
+ PEP 681 ``dataclass_transform`` on the ``Marker`` metaclass.
52
+ - ``.fields``, ``.methods``, ``.members`` are typed as
53
+ ``dict[str, MemberInfo]`` on any class using a group mixin.
54
+ - ``Marker.collect(cls)`` is the fully-typed alternative for
55
+ marker-specific queries (e.g. ``Required.collect(User)``).
56
+ - Decorator usage preserves function signatures (``@OnSave()``
57
+ does not erase the decorated function's type).
58
+ - Marker-specific descriptors (``.primary_key``, ``.required``)
59
+ are dynamic and not visible to type checkers. See ``MarkerGroup``
60
+ docstring for typed alternatives.
45
61
  """
46
62
 
47
63
  from markers._types import MISSING, MarkerInstance, MemberInfo, MemberKind
@@ -6,7 +6,9 @@ from __future__ import annotations
6
6
 
7
7
  from collections.abc import Callable
8
8
  from enum import Enum, auto
9
- from typing import Any
9
+ from typing import Any, TypeVar
10
+
11
+ _F = TypeVar("_F", bound=Callable[..., Any])
10
12
 
11
13
  __all__ = ["MISSING", "MarkerInstance", "MemberInfo", "MemberKind"]
12
14
 
@@ -69,8 +71,12 @@ class MarkerInstance:
69
71
  """The marker type name (e.g. 'required', 'on_save')."""
70
72
  return self._marker_name
71
73
 
72
- def __call__(self, fn: Callable[..., Any]) -> Callable[..., Any]:
73
- """Decorate a function, attaching this MarkerInstance."""
74
+ def __call__(self, fn: _F) -> _F:
75
+ """Decorate a function, attaching this MarkerInstance.
76
+
77
+ The decorated function's type signature is preserved — type checkers
78
+ will see the original return type, not ``MarkerInstance``.
79
+ """
74
80
  if not callable(fn):
75
81
  raise TypeError(f"MarkerInstance '{self._marker_name}' expected a callable, got {type(fn).__name__}")
76
82
  markers: list[MarkerInstance] = list(getattr(fn, "_markers", []))
@@ -46,11 +46,33 @@ class MarkerDescriptor:
46
46
  return collector.filter(cls, self._marker_name)
47
47
 
48
48
 
49
- class BaseMixin:
49
+ class BaseMixinMeta(type):
50
+ """Metaclass for generated mixin classes.
51
+
52
+ Used by ``MarkerGroupMeta`` to create mixin classes that inherit
53
+ from ``BaseMixin`` and carry ``MarkerDescriptor`` instances for
54
+ each marker in the group.
55
+ """
56
+
57
+
58
+ class BaseMixin(metaclass=BaseMixinMeta):
50
59
  """Mixin providing fields/methods/members descriptors.
51
60
 
52
61
  Every Marker.mixin inherits from this, so any class using
53
62
  at least one marker mixin automatically gets these.
63
+
64
+ Marker-specific descriptors (e.g. ``.primary_key``, ``.required``)
65
+ are added dynamically by ``MarkerGroupMeta`` and work at runtime.
66
+ For static type checking of these descriptors, either:
67
+
68
+ - Use ``Marker.collect()``::
69
+
70
+ PrimaryKey.collect(User) # fully typed: dict[str, MemberInfo]
71
+
72
+ - Add explicit type annotations under ``TYPE_CHECKING``::
73
+
74
+ if TYPE_CHECKING:
75
+ primary_key: ClassVar[dict[str, MemberInfo]]
54
76
  """
55
77
 
56
78
  fields = FieldsDescriptor()
@@ -15,15 +15,10 @@ Markers themselves are pure schema + factory.
15
15
  User.fields # → dict[str, MemberInfo]
16
16
  """
17
17
 
18
- from __future__ import annotations
18
+ from typing import Any
19
19
 
20
- from typing import TYPE_CHECKING, Any
21
-
22
- from markers.descriptors import BaseMixin, MarkerDescriptor
23
- from markers.marker import Marker
24
-
25
- if TYPE_CHECKING:
26
- from markers.marker import MarkerMeta
20
+ from markers.descriptors import BaseMixin, BaseMixinMeta, MarkerDescriptor
21
+ from markers.marker import Marker, MarkerMeta
27
22
 
28
23
  __all__ = ["MarkerGroup"]
29
24
 
@@ -31,10 +26,10 @@ __all__ = ["MarkerGroup"]
31
26
  class MarkerGroupMeta(type):
32
27
  """Metaclass that auto-builds a .mixin from Marker class attributes."""
33
28
 
34
- mixin: type
29
+ mixin: type[BaseMixin]
35
30
  _markers: dict[str, MarkerMeta]
36
31
 
37
- def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> type:
32
+ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> "MarkerGroupMeta":
38
33
  cls = super().__new__(mcs, name, bases, namespace)
39
34
 
40
35
  if name == "MarkerGroup":
@@ -61,7 +56,7 @@ class MarkerGroupMeta(type):
61
56
  mark_name = marker_cls._mark_name
62
57
  mixin_attrs[mark_name] = MarkerDescriptor(mark_name)
63
58
 
64
- cls.mixin = type(f"{name}Mixin", (BaseMixin,), mixin_attrs)
59
+ cls.mixin = BaseMixinMeta(f"{name}Mixin", (BaseMixin,), mixin_attrs) # type: ignore[assignment]
65
60
  cls._markers = found_markers
66
61
  return cls
67
62
 
@@ -108,9 +103,28 @@ class MarkerGroup(metaclass=MarkerGroupMeta):
108
103
 
109
104
  # FullDB.mixin has all of DB's descriptors plus 'unique' and 'check'
110
105
 
106
+ Type checking:
107
+ ``.fields``, ``.methods``, and ``.members`` are fully typed as
108
+ ``dict[str, MemberInfo]`` via ``BaseMixin``. Marker-specific
109
+ descriptors (e.g. ``.primary_key``) are dynamic and not visible
110
+ to type checkers. Two typed alternatives:
111
+
112
+ - Use ``Marker.collect()``::
113
+
114
+ PrimaryKey.collect(User) # fully typed: dict[str, MemberInfo]
115
+
116
+ - Add explicit ``ClassVar`` annotations::
117
+
118
+ if TYPE_CHECKING:
119
+ primary_key: ClassVar[dict[str, MemberInfo]]
120
+
121
+ Marker constructor parameters (e.g. ``MaxLen(limit=100)``) are
122
+ fully validated by type checkers via ``dataclass_transform``.
123
+
111
124
  Attributes:
112
- mixin (type): The generated mixin class. Inherit from this.
125
+ mixin (type[BaseMixin]): The generated mixin class. Inherit from this.
113
126
  _markers (dict[str, MarkerMeta]): Mapping of attribute name to Marker class.
114
127
  """
115
128
 
129
+ mixin: type[BaseMixin]
116
130
  _markers: dict[str, MarkerMeta] = {}
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from markers.descriptors import BaseMixin
4
+ from markers.marker import MarkerMeta
5
+
6
+ class MarkerGroupMeta(type):
7
+ mixin: type[BaseMixin]
8
+ _markers: dict[str, MarkerMeta]
9
+ def __new__(
10
+ mcs,
11
+ name: str,
12
+ bases: tuple[type, ...],
13
+ namespace: dict[str, Any],
14
+ **kwargs: Any,
15
+ ) -> MarkerGroupMeta: ...
16
+
17
+ class MarkerGroup(metaclass=MarkerGroupMeta):
18
+ mixin = BaseMixin
19
+ _markers: dict[str, MarkerMeta]
@@ -203,13 +203,26 @@ class Marker(metaclass=MarkerMeta):
203
203
 
204
204
  # Both have 'priority' with default 0, different mark names.
205
205
 
206
+ Type checking:
207
+ Marker constructor parameters are fully validated by type checkers
208
+ via PEP 681 ``dataclass_transform`` (declared in ``marker.pyi``).
209
+ This means ``MaxLen(limit="oops")`` is a static type error, and
210
+ ``MaxLen()`` flags the missing required ``limit`` parameter.
211
+
212
+ Decorator usage preserves the decorated function's type signature::
213
+
214
+ @OnSave(priority=10)
215
+ def validate(self) -> list[str]: ...
216
+ # type checkers see validate as (self) -> list[str]
217
+
206
218
  Class methods:
207
219
  collect(cls, target):
208
220
  Collect all members carrying this marker from ``target``.
209
- Returns ``dict[str, MemberInfo]``.
210
- Must be called on a subclass, not ``Marker`` itself::
221
+ Returns ``dict[str, MemberInfo]``. Fully typed — this is
222
+ the recommended way to query markers when you need static
223
+ type safety::
211
224
 
212
- Required.collect(User) # {'name': MemberInfo, 'email': MemberInfo}
225
+ Required.collect(User) # dict[str, MemberInfo]
213
226
 
214
227
  invalidate(cls, target):
215
228
  Clear cached collection for ``target``. Useful after dynamic
@@ -0,0 +1,30 @@
1
+ from collections.abc import Callable
2
+ from typing import Any, ClassVar, TypeVar
3
+
4
+ from pydantic import BaseModel
5
+ from typing_extensions import dataclass_transform
6
+
7
+ from markers._types import MemberInfo
8
+
9
+ _F = TypeVar("_F", bound=Callable[..., Any])
10
+
11
+ @dataclass_transform()
12
+ class MarkerMeta(type):
13
+ _mark_name: str
14
+ _schema_model: type[BaseModel] | None
15
+ _schema_annotations: dict[str, Any]
16
+ _schema_defaults: dict[str, Any]
17
+
18
+ class Marker(metaclass=MarkerMeta):
19
+ _mark_name: ClassVar[str]
20
+ _schema_model: ClassVar[type[BaseModel] | None]
21
+ _schema_annotations: ClassVar[dict[str, Any]]
22
+ _schema_defaults: ClassVar[dict[str, Any]]
23
+ mark: ClassVar[str]
24
+ marker_name: ClassVar[str]
25
+ def __call__(self, fn: _F) -> _F: ...
26
+ def __getattr__(self, key: str) -> Any: ...
27
+ @classmethod
28
+ def collect(cls, target: type) -> dict[str, MemberInfo]: ...
29
+ @classmethod
30
+ def invalidate(cls, target: type) -> None: ...
@@ -1,28 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [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
- ## [0.1.1] - 2026-04-13
9
-
10
- ### Changed
11
-
12
- - Expanded README with motivation, detailed guides for all concepts, caching/invalidation docs, reusable mixins pattern, and complete API reference tables
13
-
14
- ## [0.1.0] - 2025-04-12
15
-
16
- ### Added
17
-
18
- - `Marker` base class — subclass to define markers with optional pydantic schema
19
- - `MarkerGroup` — bundle related markers into a `.mixin` for clean inheritance
20
- - `Registry` — track subclasses with `.subclasses()` and `.all` aggregation
21
- - `MarkerInstance` — validated marker usage, works as both `Annotated[]` metadata and method decorator
22
- - `MemberInfo` — unified metadata for fields and methods with `has`/`get`/`get_all` queries
23
- - MRO-walking `Collector` with weakref-based per-class caching
24
- - Intermediate marker base classes for shared schema fields
25
- - `AllProxy` for cross-subclass collection via `Registry.all`
26
- - PEP 561 `py.typed` marker
27
- - 70 tests covering all features
28
- - ORM-style real-world usage example with SQL generation
@@ -1,75 +0,0 @@
1
- # Contributing
2
-
3
- ## Setup
4
-
5
- ```bash
6
- git clone https://github.com/Richard-Lynch/markers.git
7
- cd markers
8
- make venv
9
- make deps
10
- ```
11
-
12
- ## Development workflow
13
-
14
- ### Run tests
15
-
16
- ```bash
17
- # Quick run
18
- make test
19
-
20
- # With coverage
21
- make test:cov
22
-
23
- # Full matrix (Python 3.10–3.13)
24
- tox
25
- ```
26
-
27
- ### Lint and format
28
-
29
- ```bash
30
- # Check
31
- make lint
32
-
33
- # Auto-fix
34
- make format
35
- ```
36
-
37
- ### Type check
38
-
39
- ```bash
40
- make typecheck
41
- ```
42
-
43
- ### Run all checks
44
-
45
- ```bash
46
- make check
47
- ```
48
-
49
- ## Release process
50
-
51
- 1. Update version in `pyproject.toml`
52
- 2. Update `CHANGELOG.md`
53
- 3. Commit: `git commit -am "Release v0.x.y"`
54
- 4. Tag: `git tag v0.x.y`
55
- 5. Push: `git push origin main --tags`
56
-
57
- The GitHub Actions release workflow will automatically:
58
- - Run all checks
59
- - Build sdist + wheel
60
- - Publish to TestPyPI
61
- - Publish to PyPI
62
- - Create a GitHub Release with auto-generated notes
63
-
64
- ## Project structure
65
-
66
- ```
67
- src/markers/
68
- ├── __init__.py # Public API exports
69
- ├── _types.py # MarkerInstance, MemberInfo, MemberKind, MISSING
70
- ├── core.py # Collector — MRO walking + caching
71
- ├── descriptors.py # BaseMixin + descriptor classes
72
- ├── marker.py # Marker + MarkerMeta
73
- ├── groups.py # MarkerGroup + MarkerGroupMeta
74
- └── registry.py # Registry + AllProxy
75
- ```