beakr-cli 0.3.0__tar.gz → 0.3.2__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.
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/PKG-INFO +1 -1
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/pyproject.toml +1 -1
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/__init__.py +1 -1
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/main.py +2 -2
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/mcp_server.py +2 -2
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/updates.py +97 -18
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_updates.py +102 -12
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/uv.lock +1 -1
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-audit.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-research.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-search.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/commands/kb-write.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/SKILL.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/examples/answer-org-question.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/examples/research-topic.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/examples/write-decision-page.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/auditing.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/provenance.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/research-workflow.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.claude/skills/beakr/reference/writing-pages.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.github/workflows/publish.yml +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/.gitignore +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/LICENSE +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/README.md +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/install.sh +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/citations.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/client.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/__init__.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/auth.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/install.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/kb.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/commands/workspace.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/config.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/src/beakr_cli/output.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/__init__.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_auth_cli.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_bundled_assets_vocabulary.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_citations.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_client.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_mcp_shared_vocabulary.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_mcp_source_rendering.py +0 -0
- {beakr_cli-0.3.0 → beakr_cli-0.3.2}/tests/test_packaging.py +0 -0
|
@@ -62,7 +62,7 @@ def version(
|
|
|
62
62
|
return
|
|
63
63
|
status = get_update_status(max_age=timedelta(hours=1))
|
|
64
64
|
if status.latest is None:
|
|
65
|
-
print("Latest release: unknown (could not
|
|
65
|
+
print("Latest release: unknown (PyPI could not be checked)")
|
|
66
66
|
elif status.update_available:
|
|
67
67
|
print(f"Update available: {status.latest}. {detect_install().instructions}")
|
|
68
68
|
else:
|
|
@@ -114,7 +114,7 @@ def update(
|
|
|
114
114
|
raise typer.Exit(1)
|
|
115
115
|
|
|
116
116
|
console.print(f"[dim]$ {' '.join(info.upgrade_command)}[/dim]")
|
|
117
|
-
result = run_upgrade(info)
|
|
117
|
+
result = run_upgrade(info, expected_version=status.latest)
|
|
118
118
|
if result.output:
|
|
119
119
|
console.print(f"[dim]{result.output}[/dim]", highlight=False)
|
|
120
120
|
if not result.ok:
|
|
@@ -831,7 +831,7 @@ async def beakr_version() -> str:
|
|
|
831
831
|
info = detect_install()
|
|
832
832
|
lines = [f"Installed: beakr-cli {status.current} ({info.method.value})"]
|
|
833
833
|
if status.latest is None:
|
|
834
|
-
lines.append("Latest release: unknown (could not
|
|
834
|
+
lines.append("Latest release: unknown (PyPI could not be checked).")
|
|
835
835
|
elif status.update_available:
|
|
836
836
|
lines.append(f"Update available: {status.latest}.")
|
|
837
837
|
if info.upgrade_command is not None:
|
|
@@ -865,7 +865,7 @@ async def update_beakr() -> str:
|
|
|
865
865
|
f"No update needed: installed {status.current}, latest release {status.latest}."
|
|
866
866
|
)
|
|
867
867
|
info = detect_install()
|
|
868
|
-
result = await asyncio.to_thread(run_upgrade, info)
|
|
868
|
+
result = await asyncio.to_thread(run_upgrade, info, expected_version=status.latest)
|
|
869
869
|
if not result.ok:
|
|
870
870
|
tail = f"\n\n{result.output[-2000:]}" if result.output else ""
|
|
871
871
|
return f"Update did not run: {result.message}{tail}"
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
|
+
import re
|
|
18
19
|
import shutil
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
@@ -104,20 +105,35 @@ def get_update_status(
|
|
|
104
105
|
allow_network: bool = True,
|
|
105
106
|
timeout: float = 2.0,
|
|
106
107
|
) -> UpdateStatus:
|
|
107
|
-
"""Current vs latest version, from cache when fresh enough, else from PyPI.
|
|
108
|
+
"""Current vs latest version, from cache when fresh enough, else from PyPI.
|
|
109
|
+
|
|
110
|
+
A "latest" older than the running version is never reported or cached. PyPI's
|
|
111
|
+
CDN serves the project JSON with ``max-age=900``, so for up to 15 minutes after
|
|
112
|
+
a release it can still list the previous one; caching that answer used to make
|
|
113
|
+
a freshly upgraded CLI report the version it had just replaced as the latest.
|
|
114
|
+
"""
|
|
108
115
|
cached_latest, checked_at = _read_cache()
|
|
116
|
+
if cached_latest is not None and _older_than_running(cached_latest):
|
|
117
|
+
cached_latest, checked_at = None, None
|
|
109
118
|
now = datetime.now(timezone.utc)
|
|
110
119
|
fresh = checked_at is not None and now - checked_at <= max_age
|
|
111
120
|
if fresh or not allow_network or check_disabled():
|
|
112
121
|
return UpdateStatus(__version__, cached_latest, checked_at)
|
|
113
122
|
latest = fetch_latest_version(timeout)
|
|
114
|
-
if latest is None:
|
|
115
|
-
# Offline
|
|
123
|
+
if latest is None or _older_than_running(latest):
|
|
124
|
+
# Offline, PyPI unavailable, or a stale CDN listing: keep what we last knew.
|
|
116
125
|
return UpdateStatus(__version__, cached_latest, checked_at)
|
|
117
126
|
_write_cache(latest, now)
|
|
118
127
|
return UpdateStatus(__version__, latest, now)
|
|
119
128
|
|
|
120
129
|
|
|
130
|
+
def _older_than_running(version: str) -> bool:
|
|
131
|
+
try:
|
|
132
|
+
return Version(version) < Version(__version__)
|
|
133
|
+
except InvalidVersion:
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
121
137
|
# ---------------------------------------------------------------------------
|
|
122
138
|
# Install method detection
|
|
123
139
|
# ---------------------------------------------------------------------------
|
|
@@ -126,6 +142,7 @@ def get_update_status(
|
|
|
126
142
|
class InstallMethod(str, Enum):
|
|
127
143
|
uv_tool = "uv tool"
|
|
128
144
|
uv_tool_source = "uv tool (local source)"
|
|
145
|
+
uv_tool_pinned = "uv tool (pinned version)"
|
|
129
146
|
pipx = "pipx"
|
|
130
147
|
uvx = "uvx"
|
|
131
148
|
other = "other"
|
|
@@ -148,17 +165,40 @@ def _uv_tool_dir() -> Path:
|
|
|
148
165
|
return base / "uv" / "tools"
|
|
149
166
|
|
|
150
167
|
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
#: The ``{ name = "beakr-cli", ... }`` inline table in a uv tool receipt's
|
|
169
|
+
#: ``requirements``. Parsed from the requirement entry alone: the receipt also
|
|
170
|
+
#: carries ``entrypoints`` with ``install-path = ...``, and matching keys against
|
|
171
|
+
#: the whole file made every uv tool install look like a local checkout.
|
|
172
|
+
_RECEIPT_REQUIREMENT = re.compile(r'\{\s*name\s*=\s*"' + re.escape(PACKAGE_NAME) + r'"([^}]*)\}')
|
|
173
|
+
_SOURCE_KEYS = re.compile(r"(?<![\w-])(directory|path|git|url|editable)\s*=")
|
|
174
|
+
_PINNED_SPECIFIER = re.compile(r'(?<![\w-])specifier\s*=\s*"\s*==')
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _receipt_requirement(receipt: Path) -> str | None:
|
|
178
|
+
"""The body of this package's requirement entry in a uv receipt, if readable."""
|
|
179
|
+
try:
|
|
180
|
+
match = _RECEIPT_REQUIREMENT.search(receipt.read_text())
|
|
181
|
+
except OSError:
|
|
182
|
+
return None
|
|
183
|
+
return match.group(1) if match else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _receipt_is_local_source(requirement: str) -> bool:
|
|
187
|
+
"""True when the uv tool was installed from a directory, path, URL, or git.
|
|
153
188
|
|
|
154
189
|
Upgrading such an install from PyPI would silently swap a developer's local
|
|
155
190
|
build for the release, so it is reported rather than done.
|
|
156
191
|
"""
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
192
|
+
return _SOURCE_KEYS.search(requirement) is not None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _receipt_pins_version(requirement: str) -> bool:
|
|
196
|
+
"""True when the uv tool was installed as ``beakr-cli==X``.
|
|
197
|
+
|
|
198
|
+
``uv tool upgrade`` honours the pin and does nothing, so offering it would
|
|
199
|
+
report an upgrade that never happens.
|
|
200
|
+
"""
|
|
201
|
+
return _PINNED_SPECIFIER.search(requirement) is not None
|
|
162
202
|
|
|
163
203
|
|
|
164
204
|
def detect_install(prefix: Path | None = None) -> InstallInfo:
|
|
@@ -171,8 +211,8 @@ def detect_install(prefix: Path | None = None) -> InstallInfo:
|
|
|
171
211
|
except OSError:
|
|
172
212
|
in_uv_tools = False
|
|
173
213
|
if in_uv_tools or ("uv" in parts and "tools" in parts):
|
|
174
|
-
|
|
175
|
-
if _receipt_is_local_source(
|
|
214
|
+
requirement = _receipt_requirement(env / "uv-receipt.toml") or ""
|
|
215
|
+
if _receipt_is_local_source(requirement):
|
|
176
216
|
return InstallInfo(
|
|
177
217
|
InstallMethod.uv_tool_source,
|
|
178
218
|
None,
|
|
@@ -180,16 +220,28 @@ def detect_install(prefix: Path | None = None) -> InstallInfo:
|
|
|
180
220
|
"`uv tool install --force <checkout>`, or switch to the published "
|
|
181
221
|
f"release with `uv tool install --force {PACKAGE_NAME}`.",
|
|
182
222
|
)
|
|
223
|
+
if _receipt_pins_version(requirement):
|
|
224
|
+
return InstallInfo(
|
|
225
|
+
InstallMethod.uv_tool_pinned,
|
|
226
|
+
None,
|
|
227
|
+
"Installed with uv pinned to one version, which `uv tool upgrade` keeps. "
|
|
228
|
+
f"Unpin it with `uv tool install --force {PACKAGE_NAME}`.",
|
|
229
|
+
)
|
|
230
|
+
# --reinstall-package implies --refresh-package (which `uv tool upgrade` does not
|
|
231
|
+
# accept directly). uv caches the package index, and without a refresh an
|
|
232
|
+
# upgrade run shortly after a release resolves the old version and exits 0.
|
|
183
233
|
return InstallInfo(
|
|
184
234
|
InstallMethod.uv_tool,
|
|
185
|
-
["uv", "tool", "upgrade", PACKAGE_NAME],
|
|
235
|
+
["uv", "tool", "upgrade", "--reinstall-package", PACKAGE_NAME, PACKAGE_NAME],
|
|
186
236
|
f"Run `beakr update` (or `uv tool upgrade {PACKAGE_NAME}`).",
|
|
187
237
|
)
|
|
188
238
|
|
|
189
|
-
|
|
239
|
+
pipx_home = os.environ.get("PIPX_HOME")
|
|
240
|
+
in_custom_pipx = bool(pipx_home) and env.parent == (Path(pipx_home) / "venvs").resolve()
|
|
241
|
+
if in_custom_pipx or ("pipx" in parts and "venvs" in parts):
|
|
190
242
|
return InstallInfo(
|
|
191
243
|
InstallMethod.pipx,
|
|
192
|
-
["pipx", "upgrade", PACKAGE_NAME],
|
|
244
|
+
["pipx", "upgrade", "--pip-args=--no-cache-dir", PACKAGE_NAME],
|
|
193
245
|
f"Run `beakr update` (or `pipx upgrade {PACKAGE_NAME}`).",
|
|
194
246
|
)
|
|
195
247
|
|
|
@@ -227,11 +279,16 @@ def _beakr_executable() -> str | None:
|
|
|
227
279
|
return shutil.which("beakr")
|
|
228
280
|
|
|
229
281
|
|
|
230
|
-
def run_upgrade(
|
|
231
|
-
|
|
282
|
+
def run_upgrade(
|
|
283
|
+
info: InstallInfo, *, expected_version: str | None = None, timeout: float = 300.0
|
|
284
|
+
) -> UpgradeResult:
|
|
285
|
+
"""Upgrade the package, confirm it moved, then refresh the skills it installed.
|
|
232
286
|
|
|
233
287
|
The skill refresh runs through the NEW binary on PATH, not this process: the
|
|
234
288
|
running interpreter still has the old code and the old bundled assets loaded.
|
|
289
|
+
A package manager exiting 0 is not proof of an upgrade (a cached index or a
|
|
290
|
+
pin both "succeed" without changing anything), so the binary's own version is
|
|
291
|
+
checked against ``expected_version`` before anything is reported as upgraded.
|
|
235
292
|
"""
|
|
236
293
|
if info.upgrade_command is None:
|
|
237
294
|
return UpgradeResult(False, info.instructions, "")
|
|
@@ -255,6 +312,17 @@ def run_upgrade(info: InstallInfo, *, timeout: float = 300.0) -> UpgradeResult:
|
|
|
255
312
|
False, f"`{' '.join(info.upgrade_command)}` failed (exit {upgrade.returncode}).", output
|
|
256
313
|
)
|
|
257
314
|
|
|
315
|
+
if expected_version is not None:
|
|
316
|
+
now = installed_version_on_path()
|
|
317
|
+
if now is None or _version_lt(now, expected_version):
|
|
318
|
+
return UpgradeResult(
|
|
319
|
+
False,
|
|
320
|
+
f"The upgrade ran but `beakr` on PATH is still {now or 'unknown'}, not "
|
|
321
|
+
f"{expected_version}. A new release can take a few minutes to reach PyPI's "
|
|
322
|
+
"mirrors; try again shortly.",
|
|
323
|
+
output,
|
|
324
|
+
)
|
|
325
|
+
|
|
258
326
|
beakr = _beakr_executable()
|
|
259
327
|
if beakr is None:
|
|
260
328
|
return UpgradeResult(
|
|
@@ -294,7 +362,11 @@ def installed_version_on_path() -> str | None:
|
|
|
294
362
|
return None
|
|
295
363
|
try:
|
|
296
364
|
result = subprocess.run(
|
|
297
|
-
[beakr, "version"
|
|
365
|
+
[beakr, "version", "--offline"],
|
|
366
|
+
capture_output=True,
|
|
367
|
+
text=True,
|
|
368
|
+
timeout=30,
|
|
369
|
+
check=False,
|
|
298
370
|
)
|
|
299
371
|
except subprocess.TimeoutExpired:
|
|
300
372
|
return None
|
|
@@ -303,3 +375,10 @@ def installed_version_on_path() -> str | None:
|
|
|
303
375
|
return None
|
|
304
376
|
# First line is "beakr-cli X.Y.Z"; later lines may describe update status.
|
|
305
377
|
return text[0].removeprefix(f"{PACKAGE_NAME} ").strip() or None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _version_lt(left: str, right: str) -> bool:
|
|
381
|
+
try:
|
|
382
|
+
return Version(left) < Version(right)
|
|
383
|
+
except InvalidVersion:
|
|
384
|
+
return True
|
|
@@ -102,6 +102,33 @@ def test_disabled_check_never_touches_network(monkeypatch) -> None:
|
|
|
102
102
|
assert route.call_count == 0
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
@respx.mock
|
|
106
|
+
def test_cached_latest_older_than_running_version_is_refetched() -> None:
|
|
107
|
+
"""Right after upgrading, a cache written by the old version must not be shown."""
|
|
108
|
+
route = respx.get(updates.PYPI_JSON_URL).mock(
|
|
109
|
+
return_value=httpx.Response(200, json=_pypi("0.2.1", "0.3.0"))
|
|
110
|
+
)
|
|
111
|
+
_write_cache("0.2.1", age=timedelta(minutes=5))
|
|
112
|
+
status = updates.get_update_status()
|
|
113
|
+
assert status.latest == "0.3.0"
|
|
114
|
+
assert route.call_count == 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@respx.mock
|
|
118
|
+
def test_stale_cdn_listing_older_than_running_version_is_not_cached() -> None:
|
|
119
|
+
"""PyPI's CDN can list the previous release for ~15 minutes after publishing.
|
|
120
|
+
|
|
121
|
+
That answer was cached for a day and shown as "latest release: 0.2.1" on 0.3.0.
|
|
122
|
+
"""
|
|
123
|
+
respx.get(updates.PYPI_JSON_URL).mock(
|
|
124
|
+
return_value=httpx.Response(200, json=_pypi("0.2.0", "0.2.1"))
|
|
125
|
+
)
|
|
126
|
+
status = updates.get_update_status()
|
|
127
|
+
assert status.latest is None
|
|
128
|
+
assert not status.update_available
|
|
129
|
+
assert not (config.CONFIG_DIR / "update_check.json").exists()
|
|
130
|
+
|
|
131
|
+
|
|
105
132
|
def test_running_ahead_of_pypi_is_not_an_update() -> None:
|
|
106
133
|
assert not updates.UpdateStatus("0.3.0", "0.2.1", None).update_available
|
|
107
134
|
|
|
@@ -110,28 +137,58 @@ def test_running_ahead_of_pypi_is_not_an_update() -> None:
|
|
|
110
137
|
# Install detection
|
|
111
138
|
# ---------------------------------------------------------------------------
|
|
112
139
|
|
|
140
|
+
#: Receipts exactly as uv 0.11 writes them. The entrypoints block matters: its
|
|
141
|
+
#: ``install-path = ...`` made every PyPI install look like a local checkout when
|
|
142
|
+
#: detection searched the whole file for ``path =``, so `beakr update` refused to
|
|
143
|
+
#: upgrade any uv tool install in 0.3.0 and 0.3.1.
|
|
144
|
+
_ENTRYPOINTS = """entrypoints = [
|
|
145
|
+
{ name = "beakr", install-path = "/Users/u/.local/bin/beakr", from = "beakr-cli" },
|
|
146
|
+
]
|
|
147
|
+
"""
|
|
148
|
+
PYPI_RECEIPT = '[tool]\nrequirements = [{ name = "beakr-cli" }]\n' + _ENTRYPOINTS
|
|
149
|
+
PINNED_RECEIPT = (
|
|
150
|
+
'[tool]\nrequirements = [{ name = "beakr-cli", specifier = "==0.3.0" }]\n' + _ENTRYPOINTS
|
|
151
|
+
)
|
|
152
|
+
RANGE_RECEIPT = (
|
|
153
|
+
'[tool]\nrequirements = [{ name = "beakr-cli", specifier = ">=0.3" }]\n' + _ENTRYPOINTS
|
|
154
|
+
)
|
|
155
|
+
SOURCE_RECEIPT = (
|
|
156
|
+
'[tool]\nrequirements = [{ name = "beakr-cli", directory = "/src/beakr-cli" }]\n'
|
|
157
|
+
'constraints = [{ name = "mcp", specifier = "<2" }]\n' + _ENTRYPOINTS
|
|
158
|
+
)
|
|
159
|
+
|
|
113
160
|
|
|
114
|
-
def
|
|
161
|
+
def _uv_tool_env(tmp_path, monkeypatch, receipt: str) -> Path:
|
|
115
162
|
tools = tmp_path / "uv" / "tools"
|
|
116
163
|
env = tools / "beakr-cli"
|
|
117
164
|
env.mkdir(parents=True)
|
|
118
|
-
(env / "uv-receipt.toml").write_text(
|
|
165
|
+
(env / "uv-receipt.toml").write_text(receipt)
|
|
119
166
|
monkeypatch.setenv("UV_TOOL_DIR", str(tools))
|
|
120
|
-
|
|
167
|
+
return env
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize("receipt", [PYPI_RECEIPT, RANGE_RECEIPT])
|
|
171
|
+
def test_detects_pypi_uv_tool_install(receipt, tmp_path, monkeypatch) -> None:
|
|
172
|
+
info = updates.detect_install(_uv_tool_env(tmp_path, monkeypatch, receipt))
|
|
121
173
|
assert info.method is updates.InstallMethod.uv_tool
|
|
122
|
-
|
|
174
|
+
# --reinstall-package implies a refresh of the cached index; without one an
|
|
175
|
+
# upgrade shortly after a release resolved the old version and still exited 0.
|
|
176
|
+
assert info.upgrade_command == [
|
|
177
|
+
"uv", "tool", "upgrade", "--reinstall-package", "beakr-cli", "beakr-cli",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_version_pinned_uv_tool_is_reported_not_upgraded(tmp_path, monkeypatch) -> None:
|
|
182
|
+
"""`uv tool upgrade` keeps a `==X` pin, so running it would upgrade nothing."""
|
|
183
|
+
info = updates.detect_install(_uv_tool_env(tmp_path, monkeypatch, PINNED_RECEIPT))
|
|
184
|
+
assert info.method is updates.InstallMethod.uv_tool_pinned
|
|
185
|
+
assert info.upgrade_command is None
|
|
186
|
+
assert "uv tool install --force beakr-cli" in info.instructions
|
|
123
187
|
|
|
124
188
|
|
|
125
189
|
def test_local_checkout_uv_tool_is_never_upgraded_from_pypi(tmp_path, monkeypatch) -> None:
|
|
126
190
|
"""A developer's local build must not be silently replaced by the release."""
|
|
127
|
-
|
|
128
|
-
env = tools / "beakr-cli"
|
|
129
|
-
env.mkdir(parents=True)
|
|
130
|
-
(env / "uv-receipt.toml").write_text(
|
|
131
|
-
'[tool]\nrequirements = [{ name = "beakr-cli", directory = "/src/beakr-cli" }]\n'
|
|
132
|
-
)
|
|
133
|
-
monkeypatch.setenv("UV_TOOL_DIR", str(tools))
|
|
134
|
-
info = updates.detect_install(env)
|
|
191
|
+
info = updates.detect_install(_uv_tool_env(tmp_path, monkeypatch, SOURCE_RECEIPT))
|
|
135
192
|
assert info.method is updates.InstallMethod.uv_tool_source
|
|
136
193
|
assert info.upgrade_command is None
|
|
137
194
|
|
|
@@ -151,6 +208,16 @@ def test_detects_other_install_methods(prefix, method, can_upgrade, monkeypatch,
|
|
|
151
208
|
assert (info.upgrade_command is not None) is can_upgrade
|
|
152
209
|
|
|
153
210
|
|
|
211
|
+
def test_detects_pipx_with_a_custom_pipx_home(tmp_path, monkeypatch) -> None:
|
|
212
|
+
monkeypatch.setenv("UV_TOOL_DIR", str(tmp_path / "no-tools-here"))
|
|
213
|
+
monkeypatch.setenv("PIPX_HOME", str(tmp_path / "custom-home"))
|
|
214
|
+
env = tmp_path / "custom-home" / "venvs" / "beakr-cli"
|
|
215
|
+
env.mkdir(parents=True)
|
|
216
|
+
info = updates.detect_install(env)
|
|
217
|
+
assert info.method is updates.InstallMethod.pipx
|
|
218
|
+
assert info.upgrade_command == ["pipx", "upgrade", "--pip-args=--no-cache-dir", "beakr-cli"]
|
|
219
|
+
|
|
220
|
+
|
|
154
221
|
def test_upgrade_without_a_safe_command_only_returns_instructions() -> None:
|
|
155
222
|
info = updates.InstallInfo(updates.InstallMethod.uvx, None, "use uvx --refresh")
|
|
156
223
|
result = updates.run_upgrade(info)
|
|
@@ -179,6 +246,29 @@ def test_upgrade_refreshes_skills_through_the_new_binary(monkeypatch) -> None:
|
|
|
179
246
|
assert calls == [["uv", "tool", "upgrade", "x"], ["/bin/beakr", "install", "--refresh"]]
|
|
180
247
|
|
|
181
248
|
|
|
249
|
+
def test_upgrade_that_did_not_move_the_version_is_a_failure(monkeypatch) -> None:
|
|
250
|
+
"""A package manager exiting 0 is not an upgrade; skills must not be refreshed."""
|
|
251
|
+
calls: list[list[str]] = []
|
|
252
|
+
|
|
253
|
+
class Done:
|
|
254
|
+
returncode = 0
|
|
255
|
+
stdout = ""
|
|
256
|
+
stderr = ""
|
|
257
|
+
|
|
258
|
+
def fake_run(cmd, **_kwargs):
|
|
259
|
+
calls.append(cmd)
|
|
260
|
+
return Done()
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(updates.shutil, "which", lambda name: f"/bin/{name}")
|
|
263
|
+
monkeypatch.setattr(updates.subprocess, "run", fake_run)
|
|
264
|
+
monkeypatch.setattr(updates, "installed_version_on_path", lambda: "0.3.0")
|
|
265
|
+
info = updates.InstallInfo(updates.InstallMethod.uv_tool, ["uv", "tool", "upgrade", "x"], "")
|
|
266
|
+
result = updates.run_upgrade(info, expected_version="0.3.1")
|
|
267
|
+
assert not result.ok
|
|
268
|
+
assert "still 0.3.0" in result.message
|
|
269
|
+
assert calls == [["uv", "tool", "upgrade", "x"]]
|
|
270
|
+
|
|
271
|
+
|
|
182
272
|
# ---------------------------------------------------------------------------
|
|
183
273
|
# Skill refresh
|
|
184
274
|
# ---------------------------------------------------------------------------
|
|
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
|
|
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
|