diff --git a/pudb/debugger.py b/pudb/debugger.py index 8a9c07ad..4efafeb5 100644 --- a/pudb/debugger.py +++ b/pudb/debugger.py @@ -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 @@ -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) @@ -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() @@ -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) diff --git a/pudb/settings.py b/pudb/settings.py index c4f4e153..1c71a034 100644 --- a/pudb/settings.py +++ b/pudb/settings.py @@ -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] @@ -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") @@ -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") @@ -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} @@ -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 " @@ -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. " @@ -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 @@ -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 diff --git a/pudb/test/test_var_view.py b/pudb/test/test_var_view.py index fe308f94..bf7919a1 100644 --- a/pudb/test/test_var_view.py +++ b/pudb/test/test_var_view.py @@ -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, @@ -12,6 +15,8 @@ PudbMapping, PudbSequence, ValueWalker, + Watches, + WatchExpression, get_stringifier, ui_log, ) @@ -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) diff --git a/pudb/var_view.py b/pudb/var_view.py index 4048ed8c..3185fd29 100644 --- a/pudb/var_view.py +++ b/pudb/var_view.py @@ -27,14 +27,16 @@ # {{{ constants and imports import inspect +import os import warnings from abc import ABC, abstractmethod from collections.abc import Callable, Sized -from typing import List, Tuple +from typing import List, Set, Tuple import urwid -from pudb.lowlevel import ui_log +from pudb.lowlevel import settings_log, ui_log +from pudb.settings import get_watches_file_name from pudb.ui_tools import text_width @@ -173,7 +175,6 @@ def length(cls, mapping): class FrameVarInfo: def __init__(self): self.id_path_to_iinfo = {} - self.watches = [] def get_inspect_info(self, id_path, read_only): if read_only: @@ -199,13 +200,40 @@ def __init__(self): class WatchExpression: - def __init__(self, expression): - self.expression = expression + """ + A few requirements for WatchExpression: + - must be sortable, hashable and comparable + - encloses a string, and is immutable + """ + def __init__(self, expression: str): + self._expression = expression.strip() + + @property + def expression(self): + return self._expression + + def __hash__(self): + return hash(self._expression) + + def __lt__(self, other): + return self._expression < other + + def __gt__(self, other): + return self._expression > other + + def __eq__(self, other): + return self._expression == other + + def __str__(self): + return self._expression + + __repr__ = __str__ class WatchEvalError: def __str__(self): return "" + __repr__ = __str__ # }}} @@ -727,7 +755,7 @@ def make_var_view(frame_var_info, locals, globals): ret_walker = BasicValueWalker(frame_var_info) watch_widget_list = [] - for watch_expr in frame_var_info.watches: + for watch_expr in Watches.all(): try: value = eval(watch_expr.expression, globals, locals) except Exception: @@ -771,6 +799,86 @@ def get_frame_var_info(self, read_only, ssid=None): else: return self.frame_var_info.setdefault(ssid, FrameVarInfo()) + +class Watches: + """ + Watches encloses a set of WatchExpression objects, and exports + its entry to a canonical file whenever altered. It also acts as a + runtime cache so that we don't have to reload and reparse the file + every time we want to refresh the var view. + """ + _expressions: Set[WatchExpression] = set() + + def __init__(self): + raise RuntimeError("This class is not meant to be instantiated.") + + @classmethod + def clear(cls): + cls._expressions.clear() + cls.save() + + @classmethod + def has(cls, expression: WatchExpression): + if not isinstance(expression, WatchExpression): + raise TypeError("expression must be a WatchExpression object") + return expression in cls._expressions + + @classmethod + def add(cls, expression: WatchExpression): + if not isinstance(expression, WatchExpression): + raise TypeError("expression must be a WatchExpression object") + if expression not in cls._expressions: + cls._expressions.add(expression) + cls.save() + + @classmethod + def remove(cls, expression: WatchExpression): + if not isinstance(expression, WatchExpression): + raise TypeError("expression must be a WatchExpression object") + if expression in cls._expressions: + cls._expressions.remove(expression) + cls.save() + + @classmethod + def save(cls): + from pudb.debugger import CONFIG + if not CONFIG.get("persist_watches", False): + return + + try: + with open(get_watches_file_name(), "w+") as histfile: + for watch in cls.all(): + if watch: + histfile.write(watch.expression + "\n") + + except Exception as save_exc: + settings_log.exception("Failed to save watches", save_exc) + raise save_exc + + @classmethod + def load(cls): + from pudb.debugger import CONFIG + if not CONFIG.get("persist_watches", False): + return + + watch_fn = get_watches_file_name() + if os.path.exists(watch_fn): + try: + with open(watch_fn, "r") as histfile: + cls._expressions = set() + for line in histfile.readlines(): + cls._expressions.add(WatchExpression(line.strip())) + except Exception as load_exc: + settings_log.exception("Failed to load watches", load_exc) + raise load_exc + + @classmethod + def all(cls): + if not cls._expressions: + cls.load() + + return sorted(cls._expressions, key=lambda x: x.expression) + # }}} # vim: foldmethod=marker