code-is-magic-markers 0.1.0__py3-none-any.whl

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,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: code-is-magic-markers
3
+ Version: 0.1.0
4
+ Summary: Lightweight class introspection toolkit — define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors.
5
+ Project-URL: Homepage, https://github.com/Richard-Lynch/markers
6
+ Project-URL: Repository, https://github.com/Richard-Lynch/markers
7
+ Project-URL: Changelog, https://github.com/Richard-Lynch/markers/blob/main/CHANGELOG.md
8
+ Author: Richie
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: annotations,descriptors,introspection,markers,metadata,pydantic,registry,typing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: pydantic>=2.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.10; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Requires-Dist: tox>=4.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # markers
33
+
34
+ Lightweight class introspection toolkit for Python. Define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install code-is-magic-markers
40
+ ```
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from typing import Annotated
46
+ from markers import Marker, MarkerGroup, Registry
47
+
48
+ # 1. Define markers — the class body IS the schema
49
+ class Required(Marker): pass
50
+
51
+ class MaxLen(Marker):
52
+ mark = "max_length"
53
+ limit: int
54
+
55
+ class Searchable(Marker):
56
+ boost: float = 1.0
57
+ analyzer: str = "standard"
58
+
59
+ class OnSave(Marker):
60
+ mark = "on_save"
61
+ priority: int = 0
62
+
63
+
64
+ # 2. Bundle into groups
65
+ class Validation(MarkerGroup):
66
+ Required = Required
67
+ MaxLen = MaxLen
68
+
69
+ class Lifecycle(MarkerGroup):
70
+ OnSave = OnSave
71
+
72
+
73
+ # 3. Annotate your classes
74
+ class User(Validation.mixin, Lifecycle.mixin):
75
+ name: Annotated[str, Validation.Required(), Validation.MaxLen(limit=100)]
76
+ email: Annotated[str, Validation.Required()]
77
+ bio: Annotated[str, Searchable()] = ""
78
+
79
+ @Lifecycle.OnSave(priority=10)
80
+ def validate(self) -> list[str]:
81
+ errors = []
82
+ for name, info in type(self).required.items():
83
+ if info.is_field and not getattr(self, name, None):
84
+ errors.append(f"{name} is required")
85
+ return errors
86
+
87
+
88
+ # 4. Query metadata — same dict[str, MemberInfo] everywhere
89
+ User.fields # all fields
90
+ User.methods # all methods
91
+ User.members # both
92
+ User.required # only members marked 'required'
93
+ User.on_save # only members marked 'on_save'
94
+
95
+ # Introspect
96
+ User.fields["name"].get("max_length").limit # 100
97
+ User.methods["validate"].get("on_save").priority # 10
98
+ ```
99
+
100
+ ## Core concepts
101
+
102
+ ### Marker
103
+
104
+ Subclass `Marker` to define a marker. The class body is the pydantic schema — typed fields become validated parameters:
105
+
106
+ ```python
107
+ class ForeignKey(Marker):
108
+ mark = "foreign_key" # explicit name (default: lowercased class name)
109
+ table: str # required parameter
110
+ column: str = "id" # optional with default
111
+ on_delete: str = "CASCADE"
112
+ ```
113
+
114
+ Markers work as both `Annotated[]` metadata and method decorators:
115
+
116
+ ```python
117
+ # As annotation
118
+ author_id: Annotated[int, ForeignKey(table="users")]
119
+
120
+ # As decorator
121
+ @OnSave(priority=10)
122
+ def validate(self): ...
123
+ ```
124
+
125
+ Schema-less markers accept no parameters:
126
+
127
+ ```python
128
+ class Required(Marker): pass
129
+ Required() # ok
130
+ Required(x=1) # TypeError
131
+ ```
132
+
133
+ Intermediate bases share schema fields:
134
+
135
+ ```python
136
+ class LifecycleMarker(Marker):
137
+ priority: int = 0
138
+
139
+ class OnSave(LifecycleMarker):
140
+ mark = "on_save"
141
+
142
+ class OnDelete(LifecycleMarker):
143
+ mark = "on_delete"
144
+
145
+ # Both have 'priority'
146
+ ```
147
+
148
+ ### MarkerGroup
149
+
150
+ Bundle related markers and produce a `.mixin`:
151
+
152
+ ```python
153
+ class DB(MarkerGroup):
154
+ PrimaryKey = PrimaryKey
155
+ Indexed = Indexed
156
+ ForeignKey = ForeignKey
157
+
158
+ class User(DB.mixin):
159
+ id: Annotated[int, DB.PrimaryKey()]
160
+ email: Annotated[str, DB.Indexed(unique=True)]
161
+
162
+ User.primary_key # {'id': MemberInfo(...)}
163
+ User.indexed # {'email': MemberInfo(...)}
164
+ User.fields # all fields (from BaseMixin)
165
+ ```
166
+
167
+ Groups compose via inheritance:
168
+
169
+ ```python
170
+ class ExtendedDB(DB):
171
+ Unique = Unique
172
+ ```
173
+
174
+ ### Registry
175
+
176
+ Track subclasses for cross-class queries:
177
+
178
+ ```python
179
+ class Entity(DB.mixin, Registry):
180
+ id: Annotated[int, DB.PrimaryKey()]
181
+
182
+ class User(Entity):
183
+ name: Annotated[str, Required()]
184
+
185
+ class Post(Entity):
186
+ title: Annotated[str, Required()]
187
+
188
+ # List all subclasses
189
+ Entity.subclasses() # [User, Post]
190
+
191
+ # Iterate with the same per-class API
192
+ for cls in Entity.subclasses():
193
+ print(cls.__name__, list(cls.required.keys()))
194
+
195
+ # Or gather across all subclasses
196
+ Entity.all.required # {'name': [MemberInfo(owner=User)], 'title': [MemberInfo(owner=Post)]}
197
+ Entity.all.fields # {'id': [MemberInfo(owner=User), MemberInfo(owner=Post)], ...}
198
+ ```
199
+
200
+ ### MemberInfo
201
+
202
+ Every collected member (field or method) is a `MemberInfo`:
203
+
204
+ ```python
205
+ info = User.fields["name"]
206
+ info.name # 'name'
207
+ info.kind # MemberKind.FIELD
208
+ info.type # <class 'str'>
209
+ info.owner # <class 'User'>
210
+ info.default # MISSING (no default)
211
+ info.has_default # False
212
+ info.is_field # True
213
+ info.is_method # False
214
+ info.markers # [MarkerInstance('required', ...), MarkerInstance('max_length', ...)]
215
+ info.has("required") # True
216
+ info.get("max_length").limit # 100
217
+ info.get_all("required") # [MarkerInstance(...)]
218
+ ```
219
+
220
+ ## API reference
221
+
222
+ | Class | Purpose |
223
+ |-------|---------|
224
+ | `Marker` | Subclass to define markers with optional typed schema |
225
+ | `MarkerGroup` | Subclass to bundle markers into a `.mixin` |
226
+ | `Registry` | Subclass to track all subclasses, provides `.subclasses()` and `.all` |
227
+ | `MarkerInstance` | A specific usage of a marker with validated params |
228
+ | `MemberInfo` | Metadata about a field or method |
229
+ | `MemberKind` | Enum: `FIELD` or `METHOD` |
230
+ | `MISSING` | Sentinel for fields with no default |
231
+
232
+ ### Marker class methods
233
+
234
+ | Method | Description |
235
+ |--------|-------------|
236
+ | `MyMarker.collect(cls)` | Collect members carrying this marker from `cls` |
237
+ | `Marker.invalidate(cls)` | Clear cached collection for `cls` |
238
+
239
+ ## License
240
+
241
+ MIT
@@ -0,0 +1,12 @@
1
+ markers/__init__.py,sha256=9m_dO-j8ebqL3U3ACgM8fGb3Wbrn8ZpSdvzAi4e4x6s,881
2
+ markers/_types.py,sha256=PApctFsFKX0I-pbrz2DACrGR5XLJsXBXytqZZlWQi7E,4345
3
+ markers/core.py,sha256=wPopCUY-vU-vnTF2Ald8NeWNdK5NMB-XvEgrSTDAjW8,4292
4
+ markers/descriptors.py,sha256=_c8hNK0INxPyuFTkoZQlRnsLWV4ciWlL3cnYeOV4bFI,1677
5
+ markers/groups.py,sha256=ao2bh3cXeB4s5gd90AMS4ZvLKBe_pWHLQUwyvqzafa8,2774
6
+ markers/marker.py,sha256=1k97hD71DkZsBc6gVtMMImMIBpOkfnNkUoAARQNQsxY,6223
7
+ markers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ markers/registry.py,sha256=1-of8GtyrQpZMDNelli0yPSNx10OibOZFeX24puf4g4,4324
9
+ code_is_magic_markers-0.1.0.dist-info/METADATA,sha256=QsAgQnJ4X6jiu3Q9VE9GHwXkvr0BxDcHESzkLWF9aFc,6617
10
+ code_is_magic_markers-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ code_is_magic_markers-0.1.0.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
12
+ code_is_magic_markers-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
markers/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ markers - Lightweight class introspection toolkit.
3
+
4
+ Public API:
5
+ Marker — Subclass to define markers (with optional pydantic schema).
6
+ MarkerGroup — Subclass to bundle related markers into a .mixin.
7
+ Registry — Subclass to track and query across all subclasses.
8
+
9
+ Supporting types (for type hints):
10
+ MarkerInstance — A specific usage of a marker with params.
11
+ MemberInfo — Metadata about a field or method.
12
+ MemberKind — Enum: FIELD or METHOD.
13
+ MISSING — Sentinel for fields with no default.
14
+ """
15
+
16
+ from markers._types import MISSING, MarkerInstance, MemberInfo, MemberKind
17
+ from markers.groups import MarkerGroup
18
+ from markers.marker import Marker
19
+ from markers.registry import Registry
20
+
21
+ __all__ = [
22
+ "MISSING",
23
+ "Marker",
24
+ "MarkerGroup",
25
+ "MarkerInstance",
26
+ "MemberInfo",
27
+ "MemberKind",
28
+ "Registry",
29
+ ]
markers/_types.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ markers._types - Core data types.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from enum import Enum, auto
9
+ from typing import Any
10
+
11
+ __all__ = ["MISSING", "MarkerInstance", "MemberInfo", "MemberKind"]
12
+
13
+ MISSING = object()
14
+
15
+
16
+ class MemberKind(Enum):
17
+ FIELD = auto()
18
+ METHOD = auto()
19
+
20
+
21
+ class MarkerInstance:
22
+ """A specific usage of a Marker with validated parameters.
23
+
24
+ Internal state is stored in underscore-prefixed slots to avoid
25
+ collisions with schema field names on ``__getattr__`` lookup.
26
+
27
+ Access the marker type name via ``.marker_name``.
28
+ Access schema fields directly as attributes: ``inst.boost``, ``inst.limit``.
29
+
30
+ Also callable as a decorator::
31
+
32
+ @OnSave(priority=1)
33
+ def validate(self): ...
34
+ """
35
+
36
+ __slots__ = ("_kwargs", "_marker_name", "_params")
37
+
38
+ def __init__(
39
+ self,
40
+ marker_name: str,
41
+ kwargs: dict[str, Any],
42
+ params: Any = None,
43
+ ) -> None:
44
+ self._marker_name = marker_name
45
+ self._kwargs = kwargs
46
+ self._params = params
47
+
48
+ @property
49
+ def marker_name(self) -> str:
50
+ """The marker type name (e.g. 'required', 'on_save')."""
51
+ return self._marker_name
52
+
53
+ def __call__(self, fn: Callable[..., Any]) -> Callable[..., Any]:
54
+ """Decorate a function, attaching this MarkerInstance."""
55
+ if not callable(fn):
56
+ raise TypeError(f"MarkerInstance '{self._marker_name}' expected a callable, got {type(fn).__name__}")
57
+ markers: list[MarkerInstance] = list(getattr(fn, "_markers", []))
58
+ markers.append(self)
59
+ fn._markers = markers # type: ignore[attr-defined]
60
+ return fn
61
+
62
+ def __getattr__(self, key: str) -> Any:
63
+ """Access schema fields as attributes.
64
+
65
+ Checks the pydantic params model first, then raw kwargs.
66
+ Since internal state uses underscore-prefixed slots, schema
67
+ fields like 'name', 'params', 'kwargs' work without collision.
68
+ """
69
+ params = self._params
70
+ if params is not None:
71
+ try:
72
+ return getattr(params, key)
73
+ except AttributeError:
74
+ pass
75
+ kwargs = self._kwargs
76
+ if key in kwargs:
77
+ return kwargs[key]
78
+ raise AttributeError(f"MarkerInstance '{self._marker_name}' has no parameter '{key}'")
79
+
80
+ def __repr__(self) -> str:
81
+ if self._params is not None:
82
+ data = self._params.model_dump()
83
+ else:
84
+ data = self._kwargs
85
+ parts = [f"{k}={v!r}" for k, v in data.items()]
86
+ return f"{self._marker_name}({', '.join(parts)})"
87
+
88
+
89
+ class MemberInfo:
90
+ """Metadata about a single class member (field or method)."""
91
+
92
+ __slots__ = ("default", "kind", "markers", "name", "owner", "type")
93
+
94
+ def __init__(
95
+ self,
96
+ name: str,
97
+ kind: MemberKind,
98
+ markers: list[MarkerInstance],
99
+ type_: Any = None,
100
+ default: Any = MISSING,
101
+ owner: type | None = None,
102
+ ) -> None:
103
+ self.name = name
104
+ self.kind = kind
105
+ self.type = type_
106
+ self.markers = markers
107
+ self.default = default
108
+ self.owner = owner
109
+
110
+ @property
111
+ def is_field(self) -> bool:
112
+ return self.kind == MemberKind.FIELD
113
+
114
+ @property
115
+ def is_method(self) -> bool:
116
+ return self.kind == MemberKind.METHOD
117
+
118
+ @property
119
+ def has_default(self) -> bool:
120
+ return self.default is not MISSING
121
+
122
+ def has(self, marker_name: str) -> bool:
123
+ return any(m._marker_name == marker_name for m in self.markers)
124
+
125
+ def get(self, marker_name: str) -> MarkerInstance | None:
126
+ return next((m for m in self.markers if m._marker_name == marker_name), None)
127
+
128
+ def get_all(self, marker_name: str) -> list[MarkerInstance]:
129
+ return [m for m in self.markers if m._marker_name == marker_name]
130
+
131
+ def __repr__(self) -> str:
132
+ parts = [f"name={self.name!r}", f"kind={self.kind.name}"]
133
+ if self.type is not None:
134
+ parts.append(f"type={self.type!r}")
135
+ if self.markers:
136
+ parts.append(f"markers={self.markers!r}")
137
+ if self.has_default:
138
+ parts.append(f"default={self.default!r}")
139
+ if self.owner is not None:
140
+ parts.append(f"owner={self.owner.__name__!r}")
141
+ return f"MemberInfo({', '.join(parts)})"
markers/core.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ markers.core - Unified member collection and caching.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import weakref
8
+ from typing import get_type_hints
9
+
10
+ from markers._types import MISSING, MarkerInstance, MemberInfo, MemberKind
11
+
12
+ __all__: list[str] = [] # No public API — use Marker/Registry instead
13
+
14
+
15
+ class Collector:
16
+ """Walks a class's MRO once, collects all members (fields + methods),
17
+ and caches the result per-class using weak references.
18
+
19
+ Not part of the public API. Access via Marker.collect / Marker.invalidate.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._cache: dict[int, dict[str, MemberInfo]] = {}
24
+ self._refs: dict[int, weakref.ref[type]] = {}
25
+
26
+ def _cleanup(self, cls_id: int) -> None:
27
+ self._cache.pop(cls_id, None)
28
+ self._refs.pop(cls_id, None)
29
+
30
+ def collect(self, cls: type) -> dict[str, MemberInfo]:
31
+ cls_id = id(cls)
32
+
33
+ # Check cache — verify the weakref is still alive
34
+ if cls_id in self._cache:
35
+ ref = self._refs.get(cls_id)
36
+ if ref is not None and ref() is not None:
37
+ return self._cache[cls_id]
38
+ else:
39
+ self._cleanup(cls_id)
40
+
41
+ members: dict[str, MemberInfo] = {}
42
+
43
+ for klass in reversed(cls.__mro__):
44
+ if klass is object:
45
+ continue
46
+
47
+ # --- Fields from annotations ---
48
+ try:
49
+ hints = get_type_hints(klass, include_extras=True)
50
+ except Exception:
51
+ hints = getattr(klass, "__annotations__", {})
52
+
53
+ own_names = set(getattr(klass, "__annotations__", {}).keys())
54
+ for name in own_names:
55
+ if name.startswith("_"):
56
+ continue
57
+ hint = hints.get(name)
58
+ if hint is None:
59
+ continue
60
+ if hasattr(hint, "__metadata__"):
61
+ base_type = hint.__args__[0]
62
+ markers = [m for m in hint.__metadata__ if isinstance(m, MarkerInstance)]
63
+ else:
64
+ base_type = hint
65
+ markers = []
66
+ default = vars(klass).get(name, MISSING)
67
+ members[name] = MemberInfo(
68
+ name=name,
69
+ kind=MemberKind.FIELD,
70
+ type_=base_type,
71
+ markers=markers,
72
+ default=default,
73
+ owner=klass,
74
+ )
75
+
76
+ # --- Methods with _markers ---
77
+ for attr, val in vars(klass).items():
78
+ method_markers: list[MarkerInstance] | None = getattr(val, "_markers", None)
79
+ if method_markers:
80
+ members[attr] = MemberInfo(
81
+ name=attr,
82
+ kind=MemberKind.METHOD,
83
+ markers=list(method_markers),
84
+ owner=klass,
85
+ )
86
+
87
+ self._cache[cls_id] = members
88
+
89
+ # Store weakref for cache invalidation on GC.
90
+ # If weakref creation fails (e.g. some C extension types),
91
+ # cache without auto-cleanup — manual invalidate() still works.
92
+ def _weak_callback(ref: weakref.ref[type], cid: int = cls_id) -> None:
93
+ self._cleanup(cid)
94
+
95
+ try:
96
+ self._refs[cls_id] = weakref.ref(cls, _weak_callback)
97
+ except TypeError:
98
+ # Can't weakref this type — cache persists until manual invalidate()
99
+ pass
100
+
101
+ return members
102
+
103
+ def invalidate(self, cls: type) -> None:
104
+ """Clear the cached collection for a class."""
105
+ self._cleanup(id(cls))
106
+
107
+ def filter(self, cls: type, marker_name: str) -> dict[str, MemberInfo]:
108
+ """Collect and return only members carrying a specific marker."""
109
+ return {n: m for n, m in self.collect(cls).items() if m.has(marker_name)}
110
+
111
+ def fields(self, cls: type) -> dict[str, MemberInfo]:
112
+ """Collect and return only field members."""
113
+ return {n: m for n, m in self.collect(cls).items() if m.kind == MemberKind.FIELD}
114
+
115
+ def methods(self, cls: type) -> dict[str, MemberInfo]:
116
+ """Collect and return only method members."""
117
+ return {n: m for n, m in self.collect(cls).items() if m.kind == MemberKind.METHOD}
118
+
119
+
120
+ collector = Collector()
markers/descriptors.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ markers.descriptors - Lazy descriptors and BaseMixin.
3
+
4
+ BaseMixin carries fields/methods/members descriptors and is
5
+ automatically inherited by every MarkerGroup.mixin, so any class
6
+ using at least one group mixin gets all three for free.
7
+ """
8
+
9
+ from markers._types import MemberInfo
10
+ from markers.core import collector
11
+
12
+ __all__: list[str] = [] # No public API — used internally by Marker
13
+
14
+
15
+ class MembersDescriptor:
16
+ """Descriptor returning all collected members (fields + methods).
17
+
18
+ Returns a copy to protect the internal cache from mutation.
19
+ """
20
+
21
+ def __get__(self, obj: object, cls: type) -> dict[str, MemberInfo]:
22
+ return dict(collector.collect(cls))
23
+
24
+
25
+ class FieldsDescriptor:
26
+ """Descriptor returning only field members."""
27
+
28
+ def __get__(self, obj: object, cls: type) -> dict[str, MemberInfo]:
29
+ return collector.fields(cls)
30
+
31
+
32
+ class MethodsDescriptor:
33
+ """Descriptor returning only method members."""
34
+
35
+ def __get__(self, obj: object, cls: type) -> dict[str, MemberInfo]:
36
+ return collector.methods(cls)
37
+
38
+
39
+ class MarkerDescriptor:
40
+ """Descriptor returning all members matching a specific marker."""
41
+
42
+ def __init__(self, marker_name: str) -> None:
43
+ self._marker_name = marker_name
44
+
45
+ def __get__(self, obj: object, cls: type) -> dict[str, MemberInfo]:
46
+ return collector.filter(cls, self._marker_name)
47
+
48
+
49
+ class BaseMixin:
50
+ """Mixin providing fields/methods/members descriptors.
51
+
52
+ Every Marker.mixin inherits from this, so any class using
53
+ at least one marker mixin automatically gets these.
54
+ """
55
+
56
+ fields = FieldsDescriptor()
57
+ methods = MethodsDescriptor()
58
+ members = MembersDescriptor()
markers/groups.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ markers.groups - MarkerGroup for bundling related markers.
3
+
4
+ MarkerGroup is the only way to get marker descriptors onto model classes.
5
+ Markers themselves are pure schema + factory.
6
+
7
+ class DB(MarkerGroup):
8
+ PrimaryKey = PrimaryKey
9
+ Indexed = Indexed
10
+
11
+ class User(DB.mixin):
12
+ id: Annotated[int, DB.PrimaryKey()]
13
+
14
+ User.primary_key # → dict[str, MemberInfo]
15
+ User.fields # → dict[str, MemberInfo]
16
+ """
17
+
18
+ from __future__ import annotations
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
27
+
28
+ __all__ = ["MarkerGroup"]
29
+
30
+
31
+ class MarkerGroupMeta(type):
32
+ """Metaclass that auto-builds a .mixin from Marker class attributes."""
33
+
34
+ mixin: type
35
+ _markers: dict[str, MarkerMeta]
36
+
37
+ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> type:
38
+ cls = super().__new__(mcs, name, bases, namespace)
39
+
40
+ if name == "MarkerGroup":
41
+ return cls
42
+
43
+ # Find all Marker subclasses assigned as class attributes
44
+ found_markers: dict[str, MarkerMeta] = {}
45
+ for attr, val in namespace.items():
46
+ if isinstance(val, type) and issubclass(val, Marker) and val is not Marker:
47
+ found_markers[attr] = val # type: ignore[assignment]
48
+
49
+ # Also check base groups for inherited markers
50
+ for base in bases:
51
+ if base is MarkerGroup:
52
+ continue
53
+ base_markers: dict[str, MarkerMeta] = getattr(base, "_markers", {})
54
+ for attr, val in base_markers.items():
55
+ if attr not in found_markers:
56
+ found_markers[attr] = val
57
+
58
+ # Build mixin: BaseMixin + a MarkerDescriptor per marker
59
+ mixin_attrs: dict[str, Any] = {}
60
+ for marker_cls in found_markers.values():
61
+ mark_name = marker_cls._mark_name
62
+ mixin_attrs[mark_name] = MarkerDescriptor(mark_name)
63
+
64
+ cls.mixin = type(f"{name}Mixin", (BaseMixin,), mixin_attrs)
65
+ cls._markers = found_markers
66
+ return cls
67
+
68
+
69
+ class MarkerGroup(metaclass=MarkerGroupMeta):
70
+ """Base class for grouping related markers.
71
+
72
+ Subclass and assign Marker subclasses as class attributes::
73
+
74
+ class DB(MarkerGroup):
75
+ PrimaryKey = PrimaryKey
76
+ Indexed = Indexed
77
+
78
+ The class automatically gets a ``.mixin`` that provides:
79
+ - A MarkerDescriptor for each marker (e.g. ``.primary_key``, ``.indexed``)
80
+ - ``fields``, ``methods``, ``members`` from BaseMixin
81
+
82
+ Groups can inherit from other groups to compose::
83
+
84
+ class FullDB(DB):
85
+ ForeignKey = ForeignKey
86
+ """
87
+
88
+ _markers: dict[str, MarkerMeta] = {}
markers/marker.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ markers.marker - Marker is both the schema and the factory.
3
+
4
+ Define markers by subclassing Marker. The class body IS the pydantic
5
+ schema. Calling the class creates a validated MarkerInstance.
6
+
7
+ class Searchable(Marker):
8
+ boost: float = 1.0
9
+ analyzer: str = "standard"
10
+
11
+ class Required(Marker):
12
+ pass
13
+
14
+ Markers don't carry mixins — use MarkerGroup for that.
15
+
16
+ Intermediate base markers for shared schema fields::
17
+
18
+ class LifecycleMarker(Marker):
19
+ priority: int = 0
20
+
21
+ class OnSave(LifecycleMarker):
22
+ mark = "on_save"
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ from pydantic import BaseModel, ConfigDict
30
+
31
+ from markers._types import MarkerInstance, MemberInfo
32
+ from markers.core import collector
33
+
34
+ __all__ = ["Marker"]
35
+
36
+ # Fields that belong to Marker infrastructure, not the schema
37
+ _MARKER_INTERNAL = {"mark", "collect", "invalidate"}
38
+
39
+
40
+ class MarkerMeta(type):
41
+ """Metaclass that makes Marker subclasses act as schema + factory."""
42
+
43
+ _mark_name: str
44
+ _schema_model: type[BaseModel] | None
45
+ _schema_annotations: dict[str, Any]
46
+ _schema_defaults: dict[str, Any]
47
+
48
+ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> type:
49
+ # Don't process Marker base class itself
50
+ if name == "Marker":
51
+ return super().__new__(mcs, name, bases, namespace)
52
+
53
+ # Determine marker name — only set if explicitly provided or this is a leaf
54
+ mark_name = namespace.pop("mark", None)
55
+
56
+ # Collect schema annotations from this class AND parent markers
57
+ schema_annotations: dict[str, Any] = {}
58
+ schema_defaults: dict[str, Any] = {}
59
+
60
+ # Walk bases for inherited schema fields
61
+ for base in reversed(bases):
62
+ if base is Marker:
63
+ continue
64
+ base_schema = getattr(base, "_schema_annotations", {})
65
+ base_defaults = getattr(base, "_schema_defaults", {})
66
+ schema_annotations.update(base_schema)
67
+ schema_defaults.update(base_defaults)
68
+
69
+ # Add this class's own annotations
70
+ own_annotations = namespace.get("__annotations__", {})
71
+ for k, v in own_annotations.items():
72
+ if k not in _MARKER_INTERNAL and not k.startswith("_"):
73
+ schema_annotations[k] = v
74
+
75
+ # Extract defaults from namespace
76
+ for k in list(schema_annotations.keys()):
77
+ if k in namespace:
78
+ schema_defaults[k] = namespace.pop(k)
79
+
80
+ # Build pydantic model if there are schema fields
81
+ if schema_annotations:
82
+ model_ns: dict[str, Any] = {
83
+ "__annotations__": dict(schema_annotations),
84
+ "model_config": ConfigDict(extra="forbid"),
85
+ }
86
+ model_ns.update(schema_defaults)
87
+ schema_model = type(f"{name}Params", (BaseModel,), model_ns)
88
+ else:
89
+ schema_model = None
90
+
91
+ # Clean annotations so they don't interfere with the class
92
+ if "__annotations__" in namespace:
93
+ namespace["__annotations__"] = {
94
+ k: v for k, v in namespace["__annotations__"].items() if k not in schema_annotations
95
+ }
96
+
97
+ cls = super().__new__(mcs, name, bases, namespace)
98
+
99
+ # If no explicit mark name, default to lowercased class name
100
+ if mark_name is None:
101
+ mark_name = name.lower()
102
+
103
+ cls._mark_name = mark_name
104
+ cls._schema_model = schema_model
105
+ cls._schema_annotations = schema_annotations
106
+ cls._schema_defaults = schema_defaults
107
+
108
+ return cls
109
+
110
+ def __call__(cls, **kwargs: Any) -> MarkerInstance:
111
+ """Create a validated MarkerInstance."""
112
+ if cls is Marker:
113
+ raise TypeError("Cannot instantiate Marker directly — subclass it")
114
+
115
+ schema_model = cls._schema_model
116
+
117
+ if schema_model is not None:
118
+ params = schema_model(**kwargs)
119
+ return MarkerInstance(cls._mark_name, params.model_dump(), params)
120
+ else:
121
+ if kwargs:
122
+ raise TypeError(f"Marker '{cls._mark_name}' accepts no parameters, got: {set(kwargs.keys())}")
123
+ return MarkerInstance(cls._mark_name, {})
124
+
125
+ def __repr__(cls) -> str:
126
+ if cls is Marker:
127
+ return "<class 'Marker'>"
128
+ if cls._schema_model is not None:
129
+ return f"<Marker '{cls._mark_name}' schema={cls._schema_model.__name__}>"
130
+ return f"<Marker '{cls._mark_name}'>"
131
+
132
+
133
+ class Marker(metaclass=MarkerMeta):
134
+ """Base class for defining markers.
135
+
136
+ Subclass to create a marker. Add typed fields for a validated schema,
137
+ or leave empty for a schema-less marker.
138
+
139
+ Class attributes:
140
+ mark: Optional explicit marker name. Defaults to lowercased class name.
141
+
142
+ Markers are pure schema + factory. Use ``MarkerGroup`` to bundle
143
+ markers and create mixins for your model classes.
144
+
145
+ Intermediate bases work naturally for shared fields::
146
+
147
+ class LifecycleMarker(Marker):
148
+ priority: int = 0
149
+
150
+ class OnSave(LifecycleMarker):
151
+ mark = "on_save"
152
+
153
+ class OnDelete(LifecycleMarker):
154
+ mark = "on_delete"
155
+
156
+ # OnSave and OnDelete both have priority, with different mark names.
157
+ """
158
+
159
+ _mark_name: str
160
+ _schema_model: type[BaseModel] | None
161
+ _schema_annotations: dict[str, Any]
162
+ _schema_defaults: dict[str, Any]
163
+
164
+ @classmethod
165
+ def collect(cls, target: type) -> dict[str, MemberInfo]:
166
+ """Collect all members carrying this marker from target class.
167
+
168
+ Must be called on a concrete Marker subclass, not on Marker itself.
169
+ """
170
+ if cls is Marker:
171
+ raise TypeError(
172
+ "collect() must be called on a Marker subclass, e.g. Required.collect(User), not Marker.collect(User)"
173
+ )
174
+ return collector.filter(target, cls._mark_name)
175
+
176
+ @classmethod
177
+ def invalidate(cls, target: type) -> None:
178
+ """Clear the cached collection for a target class.
179
+
180
+ Can be called on any Marker subclass or on Marker itself —
181
+ invalidation is not marker-specific, it clears the entire
182
+ cache for the target class.
183
+ """
184
+ collector.invalidate(target)
markers/py.typed ADDED
File without changes
markers/registry.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ markers.registry - Registry base class for cross-subclass collection.
3
+
4
+ class Entity(DB.mixin, Registry): ...
5
+ class User(Entity): ...
6
+ class Post(Entity): ...
7
+
8
+ # List registered subclasses
9
+ Entity.subclasses() # [User, Post]
10
+
11
+ # Per-class access (same as always)
12
+ User.required # {'name': MemberInfo, 'email': MemberInfo}
13
+
14
+ # Cross-class access via .all — dict of lists, grouped by member name
15
+ Entity.all.required # {'name': [MemberInfo(owner=User)], 'email': [...], 'title': [...]}
16
+ Entity.all.fields # {'id': [MemberInfo(owner=User), MemberInfo(owner=Post)], ...}
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections import defaultdict
22
+ from typing import Any
23
+
24
+ from markers._types import MemberInfo
25
+ from markers.core import collector
26
+ from markers.descriptors import BaseMixin
27
+
28
+ __all__ = ["Registry"]
29
+
30
+
31
+ class AllProxy:
32
+ """Proxy that gathers members from all subclasses into ``dict[str, list[MemberInfo]]``.
33
+
34
+ Each key is a member name. Each value is a list of MemberInfo from
35
+ every subclass that defines that member — so you can see which
36
+ classes contribute each field/method.
37
+
38
+ Entity.all.fields
39
+ # {'id': [MemberInfo(owner=User), MemberInfo(owner=Post)],
40
+ # 'name': [MemberInfo(owner=User)],
41
+ # 'title': [MemberInfo(owner=Post)], ...}
42
+
43
+ Entity.all.required
44
+ # {'name': [MemberInfo(owner=User)],
45
+ # 'email': [MemberInfo(owner=User), MemberInfo(owner=Customer)], ...}
46
+ """
47
+
48
+ def __init__(self, registry_cls: type) -> None:
49
+ self._cls = registry_cls
50
+
51
+ def _gather(self, extractor: Any) -> dict[str, list[MemberInfo]]:
52
+ result: dict[str, list[MemberInfo]] = defaultdict(list)
53
+ registry: dict[str, type] = getattr(self._cls, "_registry", {})
54
+ for sub in registry.values():
55
+ for name, info in extractor(sub).items():
56
+ result[name].append(info)
57
+ return dict(result)
58
+
59
+ @property
60
+ def members(self) -> dict[str, list[MemberInfo]]:
61
+ """All members from all subclasses."""
62
+ return self._gather(collector.collect)
63
+
64
+ @property
65
+ def fields(self) -> dict[str, list[MemberInfo]]:
66
+ """All fields from all subclasses."""
67
+ return self._gather(collector.fields)
68
+
69
+ @property
70
+ def methods(self) -> dict[str, list[MemberInfo]]:
71
+ """All methods from all subclasses."""
72
+ return self._gather(collector.methods)
73
+
74
+ def __getattr__(self, marker_name: str) -> dict[str, list[MemberInfo]]:
75
+ """Collect a specific marker across all subclasses."""
76
+ if marker_name.startswith("_"):
77
+ raise AttributeError(marker_name)
78
+ return self._gather(lambda sub: collector.filter(sub, marker_name))
79
+
80
+
81
+ class AllDescriptor:
82
+ """Descriptor that returns an AllProxy bound to the registry class."""
83
+
84
+ def __get__(self, obj: object, cls: type) -> AllProxy:
85
+ return AllProxy(cls)
86
+
87
+
88
+ class Registry(BaseMixin):
89
+ """Base class that tracks all subclasses.
90
+
91
+ Inherits BaseMixin so ``fields``, ``methods``, and ``members``
92
+ are always available.
93
+
94
+ Per-class access works as usual::
95
+
96
+ User.fields # dict[str, MemberInfo]
97
+ User.required # dict[str, MemberInfo]
98
+
99
+ Cross-class access via ``.all`` gathers into lists by member name::
100
+
101
+ Entity.all.fields # dict[str, list[MemberInfo]]
102
+ Entity.all.required # dict[str, list[MemberInfo]]
103
+
104
+ Each list entry has ``.owner`` so you know which class it came from.
105
+
106
+ Use ``subclasses()`` for direct iteration::
107
+
108
+ for cls in Entity.subclasses():
109
+ print(cls.__name__, list(cls.required.keys()))
110
+ """
111
+
112
+ _registry: dict[str, type] = {}
113
+ all = AllDescriptor()
114
+
115
+ def __init_subclass__(cls, abstract: bool = False, **kwargs: Any) -> None:
116
+ super().__init_subclass__(**kwargs)
117
+ if not abstract:
118
+ cls._registry = {}
119
+ for base in cls.__mro__[1:]:
120
+ if base is Registry:
121
+ break
122
+ if issubclass(base, Registry) and hasattr(base, "_registry"):
123
+ base._registry[cls.__name__] = cls
124
+ break
125
+
126
+ @classmethod
127
+ def subclasses(cls) -> list[type]:
128
+ """Return all registered subclasses."""
129
+ return list(cls._registry.values())