slab-cli 0.8.0__tar.gz → 0.9.0__tar.gz
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.
- {slab_cli-0.8.0 → slab_cli-0.9.0}/PKG-INFO +1 -1
- {slab_cli-0.8.0 → slab_cli-0.9.0}/pyproject.toml +1 -1
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/browse.py +25 -87
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/display.py +16 -5
- slab_cli-0.9.0/src/slab_cli/platforms/__init__.py +70 -0
- slab_cli-0.9.0/src/slab_cli/platforms/base.py +86 -0
- slab_cli-0.9.0/src/slab_cli/platforms/posix.py +99 -0
- slab_cli-0.9.0/src/slab_cli/platforms/windows.py +91 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/README.md +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/client.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/custom_sets.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/registry.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/main.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/prompts.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/theme.py +0 -0
- {slab_cli-0.8.0 → slab_cli-0.9.0}/src/slab_cli/updates.py +0 -0
|
@@ -6,8 +6,13 @@ across every column, and q leaves — printing the plain table on the way out so
|
|
|
6
6
|
survives in scrollback. Enter drills down: with a `detail` callback the highlighted row's
|
|
7
7
|
detail view REPLACES the list as a scrollable page (Esc/q climbs back up to the list, same row
|
|
8
8
|
highlighted); without one, Enter simply returns the row's item to the caller. Anywhere output
|
|
9
|
-
isn't an interactive terminal (a pipe, CI, redirection
|
|
10
|
-
|
|
9
|
+
isn't an interactive terminal (a pipe, CI, redirection) it degrades to the static table, so
|
|
10
|
+
`slab ... | grep` keeps working unchanged.
|
|
11
|
+
|
|
12
|
+
Reading keys is the one OS-specific part, and this module doesn't do it: `platforms.current()`
|
|
13
|
+
hands back a backend whose `key_source()` yields a `read()` returning platform-neutral key NAMES
|
|
14
|
+
('up', 'enter', …). Everything below is written against those names, so it behaves identically on
|
|
15
|
+
macOS and Windows and stays that way — see `platforms/base.py` for the rule.
|
|
11
16
|
|
|
12
17
|
This is THE tabular display for result lists: a command builds `(item, cells)` rows once and
|
|
13
18
|
the same spec drives the interactive view, the static fallback, and the exit receipt.
|
|
@@ -15,7 +20,6 @@ the same spec drives the interactive view, the static fallback, and the exit rec
|
|
|
15
20
|
|
|
16
21
|
from __future__ import annotations
|
|
17
22
|
|
|
18
|
-
import os
|
|
19
23
|
import sys
|
|
20
24
|
from typing import Any, Callable, Sequence
|
|
21
25
|
|
|
@@ -23,6 +27,7 @@ from rich.console import Group
|
|
|
23
27
|
from rich.live import Live
|
|
24
28
|
from rich.text import Text
|
|
25
29
|
|
|
30
|
+
from .platforms import KeyReader, current as current_platform
|
|
26
31
|
from .theme import console, heading, slab_table
|
|
27
32
|
|
|
28
33
|
# One row: the domain object Enter should act on, plus its cell markup strings (one per column).
|
|
@@ -85,76 +90,16 @@ def pager_top(top: int, viewport: int, n: int) -> int:
|
|
|
85
90
|
return max(0, min(top, n - viewport))
|
|
86
91
|
|
|
87
92
|
|
|
88
|
-
_SEQUENCES = {
|
|
89
|
-
b"\x1b[A": "up", b"\x1b[B": "down",
|
|
90
|
-
b"\x1bOA": "up", b"\x1bOB": "down", # application cursor mode
|
|
91
|
-
b"\x1b[5~": "pgup", b"\x1b[6~": "pgdn",
|
|
92
|
-
b"\x1b[H": "home", b"\x1b[F": "end",
|
|
93
|
-
b"\x1b[1~": "home", b"\x1b[4~": "end",
|
|
94
|
-
b"\x1b[D": "left", b"\x1bOD": "left", # ← also climbs back up a level
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def split_keys(data: bytes) -> list[str]:
|
|
99
|
-
"""A raw input burst -> ordered key names ('up', 'enter', 'esc', …) and printable chars.
|
|
100
|
-
|
|
101
|
-
Terminals deliver fast typing, pastes, and escape sequences as multi-byte chunks; parsing
|
|
102
|
-
the whole burst (instead of assuming one read = one key) is what keeps a pasted filter
|
|
103
|
-
query intact and an arrow key from being mistaken for its component bytes."""
|
|
104
|
-
keys: list[str] = []
|
|
105
|
-
i = 0
|
|
106
|
-
while i < len(data):
|
|
107
|
-
if data[i:i + 1] == b"\x1b":
|
|
108
|
-
for seq, name in _SEQUENCES.items():
|
|
109
|
-
if data.startswith(seq, i):
|
|
110
|
-
keys.append(name)
|
|
111
|
-
i += len(seq)
|
|
112
|
-
break
|
|
113
|
-
else:
|
|
114
|
-
if data[i + 1:i + 2] in (b"[", b"O"):
|
|
115
|
-
# An escape sequence we don't drive anything with: swallow through its
|
|
116
|
-
# final byte (0x40–0x7e) so its payload doesn't leak in as typed text.
|
|
117
|
-
j = i + 2
|
|
118
|
-
while j < len(data) and not 0x40 <= data[j] <= 0x7E:
|
|
119
|
-
j += 1
|
|
120
|
-
i = j + 1
|
|
121
|
-
else:
|
|
122
|
-
i += 1 # bare Escape
|
|
123
|
-
keys.append("esc")
|
|
124
|
-
continue
|
|
125
|
-
j = data.find(b"\x1b", i)
|
|
126
|
-
run = data[i:] if j == -1 else data[i:j]
|
|
127
|
-
i = len(data) if j == -1 else j
|
|
128
|
-
for ch in run.decode(errors="ignore"):
|
|
129
|
-
if ch in ("\r", "\n"):
|
|
130
|
-
keys.append("enter")
|
|
131
|
-
elif ch in ("\x7f", "\x08"):
|
|
132
|
-
keys.append("backspace")
|
|
133
|
-
elif ch == "\x03":
|
|
134
|
-
keys.append("quit") # Ctrl-C when ISIG doesn't get there first
|
|
135
|
-
elif ch.isprintable():
|
|
136
|
-
keys.append(ch)
|
|
137
|
-
return keys
|
|
138
|
-
|
|
139
|
-
|
|
140
93
|
# ---------------------------------------------------------------------------
|
|
141
94
|
# The interactive machinery
|
|
142
95
|
# ---------------------------------------------------------------------------
|
|
143
96
|
|
|
144
97
|
def _interactive_ok() -> bool:
|
|
145
|
-
"""True when we can actually run a keyboard loop: a real terminal
|
|
98
|
+
"""True when we can actually run a keyboard loop: a real terminal (not a pipe, not CI), on a
|
|
99
|
+
platform that can read single keypresses. Either half failing is a fallback, not an error."""
|
|
146
100
|
if not (console.is_terminal and sys.stdin.isatty()):
|
|
147
101
|
return False
|
|
148
|
-
|
|
149
|
-
import termios # noqa: F401 (Unix-only; absent on Windows)
|
|
150
|
-
import tty # noqa: F401
|
|
151
|
-
except ImportError:
|
|
152
|
-
return False
|
|
153
|
-
return True
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _read_keys(fd: int) -> list[str]:
|
|
157
|
-
return split_keys(os.read(fd, 64))
|
|
102
|
+
return current_platform().can_read_keys()
|
|
158
103
|
|
|
159
104
|
|
|
160
105
|
def _frame(
|
|
@@ -196,7 +141,7 @@ def _frame(
|
|
|
196
141
|
|
|
197
142
|
|
|
198
143
|
def _run_list(
|
|
199
|
-
|
|
144
|
+
read: KeyReader,
|
|
200
145
|
columns: list[tuple[str, dict]],
|
|
201
146
|
rows: list[Row],
|
|
202
147
|
*,
|
|
@@ -232,7 +177,7 @@ def _run_list(
|
|
|
232
177
|
)
|
|
233
178
|
|
|
234
179
|
if not pending:
|
|
235
|
-
pending =
|
|
180
|
+
pending = read()
|
|
236
181
|
if not pending:
|
|
237
182
|
continue
|
|
238
183
|
key = pending.pop(0)
|
|
@@ -274,7 +219,7 @@ def _run_list(
|
|
|
274
219
|
typing = True
|
|
275
220
|
|
|
276
221
|
|
|
277
|
-
def _run_pager(
|
|
222
|
+
def _run_pager(read: KeyReader, lines: list[Text], *, back_hint: str) -> None:
|
|
278
223
|
"""The detail screen: a captured view as a scrollable page. Esc/q/←/backspace climbs back
|
|
279
224
|
up to the list; ↑↓, PgUp/PgDn, and space scroll when the content is taller than the
|
|
280
225
|
terminal. Returns when the user leaves."""
|
|
@@ -294,7 +239,7 @@ def _run_pager(fd: int, lines: list[Text], *, back_hint: str) -> None:
|
|
|
294
239
|
live.update(Group(*window), refresh=True)
|
|
295
240
|
|
|
296
241
|
if not pending:
|
|
297
|
-
pending =
|
|
242
|
+
pending = read()
|
|
298
243
|
if not pending:
|
|
299
244
|
continue
|
|
300
245
|
key = pending.pop(0)
|
|
@@ -356,28 +301,21 @@ def browse(
|
|
|
356
301
|
print_table(columns, rows, title=title, note=note, footer=footer)
|
|
357
302
|
return None
|
|
358
303
|
|
|
359
|
-
import termios
|
|
360
|
-
import tty
|
|
361
|
-
|
|
362
|
-
fd = sys.stdin.fileno()
|
|
363
|
-
saved = termios.tcgetattr(fd)
|
|
364
304
|
picked: Any | None = None
|
|
365
305
|
sel = start
|
|
366
306
|
try:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
307
|
+
with current_platform().key_source() as read:
|
|
308
|
+
while True:
|
|
309
|
+
item, sel = _run_list(read, columns, rows, title=title, note=note,
|
|
310
|
+
hint=hint, start=sel)
|
|
311
|
+
if item is None:
|
|
312
|
+
break
|
|
313
|
+
if detail is None:
|
|
314
|
+
picked = item
|
|
315
|
+
break
|
|
316
|
+
_run_pager(read, _capture_lines(lambda: detail(item)), back_hint="back to list")
|
|
377
317
|
except KeyboardInterrupt:
|
|
378
318
|
pass
|
|
379
|
-
finally:
|
|
380
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
381
319
|
|
|
382
320
|
if picked is not None:
|
|
383
321
|
return picked
|
|
@@ -19,7 +19,7 @@ from slab_schemas.community import (
|
|
|
19
19
|
)
|
|
20
20
|
from slab_schemas.custom_sets import CustomSetDetail, CustomSetOut, CustomSetSearchResult
|
|
21
21
|
from slab_schemas.dashboard import CatalogStats, DashboardStats, HighlightCard, LabeledCount
|
|
22
|
-
from slab_schemas.enums import Grade
|
|
22
|
+
from slab_schemas.enums import Grade, SealedFormat
|
|
23
23
|
from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTopCards
|
|
24
24
|
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
25
25
|
from slab_schemas.timeseries import CardPriceHistory
|
|
@@ -908,16 +908,27 @@ def _format_label(fmt: str) -> str:
|
|
|
908
908
|
return fmt.replace("_", " ").title()
|
|
909
909
|
|
|
910
910
|
|
|
911
|
+
_CASE_FORMATS = {SealedFormat.hobby_case, SealedFormat.retail_case}
|
|
912
|
+
|
|
913
|
+
|
|
911
914
|
def _sealed_config(p: SealedProductOut) -> str:
|
|
912
|
-
"""A compact config string for a sealed SKU
|
|
915
|
+
"""A compact config string for a sealed SKU — what you actually get when you open THIS SKU:
|
|
916
|
+
'12 boxes × 8 packs × 12 cards' for a case, '4 packs × 12 cards' for a box, '24 cards' for a
|
|
917
|
+
single-pack SKU. `boxes_per_case` describes the CASE, not the box, so on a non-case SKU it
|
|
918
|
+
rides along as a '· 20/case' suffix — never as a factor (a blaster box holds 4 packs, not the
|
|
919
|
+
20 blasters that share its case)."""
|
|
913
920
|
parts = []
|
|
914
|
-
if p.boxes_per_case:
|
|
921
|
+
if p.format in _CASE_FORMATS and p.boxes_per_case:
|
|
915
922
|
parts.append(f"{p.boxes_per_case} boxes")
|
|
916
|
-
|
|
923
|
+
# A 1-pack SKU (hanger, starter) is just its pack — "1 packs × 24 cards" reads as noise.
|
|
924
|
+
if p.packs_per_box and p.packs_per_box > 1:
|
|
917
925
|
parts.append(f"{p.packs_per_box} packs")
|
|
918
926
|
if p.cards_per_pack:
|
|
919
927
|
parts.append(f"{p.cards_per_pack} cards")
|
|
920
|
-
|
|
928
|
+
config = " × ".join(parts) if parts else "—"
|
|
929
|
+
if p.format not in _CASE_FORMATS and p.boxes_per_case:
|
|
930
|
+
config = f"{config} · {p.boxes_per_case}/case"
|
|
931
|
+
return config
|
|
921
932
|
|
|
922
933
|
|
|
923
934
|
def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCards) -> None:
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The platform registry — `current()` is how the rest of the CLI gets its `Platform`.
|
|
2
|
+
|
|
3
|
+
from .platforms import current
|
|
4
|
+
|
|
5
|
+
plat = current()
|
|
6
|
+
if plat.can_read_keys():
|
|
7
|
+
with plat.key_source() as read: ...
|
|
8
|
+
|
|
9
|
+
Selection happens once and is cached: the OS doesn't change mid-run. `SLAB_PLATFORM` overrides it
|
|
10
|
+
by name, which is how you drive the Windows code path from a Mac (its decoding is pure; only
|
|
11
|
+
`key_source` actually needs the OS) — an unknown name falls back to the real platform rather than
|
|
12
|
+
erroring, so a typo can't brick the CLI.
|
|
13
|
+
|
|
14
|
+
See `base.py` for the protocol and the rule about where OS forks may live.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from .base import KEY_NAMES, KeyReader, Platform, unsupported_key_source
|
|
24
|
+
from .posix import PosixPlatform
|
|
25
|
+
from .windows import WindowsPlatform
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"KEY_NAMES", "KeyReader", "Platform", "unsupported_key_source",
|
|
29
|
+
"PLATFORMS", "current", "detect_name", "get_platform",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# name -> factory. Add a platform here and nothing else in the CLI changes.
|
|
33
|
+
PLATFORMS: dict[str, Callable[[], Platform]] = {
|
|
34
|
+
"posix": PosixPlatform,
|
|
35
|
+
"windows": WindowsPlatform,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ENV_OVERRIDE = "SLAB_PLATFORM"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_name(platform_str: str | None = None) -> str:
|
|
42
|
+
"""Which registry entry this interpreter is running on. Pure — pass `sys.platform` to test.
|
|
43
|
+
|
|
44
|
+
Everything that isn't Windows is POSIX as far as the CLI is concerned: macOS, Linux, and the
|
|
45
|
+
BSDs all read keys the same way, so they share one implementation rather than one each."""
|
|
46
|
+
return "windows" if (platform_str or sys.platform).startswith("win32") else "posix"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_platform(name: str) -> Platform:
|
|
50
|
+
"""The named platform. Raises KeyError for an unknown name — callers that take user input
|
|
51
|
+
should go through `current()`, which falls back instead."""
|
|
52
|
+
return PLATFORMS[name]()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_current: Platform | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def current() -> Platform:
|
|
59
|
+
"""The `Platform` for this run, detected once and cached."""
|
|
60
|
+
global _current
|
|
61
|
+
if _current is None:
|
|
62
|
+
requested = os.environ.get(ENV_OVERRIDE, "").strip().lower()
|
|
63
|
+
_current = PLATFORMS.get(requested, PLATFORMS[detect_name()])()
|
|
64
|
+
return _current
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def set_platform(platform: Platform | None) -> None:
|
|
68
|
+
"""Force the active platform, or None to re-detect. For tests — nothing in the CLI calls it."""
|
|
69
|
+
global _current
|
|
70
|
+
_current = platform
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The `Platform` protocol — the ONE contract every OS-specific behaviour in the CLI goes through.
|
|
2
|
+
|
|
3
|
+
Mirrors the seeder's `sources/` seam: a protocol here, one module per implementation beside it,
|
|
4
|
+
a registry in `__init__.py`. The point is the same — the rest of the code is written against the
|
|
5
|
+
protocol and never learns which OS it's on.
|
|
6
|
+
|
|
7
|
+
## The rule
|
|
8
|
+
|
|
9
|
+
**No `sys.platform` check, and no import of an OS-only stdlib module (`termios`, `tty`, `msvcrt`,
|
|
10
|
+
`fcntl`, `winreg`, …), may live outside this package.** `tests/test_platforms.py` enforces it by
|
|
11
|
+
scanning the source, so a stray fork fails CI rather than quietly shipping a Mac-only feature —
|
|
12
|
+
which is exactly how the browse table came to be invisible on Windows for a release.
|
|
13
|
+
|
|
14
|
+
## Adding a capability
|
|
15
|
+
|
|
16
|
+
When something new turns out to differ by OS:
|
|
17
|
+
|
|
18
|
+
1. Add the method to `Platform` below, documenting **what varies and why** — not just its
|
|
19
|
+
signature. If you can't name the divergence, the caller probably doesn't need a fork.
|
|
20
|
+
2. Implement it in EVERY module in this package. A platform that can't do the thing returns a
|
|
21
|
+
falsey/None value the caller degrades on; it never raises for being the wrong OS.
|
|
22
|
+
3. Keep the pure part pure. Decoding, parsing, and formatting go in module-level functions that
|
|
23
|
+
take plain data (see `posix.split_keys` / `windows.split_win_keys`), so the other platform's
|
|
24
|
+
logic stays unit-testable from whatever machine you happen to be on.
|
|
25
|
+
|
|
26
|
+
## Adding a platform
|
|
27
|
+
|
|
28
|
+
Add a module, implement `Platform`, register it in `__init__.py`. Nothing else changes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from contextlib import contextmanager
|
|
34
|
+
from typing import Callable, ContextManager, Iterator, Protocol, runtime_checkable
|
|
35
|
+
|
|
36
|
+
# A key burst, decoded: the platform-neutral NAMES the interactive loops switch on. Both backends
|
|
37
|
+
# translate their own wire format (POSIX escape sequences, Windows `\x00`/`\xe0` scan codes) into
|
|
38
|
+
# these, plus bare printable characters for anything typed. Adding a name means teaching EVERY
|
|
39
|
+
# platform to produce it — an unmapped key must be dropped, never leaked through as text.
|
|
40
|
+
KEY_NAMES = frozenset({
|
|
41
|
+
"up", "down", "left",
|
|
42
|
+
"pgup", "pgdn", "home", "end",
|
|
43
|
+
"enter", "esc", "backspace",
|
|
44
|
+
"quit", # Ctrl-C where the platform hands it over as data rather than a signal
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
# `read() -> [key names]`: blocks for at least one key, then returns everything already buffered
|
|
48
|
+
# alongside it. Returning the whole burst (rather than one key per call) is what keeps a pasted
|
|
49
|
+
# filter query intact and stops a held arrow key from lagging behind the render.
|
|
50
|
+
KeyReader = Callable[[], list[str]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@runtime_checkable
|
|
54
|
+
class Platform(Protocol):
|
|
55
|
+
"""What the CLI needs from the operating system. One implementation per OS."""
|
|
56
|
+
|
|
57
|
+
#: Registry key and what the diagnostics print — "posix", "windows".
|
|
58
|
+
name: str
|
|
59
|
+
|
|
60
|
+
def can_read_keys(self) -> bool:
|
|
61
|
+
"""Whether single-keypress reads are possible here at all — i.e. this platform's
|
|
62
|
+
keyboard module imported. False makes every interactive view degrade to its static
|
|
63
|
+
rendering, so a missing backend costs a feature, never a traceback.
|
|
64
|
+
|
|
65
|
+
This is the PLATFORM half of the question only. Whether stdout/stdin are actually a
|
|
66
|
+
terminal is universal, so callers check that themselves (see `browse._interactive_ok`)."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def key_source(self) -> ContextManager[KeyReader]:
|
|
70
|
+
"""Enter unbuffered key reading, yielding a `read()`; restore the terminal on the way out.
|
|
71
|
+
|
|
72
|
+
What varies: POSIX must put the tty into cbreak mode and put it back (a crash mid-browse
|
|
73
|
+
would otherwise leave the user's shell without echo), and parses raw escape-sequence
|
|
74
|
+
bytes. Windows reads the console API through `msvcrt`, which needs no mode change, gives
|
|
75
|
+
back already-decoded wide chars, and signals special keys with a scan-code prefix.
|
|
76
|
+
|
|
77
|
+
Only call this when `can_read_keys()` is True."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@contextmanager
|
|
82
|
+
def unsupported_key_source() -> Iterator[KeyReader]:
|
|
83
|
+
"""The `key_source` a platform without a keyboard backend returns — a valid context manager
|
|
84
|
+
whose reader answers "quit", so a caller that skipped `can_read_keys()` leaves its loop
|
|
85
|
+
immediately instead of spinning or blowing up."""
|
|
86
|
+
yield lambda: ["quit"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""macOS / Linux / BSD — keys via `termios` + `tty`.
|
|
2
|
+
|
|
3
|
+
A POSIX terminal delivers keys as raw bytes on stdin, with anything that isn't a plain character
|
|
4
|
+
encoded as an escape sequence (`\\x1b[A` = up). Reading them one at a time requires taking the tty
|
|
5
|
+
out of line-buffered mode, which also means putting it BACK — a browse that exits without
|
|
6
|
+
restoring leaves the user's shell with no echo.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
from .base import KeyReader
|
|
17
|
+
|
|
18
|
+
_SEQUENCES = {
|
|
19
|
+
b"\x1b[A": "up", b"\x1b[B": "down",
|
|
20
|
+
b"\x1bOA": "up", b"\x1bOB": "down", # application cursor mode
|
|
21
|
+
b"\x1b[5~": "pgup", b"\x1b[6~": "pgdn",
|
|
22
|
+
b"\x1b[H": "home", b"\x1b[F": "end",
|
|
23
|
+
b"\x1b[1~": "home", b"\x1b[4~": "end",
|
|
24
|
+
b"\x1b[D": "left", b"\x1bOD": "left", # ← also climbs back up a level
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# One read is sized to swallow a whole burst (a paste, a held key) rather than a single sequence.
|
|
28
|
+
_BURST_BYTES = 64
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split_keys(data: bytes) -> list[str]:
|
|
32
|
+
"""A raw input burst -> ordered key names ('up', 'enter', 'esc', …) and printable chars.
|
|
33
|
+
|
|
34
|
+
Terminals deliver fast typing, pastes, and escape sequences as multi-byte chunks; parsing
|
|
35
|
+
the whole burst (instead of assuming one read = one key) is what keeps a pasted filter
|
|
36
|
+
query intact and an arrow key from being mistaken for its component bytes."""
|
|
37
|
+
keys: list[str] = []
|
|
38
|
+
i = 0
|
|
39
|
+
while i < len(data):
|
|
40
|
+
if data[i:i + 1] == b"\x1b":
|
|
41
|
+
for seq, name in _SEQUENCES.items():
|
|
42
|
+
if data.startswith(seq, i):
|
|
43
|
+
keys.append(name)
|
|
44
|
+
i += len(seq)
|
|
45
|
+
break
|
|
46
|
+
else:
|
|
47
|
+
if data[i + 1:i + 2] in (b"[", b"O"):
|
|
48
|
+
# An escape sequence we don't drive anything with: swallow through its
|
|
49
|
+
# final byte (0x40–0x7e) so its payload doesn't leak in as typed text.
|
|
50
|
+
j = i + 2
|
|
51
|
+
while j < len(data) and not 0x40 <= data[j] <= 0x7E:
|
|
52
|
+
j += 1
|
|
53
|
+
i = j + 1
|
|
54
|
+
else:
|
|
55
|
+
i += 1 # bare Escape
|
|
56
|
+
keys.append("esc")
|
|
57
|
+
continue
|
|
58
|
+
j = data.find(b"\x1b", i)
|
|
59
|
+
run = data[i:] if j == -1 else data[i:j]
|
|
60
|
+
i = len(data) if j == -1 else j
|
|
61
|
+
for ch in run.decode(errors="ignore"):
|
|
62
|
+
if ch in ("\r", "\n"):
|
|
63
|
+
keys.append("enter")
|
|
64
|
+
elif ch in ("\x7f", "\x08"):
|
|
65
|
+
keys.append("backspace")
|
|
66
|
+
elif ch == "\x03":
|
|
67
|
+
keys.append("quit") # Ctrl-C when ISIG doesn't get there first
|
|
68
|
+
elif ch.isprintable():
|
|
69
|
+
keys.append(ch)
|
|
70
|
+
return keys
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PosixPlatform:
|
|
74
|
+
"""`Platform` for anything with a termios tty."""
|
|
75
|
+
|
|
76
|
+
name = "posix"
|
|
77
|
+
|
|
78
|
+
def can_read_keys(self) -> bool:
|
|
79
|
+
try:
|
|
80
|
+
import termios # noqa: F401
|
|
81
|
+
import tty # noqa: F401
|
|
82
|
+
except ImportError:
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
@contextmanager
|
|
87
|
+
def key_source(self) -> Iterator[KeyReader]:
|
|
88
|
+
import termios
|
|
89
|
+
import tty
|
|
90
|
+
|
|
91
|
+
fd = sys.stdin.fileno()
|
|
92
|
+
saved = termios.tcgetattr(fd)
|
|
93
|
+
try:
|
|
94
|
+
tty.setcbreak(fd)
|
|
95
|
+
yield lambda: split_keys(os.read(fd, _BURST_BYTES))
|
|
96
|
+
finally:
|
|
97
|
+
# TCSADRAIN, not TCSANOW: let queued output flush before the mode flips back, so a
|
|
98
|
+
# half-drawn frame doesn't survive into the restored shell.
|
|
99
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Windows — keys via `msvcrt`.
|
|
2
|
+
|
|
3
|
+
The Windows console has no termios and sends no escape sequences. `msvcrt.getwch()` reads the
|
|
4
|
+
console directly, one already-decoded wide character at a time, so there is no terminal mode to
|
|
5
|
+
set or restore and no bytes to decode. Special keys arrive as two reads instead: a `\\x00` or
|
|
6
|
+
`\\xe0` prefix, then a scan code identifying the key.
|
|
7
|
+
|
|
8
|
+
Two consequences worth knowing, both handled here:
|
|
9
|
+
- Ctrl-C comes back as ordinary data (`\\x03`), not a `KeyboardInterrupt`, so it is mapped to
|
|
10
|
+
"quit" the way POSIX cbreak mode does.
|
|
11
|
+
- Backspace is `\\x08` (BS), where a POSIX terminal usually sends `\\x7f` (DEL).
|
|
12
|
+
|
|
13
|
+
Note this covers the real Windows console and Windows Terminal. Under mintty (Git Bash), Python's
|
|
14
|
+
stdin is a pipe rather than a console handle, so `sys.stdin.isatty()` is False and interactive
|
|
15
|
+
views fall back to their static rendering — correct, if not ideal.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from typing import Iterator, Sequence
|
|
22
|
+
|
|
23
|
+
from .base import KeyReader
|
|
24
|
+
|
|
25
|
+
# The scan codes the console reports after a `\x00`/`\xe0` prefix — its stand-in for the escape
|
|
26
|
+
# sequences a POSIX terminal sends. Same key names as `posix._SEQUENCES`.
|
|
27
|
+
_SPECIAL = {
|
|
28
|
+
"H": "up", "P": "down",
|
|
29
|
+
"I": "pgup", "Q": "pgdn",
|
|
30
|
+
"G": "home", "O": "end",
|
|
31
|
+
"K": "left",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_PREFIXES = ("\x00", "\xe0")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_win_keys(chars: Sequence[str]) -> list[str]:
|
|
38
|
+
"""A burst of Windows console wide chars -> the same key names `posix.split_keys` produces.
|
|
39
|
+
|
|
40
|
+
A prefix at the very end of a burst (its scan code not read yet) and an unrecognized scan code
|
|
41
|
+
are both dropped rather than leaking in as typed text — an unmapped F-key must not end up in
|
|
42
|
+
a filter query."""
|
|
43
|
+
keys: list[str] = []
|
|
44
|
+
i = 0
|
|
45
|
+
while i < len(chars):
|
|
46
|
+
ch = chars[i]
|
|
47
|
+
if ch in _PREFIXES:
|
|
48
|
+
name = _SPECIAL.get(chars[i + 1]) if i + 1 < len(chars) else None
|
|
49
|
+
if name:
|
|
50
|
+
keys.append(name)
|
|
51
|
+
i += 2
|
|
52
|
+
continue
|
|
53
|
+
if ch in ("\r", "\n"):
|
|
54
|
+
keys.append("enter")
|
|
55
|
+
elif ch in ("\x7f", "\x08"):
|
|
56
|
+
keys.append("backspace")
|
|
57
|
+
elif ch == "\x1b":
|
|
58
|
+
keys.append("esc")
|
|
59
|
+
elif ch == "\x03":
|
|
60
|
+
keys.append("quit")
|
|
61
|
+
elif ch.isprintable():
|
|
62
|
+
keys.append(ch)
|
|
63
|
+
i += 1
|
|
64
|
+
return keys
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WindowsPlatform:
|
|
68
|
+
"""`Platform` for the Windows console."""
|
|
69
|
+
|
|
70
|
+
name = "windows"
|
|
71
|
+
|
|
72
|
+
def can_read_keys(self) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
import msvcrt # noqa: F401
|
|
75
|
+
except ImportError:
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
@contextmanager
|
|
80
|
+
def key_source(self) -> Iterator[KeyReader]:
|
|
81
|
+
import msvcrt
|
|
82
|
+
|
|
83
|
+
def read() -> list[str]:
|
|
84
|
+
# Block for one key, then drain what's already buffered — the same burst semantics as
|
|
85
|
+
# a single `os.read` on POSIX, so pastes and held keys arrive together.
|
|
86
|
+
chars = [msvcrt.getwch()]
|
|
87
|
+
while msvcrt.kbhit():
|
|
88
|
+
chars.append(msvcrt.getwch())
|
|
89
|
+
return split_win_keys(chars)
|
|
90
|
+
|
|
91
|
+
yield read # nothing to restore: msvcrt never changed the console's mode
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|