slab-cli 0.8.0__tar.gz → 0.10.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.8.0 → slab_cli-0.10.0}/PKG-INFO +1 -1
  2. {slab_cli-0.8.0 → slab_cli-0.10.0}/pyproject.toml +1 -1
  3. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/browse.py +25 -87
  4. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/breaks.py +8 -3
  5. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/display.py +21 -9
  6. slab_cli-0.10.0/src/slab_cli/platforms/__init__.py +70 -0
  7. slab_cli-0.10.0/src/slab_cli/platforms/base.py +86 -0
  8. slab_cli-0.10.0/src/slab_cli/platforms/posix.py +99 -0
  9. slab_cli-0.10.0/src/slab_cli/platforms/windows.py +91 -0
  10. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/prompts.py +11 -2
  11. {slab_cli-0.8.0 → slab_cli-0.10.0}/README.md +0 -0
  12. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/__init__.py +0 -0
  13. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/banner.py +0 -0
  14. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/client.py +0 -0
  15. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/__init__.py +0 -0
  16. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/catalog.py +0 -0
  17. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/collection.py +0 -0
  18. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/custom_sets.py +0 -0
  19. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/export.py +0 -0
  20. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/lots.py +0 -0
  21. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/pricing.py +0 -0
  22. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/registry.py +0 -0
  23. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/setup.py +0 -0
  24. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/commands/update.py +0 -0
  25. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/config.py +0 -0
  26. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/context.py +0 -0
  27. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/flags.py +0 -0
  28. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/help.py +0 -0
  29. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/main.py +0 -0
  30. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/paging.py +0 -0
  31. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/picker.py +0 -0
  32. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.8.0 → slab_cli-0.10.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.8.0 → slab_cli-0.10.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.8.0
3
+ Version: 0.10.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.8.0"
3
+ version = "0.10.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
@@ -6,7 +6,6 @@ from __future__ import annotations
6
6
 
7
7
  from slab_schemas.cards import SetSearchQuery
8
8
  from slab_schemas.collection import BreakCreate, BreakOut, BreakSearchQuery, CollectionSearchQuery
9
- from slab_schemas.enums import BreakType
10
9
 
11
10
  from ..client import SlabClient
12
11
  from ..context import ctx
@@ -59,7 +58,13 @@ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | N
59
58
  if set_choice is None:
60
59
  return None
61
60
 
62
- break_type = select_break_type()
61
+ # Offer the formats this set actually comes in (its sealed SKUs); a set with no SKUs — or a
62
+ # failed lookup — falls back to the generic picker. Never let the extra lookup kill the flow.
63
+ try:
64
+ formats = [p.format.value for p in client.get_set_sealed(set_choice.uuid)]
65
+ except Exception:
66
+ formats = []
67
+ break_type = select_break_type(formats)
63
68
  break_date = ask_date("When did you open it?")
64
69
  total_cost = ask_decimal("Total cost:")
65
70
  # Offer the collector's previously-used sources for arrow-key reuse (falls back to a plain
@@ -70,7 +75,7 @@ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | N
70
75
  collector,
