update-kit 0.1.0__tar.gz → 0.2.1__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.
- update_kit-0.2.1/.github/workflows/ci.yml +102 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/.github/workflows/release.yml +2 -2
- {update_kit-0.1.0 → update_kit-0.2.1}/CHANGELOG.md +14 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/PKG-INFO +17 -2
- {update_kit-0.1.0 → update_kit-0.2.1}/README.md +16 -1
- {update_kit-0.1.0 → update_kit-0.2.1}/src/update_kit/__init__.py +42 -21
- {update_kit-0.1.0 → update_kit-0.2.1}/tests/test_update_kit.py +100 -15
- update_kit-0.1.0/.github/workflows/ci.yml +0 -62
- {update_kit-0.1.0 → update_kit-0.2.1}/.github/dependabot.yml +0 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/.gitignore +0 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/CLAUDE.md +0 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/LICENSE +0 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/TODO.md +0 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/pyproject.toml +0 -0
- {update_kit-0.1.0 → update_kit-0.2.1}/uv.lock +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
PYTHONUTF8: "1"
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
test:
|
|
19
|
+
name: Python 3.10 / ${{ matrix.os }}
|
|
20
|
+
timeout-minutes: 15
|
|
21
|
+
strategy:
|
|
22
|
+
fail-fast: false
|
|
23
|
+
matrix:
|
|
24
|
+
os: [macos-latest, ubuntu-latest, windows-latest]
|
|
25
|
+
runs-on: ${{ matrix.os }}
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
28
|
+
with:
|
|
29
|
+
persist-credentials: false
|
|
30
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.10"
|
|
33
|
+
enable-cache: true
|
|
34
|
+
cache-dependency-glob: uv.lock
|
|
35
|
+
- name: Install locked dependencies
|
|
36
|
+
run: uv sync --locked --all-groups
|
|
37
|
+
- name: Lint
|
|
38
|
+
run: uv run ruff check .
|
|
39
|
+
- name: Test
|
|
40
|
+
run: uv run pytest -v
|
|
41
|
+
- name: Build distributions
|
|
42
|
+
run: uv build
|
|
43
|
+
- name: Validate wheel is importable standalone
|
|
44
|
+
shell: python
|
|
45
|
+
run: |
|
|
46
|
+
import os
|
|
47
|
+
import pathlib
|
|
48
|
+
import subprocess
|
|
49
|
+
import tempfile
|
|
50
|
+
|
|
51
|
+
wheel, = pathlib.Path("dist").glob("*.whl")
|
|
52
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
53
|
+
environment = pathlib.Path(directory)
|
|
54
|
+
subprocess.run(["uv", "venv", environment], check=True)
|
|
55
|
+
scripts = environment / ("Scripts" if os.name == "nt" else "bin")
|
|
56
|
+
python = scripts / ("python.exe" if os.name == "nt" else "python")
|
|
57
|
+
subprocess.run(
|
|
58
|
+
["uv", "pip", "install", "--python", python, "--no-index", wheel],
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
subprocess.run([python, "-I", "-c", "import update_kit"], check=True)
|
|
62
|
+
print("update_kit imports standalone OK")
|
|
63
|
+
|
|
64
|
+
# Failure-only pager. A green run sends nothing on purpose: a notification
|
|
65
|
+
# that arrives on every push is one nobody reads. Credentials live in repo
|
|
66
|
+
# secrets, never in this file — see README "CI notifications" for the two
|
|
67
|
+
# `gh secret set` commands that configure them.
|
|
68
|
+
notify-telegram:
|
|
69
|
+
needs: test
|
|
70
|
+
if: ${{ failure() && github.event_name == 'push' }}
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
timeout-minutes: 5
|
|
73
|
+
steps:
|
|
74
|
+
- name: Notify Telegram
|
|
75
|
+
env:
|
|
76
|
+
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
|
77
|
+
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
|
78
|
+
run: |
|
|
79
|
+
# Unconfigured secrets are a valid state (a fork, or a clone whose
|
|
80
|
+
# owner never set them up). Skip quietly instead of failing the job
|
|
81
|
+
# and reporting a second, misleading red X on top of the real one.
|
|
82
|
+
if [ -z "$TOKEN" ] || [ -z "$CHAT_ID" ]; then
|
|
83
|
+
echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification"
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
# parse_mode=HTML: a branch or commit subject containing < & > would
|
|
87
|
+
# otherwise produce unparseable markup and Telegram would 400.
|
|
88
|
+
esc() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'; }
|
|
89
|
+
ci_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
|
90
|
+
message="🚨 <b>CI failed</b>
|
|
91
|
+
|
|
92
|
+
<b>Repository:</b> <code>$(esc "$GITHUB_REPOSITORY")</code>
|
|
93
|
+
<b>Branch:</b> <code>$(esc "$GITHUB_REF_NAME")</code>
|
|
94
|
+
<b>Commit:</b> <code>$(esc "${GITHUB_SHA::7}")</code>
|
|
95
|
+
|
|
96
|
+
<a href=\"$ci_url\">Open failed CI run</a>"
|
|
97
|
+
curl --fail --silent --show-error --max-time 30 \
|
|
98
|
+
--data-urlencode "chat_id=$CHAT_ID" \
|
|
99
|
+
--data-urlencode "parse_mode=HTML" \
|
|
100
|
+
--data-urlencode "disable_web_page_preview=true" \
|
|
101
|
+
--data-urlencode "text=$message" \
|
|
102
|
+
"https://api.telegram.org/bot$TOKEN/sendMessage"
|
|
@@ -21,10 +21,10 @@ jobs:
|
|
|
21
21
|
runs-on: ubuntu-latest
|
|
22
22
|
timeout-minutes: 15
|
|
23
23
|
steps:
|
|
24
|
-
- uses: actions/checkout@
|
|
24
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
25
25
|
with:
|
|
26
26
|
persist-credentials: false
|
|
27
|
-
- uses: astral-sh/setup-uv@
|
|
27
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
28
28
|
with:
|
|
29
29
|
python-version: "3.10"
|
|
30
30
|
enable-cache: true
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.2.1] - 2026-10-16
|
|
2
|
+
|
|
3
|
+
### 🧪 Testing
|
|
4
|
+
|
|
5
|
+
- Stop pinning __version__ in the api test
|
|
6
|
+
## [0.2.0] - 2026-10-16
|
|
7
|
+
|
|
8
|
+
### 🚀 Features
|
|
9
|
+
|
|
10
|
+
- **update:** [**breaking**] Always land on the newest release tag
|
|
11
|
+
|
|
12
|
+
### ⚙️ Miscellaneous Tasks
|
|
13
|
+
|
|
14
|
+
- Add Telegram failure notification
|
|
1
15
|
## [0.1.0] - 2026-10-15
|
|
2
16
|
|
|
3
17
|
### 🚀 Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: update-kit
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Self-update for git-tag-released Python CLIs (uv tool / pipx). Stdlib only.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -60,7 +60,7 @@ def main(argv=None):
|
|
|
60
60
|
|---|---|
|
|
61
61
|
| `latest_tag(repo, *, timeout=5, url=None) -> str` | Newest strict `v?X.Y.Z` tag (ASCII digits) via `git ls-remote --tags --refs`. Prereleases and other names are ignored. Raises `UpdateError` (offline, no tags, git missing). |
|
|
62
62
|
| `install_kind(dist) -> (kind, detail)` | `"dev"` (PEP 610 local/editable dir, detail = the dir), `"uv"` / `"pipx"` (from `sys.prefix`), else `"unknown"`. |
|
|
63
|
-
| `update(*, app, repo, dist, current=None, lang="en", after=(), out=sys.stderr, run=subprocess.run, url=None) -> int` | Exit code: **0** updated or already
|
|
63
|
+
| `update(*, app, repo, dist, current=None, lang="en", after=(), out=sys.stderr, run=subprocess.run, url=None) -> int` | Always lands on the newest tag: a dev checkout, a branch/commit install, a newer build or an unmanaged install is replaced by it (via `uv`, else `pipx`). Exit code: **0** updated, or already installed from that tag (the message names the version) · **1** couldn't check (offline/rate limit) or install/after-step failed · **2** no `uv`/`pipx` found (manual command printed). |
|
|
64
64
|
| `check(*, repo, current, cache_path=None, ttl=86400, timeout=1.5, url=None) -> str \| None` | Newer tag or `None`. Never raises. Results — failures too — are cached for `ttl` in `$HOME/.update-kit/state.json`, keyed by the resolved source URL. |
|
|
65
65
|
| `hint(cmd, latest, current, lang="en") -> str` | `my-cli v1.2.0 is available (you have 1.1.0) — run` `` `my-cli update` `` |
|
|
66
66
|
| `should_hint(stream=sys.stderr) -> bool` | `True` only when `stream` is a TTY. |
|
|
@@ -100,6 +100,21 @@ uv run ruff check .
|
|
|
100
100
|
uv build
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
## CI notifications
|
|
104
|
+
|
|
105
|
+
`.github/workflows/ci.yml` runs the test matrix (macOS/Linux/Windows) on every push and PR,
|
|
106
|
+
then a separate `notify-telegram` job sends a Telegram message only when the test job fails on
|
|
107
|
+
a `push` (never on green runs, never on `pull_request`, to avoid pinging on forks/external PRs).
|
|
108
|
+
Configure it once per repo:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
gh secret set TELEGRAM_BOT_TOKEN
|
|
112
|
+
gh secret set TELEGRAM_CHAT_ID
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Unconfigured secrets are a valid state — the job skips quietly instead of failing a second time
|
|
116
|
+
on top of the real failure.
|
|
117
|
+
|
|
103
118
|
## License
|
|
104
119
|
|
|
105
120
|
MIT
|
|
@@ -51,7 +51,7 @@ def main(argv=None):
|
|
|
51
51
|
|---|---|
|
|
52
52
|
| `latest_tag(repo, *, timeout=5, url=None) -> str` | Newest strict `v?X.Y.Z` tag (ASCII digits) via `git ls-remote --tags --refs`. Prereleases and other names are ignored. Raises `UpdateError` (offline, no tags, git missing). |
|
|
53
53
|
| `install_kind(dist) -> (kind, detail)` | `"dev"` (PEP 610 local/editable dir, detail = the dir), `"uv"` / `"pipx"` (from `sys.prefix`), else `"unknown"`. |
|
|
54
|
-
| `update(*, app, repo, dist, current=None, lang="en", after=(), out=sys.stderr, run=subprocess.run, url=None) -> int` | Exit code: **0** updated or already
|
|
54
|
+
| `update(*, app, repo, dist, current=None, lang="en", after=(), out=sys.stderr, run=subprocess.run, url=None) -> int` | Always lands on the newest tag: a dev checkout, a branch/commit install, a newer build or an unmanaged install is replaced by it (via `uv`, else `pipx`). Exit code: **0** updated, or already installed from that tag (the message names the version) · **1** couldn't check (offline/rate limit) or install/after-step failed · **2** no `uv`/`pipx` found (manual command printed). |
|
|
55
55
|
| `check(*, repo, current, cache_path=None, ttl=86400, timeout=1.5, url=None) -> str \| None` | Newer tag or `None`. Never raises. Results — failures too — are cached for `ttl` in `$HOME/.update-kit/state.json`, keyed by the resolved source URL. |
|
|
56
56
|
| `hint(cmd, latest, current, lang="en") -> str` | `my-cli v1.2.0 is available (you have 1.1.0) — run` `` `my-cli update` `` |
|
|
57
57
|
| `should_hint(stream=sys.stderr) -> bool` | `True` only when `stream` is a TTY. |
|
|
@@ -91,6 +91,21 @@ uv run ruff check .
|
|
|
91
91
|
uv build
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
## CI notifications
|
|
95
|
+
|
|
96
|
+
`.github/workflows/ci.yml` runs the test matrix (macOS/Linux/Windows) on every push and PR,
|
|
97
|
+
then a separate `notify-telegram` job sends a Telegram message only when the test job fails on
|
|
98
|
+
a `push` (never on green runs, never on `pull_request`, to avoid pinging on forks/external PRs).
|
|
99
|
+
Configure it once per repo:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
gh secret set TELEGRAM_BOT_TOKEN
|
|
103
|
+
gh secret set TELEGRAM_CHAT_ID
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Unconfigured secrets are a valid state — the job skips quietly instead of failing a second time
|
|
107
|
+
on top of the real failure.
|
|
108
|
+
|
|
94
109
|
## License
|
|
95
110
|
|
|
96
111
|
MIT
|
|
@@ -22,6 +22,7 @@ import json
|
|
|
22
22
|
import os
|
|
23
23
|
import re
|
|
24
24
|
import shlex
|
|
25
|
+
import shutil
|
|
25
26
|
import subprocess
|
|
26
27
|
import sys
|
|
27
28
|
import time
|
|
@@ -32,7 +33,7 @@ from typing import TextIO
|
|
|
32
33
|
from urllib.parse import urlparse
|
|
33
34
|
from urllib.request import url2pathname
|
|
34
35
|
|
|
35
|
-
__version__ = "0.1
|
|
36
|
+
__version__ = "0.2.1"
|
|
36
37
|
|
|
37
38
|
__all__ = [
|
|
38
39
|
"UpdateError", "check", "hint", "install_kind", "latest_tag", "normalize_lang",
|
|
@@ -52,26 +53,26 @@ _CMD_META = re.compile(r'[&|<>^%"()!\r\n]')
|
|
|
52
53
|
|
|
53
54
|
_MESSAGES = {
|
|
54
55
|
"en": {
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"up_to_date": "{app} {current} is already up to date",
|
|
56
|
+
"check_failed": "❌ {app}: couldn't check for updates ({error})",
|
|
57
|
+
"up_to_date": "✅ {app} {current} is already the latest version ({latest}).",
|
|
58
58
|
"manual": "{app} {latest} is available (you have {current}). Install it manually:\n {command}",
|
|
59
|
-
"installing": "Updating {app} {current}
|
|
60
|
-
"install_failed": "{app}: update failed ({error})",
|
|
61
|
-
"updated": "{app} updated to {latest}",
|
|
59
|
+
"installing": "⬆️ Updating {app} {current} → {latest} via `{command}` ...",
|
|
60
|
+
"install_failed": "❌ {app}: update failed ({error})",
|
|
61
|
+
"updated": "🎉 {app} updated to {latest}!",
|
|
62
|
+
"left_alone": "ℹ️ Installed as a {tool} tool; the copy at {dir} is unchanged.",
|
|
62
63
|
"after_failed": "warning: `{step}` failed after the update ({error})",
|
|
63
64
|
"background": "{app} {latest}: update continues in the background (log: {log})",
|
|
64
65
|
"hint": "{cmd} {latest} is available (you have {current}) — run `{cmd} update`",
|
|
65
66
|
"source": "{app}: using update source {url} (UPDATE_KIT_REPO_URL)",
|
|
66
67
|
},
|
|
67
68
|
"zh-TW": {
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"up_to_date": "{app} {current} 已是最新版本",
|
|
69
|
+
"check_failed": "❌ {app}:無法檢查更新({error})",
|
|
70
|
+
"up_to_date": "✅ {app} {current} 已是最新版本({latest})。",
|
|
71
71
|
"manual": "{app} {latest} 可更新(目前為 {current}),請手動安裝:\n {command}",
|
|
72
|
-
"installing": "正在更新 {app} {current}
|
|
73
|
-
"install_failed": "{app}:更新失敗({error})",
|
|
74
|
-
"updated": "{app} 已更新至 {latest}",
|
|
72
|
+
"installing": "⬆️ 正在更新 {app} {current} → {latest},執行 `{command}` ...",
|
|
73
|
+
"install_failed": "❌ {app}:更新失敗({error})",
|
|
74
|
+
"updated": "🎉 {app} 已更新至 {latest}!",
|
|
75
|
+
"left_alone": "ℹ️ 已安裝為 {tool} 工具;{dir} 的原本那份沒有變更。",
|
|
75
76
|
"after_failed": "警告:更新後執行 `{step}` 失敗({error})",
|
|
76
77
|
"background": "{app} {latest}:更新將在背景繼續(記錄檔:{log})",
|
|
77
78
|
"hint": "{cmd} {latest} 可更新(目前為 {current})— 執行 `{cmd} update`",
|
|
@@ -178,6 +179,17 @@ def install_kind(dist: str) -> tuple[str, str | None]:
|
|
|
178
179
|
return "unknown", str(prefix)
|
|
179
180
|
|
|
180
181
|
|
|
182
|
+
def _installed_revision(dist: str) -> str | None:
|
|
183
|
+
"""The git ref a PEP 610 VCS install was requested at (``v1.2.0``, ``main``); ``""`` for
|
|
184
|
+
a VCS install with no ref (the default branch); None when it is not a VCS install."""
|
|
185
|
+
try:
|
|
186
|
+
direct = json.loads(metadata.distribution(dist).read_text("direct_url.json") or "{}")
|
|
187
|
+
revision = direct["vcs_info"].get("requested_revision")
|
|
188
|
+
return revision if isinstance(revision, str) else ""
|
|
189
|
+
except Exception: # noqa: BLE001 - not installed, not a VCS install, malformed record
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
181
193
|
def _windows_chain(commands: Sequence[Sequence[str]], log: str) -> str:
|
|
182
194
|
"""One cmd.exe line: wait for the caller to exit (its exe is locked), then run *commands*."""
|
|
183
195
|
chain = " && ".join(subprocess.list2cmdline(list(argv)) for argv in commands)
|
|
@@ -197,11 +209,11 @@ def _spawn_detached(cmdline: str, log: str, env: dict | None = None) -> None: #
|
|
|
197
209
|
def update(*, app: str, repo: str, dist: str, current: str | None = None, lang: str = "en",
|
|
198
210
|
after: Sequence[Sequence[str]] = (), out: TextIO = sys.stderr,
|
|
199
211
|
run: Callable = subprocess.run, url: str | None = None) -> int:
|
|
200
|
-
"""Install the newest release. 0 updated/up to date, 1 failure, 2
|
|
212
|
+
"""Install the newest release tag. 0 updated/up to date, 1 failure, 2 no installer found.
|
|
213
|
+
|
|
214
|
+
Up to date means installed from exactly that tag; anything else (a dev checkout, a
|
|
215
|
+
branch or commit, a newer build, an unmanaged install) is replaced by it."""
|
|
201
216
|
kind, detail = install_kind(dist)
|
|
202
|
-
if kind == "dev":
|
|
203
|
-
_say(out, _msg(lang, "dev", app=app, dir=detail))
|
|
204
|
-
return 2
|
|
205
217
|
if current is None:
|
|
206
218
|
try:
|
|
207
219
|
current = metadata.version(dist)
|
|
@@ -215,15 +227,20 @@ def update(*, app: str, repo: str, dist: str, current: str | None = None, lang:
|
|
|
215
227
|
except UpdateError as exc:
|
|
216
228
|
_say(out, _msg(lang, "check_failed", app=app, error=exc))
|
|
217
229
|
return 1
|
|
218
|
-
|
|
219
|
-
if
|
|
220
|
-
_say(out, _msg(lang, "up_to_date", app=app, current=current))
|
|
230
|
+
revision = _installed_revision(dist)
|
|
231
|
+
if kind in ("uv", "pipx") and _parse(current) == _parse(latest) and revision in (None, latest):
|
|
232
|
+
_say(out, _msg(lang, "up_to_date", app=app, current=current, latest=latest))
|
|
221
233
|
return 0
|
|
222
234
|
# An unparseable/unknown current version is treated as older: the user asked to update.
|
|
223
235
|
# pip/uv can't parse the scp form (git@host:path); install over the equivalent ssh:// URL.
|
|
224
236
|
source = re.sub(r"^git@([^:]+):/?", r"ssh://git@\1/", target)
|
|
225
237
|
spec = f"git+{source}@{latest}"
|
|
226
238
|
shown = current or "?"
|
|
239
|
+
# A checkout or unmanaged install becomes a separate tool install of the tag; it stays as it was.
|
|
240
|
+
left_alone = detail if kind in ("dev", "unknown") else None
|
|
241
|
+
if kind in ("dev", "unknown"):
|
|
242
|
+
kind = next((tool for tool in ("uv", "pipx") if shutil.which(tool)), "unknown")
|
|
243
|
+
note = _msg(lang, "left_alone", tool=kind, dir=left_alone) if left_alone else None
|
|
227
244
|
if kind == "uv":
|
|
228
245
|
install = ["uv", "tool", "install", "--force", "--no-sources", spec]
|
|
229
246
|
elif kind == "pipx":
|
|
@@ -245,8 +262,10 @@ def update(*, app: str, repo: str, dist: str, current: str | None = None, lang:
|
|
|
245
262
|
_say(out, _msg(lang, "install_failed", app=app, error=exc))
|
|
246
263
|
return 1
|
|
247
264
|
_say(out, _msg(lang, "background", app=app, latest=latest, log=log))
|
|
265
|
+
if note:
|
|
266
|
+
_say(out, note)
|
|
248
267
|
return 0
|
|
249
|
-
_say(out, _msg(lang, "installing", app=app, current=shown, latest=latest))
|
|
268
|
+
_say(out, _msg(lang, "installing", app=app, current=shown, latest=latest, command=shlex.join(install)))
|
|
250
269
|
try:
|
|
251
270
|
code = run(install, cwd=Path.home(), env={**os.environ, **_NO_PROMPT}).returncode
|
|
252
271
|
except OSError as exc:
|
|
@@ -256,6 +275,8 @@ def update(*, app: str, repo: str, dist: str, current: str | None = None, lang:
|
|
|
256
275
|
_say(out, _msg(lang, "install_failed", app=app, error=f"exit {code}"))
|
|
257
276
|
return 1
|
|
258
277
|
_say(out, _msg(lang, "updated", app=app, latest=latest))
|
|
278
|
+
if note:
|
|
279
|
+
_say(out, note)
|
|
259
280
|
result = 0
|
|
260
281
|
for step in after:
|
|
261
282
|
step = list(step)
|
|
@@ -59,8 +59,11 @@ def make_repo(path, tags):
|
|
|
59
59
|
|
|
60
60
|
@pytest.fixture
|
|
61
61
|
def fake_env(monkeypatch):
|
|
62
|
-
"""update() with a uv install, latest v1.1.0, no network.
|
|
63
|
-
|
|
62
|
+
"""update() with a uv install, latest v1.1.0, no network.
|
|
63
|
+
|
|
64
|
+
``direct_url`` is the install's PEP 610 record (None: none); ``on_path`` the installers found."""
|
|
65
|
+
state = {"kind": ("uv", None), "latest": "v1.1.0", "tag_calls": [], "direct_url": None,
|
|
66
|
+
"on_path": {"uv", "pipx"}}
|
|
64
67
|
# Real Windows CI must not spawn a detached installer from the POSIX-path tests.
|
|
65
68
|
monkeypatch.setattr(uk, "_IS_WINDOWS", False)
|
|
66
69
|
|
|
@@ -72,6 +75,8 @@ def fake_env(monkeypatch):
|
|
|
72
75
|
|
|
73
76
|
monkeypatch.setattr(uk, "install_kind", lambda dist: state["kind"])
|
|
74
77
|
monkeypatch.setattr(uk, "latest_tag", latest_tag)
|
|
78
|
+
monkeypatch.setattr(uk.metadata, "distribution", lambda name: FakeDist(state["direct_url"]))
|
|
79
|
+
monkeypatch.setattr(uk.shutil, "which", lambda cmd: f"/bin/{cmd}" if cmd in state["on_path"] else None)
|
|
75
80
|
return state
|
|
76
81
|
|
|
77
82
|
|
|
@@ -225,15 +230,41 @@ def test_update_passes_url_through(fake_env):
|
|
|
225
230
|
assert run.calls[0][-1] == "git+file:///srv/demo@v1.1.0"
|
|
226
231
|
|
|
227
232
|
|
|
228
|
-
def
|
|
233
|
+
def test_update_already_on_the_latest_tag_says_so_with_the_version(fake_env):
|
|
229
234
|
run = FakeRun()
|
|
230
|
-
for current in ("1.1.0", "v1.1.0"
|
|
235
|
+
for current in ("1.1.0", "v1.1.0"):
|
|
231
236
|
code, out = do_update(run, current=current)
|
|
232
237
|
assert code == 0
|
|
233
|
-
assert "already
|
|
238
|
+
assert out.strip() == f"✅ demo {current} is already the latest version (v1.1.0)."
|
|
239
|
+
assert run.calls == []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_update_installed_from_the_latest_tag_is_up_to_date(fake_env):
|
|
243
|
+
fake_env["direct_url"] = {"url": "https://github.com/owner/demo.git",
|
|
244
|
+
"vcs_info": {"vcs": "git", "requested_revision": "v1.1.0"}}
|
|
245
|
+
run = FakeRun()
|
|
246
|
+
assert do_update(run, current="1.1.0")[0] == 0
|
|
234
247
|
assert run.calls == []
|
|
235
248
|
|
|
236
249
|
|
|
250
|
+
@pytest.mark.parametrize("current, vcs_info", [
|
|
251
|
+
("1.2.0", None),
|
|
252
|
+
("1.1.0", {"vcs": "git", "requested_revision": "main"}),
|
|
253
|
+
("1.1.0", {"vcs": "git", "requested_revision": "0123abc"}),
|
|
254
|
+
("1.1.0", {"vcs": "git", "commit_id": "0123abc"}), # `git+URL` with no ref: the default branch
|
|
255
|
+
])
|
|
256
|
+
def test_update_off_the_latest_tag_reinstalls_that_tag(fake_env, current, vcs_info):
|
|
257
|
+
"""A newer build, or one installed from a branch/commit, is replaced by the newest tag."""
|
|
258
|
+
if vcs_info:
|
|
259
|
+
fake_env["direct_url"] = {"url": "https://github.com/owner/demo.git", "vcs_info": vcs_info}
|
|
260
|
+
run = FakeRun()
|
|
261
|
+
code, out = do_update(run, current=current)
|
|
262
|
+
assert code == 0
|
|
263
|
+
assert run.calls == [["uv", "tool", "install", "--force", "--no-sources",
|
|
264
|
+
"git+https://github.com/owner/demo.git@v1.1.0"]]
|
|
265
|
+
assert "already" not in out
|
|
266
|
+
|
|
267
|
+
|
|
237
268
|
def test_update_couldnt_check_is_failure_not_up_to_date(fake_env):
|
|
238
269
|
fake_env["latest"] = uk.UpdateError("offline")
|
|
239
270
|
run = FakeRun()
|
|
@@ -273,17 +304,57 @@ def test_update_failing_after_step_warns_and_returns_1(fake_env):
|
|
|
273
304
|
assert run.calls[-1] == ["other", "step"]
|
|
274
305
|
|
|
275
306
|
|
|
276
|
-
|
|
307
|
+
@pytest.mark.parametrize("kind", [("dev", "/src/demo"), ("unknown", None)])
|
|
308
|
+
@pytest.mark.parametrize("current", ["1.0.0", "1.1.0", "9.0.0"])
|
|
309
|
+
def test_update_dev_or_unknown_install_is_replaced_by_the_latest_tag(fake_env, kind, current):
|
|
310
|
+
"""A local checkout or unmanaged install is never kept, whatever version it reports."""
|
|
311
|
+
fake_env["kind"] = kind
|
|
312
|
+
run = FakeRun()
|
|
313
|
+
code, out = do_update(run, current=current)
|
|
314
|
+
assert code == 0
|
|
315
|
+
assert run.calls == [["uv", "tool", "install", "--force", "--no-sources",
|
|
316
|
+
"git+https://github.com/owner/demo.git@v1.1.0"]]
|
|
317
|
+
assert "git pull" not in out and "manually" not in out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.parametrize("lang, note", [
|
|
321
|
+
("en", "ℹ️ Installed as a uv tool; the copy at /src/demo is unchanged."),
|
|
322
|
+
("zh-TW", "ℹ️ 已安裝為 uv 工具;/src/demo 的原本那份沒有變更。"),
|
|
323
|
+
])
|
|
324
|
+
def test_update_dev_install_says_the_local_copy_is_left_alone(fake_env, lang, note):
|
|
325
|
+
"""`--force` installs a separate tool copy; the checkout (or venv) the user ran stays as it was."""
|
|
277
326
|
fake_env["kind"] = ("dev", "/src/demo")
|
|
327
|
+
_, out = do_update(FakeRun(), lang=lang)
|
|
328
|
+
assert out.splitlines()[-1] == note
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_update_uv_install_has_no_local_copy_note(fake_env):
|
|
332
|
+
_, out = do_update(FakeRun())
|
|
333
|
+
assert "unchanged" not in out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_update_windows_dev_install_spawns_the_uv_install(fake_env, monkeypatch):
|
|
337
|
+
spawned = []
|
|
338
|
+
monkeypatch.setattr(uk, "_spawn_detached", lambda cmdline, log, env=None: spawned.append(cmdline))
|
|
339
|
+
monkeypatch.setattr(uk, "_IS_WINDOWS", True)
|
|
340
|
+
fake_env["kind"] = ("dev", "C:\\src\\demo")
|
|
341
|
+
code, out = do_update(FakeRun())
|
|
342
|
+
assert code == 0
|
|
343
|
+
assert "uv tool install --force --no-sources git+https://github.com/owner/demo.git@v1.1.0" in spawned[0]
|
|
344
|
+
assert out.splitlines()[-1] == "ℹ️ Installed as a uv tool; the copy at C:\\src\\demo is unchanged."
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_update_dev_install_falls_back_to_pipx(fake_env):
|
|
348
|
+
fake_env["kind"] = ("dev", "/src/demo")
|
|
349
|
+
fake_env["on_path"] = {"pipx"}
|
|
278
350
|
run = FakeRun()
|
|
279
|
-
|
|
280
|
-
assert
|
|
281
|
-
assert "/src/demo" in out and "git pull" in out
|
|
282
|
-
assert fake_env["tag_calls"] == [] and run.calls == []
|
|
351
|
+
assert do_update(run)[0] == 0
|
|
352
|
+
assert run.calls == [["pipx", "install", "--force", "git+https://github.com/owner/demo.git@v1.1.0"]]
|
|
283
353
|
|
|
284
354
|
|
|
285
|
-
def
|
|
355
|
+
def test_update_unknown_kind_without_an_installer_prints_manual_command(fake_env):
|
|
286
356
|
fake_env["kind"] = ("unknown", None)
|
|
357
|
+
fake_env["on_path"] = set()
|
|
287
358
|
run = FakeRun()
|
|
288
359
|
code, out = do_update(run)
|
|
289
360
|
assert code == 2
|
|
@@ -291,17 +362,29 @@ def test_update_unknown_kind_prints_manual_command(fake_env):
|
|
|
291
362
|
assert run.calls == []
|
|
292
363
|
|
|
293
364
|
|
|
365
|
+
def test_update_announces_the_command_and_celebrates(fake_env):
|
|
366
|
+
code, out = do_update(FakeRun())
|
|
367
|
+
assert code == 0
|
|
368
|
+
assert out.splitlines() == [
|
|
369
|
+
(
|
|
370
|
+
"⬆️ Updating demo 1.0.0 → v1.1.0 via `uv tool install --force --no-sources "
|
|
371
|
+
"git+https://github.com/owner/demo.git@v1.1.0` ..."
|
|
372
|
+
),
|
|
373
|
+
"🎉 demo updated to v1.1.0!",
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
|
|
294
377
|
def test_update_current_defaults_to_installed_version(fake_env, monkeypatch):
|
|
295
378
|
monkeypatch.setattr(uk.metadata, "version", lambda dist: "1.1.0")
|
|
296
379
|
code, out = do_update(FakeRun(), current=None)
|
|
297
|
-
assert code == 0 and "already
|
|
380
|
+
assert code == 0 and "1.1.0 is already the latest version" in out
|
|
298
381
|
|
|
299
382
|
|
|
300
383
|
def test_update_zh_tw_messages(fake_env):
|
|
301
384
|
fake_env["latest"] = "v1.0.0"
|
|
302
385
|
code, out = do_update(FakeRun(), lang="zh_TW")
|
|
303
386
|
assert code == 0
|
|
304
|
-
assert "
|
|
387
|
+
assert out.strip() == "✅ demo 1.0.0 已是最新版本(v1.0.0)。"
|
|
305
388
|
|
|
306
389
|
|
|
307
390
|
def test_update_windows_spawns_detached_chain(fake_env, monkeypatch):
|
|
@@ -453,7 +536,8 @@ def test_should_hint():
|
|
|
453
536
|
|
|
454
537
|
|
|
455
538
|
def test_public_api():
|
|
456
|
-
|
|
539
|
+
# A strict X.Y.Z, not a pinned value: the release script bumps __version__ before CI runs this.
|
|
540
|
+
assert uk._parse(uk.__version__) is not None and not uk.__version__.startswith("v")
|
|
457
541
|
for name in ("UpdateError", "latest_tag", "install_kind", "update", "check", "hint",
|
|
458
542
|
"should_hint", "normalize_lang"):
|
|
459
543
|
assert callable(getattr(uk, name))
|
|
@@ -468,7 +552,7 @@ def test_parse_rejects_non_ascii_digits():
|
|
|
468
552
|
|
|
469
553
|
def test_update_windows_refuses_cmd_metacharacters(fake_env, monkeypatch):
|
|
470
554
|
spawned = []
|
|
471
|
-
monkeypatch.setattr(uk, "_spawn_detached", lambda cmdline, log: spawned.append(cmdline))
|
|
555
|
+
monkeypatch.setattr(uk, "_spawn_detached", lambda cmdline, log, env=None: spawned.append(cmdline))
|
|
472
556
|
monkeypatch.setattr(uk, "_IS_WINDOWS", True)
|
|
473
557
|
code, out = do_update(FakeRun(), url="https://example.invalid/r.git?a=1&calc")
|
|
474
558
|
assert code == 1 and spawned == []
|
|
@@ -477,6 +561,7 @@ def test_update_windows_refuses_cmd_metacharacters(fake_env, monkeypatch):
|
|
|
477
561
|
|
|
478
562
|
def test_update_manual_command_is_shell_quoted_on_posix(fake_env):
|
|
479
563
|
fake_env["kind"] = ("unknown", None)
|
|
564
|
+
fake_env["on_path"] = set()
|
|
480
565
|
_, out = do_update(FakeRun(), url="https://example.invalid/r.git?a=1&b=2")
|
|
481
566
|
assert "'git+https://example.invalid/r.git?a=1&b=2@v1.1.0'" in out
|
|
482
567
|
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
pull_request:
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
contents: read
|
|
9
|
-
|
|
10
|
-
concurrency:
|
|
11
|
-
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
12
|
-
cancel-in-progress: true
|
|
13
|
-
|
|
14
|
-
env:
|
|
15
|
-
PYTHONUTF8: "1"
|
|
16
|
-
|
|
17
|
-
jobs:
|
|
18
|
-
test:
|
|
19
|
-
name: Python 3.10 / ${{ matrix.os }}
|
|
20
|
-
timeout-minutes: 15
|
|
21
|
-
strategy:
|
|
22
|
-
fail-fast: false
|
|
23
|
-
matrix:
|
|
24
|
-
os: [macos-latest, ubuntu-latest, windows-latest]
|
|
25
|
-
runs-on: ${{ matrix.os }}
|
|
26
|
-
steps:
|
|
27
|
-
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
|
28
|
-
with:
|
|
29
|
-
persist-credentials: false
|
|
30
|
-
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
|
31
|
-
with:
|
|
32
|
-
python-version: "3.10"
|
|
33
|
-
enable-cache: true
|
|
34
|
-
cache-dependency-glob: uv.lock
|
|
35
|
-
- name: Install locked dependencies
|
|
36
|
-
run: uv sync --locked --all-groups
|
|
37
|
-
- name: Lint
|
|
38
|
-
run: uv run ruff check .
|
|
39
|
-
- name: Test
|
|
40
|
-
run: uv run pytest -v
|
|
41
|
-
- name: Build distributions
|
|
42
|
-
run: uv build
|
|
43
|
-
- name: Validate wheel is importable standalone
|
|
44
|
-
shell: python
|
|
45
|
-
run: |
|
|
46
|
-
import os
|
|
47
|
-
import pathlib
|
|
48
|
-
import subprocess
|
|
49
|
-
import tempfile
|
|
50
|
-
|
|
51
|
-
wheel, = pathlib.Path("dist").glob("*.whl")
|
|
52
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
53
|
-
environment = pathlib.Path(directory)
|
|
54
|
-
subprocess.run(["uv", "venv", environment], check=True)
|
|
55
|
-
scripts = environment / ("Scripts" if os.name == "nt" else "bin")
|
|
56
|
-
python = scripts / ("python.exe" if os.name == "nt" else "python")
|
|
57
|
-
subprocess.run(
|
|
58
|
-
["uv", "pip", "install", "--python", python, "--no-index", wheel],
|
|
59
|
-
check=True,
|
|
60
|
-
)
|
|
61
|
-
subprocess.run([python, "-I", "-c", "import update_kit"], check=True)
|
|
62
|
-
print("update_kit imports standalone OK")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|