backpack-backbone 0.2.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.
- backbone/__init__.py +30 -0
- backbone/app.py +26 -0
- backbone/datetime_parse.py +224 -0
- backbone/deps.py +98 -0
- backbone/files.py +61 -0
- backbone/keyboard.py +148 -0
- backbone/keys.py +271 -0
- backbone/log.py +61 -0
- backbone/nav.py +17 -0
- backbone/notify.py +36 -0
- backbone/numbering.py +182 -0
- backbone/output.py +216 -0
- backbone/procs.py +60 -0
- backbone/prompt/__init__.py +20 -0
- backbone/prompt/audio.py +496 -0
- backbone/prompt/chrome.py +238 -0
- backbone/prompt/core.py +1562 -0
- backbone/prompt/dates.py +555 -0
- backbone/prompt/keymap.py +143 -0
- backbone/prompt/list_edit.py +974 -0
- backbone/prompt/lists.py +1280 -0
- backbone/prompt/text.py +356 -0
- backbone/prompt/timezone.py +1543 -0
- backbone/prompt/values.py +581 -0
- backbone/terminal_input.py +60 -0
- backbone/timefmt.py +34 -0
- backbone/ui.py +1044 -0
- backpack_backbone-0.2.0.dist-info/METADATA +177 -0
- backpack_backbone-0.2.0.dist-info/RECORD +32 -0
- backpack_backbone-0.2.0.dist-info/WHEEL +5 -0
- backpack_backbone-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backbone-0.2.0.dist-info/top_level.txt +1 -0
backbone/keys.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Key bindings: every rebindable key, named by what it does.
|
|
2
|
+
|
|
3
|
+
A screen defines its actions where it uses them, then asks which action a key
|
|
4
|
+
is rather than comparing literal keys, and builds its hints from the same
|
|
5
|
+
table, so a rebound key works and is shown everywhere at once:
|
|
6
|
+
|
|
7
|
+
keys.define("player", "Player", [
|
|
8
|
+
("next", ("]",), "next track"),
|
|
9
|
+
("prev", ("[",), "previous track"),
|
|
10
|
+
])
|
|
11
|
+
if keys.action(key, "player") == "player.next": ...
|
|
12
|
+
hint pair: (keys.label("player.prev", "player.next"), "prev/next")
|
|
13
|
+
|
|
14
|
+
Key names are what `prompt.core._read_key` returns ('UP', 'SPACE', 'ENTER',
|
|
15
|
+
'\\x10' for Ctrl-P, 'b', ...). An action may have several keys (aliases such
|
|
16
|
+
as b/B/Esc). The user's changes live in <config folder>/keys.json, apart from
|
|
17
|
+
the host's own config for the reason the hints switch is: a screen holding a
|
|
18
|
+
loaded config would save it back over a change made meanwhile.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from backbone.log import log
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Action:
|
|
31
|
+
id: str # "scope.name"
|
|
32
|
+
scope: str
|
|
33
|
+
label: str # what it does, for the Key bindings page
|
|
34
|
+
default: tuple
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_titles: dict[str, str] = {} # scope → its heading, in definition order
|
|
38
|
+
_within: dict[str, tuple] = {} # scope → the scopes active alongside it
|
|
39
|
+
_actions: dict[str, Action] = {} # id → Action, in definition order
|
|
40
|
+
|
|
41
|
+
# Never rebindable: Ctrl-C always quits, and pointer/focus events aren't keys.
|
|
42
|
+
_FIXED = ('CTRL_C',)
|
|
43
|
+
_EVENT_PREFIXES = ('MOUSE_', 'SCROLL_', 'FOCUS_')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def define(scope: str, title: str, actions: list, within: tuple = ("global",)) -> None:
|
|
47
|
+
"""Register a scope's actions: (name, default keys, label) each. `within`:
|
|
48
|
+
the scopes also live on that screen (their keys are found after its own,
|
|
49
|
+
and a key can't mean two things across them)."""
|
|
50
|
+
_titles.setdefault(scope, title)
|
|
51
|
+
_within[scope] = tuple(s for s in within if s != scope)
|
|
52
|
+
for name, default, label in actions:
|
|
53
|
+
aid = f"{scope}.{name}"
|
|
54
|
+
_actions[aid] = Action(aid, scope, label, tuple(default))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def actions(scope: str | None = None) -> list[Action]:
|
|
58
|
+
"""Every action, or one scope's, in definition order."""
|
|
59
|
+
return [a for a in _actions.values() if scope is None or a.scope == scope]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def scopes() -> list[tuple[str, str]]:
|
|
63
|
+
"""(scope, title) for every scope that has actions, in definition order."""
|
|
64
|
+
return [(s, t) for s, t in _titles.items() if any(a.scope == s for a in _actions.values())]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- the user's bindings: keys.json -------------------------------------------
|
|
68
|
+
|
|
69
|
+
_saved: dict = {'map': None, 'mtime': None, 'checked': 0.0}
|
|
70
|
+
_RECHECK_S = 1.0 # how often to look for a change made by another window
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _path():
|
|
74
|
+
from backbone import app
|
|
75
|
+
return app.config_dir / "keys.json"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _overrides() -> dict[str, tuple]:
|
|
79
|
+
"""The user's bindings, re-read when the file changes (another window may
|
|
80
|
+
have rebound a key). A broken file means the defaults, and a log line."""
|
|
81
|
+
now = time.monotonic()
|
|
82
|
+
if _saved['map'] is not None and now - _saved['checked'] < _RECHECK_S:
|
|
83
|
+
return _saved['map']
|
|
84
|
+
_saved['checked'] = now
|
|
85
|
+
try:
|
|
86
|
+
mtime = _path().stat().st_mtime
|
|
87
|
+
except OSError:
|
|
88
|
+
mtime = None
|
|
89
|
+
if _saved['map'] is not None and mtime == _saved['mtime']:
|
|
90
|
+
return _saved['map']
|
|
91
|
+
_saved['mtime'] = mtime
|
|
92
|
+
found: dict[str, tuple] = {}
|
|
93
|
+
if mtime is not None:
|
|
94
|
+
try:
|
|
95
|
+
raw = json.loads(_path().read_text(encoding="utf-8"))
|
|
96
|
+
if not isinstance(raw, dict):
|
|
97
|
+
raise ValueError("not a JSON object")
|
|
98
|
+
except (OSError, ValueError) as exc:
|
|
99
|
+
log.warning("keys.json unreadable, using the default keys: %s", exc)
|
|
100
|
+
raw = {}
|
|
101
|
+
for aid, ks in raw.items():
|
|
102
|
+
if isinstance(ks, list) and all(isinstance(k, str) and k for k in ks):
|
|
103
|
+
found[aid] = tuple(ks)
|
|
104
|
+
else:
|
|
105
|
+
log.warning("keys.json: ignoring %r (not a list of key names)", aid)
|
|
106
|
+
_saved['map'] = found
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write(overrides: dict[str, tuple]) -> None:
|
|
111
|
+
"""Save the bindings atomically. Entries for actions this build doesn't
|
|
112
|
+
define are kept: another tool or version may still use them."""
|
|
113
|
+
path = _path()
|
|
114
|
+
try:
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
tmp = path.with_suffix(".json.tmp")
|
|
117
|
+
tmp.write_text(json.dumps({k: list(v) for k, v in overrides.items()}, indent=1),
|
|
118
|
+
encoding="utf-8")
|
|
119
|
+
os.replace(tmp, path)
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
log.warning("couldn't save keys.json: %s", exc)
|
|
122
|
+
_saved.update(map=dict(overrides), mtime=None, checked=0.0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def of(aid: str) -> tuple:
|
|
126
|
+
"""The keys bound to an action: the user's, or else its default."""
|
|
127
|
+
return _overrides().get(aid, _actions[aid].default)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def changed(aid: str) -> bool:
|
|
131
|
+
return of(aid) != _actions[aid].default
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bind(aid: str, keys: tuple) -> None:
|
|
135
|
+
"""Set an action's keys (an empty tuple leaves it unbound)."""
|
|
136
|
+
overrides = dict(_overrides())
|
|
137
|
+
if tuple(keys) == _actions[aid].default:
|
|
138
|
+
overrides.pop(aid, None)
|
|
139
|
+
else:
|
|
140
|
+
overrides[aid] = tuple(keys)
|
|
141
|
+
if not keys:
|
|
142
|
+
log.info("key binding %s left without a key", aid)
|
|
143
|
+
_write(overrides)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def reset(aid: str | None = None) -> None:
|
|
147
|
+
"""Back to the default keys: one action, or every one."""
|
|
148
|
+
if aid is None:
|
|
149
|
+
_write({k: v for k, v in _overrides().items() if k not in _actions})
|
|
150
|
+
else:
|
|
151
|
+
overrides = dict(_overrides())
|
|
152
|
+
overrides.pop(aid, None)
|
|
153
|
+
_write(overrides)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def bindable(key: str) -> bool:
|
|
157
|
+
"""Whether a key can be bound at all."""
|
|
158
|
+
return bool(key) and key not in _FIXED and not key.startswith(_EVENT_PREFIXES)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- lookups -------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _chain(scope: str) -> tuple:
|
|
164
|
+
return (scope, *_within.get(scope, ()))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def action(key: str, scope: str) -> str | None:
|
|
168
|
+
"""Which action `key` is on a screen of `scope`: its own actions first,
|
|
169
|
+
then the scopes live alongside it. None when the key is unbound there."""
|
|
170
|
+
for s in _chain(scope):
|
|
171
|
+
for a in _actions.values():
|
|
172
|
+
if a.scope == s and key in of(a.id):
|
|
173
|
+
return a.id
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def keys_for(spec: str) -> tuple:
|
|
178
|
+
"""A widget parameter that takes a key may be given an action id instead:
|
|
179
|
+
its bound keys, or the plain key itself."""
|
|
180
|
+
if spec in _actions:
|
|
181
|
+
return of(spec)
|
|
182
|
+
if len(spec) > 2 and "." in spec[1:-1] and spec.replace(".", "").replace("_", "").isalnum():
|
|
183
|
+
log.warning("key spec %r looks like an action id but none is defined", spec)
|
|
184
|
+
return (spec,)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def hint_for(spec: str) -> str:
|
|
188
|
+
"""The hint text for such a parameter: the action's keys, or the key as given."""
|
|
189
|
+
return label(spec) if spec in _actions else spec
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def describe(spec: str) -> str:
|
|
193
|
+
"""What an action does, for a menu ("" for a plain key)."""
|
|
194
|
+
a = _actions.get(spec)
|
|
195
|
+
return a.label if a else ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def expand(mapping: dict | None) -> dict:
|
|
199
|
+
"""{key or action id: value} → {key: value}, every bound key of an action."""
|
|
200
|
+
return {k: v for spec, v in (mapping or {}).items() for k in keys_for(spec)}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def pressed(key: str, aid: str) -> bool:
|
|
204
|
+
"""Whether `key` is one of the action's keys."""
|
|
205
|
+
return key in of(aid)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def conflicts(aid: str, key: str) -> list[str]:
|
|
209
|
+
"""Other actions already using `key` somewhere this action is live: its own
|
|
210
|
+
scope, the scopes live alongside it, and the scopes it is live alongside."""
|
|
211
|
+
scope = _actions[aid].scope
|
|
212
|
+
related = set(_chain(scope)) | {s for s, w in _within.items() if scope in w}
|
|
213
|
+
return [a.id for a in _actions.values()
|
|
214
|
+
if a.id != aid and a.scope in related and key in of(a.id)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- how keys are shown ---------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
_GLYPHS = {
|
|
220
|
+
'UP': '↑', 'DOWN': '↓', 'LEFT': '←', 'RIGHT': '→', 'PGUP': '⇞', 'PGDN': '⇟',
|
|
221
|
+
'SPACE': 'space', 'ENTER': '↵', 'ESC': 'esc', 'TAB': 'tab', 'BACKTAB': '⇧tab',
|
|
222
|
+
'HOME': 'home', 'END': 'end', 'BACKSPACE': '⌫', 'DELETE': 'del', 'INSERT': 'ins',
|
|
223
|
+
'\x1f': '^/', '\x1b': 'esc',
|
|
224
|
+
}
|
|
225
|
+
_ARROWS = set('↑↓←→⇞⇟')
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def glyph(key: str) -> str:
|
|
229
|
+
"""How one key is written in hints and on the Key bindings page."""
|
|
230
|
+
if key in _GLYPHS:
|
|
231
|
+
return _GLYPHS[key]
|
|
232
|
+
if len(key) == 1 and 1 <= ord(key) <= 26:
|
|
233
|
+
return '^' + chr(ord(key) + 96) # '\x10' → '^p'
|
|
234
|
+
return key
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class HintKey(str):
|
|
238
|
+
"""A hint's key text that also knows the real key behind each glyph, so a
|
|
239
|
+
click on it replays the bound key whatever it looks like ('/', '[', '^/')."""
|
|
240
|
+
tokens: list # (offset, glyph length, key)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _display_keys(aids: tuple) -> list[str]:
|
|
244
|
+
"""The keys to show for these actions: each once, and a letter's other
|
|
245
|
+
case left out when both are bound (b/B is one key to the reader)."""
|
|
246
|
+
shown: list[str] = []
|
|
247
|
+
for aid in aids:
|
|
248
|
+
for k in of(aid):
|
|
249
|
+
if k in shown or (len(k) == 1 and k.isalpha() and k.swapcase() in shown):
|
|
250
|
+
continue
|
|
251
|
+
shown.append(k)
|
|
252
|
+
return shown
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def label(*aids: str, first: bool = False, most: int | None = None) -> HintKey:
|
|
256
|
+
"""The hint text for these actions' keys ("[/]", "↑↓", "space/p"), for a
|
|
257
|
+
hint short on room: `first` shows each action's first key only, `most`
|
|
258
|
+
caps how many keys are shown in all."""
|
|
259
|
+
shown = _display_keys(aids) if not first else [
|
|
260
|
+
ks[0] for ks in (_display_keys((a,)) for a in aids) if ks]
|
|
261
|
+
shown = shown[:most] if most else shown
|
|
262
|
+
text, tokens = "", []
|
|
263
|
+
for k in shown:
|
|
264
|
+
g = glyph(k)
|
|
265
|
+
if text and not (g in _ARROWS and text[-1] in _ARROWS):
|
|
266
|
+
text += "/" # arrows run together: ↑↓, ←→
|
|
267
|
+
tokens.append((len(text), len(g), k))
|
|
268
|
+
text += g
|
|
269
|
+
out = HintKey(text)
|
|
270
|
+
out.tokens = tokens
|
|
271
|
+
return out
|
backbone/log.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Diagnostics log: <config folder>/<program>.log (see backbone.app) while the
|
|
2
|
+
host turns it on (backtrack's Settings → Diagnostics). Off, it costs a level
|
|
3
|
+
check and nothing more.
|
|
4
|
+
|
|
5
|
+
from backbone.log import log
|
|
6
|
+
log.debug("redrew in %.0f ms", ms)
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
from logging.handlers import RotatingFileHandler
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("backbone")
|
|
12
|
+
log.addHandler(logging.NullHandler())
|
|
13
|
+
log.propagate = False
|
|
14
|
+
log.setLevel(logging.CRITICAL + 1) # silent until configure(True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log_path():
|
|
18
|
+
"""Where the log is written."""
|
|
19
|
+
from backbone import app
|
|
20
|
+
return app.config_dir / f"{app.name}.log"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def configure(enabled: bool) -> None:
|
|
24
|
+
"""Start or stop writing the log (the `debug` setting)."""
|
|
25
|
+
for h in [h for h in log.handlers if isinstance(h, RotatingFileHandler)]:
|
|
26
|
+
log.removeHandler(h)
|
|
27
|
+
h.close()
|
|
28
|
+
if not enabled:
|
|
29
|
+
log.setLevel(logging.CRITICAL + 1)
|
|
30
|
+
return
|
|
31
|
+
path = log_path()
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
handler = RotatingFileHandler(path, maxBytes=1_000_000, backupCount=2, encoding="utf-8")
|
|
34
|
+
handler.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03d %(levelname)-7s %(message)s",
|
|
35
|
+
"%H:%M:%S"))
|
|
36
|
+
log.addHandler(handler)
|
|
37
|
+
log.setLevel(logging.DEBUG)
|
|
38
|
+
log.info("diagnostics log started")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def enabled() -> bool:
|
|
42
|
+
"""Whether anything is being logged, for callers whose message is costly to build."""
|
|
43
|
+
return log.isEnabledFor(logging.DEBUG)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class quietly:
|
|
47
|
+
"""`with quietly():` carries on past a failure that mustn't stop anything
|
|
48
|
+
(best-effort cleanup, a cosmetic redraw), but notes it in the diagnostics
|
|
49
|
+
log, so a swallowed error still leaves a trace when Diagnostics is on."""
|
|
50
|
+
|
|
51
|
+
def __enter__(self):
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, kind, exc, tb) -> bool:
|
|
55
|
+
if kind is None or not issubclass(kind, Exception):
|
|
56
|
+
return False
|
|
57
|
+
if log.isEnabledFor(logging.DEBUG):
|
|
58
|
+
import os
|
|
59
|
+
where = f"{os.path.basename(tb.tb_frame.f_code.co_filename)}:{tb.tb_lineno}"
|
|
60
|
+
log.debug("ignored at %s: %s: %s", where, kind.__name__, exc)
|
|
61
|
+
return True
|
backbone/nav.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""nav.py - app-wide navigation breadcrumb and the quit-to-terminal signal,
|
|
2
|
+
shared by every back* tool that uses backbone's prompt widgets.
|
|
3
|
+
"""
|
|
4
|
+
NAV_STACK = ["Home"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class QuitToTerminal(BaseException):
|
|
8
|
+
"""Raised to unwind the entire menu stack and exit straight to the
|
|
9
|
+
terminal. Derives from BaseException (not Exception) so it bypasses
|
|
10
|
+
``except Exception`` handlers in editors/widgets and propagates cleanly
|
|
11
|
+
up to the app's main(), where the alt-screen is restored in a finally.
|
|
12
|
+
|
|
13
|
+
`q` quits an app from anywhere by raising this on the spot - there is
|
|
14
|
+
deliberately no "leave this widget and quit later" flag: `q` is never a
|
|
15
|
+
way out of a widget; Esc (or <-/b where a widget has no other use for
|
|
16
|
+
them) is what backs out.
|
|
17
|
+
"""
|
backbone/notify.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Telling someone something happened: a push to their phone through ntfy, or
|
|
2
|
+
a chime at the machine."""
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ntfy(server: str, topic: str, title: str, message: str, priority: str = "default", tags: str = "") -> None:
|
|
9
|
+
"""Push `message` to an ntfy topic in the background. Nothing is sent
|
|
10
|
+
without a topic, and a failed send is dropped rather than raised: a missed
|
|
11
|
+
notification must never stop the work it reports on."""
|
|
12
|
+
if not topic:
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
def _send():
|
|
16
|
+
req = urllib.request.Request(
|
|
17
|
+
f"{server.rstrip('/')}/{topic}", data=message.encode(), method="POST",
|
|
18
|
+
headers={"Title": title, "Priority": priority, "Tags": tags},
|
|
19
|
+
)
|
|
20
|
+
try:
|
|
21
|
+
urllib.request.urlopen(req, timeout=10)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def chime() -> None:
|
|
29
|
+
"""The macOS done sound, or the terminal bell where there's no afplay."""
|
|
30
|
+
try:
|
|
31
|
+
if subprocess.run(["afplay", "/System/Library/Sounds/Glass.aiff"],
|
|
32
|
+
capture_output=True, timeout=10).returncode == 0:
|
|
33
|
+
return
|
|
34
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
35
|
+
pass
|
|
36
|
+
print("\a", end="", flush=True)
|
backbone/numbering.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Number rendering shared by the patterning tools: arabic, roman, or written out.
|
|
2
|
+
|
|
3
|
+
One place owns the three styles so a file-name pattern (``%track:r%``), a bulk
|
|
4
|
+
range template (``Act {r}``) and the playback panel's movement numeral all agree.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# Value → symbol, largest first: the standard subtractive-notation table.
|
|
9
|
+
_ROMAN: tuple[tuple[int, str], ...] = (
|
|
10
|
+
(1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'),
|
|
11
|
+
(100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'),
|
|
12
|
+
(10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I'),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_ONES = ('zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
|
|
16
|
+
'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
|
|
17
|
+
'sixteen', 'seventeen', 'eighteen', 'nineteen')
|
|
18
|
+
_TENS = ('', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy',
|
|
19
|
+
'eighty', 'ninety')
|
|
20
|
+
|
|
21
|
+
# Recognised number styles, and the case modifiers each accepts.
|
|
22
|
+
STYLES: dict[str, str] = {
|
|
23
|
+
'n': 'Arabic (3)',
|
|
24
|
+
'r': 'Roman (III)',
|
|
25
|
+
'en': 'Written out (Three)',
|
|
26
|
+
}
|
|
27
|
+
CASES: str = "l = lower, u = UPPER, t = Title Case"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def roman(num: int) -> str:
|
|
31
|
+
"""Integer → Roman numeral ('' for anything below 1, which has no numeral)."""
|
|
32
|
+
try:
|
|
33
|
+
n = int(num)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return ''
|
|
36
|
+
if n < 1:
|
|
37
|
+
return ''
|
|
38
|
+
out: list[str] = []
|
|
39
|
+
for value, symbol in _ROMAN:
|
|
40
|
+
count, n = divmod(n, value)
|
|
41
|
+
out.append(symbol * count)
|
|
42
|
+
return ''.join(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def in_words(num: int) -> str:
|
|
46
|
+
"""Integer → English words, lower case ('twenty-one', 'one hundred and five').
|
|
47
|
+
|
|
48
|
+
Covers 0-999,999; anything outside that falls back to the digits, since a
|
|
49
|
+
pattern is better off showing a number than nothing.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
n = int(num)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return str(num)
|
|
55
|
+
if n < 0:
|
|
56
|
+
return f"minus {in_words(-n)}"
|
|
57
|
+
if n >= 1_000_000:
|
|
58
|
+
return str(n)
|
|
59
|
+
|
|
60
|
+
if n < 20:
|
|
61
|
+
return _ONES[n]
|
|
62
|
+
if n < 100:
|
|
63
|
+
tens, ones = divmod(n, 10)
|
|
64
|
+
return _TENS[tens] + (f"-{_ONES[ones]}" if ones else "")
|
|
65
|
+
if n < 1000:
|
|
66
|
+
hundreds, rest = divmod(n, 100)
|
|
67
|
+
out = f"{_ONES[hundreds]} hundred"
|
|
68
|
+
return f"{out} and {in_words(rest)}" if rest else out
|
|
69
|
+
thousands, rest = divmod(n, 1000)
|
|
70
|
+
out = f"{in_words(thousands)} thousand"
|
|
71
|
+
if not rest:
|
|
72
|
+
return out
|
|
73
|
+
# "two thousand and five", but "two thousand one hundred and five".
|
|
74
|
+
joiner = " and " if rest < 100 else " "
|
|
75
|
+
return out + joiner + in_words(rest)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- reading numbers back ------------------------------------------------
|
|
79
|
+
|
|
80
|
+
_ROMAN_VALUES = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
|
|
81
|
+
|
|
82
|
+
_WORD_VALUES: dict[str, int] = {w: i for i, w in enumerate(_ONES)}
|
|
83
|
+
_WORD_VALUES.update({w: i * 10 for i, w in enumerate(_TENS) if w})
|
|
84
|
+
_WORD_SCALES = {'hundred': 100, 'thousand': 1000}
|
|
85
|
+
|
|
86
|
+
# Regex fragments for matching a number written in each style, for pattern
|
|
87
|
+
# templates that parse names ("Act III - Title", "Series Three, Episode Four").
|
|
88
|
+
# Case-insensitive in-place so they can drop into a larger pattern unchanged.
|
|
89
|
+
ROMAN_FRAGMENT = r'(?i:[mdclxvi]+)'
|
|
90
|
+
WORD_FRAGMENT = (r'(?i:(?:' + '|'.join(
|
|
91
|
+
sorted(list(_ONES) + [t for t in _TENS if t] + list(_WORD_SCALES) + ['and'],
|
|
92
|
+
key=len, reverse=True)) + r')(?:[- ](?:' + '|'.join(
|
|
93
|
+
sorted(list(_ONES) + [t for t in _TENS if t] + list(_WORD_SCALES) + ['and'],
|
|
94
|
+
key=len, reverse=True)) + r'))*)')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def from_roman(text: str) -> int | None:
|
|
98
|
+
"""Roman numeral → int, or None if `text` isn't one ('XIV' → 14)."""
|
|
99
|
+
t = str(text).strip().upper()
|
|
100
|
+
if not t or any(c not in _ROMAN_VALUES for c in t):
|
|
101
|
+
return None
|
|
102
|
+
total = 0
|
|
103
|
+
for i, c in enumerate(t):
|
|
104
|
+
v = _ROMAN_VALUES[c]
|
|
105
|
+
# A smaller symbol before a larger one is subtractive (IV, IX, XL…).
|
|
106
|
+
total += -v if (i + 1 < len(t) and v < _ROMAN_VALUES[t[i + 1]]) else v
|
|
107
|
+
if total < 1 or roman(total) != t:
|
|
108
|
+
return None # not canonical ('IIII', 'VX'): treat as text
|
|
109
|
+
return total
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def from_words(text: str) -> int | None:
|
|
113
|
+
"""English words → int, or None if unparseable ('twenty-one' → 21)."""
|
|
114
|
+
tokens = [w for w in str(text).lower().replace('-', ' ').split() if w != 'and']
|
|
115
|
+
if not tokens:
|
|
116
|
+
return None
|
|
117
|
+
total = current = 0
|
|
118
|
+
for w in tokens:
|
|
119
|
+
if w in _WORD_SCALES:
|
|
120
|
+
scale = _WORD_SCALES[w]
|
|
121
|
+
# "two hundred" scales what's pending; "thousand" banks it.
|
|
122
|
+
current = max(current, 1) * scale
|
|
123
|
+
if scale >= 1000:
|
|
124
|
+
total += current
|
|
125
|
+
current = 0
|
|
126
|
+
elif w in _WORD_VALUES:
|
|
127
|
+
current += _WORD_VALUES[w]
|
|
128
|
+
else:
|
|
129
|
+
return None
|
|
130
|
+
return (total + current) or (0 if 'zero' in tokens else None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse(text) -> int | None:
|
|
134
|
+
"""Read a number written in any of the three styles: '4', 'IV' or 'four'."""
|
|
135
|
+
t = str(text).strip()
|
|
136
|
+
if not t:
|
|
137
|
+
return None
|
|
138
|
+
if t.lstrip('-').isdigit():
|
|
139
|
+
return int(t)
|
|
140
|
+
return from_roman(t) if from_roman(t) is not None else from_words(t)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def apply_case(text: str, case: str) -> str:
|
|
144
|
+
"""Apply a case modifier: 'l' lower, 'u' upper, 't' Title Case.
|
|
145
|
+
|
|
146
|
+
Anything else (including an empty modifier) capitalises the first letter
|
|
147
|
+
only: 'Twenty-one' rather than 'Twenty-One', which reads better mid-title.
|
|
148
|
+
"""
|
|
149
|
+
if not text:
|
|
150
|
+
return text
|
|
151
|
+
if case == 'l':
|
|
152
|
+
return text.lower()
|
|
153
|
+
if case == 'u':
|
|
154
|
+
return text.upper()
|
|
155
|
+
if case == 't':
|
|
156
|
+
return '-'.join(w.capitalize() for w in text.split('-')) if '-' in text \
|
|
157
|
+
else ' '.join(w.capitalize() for w in text.split(' '))
|
|
158
|
+
return text[0].upper() + text[1:]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render(value, style: str = 'n', spec: str = '') -> str:
|
|
162
|
+
"""Render `value` in one of the three number styles.
|
|
163
|
+
|
|
164
|
+
``style`` is 'n' (arabic), 'r' (roman) or 'en' (written out); ``spec`` is a
|
|
165
|
+
case modifier for 'r'/'en', or a `format()` spec for 'n' (so ``{n:02d}``
|
|
166
|
+
padding still works). A value that isn't a number comes back unchanged, and
|
|
167
|
+
a number with no Roman form (0 or less) falls back to its digits rather than
|
|
168
|
+
vanishing from the pattern.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
n = int(str(value).strip())
|
|
172
|
+
except (TypeError, ValueError):
|
|
173
|
+
return str(value)
|
|
174
|
+
|
|
175
|
+
if style == 'r':
|
|
176
|
+
return apply_case(roman(n), spec) or str(n)
|
|
177
|
+
if style == 'en':
|
|
178
|
+
return apply_case(in_words(n), spec or 'c')
|
|
179
|
+
try:
|
|
180
|
+
return format(n, spec) if spec else str(n)
|
|
181
|
+
except (ValueError, TypeError):
|
|
182
|
+
return str(n)
|