From f8139ba0a3412b9fb115c0a02bc696783b9e185f Mon Sep 17 00:00:00 2001 From: Mark Blakeney Date: Mon, 27 Feb 2023 16:15:21 +1000 Subject: [PATCH] Add auto save and restore for config options --- README.rst | 10 +- ptpython/entry_points/run_ptipython.py | 4 +- ptpython/entry_points/run_ptpython.py | 28 +++++- ptpython/ipython.py | 5 + ptpython/options_saver.py | 123 +++++++++++++++++++++++++ ptpython/python_input.py | 18 ++++ ptpython/repl.py | 5 + 7 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 ptpython/options_saver.py diff --git a/README.rst b/README.rst index 2db3f69..a1406a2 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ The help menu shows basic command-line options. $ ptpython --help usage: ptpython [-h] [--vi] [-i] [--light-bg] [--dark-bg] [--config-file CONFIG_FILE] - [--history-file HISTORY_FILE] [-V] + [--options-dir OPTIONS_DIR] [--history-file HISTORY_FILE] [-V] [args ...] ptpython: Interactive Python shell. @@ -75,6 +75,9 @@ The help menu shows basic command-line options. --dark-bg Run on a dark background (use light colors for text). --config-file CONFIG_FILE Location of configuration file. + --options-dir OPTIONS_DIR + Directory to store options save file. + Specify "none" to disable option storing. --history-file HISTORY_FILE Location of history file. -V, --version show program's version number and exit @@ -143,8 +146,9 @@ like this: else: sys.exit(embed(globals(), locals())) -Note config file support currently only works when invoking `ptpython` directly. -That it, the config file will be ignored when embedding ptpython in an application. +Note config file and option storage support currently only works when invoking +`ptpython` directly. That is, the config file will be ignored when embedding +ptpython in an application and option changes will not be saved. Multiline editing ***************** diff --git a/ptpython/entry_points/run_ptipython.py b/ptpython/entry_points/run_ptipython.py index b660a0a..9e7fe6e 100644 --- a/ptpython/entry_points/run_ptipython.py +++ b/ptpython/entry_points/run_ptipython.py @@ -4,13 +4,14 @@ import os import sys -from .run_ptpython import create_parser, get_config_and_history_file +from .run_ptpython import create_parser, get_config_and_history_file, get_options_file def run(user_ns=None): a = create_parser().parse_args() config_file, history_file = get_config_and_history_file(a) + options_file = get_options_file(a, "ipython-config") # If IPython is not available, show message and exit here with error status # code. @@ -72,6 +73,7 @@ def configure(repl): embed( vi_mode=a.vi, history_filename=history_file, + options_filename=options_file, configure=configure, user_ns=user_ns, title="IPython REPL (ptipython)", diff --git a/ptpython/entry_points/run_ptpython.py b/ptpython/entry_points/run_ptpython.py index 1b4074d..3df9e1e 100644 --- a/ptpython/entry_points/run_ptpython.py +++ b/ptpython/entry_points/run_ptpython.py @@ -13,6 +13,9 @@ --dark-bg Run on a dark background (use light colors for text). --config-file CONFIG_FILE Location of configuration file. + --options-dir OPTIONS_DIR + Directory to store options save file. + Specify "none" to disable option storing. --history-file HISTORY_FILE Location of history file. -V, --version show program's version number and exit @@ -25,8 +28,8 @@ import argparse import os -import pathlib import sys +from pathlib import Path from textwrap import dedent from typing import IO, Optional, Tuple @@ -81,6 +84,10 @@ def create_parser() -> _Parser: parser.add_argument( "--config-file", type=str, help="Location of configuration file." ) + parser.add_argument( + "--options-dir", type=str, help="Directory to store options save file. " + "Specify \"none\" to disable option storing." + ) parser.add_argument("--history-file", type=str, help="Location of history file.") parser.add_argument( "-V", @@ -105,7 +112,7 @@ def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str # Create directories. for d in (config_dir, data_dir): - pathlib.Path(d).mkdir(parents=True, exist_ok=True) + Path(d).mkdir(parents=True, exist_ok=True) # Determine config file to be used. config_file = os.path.join(config_dir, "config.py") @@ -155,10 +162,26 @@ def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str return config_file, history_file +def get_options_file(namespace: argparse.Namespace, filename: str) -> str | None: + """ + Given the options storage file name, add the directory path. + """ + if namespace.options_dir: + if namespace.options_dir.lower() in {"none", "nil"}: + return None + return str(Path(namespace.options_dir, filename)) + + cnfdir = Path(os.getenv("PTPYTHON_CONFIG_HOME", + appdirs.user_config_dir("ptpython", "prompt_toolkit"))) + + return str(cnfdir / filename) + + def run() -> None: a = create_parser().parse_args() config_file, history_file = get_config_and_history_file(a) + options_file = get_options_file(a, "config") # Startup path startup_paths = [] @@ -209,6 +232,7 @@ def configure(repl: PythonRepl) -> None: embed( vi_mode=a.vi, history_filename=history_file, + options_filename=options_file, configure=configure, locals=__main__.__dict__, globals=__main__.__dict__, diff --git a/ptpython/ipython.py b/ptpython/ipython.py index fb4b5ed..c71ef7f 100644 --- a/ptpython/ipython.py +++ b/ptpython/ipython.py @@ -40,6 +40,7 @@ from .python_input import PythonInput from .style import default_ui_style from .validator import PythonValidator +from . import options_saver __all__ = ["embed"] @@ -223,6 +224,7 @@ class InteractiveShellEmbed(_InteractiveShellEmbed): def __init__(self, *a, **kw): vi_mode = kw.pop("vi_mode", False) history_filename = kw.pop("history_filename", None) + options_filename = kw.pop("options_filename", None) configure = kw.pop("configure", None) title = kw.pop("title", None) @@ -248,6 +250,9 @@ def get_globals(): configure(python_input) python_input.prompt_style = "ipython" # Don't take from config. + if options_filename: + options_saver.create(python_input, options_filename) + self.python_input = python_input def prompt_for_code(self) -> str: diff --git a/ptpython/options_saver.py b/ptpython/options_saver.py new file mode 100644 index 0000000..4667db4 --- /dev/null +++ b/ptpython/options_saver.py @@ -0,0 +1,123 @@ +""" +Restores options on startup and saves changed options on termination. +""" +from __future__ import annotations + +import sys +import json +import atexit +from pathlib import Path +from functools import partial +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from python_input import PythonInput + + +class OptionsSaver: + "Manages options saving and restoring" + def __init__(self, repl: "PythonInput", filename: str) -> None: + "Instance created at program startup" + self.repl = repl + + # Add suffix if given file does not have one + self.file = Path(filename) + if not self.file.suffix: + self.file = self.file.with_suffix(".json") + + self.file_bad = False + + # Read all stored options from file. Skip and report at + # termination if the file is corrupt/unreadable. + self.stored = {} + if self.file.exists(): + try: + with self.file.open() as fp: + self.stored = json.load(fp) + except Exception: + self.file_bad = True + + # Iterate over all options and save record of defaults and also + # activate any saved options + self.defaults = {} + for category in self.repl.options: + for option in category.options: + field = option.field_name + def_val, val_type = self.get_option(field) + self.defaults[field] = def_val + val = self.stored.get(field) + if val is not None and val != def_val: + + # Handle special case to convert enums from int + if issubclass(val_type, Enum): + val = list(val_type)[val] + + # Handle special cases where a function must be + # called to store and enact change + funcs = option.get_values() + if isinstance(list(funcs.values())[0], partial): + if val_type is float: + val = f"{val:.2f}" + funcs[val]() + else: + setattr(self.repl, field, val) + + # Save changes at program exit + atexit.register(self.save) + + def get_option(self, field: str) -> tuple[object, type]: + "Returns option value and type for specified field" + val = getattr(self.repl, field) + val_type = type(val) + + # Handle special case to convert enums to int + if issubclass(val_type, Enum): + val = list(val_type).index(val) + + # Floats should be rounded to 2 decimal places + if isinstance(val, float): + val = round(val, 2) + + return val, val_type + + def save(self) -> None: + "Save changed options to file (called once at termination)" + # Ignore if abnormal (i.e. within exception) termination + if sys.exc_info()[0]: + return + + new = {} + for category in self.repl.options: + for option in category.options: + field = option.field_name + val, _ = self.get_option(field) + if val != self.defaults[field]: + new[field] = val + + # Save if file will change. We only save options which are + # different to the defaults and we always prune all other + # options. + if new != self.stored and not self.file_bad: + if new: + try: + self.file.parent.mkdir(parents=True, exist_ok=True) + with self.file.open("w") as fp: + json.dump(new, fp, indent=2) + except Exception: + self.file_bad = True + + elif self.file.exists(): + try: + self.file.unlink() + except Exception: + self.file_bad = True + + if self.file_bad: + print(f"Failed to read/write file: {self.file}", file=sys.stderr) + +def create(repl: "PythonInput", filename: str) -> None: + 'Create/activate the options saver' + # Note, no need to save the instance because it is kept alive by + # reference from atexit() + OptionsSaver(repl, filename) diff --git a/ptpython/python_input.py b/ptpython/python_input.py index 0c7fef6..34f9e72 100644 --- a/ptpython/python_input.py +++ b/ptpython/python_input.py @@ -133,6 +133,7 @@ def __init__( self, title: str, description: str, + field_name: str, get_current_value: Callable[[], _T], # We accept `object` as return type for the select functions, because # often they return an unused boolean. Maybe this can be improved. @@ -140,6 +141,7 @@ def __init__( ) -> None: self.title = title self.description = description + self.field_name = field_name self.get_current_value = get_current_value self.get_values = get_values @@ -583,6 +585,7 @@ def get_values() -> dict[str, Callable[[], bool]]: return Option( title=title, description=description, + field_name=field_name, get_values=get_values, get_current_value=get_current_value, ) @@ -596,6 +599,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Editing mode", description="Vi or emacs key bindings.", + field_name="vi_mode", get_current_value=lambda: ["Emacs", "Vi"][self.vi_mode], get_values=lambda: { "Emacs": lambda: disable("vi_mode"), @@ -606,6 +610,7 @@ def get_values() -> dict[str, Callable[[], bool]]: title="Cursor shape", description="Change the cursor style, possibly according " "to the Vi input mode.", + field_name="cursor_shape_config", get_current_value=lambda: self.cursor_shape_config, get_values=lambda: dict( (s, partial(enable, "cursor_shape_config", s)) @@ -621,6 +626,7 @@ def get_values() -> dict[str, Callable[[], bool]]: title="Complete while typing", description="Generate autocompletions automatically while typing. " 'Don\'t require pressing TAB. (Not compatible with "History search".)', + field_name="complete_while_typing", get_current_value=lambda: ["off", "on"][ self.complete_while_typing ], @@ -635,6 +641,7 @@ def get_values() -> dict[str, Callable[[], bool]]: description="Show or hide private attributes in the completions. " "'If no public' means: show private attributes only if no public " "matches are found or if an underscore was typed.", + field_name="complete_private_attributes", get_current_value=lambda: { CompletePrivateAttributes.NEVER: "Never", CompletePrivateAttributes.ALWAYS: "Always", @@ -658,6 +665,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Enable fuzzy completion", description="Enable fuzzy completion.", + field_name="enable_fuzzy_completion", get_current_value=lambda: ["off", "on"][ self.enable_fuzzy_completion ], @@ -672,6 +680,7 @@ def get_values() -> dict[str, Callable[[], bool]]: 'WARNING: this does "eval" on fragments of\n' " your Python input and is\n" " potentially unsafe.", + field_name="enable_dictionary_completion", get_current_value=lambda: ["off", "on"][ self.enable_dictionary_completion ], @@ -684,6 +693,7 @@ def get_values() -> dict[str, Callable[[], bool]]: title="History search", description="When pressing the up-arrow, filter the history on input starting " 'with the current text. (Not compatible with "Complete while typing".)', + field_name="enable_history_search", get_current_value=lambda: ["off", "on"][ self.enable_history_search ], @@ -720,6 +730,7 @@ def get_values() -> dict[str, Callable[[], bool]]: title="Accept input on enter", description="Amount of ENTER presses required to execute input when the cursor " "is at the end of the input. (Note that META+ENTER will always execute.)", + field_name="accept_input_on_enter", get_current_value=lambda: str( self.accept_input_on_enter or "meta-enter" ), @@ -738,6 +749,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Completions", description="Visualisation to use for displaying the completions. (Multiple columns, one column, a toolbar or nothing.)", + field_name="completion_visualisation", get_current_value=lambda: self.completion_visualisation.value, get_values=lambda: { CompletionVisualisation.NONE.value: lambda: enable( @@ -760,6 +772,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Prompt", description="Visualisation of the prompt. ('>>>' or 'In [1]:')", + field_name="prompt_style", get_current_value=lambda: self.prompt_style, get_values=lambda: { s: partial(enable, "prompt_style", s) @@ -846,6 +859,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Code", description="Color scheme to use for the Python code.", + field_name="_current_code_style_name", get_current_value=lambda: self._current_code_style_name, get_values=lambda: { name: partial(self.use_code_colorscheme, name) @@ -855,6 +869,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="User interface", description="Color scheme to use for the user interface.", + field_name="_current_ui_style_name", get_current_value=lambda: self._current_ui_style_name, get_values=lambda: { name: partial(self.use_ui_colorscheme, name) @@ -864,6 +879,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Color depth", description="Monochrome (1 bit), 16 ANSI colors (4 bit),\n256 colors (8 bit), or 24 bit.", + field_name="color_depth", get_current_value=lambda: COLOR_DEPTHS[self.color_depth], get_values=lambda: { name: partial(self._use_color_depth, depth) @@ -873,6 +889,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Min brightness", description="Minimum brightness for the color scheme (default=0.0).", + field_name="min_brightness", get_current_value=lambda: "%.2f" % self.min_brightness, get_values=lambda: { "%.2f" % value: partial(self._set_min_brightness, value) @@ -882,6 +899,7 @@ def get_values() -> dict[str, Callable[[], bool]]: Option( title="Max brightness", description="Maximum brightness for the color scheme (default=1.0).", + field_name="max_brightness", get_current_value=lambda: "%.2f" % self.max_brightness, get_values=lambda: { "%.2f" % value: partial(self._set_max_brightness, value) diff --git a/ptpython/repl.py b/ptpython/repl.py index 02a5075..82f7a76 100644 --- a/ptpython/repl.py +++ b/ptpython/repl.py @@ -45,6 +45,7 @@ from pygments.token import Token from .python_input import PythonInput +from . import options_saver PyCF_ALLOW_TOP_LEVEL_AWAIT: int try: @@ -675,6 +676,7 @@ def embed( configure: Callable[[PythonRepl], None] | None = None, vi_mode: bool = False, history_filename: str | None = None, + options_filename: str | None = None, title: str | None = None, startup_paths=None, patch_stdout: bool = False, @@ -726,6 +728,9 @@ def get_locals(): if configure: configure(repl) + if options_filename: + options_saver.create(repl, options_filename) + # Start repl. patch_context: ContextManager[None] = ( patch_stdout_context() if patch_stdout else DummyContext()