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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Lot commands — record and list purchases (a single multi-card buy).
|
|
2
|
+
|
|
3
|
+
A lot is the sibling of a break: one price paid for one-or-more cards (an eBay bundle, a card-show
|
|
4
|
+
pickup, a COMC order), split evenly across the cards attached to it — but tied to no set, so its
|
|
5
|
+
cards can span any number of products. Also provides helpers used by the collection add flow.
|
|
6
|
+
|
|
7
|
+
`lot fill` lives in `collection.py` (like `break fill`) because it drives the shared add session."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from slab_schemas.collection import CollectionSearchQuery, LotCreate, LotOut, LotSearchQuery
|
|
12
|
+
|
|
13
|
+
from ..client import SlabClient
|
|
14
|
+
from ..context import ctx
|
|
15
|
+
from ..display import console, print_lots, render_lot_contents
|
|
16
|
+
from ..prompts import ask_date, ask_decimal, confirm, select_lot
|
|
17
|
+
from ..sources import pick_source
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pick_or_create_lot(client: SlabClient, collector: str) -> LotOut | None:
|
|
21
|
+
"""Let the user pick an existing purchase or create a new one. Returns the LotOut (whose cost
|
|
22
|
+
splits across the cards attached to it) or None if skipped."""
|
|
23
|
+
lots = client.search_lots(collector)
|
|
24
|
+
if lots.items:
|
|
25
|
+
choice = select_lot(lots, allow_new=True)
|
|
26
|
+
if choice == "new":
|
|
27
|
+
return create_lot_interactive(client, collector)
|
|
28
|
+
elif isinstance(choice, LotOut):
|
|
29
|
+
return choice
|
|
30
|
+
return None # skipped
|
|
31
|
+
else:
|
|
32
|
+
if confirm("No purchases yet. Record one?", default=True):
|
|
33
|
+
return create_lot_interactive(client, collector)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_lot_interactive(client: SlabClient, collector: str) -> LotOut | None:
|
|
38
|
+
"""Walk the user through recording a purchase. Returns the new LotOut or None."""
|
|
39
|
+
console.print(
|
|
40
|
+
"[dim]A purchase is one price paid for one-or-more cards — an eBay bundle, a show pickup. "
|
|
41
|
+
"Its cost splits across the cards you attach, so each carries what it actually cost you.[/dim]"
|
|
42
|
+
)
|
|
43
|
+
total_cost = ask_decimal("Total paid:")
|
|
44
|
+
lot_date = ask_date("When did you buy it?")
|
|
45
|
+
# Offer the collector's previously-used sources for arrow-key reuse (falls back to a plain
|
|
46
|
+
# text prompt on the first purchase, when there's nothing to reuse yet).
|
|
47
|
+
source = pick_source(client, collector)
|
|
48
|
+
|
|
49
|
+
lot = client.create_lot(
|
|
50
|
+
collector,
|
|
51
|
+
LotCreate(total_cost=total_cost, lot_date=lot_date, source=source),
|
|
52
|
+
)
|
|
53
|
+
console.print(f"[green]Purchase recorded:[/green] {lot.lot_date} — ${lot.total_cost:.2f}"
|
|
54
|
+
+ (f" · {lot.source}" if lot.source else ""))
|
|
55
|
+
console.print(
|
|
56
|
+
"[dim]Why it matters: as you add the cards from this purchase, its cost splits across "
|
|
57
|
+
"them — so every card carries what it actually cost you (real profit/loss later).[/dim]"
|
|
58
|
+
)
|
|
59
|
+
return lot
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_lot_list(args: list[str]) -> None:
|
|
63
|
+
"""`slab lot list` — your purchases, then pick one to see value vs cost + its cards."""
|
|
64
|
+
result = ctx.client.search_lots(ctx.collector, LotSearchQuery(limit=25))
|
|
65
|
+
print_lots(result)
|
|
66
|
+
if not result.items:
|
|
67
|
+
return
|
|
68
|
+
choice = select_lot(result, allow_new=False)
|
|
69
|
+
if isinstance(choice, LotOut):
|
|
70
|
+
_show_lot_contents(choice)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _show_lot_contents(lot: LotOut) -> None:
|
|
74
|
+
"""The purchase's report card: one collection search scoped to the lot — each copy carries its
|
|
75
|
+
own market data — rendered as value-vs-cost + most valuable + rarest."""
|
|
76
|
+
cards = ctx.client.search_collection(
|
|
77
|
+
ctx.collector, CollectionSearchQuery(lot_uuid=lot.uuid, limit=200))
|
|
78
|
+
render_lot_contents(lot, cards)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_lot_new(args: list[str]) -> None:
|
|
82
|
+
"""`slab lot new` — record a purchase (its cost splits across the cards you add to it, so every
|
|
83
|
+
card carries what it actually cost you)."""
|
|
84
|
+
create_lot_interactive(ctx.client, ctx.collector)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_lot_remove(args: list[str]) -> None:
|
|
88
|
+
"""`slab lot remove` — pick a purchase and remove it. The cards stay in your collection but are
|
|
89
|
+
detached from the purchase (their split-derived cost basis is lost, since that cost was only
|
|
90
|
+
ever split across them while linked)."""
|
|
91
|
+
lots = ctx.client.search_lots(ctx.collector, LotSearchQuery(limit=100))
|
|
92
|
+
if not lots.items:
|
|
93
|
+
console.print("[slab.dim]No purchases to delete.[/]")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
choice = select_lot(lots, allow_new=False)
|
|
97
|
+
if not isinstance(choice, LotOut):
|
|
98
|
+
return # skipped / cancelled
|
|
99
|
+
|
|
100
|
+
console.print(
|
|
101
|
+
f"[yellow]Deleting:[/yellow] {choice.lot_date} — ${choice.total_cost:.2f} "
|
|
102
|
+
f"({choice.copy_count} card(s))"
|
|
103
|
+
)
|
|
104
|
+
if choice.copy_count:
|
|
105
|
+
console.print(
|
|
106
|
+
f"[dim]Those {choice.copy_count} card(s) stay in your collection but lose the cost "
|
|
107
|
+
"basis they got from this purchase.[/dim]"
|
|
108
|
+
)
|
|
109
|
+
if not confirm("Delete this purchase?", default=False):
|
|
110
|
+
console.print("[slab.dim]Cancelled.[/]")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
ctx.client.delete_lot(ctx.collector, choice.uuid)
|
|
114
|
+
console.print("[green]Purchase deleted.[/green]")
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Pricing commands — market price lookups and portfolio valuation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from slab_schemas.cards import SetSearchQuery
|
|
6
|
+
from slab_schemas.collection import CollectionSearchQuery, PortfolioSummary
|
|
7
|
+
|
|
8
|
+
from slab_schemas.cards import CardOut, SetOut
|
|
9
|
+
|
|
10
|
+
from ..context import MissingCollector, ctx
|
|
11
|
+
from ..display import (
|
|
12
|
+
console,
|
|
13
|
+
render_card_market,
|
|
14
|
+
render_comps,
|
|
15
|
+
render_portfolio,
|
|
16
|
+
render_price_history,
|
|
17
|
+
render_sealed_price_history,
|
|
18
|
+
render_set_market,
|
|
19
|
+
)
|
|
20
|
+
from ..paging import fetch_all_pages
|
|
21
|
+
from ..picker import pick_card, search_and_pick_subject
|
|
22
|
+
from ..prompts import (
|
|
23
|
+
select_priced_card,
|
|
24
|
+
select_set,
|
|
25
|
+
select_set_filter,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _optional_collector() -> str | None:
|
|
30
|
+
"""The active collector if one is resolvable, else None — pricing is catalog-level and works
|
|
31
|
+
without ownership, but if we have a collector we annotate each row with owned quantity."""
|
|
32
|
+
try:
|
|
33
|
+
return ctx.collector
|
|
34
|
+
except MissingCollector:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pick_card(args: list[str]) -> CardOut | None:
|
|
39
|
+
"""Price/comps selection: player → set (or all) → a card from the FMV-tagged list. Shares the
|
|
40
|
+
subject-first core (`search_and_pick_subject`) with every other picker, then adds the two
|
|
41
|
+
pricing-specific steps: the enriched search (each row carries its headline FMV + owned tag) and
|
|
42
|
+
a set-narrowing step before the FMV list. Returns the chosen card, or None if the user backed
|
|
43
|
+
out or nothing matched."""
|
|
44
|
+
picked = search_and_pick_subject(
|
|
45
|
+
" ".join(args) if args else None,
|
|
46
|
+
collector=_optional_collector(),
|
|
47
|
+
include_market=True,
|
|
48
|
+
)
|
|
49
|
+
if picked is None:
|
|
50
|
+
return None
|
|
51
|
+
cards, subject = picked
|
|
52
|
+
|
|
53
|
+
# Narrow to a set (auto-skips when the player only appears in one set).
|
|
54
|
+
slug = select_set_filter(cards)
|
|
55
|
+
if slug:
|
|
56
|
+
cards = [c for c in cards if c.set_slug == slug]
|
|
57
|
+
|
|
58
|
+
return select_priced_card(cards, subject, show_set=not slug)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_price(args: list[str]) -> None:
|
|
62
|
+
"""Price a player's cards: pick the player, narrow to a set (or all), see every printing with
|
|
63
|
+
its FMV (● = you own it), then drill into one for the full market view. Catalog-level — no
|
|
64
|
+
ownership required, but owned copies are tagged when a collector is configured."""
|
|
65
|
+
card = _pick_card(args)
|
|
66
|
+
if card is None:
|
|
67
|
+
return
|
|
68
|
+
render_card_market(ctx.client.get_card_market(card.uuid))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_comps(args: list[str]) -> None:
|
|
72
|
+
"""See the recent comps (raw observed sales) behind a card's price. Same player → set → card
|
|
73
|
+
flow as `price`, then lists the individual sales that FMV is built from."""
|
|
74
|
+
card = _pick_card(args)
|
|
75
|
+
if card is None:
|
|
76
|
+
return
|
|
77
|
+
render_comps(ctx.client.get_card_comps(card.uuid))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fetch_all_copies(page: int = 200, cap: int = 5000) -> tuple[list, PortfolioSummary | None]:
|
|
81
|
+
"""Page through the whole collection for the per-set/mover/break breakdowns, and grab the
|
|
82
|
+
server's summary block (full-result aggregates, identical on every page) from the first page —
|
|
83
|
+
that's the source of truth for grand totals. `cap` is a safety bound so a runaway collection
|
|
84
|
+
can't spin forever."""
|
|
85
|
+
copies, first = fetch_all_pages(
|
|
86
|
+
lambda limit, offset: ctx.client.search_collection(
|
|
87
|
+
ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=limit, offset=offset)
|
|
88
|
+
),
|
|
89
|
+
page=page,
|
|
90
|
+
cap=cap,
|
|
91
|
+
)
|
|
92
|
+
return copies, (first.summary if first else None)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _aggregate_by_set(copies: list) -> list[dict]:
|
|
96
|
+
"""Roll copies up per set, mirroring the server's headline math so the parts reconcile with the
|
|
97
|
+
whole: cost basis is summed per-copy, FMV is quantity-weighted, comp coverage is copy-weighted."""
|
|
98
|
+
sets: dict[str, dict] = {}
|
|
99
|
+
for cp in copies:
|
|
100
|
+
card = cp.card
|
|
101
|
+
name = (card.set_name if card else None) or "— Unknown set —"
|
|
102
|
+
s = sets.setdefault(name, {
|
|
103
|
+
"name": name, "copies": 0, "total_qty": 0, "priced_qty": 0,
|
|
104
|
+
"cost_basis": 0.0, "fmv": 0.0, "has_fmv": False,
|
|
105
|
+
})
|
|
106
|
+
qty = cp.quantity or 1
|
|
107
|
+
s["copies"] += 1
|
|
108
|
+
s["total_qty"] += qty
|
|
109
|
+
if cp.cost_basis is not None:
|
|
110
|
+
s["cost_basis"] += float(cp.cost_basis)
|
|
111
|
+
if cp.market and cp.market.fair_market_value is not None:
|
|
112
|
+
s["fmv"] += float(cp.market.fair_market_value) * qty
|
|
113
|
+
s["priced_qty"] += qty
|
|
114
|
+
s["has_fmv"] = True
|
|
115
|
+
|
|
116
|
+
for s in sets.values():
|
|
117
|
+
s["unrealized"] = (s["fmv"] - s["cost_basis"]) if s["has_fmv"] else None
|
|
118
|
+
s["comp_pct"] = (s["priced_qty"] / s["total_qty"] * 100) if s["total_qty"] else 0.0
|
|
119
|
+
|
|
120
|
+
# Most valuable first; sets with no market data sink to the bottom (sorted by cost basis).
|
|
121
|
+
return sorted(
|
|
122
|
+
sets.values(),
|
|
123
|
+
key=lambda s: (s["has_fmv"], s["fmv"] if s["has_fmv"] else s["cost_basis"]),
|
|
124
|
+
reverse=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _portfolio_totals(summary: PortfolioSummary) -> dict:
|
|
129
|
+
"""Grand totals for the valuation panel, straight from the server's summary block — the same
|
|
130
|
+
math the dashboard shows, so the two never diverge."""
|
|
131
|
+
fmv = summary.portfolio_value
|
|
132
|
+
return {
|
|
133
|
+
"cost_basis": float(summary.total_cost_basis or 0),
|
|
134
|
+
"fmv": float(fmv) if fmv is not None else None,
|
|
135
|
+
"unrealized": (
|
|
136
|
+
float(summary.total_unrealized_gain_loss)
|
|
137
|
+
if summary.total_unrealized_gain_loss is not None else None
|
|
138
|
+
),
|
|
139
|
+
"roi": float(summary.portfolio_roi) if summary.portfolio_roi is not None else None,
|
|
140
|
+
"priced_qty": summary.priced_copies,
|
|
141
|
+
"total_qty": summary.priced_copies + summary.unpriced_copies,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _most_valuable_break(copies: list, breaks: list) -> dict | None:
|
|
146
|
+
"""The break whose pulled copies carry the most market value — did opening that box pay off?
|
|
147
|
+
FMV is summed from the copies we already priced; cost/metadata comes from the breaks list."""
|
|
148
|
+
fmv_by_break: dict[str, float] = {}
|
|
149
|
+
priced_by_break: dict[str, int] = {}
|
|
150
|
+
for cp in copies:
|
|
151
|
+
if cp.break_uuid and cp.market and cp.market.fair_market_value is not None:
|
|
152
|
+
qty = cp.quantity or 1
|
|
153
|
+
fmv_by_break[cp.break_uuid] = (
|
|
154
|
+
fmv_by_break.get(cp.break_uuid, 0.0) + float(cp.market.fair_market_value) * qty
|
|
155
|
+
)
|
|
156
|
+
priced_by_break[cp.break_uuid] = priced_by_break.get(cp.break_uuid, 0) + qty
|
|
157
|
+
|
|
158
|
+
if not fmv_by_break:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
top_uuid = max(fmv_by_break, key=fmv_by_break.get)
|
|
162
|
+
brk = next((b for b in breaks if b.uuid == top_uuid), None)
|
|
163
|
+
if brk is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
cost = float(brk.total_cost)
|
|
167
|
+
fmv = fmv_by_break[top_uuid]
|
|
168
|
+
return {
|
|
169
|
+
"set_name": brk.set_name or "— Unknown set —",
|
|
170
|
+
"break_type": brk.break_type,
|
|
171
|
+
"break_date": brk.break_date,
|
|
172
|
+
"cost": cost,
|
|
173
|
+
"fmv": fmv,
|
|
174
|
+
"gain": fmv - cost,
|
|
175
|
+
"copy_count": brk.copy_count,
|
|
176
|
+
"priced": priced_by_break.get(top_uuid, 0),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_portfolio(args: list[str]) -> None:
|
|
181
|
+
"""Portfolio-level financial summary — cost basis vs market value, per-set stats, top movers."""
|
|
182
|
+
copies, summary = _fetch_all_copies()
|
|
183
|
+
if not copies:
|
|
184
|
+
console.print("[dim]No cards in your collection yet.[/dim]")
|
|
185
|
+
return
|
|
186
|
+
if summary is None:
|
|
187
|
+
# The API always includes the summary block; its absence is a server bug — say so
|
|
188
|
+
# rather than invent numbers client-side.
|
|
189
|
+
console.print("[red]Server response had no portfolio summary — can't value the collection.[/red]")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
by_set = _aggregate_by_set(copies)
|
|
193
|
+
totals = _portfolio_totals(summary)
|
|
194
|
+
|
|
195
|
+
# Top movers: copies with real unrealized P&L, biggest gain first (already sorted by the fetch).
|
|
196
|
+
movers = [
|
|
197
|
+
cp for cp in copies
|
|
198
|
+
if cp.market and cp.market.unrealized_gain_loss is not None
|
|
199
|
+
][:10] or None
|
|
200
|
+
|
|
201
|
+
breaks = ctx.client.search_breaks(ctx.collector).items
|
|
202
|
+
mvb = _most_valuable_break(copies, breaks)
|
|
203
|
+
|
|
204
|
+
render_portfolio(
|
|
205
|
+
len(copies), totals, movers=movers, by_set=by_set, most_valuable_break=mvb
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _fetch_all_sets(page: int = 200, cap: int = 1000) -> list[SetOut]:
|
|
210
|
+
"""The whole set catalog for client-side fuzzy picking. Sets number in the dozens today, so
|
|
211
|
+
this is one request; `cap` bounds the paging if the catalog grows past reason."""
|
|
212
|
+
sets, _ = fetch_all_pages(
|
|
213
|
+
lambda limit, offset: ctx.client.search_sets(SetSearchQuery(limit=limit, offset=offset)),
|
|
214
|
+
page=page,
|
|
215
|
+
cap=cap,
|
|
216
|
+
)
|
|
217
|
+
return sets
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_set_market(args: list[str]) -> None:
|
|
221
|
+
"""The set-level money view: type a set name, fuzzy-pick the match, then see every sealed
|
|
222
|
+
SKU's FMV (case/box/blaster) next to the most expensive cards inside it — what the wax costs
|
|
223
|
+
vs what you're chasing. Catalog-level, no ownership required."""
|
|
224
|
+
query = " ".join(args) if args else None
|
|
225
|
+
|
|
226
|
+
sets = _fetch_all_sets()
|
|
227
|
+
if not sets:
|
|
228
|
+
console.print("[yellow]No sets in the catalog yet.[/yellow]")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
chosen = select_set(sets, initial=query)
|
|
232
|
+
if chosen is None:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
sealed = ctx.client.get_set_sealed(chosen.uuid)
|
|
236
|
+
top = ctx.client.get_set_top_cards(chosen.uuid, limit=10)
|
|
237
|
+
render_set_market(chosen, sealed, top)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Above this many daily points, an unflagged history is re-fetched weekly — a two-year series
|
|
241
|
+
# should read as a trend, not a 400-row dump. --daily always forces every point.
|
|
242
|
+
_AUTO_WEEKLY_OVER = 90
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _history_options(args: list[str]) -> tuple[list[str], str | None, int | None]:
|
|
246
|
+
"""(query_args, interval|None, days|None) from --daily/--weekly/--monthly/--days N.
|
|
247
|
+
interval None = auto (daily, bucketed to weekly when the series is long)."""
|
|
248
|
+
query: list[str] = []
|
|
249
|
+
interval: str | None = None
|
|
250
|
+
days: int | None = None
|
|
251
|
+
i = 0
|
|
252
|
+
while i < len(args):
|
|
253
|
+
a = args[i]
|
|
254
|
+
if a in ("--daily", "--weekly", "--monthly"):
|
|
255
|
+
interval = a.lstrip("-")
|
|
256
|
+
elif a == "--days":
|
|
257
|
+
if i + 1 < len(args):
|
|
258
|
+
i += 1
|
|
259
|
+
try:
|
|
260
|
+
days = max(1, int(args[i]))
|
|
261
|
+
except ValueError:
|
|
262
|
+
console.print(f"[yellow]--days expects a number, got {args[i]!r} — ignored.[/yellow]")
|
|
263
|
+
else:
|
|
264
|
+
console.print("[yellow]--days expects a number — ignored.[/yellow]")
|
|
265
|
+
else:
|
|
266
|
+
query.append(a)
|
|
267
|
+
i += 1
|
|
268
|
+
return query, interval, days
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _start_from_days(days: int | None) -> str | None:
|
|
272
|
+
if days is None:
|
|
273
|
+
return None
|
|
274
|
+
from datetime import date, timedelta
|
|
275
|
+
|
|
276
|
+
return (date.today() - timedelta(days=days)).isoformat()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _fetch_history(fetch, interval: str | None, days: int | None):
|
|
280
|
+
"""Fetch with the resolved interval: an explicit flag is obeyed; otherwise daily, re-fetched
|
|
281
|
+
weekly when the series is long (with a note so the densest view is one flag away)."""
|
|
282
|
+
start = _start_from_days(days)
|
|
283
|
+
h = fetch(start=start, interval=interval or "daily")
|
|
284
|
+
if interval is None and len(h.points) > _AUTO_WEEKLY_OVER:
|
|
285
|
+
h = fetch(start=start, interval="weekly")
|
|
286
|
+
console.print(
|
|
287
|
+
f"[slab.dim]Long series — showing weekly buckets ({len(h.points)} points). "
|
|
288
|
+
f"Use --daily for every point.[/]"
|
|
289
|
+
)
|
|
290
|
+
return h
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_history(args: list[str]) -> None:
|
|
294
|
+
"""Price history for a card — how its value has changed over time.
|
|
295
|
+
|
|
296
|
+
Flags:
|
|
297
|
+
--daily / --weekly / --monthly bucket size (default: daily, auto-weekly for long series)
|
|
298
|
+
--days N only the last N days
|
|
299
|
+
|
|
300
|
+
Examples:
|
|
301
|
+
slab card history bedard
|
|
302
|
+
slab card history bedard --days 90 --daily
|
|
303
|
+
slab card history mcdavid --monthly"""
|
|
304
|
+
from InquirerPy import inquirer
|
|
305
|
+
from InquirerPy.base.control import Choice
|
|
306
|
+
|
|
307
|
+
query, interval, days = _history_options(args)
|
|
308
|
+
card = pick_card(" ".join(query) if query else None)
|
|
309
|
+
if card is None:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# Get available grade buckets from the card's market data
|
|
313
|
+
market = ctx.client.get_card_market(card.uuid)
|
|
314
|
+
if not market.price_points:
|
|
315
|
+
console.print("[yellow]No pricing data available for this card.[/yellow]")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
grade_keys = sorted({pp.grade_key for pp in market.price_points})
|
|
319
|
+
if len(grade_keys) == 1:
|
|
320
|
+
gk = grade_keys[0]
|
|
321
|
+
else:
|
|
322
|
+
gk = inquirer.select(
|
|
323
|
+
message="Grade bucket:",
|
|
324
|
+
choices=[Choice(gk, name=gk) for gk in grade_keys],
|
|
325
|
+
).execute()
|
|
326
|
+
|
|
327
|
+
history = _fetch_history(
|
|
328
|
+
lambda start, interval: ctx.client.get_card_price_history(
|
|
329
|
+
card.uuid, grade_key=gk, start=start, interval=interval),
|
|
330
|
+
interval, days,
|
|
331
|
+
)
|
|
332
|
+
render_price_history(history)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def cmd_set_history(args: list[str]) -> None:
|
|
336
|
+
"""Price history for a sealed product — how a box/case/blaster's value has changed over time.
|
|
337
|
+
|
|
338
|
+
Flags:
|
|
339
|
+
--daily / --weekly / --monthly bucket size (default: daily, auto-weekly for long series)
|
|
340
|
+
--days N only the last N days
|
|
341
|
+
|
|
342
|
+
Examples:
|
|
343
|
+
slab set history allure
|
|
344
|
+
slab set history "series 1" --days 180 --weekly"""
|
|
345
|
+
from InquirerPy import inquirer
|
|
346
|
+
from InquirerPy.base.control import Choice
|
|
347
|
+
|
|
348
|
+
query, interval, days = _history_options(args)
|
|
349
|
+
|
|
350
|
+
sets = _fetch_all_sets()
|
|
351
|
+
if not sets:
|
|
352
|
+
console.print("[yellow]No sets in the catalog yet.[/yellow]")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
chosen = select_set(sets, initial=" ".join(query) if query else None)
|
|
356
|
+
if chosen is None:
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
sealed = ctx.client.get_set_sealed(chosen.uuid)
|
|
360
|
+
if not sealed:
|
|
361
|
+
console.print("[yellow]No sealed products catalogued for this set.[/yellow]")
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
priced = [p for p in sealed if p.price_median is not None]
|
|
365
|
+
if not priced:
|
|
366
|
+
console.print("[yellow]No sealed pricing for this set yet.[/yellow]")
|
|
367
|
+
return
|
|
368
|
+
if len(priced) == 1:
|
|
369
|
+
product = priced[0]
|
|
370
|
+
else:
|
|
371
|
+
labels = {
|
|
372
|
+
p.uuid: f"{p.format.value.replace('_', ' ').title()} · FMV ${p.price_median:,.2f}"
|
|
373
|
+
for p in priced
|
|
374
|
+
}
|
|
375
|
+
uuid = inquirer.select(
|
|
376
|
+
message="Sealed product:",
|
|
377
|
+
choices=[Choice(p.uuid, name=labels[p.uuid]) for p in priced],
|
|
378
|
+
).execute()
|
|
379
|
+
product = next(p for p in priced if p.uuid == uuid)
|
|
380
|
+
|
|
381
|
+
history = _fetch_history(
|
|
382
|
+
lambda start, interval: ctx.client.get_sealed_price_history(
|
|
383
|
+
product.uuid, start=start, interval=interval),
|
|
384
|
+
interval, days,
|
|
385
|
+
)
|
|
386
|
+
render_sealed_price_history(history)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The command registry — the ONE source of truth for the slab CLI's command surface.
|
|
2
|
+
|
|
3
|
+
Every command is declared here exactly once as a `Command` under a `Group`. This single list drives
|
|
4
|
+
BOTH dispatch (main.py routes `slab <group> <verb>` through it) AND the help screens (`slab`,
|
|
5
|
+
`slab <group>`), so the help you read and the commands that run can never disagree. Adding a command
|
|
6
|
+
= adding one line here; the help updates itself. This is deliberately the canonical reference for
|
|
7
|
+
what the CLI can do — external docs should point here rather than restate it.
|
|
8
|
+
|
|
9
|
+
Structure is pure noun-verb (like `git`, `docker`, `gh`): a `<group>` noun, then a `<verb>`. Learn a
|
|
10
|
+
noun and you can guess its verbs; verbs stay consistent across nouns (`search`, `add`, `remove`,
|
|
11
|
+
`dashboard`, `price`…). Everything searchable uses the verb `search` with the SHARED flag grammar
|
|
12
|
+
(`slab_cli.flags`) — the same `--team`/`--year`/… work on `card`, `set`, and `collection` search."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from difflib import get_close_matches
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from . import breaks, catalog, collection, custom_sets, export, lots, pricing, setup
|
|
21
|
+
|
|
22
|
+
Handler = Callable[[list[str]], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Command:
|
|
27
|
+
"""One `slab <group> <verb>` command: the verb, a one-line summary, its args hint, and the
|
|
28
|
+
function that runs it. Summary + args render straight into the help — keep them tight."""
|
|
29
|
+
|
|
30
|
+
verb: str
|
|
31
|
+
summary: str
|
|
32
|
+
handler: Handler
|
|
33
|
+
args: str = ""
|
|
34
|
+
|
|
35
|
+
def usage(self, group: str) -> str:
|
|
36
|
+
"""`slab card search <query>` — the full invocation, args included."""
|
|
37
|
+
base = f"slab {group} {self.verb}"
|
|
38
|
+
return f"{base} {self.args}" if self.args else base
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Group:
|
|
43
|
+
"""A noun (`card`, `collection`, …) and the verbs under it, plus a one-line blurb for the header.
|
|
44
|
+
|
|
45
|
+
`default` names a verb to run when the group is invoked bare (`slab community` → its board);
|
|
46
|
+
groups without one show their help instead — the right behavior for multi-verb nouns."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
blurb: str
|
|
50
|
+
commands: list[Command] = field(default_factory=list)
|
|
51
|
+
default: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- The command surface -----------------------------------------------------------------------
|
|
55
|
+
# Each group is a noun; each Command a verb. This list IS the CLI. Edit here, help follows.
|
|
56
|
+
|
|
57
|
+
GROUPS: list[Group] = [
|
|
58
|
+
Group("card", "look up a card in the catalog", [
|
|
59
|
+
Command("search", "Search the catalog (player, set, team…)", catalog.cmd_search, "[query] [flags]"),
|
|
60
|
+
Command("parallels", "Every parallel (the rainbow) of a card", catalog.cmd_rainbow, "<query>"),
|
|
61
|
+
Command("price", "Market price + 7d/30d trend", pricing.cmd_price, "<query>"),
|
|
62
|
+
Command("comps", "Recent raw sales behind the price", pricing.cmd_comps, "<query>"),
|
|
63
|
+
Command("history", "Price history over time", pricing.cmd_history, "<query>"),
|
|
64
|
+
]),
|
|
65
|
+
Group("set", "browse and price sealed products", [
|
|
66
|
+
Command("search", "Browse sets / products", catalog.cmd_sets, "[query] [flags]"),
|
|
67
|
+
Command("price", "Set market: sealed FMV (case/box/blaster) + top cards", pricing.cmd_set_market, "[query]"),
|
|
68
|
+
Command("history", "Sealed price history over time (box/case/blaster)", pricing.cmd_set_history, "[query]"),
|
|
69
|
+
]),
|
|
70
|
+
Group("community", "everyone's catalog activity", [
|
|
71
|
+
Command("board", "Community board: catalog stats, activity, leaderboards", catalog.cmd_community),
|
|
72
|
+
], default="board"),
|
|
73
|
+
Group("collection", "your inventory", [
|
|
74
|
+
Command("add", "Add card(s) you own", collection.cmd_add),
|
|
75
|
+
Command("search", "Search your collection (same flags as card search)", collection.cmd_search, "[query] [flags]"),
|
|
76
|
+
Command("sell", "Record a sale (mark sold with price)", collection.cmd_sell),
|
|
77
|
+
Command("remove", "Remove a copy", collection.cmd_remove),
|
|
78
|
+
Command("cost", "Log a cost (grading, shipping) on a copy", collection.cmd_cost),
|
|
79
|
+
Command("export", "Export a set to a printable checklist", export.cmd_export),
|
|
80
|
+
Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
|
|
81
|
+
Command("portfolio", "Portfolio: cost basis vs FMV + top movers", pricing.cmd_portfolio),
|
|
82
|
+
]),
|
|
83
|
+
Group("break", "case / box breaks", [
|
|
84
|
+
Command("list", "Your breaks — pick one to see value vs cost + top pulls", breaks.cmd_break_list),
|
|
85
|
+
Command("new", "Record a break", breaks.cmd_break_new),
|
|
86
|
+
Command("fill", "Walk a break's checklist, adding what you pulled", collection.cmd_break_fill),
|
|
87
|
+
Command("remove", "Delete a break (pulled cards stay in your collection)", breaks.cmd_break_remove),
|
|
88
|
+
]),
|
|
89
|
+
Group("lot", "multi-card purchases (eBay bundles)", [
|
|
90
|
+
Command("list", "Your purchases — pick one to see value vs cost + its cards", lots.cmd_lot_list),
|
|
91
|
+
Command("new", "Record a purchase (cost splits across its cards)", lots.cmd_lot_new),
|
|
92
|
+
Command("fill", "Add the cards from one purchase back-to-back", collection.cmd_lot_fill),
|
|
93
|
+
Command("remove", "Delete a purchase (its cards stay in your collection)", lots.cmd_lot_remove),
|
|
94
|
+
]),
|
|
95
|
+
Group("chase", "custom sets & set-completion", [
|
|
96
|
+
Command("create", "Create a custom set (curated or dynamic)", custom_sets.cmd_chase_create),
|
|
97
|
+
Command("add", "Add cards to a curated set", custom_sets.cmd_chase_add),
|
|
98
|
+
Command("view", "View a set with completion + add missing", custom_sets.cmd_chase_view, "[uuid]"),
|
|
99
|
+
Command("edit", "Edit name, description, or visibility", custom_sets.cmd_chase_edit),
|
|
100
|
+
Command("list", "Your sets (created + subscribed)", custom_sets.cmd_chase_list),
|
|
101
|
+
Command("find", "Discover public sets by name", custom_sets.cmd_chase_find, "[query]"),
|
|
102
|
+
Command("subscribe", "Subscribe to / unsubscribe from a public set", custom_sets.cmd_chase_subscribe, "[query]"),
|
|
103
|
+
Command("remove", "Remove a card from a set", custom_sets.cmd_chase_remove),
|
|
104
|
+
Command("delete", "Delete a set you created", custom_sets.cmd_chase_delete),
|
|
105
|
+
]),
|
|
106
|
+
Group("collector", "your identity", [
|
|
107
|
+
Command("create", "Create a collector", setup.cmd_register, "<name>"),
|
|
108
|
+
Command("current", "Show the active collector", setup.cmd_whoami),
|
|
109
|
+
]),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# --- Lookup + suggestion (used by dispatch) ----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
_GROUPS_BY_NAME: dict[str, Group] = {g.name: g for g in GROUPS}
|
|
115
|
+
_COMMANDS: dict[tuple[str, str], Command] = {
|
|
116
|
+
(g.name, c.verb): c for g in GROUPS for c in g.commands
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_group(name: str) -> Group | None:
|
|
121
|
+
return _GROUPS_BY_NAME.get(name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_command(group: str, verb: str) -> Command | None:
|
|
125
|
+
return _COMMANDS.get((group, verb))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def suggest_group(name: str) -> str | None:
|
|
129
|
+
"""Closest group name to a typo, or None — powers 'did you mean …?'."""
|
|
130
|
+
m = get_close_matches(name, list(_GROUPS_BY_NAME), n=1, cutoff=0.6)
|
|
131
|
+
return m[0] if m else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def suggest_verb(group: str, verb: str) -> str | None:
|
|
135
|
+
"""Closest verb within a group to a typo, or None."""
|
|
136
|
+
g = get_group(group)
|
|
137
|
+
if not g:
|
|
138
|
+
return None
|
|
139
|
+
m = get_close_matches(verb, [c.verb for c in g.commands], n=1, cutoff=0.6)
|
|
140
|
+
return m[0] if m else None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Setup commands — register a collector, check identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..config import get_collector_uuid
|
|
6
|
+
from ..context import ctx
|
|
7
|
+
from ..display import console
|
|
8
|
+
from ..prompts import ask_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_register(args: list[str]) -> None:
|
|
12
|
+
name = " ".join(args) if args else ask_text("Collector name:")
|
|
13
|
+
collector = ctx.client.create_collector(name)
|
|
14
|
+
console.print(f"\n[green]Collector created:[/green] {collector.name}")
|
|
15
|
+
console.print(f" UUID: {collector.uuid}")
|
|
16
|
+
console.print(f"\n Now run: [bold]export SLAB_COLLECTOR={collector.uuid}[/bold]")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_whoami(args: list[str]) -> None:
|
|
20
|
+
uuid = get_collector_uuid()
|
|
21
|
+
if uuid:
|
|
22
|
+
console.print(f"Active collector: [bold]{uuid}[/bold]")
|
|
23
|
+
else:
|
|
24
|
+
console.print("[dim]No collector set. Run: slab collector create <name>[/dim]")
|
slab_cli/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""CLI configuration — resolved from env vars or defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_base_url() -> str:
|
|
9
|
+
url = os.environ.get("SLAB_API_URL", "").strip()
|
|
10
|
+
return url or "https://api.slab.dev-jeb.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_api_key() -> str | None:
|
|
14
|
+
key = os.environ.get("SLAB_API_KEY", "").strip()
|
|
15
|
+
return key or None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_collector_uuid() -> str | None:
|
|
19
|
+
"""The active collector UUID. Set once, reused across commands."""
|
|
20
|
+
uuid = os.environ.get("SLAB_COLLECTOR", "").strip()
|
|
21
|
+
return uuid or None
|