slab-cli 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- slab_cli/banner.py +92 -0
- slab_cli/client.py +386 -0
- slab_cli/commands/__init__.py +4 -0
- slab_cli/commands/breaks.py +140 -0
- slab_cli/commands/catalog.py +84 -0
- slab_cli/commands/collection.py +676 -0
- slab_cli/commands/custom_sets.py +484 -0
- slab_cli/commands/export.py +129 -0
- slab_cli/commands/lots.py +114 -0
- slab_cli/commands/pricing.py +386 -0
- slab_cli/commands/registry.py +140 -0
- slab_cli/commands/setup.py +24 -0
- slab_cli/config.py +21 -0
- slab_cli/context.py +50 -0
- slab_cli/display.py +1187 -0
- slab_cli/flags.py +189 -0
- slab_cli/help.py +87 -0
- slab_cli/main.py +95 -0
- slab_cli/paging.py +46 -0
- slab_cli/picker.py +78 -0
- slab_cli/prompts.py +549 -0
- slab_cli/sources.py +17 -0
- slab_cli/theme.py +184 -0
- slab_cli-0.1.0.dist-info/METADATA +70 -0
- slab_cli-0.1.0.dist-info/RECORD +28 -0
- slab_cli-0.1.0.dist-info/WHEEL +4 -0
- slab_cli-0.1.0.dist-info/entry_points.txt +3 -0
slab_cli/prompts.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""Interactive prompts built on InquirerPy — arrow-key selects, fuzzy search, confirmations.
|
|
2
|
+
|
|
3
|
+
Each function returns the user's choice. Functions suffixed with `_optional` allow skipping
|
|
4
|
+
(empty input = None). Functions without that suffix loop until valid input is provided."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from datetime import date
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
|
|
12
|
+
from InquirerPy import inquirer
|
|
13
|
+
from InquirerPy.base.control import Choice
|
|
14
|
+
|
|
15
|
+
from slab_schemas.cards import CardOut, SetOut, SetSearchResult
|
|
16
|
+
from slab_schemas.collection import (
|
|
17
|
+
BreakOut,
|
|
18
|
+
BreakSearchResult,
|
|
19
|
+
CardCopyCostOut,
|
|
20
|
+
CardCopyOut,
|
|
21
|
+
LotOut,
|
|
22
|
+
LotSearchResult,
|
|
23
|
+
)
|
|
24
|
+
from slab_schemas.enums import GRADE_ORDINAL, AcquisitionType, BreakType, Grade
|
|
25
|
+
|
|
26
|
+
from .theme import console as _console
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Label builders (for select menus)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _slot(c: CardOut) -> str:
|
|
34
|
+
"""The card's line: its subset, plus the parallel finish when it has one. 'Base' shows only
|
|
35
|
+
when the subset is literally Base — a no-finish INSERT shows its insert name, not 'Base'
|
|
36
|
+
(id_finish NULL means 'base printing of this line', not 'the base set')."""
|
|
37
|
+
return (c.subset or "?") + (f" · {c.finish}" if c.finish else "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _card_label(c: CardOut) -> str:
|
|
41
|
+
subjects = ", ".join(s.name for s in c.subjects)
|
|
42
|
+
pr = f" /{c.print_run}" if c.print_run else ""
|
|
43
|
+
attrs = f" [{', '.join(a.name for a in c.attributes)}]" if c.attributes else ""
|
|
44
|
+
return f"{c.card_number} {subjects} — {c.set_name} — {_slot(c)}{pr}{attrs}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _set_label(s: SetOut) -> str:
|
|
48
|
+
return f"{s.brand} {s.name} ({s.season or '—'}) [{s.card_count or 0} cards]"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _break_label(b: BreakOut) -> str:
|
|
52
|
+
# Lead with the open date so identical products are distinguishable; short uuid guarantees it.
|
|
53
|
+
when = b.break_date.isoformat() if b.break_date else "—"
|
|
54
|
+
src = f" · {b.source}" if b.source else ""
|
|
55
|
+
cpc = f" @ ${b.cost_per_card:.2f}/card" if b.cost_per_card else ""
|
|
56
|
+
return (f"{when} · {b.set_name} · {b.break_type} · ${b.total_cost:.2f}{src} · "
|
|
57
|
+
f"{b.copy_count} cards{cpc} [{b.uuid[:8]}]")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _lot_label(lot: LotOut) -> str:
|
|
61
|
+
# Lead with the purchase date so identical-value lots are distinguishable; short uuid guarantees it.
|
|
62
|
+
when = lot.lot_date.isoformat() if lot.lot_date else "—"
|
|
63
|
+
src = f" · {lot.source}" if lot.source else ""
|
|
64
|
+
cpc = f" @ ${lot.cost_per_card:.2f}/card" if lot.cost_per_card else ""
|
|
65
|
+
return (f"{when} · ${lot.total_cost:.2f}{src} · {lot.copy_count} cards{cpc} [{lot.uuid[:8]}]")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _copy_label(cp: CardCopyOut) -> str:
|
|
69
|
+
card = cp.card
|
|
70
|
+
if card:
|
|
71
|
+
subjects = ", ".join(s.name for s in card.subjects)
|
|
72
|
+
grade = f" {cp.grading_company} {cp.grade}" if cp.grading_company else ""
|
|
73
|
+
serial = f" #{cp.serial_number}" if cp.serial_number else ""
|
|
74
|
+
basis = f" [${cp.cost_basis:.2f}]" if cp.cost_basis is not None else ""
|
|
75
|
+
return f"{card.card_number} {subjects} — {card.set_name} — {_slot(card)}{grade}{serial}{basis}"
|
|
76
|
+
return f"Copy {cp.uuid[:8]}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Select prompts (arrow-key navigation)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def select_subject(cards: list[CardOut]) -> str | None:
|
|
84
|
+
"""Subject-first step 1: from a card search, pick the player. Auto-selects when only one
|
|
85
|
+
player matches; otherwise type-to-filter among the distinct players (with card counts)."""
|
|
86
|
+
counts: Counter[str] = Counter()
|
|
87
|
+
for c in cards:
|
|
88
|
+
for s in c.subjects:
|
|
89
|
+
counts[s.name] += 1
|
|
90
|
+
if not counts:
|
|
91
|
+
return None
|
|
92
|
+
if len(counts) == 1:
|
|
93
|
+
return next(iter(counts))
|
|
94
|
+
choices = [Choice(value=name, name=f"{name} ({n})") for name, n in sorted(counts.items())]
|
|
95
|
+
return inquirer.fuzzy(
|
|
96
|
+
message=f"Which player? ({len(counts)} found — type to filter):",
|
|
97
|
+
choices=choices,
|
|
98
|
+
max_height="70%",
|
|
99
|
+
).execute()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def select_printing_for_slot(printings: list[CardOut]) -> CardOut | None:
|
|
103
|
+
"""Break-fill step: given the full rainbow of one checklist slot (base first, then every
|
|
104
|
+
parallel — the output of `get_parallels`), pick the exact printing you pulled. Defaults to
|
|
105
|
+
the base printing, so the common case is a single Enter; pick a parallel only when you pulled
|
|
106
|
+
one. Auto-selects when the slot has no parallels."""
|
|
107
|
+
if not printings:
|
|
108
|
+
return None
|
|
109
|
+
if len(printings) == 1:
|
|
110
|
+
return printings[0]
|
|
111
|
+
|
|
112
|
+
def label(c: CardOut) -> str:
|
|
113
|
+
pr = f" /{c.print_run}" if c.print_run else ""
|
|
114
|
+
attrs = f" [{', '.join(a.name for a in c.attributes)}]" if c.attributes else ""
|
|
115
|
+
name = "Base" if not c.finish else c.finish
|
|
116
|
+
return f"{name}{pr}{attrs}"
|
|
117
|
+
|
|
118
|
+
# Base first (id_finish NULL → no finish) is already the server order; make it the default.
|
|
119
|
+
choices = [Choice(value=c, name=label(c)) for c in printings]
|
|
120
|
+
return inquirer.select(
|
|
121
|
+
message="Which printing did you pull?",
|
|
122
|
+
choices=choices,
|
|
123
|
+
default=choices[0].value,
|
|
124
|
+
max_height="70%",
|
|
125
|
+
).execute()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def walk_prompt(card: CardOut, position: tuple[int, int]) -> tuple[str, str | None]:
|
|
129
|
+
"""One step of the checklist walk. Prints the slot, then reads a single compact control:
|
|
130
|
+
Enter = skip to next, y = add this one, a card number = jump to it, q = quit.
|
|
131
|
+
Returns one of ("skip", None), ("add", None), ("quit", None), ("jump", <typed value>)."""
|
|
132
|
+
idx, total = position
|
|
133
|
+
subjects = ", ".join(s.name for s in card.subjects) or "—"
|
|
134
|
+
slot = (card.subset or "?") + (f" · {card.finish}" if card.finish else "")
|
|
135
|
+
owned = f" [green]● owned x{card.owned_quantity}[/green]" if card.owned_quantity else ""
|
|
136
|
+
_console.print(f"\n[dim]{idx}/{total}[/dim] [bold]#{card.card_number}[/bold] {subjects} · {slot}{owned}")
|
|
137
|
+
val = inquirer.text(message="[y]es · [Enter] skip · #=jump · [q]uit:", default="").execute()
|
|
138
|
+
v = (val or "").strip()
|
|
139
|
+
if not v:
|
|
140
|
+
return ("skip", None)
|
|
141
|
+
low = v.lower()
|
|
142
|
+
if low in ("y", "yes"):
|
|
143
|
+
return ("add", None)
|
|
144
|
+
if low in ("q", "quit"):
|
|
145
|
+
return ("quit", None)
|
|
146
|
+
return ("jump", v)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def select_printing(cards: list[CardOut], subject: str, show_set: bool = False) -> CardOut | None:
|
|
150
|
+
"""Subject-first step 2: pick the exact card for the chosen player — any printing (base,
|
|
151
|
+
parallel, insert, auto). Auto-selects when there's only one. `show_set` prepends the set name
|
|
152
|
+
to each row — used when the list spans multiple sets (e.g. the player wasn't narrowed to one
|
|
153
|
+
set), so you can tell a 2023-24 Young Guns from a 2024-25 one at a glance."""
|
|
154
|
+
if not cards:
|
|
155
|
+
return None
|
|
156
|
+
if len(cards) == 1:
|
|
157
|
+
return cards[0]
|
|
158
|
+
|
|
159
|
+
def label(c: CardOut) -> str:
|
|
160
|
+
pr = f" /{c.print_run}" if c.print_run else ""
|
|
161
|
+
upd = f" ({c.season} update)" if c.release_set_slug else ""
|
|
162
|
+
attrs = f" [{', '.join(a.name for a in c.attributes)}]" if c.attributes else ""
|
|
163
|
+
others = [s.name for s in c.subjects if s.name != subject]
|
|
164
|
+
co = f" + {', '.join(others)}" if others else ""
|
|
165
|
+
setp = f"{c.set_name} — " if show_set else ""
|
|
166
|
+
return f"{setp}#{c.card_number}{co} — {_slot(c)}{pr}{upd}{attrs}"
|
|
167
|
+
|
|
168
|
+
choices = [Choice(value=c, name=label(c)) for c in cards]
|
|
169
|
+
return inquirer.fuzzy(
|
|
170
|
+
message=f"Which card? — {subject} ({len(cards)} printings — type to filter):",
|
|
171
|
+
choices=choices,
|
|
172
|
+
max_height="70%",
|
|
173
|
+
).execute()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def select_set(sets: list[SetOut], initial: str | None = None) -> SetOut | None:
|
|
177
|
+
"""Pick one set from the catalog — fuzzy type-to-filter, pre-seeded with the user's query so
|
|
178
|
+
the closest names surface immediately (backspace to widen). Auto-selects a lone match;
|
|
179
|
+
Ctrl-C backs out."""
|
|
180
|
+
if not sets:
|
|
181
|
+
return None
|
|
182
|
+
if len(sets) == 1:
|
|
183
|
+
return sets[0]
|
|
184
|
+
ranked = sorted(sets, key=lambda s: (-(s.year or 0), s.name or "")) # newest first
|
|
185
|
+
choices = [Choice(value=s, name=_set_label(s)) for s in ranked]
|
|
186
|
+
return inquirer.fuzzy(
|
|
187
|
+
message=f"Which set? ({len(sets)} in catalog — type to filter):",
|
|
188
|
+
choices=choices,
|
|
189
|
+
default=initial or "",
|
|
190
|
+
max_height="70%",
|
|
191
|
+
).execute()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def select_set_filter(cards: list[CardOut]) -> str | None:
|
|
195
|
+
"""Price step 2: narrow the player's cards to one set, or keep all. Returns a set slug, or
|
|
196
|
+
"" for all sets. Auto-returns "" when every card is from the same set (nothing to choose)."""
|
|
197
|
+
sets: dict[str, tuple[str, int]] = {}
|
|
198
|
+
for c in cards:
|
|
199
|
+
if not c.set_slug:
|
|
200
|
+
continue
|
|
201
|
+
name, n = sets.get(c.set_slug, (c.set_name or c.set_slug, 0))
|
|
202
|
+
sets[c.set_slug] = (name, n + 1)
|
|
203
|
+
if len(sets) <= 1:
|
|
204
|
+
return ""
|
|
205
|
+
ranked = sorted(sets.items(), key=lambda kv: (-kv[1][1], kv[1][0]))
|
|
206
|
+
choices = [Choice(value="", name=f"★ All sets ({len(cards)} cards)")]
|
|
207
|
+
choices += [Choice(value=slug, name=f"{name} ({n})") for slug, (name, n) in ranked]
|
|
208
|
+
return inquirer.fuzzy(
|
|
209
|
+
message=f"Which set? ({len(sets)} found — type to filter):",
|
|
210
|
+
choices=choices,
|
|
211
|
+
max_height="70%",
|
|
212
|
+
).execute()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def select_priced_card(cards: list[CardOut], subject: str, show_set: bool) -> CardOut | None:
|
|
216
|
+
"""Price step 3: the card list — each row shows its FMV (● = you own it) so values are visible
|
|
217
|
+
before drilling in. Selecting a row returns its card for the full market view. Auto-selects a
|
|
218
|
+
lone card. `show_set` includes the set name (used when viewing across all sets)."""
|
|
219
|
+
if not cards:
|
|
220
|
+
return None
|
|
221
|
+
if len(cards) == 1:
|
|
222
|
+
return cards[0]
|
|
223
|
+
|
|
224
|
+
def label(c: CardOut) -> str:
|
|
225
|
+
pr = f" /{c.print_run}" if c.print_run else ""
|
|
226
|
+
attrs = f" [{', '.join(a.name for a in c.attributes)}]" if c.attributes else ""
|
|
227
|
+
others = [s.name for s in c.subjects if s.name != subject]
|
|
228
|
+
co = f" + {', '.join(others)}" if others else ""
|
|
229
|
+
setp = f" — {c.set_name}" if show_set else ""
|
|
230
|
+
# FMV column: value + low-confidence marker + grade bucket when not RAW.
|
|
231
|
+
if c.market is not None:
|
|
232
|
+
conf = "*" if c.market.low_confidence else ""
|
|
233
|
+
gk = "" if c.market.grade_key == "RAW" else f" {c.market.grade_key}"
|
|
234
|
+
fmv = f"${c.market.fair_market_value:,.2f}{conf}{gk}"
|
|
235
|
+
else:
|
|
236
|
+
fmv = "no comps"
|
|
237
|
+
own = f" ● x{c.owned_quantity}" if c.owned_quantity else ""
|
|
238
|
+
return f"#{c.card_number}{co}{setp} — {_slot(c)}{pr}{attrs} {fmv}{own}"
|
|
239
|
+
|
|
240
|
+
choices = [Choice(value=c, name=label(c)) for c in cards]
|
|
241
|
+
return inquirer.fuzzy(
|
|
242
|
+
message=f"{subject} — {len(cards)} cards (● = owned; select for full market):",
|
|
243
|
+
choices=choices,
|
|
244
|
+
max_height="70%",
|
|
245
|
+
).execute()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def select_search_set(result: SetSearchResult) -> SetOut | None:
|
|
249
|
+
"""Pick one set from a bounded search result (arrow-key select). Distinct from `select_set`,
|
|
250
|
+
which fuzzy-filters the whole catalog list — this one is for an already-narrowed search."""
|
|
251
|
+
if not result.items:
|
|
252
|
+
return None
|
|
253
|
+
choices = [Choice(value=s, name=_set_label(s)) for s in result.items]
|
|
254
|
+
return inquirer.select(
|
|
255
|
+
message=f"Select a set ({result.total} found):",
|
|
256
|
+
choices=choices,
|
|
257
|
+
max_height="70%",
|
|
258
|
+
).execute()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def select_owned_set(sets: list[tuple[str, str, int]], total: int) -> str:
|
|
262
|
+
"""Pick a set to export from the sets the collector actually owns cards in. Fuzzy — arrow keys
|
|
263
|
+
plus type-to-filter, so you never have to know the exact set name. `sets` = [(slug, name, count)]
|
|
264
|
+
sorted by the caller. Returns a set slug, or "" for the whole collection."""
|
|
265
|
+
choices = [Choice(value="", name=f"★ Whole collection ({total} cards)")]
|
|
266
|
+
choices += [Choice(value=slug, name=f"{name} ({count})") for slug, name, count in sets]
|
|
267
|
+
return inquirer.fuzzy(
|
|
268
|
+
message="Export which set?",
|
|
269
|
+
choices=choices,
|
|
270
|
+
max_height="70%",
|
|
271
|
+
).execute()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def select_copy(items: list[CardCopyOut], message: str = "Select a copy:") -> CardCopyOut | None:
|
|
275
|
+
"""Arrow-key select from collection results."""
|
|
276
|
+
if not items:
|
|
277
|
+
return None
|
|
278
|
+
choices = [Choice(value=cp, name=_copy_label(cp)) for cp in items]
|
|
279
|
+
return inquirer.select(
|
|
280
|
+
message=message,
|
|
281
|
+
choices=choices,
|
|
282
|
+
max_height="70%",
|
|
283
|
+
).execute()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def select_break(result: BreakSearchResult, allow_new: bool = True) -> BreakOut | str | None:
|
|
287
|
+
"""Select a break. Returns a BreakOut, the string "new" if user wants to create, or None."""
|
|
288
|
+
choices = [Choice(value=b, name=_break_label(b)) for b in result.items]
|
|
289
|
+
if allow_new:
|
|
290
|
+
choices.append(Choice(value="new", name="+ Create a new break"))
|
|
291
|
+
choices.append(Choice(value=None, name="Skip (no break)"))
|
|
292
|
+
return inquirer.select(
|
|
293
|
+
message="Select a break:",
|
|
294
|
+
choices=choices,
|
|
295
|
+
max_height="70%",
|
|
296
|
+
).execute()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def select_lot(result: LotSearchResult, allow_new: bool = True) -> LotOut | str | None:
|
|
300
|
+
"""Select a purchase lot. Returns a LotOut, the string "new" to create one, or None to skip."""
|
|
301
|
+
choices = [Choice(value=lot, name=_lot_label(lot)) for lot in result.items]
|
|
302
|
+
if allow_new:
|
|
303
|
+
choices.append(Choice(value="new", name="+ Create a new purchase"))
|
|
304
|
+
choices.append(Choice(value=None, name="Skip (no purchase lot)"))
|
|
305
|
+
return inquirer.select(
|
|
306
|
+
message="Select a purchase:",
|
|
307
|
+
choices=choices,
|
|
308
|
+
max_height="70%",
|
|
309
|
+
).execute()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Sentinel VALUE for "enter a new source". A string compared by ==, deliberately NOT `object()`
|
|
313
|
+
# checked by `is`: InquirerPy deep-copies choice values internally, so an object() sentinel comes
|
|
314
|
+
# back as a different instance, fails the identity check, and leaks raw into the payload (real
|
|
315
|
+
# crash: BreakCreate(source=<object object>) pydantic ValidationError).
|
|
316
|
+
_NEW_SOURCE = "__slab_new_source__"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def select_source(known: list[str]) -> str | None:
|
|
320
|
+
"""Pick a previously-used source with arrow keys, or enter a new one. Empty/skip = None.
|
|
321
|
+
|
|
322
|
+
With no prior sources it degrades to a plain text prompt, so the first break behaves as before."""
|
|
323
|
+
if not known:
|
|
324
|
+
return ask_text_optional("Source (e.g. eBay, LCS — enter to skip):")
|
|
325
|
+
choices = [Choice(value=s, name=s) for s in known]
|
|
326
|
+
choices.append(Choice(value=_NEW_SOURCE, name="+ Enter a new source"))
|
|
327
|
+
choices.append(Choice(value=None, name="Skip (no source)"))
|
|
328
|
+
picked = inquirer.select(
|
|
329
|
+
message="Source (↑↓ to reuse, or add new):",
|
|
330
|
+
choices=choices,
|
|
331
|
+
max_height="70%",
|
|
332
|
+
).execute()
|
|
333
|
+
if picked == _NEW_SOURCE:
|
|
334
|
+
return ask_text_optional("New source (enter to skip):")
|
|
335
|
+
return picked
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# Human-friendly blurbs for the enum members. The VALUE SET always comes from the schema enum (so a
|
|
339
|
+
# new member reaches the CLI automatically); these only supply nicer labels, with a title-cased
|
|
340
|
+
# fallback for any member not listed here.
|
|
341
|
+
_ACQUISITION_LABELS: dict[AcquisitionType, str] = {
|
|
342
|
+
AcquisitionType.purchase: "Purchase — bought the card",
|
|
343
|
+
AcquisitionType.pack_pull: "Pack pull — from a break",
|
|
344
|
+
AcquisitionType.trade: "Trade",
|
|
345
|
+
AcquisitionType.gift: "Gift",
|
|
346
|
+
AcquisitionType.other: "Other",
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_BREAK_TYPE_LABELS: dict[BreakType, str] = {
|
|
350
|
+
BreakType.pack: "Pack",
|
|
351
|
+
BreakType.box: "Box",
|
|
352
|
+
BreakType.case: "Case",
|
|
353
|
+
BreakType.other: "Other",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# The 1-10 rung display names. Ordering and ordinals come from GRADE_ORDINAL, not from here.
|
|
357
|
+
_GRADE_LABELS: dict[Grade, str] = {
|
|
358
|
+
Grade.gem_mint: "Gem Mint",
|
|
359
|
+
Grade.mint: "Mint",
|
|
360
|
+
Grade.near_mint_mint: "Near Mint-Mint",
|
|
361
|
+
Grade.near_mint: "Near Mint",
|
|
362
|
+
Grade.excellent_mint: "Excellent-Mint",
|
|
363
|
+
Grade.excellent: "Excellent",
|
|
364
|
+
Grade.very_good_excellent: "Very Good-Excellent",
|
|
365
|
+
Grade.very_good: "Very Good",
|
|
366
|
+
Grade.good: "Good",
|
|
367
|
+
Grade.poor: "Poor",
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _enum_label(member, labels: dict) -> str:
|
|
372
|
+
"""A member's display label, falling back to a title-cased name so a newly-added enum member is
|
|
373
|
+
still selectable (never silently dropped from the menu)."""
|
|
374
|
+
return labels.get(member) or member.value.replace("_", " ").title()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def select_acquisition_type() -> str:
|
|
378
|
+
choices = [Choice(value=t.value, name=_enum_label(t, _ACQUISITION_LABELS)) for t in AcquisitionType]
|
|
379
|
+
return inquirer.select(message="How did you acquire this card?", choices=choices).execute()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def select_break_type() -> str:
|
|
383
|
+
choices = [Choice(value=t.value, name=_enum_label(t, _BREAK_TYPE_LABELS)) for t in BreakType]
|
|
384
|
+
return inquirer.select(message="What did you open?", choices=choices).execute()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def select_grade(message: str = "Your grade assessment:") -> str | None:
|
|
388
|
+
"""Self-assessed condition on the shared 1-10 rung scale. Returns the Grade enum value
|
|
389
|
+
(e.g. "NM-MT") or None if skipped. Rungs and their 1-10 positions come from the schema's
|
|
390
|
+
Grade + GRADE_ORDINAL, so a new rung reaches the CLI automatically; listed best-first."""
|
|
391
|
+
rungs = sorted(Grade, key=lambda g: GRADE_ORDINAL.get(g, 0), reverse=True)
|
|
392
|
+
choices = []
|
|
393
|
+
for g in rungs:
|
|
394
|
+
ordinal = GRADE_ORDINAL.get(g)
|
|
395
|
+
label = _enum_label(g, _GRADE_LABELS)
|
|
396
|
+
name = f"{label} ({ordinal})" if ordinal is not None else label
|
|
397
|
+
choices.append(Choice(value=g.value, name=name))
|
|
398
|
+
choices.append(Choice(value=None, name="Skip / unsure"))
|
|
399
|
+
return inquirer.select(message=message, choices=choices, max_height="70%").execute()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def select_grading_company() -> str:
|
|
403
|
+
choices = [
|
|
404
|
+
Choice(value="PSA", name="PSA"),
|
|
405
|
+
Choice(value="BGS", name="BGS (Beckett)"),
|
|
406
|
+
Choice(value="SGC", name="SGC"),
|
|
407
|
+
Choice(value="CGC", name="CGC"),
|
|
408
|
+
]
|
|
409
|
+
return inquirer.select(message="Grading company:", choices=choices).execute()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def select_match_mode() -> str:
|
|
413
|
+
choices = [
|
|
414
|
+
Choice(value="exact", name="Exact — this specific printing (parallel/finish)"),
|
|
415
|
+
Choice(value="any_printing", name="Any printing — any version of this card slot"),
|
|
416
|
+
Choice(value="exact_serial", name="Exact serial — this card with a specific serial number"),
|
|
417
|
+
]
|
|
418
|
+
return inquirer.select(message="Match mode:", choices=choices).execute()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def select_visibility() -> str:
|
|
422
|
+
choices = [
|
|
423
|
+
Choice(value="private", name="Private — only you can see it"),
|
|
424
|
+
Choice(value="public", name="Public — any collector can find and subscribe"),
|
|
425
|
+
]
|
|
426
|
+
return inquirer.select(message="Visibility:", choices=choices).execute()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def select_custom_set_type() -> str:
|
|
430
|
+
choices = [
|
|
431
|
+
Choice(value="curated", name="Curated — hand-pick specific cards"),
|
|
432
|
+
Choice(value="dynamic", name="Dynamic — defined by a search filter (auto-updates)"),
|
|
433
|
+
]
|
|
434
|
+
return inquirer.select(message="Set type:", choices=choices).execute()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def select_cost_category() -> str:
|
|
438
|
+
choices = [
|
|
439
|
+
Choice(value="grading", name="Grading fee"),
|
|
440
|
+
Choice(value="shipping", name="Shipping"),
|
|
441
|
+
Choice(value="insurance", name="Insurance"),
|
|
442
|
+
Choice(value="authentication", name="Authentication"),
|
|
443
|
+
Choice(value="other", name="Other"),
|
|
444
|
+
]
|
|
445
|
+
return inquirer.select(message="Cost type:", choices=choices).execute()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def select_cost_action() -> str | None:
|
|
449
|
+
"""On a copy that already has costs: add a new one, edit one, or delete one. None = cancel."""
|
|
450
|
+
choices = [
|
|
451
|
+
Choice(value="add", name="Add a new cost"),
|
|
452
|
+
Choice(value="edit", name="Edit an existing cost"),
|
|
453
|
+
Choice(value="delete", name="Delete a cost"),
|
|
454
|
+
Choice(value=None, name="Cancel"),
|
|
455
|
+
]
|
|
456
|
+
return inquirer.select(message="What would you like to do?", choices=choices).execute()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def select_cost_entry(costs: list[CardCopyCostOut], message: str = "Select a cost:") -> CardCopyCostOut | None:
|
|
460
|
+
"""Pick one entry from a copy's cost ledger."""
|
|
461
|
+
if not costs:
|
|
462
|
+
return None
|
|
463
|
+
choices = []
|
|
464
|
+
for c in costs:
|
|
465
|
+
extra = f" · {c.vendor}" if c.vendor else ""
|
|
466
|
+
when = f" · {c.incurred_date}" if c.incurred_date else ""
|
|
467
|
+
choices.append(Choice(value=c, name=f"{c.cost_category} ${c.amount:.2f}{extra}{when}"))
|
|
468
|
+
return inquirer.select(message=message, choices=choices, max_height="70%").execute()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# Text prompts — required (loops until non-empty)
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def ask_text(message: str) -> str:
|
|
476
|
+
"""Prompt for text input. Loops until the user provides a non-empty value."""
|
|
477
|
+
while True:
|
|
478
|
+
val = inquirer.text(message=message).execute()
|
|
479
|
+
if val and val.strip():
|
|
480
|
+
return val.strip()
|
|
481
|
+
_console.print("[yellow] This field is required.[/yellow]")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def ask_decimal(message: str) -> Decimal:
|
|
485
|
+
"""Prompt for a decimal. Loops until valid."""
|
|
486
|
+
while True:
|
|
487
|
+
val = inquirer.text(message=message).execute()
|
|
488
|
+
if not val or not val.strip():
|
|
489
|
+
_console.print("[yellow] This field is required.[/yellow]")
|
|
490
|
+
continue
|
|
491
|
+
try:
|
|
492
|
+
return Decimal(val.strip())
|
|
493
|
+
except InvalidOperation:
|
|
494
|
+
_console.print("[yellow] Enter a valid number (e.g. 25.00).[/yellow]")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
# Text prompts — optional (empty = None)
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def ask_text_optional(message: str) -> str | None:
|
|
502
|
+
"""Prompt for text input. Empty = None (skip)."""
|
|
503
|
+
val = inquirer.text(message=message, default="").execute()
|
|
504
|
+
return val.strip() or None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def ask_decimal_optional(message: str) -> Decimal | None:
|
|
508
|
+
"""Prompt for a decimal. Empty = None (skip). Invalid input retries."""
|
|
509
|
+
while True:
|
|
510
|
+
val = inquirer.text(message=message, default="").execute()
|
|
511
|
+
if not val or not val.strip():
|
|
512
|
+
return None
|
|
513
|
+
try:
|
|
514
|
+
return Decimal(val.strip())
|
|
515
|
+
except InvalidOperation:
|
|
516
|
+
_console.print("[yellow] Enter a valid number or leave empty to skip.[/yellow]")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def ask_int_optional(message: str) -> int | None:
|
|
520
|
+
"""Prompt for an integer. Empty = None (skip). Invalid input retries."""
|
|
521
|
+
while True:
|
|
522
|
+
val = inquirer.text(message=message, default="").execute()
|
|
523
|
+
if not val or not val.strip():
|
|
524
|
+
return None
|
|
525
|
+
try:
|
|
526
|
+
return int(val.strip())
|
|
527
|
+
except ValueError:
|
|
528
|
+
_console.print("[yellow] Enter a whole number or leave empty to skip.[/yellow]")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def ask_date(message: str) -> date:
|
|
532
|
+
"""Prompt for a date (YYYY-MM-DD). Empty input defaults to today. Loops until valid."""
|
|
533
|
+
today = date.today()
|
|
534
|
+
while True:
|
|
535
|
+
val = inquirer.text(message=f"{message} (YYYY-MM-DD, enter for {today})", default="").execute()
|
|
536
|
+
if not val or not val.strip():
|
|
537
|
+
return today
|
|
538
|
+
try:
|
|
539
|
+
return date.fromisoformat(val.strip())
|
|
540
|
+
except ValueError:
|
|
541
|
+
_console.print("[yellow] Enter a date as YYYY-MM-DD, or leave empty for today.[/yellow]")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# Confirm
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
def confirm(message: str, default: bool = True) -> bool:
|
|
549
|
+
return inquirer.confirm(message=message, default=default).execute()
|
slab_cli/sources.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Acquisition sources — the collector's growing list of where cards and breaks came from.
|
|
2
|
+
|
|
3
|
+
One home for "where did this come from?": the reuse-or-type flow the break and copy-add flows share,
|
|
4
|
+
so the source UX is identical everywhere. The distinct-source list is computed server-side
|
|
5
|
+
(``GET /collectors/{id}/sources``, union of break + copy sources); the generic picker UI lives in
|
|
6
|
+
``prompts.select_source`` with its ``select_*`` siblings. This module composes the two."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import SlabClient
|
|
11
|
+
from .prompts import select_source
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def pick_source(client: SlabClient, collector: str) -> str | None:
|
|
15
|
+
"""Fetch the collector's prior sources, then let them reuse one (arrow keys) or enter a new one.
|
|
16
|
+
None = skipped. The single entry point both the break and copy-add flows call."""
|
|
17
|
+
return select_source(client.get_sources(collector))
|