slab-cli 0.1.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.1.0/PKG-INFO +70 -0
- slab_cli-0.1.0/README.md +58 -0
- slab_cli-0.1.0/pyproject.toml +27 -0
- slab_cli-0.1.0/src/slab_cli/__init__.py +0 -0
- slab_cli-0.1.0/src/slab_cli/banner.py +92 -0
- slab_cli-0.1.0/src/slab_cli/client.py +386 -0
- slab_cli-0.1.0/src/slab_cli/commands/__init__.py +4 -0
- slab_cli-0.1.0/src/slab_cli/commands/breaks.py +140 -0
- slab_cli-0.1.0/src/slab_cli/commands/catalog.py +84 -0
- slab_cli-0.1.0/src/slab_cli/commands/collection.py +676 -0
- slab_cli-0.1.0/src/slab_cli/commands/custom_sets.py +484 -0
- slab_cli-0.1.0/src/slab_cli/commands/export.py +129 -0
- slab_cli-0.1.0/src/slab_cli/commands/lots.py +114 -0
- slab_cli-0.1.0/src/slab_cli/commands/pricing.py +386 -0
- slab_cli-0.1.0/src/slab_cli/commands/registry.py +140 -0
- slab_cli-0.1.0/src/slab_cli/commands/setup.py +24 -0
- slab_cli-0.1.0/src/slab_cli/config.py +21 -0
- slab_cli-0.1.0/src/slab_cli/context.py +50 -0
- slab_cli-0.1.0/src/slab_cli/display.py +1187 -0
- slab_cli-0.1.0/src/slab_cli/flags.py +189 -0
- slab_cli-0.1.0/src/slab_cli/help.py +87 -0
- slab_cli-0.1.0/src/slab_cli/main.py +95 -0
- slab_cli-0.1.0/src/slab_cli/paging.py +46 -0
- slab_cli-0.1.0/src/slab_cli/picker.py +78 -0
- slab_cli-0.1.0/src/slab_cli/prompts.py +549 -0
- slab_cli-0.1.0/src/slab_cli/sources.py +17 -0
- slab_cli-0.1.0/src/slab_cli/theme.py +184 -0
slab_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: slab-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
|
|
5
|
+
Author: dev_jeb
|
|
6
|
+
Requires-Dist: slab-schemas>=0.1.0
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: rich>=13.0
|
|
9
|
+
Requires-Dist: inquirerpy>=0.3
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# slab-cli
|
|
14
|
+
|
|
15
|
+
A terminal client for the [slab](https://api.slab.dev-jeb.com) trading-card API — browse the
|
|
16
|
+
catalog, price cards against real sales comps, and track your own collection from the command line.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install slab-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This installs the `slab` command.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
The CLI talks to the hosted slab API. You need an **API key** — mint one from the
|
|
27
|
+
[slab portal](https://app.slab.dev-jeb.com) (sign in → **API Keys** → create). Then:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export SLAB_API_KEY=sk_live_... # your key (required)
|
|
31
|
+
# optional:
|
|
32
|
+
export SLAB_API_URL=https://api.slab.dev-jeb.com # default; override for a self-hosted API
|
|
33
|
+
export SLAB_COLLECTOR=<collector-uuid> # your active collector, reused across commands
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Catalog + pricing (no collector needed)
|
|
40
|
+
slab card search --subject "Connor Bedard" # find cards
|
|
41
|
+
slab card price <card-uuid> # fair-market value from sales comps
|
|
42
|
+
slab card comps <card-uuid> # the underlying recent sales
|
|
43
|
+
slab set "OPC Platinum" # a set's sealed FMV + top cards
|
|
44
|
+
|
|
45
|
+
# Your collection
|
|
46
|
+
slab collector create # one-time: create your collector profile
|
|
47
|
+
slab collection add # add an owned copy (grade, cost, acquisition)
|
|
48
|
+
slab collection cost # log grading/shipping costs on a copy
|
|
49
|
+
slab break new # record a box/case break
|
|
50
|
+
slab collection dashboard # portfolio value, cost basis, P&L
|
|
51
|
+
slab collection search # search + filter your copies
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run `slab` with no arguments for the full command list, or `slab <group>` (e.g. `slab collection`)
|
|
55
|
+
to see a group's verbs.
|
|
56
|
+
|
|
57
|
+
## Configuration reference
|
|
58
|
+
|
|
59
|
+
| Env var | Purpose | Default |
|
|
60
|
+
| --------------- | ------------------------------------------ | -------------------------------- |
|
|
61
|
+
| `SLAB_API_KEY` | API key for authentication (**required**) | — |
|
|
62
|
+
| `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
|
|
63
|
+
| `SLAB_COLLECTOR`| Active collector UUID, reused per command | — |
|
|
64
|
+
|
|
65
|
+
## What it depends on
|
|
66
|
+
|
|
67
|
+
Requests and responses are typed against [`slab-schemas`](https://pypi.org/project/slab-schemas/) —
|
|
68
|
+
the same pydantic wire contract the API itself uses — so the CLI and API can never drift.
|
|
69
|
+
|
|
70
|
+
Requires Python 3.13+.
|
slab_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# slab-cli
|
|
2
|
+
|
|
3
|
+
A terminal client for the [slab](https://api.slab.dev-jeb.com) trading-card API — browse the
|
|
4
|
+
catalog, price cards against real sales comps, and track your own collection from the command line.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install slab-cli
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
This installs the `slab` command.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
The CLI talks to the hosted slab API. You need an **API key** — mint one from the
|
|
15
|
+
[slab portal](https://app.slab.dev-jeb.com) (sign in → **API Keys** → create). Then:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export SLAB_API_KEY=sk_live_... # your key (required)
|
|
19
|
+
# optional:
|
|
20
|
+
export SLAB_API_URL=https://api.slab.dev-jeb.com # default; override for a self-hosted API
|
|
21
|
+
export SLAB_COLLECTOR=<collector-uuid> # your active collector, reused across commands
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Catalog + pricing (no collector needed)
|
|
28
|
+
slab card search --subject "Connor Bedard" # find cards
|
|
29
|
+
slab card price <card-uuid> # fair-market value from sales comps
|
|
30
|
+
slab card comps <card-uuid> # the underlying recent sales
|
|
31
|
+
slab set "OPC Platinum" # a set's sealed FMV + top cards
|
|
32
|
+
|
|
33
|
+
# Your collection
|
|
34
|
+
slab collector create # one-time: create your collector profile
|
|
35
|
+
slab collection add # add an owned copy (grade, cost, acquisition)
|
|
36
|
+
slab collection cost # log grading/shipping costs on a copy
|
|
37
|
+
slab break new # record a box/case break
|
|
38
|
+
slab collection dashboard # portfolio value, cost basis, P&L
|
|
39
|
+
slab collection search # search + filter your copies
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run `slab` with no arguments for the full command list, or `slab <group>` (e.g. `slab collection`)
|
|
43
|
+
to see a group's verbs.
|
|
44
|
+
|
|
45
|
+
## Configuration reference
|
|
46
|
+
|
|
47
|
+
| Env var | Purpose | Default |
|
|
48
|
+
| --------------- | ------------------------------------------ | -------------------------------- |
|
|
49
|
+
| `SLAB_API_KEY` | API key for authentication (**required**) | — |
|
|
50
|
+
| `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
|
|
51
|
+
| `SLAB_COLLECTOR`| Active collector UUID, reused per command | — |
|
|
52
|
+
|
|
53
|
+
## What it depends on
|
|
54
|
+
|
|
55
|
+
Requests and responses are typed against [`slab-schemas`](https://pypi.org/project/slab-schemas/) —
|
|
56
|
+
the same pydantic wire contract the API itself uses — so the CLI and API can never drift.
|
|
57
|
+
|
|
58
|
+
Requires Python 3.13+.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "slab-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{name = "dev_jeb"}]
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"slab-schemas>=0.1.0",
|
|
10
|
+
"httpx>=0.27",
|
|
11
|
+
"rich>=13.0",
|
|
12
|
+
"InquirerPy>=0.3",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = ["pytest>=8.0"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.9.5,<0.10.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[[tool.uv.index]]
|
|
23
|
+
name = "slab-registry"
|
|
24
|
+
url = "https://slab-package-domain-682033488592.d.codeartifact.us-east-1.amazonaws.com/pypi/slab-package-repository/simple/"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
slab = "slab_cli.main:main"
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The slab banner — the wordmark rendered as the thing a slab IS: a graded card in its case.
|
|
2
|
+
|
|
3
|
+
A rounded foil-edged case (the same card silhouette every panel in the CLI uses), a grade label
|
|
4
|
+
across the top — because obviously this CLI grades a perfect GEM MT 10 — and the SLAB wordmark
|
|
5
|
+
printed in full gold foil: the letter faces run a top-to-bottom gradient between the portal's two
|
|
6
|
+
foil tokens (bright gold catching the light, fading toward dark gold), and the drop-shadow strokes
|
|
7
|
+
sit in dark gold so every letterform — the B's bowls especially — stays crisp instead of dissolving
|
|
8
|
+
into the background. Both endpoint colors come from `theme.py`, so the banner restyles with the
|
|
9
|
+
rest of the CLI."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from rich.console import Group, RenderableType
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from .theme import CARD_BOX, FOIL, FOIL_DIM
|
|
18
|
+
|
|
19
|
+
# ANSI-shadow letterform: solid blocks are the foil face, box-drawing chars the drop shadow.
|
|
20
|
+
_WORDMARK = """\
|
|
21
|
+
███████╗██╗ █████╗ ██████╗
|
|
22
|
+
██╔════╝██║ ██╔══██╗██╔══██╗
|
|
23
|
+
███████╗██║ ███████║██████╔╝
|
|
24
|
+
╚════██║██║ ██╔══██║██╔══██╗
|
|
25
|
+
███████║███████╗██║ ██║██████╔╝
|
|
26
|
+
╚══════╝╚══════╝╚═╝ ╚═╝╚═════╝"""
|
|
27
|
+
|
|
28
|
+
_SHADOW = set("╔╗╚╝═║")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
|
32
|
+
h = hex_color.lstrip("#")
|
|
33
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lerp_hex(a: str, b: str, t: float) -> str:
|
|
37
|
+
"""Blend between two theme hex colors — the in-between shades of the foil gradient."""
|
|
38
|
+
ra, ga, ba = _hex_to_rgb(a)
|
|
39
|
+
rb, gb, bb = _hex_to_rgb(b)
|
|
40
|
+
return f"#{round(ra + (rb - ra) * t):02x}{round(ga + (gb - ga) * t):02x}{round(ba + (bb - ba) * t):02x}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _foil_print(art: str) -> Text:
|
|
44
|
+
"""Print the letterform in foil: gradient gold faces, dark-gold shadow strokes.
|
|
45
|
+
|
|
46
|
+
The face gradient stops short of FOIL_DIM (t ≤ 0.6) so even the bottom row of the face stays
|
|
47
|
+
visibly brighter than the shadow — face and shadow must never blend or letter shapes smear."""
|
|
48
|
+
lines = art.splitlines()
|
|
49
|
+
width = max(len(line) for line in lines)
|
|
50
|
+
last = len(lines) - 1
|
|
51
|
+
t = Text()
|
|
52
|
+
for i, line in enumerate(lines):
|
|
53
|
+
face = f"bold {_lerp_hex(FOIL, FOIL_DIM, 0.6 * (i / last))}"
|
|
54
|
+
for ch in line.ljust(width):
|
|
55
|
+
t.append(ch, style=face if ch == "█" else f"{FOIL_DIM}" if ch in _SHADOW else None)
|
|
56
|
+
if i < last:
|
|
57
|
+
t.append("\n")
|
|
58
|
+
return t
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _grade_label(width: int) -> Text:
|
|
62
|
+
"""The slab's grade label: what it holds on the left, the verdict in gold on the right."""
|
|
63
|
+
left, right = "CATALOG & COLLECTION", "GEM MT 10"
|
|
64
|
+
t = Text()
|
|
65
|
+
t.append(left, style="slab.label")
|
|
66
|
+
t.append(" " * max(1, width - len(left) - len(right)))
|
|
67
|
+
t.append(right, style="slab.foil")
|
|
68
|
+
return t
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build() -> RenderableType:
|
|
72
|
+
width = max(len(line) for line in _WORDMARK.splitlines())
|
|
73
|
+
case = Panel(
|
|
74
|
+
Group(
|
|
75
|
+
_grade_label(width),
|
|
76
|
+
Text("─" * width, style="slab.foil_dim"),
|
|
77
|
+
_foil_print(_WORDMARK),
|
|
78
|
+
),
|
|
79
|
+
box=CARD_BOX,
|
|
80
|
+
border_style="slab.foil_dim",
|
|
81
|
+
padding=(0, 2),
|
|
82
|
+
expand=False,
|
|
83
|
+
)
|
|
84
|
+
return Group(
|
|
85
|
+
Text(),
|
|
86
|
+
case,
|
|
87
|
+
Text(" trading card catalog & collection tracker", style="slab.dim"),
|
|
88
|
+
Text(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
BANNER: RenderableType = _build()
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Typed HTTP client for the slab API. Talks to the API via httpx, returns schema DTOs.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
client = SlabClient("https://api.slab.dev-jeb.com", api_key="sk_live_...")
|
|
5
|
+
results = client.search_cards(CardSearchQuery(subject="McDavid", rookie=True))
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from slab_schemas.account import MeOut
|
|
12
|
+
from slab_schemas.cards import (
|
|
13
|
+
CardOut,
|
|
14
|
+
CardSearchQuery,
|
|
15
|
+
CardSearchResult,
|
|
16
|
+
SetSearchQuery,
|
|
17
|
+
SetSearchResult,
|
|
18
|
+
)
|
|
19
|
+
from slab_schemas.community import CommunityBoard
|
|
20
|
+
from slab_schemas.collection import (
|
|
21
|
+
BreakCreate,
|
|
22
|
+
BreakOut,
|
|
23
|
+
BreakSearchQuery,
|
|
24
|
+
BreakSearchResult,
|
|
25
|
+
BreakUpdate,
|
|
26
|
+
LotCreate,
|
|
27
|
+
LotOut,
|
|
28
|
+
LotSearchQuery,
|
|
29
|
+
LotSearchResult,
|
|
30
|
+
LotUpdate,
|
|
31
|
+
CardCopyCostCreate,
|
|
32
|
+
CardCopyCostOut,
|
|
33
|
+
CardCopyCostUpdate,
|
|
34
|
+
CardCopyCreate,
|
|
35
|
+
CardCopyOut,
|
|
36
|
+
CardCopyUpdate,
|
|
37
|
+
CollectionResult,
|
|
38
|
+
CollectionSearchQuery,
|
|
39
|
+
CollectorCreate,
|
|
40
|
+
CollectorOut,
|
|
41
|
+
CollectorUpdate,
|
|
42
|
+
)
|
|
43
|
+
from slab_schemas.custom_sets import (
|
|
44
|
+
CustomSetCardAdd,
|
|
45
|
+
CustomSetCardOut,
|
|
46
|
+
CustomSetCreate,
|
|
47
|
+
CustomSetDetail,
|
|
48
|
+
CustomSetOut,
|
|
49
|
+
CustomSetSearchQuery,
|
|
50
|
+
CustomSetSearchResult,
|
|
51
|
+
CustomSetUpdate,
|
|
52
|
+
)
|
|
53
|
+
from slab_schemas.dashboard import CatalogStats, DashboardStats
|
|
54
|
+
from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
|
|
55
|
+
from slab_schemas.sealed import SealedMarket, SealedPriceHistory, SealedProductOut
|
|
56
|
+
from slab_schemas.timeseries import CardPriceHistory, PortfolioHistory
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ApiError(Exception):
|
|
60
|
+
"""An error returned by the API (4xx/5xx)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, status: int, detail: str):
|
|
63
|
+
self.status = status
|
|
64
|
+
self.detail = detail
|
|
65
|
+
super().__init__(f"[{status}] {detail}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ApiConnectionError(Exception):
|
|
69
|
+
"""Cannot reach the API."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, url: str, reason: str):
|
|
72
|
+
self.url = url
|
|
73
|
+
super().__init__(f"Cannot connect to {url}: {reason}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SlabClient:
|
|
77
|
+
"""Typed client for the slab API."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, base_url: str, api_key: str | None = None):
|
|
80
|
+
headers = {}
|
|
81
|
+
if api_key:
|
|
82
|
+
headers["x-api-key"] = api_key
|
|
83
|
+
self._base_url = base_url
|
|
84
|
+
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30)
|
|
85
|
+
|
|
86
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
87
|
+
try:
|
|
88
|
+
resp = self._http.request(method, path, **kwargs)
|
|
89
|
+
except httpx.ConnectError:
|
|
90
|
+
raise ApiConnectionError(self._base_url, "connection refused — is the API running?")
|
|
91
|
+
except httpx.TimeoutException:
|
|
92
|
+
raise ApiConnectionError(self._base_url, "request timed out")
|
|
93
|
+
except httpx.HTTPError as e:
|
|
94
|
+
raise ApiConnectionError(self._base_url, str(e))
|
|
95
|
+
|
|
96
|
+
if resp.status_code >= 400:
|
|
97
|
+
detail = resp.text
|
|
98
|
+
try:
|
|
99
|
+
body = resp.json()
|
|
100
|
+
if isinstance(body, dict) and "detail" in body:
|
|
101
|
+
detail = body["detail"]
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
raise ApiError(resp.status_code, detail)
|
|
105
|
+
return resp
|
|
106
|
+
|
|
107
|
+
# --- catalog ---
|
|
108
|
+
|
|
109
|
+
def search_cards(self, query: CardSearchQuery) -> CardSearchResult:
|
|
110
|
+
resp = self._request("POST", "/cards/search", json=query.model_dump(mode="json", exclude_none=True))
|
|
111
|
+
return CardSearchResult.model_validate(resp.json())
|
|
112
|
+
|
|
113
|
+
def get_parallels(self, card_uuid: str) -> list[CardOut]:
|
|
114
|
+
resp = self._request("GET", f"/cards/{card_uuid}/parallels")
|
|
115
|
+
return [CardOut.model_validate(c) for c in resp.json()]
|
|
116
|
+
|
|
117
|
+
def get_card_market(self, card_uuid: str) -> CardMarket:
|
|
118
|
+
resp = self._request("GET", f"/cards/{card_uuid}/market")
|
|
119
|
+
return CardMarket.model_validate(resp.json())
|
|
120
|
+
|
|
121
|
+
def get_card_comps(
|
|
122
|
+
self, card_uuid: str, grade_key: str | None = None, limit: int = 50
|
|
123
|
+
) -> CardComps:
|
|
124
|
+
params: dict[str, str] = {"limit": str(limit)}
|
|
125
|
+
if grade_key:
|
|
126
|
+
params["grade_key"] = grade_key
|
|
127
|
+
resp = self._request("GET", f"/cards/{card_uuid}/comps", params=params)
|
|
128
|
+
return CardComps.model_validate(resp.json())
|
|
129
|
+
|
|
130
|
+
def get_card_price_history(
|
|
131
|
+
self,
|
|
132
|
+
card_uuid: str,
|
|
133
|
+
grade_key: str = "RAW",
|
|
134
|
+
finish: str | None = None,
|
|
135
|
+
start: str | None = None,
|
|
136
|
+
end: str | None = None,
|
|
137
|
+
interval: str = "daily",
|
|
138
|
+
) -> CardPriceHistory:
|
|
139
|
+
params: dict[str, str] = {"grade_key": grade_key, "interval": interval}
|
|
140
|
+
if finish:
|
|
141
|
+
params["finish"] = finish
|
|
142
|
+
if start:
|
|
143
|
+
params["start"] = start
|
|
144
|
+
if end:
|
|
145
|
+
params["end"] = end
|
|
146
|
+
resp = self._request("GET", f"/cards/{card_uuid}/price-history", params=params)
|
|
147
|
+
return CardPriceHistory.model_validate(resp.json())
|
|
148
|
+
|
|
149
|
+
def get_portfolio_history(
|
|
150
|
+
self,
|
|
151
|
+
collector_uuid: str,
|
|
152
|
+
start: str | None = None,
|
|
153
|
+
end: str | None = None,
|
|
154
|
+
interval: str = "daily",
|
|
155
|
+
) -> PortfolioHistory:
|
|
156
|
+
params: dict[str, str] = {"interval": interval}
|
|
157
|
+
if start:
|
|
158
|
+
params["start"] = start
|
|
159
|
+
if end:
|
|
160
|
+
params["end"] = end
|
|
161
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/portfolio/history", params=params)
|
|
162
|
+
return PortfolioHistory.model_validate(resp.json())
|
|
163
|
+
|
|
164
|
+
def search_sets(self, query: SetSearchQuery) -> SetSearchResult:
|
|
165
|
+
resp = self._request("POST", "/sets/search", json=query.model_dump(mode="json", exclude_none=True))
|
|
166
|
+
return SetSearchResult.model_validate(resp.json())
|
|
167
|
+
|
|
168
|
+
# --- sealed / set market ---
|
|
169
|
+
|
|
170
|
+
def get_set_sealed(self, set_uuid: str) -> list[SealedProductOut]:
|
|
171
|
+
"""The set's sealed SKUs, each with its headline (latest) market value."""
|
|
172
|
+
resp = self._request("GET", f"/sets/{set_uuid}/sealed")
|
|
173
|
+
return [SealedProductOut.model_validate(p) for p in resp.json()]
|
|
174
|
+
|
|
175
|
+
def get_set_top_cards(self, set_uuid: str, limit: int = 10) -> SetTopCards:
|
|
176
|
+
resp = self._request("GET", f"/sets/{set_uuid}/top-cards", params={"limit": str(limit)})
|
|
177
|
+
return SetTopCards.model_validate(resp.json())
|
|
178
|
+
|
|
179
|
+
def get_sealed_market(self, product_uuid: str, comps_limit: int = 25) -> SealedMarket:
|
|
180
|
+
resp = self._request(
|
|
181
|
+
"GET", f"/sealed/{product_uuid}/market", params={"comps_limit": str(comps_limit)}
|
|
182
|
+
)
|
|
183
|
+
return SealedMarket.model_validate(resp.json())
|
|
184
|
+
|
|
185
|
+
def get_sealed_price_history(
|
|
186
|
+
self,
|
|
187
|
+
product_uuid: str,
|
|
188
|
+
start: str | None = None,
|
|
189
|
+
end: str | None = None,
|
|
190
|
+
interval: str = "daily",
|
|
191
|
+
) -> SealedPriceHistory:
|
|
192
|
+
params: dict[str, str] = {"interval": interval}
|
|
193
|
+
if start:
|
|
194
|
+
params["start"] = start
|
|
195
|
+
if end:
|
|
196
|
+
params["end"] = end
|
|
197
|
+
resp = self._request("GET", f"/sealed/{product_uuid}/price-history", params=params)
|
|
198
|
+
return SealedPriceHistory.model_validate(resp.json())
|
|
199
|
+
|
|
200
|
+
# --- account / identity ---
|
|
201
|
+
|
|
202
|
+
def get_account_context(self) -> MeOut:
|
|
203
|
+
"""Resolve the account behind this API key — its collectors and default collector. Used to
|
|
204
|
+
pick a default collector when SLAB_COLLECTOR is unset."""
|
|
205
|
+
resp = self._request("GET", "/account")
|
|
206
|
+
return MeOut.model_validate(resp.json())
|
|
207
|
+
|
|
208
|
+
# --- collectors ---
|
|
209
|
+
|
|
210
|
+
def create_collector(self, name: str) -> CollectorOut:
|
|
211
|
+
resp = self._request("POST", "/collectors", json=CollectorCreate(name=name).model_dump(mode="json"))
|
|
212
|
+
return CollectorOut.model_validate(resp.json())
|
|
213
|
+
|
|
214
|
+
def rename_collector(self, collector_uuid: str, name: str) -> CollectorOut:
|
|
215
|
+
resp = self._request(
|
|
216
|
+
"PATCH", f"/collectors/{collector_uuid}", json=CollectorUpdate(name=name).model_dump(mode="json")
|
|
217
|
+
)
|
|
218
|
+
return CollectorOut.model_validate(resp.json())
|
|
219
|
+
|
|
220
|
+
# --- breaks ---
|
|
221
|
+
|
|
222
|
+
def create_break(self, collector_uuid: str, payload: BreakCreate) -> BreakOut:
|
|
223
|
+
resp = self._request(
|
|
224
|
+
"POST", f"/collectors/{collector_uuid}/breaks", json=payload.model_dump(mode="json", exclude_none=True)
|
|
225
|
+
)
|
|
226
|
+
return BreakOut.model_validate(resp.json())
|
|
227
|
+
|
|
228
|
+
def search_breaks(self, collector_uuid: str, query: BreakSearchQuery | None = None) -> BreakSearchResult:
|
|
229
|
+
body = (query or BreakSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
230
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/breaks/search", json=body)
|
|
231
|
+
return BreakSearchResult.model_validate(resp.json())
|
|
232
|
+
|
|
233
|
+
def update_break(self, collector_uuid: str, break_uuid: str, payload: BreakUpdate) -> BreakOut:
|
|
234
|
+
resp = self._request(
|
|
235
|
+
"PATCH",
|
|
236
|
+
f"/collectors/{collector_uuid}/breaks/{break_uuid}",
|
|
237
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
238
|
+
)
|
|
239
|
+
return BreakOut.model_validate(resp.json())
|
|
240
|
+
|
|
241
|
+
def delete_break(self, collector_uuid: str, break_uuid: str) -> None:
|
|
242
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/breaks/{break_uuid}")
|
|
243
|
+
|
|
244
|
+
# --- lots (a single multi-card purchase) ---
|
|
245
|
+
|
|
246
|
+
def create_lot(self, collector_uuid: str, payload: LotCreate) -> LotOut:
|
|
247
|
+
resp = self._request(
|
|
248
|
+
"POST", f"/collectors/{collector_uuid}/lots", json=payload.model_dump(mode="json", exclude_none=True)
|
|
249
|
+
)
|
|
250
|
+
return LotOut.model_validate(resp.json())
|
|
251
|
+
|
|
252
|
+
def search_lots(self, collector_uuid: str, query: LotSearchQuery | None = None) -> LotSearchResult:
|
|
253
|
+
body = (query or LotSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
254
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/lots/search", json=body)
|
|
255
|
+
return LotSearchResult.model_validate(resp.json())
|
|
256
|
+
|
|
257
|
+
def update_lot(self, collector_uuid: str, lot_uuid: str, payload: LotUpdate) -> LotOut:
|
|
258
|
+
resp = self._request(
|
|
259
|
+
"PATCH",
|
|
260
|
+
f"/collectors/{collector_uuid}/lots/{lot_uuid}",
|
|
261
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
262
|
+
)
|
|
263
|
+
return LotOut.model_validate(resp.json())
|
|
264
|
+
|
|
265
|
+
def delete_lot(self, collector_uuid: str, lot_uuid: str) -> None:
|
|
266
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/lots/{lot_uuid}")
|
|
267
|
+
|
|
268
|
+
# --- copies ---
|
|
269
|
+
|
|
270
|
+
def add_copy(self, collector_uuid: str, payload: CardCopyCreate) -> CardCopyOut:
|
|
271
|
+
resp = self._request(
|
|
272
|
+
"POST", f"/collectors/{collector_uuid}/copies", json=payload.model_dump(mode="json", exclude_none=True)
|
|
273
|
+
)
|
|
274
|
+
return CardCopyOut.model_validate(resp.json())
|
|
275
|
+
|
|
276
|
+
def update_copy(self, collector_uuid: str, copy_uuid: str, payload: CardCopyUpdate) -> CardCopyOut:
|
|
277
|
+
resp = self._request(
|
|
278
|
+
"PATCH",
|
|
279
|
+
f"/collectors/{collector_uuid}/copies/{copy_uuid}",
|
|
280
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
281
|
+
)
|
|
282
|
+
return CardCopyOut.model_validate(resp.json())
|
|
283
|
+
|
|
284
|
+
def delete_copy(self, collector_uuid: str, copy_uuid: str) -> None:
|
|
285
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/copies/{copy_uuid}")
|
|
286
|
+
|
|
287
|
+
# --- collection search ---
|
|
288
|
+
|
|
289
|
+
def search_collection(self, collector_uuid: str, query: CollectionSearchQuery | None = None) -> CollectionResult:
|
|
290
|
+
body = (query or CollectionSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
291
|
+
resp = self._request("POST", f"/collectors/{collector_uuid}/collection/search", json=body)
|
|
292
|
+
return CollectionResult.model_validate(resp.json())
|
|
293
|
+
|
|
294
|
+
# --- costs ---
|
|
295
|
+
|
|
296
|
+
def add_cost(self, collector_uuid: str, copy_uuid: str, payload: CardCopyCostCreate) -> CardCopyCostOut:
|
|
297
|
+
resp = self._request(
|
|
298
|
+
"POST",
|
|
299
|
+
f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs",
|
|
300
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
301
|
+
)
|
|
302
|
+
return CardCopyCostOut.model_validate(resp.json())
|
|
303
|
+
|
|
304
|
+
def update_cost(
|
|
305
|
+
self, collector_uuid: str, copy_uuid: str, cost_uuid: str, payload: CardCopyCostUpdate
|
|
306
|
+
) -> CardCopyCostOut:
|
|
307
|
+
resp = self._request(
|
|
308
|
+
"PATCH",
|
|
309
|
+
f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs/{cost_uuid}",
|
|
310
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
311
|
+
)
|
|
312
|
+
return CardCopyCostOut.model_validate(resp.json())
|
|
313
|
+
|
|
314
|
+
def delete_cost(self, collector_uuid: str, copy_uuid: str, cost_uuid: str) -> None:
|
|
315
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/copies/{copy_uuid}/costs/{cost_uuid}")
|
|
316
|
+
|
|
317
|
+
# --- dashboards ---
|
|
318
|
+
|
|
319
|
+
def get_dashboard(self, collector_uuid: str) -> DashboardStats:
|
|
320
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/dashboard")
|
|
321
|
+
return DashboardStats.model_validate(resp.json())
|
|
322
|
+
|
|
323
|
+
def get_sources(self, collector_uuid: str) -> list[str]:
|
|
324
|
+
"""Distinct acquisition sources this collector has used (breaks + copies)."""
|
|
325
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/sources")
|
|
326
|
+
return resp.json()
|
|
327
|
+
|
|
328
|
+
def get_catalog_stats(self) -> CatalogStats:
|
|
329
|
+
resp = self._request("GET", "/stats")
|
|
330
|
+
return CatalogStats.model_validate(resp.json())
|
|
331
|
+
|
|
332
|
+
def get_community_board(self, limit: int = 10) -> CommunityBoard:
|
|
333
|
+
resp = self._request("GET", "/community", params={"limit": limit})
|
|
334
|
+
return CommunityBoard.model_validate(resp.json())
|
|
335
|
+
|
|
336
|
+
# --- custom sets (collector-scoped) ---
|
|
337
|
+
|
|
338
|
+
def create_custom_set(self, collector_uuid: str, payload: CustomSetCreate) -> CustomSetOut:
|
|
339
|
+
resp = self._request(
|
|
340
|
+
"POST", f"/collectors/{collector_uuid}/custom-sets", json=payload.model_dump(mode="json", exclude_none=True)
|
|
341
|
+
)
|
|
342
|
+
return CustomSetOut.model_validate(resp.json())
|
|
343
|
+
|
|
344
|
+
def update_custom_set(self, collector_uuid: str, set_uuid: str, payload: CustomSetUpdate) -> CustomSetOut:
|
|
345
|
+
resp = self._request(
|
|
346
|
+
"PATCH",
|
|
347
|
+
f"/collectors/{collector_uuid}/custom-sets/{set_uuid}",
|
|
348
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
349
|
+
)
|
|
350
|
+
return CustomSetOut.model_validate(resp.json())
|
|
351
|
+
|
|
352
|
+
def delete_custom_set(self, collector_uuid: str, set_uuid: str) -> None:
|
|
353
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}")
|
|
354
|
+
|
|
355
|
+
def add_set_card(self, collector_uuid: str, set_uuid: str, payload: CustomSetCardAdd) -> CustomSetCardOut:
|
|
356
|
+
resp = self._request(
|
|
357
|
+
"POST",
|
|
358
|
+
f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards",
|
|
359
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
360
|
+
)
|
|
361
|
+
return CustomSetCardOut.model_validate(resp.json())
|
|
362
|
+
|
|
363
|
+
def delete_set_card(self, collector_uuid: str, set_uuid: str, entry_uuid: str) -> None:
|
|
364
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}")
|
|
365
|
+
|
|
366
|
+
def subscribe(self, collector_uuid: str, set_uuid: str) -> None:
|
|
367
|
+
self._request("POST", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/subscribe")
|
|
368
|
+
|
|
369
|
+
def unsubscribe(self, collector_uuid: str, set_uuid: str) -> None:
|
|
370
|
+
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/subscribe")
|
|
371
|
+
|
|
372
|
+
def collector_custom_sets(self, collector_uuid: str) -> list[CustomSetOut]:
|
|
373
|
+
resp = self._request("GET", f"/collectors/{collector_uuid}/custom-sets")
|
|
374
|
+
return [CustomSetOut.model_validate(s) for s in resp.json()]
|
|
375
|
+
|
|
376
|
+
# --- custom sets (unscoped — discovery/viewing) ---
|
|
377
|
+
|
|
378
|
+
def search_custom_sets(self, query: CustomSetSearchQuery | None = None) -> CustomSetSearchResult:
|
|
379
|
+
body = (query or CustomSetSearchQuery()).model_dump(mode="json", exclude_none=True)
|
|
380
|
+
resp = self._request("POST", "/custom-sets/search", json=body)
|
|
381
|
+
return CustomSetSearchResult.model_validate(resp.json())
|
|
382
|
+
|
|
383
|
+
def get_custom_set(self, set_uuid: str, collector_uuid: str | None = None) -> CustomSetDetail:
|
|
384
|
+
params = {"collector_uuid": collector_uuid} if collector_uuid else {}
|
|
385
|
+
resp = self._request("GET", f"/custom-sets/{set_uuid}", params=params)
|
|
386
|
+
return CustomSetDetail.model_validate(resp.json())
|