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

Add auto save and restore for config options #531

Open
wants to merge 1 commit into
base: master
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
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