Source code for pypulseqpp.sequences._module

"""Base class of the reusable sequence modules."""

from __future__ import annotations

__all__ = ["SequenceModule"]

import ast
import inspect
import re
import sys
import textwrap
import warnings
from abc import ABC, abstractmethod
from types import SimpleNamespace
from typing import Any

#: Analyses forwarded to the module's stored sequence.
SEQUENCE_VIEWS = (
    "calculate_kspace",
    "check_timing",
    "paper_plot",
    "test_report",
    "waveforms_and_times",
)


def _unique(events: tuple) -> list:
    """Return the distinct objects in ``events``, by identity, in first-seen order."""
    seen: dict[int, Any] = {}
    for event in events:
        seen.setdefault(id(event), event)
    return list(seen.values())


#: Docstring sections a module inherits from the modules it is built on. With
#: the signature they are the constructor's contract, which a family states
#: once and its variants narrow; the description and every other section stay
#: with the class that wrote them.
_INHERITED_SECTIONS = ("Parameters", "Attributes", "Raises")

_SECTION_HEADING = re.compile(r"^([A-Z][\w ]*)\n-{3,}\n", re.M)


def _super_init_call(init_module: Any) -> tuple[int, set[str]] | None:
    """Positional count and keyword names of the parent ``init_module`` call.

    None when the call does not forward ``**kwargs`` or the source cannot be
    read.
    """
    try:
        tree = ast.parse(textwrap.dedent(inspect.getsource(init_module)))
    except (OSError, TypeError, SyntaxError):
        return None
    for node in ast.walk(tree):
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and node.func.attr == "init_module"
            and isinstance(node.func.value, ast.Call)
            and getattr(node.func.value.func, "id", None) == "super"
        ):
            forwards = any(keyword.arg is None for keyword in node.keywords)
            if not forwards or any(isinstance(arg, ast.Starred) for arg in node.args):
                return None
            return len(node.args), {kw.arg for kw in node.keywords if kw.arg}
    return None


def _constructor_signature(cls: type) -> inspect.Signature:
    """Return the constructor's parameters, with forwarded keywords spelt out.

    A subclass that passes ``**kwargs`` on to its parent's ``init_module``
    takes the parent's parameters too, less those it passes itself.
    """
    parent = next((k for k in cls.__mro__[1:] if "__signature__" in vars(k)), None)
    if "init_module" not in vars(cls) and parent is not None:
        return parent.__signature__
    parameters = list(inspect.signature(cls.init_module).parameters.values())[1:]
    if not parameters or parameters[-1].kind is not inspect.Parameter.VAR_KEYWORD:
        return inspect.Signature(parameters)
    call = _super_init_call(cls.init_module)
    if parent is None or call is None:
        return inspect.Signature(parameters)
    n_positional, passed = call
    skipped = passed | {parameter.name for parameter in parameters}
    forwarded = [
        parameter
        if parameter.kind is inspect.Parameter.VAR_KEYWORD
        else parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY)
        for parameter in list(parent.__signature__.parameters.values())[n_positional:]
        if parameter.name not in skipped
        and parameter.kind is not inspect.Parameter.VAR_POSITIONAL
    ]
    return inspect.Signature([*parameters[:-1], *forwarded])


def _doc_sections(doc: str | None) -> tuple[str, dict[str, str]]:
    """Split a docstring into its description and its sections, by heading."""
    parts = _SECTION_HEADING.split(inspect.cleandoc(doc or ""))
    return parts[0].strip(), {
        heading: body.strip("\n")
        for heading, body in zip(parts[1::2], parts[2::2], strict=True)
    }


def _section_entries(body: str) -> list[str]:
    """Split a section into entries: an unindented line and the lines under it."""
    entries: list[str] = []
    for line in body.splitlines():
        if line[:1].strip():
            entries.append(line)
        elif entries:
            entries[-1] += "\n" + line
    return [entry.rstrip() for entry in entries]


def _entry_key(entry: str) -> str:
    return entry.partition("\n")[0].partition(" : ")[0]


