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.
- {slab_cli-0.21.0 → slab_cli-0.22.0}/PKG-INFO +1 -1
- {slab_cli-0.21.0 → slab_cli-0.22.0}/pyproject.toml +1 -1
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/collection.py +46 -19
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/custom_sets.py +8 -16
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/pricing.py +9 -15
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/help.py +4 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/main.py +4 -1
- slab_cli-0.22.0/src/slab_cli/nav.py +112 -0
- slab_cli-0.22.0/src/slab_cli/picker.py +178 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/prompts.py +2 -1
- slab_cli-0.21.0/src/slab_cli/picker.py +0 -127
- {slab_cli-0.21.0 → slab_cli-0.22.0}/README.md +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/browse.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/client.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/registry.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/display.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/__init__.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/base.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/posix.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/platforms/windows.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/theme.py +0 -0
- {slab_cli-0.21.0 → slab_cli-0.22.0}/src/slab_cli/updates.py +0 -0
|
@@ -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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
269
|
+
def s_cost(st: dict) -> None:
|
|
270
|
+
st["acq_cost"] = _per_card_cost(session)
|
|
260
271
|
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
289
|
+
if not st["confirmed"]:
|
|
269
290
|
console.print("[dim]Skipped.[/dim]\n")
|
|
270
291
|
return False
|
|
271
292
|
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
99
|
-
(uuid, subject) return shape the batch loop wants."""
|
|
100
|
-
picked =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|