Lightweight class introspection toolkit for Python. Define typed markers, annotate fields and methods, collect metadata via MRO-walking descriptors.
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 accesspip install code-is-magic-markersRequires Python 3.10+ and pydantic 2.0+.
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 # 10Subclass 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 fieldSchema-less markers accept no parameters:
class Required(Marker): pass
Required() # ok — empty MarkerInstance
Required(x=1) # TypeError — no parameters acceptedUsing 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 = TrueIntermediate 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=5Bundle 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)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_atTrack 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']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 |
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 |
The library ships with PEP 561 py.typed support and type stubs for full IDE integration (Pylance, Pyright, mypy).
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 neededMarker-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.
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") # TrueCollection 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 scratchYou 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.
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| 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 |
| Method | Description |
|---|---|
MyMarker.collect(cls) |
Collect members carrying this marker from cls |
Marker.invalidate(cls) |
Clear cached collection for cls |
| 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 |
| 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 |
MIT