slab-cli 0.5.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.5.0 → slab_cli-0.6.0}/PKG-INFO +27 -6
- {slab_cli-0.5.0 → slab_cli-0.6.0}/README.md +26 -5
- {slab_cli-0.5.0 → slab_cli-0.6.0}/pyproject.toml +1 -1
- {slab_cli-0.5.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.5.0 → slab_cli-0.6.0}/src/slab_cli/help.py +6 -1
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/main.py +38 -6
- slab_cli-0.6.0/src/slab_cli/updates.py +242 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/client.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/catalog.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/custom_sets.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/display.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/prompts.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.6.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.5.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
|
|
|
@@ -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[/]")
|
|
@@ -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__":
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|