slab-cli 0.24.0__tar.gz → 0.26.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.24.0 → slab_cli-0.26.0}/PKG-INFO +3 -2
- {slab_cli-0.24.0 → slab_cli-0.26.0}/README.md +1 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/pyproject.toml +2 -2
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/client.py +47 -14
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/custom_sets.py +24 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/pricing.py +42 -96
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/registry.py +2 -1
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/config.py +6 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/display.py +74 -72
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/prompts.py +60 -15
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/theme.py +11 -3
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/browse.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/main.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/nav.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/__init__.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/base.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/posix.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/platforms/windows.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.24.0 → slab_cli-0.26.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.26.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.50.0
|
|
7
7
|
Requires-Dist: httpx>=0.27
|
|
8
8
|
Requires-Dist: rich>=13.0
|
|
9
9
|
Requires-Dist: inquirerpy>=0.3
|
|
@@ -61,6 +61,7 @@ to see a group's verbs.
|
|
|
61
61
|
| `SLAB_API_KEY` | API key for authentication (**required**) | — |
|
|
62
62
|
| `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
|
|
63
63
|
| `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
|
|
64
|
+
| `SLAB_APP_URL` | Where `slab chase share` links open | `https://collection.slab.dev-jeb.com` |
|
|
64
65
|
| `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
|
|
65
66
|
|
|
66
67
|
## Staying up to date
|
|
@@ -49,6 +49,7 @@ to see a group's verbs.
|
|
|
49
49
|
| `SLAB_API_KEY` | API key for authentication (**required**) | — |
|
|
50
50
|
| `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
|
|
51
51
|
| `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
|
|
52
|
+
| `SLAB_APP_URL` | Where `slab chase share` links open | `https://collection.slab.dev-jeb.com` |
|
|
52
53
|
| `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
|
|
53
54
|
|
|
54
55
|
## Staying up to date
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "slab-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.26.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.50.0", # metrics registry: served momentum/trend labels
|
|
16
16
|
"httpx>=0.27",
|
|
17
17
|
"rich>=13.0",
|
|
18
18
|
"InquirerPy>=0.3",
|
|
@@ -18,6 +18,7 @@ from slab_schemas.cards import (
|
|
|
18
18
|
)
|
|
19
19
|
from slab_schemas.community import CommunityBoard
|
|
20
20
|
from slab_schemas.serials import CardSerials
|
|
21
|
+
from slab_schemas.snapshots import SnapshotRef
|
|
21
22
|
from slab_schemas.collection import (
|
|
22
23
|
BreakCreate,
|
|
23
24
|
BreakOut,
|
|
@@ -27,16 +28,17 @@ from slab_schemas.collection import (
|
|
|
27
28
|
LotOut,
|
|
28
29
|
LotSearchQuery,
|
|
29
30
|
LotSearchResult,
|
|
30
|
-
LotUpdate,
|
|
31
31
|
CardCopyCostCreate,
|
|
32
32
|
CardCopyCostOut,
|
|
33
33
|
CardCopyCostUpdate,
|
|
34
34
|
CardCopyCreate,
|
|
35
35
|
CardCopyOut,
|
|
36
36
|
CardCopyUpdate,
|
|
37
|
+
CollectionGroupQuery,
|
|
37
38
|
CollectionResult,
|
|
38
39
|
CollectionSearchQuery,
|
|
39
40
|
CollectorOut,
|
|
41
|
+
SetGroupResult,
|
|
40
42
|
)
|
|
41
43
|
from slab_schemas.custom_sets import (
|
|
42
44
|
CustomSetCardAdd,
|
|
@@ -49,11 +51,12 @@ from slab_schemas.custom_sets import (
|
|
|
49
51
|
CustomSetSearchResult,
|
|
50
52
|
CustomSetUpdate,
|
|
51
53
|
)
|
|
52
|
-
from slab_schemas.dashboard import
|
|
54
|
+
from slab_schemas.dashboard import DashboardStats
|
|
53
55
|
from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
|
|
54
56
|
from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
|
|
55
57
|
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
56
58
|
from slab_schemas.timeseries import CardPriceHistory
|
|
59
|
+
from slab_schemas.vocab import VocabOut
|
|
57
60
|
|
|
58
61
|
|
|
59
62
|
class ApiError(Exception):
|
|
@@ -82,6 +85,8 @@ class SlabClient:
|
|
|
82
85
|
headers["x-api-key"] = api_key
|
|
83
86
|
self._base_url = base_url
|
|
84
87
|
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
|
|
88
|
+
self._vocab: VocabOut | None = None
|
|
89
|
+
self._vocab_fetched = False
|
|
85
90
|
|
|
86
91
|
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
87
92
|
try:
|
|
@@ -207,6 +212,29 @@ class SlabClient:
|
|
|
207
212
|
resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
|
|
208
213
|
return SealedPriceHistory.model_validate(resp.json())
|
|
209
214
|
|
|
215
|
+
# --- vocabulary (public) ---
|
|
216
|
+
|
|
217
|
+
def get_vocab(self) -> VocabOut:
|
|
218
|
+
"""Every enumerable value the API accepts or serves — wire enums, sort grammars, and the
|
|
219
|
+
LIVE catalog dimensions (grading companies, attributes) that grow as sets are seeded.
|
|
220
|
+
Raises like any other call; `vocab()` is the forgiving, cached form pickers use."""
|
|
221
|
+
resp = self._request("GET", "/vocab")
|
|
222
|
+
return VocabOut.model_validate(resp.json())
|
|
223
|
+
|
|
224
|
+
def vocab(self) -> VocabOut | None:
|
|
225
|
+
"""The served vocabulary, fetched at most ONCE per process and None when the API can't
|
|
226
|
+
answer (offline, unauthorized, down). Same contract as the portal's `getVocab()`: a
|
|
227
|
+
picker renders the served list when it has one and its static fallback otherwise, so a
|
|
228
|
+
new grading company reaches the CLI with no release — and a dead API never blocks a
|
|
229
|
+
prompt. The miss is cached too, so one failure doesn't retry at every prompt."""
|
|
230
|
+
if not self._vocab_fetched:
|
|
231
|
+
self._vocab_fetched = True
|
|
232
|
+
try:
|
|
233
|
+
self._vocab = self.get_vocab()
|
|
234
|
+
except (ApiError, ApiConnectionError):
|
|
235
|
+
self._vocab = None
|
|
236
|
+
return self._vocab
|
|
237
|
+
|
|
210
238
|
# --- account / identity ---
|
|
211
239
|
|
|
212
240
|
def get_account_context(self) -> MeOut:
|
|
@@ -244,14 +272,6 @@ class SlabClient:
|
|
|
244
272
|
resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
|
|
245
273
|
return LotSearchResult.model_validate(resp.json())
|
|
246
274
|
|
|
247
|
-
def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
|
|
248
|
-
resp = self._request(
|
|
249
|
-
"PATCH",
|
|
250
|
-
f"/collectors/{collector_uuid}/lots/{lot_uuid}",
|
|
251
|
-
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
252
|
-
)
|
|
253
|
-
return LotOut.model_validate(resp.json())
|
|
254
|
-
|
|
255
275
|
def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
|
|
256
276
|
self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
|
|
257
277
|
|
|
@@ -281,6 +301,17 @@ class SlabClient:
|
|
|
281
301
|
resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
|
|
282
302
|
return CollectionResult.model_validate(resp.json())
|
|
283
303
|
|
|
304
|
+
def collection_sets(
|
|
305
|
+
self, collector_uuid: str, query: CollectionGroupQuery | None = None
|
|
306
|
+
) -> SetGroupResult:
|
|
307
|
+
"""The collection rolled up by set — the SERVER's per-set math (copies, distinct cards,
|
|
308
|
+
total value), ranked most valuable first and paged over groups, so a set is never split
|
|
309
|
+
across a page. This is the number the portfolio view renders per set; the CLI must not
|
|
310
|
+
re-derive it from copies (cli/AGENTS.md, "Served numbers, not client math")."""
|
|
311
|
+
body = (query or CollectionGroupQuery()).model_dump(mode="json", exclude_none=True)
|
|
312
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/collection/sets", json=body)
|
|
313
|
+
return SetGroupResult.model_validate(resp.json())
|
|
314
|
+
|
|
284
315
|
# --- costs ---
|
|
285
316
|
|
|
286
317
|
def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
|
|
@@ -315,10 +346,6 @@ class SlabClient:
|
|
|
315
346
|
resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
|
|
316
347
|
return resp.json()
|
|
317
348
|
|
|
318
|
-
def get_catalog_stats(self) -> CatalogStats:
|
|
319
|
-
resp = self._request("GET", "/stats")
|
|
320
|
-
return CatalogStats.model_validate(resp.json())
|
|
321
|
-
|
|
322
349
|
def get_community_board(self, limit: int = 10) -> CommunityBoard:
|
|
323
350
|
resp = self._request("GET", "/community", params={"limit": limit})
|
|
324
351
|
return CommunityBoard.model_validate(resp.json())
|
|
@@ -331,6 +358,12 @@ class SlabClient:
|
|
|
331
358
|
)
|
|
332
359
|
return CustomSetOut.model_validate(resp.json())
|
|
333
360
|
|
|
361
|
+
def create_custom_set_snapshot(self, set_uuid: str, collector_uuid: str | None = None) -> SnapshotRef:
|
|
362
|
+
"""Today's tear sheet for a chase — with this collector's have/need when one is given."""
|
|
363
|
+
params = {"collector_uuid": collector_uuid} if collector_uuid else {}
|
|
364
|
+
resp = self._request("POST", f"/custom-sets/{set_uuid}/snapshots", params=params)
|
|
365
|
+
return SnapshotRef.model_validate(resp.json())
|
|
366
|
+
|
|
334
367
|
def update_custom_set(self, collector_uuid: str, set_uuid: str, payload: CustomSetUpdate) -> CustomSetOut:
|
|
335
368
|
resp = self._request(
|
|
336
369
|
"PATCH",
|
|
@@ -18,6 +18,7 @@ from slab_schemas.custom_sets import (
|
|
|
18
18
|
from slab_schemas.enums import CustomSetType, DynamicMatch, MatchMode, Visibility
|
|
19
19
|
|
|
20
20
|
from ..client import SlabClient
|
|
21
|
+
from ..config import get_app_url
|
|
21
22
|
from ..context import ctx
|
|
22
23
|
from ..display import (
|
|
23
24
|
browse_custom_sets,
|
|
@@ -385,6 +386,29 @@ def cmd_chase_view(args: list[str]) -> None:
|
|
|
385
386
|
break
|
|
386
387
|
|
|
387
388
|
|
|
389
|
+
def cmd_chase_share(args: list[str]) -> None:
|
|
390
|
+
"""Print a tear sheet: a set's whole checklist frozen as of today, at a public link."""
|
|
391
|
+
collector = ctx.collector
|
|
392
|
+
client = ctx.client
|
|
393
|
+
|
|
394
|
+
if args:
|
|
395
|
+
set_uuid = args[0]
|
|
396
|
+
else:
|
|
397
|
+
cs = _select_custom_set(client, collector, message="Which set to share?")
|
|
398
|
+
if cs is None:
|
|
399
|
+
return
|
|
400
|
+
set_uuid = cs.uuid
|
|
401
|
+
|
|
402
|
+
# Progress is opt-in: the sheet is public, and have/need is the one personal thing on it.
|
|
403
|
+
mine = confirm("Include your progress (which cards you have)? It never shows your name.", default=False)
|
|
404
|
+
ref = client.create_custom_set_snapshot(set_uuid, collector if mine else None)
|
|
405
|
+
link = f"{get_app_url()}/s/{ref.uuid}"
|
|
406
|
+
console.print(f"\n [bold]{link}[/bold]")
|
|
407
|
+
note = "made just now" if ref.created else "already made today — same link"
|
|
408
|
+
console.print(f" [dim]As of {ref.as_of:%b %-d, %Y} · {note} · "
|
|
409
|
+
f"{'with your progress' if ref.includes_progress else 'no progress'}[/dim]\n")
|
|
410
|
+
|
|
411
|
+
|
|
388
412
|
def cmd_chase_edit(args: list[str]) -> None:
|
|
389
413
|
"""Edit a custom set's name, description, visibility, or (dynamic) match granularity."""
|
|
390
414
|
collector = ctx.collector
|
|
@@ -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 (
|
|
@@ -128,57 +131,34 @@ def cmd_grade_sweep(args: list[str]) -> None:
|
|
|
128
131
|
)
|
|
129
132
|
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
),
|
|
139
152
|
),
|
|
140
153
|
page=page,
|
|
141
154
|
cap=cap,
|
|
142
155
|
)
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _aggregate_by_set(copies: list) -> list[dict]:
|
|
147
|
-
"""Roll copies up per set, mirroring the server's headline math so the parts reconcile with the
|
|
148
|
-
whole: cost basis is summed per-copy, FMV is quantity-weighted, comp coverage is copy-weighted."""
|
|
149
|
-
sets: dict[str, dict] = {}
|
|
150
|
-
for cp in copies:
|
|
151
|
-
card = cp.card
|
|
152
|
-
name = (card.set_name if card else None) or "— Unknown set —"
|
|
153
|
-
s = sets.setdefault(name, {
|
|
154
|
-
"name": name, "copies": 0, "total_qty": 0, "priced_qty": 0,
|
|
155
|
-
"cost_basis": 0.0, "fmv": 0.0, "has_fmv": False,
|
|
156
|
-
})
|
|
157
|
-
qty = cp.quantity or 1
|
|
158
|
-
s["copies"] += 1
|
|
159
|
-
s["total_qty"] += qty
|
|
160
|
-
if cp.cost_basis is not None:
|
|
161
|
-
s["cost_basis"] += float(cp.cost_basis)
|
|
162
|
-
if cp.market and cp.market.fair_market_value is not None:
|
|
163
|
-
s["fmv"] += float(cp.market.fair_market_value) * qty
|
|
164
|
-
s["priced_qty"] += qty
|
|
165
|
-
s["has_fmv"] = True
|
|
166
|
-
|
|
167
|
-
for s in sets.values():
|
|
168
|
-
s["unrealized"] = (s["fmv"] - s["cost_basis"]) if s["has_fmv"] else None
|
|
169
|
-
s["comp_pct"] = (s["priced_qty"] / s["total_qty"] * 100) if s["total_qty"] else 0.0
|
|
170
|
-
|
|
171
|
-
# Most valuable first; sets with no market data sink to the bottom (sorted by cost basis).
|
|
172
|
-
return sorted(
|
|
173
|
-
sets.values(),
|
|
174
|
-
key=lambda s: (s["has_fmv"], s["fmv"] if s["has_fmv"] else s["cost_basis"]),
|
|
175
|
-
reverse=True,
|
|
176
|
-
)
|
|
156
|
+
return groups
|
|
177
157
|
|
|
178
158
|
|
|
179
159
|
def _portfolio_totals(summary: PortfolioSummary) -> dict:
|
|
180
160
|
"""Grand totals for the valuation panel, straight from the server's summary block — the same
|
|
181
|
-
|
|
161
|
+
numbers `GET .../dashboard` serves, so the two never diverge."""
|
|
182
162
|
fmv = summary.portfolio_value
|
|
183
163
|
return {
|
|
184
164
|
"cost_basis": float(summary.total_cost_basis or 0),
|
|
@@ -193,67 +173,33 @@ def _portfolio_totals(summary: PortfolioSummary) -> dict:
|
|
|
193
173
|
}
|
|
194
174
|
|
|
195
175
|
|
|
196
|
-
def _most_valuable_break(copies: list, breaks: list) -> dict | None:
|
|
197
|
-
"""The break whose pulled copies carry the most market value — did opening that box pay off?
|
|
198
|
-
FMV is summed from the copies we already priced; cost/metadata comes from the breaks list."""
|
|
199
|
-
fmv_by_break: dict[str, float] = {}
|
|
200
|
-
priced_by_break: dict[str, int] = {}
|
|
201
|
-
for cp in copies:
|
|
202
|
-
if cp.break_uuid and cp.market and cp.market.fair_market_value is not None:
|
|
203
|
-
qty = cp.quantity or 1
|
|
204
|
-
fmv_by_break[cp.break_uuid] = (
|
|
205
|
-
fmv_by_break.get(cp.break_uuid, 0.0) + float(cp.market.fair_market_value) * qty
|
|
206
|
-
)
|
|
207
|
-
priced_by_break[cp.break_uuid] = priced_by_break.get(cp.break_uuid, 0) + qty
|
|
208
|
-
|
|
209
|
-
if not fmv_by_break:
|
|
210
|
-
return None
|
|
211
|
-
|
|
212
|
-
top_uuid = max(fmv_by_break, key=fmv_by_break.get)
|
|
213
|
-
brk = next((b for b in breaks if b.uuid == top_uuid), None)
|
|
214
|
-
if brk is None:
|
|
215
|
-
return None
|
|
216
|
-
|
|
217
|
-
cost = float(brk.total_cost)
|
|
218
|
-
fmv = fmv_by_break[top_uuid]
|
|
219
|
-
return {
|
|
220
|
-
"set_name": brk.set_name or "— Unknown set —",
|
|
221
|
-
"break_type": brk.break_type,
|
|
222
|
-
"break_date": brk.break_date,
|
|
223
|
-
"cost": cost,
|
|
224
|
-
"fmv": fmv,
|
|
225
|
-
"gain": fmv - cost,
|
|
226
|
-
"copy_count": brk.copy_count,
|
|
227
|
-
"priced": priced_by_break.get(top_uuid, 0),
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
176
|
def cmd_portfolio(args: list[str]) -> None:
|
|
232
|
-
"""Portfolio-level financial summary — cost basis vs market value, per-set
|
|
233
|
-
|
|
234
|
-
|
|
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:
|
|
235
187
|
console.print("[dim]No cards in your collection yet.[/dim]")
|
|
236
188
|
return
|
|
237
|
-
if summary is None:
|
|
189
|
+
if page.summary is None:
|
|
238
190
|
# The API always includes the summary block; its absence is a server bug — say so
|
|
239
191
|
# rather than invent numbers client-side.
|
|
240
192
|
console.print("[red]Server response had no portfolio summary — can't value the collection.[/red]")
|
|
241
193
|
return
|
|
242
194
|
|
|
243
|
-
|
|
244
|
-
totals = _portfolio_totals(summary)
|
|
245
|
-
|
|
246
|
-
# 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).
|
|
247
196
|
movers = [
|
|
248
|
-
cp for cp in
|
|
197
|
+
cp for cp in page.items
|
|
249
198
|
if cp.market and cp.market.unrealized_gain_loss is not None
|
|
250
|
-
][:
|
|
251
|
-
|
|
252
|
-
breaks = ctx.client.search_breaks(ctx.collector).items
|
|
253
|
-
mvb = _most_valuable_break(copies, breaks)
|
|
199
|
+
][:_MOVERS_SHOWN] or None
|
|
254
200
|
|
|
255
201
|
render_portfolio(
|
|
256
|
-
|
|
202
|
+
page.total, _portfolio_totals(page.summary), movers=movers, by_set=_fetch_set_groups()
|
|
257
203
|
)
|
|
258
204
|
|
|
259
205
|
|
|
@@ -80,7 +80,7 @@ GROUPS: list[Group] = [
|
|
|
80
80
|
Command("cost", "Log a cost (grading, shipping) on a copy", collection.cmd_cost),
|
|
81
81
|
Command("export", "Export a set to a printable checklist", export.cmd_export),
|
|
82
82
|
Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
|
|
83
|
-
Command("portfolio", "Portfolio: cost basis vs FMV
|
|
83
|
+
Command("portfolio", "Portfolio: cost basis vs FMV, by set, top movers", pricing.cmd_portfolio),
|
|
84
84
|
Command("grade", "Grading math for every raw copy, best upside first", pricing.cmd_grade_sweep, "[--fee N]"),
|
|
85
85
|
]),
|
|
86
86
|
Group("break", "case / box breaks", [
|
|
@@ -99,6 +99,7 @@ GROUPS: list[Group] = [
|
|
|
99
99
|
Command("create", "Create a custom set (curated or dynamic)", custom_sets.cmd_chase_create),
|
|
100
100
|
Command("add", "Add cards or player slots to a curated set", custom_sets.cmd_chase_add),
|
|
101
101
|
Command("view", "View a set with completion + add missing", custom_sets.cmd_chase_view, "[uuid]"),
|
|
102
|
+
Command("share", "Tear sheet: a dated public link to a set's checklist", custom_sets.cmd_chase_share, "[uuid]"),
|
|
102
103
|
Command("edit", "Edit name, description, visibility, or granularity", custom_sets.cmd_chase_edit),
|
|
103
104
|
Command("tune", "Retune one entry — match mode or player-slot qualifiers", custom_sets.cmd_chase_tune),
|
|
104
105
|
Command("list", "Your sets (created + subscribed)", custom_sets.cmd_chase_list),
|
|
@@ -10,6 +10,12 @@ def get_base_url() -> str:
|
|
|
10
10
|
return url or "https://api.slab.dev-jeb.com"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def get_app_url() -> str:
|
|
14
|
+
"""The hosted app — where a tear sheet link opens (``/s/<uuid>``)."""
|
|
15
|
+
url = os.environ.get("SLAB_APP_URL", "").strip()
|
|
16
|
+
return (url or "https://collection.slab.dev-jeb.com").rstrip("/")
|
|
17
|
+
|
|
18
|
+
|
|
13
19
|
def get_api_key() -> str | None:
|
|
14
20
|
key = os.environ.get("SLAB_API_KEY", "").strip()
|
|
15
21
|
return key or None
|
|
@@ -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,6 +22,7 @@ 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
|
|
27
28
|
from slab_schemas.serials import CardSerials
|
|
@@ -505,10 +506,29 @@ def _pull_table(pulls) -> object:
|
|
|
505
506
|
return table
|
|
506
507
|
|
|
507
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
|
+
|
|
508
527
|
def render_break_pulls(brk, result: CollectionResult) -> None:
|
|
509
528
|
"""A break's report card: what you paid vs what the pulls are worth today, then the most
|
|
510
529
|
valuable pulls and the rarest (lowest print run). Every number in plain words — this view
|
|
511
|
-
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."""
|
|
512
532
|
console.print()
|
|
513
533
|
t = Text()
|
|
514
534
|
t.append(brk.set_name or "Break", style="slab.value")
|
|
@@ -535,9 +555,7 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
|
|
|
535
555
|
f"\n You paid [slab.value]{money(brk.total_cost)}[/] · your {len(result.items)} pulls are "
|
|
536
556
|
f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
|
|
537
557
|
)
|
|
538
|
-
|
|
539
|
-
console.print(f" [slab.dim]{unpriced} pull(s) have no market price yet and count as $0 here — "
|
|
540
|
-
f"the real total can only be higher.[/]")
|
|
558
|
+
console.print(_net_scope_line(result, unpriced))
|
|
541
559
|
|
|
542
560
|
top = sorted(priced, key=_fmv, reverse=True)[:10]
|
|
543
561
|
if top:
|
|
@@ -589,7 +607,7 @@ def browse_lots(result: LotSearchResult, *, detail=None) -> None:
|
|
|
589
607
|
def render_lot_contents(lot, result: CollectionResult) -> None:
|
|
590
608
|
"""A purchase's report card: what you paid vs what the cards are worth today, then the most
|
|
591
609
|
valuable and rarest cards in it. Answers "did that bundle come out ahead?" at a glance — the
|
|
592
|
-
lot counterpart of `render_break_pulls
|
|
610
|
+
lot counterpart of `render_break_pulls`, same `PAGE_NET_SCOPE` label on its total."""
|
|
593
611
|
console.print()
|
|
594
612
|
t = Text()
|
|
595
613
|
t.append(f"Purchase {lot.uuid[:8]}", style="slab.value")
|
|
@@ -614,9 +632,7 @@ def render_lot_contents(lot, result: CollectionResult) -> None:
|
|
|
614
632
|
f"\n You paid [slab.value]{money(lot.total_cost)}[/] · its {len(result.items)} cards are "
|
|
615
633
|
f"worth [slab.foil]{money(total_fmv)}[/] today → {signed_money(net)} — {verdict}."
|
|
616
634
|
)
|
|
617
|
-
|
|
618
|
-
console.print(f" [slab.dim]{unpriced} card(s) have no market price yet and count as $0 here — "
|
|
619
|
-
f"the real total can only be higher.[/]")
|
|
635
|
+
console.print(_net_scope_line(result, unpriced))
|
|
620
636
|
|
|
621
637
|
top = sorted(priced, key=_fmv, reverse=True)[:10]
|
|
622
638
|
if top:
|
|
@@ -866,26 +882,39 @@ def _collected_players_table(entries: list[CollectedPlayer]) -> Group | None:
|
|
|
866
882
|
return titled("Most Collected Players", table)
|
|
867
883
|
|
|
868
884
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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":
|
|
872
899
|
return "[slab.accent]new[/]"
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
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":
|
|
877
903
|
return f"[slab.dim]↓{prev / cur:.1f}×[/]" if cur else "[slab.dim]↓[/]"
|
|
878
904
|
return "[slab.dim]steady[/]"
|
|
879
905
|
|
|
880
906
|
|
|
881
|
-
def _hot_trend(
|
|
882
|
-
"""Price direction arrow — how to tell a breakout (selling up) from a sell-off (selling down).
|
|
883
|
-
|
|
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:
|
|
884
913
|
return "[slab.dim]—[/]"
|
|
885
|
-
if
|
|
886
|
-
return f"[slab.gain]↗
|
|
887
|
-
if
|
|
888
|
-
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}%[/]"
|
|
889
918
|
return f"[slab.dim]→ {pct:+.0f}%[/]"
|
|
890
919
|
|
|
891
920
|
|
|
@@ -904,10 +933,10 @@ def _hottest_players_table(entries: list[HotPlayer]) -> Group | None:
|
|
|
904
933
|
table.add_row(
|
|
905
934
|
e.name,
|
|
906
935
|
str(e.sales_30d),
|
|
907
|
-
_hot_momentum(e
|
|
936
|
+
_hot_momentum(e),
|
|
908
937
|
money(e.dollar_volume_30d),
|
|
909
938
|
str(e.distinct_cards_30d),
|
|
910
|
-
_hot_trend(e
|
|
939
|
+
_hot_trend(e),
|
|
911
940
|
)
|
|
912
941
|
return titled("🔥 Hottest Players", table)
|
|
913
942
|
|
|
@@ -1488,67 +1517,41 @@ def render_collection_grading_desk(sweep: CollectionGradingDesk) -> None:
|
|
|
1488
1517
|
console.print()
|
|
1489
1518
|
|
|
1490
1519
|
|
|
1491
|
-
def _render_portfolio_by_set(by_set: list | None) -> None:
|
|
1492
|
-
"""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)."""
|
|
1493
1526
|
if not by_set:
|
|
1494
1527
|
return
|
|
1495
1528
|
|
|
1496
1529
|
table = slab_table([
|
|
1497
1530
|
("Set", {"style": "slab.value", "no_wrap": False}),
|
|
1531
|
+
("Copies", {"justify": "right", "style": "slab.dim"}),
|
|
1498
1532
|
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
1499
|
-
("
|
|
1500
|
-
("FMV", {"justify": "right"}),
|
|
1501
|
-
("Gain/Loss", {"justify": "right"}),
|
|
1502
|
-
("Comps", {"justify": "right"}),
|
|
1533
|
+
("Value", {"justify": "right"}),
|
|
1503
1534
|
])
|
|
1504
1535
|
|
|
1505
|
-
for i,
|
|
1506
|
-
# crown the single most valuable set
|
|
1507
|
-
name = f"[slab.foil]★[/] {
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
fmv = f"[slab.foil]{money(st['fmv'])}[/]" if st["has_fmv"] else "[slab.dim]—[/]"
|
|
1511
|
-
gain = signed_money(st["unrealized"])
|
|
1512
|
-
comps = pct_coverage(st["comp_pct"])
|
|
1513
|
-
|
|
1514
|
-
table.add_row(name, cards, basis, fmv, gain, comps)
|
|
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)
|
|
1515
1541
|
|
|
1516
|
-
console.print(heading("By Set", "most valuable first"))
|
|
1542
|
+
console.print(heading("By Set", "most valuable first · unpriced copies add nothing to Value"))
|
|
1517
1543
|
console.print(table)
|
|
1518
1544
|
|
|
1519
1545
|
|
|
1520
|
-
def _render_most_valuable_break(mvb: dict | None) -> None:
|
|
1521
|
-
"""Highlight the single break whose pulled cards carry the most market value — did it pay off?"""
|
|
1522
|
-
if not mvb:
|
|
1523
|
-
return
|
|
1524
|
-
|
|
1525
|
-
date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
|
|
1526
|
-
title = Text()
|
|
1527
|
-
title.append(mvb["set_name"], style="slab.value")
|
|
1528
|
-
title.append(f" {mvb['break_type'].replace('_', ' ')}{date_str}", style="slab.dim")
|
|
1529
|
-
|
|
1530
|
-
g = mvb["gain"]
|
|
1531
|
-
verdict = "paid off" if g >= 0 else "underwater"
|
|
1532
|
-
roi = f" [slab.dim]({'+' if g >= 0 else '-'}{abs(g) / mvb['cost'] * 100:.0f}%)[/]" if mvb["cost"] else ""
|
|
1533
|
-
|
|
1534
|
-
body = kv_grid([
|
|
1535
|
-
("Cost", f"{money(mvb['cost'])} [slab.dim]({_n(mvb['copy_count'], 'card')})[/]"),
|
|
1536
|
-
("Market Value", f"[slab.foil]{money(mvb['fmv'])}[/] [slab.dim]({mvb['priced']} priced)[/]"),
|
|
1537
|
-
("Net vs Cost", f"{signed_money(g)}{roi} [slab.dim]· {verdict}[/]"),
|
|
1538
|
-
])
|
|
1539
|
-
|
|
1540
|
-
console.print(heading("Most Valuable Break"))
|
|
1541
|
-
console.print(slab_panel(Group(title, Text(""), body), foil=True))
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
1546
|
def render_portfolio(
|
|
1545
|
-
total: int, totals: dict, movers: list | None = None, by_set: list | None = None,
|
|
1546
|
-
most_valuable_break: dict | None = None,
|
|
1547
|
+
total: int, totals: dict, movers: list | None = None, by_set: list[SetGroupOut] | None = None,
|
|
1547
1548
|
) -> None:
|
|
1548
|
-
"""Display portfolio-level financial summary, per-set
|
|
1549
|
+
"""Display portfolio-level financial summary, the per-set rollup, and optional top movers.
|
|
1549
1550
|
|
|
1550
|
-
`totals` is the grand-total dict (cost_basis,
|
|
1551
|
-
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."""
|
|
1552
1555
|
rows: list[tuple[str, str]] = [("Cost Basis", money(totals["cost_basis"]))]
|
|
1553
1556
|
|
|
1554
1557
|
if totals["fmv"] is not None:
|
|
@@ -1575,7 +1578,6 @@ def render_portfolio(
|
|
|
1575
1578
|
console.print(slab_panel(kv_grid(rows), foil=True))
|
|
1576
1579
|
|
|
1577
1580
|
_render_portfolio_by_set(by_set)
|
|
1578
|
-
_render_most_valuable_break(most_valuable_break)
|
|
1579
1581
|
|
|
1580
1582
|
# Top movers
|
|
1581
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
|