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.
@@ -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/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
24
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
25
25
  with:
26
26
  persist-credentials: false
27
- - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
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.0
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 up to date · **1** couldn't check (offline/rate limit) or install/after-step failed · **2** dev install (use `git pull`) or unknown install (manual command printed). |
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 up to date · **1** couldn't check (offline/rate limit) or install/after-step failed · **2** dev install (use `git pull`) or unknown install (manual command printed). |
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.0"
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
- "dev": "{app}: dev install from {dir}; use git pull to update",
56
- "check_failed": "{app}: couldn't check for updates ({error})",
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} -> {latest} ...",
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
- "dev": "{app}:開發版安裝({dir}),請用 git pull 更新",
69
- "check_failed": "{app}:無法檢查更新({error})",
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} -> {latest} ...",
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 dev/unknown install."""
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
- have = _parse(current)
219
- if have and have >= _parse(latest):
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
- state = {"kind": ("uv", None), "latest": "v1.1.0", "tag_calls": []}
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 test_update_already_up_to_date(fake_env):
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", "1.2.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 up to date" in out
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
- def test_update_dev_install_returns_2_without_network(fake_env):
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
- code, out = do_update(run)
280
- assert code == 2
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 test_update_unknown_kind_prints_manual_command(fake_env):
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 up to date" in out
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 "已是最新版本" in out
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
- assert uk.__version__ == "0.1.0"
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