slab-cli 0.7.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.
Files changed (34) hide show
  1. {slab_cli-0.7.0 → slab_cli-0.9.0}/PKG-INFO +1 -1
  2. {slab_cli-0.7.0 → slab_cli-0.9.0}/pyproject.toml +1 -1
  3. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/browse.py +25 -87
  4. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/collection.py +3 -1
  5. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/display.py +16 -5
  6. slab_cli-0.9.0/src/slab_cli/platforms/__init__.py +70 -0
  7. slab_cli-0.9.0/src/slab_cli/platforms/base.py +86 -0
  8. slab_cli-0.9.0/src/slab_cli/platforms/posix.py +99 -0
  9. slab_cli-0.9.0/src/slab_cli/platforms/windows.py +91 -0
  10. {slab_cli-0.7.0 → slab_cli-0.9.0}/README.md +0 -0
  11. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/__init__.py +0 -0
  12. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/banner.py +0 -0
  13. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/client.py +0 -0
  14. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/__init__.py +0 -0
  15. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/breaks.py +0 -0
  16. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/catalog.py +0 -0
  17. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/custom_sets.py +0 -0
  18. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/export.py +0 -0
  19. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/lots.py +0 -0
  20. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/pricing.py +0 -0
  21. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/registry.py +0 -0
  22. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/setup.py +0 -0
  23. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/commands/update.py +0 -0
  24. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/config.py +0 -0
  25. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/context.py +0 -0
  26. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/flags.py +0 -0
  27. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/help.py +0 -0
  28. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/main.py +0 -0
  29. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/paging.py +0 -0
  30. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/picker.py +0 -0
  31. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/prompts.py +0 -0
  32. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.7.0 → slab_cli-0.9.0}/src/slab_cli/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
6
  Requires-Dist: slab-schemas>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.7.0"
3
+ version = "0.9.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -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, a platform without termios) it
10
- degrades to the static table, so `slab ... | grep` keeps working unchanged.
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 on a termios platform."""
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
- try:
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
- fd: int,
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 = _read_keys(fd)
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(fd: int, lines: list[Text], *, back_hint: str) -> None:
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 = _read_keys(fd)
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
- tty.setcbreak(fd)
368
- while True:
369
- item, sel = _run_list(fd, columns, rows, title=title, note=note,
370
- hint=hint, start=sel)
371
- if item is None:
372
- break
373
- if detail is None:
374
- picked = item
375
- break
376
- _run_pager(fd, _capture_lines(lambda: detail(item)), back_hint="back to list")
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
@@ -142,7 +142,9 @@ def _collect_grade_details() -> tuple[str | None, str | None, str | None, str |
142
142
  grading_company = grade = cert_number = None
143
143
  if confirm("Is it professionally graded?", default=False):
144
144
  grading_company = select_grading_company()
145
- grade = ask_text_optional("Pro grade (e.g. 10, 9.5, Authentic — enter to skip):")
145
+ # Required with the company: the (company, grade) pair is the copy's price key — the API
146
+ # rejects one without the other (a half-entered slab would price as RAW).
147
+ grade = ask_text("Pro grade (e.g. 10, 9.5, Authentic):")
146
148
  cert_number = ask_text_optional("Cert number (enter to skip):")
147
149
 
148
150
  return self_grade, grading_company, grade, cert_number
@@ -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: '12 packs × 8 cards' / '8 boxes'."""
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
- if p.packs_per_box:
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
- return " × ".join(parts) if parts else "—"
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