Lightweight class introspection toolkit — define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors.
Project description
markers
Lightweight class introspection toolkit for Python. Define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors.
Why markers?
Python's Annotated type lets you attach metadata to fields, but there's no standard way to:
- Define what that metadata looks like (with validation)
- Collect it from a class and its entire MRO
- Query it with a consistent API across fields and methods
- Aggregate it across a family of related classes
markers solves all four. You define markers as classes (with optional pydantic schemas), attach them via Annotated or as decorators, and query everything through descriptors that walk the MRO and cache results automatically.
# Without markers — manual, fragile, no validation
class User:
name: Annotated[str, {"required": True, "max_length": 100}] # just a dict, no validation
email: Annotated[str, {"required": True}]
# With markers — typed, validated, queryable
class User(Validation.mixin):
name: Annotated[str, Validation.Required(), Validation.MaxLen(limit=100)]
email: Annotated[str, Validation.Required()]
User.required # {'name': MemberInfo(...), 'email': MemberInfo(...)}
User.fields["name"].get("max_length").limit # 100 — typed, validated access
Install
pip install code-is-magic-markers
Requires Python 3.10+ and pydantic 2.0+.
Quick start
from typing import Annotated
from markers import Marker, MarkerGroup, Registry
# 1. Define markers — the class body IS the pydantic schema
class Required(Marker): pass
class MaxLen(Marker):
mark = "max_length"
limit: int
class Searchable(Marker):
boost: float = 1.0
analyzer: str = "standard"
class OnSave(Marker):
mark = "on_save"
priority: int = 0
# 2. Bundle into groups
class Validation(MarkerGroup):
Required = Required
MaxLen = MaxLen
class Lifecycle(MarkerGroup):
OnSave = OnSave
# 3. Annotate your classes
class User(Validation.mixin, Lifecycle.mixin):
name: Annotated[str, Validation.Required(), Validation.MaxLen(limit=100)]
email: Annotated[str, Validation.Required()]
bio: Annotated[str, Searchable()] = ""
@Lifecycle.OnSave(priority=10)
def validate(self) -> list[str]:
errors = []
for name, info in type(self).required.items():
if info.is_field and not getattr(self, name, None):
errors.append(f"{name} is required")
return errors
# 4. Query metadata — same dict[str, MemberInfo] everywhere
User.fields # all fields
User.methods # all methods
User.members # both
User.required # only members marked 'required'
User.on_save # only members marked 'on_save'
# Introspect individual members
User.fields["name"].get("max_length").limit # 100
User.methods["validate"].get("on_save").priority # 10
Core concepts
Marker
Subclass Marker to define a marker. The class body is the pydantic schema — typed fields become validated parameters.
class ForeignKey(Marker):
mark = "foreign_key" # explicit name (default: lowercased class name)
table: str # required parameter
column: str = "id" # optional with default
on_delete: str = "CASCADE"
Calling a marker creates a MarkerInstance with validated parameters:
ForeignKey(table="users") # ok
ForeignKey(table="users", column="user_id") # ok — override default
ForeignKey() # ValidationError — 'table' is required
ForeignKey(table="users", extra=True) # ValidationError — unknown field
Schema-less markers accept no parameters:
class Required(Marker): pass
Required() # ok — empty MarkerInstance
Required(x=1) # TypeError — no parameters accepted
Using markers — they work as both Annotated[] metadata and method decorators:
# As field annotation
author_id: Annotated[int, ForeignKey(table="users")]
# As method decorator
@OnSave(priority=10)
def validate(self): ...
Multiple decorators stack naturally:
@OnSave(priority=10)
@Audited()
def save(self): ...
Marker names default to the lowercased class name. Set mark to override:
class PK(Marker):
mark = "primary_key" # queried as .primary_key, not .pk
auto_increment: bool = True
Intermediate bases share schema fields across related markers:
class LifecycleMarker(Marker):
priority: int = 0
class OnSave(LifecycleMarker):
mark = "on_save"
class OnDelete(LifecycleMarker):
mark = "on_delete"
# Both have 'priority' with default 0
OnSave() # priority=0
OnSave(priority=10) # priority=10
OnDelete(priority=5) # priority=5
MarkerGroup
Bundle related markers and produce a .mixin for your model classes. This is how marker descriptors get onto classes.
class DB(MarkerGroup):
PrimaryKey = PrimaryKey
Indexed = Indexed
ForeignKey = ForeignKey
class User(DB.mixin):
id: Annotated[int, DB.PrimaryKey()]
email: Annotated[str, DB.Indexed(unique=True)]
User.primary_key # {'id': MemberInfo(...)}
User.indexed # {'email': MemberInfo(...)}
User.fields # all fields (always available via BaseMixin)
The .mixin automatically provides:
- A marker descriptor for each marker (e.g.
.primary_key,.indexed) — returnsdict[str, MemberInfo]of members carrying that marker .fields,.methods,.membersfromBaseMixin— always available
Composing groups — groups inherit from other groups:
class FullDB(DB):
Unique = Unique
Check = Check
# FullDB.mixin has all of DB's descriptors plus 'unique' and 'check'
Multiple group mixins on one class:
class User(DB.mixin, Validation.mixin, Search.mixin, Lifecycle.mixin):
id: Annotated[int, DB.PrimaryKey()]
name: Annotated[str, Validation.Required(), Search.Searchable(boost=2.0)]
email: Annotated[str, Validation.Required(), DB.Indexed(unique=True)]
@Lifecycle.OnSave(priority=10)
def validate(self): ...
# All descriptors available
User.primary_key # from DB
User.required # from Validation
User.searchable # from Search
User.on_save # from Lifecycle
User.fields # all fields (always)
User.methods # all methods (always)
Reusable mixins
Factor out common field patterns into plain mixins and compose them:
class TimestampMixin:
created_at: Annotated[str, DB.Indexed()]
updated_at: Annotated[str, DB.Indexed()]
class SoftDeleteMixin:
deleted_at: Annotated[str | None, DB.Indexed()] = None
is_deleted: Annotated[bool, Search.Filterable()] = False
class User(TimestampMixin, SoftDeleteMixin, DB.mixin, Search.mixin):
id: Annotated[int, DB.PrimaryKey()]
name: str
# Fields from all mixins are collected via MRO
User.fields # id, name, created_at, updated_at, deleted_at, is_deleted
User.indexed # created_at, updated_at, deleted_at
Registry
Track subclasses and query metadata across all of them:
class Entity(DB.mixin, Validation.mixin, Registry):
id: Annotated[int, DB.PrimaryKey()]
class User(Entity):
name: Annotated[str, Validation.Required()]
class Post(Entity):
title: Annotated[str, Validation.Required()]
List subclasses:
Entity.subclasses() # [User, Post]
Per-class access works the same as always:
User.required # {'name': MemberInfo(...)}
Post.required # {'title': MemberInfo(...)}
Cross-class access via .all gathers into dict[str, list[MemberInfo]]:
Entity.all.required
# {'name': [MemberInfo(owner=User)], 'title': [MemberInfo(owner=Post)]}
Entity.all.fields
# {'id': [MemberInfo(owner=User), MemberInfo(owner=Post)],
# 'name': [MemberInfo(owner=User)],
# 'title': [MemberInfo(owner=Post)]}
Each MemberInfo in the list has .owner so you know which class it came from.
Iterate subclasses with the same per-class API:
for cls in Entity.subclasses():
print(cls.__name__, list(cls.required.keys()))
# User ['name']
# Post ['title']
MemberInfo
Every collected member (field or method) is a MemberInfo with a consistent API:
info = User.fields["name"]
| Attribute / Method | Type | Description |
|---|---|---|
info.name |
str |
Member name |
info.kind |
MemberKind |
MemberKind.FIELD or MemberKind.METHOD |
info.type |
type | None |
The base type (unwrapped from Annotated). None for methods. |
info.owner |
type | None |
The class that defined this member |
info.default |
Any |
Default value, or MISSING if none |
info.has_default |
bool |
True if a default value exists |
info.is_field |
bool |
True if kind == FIELD |
info.is_method |
bool |
True if kind == METHOD |
info.markers |
list[MarkerInstance] |
All markers attached to this member |
info.has(name) |
bool |
Check if a marker is present |
info.get(name) |
MarkerInstance | None |
Get first matching marker |
info.get_all(name) |
list[MarkerInstance] |
Get all matching markers |
MarkerInstance
A MarkerInstance is what you get when you call a marker. Schema fields are accessible as attributes:
inst = Searchable(boost=2.5, analyzer="english")
inst.marker_name # 'searchable'
inst.boost # 2.5
inst.analyzer # 'english'
| Attribute | Type | Description |
|---|---|---|
inst.marker_name |
str |
The marker type name |
inst.<field> |
Any |
Access any schema field as an attribute |
Type checking
The library ships with PEP 561 py.typed support and type stubs for full IDE integration (Pylance, Pyright, mypy).
What's typed automatically
Marker constructors — parameters are fully validated by type checkers via PEP 681 dataclass_transform. Typos, wrong types, and missing required params are all caught:
class MaxLen(Marker):
mark = "max_length"
limit: int
MaxLen(limit=100) # ok
MaxLen(limit="oops") # type error: str is not int
MaxLen() # type error: missing 'limit'
MaxLen(limti=100) # type error: no param 'limti'
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:
class User(DB.mixin):
id: Annotated[int, DB.PrimaryKey()]
for name, info in User.fields.items(): # (str, MemberInfo) — fully typed
print(info.is_field, info.has("primary_key"))
Decorator signatures — @OnSave(priority=10) preserves the decorated function's type. Type checkers see the original return type, not MarkerInstance:
@Lifecycle.OnSave(priority=10)
def validate(self) -> list[str]: ...
reveal_type(User().validate()) # list[str]
Registry .all proxy — .all.fields, .all.methods, .all.members, and .all.<marker_name> are typed as dict[str, list[MemberInfo]].
Marker.collect() — always returns dict[str, MemberInfo], fully typed:
PrimaryKey.collect(User) # dict[str, MemberInfo] — no type: ignore needed
What's not typed automatically
Marker-specific descriptors like .primary_key, .required, .indexed are added dynamically by MarkerGroupMeta and are not visible to type checkers. Two options:
Option A — Use Marker.collect() (no annotations needed):
# Instead of User.primary_key, use:
PrimaryKey.collect(User) # fully typed: dict[str, MemberInfo]
Option B — Add explicit ClassVar annotations (opt-in per class):
from typing import TYPE_CHECKING, ClassVar
class User(DB.mixin):
if TYPE_CHECKING:
primary_key: ClassVar[dict[str, MemberInfo]]
indexed: ClassVar[dict[str, MemberInfo]]
id: Annotated[int, DB.PrimaryKey()]
email: Annotated[str, DB.Indexed(unique=True)]
User.primary_key # now typed as dict[str, MemberInfo]
The TYPE_CHECKING guard ensures these annotations don't affect runtime behavior — the actual values come from the dynamically-installed MarkerDescriptor instances.
How collection works
When you access a descriptor like User.fields or User.required, the library:
- Walks the class MRO in reverse (base classes first, subclass last — so subclasses override)
- Collects fields from
__annotations__+get_type_hints(include_extras=True), extractingMarkerInstanceobjects fromAnnotatedmetadata - Collects methods by finding callables with a
_markersattribute (set by the decorator) - Caches the result per-class using weak references — the cache auto-clears when a class is garbage collected
Private fields (names starting with _) are skipped.
Subclass overrides work naturally — if a child redefines a field, the child's version wins:
class Parent(Validation.mixin):
name: Annotated[str, Validation.Required()]
class Child(Parent):
name: Annotated[str, Validation.Required(), Validation.MaxLen(limit=50)]
Child.fields["name"].owner # Child
Child.fields["name"].has("max_length") # True
Caching and invalidation
Collection results are cached per-class for performance. The cache is:
- Automatic — first access triggers collection, subsequent accesses return cached results
- Weakref-based — if a class is garbage collected, its cache entry is cleaned up automatically
- Invalidatable — call
Marker.invalidate(cls)to clear the cache for a specific class
# First access collects and caches
User.fields # walks MRO, caches result
User.fields # returns cached result (same dict object)
# Invalidate if you've dynamically modified a class
Marker.invalidate(User)
User.fields # re-collects from scratch
You typically don't need to call invalidate() — classes are usually defined once at import time. It's useful if you're dynamically modifying classes in tests or metaprogramming.
Imperative collection
Besides descriptors, you can collect members carrying a specific marker imperatively:
# Via the marker class
Required.collect(User) # {'name': MemberInfo(...), 'email': MemberInfo(...)}
ForeignKey.collect(Post) # {'author_id': MemberInfo(...)}
# Invalidation works on any Marker subclass (or Marker itself)
Marker.invalidate(User)
Required.invalidate(User) # same effect — clears the whole cache for User
API reference
Classes
| Class | Purpose |
|---|---|
Marker |
Subclass to define markers with optional typed schema |
MarkerGroup |
Subclass to bundle markers into a .mixin |
Registry |
Subclass to track all subclasses, provides .subclasses() and .all |
MarkerInstance |
A specific usage of a marker with validated params |
MemberInfo |
Metadata about a field or method |
MemberKind |
Enum: FIELD or METHOD |
MISSING |
Sentinel for fields with no default |
Marker class methods
| Method | Description |
|---|---|
MyMarker.collect(cls) |
Collect members carrying this marker from cls |
Marker.invalidate(cls) |
Clear cached collection for cls |
Descriptors (available on any class using a group mixin)
| Descriptor | Returns | Description |
|---|---|---|
.fields |
dict[str, MemberInfo] |
All field members |
.methods |
dict[str, MemberInfo] |
All method members |
.members |
dict[str, MemberInfo] |
All members (fields + methods) |
.<marker_name> |
dict[str, MemberInfo] |
Members carrying that specific marker |
Registry extras
| Attribute / Method | Returns | Description |
|---|---|---|
.subclasses() |
list[type] |
All registered subclasses |
.all.fields |
dict[str, list[MemberInfo]] |
Fields from all subclasses, grouped by name |
.all.methods |
dict[str, list[MemberInfo]] |
Methods from all subclasses, grouped by name |
.all.members |
dict[str, list[MemberInfo]] |
All members from all subclasses |
.all.<marker_name> |
dict[str, list[MemberInfo]] |
Marker-filtered, from all subclasses |
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file code_is_magic_markers-0.4.0.tar.gz.
File metadata
- Download URL: code_is_magic_markers-0.4.0.tar.gz
- Upload date:
- Size: 85.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
24f3d21c2e9ac64df58b87dd8cca4a7244eaa339808bf392c52b13a894dd035c
|
|
| MD5 |
a3f9e03912d9853ad72c867cb3e06c3e
|
|
| BLAKE2b-256 |
676fcc66f061447dbfa37928a07432db8d357de32f12df5166bafa0f3720cfd7
|
Provenance
The following attestation bundles were made for code_is_magic_markers-0.4.0.tar.gz:
Publisher:
release.yml on Richard-Lynch/markers
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
code_is_magic_markers-0.4.0.tar.gz -
Subject digest:
24f3d21c2e9ac64df58b87dd8cca4a7244eaa339808bf392c52b13a894dd035c - Sigstore transparency entry: 1289173229
- Sigstore integration time:
-
Permalink:
Richard-Lynch/markers@de5cb8aba25fc5ac12dea729617b351c007efcef -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Richard-Lynch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@de5cb8aba25fc5ac12dea729617b351c007efcef -
Trigger Event:
push
-
Statement type:
File details
Details for the file code_is_magic_markers-0.4.0-py3-none-any.whl.
File metadata
- Download URL: code_is_magic_markers-0.4.0-py3-none-any.whl
- Upload date:
- Size: 25.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b820ab3142b3e8727fd3b1268bc8258113545a1040c72a6727269b3f066c8f01
|
|
| MD5 |
917773af1a868734f7b507985d69bc90
|
|
| BLAKE2b-256 |
9764932fe7943075e88f288ec49929f10125861f519fa0aa7d2551d2aa89d64e
|
Provenance
The following attestation bundles were made for code_is_magic_markers-0.4.0-py3-none-any.whl:
Publisher:
release.yml on Richard-Lynch/markers
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
code_is_magic_markers-0.4.0-py3-none-any.whl -
Subject digest:
b820ab3142b3e8727fd3b1268bc8258113545a1040c72a6727269b3f066c8f01 - Sigstore transparency entry: 1289173301
- Sigstore integration time:
-
Permalink:
Richard-Lynch/markers@de5cb8aba25fc5ac12dea729617b351c007efcef -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Richard-Lynch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@de5cb8aba25fc5ac12dea729617b351c007efcef -
Trigger Event:
push
-
Statement type: