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

Persistent watch expressions (continuation of #150) #661

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
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
30 changes: 14 additions & 16 deletions pudb/debugger.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ def runcall(self, *args, **kwargs):
labelled_value,
make_hotkey_markup,
)
from pudb.var_view import FrameVarInfoKeeper
from pudb.var_view import FrameVarInfoKeeper, Watches, WatchExpression


# {{{ display setup
Expand Down Expand Up @@ -1037,11 +1037,7 @@ def change_var_state(w, size, key):
elif key == "m":
iinfo.show_methods = not iinfo.show_methods
elif key == "delete":
fvi = self.get_frame_var_info(read_only=False)
for i, watch_expr in enumerate(fvi.watches):
if watch_expr is var.watch_expr:
del fvi.watches[i]
break
Watches.remove(var.watch_expr)

self.update_var_view(focus_index=focus_index)

Expand Down Expand Up @@ -1154,13 +1150,15 @@ def edit_inspector_detail(w, size, key):
iinfo.access_level = "all"

if var.watch_expr is not None:
var.watch_expr.expression = watch_edit.get_edit_text()
# Remove old expression and add new one, to avoid rehashing
Watches.remove(var.watch_expr)
var.watch_expr = WatchExpression(
watch_edit.get_edit_text())
Watches.add(var.watch_expr)

elif result == "del":
for i, watch_expr in enumerate(fvi.watches):
if watch_expr is var.watch_expr:
del fvi.watches[i]
break
# Remove saved expression
Watches.remove(var.watch_expr)

self.update_var_view()

Expand All @@ -1178,11 +1176,11 @@ def insert_watch(w, size, key):
("Cancel", False),
], title="Add Watch Expression"):

from pudb.var_view import WatchExpression
we = WatchExpression(watch_edit.get_edit_text())
fvi = self.get_frame_var_info(read_only=False)
fvi.watches.append(we)
self.update_var_view()
# Add new watch expression, if not empty
watch_text = watch_edit.get_edit_text()
if watch_text and watch_text.strip():
Watches.add(WatchExpression(watch_text))
self.update_var_view()

self.var_list.listen("\\", change_var_state)
self.var_list.listen(" ", change_var_state)
Expand Down
32 changes: 32 additions & 0 deletions pudb/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def get_save_config_path():

SAVED_BREAKPOINTS_FILE_NAME = "saved-breakpoints-%d.%d" % sys.version_info[:2]
BREAKPOINTS_FILE_NAME = "breakpoints-%d.%d" % sys.version_info[:2]
SAVED_WATCHES_FILE_NAME = "saved-watches-%d.%d" % sys.version_info[:2]


_config_ = [None]
Expand Down Expand Up @@ -110,6 +111,8 @@ def load_config():
conf_dict.setdefault("wrap_variables", "True")
conf_dict.setdefault("default_variables_access_level", "public")

conf_dict.setdefault("persist_watches", True)

conf_dict.setdefault("display", "auto")

conf_dict.setdefault("prompt_on_quit", "True")
Expand All @@ -134,6 +137,7 @@ def normalize_bool_inplace(name):

normalize_bool_inplace("line_numbers")
normalize_bool_inplace("wrap_variables")
normalize_bool_inplace("persist_watches")
normalize_bool_inplace("prompt_on_quit")
normalize_bool_inplace("hide_cmdline_win")

Expand Down Expand Up @@ -196,6 +200,9 @@ def _update_default_variables_access_level():
def _update_wrap_variables():
ui.update_var_view()

def _update_persist_watches():
pass

def _update_config(check_box, new_state, option_newvalue):
option, newvalue = option_newvalue
new_conf_dict = {option: newvalue}
Expand Down Expand Up @@ -252,6 +259,11 @@ def _update_config(check_box, new_state, option_newvalue):
conf_dict.update(new_conf_dict)
_update_wrap_variables()

elif option == "persist_watches":
new_conf_dict["persist_watches"] = not check_box.get_state()
conf_dict.update(new_conf_dict)
_update_persist_watches()

