slab-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slab_cli/__init__.py +0 -0
- slab_cli/banner.py +92 -0
- slab_cli/client.py +386 -0
- slab_cli/commands/__init__.py +4 -0
- slab_cli/commands/breaks.py +140 -0
- slab_cli/commands/catalog.py +84 -0
- slab_cli/commands/collection.py +676 -0
- slab_cli/commands/custom_sets.py +484 -0
- slab_cli/commands/export.py +129 -0
- slab_cli/commands/lots.py +114 -0
- slab_cli/commands/pricing.py +386 -0
- slab_cli/commands/registry.py +140 -0
- slab_cli/commands/setup.py +24 -0
- slab_cli/config.py +21 -0
- slab_cli/context.py +50 -0
- slab_cli/display.py +1187 -0
- slab_cli/flags.py +189 -0
- slab_cli/help.py +87 -0
- slab_cli/main.py +95 -0
- slab_cli/paging.py +46 -0
- slab_cli/picker.py +78 -0
- slab_cli/prompts.py +549 -0
- slab_cli/sources.py +17 -0
- slab_cli/theme.py +184 -0
- slab_cli-0.1.0.dist-info/METADATA +70 -0
- slab_cli-0.1.0.dist-info/RECORD +28 -0
- slab_cli-0.1.0.dist-info/WHEEL +4 -0
- slab_cli-0.1.0.dist-info/entry_points.txt +3 -0
slab_cli/__init__.py
ADDED
|
File without changes
|
slab_cli/banner.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The slab banner — the wordmark rendered as the thing a slab IS: a graded card in its case.
|
|
2
|
+
|
|
3
|
+
A rounded foil-edged case (the same card silhouette every panel in the CLI uses), a grade label
|
|
4
|
+
across the top — because obviously this CLI grades a perfect GEM MT 10 — and the SLAB wordmark
|
|
5
|
+
printed in full gold foil: the letter faces run a top-to-bottom gradient between the portal's two
|
|
6
|
+
foil tokens (bright gold catching the light, fading toward dark gold), and the drop-shadow strokes
|
|
7
|
+
sit in dark gold so every letterform — the B's bowls especially — stays crisp instead of dissolving
|
|
8
|
+
into the background. Both endpoint colors come from `theme.py`, so the banner restyles with the
|
|
9
|
+
rest of the CLI."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from rich.console import Group, RenderableType
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from .theme import CARD_BOX, FOIL, FOIL_DIM
|
|
18
|
+
|
|
19
|
+
# ANSI-shadow letterform: solid blocks are the foil face, box-drawing chars the drop shadow.
|
|
20
|
+
_WORDMARK = """\
|
|
21
|
+
███████╗██╗ █████╗ ██████╗
|
|
22
|
+
██╔════╝██║ ██╔══██╗██╔══██╗
|
|
23
|
+
███████╗██║ ███████║██████╔╝
|
|
24
|
+
╚════██║██║ ██╔══██║██╔══██╗
|
|
25
|
+
███████║███████╗██║ ██║██████╔╝
|
|
26
|
+
╚══════╝╚══════╝╚═╝ ╚═╝╚═════╝"""
|
|
27
|
+
|
|
28
|
+
_SHADOW = set("╔╗╚╝═║")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
|
32
|
+
h = hex_color.lstrip("#")
|
|
33
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lerp_hex(a: str, b: str, t: float) -> str:
|
|
37
|
+
"""Blend between two theme hex colors — the in-between shades of the foil gradient."""
|
|
38
|
+
ra, ga, ba = _hex_to_rgb(a)
|
|
39
|
+
rb, gb, bb = _hex_to_rgb(b)
|
|
40
|
+
return f"#{round(ra + (rb - ra) * t):02x}{round(ga + (gb - ga) * t):02x}{round(ba + (bb - ba) * t):02x}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _foil_print(art: str) -> Text:
|
|
44
|
+
"""Print the letterform in foil: gradient gold faces, dark-gold shadow strokes.
|
|
45
|
+
|
|
46
|
+
The face gradient stops short of FOIL_DIM (t ≤ 0.6) so even the bottom row of the face stays
|
|
47
|
+
visibly brighter than the shadow — face and shadow must never blend or letter shapes smear."""
|
|
48
|
+
lines = art.splitlines()
|
|
49
|
+
width = max(len(line) for line in lines)
|
|
50
|
+
last = len(lines) - 1
|
|
51
|
+
t = Text()
|
|
52
|
+
for i, line in enumerate(lines):
|
|
53
|
+
face = f"bold {_lerp_hex(FOIL, FOIL_DIM, 0.6 * (i / last))}"
|
|
54
|
+
for ch in line.ljust(width):
|
|
55
|
+
t.append(ch, style=face if ch == "█" else f"{FOIL_DIM}" if ch in _SHADOW else None)
|
|
56
|
+
if i < last:
|
|
57
|
+
t.append("\n")
|
|
58
|
+
return t
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _grade_label(width: int) -> Text:
|
|
62
|
+
"""The slab's grade label: what it holds on the left, the verdict in gold on the right."""
|
|
63
|
+
left, right = "CATALOG & COLLECTION", "GEM MT 10"
|
|
64
|
+
t = Text()
|
|
65
|
+
t.append(left, style="slab.label")
|
|
66
|
+
t.append(" " * max(1, width - len(left) - len(right)))
|
|
67
|
+
t.append(right, style="slab.foil")
|
|
68
|
+
return t
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build() -> RenderableType:
|
|
72
|
+
width = max(len(line) for line in _WORDMARK.splitlines())
|
|
73
|
+
case = Panel(
|
|
74
|
+
Group(
|
|
75
|
+
_grade_label(width),
|
|
76
|
+
Text("─" * width, style="slab.foil_dim"),
|
|
77
|
+
_foil_print(_WORDMARK),
|
|
78
|
+
),
|
|
79
|
+
box=CARD_BOX,
|
|
80
|
+
border_style="slab.foil_dim",
|
|
81
|
+
padding=(0, 2),
|
|
82
|
+
expand=False,
|
|
83
|
+
)
|
|
84
|
+
return Group(
|
|
85
|
+
Text(),
|
|
86
|
+
case,
|
|
87
|
+
Text(" trading card catalog & collection tracker", style="slab.dim"),
|
|
88
|
+
Text(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
BANNER: RenderableType = _build()
|
slab_cli/client.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Typed HTTP client for the slab API. Talks to the API via httpx, returns schema DTOs.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
client = SlabClient("https://api.slab.dev-jeb.com", api_key="sk_live_...")
|
|
5
|
+
results = client.search_cards(CardSearchQuery(subject="McDavid", rookie=True))
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from slab_schemas.account import MeOut
|
|
12
|
+
from slab_schemas.cards import (
|
|
13
|
+
CardOut,
|
|
14
|
+
CardSearchQuery,
|
|
15
|
+
CardSearchResult,
|
|
16
|
+
SetSearchQuery,
|
|
17
|
+
SetSearchResult,
|
|
18
|
+
)
|
|
19
|
+
from slab_schemas.community import CommunityBoard
|
|
20
|
+
from slab_schemas.collection import (
|
|
21
|
+
BreakCreate,
|
|
22
|
+
BreakOut,
|
|
23
|
+
BreakSearchQuery,
|
|
24
|
+
BreakSearchResult,
|
|
25
|
+
BreakUpdate,
|
|
26
|
+
LotCreate,
|
|
27
|
+
LotOut,
|
|
28
|
+
LotSearchQuery,
|
|
29
|
+
LotSearchResult,
|
|
30
|
+
LotUpdate,
|
|
31
|
+
CardCopyCostCreate,
|
|
32
|
+
CardCopyCostOut,
|
|
33
|
+
CardCopyCostUpdate,
|
|
34
|
+
CardCopyCreate,
|
|
35
|
+
CardCopyOut,
|
|
36
|
+
CardCopyUpdate,
|
|
37
|
+
CollectionResult,
|
|
38
|
+
CollectionSearchQuery,
|
|
39
|
+
CollectorCreate,
|
|
40
|
+
CollectorOut,
|
|
41
|
+
CollectorUpdate,
|
|
42
|
+
)
|
|
43
|
+
from slab_schemas.custom_sets import (
|
|
44
|
+
CustomSetCardAdd,
|
|
45
|
+
CustomSetCardOut,
|
|
46
|
+
CustomSetCreate,
|
|
47
|
+
CustomSetDetail,
|
|
48
|
+
CustomSetOut,
|
|
49
|
+
CustomSetSearchQuery,
|
|
50
|
+
CustomSetSearchResult,
|
|
51
|
+
CustomSetUpdate,
|
|
52
|
+
)
|
|
53
|
+
from slab_schemas.dashboard import CatalogStats, DashboardStats
|
|
54
|
+
from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
|
|
55
|
+
from slab_schemas.sealed import SealedMarket, SealedPriceHistory, SealedProductOut
|
|
56
|
+
from slab_schemas.timeseries import CardPriceHistory, PortfolioHistory
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ApiError(Exception):
|
|
60
|
+
"""An error returned by the API (4xx/5xx)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, status: int, detail: str):
|
|
63
|
+
self.status = status
|
|
64
|
+
self.detail = detail
|
|
65
|
+
super().__init__(f"[{status}] {detail}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ApiConnectionError(Exception):
|
|
69
|
+
"""Cannot reach the API."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, url: str, reason: str):
|
|
72
|
+
self.url = url
|
|
73
|
+
super().__init__(f"Cannot connect to {url}: {reason}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SlabClient:
|
|
77
|
+
"""Typed client for the slab API."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, base_url: str, api_key: str | None = None):
|
|
80
|
+
headers = {}
|
|
81
|
+
if api_key:
|
|
82
|
+
headers["x-api-key"] = api_key
|
|
83
|
+
self._base_url = base_url
|
|
84
|
+
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
|
|
85
|
+
|
|
86
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
87
|
+
try:
|
|
88
|
+
resp = self._http.request(method, path, **kwargs)
|
|
89
|
+
except httpx.ConnectError:
|
|
90
|
+
raise ApiConnectionError(self._base_url, "connection refused — is the API running?")
|
|
91
|
+
except httpx.TimeoutException:
|
|
92
|
+
raise ApiConnectionError(self._base_url, "request timed out")
|
|
93
|
+
except httpx.HTTPError as e:
|
|
94
|
+
raise ApiConnectionError(self._base_url, str(e))
|
|
95
|
+
|
|
96
|
+
if resp.status_code >= 400:
|
|
97
|
+
detail = resp.text
|
|
98
|
+
try:
|
|
99
|
+
body = resp.json()
|
|
100
|
+
if isinstance(body, dict) and "detail" in body:
|
|
101
|
+
detail = body["detail"]
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
raise ApiError(resp.status_code, detail)
|
|
105
|
+
return resp
|
|
106
|
+
|
|
107
|
+
# --- catalog ---
|
|
108
|
+
|
|
109
|
+
def search_cards(self, query: CardSearchQuery) -> CardSearchResult:
|
|
110
|
+
resp = self._request("POST", "/cards/search", json=query.model_dump(mode="json", exclude_none=True))
|
|
111
|
+
return CardSearchResult.model_validate(resp.json())
|
|
112
|
+
|
|
113
|
+
def get_parallels(self, card_uuid: str) -> list[CardOut]:
|
|
114
|
+
resp = self._request("GET", f"/cards/{card_uuid}/parallels")
|
|
115
|
+
return [CardOut.model_validate(c) for c in resp.json()]
|
|
116
|
+
|
|
117
|
+
def get_card_market(self, card_uuid: str) -> CardMarket:
|
|
118
|
+
resp = self._request("GET", f"/cards/{card_uuid}/market")
|
|
119
|
+
return CardMarket.model_validate(resp.json())
|
|
120
|
+
|
|
121
|
+
def get_card_comps(
|
|
122
|
+
self, card_uuid: str, grade_key: str | None = None, limit: int = 50
|
|
123
|
+
) -> CardComps:
|
|
124
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
125
|
+
if grade_key:
|
|
126
|
+
params["grade_key"] = grade_key
|
|
127
|
+
resp = self._request("GET", f"/cards/{card_uuid}/comps", params=params)
|
|
128
|
+
return CardComps.model_validate(resp.json())
|
|
129
|
+
|
|
130
|
+
def get_card_price_history(
|
|
131
|
+
self,
|
|
132
|
+
card_uuid: str,
|
|
133
|
+
grade_key: str = "RAW",
|
|
134
|
+
finish: str | None = None,
|
|
135
|
+
start: str | None = None,
|
|
136
|
+
end: str | None = None,
|
|
137
|
+
interval: str = "daily",
|
|
138
|
+
) -> CardPriceHistory:
|
|
139
|
+
params: dict[str, str] = {"grade_key": grade_key, "interval": interval}
|
|
140
|
+
if finish:
|
|
141
|
+
params["finish"] = finish
|
|
142
|
+
if start:
|
|
143
|
+
params["start"] = start
|
|
144
|
+
if end:
|
|
145
|
+
params["end"] = end
|
|
146
|
+
resp = self._request("GET", f"/cards/{card_uuid}/price-history", params=params)
|
|
147
|
+
return CardPriceHistory.model_validate(resp.json())
|
|
148
|
+
|
|
149
|
+
def get_portfolio_history(
|
|
150
|
+
self,
|
|
151
|
+
collector_uuid: str,
|
|
152
|
+
start: str | None = None,
|
|
153
|
+
end: str | None = None,
|
|
154
|
+
interval: str = "daily",
|
|
155
|
+
) -> PortfolioHistory:
|
|
156
|
+
params: dict[str, str] = {"interval": interval}
|
|
157
|
+
if start:
|
|
158
|
+
params["start"] = start
|
|
159
|
+
if end:
|
|
160
|
+
params["end"] = end
|
|
161
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/portfolio/history", params=params)
|
|
162
|
+
return PortfolioHistory.model_validate(resp.json())
|
|
163
|
+
|
|
164
|
+
def search_sets(self, query: SetSearchQuery) -> SetSearchResult:
|
|
165
|
+
resp = self._request("POST", "/sets/search", json=query.model_dump(mode="json", exclude_none=True))
|
|
166
|
+
return SetSearchResult.model_validate(resp.json())
|
|
167
|
+
|
|
168
|
+
# --- sealed / set market ---
|
|
169
|
+
|
|
170
|
+
def get_set_sealed(self, set_uuid: str) -> list[SealedProductOut]:
|
|
171
|
+
"""The set's sealed SKUs, each with its headline (latest) market value."""
|
|
172
|
+
resp = self._request("GET", f"/sets/{set_uuid}/sealed")
|
|
173
|
+
return [SealedProductOut.model_validate(p) for p in resp.json()]
|
|
174
|
+
|
|
175
|
+
def get_set_top_cards(self, set_uuid: str, limit: int = 10) -> SetTopCards:
|
|
176
|
+
resp = self._request("GET", f"/sets/{set_uuid}/top-cards", params={"limit": str(limit)})
|
|
177
|
+
return SetTopCards.model_validate(resp.json())
|
|
178
|
+
|
|
179
|
+
def get_sealed_market(self, product_uuid: str, comps_limit: int = 25) -> SealedMarket:
|
|
180
|
+
resp = self._request(
|
|
181
|
+
"GET", f"/sealed/{product_uuid}/market", params={"comps_limit": str(comps_limit)}
|
|
182
|
+
)
|
|
183
|
+
return SealedMarket.model_validate(resp.json())
|
|
184
|
+
|
|
185
|
+
def get_sealed_price_history(
|
|
186
|
+
self,
|
|
187
|
+
product_uuid: str,
|
|
188
|
+
start: str | None = None,
|
|
189
|
+
end: str | None = None,
|
|
190
|
+
interval: str = "daily",
|
|
191
|
+
) -> SealedPriceHistory:
|
|
192
|
+
params: dict[str, str] = {"interval": interval}
|
|
193
|
+
if start:
|
|
194
|
+
params["start"] = start
|
|
195
|
+
if end:
|
|
196
|
+
params["end"] = end
|
|
197
|
+
resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
|
|
198
|
+
return SealedPriceHistory.model_validate(resp.json())
|
|
199
|
+
|
|
200
|
+
# --- account / identity ---
|
|
201
|
+
|
|
202
|
+
def get_account_context(self) -> MeOut:
|
|
203
|
+
"""Resolve the account behind this API key — its collectors and default collector. Used to
|
|
204
|
+
pick a default collector when SLAB_COLLECTOR is unset."""
|
|
205
|
+
resp = self._request("GET", "/account")
|
|
206
|
+
return MeOut.model_validate(resp.json())
|
|
207
|
+
|
|
208
|
+
# --- collectors ---
|
|
209
|
+
|
|
210
|
+
def create_collector(self, name: str) -> CollectorOut:
|
|
211
|
+
resp = self._request("POST", "/collectors", json=CollectorCreate(name=name).model_dump(mode="json"))
|
|
212
|
+
return CollectorOut.model_validate(resp.json())
|
|
213
|
+
|
|
214
|
+
def rename_collector(self, collector_uuid: str, name: str) -> CollectorOut:
|
|
215
|
+
resp = self._request(
|
|
216
|
+
"PATCH", f"/collectors/{collector_uuid}", json=CollectorUpdate(name=name).model_dump(mode="json")
|
|
217
|
+
)
|
|
218
|
+
return CollectorOut.model_validate(resp.json())
|
|
219
|
+
|
|
220
|
+
# --- breaks ---
|
|
221
|
+
|
|
222
|
+
def create_break(self, collector_uuid: str, payload: BreakCreate) -> BreakOut:
|
|
223
|
+
resp = self._request(
|
|
224
|
+
"POST", f"/collectors/{collector_uuid}/breaks", json=payload.model_dump(mode="json", exclude_none=True)
|
|
225
|
+
)
|
|
226
|
+
return BreakOut.model_validate(resp.json())
|
|
227
|
+
|
|
228
|
+
def search_breaks(self, collector_uuid: str, query: BreakSearchQuery | None = None) -> BreakSearchResult:
|
|
229
|
+
body = (query or BreakSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
230
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/breaks/search", json=body)
|
|
231
|
+
return BreakSearchResult.model_validate(resp.json())
|
|
232
|
+
|
|
233
|
+
def update_break(self, collector_uuid: str, break_uuid: str, payload: BreakUpdate) -> BreakOut:
|
|
234
|
+
resp = self._request(
|
|
235
|
+
"PATCH",
|
|
236
|
+
f"/collectors/{collector_uuid}/breaks/{break_uuid}",
|
|
237
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
238
|
+
)
|
|
239
|
+
return BreakOut.model_validate(resp.json())
|
|
240
|
+
|
|
241
|
+
def delete_break(self, collector_uuid: str, break_uuid: str) -> None:
|
|
242
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/breaks/{break_uuid}")
|
|
243
|
+
|
|
244
|
+
# --- lots (a single multi-card purchase) ---
|
|
245
|
+
|
|
246
|
+
def create_lot(self, collector_uuid: str, payload: LotCreate) -> LotOut:
|
|
247
|
+
resp = self._request(
|
|
248
|
+
"POST", f"/collectors/{collector_uuid}/lots", json=payload.model_dump(mode="json", exclude_none=True)
|
|
249
|
+
)
|
|
250
|
+
return LotOut.model_validate(resp.json())
|
|
251
|
+
|
|
252
|
+
def search_lots(self, collector_uuid: str, query: LotSearchQuery | None = None) -> LotSearchResult:
|
|
253
|
+
body = (query or LotSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
254
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
|
|
255
|
+
return LotSearchResult.model_validate(resp.json())
|
|
256
|
+
|
|
257
|
+
def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
|
|
258
|
+
resp = self._request(
|
|
259
|
+
"PATCH",
|
|
260
|
+
f"/collectors/{collector_uuid}/lots/{lot_uuid}",
|
|
261
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
262
|
+
)
|
|
263
|
+
return LotOut.model_validate(resp.json())
|
|
264
|
+
|
|
265
|
+
def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
|
|
266
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
|
|
267
|
+
|
|
268
|
+
# --- copies ---
|
|
269
|
+
|
|
270
|
+
def add_copy(self, collector_uuid: str, payload: CardCopyCreate) -> CardCopyOut:
|
|
271
|
+
resp = self._request(
|
|
272
|
+
"POST", f"/collectors/{collector_uuid}/copies", json=payload.model_dump(mode="json", exclude_none=True)
|
|
273
|
+
)
|
|
274
|
+
return CardCopyOut.model_validate(resp.json())
|
|
275
|
+
|
|
276
|
+
def update_copy(self, collector_uuid: str, copy_uuid: str, payload: CardCopyUpdate) -> CardCopyOut:
|
|
277
|
+
resp = self._request(
|
|
278
|
+
"PATCH",
|
|
279
|
+
f"/collectors/{collector_uuid}/copies/{copy_uuid}",
|
|
280
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
281
|
+
)
|
|
282
|
+
return CardCopyOut.model_validate(resp.json())
|
|
283
|
+
|
|
284
|
+
def delete_copy(self, collector_uuid: str, copy_uuid: str) -> None:
|
|
285
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/copies/{copy_uuid}")
|
|
286
|
+
|
|
287
|
+
# --- collection search ---
|
|
288
|
+
|
|
289
|
+
def search_collection(self, collector_uuid: str, query: CollectionSearchQuery | None = None) -> CollectionResult:
|
|
290
|
+
body = (query or CollectionSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
291
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
|
|
292
|
+
return CollectionResult.model_validate(resp.json())
|
|
293
|
+
|
|
294
|
+
# --- costs ---
|
|
295
|
+
|
|
296
|
+
def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
|
|
297
|
+
resp = self._request(
|
|
298
|
+
"POST",
|
|
299
|
+
f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs",
|
|
300
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
301
|
+
)
|
|
302
|
+
return CardCopyCostOut.model_validate(resp.json())
|
|
303
|
+
|
|
304
|
+
def update_cost(
|
|
305
|
+
self, collector_uuid: str, copy_uuid: str, cost_uuid: str, payload: CardCopyCostUpdate
|
|
306
|
+
) -> CardCopyCostOut:
|
|
307
|
+
resp = self._request(
|
|
308
|
+
"PATCH",
|
|
309
|
+
f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs/{cost_uuid}",
|
|
310
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
311
|
+
)
|
|
312
|
+
return CardCopyCostOut.model_validate(resp.json())
|
|
313
|
+
|
|
314
|
+
def delete_cost(self, collector_uuid: str, copy_uuid: str, cost_uuid: str) -> None:
|
|
315
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs/{cost_uuid}")
|
|
316
|
+
|
|
317
|
+
# --- dashboards ---
|
|
318
|
+
|
|
319
|
+
def get_dashboard(self, collector_uuid: str) -> DashboardStats:
|
|
320
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/dashboard")
|
|
321
|
+
return DashboardStats.model_validate(resp.json())
|
|
322
|
+
|
|
323
|
+
def get_sources(self, collector_uuid: str) -> list[str]:
|
|
324
|
+
"""Distinct acquisition sources this collector has used (breaks + copies)."""
|
|
325
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
|
|
326
|
+
return resp.json()
|
|
327
|
+
|
|
328
|
+
def get_catalog_stats(self) -> CatalogStats:
|
|
329
|
+
resp = self._request("GET", "/stats")
|
|
330
|
+
return CatalogStats.model_validate(resp.json())
|
|
331
|
+
|
|
332
|
+
def get_community_board(self, limit: int = 10) -> CommunityBoard:
|
|
333
|
+
resp = self._request("GET", "/community", params={"limit": limit})
|
|
334
|
+
return CommunityBoard.model_validate(resp.json())
|
|
335
|
+
|
|
336
|
+
# --- custom sets (collector-scoped) ---
|
|
337
|
+
|
|
338
|
+
def create_custom_set(self, collector_uuid: str, payload: CustomSetCreate) -> CustomSetOut:
|
|
339
|
+
resp = self._request(
|
|
340
|
+
"POST", f"/collectors/{collector_uuid}/custom-sets", json=payload.model_dump(mode="json", exclude_none=True)
|
|
341
|
+
)
|
|
342
|
+
return CustomSetOut.model_validate(resp.json())
|
|
343
|
+
|
|
344
|
+
def update_custom_set(self, collector_uuid: str, set_uuid: str, payload: CustomSetUpdate) -> CustomSetOut:
|
|
345
|
+
resp = self._request(
|
|
346
|
+
"PATCH",
|
|
347
|
+
f"/collectors/{collector_uuid}/custom-sets/{set_uuid}",
|
|
348
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
349
|
+
)
|
|
350
|
+
return CustomSetOut.model_validate(resp.json())
|
|
351
|
+
|
|
352
|
+
def delete_custom_set(self, collector_uuid: str, set_uuid: str) -> None:
|
|
353
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}")
|
|
354
|
+
|
|
355
|
+
def add_set_card(self, collector_uuid: str, set_uuid: str, payload: CustomSetCardAdd) -> CustomSetCardOut:
|
|
356
|
+
resp = self._request(
|
|
357
|
+
"POST",
|
|
358
|
+
f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards",
|
|
359
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
360
|
+
)
|
|
361
|
+
return CustomSetCardOut.model_validate(resp.json())
|
|
362
|
+
|
|
363
|
+
def delete_set_card(self, collector_uuid: str, set_uuid: str, entry_uuid: str) -> None:
|
|
364
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}")
|
|
365
|
+
|
|
366
|
+
def subscribe(self, collector_uuid: str, set_uuid: str) -> None:
|
|
367
|
+
self._request("POST", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/subscribe")
|
|
368
|
+
|
|
369
|
+
def unsubscribe(self, collector_uuid: str, set_uuid: str) -> None:
|
|
370
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/subscribe")
|
|
371
|
+
|
|
372
|
+
def collector_custom_sets(self, collector_uuid: str) -> list[CustomSetOut]:
|
|
373
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/custom-sets")
|
|
374
|
+
return [CustomSetOut.model_validate(s) for s in resp.json()]
|
|
375
|
+
|
|
376
|
+
# --- custom sets (unscoped — discovery/viewing) ---
|
|
377
|
+
|
|
378
|
+
def search_custom_sets(self, query: CustomSetSearchQuery | None = None) -> CustomSetSearchResult:
|
|
379
|
+
body = (query or CustomSetSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
380
|
+
resp = self._request("POST", "/custom-sets/search", json=body)
|
|
381
|
+
return CustomSetSearchResult.model_validate(resp.json())
|
|
382
|
+
|
|
383
|
+
def get_custom_set(self, set_uuid: str, collector_uuid: str | None = None) -> CustomSetDetail:
|
|
384
|
+
params = {"collector_uuid": collector_uuid} if collector_uuid else {}
|
|
385
|
+
resp = self._request("GET", f"/custom-sets/{set_uuid}", params=params)
|
|
386
|
+
return CustomSetDetail.model_validate(resp.json())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Break commands — create and list breaks (opening sealed product).
|
|
2
|
+
|
|
3
|
+
Also provides helpers used by the collection add flow."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from slab_schemas.cards import SetSearchQuery
|
|
8
|
+
from slab_schemas.collection import BreakCreate, BreakOut, BreakSearchQuery, CollectionSearchQuery
|
|
9
|
+
from slab_schemas.enums import BreakType
|
|
10
|
+
|
|
11
|
+
from ..client import SlabClient
|
|
12
|
+
from ..context import ctx
|
|
13
|
+
from ..display import console, print_breaks, render_break_pulls
|
|
14
|
+
from ..prompts import (
|
|
15
|
+
ask_date,
|
|
16
|
+
ask_decimal,
|
|
17
|
+
ask_text,
|
|
18
|
+
confirm,
|
|
19
|
+
select_break,
|
|
20
|
+
select_break_type,
|
|
21
|
+
select_search_set,
|
|
22
|
+
)
|
|
23
|
+
from ..sources import pick_source
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pick_or_create_break(client: SlabClient, collector: str) -> BreakOut | None:
|
|
27
|
+
"""Let the user pick an existing break or create a new one. Returns the BreakOut (its set is
|
|
28
|
+
used to scope the box) or None if skipped."""
|
|
29
|
+
breaks = client.search_breaks(collector)
|
|
30
|
+
if breaks.items:
|
|
31
|
+
choice = select_break(breaks, allow_new=True)
|
|
32
|
+
if choice == "new":
|
|
33
|
+
return create_break_interactive(client, collector)
|
|
34
|
+
elif isinstance(choice, BreakOut):
|
|
35
|
+
return choice
|
|
36
|
+
return None # skipped
|
|
37
|
+
else:
|
|
38
|
+
if confirm("No breaks yet. Create one?", default=True):
|
|
39
|
+
return create_break_interactive(client, collector)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | None:
|
|
44
|
+
"""Walk the user through creating a break. Returns the new BreakOut or None."""
|
|
45
|
+
console.print(
|
|
46
|
+
'[dim]A break is a sealed product you opened. Find it by name — type part of the '
|
|
47
|
+
'product, e.g. "Platinum" or "O-Pee-Chee" (real words, not abbreviations like "OPC").[/dim]'
|
|
48
|
+
)
|
|
49
|
+
q = ask_text("Which product did you open?")
|
|
50
|
+
sets = client.search_sets(SetSearchQuery(q=q, limit=15))
|
|
51
|
+
if not sets.items:
|
|
52
|
+
console.print(
|
|
53
|
+
f'[yellow]No products matched "{q}".[/yellow] Try fewer/real words from the name '
|
|
54
|
+
'(e.g. "Platinum", "Series 1", "SP Authentic").'
|
|
55
|
+
)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
set_choice = select_search_set(sets)
|
|
59
|
+
if set_choice is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
break_type = select_break_type()
|
|
63
|
+
break_date = ask_date("When did you open it?")
|
|
64
|
+
total_cost = ask_decimal("Total cost:")
|
|
65
|
+
# Offer the collector's previously-used sources for arrow-key reuse (falls back to a plain
|
|
66
|
+
# text prompt on the first break, when there's nothing to reuse yet).
|
|
67
|
+
source = pick_source(client, collector)
|
|
68
|
+
|
|
69
|
+
brk = client.create_break(
|
|
70
|
+
collector,
|
|
71
|
+
BreakCreate(
|
|
72
|
+
set_uuid=set_choice.uuid,
|
|
73
|
+
break_type=BreakType(break_type),
|
|
74
|
+
break_date=break_date,
|
|
75
|
+
total_cost=total_cost,
|
|
76
|
+
source=source,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
console.print(f"[green]Break created:[/green] {brk.break_date} · {brk.set_name} — ${brk.total_cost:.2f}")
|
|
80
|
+
console.print(
|
|
81
|
+
"[dim]Why it matters: as you add the cards you pulled to this break, its cost splits "
|
|
82
|
+
"across them — so every card carries what it actually cost you (real profit/loss later).[/dim]"
|
|
83
|
+
)
|
|
84
|
+
return brk
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_break_list(args: list[str]) -> None:
|
|
88
|
+
"""`slab break list` — your breaks, then pick one to see what it delivered (value vs cost,
|
|
89
|
+
the top pulls, the rarest pulls). Skippable — the list stays on screen either way."""
|
|
90
|
+
result = ctx.client.search_breaks(ctx.collector, BreakSearchQuery(limit=25))
|
|
91
|
+
print_breaks(result)
|
|
92
|
+
if not result.items:
|
|
93
|
+
return
|
|
94
|
+
choice = select_break(result, allow_new=False)
|
|
95
|
+
if isinstance(choice, BreakOut):
|
|
96
|
+
_show_break_pulls(choice)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _show_break_pulls(brk: BreakOut) -> None:
|
|
100
|
+
"""The break's report card: one collection search scoped to the break — each copy carries its
|
|
101
|
+
own market data — rendered as value-vs-cost + most valuable + rarest pulls."""
|
|
102
|
+
pulls = ctx.client.search_collection(
|
|
103
|
+
ctx.collector, CollectionSearchQuery(break_uuid=brk.uuid, limit=200))
|
|
104
|
+
render_break_pulls(brk, pulls)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_break_new(args: list[str]) -> None:
|
|
108
|
+
"""`slab break new` — record opening a sealed product (its cost splits across the cards you
|
|
109
|
+
add to it, so every pull carries what it actually cost you)."""
|
|
110
|
+
create_break_interactive(ctx.client, ctx.collector)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_break_remove(args: list[str]) -> None:
|
|
114
|
+
"""`slab break remove` — pick a break and remove it. The cards you pulled are NOT deleted;
|
|
115
|
+
they stay in your collection but are detached from the break (their break-derived cost basis
|
|
116
|
+
is lost, since that cost was only ever split across them while linked)."""
|
|
117
|
+
breaks = ctx.client.search_breaks(ctx.collector, BreakSearchQuery(limit=100))
|
|
118
|
+
if not breaks.items:
|
|
119
|
+
console.print("[slab.dim]No breaks to delete.[/]")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
choice = select_break(breaks, allow_new=False)
|
|
123
|
+
if not isinstance(choice, BreakOut):
|
|
124
|
+
return # skipped / cancelled
|
|
125
|
+
|
|
126
|
+
console.print(
|
|
127
|
+
f"[yellow]Deleting:[/yellow] {choice.break_date} · {choice.set_name} — "
|
|
128
|
+
f"${choice.total_cost:.2f} ({choice.copy_count} card(s))"
|
|
129
|
+
)
|
|
130
|
+
if choice.copy_count:
|
|
131
|
+
console.print(
|
|
132
|
+
f"[dim]Those {choice.copy_count} card(s) stay in your collection but lose the cost "
|
|
133
|
+
"basis they got from this break.[/dim]"
|
|
134
|
+
)
|
|
135
|
+
if not confirm("Delete this break?", default=False):
|
|
136
|
+
console.print("[slab.dim]Cancelled.[/]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
ctx.client.delete_break(ctx.collector, choice.uuid)
|
|
140
|
+
console.print("[green]Break deleted.[/green]")
|