slab-cli 0.21.0__tar.gz → 0.22.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 (36) hide show
  1. {slab_cli-0.21.0 → slab_cli-0.22.0}/PKG-INFO +1 -1
  2. {slab_cli-0.21.0 → slab_cli-0.22.0}/pyproject.toml +1 -1
  3. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/collection.py +46 -19
  4. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/custom_sets.py +8 -16
  5. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/pricing.py +9 -15
  6. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/help.py +4 -0
  7. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/main.py +4 -1
  8. slab_cli-0.22.0/src/slab_cli/nav.py +112 -0
  9. slab_cli-0.22.0/src/slab_cli/picker.py +178 -0
  10. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/prompts.py +2 -1
  11. slab_cli-0.21.0/src/slab_cli/picker.py +0 -127
  12. {slab_cli-0.21.0 → slab_cli-0.22.0}/README.md +0 -0
  13. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/__init__.py +0 -0
  14. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/banner.py +0 -0
  15. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/browse.py +0 -0
  16. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/client.py +0 -0
  17. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/__init__.py +0 -0
  18. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/breaks.py +0 -0
  19. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/catalog.py +0 -0
  20. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/export.py +0 -0
  21. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/lots.py +0 -0
  22. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/registry.py +0 -0
  23. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/setup.py +0 -0
  24. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/update.py +0 -0
  25. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/config.py +0 -0
  26. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/context.py +0 -0
  27. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/display.py +0 -0
  28. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/flags.py +0 -0
  29. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/paging.py +0 -0
  30. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/__init__.py +0 -0
  31. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/base.py +0 -0
  32. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/posix.py +0 -0
  33. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/windows.py +0 -0
  34. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/sources.py +0 -0
  35. {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/theme.py +0 -0
  36. {slab_cli-0.21.0 → slab_cli-0.22.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.21.0
3
+ Version: 0.22.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.35.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.21.0"
3
+ version = "0.22.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"}]
@@ -20,6 +20,7 @@ from slab_schemas.enums import AcquisitionType, CopyStatus, CostCategory, grade_
20
20
  from ..client import ApiError, SlabClient
21
21
  from ..context import ctx
22
22
  from ..flags import COLLECTION_SEARCH_FLAGS, parse_flags
23
+ from ..nav import BackStep, flow
23
24
  from ..paging import fetch_all_pages
24
25
  from ..picker import pick_card
