Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions pysnooper/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ def __init__(self, output=None, watch=(), watch_explode=(), depth=1,
for v in utils.ensure_tuple(watch_explode)
]
self.frame_to_local_reprs = {}
self.frame_to_with_line = {}
self.start_times = {}
self.depth = depth
self.prefix = prefix
Expand Down Expand Up @@ -372,6 +373,7 @@ def __enter__(self):
if not self._is_internal_frame(calling_frame):
calling_frame.f_trace = self.trace
self.target_frames.add(calling_frame)
self.frame_to_with_line[calling_frame] = calling_frame.f_lineno

stack = self.thread_local.__dict__.setdefault(
'original_trace_functions', []
Expand All @@ -387,6 +389,16 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
sys.settrace(stack.pop())
calling_frame = inspect.currentframe().f_back
self.target_frames.discard(calling_frame)
# Variable changes are reported on the *next* trace event. The last
# line of a `with` block has no next event before we get here, so we
# flush it now. On Python 3.10+ leaving the block emits a line event
# that already did the flush; frame_to_with_line is set to None in
# that case, so we don't report (and re-evaluate) everything twice.
with_line = self.frame_to_with_line.pop(calling_frame, None)
if with_line is not None:
self._report_variable_changes(
calling_frame, ' ' * 4 * thread_global.depth
)
self.frame_to_local_reprs.pop(calling_frame, None)

### Writing elapsed time: #############################################
Expand All @@ -408,6 +420,32 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
# #
### Finished writing elapsed time. ####################################

def _report_variable_changes(self, frame, indent, is_call=False):
_FOREGROUND_GREEN = self._FOREGROUND_GREEN
_STYLE_DIM = self._STYLE_DIM
_STYLE_NORMAL = self._STYLE_NORMAL
_STYLE_RESET_ALL = self._STYLE_RESET_ALL

old_local_reprs = self.frame_to_local_reprs.get(frame, {})
self.frame_to_local_reprs[frame] = local_reprs = \
get_local_reprs(frame,
watch=self.watch, custom_repr=self.custom_repr,
max_length=self.max_variable_length,
normalize=self.normalize,
)

newish_string = 'Starting var:.. ' if is_call else 'New var:....... '

for name, value_repr in local_reprs.items():
if name not in old_local_reprs:
self.write('{indent}{_FOREGROUND_GREEN}{_STYLE_DIM}'
'{newish_string}{_STYLE_NORMAL}{name} = '
'{value_repr}{_STYLE_RESET_ALL}'.format(**locals()))
elif old_local_reprs[name] != value_repr:
self.write('{indent}{_FOREGROUND_GREEN}{_STYLE_DIM}'
'Modified var:.. {_STYLE_NORMAL}{name} = '
'{value_repr}{_STYLE_RESET_ALL}'.format(**locals()))

def _is_internal_frame(self, frame):
return frame.f_code.co_filename == Tracer.__enter__.__code__.co_filename

Expand Down Expand Up @@ -504,27 +542,12 @@ def trace(self, frame, event, arg):

### Reporting newish and modified variables: ##########################
# #
old_local_reprs = self.frame_to_local_reprs.get(frame, {})
self.frame_to_local_reprs[frame] = local_reprs = \
get_local_reprs(frame,
watch=self.watch, custom_repr=self.custom_repr,
max_length=self.max_variable_length,
normalize=self.normalize,
)

newish_string = ('Starting var:.. ' if event == 'call' else
'New var:....... ')

for name, value_repr in local_reprs.items():
if name not in old_local_reprs:
self.write('{indent}{_FOREGROUND_GREEN}{_STYLE_DIM}'
'{newish_string}{_STYLE_NORMAL}{name} = '
'{value_repr}{_STYLE_RESET_ALL}'.format(**locals()))
elif old_local_reprs[name] != value_repr:
self.write('{indent}{_FOREGROUND_GREEN}{_STYLE_DIM}'
'Modified var:.. {_STYLE_NORMAL}{name} = '
'{value_repr}{_STYLE_RESET_ALL}'.format(**locals()))

self._report_variable_changes(frame, indent, is_call=(event == 'call'))
# A line event back on the `with` statement line is the block exit on
# Python 3.10+. It just flushed the final line's variables, so mark it
# so __exit__ doesn't report them (and re-run every repr) a second time.
if self.frame_to_with_line.get(frame) == line_no:
self.frame_to_with_line[frame] = None
# #
### Finished newish and modified variables. ###########################

Expand Down
83 changes: 80 additions & 3 deletions tests/test_pysnooper.py
Original file line number Diff line number Diff line change
Expand Up @@ -1190,7 +1190,7 @@ def f1(x1):
LineEntry(),
ReturnEntry(),
ReturnValueEntry('20'),
VariableEntry(min_python_version=(3, 10)),
VariableEntry(),
LineEntry(source_regex="with pysnooper.snoop.*", min_python_version=(3, 10)),
ElapsedTimeEntry(),
),
Expand Down Expand Up @@ -1259,14 +1259,91 @@ def f1(a):
ReturnValueEntry(),
ReturnEntry(),
ReturnValueEntry(),
VariableEntry(min_python_version=(3, 10)),
VariableEntry(),
LineEntry(source_regex="with pysnooper.snoop.*", min_python_version=(3, 10)),
ElapsedTimeEntry(),
),
normalize=normalize,
)


