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/generate.py ADDED
@@ -0,0 +1,287 @@
1
+ """Generate random strings from named character sets.
2
+
3
+ The behavior is defined in docs/spec.md; section numbers below refer to it.
4
+ """
5
+
6
+ import math
7
+ import secrets
8
+ import string
9
+ import unicodedata
10
+ from collections.abc import Iterable
11
+ from dataclasses import asdict, dataclass
12
+ from typing import NamedTuple
13
+
14
+
15
+ def _span(first: int, last: int, *, without: Iterable[int] = ()) -> str:
16
+ """The code points `first` through `last` inclusive, less those in `without`."""
17
+ skip = set(without)
18
+ return "".join(chr(c) for c in range(first, last + 1) if c not in skip)
19
+
20
+
21
+ def _cards() -> str:
22
+ # Four suits of U+1Fxx1–U+1FxxE, each without its knight, U+1FxxC (§2.1).
23
+ return "".join(
24
+ _span(base + 0x1, base + 0xE, without=[base + 0xC])
25
+ for base in range(0x1F0A0, 0x1F0E0, 0x10)
26
+ )
27
+
28
+
29
+ # The named sets of §2.1. Insertion order is the canonical order (§2.4).
30
+ CHARSETS: dict[str, str] = {
31
+ "lower": string.ascii_lowercase,
32
+ "upper": string.ascii_uppercase,
33
+ "digits": string.digits,
34
+ "symbols": string.punctuation, # DEC-2
35
+ "space": " ",
36
+ "chess": _span(0x2654, 0x265F),
37
+ "cards": _cards(),
38
+ "dice": _span(0x2680, 0x2685),
39
+ "emoji-faces": _span(0x1F600, 0x1F64F),
40
+ "emoji-animals": _span(0x1F400, 0x1F43E),
41
+ "emoji-food": _span(0x1F345, 0x1F37B),
42
+ "greek": _span(0x0391, 0x03A9, without=[0x03A2]) + _span(0x03B1, 0x03C9, without=[0x03C2]),
43
+ "cyrillic": _span(0x0410, 0x044F),
44
+ "latin-accented": _span(0x00C0, 0x00FF, without=[0x00D7, 0x00F7]),
45
+ "arrows": _span(0x2190, 0x2199),
46
+ "math": "±×÷∞≠≈≤≥√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨",
47
+ "currency": "¢£¥€₹₽₩₪₺₿₫₱",
48
+ "box": _span(0x2500, 0x259F),
49
+ }
50
+
51
+ # Look-alike characters left out by `no_ambiguous`, in §3.1's order (DEC-3).
52
+ LOOK_ALIKES = "0Ool1I|`'\";:.,-"
53
+ AMBIGUOUS: frozenset[str] = frozenset(LOOK_ALIKES)
54
+
55
+ CUSTOM = "custom"
56
+
57
+ # Unicode categories custom characters may not use (§2.3, DEC-4).
58
+ _FORBIDDEN_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Co", "Cn"})
59
+
60
+
61
+ class JumbleError(ValueError):
62
+ """Settings that can't produce strings; the text is a §6 message."""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Settings:
67
+ """The settings every front end shares (§1, DEC-10).
68
+
69
+ Holds what it was given, unchecked; `validate` judges it.
70
+ """
71
+
72
+ length: int = 16
73
+ count: int = 1
74
+ charsets: tuple[str, ...] = ("lower", "upper", "digits")
75
+ custom: str = ""
76
+ no_ambiguous: bool = False
77
+ each: bool = False
78
+ no_repeat: bool = False
79
+ unique: bool = False
80
+
81
+
82
+ def settings_dict(settings: Settings) -> dict[str, object]:
83
+ """`settings` as §1's eight values, keyed by setting name in §1's order (DEC-10).
84
+
85
+ `charsets` lists each chosen named set once, in canonical order; `custom`
86
+ is the string as given (§4.2). Unknown set names are dropped, so pass
87
+ settings that have passed `validate`.
88
+ """
89
+ values = asdict(settings)
90
+ chosen = set(settings.charsets)
91
+ values["charsets"] = [name for name in CHARSETS if name in chosen]
92
+ return values
93
+
94
+
95
+ def _check_custom(custom: str) -> None:
96
+ """Raise §6's message if `custom` has a control or combining character (§2.3, DEC-4)."""
97
+ for c in custom:
98
+ category = unicodedata.category(c)
99
+ if category in _FORBIDDEN_CATEGORIES or category.startswith("M"):
100
+ raise JumbleError("custom characters can't include control or combining characters")
101
+
102
+
103
+ def check_names(charsets: Iterable[str]) -> None:
104
+ """Raise §6's `unknown character set: 'NAME'` for the first name not in `CHARSETS`.
105
+
106
+ An empty name (from `-s lower,`) is unknown too (§2.2).
107
+ """
108
+ for name in charsets:
109
+ if name not in CHARSETS:
110
+ raise JumbleError(f"unknown character set: '{name}'")
111
+
112
+
113
+ def check_value(name: str, value: object) -> None:
114
+ """Raise §6's message if one setting's `value` breaks that setting's own rule (§5.1).
115
+
116
+ The rules that judge one value alone: `length` and `count` at least 1,
117
+ every `charsets` name known, and `custom` with no control or combining
118
+ character. The other settings have none. `value` must already have the
119
+ setting's type (§1). `validate` and `chosen_sets` run their checks of
120
+ these rules through here, and so does the settings file (DEC-17).
121
+ """
122
+ if name in ("length", "count"):
123
+ assert isinstance(value, int)
124
+ if value < 1:
125
+ raise JumbleError(f"{name} must be at least 1")
126
+ elif name == "charsets":
127
+ assert isinstance(value, Iterable)
128
+ check_names(value)
129
+ elif name == "custom":
130
+ assert isinstance(value, str)
131
+ _check_custom(value)
132
+
133
+
134
+ def chosen_sets(settings: Settings) -> list[tuple[str, str]]:
135
+ """The chosen sets after §2.4 steps 1–3, as `(name, characters)` pairs.
136
+
137
+ Named sets come in canonical order, then `custom` when it is non-empty.
138
+ Runs the §6 checks from "unknown set name" to "a chosen set is empty", in
139
+ that order.
140
+ """
141
+ check_value("charsets", settings.charsets)
142
+ check_value("custom", settings.custom)
143
+ # Repeats count once, each kept at its first position (§2.3).
144
+ custom = "".join(dict.fromkeys(settings.custom))
145
+ if not settings.charsets and not custom:
146
+ raise JumbleError("choose at least one character set")
147
+
148
+ chosen = set(settings.charsets)
149
+ sets = [(name, chars) for name, chars in CHARSETS.items() if name in chosen]
150
+ if custom:
151
+ sets.append((CUSTOM, custom))
152
+
153
+ result: list[tuple[str, str]] = []
154
+ seen: set[str] = set()
155
+ for name, chars in sets:
156
+ kept = "".join(
157
+ c for c in chars if c not in seen and not (settings.no_ambiguous and c in AMBIGUOUS)
158
+ )
159
+ if not kept:
160
+ if name == CUSTOM:
161
+ raise JumbleError("custom characters are all left out or already chosen")
162
+ raise JumbleError(f"{name} has no characters left")
163
+ seen.update(kept)
164
+ result.append((name, kept))
165
+ return result
166
+
167
+
168
+ class Strength(NamedTuple):
169
+ """Alphabet size and strength of a settings value (§3.5)."""
170
+
171
+ alphabet_size: int
172
+ bits: float
173
+ """log₂ of the number of possible strings, unrounded."""
174
+ is_bound: bool
175
+ """True when `each` is on: the bits are then an upper bound (DEC-7)."""
176
+
177
+
178
+ def strength_line(strength: Strength) -> str:
179
+ """The strength line of §4.3, shared by `--strength` and the TUI's subtitle.
180
+
181
+ For example `62 characters · 95.3 bits per string`, with `at most` before
182
+ the bits when they are a bound.
183
+ """
184
+ size = strength.alphabet_size
185
+ chars = "1 character" if size == 1 else f"{size} characters"
186
+ bound = "at most " if strength.is_bound else ""
187
+ return f"{chars} · {bound}{strength.bits:.1f} bits per string"
188
+
189
+
190
+ def _possible(size: int, settings: Settings) -> int:
191
+ """The number of possible strings, P (§3.5); ignores `each` (DEC-7)."""
192
+ if settings.no_repeat:
193
+ return math.perm(size, settings.length)
194
+ return size**settings.length
195
+
196
+
197
+ def validate(settings: Settings) -> list[tuple[str, str]]:
198
+ """Run every §6 check before drawing, in the table's order; the first failure wins.
199
+
200
+ Returns the chosen sets, as `chosen_sets` does.
201
+ """
202
+ check_value("length", settings.length)
203
+ check_value("count", settings.count)
204
+ sets = chosen_sets(settings)
205
+ k = len(sets)
206
+ size = sum(len(chars) for _, chars in sets)
207
+ if settings.each and settings.length < k:
208
+ raise JumbleError(
209
+ f"length must be at least {k} to take one character from each of {k} sets"
210
+ )
211
+ if settings.no_repeat and settings.length > size:
212
+ raise JumbleError(f"length can't be more than {size} without repeats")
213
+ if settings.unique:
214
+ possible = _possible(size, settings)
215
+ if settings.count > possible:
216
+ raise JumbleError(f"only {possible} different strings are possible")
217
+ return sets
218
+
219
+
220
+ def strength(settings: Settings) -> Strength:
221
+ """Alphabet size, bits per string, and whether that is a bound (§3.5).
222
+
223
+ Validates first, so the alphabet is the one `generate` would use.
224
+ """
225
+ size = sum(len(chars) for _, chars in validate(settings))
226
+ # log₂ of the exact integer, so a long string can't overflow a float power.
227
+ bits = math.log2(_possible(size, settings))
228
+ return Strength(size, bits, settings.each)
229
+
230
+
231
+ def _sample(pool: list[str], n: int) -> list[str]:
232
+ """`n` distinct items of `pool`, by a partial Fisher–Yates shuffle (DEC-6). Reorders `pool`."""
233
+ for i in range(n):
234
+ j = i + secrets.randbelow(len(pool) - i)
235
+ pool[i], pool[j] = pool[j], pool[i]
236
+ return pool[:n]
237
+
238
+
239
+ def _shuffle(items: list[str]) -> None:
240
+ """Shuffle `items` in place with Fisher–Yates (DEC-6)."""
241
+ for i in range(len(items) - 1, 0, -1):
242
+ j = secrets.randbelow(i + 1)
243
+ items[i], items[j] = items[j], items[i]
244
+
245
+
246
+ def _draw(settings: Settings, sets: list[tuple[str, str]], alphabet: str) -> str:
247
+ """One string of `settings.length` characters (§3.2, §3.3)."""
248
+ length = settings.length
249
+ if settings.each:
250
+ chars = [s[secrets.randbelow(len(s))] for _, s in sets]
251
+ rest = length - len(chars)
252
+ if settings.no_repeat:
253
+ # The sets are disjoint, so the picks are distinct already.
254
+ picked = set(chars)
255
+ chars += _sample([c for c in alphabet if c not in picked], rest)
256
+ else:
257
+ chars += [alphabet[secrets.randbelow(len(alphabet))] for _ in range(rest)]
258
+ _shuffle(chars)
259
+ return "".join(chars)
260
+ if settings.no_repeat:
261
+ return "".join(_sample(list(alphabet), length))
262
+ return "".join(alphabet[secrets.randbelow(len(alphabet))] for _ in range(length))
263
+
264
+
265
+ def generate(settings: Settings) -> list[str]:
266
+ """Validate `settings`, then draw `count` strings (§3).
267
+
268
+ Every draw goes through `secrets.randbelow` (DEC-1, DEC-6). With `unique`,
269
+ a repeat is drawn again, and after `max(100000, 20 × count)` draws without
270
+ `count` distinct strings generation gives up (§3.4, DEC-7).
271
+ """
272
+ sets = validate(settings)
273
+ alphabet = "".join(chars for _, chars in sets)
274
+ if not settings.unique:
275
+ return [_draw(settings, sets, alphabet) for _ in range(settings.count)]
276
+
277
+ cap = max(100_000, 20 * settings.count)
278
+ strings: dict[str, None] = {} # keeps drawn order
279
+ draws = 0
280
+ while len(strings) < settings.count:
281
+ if draws == cap:
282
+ raise JumbleError(
283
+ f"gave up after {cap} draws with {len(strings)} of {settings.count} unique strings"
284
+ )
285
+ strings[_draw(settings, sets, alphabet)] = None
286
+ draws += 1
287
+ return list(strings)