slab-cli 0.23.0__tar.gz → 0.25.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.23.0 → slab_cli-0.25.0}/PKG-INFO +2 -2
- {slab_cli-0.23.0 → slab_cli-0.25.0}/pyproject.toml +2 -2
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/client.py +47 -14
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/pricing.py +54 -96
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/registry.py +2 -1
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/display.py +120 -72
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/prompts.py +60 -15
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/theme.py +11 -3
- {slab_cli-0.23.0 → slab_cli-0.25.0}/README.md +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/browse.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/custom_sets.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/main.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/nav.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/__init__.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/base.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/posix.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/platforms/windows.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.23.0 → slab_cli-0.25.0}/src/slab_cli/updates.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: slab-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.25.0
|
|
4
4
|
Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
|
|
5
5
|
Author: dev_jeb
|
|
6
|
-
Requires-Dist: slab-schemas>=0.
|
|
6
|
+
Requires-Dist: slab-schemas>=0.46.0
|
|
7
7
|
Requires-Dist: httpx>=0.27
|
|
8
8
|
Requires-Dist: rich>=13.0
|
|
9
9
|
Requires-Dist: inquirerpy>=0.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "slab-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.25.0"
|
|
4
4
|
description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{name = "dev_jeb"}]
|
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
# 0.34 reframes the grading desk from verdicts to break-even readings (BreakEvenRegion,
|
|
13
13
|
# GradingDesk.break_even) — the desk shows data, never tells the user to grade.
|
|
14
14
|
# 0.35 adds Liquidity/LiquidityLabel, which the market view's Sells column renders.
|
|
15
|
-
"slab-schemas>=0.
|
|
15
|
+
"slab-schemas>=0.46.0", # metrics registry: served momentum/trend labels
|
|
16
16
|
"httpx>=0.27",
|
|
17
17
|
"rich>=13.0",
|
|
18
18
|
"InquirerPy>=0.3",
|
|
@@ -17,6 +17,7 @@ from slab_schemas.cards import (
|
|
|
17
17
|
SetSearchResult,
|
|
18
18
|
)
|
|
19
19
|
from slab_schemas.community import CommunityBoard
|
|
20
|
+
from slab_schemas.serials import CardSerials
|
|
20
21
|
from slab_schemas.collection import (
|
|
21
22
|
BreakCreate,
|
|
22
23
|
BreakOut,
|
|
@@ -26,16 +27,17 @@ from slab_schemas.collection import (
|
|
|
26
27
|
LotOut,
|
|
27
28
|
LotSearchQuery,
|
|
28
29
|
LotSearchResult,
|
|
29
|
-
LotUpdate,
|
|
30
30
|
CardCopyCostCreate,
|
|
31
31
|
CardCopyCostOut,
|
|
32
32
|
CardCopyCostUpdate,
|
|
33
33
|
CardCopyCreate,
|
|
34
34
|
CardCopyOut,
|
|
35
35
|
CardCopyUpdate,
|
|
36
|
+
CollectionGroupQuery,
|
|
36
37
|
CollectionResult,
|
|
37
38
|
CollectionSearchQuery,
|
|
38
39
|
CollectorOut,
|
|
40
|
+
SetGroupResult,
|
|
39
41
|
)
|
|
40
42
|
from slab_schemas.custom_sets import (
|
|
41
43
|
CustomSetCardAdd,
|
|
@@ -48,11 +50,12 @@ from slab_schemas.custom_sets import (
|
|
|
48
50
|
CustomSetSearchResult,
|
|
49
51
|
CustomSetUpdate,
|
|
50
52
|
)
|
|
51
|
-
from slab_schemas.dashboard import
|
|
53
|
+
from slab_schemas.dashboard import DashboardStats
|
|
52
54
|
from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
|
|
53
55
|
from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
|
|
54
56
|
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
55
57
|
from slab_schemas.timeseries import CardPriceHistory
|
|
58
|
+
from slab_schemas.vocab import VocabOut
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
class ApiError(Exception):
|
|
@@ -81,6 +84,8 @@ class SlabClient:
|
|
|
81
84
|
headers["x-api-key"] = api_key
|
|
82
85
|
self._base_url = base_url
|
|
83
86
|
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
|
|
87
|
+
self._vocab: VocabOut | None = None
|
|
88
|
+
self._vocab_fetched = False
|
|
84
89
|
|
|
85
90
|
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
86
91
|
try:
|
|
@@ -151,6 +156,12 @@ class SlabClient:
|
|
|
151
156
|
)
|
|
152
157
|
return CollectionGradingDesk.model_validate(resp.json())
|
|
153
158
|
|
|
159
|
+
def get_card_serials(self, card_uuid: str) -> CardSerials:
|
|
160
|
+
"""Which numbered copies of this printing have surfaced — sales with a stated serial and
|
|
161
|
+
collection copies — grouped by serial (GET /cards/{uuid}/serials)."""
|
|
162
|
+
resp = self._request("GET", f"/cards/{card_uuid}/serials")
|
|
163
|
+
return CardSerials.model_validate(resp.json())
|
|
164
|
+
|
|
154
165
|
def get_card_price_history(
|
|
155
166
|
self,
|
|
156
167
|
card_uuid: str,
|
|
@@ -200,6 +211,29 @@ class SlabClient:
|
|
|
200
211
|
resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
|
|
201
212
|
return SealedPriceHistory.model_validate(resp.json())
|
|
202
213
|
|
|
214
|
+
# --- vocabulary (public) ---
|
|
215
|
+
|
|
216
|
+
def get_vocab(self) -> VocabOut:
|
|
217
|
+
"""Every enumerable value the API accepts or serves — wire enums, sort grammars, and the
|
|
218
|
+
LIVE catalog dimensions (grading companies, attributes) that grow as sets are seeded.
|
|
219
|
+
Raises like any other call; `vocab()` is the forgiving, cached form pickers use."""
|
|
220
|
+
resp = self._request("GET", "/vocab")
|
|
221
|
+
return VocabOut.model_validate(resp.json())
|
|
222
|
+
|
|
223
|
+
def vocab(self) -> VocabOut | None:
|
|
224
|
+
"""The served vocabulary, fetched at most ONCE per process and None when the API can't
|
|
225
|
+
answer (offline, unauthorized, down). Same contract as the portal's `getVocab()`: a
|
|
226
|
+
picker renders the served list when it has one and its static fallback otherwise, so a
|
|
227
|
+
new grading company reaches the CLI with no release — and a dead API never blocks a
|
|
228
|
+
prompt. The miss is cached too, so one failure doesn't retry at every prompt."""
|
|
229
|
+
if not self._vocab_fetched:
|
|
230
|
+
self._vocab_fetched = True
|
|
231
|
+
try:
|
|
232
|
+
self._vocab = self.get_vocab()
|
|
233
|
+
except (ApiError, ApiConnectionError):
|
|
234
|
+
self._vocab = None
|
|
235
|
+
return self._vocab
|
|
236
|
+
|
|
203
237
|
# --- account / identity ---
|
|
204
238
|
|
|
205
239
|
def get_account_context(self) -> MeOut:
|
|
@@ -237,14 +271,6 @@ class SlabClient:
|
|
|
237
271
|
resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
|
|
238
272
|
return LotSearchResult.model_validate(resp.json())
|
|
239
273
|
|
|
240
|
-
def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
|
|
241
|
-
resp = self._request(
|
|
242
|
-
"PATCH",
|
|
243
|
-
f"/collectors/{collector_uuid}/lots/{lot_uuid}",
|
|
244
|
-
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
245
|
-
)
|
|
246
|
-
return LotOut.model_validate(resp.json())
|
|
247
|
-
|
|
248
274
|
def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
|
|
249
275
|
self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
|
|
250
276
|
|
|
@@ -274,6 +300,17 @@ class SlabClient:
|
|
|
274
300
|
resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
|
|
275
301
|
return CollectionResult.model_validate(resp.json())
|
|
276
302
|
|
|
303
|
+
def collection_sets(
|
|
304
|
+
self, collector_uuid: str, query: CollectionGroupQuery | None = None
|
|
305
|
+
) -> SetGroupResult:
|
|
306
|
+
"""The collection rolled up by set — the SERVER's per-set math (copies, distinct cards,
|
|
307
|
+
total value), ranked most valuable first and paged over groups, so a set is never split
|
|
308
|
+
across a page. This is the number the portfolio view renders per set; the CLI must not
|
|
309
|
+
re-derive it from copies (cli/AGENTS.md, "Served numbers, not client math")."""
|
|
310
|
+
body = (query or CollectionGroupQuery()).model_dump(mode="json", exclude_none=True)
|
|
311
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/collection/sets", json=body)
|
|
312
|
+
return SetGroupResult.model_validate(resp.json())
|
|
313
|
+
|
|
277
314
|
# --- costs ---
|
|
278
315
|
|
|
279
316
|
def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
|
|
@@ -308,10 +345,6 @@ class SlabClient:
|
|
|
308
345
|
resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
|
|
309
346
|
return resp.json()
|
|
310
347
|
|
|
311
|
-
def get_catalog_stats(self) -> CatalogStats:
|
|
312
|
-
resp = self._request("GET", "/stats")
|
|
313
|
-
return CatalogStats.model_validate(resp.json())
|
|
314
|
-
|
|
315
348
|
def get_community_board(self, limit: int = 10) -> CommunityBoard:
|
|
316
349
|
resp = self._request("GET", "/community", params={"limit": limit})
|
|
317
350
|
return CommunityBoard.model_validate(resp.json())
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from slab_schemas.cards import SetSearchQuery
|
|
6
|
-
from slab_schemas.collection import
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
from slab_schemas.cards import CardOut, SetOut, SetSearchQuery
|
|
6
|
+
from slab_schemas.collection import (
|
|
7
|
+
CollectionGroupQuery,
|
|
8
|
+
CollectionSearchQuery,
|
|
9
|
+
PortfolioSummary,
|
|
10
|
+
SetGroupOut,
|
|
11
|
+
)
|
|
9
12
|
|
|
10
13
|
from ..context import MissingCollector, ctx
|
|
11
14
|
from ..display import (
|
|
@@ -17,6 +20,7 @@ from ..display import (
|
|
|
17
20
|
render_portfolio,
|
|
18
21
|
render_price_history,
|
|
19
22
|
render_sealed_price_history,
|
|
23
|
+
render_serials,
|
|
20
24
|
render_set_market,
|
|
21
25
|
)
|
|
22
26
|
from ..paging import fetch_all_pages
|
|
@@ -71,6 +75,17 @@ def cmd_comps(args: list[str]) -> None:
|
|
|
71
75
|
render_comps(ctx.client.get_card_comps(card.uuid))
|
|
72
76
|
|
|
73
77
|
|
|
78
|
+
def cmd_serials(args: list[str]) -> None:
|
|
79
|
+
"""Which numbered copies of a card have surfaced. Same player → set → card flow as `price`,
|
|
80
|
+
then one row per serial seen: how many collections hold it, how many times it has sold, and
|
|
81
|
+
its last sale. Sales and held copies are shown apart — the collector who bought #24 on eBay
|
|
82
|
+
is one transfer seen from both sides. Unnumbered printings say so and stop."""
|
|
83
|
+
card = _pick_card(args)
|
|
84
|
+
if card is None:
|
|
85
|
+
return
|
|
86
|
+
render_serials(ctx.client.get_card_serials(card.uuid))
|
|
87
|
+
|
|
88
|
+
|
|
74
89
|
def _split_desk_flags(args: list[str]) -> tuple[list[str], float | None, str | None]:
|
|
75
90
|
"""Pull `--fee <amount>` and `--company <name>` out of a grading query. Hand-rolled (the
|
|
76
91
|
shared flag grammar is search-specific); an unparseable fee errors loudly rather than
|
|
@@ -116,57 +131,34 @@ def cmd_grade_sweep(args: list[str]) -> None:
|
|
|
116
131
|
)
|
|
117
132
|
|
|
118
133
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
# How many top movers the portfolio view lists, and the page it reads them from: the server's
|
|
135
|
+
# `-unrealized` sort puts the biggest paper gains first (unpriced copies have no gain and sort
|
|
136
|
+
# last), so one page is the whole answer — no need to pull every copy to find the top ten.
|
|
137
|
+
_MOVERS_SHOWN = 10
|
|
138
|
+
_MOVERS_PAGE = 25
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fetch_set_groups(page: int = 200, cap: int = 2000) -> list[SetGroupOut]:
|
|
142
|
+
"""Every set the collection holds from, as the server's grouped rollup — copies, distinct
|
|
143
|
+
cards, and total value per set, most valuable first. Pages over GROUPS (a set is never split
|
|
144
|
+
across a page boundary) as headers only (`include_copies=False`); `cap` bounds the paging if a
|
|
145
|
+
collection somehow spans thousands of products."""
|
|
146
|
+
groups, _ = fetch_all_pages(
|
|
147
|
+
lambda limit, offset: ctx.client.collection_sets(
|
|
148
|
+
ctx.collector,
|
|
149
|
+
CollectionGroupQuery(
|
|
150
|
+
group_sort="-value", include_copies=False, limit=limit, offset=offset
|
|
151
|
+
),
|
|
127
152
|
),
|
|
128
153
|
page=page,
|
|
129
154
|
cap=cap,
|
|
130
155
|
)
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _aggregate_by_set(copies: list) -> list[dict]:
|
|
135
|
-
"""Roll copies up per set, mirroring the server's headline math so the parts reconcile with the
|
|
136
|
-
whole: cost basis is summed per-copy, FMV is quantity-weighted, comp coverage is copy-weighted."""
|
|
137
|
-
sets: dict[str, dict] = {}
|
|
138
|
-
for cp in copies:
|
|
139
|
-
card = cp.card
|
|
140
|
-
name = (card.set_name if card else None) or "— Unknown set —"
|
|
141
|
-
s = sets.setdefault(name, {
|
|
142
|
-
"name": name, "copies": 0, "total_qty": 0, "priced_qty": 0,
|
|
143
|
-
"cost_basis": 0.0, "fmv": 0.0, "has_fmv": False,
|
|
144
|
-
})
|
|
145
|
-
qty = cp.quantity or 1
|
|
146
|
-
s["copies"] += 1
|
|
147
|
-
s["total_qty"] += qty
|
|
148
|
-
if cp.cost_basis is not None:
|
|
149
|
-
s["cost_basis"] += float(cp.cost_basis)
|
|
150
|
-
if cp.market and cp.market.fair_market_value is not None:
|
|
151
|
-
s["fmv"] += float(cp.market.fair_market_value) * qty
|
|
152
|
-
s["priced_qty"] += qty
|
|
153
|
-
s["has_fmv"] = True
|
|
154
|
-
|
|
155
|
-
for s in sets.values():
|
|
156
|
-
s["unrealized"] = (s["fmv"] - s["cost_basis"]) if s["has_fmv"] else None
|
|
157
|
-
s["comp_pct"] = (s["priced_qty"] / s["total_qty"] * 100) if s["total_qty"] else 0.0
|
|
158
|
-
|
|
159
|
-
# Most valuable first; sets with no market data sink to the bottom (sorted by cost basis).
|
|
160
|
-
return sorted(
|
|
161
|
-
sets.values(),
|
|
162
|
-
key=lambda s: (s["has_fmv"], s["fmv"] if s["has_fmv"] else s["cost_basis"]),
|
|
163
|
-
reverse=True,
|
|
164
|
-
)
|
|
156
|
+
return groups
|
|
165
157
|
|
|
166
158
|
|
|
167
159
|
def _portfolio_totals(summary: PortfolioSummary) -> dict:
|
|
168
160
|
"""Grand totals for the valuation panel, straight from the server's summary block — the same
|
|
169
|
-
|
|
161
|
+
numbers `GET .../dashboard` serves, so the two never diverge."""
|
|
170
162
|
fmv = summary.portfolio_value
|
|
171
163
|
return {
|
|
172
164
|
"cost_basis": float(summary.total_cost_basis or 0),
|
|
@@ -181,67 +173,33 @@ def _portfolio_totals(summary: PortfolioSummary) -> dict:
|
|
|
181
173
|
}
|
|
182
174
|
|
|
183
175
|
|
|
184
|
-
def _most_valuable_break(copies: list, breaks: list) -> dict | None:
|
|
185
|
-
"""The break whose pulled copies carry the most market value — did opening that box pay off?
|
|
186
|
-
FMV is summed from the copies we already priced; cost/metadata comes from the breaks list."""
|
|
187
|
-
fmv_by_break: dict[str, float] = {}
|
|
188
|
-
priced_by_break: dict[str, int] = {}
|
|
189
|
-
for cp in copies:
|
|
190
|
-
if cp.break_uuid and cp.market and cp.market.fair_market_value is not None:
|
|
191
|
-
qty = cp.quantity or 1
|
|
192
|
-
fmv_by_break[cp.break_uuid] = (
|
|
193
|
-
fmv_by_break.get(cp.break_uuid, 0.0) + float(cp.market.fair_market_value) * qty
|
|
194
|
-
)
|
|
195
|
-
priced_by_break[cp.break_uuid] = priced_by_break.get(cp.break_uuid, 0) + qty
|
|
196
|
-
|
|
197
|
-
if not fmv_by_break:
|
|
198
|
-
return None
|
|
199
|
-
|
|
200
|
-
top_uuid = max(fmv_by_break, key=fmv_by_break.get)
|
|
201
|
-
brk = next((b for b in breaks if b.uuid == top_uuid), None)
|
|
202
|
-
if brk is None:
|
|
203
|
-
return None
|
|
204
|
-
|
|
205
|
-
cost = float(brk.total_cost)
|
|
206
|
-
fmv = fmv_by_break[top_uuid]
|
|
207
|
-
return {
|
|
208
|
-
"set_name": brk.set_name or "— Unknown set —",
|
|
209
|
-
"break_type": brk.break_type,
|
|
210
|
-
"break_date": brk.break_date,
|
|
211
|
-
"cost": cost,
|
|
212
|
-
"fmv": fmv,
|
|
213
|
-
"gain": fmv - cost,
|
|
214
|
-
"copy_count": brk.copy_count,
|
|
215
|
-
"priced": priced_by_break.get(top_uuid, 0),
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
176
|
def cmd_portfolio(args: list[str]) -> None:
|
|
220
|
-
"""Portfolio-level financial summary — cost basis vs market value, per-set
|
|
221
|
-
|
|
222
|
-
|
|
177
|
+
"""Portfolio-level financial summary — cost basis vs market value, the per-set rollup, and
|
|
178
|
+
the top movers — every number as the SERVER computed it. Grand totals are the response's
|
|
179
|
+
`summary` block (the dashboard's math), the per-set table is `POST .../collection/sets`, and
|
|
180
|
+
the movers are the first page of the `-unrealized` sort. Nothing is re-derived from copies:
|
|
181
|
+
the old per-set client math paged a capped 5,000 copies and summed them, which both duplicated
|
|
182
|
+
the dashboard's definitions and went quietly wrong past the cap."""
|
|
183
|
+
page = ctx.client.search_collection(
|
|
184
|
+
ctx.collector, CollectionSearchQuery(sort="-unrealized", limit=_MOVERS_PAGE)
|
|
185
|
+
)
|
|
186
|
+
if not page.items:
|
|
223
187
|
console.print("[dim]No cards in your collection yet.[/dim]")
|
|
224
188
|
return
|
|
225
|
-
if summary is None:
|
|
189
|
+
if page.summary is None:
|
|
226
190
|
# The API always includes the summary block; its absence is a server bug — say so
|
|
227
191
|
# rather than invent numbers client-side.
|
|
228
192
|
console.print("[red]Server response had no portfolio summary — can't value the collection.[/red]")
|
|
229
193
|
return
|
|
230
194
|
|
|
231
|
-
|
|
232
|
-
totals = _portfolio_totals(summary)
|
|
233
|
-
|
|
234
|
-
# Top movers: copies with real unrealized P&L, biggest gain first (already sorted by the fetch).
|
|
195
|
+
# Top movers: copies with real unrealized P&L, biggest gain first (the server's sort).
|
|
235
196
|
movers = [
|
|
236
|
-
cp for cp in
|
|
197
|
+
cp for cp in page.items
|
|
237
198
|
if cp.market and cp.market.unrealized_gain_loss is not None
|
|
238
|
-
][:
|
|
239
|
-
|
|
240
|
-
breaks = ctx.client.search_breaks(ctx.collector).items
|
|
241
|
-
mvb = _most_valuable_break(copies, breaks)
|
|
199
|
+
][:_MOVERS_SHOWN] or None
|
|
242
200
|
|
|
243
201
|
render_portfolio(
|
|
244
|
-
|
|
202
|
+
page.total, _portfolio_totals(page.summary), movers=movers, by_set=_fetch_set_groups()
|
|
245
203
|
)
|
|
246
204
|
|
|
247
205
|
|
|
@@ -61,6 +61,7 @@ GROUPS: list[Group] = [
|
|
|
61
61
|
Command("price", "Market price + 7d/30d trend", pricing.cmd_price, "<query>"),
|
|
62
62
|
Command("grade", "The grading math: payoffs per grade + break-even", pricing.cmd_grade, "<query> [--fee N]"),
|
|
63
63
|
Command("comps", "Recent raw sales behind the price", pricing.cmd_comps, "<query>"),
|
|
64
|
+
Command("serials", "Which numbered copies have surfaced (sales + collections)", pricing.cmd_serials, "<query>"),
|
|
64
65
|
Command("history", "Price history over time", pricing.cmd_history, "<query>"),
|
|
65
66
|
]),
|
|
66
67
|
Group("set", "browse and price sealed products", [
|
|
@@ -79,7 +80,7 @@ GROUPS: list[Group] = [
|
|
|
79
80
|
Command("cost", "Log a cost (grading, shipping) on a copy", collection.cmd_cost),
|
|
80
81
|
Command("export", "Export a set to a printable checklist", export.cmd_export),
|
|
81
82
|
Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
|
|
82
|
-
Command("portfolio", "Portfolio: cost basis vs FMV
|
|
83
|
+
Command("portfolio", "Portfolio: cost basis vs FMV, by set, top movers", pricing.cmd_portfolio),
|
|
83
84
|
Command("grade", "Grading math for every raw copy, best upside first", pricing.cmd_grade_sweep, "[--fee N]"),
|
|
84
85
|
]),
|
|
85
86
|
Group("break", "case / box breaks", [
|
|
@@ -6,7 +6,7 @@ from rich.console import Group
|
|
|
6
6
|
from rich.text import Text
|
|
7
7
|
|
|
8
8
|
from slab_schemas.cards import CardOut, CardSearchResult, SetOut, SetSearchResult
|
|
9
|
-
from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult
|
|
9
|
+
from slab_schemas.collection import BreakSearchResult, CollectionResult, LotSearchResult, SetGroupOut
|
|
10
10
|
from slab_schemas.community import (
|
|
11
11
|
CollectedCard,
|
|
12
12
|
CollectedPlayer,
|
|
@@ -22,8 +22,10 @@ from slab_schemas.dashboard import CatalogStats, DashboardStats, HighlightCard,
|
|
|
22
22
|
from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
|
|
23
23
|
from slab_schemas.enums import BreakEvenRegion, Grade, LiquidityLabel, SealedFormat
|
|
24
24
|
from slab_schemas.liquidity import Liquidity
|
|
25
|
+
from slab_schemas.metrics import momentum_label, trend_label
|
|
25
26
|
from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTopCards
|
|
26
27
|
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
28
|
+
from slab_schemas.serials import CardSerials
|
|
27
29
|
from slab_schemas.timeseries import CardPriceHistory
|
|
28
30
|
|
|
29
31
|
from .browse import Page, browse
|
|
@@ -504,10 +506,29 @@ def _pull_table(pulls) -> object:
|
|
|
504
506
|
return table
|
|
505
507
|
|
|
506
508
|
|
|
509
|
+
# A break's or lot's "worth today" is CLIENT math: FMV summed over the copies in the response.
|
|
510
|
+
# There is no server number for it (the API prices copies, not containers), so this is the one
|
|
511
|
+
# place the CLI adds market values up itself — and it says exactly what it added, from this ONE
|
|
512
|
+
# string, on both report cards. It must never read as the dashboard's portfolio math: that is
|
|
513
|
+
# as-of, full-collection, and served (`cli/AGENTS.md`, "Served numbers, not client math").
|
|
514
|
+
PAGE_NET_SCOPE = "priced copies on this page only; unpriced count as $0"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _net_scope_line(result: CollectionResult, unpriced: int) -> str:
|
|
518
|
+
"""The honest footnote under a break/lot money line: what the total covers and what it skips."""
|
|
519
|
+
parts = [f"Worth today = {PAGE_NET_SCOPE}"]
|
|
520
|
+
if unpriced:
|
|
521
|
+
parts.append(f"{unpriced} unpriced here, so the real total can only be higher")
|
|
522
|
+
if result.total > len(result.items):
|
|
523
|
+
parts.append(f"{len(result.items)} of {result.total} copies shown")
|
|
524
|
+
return f" [slab.dim]{' · '.join(parts)}.[/]"
|
|
525
|
+
|
|
526
|
+
|
|
507
527
|
def render_break_pulls(brk, result: CollectionResult) -> None:
|
|
508
528
|
"""A break's report card: what you paid vs what the pulls are worth today, then the most
|
|
509
529
|
valuable pulls and the rarest (lowest print run). Every number in plain words — this view
|
|
510
|
-
answers "did the box pay for itself?" at a glance.""
|
|
530
|
+
answers "did the box pay for itself?" at a glance. "Worth today" is summed here from the
|
|
531
|
+
page's priced copies (`PAGE_NET_SCOPE`), and labeled so."""
|
|
511
532
|
console.print()
|
|
512
533
|
t = Text()
|
|
513
534
|
t.append(brk.set_name or "Break", style="slab.value")
|
|
@@ -534,9 +555,7 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
|
|
|
534
555
|
f"\n You paid [slab.value]{money(brk.total_cost)}[/] · your {len(result.items)} pulls are "
|
|
535
556
|
f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
|
|
536
557
|
)
|
|
537
|
-
|
|
538
|
-
console.print(f" [slab.dim]{unpriced} pull(s) have no market price yet and count as $0 here — "
|
|
539
|
-
f"the real total can only be higher.[/]")
|
|
558
|
+
console.print(_net_scope_line(result, unpriced))
|
|
540
559
|
|
|
541
560
|
top = sorted(priced, key=_fmv, reverse=True)[:10]
|
|
542
561
|
if top:
|
|
@@ -588,7 +607,7 @@ def browse_lots(result: LotSearchResult, *, detail=None) -> None:
|
|
|
588
607
|
def render_lot_contents(lot, result: CollectionResult) -> None:
|
|
589
608
|
"""A purchase's report card: what you paid vs what the cards are worth today, then the most
|
|
590
609
|
valuable and rarest cards in it. Answers "did that bundle come out ahead?" at a glance — the
|
|
591
|
-
lot counterpart of `render_break_pulls
|
|
610
|
+
lot counterpart of `render_break_pulls`, same `PAGE_NET_SCOPE` label on its total."""
|
|
592
611
|
console.print()
|
|
593
612
|
t = Text()
|
|
594
613
|
t.append(f"Purchase {lot.uuid[:8]}", style="slab.value")
|
|
@@ -613,9 +632,7 @@ def render_lot_contents(lot, result: CollectionResult) -> None:
|
|
|
613
632
|
f"\n You paid [slab.value]{money(lot.total_cost)}[/] · its {len(result.items)} cards are "
|
|
614
633
|
f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
|
|
615
634
|
)
|
|
616
|
-
|
|
617
|
-
console.print(f" [slab.dim]{unpriced} card(s) have no market price yet and count as $0 here — "
|
|
618
|
-
f"the real total can only be higher.[/]")
|
|
635
|
+
console.print(_net_scope_line(result, unpriced))
|
|
619
636
|
|
|
620
637
|
top = sorted(priced, key=_fmv, reverse=True)[:10]
|
|
621
638
|
if top:
|
|
@@ -865,26 +882,39 @@ def _collected_players_table(entries: list[CollectedPlayer]) -> Group | None:
|
|
|
865
882
|
return titled("Most Collected Players", table)
|
|
866
883
|
|
|
867
884
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
885
|
+
# The hottest-players badges render the label the SERVER computed (`HotPlayer.momentum` /
|
|
886
|
+
# `HotPlayer.trend`). The thresholds that turn a ratio into "up" are the API's metric
|
|
887
|
+
# definitions (`slab_schemas.metrics`), not the CLI's to copy: three clients once each carried
|
|
888
|
+
# their own ±2% band and disagreed. When an older server omits the fields, the fallback is the
|
|
889
|
+
# registry's own functions, imported — the same rule, never a restated literal.
|
|
890
|
+
# `tests/test_hot_labels.py` asserts no threshold number lives in this module.
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _hot_momentum(e: HotPlayer) -> str:
|
|
894
|
+
"""The window-over-window badge in plain terms: 'new' (was silent), ×N up, ×N down, or steady.
|
|
895
|
+
The label is served; the ×N beside it is the served counts' ratio, shown for scale."""
|
|
896
|
+
cur, prev = e.sales_30d, e.sales_prev_30d
|
|
897
|
+
label = e.momentum or momentum_label(cur, prev)
|
|
898
|
+
if label == "new":
|
|
871
899
|
return "[slab.accent]new[/]"
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
if ratio <= 0.67:
|
|
900
|
+
if label == "up":
|
|
901
|
+
return f"[slab.accent]↑{cur / prev:.1f}×[/]" if prev else "[slab.accent]↑[/]"
|
|
902
|
+
if label == "down":
|
|
876
903
|
return f"[slab.dim]↓{prev / cur:.1f}×[/]" if cur else "[slab.dim]↓[/]"
|
|
877
904
|
return "[slab.dim]steady[/]"
|
|
878
905
|
|
|
879
906
|
|
|
880
|
-
def _hot_trend(
|
|
881
|
-
"""Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down).
|
|
882
|
-
|
|
907
|
+
def _hot_trend(e: HotPlayer) -> str:
|
|
908
|
+
"""Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down).
|
|
909
|
+
The direction is served; the percent beside it is the served `price_trend_pct`."""
|
|
910
|
+
pct = e.price_trend_pct
|
|
911
|
+
label = e.trend or trend_label(pct)
|
|
912
|
+
if pct is None or label is None:
|
|
883
913
|
return "[slab.dim]—[/]"
|
|
884
|
-
if
|
|
885
|
-
return f"[slab.gain]↗
|
|
886
|
-
if
|
|
887
|
-
return f"[slab.loss]↘ {pct
|
|
914
|
+
if label == "up":
|
|
915
|
+
return f"[slab.gain]↗ {pct:+.0f}%[/]"
|
|
916
|
+
if label == "down":
|
|
917
|
+
return f"[slab.loss]↘ {pct:+.0f}%[/]"
|
|
888
918
|
return f"[slab.dim]→ {pct:+.0f}%[/]"
|
|
889
919
|
|
|
890
920
|
|
|
@@ -903,10 +933,10 @@ def _hottest_players_table(entries: list[HotPlayer]) -> Group | None:
|
|
|
903
933
|
table.add_row(
|
|
904
934
|
e.name,
|
|
905
935
|
str(e.sales_30d),
|
|
906
|
-
_hot_momentum(e
|
|
936
|
+
_hot_momentum(e),
|
|
907
937
|
money(e.dollar_volume_30d),
|
|
908
938
|
str(e.distinct_cards_30d),
|
|
909
|
-
_hot_trend(e
|
|
939
|
+
_hot_trend(e),
|
|
910
940
|
)
|
|
911
941
|
return titled("🔥 Hottest Players", table)
|
|
912
942
|
|
|
@@ -1254,6 +1284,51 @@ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCard
|
|
|
1254
1284
|
console.print(" [slab.dim]Drill in with `slab card price` / `slab card comps`.[/]\n")
|
|
1255
1285
|
|
|
1256
1286
|
|
|
1287
|
+
def render_serials(s: CardSerials) -> None:
|
|
1288
|
+
"""Which numbered copies of a printing have surfaced — one row per serial, sales and held
|
|
1289
|
+
copies kept apart (the same transfer can be seen from both sides). The meaning text comes
|
|
1290
|
+
from the response's glossary, so the CLI, the site and the API say the same words."""
|
|
1291
|
+
subjects = ", ".join(s.subjects) or "—"
|
|
1292
|
+
console.print()
|
|
1293
|
+
console.print(_card_headline(s.card_number, subjects, s.set_name, _slot(s)))
|
|
1294
|
+
|
|
1295
|
+
if not s.print_run:
|
|
1296
|
+
console.print("\n [slab.dim]This printing isn't serial-numbered, so there are no serials to track.[/]\n")
|
|
1297
|
+
return
|
|
1298
|
+
|
|
1299
|
+
unseen = max(0, s.print_run - s.serials_seen)
|
|
1300
|
+
pct = f" ({s.coverage_pct:.0f}%)" if s.coverage_pct is not None else ""
|
|
1301
|
+
console.print(heading("Serials Seen", f"{s.serials_seen} of /{s.print_run}{pct} · {unseen} never surfaced"))
|
|
1302
|
+
if not s.serials:
|
|
1303
|
+
console.print(" [slab.dim]No numbered copy of this card has surfaced in a sale or a collection yet.[/]\n")
|
|
1304
|
+
return
|
|
1305
|
+
|
|
1306
|
+
table = slab_table([
|
|
1307
|
+
("Serial", {"justify": "right", "style": "slab.foil"}),
|
|
1308
|
+
("Held", {"justify": "right"}),
|
|
1309
|
+
("Sales", {"justify": "right"}),
|
|
1310
|
+
("Last sale", {"justify": "right", "style": "slab.foil"}),
|
|
1311
|
+
("Grade", {"style": "slab.dim"}),
|
|
1312
|
+
("Last seen", {"style": "slab.dim"}),
|
|
1313
|
+
])
|
|
1314
|
+
for sig in s.serials:
|
|
1315
|
+
last = sig.sales[-1] if sig.sales else None
|
|
1316
|
+
table.add_row(
|
|
1317
|
+
f"{sig.serial_number}/{s.print_run}",
|
|
1318
|
+
str(sig.held) if sig.held else "[slab.dim]—[/]",
|
|
1319
|
+
str(len(sig.sales)) if sig.sales else "[slab.dim]—[/]",
|
|
1320
|
+
money(last.sale_price) if last and last.sale_price is not None else "—",
|
|
1321
|
+
(last.grade_key or "—") if last else "—",
|
|
1322
|
+
sig.last_seen.isoformat() if sig.last_seen else "—",
|
|
1323
|
+
)
|
|
1324
|
+
console.print(table)
|
|
1325
|
+
for key in ("serials.coverage", "serials.held"):
|
|
1326
|
+
info = s.glossary.get(key)
|
|
1327
|
+
if info:
|
|
1328
|
+
console.print(f" [slab.dim]{info.label}: {info.summary}[/]")
|
|
1329
|
+
console.print()
|
|
1330
|
+
|
|
1331
|
+
|
|
1257
1332
|
def render_comps(c: CardComps) -> None:
|
|
1258
1333
|
"""Display recent comps (raw sales) for a catalog card — the evidence behind its FMV."""
|
|
1259
1334
|
subjects = ", ".join(c.subjects) or "—"
|
|
@@ -1442,67 +1517,41 @@ def render_collection_grading_desk(sweep: CollectionGradingDesk) -> None:
|
|
|
1442
1517
|
console.print()
|
|
1443
1518
|
|
|
1444
1519
|
|
|
1445
|
-
def _render_portfolio_by_set(by_set: list | None) -> None:
|
|
1446
|
-
"""Per-set breakdown
|
|
1520
|
+
def _render_portfolio_by_set(by_set: list[SetGroupOut] | None) -> None:
|
|
1521
|
+
"""Per-set breakdown straight from the server's grouped rollup (`POST .../collection/sets`):
|
|
1522
|
+
copies held, distinct cards, and what they're worth, most valuable first. Only SERVED columns —
|
|
1523
|
+
there is no per-set cost basis, gain, or comps coverage here because the API computes none,
|
|
1524
|
+
and a client-side version was a second, drifting definition of the dashboard's math (it
|
|
1525
|
+
summed a capped page of copies and called it the set)."""
|
|
1447
1526
|
if not by_set:
|
|
1448
1527
|
return
|
|
1449
1528
|
|
|
1450
1529
|
table = slab_table([
|
|
1451
1530
|
("Set", {"style": "slab.value", "no_wrap": False}),
|
|
1531
|
+
("Copies", {"justify": "right", "style": "slab.dim"}),
|
|
1452
1532
|
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
1453
|
-
("
|
|
1454
|
-
("FMV", {"justify": "right"}),
|
|
1455
|
-
("Gain/Loss", {"justify": "right"}),
|
|
1456
|
-
("Comps", {"justify": "right"}),
|
|
1533
|
+
("Value", {"justify": "right"}),
|
|
1457
1534
|
])
|
|
1458
1535
|
|
|
1459
|
-
for i,
|
|
1460
|
-
# crown the single most valuable set
|
|
1461
|
-
name = f"[slab.foil]★[/] {
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
fmv = f"[slab.foil]{money(st['fmv'])}[/]" if st["has_fmv"] else "[slab.dim]—[/]"
|
|
1465
|
-
gain = signed_money(st["unrealized"])
|
|
1466
|
-
comps = pct_coverage(st["comp_pct"])
|
|
1536
|
+
for i, g in enumerate(by_set):
|
|
1537
|
+
# crown the single most valuable set (the server ranks by value; a None value is unpriced)
|
|
1538
|
+
name = f"[slab.foil]★[/] {g.name}" if i == 0 and g.total_value is not None else g.name
|
|
1539
|
+
value = f"[slab.foil]{money(g.total_value)}[/]" if g.total_value is not None else "[slab.dim]—[/]"
|
|
1540
|
+
table.add_row(name, f"{g.copy_count:,}", f"{g.card_count:,}", value)
|
|
1467
1541
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
console.print(heading("By Set", "most valuable first"))
|
|
1542
|
+
console.print(heading("By Set", "most valuable first · unpriced copies add nothing to Value"))
|
|
1471
1543
|
console.print(table)
|
|
1472
1544
|
|
|
1473
1545
|
|
|
1474
|
-
def _render_most_valuable_break(mvb: dict | None) -> None:
|
|
1475
|
-
"""Highlight the single break whose pulled cards carry the most market value — did it pay off?"""
|
|
1476
|
-
if not mvb:
|
|
1477
|
-
return
|
|
1478
|
-
|
|
1479
|
-
date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
|
|
1480
|
-
title = Text()
|
|
1481
|
-
title.append(mvb["set_name"], style="slab.value")
|
|
1482
|
-
title.append(f" {mvb['break_type'].replace('_', ' ')}{date_str}", style="slab.dim")
|
|
1483
|
-
|
|
1484
|
-
g = mvb["gain"]
|
|
1485
|
-
verdict = "paid off" if g >= 0 else "underwater"
|
|
1486
|
-
roi = f" [slab.dim]({'+' if g >= 0 else '-'}{abs(g) / mvb['cost'] * 100:.0f}%)[/]" if mvb["cost"] else ""
|
|
1487
|
-
|
|
1488
|
-
body = kv_grid([
|
|
1489
|
-
("Cost", f"{money(mvb['cost'])} [slab.dim]({_n(mvb['copy_count'], 'card')})[/]"),
|
|
1490
|
-
("Market Value", f"[slab.foil]{money(mvb['fmv'])}[/] [slab.dim]({mvb['priced']} priced)[/]"),
|
|
1491
|
-
("Net vs Cost", f"{signed_money(g)}{roi} [slab.dim]· {verdict}[/]"),
|
|
1492
|
-
])
|
|
1493
|
-
|
|
1494
|
-
console.print(heading("Most Valuable Break"))
|
|
1495
|
-
console.print(slab_panel(Group(title, Text(""), body), foil=True))
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
1546
|
def render_portfolio(
|
|
1499
|
-
total: int, totals: dict, movers: list | None = None, by_set: list | None = None,
|
|
1500
|
-
most_valuable_break: dict | None = None,
|
|
1547
|
+
total: int, totals: dict, movers: list | None = None, by_set: list[SetGroupOut] | None = None,
|
|
1501
1548
|
) -> None:
|
|
1502
|
-
"""Display portfolio-level financial summary, per-set
|
|
1549
|
+
"""Display portfolio-level financial summary, the per-set rollup, and optional top movers.
|
|
1503
1550
|
|
|
1504
|
-
`totals` is the grand-total dict (cost_basis,
|
|
1505
|
-
built from the
|
|
1551
|
+
Every number here is one the server computed: `totals` is the grand-total dict (cost_basis,
|
|
1552
|
+
fmv, unrealized, roi, priced_qty, total_qty) built from the response's `PortfolioSummary`
|
|
1553
|
+
(see `_portfolio_totals`), `by_set` is the served `SetGroupOut` rollup, and `movers` is the
|
|
1554
|
+
first page of the server's `-unrealized` sort."""
|
|
1506
1555
|
rows: list[tuple[str, str]] = [("Cost Basis", money(totals["cost_basis"]))]
|
|
1507
1556
|
|
|
1508
1557
|
if totals["fmv"] is not None:
|
|
@@ -1529,7 +1578,6 @@ def render_portfolio(
|
|
|
1529
1578
|
console.print(slab_panel(kv_grid(rows), foil=True))
|
|
1530
1579
|
|
|
1531
1580
|
_render_portfolio_by_set(by_set)
|
|
1532
|
-
_render_most_valuable_break(most_valuable_break)
|
|
1533
1581
|
|
|
1534
1582
|
# Top movers
|
|
1535
1583
|
if movers:
|
|
@@ -12,6 +12,7 @@ from typing import Protocol
|
|
|
12
12
|
|
|
13
13
|
from InquirerPy.base.control import Choice
|
|
14
14
|
|
|
15
|
+
from .context import ctx
|
|
15
16
|
from .nav import inquirer # the nav proxy: every prompt gains Esc=back / Ctrl-C=quit
|
|
16
17
|
|
|
17
18
|
from slab_schemas.cards import CardOut, SetOut, SetSearchResult
|
|
@@ -435,22 +436,69 @@ def select_grade(message: str = "Your grade assessment:") -> str | None:
|
|
|
435
436
|
return inquirer.select(message=message, choices=choices, max_height="70%").execute()
|
|
436
437
|
|
|
437
438
|
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
# Served-vocab pickers
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# These lists come from GET /vocab when the API answers and fall back to the literals below when it
|
|
443
|
+
# doesn't — the portal's `getVocab()` pattern (portal/src/lib/vocab.ts). Grading companies are LIVE
|
|
444
|
+
# catalog rows (a new one appears when a set is seeded), and the wire enums can grow in a schemas
|
|
445
|
+
# release the installed CLI predates; either way the served list is the truth and the fallback is
|
|
446
|
+
# only what this build knew. The label maps are the human wording for the values we know about; an
|
|
447
|
+
# unknown served value still renders (title-cased) rather than being silently dropped.
|
|
448
|
+
|
|
449
|
+
_GRADING_COMPANY_FALLBACK = ["PSA", "BGS", "SGC", "CGC"]
|
|
450
|
+
_GRADING_COMPANY_LABELS = {"BGS": "BGS (Beckett)"}
|
|
451
|
+
|
|
452
|
+
_MATCH_MODE_FALLBACK = ["exact", "any_printing", "exact_serial"]
|
|
453
|
+
_MATCH_MODE_LABELS = {
|
|
454
|
+
"exact": "Exact — this specific printing (parallel/finish)",
|
|
455
|
+
"any_printing": "Any printing — any version of this card slot",
|
|
456
|
+
"exact_serial": "Exact serial — this card with a specific serial number",
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
_VISIBILITY_FALLBACK = ["private", "public"]
|
|
460
|
+
_VISIBILITY_LABELS = {
|
|
461
|
+
"private": "Private — only you can see it",
|
|
462
|
+
"public": "Public — any collector can find and subscribe",
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def vocab_values(
|
|
467
|
+
field: str, fallback: list[str], vocab=None, *, exclude: frozenset[str] = frozenset()
|
|
468
|
+
) -> list[str]:
|
|
469
|
+
"""The served list for one vocab field, else `fallback`. Pure: pass `vocab` (a VocabOut, or
|
|
470
|
+
None for "the API didn't answer") to test it; the pickers pass the client's cached one. A
|
|
471
|
+
served list that is empty (a catalog with no grading companies yet) also falls back, so a
|
|
472
|
+
prompt never renders with nothing to pick. `exclude` drops values this picker must not offer
|
|
473
|
+
because another flow owns them (applied to served and fallback alike)."""
|
|
474
|
+
values = getattr(vocab, field, None) if vocab is not None else None
|
|
475
|
+
chosen = list(values) if values else list(fallback)
|
|
476
|
+
return [v for v in chosen if v not in exclude]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _served_choices(
|
|
480
|
+
field: str, fallback: list[str], labels: dict[str, str], exclude: frozenset[str] = frozenset()
|
|
481
|
+
) -> list[Choice]:
|
|
482
|
+
values = vocab_values(field, fallback, ctx.client.vocab(), exclude=exclude)
|
|
483
|
+
return [Choice(value=v, name=labels.get(v) or v.replace("_", " ").title()) for v in values]
|
|
484
|
+
|
|
485
|
+
|
|
438
486
|
def select_grading_company() -> str:
|
|
439
|
-
choices =
|
|
440
|
-
Choice(value="PSA", name="PSA"),
|
|
441
|
-
Choice(value="BGS", name="BGS (Beckett)"),
|
|
442
|
-
Choice(value="SGC", name="SGC"),
|
|
443
|
-
Choice(value="CGC", name="CGC"),
|
|
444
|
-
]
|
|
487
|
+
choices = _served_choices("grading_companies", _GRADING_COMPANY_FALLBACK, _GRADING_COMPANY_LABELS)
|
|
445
488
|
return inquirer.select(message="Grading company:", choices=choices).execute()
|
|
446
489
|
|
|
447
490
|
|
|
491
|
+
# `any_card` is a PLAYER slot, not a way to match a card: both callers of select_match_mode are
|
|
492
|
+
# tuning a CARD entry, and the player rung is reached through `select_entry_kind` instead. So it
|
|
493
|
+
# is excluded from this picker however the list arrives — offering it here would send a card entry
|
|
494
|
+
# with a subject-shaped mode.
|
|
495
|
+
_CARD_ENTRY_MODE_EXCLUDE = frozenset({"any_card"})
|
|
496
|
+
|
|
497
|
+
|
|
448
498
|
def select_match_mode() -> str:
|
|
449
|
-
choices =
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
Choice(value="exact_serial", name="Exact serial — this card with a specific serial number"),
|
|
453
|
-
]
|
|
499
|
+
choices = _served_choices(
|
|
500
|
+
"match_modes", _MATCH_MODE_FALLBACK, _MATCH_MODE_LABELS, exclude=_CARD_ENTRY_MODE_EXCLUDE
|
|
501
|
+
)
|
|
454
502
|
return inquirer.select(message="Match mode:", choices=choices).execute()
|
|
455
503
|
|
|
456
504
|
|
|
@@ -464,10 +512,7 @@ def select_entry_kind() -> str:
|
|
|
464
512
|
|
|
465
513
|
|
|
466
514
|
def select_visibility() -> str:
|
|
467
|
-
choices =
|
|
468
|
-
Choice(value="private", name="Private — only you can see it"),
|
|
469
|
-
Choice(value="public", name="Public — any collector can find and subscribe"),
|
|
470
|
-
]
|
|
515
|
+
choices = _served_choices("visibilities", _VISIBILITY_FALLBACK, _VISIBILITY_LABELS)
|
|
471
516
|
return inquirer.select(message="Visibility:", choices=choices).execute()
|
|
472
517
|
|
|
473
518
|
|
|
@@ -16,6 +16,8 @@ Building block cheat-sheet:
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import math
|
|
20
|
+
|
|
19
21
|
from rich import box
|
|
20
22
|
from rich.console import Console, Group, RenderableType
|
|
21
23
|
from rich.panel import Panel
|
|
@@ -148,9 +150,15 @@ def signed_money(v) -> str:
|
|
|
148
150
|
|
|
149
151
|
|
|
150
152
|
def pct_coverage(pct: float) -> str:
|
|
151
|
-
"""A coverage/health percentage, colored by band: green ≥75, gold ≥40, red below.
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
"""A coverage/health percentage, colored by band: green ≥75, gold ≥40, red below.
|
|
154
|
+
|
|
155
|
+
The ratio itself is two served counts (e.g. `priced_count / card_count`), so the only thing a
|
|
156
|
+
client decides is the rounding — and it must round the way the frontend does (`Math.round`,
|
|
157
|
+
half UP: 62.5 → 63), not Python's `format(..., '.0f')` (half to EVEN: 62.5 → 62), or the two
|
|
158
|
+
clients print different percentages for the same set. `floor(x + 0.5)` is `Math.round`."""
|
|
159
|
+
shown = math.floor(pct + 0.5)
|
|
160
|
+
color = "slab.gain" if shown >= 75 else ("slab.foil" if shown >= 40 else "slab.loss")
|
|
161
|
+
return f"[{color}]{shown}%[/]"
|
|
154
162
|
|
|
155
163
|
|
|
156
164
|
_SPARK_TICKS = "▁▂▃▄▅▆▇█"
|
|
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
|
|
File without changes
|
|
File without changes
|