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.
- {slab_cli-0.4.0 → slab_cli-0.6.0}/PKG-INFO +27 -6
- {slab_cli-0.4.0 → slab_cli-0.6.0}/README.md +26 -5
- {slab_cli-0.4.0 → slab_cli-0.6.0}/pyproject.toml +1 -1
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/client.py +2 -41
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/registry.py +7 -1
- slab_cli-0.6.0/src/slab_cli/commands/update.py +124 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/display.py +7 -6
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/help.py +6 -1
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/main.py +38 -6
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/prompts.py +10 -1
- slab_cli-0.6.0/src/slab_cli/updates.py +242 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/custom_sets.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.4.0 → slab_cli-0.6.0}/src/slab_cli/sources.py +0 -0
- {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.
|
|
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
|
|
60
|
-
|
|
|
61
|
-
| `SLAB_API_KEY`
|
|
62
|
-
| `SLAB_API_URL`
|
|
63
|
-
| `SLAB_COLLECTOR
|
|
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
|
|
48
|
-
|
|
|
49
|
-
| `SLAB_API_KEY`
|
|
50
|
-
| `SLAB_API_URL`
|
|
51
|
-
| `SLAB_COLLECTOR
|
|
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
|
|
|
@@ -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
|
|
55
|
-
from slab_schemas.timeseries import CardPriceHistory
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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[/]
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|