slab-cli 0.10.0__tar.gz → 0.11.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.10.0 → slab_cli-0.11.0}/PKG-INFO +1 -1
- {slab_cli-0.10.0 → slab_cli-0.11.0}/pyproject.toml +1 -1
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/client.py +9 -2
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/collection.py +13 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/custom_sets.py +44 -9
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/display.py +18 -2
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/prompts.py +13 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/README.md +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/browse.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/registry.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/main.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/platforms/__init__.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/platforms/base.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/platforms/posix.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/platforms/windows.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/theme.py +0 -0
- {slab_cli-0.10.0 → slab_cli-0.11.0}/src/slab_cli/updates.py +0 -0
|
@@ -336,7 +336,14 @@ class SlabClient:
|
|
|
336
336
|
resp = self._request("POST", "/custom-sets/search", json=body)
|
|
337
337
|
return CustomSetSearchResult.model_validate(resp.json())
|
|
338
338
|
|
|
339
|
-
def get_custom_set(
|
|
340
|
-
|
|
339
|
+
def get_custom_set(
|
|
340
|
+
self, set_uuid: str, collector_uuid: str | None = None,
|
|
341
|
+
limit: int | None = None, offset: int | None = None,
|
|
342
|
+
) -> CustomSetDetail:
|
|
343
|
+
params: dict = {"collector_uuid": collector_uuid} if collector_uuid else {}
|
|
344
|
+
if limit is not None:
|
|
345
|
+
params["limit"] = limit
|
|
346
|
+
if offset is not None:
|
|
347
|
+
params["offset"] = offset
|
|
341
348
|
resp = self._request("GET", f"/custom-sets/{set_uuid}", params=params)
|
|
342
349
|
return CustomSetDetail.model_validate(resp.json())
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date
|
|
7
8
|
from decimal import Decimal
|
|
8
9
|
|
|
9
10
|
from slab_schemas.cards import CardOut, CardSearchQuery
|
|
@@ -68,6 +69,7 @@ class AddSession:
|
|
|
68
69
|
lot_uuid: str | None = None # the purchase this batch belongs to; its cost splits across cards
|
|
69
70
|
release_set_uuid: str | None = None # the break's product — scopes every card search to that box
|
|
70
71
|
acquired_source: str | None = None # where these came from (purchases/trades/gifts), set once
|
|
72
|
+
acquired_date: date | None = None # when you got them; None for break/lot cards (theirs wins)
|
|
71
73
|
cards_added: int = 0
|
|
72
74
|
|
|
73
75
|
|
|
@@ -119,6 +121,14 @@ def _ensure_acquisition_context(session: AddSession) -> None:
|
|
|
119
121
|
# Trade / gift / other → ask where these came from, once for the batch.
|
|
120
122
|
session.acquired_source = pick_source(session.client, session.collector)
|
|
121
123
|
|
|
124
|
+
# When you got them — asked once, and ONLY for standalone cards: a break/lot card takes its
|
|
125
|
+
# date from the container (which already asked), and storing a second copy of it would just be
|
|
126
|
+
# something else to keep in sync. Enter defaults to today, so the common "adding what I bought
|
|
127
|
+
# today" case is one keystroke — but an old pickup can be dated correctly, which matters
|
|
128
|
+
# because the portfolio chart only counts a card from the day you got it.
|
|
129
|
+
if session.break_uuid is None and session.lot_uuid is None:
|
|
130
|
+
session.acquired_date = ask_date("When did you get it?")
|
|
131
|
+
|
|
122
132
|
|
|
123
133
|
def _per_card_cost(session: AddSession) -> Decimal | None:
|
|
124
134
|
"""The cost that varies per card. Pack pulls derive cost from the break, and a purchase attached
|
|
@@ -164,6 +174,8 @@ def _show_review(card: CardOut, session: AddSession, acq_cost: Decimal | None,
|
|
|
164
174
|
console.print(f" Purchase: {session.lot_uuid[:8]} [dim](cost split across the lot)[/dim]")
|
|
165
175
|
if acq_cost is not None:
|
|
166
176
|
console.print(f" Cost: ${acq_cost:.2f}")
|
|
177
|
+
if session.acquired_date:
|
|
178
|
+
console.print(f" Date: {session.acquired_date}")
|
|
167
179
|
if session.acquired_source:
|
|
168
180
|
console.print(f" Source: {session.acquired_source}")
|
|
169
181
|
if self_grade:
|
|
@@ -197,6 +209,7 @@ def _submit_and_report(session: AddSession, card: CardOut, acq_cost: Decimal | N
|
|
|
197
209
|
lot_uuid=session.lot_uuid,
|
|
198
210
|
acquisition_type=session.acq_type,
|
|
199
211
|
acquisition_cost=acq_cost,
|
|
212
|
+
acquired_date=session.acquired_date,
|
|
200
213
|
acquired_source=session.acquired_source,
|
|
201
214
|
self_grade=self_grade,
|
|
202
215
|
grading_company=grading_company,
|
|
@@ -5,13 +5,16 @@ from __future__ import annotations
|
|
|
5
5
|
from InquirerPy import inquirer
|
|
6
6
|
from InquirerPy.base.control import Choice
|
|
7
7
|
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
8
10
|
from slab_schemas.custom_sets import (
|
|
9
11
|
CustomSetCardAdd,
|
|
10
12
|
CustomSetCreate,
|
|
13
|
+
CustomSetDetail,
|
|
11
14
|
CustomSetOut,
|
|
12
15
|
CustomSetSearchQuery,
|
|
13
16
|
)
|
|
14
|
-
from slab_schemas.enums import CustomSetType, MatchMode, Visibility
|
|
17
|
+
from slab_schemas.enums import CustomSetType, DynamicMatch, MatchMode, Visibility
|
|
15
18
|
|
|
16
19
|
from ..client import SlabClient
|
|
17
20
|
from ..context import ctx
|
|
@@ -21,6 +24,7 @@ from ..display import (
|
|
|
21
24
|
format_card_summary,
|
|
22
25
|
print_custom_set_detail,
|
|
23
26
|
)
|
|
27
|
+
from ..paging import fetch_all_pages
|
|
24
28
|
from ..picker import search_and_pick_subject
|
|
25
29
|
from ..prompts import (
|
|
26
30
|
ask_int_optional,
|
|
@@ -28,6 +32,7 @@ from ..prompts import (
|
|
|
28
32
|
ask_text_optional,
|
|
29
33
|
confirm,
|
|
30
34
|
select_custom_set_type,
|
|
35
|
+
select_dynamic_match,
|
|
31
36
|
select_match_mode,
|
|
32
37
|
select_printing,
|
|
33
38
|
select_visibility,
|
|
@@ -38,6 +43,25 @@ from ..prompts import (
|
|
|
38
43
|
# Helpers
|
|
39
44
|
# ---------------------------------------------------------------------------
|
|
40
45
|
|
|
46
|
+
def _fetch_full_detail(
|
|
47
|
+
client: SlabClient, set_uuid: str, collector: str | None
|
|
48
|
+
) -> CustomSetDetail:
|
|
49
|
+
"""The set with its WHOLE card list — the detail endpoint pages `cards` (completion stats
|
|
50
|
+
already cover the full set), so flows that show or iterate every entry stitch the pages back
|
|
51
|
+
together here. Capped at 5000 entries as a runaway bound (a `year`-only dynamic filter can
|
|
52
|
+
match a whole catalog); past the cap, `card_count` > len(cards) and the display says so."""
|
|
53
|
+
first: list = []
|
|
54
|
+
|
|
55
|
+
def fetch(limit: int, offset: int):
|
|
56
|
+
d = client.get_custom_set(set_uuid, collector, limit=limit, offset=offset)
|
|
57
|
+
if not first:
|
|
58
|
+
first.append(d)
|
|
59
|
+
return SimpleNamespace(items=d.cards, total=d.card_count)
|
|
60
|
+
|
|
61
|
+
cards, _ = fetch_all_pages(fetch, page=500, cap=5000)
|
|
62
|
+
return first[0].model_copy(update={"cards": cards, "offset": 0})
|
|
63
|
+
|
|
64
|
+
|
|
41
65
|
def _select_custom_set(client: SlabClient, collector: str, message: str = "Select a set:") -> CustomSetOut | None:
|
|
42
66
|
"""Browse the collector's custom sets and pick one."""
|
|
43
67
|
sets = client.collector_custom_sets(collector)
|
|
@@ -98,6 +122,7 @@ def cmd_chase_create(args: list[str]) -> None:
|
|
|
98
122
|
visibility = select_visibility()
|
|
99
123
|
|
|
100
124
|
filter_json = None
|
|
125
|
+
dynamic_match = "any_printing"
|
|
101
126
|
if set_type == "dynamic":
|
|
102
127
|
console.print("\n[dim]A dynamic set is defined by a search filter. Enter the filter criteria.[/dim]")
|
|
103
128
|
console.print("[dim]Cards matching this filter will automatically be in the set.[/dim]\n")
|
|
@@ -106,6 +131,15 @@ def cmd_chase_create(args: list[str]) -> None:
|
|
|
106
131
|
if filter_json is None:
|
|
107
132
|
console.print("[dim]Cancelled.[/dim]")
|
|
108
133
|
return
|
|
134
|
+
# One set-wide granularity. Default follows the filter's intent: a filter that names a
|
|
135
|
+
# finish/parallel dimension reads as a per-printing chase; anything else (team, player,
|
|
136
|
+
# season) reads as "one of each card — base or any parallel counts".
|
|
137
|
+
chasing_printings = any(
|
|
138
|
+
k in filter_json for k in ("finish", "parallel_only", "is_numbered", "is_1of1")
|
|
139
|
+
)
|
|
140
|
+
dynamic_match = select_dynamic_match(
|
|
141
|
+
default="exact" if chasing_printings else "any_printing"
|
|
142
|
+
)
|
|
109
143
|
|
|
110
144
|
cs = ctx.client.create_custom_set(collector, CustomSetCreate(
|
|
111
145
|
name=name,
|
|
@@ -113,6 +147,7 @@ def cmd_chase_create(args: list[str]) -> None:
|
|
|
113
147
|
visibility=Visibility(visibility),
|
|
114
148
|
set_type=CustomSetType(set_type),
|
|
115
149
|
filter_json=filter_json,
|
|
150
|
+
dynamic_match=DynamicMatch(dynamic_match),
|
|
116
151
|
))
|
|
117
152
|
|
|
118
153
|
console.print(f"\n[green]Created![/green] {cs.name} [dim]({cs.uuid[:8]})[/dim]")
|
|
@@ -277,12 +312,12 @@ def cmd_chase_view(args: list[str]) -> None:
|
|
|
277
312
|
client = ctx.client
|
|
278
313
|
|
|
279
314
|
if args:
|
|
280
|
-
detail = client
|
|
315
|
+
detail = _fetch_full_detail(client, args[0], collector)
|
|
281
316
|
else:
|
|
282
317
|
cs = _select_custom_set(client, collector, message="Which set to view?")
|
|
283
318
|
if cs is None:
|
|
284
319
|
return
|
|
285
|
-
detail = client
|
|
320
|
+
detail = _fetch_full_detail(client, cs.uuid, collector)
|
|
286
321
|
|
|
287
322
|
print_custom_set_detail(detail)
|
|
288
323
|
|
|
@@ -311,8 +346,8 @@ def cmd_chase_edit(args: list[str]) -> None:
|
|
|
311
346
|
if cs is None:
|
|
312
347
|
return
|
|
313
348
|
|
|
314
|
-
# Check ownership
|
|
315
|
-
detail = client.get_custom_set(cs.uuid, collector)
|
|
349
|
+
# Check ownership — metadata only, skip the card list
|
|
350
|
+
detail = client.get_custom_set(cs.uuid, collector, limit=1)
|
|
316
351
|
if detail.creator_uuid != collector:
|
|
317
352
|
console.print("[yellow]You can only edit sets you created.[/yellow]")
|
|
318
353
|
return
|
|
@@ -350,7 +385,7 @@ def cmd_chase_edit(args: list[str]) -> None:
|
|
|
350
385
|
|
|
351
386
|
def _chase_detail(s) -> None:
|
|
352
387
|
"""The drill-in for a browsed chase set: its full card list + completion, owner-aware."""
|
|
353
|
-
print_custom_set_detail(ctx.client
|
|
388
|
+
print_custom_set_detail(_fetch_full_detail(ctx.client, s.uuid, ctx.collector))
|
|
354
389
|
|
|
355
390
|
|
|
356
391
|
def cmd_chase_list(args: list[str]) -> None:
|
|
@@ -436,7 +471,7 @@ def cmd_chase_remove(args: list[str]) -> None:
|
|
|
436
471
|
console.print("[yellow]Can only remove cards from curated sets.[/yellow]")
|
|
437
472
|
return
|
|
438
473
|
|
|
439
|
-
detail = client
|
|
474
|
+
detail = _fetch_full_detail(client, cs.uuid, collector)
|
|
440
475
|
if not detail.cards:
|
|
441
476
|
console.print("[dim]This set has no cards.[/dim]")
|
|
442
477
|
return
|
|
@@ -475,8 +510,8 @@ def cmd_chase_delete(args: list[str]) -> None:
|
|
|
475
510
|
if cs is None:
|
|
476
511
|
return
|
|
477
512
|
|
|
478
|
-
# Check ownership
|
|
479
|
-
detail = client.get_custom_set(cs.uuid, collector)
|
|
513
|
+
# Check ownership — metadata only, skip the card list
|
|
514
|
+
detail = client.get_custom_set(cs.uuid, collector, limit=1)
|
|
480
515
|
if detail.creator_uuid != collector:
|
|
481
516
|
console.print("[yellow]You can only delete sets you created.[/yellow]")
|
|
482
517
|
return
|
|
@@ -530,10 +530,15 @@ def render_dashboard(d: DashboardStats) -> None:
|
|
|
530
530
|
if d.priced_coverage is not None:
|
|
531
531
|
rows.append(("Coverage", f"{pct_coverage(float(d.priced_coverage) * 100)} [slab.dim]priced[/]"))
|
|
532
532
|
# Portfolio value over time — the CLI stand-in for the portal's chart, from the same payload.
|
|
533
|
+
# Each point values only the copies owned that day, so the net includes cards you ADDED in
|
|
534
|
+
# the window, not just price movement — said plainly so it isn't read as a 90d gain.
|
|
533
535
|
if len(d.portfolio_series) >= 2:
|
|
534
536
|
vals = [p.portfolio_value for p in d.portfolio_series]
|
|
535
537
|
net = float(vals[-1]) - float(vals[0])
|
|
536
|
-
rows.append((
|
|
538
|
+
rows.append((
|
|
539
|
+
"Trend 90d",
|
|
540
|
+
f"{sparkline(vals)} {signed_money(net)} [slab.dim]incl. cards added[/]",
|
|
541
|
+
))
|
|
537
542
|
value = section("Value", kv_grid(rows), foil=True)
|
|
538
543
|
|
|
539
544
|
comp = _composition_panel(
|
|
@@ -805,7 +810,11 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
|
|
|
805
810
|
vis = "Public" if detail.visibility == "public" else "Private"
|
|
806
811
|
sub = "subscribed" if detail.is_subscribed else "not subscribed"
|
|
807
812
|
|
|
808
|
-
|
|
813
|
+
kind = detail.set_type
|
|
814
|
+
if detail.dynamic_match:
|
|
815
|
+
grain = "any printing counts" if detail.dynamic_match == "any_printing" else "exact printings"
|
|
816
|
+
kind = f"{detail.set_type} ({grain})"
|
|
817
|
+
head = [f"[slab.dim]{kind} · {vis} · {sub}[/]"]
|
|
809
818
|
if detail.description:
|
|
810
819
|
head.append(detail.description)
|
|
811
820
|
head.append(f"[slab.label]Creator[/] {detail.creator_name or '—'} "
|
|
@@ -848,6 +857,13 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
|
|
|
848
857
|
table.add_row(str(entry.position), label, match, owned)
|
|
849
858
|
|
|
850
859
|
console.print(table)
|
|
860
|
+
if detail.card_count > len(detail.cards):
|
|
861
|
+
first = detail.offset + 1
|
|
862
|
+
last = detail.offset + len(detail.cards)
|
|
863
|
+
console.print(
|
|
864
|
+
f" [slab.dim]Showing entries {first}–{last} of {detail.card_count} "
|
|
865
|
+
f"(completion covers the full set).[/]"
|
|
866
|
+
)
|
|
851
867
|
console.print()
|
|
852
868
|
|
|
853
869
|
|
|
@@ -452,6 +452,19 @@ def select_custom_set_type() -> str:
|
|
|
452
452
|
return inquirer.select(message="Set type:", choices=choices).execute()
|
|
453
453
|
|
|
454
454
|
|
|
455
|
+
def select_dynamic_match(default: str = "any_printing") -> str:
|
|
456
|
+
"""The set-wide granularity of a dynamic set — what counts as completing a card."""
|
|
457
|
+
choices = [
|
|
458
|
+
Choice(value="any_printing",
|
|
459
|
+
name="Any printing — one entry per card; base or any parallel completes it"),
|
|
460
|
+
Choice(value="exact",
|
|
461
|
+
name="Exact — every matching parallel is its own entry to chase"),
|
|
462
|
+
]
|
|
463
|
+
return inquirer.select(
|
|
464
|
+
message="What counts as having a card?", choices=choices, default=default,
|
|
465
|
+
).execute()
|
|
466
|
+
|
|
467
|
+
|
|
455
468
|
def select_cost_category() -> str:
|
|
456
469
|
choices = [
|
|
457
470
|
Choice(value="grading", name="Grading fee"),
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|