from typing import Any, Optional, Union, Tuple, ClassVar
# Melder Imports
from melder.utilities.general_base.cleanable import Cleanable
from melder.utilities.helpers.general_helpers import SpellInputUtils
[docs]
class SpellMap(Cleanable):
"""
Declarative DI descriptor for normal spellbook-local resolution.
`SpellMap` is the explicit descriptor form of ordinary Melder DI intent. It
tells SpellCrafter and Meld which spell or frame/binding identity should be
injected at a given parameter or attribute location, optionally along with
an override payload that should be carried into runtime construction.
Unlike `SpellContract`, this descriptor does not declare a cross-conduit
hole that will be satisfied later. It stays inside the current resolution
world. SpellCrafter and the runtime planning path inspect it, derive the
canonical lookup identity, and then resolve a local spell through the usual
Spellbook, SpellIndex, and Existence machinery.
Supported shapes:
- `SpellMap(MyService)`
Direct type-based DI where the spell itself supplies the lookup identity.
- `SpellMap(ILogic)`
Frame-based DI where a Protocol / interface acts as the lookup frame.
- `SpellMap(MyService, spellframe=ILogic, binding_name="primary")`
Fully explicit spell + frame + binding declaration.
- `SpellMap(spell=None, spellframe=ILogic, binding_name="primary")`
Frame-only descriptor where the DI key is derived entirely from frame and
binding.
Typical usage:
`python
class MyService:
def __init__(self, repo=SpellMap(MyRepo)):
self.repo = repo
class UsesLogic:
def __init__(self, logic=SpellMap(ILogic, binding_name="primary")):
self.logic = logic
class UsesConfig:
def __init__(
self,
cfg=SpellMap(
spell=None,
spellframe=IAppConfig,
binding_name="primary",
),
):
self.cfg = cfg
`
Contract:
- Pure intent object; it does not resolve providers by itself.
- Used for normal in-graph DI, not late conduit linking.
- Carries spell/frame/binding identity plus optional override payload.
- Cleanable and invalid after cleanup.
- Should not be subclassed or treated like a runtime-resolved object.
Threading:
Value-shaped and effectively immutable after construction: the four
slots are set in `__init__` and read thereafter. No lock is taken,
because a descriptor written into a constructor default is shared
read-only across every resolution that reads it.
Lifecycle / Cleanup:
Cleanable. The descriptor lives as long as the class default that
declares it, which in practice is the lifetime of the defining module.
Registration:
MELDER KERNEL, USER-INSTANTIATED but NOT user-bindable. A user writes
`SpellMap(MyRepo)` in their own constructor default constantly, so instances
are authored outside melder - but binding the SpellMap CLASS itself is
refused, which would be meaningless because a descriptor is a statement of
intent, not a service to resolve.
Subsystem Context:
One of the two declarative DI descriptors, paired with `SpellContract`.
The division is scope: `SpellMap` stays INSIDE the current resolution
world and resolves through the ordinary Spellbook / SpellIndex /
Existence machinery, while `SpellContract` declares a hole a future
linked conduit may fill. Phase 1 classifies each parameter into a
`ParameterDIShape`; a SpellMap default lands as `SPELLMAP_DEFAULT`, and
Phase 3 performs the actual candidate resolution.
System Context:
The four supported shapes exist because DI identity in Melder is a
(spell, frame, binding) triple rather than a single type key, and
different call sites know different parts of it. Passing a concrete
class supplies the whole identity; passing a Protocol supplies a frame
and asks the graph to find the implementation; the frame-only form
(`spell=None`) exists for the case where the caller deliberately knows
NOTHING but the contract and the binding name.
Ambiguity is a build-time failure, not a runtime one: a SpellMap
default that resolves to zero or to multiple candidates raises rather
than silently picking. That is the whole reason the explicit
`binding_name` form exists - it is the documented way to disambiguate
when several spells legitimately satisfy the same frame.
AGENT_ACCESS: public
AGENT_PURPOSE:
access: public. Declarative DI descriptor for normal in-graph resolution. Write it as a
constructor default: SpellMap(MyRepo), SpellMap(ILogic, binding_name='primary'), or
frame-only with spell=None. Zero or multiple matches raise at build time.
"""
__slots__ = Cleanable.__slots__ + [
"spell",
"spellframe",
"binding_name",
"override",
]
def __init__(
self,
spell: Any | None = None,
*,
spellframe: Optional[Any] = None,
binding_name: Optional[str] = None,
override: Optional[Union[dict, list, tuple]] = None,
) -> None:
"""
Create one `SpellMap` declarative descriptor.
Args:
spell:
Primary lookup target, or `None` for frame-only SpellMaps.
When not `None`, this is typically:
- the concrete implementation class
- a Protocol used as a frame-like identity
- a callable spell target
- or another resolver-supported object
Resolution semantics:
- if `spellframe` is provided, the frame becomes the primary
grouping identity and `spell` acts as the concrete spell side
of the declaration
- if `spellframe` is omitted, `spell` itself supplies the DI key
- if `spell` is `None`, `spellframe` must be present and becomes
the sole DI identity
spellframe:
Optional logical interface, Protocol, or frame key used to
group spells in Spellbook lookup space.
binding_name:
Optional named binding used to disambiguate multiple providers
under the same frame. Kept exactly as written, the way `Bind`
keeps a spell's binding name (0.2.8226; it was lowercased here
before). Matching is case-insensitive: Phase 3 compares
normalized names, so "ScanProfile", "scanprofile" and
"SCANPROFILE" select the provider bound as "ScanProfile", as
`meld` does. `None` (or "") keeps default-binding semantics.
override:
Optional construction payload for the provider spell this descriptor
resolves to, applied when that provider is constructed for this
consumer:
- `dict`: keyword arguments for the provider's constructor
- `list` / `tuple`: positional arguments
Values may be any Python object. They are never copied into the
compiled plan or the creation cache: phase 9 records a reference
to this descriptor and the executor reads the live value from it
at hydration, so the provider receives the object itself, by
identity (2026-09-26). A caller payload on `meld(override=...)`
still wins over this payload (override > contract > dependency).
A payload on a non-resolvable definition is refused by phase 3.
When `None`, no payload is attached.
Raises:
ValueError: If both `spell` and `spellframe` are omitted, because
the descriptor would have no DI identity.
TypeError: If `binding_name` is neither a string nor `None`.
Returns:
None.
"""
if spell is None and spellframe is None:
raise ValueError(
"SpellMap requires at least one of `spell` or `spellframe` "
"to be provided."
)
if binding_name is not None and not isinstance(binding_name, str):
raise TypeError(
"SpellMap binding_name must be a string or None; "
f"got {type(binding_name).__name__}."
)
super().__init__()
self.spell = spell
self.spellframe = spellframe
# Kept as written, as Bind keeps a spell's binding name; `canonical_key`
# and Phase 3 compare its normalized form, so case never decides a match.
self.binding_name = binding_name
# Preserve the caller payload by reference; None means no payload is attached.
self.override = override
[docs]
def cleanup(self) -> None:
"""
Release descriptor references and invalidate the object.
Contract:
- Idempotent and safe to call multiple times.
- Clears mutable override payloads before dropping references.
- After cleanup the descriptor should be treated as unusable and
callers should rely on `check_cleaned()` before reading it.
Returns:
None.
"""
if self._cleaned:
return
# No internal lock needed; this is a simple intent object.
self._cleaned = True
# Clear override payload if it is a container.
if isinstance(self.override, (list, dict)):
self.override.clear()
self.override = None
self.spell = None
self.spellframe = None
self.binding_name = None
@property
def lookup_triplet(self) -> tuple[Any, Optional[Any], Optional[str]]:
"""
Return the raw descriptor identity tuple.
This is the shape SpellCrafter and the runtime planning path consume
before they derive the normalized key or resolve the underlying spell.
Contract:
- Returns the RAW, AS-SUPPLIED triplet - the spell, spellframe and binding
name exactly as the caller gave them, WITHOUT normalization.
- This is NOT the registry key. Use `canonical_key` when you need the
normalized identity the spellbook actually indexes by; the two can
differ for the same SpellMap.
Threading:
Pure computation over immutable fields; safe from any thread.
Lifecycle / Cleanup:
Carries no cleaned-state guard.
Returns:
tuple[Any, Optional[Any], Optional[str]]: `(spell, spellframe,
binding_name)` exactly as stored on the descriptor.
Notes:
- For frame-only SpellMaps (`spell is None`), only `spellframe` and
`binding_name` contribute to the runtime identity.
- For fully explicit SpellMaps, higher layers may use both `spell`
and `spellframe` when enforcing resolution rules.
- The binding name is the caller's text (0.2.8226); `canonical_key`
carries the normalized form that matching uses.
"""
return (self.spell, self.spellframe, self.binding_name)
@property
def canonical_key(self) -> Tuple[str, str]:
"""
Return the normalized `(frame_key, binding_key)` pair.
This is the canonical Spellbook-style key derived through
`SpellInputUtils.normalize_spell_key(...)`. It is the stable string pair
higher layers use when indexing or matching SpellMap intent.
Shape rules:
- when `spellframe` is present, the frame key is derived from
`spellframe`
- when `spellframe` is absent, the frame key is derived from `spell`
- binding defaults normalize to `"__default__"` inside the helper path
Contract:
- NORMALIZED identity: it runs the raw triplet through the shared key
normalizer, so equivalent-but-differently-spelled inputs collapse to
the same key. This is what the spellbook indexes by.
- Recomputed on every access rather than cached, so it always reflects
the current field values.
Threading:
Pure computation over immutable fields; safe from any thread.
Lifecycle / Cleanup:
Carries no cleaned-state guard.
Returns:
Tuple[str, str]: Normalized `(frame_key, binding_key)` pair.
"""
frame_key, bind_key = SpellInputUtils.normalize_spell_key(
spell=self.spell,
spellframe=self.spellframe,
binding_name=self.binding_name,
)
return frame_key, bind_key
@property
def spell_key(self) -> Tuple[str, str]:
"""
Compatibility alias for `canonical_key`.
Contract:
- ALIAS for `canonical_key`, kept for call-site readability. Identical
behaviour - it is the NORMALIZED key, not the raw triplet.
Threading:
Pure computation over immutable fields; safe from any thread.
Lifecycle / Cleanup:
Carries no cleaned-state guard.
Returns:
Tuple[str, str]: The same normalized key pair returned by
`canonical_key`.
"""
return self.canonical_key
def __repr__(self) -> str:
"""
Return a debug-oriented representation of the SpellMap descriptor.
Contract:
- Includes the override payload alongside the identity fields, so a `SpellMap`
repr shows what it will DO as well as what it names.
- Unguarded, unlike some other repr implementations in the codebase, so
it stays safe to log.
Threading:
Pure computation over immutable fields; safe from any thread.
Lifecycle / Cleanup:
Carries no cleaned-state guard.
Returns:
str: Representation showing stored spell/frame/binding/override
fields without performing any resolution.
"""
return (
f"<SpellMap spell={self.spell!r} "
f"spellframe={self.spellframe!r} "
f"binding_name={self.binding_name!r} "
f"override={self.override!r}>"
)