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 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
@@ -0,0 +1,3 @@
1
+ from jumble.cli import main
2
+
3
+ raise SystemExit(main())
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