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/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)