def _constructor_doc(cls: type) -> str | None:
    """Compose the class docstring from the modules ``cls`` is built on.

    The description is the class's own, followed by what the private classes
    directly above it add to their summaries: a private class has no page of
    its own to say it on. Parameters, Attributes and Raises gather over every
    ancestor below :class:`SequenceModule`: the nearest entry of a name wins,
    except that an exception lists every class's conditions, nearest first.
    Parameters follow the signature, and an inherited one the signature no
    longer takes is dropped.
    """
    ancestors = [
        klass
        for klass in cls.__mro__
        if issubclass(klass, SequenceModule) and klass is not SequenceModule
    ]
    chain = [_doc_sections(vars(klass).get("_written_doc")) for klass in ancestors]
    description, own = chain[0]
    if description:
        for klass, (text, _) in zip(ancestors[1:], chain[1:], strict=True):
            if not klass.__name__.startswith("_"):
                break
            extended = text.partition("\n\n")[2].strip()
            if extended:
                description += "\n\n" + extended
    else:
        description = next((text for text, _ in chain[1:] if text), "")

    position = {
        ("**" if p.kind is p.VAR_KEYWORD else "*" if p.kind is p.VAR_POSITIONAL else "")
        + p.name: index
        for index, p in enumerate(cls.__signature__.parameters.values())
    }

    def taken(entry: str) -> list[int]:
        return [
            position[name.strip()]
            for name in _entry_key(entry).split(",")
            if name.strip() in position
        ]

    sections = {}
    for heading in _INHERITED_SECTIONS:
        merged: dict[str, str] = {}
        for depth in reversed(range(len(chain))):
            for entry in _section_entries(chain[depth][1].get(heading, "")):
                key = _entry_key(entry)
                if heading == "Parameters" and depth and not taken(entry):
                    continue
                if heading == "Raises" and key in merged:
                    head, _, conditions = entry.partition("\n")
                    merged[key] = (
                        f"{head}\n{conditions}\n" + merged[key].partition("\n")[2]
                    )
                    continue
                merged[key] = entry
        entries = list(merged.values())
        if heading == "Parameters":
            entries.sort(key=lambda entry: min(taken(entry), default=len(position)))
        sections[heading] = "\n".join(entries)
    sections.update(
        (heading, body)
        for heading, body in own.items()
        if heading not in _INHERITED_SECTIONS
    )
    blocks = [
        description,
        *(f"{h}\n{'-' * len(h)}\n{body}" for h, body in sections.items() if body),
    ]
    return "\n\n".join(block for block in blocks if block) or None


