๐ Technical Design 029 โ Member Parsing Seam Architecture¶
| Field | Value |
|---|---|
| ID | TD-029 |
| Title | Member Parsing Seam โ Composable, Multi-Strategy Parsing for Complex Introspection |
| Status | Draft |
| Owner | Reflection maintainers |
| Audience | Reflection/DI contributors, ORM and framework integrators |
| Created | 2025-10-25 |
| Last Updated | 2025-11-09 (Consolidated architecture under engine; renamed to Generation terminology) |
| Reviewers | TBD |
| Approvers | TBD |
| Tracking | Phase C of 002-reflection-modernization-roadmap.md |
| Related | TD-030 โ Reflection Handle Hierarchy Rationalization (foundation), TD-027 โ ReflectionContext API Simplification, TD-028 โ Reflection Metrics Architecture |
๐งญ 1. Summary¶
MemberBuilder mixes discovery, enrichment, caching, and metrics inside one monolithic class. TD-029 replaces it with a single HandleGenerationStrategy protocol and pluggable implementations, letting TypeResolver orchestrate specs via a depth-first parsing context that manages ownership chains and circular references.
At a glance:
- Single
HandleGenerationStrategyprotocol unifying identify + enrich responsibilities. - Source-specific implementations (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper) compose in pipeline.
- Depth-first discovery via stack (LIFO) minimizes memory footprint.
- Immutable specs use copy() + with_*() methods for enrichment.
- Per-strategy metrics and deduplication handling for circular imports/references.
- All strategy components live in
loryth.common.reflection.engine.strategies(public API viaengine/__init__.py).
๐ 2. Context and Problem Statement¶
MemberBuilder was designed when reflection scenarios were limited to core Python constructs. As frameworks layered metaclasses, descriptors, runtime mutation, and domain-specific metadata, the hardcoded parsing pipeline grew to ~450 lines with intertwined responsibilities. Today the class couples cache handling, discovery, metadata enrichment, and observability, making it nearly impossible to add new behavior without editing private methods. Testing pathways in isolation requires deep mocking because the only seam is the public build_* entry points.
Pain points driving this design:
- No extensibility: custom member types (ORM columns, descriptors) require editing
MemberBuilderinternals. - Hardcoded detection:
_process_callable_or_field_member()embeds method vs field heuristics that cannot be swapped. - Monolithic complexity: seven parsing responsibilities live in one class, exceeding our complexity guardrails.
- Testing friction: contributors cannot unit-test specific parsing behaviors without invoking the entire builder.
- Maintenance drag: supporting emerging Python proposals (e.g., PEP 681) requires coordinated edits across multiple private helpers.
- Missing composition: teams cannot combine core parsing with framework-specific discovery in a controlled order.
2.1 Goals¶
- Publish
HandleGenerationStrategyprotocol: single interface for identifying + enriching specs with stackability contracts and examples. - Extract core mappers: create source-type implementations (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper) each โค100 lines.
- Deterministic composition: define ordering semantics so multiple strategies refine specs safely (last-writer-wins per qualname).
- Preserve public APIs: keep
TypeResolver.resolve()signature and cache identity guarantees untouched. - Observable extensibility: provide per-strategy metrics namespaces (
handle_generation.<slug>) so performance/failures are attributable. - Contract + integration coverage: deliver suites that validate pipeline orchestration and composed configurations.
- Zero regression: prove parity with current MemberBuilder outputs across fixtures.
- Public API clarity: export strategy components from
loryth.common.reflection.enginefor external customization.
2.2 Out of Scope¶
- Forward-reference resolution algorithms (handled in a later roadmap phase).
- Type-checking/type-provider redesign (Phase F of the roadmap).
- Performance optimization work beyond the complexity reductions here.
- Annotation/Type Hint Provider overhaul (Phase E of the roadmap).
2.3 Assumptions and Constraints¶
- Python 3.13+ with strict mypy, PEP 695 generics, and updated typing features.
- Immutable member identity: once discovered,
qualnamestays fixed; specs enrich metadata only. - Metrics never-raise: HandleBuildingPipeline instrumentation wraps every strategy invocation and suppresses metric failures.
- Qualified addressing: all members are keyed by
qualnamethroughout introspection phases. - External strategy access: all strategy components are part of public API in
loryth.common.reflection.engine.
๐งฉ 3. Design Overview¶
The core insight: Spec generation is fundamentally mapping a source object (module, class, function, parameter, property) to a Spec. A single-strategy pipeline maps sources to specs via map(source, context) -> spec, where strategies can enrich the spec and enqueue discovered members for recursive processing.
Narrative flow: A caller asks TypeResolver to resolve my_app.Customer. On a cache miss, the resolver creates a HandleGenerationContext seeded with a placeholder for my_app.Customer and delegates to HandleBuildingPipeline. The context manages a depth-first stack of members to build:
- Pop
my_app.Customer(placeholder spec) - Import the class, run mappers via
pipeline.map(Customer_class, placeholder_spec, context) - Mappers identify it's a type, enrich with methods/properties
- Enrichment discovers
place_order()method โ callscontext.ensure_member(method_spec)โ pushed to stack - Pop
my_app.Customer.place_order(placeholder spec) - Run mappers on the method โ discovers parameters โ pushed to stack
- Continue until stack empty
finalize_all()materializes the graph with ownership chains intact- Resolver caches and returns
TypeHandleto caller
3.1 Key Components¶
| Component | Purpose |
|---|---|
HandleGenerationStrategy | Single map(source, context) -> spec protocol that identifies a source and enriches its spec with discovered members. Multiple source-specific implementations (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper, etc.). |
MappingContext | Immutable context providing source object, skeleton spec, parent type, owning module, and ensure_member() helper for enqueueing discovered members. |
| Typed Specs | Immutable classes using copy() + with_*() pattern (ModuleSpec, ClassSpec, FunctionSpec, ParameterSpec, etc.) enriched via copy() chains. |
HandleGenerationContext | Stack/registry manager: tracks pending specs (LIFO), maintains spec registry, deduplicates circular references, manages finalization. |
HandleBuildingPipeline | Orchestrates mapping strategies in sequence, wraps execution with metrics, pops pending specs from stack for processing. |
TypeResolver | Public API that instantiates HandleGenerationContext + Pipeline on cache miss, manages source imports, caches finalized graph. |
3.2 Architecture¶
graph TD
User["User calls<br/>resolver.resolve()"]
CacheHit{Cached?}
ParsingSetup["Create ParsingContext<br/>with root qualname"]
Loop["Dequeue pending<br/>builder"]
Import["Import source<br/>object"]
Mappers["Run mapping<br/>strategies"]
Enrich["Strategies identify<br/>+ enrich builder"]
Discover["Discovered members<br/>enqueued via<br/>ensure_member()"]
LoopCheck{More<br/>pending?}
Finalize["finalize_all()<br/>materializes graph"]
Cache["Cache result"]
Return["Return Handle<br/>to caller"]
User --> CacheHit
CacheHit -->|yes| Return
CacheHit -->|no| ParsingSetup
ParsingSetup --> Loop
Loop --> Import
Import --> Mappers
Mappers --> Enrich
Enrich --> Discover
Discover --> LoopCheck
LoopCheck -->|yes| Loop
LoopCheck -->|no| Finalize
Finalize --> Cache
Cache --> Return
classDef process fill:none,stroke:#1976D2,stroke-width:2px,color:#1976D2;
classDef decision fill:none,stroke:#FF9800,stroke-width:2px,color:#FF9800;
classDef terminal fill:none,stroke:#4CAF50,stroke-width:2px,color:#4CAF50;
class Mappers,Enrich,Discover process;
class CacheHit,LoopCheck decision;
class Return terminal; 3.3 Interface Impact (Before โ After)¶
| Public surface | Before | After |
|---|---|---|
| TypeResolver public API | Cache miss path intertwined with builder internals | Same resolve() signature, but internally instantiates ParsingContext + ParsingPipeline on cache miss. |
| Strategy authoring | No public contracts for mapping/enrichment | Single HandleBuildingStrategy protocol with map(source, context) -> builder, source-specific implementations (ModuleHandleMapper, TypeHandleMapper, etc.). |
| Strategy composition | No composition; must override MemberBuilder internals | Multiple strategies execute in sequence; later strategies refine earlier results. Custom strategies chain after core mappers. |
| Metrics namespace | Aggregated counters under member_parsing.* with no attribution | Namespaced counters per strategy: handle_building.<slug> (e.g., handle_building.module, handle_building.type, handle_building.function). |
| Member enqueueing | N/A | Strategies call context.ensure_member(builder) to enqueue discovered members for recursive processing. ParsingContext manages breadth-first queue. |
3.4 Removal/Deprecation Policy¶
The legacy MemberBuilder faรงade is removed. Callers now compose ParsingPipeline, ParsingContext, and TypeResolver instances (directly or via thin factories) to configure custom stacks. Deprecated behaviorโoverriding MemberBuilder internals or mutating MemberHandle directlyโis no longer supported; contributors must implement strategies instead.
โ๏ธ 4. Runtime Flow¶
4.1 ParsingContext Lifecycle¶
ParsingContext owns the full lifecycle of builders during a cache miss. It manages a depth-first stack (LIFO) to minimize memory footprint during discovery.
class ParsingContext:
"""Manages builder lifecycle: discovery, registration, finalization.
Uses a stack (LIFO) for depth-first discovery to minimize peak memory usage.
When building Customer, immediately process its methods, then their parameters,
before discovering other top-level types.
"""
_builder_registry: dict[str, HandleBuilder]
"""Stores the latest builder (placeholder or concrete) per qualname.
Acts as authoritative source: strategies look up existing builders here
before creating placeholders.
"""
_pending: list[HandleBuilder]
"""Stack (LIFO) of builders to process. Depth-first discovery.
Use stack.pop() to get next item (LIFO). When builder discovered,
push it onto stack to process immediately (depth-first).
"""
_finalized: dict[str, HandleBuilder]
"""Builders that have been fully processed and materialized.
Tracks which builders are done to detect circular references.
next_pending() skips qualnames already in _finalized.
"""
_cache: HierarchicalCache
"""Read-only cache for type lookups during mapping."""
def register_builder(self, builder: HandleBuilder) -> None:
"""Add or replace a builder in the registry. Deduplicates by qualname.
:param builder: Builder to register. If qualname exists, replaces old entry.
"""
def next_pending(self) -> HandleBuilder | None:
"""Pop next builder from stack for processing. Skips finalized ones.
Handles circular imports: if a qualname is already finalized, skip it.
Returns None when stack empty.
:return: Next HandleBuilder to process, or None if stack empty.
"""
def finalize_all(self) -> dict[str, HandleBuilder]:
"""Materialize all builders. Called when stack is empty.
Walks builder registry, materializes each builder that hasn't been
finalized yet. Circular references resolve automatically since both
handles are in the registry.
:return: dict mapping qualname โ finalized HandleBuilder.
"""
Depth-First Processing Example: Customer โ place_order โ parameter_order_id
resolve("my_app.Customer")misses cache โ ParsingContext created with stack = [Customer placeholder]- Pop Customer placeholder, import class, run mappers
- Mappers discover
place_order()method โ callcontext.ensure_member(method_placeholder)โ push onto stack - Pop place_order placeholder, import method, run mappers
- Mappers discover
order_idparameter โ callcontext.ensure_member(param_placeholder)โ push onto stack - Pop parameter placeholder, run mappers
- No more pending โ
finalize_all()materializes with ownership chains intact
Memory benefit: Stack is small throughout (only current branch depth); contrast with breadth-first where all Customer's methods would sit in queue before processing any.
Circular Reference Handling: Customer โ Order
- Pop Customer, mappers discover Order reference โ
ensure_member(Order placeholder)โ push to stack - Pop Order (or complete Customer first, depending on push order), mappers discover Customer reference โ Customer already in registry โ skip (dedup)
- Finalization resolves both: Customer.orders โ [Order], Order.customer โ Customer (same finalized instance)
4.2 MappingContext¶
The context provided to each mapping strategy:
@dataclass(frozen=True)
class MappingContext:
source: object # Python object being mapped
builder: HandleBuilder # Placeholder to enhance
parent: TypeHandleBuilder | None # Containing type (if applicable)
module: ModuleHandleBuilder # Owning module (guaranteed)
cache: HierarchicalCache # Read-only cache
_parsing_context: ParsingContext # Private reference
def ensure_member(self, builder: HandleBuilder) -> HandleBuilder:
"""Enqueue discovered member builder for recursive processing."""
self._parsing_context.register(builder)
self._parsing_context._pending.append(builder)
return builder
def get_builder(self, qualname: str) -> HandleBuilder | None:
"""Look up an already-discovered builder."""
return self._parsing_context._registry.get(qualname)
Immutability guarantee: Strategies cannot mutate the context. They return enhanced builders via dataclasses.replace().
4.3 ParsingPipeline Orchestration¶
class ParsingPipeline:
def __init__(self, mapping_strategies: Sequence[HandleBuildingStrategy], metrics: ...):
self._mapping_strategies = [ModuleHandleMapper(), TypeHandleMapper(), FunctionHandleMapper(), ...]
def map(self, source: object, builder: HandleBuilder, parsing_context: ParsingContext,
parent: TypeHandleBuilder | None, module: ModuleHandleBuilder) -> HandleBuilder:
"""Execute mapping strategies in sequence."""
context = MappingContext(source, builder, parent, module, parsing_context._cache, parsing_context)
current_builder = builder
for strategy in self._mapping_strategies:
current_builder = self._execute_with_metrics(
strategy,
lambda: strategy.map(source, dataclasses.replace(context, builder=current_builder))
)
return current_builder
Metrics Emission: - Strategy executes: strategy.map(source, context) - Wraps with try/finally to measure duration, count, errors - Namespace: handle_building.<strategy_slug> (e.g., handle_building.module, handle_building.type) - Metrics never raise; failures are logged but don't interrupt mapping
๐งฑ 5. Strategy & Builder Catalog¶
5.1 HandleBuilder Contract¶
Builders are dataclasses with type-safe fields. Strategies use dataclasses.replace() for immutable enrichment.
@dataclass(frozen=True)
class HandleBuilder:
"""Immutable builder for Handle objects.
Use dataclasses.replace() to create enhanced copies with new attributes.
Frozen ensures thread-safety and prevents accidental mutations.
"""
qualname: str
"""Fully qualified name (e.g., "my_app.Customer"). Set once at creation, never changes."""
module: ModuleHandleBuilder | None = None
"""Owning module. Set during mapping, read-only."""
parent: TypeHandleBuilder | None = None
"""Containing type for methods/fields. Set during mapping, read-only."""
@classmethod
def placeholder(cls, qualname: str) -> "HandleBuilder":
"""Create a lightweight placeholder builder (just qualname).
:param qualname: Fully qualified name of the builder.
:return: Placeholder builder with only qualname set.
"""
return cls(qualname=qualname)
Concrete Builders (example):
@dataclass(frozen=True)
class TypeHandleBuilder(HandleBuilder):
"""Builder for TypeHandle objects."""
bases: tuple[TypeHandleBuilder, ...] = ()
"""Base classes."""
type_hints: dict[str, TypeHandleBuilder] | None = None
"""Type hints for type parameters."""
methods: list[MethodHandleBuilder] = field(default_factory=list)
"""Discovered methods."""
Usage Pattern:
# Strategies enrich builders using dataclasses.replace()
builder = context.builder # TypeHandleBuilder
# Instead of: builder.with_attr("bases", bases)
# Use:
builder = replace(builder, bases=bases)
# Chain multiple enrichments
builder = replace(builder, bases=bases, type_hints=hints)
Design principles: - Type-safe API: Each field is explicitly typed; no string keys - Immutable by design: frozen=True prevents mutations - Minimal placeholders: Just qualname at creation; other fields populated during mapping - Dataclasses.replace(): Leverages standard library; no custom clone logic needed
5.3 HandleBuildingStrategy Protocol¶
A single protocol that both identifies a source's type AND enriches the builder with discovered members. Source-specific implementations (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper) execute in sequence, each enhancing the builder.
class HandleBuildingStrategy(Protocol):
"""Maps a source object to an enhanced Handle builder.
Responsibilities:
1. Identify: Determine source type (module, type, function, property, etc.)
2. Enrich: Discover members and enqueue them via context.ensure_member()
3. Return: Enhanced builder (immutable, via with_attr() chaining)
Multiple strategies execute in sequence. Later strategies refine earlier results.
Strategies never return None; pass through unchanged if not applicable.
"""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
"""Transform a source object and builder into an enhanced builder.
:param source: The Python object being mapped (module, class, function, etc.).
:param context: Mapping context with builder, parent, module, cache, ensure_member().
:return: Enhanced builder with discovered members and metadata.
:raises ValueError: If source is unsupported and strategy requires strict validation.
:rtype: HandleBuilder
"""
...
Contract highlights: - Single responsibility: Identify the source AND enrich with members in one pass - Pipeline composition: Multiple strategies execute in order; each refines the builder - Member enqueueing: Discovered members enqueued via context.ensure_member(placeholder) for recursive processing - Ownership guarantee: Parent type and owning module are guaranteed to be building in parallel - Immutability: Return new builder via dataclasses.replace(), never mutate input
5.4 Source-Specific Mapper Implementations¶
Core mappers handle standard Python objects (modules, types, functions). Custom mappers chain after core mappers to specialize behavior.
5.4.1 ModuleHandleMapper¶
from dataclasses import replace
class ModuleHandleMapper(HandleBuildingStrategy):
"""Maps module objects to ModuleHandle builders."""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
if not isinstance(source, types.ModuleType):
return context.builder # Not applicable, pass through
# Identify: core module properties
builder = replace(context.builder,
qualname=source.__name__,
is_package=hasattr(source, "__path__"))
# Enrich: discover submodules (if package)
if hasattr(source, "__path__"):
for _, name, is_pkg in pkgutil.iter_modules(source.__path__):
submod = ModuleHandleBuilder.placeholder(f"{source.__name__}.{name}")
context.ensure_member(submod)
# Enrich: discover types and functions
for name, obj in vars(source).items():
if isinstance(obj, type) and obj.__module__ == source.__name__:
type_builder = TypeHandleBuilder.placeholder(f"{source.__qualname__}.{name}")
context.ensure_member(type_builder)
elif inspect.isfunction(obj) and obj.__module__ == source.__name__:
func_builder = FunctionHandleBuilder.placeholder(f"{source.__qualname__}.{name}")
context.ensure_member(func_builder)
return builder
5.4.2 TypeHandleMapper¶
from dataclasses import replace
class TypeHandleMapper(HandleBuildingStrategy):
"""Maps class objects to TypeHandle builders."""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
if not isinstance(source, type):
return context.builder
# Identify
builder = replace(context.builder,
qualname=source.__qualname__,
module=context.module,
bases=source.__bases__)
# Enrich: discover methods, fields, properties
for name, value in vars(source).items():
if inspect.isfunction(value):
method = MethodHandleBuilder.placeholder(f"{source.__qualname__}.{name}")
context.ensure_member(method)
elif isinstance(value, property):
prop = PropertyHandleBuilder.placeholder(f"{source.__qualname__}.{name}")
context.ensure_member(prop)
return builder
5.4.3 FunctionHandleMapper¶
from dataclasses import replace
class FunctionHandleMapper(HandleBuildingStrategy):
"""Maps function/method objects to FunctionHandle builders."""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
if not (inspect.isfunction(source) or inspect.ismethod(source)):
return context.builder
# Identify
builder = replace(context.builder,
qualname=source.__qualname__,
module=context.module)
# Set parent if this is a method
if context.parent is not None:
builder = replace(builder, type=context.parent)
# Enrich: discover parameters
sig = inspect.signature(source)
for param_name, param in sig.parameters.items():
param_builder = ParameterHandleBuilder.placeholder(
f"{source.__qualname__}.{param_name}"
)
context.ensure_member(param_builder)
return builder
5.5 Custom Strategy Example: SQLAlchemy¶
Framework integrators extend the mapper pipeline to handle domain-specific objects. Custom mappers chain AFTER core mappers:
from dataclasses import replace
class SQLAlchemyHandleMapper(HandleBuildingStrategy):
"""Discovers SQLAlchemy ORM columns and relationships as field handles."""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
# Only handle SQLAlchemy mapped classes
if not isinstance(source, type) or not hasattr(source, "__mapper__"):
return context.builder # Not an ORM model, pass through
# Mapper already processed by TypeHandleMapper; enrich with ORM details
mapper = source.__mapper__
builder = context.builder
# Add ORM columns as field handles
for column in mapper.columns:
field = FieldHandleBuilder.placeholder(f"{source.__qualname__}.{column.name}")
field = replace(field, orm_column=column)
context.ensure_member(field)
# Add ORM relationships with links
for rel in mapper.relationships:
field = FieldHandleBuilder.placeholder(f"{source.__qualname__}.{rel.key}")
field = replace(field, orm_relationship=rel)
# Enqueue the related type for processing
target_qualname = rel.mapper.class_.__module__ + "." + rel.mapper.class_.__qualname__
target = TypeHandleBuilder.placeholder(target_qualname)
context.ensure_member(target)
context.ensure_member(field)
return builder
5.6 Type Resolver Orchestration¶
TypeResolver keeps its public API (resolve(dotted_path: str) -> TypeHandle), but internally orchestrates the ParsingContext and ParsingPipeline on cache miss:
class TypeResolver:
def resolve(self, dotted_path: str) -> TypeHandle:
"""Resolve a type by dotted path.
:param dotted_path: Fully qualified name (e.g., "my_app.Customer").
:return: Fully resolved TypeHandle with ownership chains intact.
"""
# Cache hit: return immediately
cached = self._cache.get(dotted_path)
if cached is not None:
return cached
# Cache miss: spin up parsing context + pipeline
context = ParsingContext(root_qualname=dotted_path, cache=self._cache)
# Depth-first processing (LIFO stack)
while (pending_builder := context.next_pending()) is not None:
# Import the source object
source_obj = self._import_from_qualname(pending_builder.qualname)
# Determine parent and module contexts
parent, module = self._resolve_context(pending_builder.qualname)
# Run mappers
enhanced = self._pipeline.map(source_obj, pending_builder, context, parent, module)
# Register back into context
context.register(enhanced)
# Finalize and cache
finalized = context.finalize_all()
self._cache.update(finalized)
return finalized[dotted_path]
๐ง 6. Semantics¶
- Mapping-centric design: Handle creation is fundamentally about mapping a source object (module, class, function, property) to a HandleBuilder. Identification and enrichment happen in a single pass per source type.
- Single-strategy protocol:
HandleBuildingStrategyunifies identify + enrich responsibilities. Multiple source-specific implementations (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper) execute in sequence, each enhancing the builder. - Breadth-first discovery: ParsingContext maintains a queue of pending builders. When a strategy discovers a member, it enqueues a placeholder via
context.ensure_member(). The queue ensures breadth-first processing: all of Customer's methods are discovered before any method's parameters are discovered. - Ownership chain guarantee: The parsing context ensures that when building a MethodHandle, its containing TypeHandle is already in the builder registry (and its containing ModuleHandle is guaranteed). This enables
context.parentandcontext.moduleto be set correctly during mapping. - Scope expansion (Option A): Resolving
my_app.Customerexpands to build Customer AND all its methods AND all their parameters AND any referenced types. Full scope expansion ensures complete ownership chains without gaps. Deferred lazy-hydration (Phase C of roadmap-002) will optimize this. - Deterministic stacking: Strategies execute in pipeline order. Later strategies refine earlier results (last-writer-wins per qualname). Each source gets mapped exactly once per resolve cycle.
- Builder immutability: Every
with_attr()returns a new builder; strategies never mutate input builders. This preserves references and enables safe composition. - Circular reference handling: When Customer โ Order โ Customer, both types get enqueued. When dequeued, the second reference skips (already in registry). Finalization resolves links automatically.
- Metrics isolation:
_execute_with_metrics()wraps each strategy, measuring duration/count/errors underhandle_building.<slug>. Metrics never raise; failures are logged but don't interrupt mapping. - Resolver boundary:
TypeResolver.resolve(path)handles cache hits, instantiates ParsingContext + ParsingPipeline on miss, manages imports, and persists results. External API unchanged; internal orchestration is cleaner.
๐งช 7. Examples¶
7.1 Custom SQLAlchemy Mapper¶
Framework integrators extend the core mapping pipeline by chaining custom mappers after the core set. Custom mappers refine builders without rediscovering core members.
from dataclasses import replace
class SQLAlchemyHandleMapper(HandleBuildingStrategy):
"""Enriches type handles with SQLAlchemy ORM metadata."""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
# Only handle SQLAlchemy mapped classes
if not isinstance(source, type) or not hasattr(source, "__mapper__"):
return context.builder # Pass through if not ORM
mapper = source.__mapper__
builder = context.builder
# Discover ORM columns as field handles
for column in mapper.columns:
field = FieldHandleBuilder.placeholder(f"{source.__qualname__}.{column.name}")
field = replace(field, orm_column=column)
context.ensure_member(field)
# Discover ORM relationships with type linking
for rel in mapper.relationships:
field = FieldHandleBuilder.placeholder(f"{source.__qualname__}.{rel.key}")
field = replace(field, orm_relationship=rel)
# Enqueue the related type
target_type = TypeHandleBuilder.placeholder(rel.mapper.class_.__qualname__)
context.ensure_member(target_type)
context.ensure_member(field)
return builder
Usage: Chain after core mappers to add ORM semantics.
7.2 Custom Descriptor Mapper¶
User-defined descriptors can be treated as first-class members via a custom mapper:
from dataclasses import replace
class DescriptorHandleMapper(HandleBuildingStrategy):
"""Discovers descriptor implementations on classes."""
def map(self, source: object, context: MappingContext) -> HandleBuilder:
if not isinstance(source, type):
return context.builder
builder = context.builder
# Find descriptor implementations (classes with __get__ and __set__)
for name, value in vars(source).items():
if hasattr(value, "__get__") and hasattr(value, "__set__"):
descriptor = PropertyHandleBuilder.placeholder(f"{source.__qualname__}.{name}")
descriptor = replace(descriptor, descriptor_type=type(value).__name__)
context.ensure_member(descriptor)
return builder
7.3 ReflectionContext Integration¶
Wire custom mappers into the parsing pipeline by composing them after core mappers:
from loryth.common.reflection import ReflectionContext
from loryth.common.collections import ConcurrentHierarchicalCache
# Create pipeline with core + custom mappers
cache = ConcurrentHierarchicalCache()
pipeline = ParsingPipeline(
mapping_strategies=[
# Core mappers (always first)
ModuleHandleMapper(),
TypeHandleMapper(),
FunctionHandleMapper(),
# Custom mappers (chain after core)
SQLAlchemyHandleMapper(),
DescriptorHandleMapper(),
],
metrics=ReflectionMetricsCollector("handle_building"),
)
# Create resolver with pipeline
resolver = TypeResolver(cache=cache, pipeline=pipeline)
# Use in ReflectionContext
context = ReflectionContext(type_resolver=resolver)
# Resolve a type: builds Customer + methods + parameters + ORM columns + relationships
customer_handle = context.resolve_type("my_app.models.Customer")
Expected behavior: - Customer handle includes standard methods/properties (from TypeHandleMapper) - Customer handle includes ORM columns/relationships (from SQLAlchemyHandleMapper) - All related types (Order, User, etc.) are fully resolved via breadth-first queueing - Ownership chains intact: Customer.orders[0].customer points back to Customer (shared finalized instance)
๐งช 8. Test Matrix¶
8.1 Unit Coverage¶
| Target | Coverage | Notes |
|---|---|---|
| HandleBuildingStrategy implementations | โฅ80% branch coverage | Core mappers (Module, Type, Function) tested for identity + enrichment. |
| MappingContext immutability | โฅ90% coverage | Verify context cannot be mutated; builder clones work correctly. |
| ParsingContext queue management | โฅ90% coverage | Test next_pending(), register_builder(), circular deduplication. |
| ParsingPipeline orchestration | โฅ90% coverage | Strategy execution order, metrics wrapping, error handling. |
| Builder API (with_attr chains) | โฅ85% coverage | Test fluent API, placeholder creation, attribute accumulation. |
8.2 Integration Coverage¶
| Scenario | Validation |
|---|---|
| Default strategy stack | Compare outputs against current golden fixtures (regression parity). |
| Mixed core + custom mappers | Verify override/refinement semantics (later mappers refine earlier). |
| Cache hit/miss paths | Ensure cache hits bypass pipeline; misses trigger full resolution. |
| Custom mapper composition | Test SQLAlchemy, descriptor, and other framework-specific mappers. |
8.3 Edge Case Coverage (Required)¶
| Edge Case | Scenario | Validation Strategy |
|---|---|---|
| 1. Circular Imports | Module A imports Module B; Module B imports Module A | Assert: next_pending() skips already-finalized qualnames; no infinite loops; both modules in final registry. |
| 3. Deeply Nested Members | Module โ Type โ Method โ Parameter โ Type โ Method (5+ levels) | Assert: all levels build correctly; ownership chains intact; finalization completes without stack overflow. |
| 7. Circular Type References | Customer type has Order field; Order has Customer field | Assert: both types enqueued once; finalization resolves links; Customer.orders[0].customer โ same Customer instance. |
8.4 Accepted Risks (Deferred to Future Phases)¶
| Risk | Description | Current Behavior | Mitigation Strategy | Target Phase |
|---|---|---|---|---|
| 2. Unexpectedly Large Scope | resolve_type("Customer") builds entire module + dependencies | Full scope expansion (Option A) | Lazy-hydration (Phase C) will optimize | Phase C |
| 4. Conditional/Dynamic Members | Runtime-added attributes not discovered statically | Scope limited to vars(source) | Accept as static reflection limitation | Future enhancement |
| 5. Forward References in Type Hints | String forward refs not resolved during mapping | Skip resolution during member discovery | Type-hint resolution phase (Phase F) handles this | Phase F |
| 6. Test Performance Regression | Tests become slow due to full scope building | Accept slower baseline | Add mock mappers to limit scope; benchmark | Phase C optimization |
โ 9. Acceptance Criteria¶
- HandleBuildingStrategy protocol: Single protocol with
map(source, context) -> buildersignature published and documented; source-specific implementations (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper) with โฅ80% unit coverage. - MappingContext immutability: Context is frozen dataclass; strategies cannot mutate it; all enrichment via
with_attr()builder API; โฅ90% coverage. - ParsingContext queue management:
next_pending()correctly deduplicates (skips finalized qualnames);ensure_member()andregister_builder()work correctly; circular deduplication tested; โฅ90% coverage. - Edge Case 1 (Circular Imports): Module A โ Module B circular import handled without infinite loops; both in final registry; test assertion verifies deduplication.
- Edge Case 3 (Deeply Nested): 5+ level nesting (Module โ Type โ Method โ Parameter โ Type โ Method) builds correctly with intact ownership chains; finalization completes without stack overflow.
- Edge Case 7 (Circular Types): Customer โ Order bidirectional references resolve correctly; both enqueued once; finalized graph shows shared instances (Customer.orders[0].customer == original Customer).
- Accepted Risks Documented: Risks 2, 4, 5, 6 are documented with current behavior, mitigation strategy, and target phase for resolution.
- Runtime flow:
TypeResolver+ParsingContextbreadth-first processing works end-to-end; cache hits bypass pipeline; cache misses expand full scope correctly. - Default strategy parity: Core mappers produce outputs matching current golden fixtures (regression parity).
- Metrics instrumentation: Metrics emitted under
handle_building.<slug>namespace with never-raise semantics; โฅ90% coverage. - Migration guide: Custom mapper authoring guide with SQLAlchemy and descriptor examples; contributors can extend pipeline safely.
๐ 10. Rollout and Migration¶
10.1 Implementation Phases¶
Phase 1 โ Foundation & Core Strategy Extraction (โณ Pending) - Create reflection/strategies/ package, define protocols, contexts, and ParsingPipeline. - Extract class/module/package inspectors plus type-hint/visibility/docstring annotators from MemberBuilder. - Update TypeResolver internals to instantiate a parsing context + pipeline on cache miss while preserving the public resolve() signature. - Wire default strategies into the pipeline, seed builder factories, and run golden regression fixtures for parity. - Instrument the pipeline with _execute_with_metrics() and validate namespaces via local dashboards. - Outcome: the default builder path uses strategies with no observable API change.
Phase 2 โ Composability, Merging & Contract Tests (โณ Pending) - Implement metadata + member merging utilities (qualname dedupe, dictionary merge) and provenance tracking. - Extend the builder index so inspectors can emit placeholders and annotators can resolve related members safely. - Ship reusable inspection/annotation contract tests plus stacked-strategy integration suites (core + custom mixes). - Document deterministic ordering rules, builder placeholder semantics, and strategy registration; update metrics coverage to include custom slugs. - Outcome: stacking semantics are proven with automated tests guarding regressions, and external contributors have a contract harness to validate their strategies.
Phase 3 โ Examples, Documentation & Migration (โณ Pending) - Deliver example strategies (SQLAlchemy, descriptors, runtime attributes) plus an authoring guide and FAQ. - Provide migration documentation with before/after snippets, DI/runtime integration tests, and performance + memory benchmarks. - Update contributor docs (reflection-architecture.md, roadmap) and add monitoring dashboards for the new metrics namespaces. - Partner with framework teams to pilot custom strategies and capture telemetry/ergonomics feedback. - Outcome: maintainers and integrators have prescriptive guidance plus telemetry to adopt the seam safely, backed by pilot implementations.
Phase 4 โ Remove Legacy MemberBuilder (โณ Pending) - Identify and update all tests that depend on MemberBuilder internals; migrate to builder-based assertions or remove/replace tests as needed. - Remove MemberBuilder class and all internal parsing methods from the codebase. - Update any remaining DI or reflection code that may reference deprecated helpers (e.g., build_type, build_module). - Run full regression suite to ensure no observable API changes for end-users (public TypeResolver API remains unchanged). - Outcome: legacy monolithic builder is completely removed; codebase is cleaner and uses only the strategy-based architecture.
10.2 Migration Strategy¶
Existing Reflection Codebase - Keep calling TypeResolver.resolve() / ReflectionContext.resolve_type(); the resolver now spins up parsing contexts internally, so no call-site changes are required. - When legacy code touches MemberBuilder internals, migrate to HandleBuildingStrategy implementations and register with the shared pipeline. - Replace any direct MemberHandle mutation with dataclasses.replace() on builder instances; regression fixtures should compare finalized graphs before/after.
Framework / ORM Integrators - Provide custom mappers for framework-specific discovery (e.g., SQLAlchemyHandleMapper) by implementing map() and enqueueing specialized builders via context.ensure_member(). - Use dataclasses.replace() to enrich builders with framework metadata rather than mutating handles directly. - Compose pipelines/resolvers through small factories (as shown in ยง7.3) so DI containers and reflection entry points can swap strategy stacks without subclassing. - Use before/after verification by comparing finalized MemberHandle graphs with and without the new strategies, and rely on pipeline metrics to monitor custom hook performance.
โ ๏ธ 11. Risks and Mitigations¶
| Risk | Mitigation |
|---|---|
| Conflicting inspectors redefine the same member inconsistently | Deterministic ordering + provenance metadata, plus contract tests that enforce last-writer-wins semantics. |
| Custom annotators accidentally mutate identity | Contracts require preserving qualname/MemberKind; regression tests guard against drift. |
| Performance regressions from additional strategy loops | Cache short-circuit remains first, strategies keep โคO(n) complexity, and benchmarks run in Phase 3. |
| Metrics namespace explosion or collisions | Adapter enforces <phase>.<core|custom>.<slug> naming and validates during registration. |
| Migration confusion for contributors accustomed to overriding MemberBuilder internals | Authoring guide + migration examples plus deprecation notices for private helper overrides. |
๐ 12. Progress¶
Legend: โณ Pending ยท ๐ง In Progress ยท โ Completed
- Phase 1 โ Foundation & Core Strategy Extraction โ โ Complete (2025-10-27)
- Phase 2 โ Composability, Merging & Contract Tests โ โณ
- Phase 3 โ Examples, Documentation & Migration โ โณ
- Phase 4 โ Remove Legacy MemberBuilder โ โณ
๐ 13. Changelog¶
- 2025-11-09 โ Architecture consolidation and terminology alignment:
- Consolidated all strategy components under
loryth.common.reflection.engine.strategies(moved from reflection/strategies) - Replaced builders with specs (immutable classes using copy() + with_*() pattern instead of frozen dataclasses)
- Renamed
HandleBuildingStrategyโHandleGenerationStrategyfor clarity - Renamed
ParsingContextโHandleGenerationContextfor consistency - Created BaseSpec base class with qualname, docstring, meta attributes
- Implemented all spec types: ModuleSpec, FunctionSpec, ClassSpec, ClassMethodSpec, ClassPropertySpec, ClassFieldSpec, ParameterSpec
- Updated HandleBuildingPipeline to work with specs instead of builders
- Created comprehensive public API exports in engine/init.py
- Updated TD-029 document to reflect Generation terminology and engine location
- Backward compatibility maintained via re-exports in deprecated handle_generation_strategy.py
- Ready for Phase 2: Test coverage implementation
- 2025-10-27 โ Phase 1 implementation complete:
- Created strategies package with all core components (~1,500 LOC)
- Implemented HandleBuildingStrategy protocol (protocols.py)
- Created ParsingContext and MappingContext (contexts.py)
- Implemented 8 frozen dataclass builders (handle_builder.py, module_builder.py, type_builder.py, function_builder.py, method_builder.py, parameter_builder.py, field_builder.py, property_builder.py)
- Extracted 3 core mappers (ModuleHandleMapper, TypeHandleMapper, FunctionHandleMapper)
- Implemented ParsingPipeline with metrics instrumentation (pipeline.py)
- Created default pipeline factory:
create_default_parsing_pipeline()in reflection/init.py - Updated RuntimeTypeResolver to support new architecture while maintaining backward compatibility
- All 791 existing tests pass with zero regressions
- New strategies package fully exported in reflection/init.py
- Used
from __future__ import annotationsto resolve circular imports between builder modules - Mark Phase 1 โ Complete
- 2025-10-26 โ Final consistency review and fixes:
- Fixed Section 3.1 table: Updated to reference
dataclasses.replace()instead ofwith_attr() - Deleted Section 5.2 (Specialized Member Builders): Removed outdated fluent API documentation that contradicted the design
- Fixed all mapper code examples (Sections 5.4.1, 5.4.2, 5.4.3, 5.5): Replaced
with_attr()chains withdataclasses.replace()calls - Added
from dataclasses import replaceimports to all mapper examples - Fixed Section 5.3: Updated contract highlights to reference
dataclasses.replace()instead ofwith_attr() - Fixed Section 5.6: Changed comment from "breadth-first processing" to "depth-first processing (LIFO stack)"
- Fixed Section 7.1 and 7.2: Updated SQLAlchemy and Descriptor mapper examples to use
dataclasses.replace() - Completely rewrote Appendix A: Removed outdated two-strategy model (inspection/annotation); replaced with current single-strategy architecture (Appendix A.1-A.4 now document ParsingContext, MappingContext, ParsingPipeline, and TypeResolver correctly)
- All code examples now consistent with design decision to use frozen dataclasses with
dataclasses.replace() - 2025-10-26 โ Major design refinement:
- Renamed
HandleMappingStrategyโHandleBuildingStrategyto better express "building" handles - Changed from breadth-first queue to depth-first stack (LIFO) to reduce peak memory usage
- Replaced
with_attr()fluent API withdataclasses.replace()for type-safe builder enrichment - Updated Section 4.1: ParsingContext documented with code + docstrings (removed table)
- Added Phase 4 (Remove Legacy MemberBuilder) to rollout plan with test migration details
- Removed "previous design" references from Section 3
- Updated metrics namespace from
handle_mappingtohandle_building - Status changed to Draft (from Proposed)
- 2025-10-26 โ Updated to reflect TD-030 completion (Handle hierarchy):
- Updated all
*Infoclass references to*Handle(MemberInfo โ MemberHandle, TypeInfo โ TypeHandle, ModuleInfo โ ModuleHandle, etc.) - Added TD-030 to Related section (foundation for this design)
- Revised Section 5.8 (PackageInspectionStrategy) to reflect unified ModuleHandle with
is_packageproperty (no separate PackageHandle) - Updated code examples to use Handle terminology
- Note: PackageHandle no longer exists; packages are now ModuleHandle with
is_package=True - 2025-10-25 โ Initial draft created (status Proposed) with single HandleBuildingStrategy protocol, phase plan, contract tests, and examples.
๐ 14. Glossary¶
| Term | Definition |
|---|---|
| Member identity | Combination of qualname + MemberKind that must never change once a member is discovered. |
| Strategy stacking | Deterministic execution of multiple inspectors or annotators where later entries refine or override earlier work. |
| Pipeline orchestration | Cache-aware flow that runs inspection, merges members, executes annotation, and persists results as a single unit. |
| Provenance metadata | Diagnostic tags attached to members to record which strategy created or last modified them. |
| Metrics namespace | Scoped counter/timer hierarchy (member_parsing.<phase>.<core|custom>.<slug>) used to attribute performance to individual strategies. |
| Parsing context | Resolver-owned lifecycle manager that tracks builders, placeholders, and pending subjects during a cache miss. |
| Builder placeholder | Lightweight MemberHandle stub emitted early so strategies can link related members before finalization without copying data. |
| Type resolver orchestration | The process where TypeResolver.resolve() instantiates a parsing context + pipeline on cache miss but keeps its external signature unchanged. |
๐ Appendix A โ Reference Sketches¶
A.1 ParsingContext Implementation¶
from dataclasses import dataclass
class ParsingContext:
"""Manages builder lifecycle during cache miss.
Uses LIFO stack for depth-first discovery.
"""
def __init__(self, root_qualname: str, cache: HierarchicalCache) -> None:
self._cache = cache
self._builder_registry: dict[str, HandleBuilder] = {}
self._pending: list[HandleBuilder] = [HandleBuilder.placeholder(root_qualname)]
self._finalized: dict[str, HandleBuilder] = {}
def register_builder(self, builder: HandleBuilder) -> None:
"""Add or replace a builder in the registry."""
self._builder_registry[builder.qualname] = builder
def next_pending(self) -> HandleBuilder | None:
"""Pop next builder from stack (LIFO). Skip if finalized."""
while self._pending:
candidate = self._pending.pop() # Pop from end (LIFO)
if candidate.qualname not in self._finalized:
return candidate
return None
def finalize_all(self) -> dict[str, HandleBuilder]:
"""Materialize all builders in registry."""
for qualname, builder in self._builder_registry.items():
if qualname not in self._finalized:
self._finalized[qualname] = builder # In practice, call builder.finalize()
return self._finalized
A.2 MappingContext Implementation¶
from dataclasses import dataclass
@dataclass(frozen=True)
class MappingContext:
"""Immutable context provided to mapping strategies."""
source: object
builder: HandleBuilder
parent: TypeHandleBuilder | None
module: ModuleHandleBuilder
cache: HierarchicalCache
_parsing_context: ParsingContext
def ensure_member(self, builder: HandleBuilder) -> HandleBuilder:
"""Enqueue discovered member for recursive processing."""
self._parsing_context.register(builder)
self._parsing_context._pending.append(builder) # Push to stack
return builder
def get_builder(self, qualname: str) -> HandleBuilder | None:
"""Look up an already-discovered builder."""
return self._parsing_context._registry.get(qualname)
A.3 ParsingPipeline Implementation¶
import time
from typing import Sequence
class ParsingPipeline:
"""Orchestrates mapping strategies in sequence."""
def __init__(
self,
mapping_strategies: Sequence[HandleBuildingStrategy],
metrics: ReflectionMetricsCollector,
) -> None:
self._mapping_strategies = list(mapping_strategies)
self._metrics = metrics
def map(
self,
source: object,
builder: HandleBuilder,
parsing_context: ParsingContext,
parent: TypeHandleBuilder | None,
module: ModuleHandleBuilder,
) -> HandleBuilder:
"""Execute mapping strategies in sequence."""
context = MappingContext(source, builder, parent, module, parsing_context._cache, parsing_context)
current_builder = builder
for strategy in self._mapping_strategies:
current_builder = self._execute_with_metrics(
strategy,
lambda: strategy.map(source, dataclasses.replace(context, builder=current_builder))
)
return current_builder
def _execute_with_metrics(
self,
strategy: HandleBuildingStrategy,
func,
) -> HandleBuilder:
"""Execute strategy with metrics wrapping."""
start = time.perf_counter()
errors = 0
result = None
try:
result = func()
return result
except Exception:
errors = 1
raise
finally:
duration = time.perf_counter() - start
slug = strategy.__class__.__name__.removesuffix("Mapper").lower()
namespace = f"handle_building.{slug}"
self._metrics.record(namespace=namespace, duration=duration, count=1, errors=errors)
A.4 Complete TypeResolver Example¶
import importlib
from typing import Callable
class TypeResolver:
"""Public API for resolving types to TypeHandle builders."""
def __init__(
self,
cache: HierarchicalCache,
pipeline: ParsingPipeline,
) -> None:
self._cache = cache
self._pipeline = pipeline
def resolve(self, dotted_path: str) -> TypeHandle:
"""Resolve a type by dotted path.
Returns cached result on hit; builds full scope on miss.
"""
# Cache hit: return immediately
cached = self._cache.get(dotted_path)
if cached is not None:
return cached
# Cache miss: spin up parsing context
context = ParsingContext(dotted_path, self._cache)
# Depth-first processing (LIFO stack)
while (pending_builder := context.next_pending()) is not None:
# Import source object
source_obj = self._import(pending_builder.qualname)
# Resolve parent and module contexts
parent, module = self._resolve_context(pending_builder.qualname)
# Run mappers
enhanced = self._pipeline.map(source_obj, pending_builder, context, parent, module)
# Register back into context
context.register(enhanced)
# Finalize and cache
finalized = context.finalize_all()
self._cache.update(finalized)
return finalized[dotted_path]
def _import(self, dotted_path: str) -> object:
"""Import a Python object by fully qualified dotted path."""
module_name, _, attr = dotted_path.rpartition(".")
module = importlib.import_module(module_name)
return getattr(module, attr) if attr else module
def _resolve_context(self, qualname: str) -> tuple[TypeHandleBuilder | None, ModuleHandleBuilder]:
"""Resolve parent type and owning module for a qualname."""
# Implementation: parse qualname, look up parent and module in registry
# Return (parent_type_handle, module_handle)
pass