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.
Files changed (28) hide show
  1. {slab_cli-0.3.0 → slab_cli-0.5.0}/PKG-INFO +1 -1
  2. {slab_cli-0.3.0 → slab_cli-0.5.0}/pyproject.toml +1 -1
  3. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/client.py +2 -41
  4. slab_cli-0.5.0/src/slab_cli/commands/setup.py +51 -0
  5. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/context.py +4 -15
  6. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/display.py +7 -6
  7. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/main.py +19 -1
  8. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/prompts.py +10 -1
  9. slab_cli-0.3.0/src/slab_cli/commands/setup.py +0 -23
  10. {slab_cli-0.3.0 → slab_cli-0.5.0}/README.md +0 -0
  11. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/__init__.py +0 -0
  12. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/banner.py +0 -0
  13. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/__init__.py +0 -0
  14. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/breaks.py +0 -0
  15. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/catalog.py +0 -0
  16. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/collection.py +0 -0
  17. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/custom_sets.py +0 -0
  18. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/export.py +0 -0
  19. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/lots.py +0 -0
  20. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/pricing.py +0 -0
  21. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/commands/registry.py +0 -0
  22. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/config.py +0 -0
  23. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/flags.py +0 -0
  24. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/help.py +0 -0
  25. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/paging.py +0 -0
  26. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/picker.py +0 -0
  27. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/sources.py +0 -0
  28. {slab_cli-0.3.0 → slab_cli-0.5.0}/src/slab_cli/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
6
  Requires-Dist: slab-schemas>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.3.0"
3
+ version = "0.5.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"}]
@@ -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 SealedMarket, SealedPriceHistory, SealedProductOut
55
- from slab_schemas.timeseries import CardPriceHistory, PortfolioHistory
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. Resolution order: SLAB_COLLECTOR env var, then — for a
35
- single-collector (`collector`) account only — the account's default collector, so a solo
36
- user needs just their API key. A `developer` account owns many collectors (one per
37
- end-user), so there is NO safe default: it must set SLAB_COLLECTOR explicitly, or this
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.subset or "?") + (f" · {card.finish}" if card.finish else "")
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.subset or "?") + (f" · {h.finish}" if h.finish else "")
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.subset or "?") + (f" · {card.finish}" if card and card.finish else "")
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.subset or "?") + (f" · {m.finish}" if m.finish else "")
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.subset or "?") + (f" · {entry.finish}" if entry.finish else "")
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.subset or "?") + (f" · {c.finish}" if c.finish else "")
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
- def _slot(c: CardOut) -> str:
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