slab-cli 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {slab_cli-0.4.0 → slab_cli-0.6.0}/PKG-INFO +27 -6
  2. {slab_cli-0.4.0 → slab_cli-0.6.0}/README.md +26 -5
  3. {slab_cli-0.4.0 → slab_cli-0.6.0}/pyproject.toml +1 -1
  4. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/client.py +2 -41
  5. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/registry.py +7 -1
  6. slab_cli-0.6.0/src/slab_cli/commands/update.py +124 -0
  7. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/display.py +7 -6
  8. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/help.py +6 -1
  9. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/main.py +38 -6
  10. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/prompts.py +10 -1
  11. slab_cli-0.6.0/src/slab_cli/updates.py +242 -0
  12. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/__init__.py +0 -0
  13. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/banner.py +0 -0
  14. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/__init__.py +0 -0
  15. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/breaks.py +0 -0
  16. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/catalog.py +0 -0
  17. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/collection.py +0 -0
  18. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/custom_sets.py +0 -0
  19. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/export.py +0 -0
  20. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/lots.py +0 -0
  21. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/pricing.py +0 -0
  22. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/setup.py +0 -0
  23. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/config.py +0 -0
  24. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/context.py +0 -0
  25. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/flags.py +0 -0
  26. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/paging.py +0 -0
  27. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/picker.py +0 -0
  28. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/sources.py +0 -0
  29. {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
6
  Requires-Dist: slab-schemas>=0.1.0
@@ -56,11 +56,32 @@ to see a group's verbs.
56
56
 
57
57
  ## Configuration reference
58
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 | — |
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
+ | `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
65
+
66
+ ## Staying up to date
67
+
68
+ Nothing updates itself. At most once a day the CLI asks PyPI (in the background, while your command
69
+ runs) whether there's a newer release, and if so prints one line after the output:
70
+
71
+ ```
72
+ ▍ slab-cli 0.7.0 is available — you have 0.6.0
73
+ Update when you like: slab update
74
+ ```
75
+
76
+ ```bash
77
+ slab --version # what you have
78
+ slab update check # ask PyPI right now; installs nothing
79
+ slab update # upgrade, after showing the command and asking
80
+ ```
81
+
82
+ `slab update` runs the upgrade that matches how you installed slab — `pipx upgrade`,
83
+ `uv tool upgrade`, or `pip install --upgrade` — and prints it first, so you can decline and run it
84
+ yourself. To turn the whole thing off: `export SLAB_NO_UPDATE_CHECK=1`.
64
85
 
65
86
  ## What it depends on
66
87
 
@@ -44,11 +44,32 @@ to see a group's verbs.
44
44
 
45
45
  ## Configuration reference
46
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 | — |
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
+ | `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
53
+
54
+ ## Staying up to date
55
+
56
+ Nothing updates itself. At most once a day the CLI asks PyPI (in the background, while your command
57
+ runs) whether there's a newer release, and if so prints one line after the output:
58
+
59
+ ```
60
+ ▍ slab-cli 0.7.0 is available — you have 0.6.0
61
+ Update when you like: slab update
62
+ ```
63
+
64
+ ```bash
65
+ slab --version # what you have
66
+ slab update check # ask PyPI right now; installs nothing
67
+ slab update # upgrade, after showing the command and asking
68
+ ```
69
+
70
+ `slab update` runs the upgrade that matches how you installed slab — `pipx upgrade`,
71
+ `uv tool upgrade`, or `pip install --upgrade` — and prints it first, so you can decline and run it
72
+ yourself. To turn the whole thing off: `export SLAB_NO_UPDATE_CHECK=1`.
52
73
 
53
74
  ## What it depends on
54
75
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -22,7 +22,6 @@ from slab_schemas.collection import (
22
22
  BreakOut,
23
23
  BreakSearchQuery,
24
24
  BreakSearchResult,
25
- BreakUpdate,
26
25
  LotCreate,
27
26
  LotOut,
28
27
  LotSearchQuery,
@@ -37,7 +36,6 @@ from slab_schemas.collection import (
37
36
  CollectionResult,
38
37
  CollectionSearchQuery,
39
38
  CollectorOut,
40
- CollectorUpdate,
41
39
  )
42
40
  from slab_schemas.custom_sets import (
43
41
  CustomSetCardAdd,
@@ -51,8 +49,8 @@ from slab_schemas.custom_sets import (
51
49
  )
52
50
  from slab_schemas.dashboard import CatalogStats, DashboardStats
53
51
  from slab_schemas.pricing import CardComps, CardMarket, SetTopCards
54
- from slab_schemas.sealed import SealedMarket, SealedPriceHistory, SealedProductOut
55
- from slab_schemas.timeseries import CardPriceHistory, PortfolioHistory
52
+ from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
53
+ from slab_schemas.timeseries import CardPriceHistory
56
54
 
57
55
 
58
56
  class ApiError(Exception):
@@ -145,21 +143,6 @@ class SlabClient:
145
143
  resp = self._request("GET", f"/cards/{card_uuid}/price-history", params=params)
146
144
  return CardPriceHistory.model_validate(resp.json())
147
145
 
148
- def get_portfolio_history(
149
- self,
150
- collector_uuid: str,
151
- start: str | None = None,
152
- end: str | None = None,
153
- interval: str = "daily",
154
- ) -> PortfolioHistory:
155
- params: dict[str, str] = {"interval": interval}
156
- if start:
157
- params["start"] = start
158
- if end:
159
- params["end"] = end
160
- resp = self._request("GET", f"/collectors/{collector_uuid}/portfolio/history", params=params)
161
- return PortfolioHistory.model_validate(resp.json())
162
-
163
146
  def search_sets(self, query: SetSearchQuery) -> SetSearchResult:
164
147
  resp = self._request("POST", "/sets/search", json=query.model_dump(mode="json", exclude_none=True))
165
148
  return SetSearchResult.model_validate(resp.json())
@@ -175,12 +158,6 @@ class SlabClient:
175
158
  resp = self._request("GET", f"/sets/{set_uuid}/top-cards", params={"limit": str(limit)})
176
159
  return SetTopCards.model_validate(resp.json())
177
160
 
178
- def get_sealed_market(self, product_uuid: str, comps_limit: int = 25) -> SealedMarket:
179
- resp = self._request(
180
- "GET", f"/sealed/{product_uuid}/market", params={"comps_limit": str(comps_limit)}
181
- )
182
- return SealedMarket.model_validate(resp.json())
183
-
184
161
  def get_sealed_price_history(
185
162
  self,
186
163
  product_uuid: str,
@@ -204,14 +181,6 @@ class SlabClient:
204
181
  resp = self._request("GET", "/account")
205
182
  return MeOut.model_validate(resp.json())
206
183
 
207
- # --- collectors ---
208
-
209
- def rename_collector(self, collector_uuid: str, name: str) -> CollectorOut:
210
- resp = self._request(
211
- "PATCH", f"/collectors/{collector_uuid}", json=CollectorUpdate(name=name).model_dump(mode="json")
212
- )
213
- return CollectorOut.model_validate(resp.json())
214
-
215
184
  # --- breaks ---
216
185
 
217
186
  def create_break(self, collector_uuid: str, payload: BreakCreate) -> BreakOut:
@@ -225,14 +194,6 @@ class SlabClient:
225
194
  resp = self._request("POST", f"/collectors/{collector_uuid}/breaks/search", json=body)
226
195
  return BreakSearchResult.model_validate(resp.json())
227
196
 
228
- def update_break(self, collector_uuid: str, break_uuid: str, payload: BreakUpdate) -> BreakOut:
229
- resp = self._request(
230
- "PATCH",
231
- f"/collectors/{collector_uuid}/breaks/{break_uuid}",
232
- json=payload.model_dump(mode="json", exclude_unset=True),
233
- )
234
- return BreakOut.model_validate(resp.json())
235
-
236
197
  def delete_break(self, collector_uuid: str, break_uuid: str) -> None:
237
198
  self._request("DELETE", f"/collectors/{collector_uuid}/breaks/{break_uuid}")
238
199
 
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
17
17
  from difflib import get_close_matches
18
18
  from typing import Callable
19
19
 
20
- from . import breaks, catalog, collection, custom_sets, export, lots, pricing, setup
20
+ from . import breaks, catalog, collection, custom_sets, export, lots, pricing, setup, update
21
21
 
22
22
  Handler = Callable[[list[str]], None]
23
23
 
@@ -106,6 +106,12 @@ GROUPS: list[Group] = [
106
106
  Group("collector", "your identity", [
107
107
  Command("current", "Show the active collector", setup.cmd_whoami),
108
108
  ]),
109
+ # Bare `slab update` upgrades (the verb the update nudge tells people to run); `slab update
110
+ # check` is the look-don't-touch half. Nothing installs without a confirmation either way.
111
+ Group("update", "keep the CLI current", [
112
+ Command("install", "Upgrade slab-cli to the newest published version", update.cmd_install),
113
+ Command("check", "Check PyPI for a newer version (installs nothing)", update.cmd_check),
114
+ ], default="install"),
109
115
  ]
110
116
 
111
117
  # --- Lookup + suggestion (used by dispatch) ----------------------------------------------------
@@ -0,0 +1,124 @@
1
+ """`slab update` — the explicit half of update handling: check PyPI now, then upgrade on confirm.
2
+
3
+ The nudge after other commands is passive and cache-backed (`slab_cli.updates`); everything here is
4
+ synchronous and asks first. Nothing is ever installed without a yes, and the exact command is printed
5
+ before it runs — so a user who'd rather run it themselves (or in another environment) can just copy it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+
12
+ from ..flags import FlagError
13
+ from ..prompts import confirm
14
+ from ..theme import console
15
+ from .. import updates
16
+
17
+
18
+ def _say(markup: str) -> None:
19
+ """Print markup with Rich's number highlighting off — "0.5.0" is one version token, not three
20
+ numbers to paint cyan."""
21
+ console.print(markup, highlight=False)
22
+
23
+
24
+ def _no_args(args: list[str], usage: str) -> None:
25
+ """These commands take nothing — say so loudly rather than ignoring a typo'd flag."""
26
+ if args:
27
+ raise FlagError(f"`{usage}` takes no arguments (got: {' '.join(args)}).")
28
+
29
+
30
+ def _versions() -> tuple[str, str] | None:
31
+ """(current, latest) from a live PyPI check, or None after explaining why we can't tell."""
32
+ current = updates.current_version()
33
+ if current is None:
34
+ _say("[yellow]This slab isn't an installed package.[/yellow]")
35
+ _say(" [slab.dim]It's running from a source checkout, which has no published "
36
+ "version to compare against — update it with git, not pip.[/]")
37
+ return None
38
+
39
+ with console.status("[slab.dim]Checking PyPI for a newer slab-cli…[/]", spinner="dots"):
40
+ latest = updates.check_now()
41
+
42
+ if latest is None:
43
+ _say("[yellow]Couldn't reach PyPI to check for a newer version.[/yellow]")
44
+ _say(f" [slab.dim]You have slab-cli {current}. Check your connection, or upgrade "
45
+ f"manually:[/] [slab.value]{' '.join(updates.upgrade_command())}[/]")
46
+ return None
47
+ return current, latest
48
+
49
+
50
+ def _report(current: str, latest: str) -> bool:
51
+ """Print where you stand. True when an upgrade is actually available."""
52
+ if updates.is_newer(current, latest):
53
+ # Ahead of PyPI — an unreleased build (a dev install, a pre-release). Don't call that
54
+ # "up to date", which would imply the two versions match.
55
+ _say(f"[slab.gain]You're ahead of PyPI[/] [slab.dim]— you have slab-cli {current}; "
56
+ f"the newest published is {latest}.[/]")
57
+ return False
58
+ if not updates.is_newer(latest, current):
59
+ _say(f"[slab.gain]You're up to date[/] [slab.dim]— slab-cli {current} is the "
60
+ f"newest published version.[/]")
61
+ return False
62
+ _say(f"[slab.foil]slab-cli {latest} is available[/] [slab.dim]— you have {current}[/]")
63
+ return True
64
+
65
+
66
+ def cmd_check(args: list[str]) -> None:
67
+ """Ask PyPI whether a newer slab-cli exists, and print the upgrade command — install nothing.
68
+
69
+ This is the same lookup `slab update` does, minus the install. It bypasses the once-a-day cache
70
+ behind the "an update is available" nudge, so it always reflects PyPI as of right now."""
71
+ _no_args(args, "slab update check")
72
+ pair = _versions()
73
+ if pair is None:
74
+ return
75
+ current, latest = pair
76
+ if _report(current, latest):
77
+ _say(f" [slab.dim]Install it with[/] [slab.value]slab update[/] "
78
+ f"[slab.dim]— or run[/] [slab.value]{' '.join(updates.upgrade_command())}[/] "
79
+ f"[slab.dim]yourself.[/]")
80
+
81
+
82
+ def cmd_install(args: list[str]) -> None:
83
+ """Upgrade slab-cli to the newest version published on PyPI.
84
+
85
+ Checks PyPI live, then — only if you confirm — runs the upgrade the way this slab was installed:
86
+ `pipx upgrade`, `uv tool upgrade`, or `pip install --upgrade` for a plain venv. The command is
87
+ printed before it runs, so you can decline and run it yourself instead.
88
+
89
+ Updates are never automatic: other commands only ever print a one-line nudge (silence the whole
90
+ thing with SLAB_NO_UPDATE_CHECK=1)."""
91
+ _no_args(args, "slab update")
92
+ pair = _versions()
93
+ if pair is None:
94
+ return
95
+ current, latest = pair
96
+ if not _report(current, latest):
97
+ return
98
+
99
+ command = updates.upgrade_command()
100
+ _say(f" [slab.dim]Upgrade command:[/] [slab.value]{' '.join(command)}[/]")
101
+ if not confirm("Run it now?", default=True):
102
+ _say(" [slab.dim]Nothing installed — run the command above whenever you're ready.[/]")
103
+ return
104
+
105
+ console.print()
106
+ try:
107
+ # Inherit stdio so pip/pipx/uv progress streams straight through — a silent install that
108
+ # takes 20 seconds looks hung.
109
+ code = subprocess.call(command)
110
+ except (OSError, subprocess.SubprocessError) as e:
111
+ _say(f"[red]Couldn't run the upgrade: {e}[/red]")
112
+ _say(f" [slab.dim]Run it yourself:[/] [slab.value]{' '.join(command)}[/]")
113
+ return
114
+
115
+ if code != 0:
116
+ _say(f"\n[red]The upgrade command exited with status {code}.[/red]")
117
+ _say(" [slab.dim]Its output is above. Nothing about your collection is affected — "
118
+ "this only replaces the CLI.[/]")
119
+ return
120
+
121
+ # The running process still has the OLD version imported, so confirm from a fresh one rather
122
+ # than reporting a version we can't actually see yet.
123
+ _say(f"\n[slab.gain]Upgraded to slab-cli {latest}.[/] "
124
+ f"[slab.dim]Confirm with[/] [slab.value]slab --version[/]")
@@ -38,6 +38,7 @@ from .theme import (
38
38
  sparkline,
39
39
  titled,
40
40
  )
41
+ from .prompts import _slot
41
42
 
42
43
 
43
44
  def format_grade_delta(delta: float | None) -> str | None:
@@ -81,7 +82,7 @@ def format_card_summary(card: CardOut) -> str:
81
82
  """One-line summary of a card for inline display. Shows the subset (the line) and the parallel
82
83
  finish only when there is one — so a no-finish INSERT reads as its insert name, not 'Base'."""
83
84
  subjects = ", ".join(s.name for s in card.subjects) or "—"
84
- slot = (card.subset or "?") + (f" · {card.finish}" if card.finish else "")
85
+ slot = _slot(card)
85
86
  pr = f" /{card.print_run}" if card.print_run else ""
86
87
  attrs = f" [{', '.join(a.name for a in card.attributes)}]" if card.attributes else ""
87
88
  return f"{card.card_number} {subjects} — {card.brand or ''} {card.set_name or ''} — {slot}{pr}{attrs}"
@@ -419,7 +420,7 @@ def _highlight_panel(title: str, cards: list[HighlightCard], show_cost: bool = F
419
420
  rows = []
420
421
  for h in cards:
421
422
  subj = ", ".join(h.subjects) or "—"
422
- slot = (h.subset or "?") + (f" · {h.finish}" if h.finish else "")
423
+ slot = _slot(h)
423
424
  run = f" /{h.print_run}" if h.print_run else ""
424
425
  cost = f" [slab.gain]{money(h.cost_basis)}[/]" if (show_cost and h.cost_basis is not None) else ""
425
426
  rows.append(f"[slab.value]#{h.card_number}[/] {subj} [slab.dim]{slot}{run}[/]{cost}")
@@ -785,7 +786,7 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
785
786
  for entry in detail.cards:
786
787
  card = entry.card
787
788
  subjects = ", ".join(s.name for s in card.subjects) if card else "—"
788
- slot = (card.subset or "?") + (f" · {card.finish}" if card and card.finish else "")
789
+ slot = _slot(card) if card else "—"
789
790
  pr = f" /{card.print_run}" if card and card.print_run else ""
790
791
  label = f"{card.card_number} {subjects} [slab.dim]— {slot}{pr}[/]" if card else "—"
791
792
 
@@ -819,7 +820,7 @@ def _card_headline(card_number, subjects: str, set_name: str | None, slot: str)
819
820
  def render_card_market(m: CardMarket) -> None:
820
821
  """Display the market pricing for a catalog card — all grade buckets."""
821
822
  subjects = ", ".join(m.subjects) or "—"
822
- slot = (m.subset or "?") + (f" · {m.finish}" if m.finish else "")
823
+ slot = _slot(m)
823
824
  console.print()
824
825
  console.print(_card_headline(m.card_number, subjects, m.set_name, slot))
825
826
 
@@ -926,7 +927,7 @@ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCard
926
927
  ("Comps", {"justify": "right", "style": "slab.dim"}),
927
928
  ])
928
929
  for i, entry in enumerate(top.cards, 1):
929
- slot = (entry.subset or "?") + (f" · {entry.finish}" if entry.finish else "")
930
+ slot = _slot(entry)
930
931
  if entry.print_run:
931
932
  slot += f" /{entry.print_run}"
932
933
  fmv = f"[slab.foil]{money(entry.market.fair_market_value)}[/]"
@@ -947,7 +948,7 @@ def render_set_market(s: SetOut, sealed: list[SealedProductOut], top: SetTopCard
947
948
  def render_comps(c: CardComps) -> None:
948
949
  """Display recent comps (raw sales) for a catalog card — the evidence behind its FMV."""
949
950
  subjects = ", ".join(c.subjects) or "—"
950
- slot = (c.subset or "?") + (f" · {c.finish}" if c.finish else "")
951
+ slot = _slot(c)
951
952
  console.print()
952
953
  console.print(_card_headline(c.card_number, subjects, c.set_name, slot))
953
954
 
@@ -14,6 +14,7 @@ import inspect
14
14
  from .banner import BANNER
15
15
  from .commands.registry import GROUPS, Command, Group
16
16
  from .theme import console, heading
17
+ from .updates import current_version
17
18
 
18
19
  _CONFIG = "SLAB_API_URL · SLAB_API_KEY · SLAB_COLLECTOR"
19
20
 
@@ -100,4 +101,8 @@ def render_root_help() -> None:
100
101
  console.print(heading(group.name, group.blurb))
101
102
  console.print(_group_table(group))
102
103
  console.print()
103
- console.print(Text.from_markup(f" [slab.label]Config[/] [slab.dim]{_CONFIG}[/]\n"))
104
+ console.print(Text.from_markup(f" [slab.label]Config[/] [slab.dim]{_CONFIG}[/]"))
105
+ # The installed version, right where people look for it. Whether it's the NEWEST version is the
106
+ # update nudge's job (printed after any command from a cached daily check) — not restated here.
107
+ version = current_version() or "source checkout"
108
+ console.print(Text.from_markup(f" [slab.label]Version[/] [slab.dim]slab-cli {version}[/]\n"))
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  import re
12
12
  import sys
13
13
 
14
+ from . import updates
14
15
  from .client import ApiConnectionError, ApiError
15
16
  from .commands.registry import get_command, get_group, suggest_group, suggest_verb
16
17
  from .context import MissingCollector
@@ -19,6 +20,7 @@ from .flags import FlagError
19
20
  from .help import render_command_help, render_group_help, render_root_help
20
21
 
21
22
  _HELP_FLAGS = ("-h", "--help", "help")
23
+ _VERSION_FLAGS = ("-V", "--version")
22
24
 
23
25
  # assert_owned's 404 detail for an unknown/unowned collector is exactly "collector <uuid> not found".
24
26
  # Distinct from copy/break/lot not-found details ("copy X not found for collector Y"), which name a
@@ -31,12 +33,24 @@ def _is_missing_collector(detail: str | None) -> bool:
31
33
  return bool(detail and _MISSING_COLLECTOR_RE.match(detail))
32
34
 
33
35
 
36
+ def _print_version() -> None:
37
+ """`slab --version` — the installed version. Instant: no network, and if a newer release is
38
+ already known from the cached daily check, the usual nudge follows it. `slab update check` is
39
+ the one that asks PyPI live."""
40
+ version = updates.current_version() or "(source checkout — no installed version)"
41
+ console.print(f"slab-cli {version}", highlight=False) # a version is one token, not numbers to color
42
+
43
+
34
44
  def _run(args: list[str]) -> int:
35
45
  """Resolve `args` against the registry and run the command. Returns a process exit code."""
36
46
  if not args or args[0] in _HELP_FLAGS:
37
47
  render_root_help()
38
48
  return 0
39
49
 
50
+ if args[0] in _VERSION_FLAGS:
51
+ _print_version()
52
+ return 0
53
+
40
54
  group_name = args[0]
41
55
  group = get_group(group_name)
42
56
  if group is None:
@@ -75,23 +89,27 @@ def _run(args: list[str]) -> int:
75
89
  return 0
76
90
 
77
91
 
78
- def main() -> None:
92
+ def _dispatch(args: list[str]) -> int:
93
+ """Run the command, turning every expected failure into an exit code + a plain-English message
94
+ (never a traceback). Split out of `main` so the update nudge can be printed on the way out of
95
+ *any* path — success, cancel, or error — exactly once."""
79
96
  try:
80
- sys.exit(_run(sys.argv[1:]))
97
+ return _run(args)
81
98
  except KeyboardInterrupt:
82
99
  console.print("\n[dim]Cancelled.[/dim]")
100
+ return 0
83
101
  except FlagError as e:
84
102
  console.print(f"[red]{e}[/red]")
85
- sys.exit(2)
103
+ return 2
86
104
  except MissingCollector:
87
105
  console.print("[red]No collector resolved for this API key.[/red]")
88
106
  console.print(" Your collector is created when you sign up in the portal.")
89
107
  console.print(" Grab its UUID there, then: export SLAB_COLLECTOR=<uuid>")
90
- sys.exit(1)
108
+ return 1
91
109
  except ApiConnectionError as e:
92
110
  console.print(f"\n[red]{e}[/red]")
93
111
  console.print("[dim] Check that the API is running and SLAB_API_URL is correct.[/dim]")
94
- sys.exit(1)
112
+ return 1
95
113
  except ApiError as e:
96
114
  if e.status == 404 and _is_missing_collector(e.detail):
97
115
  # The active collector (SLAB_COLLECTOR) doesn't resolve to a live collector on this
@@ -106,7 +124,21 @@ def main() -> None:
106
124
  console.print("[red]Unauthorized — check your SLAB_API_KEY.[/red]")
107
125
  else:
108
126
  console.print(f"[red]API error: {e}[/red]")
109
- sys.exit(1)
127
+ return 1
128
+
129
+
130
+ def main() -> None:
131
+ args = sys.argv[1:]
132
+ # Kick the once-a-day PyPI check off first so it overlaps the command's own work in a daemon
133
+ # thread; the nudge itself is printed from cache, last, so it's the line left on screen and it
134
+ # never delays anything. `slab update` reports its own version state — don't tail it with a nudge.
135
+ updates.start_background_check()
136
+ try:
137
+ code = _dispatch(args)
138
+ finally:
139
+ if args[:1] != ["update"]:
140
+ updates.print_notice()
141
+ sys.exit(code)
110
142
 
111
143
 
112
144
  if __name__ == "__main__":
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  from collections import Counter
9
9
  from datetime import date
10
10
  from decimal import Decimal, InvalidOperation
11
+ from typing import Protocol
11
12
 
12
13
  from InquirerPy import inquirer
13
14
  from InquirerPy.base.control import Choice
@@ -30,7 +31,15 @@ from .theme import console as _console
30
31
  # Label builders (for select menus)
31
32
  # ---------------------------------------------------------------------------
32
33
 
33
- def _slot(c: CardOut) -> str:
34
+ class _HasSlot(Protocol):
35
+ """Any DTO that carries a card's line identity — a subset and an optional parallel finish
36
+ (CardOut, HighlightCard, CardMarket, custom-set entries, …). `_slot` is duck-typed over these."""
37
+
38
+ subset: str | None
39
+ finish: str | None
40
+
41
+
42
+ def _slot(c: _HasSlot) -> str:
34
43
  """The card's line: its subset, plus the parallel finish when it has one. 'Base' shows only
35
44
  when the subset is literally Base — a no-finish INSERT shows its insert name, not 'Base'
36
45
  (id_finish NULL means 'base printing of this line', not 'the base set')."""
@@ -0,0 +1,242 @@
1
+ """Update awareness — tell the user when a newer `slab-cli` is on PyPI, never force one on them.
2
+
3
+ The rule this module exists to keep: **checking for updates must be invisible unless there IS one.**
4
+ So it never blocks a command and never fails one:
5
+
6
+ - The nudge printed after a command comes from a small cache file (`~/.slab/update-check.json`),
7
+ so it normally costs no network at all.
8
+ - The cache is refreshed at most once a day, in a **daemon thread started at the top of `main()`** —
9
+ it runs alongside the command's own API calls, so it's usually done before the command is. On the
10
+ way out we wait at most `FINISH_GRACE_S` for it (see `_await_check`): without that grace a fast
11
+ command like `slab --version` exits first and the check never lands at all. Writes are atomic
12
+ (tmp + rename) because the thread can still be killed at exit.
13
+ - Every failure path — offline, PyPI 500, junk JSON, unwritable home — resolves to "say nothing".
14
+ A version check is never worth breaking `slab card price` over.
15
+ - A failed refresh still stamps `checked_at`, so an offline user isn't retried on every command.
16
+
17
+ `slab update` (`commands/update.py`) is the explicit counterpart: it bypasses the cache, checks
18
+ synchronously, and runs the upgrade — after asking. Set `SLAB_NO_UPDATE_CHECK=1` to silence the
19
+ whole mechanism (both the nudge and the background fetch).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ import threading
29
+ import time
30
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
31
+ from pathlib import Path
32
+
33
+ import httpx
34
+ from rich.text import Text
35
+
36
+ from .theme import console
37
+
38
+ PACKAGE = "slab-cli"
39
+ PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
40
+
41
+ CACHE_PATH = Path.home() / ".slab" / "update-check.json"
42
+ CHECK_INTERVAL_S = 24 * 60 * 60 # once a day is plenty — releases aren't hourly
43
+ BACKGROUND_TIMEOUT_S = 3.0 # nobody is waiting on this one
44
+ FOREGROUND_TIMEOUT_S = 10.0 # `slab update` — the user asked and is watching
45
+ FINISH_GRACE_S = 1.0 # most we'll delay exit for an in-flight check (PyPI answers in ~0.2s)
46
+
47
+
48
+ # --- Versions (pure) -------------------------------------------------------------------------------
49
+
50
+ # PEP 440-lite: a dotted release, optionally followed by a pre-release marker. Long alternatives
51
+ # come first so `alpha`/`beta`/`preview` win over `a`/`b`/`pre`.
52
+ _VERSION_RE = re.compile(
53
+ r"^\s*v?(\d+(?:\.\d+)*)"
54
+ r"(?:[-_.]?(dev|alpha|beta|preview|pre|rc|a|b|c)[-_.]?(\d*))?",
55
+ re.IGNORECASE,
56
+ )
57
+
58
+ # Pre-release ranks sort BELOW a final release (4), so 0.6.0rc1 < 0.6.0.
59
+ _PRE_RANK = {"dev": 0, "a": 1, "alpha": 1, "b": 2, "beta": 2, "c": 3, "rc": 3, "pre": 3, "preview": 3}
60
+
61
+
62
+ def parse_version(v: str) -> tuple:
63
+ """A sortable key for a version string — the ONE definition of 'newer' in the CLI.
64
+
65
+ Compares numerically (so `0.10.0` beats `0.9.0`, which a string compare gets backwards), pads
66
+ the release to four segments (so `0.6` == `0.6.0`), and ranks pre-releases below their final.
67
+ Anything unparseable sorts lowest, which makes an odd string on PyPI a no-op rather than a
68
+ bogus "update available"."""
69
+ m = _VERSION_RE.match(v or "")
70
+ if not m:
71
+ return ((0,), -1, 0)
72
+ release = tuple(int(p) for p in m.group(1).split("."))
73
+ if len(release) < 4:
74
+ release += (0,) * (4 - len(release))
75
+ label = (m.group(2) or "").lower()
76
+ return (release, _PRE_RANK.get(label, 4), int(m.group(3) or 0))
77
+
78
+
79
+ def is_newer(latest: str | None, current: str | None) -> bool:
80
+ """True when `latest` is a strictly newer version than `current`."""
81
+ if not latest or not current:
82
+ return False
83
+ return parse_version(latest) > parse_version(current)
84
+
85
+
86
+ def current_version() -> str | None:
87
+ """The installed `slab-cli` version, or None when there is no dist metadata — i.e. the CLI is
88
+ being run straight from a source checkout. None means "don't check": a git working copy has no
89
+ published version to compare against, and every run would claim to be out of date."""
90
+ try:
91
+ return _dist_version(PACKAGE)
92
+ except PackageNotFoundError:
93
+ return None
94
+
95
+
96
+ def upgrade_command(prefix: str | None = None) -> list[str]:
97
+ """The upgrade command for HOW this slab was installed, inferred from the environment root.
98
+
99
+ `pipx install slab-cli` lands in `…/pipx/venvs/slab-cli`, `uv tool install` in `…/uv/tools/slab-cli`
100
+ — both manage their own venvs and must be upgraded through their own tool, not pip. Anything else
101
+ (a plain venv, `pip install --user`) upgrades with the running interpreter's pip."""
102
+ parts = {p.lower() for p in Path(prefix or sys.prefix).parts}
103
+ if "pipx" in parts:
104
+ return ["pipx", "upgrade", PACKAGE]
105
+ if "uv" in parts and "tools" in parts:
106
+ return ["uv", "tool", "upgrade", PACKAGE]
107
+ return [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE]
108
+
109
+
110
+ def notice_text(current: str | None, latest: str | None) -> str | None:
111
+ """The nudge as markup, or None when there's nothing to say. Pure — the whole decision of
112
+ *whether* to nag lives here, so the wording and the condition are testable together."""
113
+ if not is_newer(latest, current):
114
+ return None
115
+ return (
116
+ f"\n[slab.foil]▍ slab-cli {latest} is available[/] [slab.dim]— you have {current}[/]\n"
117
+ f" [slab.dim]Update when you like:[/] [slab.value]slab update[/]"
118
+ )
119
+
120
+
121
+ # --- Cache (the nudge's only data source) ----------------------------------------------------------
122
+
123
+ def read_cache(path: Path = CACHE_PATH) -> dict:
124
+ """The cached check as a dict, or `{}` for missing/corrupt/unreadable — never raises."""
125
+ try:
126
+ data = json.loads(path.read_text())
127
+ except (OSError, ValueError):
128
+ return {}
129
+ return data if isinstance(data, dict) else {}
130
+
131
+
132
+ def write_cache(latest: str | None, path: Path = CACHE_PATH, *, now: float | None = None) -> None:
133
+ """Record a completed check. Written tmp-then-rename so the background thread being killed
134
+ mid-write can't leave a half-parsed file behind. `latest=None` still stamps the time (a failed
135
+ check counts as checked, so an offline user isn't retried every command)."""
136
+ payload = {"checked_at": now if now is not None else time.time(), "latest": latest}
137
+ try:
138
+ path.parent.mkdir(parents=True, exist_ok=True)
139
+ tmp = path.with_name(path.name + ".tmp")
140
+ tmp.write_text(json.dumps(payload))
141
+ tmp.replace(path)
142
+ except OSError:
143
+ pass # a read-only home is not an error worth surfacing
144
+
145
+
146
+ def is_due(cache: dict, now: float, interval: float = CHECK_INTERVAL_S) -> bool:
147
+ """True when the cached check is older than `interval` (or absent)."""
148
+ checked_at = cache.get("checked_at")
149
+ if not isinstance(checked_at, (int, float)):
150
+ return True
151
+ return (now - checked_at) >= interval
152
+
153
+
154
+ # --- Fetching --------------------------------------------------------------------------------------
155
+
156
+ def fetch_latest(timeout: float = BACKGROUND_TIMEOUT_S) -> str | None:
157
+ """The newest published version per PyPI's JSON API, or None on ANY failure.
158
+
159
+ `info.version` is PyPI's own "latest release" (yanked releases excluded), so no client-side
160
+ sorting is needed. The blanket except is deliberate: a version check must never turn into a
161
+ traceback in front of a user who was trying to price a card."""
162
+ try:
163
+ response = httpx.get(PYPI_JSON_URL, timeout=timeout, follow_redirects=True)
164
+ response.raise_for_status()
165
+ latest = response.json()["info"]["version"]
166
+ except Exception:
167
+ return None
168
+ return latest if isinstance(latest, str) else None
169
+
170
+
171
+ def check_now(timeout: float = FOREGROUND_TIMEOUT_S) -> str | None:
172
+ """Check PyPI right now, bypassing the daily cache, and record the result. `slab update`'s
173
+ lookup — an explicit command should never answer from a day-old cache."""
174
+ latest = fetch_latest(timeout)
175
+ write_cache(latest or read_cache().get("latest"))
176
+ return latest
177
+
178
+
179
+ # --- The two hooks main() calls --------------------------------------------------------------------
180
+
181
+ def checks_disabled() -> bool:
182
+ """SLAB_NO_UPDATE_CHECK — one switch that silences the nudge AND the background fetch."""
183
+ return bool(os.environ.get("SLAB_NO_UPDATE_CHECK", "").strip())
184
+
185
+
186
+ # The in-flight check, process-wide: one CLI run performs at most one. `_fetched` lets this run's
187
+ # nudge use a result that arrived just now, so discovering a release doesn't wait until tomorrow.
188
+ _thread: threading.Thread | None = None
189
+ _fetched: dict[str, str | None] = {}
190
+ _awaited = False
191
+
192
+
193
+ def start_background_check() -> None:
194
+ """Refresh the cache in a daemon thread when a check is due. Returns immediately — the command
195
+ runs while PyPI answers. Call once, at the top of `main()`."""
196
+ global _thread
197
+ if checks_disabled() or current_version() is None or _thread is not None:
198
+ return
199
+ if not is_due(read_cache(), time.time()):
200
+ return
201
+ _thread = threading.Thread(target=_refresh, name="slab-update-check", daemon=True)
202
+ _thread.start()
203
+
204
+
205
+ def _refresh() -> None:
206
+ latest = fetch_latest() or read_cache().get("latest")
207
+ _fetched["latest"] = latest if isinstance(latest, str) else None
208
+ write_cache(_fetched["latest"])
209
+
210
+
211
+ def _await_check(grace: float = FINISH_GRACE_S) -> None:
212
+ """Wait a beat for an in-flight check, at most `grace`, and only ever once. Needed because a
213
+ short command (`slab --version`, a help screen) finishes in ~30ms and would exit before the fetch
214
+ completed — leaving the cache permanently empty and the nudge permanently silent. Paid only on the
215
+ once-a-day run that actually checks; a slower check is abandoned (it's a daemon thread)."""
216
+ global _awaited
217
+ if _thread is None or _awaited:
218
+ return
219
+ _awaited = True
220
+ _thread.join(grace)
221
+
222
+
223
+ def pending_notice() -> str | None:
224
+ """The nudge for this run — from this run's own check if it landed, else the cache. No network."""
225
+ if checks_disabled():
226
+ return None
227
+ latest = _fetched["latest"] if "latest" in _fetched else read_cache().get("latest")
228
+ return notice_text(current_version(), latest if isinstance(latest, str) else None)
229
+
230
+
231
+ def print_notice() -> None:
232
+ """Print the nudge, if there is one. Call last, on the way out of `main()`.
233
+
234
+ The wait happens even when nothing will be printed, so a piped run still refreshes the cache for
235
+ the next interactive one. Printing itself is terminal-only: appended to redirected output the
236
+ nudge would be noise in someone's file, not a helpful hint."""
237
+ _await_check()
238
+ if not console.is_terminal:
239
+ return
240
+ notice = pending_notice()
241
+ if notice:
242
+ console.print(Text.from_markup(notice))
File without changes
File without changes