heading = urwid.Text("This is the preferences screen for PuDB. "
"Hit Ctrl-P at any time to get back to it.\n\n"
"Configuration settings are saved in "
Expand Down Expand Up @@ -416,6 +428,17 @@ def _update_config(check_box, new_state, option_newvalue):

# }}}

# {{{ persist watches

cb_persist_watches = urwid.CheckBox("Persist watches",
bool(conf_dict["persist_watches"]), on_state_change=_update_config,
user_data=("persist_watches", None))

persist_watches_info = urwid.Text("\nKeep watched expressions between "
"debugging sessions.")

# }}}

# {{{ display

display_info = urwid.Text("What driver is used to talk to your terminal. "
Expand Down Expand Up @@ -469,6 +492,10 @@ def _update_config(check_box, new_state, option_newvalue):
+ [cb_wrap_variables]
+ [wrap_variables_info]

+ [urwid.AttrMap(urwid.Text("\nPersist Watches:\n"), "group head")]
+ [cb_persist_watches]
+ [persist_watches_info]

+ [urwid.AttrMap(urwid.Text("\nDisplay driver:\n"), "group head")]
+ [display_info]
+ display_rbs
Expand Down Expand Up @@ -618,4 +645,9 @@ def save_breakpoints(bp_list):

# }}}


def get_watches_file_name():
from os.path import join
return join(get_save_config_path(), SAVED_WATCHES_FILE_NAME)

# vim:foldmethod=marker
137 changes: 137 additions & 0 deletions pudb/test/test_var_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import itertools
import string
import unittest
from unittest.mock import mock_open, patch

import pytest

from pudb.var_view import (
STRINGIFIERS,
Expand All @@ -12,6 +15,8 @@
PudbMapping,
PudbSequence,
ValueWalker,
Watches,
WatchExpression,
get_stringifier,
ui_log,
)
Expand Down Expand Up @@ -402,3 +407,135 @@ def test_maybe_unreasonable_classes(self):
# This effectively makes sure that class definitions aren't considered
# containers.
self.assert_class_counts_equal({"other": 2048})


class WatchExpressionTests(unittest.TestCase):
"""
Test class WatchExpression for expected behaviors
"""

def test_watch_expression_sorting(self):
alpha_watches = [
WatchExpression("a"),
WatchExpression("c"),
WatchExpression("b"),
WatchExpression("d"),
WatchExpression("f"),
WatchExpression("e"),
]
self.assertEqual(
"".join(sorted(str(watch_expr)
for watch_expr in alpha_watches)),
"abcdef")

def test_hashing(self):
we_a = WatchExpression("a")
we_b = WatchExpression("b")
self.assertEqual(hash(we_a), hash("a"))
self.assertEqual(hash(we_b), hash("b"))
self.assertNotEqual(hash(we_a), hash(we_b))

def test_equality(self):
we_a = WatchExpression("a")
we_a2 = WatchExpression("a")
we_b = WatchExpression("b")
self.assertEqual(we_a, we_a2)
self.assertNotEqual(we_a, we_b)

def test_repr(self):
expr = WatchExpression("a")
self.assertEqual(repr(expr), "a")

def test_str(self):
expr = WatchExpression("a")
self.assertEqual(str(expr), "a")

def test_set(self):
"""
watch expressions should be hashable and comparable,
and more or less equivalent to class str
"""
expr = WatchExpression("a")
self.assertIn(expr, {"a"})

# test set membership
we_a = WatchExpression("a")
we_b = WatchExpression("b")
test_set1 = {we_a, we_b}
self.assertIn(we_a, test_set1)
self.assertIn(we_b, test_set1)

# test equivalent sets
test_set2 = {we_b, we_a}
self.assertEqual(test_set1, test_set2)
self.assertIn(we_a, test_set2)
self.assertIn(we_b, test_set2)

# test adding a duplicate
test_set2.add(WatchExpression("a"))
self.assertEqual(test_set1, test_set2)

def test_immutability(self):
Watches.clear()
we_a = WatchExpression("a")
with pytest.raises(AttributeError):
we_a.expression = "b"


class WatchesTests(unittest.TestCase):
"""
Test class Watches for expected behavior
"""

def tearDown(self):
# Since Watches is a global object, we must clear out after each test
Watches.clear()

def test_add_watch(self):
we_z = WatchExpression("z")
Watches.add(we_z)
self.assertIn(we_z, Watches.all())

def test_add_watches(self):
watch_expressions_file_log = []

def mocked_file_write(*args):
watch_expressions_file_log.append(args)

mocked_open = mock_open()
# mock the write method of the file object
mocked_open.return_value.write = mocked_file_write
we_a = WatchExpression("a")
we_b = WatchExpression("b")
we_c = WatchExpression("c")
expressions = [we_a, we_b, we_c]

"""
The expressions file is cumulative, writing out whatever
current set of expressions Watches contains,
so we expect to see: [a], [a], [b], [a], [b], [c]
"""
expected_file_log = []
for i in range(len(expressions)):
for expr in expressions[:i + 1]:
expected_file_log.append((f"{str(expr)}\n", ))

with patch("builtins.open", mocked_open):
Watches.add(we_a)
Watches.add(we_b)
Watches.add(we_c)

self.assertEqual(len(watch_expressions_file_log), 6)
self.assertEqual(watch_expressions_file_log, expected_file_log)

self.assertIn(we_a, Watches.all())
self.assertIn(we_b, Watches.all())
self.assertIn(we_c, Watches.all())

def test_remove_watch(self):
we_z = WatchExpression("z")
Watches.add(we_z)
self.assertTrue(Watches.has(we_z))
Watches.remove(we_z)
self.assertFalse(Watches.has(we_z))
self.assertEqual(len(Watches.all()), 0)
Loading