slab-cli 0.16.0__tar.gz → 0.18.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 (34) hide show
  1. {slab_cli-0.16.0 → slab_cli-0.18.0}/PKG-INFO +2 -2
  2. {slab_cli-0.16.0 → slab_cli-0.18.0}/pyproject.toml +4 -2
  3. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/client.py +26 -0
  4. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/pricing.py +47 -0
  5. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/registry.py +2 -0
  6. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/display.py +143 -1
  7. {slab_cli-0.16.0 → slab_cli-0.18.0}/README.md +0 -0
  8. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/__init__.py +0 -0
  9. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/banner.py +0 -0
  10. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/browse.py +0 -0
  11. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/__init__.py +0 -0
  12. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/breaks.py +0 -0
  13. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/catalog.py +0 -0
  14. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/collection.py +0 -0
  15. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/custom_sets.py +0 -0
  16. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/export.py +0 -0
  17. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/lots.py +0 -0
  18. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/setup.py +0 -0
  19. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/commands/update.py +0 -0
  20. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/config.py +0 -0
  21. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/context.py +0 -0
  22. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/flags.py +0 -0
  23. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/help.py +0 -0
  24. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/main.py +0 -0
  25. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/paging.py +0 -0
  26. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/picker.py +0 -0
  27. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/platforms/__init__.py +0 -0
  28. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/platforms/base.py +0 -0
  29. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/platforms/posix.py +0 -0
  30. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/platforms/windows.py +0 -0
  31. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/prompts.py +0 -0
  32. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.16.0 → slab_cli-0.18.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.16.0 → slab_cli-0.18.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.16.0
3
+ Version: 0.18.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.31.0
6
+ Requires-Dist: slab-schemas>=0.32.0
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: rich>=13.0
9
9
  Requires-Dist: inquirerpy>=0.3
@@ -1,13 +1,15 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.16.0"
3
+ version = "0.18.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"}]
7
7
  requires-python = ">=3.13"
