235 lines
9.1 KiB
Python
235 lines
9.1 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_missing_ctx, fix_missing_locations
|
|
from .markers import ASTMarker, delete_markers
|
|
from .utils import flatten_suite, 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):
|
|
'''Error during macro expansion.'''
|
|
|
|
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._bindings = bindings
|
|
self.bindings = ChainMap(self._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 type(tree) is Done:
|
|
return tree
|
|
if tree is None:
|
|
return None
|
|
if isinstance(tree, list):
|
|
newtree = flatten_suite(self.visit(elt) for elt in tree)
|
|
if newtree:
|
|
tree[:] = newtree
|
|
return tree
|
|
return None
|
|
return super().visit(tree)
|
|
|
|
def visit_recursively(self, tree):
|
|
'''Entrypoint. 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):
|
|
'''Entrypoint. 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`.
|
|
'''
|
|
macro = self.bindings[macroname]
|
|
kw = kw or {}
|
|
kw.update({
|
|
'syntax': syntax,
|
|
'expander': self,
|
|
'invocation': target})
|
|
|
|
loc = format_location(self.filename, target, sourcecode)
|
|
|
|
# Expand the macro.
|
|
try:
|
|
expansion = _apply_macro(macro, tree, kw)
|
|
except Exception as err:
|
|
msg = f"{loc}\nwhile expanding {syntax} macro invocation for '{macroname}'"
|
|
if isinstance(err, MacroExpansionError) and err.__cause__: # telescope nested use site reports
|
|
oldmsg = err.args[0]
|
|
if oldmsg[0] == "\n":
|
|
oldmsg = oldmsg[1:]
|
|
msg = f'\n{msg}\n{oldmsg}'
|
|
err = err.__cause__
|
|
raise MacroExpansionError(msg) from err
|
|
|
|
# Convert possible iterable result to `list`, then typecheck macro output.
|
|
output_type_ok = True
|
|
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 MacroExpansionError
|
|
else:
|
|
raise MacroExpansionError
|
|
except Exception:
|
|
output_type_ok = False
|
|
if not output_type_ok:
|
|
reason = f"expected macro to return AST, iterable or None; got {type(expansion)} with value {repr(expansion)}"
|
|
msg = f"{loc}\n{reason}"
|
|
raise MacroExpansionError(msg)
|
|
|
|
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_missing_locations(expansion, target, mode="reference")
|
|
expansion = fix_missing_ctx(expansion)
|
|
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
|
|
# entrypoints `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`.
|
|
'''
|
|
return delete_markers(tree, cls=MacroExpanderMarker)
|