def test_with_block_reports_last_line_variables():
# Regression for #237: the last line of a `with` block is not followed by
# another trace event on Python < 3.10, so a variable created or modified
# there used to be dropped. Make sure a final line that both modifies an
# existing variable and creates a new one is reported, exactly once, on
# every Python version.
string_io = io.StringIO()

def f():
with pysnooper.snoop(string_io, color=False, normalize=True):
x = 1
y = 2
x = 3; z = 4

f()
assert_output(
string_io.getvalue(),
(
SourcePathEntry(),
VariableEntry(), # the output stream local
LineEntry('x = 1'),
VariableEntry('x', '1', stage='new'),
LineEntry('y = 2'),
VariableEntry('y', '2', stage='new'),
LineEntry('x = 3; z = 4'),
VariableEntry('x', '3', stage='modified'),
VariableEntry('z', '4', stage='new'),
LineEntry(source_regex="with pysnooper.snoop.*",
min_python_version=(3, 10)),
ElapsedTimeEntry(),
),
normalize=True,
)


def test_with_block_one_line_body():
# Regression for #237: a one-line `with` body produces no trace event at
# all, so its variables have to be reported from __exit__.
string_io = io.StringIO()

def f():
with pysnooper.snoop(string_io, color=False, normalize=True): answer = 42

f()
assert_output(
string_io.getvalue(),
(
VariableEntry('answer', '42', stage='new'),
VariableEntry(), # the output stream local
ElapsedTimeEntry(),
),
normalize=True,
)


def test_with_block_exit_does_not_reevaluate_reprs():
# Regression for #237: on Python 3.10+ leaving the block already reports
# the final variables via a line event, so __exit__ must not evaluate the
# reprs a second time (which would double custom_repr / watch calls).
string_io = io.StringIO()
call_count = [0]

def count_repr(x):
call_count[0] += 1
return 'LIST'

def f():
with pysnooper.snoop(
string_io, color=False,
custom_repr=((lambda x: isinstance(x, list), count_repr),),
):
data = [1, 2, 3]

f()
assert call_count[0] == 1


@pytest.mark.parametrize("normalize", (True, False))
def test_var_order(normalize):
string_io = io.StringIO()
Expand Down Expand Up @@ -1309,7 +1386,7 @@ def f(one, two, three, four):
VariableEntry("seven", "7"),
ReturnEntry(),
ReturnValueEntry(),
VariableEntry("result", "None", min_python_version=(3, 10)),
VariableEntry("result", "None"),
LineEntry(source_regex="with pysnooper.snoop.*", min_python_version=(3, 10)),
ElapsedTimeEntry(),
),
Expand Down