Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recursion table #984

Merged
merged 12 commits into from
Dec 10, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `AccumulationTable` now stores deep copies of objects rather than shallow copies, thus fixing issues that come up in case of mutation during loop.
- `AccumulationTable` can now take in any accumulator expressions, for eg. `x * 2`, instead of just variables.
- `AccumulationTable` now has an optional initialization argument `output` which allows the users to choose whether they want to write the Accumulation Table to a file.
- Created a `RecursionTable` context manager for recursive tracing using a tabular output.

### Bug fixes

Expand Down
102 changes: 95 additions & 7 deletions docs/debug/index.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# Loop Debugging
# Loop and Recursion Tracing

This page describes an additional PythonTA feature: print-based loop debugging.
This feature makes it easier to trace the execution of a loop by printing the state of each loop iteration in a nicely-formatted table using the [tabulate] library.
This page describes an additional PythonTA feature: print-based debugging for loops and recursion.
This feature makes tracing easier by printing the state of each loop iteration or recursive function call in a nicely-formatted table using the [tabulate] library.
This functionality is found in the `python_ta.debug` submodule.

## Example usage
## Loop tracing with `AccumulationTable`

The following section will focus on the debugging of while and for loops.
This feature uses the `python_ta.debug.AccumulationTable` as a context manager wrapping a loop.
Let's start with two examples.

### For loop example
### Example 1: for loop

```python
# demo.py
Expand Down Expand Up @@ -48,7 +51,7 @@ iteration number sum_so_far avg_so_far list_so_far
6 60 210 35.0 [(10, 10.0), (30, 15.0), (60, 20.0), (100, 25.0), (150, 30.0), (210, 35.0)]
```

### While loop example
### Example 2: while loop

To use `AccumulationTable` with while loops, you need to pass in the name of the loop variable when initializing the table.

Expand Down Expand Up @@ -91,7 +94,7 @@ iteration number sum_so_far list_so_far
6 6 15 [0, 1, 2, 3, 4, 5]
```

## API
### API

```{eval-rst}
.. automethod:: python_ta.debug.AccumulationTable.__init__
Expand Down Expand Up @@ -160,5 +163,90 @@ The `AccumulationTable` is a new PythonTA feature and currently has the followin
2. The `AccumulationTable` context manager can only log the execution of one for loop.
To log the state of multiple for loops, each must be wrapped in a separate `with` statement and fresh `AccumulationTable` instance.

## Recursion tracing with `RecursionTable`

This section will discuss the debugging of recursive functions.
This feature uses the `python_ta.debug.RecursionTable` class as a context manager wrapping a recursive function.

### Example usage

```python
# demo.py
from python_ta.debug import RecursionTable

def factorial(n: int) -> int:
"""Calculate the factorial of n."""
if n == 0:
return 1
return n * factorial(n - 1)

def trace_factorial(number: int) -> None:
"Trace a recursively defined factorial function using RecursionTable."
with RecursionTable("factorial"):
factorial(number)

if __name__ == '__main__':
trace_factorial(4)
```

When this file is run, we get the following output:

```console
$ python demo.py
n return value called by
--- -------------- ------------
4 24 N/A
3 6 factorial(4)
2 2 factorial(3)
1 1 factorial(2)
0 1 factorial(1)
```

### API

```{eval-rst}
.. automethod:: python_ta.debug.RecursionTable.__init__
```

The `RecursionTable` class has the following methods you can access after the `with` statement.

```{eval-rst}
.. automethod:: python_ta.debug.RecursionTable.get_recursive_dict
```

For example:

```python
# demo.py
from python_ta.debug import RecursionTable

def factorial(n: int) -> int:
"""Calculate the factorial of n."""
if n == 0:
return 1
return n * factorial(n - 1)

def trace_factorial(number: int) -> None:
"Trace a recursively defined factorial function using RecursionTable."
with RecursionTable("factorial") as table:
factorial(number)

traced_data = table.get_recursive_dict()
print(traced_data)
```

## Tracing with user-defined classes

Both `AccumulationTable` and `RecursionTable` call `str` on objects to display table entries.
If you plan to use instances of a user-defined class in these tables (for example, a `Tree` class when tracing a recursive `Tree` method), we recommend implementing the `__str__` method in your class to ensure a meaningful output is displayed.

### Current limitations

The `RecursionTable` is a new PythonTA feature and currently has the following known limitations:

1. `RecursionTable` uses [`sys.settrace`] to update variable state, and so is not compatible with other libraries (e.g. debuggers, code coverage tools).

2. Only one function can be traced per use of `RecursionTable`, and so mutually-recursive functions are not supported.

[tabulate]: https://github.com/astanin/python-tabulate
[`sys.settrace`]: https://docs.python.org/3/library/sys.html#sys.settrace
1 change: 1 addition & 0 deletions python_ta/debug/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .accumulation_table import AccumulationTable
from .recursion_table import RecursionTable
164 changes: 164 additions & 0 deletions python_ta/debug/recursion_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""
Table data structure that prints a nicely formatted table
for a recursive function.
"""
from __future__ import annotations

import copy
import inspect
import sys
import types
from typing import Any, Callable, Optional

import tabulate

from python_ta.util.tree import Tree

DEFAULT_FUNCTION_STRING = "N/A"


def clean_frame_variables(frame: types.FrameType) -> dict[str, Any]:
"""Remove the local variables from the frame's locals and keep only the
parameters.
"""
raw_variables = frame.f_locals
parameters = inspect.getargvalues(frame).args
cleaned_variables = {param: copy.deepcopy(raw_variables[param]) for param in parameters}
return cleaned_variables


class RecursionTable:
"""
Class used as a form of print debugging to analyze the inputs
and return values for a recursive function.