8
8
  dependencies = [
9
9
  # 0.31: CardSearchQuery.collapse_parallels — `card search` sends it on every query.
10
- "slab-schemas>=0.31.0",
10
+ # 0.32 adds CompOut.grade_unconfirmed (the comps table's grade-sanity "?" marker) and the
11
+ # grading-desk models `slab card grade` / `slab collection grade` parse responses into.
12
+ "slab-schemas>=0.32.0",
11
13
  "httpx>=0.27",
12
14
  "rich>=13.0",
13
15
  "InquirerPy>=0.3",
@@ -49,6 +49,7 @@ from slab_schemas.custom_sets import (
49
49
  CustomSetUpdate,
50
50
  )
51
51
  from slab_schemas.dashboard import CatalogStats, DashboardStats
52
+ from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
52
53
  from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
53
54
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
54
55
  from slab_schemas.timeseries import CardPriceHistory
@@ -125,6 +126,31 @@ class SlabClient:
125
126
  resp = self._request("GET", f"/cards/{card_uuid}/comps", params=params)
126
127
  return CardComps.model_validate(resp.json())
127
128
 
129
+ def get_grading_desk(
130
+ self, card_uuid: str, fee: float | None = None, company: str | None = None
131
+ ) -> GradingDesk:
132
+ params: dict[str, str] = {}
133
+ if fee is not None:
134
+ params["fee"] = str(fee)
135
+ if company:
136
+ params["company"] = company
137
+ resp = self._request("GET", f"/cards/{card_uuid}/grading-desk", params=params)
138
+ return GradingDesk.model_validate(resp.json())
139
+
140
+ def get_collection_grading_desk(
141
+ self, collector_uuid: str, fee: float | None = None,
142
+ company: str | None = None, limit: int = 50,
143
+ ) -> CollectionGradingDesk:
144
+ params: dict[str, str] = {"limit": str(limit)}
145
+ if fee is not None:
146
+ params["fee"] = str(fee)
147
+ if company:
148
+ params["company"] = company
149
+ resp = self._request(
150
+ "GET", f"/collectors/{collector_uuid}/collection/grading-desk", params=params
151
+ )
152
+ return CollectionGradingDesk.model_validate(resp.json())
153
+
128
154
  def get_card_price_history(
129
155
  self,
130
156
  card_uuid: str,
@@ -11,7 +11,9 @@ from ..context import MissingCollector, ctx
11
11
  from ..display import (
12
12
  console,
13
13
  render_card_market,
14
+ render_collection_grading_desk,
14
15
  render_comps,
16
+ render_grading_desk,
15
17
  render_portfolio,
16
18
  render_price_history,
17
19
  render_sealed_price_history,
@@ -77,6 +79,51 @@ def cmd_comps(args: list[str]) -> None:
77
79
  render_comps(ctx.client.get_card_comps(card.uuid))
78
80
 
79
81
 
82
+ def _split_desk_flags(args: list[str]) -> tuple[list[str], float | None, str | None]:
83
+ """Pull `--fee <amount>` and `--company <name>` out of a grading query. Hand-rolled (the
84
+ shared flag grammar is search-specific); an unparseable fee errors loudly rather than
85
+ silently pricing off the default."""
86
+ rest: list[str] = []
87
+ fee: float | None = None
88
+ company: str | None = None
89
+ i = 0
90
+ while i < len(args):
91
+ a = args[i]
92
+ if a == "--fee" and i + 1 < len(args):
93
+ try:
94
+ fee = float(args[i + 1].lstrip("$"))
95
+ except ValueError:
96
+ raise SystemExit(f"--fee expects a number, got {args[i + 1]!r}")
97
+ i += 2
98
+ elif a == "--company" and i + 1 < len(args):
99
+ company = args[i + 1]
100
+ i += 2
101
+ else:
102
+ rest.append(a)
103
+ i += 1
104
+ return rest, fee, company
105
+
106
+
107
+ def cmd_grade(args: list[str]) -> None:
108
+ """Should I grade this card? Same player → set → card flow as `price`, then the grading desk:
109
+ what each grade nets after the fee, and how sure you'd need to be it gems before grading
110
+ beats keeping it raw. `--fee 19` prices at your rate; `--company SGC` switches grader."""
111
+ args, fee, company = _split_desk_flags(args)
112
+ card = _pick_card(args)
113
+ if card is None:
114
+ return
115
+ render_grading_desk(ctx.client.get_grading_desk(card.uuid, fee=fee, company=company))
116
+
117
+
118
+ def cmd_grade_sweep(args: list[str]) -> None:
119
+ """Which of your raw cards are worth grading? Ranks every ungraded copy best-case first —
120
+ where the per-card bars become batch odds. `--fee 19` prices at your rate."""
121
+ args, fee, company = _split_desk_flags(args)
122
+ render_collection_grading_desk(
123
+ ctx.client.get_collection_grading_desk(ctx.collector, fee=fee, company=company)
124
+ )
125
+
126
+
80
127
  def _fetch_all_copies(page: int = 200, cap: int = 5000) -> tuple[list, PortfolioSummary | None]:
81
128
  """Page through the whole collection for the per-set/mover/break breakdowns, and grab the
82
129
  server's summary block (full-result aggregates, identical on every page) from the first page —
@@ -59,6 +59,7 @@ GROUPS: list[Group] = [
59
59
  Command("search", "Search the catalog (player, set, team…)", catalog.cmd_search, "[query] [flags]"),
60
60
  Command("parallels", "Every parallel (the rainbow) of a card", catalog.cmd_rainbow, "<query>"),
61
61
  Command("price", "Market price + 7d/30d trend", pricing.cmd_price, "<query>"),
62
+ Command("grade", "Should I grade it? Payoffs, the bar, a verdict", pricing.cmd_grade, "<query> [--fee N]"),
62
63
  Command("comps", "Recent raw sales behind the price", pricing.cmd_comps, "<query>"),
63
64
  Command("history", "Price history over time", pricing.cmd_history, "<query>"),
64
65
  ]),
@@ -79,6 +80,7 @@ GROUPS: list[Group] = [
79
80
  Command("export", "Export a set to a printable checklist", export.cmd_export),
80
81
  Command("dashboard", "Overview: counts, value, composition", collection.cmd_dashboard),
81
82
  Command("portfolio", "Portfolio: cost basis vs FMV + top movers", pricing.cmd_portfolio),
83
+ Command("grade", "Which of your raw cards are worth grading", pricing.cmd_grade_sweep, "[--fee N]"),
82
84
  ]),
83
85
  Group("break", "case / box breaks", [
84
86
  Command("list", "Your breaks — pick one to see value vs cost + top pulls", breaks.cmd_break_list),
@@ -19,7 +19,8 @@ from slab_schemas.community import (
19
19
  )
20
20
  from slab_schemas.custom_sets import CustomSetDetail, CustomSetOut, CustomSetSearchResult
21
21
  from slab_schemas.dashboard import CatalogStats, DashboardStats, HighlightCard, LabeledCount
22
- from slab_schemas.enums import Grade, SealedFormat
22
+ from slab_schemas.grading_desk import CollectionGradingDesk, GradingDesk
23
+ from slab_schemas.enums import Grade, GradingVerdict, SealedFormat
23
24
  from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTopCards
24
25
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
25
26
  from slab_schemas.timeseries import CardPriceHistory
@@ -1116,10 +1117,16 @@ def render_comps(c: CardComps) -> None:
1116
1117
  ("Title", {}),
1117
1118
  ])
1118
1119
 
1120
+ flagged = 0
1119
1121
  for comp in c.comps:
1120
1122
  when = comp.sold_date.isoformat() if comp.sold_date else "—"
1121
1123
  price = money(comp.sale_price) if comp.sale_price is not None else "—"
1122
1124
  grade = comp.grade_key or "—"
1125
+ # Grade sanity: "RAW" only means the title named no grade. A raw-labeled sale priced
1126
+ # like this card's graded copies is probably a graded sale with a terse title.
1127
+ if getattr(comp, "grade_unconfirmed", False):
1128
+ grade = f"[slab.warn]{grade}?[/]"
1129
+ flagged += 1
1123
1130
  market = comp.marketplace or "—"
1124
1131
  sale_type = comp.sale_type or "—"
1125
1132
  title = comp.title if len(comp.title) <= 50 else comp.title[:49] + "…"
@@ -1127,6 +1134,141 @@ def render_comps(c: CardComps) -> None:
1127
1134
 
1128
1135
  console.print(heading("Recent Comps", f"showing {len(c.comps)} of {c.total}"))
1129
1136
  console.print(table)
1137
+ if flagged:
1138
+ console.print(
1139
+ f" [slab.dim]? = grade unconfirmed: no grade in the listing title, but priced like "
1140
+ f"this card's graded copies — likely a graded sale, so don't read it as raw value.[/]"
1141
+ )
1142
+ console.print()
1143
+
1144
+
1145
+ def _desk_verdict_line(d: GradingDesk) -> str:
1146
+ """The verdict as one plain sentence — the confidence-BAR framing (you grade a card once, so
1147
+ the number is how good YOUR copy has to look, never "how often"). Wording mirrors the
1148
+ `grading.verdict` glossary entry."""
1149
+ def payoff_of(grade_key: str | None):
1150
+ return next((l.payoff for l in d.lanes if l.grade_key == grade_key), None)
1151
+
1152
+ gem = next((l for l in d.lanes if l.grade_key.startswith(f"{d.grading_company}-")), None)
1153
+ if d.verdict == GradingVerdict.not_enough_data:
1154
+ return ("[slab.dim]No graded market to read yet — nobody has sold graded copies of this "
1155
+ "card recently. Not a no; an honest shrug.[/]")
1156
+ if d.verdict == GradingVerdict.easy_no:
1157
+ top = f"even a {gem.grade_key} only returns {signed_money(gem.payoff)}" if gem and gem.payoff is not None else "no grade covers the fee"
1158
+ return f"[slab.loss]Keep it raw[/] — {top} after the {money(d.fee)} fee."
1159
+ miss_pay = payoff_of(d.miss_grade)
1160
+ if d.verdict == GradingVerdict.easy_yes:
1161
+ miss_txt = (f"even a {d.miss_grade} nets {signed_money(miss_pay)}"
1162
+ if miss_pay is not None else "even a realistic miss beats staying raw")
1163
+ return f"[slab.gain]Send it[/] — {miss_txt} after the {money(d.fee)} fee."
1164
+ pct = f"{d.ten_confidence_needed:.0%}"
1165
+ gem_txt = f"a {gem.grade_key} nets {signed_money(gem.payoff)}" if gem else "a gem pays"
1166
+ miss_txt = (f"a {d.miss_grade} comes back {signed_money(miss_pay)}"
1167
+ if miss_pay is not None else f"a miss holds roughly raw value")
1168
+ return (f"[slab.foil]Your call[/] — grade it only if you're more than {pct} sure it's a gem: "
1169
+ f"{gem_txt}, {miss_txt}.")
1170
+
1171
+
1172
+ def render_grading_desk(d: GradingDesk) -> None:
1173
+ """One card's grading decision: raw baseline, payoff per graded lane, and the verdict in the
1174
+ bar framing. Every sentence traces to a `grading.*` glossary entry."""
1175
+ subjects = ", ".join(d.subjects) or "—"
1176
+ slot = f"{d.subset or '—'} · {d.finish}" if d.finish else (d.subset or "base")
1177
+ console.print()
1178
+ console.print(_card_headline(d.card_number, subjects, d.set_name, slot))
1179
+
1180
+ if d.raw is None:
1181
+ console.print("\n [slab.dim]This card has no raw market price yet, so there is no "
1182
+ "baseline to grade against.[/]\n")
1183
+ return
1184
+
1185
+ console.print(
1186
+ f"\n Raw value: [slab.value]{money(d.raw.fair_market_value)}[/] "
1187
+ f"[slab.dim](from {d.raw.sample_size} sales) · {d.grading_company} fee assumed: "
1188
+ f"{money(d.fee)}"
1189
+ + (" — pass --fee to use your rate" if d.fee_source == "default" else " (your rate)")
1190
+ + "[/]"
1191
+ )
1192
+
1193
+ if d.lanes:
1194
+ table = slab_table([
1195
+ ("If it grades", {}),
1196
+ ("Sells for", {"justify": "right", "style": "slab.foil"}),
1197
+ ("You clear", {"justify": "right"}),
1198
+ ("Sales", {"justify": "right", "style": "slab.dim"}),
1199
+ ])
1200
+ for l in d.lanes:
1201
+ n = f"{l.sample_size}!" if l.low_confidence else str(l.sample_size)
1202
+ table.add_row(l.grade_key, money(l.fair_market_value), signed_money(l.payoff), n)
1203
+ console.print(heading("Payoff per Grade", "after the fee, vs staying raw"))
1204
+ console.print(table)
1205
+ if any(l.low_confidence for l in d.lanes):
1206
+ console.print(" [slab.dim]! = priced off very few sales — an anecdote, "
1207
+ "not a market.[/]")
1208
+
1209
+ console.print()
1210
+ console.print(f" {_desk_verdict_line(d)}")
1211
+ if d.verdict in (GradingVerdict.easy_yes, GradingVerdict.judgment_call) \
1212
+ and d.ten_confidence_needed_bad_day is not None and d.bad_day_grade:
1213
+ console.print(
1214
+ f" [slab.dim]Cautious read: if misses come back {d.bad_day_grade} instead, "
1215
+ f"the bar is {d.ten_confidence_needed_bad_day:.0%}.[/]"
1216
+ )
1217
+ if d.thin_data:
1218
+ console.print(" [slab.warn]Thin data[/][slab.dim] — part of this verdict rests on "
1219
+ "1-2 sales. Treat it as a hint, not a market reading.[/]")
1220
+ console.print()
1221
+
1222
+
1223
+ def render_collection_grading_desk(sweep: CollectionGradingDesk) -> None:
1224
+ """The batch view: raw copies ranked best-case first. Here the per-card bars become real
1225
+ odds — graded as a group, wins at these bars pay for the losses (the rip-EV logic)."""
1226
+ console.print()
1227
+ covered = f"{sweep.deskable} of your {sweep.total_raw_copies} raw copies have a graded market to read"
1228
+ console.print(heading("Grading Desk", covered))
1229
+ if not sweep.entries:
1230
+ console.print("\n [slab.dim]None of your raw cards have graded sales to compare "
1231
+ "against yet — no verdicts to give. That's the honest answer, not an "
1232
+ "error.[/]\n")
1233
+ return
1234
+
1235
+ table = slab_table([
1236
+ ("Verdict", {}),
1237
+ ("Card", {"no_wrap": False}),
1238
+ ("Raw", {"justify": "right", "style": "slab.dim"}),
1239
+ ("Gem pays", {"justify": "right"}),
1240
+ ("Bar", {"justify": "right", "style": "slab.foil"}),
1241
+ ("Qty", {"justify": "right", "style": "slab.dim"}),
1242
+ ])
1243
+ VERDICT_TXT = {
1244
+ GradingVerdict.easy_yes: "[slab.gain]send it[/]",
1245
+ GradingVerdict.judgment_call: "[slab.foil]your call[/]",
1246
+ GradingVerdict.easy_no: "[slab.loss]keep raw[/]",
1247
+ }
1248
+ for e in sweep.entries:
1249
+ d = e.desk
1250
+ who = ", ".join(d.subjects) or f"#{d.card_number}"
1251
+ card = f"{who} · {d.set_name or '—'}" + (f" · {d.finish}" if d.finish else "")
1252
+ gem = next((l for l in d.lanes
1253
+ if l.grade_key.startswith(f"{d.grading_company}-") and l.payoff is not None),
1254
+ None)
1255
+ bar = "—"
1256
+ if d.verdict == GradingVerdict.judgment_call and d.ten_confidence_needed is not None:
1257
+ bar = f"{d.ten_confidence_needed:.0%}"
1258
+ elif d.verdict == GradingVerdict.easy_yes:
1259
+ bar = "any"
1260
+ thin = " [slab.warn]!" if d.thin_data else ""
1261
+ table.add_row(
1262
+ VERDICT_TXT.get(d.verdict, d.verdict.value) + thin, card,
1263
+ money(d.raw.fair_market_value) if d.raw else "—",
1264
+ signed_money(gem.payoff) if gem else "[slab.dim]—[/]",
1265
+ bar, str(e.quantity),
1266
+ )
1267
+ console.print(table)
1268
+ console.print(f" [slab.dim]{sweep.fee_note}[/]")
1269
+ console.print(" [slab.dim]Bar = how sure you'd need to be the card gems before grading "
1270
+ "beats keeping it raw. You can see its corners; slab can't. "
1271
+ "! = verdict rests on very few sales.[/]")
1130
1272
  console.print()
1131
1273
 
1132
1274
 
File without changes