KiBot/kibot/mcpyrate/debug.py

202 lines
7.9 KiB
Python

# -*- coding: utf-8; -*-
"""Macro debugging utilities."""
__all__ = ["step_expansion", "StepExpansion",
"show_bindings", "format_bindings",
"SourceLocationInfoValidator"]
import ast
import functools
import io
from sys import stderr
import textwrap
from .astdumper import dump
from .dialects import StepExpansion # re-export for discoverability, it's a debug feature
from .expander import MacroCollector, namemacro, parametricmacro
from .unparser import unparse_with_fallbacks
from .utils import NestingLevelTracker, format_macrofunction
from .walker import Walker
_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 by one step; 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` in a Python-like pseudocode notation.
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.
"""
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)
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)
with _step_expansion_level.changed_by(+1):
indent = 2 * _step_expansion_level.value
stars = indent * '*'
codeindent = indent
tag = id(tree)
print(f"{stars}Tree 0x{tag:x} before macro expansion:", 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"{stars}Tree 0x{tag:x} after step {step}:", file=stderr)
print(textwrap.indent(formatter(tree), codeindent * ' '), file=stderr)
mc.clear()
mc.visit(tree)
plural = "s" if step != 1 else ""
print(f"{stars}Tree 0x{tag:x} macro expansion complete after {step} step{plural}.", 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`.
At macro expansion time, for each macro binding, this prints 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 make a per-process global binding across all modules
and all expander instances.
"""
if syntax != "name":
raise SyntaxError("`show_bindings` is an identifier macro only")
print(format_bindings(expander), file=stderr)
# Can't just delete the node (return None) if it's in an Expr(value=...).
#
# For correct coverage reporting, we can't return a `Constant`, because CPython
# optimizes away do-nothing constants. So trick the compiler into thinking
# this is important, by making the expansion result call a no-op function.
lam = ast.Lambda(args=ast.arguments(posonlyargs=[], args=[], vararg=None,
kwonlyargs=[], kw_defaults=[], kwarg=None,
defaults=[]),
body=ast.Constant(value=None))
return ast.Call(func=lam, args=[], keywords=[])
def format_bindings(expander):
"""Return a human-readable report of the macro bindings currently seen by `expander`.
Global bindings (across all expanders) are also included.
If you want to access them programmatically, just access `expander.bindings` directly.
"""
with io.StringIO() as output:
output.write(f"Macro bindings for {expander.filename}:\n")
if not expander.bindings:
output.write(" <no bindings>\n")
else:
for k, v in sorted(expander.bindings.items()):
output.write(f" {k}: {format_macrofunction(v)}\n")
return output.getvalue()
class SourceLocationInfoValidator(Walker):
"""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 transform(self, tree):
if tree not in self.ignore:
present = [hasattr(tree, x) for x in self.check_fields]
if not all(present):
code_lines = unparse_with_fallbacks(tree).split("\n")
code = "\n".join(code_lines[:self.n])
if len(code_lines) > self.n:
code += "\n..."
self.collect((tree,
code,
[fieldname for fieldname, p in zip(self.check_fields, present) if not p]))
return self.generic_visit(tree)