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.
- code_is_magic_markers-0.1.0.dist-info/METADATA +241 -0
- code_is_magic_markers-0.1.0.dist-info/RECORD +12 -0
- code_is_magic_markers-0.1.0.dist-info/WHEEL +4 -0
- code_is_magic_markers-0.1.0.dist-info/licenses/LICENSE +21 -0
- markers/__init__.py +29 -0
- markers/_types.py +141 -0
- markers/core.py +120 -0
- markers/descriptors.py +58 -0
- markers/groups.py +88 -0
- markers/marker.py +184 -0
- markers/py.typed +0 -0
- markers/registry.py +129 -0
|
@@ -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,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())
|