slab-cli 0.3.0__tar.gz → 0.5.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.3.0 → slab_cli-0.5.0}/PKG-INFO +1 -1
- {slab_cli-0.3.0 → slab_cli-0.5.0}/pyproject.toml +1 -1
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/client.py +2 -41
- slab_cli-0.5.0/src/slab_cli/commands/setup.py +51 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/context.py +4 -15
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/display.py +7 -6
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/main.py +19 -1
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/prompts.py +10 -1
- slab_cli-0.3.0/src/slab_cli/commands/setup.py +0 -23
- {slab_cli-0.3.0 → slab_cli-0.5.0}/README.md +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/custom_sets.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/registry.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/theme.py +0 -0
|
@@ -22,7 +22,6 @@ from slab_schemas.collection import (
|
|
|
22
22
|
BreakOut,
|
|
23
23
|
BreakSearchQuery,
|
|
24
24
|
BreakSearchResult,
|
|
25
|
-
BreakUpdate,
|
|
26
25
|
LotCreate,
|
|
27
26
|
LotOut,
|
|
28
27
|
LotSearchQuery,
|
|
@@ -37,7 +36,6 @@ from slab_schemas.collection import (
|
|
|
37
36
|
CollectionResult,
|
|
38
37
|
CollectionSearchQuery,
|
|
39
38
|
CollectorOut,
|
|
40
|
-
CollectorUpdate,
|
|
41
39
|
)
|
|
42
40
|
from slab_schemas.custom_sets import (
|
|
43
41
|
CustomSetCardAdd,
|
|
@@ -51,8 +49,8 @@ from slab_schemas.custom_sets import (
|
|
|
51
49
|
)
|
|
52
50
|
from slab_schemas.dashboard import CatalogStats, DashboardStats
|
|
53
51
|
from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
|
|
54
|
-
from slab_schemas.sealed import
|
|
55
|
-
from slab_schemas.timeseries import CardPriceHistory
|
|
52
|
+
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
53
|
+
from slab_schemas.timeseries import CardPriceHistory
|
|
56
54
|
|
|
57
55
|
|
|
58
56
|
class ApiError(Exception):
|
|
@@ -145,21 +143,6 @@ class SlabClient:
|
|
|
145
143
|
resp = self._request("GET", f"/cards/{card_uuid}/price-history", params=params)
|
|
146
144
|
return CardPriceHistory.model_validate(resp.json())
|
|
147
145
|
|
|
148
|
-
def get_portfolio_history(
|
|
149
|
-
self,
|
|
150
|
-
collector_uuid: str,
|
|
151
|
-
start: str | None = None,
|
|
152
|
-
end: str | None = None,
|
|
153
|
-
interval: str = "daily",
|
|
154
|
-
) -> PortfolioHistory:
|
|
155
|
-
params: dict[str, str] = {"interval": interval}
|
|
156
|
-
if start:
|
|
157
|
-
params["start"] = start
|
|
158
|
-
if end:
|
|
159
|
-
params["end"] = end
|
|
160
|
-
resp = self._request("GET", f"/collectors/{collector_uuid}/portfolio/history", params=params)
|
|
161
|
-
return PortfolioHistory.model_validate(resp.json())
|
|
162
|
-
|
|
163
146
|
def search_sets(self, query: SetSearchQuery) -> SetSearchResult:
|
|
164
147
|
resp = self._request("POST", "/sets/search", json=query.model_dump(mode="json", exclude_none=True))
|
|
165
148
|
return SetSearchResult.model_validate(resp.json())
|
|
@@ -175,12 +158,6 @@ class SlabClient:
|
|
|
175
158
|
resp = self._request("GET", f"/sets/{set_uuid}/top-cards", params={"limit": str(limit)})
|
|
176
159
|
return SetTopCards.model_validate(resp.json())
|
|
177
160
|
|
|
178
|
-
def get_sealed_market(self, product_uuid: str, comps_limit: int = 25) -> SealedMarket:
|
|
179
|
-
resp = self._request(
|
|
180
|
-
"GET", f"/sealed/{product_uuid}/market", params={"comps_limit": str(comps_limit)}
|
|
181
|
-
)
|
|
182
|
-
return SealedMarket.model_validate(resp.json())
|
|
183
|
-
|
|
184
161
|
def get_sealed_price_history(
|
|
185
162
|
self,
|
|
186
163
|
product_uuid: str,
|
|
@@ -204,14 +181,6 @@ class SlabClient:
|
|
|
204
181
|
resp = self._request("GET", "/account")
|
|
205
182
|
return MeOut.model_validate(resp.json())
|
|
206
183
|
|
|
207
|
-
# --- collectors ---
|
|
208
|
-
|
|
209
|
-
def rename_collector(self, collector_uuid: str, name: str) -> CollectorOut:
|
|
210
|
-
resp = self._request(
|
|
211
|
-
"PATCH", f"/collectors/{collector_uuid}", json=CollectorUpdate(name=name).model_dump(mode="json")
|
|
212
|
-
)
|
|
213
|
-
return CollectorOut.model_validate(resp.json())
|
|
214
|
-
|
|
215
184
|
# --- breaks ---
|
|
216
185
|
|
|
217
186
|
def create_break(self, collector_uuid: str, payload: BreakCreate) -> BreakOut:
|
|
@@ -225,14 +194,6 @@ class SlabClient:
|
|
|
225
194
|
resp = self._request("POST", f"/collectors/{collector_uuid}/breaks/search", json=body)
|
|
226
195
|
return BreakSearchResult.model_validate(resp.json())
|
|
227
196
|
|
|
228
|
-
def update_break(self, collector_uuid: str, break_uuid: str, payload: BreakUpdate) -> BreakOut:
|
|
229
|
-
resp = self._request(
|
|
230
|
-
"PATCH",
|
|
231
|
-
f"/collectors/{collector_uuid}/breaks/{break_uuid}",
|
|
232
|
-
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
233
|
-
)
|
|
234
|
-
return BreakOut.model_validate(resp.json())
|
|
235
|
-
|
|
236
197
|
def delete_break(self, collector_uuid: str, break_uuid: str) -> None:
|
|
237
198
|
self._request("DELETE", f"/collectors/{collector_uuid}/breaks/{break_uuid}")
|
|
238
199
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Setup commands — check identity.
|
|
2
|
+
|
|
3
|
+
Collectors are NOT created here. Your account and its collector are provisioned when you sign up
|
|
4
|
+
in the portal; the CLI just consumes the API key and collector UUID you get there. SLAB_COLLECTOR
|
|
5
|
+
is required and explicit — the CLI does not guess a collector from the account, so you must set it
|
|
6
|
+
to the UUID you copy from the portal (Settings).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from slab_schemas.account import MeOut
|
|
12
|
+
|
|
13
|
+
from ..config import get_collector_uuid
|
|
14
|
+
from ..context import ctx
|
|
15
|
+
from ..display import console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_whoami(args: list[str]) -> None:
|
|
19
|
+
"""Show the active collector — and VALIDATE it against your account. SLAB_COLLECTOR is just a
|
|
20
|
+
string until it's checked, so this resolves it against the account behind your API key and
|
|
21
|
+
prints the collector's name. A wrong or stale UUID is caught HERE (with the list of collectors
|
|
22
|
+
you actually own) instead of surfacing later as a cryptic 'collector not found' mid-command."""
|
|
23
|
+
me = ctx.client.get_account_context()
|
|
24
|
+
names = {c.uuid: c.name for c in me.collectors}
|
|
25
|
+
uuid = get_collector_uuid()
|
|
26
|
+
|
|
27
|
+
if not uuid:
|
|
28
|
+
console.print("[yellow]SLAB_COLLECTOR is not set.[/yellow]")
|
|
29
|
+
_print_collectors(me, names)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
name = names.get(uuid)
|
|
33
|
+
if name is None:
|
|
34
|
+
console.print(f"[red]SLAB_COLLECTOR is {uuid},[/red]")
|
|
35
|
+
console.print("[red]but that isn't a collector on this account.[/red]")
|
|
36
|
+
_print_collectors(me, names)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
console.print(f"Active collector: [bold]{name}[/bold] [dim]{uuid}[/dim]")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_collectors(me: MeOut, names: dict[str, str]) -> None:
|
|
43
|
+
"""List the account's real collectors (marking the default) so the user knows what to set."""
|
|
44
|
+
if not names:
|
|
45
|
+
console.print("[dim]This account has no collectors yet.[/dim]")
|
|
46
|
+
return
|
|
47
|
+
console.print("[dim]Collectors on this account:[/dim]")
|
|
48
|
+
for c in me.collectors:
|
|
49
|
+
tag = " [dim](default)[/dim]" if c.uuid == me.default_collector_uuid else ""
|
|
50
|
+
console.print(f" [bold]{c.name}[/bold]{tag} [dim]{c.uuid}[/dim]")
|
|
51
|
+
console.print("[dim]Set one: export SLAB_COLLECTOR=<uuid>[/dim]")
|
|
@@ -5,11 +5,8 @@ calling _client()/_require_collector() themselves."""
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
import sys
|
|
9
|
-
|
|
10
8
|
from .client import SlabClient
|
|
11
9
|
from .config import get_api_key, get_base_url, get_collector_uuid
|
|
12
|
-
from .display import console
|
|
13
10
|
|
|
14
11
|
|
|
15
12
|
class MissingCollector(Exception):
|
|
@@ -31,21 +28,13 @@ class CliContext:
|
|
|
31
28
|
|
|
32
29
|
@property
|
|
33
30
|
def collector(self) -> str:
|
|
34
|
-
"""The active collector UUID
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
raises MissingCollector. Cached per invocation."""
|
|
31
|
+
"""The active collector UUID, from the SLAB_COLLECTOR env var. Required and explicit — the
|
|
32
|
+
CLI does not guess or auto-resolve a collector from the account. Copy your collector's UUID
|
|
33
|
+
from the portal (Settings) and set SLAB_COLLECTOR to it; if it's unset this raises
|
|
34
|
+
MissingCollector. Cached per invocation."""
|
|
39
35
|
if self._collector:
|
|
40
36
|
return self._collector
|
|
41
37
|
uuid = get_collector_uuid()
|
|
42
|
-
if not uuid:
|
|
43
|
-
# No explicit collector. Only a single-collector account may zero-config fall back to
|
|
44
|
-
# its default; a developer account has no unambiguous default and must set one.
|
|
45
|
-
me = self.client.get_account_context()
|
|
46
|
-
if me.account.account_type == "developer":
|
|
47
|
-
raise MissingCollector
|
|
48
|
-
uuid = me.default_collector_uuid
|
|
49
38
|
if not uuid:
|
|
50
39
|
raise MissingCollector
|
|
51
40
|
self._collector = uuid
|
|
@@ -38,6 +38,7 @@ from .theme import (
|
|
|
38
38
|
sparkline,
|
|
39
39
|
titled,
|
|
40
40
|
)
|
|
41
|
+
from .prompts import _slot
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def format_grade_delta(delta: float | None) -> str | None:
|
|
@@ -81,7 +82,7 @@ def format_card_summary(card: CardOut) -> str:
|
|
|
81
82
|
"""One-line summary of a card for inline display. Shows the subset (the line) and the parallel
|
|
82
83
|
finish only when there is one — so a no-finish INSERT reads as its insert name, not 'Base'."""
|
|
83
84
|
subjects = ", ".join(s.name for s in card.subjects) or "—"
|
|
84
|
-
slot = (card
|
|
85
|
+
slot = _slot(card)
|
|
85
86
|
pr = f" /{card.print_run}" if card.print_run else ""
|
|
86
87
|
attrs = f" [{', '.join(a.name for a in card.attributes)}]" if card.attributes else ""
|
|
87
88
|
return f"{card.card_number} {subjects} — {card.brand or ''} {card.set_name or ''} — {slot}{pr}{attrs}"
|
|
@@ -419,7 +420,7 @@ def _highlight_panel(title: str, cards: list[HighlightCard], show_cost: bool = F
|
|
|
419
420
|
rows = []
|
|
420
421
|
for h in cards:
|
|
421
422
|
subj = ", ".join(h.subjects) or "—"
|
|
422
|
-
slot = (h
|
|
423
|
+
slot = _slot(h)
|
|
423
424
|
run = f" /{h.print_run}" if h.print_run else ""
|
|
424
425
|
cost = f" [slab.gain]{money(h.cost_basis)}[/]" if (show_cost and h.cost_basis is not None) else ""
|
|
425
426
|
rows.append(f"[slab.value]#{h.card_number}[/] {subj} [slab.dim]{slot}{run}[/]{cost}")
|
|
@@ -785,7 +786,7 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
|
|
|
785
786
|
for entry in detail.cards:
|
|
786
787
|
card = entry.card
|
|
787
788
|
subjects = ", ".join(s.name for s in card.subjects) if card else "—"
|
|
788
|
-
slot = (card
|
|
789
|
+
slot = _slot(card) if card else "—"
|
|
789
790
|
pr = f" /{card.print_run}" if card and card.print_run else ""
|
|
790
791
|
label = f"{card.card_number} {subjects} [slab.dim]— {slot}{pr}[/]" if card else "—"
|
|
791
792
|
|
|
@@ -819,7 +820,7 @@ def _card_headline(card_number, subjects: str, set_name: str | None, slot: str)
|
|
|
819
820
|
def render_card_market(m: CardMarket) -> None:
|
|
820
821
|
"""Display the market pricing for a catalog card — all grade buckets."""
|
|
821
822
|
subjects = ", ".join(m.subjects) or "—"
|
|
822
|
-
slot = (m
|
|
823
|
+
slot = _slot(m)
|
|
823
824
|
console.print()
|
|
824
825
|
console.print(_card_headline(m.card_number, subjects, m.set_name, slot))
|
|
825
826
|
|
|
@@ -926,7 +927,7 @@ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCard
|
|
|
926
927
|
("Comps", {"justify": "right", "style": "slab.dim"}),
|
|
927
928
|
])
|
|
928
929
|
for i, entry in enumerate(top.cards, 1):
|
|
929
|
-
slot = (entry
|
|
930
|
+
slot = _slot(entry)
|
|
930
931
|
if entry.print_run:
|
|
931
932
|
slot += f" /{entry.print_run}"
|
|
932
933
|
fmv = f"[slab.foil]{money(entry.market.fair_market_value)}[/]"
|
|
@@ -947,7 +948,7 @@ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCard
|
|
|
947
948
|
def render_comps(c: CardComps) -> None:
|
|
948
949
|
"""Display recent comps (raw sales) for a catalog card — the evidence behind its FMV."""
|
|
949
950
|
subjects = ", ".join(c.subjects) or "—"
|
|
950
|
-
slot = (c
|
|
951
|
+
slot = _slot(c)
|
|
951
952
|
console.print()
|
|
952
953
|
console.print(_card_headline(c.card_number, subjects, c.set_name, slot))
|
|
953
954
|
|
|
@@ -8,6 +8,7 @@ one group; `slab <group> <command> --help` explains a single command.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import re
|
|
11
12
|
import sys
|
|
12
13
|
|
|
13
14
|
from .client import ApiConnectionError, ApiError
|
|
@@ -19,6 +20,16 @@ from .help import render_command_help, render_group_help, render_root_help
|
|
|
19
20
|
|
|
20
21
|
_HELP_FLAGS = ("-h", "--help", "help")
|
|
21
22
|
|
|
23
|
+
# assert_owned's 404 detail for an unknown/unowned collector is exactly "collector <uuid> not found".
|
|
24
|
+
# Distinct from copy/break/lot not-found details ("copy X not found for collector Y"), which name a
|
|
25
|
+
# parent collector but don't start with it — so anchoring to the start avoids false positives.
|
|
26
|
+
_MISSING_COLLECTOR_RE = re.compile(r"^collector \S+ not found$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_missing_collector(detail: str | None) -> bool:
|
|
30
|
+
"""True when a 404 detail means the active collector itself is unknown to this account."""
|
|
31
|
+
return bool(detail and _MISSING_COLLECTOR_RE.match(detail))
|
|
32
|
+
|
|
22
33
|
|
|
23
34
|
def _run(args: list[str]) -> int:
|
|
24
35
|
"""Resolve `args` against the registry and run the command. Returns a process exit code."""
|
|
@@ -82,7 +93,14 @@ def main() -> None:
|
|
|
82
93
|
console.print("[dim] Check that the API is running and SLAB_API_URL is correct.[/dim]")
|
|
83
94
|
sys.exit(1)
|
|
84
95
|
except ApiError as e:
|
|
85
|
-
if e.status == 404:
|
|
96
|
+
if e.status == 404 and _is_missing_collector(e.detail):
|
|
97
|
+
# The active collector (SLAB_COLLECTOR) doesn't resolve to a live collector on this
|
|
98
|
+
# account. This otherwise surfaces mid-flow (e.g. picking Gift → fetching prior sources)
|
|
99
|
+
# as a cryptic "Not found" — point the user at the fix instead.
|
|
100
|
+
console.print("[red]Your active collector wasn't found for this account.[/red]")
|
|
101
|
+
console.print(" See which one the CLI is using: [bold]slab collector current[/bold]")
|
|
102
|
+
console.print(" Then set SLAB_COLLECTOR to a collector UUID from the portal (Settings).")
|
|
103
|
+
elif e.status == 404:
|
|
86
104
|
console.print(f"[yellow]Not found: {e.detail}[/yellow]")
|
|
87
105
|
elif e.status == 401:
|
|
88
106
|
console.print("[red]Unauthorized — check your SLAB_API_KEY.[/red]")
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
from collections import Counter
|
|
9
9
|
from datetime import date
|
|
10
10
|
from decimal import Decimal, InvalidOperation
|
|
11
|
+
from typing import Protocol
|
|
11
12
|
|
|
12
13
|
from InquirerPy import inquirer
|
|
13
14
|
from InquirerPy.base.control import Choice
|
|
@@ -30,7 +31,15 @@ from .theme import console as _console
|
|
|
30
31
|
# Label builders (for select menus)
|
|
31
32
|
# ---------------------------------------------------------------------------
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
class _HasSlot(Protocol):
|
|
35
|
+
"""Any DTO that carries a card's line identity — a subset and an optional parallel finish
|
|
36
|
+
(CardOut, HighlightCard, CardMarket, custom-set entries, …). `_slot` is duck-typed over these."""
|
|
37
|
+
|
|
38
|
+
subset: str | None
|
|
39
|
+
finish: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _slot(c: _HasSlot) -> str:
|
|
34
43
|
"""The card's line: its subset, plus the parallel finish when it has one. 'Base' shows only
|
|
35
44
|
when the subset is literally Base — a no-finish INSERT shows its insert name, not 'Base'
|
|
36
45
|
(id_finish NULL means 'base printing of this line', not 'the base set')."""
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
"""Setup commands — check identity.
|
|
2
|
-
|
|
3
|
-
Collectors are NOT created here. Your account and its collector are provisioned when you sign up
|
|
4
|
-
in the portal; the CLI just consumes the API key and collector UUID you get there. When
|
|
5
|
-
SLAB_COLLECTOR is unset the CLI falls back to your account's default collector (see context.py),
|
|
6
|
-
so a solo user only needs their API key.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from ..config import get_collector_uuid
|
|
12
|
-
from ..display import console
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def cmd_whoami(args: list[str]) -> None:
|
|
16
|
-
uuid = get_collector_uuid()
|
|
17
|
-
if uuid:
|
|
18
|
-
console.print(f"Active collector: [bold]{uuid}[/bold]")
|
|
19
|
-
else:
|
|
20
|
-
console.print(
|
|
21
|
-
"[dim]SLAB_COLLECTOR not set — the CLI will use your account's default collector.\n"
|
|
22
|
-
"Find your collector UUID and API key in the portal.[/dim]"
|
|
23
|
-
)
|
|
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
|