KiBot/kibot/mcpyrate/core.py

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