jumble-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jumble/__init__.py +6 -0
- jumble/__main__.py +3 -0
- jumble/cli.py +271 -0
- jumble/config.py +182 -0
- jumble/generate.py +287 -0
- jumble/tui.py +481 -0
- jumble_cli-0.1.0.dist-info/METADATA +282 -0
- jumble_cli-0.1.0.dist-info/RECORD +11 -0
- jumble_cli-0.1.0.dist-info/WHEEL +4 -0
- jumble_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jumble_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
jumble/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Random string generator, with a command line and a Textual TUI."""
|
|
2
|
+
|
|
3
|
+
from jumble.cli import main
|
|
4
|
+
from jumble.generate import CHARSETS, JumbleError, Settings, generate, strength, validate
|
|
5
|
+
|
|
6
|
+
__all__ = ["CHARSETS", "JumbleError", "Settings", "generate", "main", "strength", "validate"]
|
jumble/__main__.py
ADDED
jumble/cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The `jumble` command (docs/spec.md §4)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jumble.config import config_path, file_settings, load
|
|
12
|
+
from jumble.generate import (
|
|
13
|
+
CHARSETS,
|
|
14
|
+
LOOK_ALIKES,
|
|
15
|
+
JumbleError,
|
|
16
|
+
Settings,
|
|
17
|
+
Strength,
|
|
18
|
+
check_names,
|
|
19
|
+
generate,
|
|
20
|
+
settings_dict,
|
|
21
|
+
strength,
|
|
22
|
+
strength_line,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULTS = Settings()
|
|
26
|
+
|
|
27
|
+
# The PyPI distribution; `jumble` there is someone else's (DEC-24).
|
|
28
|
+
DISTRIBUTION = "jumble-cli"
|
|
29
|
+
|
|
30
|
+
# The settings a command-line flag can only turn on (§5.2, DEC-9).
|
|
31
|
+
BOOLEANS = ("no_ambiguous", "each", "no_repeat", "unique")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def installed_version() -> str:
|
|
35
|
+
"""The installed package's version, as `--version` and the TUI header show it (§4, §7.1)."""
|
|
36
|
+
return importlib.metadata.version(DISTRIBUTION)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parser() -> argparse.ArgumentParser:
|
|
40
|
+
p = argparse.ArgumentParser(
|
|
41
|
+
prog="jumble",
|
|
42
|
+
# Bare jumble's two routes (§4, DEC-21).
|
|
43
|
+
description="Generate random strings. jumble alone, typed in a terminal, opens the "
|
|
44
|
+
"interactive generator, as jumble tui does; piped, in a script, or with any option, "
|
|
45
|
+
"it prints.",
|
|
46
|
+
)
|
|
47
|
+
# -n, -c, and --custom default to None, so an option given at its default
|
|
48
|
+
# value still overrides the settings file (§5.2).
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"-n",
|
|
51
|
+
"--length",
|
|
52
|
+
type=int,
|
|
53
|
+
metavar="N",
|
|
54
|
+
help=f"characters per string (default {DEFAULTS.length})",
|
|
55
|
+
)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"-c",
|
|
58
|
+
"--count",
|
|
59
|
+
type=int,
|
|
60
|
+
metavar="N",
|
|
61
|
+
help=f"how many strings (default {DEFAULTS.count})",
|
|
62
|
+
)
|
|
63
|
+
# No argparse choices: validation reports unknown and empty names (§2.2, §6).
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"-s",
|
|
66
|
+
"--charset",
|
|
67
|
+
action="append",
|
|
68
|
+
default=[],
|
|
69
|
+
metavar="NAME[,NAME...]",
|
|
70
|
+
help="character sets to draw from; repeat or separate with commas. "
|
|
71
|
+
f"Sets: {', '.join(CHARSETS)} "
|
|
72
|
+
f"(default: {', '.join(DEFAULTS.charsets)}, when neither --custom nor the "
|
|
73
|
+
"settings file chooses characters)",
|
|
74
|
+
)
|
|
75
|
+
p.add_argument(
|
|
76
|
+
"--custom",
|
|
77
|
+
metavar="CHARS",
|
|
78
|
+
help="also draw from these characters, as one more set",
|
|
79
|
+
)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"-a",
|
|
82
|
+
"--no-ambiguous",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help=f"leave out look-alike characters: {' '.join(LOOK_ALIKES)}",
|
|
85
|
+
)
|
|
86
|
+
p.add_argument(
|
|
87
|
+
"-e", "--each", action="store_true", help="at least one character from every chosen set"
|
|
88
|
+
)
|
|
89
|
+
p.add_argument(
|
|
90
|
+
"-r", "--no-repeat", action="store_true", help="no character twice within a string"
|
|
91
|
+
)
|
|
92
|
+
p.add_argument("-u", "--unique", action="store_true", help="no string twice in the output")
|
|
93
|
+
# Default None, so `-o -` can be told apart from no -o (§4).
|
|
94
|
+
p.add_argument(
|
|
95
|
+
"-o",
|
|
96
|
+
"--output",
|
|
97
|
+
metavar="FILE",
|
|
98
|
+
help="write to FILE, created or overwritten, instead of stdout; - means stdout",
|
|
99
|
+
)
|
|
100
|
+
p.add_argument(
|
|
101
|
+
"--json",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="write one JSON object with the strings, their strength, and the settings used",
|
|
104
|
+
)
|
|
105
|
+
p.add_argument(
|
|
106
|
+
"--strength",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="also print the alphabet size and bits per string to stderr",
|
|
109
|
+
)
|
|
110
|
+
p.add_argument(
|
|
111
|
+
"--no-config",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help=f"ignore the settings file ({config_path()})",
|
|
114
|
+
)
|
|
115
|
+
# argparse prints and exits as it reaches it, before any file is read (§4).
|
|
116
|
+
p.add_argument(
|
|
117
|
+
"--version",
|
|
118
|
+
action="version",
|
|
119
|
+
version=f"jumble {installed_version()}",
|
|
120
|
+
help="print the version and exit",
|
|
121
|
+
)
|
|
122
|
+
sub = p.add_subparsers(dest="command")
|
|
123
|
+
sub.add_parser(
|
|
124
|
+
"tui",
|
|
125
|
+
# It starts from the effective settings, the file's included (§7.5).
|
|
126
|
+
help="open the interactive generator, starting from the settings file and the "
|
|
127
|
+
"options before tui (not with --output, --json, or --strength)",
|
|
128
|
+
description="Open the interactive generator. It starts from the settings file, "
|
|
129
|
+
"and options given before tui override it, for example: jumble -n 32 -s digits tui",
|
|
130
|
+
)
|
|
131
|
+
return p
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def settings_from(args: argparse.Namespace, file: Mapping[str, Any]) -> Settings:
|
|
135
|
+
"""One `Settings` from parsed arguments over the settings file's keys (§5.2, DEC-9).
|
|
136
|
+
|
|
137
|
+
Built-in defaults, then `file` (what `config.load` returned, or `{}`), then
|
|
138
|
+
the command line. `length`, `count`, and the booleans go key by key; an
|
|
139
|
+
option counts when it is given, even at its default value, and a flag
|
|
140
|
+
only turns its option on. The characters go as a whole: `-s` or
|
|
141
|
+
`--custom` (even `--custom ''`) replaces both of the file's `charsets` and
|
|
142
|
+
`custom`. The default sets apply only when no level chose any. The
|
|
143
|
+
command line is laid over the file's keys and merged by
|
|
144
|
+
`config.file_settings`, so there is one merge.
|
|
145
|
+
"""
|
|
146
|
+
values = dict(file)
|
|
147
|
+
for key in ("length", "count"):
|
|
148
|
+
if getattr(args, key) is not None:
|
|
149
|
+
values[key] = getattr(args, key)
|
|
150
|
+
for key in BOOLEANS:
|
|
151
|
+
if getattr(args, key):
|
|
152
|
+
values[key] = True
|
|
153
|
+
names = tuple(name for value in args.charset for name in value.split(","))
|
|
154
|
+
if names or args.custom is not None:
|
|
155
|
+
values["charsets"], values["custom"] = names, args.custom or ""
|
|
156
|
+
return file_settings(values)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
160
|
+
p = parser()
|
|
161
|
+
if argv is None:
|
|
162
|
+
argv = sys.argv[1:]
|
|
163
|
+
# Bare jumble at a prompt is jumble tui; piped or scripted, it prints (§4, DEC-21).
|
|
164
|
+
# The TUI needs both streams, so a terminal on stdout alone isn't enough.
|
|
165
|
+
if not argv and is_terminal(sys.stdin) and is_terminal(sys.stdout):
|
|
166
|
+
argv = ["tui"]
|
|
167
|
+
args = p.parse_args(argv)
|
|
168
|
+
if args.command == "tui":
|
|
169
|
+
# Before the TUI starts or any file is touched; -o - counts as given (§4, §6).
|
|
170
|
+
misused = (
|
|
171
|
+
("--output", args.output is not None),
|
|
172
|
+
("--json", args.json),
|
|
173
|
+
("--strength", args.strength),
|
|
174
|
+
)
|
|
175
|
+
for option, given in misused:
|
|
176
|
+
if given:
|
|
177
|
+
p.error(f"{option} can't be used with tui")
|
|
178
|
+
file_error = None
|
|
179
|
+
try:
|
|
180
|
+
file = {} if args.no_config else load(config_path())
|
|
181
|
+
except JumbleError as e:
|
|
182
|
+
# The TUI starts as if --no-config were given and toasts the
|
|
183
|
+
# message, so the file can be repaired from there (§7.4, DEC-17).
|
|
184
|
+
file, file_error = {}, str(e)
|
|
185
|
+
settings = settings_from(args, file)
|
|
186
|
+
try:
|
|
187
|
+
# Only a name no checkbox can hold stops the TUI; any other bad
|
|
188
|
+
# value starts it with the banner shown (§7.5, DEC-16).
|
|
189
|
+
check_names(settings.charsets)
|
|
190
|
+
except JumbleError as e:
|
|
191
|
+
print(f"jumble: error: {e}", file=sys.stderr)
|
|
192
|
+
return 2
|
|
193
|
+
from jumble.tui import JumbleApp
|
|
194
|
+
|
|
195
|
+
JumbleApp(settings, file_error=file_error).run()
|
|
196
|
+
return 0
|
|
197
|
+
# Generate everything before opening any output, so a failed run writes
|
|
198
|
+
# nothing and leaves an existing -o file as it was (§4.1, DEC-8).
|
|
199
|
+
try:
|
|
200
|
+
# A broken file stops the run even where the command line overrides
|
|
201
|
+
# the bad key (§5.1, DEC-17); --no-config doesn't read it at all.
|
|
202
|
+
file = {} if args.no_config else load(config_path())
|
|
203
|
+
settings = settings_from(args, file)
|
|
204
|
+
strings = generate(settings)
|
|
205
|
+
except JumbleError as e:
|
|
206
|
+
# The message is the whole output, with no usage line (§6).
|
|
207
|
+
print(f"jumble: error: {e}", file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
# After generate, so a failed run's error is generate's.
|
|
210
|
+
st = strength(settings)
|
|
211
|
+
text = json_output(strings, settings, st) if args.json else "".join(f"{s}\n" for s in strings)
|
|
212
|
+
data = text.encode()
|
|
213
|
+
to_stdout = args.output is None or args.output == "-"
|
|
214
|
+
try:
|
|
215
|
+
write_output(args.output, data)
|
|
216
|
+
except OSError as e:
|
|
217
|
+
if not to_stdout:
|
|
218
|
+
print(f"jumble: error: can't write {args.output}: {e.strerror}", file=sys.stderr)
|
|
219
|
+
elif isinstance(e, BrokenPipeError):
|
|
220
|
+
# The reader closed the pipe and got what it asked for: exit quietly (§6, DEC-14).
|
|
221
|
+
quiet_stdout()
|
|
222
|
+
else:
|
|
223
|
+
print(f"jumble: error: can't write stdout: {e.strerror}", file=sys.stderr)
|
|
224
|
+
return 1
|
|
225
|
+
if args.strength:
|
|
226
|
+
# After the output, and only for a run that succeeded (§4.3).
|
|
227
|
+
print(strength_line(st), file=sys.stderr)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_terminal(stream: Any) -> bool:
|
|
232
|
+
"""Whether `stream` is a terminal; a closed standard stream (`None`) is not."""
|
|
233
|
+
return stream is not None and stream.isatty()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def json_output(strings: list[str], settings: Settings, st: Strength) -> str:
|
|
237
|
+
"""The §4.2 object for `strings`, drawn from `settings` of strength `st`, and a newline."""
|
|
238
|
+
obj = {
|
|
239
|
+
"strings": strings,
|
|
240
|
+
"alphabet_size": st.alphabet_size,
|
|
241
|
+
"strength_bits": round(st.bits, 1),
|
|
242
|
+
"strength_is_bound": st.is_bound,
|
|
243
|
+
"settings": settings_dict(settings),
|
|
244
|
+
}
|
|
245
|
+
return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def write_output(path: str | None, data: bytes) -> None:
|
|
249
|
+
"""Write `data` to stdout when `path` is None or `-`, else to the file `path` (§4.1).
|
|
250
|
+
|
|
251
|
+
Stdout gets the bytes through its binary buffer, so output is UTF-8 whatever
|
|
252
|
+
the locale's encoding.
|
|
253
|
+
"""
|
|
254
|
+
if path is None or path == "-":
|
|
255
|
+
sys.stdout.flush()
|
|
256
|
+
sys.stdout.buffer.write(data)
|
|
257
|
+
sys.stdout.buffer.flush()
|
|
258
|
+
return
|
|
259
|
+
with open(path, "wb") as f:
|
|
260
|
+
f.write(data)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def quiet_stdout() -> None:
|
|
264
|
+
"""Point fd 1 at the null device after a closed pipe (DEC-14).
|
|
265
|
+
|
|
266
|
+
Stdout's buffer may still hold bytes; the flush at interpreter exit then
|
|
267
|
+
has somewhere to go instead of printing `Exception ignored … BrokenPipeError`.
|
|
268
|
+
This is the recipe in the Python `signal` docs' note on SIGPIPE.
|
|
269
|
+
"""
|
|
270
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
271
|
+
os.dup2(devnull, sys.stdout.fileno())
|
jumble/config.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The settings file (docs/spec.md §5): reading it, and saving over it.
|
|
2
|
+
|
|
3
|
+
Section numbers below refer to the spec.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import fields, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import tomlkit
|
|
14
|
+
from tomlkit.exceptions import TOMLKitError
|
|
15
|
+
|
|
16
|
+
from jumble.generate import JumbleError, Settings, check_value, settings_dict
|
|
17
|
+
|
|
18
|
+
# §1's setting names, in §1's order.
|
|
19
|
+
NAMES: tuple[str, ...] = tuple(f.name for f in fields(Settings))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def config_path() -> Path:
|
|
23
|
+
"""Where the settings file is (§5.1, DEC-10).
|
|
24
|
+
|
|
25
|
+
`$JUMBLE_CONFIG` if set and not empty, as given; else
|
|
26
|
+
`$XDG_CONFIG_HOME/jumble/config.toml` if that is an absolute path; else
|
|
27
|
+
`~/.config/jumble/config.toml`, on macOS too.
|
|
28
|
+
"""
|
|
29
|
+
if given := os.environ.get("JUMBLE_CONFIG"):
|
|
30
|
+
return Path(given)
|
|
31
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", ""))
|
|
32
|
+
if xdg.is_absolute():
|
|
33
|
+
return xdg / "jumble" / "config.toml"
|
|
34
|
+
return Path.home() / ".config" / "jumble" / "config.toml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _type_word(name: str, value: object) -> str | None:
|
|
38
|
+
"""The §6 type word when `value` hasn't setting `name`'s type (§5.1), else None."""
|
|
39
|
+
if name in ("length", "count"):
|
|
40
|
+
# A TOML boolean is a Python bool, which is an int too: rule it out.
|
|
41
|
+
ok = isinstance(value, int) and not isinstance(value, bool)
|
|
42
|
+
return None if ok else "an integer"
|
|
43
|
+
if name == "charsets":
|
|
44
|
+
ok = isinstance(value, list) and all(isinstance(v, str) for v in value)
|
|
45
|
+
return None if ok else "a list of set names"
|
|
46
|
+
if name == "custom":
|
|
47
|
+
return None if isinstance(value, str) else "a string"
|
|
48
|
+
return None if isinstance(value, bool) else "a boolean"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load(path: Path) -> dict[str, object]:
|
|
52
|
+
"""The settings file at `path`, as its keys and values in the file's order.
|
|
53
|
+
|
|
54
|
+
A missing file is `{}`. Anything else wrong raises `JumbleError` with the
|
|
55
|
+
§6 settings-file message for the first error, checked in §5.1's order:
|
|
56
|
+
reading, then UTF-8 and TOML, then each key's name, type, and own rule.
|
|
57
|
+
Rules that relate settings to each other are left to `validate` (DEC-17).
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
data = path.read_bytes()
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
return {}
|
|
63
|
+
except OSError as e:
|
|
64
|
+
raise JumbleError(f"can't read {path}: {e.strerror}") from e
|
|
65
|
+
try:
|
|
66
|
+
text = data.decode("utf-8")
|
|
67
|
+
except UnicodeDecodeError as e:
|
|
68
|
+
raise JumbleError(f"{path}: invalid TOML: not UTF-8") from e
|
|
69
|
+
try:
|
|
70
|
+
table = tomllib.loads(text)
|
|
71
|
+
except tomllib.TOMLDecodeError as e:
|
|
72
|
+
raise JumbleError(f"{path}: invalid TOML: {e}") from e
|
|
73
|
+
|
|
74
|
+
for key, value in table.items():
|
|
75
|
+
if key not in NAMES:
|
|
76
|
+
raise JumbleError(f"{path}: {key}: unknown setting")
|
|
77
|
+
if word := _type_word(key, value):
|
|
78
|
+
raise JumbleError(f"{path}: {key}: must be {word}")
|
|
79
|
+
try:
|
|
80
|
+
check_value(key, value)
|
|
81
|
+
except JumbleError as e:
|
|
82
|
+
raise JumbleError(f"{path}: {key}: {e}") from e
|
|
83
|
+
return table
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def file_settings(file: Mapping[str, Any]) -> Settings:
|
|
87
|
+
"""The built-in defaults, then `file`'s keys, merged as §5.2 merges them (DEC-9).
|
|
88
|
+
|
|
89
|
+
`file` is what `load` returned, or `{}`. `length`, `count`, and the four
|
|
90
|
+
options go key by key. The characters go as a whole: a file that sets
|
|
91
|
+
`charsets` or `custom` chooses with both, so `custom = "abc"` alone
|
|
92
|
+
chooses no named set; the default sets apply only when neither chooses
|
|
93
|
+
any. `cli.settings_from` lays the command line over the file and merges
|
|
94
|
+
through here, and the settings screen fills its form from this (§7.4).
|
|
95
|
+
"""
|
|
96
|
+
defaults = Settings()
|
|
97
|
+
values = {key: file[key] for key in NAMES if key in file}
|
|
98
|
+
charsets = tuple(values.pop("charsets", ()))
|
|
99
|
+
custom = values.pop("custom", "")
|
|
100
|
+
if not charsets and not custom:
|
|
101
|
+
# Nothing chose any characters, so the default sets apply (§1, DEC-9).
|
|
102
|
+
charsets = defaults.charsets
|
|
103
|
+
return replace(defaults, **values, charsets=charsets, custom=custom)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# A new file starts from this, with the saved values in place of these
|
|
107
|
+
# defaults (§5.3); a test holds it to the spec's copy.
|
|
108
|
+
TEMPLATE = """\
|
|
109
|
+
# jumble settings. Every key is optional; options given on the command
|
|
110
|
+
# line override them. See `jumble --help` for the character sets.
|
|
111
|
+
|
|
112
|
+
# Characters per string, at least 1.
|
|
113
|
+
length = 16
|
|
114
|
+
# How many strings, at least 1.
|
|
115
|
+
count = 1
|
|
116
|
+
# The named sets to draw from.
|
|
117
|
+
charsets = ["lower", "upper", "digits"]
|
|
118
|
+
# Extra characters, drawn as one more set.
|
|
119
|
+
custom = ""
|
|
120
|
+
# Leave out look-alike characters such as 0 O l 1 I.
|
|
121
|
+
no_ambiguous = false
|
|
122
|
+
# At least one character from every chosen set.
|
|
123
|
+
each = false
|
|
124
|
+
# No character twice within a string.
|
|
125
|
+
no_repeat = false
|
|
126
|
+
# No string twice in the output.
|
|
127
|
+
unique = false
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _document(path: Path) -> tomlkit.TOMLDocument:
|
|
132
|
+
"""The file at `path` to save over, or the template in its place (§5.3 step 1).
|
|
133
|
+
|
|
134
|
+
A missing file, or one that is not UTF-8 or not valid TOML, is replaced
|
|
135
|
+
by the template; a file that exists but can't be read raises `JumbleError`.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
data = path.read_bytes()
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return tomlkit.parse(TEMPLATE)
|
|
141
|
+
except OSError as e:
|
|
142
|
+
raise JumbleError(f"can't save {path}: {e.strerror}") from e
|
|
143
|
+
try:
|
|
144
|
+
text = data.decode("utf-8")
|
|
145
|
+
# Valid TOML is what `load` reads: tomlkit also parses TOML 1.1, which
|
|
146
|
+
# tomllib rejects, and a line kept from such a file would still break
|
|
147
|
+
# the command line (DEC-19).
|
|
148
|
+
tomllib.loads(text)
|
|
149
|
+
return tomlkit.parse(text)
|
|
150
|
+
except (UnicodeDecodeError, tomllib.TOMLDecodeError, TOMLKitError):
|
|
151
|
+
return tomlkit.parse(TEMPLATE)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save(path: Path, settings: Settings) -> None:
|
|
155
|
+
"""Write `settings` to the settings file at `path`, keeping what it can (§5.3, DEC-18).
|
|
156
|
+
|
|
157
|
+
Unknown keys are removed; a key whose value already equals the saved one,
|
|
158
|
+
in TOML type and value, is left as it is, comments and all; any other is
|
|
159
|
+
replaced on its own line, and a missing one is appended. The write is in
|
|
160
|
+
place, through a symlink, after creating missing parent directories. A
|
|
161
|
+
file that can't be read or written raises `JumbleError` with
|
|
162
|
+
`can't save PATH: REASON` (§7.4). Pass settings that have passed
|
|
163
|
+
`validate`.
|
|
164
|
+
"""
|
|
165
|
+
doc = _document(path)
|
|
166
|
+
for key in [key for key in doc if key not in NAMES]:
|
|
167
|
+
del doc[key]
|
|
168
|
+
for key, value in settings_dict(settings).items():
|
|
169
|
+
if key in doc:
|
|
170
|
+
old = doc.item(key).unwrap()
|
|
171
|
+
# Same type, then same value: `True == 1` and `16.0 == 16` would
|
|
172
|
+
# otherwise leave `length = true` and `length = 16.0` in place.
|
|
173
|
+
if type(old) is type(value) and old == value:
|
|
174
|
+
continue
|
|
175
|
+
# Replaces the value in place, keeping the line's comment, or appends
|
|
176
|
+
# `KEY = VALUE` at the end.
|
|
177
|
+
doc[key] = value
|
|
178
|
+
try:
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
path.write_bytes(tomlkit.dumps(doc).encode("utf-8"))
|
|
181
|
+
except OSError as e:
|
|
182
|
+
raise JumbleError(f"can't save {path}: {e.strerror}") from e
|