Skip to content

Commit 52dffe1

Browse files
committed
Add auto save and restore for config options
1 parent 44f0c6e commit 52dffe1

File tree

7 files changed

+187
-6
lines changed

7 files changed

+187
-6
lines changed

README.rst

+7-3
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ The help menu shows basic command-line options.
5959

6060
$ ptpython --help
6161
usage: ptpython [-h] [--vi] [-i] [--light-bg] [--dark-bg] [--config-file CONFIG_FILE]
62-
[--history-file HISTORY_FILE] [-V]
62+
[--options-dir OPTIONS_DIR] [--history-file HISTORY_FILE] [-V]
6363
[args ...]
6464

6565
ptpython: Interactive Python shell.
@@ -75,6 +75,9 @@ The help menu shows basic command-line options.
7575
--dark-bg Run on a dark background (use light colors for text).
7676
--config-file CONFIG_FILE
7777
Location of configuration file.
78+
--options-dir OPTIONS_DIR
79+
Directory to store options save file.
80+
Specify "none" to disable option storing.
7881
--history-file HISTORY_FILE
7982
Location of history file.
8083
-V, --version show program's version number and exit
@@ -143,8 +146,9 @@ like this:
143146
else:
144147
sys.exit(embed(globals(), locals()))
145148
146-
Note config file support currently only works when invoking `ptpython` directly.
147-
That it, the config file will be ignored when embedding ptpython in an application.
149+
Note config file and option storage support currently only works when invoking
150+
`ptpython` directly. That is, the config file will be ignored when embedding
151+
ptpython in an application and option changes will not be saved.
148152

149153
Multiline editing
150154
*****************

ptpython/entry_points/run_ptipython.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
import os
55
import sys
66

7-
from .run_ptpython import create_parser, get_config_and_history_file
7+
from .run_ptpython import create_parser, get_config_and_history_file, get_options_file
88

99

1010
def run(user_ns=None):
1111
a = create_parser().parse_args()
1212

1313
config_file, history_file = get_config_and_history_file(a)
14+
options_file = get_options_file(a, "ipython-config")
1415

