slab-cli 0.17.0__tar.gz → 0.19.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.17.0 → slab_cli-0.19.0}/PKG-INFO +2 -2
  2. {slab_cli-0.17.0 → slab_cli-0.19.0}/pyproject.toml +6 -4
  3. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/client.py +26 -0
  4. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/pricing.py +47 -0
  5. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/registry.py +2 -0
  6. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/display.py +139 -1
  7. {slab_cli-0.17.0 → slab_cli-0.19.0}/README.md +0 -0
  8. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/__init__.py +0 -0
  9. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/banner.py +0 -0
  10. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/browse.py +0 -0
  11. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/__init__.py +0 -0
  12. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/breaks.py +0 -0
  13. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/catalog.py +0 -0
  14. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/collection.py +0 -0
  15. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/custom_sets.py +0 -0
  16. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/export.py +0 -0
  17. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/lots.py +0 -0
  18. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/setup.py +0 -0
  19. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/commands/update.py +0 -0
  20. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/config.py +0 -0
  21. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/context.py +0 -0
  22. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/flags.py +0 -0
  23. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/help.py +0 -0
  24. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/main.py +0 -0
  25. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/paging.py +0 -0
  26. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/picker.py +0 -0
  27. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/platforms/__init__.py +0 -0
  28. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/platforms/base.py +0 -0
  29. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/platforms/posix.py +0 -0
  30. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/platforms/windows.py +0 -0
  31. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/prompts.py +0 -0
  32. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.17.0 → slab_cli-0.19.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.17.0 → slab_cli-0.19.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.17.0
3
+ Version: 0.19.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.32.0
6
+ Requires-Dist: slab-schemas>=0.34.0
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: rich>=13.0
9
9
  Requires-Dist: inquirerpy>=0.3
@@ -1,15 +1,17 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.17.0"
3
+ version = "0.19.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
- # 0.32 adds CompOut.grade_unconfirmed; older schemas drop the field on parse and the
11
- # comps table silently loses its grade-sanity "?" marker.
12
- "slab-schemas>=0.32.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
+ # 0.34 reframes the grading desk from verdicts to break-even readings (BreakEvenRegion,
13
+ # GradingDesk.break_even) — the desk shows data, never tells the user to grade.
14
+ "slab-schemas>=0.34.0",
13
15
  "httpx>=0.27",
14
16
  "rich>=13.0",
15
17
  "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
+ """The grading math for a card. Same player → set → card flow as `price`, then the grading
109
+ desk: what each grade nets after the fee and where break-even sits — the data to decide with;
110
+ the decision is yours. `--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
+ """The grading math across your raw cards, biggest upside first — where the per-card
120
+ break-even 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", "The grading math: payoffs per grade + break-even", 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", "Grading math for every raw copy, best upside first", 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 BreakEvenRegion, Grade, 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
@@ -1141,6 +1142,143 @@ def render_comps(c: CardComps) -> None:
1141
1142
  console.print()
1142
1143
 
1143
1144
 
