slab-cli 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- slab_cli/banner.py +92 -0
- slab_cli/client.py +386 -0
- slab_cli/commands/__init__.py +4 -0
- slab_cli/commands/breaks.py +140 -0
- slab_cli/commands/catalog.py +84 -0
- slab_cli/commands/collection.py +676 -0
- slab_cli/commands/custom_sets.py +484 -0
- slab_cli/commands/export.py +129 -0
- slab_cli/commands/lots.py +114 -0
- slab_cli/commands/pricing.py +386 -0
- slab_cli/commands/registry.py +140 -0
- slab_cli/commands/setup.py +24 -0
- slab_cli/config.py +21 -0
- slab_cli/context.py +50 -0
- slab_cli/display.py +1187 -0
- slab_cli/flags.py +189 -0
- slab_cli/help.py +87 -0
- slab_cli/main.py +95 -0
- slab_cli/paging.py +46 -0
- slab_cli/picker.py +78 -0
- slab_cli/prompts.py +549 -0
- slab_cli/sources.py +17 -0
- slab_cli/theme.py +184 -0
- slab_cli-0.1.0.dist-info/METADATA +70 -0
- slab_cli-0.1.0.dist-info/RECORD +28 -0
- slab_cli-0.1.0.dist-info/WHEEL +4 -0
- slab_cli-0.1.0.dist-info/entry_points.txt +3 -0
slab_cli/flags.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The shared search-flag grammar — ONE definition of the --flags every search command accepts.
|
|
2
|
+
|
|
3
|
+
The grammar mirrors the wire contract instead of re-inventing it per command: CARD_FLAGS maps onto
|
|
4
|
+
`slab_schemas.CardFilter` (the filter grammar catalog and collection search share on the server),
|
|
5
|
+
and COLLECTION_SEARCH_FLAGS extends it exactly like `CollectionSearchQuery` extends `CardFilter`.
|
|
6
|
+
Each search command parses with its list and also attaches it to the handler (`handler.flags`), so
|
|
7
|
+
`--help` renders the flag table from the same spec dispatch uses — help and behavior can't drift.
|
|
8
|
+
|
|
9
|
+
Parsing is loud: an unknown flag raises `FlagError` with a did-you-mean suggestion instead of being
|
|
10
|
+
silently folded into the free-text query (which made `collection list --team wild` search for the
|
|
11
|
+
literal string "--team wild")."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date
|
|
17
|
+
from decimal import Decimal, InvalidOperation
|
|
18
|
+
from difflib import get_close_matches
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FlagError(Exception):
|
|
23
|
+
"""A bad flag on the command line — unknown, missing its value, or an unparseable value.
|
|
24
|
+
The message is user-facing; main() catches this and prints it without a traceback."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Flag:
|
|
29
|
+
"""One --flag: the query field it fills, how its value parses, and its help line.
|
|
30
|
+
|
|
31
|
+
kind:
|
|
32
|
+
bool — no value; stores `store` (lets --raw store graded=False)
|
|
33
|
+
str — one value, kept as-is
|
|
34
|
+
int — one value, int-parsed
|
|
35
|
+
decimal — one value, Decimal-parsed
|
|
36
|
+
date — one value, ISO date (YYYY-MM-DD)
|
|
37
|
+
csv — one value per use, repeatable; collects into a list (any-of on the server)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
field: str
|
|
42
|
+
kind: str
|
|
43
|
+
help: str
|
|
44
|
+
store: Any = True
|
|
45
|
+
metavar: str = ""
|
|
46
|
+
|
|
47
|
+
def usage(self) -> str:
|
|
48
|
+
"""`--team <value>` — the flag as it appears in help."""
|
|
49
|
+
if self.kind == "bool":
|
|
50
|
+
return self.name
|
|
51
|
+
meta = self.metavar or {"int": "<n>", "decimal": "<amount>", "date": "<yyyy-mm-dd>"}.get(
|
|
52
|
+
self.kind, "<value>"
|
|
53
|
+
)
|
|
54
|
+
return f"{self.name} {meta}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _convert(spec: Flag, raw: str) -> Any:
|
|
58
|
+
if spec.kind == "int":
|
|
59
|
+
try:
|
|
60
|
+
return int(raw)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise FlagError(f"{spec.name} expects a number, got {raw!r}") from None
|
|
63
|
+
if spec.kind == "decimal":
|
|
64
|
+
try:
|
|
65
|
+
return Decimal(raw)
|
|
66
|
+
except InvalidOperation:
|
|
67
|
+
raise FlagError(f"{spec.name} expects an amount, got {raw!r}") from None
|
|
68
|
+
if spec.kind == "date":
|
|
69
|
+
try:
|
|
70
|
+
return date.fromisoformat(raw)
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise FlagError(f"{spec.name} expects a date like 2026-01-31, got {raw!r}") from None
|
|
73
|
+
return raw
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_flags(args: list[str], flags: list[Flag]) -> tuple[dict[str, Any], str | None]:
|
|
77
|
+
"""Split `args` into (query-field values, free-text q).
|
|
78
|
+
|
|
79
|
+
Bare words join into the free-text query; anything starting with `--` must be a known flag
|
|
80
|
+
(`--flag value` or `--flag=value`). Repeatable csv flags accumulate. Unknown flags, missing
|
|
81
|
+
values, and bad values raise FlagError."""
|
|
82
|
+
by_name = {f.name: f for f in flags}
|
|
83
|
+
values: dict[str, Any] = {}
|
|
84
|
+
q_parts: list[str] = []
|
|
85
|
+
|
|
86
|
+
i = 0
|
|
87
|
+
while i < len(args):
|
|
88
|
+
arg = args[i]
|
|
89
|
+
if not arg.startswith("--"):
|
|
90
|
+
q_parts.append(arg)
|
|
91
|
+
i += 1
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
name, _, inline = arg.partition("=")
|
|
95
|
+
spec = by_name.get(name)
|
|
96
|
+
if spec is None:
|
|
97
|
+
msg = f"unknown flag {name}"
|
|
98
|
+
hint = get_close_matches(name, list(by_name), n=1, cutoff=0.6)
|
|
99
|
+
if hint:
|
|
100
|
+
msg += f" (did you mean {hint[0]}?)"
|
|
101
|
+
raise FlagError(msg)
|
|
102
|
+
|
|
103
|
+
if spec.kind == "bool":
|
|
104
|
+
if inline:
|
|
105
|
+
raise FlagError(f"{spec.name} takes no value")
|
|
106
|
+
values[spec.field] = spec.store
|
|
107
|
+
else:
|
|
108
|
+
if inline:
|
|
109
|
+
raw = inline
|
|
110
|
+
else:
|
|
111
|
+
i += 1
|
|
112
|
+
if i >= len(args) or args[i].startswith("--"):
|
|
113
|
+
raise FlagError(f"{spec.name} needs a value ({spec.usage()})")
|
|
114
|
+
raw = args[i]
|
|
115
|
+
val = _convert(spec, raw)
|
|
116
|
+
if spec.kind == "csv":
|
|
117
|
+
values.setdefault(spec.field, []).append(val)
|
|
118
|
+
else:
|
|
119
|
+
values[spec.field] = val
|
|
120
|
+
i += 1
|
|
121
|
+
|
|
122
|
+
return values, " ".join(q_parts) or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- The card grammar: mirrors slab_schemas.CardFilter --------------------------------------
|
|
126
|
+
|
|
127
|
+
CARD_FLAGS: list[Flag] = [
|
|
128
|
+
# text / player
|
|
129
|
+
Flag("--player", "subject", "str", "Player name (substring match)", metavar="<name>"),
|
|
130
|
+
Flag("--number", "card_number", "str", "Card number (exact, e.g. YG-201)", metavar="<num>"),
|
|
131
|
+
# product
|
|
132
|
+
Flag("--set", "set_slug", "csv", "Set slug — repeatable, any-of", metavar="<slug>"),
|
|
133
|
+
Flag("--brand", "brand", "csv", "Brand name — repeatable, any-of", metavar="<name>"),
|
|
134
|
+
Flag("--subset", "subset", "csv", "Subset name — repeatable, any-of", metavar="<name>"),
|
|
135
|
+
Flag("--year", "year", "int", "Exact season-start year (2025 = 2025-26)", metavar="<year>"),
|
|
136
|
+
Flag("--year-min", "year_min", "int", "Season-start year >= this", metavar="<year>"),
|
|
137
|
+
Flag("--year-max", "year_max", "int", "Season-start year <= this", metavar="<year>"),
|
|
138
|
+
Flag("--sport", "sport", "csv", "Sport — repeatable, any-of", metavar="<name>"),
|
|
139
|
+
Flag("--league", "league", "csv", "League — repeatable, any-of", metavar="<name>"),
|
|
140
|
+
# who
|
|
141
|
+
Flag("--team", "team", "csv", "Team name — repeatable, any-of", metavar="<name>"),
|
|
142
|
+
# type / attributes
|
|
143
|
+
Flag("--attr", "attribute", "csv", "Attribute name — repeatable, any-of", metavar="<name>"),
|
|
144
|
+
Flag("--rookie", "rookie", "bool", "Only rookie cards"),
|
|
145
|
+
Flag("--auto", "auto", "bool", "Only autographed cards"),
|
|
146
|
+
Flag("--relic", "relic", "bool", "Only memorabilia cards"),
|
|
147
|
+
# finish / parallel
|
|
148
|
+
Flag("--finish", "finish", "csv", "Finish name — repeatable, any-of", metavar="<name>"),
|
|
149
|
+
Flag("--base", "base_only", "bool", "Only base printings (no parallels)"),
|
|
150
|
+
Flag("--parallel", "parallel_only", "bool", "Only parallels (has a finish)"),
|
|
151
|
+
# scarcity
|
|
152
|
+
Flag("--numbered", "is_numbered", "bool", "Only serial-numbered cards"),
|
|
153
|
+
Flag("--1of1", "is_1of1", "bool", "Only one-of-ones"),
|
|
154
|
+
Flag("--run-min", "numbered_min", "int", "Print run >= this"),
|
|
155
|
+
Flag("--run-max", "numbered_max", "int", "Print run <= this"),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
_PAGING_FLAGS: list[Flag] = [
|
|
159
|
+
Flag("--sort", "sort", "str", "Sort key, prefix - for descending (keys listed above)", metavar="<key>"),
|
|
160
|
+
Flag("--limit", "limit", "int", "Results to show (default 25)"),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# What each search command parses: the shared card grammar + its scope's extras.
|
|
164
|
+
|
|
165
|
+
CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + _PAGING_FLAGS
|
|
166
|
+
|
|
167
|
+
COLLECTION_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
|
|
168
|
+
# collection-only filters — mirrors CollectionSearchQuery's extension of CardFilter
|
|
169
|
+
Flag("--status", "status", "csv", "Copy status — repeatable, any-of", metavar="<status>"),
|
|
170
|
+
Flag("--graded", "graded", "bool", "Only professionally graded copies"),
|
|
171
|
+
Flag("--raw", "graded", "bool", "Only ungraded copies", store=False),
|
|
172
|
+
Flag("--company", "grading_company", "csv", "Grading company — repeatable", metavar="<name>"),
|
|
173
|
+
Flag("--self-grade", "self_grade", "csv", "Your self-grade rung — repeatable", metavar="<grade>"),
|
|
174
|
+
Flag("--acq", "acquisition_type", "csv", "Acquisition type — repeatable", metavar="<type>"),
|
|
175
|
+
Flag("--location", "storage_location", "str", "Storage location (substring)", metavar="<text>"),
|
|
176
|
+
Flag("--serial", "serial", "int", "The copy's serial number"),
|
|
177
|
+
Flag("--cost-min", "acquisition_cost_min", "decimal", "Acquisition cost >= this"),
|
|
178
|
+
Flag("--cost-max", "acquisition_cost_max", "decimal", "Acquisition cost <= this"),
|
|
179
|
+
Flag("--acquired-after", "acquired_after", "date", "Acquired on/after this date"),
|
|
180
|
+
Flag("--acquired-before", "acquired_before", "date", "Acquired on/before this date"),
|
|
181
|
+
] + _PAGING_FLAGS
|
|
182
|
+
|
|
183
|
+
SET_SEARCH_FLAGS: list[Flag] = [
|
|
184
|
+
# mirrors SetSearchQuery (sets have a slimmer grammar than cards)
|
|
185
|
+
Flag("--brand", "brand", "csv", "Brand name — repeatable, any-of", metavar="<name>"),
|
|
186
|
+
Flag("--year", "year", "int", "Exact season-start year", metavar="<year>"),
|
|
187
|
+
Flag("--sport", "sport", "str", "Sport", metavar="<name>"),
|
|
188
|
+
Flag("--limit", "limit", "int", "Results to show (default 25)"),
|
|
189
|
+
]
|
slab_cli/help.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Help screens, rendered from the command registry.
|
|
2
|
+
|
|
3
|
+
`slab` (no args) and `slab <group>` both land here. Because the text is generated from
|
|
4
|
+
`commands.registry.GROUPS` — the same list dispatch uses — the help can never fall out of sync with
|
|
5
|
+
what the CLI actually does. This screen is the CLI's source of truth for its command surface."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
|
|
14
|
+
from .banner import BANNER
|
|
15
|
+
from .commands.registry import GROUPS, Command, Group
|
|
16
|
+
from .theme import console, heading
|
|
17
|
+
|
|
18
|
+
_CONFIG = "SLAB_API_URL · SLAB_API_KEY · SLAB_COLLECTOR"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _group_table(group: Group) -> Table:
|
|
22
|
+
"""A group's verbs as an aligned two-column grid: full invocation, then its one-line summary."""
|
|
23
|
+
grid = Table.grid(padding=(0, 3))
|
|
24
|
+
grid.add_column(justify="left", no_wrap=True)
|
|
25
|
+
grid.add_column(justify="left", style="slab.dim")
|
|
26
|
+
for c in group.commands:
|
|
27
|
+
invocation = Text()
|
|
28
|
+
invocation.append(" slab ", style="slab.muted")
|
|
29
|
+
invocation.append(group.name, style="slab.foil")
|
|
30
|
+
invocation.append(f" {c.verb}", style="slab.value")
|
|
31
|
+
if c.args:
|
|
32
|
+
invocation.append(f" {c.args}", style="slab.dim")
|
|
33
|
+
grid.add_row(invocation, c.summary)
|
|
34
|
+
return grid
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_group_help(group: Group) -> None:
|
|
38
|
+
"""`slab <group>` — just that noun's verbs."""
|
|
39
|
+
console.print(heading(group.name, group.blurb))
|
|
40
|
+
console.print(_group_table(group))
|
|
41
|
+
console.print()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_command_help(group: Group, command: Command) -> None:
|
|
45
|
+
"""`slab <group> <cmd> --help` — one command in detail: usage, summary, its handler's own
|
|
46
|
+
docstring (the leaf-level self-documentation), and — for search commands — the flag table,
|
|
47
|
+
rendered from the same spec list the parser uses (`handler.flags`) so it can't drift."""
|
|
48
|
+
console.print(Text.from_markup(
|
|
49
|
+
f"[slab.muted]usage:[/] [slab.foil]slab {group.name}[/] "
|
|
50
|
+
f"[slab.value]{command.verb}[/]"
|
|
51
|
+
+ (f" [slab.dim]{command.args}[/]" if command.args else "")
|
|
52
|
+
))
|
|
53
|
+
console.print(Text.from_markup(f"\n {command.summary}"))
|
|
54
|
+
doc = inspect.getdoc(command.handler)
|
|
55
|
+
if doc:
|
|
56
|
+
# The handler docstring is the authoritative per-command detail — print it verbatim,
|
|
57
|
+
# indented, so a leaf command explains itself with no portal round-trip.
|
|
58
|
+
body = "\n".join(f" {line}" for line in doc.splitlines())
|
|
59
|
+
console.print(Text.from_markup(f"\n[slab.dim]{body}[/]"))
|
|
60
|
+
flags = getattr(command.handler, "flags", None)
|
|
61
|
+
if flags:
|
|
62
|
+
console.print(Text.from_markup("\n [slab.label]Flags[/]"))
|
|
63
|
+
grid = Table.grid(padding=(0, 3))
|
|
64
|
+
grid.add_column(justify="left", no_wrap=True, style="slab.value")
|
|
65
|
+
grid.add_column(justify="left", style="slab.dim")
|
|
66
|
+
for f in flags:
|
|
67
|
+
grid.add_row(f" {f.usage()}", f.help)
|
|
68
|
+
console.print(grid)
|
|
69
|
+
console.print()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_root_help() -> None:
|
|
73
|
+
"""`slab` — the whole command surface, grouped by noun. The discovery home."""
|
|
74
|
+
console.print(BANNER)
|
|
75
|
+
console.print(Text.from_markup(
|
|
76
|
+
" [slab.value]slab[/] [slab.dim]<group> <command> [options][/] "
|
|
77
|
+
"[slab.muted]# e.g.[/] [slab.dim]slab card search mcdavid[/]"
|
|
78
|
+
))
|
|
79
|
+
console.print(Text.from_markup(
|
|
80
|
+
" [slab.muted]Drill into a group:[/] [slab.dim]slab card[/] "
|
|
81
|
+
"[slab.muted]Help for one command:[/] [slab.dim]slab card search --help[/]\n"
|
|
82
|
+
))
|
|
83
|
+
for group in GROUPS:
|
|
84
|
+
console.print(heading(group.name, group.blurb))
|
|
85
|
+
console.print(_group_table(group))
|
|
86
|
+
console.print()
|
|
87
|
+
console.print(Text.from_markup(f" [slab.label]Config[/] [slab.dim]{_CONFIG}[/]\n"))
|
slab_cli/main.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""slab CLI — catalog, collect, and track your cards from the terminal.
|
|
2
|
+
|
|
3
|
+
Commands are pure noun-verb: `slab <group> <command>` (like `git`, `docker`, `gh`). Run `slab` with
|
|
4
|
+
no arguments for the full, grouped command surface — that screen is generated from the command
|
|
5
|
+
registry (`commands/registry.py`), so it's always an accurate source of truth. `slab <group>` lists
|
|
6
|
+
one group; `slab <group> <command> --help` explains a single command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .client import ApiConnectionError, ApiError
|
|
14
|
+
from .commands.registry import get_command, get_group, suggest_group, suggest_verb
|
|
15
|
+
from .context import MissingCollector
|
|
16
|
+
from .display import console
|
|
17
|
+
from .flags import FlagError
|
|
18
|
+
from .help import render_command_help, render_group_help, render_root_help
|
|
19
|
+
|
|
20
|
+
_HELP_FLAGS = ("-h", "--help", "help")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(args: list[str]) -> int:
|
|
24
|
+
"""Resolve `args` against the registry and run the command. Returns a process exit code."""
|
|
25
|
+
if not args or args[0] in _HELP_FLAGS:
|
|
26
|
+
render_root_help()
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
group_name = args[0]
|
|
30
|
+
group = get_group(group_name)
|
|
31
|
+
if group is None:
|
|
32
|
+
console.print(f"[red]Unknown command group: {group_name}[/red]")
|
|
33
|
+
hint = suggest_group(group_name)
|
|
34
|
+
if hint:
|
|
35
|
+
console.print(f"[dim]Did you mean [/dim][slab.value]slab {hint}[/][dim]?[/dim]")
|
|
36
|
+
console.print("[dim]Run [/dim][slab.value]slab[/][dim] to see all commands.[/dim]")
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
# `slab <group> --help` → list that group; bare `slab <group>` runs its default verb if it
|
|
40
|
+
# declares one (`slab community` → the board), otherwise also lists the group.
|
|
41
|
+
if len(args) == 1 or args[1] in _HELP_FLAGS:
|
|
42
|
+
if len(args) == 1 and group.default:
|
|
43
|
+
get_command(group_name, group.default).handler([])
|
|
44
|
+
return 0
|
|
45
|
+
render_group_help(group)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
verb = args[1]
|
|
49
|
+
command = get_command(group_name, verb)
|
|
50
|
+
if command is None:
|
|
51
|
+
console.print(f"[red]Unknown command: slab {group_name} {verb}[/red]")
|
|
52
|
+
hint = suggest_verb(group_name, verb)
|
|
53
|
+
if hint:
|
|
54
|
+
console.print(f"[dim]Did you mean [/dim][slab.value]slab {group_name} {hint}[/][dim]?[/dim]")
|
|
55
|
+
console.print(f"[dim]Run [/dim][slab.value]slab {group_name}[/][dim] to see its commands.[/dim]")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
# `slab <group> <cmd> --help` → explain this one command instead of running it.
|
|
59
|
+
if args[2:3] and args[2] in _HELP_FLAGS:
|
|
60
|
+
render_command_help(group, command)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
command.handler(args[2:])
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main() -> None:
|
|
68
|
+
try:
|
|
69
|
+
sys.exit(_run(sys.argv[1:]))
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
console.print("\n[dim]Cancelled.[/dim]")
|
|
72
|
+
except FlagError as e:
|
|
73
|
+
console.print(f"[red]{e}[/red]")
|
|
74
|
+
sys.exit(2)
|
|
75
|
+
except MissingCollector:
|
|
76
|
+
console.print("[red]No collector set.[/red]")
|
|
77
|
+
console.print(" 1. Run: slab collector create <your name>")
|
|
78
|
+
console.print(" 2. Run: export SLAB_COLLECTOR=<uuid from step 1>")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
except ApiConnectionError as e:
|
|
81
|
+
console.print(f"\n[red]{e}[/red]")
|
|
82
|
+
console.print("[dim] Check that the API is running and SLAB_API_URL is correct.[/dim]")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
except ApiError as e:
|
|
85
|
+
if e.status == 404:
|
|
86
|
+
console.print(f"[yellow]Not found: {e.detail}[/yellow]")
|
|
87
|
+
elif e.status == 401:
|
|
88
|
+
console.print("[red]Unauthorized — check your SLAB_API_KEY.[/red]")
|
|
89
|
+
else:
|
|
90
|
+
console.print(f"[red]API error: {e}[/red]")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
slab_cli/paging.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""One paging loop for the whole CLI.
|
|
2
|
+
|
|
3
|
+
Every "fetch the whole result set" flow (portfolio copies, the set catalog, a break checklist, an
|
|
4
|
+
export) was its own hand-rolled `while` with its own cap convention (5000 / 1000 / uncapped). They
|
|
5
|
+
all do the same thing: request pages until the server says there are no more. `fetch_all_pages`
|
|
6
|
+
is that loop, once — the caller supplies only how to fetch one page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable, Protocol, TypeVar
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Page(Protocol):
|
|
15
|
+
"""The shape every search result already has: a page of items plus the full-result total."""
|
|
16
|
+
items: list
|
|
17
|
+
total: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
P = TypeVar("P", bound=_Page)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fetch_all_pages(
|
|
24
|
+
fetch: Callable[[int, int], P], *, page: int = 200, cap: int | None = None
|
|
25
|
+
) -> tuple[list, P | None]:
|
|
26
|
+
"""Page ``fetch(limit, offset)`` to completion and return ``(all_items, first_page)``.
|
|
27
|
+
|
|
28
|
+
``fetch`` returns a result object with ``.items`` (the page) and ``.total`` (the full count).
|
|
29
|
+
Paging stops when a short/empty page arrives, ``.total`` is reached, or ``cap`` items have been
|
|
30
|
+
collected (a safety bound against a runaway result set — ``None`` means no bound). ``first_page``
|
|
31
|
+
is the first response, handed back so callers can read page-invariant blocks (e.g. a portfolio
|
|
32
|
+
``.summary``) without a second request; it's ``None`` only when ``fetch`` is never called, which
|
|
33
|
+
can't happen here but keeps the type honest.
|
|
34
|
+
"""
|
|
35
|
+
items: list = []
|
|
36
|
+
first: P | None = None
|
|
37
|
+
offset = 0
|
|
38
|
+
while cap is None or len(items) < cap:
|
|
39
|
+
result = fetch(page, offset)
|
|
40
|
+
if first is None:
|
|
41
|
+
first = result
|
|
42
|
+
items.extend(result.items)
|
|
43
|
+
if not result.items or len(items) >= result.total:
|
|
44
|
+
break
|
|
45
|
+
offset += len(result.items)
|
|
46
|
+
return items, first
|
slab_cli/picker.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""One subject-first card picker for the whole CLI.
|
|
2
|
+
|
|
3
|
+
Five commands (`card parallels`, `card price`/`comps`, `card history`, `collection add`,
|
|
4
|
+
`chase add`) all resolve a human query to a specific catalog card the same way: search a player,
|
|
5
|
+
pick the player, then pick the exact printing among that player's cards. This module is that flow,
|
|
6
|
+
once. Callers that need a different *final* step (an FMV-tagged list, a set-narrowing step, a tuple
|
|
7
|
+
return) build on `search_and_pick_subject`; the common case is `pick_card`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from slab_schemas.cards import CardOut, CardSearchQuery
|
|
13
|
+
|
|
14
|
+
from .context import ctx
|
|
15
|
+
from .prompts import ask_text, select_printing, select_set_filter, select_subject
|
|
16
|
+
from .theme import console
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def search_and_pick_subject(
|
|
20
|
+
query: str | None,
|
|
21
|
+
*,
|
|
22
|
+
prompt: str = "Search for a card (player):",
|
|
23
|
+
not_found: str = "No cards found.",
|
|
24
|
+
limit: int = 200,
|
|
25
|
+
**search_kwargs,
|
|
26
|
+
) -> tuple[list[CardOut], str] | None:
|
|
27
|
+
"""The shared subject-first core: resolve a query to ``(that subject's cards, subject name)``.
|
|
28
|
+
|
|
29
|
+
``query`` is the search text; when falsy the user is prompted with ``prompt``. Extra
|
|
30
|
+
``search_kwargs`` (e.g. ``release=[...]`` to scope to a box, ``include_market=True``,
|
|
31
|
+
``collector=...``) pass straight through to ``CardSearchQuery``. Returns ``None`` if nothing
|
|
32
|
+
matched (prints ``not_found``) or the user backed out of the player pick — so the caller only
|
|
33
|
+
ever gets a non-empty card list plus the chosen subject.
|
|
34
|
+
"""
|
|
35
|
+
q = query if query else ask_text(prompt)
|
|
36
|
+
result = ctx.client.search_cards(CardSearchQuery(subject=q, limit=limit, **search_kwargs))
|
|
37
|
+
if not result.items:
|
|
38
|
+
console.print(f"[yellow]{not_found}[/yellow]")
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
subject = select_subject(result.items)
|
|
42
|
+
if subject is None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
cards = [c for c in result.items if any(s.name == subject for s in c.subjects)]
|
|
46
|
+
return cards, subject
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pick_card(
|
|
50
|
+
query: str | None,
|
|
51
|
+
*,
|
|
52
|
+
prompt: str = "Search for a card (player):",
|
|
53
|
+
not_found: str = "No cards found.",
|
|
54
|
+
limit: int = 200,
|
|
55
|
+
narrow_set: bool = False,
|
|
56
|
+
**search_kwargs,
|
|
57
|
+
) -> CardOut | None:
|
|
58
|
+
"""The common case: search a player, pick them, pick the exact printing. Returns the chosen
|
|
59
|
+
card, or ``None`` if nothing matched or the user backed out at any step.
|
|
60
|
+
|
|
61
|
+
With ``narrow_set=True`` an extra step lets you pick the set (or "all sets") after the player —
|
|
62
|
+
the same player → set → card flow the pricing commands use — so a player who appears across many
|
|
63
|
+
products isn't dumped into one undifferentiated list. The set name is shown on each row whenever
|
|
64
|
+
the (post-narrowing) list still spans more than one set."""
|
|
65
|
+
picked = search_and_pick_subject(
|
|
66
|
+
query, prompt=prompt, not_found=not_found, limit=limit, **search_kwargs
|
|
67
|
+
)
|
|
68
|
+
if picked is None:
|
|
69
|
+
return None
|
|
70
|
+
cards, subject = picked
|
|
71
|
+
|
|
72
|
+
if narrow_set:
|
|
73
|
+
slug = select_set_filter(cards) # auto-returns "" (all) when the player is in only one set
|
|
74
|
+
if slug:
|
|
75
|
+
cards = [c for c in cards if c.set_slug == slug]
|
|
76
|
+
|
|
77
|
+
show_set = len({c.set_slug for c in cards}) > 1
|
|
78
|
+
return select_printing(cards, subject, show_set=show_set)
|