Skip to content

Commit

Permalink
Add auto save and restore for config options
Browse files Browse the repository at this point in the history
  • Loading branch information
bulletmark committed Apr 16, 2023
1 parent 91d2c35 commit f8139ba
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 6 deletions.
10 changes: 7 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
*****************
Expand Down
4 changes: 3 additions & 1 deletion ptpython/entry_points/run_ptipython.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)",
Expand Down
28 changes: 26 additions & 2 deletions ptpython/entry_points/run_ptpython.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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")
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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__,
Expand Down
5 changes: 5 additions & 0 deletions ptpython/ipython.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
123 changes: 123 additions & 0 deletions ptpython/options_saver.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit f8139ba

Please sign in to comment.