304 lines
13 KiB
Python
304 lines
13 KiB
Python
# -*- coding: utf-8; -*-
|
|
"""Expander core; essentially, how to apply a macro invocation."""
|
|
|
|
__all__ = ["MacroExpansionError", "MacroExpanderMarker", "Done",
|
|
"BaseMacroExpander", "global_postprocess"]
|
|
|
|
from ast import NodeTransformer, AST
|
|
from contextlib import contextmanager
|
|
from collections import ChainMap
|
|
|
|
from .astfixers import fix_ctx, fix_locations
|
|
from .markers import ASTMarker, delete_markers
|
|
from .utils import flatten, format_location
|
|
|
|
# Global macro bindings shared across all expanders in the current process.
|
|
# This is used by `mcpyrate.quotes` for hygienically captured macro functions.
|
|
global_bindings = {}
|
|
|
|
class MacroExpansionError(Exception):
|
|
"""Base class for errors specific to macro expansion.
|
|
|
|
For errors detected **at macro expansion time**, we recommend raising:
|
|
|
|
- `SyntaxError` with a descriptive message, if there's something wrong
|
|
with how the macro was invoked, or with the AST layout of the `tree`
|
|
(or `args`) it got vs. what it was expecting.
|
|
|
|
- `TypeError` or `ValueError` as appropriate, if there is a problem in
|
|
the macro arguments meant for the macro itself. (As opposed to macro
|
|
arguments such as in the `let` demo, where `args` is just another
|
|
place to send in an AST to be transformed.)
|
|
|
|
- `MacroExpansionError`, or a custom descendant of it, if something else
|
|
macro-related went wrong.
|
|
|
|
Some operations represented by a macro may inject a call to a run-time part
|
|
of that operation itself (e.g. the quasiquote system does this). For errors
|
|
detected **at run time of the use site**:
|
|
|
|
- Use whichever exception type you would in regular Python code that
|
|
doesn't use macros. For example, a `TypeError`, `ValueError` or
|
|
`RuntimeError` may be appropriate.
|
|
"""
|
|
|
|
class MacroApplicationError(MacroExpansionError):
|
|
"""The expander core caught an exception while applying a macro function.
|
|
|
|
The expander uses this type to automatically telescope use site reports
|
|
for nested macro invocations (which occur when a macro opts to expand
|
|
inside-out).
|
|
|
|
The `macropython` wrapper also strips most of the traceback when it catches
|
|
an exception of this type. We know that for the internal uses of this type,
|
|
the traceback speaks of things such as `macropython` itself, the importer,
|
|
and the macro expander, and it is typically very long. The linked ("direct
|
|
cause") exceptions contain the actually relevant, client code tracebacks.
|
|
|
|
**CAUTION**: This type is for internal use only.
|
|
"""
|
|
|
|
class MacroExpanderMarker(ASTMarker):
|
|
"""Base class for AST markers used by the macro expander itself."""
|
|
|
|
class Done(MacroExpanderMarker):
|
|
"""A subtree that is done. Any further visits by the expander will skip it.
|
|
|
|
Emitted by `BaseMacroExpander.visit_once`, to protect the once-expanded form
|
|
from further expansion.
|
|
"""
|
|
|
|
# --------------------------------------------------------------------------------
|
|
|
|
class BaseMacroExpander(NodeTransformer):
|
|
"""Expander core. Base class for macro expanders.
|
|
|
|
After identifying valid macro syntax, each `visit` method of the actual
|
|
expander should return the result of calling the `expand()` method with
|
|
the proper arguments.
|
|
|
|
Constructor parameters:
|
|
|
|
bindings: dictionary of macro name/function pairs
|
|
filename: full path to `.py` file being expanded, for error reporting
|
|
"""
|
|
|
|
def __init__(self, bindings, filename):
|
|
self.local_bindings = bindings
|
|
self.bindings = ChainMap(self.local_bindings, global_bindings)
|
|
self.filename = filename
|
|
self.recursive = True
|
|
|
|
def visit(self, tree):
|
|
"""Expand macros in `tree`, using current setting for recursive mode.
|
|
|
|
No-op if no macro bindings, or if `tree` is marked as `Done`.
|
|
|
|
Treat `visit(stmt_suite)` as a loop for individual elements.
|
|
No-op if `tree is None`.
|
|
|
|
This is the standard visitor method; it continues an ongoing visit.
|
|
"""
|
|
if not self.bindings or isinstance(tree, Done):
|
|
return tree
|
|
if tree is None:
|
|
return None
|
|
if isinstance(tree, list):
|
|
new_tree = flatten(self.visit(elt) for elt in tree)
|
|
if new_tree:
|
|
tree[:] = new_tree
|
|
return tree
|
|
return None
|
|
return super().visit(tree)
|
|
|
|
def visit_recursively(self, tree):
|
|
"""Entry point. Expand macros in `tree`, in recursive mode.
|
|
|
|
That is, iterate the expansion process until no macros are left.
|
|
Recursive mode is temporarily enabled even if currently inside the
|
|
dynamic extent of a `visit_once`.
|
|
|
|
This starts a new visit. The dynamic extents of visits may be nested.
|
|
"""
|
|
with self._recursive_mode(True):
|
|
return self.visit(tree)
|
|
|
|
def visit_once(self, tree):
|
|
"""Entry point. Expand macros in `tree`, in non-recursive mode.
|
|
|
|
That is, make just one pass, regardless of whether there are macros
|
|
remaining in the output. Then mark `tree` as `Done`, so the rest of
|
|
the macro expansion process will leave it alone. Recursive mode is
|
|
temporarily disabled even if currently inside the dynamic extent
|
|
of a `visit_recursively`.
|
|
|
|
This starts a new visit. The dynamic extents of visits may be nested.
|
|
"""
|
|
with self._recursive_mode(False):
|
|
return Done(self.visit(tree))
|
|
|
|
def _recursive_mode(self, isrecursive):
|
|
"""Context manager. Change recursive mode, restoring the old mode when the context exits."""
|
|
@contextmanager
|
|
def recursive_mode():
|
|
wasrecursive = self.recursive
|
|
try:
|
|
self.recursive = isrecursive
|
|
yield
|
|
finally:
|
|
self.recursive = wasrecursive
|
|
return recursive_mode()
|
|
|
|
def expand(self, syntax, target, macroname, tree, sourcecode, kw=None):
|
|
"""Expand a macro invocation.
|
|
|
|
This is a hook for actual macro expanders. Macro libraries typically
|
|
don't need to care about this; you'll want one of the `visit` methods
|
|
instead.
|
|
|
|
Transform the `target` node, replacing it with the expansion result
|
|
of applying `macroname` on `tree`. Then postprocess locally, by
|
|
`_visit_expansion`.
|
|
|
|
`syntax` is the type of macro invocation detected by the actual macro
|
|
expander, such as `expr` or `block`. What invocation types exist and
|
|
what values of `syntax` represent them are defined by the actual macro
|
|
expander. The value of `syntax` and its type can be anything; we don't
|
|
even look at it, but just pass it on.
|
|
|
|
`sourcecode` is a source code dump (or unparsed backconversion from AST)
|
|
for error messages. It is a parameter, because the actual expander may
|
|
edit the `target` node (e.g. to pop a block macro) before we get control.
|
|
|
|
When calling the macro function, we pass the following named arguments:
|
|
|
|
- `syntax`: Our `syntax` argument, as-is.
|
|
- `expander`: The expander instance.
|
|
- `invocation`: The `target` AST node as-is, for introspection if you
|
|
need to see not only the destructured `tree` and `args`,
|
|
but the macro invocation itself, without any processing.
|
|
|
|
Very rarely needed; if you need it, you'll know.
|
|
|
|
**CAUTION**: not a copy, or at most a shallow copy.
|
|
|
|
To send additional named arguments from the actual expander to the
|
|
macro function, place them in a dictionary and pass that dictionary
|
|
as `kw`.
|
|
"""
|
|
loc = format_location(self.filename, target, sourcecode) # macro use site
|
|
|
|
kw = kw or {}
|
|
kw.update({"syntax": syntax,
|
|
"expander": self,
|
|
"invocation": target})
|
|
|
|
try:
|
|
# Resolve macro binding.
|
|
macro = self.isbound(macroname)
|
|
if not macro: # pragma: no cover
|
|
raise MacroApplicationError(f"{loc}\nin {syntax} macro invocation for '{macroname}': the name '{macroname}' is not bound to a macro.")
|
|
|
|
# Expand the macro.
|
|
expansion = _apply_macro(macro, tree, kw)
|
|
|
|
# Convert possible iterable result to `list`, then typecheck macro output.
|
|
try:
|
|
if expansion is not None and not isinstance(expansion, AST):
|
|
expansion = list(expansion)
|
|
|
|
if isinstance(expansion, AST) or expansion is None:
|
|
pass # ok
|
|
elif isinstance(expansion, list):
|
|
if not all(isinstance(elt, AST) for elt in expansion):
|
|
raise TypeError("Expected all elements of list returned by macro function to be ASTs")
|
|
else:
|
|
raise TypeError("Unexpected return type from macro function")
|
|
except Exception:
|
|
reason = f"in {syntax} macro invocation for '{macroname}': expected macro to return AST node, iterable of AST nodes, or None; got {type(expansion)} with value {repr(expansion)} (after iterable to list conversion)"
|
|
msg = f"{loc}\n{reason}"
|
|
err = MacroApplicationError(msg)
|
|
err.__suppress_context__ = True
|
|
raise err
|
|
|
|
# If something went wrong, generate a standardized macro use site report.
|
|
except Exception as err:
|
|
msg = f"{loc}\nin {syntax} macro invocation for '{macroname}'"
|
|
if isinstance(err, MacroApplicationError) and err.__cause__:
|
|
# Telescope nested use site reports, by keeping the original
|
|
# traceback and `__cause__`, but combining the messages.
|
|
#
|
|
# When macro invocations are nested, the `expand` call
|
|
# that is processing the innermost macro raises first.
|
|
# So when the next outer one catches the exception,
|
|
# it should add its own message to the beginning,
|
|
# to make the report read in an outside-in order,
|
|
# similarly to a Python traceback.
|
|
oldmsg = err.args[0]
|
|
oldmsg_lines = oldmsg.split("\n")
|
|
hint = "An exception occurred during macro expansion.\n\nMacro use site (most recent macro application last):"
|
|
hint_lines = hint.split("\n")
|
|
hint_length_in_lines = len(hint_lines)
|
|
if oldmsg_lines[0] == hint_lines[0]:
|
|
oldmsg = "\n".join(oldmsg_lines[hint_length_in_lines:])
|
|
msg = f"{hint}\n{msg}\n{oldmsg}"
|
|
raise MacroApplicationError(msg).with_traceback(err.__traceback__) from err.__cause__
|
|
else:
|
|
# Not nested; tack on a `MacroApplicationError` with
|
|
# use site info to whatever exception we originally got.
|
|
#
|
|
# Use site report telescoping uses the fact that this
|
|
# is the only raise-from for a `MacroApplicationError`.
|
|
# So if it has a `__cause__`, it came from here.
|
|
raise MacroApplicationError(msg) from err
|
|
|
|
return self._visit_expansion(expansion, target)
|
|
|
|
def _visit_expansion(self, expansion, target):
|
|
"""Perform local postprocessing.
|
|
|
|
Add in missing source location info and `ctx`.
|
|
|
|
Then, if in recursive mode, recurse into (`visit`) the once-expanded
|
|
macro output. That will cause the actual expander to `expand` again
|
|
if it detects any more macro invocations.
|
|
"""
|
|
if expansion is not None:
|
|
expansion = fix_locations(expansion, target, mode="reference")
|
|
expansion = fix_ctx(expansion, copy_seen_nodes=False)
|
|
if self.recursive:
|
|
expansion = self.visit(expansion)
|
|
|
|
return expansion
|
|
|
|
def isbound(self, name, *, global_only=False):
|
|
"""Return the macro function the string `name` is bound to, or `False`."""
|
|
bindings = self.bindings if not global_only else global_bindings
|
|
if name in bindings:
|
|
return bindings[name]
|
|
return False
|
|
|
|
def _apply_macro(macro, tree, kw):
|
|
"""Execute `macro` on `tree`, with the dictionary `kw` unpacked into macro's named arguments."""
|
|
return macro(tree, **kw)
|
|
|
|
|
|
# Final postprocessing for the top-level walk can't be done at the end of the
|
|
# entry points `visit_once` and `visit_recursively`, because it is valid for a
|
|
# macro to call those for a subtree.
|
|
def global_postprocess(tree):
|
|
"""Perform global postprocessing for the top-level expansion.
|
|
|
|
Delete any AST markers emitted by the macro expander that it uses to talk
|
|
with itself during expansion.
|
|
|
|
Call this after macro expansion is otherwise done, before sending `tree`
|
|
to Python's `compile`.
|
|
"""
|
|
tree = delete_markers(tree, cls=MacroExpanderMarker)
|
|
# A name macro, appearing as an assignment target, gets the wrong ctx,
|
|
# because when expanding the name macro, the expander sees only the Name
|
|
# node, and thus puts an `ast.Load` there as the ctx.
|
|
tree = fix_ctx(tree, copy_seen_nodes=True)
|
|
return tree
|