71
76
  BreakCreate(
72
77
  set_uuid=set_choice.uuid,
73
- break_type=BreakType(break_type),
78
+ break_type=break_type,
74
79
  break_date=break_date,
75
80
  total_cost=total_cost,
76
81
  source=source,
@@ -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
@@ -261,7 +261,7 @@ BREAK_COLUMNS = [
261
261
  def break_cells(b) -> list[str]:
262
262
  """One break as its BREAK_COLUMNS cells."""
263
263
  cpc = money(b.cost_per_card) if b.cost_per_card is not None else "—"
264
- return [b.set_name or "", b.break_type, money(b.total_cost), str(b.copy_count), cpc,
264
+ return [b.set_name or "", _format_label(b.break_type), money(b.total_cost), str(b.copy_count), cpc,
265
265
  str(b.break_date or "—"), b.source or "—", b.uuid[:8]]
266
266
 
267
267
 
@@ -314,7 +314,7 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
314
314
  console.print()
315
315
  t = Text()
316
316
  t.append(brk.set_name or "Break", style="slab.value")
317
- t.append(f" — {brk.break_type} · {brk.break_date or '—'}"
317
+ t.append(f" — {brk.break_type.replace('_', ' ')} · {brk.break_date or '—'}"
318
318
  + (f" · {brk.source}" if brk.source else ""), style="slab.dim")
319
319
  console.print(t)
320
320
 
@@ -555,7 +555,8 @@ def render_dashboard(d: DashboardStats) -> None:
555
555
  ("$/card", {"justify": "right", "style": "slab.dim"}),
556
556
  ])
557
557
  for b in d.break_stats:
558
- table.add_row(str(b.break_date or "—"), b.set_name or "—", b.break_type or "—",
558
+ table.add_row(str(b.break_date or "—"), b.set_name or "—",
559
+ _format_label(b.break_type) if b.break_type else "—",
559
560
  money(b.total_cost), str(b.cards), money(b.cost_per_card))
560
561
  breaks = titled("Breaks", table)
561
562
 
@@ -908,16 +909,27 @@ def _format_label(fmt: str) -> str:
908
909
  return fmt.replace("_", " ").title()
909
910
 
910
911
 
912
+ _CASE_FORMATS = {SealedFormat.hobby_case, SealedFormat.retail_case}
913
+
914
+
911
915
  def _sealed_config(p: SealedProductOut) -> str:
912
- """A compact config string for a sealed SKU: '12 packs × 8 cards' / '8 boxes'."""
916
+ """A compact config string for a sealed SKU — what you actually get when you open THIS SKU:
917
+ '12 boxes × 8 packs × 12 cards' for a case, '4 packs × 12 cards' for a box, '24 cards' for a
918
+ single-pack SKU. `boxes_per_case` describes the CASE, not the box, so on a non-case SKU it
919
+ rides along as a '· 20/case' suffix — never as a factor (a blaster box holds 4 packs, not the
920
+ 20 blasters that share its case)."""
913
921
  parts = []
914
- if p.boxes_per_case:
922
+ if p.format in _CASE_FORMATS and p.boxes_per_case:
915
923
  parts.append(f"{p.boxes_per_case} boxes")
916
- if p.packs_per_box:
924
+ # A 1-pack SKU (hanger, starter) is just its pack — "1 packs × 24 cards" reads as noise.
925
+ if p.packs_per_box and p.packs_per_box > 1:
917
926
  parts.append(f"{p.packs_per_box} packs")
918
927
  if p.cards_per_pack:
919
928
  parts.append(f"{p.cards_per_pack} cards")
920
- return " × ".join(parts) if parts else "—"
929
+ config = " × ".join(parts) if parts else "—"
930
+ if p.format not in _CASE_FORMATS and p.boxes_per_case:
931
+ config = f"{config} · {p.boxes_per_case}/case"
932
+ return config
921
933
 
922
934
 
923
935
  def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCards) -> None:
@@ -1062,7 +1074,7 @@ def _render_most_valuable_break(mvb: dict | None) -> None:
1062
1074
  date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
1063
1075
  title = Text()
1064
1076
  title.append(mvb["set_name"], style="slab.value")
1065
- title.append(f" {mvb['break_type']}{date_str}", style="slab.dim")
1077
+ title.append(f" {mvb['break_type'].replace('_', ' ')}{date_str}", style="slab.dim")
1066
1078
 
1067
1079
  g = mvb["gain"]
1068
1080
  verdict = "paid off" if g >= 0 else "underwater"
@@ -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
@@ -62,7 +62,7 @@ def _break_label(b: BreakOut) -> str:
62
62
  when = b.break_date.isoformat() if b.break_date else "—"
63
63
  src = f" · {b.source}" if b.source else ""
64
64
  cpc = f" @ ${b.cost_per_card:.2f}/card" if b.cost_per_card else ""
65
- return (f"{when} · {b.set_name} · {b.break_type} · ${b.total_cost:.2f}{src} · "
65
+ return (f"{when} · {b.set_name} · {b.break_type.replace('_', ' ')} · ${b.total_cost:.2f}{src} · "
66
66
  f"{b.copy_count} cards{cpc} [{b.uuid[:8]}]")
67
67
 
68
68
 
@@ -388,7 +388,16 @@ def select_acquisition_type() -> str:
388
388
  return inquirer.select(message="How did you acquire this card?", choices=choices).execute()
389
389
 
390
390
 
391
- def select_break_type() -> str:
391
+ def select_break_type(formats: list[str] | None = None) -> str:
392
+ """What was opened. When the set's sealed SKUs are known, offer those formats first (the
393
+ things actually purchasable for THIS set) with an Other… escape to the generic list; a set
394
+ with no SKUs goes straight to the generic pack/box/case/other."""
395
+ if formats:
396
+ choices = [Choice(value=f, name=f.replace("_", " ").title()) for f in formats]
397
+ choices.append(Choice(value=None, name="Other…"))
398
+ picked = inquirer.select(message="What did you open?", choices=choices).execute()
399
+ if picked is not None:
400
+ return picked
392
401
  choices = [Choice(value=t.value, name=_enum_label(t, _BREAK_TYPE_LABELS)) for t in BreakType]
393
402
  return inquirer.select(message="What did you open?", choices=choices).execute()
394
403
 
File without changes
File without changes
File without changes