1516
# If IPython is not available, show message and exit here with error status
1617
# code.
@@ -72,6 +73,7 @@ def configure(repl):
7273
embed(
7374
vi_mode=a.vi,
7475
history_filename=history_file,
76+
options_filename=options_file,
7577
configure=configure,
7678
user_ns=user_ns,
7779
title="IPython REPL (ptipython)",

ptpython/entry_points/run_ptpython.py

+26-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
--dark-bg Run on a dark background (use light colors for text).
1414
--config-file CONFIG_FILE
1515
Location of configuration file.
16+
--options-dir OPTIONS_DIR
17+
Directory to store options save file.
18+
Specify "none" to disable option storing.
1619
--history-file HISTORY_FILE
1720
Location of history file.
1821
-V, --version show program's version number and exit
@@ -25,8 +28,8 @@
2528

2629
import argparse
2730
import os
28-
import pathlib
2931
import sys
32+
from pathlib import Path
3033
from textwrap import dedent
3134
from typing import IO, Optional, Tuple
3235

@@ -81,6 +84,10 @@ def create_parser() -> _Parser:
8184
parser.add_argument(
8285
"--config-file", type=str, help="Location of configuration file."
8386
)
87+
parser.add_argument(
88+
"--options-dir", type=str, help="Directory to store options save file. "
89+
"Specify \"none\" to disable option storing."
90+
)
8491
parser.add_argument("--history-file", type=str, help="Location of history file.")
8592
parser.add_argument(
8693
"-V",
@@ -105,7 +112,7 @@ def get_config_and_history_file(namespace: argparse.Namespace) -> tuple[str, str
105112

106113
# Create directories.
107114
for d in (config_dir, data_dir):
108-
pathlib.Path(d).mkdir(parents=True, exist_ok=True)
115+
Path(d).mkdir(parents=True, exist_ok=True)
109116

110117
# Determine config file to be used.
111118
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
155162
return config_file, history_file
156163

157164

165+
def get_options_file(namespace: argparse.Namespace, filename: str) -> str | None:
166+
"""
167+
Given the options storage file name, add the directory path.
168+
"""
169+
if namespace.options_dir:
170+
if namespace.options_dir.lower() in {"none", "nil"}:
171+
return None
172+
return str(Path(namespace.options_dir, filename))
173+
174+
cnfdir = Path(os.getenv("PTPYTHON_CONFIG_HOME",
175+
appdirs.user_config_dir("ptpython", "prompt_toolkit")))
176+
177+
return str(cnfdir / filename)
178+
179+
158180
def run() -> None:
159181
a = create_parser().parse_args()
160182

161183
config_file, history_file = get_config_and_history_file(a)
184+
options_file = get_options_file(a, "config")
162185

163186
# Startup path
164187
startup_paths = []
@@ -209,6 +232,7 @@ def configure(repl: PythonRepl) -> None:
209232
embed(
210233
vi_mode=a.vi,
211234
history_filename=history_file,
235+
options_filename=options_file,
212236
configure=configure,
213237
locals=__main__.__dict__,
214238
globals=__main__.__dict__,

ptpython/ipython.py

+5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .python_input import PythonInput
4141
from .style import default_ui_style
4242
from .validator import PythonValidator
43+
from . import options_saver
4344

4445
__all__ = ["embed"]
4546

@@ -223,6 +224,7 @@ class InteractiveShellEmbed(_InteractiveShellEmbed):
223224
def __init__(self, *a, **kw):
224225
vi_mode = kw.pop("vi_mode", False)
225226
history_filename = kw.pop("history_filename", None)
227+
options_filename = kw.pop("options_filename", None)
226228
configure = kw.pop("configure", None)
227229
title = kw.pop("title", None)
228230

@@ -248,6 +250,9 @@ def get_globals():
248250
configure(python_input)
249251
python_input.prompt_style = "ipython" # Don't take from config.
250252

253+
if options_filename:
254+
options_saver.create(python_input, options_filename)
255+
251256
self.python_input = python_input
252257

253258
def prompt_for_code(self) -> str:

ptpython/options_saver.py

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Restores options on startup and saves changed options on termination.
3+
"""
4+
from __future__ import annotations
5+
6+
import sys
7+
import json
8+
import atexit
9+
from pathlib import Path
10+
from functools import partial
11+
from enum import Enum
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from python_input import PythonInput
16+
17+
18+
class OptionsSaver:
19+
"Manages options saving and restoring"
20+
def __init__(self, repl: "PythonInput", filename: str) -> None:
21+
"Instance created at program startup"
22+
self.repl = repl
23+
24+
# Add suffix if given file does not have one
25+
self.file = Path(filename)
26+
if not self.file.suffix:
27+
self.file = self.file.with_suffix(".json")
28+
29+
self.file_bad = False
30+
31+
# Read all stored options from file. Skip and report at
32+
# termination if the file is corrupt/unreadable.
33+
self.stored = {}
34+
if self.file.exists():
35+
try:
36+
with self.file.open() as fp:
37+
self.stored = json.load(fp)
38+
except Exception:
39+
self.file_bad = True
40+
41+
# Iterate over all options and save record of defaults and also
42+
# activate any saved options
43+
self.defaults = {}
44+
for category in self.repl.options:
45+
for option in category.options:
46+
field = option.field_name
47+
def_val, val_type = self.get_option(field)
48+
self.defaults[field] = def_val
49+
val = self.stored.get(field)
50+
if val is not None and val != def_val:
51+
52+
# Handle special case to convert enums from int
53+
if issubclass(val_type, Enum):
54+
val = list(val_type)[val]
55+
56+
# Handle special cases where a function must be
57+
# called to store and enact change
58+
funcs = option.get_values()
59+
if isinstance(list(funcs.values())[0], partial):
60+
if val_type is float:
61+
val = f"{val:.2f}"
62+
funcs[val]()
63+
else:
64+
setattr(self.repl, field, val)
65+
66+
# Save changes at program exit
67+
atexit.register(self.save)
68+
69+
def get_option(self, field: str) -> tuple[object, type]:
70+
"Returns option value and type for specified field"
71+
val = getattr(self.repl, field)
72+
val_type = type(val)
73+
74+
# Handle special case to convert enums to int
75+
if issubclass(val_type, Enum):
76+
val = list(val_type).index(val)
77+
78+
# Floats should be rounded to 2 decimal places
79+
if isinstance(val, float):
80+
val = round(val, 2)
81+
82+
return val, val_type
83+
84+
def save(self) -> None:
85+
"Save changed options to file (called once at termination)"
86+
# Ignore if abnormal (i.e. within exception) termination
87+
if sys.exc_info()[0]:
88+
return
89+
90+
new = {}
91+
for category in self.repl.options:
92+
for option in category.options:
93+
field = option.field_name
94+
val, _ = self.get_option(field)
95+
if val != self.defaults[field]:
96+
new[field] = val
97+
98+
# Save if file will change. We only save options which are
99+
# different to the defaults and we always prune all other
100+
# options.
101+
if new != self.stored and not self.file_bad:
102+
if new:
103+
try:
104+
self.file.parent.mkdir(parents=True, exist_ok=True)
105+
with self.file.open("w") as fp:
106+
json.dump(new, fp, indent=2)
107+
except Exception:
108+
self.file_bad = True
109+
110+
elif self.file.exists():
111+
try:
112+
self.file.unlink()
113+
except Exception:
114+
self.file_bad = True
115+
116+
if self.file_bad:
117+
print(f"Failed to read/write file: {self.file}", file=sys.stderr)
118+
119+
def create(repl: "PythonInput", filename: str) -> None:
120+
'Create/activate the options saver'
121+
# Note, no need to save the instance because it is kept alive by
122+
# reference from atexit()
123+
OptionsSaver(repl, filename)

0 commit comments

Comments
 (0)