25
26
  from ..display import (
@@ -246,31 +247,52 @@ def _submit_and_report(session: AddSession, card: CardOut, acq_cost: Decimal | N
246
247
  def _add_single_card(session: AddSession, card: CardOut | None = None) -> bool:
247
248
  """Run one add-card iteration. Returns True if the card was added, False if skipped/cancelled.
248
249
 
249
- When `card` is given (e.g. from a chase-set missing-card walk) the catalog search is skipped and
250
- that exact printing is used — you go straight to the acquisition details."""
250
+ The per-card sequence is a `nav.flow` — Esc at any detail step (cost, grades, serial, the
251
+ review confirm) goes back one step, and Esc past the first step skips this card. The picker
252
+ inside the first step is its own flow, so Esc also walks printing → card → set → player →
253
+ search text before falling out here.
254
+
255
+ When `card` is given (e.g. from a chase-set missing-card walk) the catalog search is skipped
256
+ and that exact printing is used — you go straight to the acquisition details."""
251
257
  # Decide how these cards were acquired (and the break/box) up front, so the search is scoped.
258
+ # Deliberately OUTSIDE the flow: it's batch-level state, and re-answering it per Esc would be
259
+ # worse than Esc simply skipping the card.
252
260
  _ensure_acquisition_context(session)
253
261
 
254
- if card is None:
255
- card = _search_and_select_card(session)
256
- if card is None:
257
- return False
262
+ def s_pick(st: dict) -> None:
263
+ picked = card if card is not None else _search_and_select_card(session)
264
+ if picked is None:
265
+ raise BackStep()
266
+ st["card"] = picked
267
+ console.print(f"\n [bold]{format_card_summary(picked)}[/bold]\n")
258
268
 
259
- console.print(f"\n [bold]{format_card_summary(card)}[/bold]\n")
269
+ def s_cost(st: dict) -> None:
270
+ st["acq_cost"] = _per_card_cost(session)
260
271
 
261
- acq_cost = _per_card_cost(session)
262
- break_uuid = session.break_uuid
263
- self_grade, grading_company, grade, cert_number = _collect_grade_details()
264
- serial = _prompt_serial(card)
272
+ def s_grades(st: dict) -> None:
273
+ st["grades"] = _collect_grade_details()
265
274
 
266
- _show_review(card, session, acq_cost, break_uuid, self_grade, grading_company, grade, serial)
275
+ def s_serial(st: dict) -> None:
276
+ st["serial"] = _prompt_serial(st["card"])
277
+
278
+ def s_review(st: dict) -> None:
279
+ self_grade, grading_company, grade, _cert = st["grades"]
280
+ _show_review(st["card"], session, st["acq_cost"], session.break_uuid,
281
+ self_grade, grading_company, grade, st["serial"])
282
+ st["confirmed"] = confirm("Add this card?", default=True)
283
+
284
+ try:
285
+ st = flow([s_pick, s_cost, s_grades, s_serial, s_review])
286
+ except BackStep:
287
+ return False
267
288
 
268
- if not confirm("Add this card?", default=True):
289
+ if not st["confirmed"]:
269
290
  console.print("[dim]Skipped.[/dim]\n")
270
291
  return False
271
292
 
272
- _submit_and_report(session, card, acq_cost, break_uuid, self_grade,
273
- grading_company, grade, cert_number, serial)
293
+ self_grade, grading_company, grade, cert_number = st["grades"]
294
+ _submit_and_report(session, st["card"], st["acq_cost"], session.break_uuid, self_grade,
295
+ grading_company, grade, cert_number, st["serial"])
274
296
  return True
275
297
 
276
298
 
@@ -278,11 +300,16 @@ def cmd_add(args: list[str]) -> None:
278
300
  """Add card(s) to your collection — interactive with batch support."""
279
301
  session = AddSession(client=ctx.client, collector=ctx.collector)
280
302
 
281
- while True:
282
- _add_single_card(session)
303
+ # Ctrl-C (or Esc at the "another?" prompt) ends the batch, not the receipt: fall through to
304
+ # the Done line so the user still sees what landed.
305
+ try:
306
+ while True:
307
+ _add_single_card(session)
283
308
 
284
- if not confirm("Add another card?", default=True):
285
- break
309
+ if not confirm("Add another card?", default=True):
310
+ break
311
+ except (KeyboardInterrupt, BackStep):
312
+ console.print()
286
313
 
287
314
  if session.cards_added > 0:
288
315
  console.print(f"[dim]Done — {session.cards_added} card(s) added.[/dim]")
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from InquirerPy import inquirer
6
5
  from InquirerPy.base.control import Choice
7
6
 
7
+ from ..nav import inquirer
8
+
8
9
  from types import SimpleNamespace
9
10
 
10
11
  from slab_schemas.custom_sets import (
@@ -25,7 +26,7 @@ from ..display import (
25
26
  print_custom_set_detail,
26
27
  )
27
28
  from ..paging import fetch_all_pages
28
- from ..picker import narrow_to_set, pick_printing_of, search_and_pick_subject
29
+ from ..picker import pick_card_with_subject, search_and_pick_subject
29
30
  from ..prompts import (
30
31
  ask_int_optional,
31
32
  ask_text,
@@ -35,7 +36,6 @@ from ..prompts import (
35
36
  select_dynamic_match,
36
37
  select_entry_kind,
37
38
  select_match_mode,
38
- select_printing,
39
39
  select_visibility,
40
40
  )
41
41
 
@@ -94,21 +94,13 @@ def _pick_from_sets(sets: list[CustomSetOut], message: str) -> CustomSetOut | No
94
94
 
95
95
  def _search_and_select_card_for_set() -> tuple[str | None, str | None]:
96
96
  """Search the catalog, pick a player, narrow to a set (or all), pick the card, then the
97
- exact printing off its rainbow. Returns (card_uuid, subject_name) or (None, None). Uses the
98
- shared subject-first picker steps; the only chase-specific part is the summary print and the
99
- (uuid, subject) return shape the batch loop wants."""
100
- picked = search_and_pick_subject(None)
97
+ exact printing off its rainbow — the shared picker flow (Esc steps back, Ctrl-C quits).
98
+ Returns (card_uuid, subject_name) or (None, None); the only chase-specific part is the
99
+ summary print and the (uuid, subject) return shape the batch loop wants."""
100
+ picked = pick_card_with_subject(None)
101
101
  if picked is None:
102
102
  return None, None
103
- cards, subject = picked
104
- cards, show_set = narrow_to_set(cards)
105
-
106
- card = select_printing(cards, subject, show_set=show_set)
107
- if card is None:
108
- return None, None
109
- card = pick_printing_of(card)
110
- if card is None:
111
- return None, None
103
+ card, subject = picked
112
104
 
113
105
  console.print(f"\n [bold]{format_card_summary(card)}[/bold]\n")
114
106
  return card.uuid, subject
@@ -20,7 +20,7 @@ from ..display import (
20
20
  render_set_market,
21
21
  )
22
22
  from ..paging import fetch_all_pages
23
- from ..picker import narrow_to_set, pick_card, pick_printing_of, search_and_pick_subject
23
+ from ..picker import pick_card, pick_card_with_subject
24
24
  from ..prompts import (
25
25
  select_priced_card,
26
26
  select_set,
@@ -38,25 +38,17 @@ def _optional_collector() -> str | None:
38
38
 
39
39
  def _pick_card(args: list[str]) -> CardOut | None:
40
40
  """Price/comps selection: player → set (or all) → a card from the FMV-tagged list → the
41
- exact printing off its rainbow. Shares the subject-first steps with every other picker; the
41
+ exact printing off its rainbow. The shared picker flow (Esc steps back, Ctrl-C quits); the
42
42
  one pricing-specific part is the enriched card list (each slot row carries its headline FMV +
43
43
  owned tag) in place of the plain card pick. Returns the chosen printing, or None if the user
44
44
  backed out or nothing matched."""
45
- picked = search_and_pick_subject(
45
+ picked = pick_card_with_subject(
46
46
  " ".join(args) if args else None,
47
47
  collector=_optional_collector(),
48
48
  include_market=True,
49
+ card_step=select_priced_card,
49
50
  )
50
- if picked is None:
51
- return None
52
- cards, subject = picked
53
-
54
- # Narrow to a set (auto-skips when the player only appears in one set).
55
- cards, show_set = narrow_to_set(cards)
56
- card = select_priced_card(cards, subject, show_set=show_set)
57
- if card is None:
58
- return None
59
- return pick_printing_of(card)
51
+ return picked[0] if picked else None
60
52
 
61
53
 
62
54
  def cmd_price(args: list[str]) -> None:
@@ -348,9 +340,10 @@ def cmd_history(args: list[str]) -> None:
348
340
  slab card history bedard
349
341
  slab card history bedard --days 90 --daily
350
342
  slab card history mcdavid --monthly"""
351
- from InquirerPy import inquirer
352
343
  from InquirerPy.base.control import Choice
353
344
 
345
+ from ..nav import inquirer
346
+
354
347
  query, interval, days = _history_options(args)
355
348
  card = pick_card(" ".join(query) if query else None)
356
349
  if card is None:
@@ -389,9 +382,10 @@ def cmd_set_history(args: list[str]) -> None:
389
382
  Examples:
390
383
  slab set history allure
391
384
  slab set history "series 1" --days 180 --weekly"""
392
- from InquirerPy import inquirer
393
385
  from InquirerPy.base.control import Choice
394
386
 
387
+ from ..nav import inquirer
388
+
395
389
  query, interval, days = _history_options(args)
396
390
 
397
391
  sets = _fetch_all_sets()
@@ -101,6 +101,10 @@ def render_root_help() -> None:
101
101
  console.print(heading(group.name, group.blurb))
102
102
  console.print(_group_table(group))
103
103
  console.print()
104
+ console.print(Text.from_markup(
105
+ " [slab.label]Keys[/] [slab.dim]In any interactive flow: "
106
+ "Esc goes back a step · Ctrl-C cancels the command[/]"
107
+ ))
104
108
  console.print(Text.from_markup(f" [slab.label]Config[/] [slab.dim]{_CONFIG}[/]"))
105
109
  # The installed version, right where people look for it. Whether it's the NEWEST version is the
106
110
  # update nudge's job (printed after any command from a cached daily check) — not restated here.
@@ -18,6 +18,7 @@ from .context import MissingCollector
18
18
  from .display import console
19
19
  from .flags import FlagError
20
20
  from .help import render_command_help, render_group_help, render_root_help
21
+ from .nav import BackStep
21
22
 
22
23
  _HELP_FLAGS = ("-h", "--help", "help")
23
24
  _VERSION_FLAGS = ("-V", "--version")
@@ -95,7 +96,9 @@ def _dispatch(args: list[str]) -> int:
95
96
  *any* path — success, cancel, or error — exactly once."""
96
97
  try:
97
98
  return _run(args)
98
- except KeyboardInterrupt:
99
+ except (KeyboardInterrupt, BackStep):
100
+ # The two standard leave-the-flow actions (nav.py): Ctrl-C anywhere, or Esc backing out
101
+ # past a flow's first step. Both end the command the same clean way.
99
102
  console.print("\n[dim]Cancelled.[/dim]")
100
103
  return 0
101
104
  except FlagError as e:
@@ -0,0 +1,112 @@
1
+ """The CLI's standard step navigation — ONE contract for every interactive flow.
2
+
3
+ Two actions work at EVERY prompt, the same keys the browse table already uses:
4
+
5
+ * **Esc — back one step.** Raises `BackStep`. Inside a `flow(...)` it returns to the previous
6
+ step that actually asked something (steps that auto-resolved — a lone match, a derived value —
7
+ are stepped over, so Esc never bounces off an invisible step). At the first step, or in a
8
+ prompt that isn't part of a flow, it bubbles up: the command cancels cleanly ("Cancelled.",
9
+ handled in `main._dispatch`) or an enclosing loop treats it as "skip this one".
10
+ * **Ctrl-C — quit.** InquirerPy raises KeyboardInterrupt, which every layer already treats as
11
+ "leave the whole command" (dispatch prints "Cancelled."; batch loops may catch it to print
12
+ their summary first). Nothing here intercepts it — quit must always win.
13
+
14
+ `q` is deliberately NOT a prompt-level key: in fuzzy filters and text inputs the letter q is
15
+ input. The browse table (browse.py) keeps its q-to-quit because its lists never type; Esc/Ctrl-C
16
+ mean the same thing there, so the two systems feel identical.
17
+
18
+ Mechanics: `inquirer` below is a drop-in proxy for `InquirerPy.inquirer` whose prompts execute
19
+ through the nav layer — import it from here instead of InquirerPy and every prompt gains the
20
+ contract with no per-call-site changes. `flow(steps)` is the step runner commands use to make
21
+ Esc rewind rather than cancel.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Callable
27
+
28
+ from InquirerPy import inquirer as _inquirer
29
+
30
+
31
+ class BackStep(Exception):
32
+ """The user pressed Esc: go back one step. `flow` catches it to rewind; uncaught, it means
33
+ 'back out of this whole interaction' and dispatch turns it into a clean cancel."""
34
+
35
+
36
+ # Counts prompts actually SHOWN (not auto-resolved). `flow` reads it around each step to learn
37
+ # which steps interacted — that's what makes Esc skip over auto-resolved steps without every
38
+ # step having to declare itself.
39
+ _prompts_shown = 0
40
+
41
+
42
+ def prompt_shown() -> None:
43
+ """Record that a prompt was displayed. Called by the executor below; test doubles and
44
+ non-InquirerPy prompts (if any grow) call it to participate in flow()'s back-tracking."""
45
+ global _prompts_shown
46
+ _prompts_shown += 1
47
+
48
+
49
+ def _nav_execute(prompt) -> Any:
50
+ """Execute an InquirerPy prompt with the standard navigation attached: Esc raises BackStep,
51
+ Ctrl-C propagates as KeyboardInterrupt (InquirerPy's default)."""
52
+ prompt_shown()
53
+ backed = False
54
+
55
+ @prompt.register_kb("escape")
56
+ def _(event): # noqa: ANN001 - prompt_toolkit event
57
+ nonlocal backed
58
+ backed = True
59
+ event.app.exit(result=None)
60
+
61
+ result = prompt._nav_original_execute()
62
+ if backed:
63
+ raise BackStep()
64
+ return result
65
+
66
+
67
+ class _NavInquirer:
68
+ """`InquirerPy.inquirer`, with the nav contract attached to every prompt it builds."""
69
+
70
+ def __getattr__(self, name: str):
71
+ factory = getattr(_inquirer, name)
72
+
73
+ def build(*args, **kwargs):
74
+ prompt = factory(*args, **kwargs)
75
+ prompt._nav_original_execute = prompt.execute
76
+ prompt.execute = lambda: _nav_execute(prompt)
77
+ return prompt
78
+
79
+ return build
80
+
81
+
82
+ inquirer = _NavInquirer()
83
+
84
+
85
+ def flow(steps: list[Callable[[dict], None]], state: dict | None = None) -> dict:
86
+ """Run `steps` in order over a shared `state` dict, with Esc stepping backward.
87
+
88
+ Each step reads/writes `state` and prompts as it likes. When a step raises BackStep, the
89
+ flow resumes at the nearest EARLIER step that actually showed a prompt on its last run —
90
+ auto-resolved steps (nothing to re-answer) are skipped on the way back, then re-run on the
91
+ way forward so derived state stays fresh. BackStep at the first interactive step propagates:
92
+ the caller decides what backing out of the whole flow means (usually `return None`).
93
+ KeyboardInterrupt is never caught — quit always exits the flow. Returns `state`.
94
+ """
95
+ state = state if state is not None else {}
96
+ asked = [False] * len(steps)
97
+ i = 0
98
+ while i < len(steps):
99
+ before = _prompts_shown
100
+ try:
101
+ steps[i](state)
102
+ except BackStep:
103
+ j = i - 1
104
+ while j >= 0 and not asked[j]:
105
+ j -= 1
106
+ if j < 0:
107
+ raise
108
+ i = j
109
+ continue
110
+ asked[i] = _prompts_shown > before
111
+ i += 1
112
+ return state
@@ -0,0 +1,178 @@
1
+ """One subject-first card picker for the whole CLI.
2
+
3
+ Every command that resolves a human query to a specific catalog card (`card parallels`,
4
+ `card price`/`comps`, `card history`, `collection add`, `chase add`) does it the same way:
5
+ search a player, pick the player, narrow to a set (or all — auto-skipped when there's only
6
+ one), pick the CARD (one row per checklist slot), then pick the exact printing off that card's
7
+ rainbow (base is the default) — the same subject → set → card → parallel drill the browse table
8
+ gives search results. This module is that flow, once, built on `nav.flow` so the standard keys
9
+ work at every step: **Esc backs up one step** (printing → card → set → player → search text —
10
+ auto-resolved steps are stepped over) and **Ctrl-C quits the command**. Callers that need a
11
+ different card list (pricing's FMV-tagged rows) pass `card_step`; ones that need the subject
12
+ too use `pick_card_with_subject`.
13
+
14
+ The search is COLLAPSED and PAGED: one row per slot (`collapse_parallels=True`), fetched past
15
+ the server's 200-per-request cap to completion. Both halves matter — a prolific player has
16
+ thousands of printings (McDavid: 1,500+), so a single uncollapsed page silently dropped most of
17
+ his sets and made catalogued cards unfindable here while `card search` showed them fine. The
18
+ rainbow is fetched per picked card instead, exactly like the break-fill walk.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Callable
24
+
25
+ from slab_schemas.cards import CardOut, CardSearchQuery
26
+
27
+ from .context import ctx
28
+ from .nav import BackStep, flow
29
+ from .paging import fetch_all_pages
30
+ from .prompts import (
31
+ ask_text,
32
+ select_printing,
33
+ select_printing_for_slot,
34
+ select_set_filter,
35
+ select_subject,
36
+ )
37
+ from .theme import console
38
+
39
+ # Slots fetched per search, not printings — a safety bound, far above any real player's card count.
40
+ _SLOT_CAP = 5000
41
+
42
+
43
+ def _search_slots(q: str, **search_kwargs) -> list[CardOut]:
44
+ """Fetch EVERY slot matching a subject query (collapsed, paged — see module docstring)."""
45
+ slots, _ = fetch_all_pages(
46
+ lambda limit, offset: ctx.client.search_cards(CardSearchQuery(
47
+ subject=q, collapse_parallels=True, limit=limit, offset=offset, **search_kwargs
48
+ )),
49
+ cap=_SLOT_CAP,
50
+ )
51
+ return slots
52
+
53
+
54
+ def _subject_steps(
55
+ query: str | None, prompt: str, not_found: str, search_kwargs: dict,
56
+ ) -> list[Callable[[dict], None]]:
57
+ """The shared first three steps of every picker flow: ask/take the query, fetch all slots,
58
+ pick the player. Leaves `q`, `slots`, `subject`, and `cards` (the subject's slots) in the
59
+ flow state. An empty search raises BackStep — back to the query prompt to retype (or out of
60
+ the flow, when the query came from argv and there's nothing to return to)."""
61
+
62
+ def s_query(st: dict) -> None:
63
+ st["q"] = query if query else ask_text(prompt)
64
+
65
+ def s_fetch(st: dict) -> None:
66
+ st["slots"] = _search_slots(st["q"], **search_kwargs)
67
+ if not st["slots"]:
68
+ console.print(f"[yellow]{not_found}[/yellow]")
69
+ raise BackStep()
70
+
71
+ def s_subject(st: dict) -> None:
72
+ subject = select_subject(st["slots"])
73
+ if subject is None:
74
+ raise BackStep()
75
+ st["subject"] = subject
76
+ st["cards"] = [c for c in st["slots"] if any(s.name == subject for s in c.subjects)]
77
+
78
+ return [s_query, s_fetch, s_subject]
79
+
80
+
81
+ def search_and_pick_subject(
82
+ query: str | None,
83
+ *,
84
+ prompt: str = "Search for a card (player):",
85
+ not_found: str = "No cards found.",
86
+ **search_kwargs,
87
+ ) -> tuple[list[CardOut], str] | None:
88
+ """The subject-first core alone: resolve a query to ``(that subject's card slots, subject)``
89
+ — for callers that stop at the player (chase player-slots). Returns ``None`` when the user
90
+ backs out of the whole thing; Esc inside steps back (player pick → search text)."""
91
+ try:
92
+ st = flow(_subject_steps(query, prompt, not_found, search_kwargs))
93
+ except BackStep:
94
+ return None
95
+ return st["cards"], st["subject"]
96
+
97
+
98
+ def narrow_to_set(cards: list[CardOut]) -> tuple[list[CardOut], bool]:
99
+ """The shared set step: offer to narrow a cross-set card list to one set — "All sets" keeps
100
+ everything, and the prompt auto-skips when the list is already a single set. Returns the
101
+ (possibly narrowed) cards plus whether they still span sets: the ``show_set`` every card
102
+ picker takes."""
103
+ slug = select_set_filter(cards) # auto-returns "" (all) when the player is in only one set
104
+ if slug:
105
+ cards = [c for c in cards if c.set_slug == slug]
106
+ return cards, len({c.set_slug for c in cards}) > 1
107
+
108
+
109
+ def pick_printing_of(card: CardOut) -> CardOut | None:
110
+ """The final parallel step: fetch the picked card's whole rainbow and choose the exact
111
+ printing — base is the default, so the common case is a single Enter, and a slot with no
112
+ parallels auto-selects. Works from any printing (`get_parallels` resolves the slot's base)."""
113
+ return select_printing_for_slot(ctx.client.get_parallels(card.uuid))
114
+
115
+
116
+ def pick_card_with_subject(
117
+ query: str | None,
118
+ *,
119
+ prompt: str = "Search for a card (player):",
120
+ not_found: str = "No cards found.",
121
+ narrow_set: bool = True,
122
+ printing: bool = True,
123
+ card_step: Callable[..., CardOut | None] = select_printing,
124
+ **search_kwargs,
125
+ ) -> tuple[CardOut, str] | None:
126
+ """The full picker flow — player → set → card → parallel — returning ``(card, subject)``,
127
+ or ``None`` if nothing matched or the user backed all the way out. Esc at any step returns
128
+ to the previous one (auto-resolved steps are skipped).
129
+
130
+ The set step is the default because a player who appears across many products shouldn't be
131
+ dumped into one undifferentiated list (and it auto-skips when everything is one set anyway).
132
+ Pass ``narrow_set=False`` only when the search is already scoped to a single release (e.g.
133
+ filling a break). ``printing=False`` stops at the card — for callers that resolve the whole
134
+ rainbow themselves (`card parallels`). ``card_step`` swaps the card list for a variant with
135
+ the same ``(cards, subject, show_set=...)`` signature (pricing's FMV-tagged list)."""
136
+
137
+ def s_set(st: dict) -> None:
138
+ if narrow_set:
139
+ st["in_set"], st["show_set"] = narrow_to_set(st["cards"])
140
+ else:
141
+ st["in_set"] = st["cards"]
142
+ st["show_set"] = len({c.set_slug for c in st["cards"]}) > 1
143
+
144
+ def s_card(st: dict) -> None:
145
+ card = card_step(st["in_set"], st["subject"], show_set=st["show_set"])
146
+ if card is None:
147
+ raise BackStep()
148
+ st["card"] = card
149
+
150
+ def s_printing(st: dict) -> None:
151
+ picked = pick_printing_of(st["card"]) if printing else st["card"]
152
+ if picked is None:
153
+ raise BackStep()
154
+ st["picked"] = picked
155
+
156
+ steps = _subject_steps(query, prompt, not_found, search_kwargs) + [s_set, s_card, s_printing]
157
+ try:
158
+ st = flow(steps)
159
+ except BackStep:
160
+ return None
161
+ return st["picked"], st["subject"]
162
+
163
+
164
+ def pick_card(
165
+ query: str | None,
166
+ *,
167
+ prompt: str = "Search for a card (player):",
168
+ not_found: str = "No cards found.",
169
+ narrow_set: bool = True,
170
+ printing: bool = True,
171
+ **search_kwargs,
172
+ ) -> CardOut | None:
173
+ """The common case: `pick_card_with_subject`, returning just the chosen card."""
174
+ picked = pick_card_with_subject(
175
+ query, prompt=prompt, not_found=not_found,
176
+ narrow_set=narrow_set, printing=printing, **search_kwargs,
177
+ )
178
+ return picked[0] if picked else None
@@ -10,9 +10,10 @@ from datetime import date
10
10
  from decimal import Decimal, InvalidOperation
11
11
  from typing import Protocol
12
12
 
13
- from InquirerPy import inquirer
14
13
  from InquirerPy.base.control import Choice
15
14
 
15
+ from .nav import inquirer # the nav proxy: every prompt gains Esc=back / Ctrl-C=quit
16
+
16
17
  from slab_schemas.cards import CardOut, SetOut, SetSearchResult
17
18
  from slab_schemas.collection import (
18
19
  BreakOut,
@@ -1,127 +0,0 @@
1
- """One subject-first card picker for the whole CLI.
2
-
3
- Every command that resolves a human query to a specific catalog card (`card parallels`,
4
- `card price`/`comps`, `card history`, `collection add`, `chase add`) does it the same way:
5
- search a player, pick the player, narrow to a set (or all — `narrow_to_set`, auto-skipped when
6
- there's only one), pick the CARD (one row per checklist slot), then pick the exact printing off
7
- that card's rainbow (base is the default) — the same subject → set → card → parallel drill the
8
- browse table gives search results. This module is that flow, once. Callers that need a
9
- different *middle* step (an FMV-tagged card list, a tuple return) compose
10
- `search_and_pick_subject` + `narrow_to_set` + `pick_printing_of`; the common case is `pick_card`.
11
-
12
- The search is COLLAPSED and PAGED: one row per slot (`collapse_parallels=True`), fetched past
13
- the server's 200-per-request cap to completion. Both halves matter — a prolific player has
14
- thousands of printings (McDavid: 1,500+), so a single uncollapsed page silently dropped most of
15
- his sets and made catalogued cards unfindable here while `card search` showed them fine. The
16
- rainbow is fetched per picked card instead, exactly like the break-fill walk.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- from slab_schemas.cards import CardOut, CardSearchQuery
22
-
23
- from .context import ctx
24
- from .paging import fetch_all_pages
25
- from .prompts import (
26
- ask_text,
27
- select_printing,
28
- select_printing_for_slot,
29
- select_set_filter,
30
- select_subject,
31
- )
32
- from .theme import console
33
-
34
- # Slots fetched per search, not printings — a safety bound, far above any real player's card count.
35
- _SLOT_CAP = 5000
36
-
37
-
38
- def search_and_pick_subject(
39
- query: str | None,
40
- *,
41
- prompt: str = "Search for a card (player):",
42
- not_found: str = "No cards found.",
43
- **search_kwargs,
44
- ) -> tuple[list[CardOut], str] | None:
45
- """The shared subject-first core: resolve a query to ``(that subject's card slots, subject)``.
46
-
47
- ``query`` is the search text; when falsy the user is prompted with ``prompt``. Extra
48
- ``search_kwargs`` (e.g. ``release=[...]`` to scope to a box, ``include_market=True``,
49
- ``collector=...``) pass straight through to ``CardSearchQuery``. The result is one row per
50
- SLOT (collapsed, paged to completion — see the module docstring), so a row's finish lives one
51
- step later, on its rainbow. Returns ``None`` if nothing matched (prints ``not_found``) or the
52
- user backed out of the player pick — so the caller only ever gets a non-empty slot list plus
53
- the chosen subject.
54
- """
55
- q = query if query else ask_text(prompt)
56
- slots, _ = fetch_all_pages(
57
- lambda limit, offset: ctx.client.search_cards(CardSearchQuery(
58
- subject=q, collapse_parallels=True, limit=limit, offset=offset, **search_kwargs
59
- )),
60
- cap=_SLOT_CAP,
61
- )
62
- if not slots:
63
- console.print(f"[yellow]{not_found}[/yellow]")
64
- return None
65
-
66
- subject = select_subject(slots)
67
- if subject is None:
68
- return None
69
-
70
- cards = [c for c in slots if any(s.name == subject for s in c.subjects)]
71
- return cards, subject
72
-
73
-
74
- def narrow_to_set(cards: list[CardOut]) -> tuple[list[CardOut], bool]:
75
- """The shared set step: offer to narrow a cross-set card list to one set — "All sets" keeps
76
- everything, and the prompt auto-skips when the list is already a single set. Returns the
77
- (possibly narrowed) cards plus whether they still span sets: the ``show_set`` every card
78
- picker takes. EVERY card picker goes through this, so the subject → set → card order feels
79
- the same wherever you search."""
80
- slug = select_set_filter(cards) # auto-returns "" (all) when the player is in only one set
81
- if slug:
82
- cards = [c for c in cards if c.set_slug == slug]
83
- return cards, len({c.set_slug for c in cards}) > 1
84
-
85
-
86
- def pick_printing_of(card: CardOut) -> CardOut | None:
87
- """The final parallel step: fetch the picked card's whole rainbow and choose the exact
88
- printing — base is the default, so the common case is a single Enter, and a slot with no
89
- parallels auto-selects. Works from any printing (`get_parallels` resolves the slot's base)."""
90
- return select_printing_for_slot(ctx.client.get_parallels(card.uuid))
91
-
92
-
93
- def pick_card(
94
- query: str | None,
95
- *,
96
- prompt: str = "Search for a card (player):",
97
- not_found: str = "No cards found.",
98
- narrow_set: bool = True,
99
- printing: bool = True,
100
- **search_kwargs,
101
- ) -> CardOut | None:
102
- """The common case: search a player, pick them, narrow to a set (or all), pick the card,
103
- then the exact printing off its rainbow. Returns the chosen card, or ``None`` if nothing
104
- matched or the user backed out at any step.
105
-
106
- The set step is the default — the same player → set → card → parallel flow everywhere —
107
- because a player who appears across many products shouldn't be dumped into one
108
- undifferentiated list (and it auto-skips when everything is one set anyway). Pass
109
- ``narrow_set=False`` only when the search is already scoped to a single release (e.g.
110
- filling a break). The set name is shown on each row whenever the (post-narrowing) list
111
- still spans more than one set. ``printing=False`` stops at the card and skips the parallel
112
- step — for callers that resolve the whole rainbow themselves (`card parallels`)."""
113
- picked = search_and_pick_subject(
114
- query, prompt=prompt, not_found=not_found, **search_kwargs
115
- )
116
- if picked is None:
117
- return None
118
- cards, subject = picked
119
-
120
- if narrow_set:
121
- cards, show_set = narrow_to_set(cards)
122
- else:
123
- show_set = len({c.set_slug for c in cards}) > 1
124
- card = select_printing(cards, subject, show_set=show_set)
125
- if card is None or not printing:
126
- return card
127
- return pick_printing_of(card)
File without changes