1145
+ def _desk_reading_line(d: GradingDesk) -> str:
1146
+ """Where the math lands, as one plain DATA sentence — never an instruction. slab's principle:
1147
+ grading is subjective and the user is holding the card, so the desk states what the prices
1148
+ show and leaves the send/keep decision with them. Wording mirrors the `grading.break_even`
1149
+ glossary entry."""
1150
+ def payoff_of(grade_key: str | None):
1151
+ return next((l.payoff for l in d.lanes if l.grade_key == grade_key), None)
1152
+
1153
+ gem = next((l for l in d.lanes if l.grade_key.startswith(f"{d.grading_company}-")), None)
1154
+ if d.break_even == BreakEvenRegion.not_enough_data:
1155
+ return ("[slab.dim]No graded market to read yet — nobody has sold graded copies of this "
1156
+ "card recently. Not a no; an honest shrug.[/]")
1157
+ if d.break_even == BreakEvenRegion.pays_at_no_grade:
1158
+ top = f"even a {gem.grade_key} returns {signed_money(gem.payoff)}" if gem and gem.payoff is not None else "no grade covers the fee"
1159
+ return (f"[slab.loss]No grade covers the fee[/] — at today's prices {top} "
1160
+ f"after the {money(d.fee)} fee.")
1161
+ miss_pay = payoff_of(d.miss_grade)
1162
+ if d.break_even == BreakEvenRegion.pays_at_any_grade:
1163
+ miss_txt = (f"even a {d.miss_grade} nets {signed_money(miss_pay)}"
1164
+ if miss_pay is not None else "even a realistic miss nets more than staying raw")
1165
+ return (f"[slab.gain]Every grade pays[/] — at today's prices {miss_txt} "
1166
+ f"after the {money(d.fee)} fee.")
1167
+ pct = f"{d.ten_confidence_needed:.0%}"
1168
+ gem_txt = f"a {gem.grade_key} nets {signed_money(gem.payoff)}" if gem else "a gem pays"
1169
+ miss_txt = (f"a {d.miss_grade} comes back {signed_money(miss_pay)}"
1170
+ if miss_pay is not None else f"a miss holds roughly raw value")
1171
+ return (f"[slab.foil]Depends on the grade[/] — {gem_txt}, {miss_txt}. Break-even sits at "
1172
+ f"{pct}: below that chance of a gem, grading loses; above it, grading wins. "
1173
+ f"You can see the card; that read is yours.")
1174
+
1175
+
1176
+ def render_grading_desk(d: GradingDesk) -> None:
1177
+ """One card's grading math: raw baseline, payoff per graded lane, and where break-even lands.
1178
+ Data to decide with, never a recommendation. Every sentence traces to a `grading.*` glossary
1179
+ entry."""
1180
+ subjects = ", ".join(d.subjects) or "—"
1181
+ slot = f"{d.subset or '—'} · {d.finish}" if d.finish else (d.subset or "base")
1182
+ console.print()
1183
+ console.print(_card_headline(d.card_number, subjects, d.set_name, slot))
1184
+
1185
+ if d.raw is None:
1186
+ console.print("\n [slab.dim]This card has no raw market price yet, so there is no "
1187
+ "baseline to grade against.[/]\n")
1188
+ return
1189
+
1190
+ console.print(
1191
+ f"\n Raw value: [slab.value]{money(d.raw.fair_market_value)}[/] "
1192
+ f"[slab.dim](from {d.raw.sample_size} sales) · {d.grading_company} fee assumed: "
1193
+ f"{money(d.fee)}"
1194
+ + (" — pass --fee to use your rate" if d.fee_source == "default" else " (your rate)")
1195
+ + "[/]"
1196
+ )
1197
+
1198
+ if d.lanes:
1199
+ table = slab_table([
1200
+ ("If it grades", {}),
1201
+ ("Sells for", {"justify": "right", "style": "slab.foil"}),
1202
+ ("You clear", {"justify": "right"}),
1203
+ ("Sales", {"justify": "right", "style": "slab.dim"}),
1204
+ ])
1205
+ for l in d.lanes:
1206
+ n = f"{l.sample_size}!" if l.low_confidence else str(l.sample_size)
1207
+ table.add_row(l.grade_key, money(l.fair_market_value), signed_money(l.payoff), n)
1208
+ console.print(heading("Payoff per Grade", "after the fee, vs staying raw"))
1209
+ console.print(table)
1210
+ if any(l.low_confidence for l in d.lanes):
1211
+ console.print(" [slab.dim]! = priced off very few sales — an anecdote, "
1212
+ "not a market.[/]")
1213
+
1214
+ console.print()
1215
+ console.print(f" {_desk_reading_line(d)}")
1216
+ if d.break_even in (BreakEvenRegion.pays_at_any_grade, BreakEvenRegion.depends_on_grade) \
1217
+ and d.ten_confidence_needed_bad_day is not None and d.bad_day_grade:
1218
+ console.print(
1219
+ f" [slab.dim]Cautious read: if misses come back {d.bad_day_grade} instead, "
1220
+ f"break-even moves to {d.ten_confidence_needed_bad_day:.0%}.[/]"
1221
+ )
1222
+ if d.thin_data:
1223
+ console.print(" [slab.warn]Thin data[/][slab.dim] — part of this reading rests on "
1224
+ "1-2 sales. Treat it as a hint, not a market reading.[/]")
1225
+ console.print()
1226
+
1227
+
1228
+ def render_collection_grading_desk(sweep: CollectionGradingDesk) -> None:
1229
+ """The batch view: raw copies with their grading math, biggest upside first. Here the per-card
1230
+ bars become real odds — graded as a group, wins at these bars pay for the losses (the rip-EV
1231
+ logic). Every cell is data; which cards to send, if any, stays the user's call."""
1232
+ console.print()
1233
+ covered = f"{sweep.deskable} of your {sweep.total_raw_copies} raw copies have a graded market to read"
1234
+ console.print(heading("Grading Desk", covered))
1235
+ if not sweep.entries:
1236
+ console.print("\n [slab.dim]None of your raw cards have graded sales to compare "
1237
+ "against yet — no market to read. That's the honest answer, not an "
1238
+ "error.[/]\n")
1239
+ return
1240
+
1241
+ table = slab_table([
1242
+ ("Card", {"no_wrap": False}),
1243
+ ("Raw", {"justify": "right", "style": "slab.dim"}),
1244
+ ("Gem pays", {"justify": "right"}),
1245
+ ("Miss pays", {"justify": "right"}),
1246
+ ("Break-even", {"justify": "right", "style": "slab.foil"}),
1247
+ ("Qty", {"justify": "right", "style": "slab.dim"}),
1248
+ ])
1249
+ BREAK_EVEN_TXT = {
1250
+ BreakEvenRegion.pays_at_any_grade: "[slab.gain]any grade[/]",
1251
+ BreakEvenRegion.pays_at_no_grade: "[slab.loss]no grade[/]",
1252
+ }
1253
+ for e in sweep.entries:
1254
+ d = e.desk
1255
+ who = ", ".join(d.subjects) or f"#{d.card_number}"
1256
+ card = f"{who} · {d.set_name or '—'}" + (f" · {d.finish}" if d.finish else "")
1257
+ gem = next((l for l in d.lanes
1258
+ if l.grade_key.startswith(f"{d.grading_company}-") and l.payoff is not None),
1259
+ None)
1260
+ miss = next((l for l in d.lanes if l.grade_key == d.miss_grade), None)
1261
+ if d.break_even == BreakEvenRegion.depends_on_grade and d.ten_confidence_needed is not None:
1262
+ be = f"{d.ten_confidence_needed:.0%} gem"
1263
+ else:
1264
+ be = BREAK_EVEN_TXT.get(d.break_even, d.break_even.value)
1265
+ thin = " [slab.warn]!" if d.thin_data else ""
1266
+ table.add_row(
1267
+ card,
1268
+ money(d.raw.fair_market_value) if d.raw else "—",
1269
+ signed_money(gem.payoff) if gem else "[slab.dim]—[/]",
1270
+ signed_money(miss.payoff) if miss and miss.payoff is not None else "[slab.dim]—[/]",
1271
+ be + thin, str(e.quantity),
1272
+ )
1273
+ console.print(table)
1274
+ console.print(f" [slab.dim]{sweep.fee_note}[/]")
1275
+ console.print(" [slab.dim]Break-even = the chance of a gem at which grading and staying raw "
1276
+ "pay the same — below it grading loses, above it grading wins; 'any grade' = "
1277
+ "even a miss pays, 'no grade' = even a gem doesn't. You can see each card's "
1278
+ "corners; slab can't — the call is yours. ! = rests on very few sales.[/]")
1279
+ console.print()
1280
+
1281
+
1144
1282
  def _render_portfolio_by_set(by_set: list | None) -> None:
1145
1283
  """Per-set breakdown, most valuable first. Highlights the top set and shows comp coverage."""
1146
1284
  if not by_set:
File without changes