Instance attributes:
frames_data: a mapping between the frame for a
recursive function and its traced values
function_name: name of the function to be traced
_trees: mapping of the frames to the corresponding tree
representing the function call
"""

frames_data: dict[types.FrameType, dict[str, Any]]
function_name: str
_trees: dict[types.FrameType, Tree]

def __init__(self, function_name: str) -> None:
"""Initialize a RecursionTable context manager for print-based recursive debugging
of <function_name>.
"""
self.function_name = function_name
self.frames_data = {}
self._trees = {}

def _get_root(self) -> Optional[Tree]:
"""Return the root node of the tree."""
if self.frames_data:
return self._trees[next(iter(self.frames_data))]

def _create_func_call_string(self, frame_variables: dict[str, Any]) -> str:
"""Create a string representation of the function call given the inputs
for eg. 'fib(2, 3)'.
"""
# note that in python dicts the order is maintained based on insertion
# we don't need to worry about the order of inputs changing
function_inputs = ", ".join(str(frame_variables[var]) for var in frame_variables)
return f"{self.function_name}({function_inputs})"

def _insert_to_tree(
self, current_func_string: str, frame: types.FrameType, caller_frame: types.FrameType
) -> None:
"""Create a new node for self._trees and add it as a child for its parent frame, if applicable."""
current_node = Tree([current_func_string])
self._trees[frame] = current_node
# this will always be true unless frame is the initial function call frame
if caller_frame in self._trees:
caller_node = self._trees[caller_frame]
caller_node.add_child(current_node)

def _record_call(self, frame: types.FrameType) -> None:
"""Update the state of the table representation after a function call is detected."""
current_frame_data = {}
caller_frame = frame.f_back
current_frame_variables = clean_frame_variables(frame)

# add the inputs to the dict
for variable in current_frame_variables:
current_frame_data[variable] = current_frame_variables[variable]

# add the parent function call string
if caller_frame not in self.frames_data:
current_frame_data["called by"] = DEFAULT_FUNCTION_STRING
else:
current_frame_data["called by"] = self.frames_data[caller_frame]["call string"]

# add the function call string for the current frame
current_func_string = self._create_func_call_string(current_frame_variables)
current_frame_data["call string"] = current_func_string

self.frames_data[frame] = current_frame_data
self._insert_to_tree(current_func_string, frame, caller_frame)

def _record_return(self, frame: types.FrameType, return_value: Any) -> None:
"""Update the state of the table representation after a function return is detected.
Note: the frame must already have been seen as returns are done 'on the way out'.
"""
self.frames_data[frame]["return value"] = return_value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll probably want a deepcopy here as well

current_node = self._trees[frame]
current_node.value.append(return_value)

def get_recursive_dict(self) -> dict[str, list]:
"""Use the instance variables that define the table to create a final dictionary
which directly represents the table.
"""
if not self.frames_data:
return {}
# intialize table columns using the first frame
parameters = inspect.getargvalues(next(iter(self.frames_data))).args
recursive_dict = {key: [] for key in parameters + ["return value", "called by"]}

for frame in self.frames_data:
current_frame_data = self.frames_data[frame]
for key in current_frame_data:
# this should always be true unless key == "call string"
if key in recursive_dict:
recursive_dict[key].append(current_frame_data[key])
return recursive_dict

def _tabulate_data(self) -> None:
"""Print the recursive table."""
recursive_dict = self.get_recursive_dict()
print(
tabulate.tabulate(
recursive_dict,
headers="keys",
colalign=(*["left"] * len(recursive_dict),),
disable_numparse=True,
missingval="None",
)
)

def _trace_recursion(self, frame: types.FrameType, event: str, _arg: Any) -> Callable:
"""Trace through the recursive exexution and call the corresponding
method depending on whether a call or return is detected.
"""
# only trace frames that match the correct function name
if frame.f_code.co_name == self.function_name:
if event == "call":
self._record_call(frame)
elif event == "return":
self._record_return(frame, _arg)

# return the function to continue tracing
return self._trace_recursion

def __enter__(self) -> RecursionTable:
"""Set up and return the recursion table for the recursive function."""
sys.settrace(self._trace_recursion)
return self

def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Exit the recursive execution, stop tracing function execution and print the table."""
sys.settrace(None)
self._tabulate_data()
40 changes: 40 additions & 0 deletions python_ta/util/tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
Simple Tree class to be used for RecursionTable.
"""
from __future__ import annotations

from typing import Any


class Tree:
david-yz-liu marked this conversation as resolved.
Show resolved Hide resolved
"""
This class is used by RecursionTable to represent a
recursive call.

Instance attributes:
value: the value of the tree node
children: the child nodes of the tree
"""

value: Any
children: list[Tree]

def __init__(self, value: Any) -> None:
"""Initialize a Tree with no children by default."""
self.value = value
self.children = []

def add_child(self, child_node: Tree) -> None:
"""Add child_node as one of the tree's children."""
self.children.append(child_node)

def __eq__(self, tree: Tree) -> bool:
"""Check if self and tree are equal by comparing their values and
structure.
"""
if self.value != tree.value or len(self.children) != len(tree.children):
return False
for i in range(len(self.children)):
if not self.children[i].__eq__(tree.children[i]):
return False
return True
Loading
Loading