KiBot/kibot/mcpyrate/core.py

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)