231 lines
9.3 KiB
Python
231 lines
9.3 KiB
Python
# -*- coding: utf-8; -*-
|
|
"""Macro debugging utilities."""
|
|
|
|
__all__ = ["step_expansion", "StepExpansion", "step_phases",
|
|
"show_bindings", "format_bindings",
|
|
"SourceLocationInfoValidator"]
|
|
|
|
import ast
|
|
import functools
|
|
import io
|
|
from sys import stderr
|
|
import textwrap
|
|
|
|
from .astdumper import dump
|
|
from .colorizer import setcolor, colorize, ColorScheme
|
|
from .dialects import StepExpansion # re-export for discoverability, it's a debug feature
|
|
from .expander import MacroCollector, namemacro, parametricmacro
|
|
from .multiphase import step_phases # re-export for discoverability, it's a debug feature
|
|
from .unparser import unparse_with_fallbacks
|
|
from .utils import NestingLevelTracker, format_macrofunction, format_context
|
|
from .walkers import ASTVisitor
|
|
|
|
|
|
_step_expansion_level = NestingLevelTracker()
|
|
|
|
@parametricmacro
|
|
def step_expansion(tree, *, args, syntax, expander, **kw):
|
|
"""[syntax, expr/block] Macroexpand `tree`, showing each step of the expansion.
|
|
|
|
Syntax::
|
|
|
|
step_expansion[...]
|
|
step_expansion[mode][...]
|
|
with step_expansion:
|
|
...
|
|
with step_expansion[mode]:
|
|
...
|
|
|
|
This calls `expander.visit_once` in a loop, discarding the `Done` markers,
|
|
and showing the AST at each step, by printing to `sys.stderr`.
|
|
|
|
A step is defined as when `expander.visit_once` returns control to the
|
|
expander core. If your macro does `expander.visit(subtree)`, that will
|
|
expand `subtree` by one layer of macros (without adding a `Done` marker)
|
|
before returning control; if it does `expander.visit_recursively(subtree)`,
|
|
then that subtree will be expanded, in a single step, until no macros remain.
|
|
|
|
Since this is a debugging utility, the source code is rendered in the debug
|
|
mode of `unparse`, which prints also invisible nodes such as `Module` and
|
|
`Expr` (as well as any AST markers) in a Python-like pseudocode notation.
|
|
|
|
The source code is rendered with syntax highlighting suitable for terminal
|
|
output.
|
|
|
|
The optional macro argument `mode`, if present, sets the renderer mode.
|
|
It must be one of the strings `"unparse"` (default) or `"dump"`.
|
|
If `"unparse"`, then at each step, the AST will be shown as source code.
|
|
If `"dump"`, then at each step, the AST will be shown as a raw AST dump.
|
|
|
|
This macro steps the expansion at macro expansion time. If you have a
|
|
run-time AST value (such as a quasiquoted tree), and want to step the
|
|
expansion of that at run time, see the `stepr` macro in `mcpyrate.metatools`.
|
|
"""
|
|
if syntax not in ("expr", "block"):
|
|
raise SyntaxError("`step_expansion` is an expr and block macro only")
|
|
|
|
formatter = functools.partial(unparse_with_fallbacks, debug=True, color=True,
|
|
expander=expander)
|
|
if args:
|
|
if len(args) != 1:
|
|
raise SyntaxError("expected `step_expansion` or `step_expansion['mode_str']`")
|
|
arg = args[0]
|
|
if type(arg) is ast.Constant:
|
|
mode = arg.value
|
|
elif type(arg) is ast.Str: # up to Python 3.7
|
|
mode = arg.s
|
|
else:
|
|
raise TypeError(f"expected mode_str, got {repr(arg)} {unparse_with_fallbacks(arg)}")
|
|
if mode not in ("unparse", "dump"):
|
|
raise ValueError(f"expected mode_str either 'unparse' or 'dump', got {repr(mode)}")
|
|
if mode == "dump":
|
|
formatter = functools.partial(dump, include_attributes=True, color=True)
|
|
|
|
c, CS = setcolor, ColorScheme
|
|
|
|
with _step_expansion_level.changed_by(+1):
|
|
indent = 2 * _step_expansion_level.value
|
|
stars = indent * '*'
|
|
codeindent = indent
|
|
tag = id(tree)
|
|
print(f"{c(CS.HEADING)}{stars}Tree {c(CS.TREEID)}0x{tag:x} ({expander.filename}) {c(CS.HEADING)}before macro expansion:{c()}",
|
|
file=stderr)
|
|
print(textwrap.indent(formatter(tree), codeindent * ' '), file=stderr)
|
|
mc = MacroCollector(expander)
|
|
mc.visit(tree)
|
|
step = 0
|
|
while mc.collected:
|
|
step += 1
|
|
tree = expander.visit_once(tree) # -> Done(body=...)
|
|
tree = tree.body
|
|
print(f"{c(CS.HEADING)}{stars}Tree {c(CS.TREEID)}0x{tag:x} ({expander.filename}) {c(CS.HEADING)}after step {step}:{c()}",
|
|
file=stderr)
|
|
print(textwrap.indent(formatter(tree), codeindent * ' '), file=stderr)
|
|
mc.clear()
|
|
mc.visit(tree)
|
|
plural = "s" if step != 1 else ""
|
|
print(f"{c(CS.HEADING)}{stars}Tree {c(CS.TREEID)}0x{tag:x} ({expander.filename}) {c(CS.HEADING)}macro expansion complete after {step} step{plural}.{c()}",
|
|
file=stderr)
|
|
return tree
|
|
|
|
|
|
@namemacro
|
|
def show_bindings(tree, *, syntax, expander, **kw):
|
|
"""[syntax, name] Show all bindings of the macro expander.
|
|
|
|
Syntax::
|
|
|
|
show_bindings
|
|
|
|
This can appear in any expression position, and at run time evaluates to `None`.
|
|
If you instead want to programmatically examine, at run time, the bindings
|
|
the expander had at macro expansion time, see the `macro_bindings` macro
|
|
in `mcpyrate.metatools`.
|
|
|
|
At macro expansion time, when the macro expander reaches the `show_bindings`
|
|
expression, bindings *that are in effect at that point in time* are shown.
|
|
|
|
(That disclaimer is important, because hygienically unquoted macros may add
|
|
new bindings as those expressions are reached, and true to the dynamic nature
|
|
of Python, when any macro runs, it is allowed to edit the expander's bindings.)
|
|
|
|
For each macro binding, we print to `sys.stderr` the macro name, and the
|
|
fully qualified name of the corresponding macro function.
|
|
|
|
Any bindings that have an uuid as part of the name are hygienically
|
|
unquoted macros. Those bindings are global across all modules and
|
|
all expander instances.
|
|
"""
|
|
if syntax != "name":
|
|
raise SyntaxError("`show_bindings` is an identifier macro only")
|
|
print(format_bindings(expander, globals_too=False, color=True), file=stderr)
|
|
return None
|
|
|
|
|
|
def format_bindings(expander, *, globals_too=False, color=False):
|
|
"""Return a human-readable report of the macro bindings currently seen by `expander`.
|
|
|
|
If `globals_too=True`, global bindings (across all expanders) are also included.
|
|
|
|
If `color=True`, colorize the output for printing into a terminal.
|
|
|
|
If you want to access them programmatically, just access `expander.bindings` directly.
|
|
"""
|
|
def maybe_setcolor(*colors):
|
|
if not color:
|
|
return ""
|
|
return setcolor(*colors)
|
|
def maybe_colorize(text, *colors):
|
|
if not color:
|
|
return text
|
|
return colorize(text, *colors)
|
|
|
|
c, CS = maybe_setcolor, ColorScheme
|
|
|
|
bindings = expander.bindings if globals_too else expander.local_bindings
|
|
with io.StringIO() as output:
|
|
output.write(f"{c(CS.HEADING)}Macro bindings for {c(CS.SOURCEFILENAME)}{expander.filename}{c(CS.HEADING)}:{c()}\n")
|
|
if not bindings:
|
|
output.write(maybe_colorize(" <no bindings>\n",
|
|
ColorScheme.GREYEDOUT))
|
|
else:
|
|
for k, v in sorted(bindings.items()):
|
|
k = maybe_colorize(k, ColorScheme.MACRONAME)
|
|
output.write(f" {k}: {format_macrofunction(v)}\n")
|
|
return output.getvalue()
|
|
|
|
|
|
class SourceLocationInfoValidator(ASTVisitor):
|
|
"""Collect nodes that are missing `lineno` and/or `col_offset`.
|
|
|
|
Usage::
|
|
|
|
v = SourceLocationInfoValidator()
|
|
v.visit(tree)
|
|
print(v.collected)
|
|
|
|
It's a rather common occurrence when developing macros to have the source
|
|
location info missing somewhere, but when we `compile`, Python won't tell us
|
|
*which* nodes are missing them.
|
|
|
|
This can also be used to debug whether the problem is what Python claims it is.
|
|
Python's `compile` is notorious for yelling about a missing source location
|
|
when the actual problem is that is got a bare value in a position where an
|
|
AST node was expected.
|
|
|
|
The macro expander *should* fill in missing source location info when it expands
|
|
a macro, so this utility will be needed only rarely.
|
|
|
|
After `visit(tree)`, `self.collected` becomes a `list` of items in the format
|
|
`(subtree, sourcecode, missing_field_names)`. Each `sourcecode` is truncated
|
|
if too long.
|
|
"""
|
|
def __init__(self, ignore={}, n=5, check_fields=["lineno", "col_offset"]):
|
|
"""Constructor.
|
|
|
|
Parameters:
|
|
|
|
`ignore={tree0, ...}` to ignore given subtrees (such as if you have
|
|
a top-level `Module` node; those don't need source location info).
|
|
Subtrees are detected by their `id`.
|
|
|
|
`n`: maximum number of source lines to show for each collected item.
|
|
|
|
`check_fields`: which fields are considered mandatory for every node
|
|
in `tree`. Defaults to checking source location info.
|
|
"""
|
|
self.ignore = ignore
|
|
self.n = n
|
|
self.check_fields = check_fields
|
|
super().__init__()
|
|
|
|
def examine(self, tree):
|
|
if tree not in self.ignore:
|
|
present = [hasattr(tree, x) for x in self.check_fields]
|
|
if not all(present):
|
|
code = format_context(tree, n=self.n)
|
|
self.collect((tree,
|
|
code,
|
|
[fieldname for fieldname, p in zip(self.check_fields, present) if not p]))
|
|
self.generic_visit(tree)
|