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/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)