[docs] class SequenceModule(ABC): """A reusable block layout with named, mutable event templates. Subclasses assign ``self.seq`` and add blocks in ``init_module``. Events are published from constructor locals onto ``events`` and onto the module itself. Repeated references are deduplicated by identity: one distinct object becomes a scalar event, several become a list in first-seen order. Explicit register calls preserve the supplied structure, including one-element lists. Parameters ---------- *args, **kwargs Forwarded to init_module. Attributes ---------- events : types.SimpleNamespace Published events. Use this namespace when a name conflicts with a module attribute; publication warns about conflicts. center : float Timing reference in seconds from the module start, set by the subclass: typically an RF centre or an echo. Notes ----- blocks retains the original event objects for replay. Mutating those objects does not rewrite the stored sequence used for analysis. Only calculate_kspace, check_timing, test_report and waveforms_and_times are forwarded to seq. Examples -------- A subclass assigns ``seq`` and adds its blocks; the constructor's locals are published under their own names and on ``events``: >>> import pypulseqpp as pp >>> import pypulseqpp.sequences as design >>> class OneTrapezoid(design.SequenceModule): ... def init_module(self, system): ... self.seq = pp.Sequence(system=system) ... gx = pp.make_trapezoid("x", area=500, system=system) ... self.seq.add_block(gx) ... self.center = 0.5 * pp.calc_duration(gx) >>> module = OneTrapezoid(pp.Opts()) >>> module.gx.channel, len(module.blocks) ('x', 1) >>> module.gx is module.events.gx True """ def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) # The constructor forwards its arguments to init_module, so these are # what help(), inspect.signature and the API pages report. cls.__signature__ = _constructor_signature(cls) cls._written_doc = vars(cls).get("__doc__") cls.__doc__ = _constructor_doc(cls) def __init__(self, *args: Any, **kwargs: Any) -> None: self._seq = None self._blocks: list[tuple] = [] self._played: dict[int, Any] = {} self._init_frame = None self._named: set[str] = set() self._mirrored: set[str] = set() self._duration: float | None = None self.events = SimpleNamespace() self.center = 0.0 self.init_module(*args, **kwargs) self._finalize()
[docs] @abstractmethod def init_module(self, *args: Any, **kwargs: Any) -> None: """Build the module's block layout; implemented by the subclass. Assign ``self.seq``, add blocks to it, and set :attr:`center` if the module is timed against something other than its own start. Events are published automatically; nothing has to be returned. Parameters ---------- *args, **kwargs : object What the subclass is constructed with, which its own docstring states. """
# ------------------------------------------------------------------ # Construction # ------------------------------------------------------------------ @property def seq(self): """Sequence holding the module's construction-time block layout. Assigning a Sequence enables block recording. Later event-template changes affect replay through blocks, not this stored sequence. """ return self._seq @seq.setter def seq(self, sequence) -> None: from pypulseqpp import Sequence if not isinstance(sequence, Sequence): raise TypeError( f"a module's seq must be a pypulseqpp.Sequence, not {type(sequence).__name__}" ) sequence.__class__ = _recording_sequence_class() sequence._module = self self._seq = sequence def _find_init_frame(self) -> None: """Return the active ``init_module`` frames for this instance. Final locals from both subclass and base constructors are needed for event publication. Release the retained frames during finalisation. """ targets = { getattr(init, "__code__", None) for init in ( getattr(klass, "init_module", None) for klass in type(self).__mro__ ) if init is not None } frames = [] frame = sys._getframe(2) while frame is not None: if frame.f_code in targets and frame.f_locals.get("self") is self: frames.append(frame) frame = frame.f_back self._init_frame = frames def _finalize(self) -> None: name = type(self).__name__ if self._seq is None: raise TypeError(f"{name}.init_module never assigned self.seq") if not self._blocks: raise TypeError(f"{name}.init_module added no blocks to self.seq") # Innermost frame first, so that where a subclass and its base both # bind a name, the subclass's meaning is the one published. for frame in self._init_frame or (): self._publish_locals(frame.f_locals) self._init_frame = None # the constructor and its locals are free to go if not vars(self.events): warnings.warn( f"{name} published no events: nothing it added to self.seq was named by a local " f"variable of {name}.init_module. Call self.publish() from wherever the events " "were built, or name them with self.register(name=event).", stacklevel=3, ) def _publish_locals(self, namespace) -> None: for name, value in namespace.items(): if name.startswith("_") or value is self: continue items = tuple(value) if isinstance(value, list | tuple) else (value,) played = tuple(item for item in items if id(item) in self._played) if not played: continue if name in self._named: continue self._set_event(name, played)
[docs] def publish(self, **named: Any) -> None: """Publish events from the caller's locals and register keyword aliases. Use in construction helpers whose locals are not captured automatically. Keyword aliases take precedence over automatic names. Parameters ---------- **named : object Events to publish under the names given, whatever the caller's locals call them. """ self._publish_locals(sys._getframe(1).f_locals) self.register(**named)
[docs] def register(self, **events: Any) -> None: """Publish named events without requiring prior block registration. Preserve the supplied container structure and override automatic publication for these names. Parameters ---------- **events : object Events to publish under the names given. A list or tuple is published as it stands. """ for name, event in events.items(): self._publish(name, event) self._named.add(name)
def _set_event(self, name: str, played: tuple) -> None: """Publish ``played`` under ``name``, collapsed to a single object where there is one.""" unique = _unique(played) self._publish(name, unique[0] if len(unique) == 1 else unique) def _publish(self, name: str, event: Any) -> None: """Mirror an event on the module and events, warning on name conflicts.""" self._warn_if_shadowed(name) setattr(self.events, name, event) vars(self)[name] = event self._mirrored.add(name) def _warn_if_shadowed(self, name: str) -> None: if name in self._mirrored: return # published before: this is a re-publication, not a clash if name in vars(self) or hasattr(type(self), name) or name in SEQUENCE_VIEWS: warnings.warn( f"{type(self).__name__} publishes an event called {name!r}, which is also a " f"module attribute; reach it as .events.{name}", stacklevel=4, ) # ------------------------------------------------------------------ # Reading the result # ------------------------------------------------------------------ def __getattr__(self, name: str): """Resolve the forwarded sequence analyses, then published event names.""" if name.startswith("_"): # Never route dunder or private lookups: copy, pickle and inspect # probe for those, and answering would answer for the module. raise AttributeError(name) if name in SEQUENCE_VIEWS: sequence = object.__getattribute__(self, "_seq") if sequence is not None: return getattr(sequence, name) try: events = object.__getattribute__(self, "events") except AttributeError: raise AttributeError(name) from None try: return getattr(events, name) except AttributeError: raise AttributeError( f"{type(self).__name__!r} module has no attribute or event {name!r}" ) from None def __dir__(self): return sorted( set(super().__dir__()) | set(vars(self.events)) | set(SEQUENCE_VIEWS) ) @property def blocks(self) -> list[tuple]: """Return block tuples in play order, retaining the original event objects. The list is a copy; event mutations are shared with published templates. """ return list(self._blocks) @property def duration(self) -> float: """Duration of the module in seconds. Summed from the blocks, unless the module derived its timing analytically and assigned the answer. """ if self._duration is not None: return self._duration return float(self._seq.duration()[0]) @duration.setter def duration(self, value: float) -> None: self._duration = float(value)
# Construct lazily to avoid importing Sequence during package initialisation. _RECORDING_SEQUENCE = None def _recording_sequence_class(): """Return a Sequence subclass that records event identities and block tuples.""" global _RECORDING_SEQUENCE if _RECORDING_SEQUENCE is None: from pypulseqpp import Sequence class _ModuleSequence(Sequence): def add_block(self, *events): module = self._module if module._init_frame is None: module._find_init_frame() module._blocks.append(events) for event in events: module._played.setdefault(id(event), event) return super().add_block(*events) _RECORDING_SEQUENCE = _ModuleSequence return _RECORDING_SEQUENCE