agent-toggle 0.16.1__tar.gz → 0.16.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.
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/PKG-INFO +6 -6
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/README.md +5 -5
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/__init__.py +1 -1
- agent_toggle-0.16.2/agent_toggle/update_check.py +209 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/update_prompt.py +5 -5
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/PKG-INFO +6 -6
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_cli.py +7 -7
- agent_toggle-0.16.2/tests/test_update_check.py +240 -0
- agent_toggle-0.16.1/agent_toggle/update_check.py +0 -361
- agent_toggle-0.16.1/tests/test_update_check.py +0 -423
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/LICENSE +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/__main__.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/__init__.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/flag_json.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/mcp_json.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/mcp_toml.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/backends/plugin_cli.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/cli.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/companions.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/completions.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/config.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/cost.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/doctor.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/fs.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/harnesses.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/help.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/i18n.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/__init__.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_cli.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_common.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_config.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/locales/zh_tw_picker.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/mechanisms.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ops.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/output.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/profiles.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/settings.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/shims/claude.md.tmpl +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/shims/generic.md.tmpl +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/spinner.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/store.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/toml_check.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/__init__.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/config_menu.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/logo.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/menu.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/model.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/picker.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/ui/theme.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle/undo.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/SOURCES.txt +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/dependency_links.txt +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/entry_points.txt +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/requires.txt +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/agent_toggle.egg-info/top_level.txt +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/pyproject.toml +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/setup.cfg +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_alias.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_all_type.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_backends.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_cli_surface.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_color.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_companions.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_completions.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_config.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_config_menu.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_conformance.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_containment.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_cost.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_crash.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_doctor.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_encoding.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_fs.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_harnesses.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_help.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_i18n.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_install.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_logo.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_mechanisms.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_menu.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_mods.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_picker.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_platform.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_profiles.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_project.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_safety.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_same_name.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_secret_audit.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_settings.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_store.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_theme.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_undo.py +0 -0
- {agent_toggle-0.16.1 → agent_toggle-0.16.2}/tests/test_update_prompt.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-toggle
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.2
|
|
4
4
|
Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/weskao/agent-toggle
|
|
@@ -498,7 +498,7 @@ invalid value in either is ignored with one warning on stderr.
|
|
|
498
498
|
|
|
499
499
|
## Update check
|
|
500
500
|
|
|
501
|
-
On every invocation (unless disabled) agent-toggle checks
|
|
501
|
+
On every invocation (unless disabled) agent-toggle checks the repository's release tags for a newer release. It starts
|
|
502
502
|
before the command and is offered after it, on every exit path; it overlaps the command and waits at most 0.8 s afterwards.
|
|
503
503
|
`update` and `completion` skip it.
|
|
504
504
|
|
|
@@ -513,9 +513,9 @@ before the command and is offered after it, on every exit path; it overlaps the
|
|
|
513
513
|
then ` agent-toggle update`.
|
|
514
514
|
- It never changes the exit code or stdout, so `--json` output stays one document.
|
|
515
515
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
516
|
+
The lookup is update-kit's: one `git ls-remote --tags --refs` of the repository on a background
|
|
517
|
+
thread, cached for 10 minutes in `~/.update-kit/state.json`. Only the version you chose to skip is
|
|
518
|
+
kept in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a normal git request. See `SECURITY.md`
|
|
519
519
|
for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
|
|
520
520
|
to off (the settings menu's "Check for updates").
|
|
521
521
|
|
|
@@ -931,7 +931,7 @@ bookkeeping.
|
|
|
931
931
|
| `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
|
|
932
932
|
| `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
|
|
933
933
|
| `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
|
|
934
|
-
| `update-check.json` | the [update check](#update-check)'s
|
|
934
|
+
| `update-check.json` | the [update check](#update-check)'s skipped version (mode `0600`, atomic write) |
|
|
935
935
|
|
|
936
936
|
A killed run loses at most the one item in flight. If that was a flag write or a
|
|
937
937
|
dir move (with its companions), its `pending` record (the full entry, a flag's
|
|
@@ -464,7 +464,7 @@ invalid value in either is ignored with one warning on stderr.
|
|
|
464
464
|
|
|
465
465
|
## Update check
|
|
466
466
|
|
|
467
|
-
On every invocation (unless disabled) agent-toggle checks
|
|
467
|
+
On every invocation (unless disabled) agent-toggle checks the repository's release tags for a newer release. It starts
|
|
468
468
|
before the command and is offered after it, on every exit path; it overlaps the command and waits at most 0.8 s afterwards.
|
|
469
469
|
`update` and `completion` skip it.
|
|
470
470
|
|
|
@@ -479,9 +479,9 @@ before the command and is offered after it, on every exit path; it overlaps the
|
|
|
479
479
|
then ` agent-toggle update`.
|
|
480
480
|
- It never changes the exit code or stdout, so `--json` output stays one document.
|
|
481
481
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
482
|
+
The lookup is update-kit's: one `git ls-remote --tags --refs` of the repository on a background
|
|
483
|
+
thread, cached for 10 minutes in `~/.update-kit/state.json`. Only the version you chose to skip is
|
|
484
|
+
kept in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a normal git request. See `SECURITY.md`
|
|
485
485
|
for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
|
|
486
486
|
to off (the settings menu's "Check for updates").
|
|
487
487
|
|
|
@@ -897,7 +897,7 @@ bookkeeping.
|
|
|
897
897
|
| `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
|
|
898
898
|
| `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
|
|
899
899
|
| `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
|
|
900
|
-
| `update-check.json` | the [update check](#update-check)'s
|
|
900
|
+
| `update-check.json` | the [update check](#update-check)'s skipped version (mode `0600`, atomic write) |
|
|
901
901
|
|
|
902
902
|
A killed run loses at most the one item in flight. If that was a flag write or a
|
|
903
903
|
dir move (with its companions), its `pending` record (the full entry, a flag's
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Update check on top of update-kit, plus the "skip this version" memory.
|
|
2
|
+
|
|
3
|
+
from agent_toggle.update_check import check
|
|
4
|
+
found = check("1.0.0", cache_path=Path.home() / ".agent-toggle" / "update-check.json")
|
|
5
|
+
if found:
|
|
6
|
+
print(f"{found.latest} is available (you have {found.current})")
|
|
7
|
+
|
|
8
|
+
The lookup itself -- ``git ls-remote`` of the release tags, the 10-minute cache in
|
|
9
|
+
``~/.update-kit/state.json``, the timeout -- is :func:`update_kit.check`; ``latest`` is
|
|
10
|
+
the tag as it returns it (``v1.2.0``). Only the skipped version lives in *cache_path*.
|
|
11
|
+
|
|
12
|
+
To overlap the lookup with the command's own work instead of paying for
|
|
13
|
+
it at exit, start it early and collect the result at the end:
|
|
14
|
+
|
|
15
|
+
started = start("1.0.0", cache_path=...)
|
|
16
|
+
... # the command's own work
|
|
17
|
+
found = collect(started)
|
|
18
|
+
|
|
19
|
+
To ask the user what to do instead of only hinting, hand :func:`offer` the
|
|
20
|
+
UI as a callback -- it owns what each answer does, the UI only picks one:
|
|
21
|
+
|
|
22
|
+
offer(started, ask, cache_path=..., upgrade=["uv", "tool", "upgrade", "agent-toggle"])
|
|
23
|
+
|
|
24
|
+
where ``ask(found)`` returns ``UPDATE_NOW``, ``SKIP`` or ``SKIP_VERSION``.
|
|
25
|
+
When the upgrade depends on the version or takes several steps, pass a
|
|
26
|
+
callable instead: ``upgrade=lambda found: [[...], [...]]`` runs each command
|
|
27
|
+
in order and stops at the first one that fails.
|
|
28
|
+
``SKIP_VERSION`` is remembered in *cache_path*; a newer release asks again.
|
|
29
|
+
|
|
30
|
+
Nothing here is allowed to raise to the caller: a missing cache, a bad
|
|
31
|
+
payload, or a dead network all become ``None``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import subprocess
|
|
40
|
+
import threading
|
|
41
|
+
from collections.abc import Callable, Sequence
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"DEFAULT_TIMEOUT",
|
|
47
|
+
"REPO",
|
|
48
|
+
"SKIP",
|
|
49
|
+
"SKIP_VERSION",
|
|
50
|
+
"UPDATE_NOW",
|
|
51
|
+
"UPGRADE_FAILED",
|
|
52
|
+
"Started",
|
|
53
|
+
"UpdateAvailable",
|
|
54
|
+
"check",
|
|
55
|
+
"collect",
|
|
56
|
+
"offer",
|
|
57
|
+
"skip",
|
|
58
|
+
"start",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
REPO = "weskao/agent-toggle"
|
|
62
|
+
#: How long :func:`collect` waits for the background lookup before giving up on it.
|
|
63
|
+
DEFAULT_TIMEOUT = 0.8
|
|
64
|
+
|
|
65
|
+
#: Answers an ``ask`` callback returns to :func:`offer`.
|
|
66
|
+
UPDATE_NOW = "update-now"
|
|
67
|
+
SKIP = "skip"
|
|
68
|
+
SKIP_VERSION = "skip-version"
|
|
69
|
+
#: What :func:`offer` returns when UPDATE_NOW's command could not run or failed.
|
|
70
|
+
UPGRADE_FAILED = "upgrade-failed"
|
|
71
|
+
|
|
72
|
+
#: What a skipped version may look like before it is trusted: it is read from a file.
|
|
73
|
+
_VERSION_RE = re.compile(r"v?\d{1,6}(\.\d{1,6}){1,3}([a-zA-Z0-9.+-]{0,20})?")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class UpdateAvailable:
|
|
78
|
+
current: str
|
|
79
|
+
latest: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check(current: str, *, cache_path: Path) -> UpdateAvailable | None:
|
|
83
|
+
"""Return an update when the latest release is newer than *current* and not the
|
|
84
|
+
version skipped in *cache_path*, else None. Never raises."""
|
|
85
|
+
try:
|
|
86
|
+
import update_kit # lazy: a checkout run or a stale env lacks it
|
|
87
|
+
|
|
88
|
+
latest = update_kit.check(repo=REPO, current=current)
|
|
89
|
+
if not isinstance(latest, str) or _bare(latest) == _bare(_load_cache(cache_path).get("skipped")):
|
|
90
|
+
return None
|
|
91
|
+
return UpdateAvailable(current=current, latest=latest)
|
|
92
|
+
except Exception: # noqa: BLE001 - a hint must never fail the command
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Started:
|
|
98
|
+
"""A background check in flight; pass to :func:`collect` to get its result."""
|
|
99
|
+
|
|
100
|
+
thread: threading.Thread
|
|
101
|
+
_result: list = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def start(current: str, *, cache_path: Path) -> Started:
|
|
105
|
+
"""Run :func:`check` on a daemon thread so it overlaps the caller's own work."""
|
|
106
|
+
result: list = []
|
|
107
|
+
|
|
108
|
+
def run() -> None:
|
|
109
|
+
result.append(check(current, cache_path=cache_path))
|
|
110
|
+
|
|
111
|
+
thread = threading.Thread(target=run, name="agent-toggle-update-check", daemon=True)
|
|
112
|
+
thread.start()
|
|
113
|
+
return Started(thread=thread, _result=result)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def collect(started: Started | None, *, timeout: float = DEFAULT_TIMEOUT) -> UpdateAvailable | None:
|
|
117
|
+
"""Wait for a :func:`start`-ed check and return its result. Never raises."""
|
|
118
|
+
if started is None:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
# Usually already done: it ran alongside the caller's own work.
|
|
122
|
+
started.thread.join(timeout)
|
|
123
|
+
return started._result[0] if started._result else None
|
|
124
|
+
except Exception: # noqa: BLE001 - a hint must never fail the command
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def skip(cache_path: Path, version: str) -> None:
|
|
129
|
+
"""Stop reporting *version*; a newer release is reported again. Never raises."""
|
|
130
|
+
data = _load_cache(cache_path)
|
|
131
|
+
data["skipped"] = version
|
|
132
|
+
_write_cache(cache_path, data)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def offer(
|
|
136
|
+
started: Started | None,
|
|
137
|
+
ask: Callable[[UpdateAvailable], str],
|
|
138
|
+
*,
|
|
139
|
+
cache_path: Path,
|
|
140
|
+
upgrade: Sequence[str] | Callable[[UpdateAvailable], Sequence[Sequence[str]]],
|
|
141
|
+
run: Callable[..., object] = subprocess.run,
|
|
142
|
+
) -> str | None:
|
|
143
|
+
"""Collect *started*, let *ask* pick an answer, then act on it.
|
|
144
|
+
|
|
145
|
+
*upgrade* is one command, or a callable building the commands from the
|
|
146
|
+
found release, run in order. Returns the answer acted on
|
|
147
|
+
(``UPGRADE_FAILED`` when a command is missing or exits nonzero — the
|
|
148
|
+
rest are then skipped), or None when there was nothing to offer. Ctrl+C
|
|
149
|
+
inside *ask* is ``SKIP``. Never raises.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
found = collect(started)
|
|
153
|
+
if found is None:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
answer = ask(found)
|
|
157
|
+
except KeyboardInterrupt:
|
|
158
|
+
return SKIP
|
|
159
|
+
if answer == SKIP_VERSION:
|
|
160
|
+
skip(cache_path, found.latest)
|
|
161
|
+
elif answer == UPDATE_NOW:
|
|
162
|
+
steps = upgrade(found) if callable(upgrade) else [upgrade]
|
|
163
|
+
for step in steps:
|
|
164
|
+
try:
|
|
165
|
+
done = run(list(step), check=False)
|
|
166
|
+
except OSError:
|
|
167
|
+
return UPGRADE_FAILED
|
|
168
|
+
if getattr(done, "returncode", 1) != 0:
|
|
169
|
+
return UPGRADE_FAILED
|
|
170
|
+
return answer
|
|
171
|
+
except Exception: # noqa: BLE001 - an update offer must never fail the command
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _bare(version: object) -> str | None:
|
|
176
|
+
"""*version* without a leading ``v`` (an old cache holds PyPI's ``0.10.0``, now tags)."""
|
|
177
|
+
return version.removeprefix("v") if isinstance(version, str) else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_cache(path: Path) -> dict:
|
|
181
|
+
try:
|
|
182
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
183
|
+
except (OSError, ValueError, RecursionError): # RecursionError: a deeply nested file
|
|
184
|
+
return {}
|
|
185
|
+
if not isinstance(data, dict):
|
|
186
|
+
return {}
|
|
187
|
+
# Only a valid skipped version survives: a hostile file can't smuggle anything
|
|
188
|
+
# (e.g. a 100k-deep dict that json.dumps can't write back), and old
|
|
189
|
+
# checked_at/latest keys are dropped on the next write.
|
|
190
|
+
skipped = data.get("skipped")
|
|
191
|
+
return {"skipped": skipped} if isinstance(skipped, str) and _VERSION_RE.fullmatch(skipped) else {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _write_cache(path: Path, data: dict) -> None:
|
|
195
|
+
# tmp + os.replace never follows a symlink at *path*; 0600 from creation.
|
|
196
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
197
|
+
try:
|
|
198
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
199
|
+
tmp.unlink(missing_ok=True)
|
|
200
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
201
|
+
try:
|
|
202
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
203
|
+
fh.write(json.dumps(data) + "\n")
|
|
204
|
+
os.replace(tmp, path)
|
|
205
|
+
except BaseException:
|
|
206
|
+
tmp.unlink(missing_ok=True)
|
|
207
|
+
raise
|
|
208
|
+
except (OSError, RecursionError, ValueError):
|
|
209
|
+
return
|
|
@@ -28,7 +28,7 @@ from .output import use_color
|
|
|
28
28
|
from .ui import theme
|
|
29
29
|
|
|
30
30
|
DIST = "agent-toggle"
|
|
31
|
-
REPO =
|
|
31
|
+
REPO = update_check.REPO
|
|
32
32
|
PLUGIN = "agent-toggle@agent-toggle"
|
|
33
33
|
UPDATE = [DIST, "update"]
|
|
34
34
|
RELEASE_NOTES = "https://github.com/weskao/agent-toggle/releases/tag/v%s"
|
|
@@ -93,12 +93,12 @@ def cache_path() -> Path:
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
def start() -> update_check.Started | None:
|
|
96
|
-
"""Kick off the
|
|
97
|
-
(env AGENT_TOGGLE_UPDATE_CHECK wins) is off. Never raises."""
|
|
96
|
+
"""Kick off the release check (update_kit) on a daemon thread, unless the `update_check`
|
|
97
|
+
setting (env AGENT_TOGGLE_UPDATE_CHECK wins) is off. Never raises."""
|
|
98
98
|
try:
|
|
99
99
|
if not settings.get("update_check"):
|
|
100
100
|
return None
|
|
101
|
-
return update_check.start(
|
|
101
|
+
return update_check.start(__version__, cache_path=cache_path())
|
|
102
102
|
except Exception: # noqa: BLE001 - a hint must never fail the command
|
|
103
103
|
return None
|
|
104
104
|
|
|
@@ -112,7 +112,7 @@ def is_interactive() -> bool:
|
|
|
112
112
|
|
|
113
113
|
def _available(found: update_check.UpdateAvailable) -> str:
|
|
114
114
|
return t("update.available", "agent-toggle %s is available (you have %s)",
|
|
115
|
-
_clean(found.latest), _clean(found.current))
|
|
115
|
+
_clean(found.latest).removeprefix("v"), _clean(found.current))
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def hint(found: update_check.UpdateAvailable) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-toggle
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.2
|
|
4
4
|
Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/weskao/agent-toggle
|
|
@@ -498,7 +498,7 @@ invalid value in either is ignored with one warning on stderr.
|
|
|
498
498
|
|
|
499
499
|
## Update check
|
|
500
500
|
|
|
501
|
-
On every invocation (unless disabled) agent-toggle checks
|
|
501
|
+
On every invocation (unless disabled) agent-toggle checks the repository's release tags for a newer release. It starts
|
|
502
502
|
before the command and is offered after it, on every exit path; it overlaps the command and waits at most 0.8 s afterwards.
|
|
503
503
|
`update` and `completion` skip it.
|
|
504
504
|
|
|
@@ -513,9 +513,9 @@ before the command and is offered after it, on every exit path; it overlaps the
|
|
|
513
513
|
then ` agent-toggle update`.
|
|
514
514
|
- It never changes the exit code or stdout, so `--json` output stays one document.
|
|
515
515
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
516
|
+
The lookup is update-kit's: one `git ls-remote --tags --refs` of the repository on a background
|
|
517
|
+
thread, cached for 10 minutes in `~/.update-kit/state.json`. Only the version you chose to skip is
|
|
518
|
+
kept in `~/.agent-toggle/update-check.json`. Nothing is sent beyond a normal git request. See `SECURITY.md`
|
|
519
519
|
for the hardening. To turn it off, set `AGENT_TOGGLE_UPDATE_CHECK=0` or the `update_check` setting
|
|
520
520
|
to off (the settings menu's "Check for updates").
|
|
521
521
|
|
|
@@ -931,7 +931,7 @@ bookkeeping.
|
|
|
931
931
|
| `parked/<sha8>/` | items parked by `--project` (`<sha8>` = first 8 hex of the SHA-1 of the resolved project dir) |
|
|
932
932
|
| `profiles/` | `<name>.json` profiles (dir `0700`, files `0600`) |
|
|
933
933
|
| `config.json` | [settings](#settings--config-menu) and the Telegram chat id (mode `0600`, atomic write, unknown keys kept) |
|
|
934
|
-
| `update-check.json` | the [update check](#update-check)'s
|
|
934
|
+
| `update-check.json` | the [update check](#update-check)'s skipped version (mode `0600`, atomic write) |
|
|
935
935
|
|
|
936
936
|
A killed run loses at most the one item in flight. If that was a flag write or a
|
|
937
937
|
dir move (with its companions), its `pending` record (the full entry, a flag's
|
|
@@ -159,13 +159,13 @@ class SandboxInvariantTest(SandboxCase):
|
|
|
159
159
|
self.assertEqual(Path(agent_toggle.__file__).resolve().parent, REPO / "agent_toggle")
|
|
160
160
|
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
TAG_99 = "v99.0.0" # update_kit.check returns the release tag
|
|
163
163
|
HINT = f"agent-toggle 99.0.0 is available (you have {__version__})"
|
|
164
164
|
|
|
165
165
|
|
|
166
166
|
class UpdateCheckTest(CliCase):
|
|
167
167
|
"""The update check: started before dispatch, offered in main()'s finally; and `update` /
|
|
168
|
-
`--update`. No network:
|
|
168
|
+
`--update`. No network: update_kit.check and update_kit.update are patched;
|
|
169
169
|
AGENT_TOGGLE_UPDATE_CHECK=0 (tests/base.py) is lifted here only."""
|
|
170
170
|
|
|
171
171
|
def setUp(self) -> None:
|
|
@@ -174,9 +174,9 @@ class UpdateCheckTest(CliCase):
|
|
|
174
174
|
env.start()
|
|
175
175
|
self.addCleanup(env.stop)
|
|
176
176
|
os.environ.pop("AGENT_TOGGLE_UPDATE_CHECK", None)
|
|
177
|
-
for target, kw in ((
|
|
178
|
-
(
|
|
179
|
-
|
|
177
|
+
for target, kw in ((update_prompt, {"is_interactive": mock.Mock(return_value=False)}),
|
|
178
|
+
(update_kit, {"check": mock.Mock(return_value=TAG_99),
|
|
179
|
+
"update": mock.Mock(return_value=0)}), # never install
|
|
180
180
|
(fs, {"WIN": False})): # the panel/upgrade flow; Windows has its own test
|
|
181
181
|
patch = mock.patch.multiple(target, **kw)
|
|
182
182
|
patch.start()
|
|
@@ -193,7 +193,7 @@ class UpdateCheckTest(CliCase):
|
|
|
193
193
|
self.assertNotIn("99.0.0", self.run_cli("status")[2])
|
|
194
194
|
settings.set("update_check", False)
|
|
195
195
|
self.assertNotIn("99.0.0", self.run_cli("status")[2])
|
|
196
|
-
|
|
196
|
+
update_kit.check.assert_not_called()
|
|
197
197
|
|
|
198
198
|
def test_json_stdout_is_one_document_and_never_prompts(self) -> None:
|
|
199
199
|
update_prompt.is_interactive.return_value = True
|
|
@@ -291,7 +291,7 @@ class UpdateCheckTest(CliCase):
|
|
|
291
291
|
self.assertNotIn("99.0.0", self.run_cli("update")[2])
|
|
292
292
|
self.assertNotIn("99.0.0", self.run_cli("completion", "bash")[2])
|
|
293
293
|
ask.assert_not_called()
|
|
294
|
-
|
|
294
|
+
update_kit.check.assert_not_called()
|
|
295
295
|
|
|
296
296
|
def test_update_and_dash_dash_update_exit_with_update_kits_code(self) -> None:
|
|
297
297
|
for argv in (("update",), ("--update",), ("--json", "--update")):
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Update hint on top of update_kit.check.
|
|
2
|
+
|
|
3
|
+
Never hits the network: every test mocks ``update_kit.check`` (which returns the newer
|
|
4
|
+
release tag, or None) and injects ``run``. The lookup, its TTL and its cache are
|
|
5
|
+
update-kit's; what these cover is the skip-version memory, the background start/collect
|
|
6
|
+
and the offer answers.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
import update_kit
|
|
21
|
+
|
|
22
|
+
from agent_toggle import update_check
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Done:
|
|
26
|
+
def __init__(self, returncode: int) -> None:
|
|
27
|
+
self.returncode = returncode
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UpdateCheckCase(unittest.TestCase):
|
|
31
|
+
def setUp(self) -> None:
|
|
32
|
+
self.tmp = Path(tempfile.mkdtemp(prefix="agent-toggle-update-"))
|
|
33
|
+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
|
34
|
+
self.cache = self.tmp / "update-check.json"
|
|
35
|
+
|
|
36
|
+
def kit(self, latest="v0.10.0", **kw):
|
|
37
|
+
"""Patch update_kit.check to answer *latest* (or raise via side_effect=...)."""
|
|
38
|
+
patch = mock.patch.object(update_kit, "check", return_value=latest, **kw)
|
|
39
|
+
self.addCleanup(patch.stop)
|
|
40
|
+
return patch.start()
|
|
41
|
+
|
|
42
|
+
def check(self, latest="v0.10.0", current="0.9.1", **kw):
|
|
43
|
+
self.kit(latest, **kw)
|
|
44
|
+
return update_check.check(current, cache_path=self.cache)
|
|
45
|
+
|
|
46
|
+
def seed(self, **data) -> None:
|
|
47
|
+
self.cache.write_text(json.dumps(data), encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CheckTest(UpdateCheckCase):
|
|
51
|
+
def test_a_newer_tag_is_reported_as_update_kit_returns_it(self):
|
|
52
|
+
found = self.check("v0.10.0")
|
|
53
|
+
self.assertEqual(found, update_check.UpdateAvailable(current="0.9.1", latest="v0.10.0"))
|
|
54
|
+
|
|
55
|
+
def test_the_lookup_is_update_kits_for_this_repo(self):
|
|
56
|
+
kit = self.kit()
|
|
57
|
+
update_check.check("0.9.1", cache_path=self.cache)
|
|
58
|
+
kit.assert_called_once_with(repo="weskao/agent-toggle", current="0.9.1")
|
|
59
|
+
|
|
60
|
+
def test_nothing_newer_stays_silent(self):
|
|
61
|
+
self.assertIsNone(self.check(None))
|
|
62
|
+
|
|
63
|
+
def test_a_failing_lookup_stays_silent(self):
|
|
64
|
+
self.assertIsNone(self.check(side_effect=OSError("offline")))
|
|
65
|
+
|
|
66
|
+
def test_a_missing_update_kit_stays_silent(self):
|
|
67
|
+
with mock.patch.dict("sys.modules", {"update_kit": None}):
|
|
68
|
+
self.assertIsNone(update_check.check("0.9.1", cache_path=self.cache))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StartCollectTest(UpdateCheckCase):
|
|
72
|
+
def start(self, latest="v0.10.0", **kw):
|
|
73
|
+
self.kit(latest, **kw)
|
|
74
|
+
return update_check.start("0.9.1", cache_path=self.cache)
|
|
75
|
+
|
|
76
|
+
def test_start_and_collect_finds_an_update(self):
|
|
77
|
+
self.assertEqual(update_check.collect(self.start()).latest, "v0.10.0")
|
|
78
|
+
|
|
79
|
+
def test_start_and_collect_stays_silent_when_current(self):
|
|
80
|
+
self.assertIsNone(update_check.collect(self.start(None)))
|
|
81
|
+
|
|
82
|
+
def test_start_overlaps_with_the_callers_own_work(self):
|
|
83
|
+
release = threading.Event()
|
|
84
|
+
|
|
85
|
+
def slow(**_kw):
|
|
86
|
+
release.wait(timeout=1)
|
|
87
|
+
return "v0.10.0"
|
|
88
|
+
|
|
89
|
+
started = self.start(side_effect=slow)
|
|
90
|
+
time.sleep(0.05)
|
|
91
|
+
self.assertTrue(started.thread.is_alive())
|
|
92
|
+
release.set()
|
|
93
|
+
self.assertEqual(update_check.collect(started).latest, "v0.10.0")
|
|
94
|
+
|
|
95
|
+
def test_collect_with_no_started_check_is_silent(self):
|
|
96
|
+
self.assertIsNone(update_check.collect(None))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SkipTest(UpdateCheckCase):
|
|
100
|
+
def test_a_skipped_version_stays_silent(self):
|
|
101
|
+
update_check.skip(self.cache, "v0.10.0")
|
|
102
|
+
self.assertIsNone(self.check("v0.10.0"))
|
|
103
|
+
|
|
104
|
+
def test_a_skip_made_before_tags_still_matches(self):
|
|
105
|
+
self.seed(skipped="0.10.0") # what the PyPI-era check stored
|
|
106
|
+
self.assertIsNone(self.check("v0.10.0"))
|
|
107
|
+
|
|
108
|
+
def test_a_release_newer_than_the_skipped_one_is_reported(self):
|
|
109
|
+
update_check.skip(self.cache, "v0.10.0")
|
|
110
|
+
self.assertEqual(self.check("v0.11.0").latest, "v0.11.0")
|
|
111
|
+
|
|
112
|
+
def test_skip_keeps_only_the_skipped_version(self):
|
|
113
|
+
self.seed(checked_at=5, latest="0.9.2", extra={"a": [1]})
|
|
114
|
+
update_check.skip(self.cache, "v0.10.0")
|
|
115
|
+
self.assertEqual(json.loads(self.cache.read_text(encoding="utf-8")), {"skipped": "v0.10.0"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SecurityTest(UpdateCheckCase):
|
|
119
|
+
def test_a_hostile_skipped_value_is_ignored(self):
|
|
120
|
+
self.seed(skipped="evil\x1b[2J", latest=["1.2.3"])
|
|
121
|
+
self.assertEqual(update_check._load_cache(self.cache), {})
|
|
122
|
+
|
|
123
|
+
def test_the_prompt_prints_no_escape_for_a_hostile_version(self):
|
|
124
|
+
import io
|
|
125
|
+
from contextlib import redirect_stderr
|
|
126
|
+
|
|
127
|
+
from agent_toggle import update_prompt
|
|
128
|
+
buf = io.StringIO()
|
|
129
|
+
with redirect_stderr(buf):
|
|
130
|
+
update_prompt.hint(update_check.UpdateAvailable(current="1.0", latest="9.9\x1b]52;c;x\x07"))
|
|
131
|
+
self.assertNotIn("\x1b", buf.getvalue())
|
|
132
|
+
|
|
133
|
+
def test_the_prompt_shows_the_tag_without_its_v(self):
|
|
134
|
+
from agent_toggle import update_prompt
|
|
135
|
+
text = update_prompt._available(update_check.UpdateAvailable(current="1.0", latest="v2.0.0"))
|
|
136
|
+
self.assertEqual(text, "agent-toggle 2.0.0 is available (you have 1.0)")
|
|
137
|
+
|
|
138
|
+
def test_a_deeply_nested_cache_is_corrupt_and_rewritten_small(self):
|
|
139
|
+
depth = 100_000 # parses on 3.14; json.dumps of the merged data used to raise
|
|
140
|
+
self.cache.write_text('{"a":' * depth + "1" + "}" * depth, encoding="utf-8")
|
|
141
|
+
update_check.skip(self.cache, "v0.10.0")
|
|
142
|
+
self.assertEqual(json.loads(self.cache.read_text(encoding="utf-8")), {"skipped": "v0.10.0"})
|
|
143
|
+
|
|
144
|
+
def test_the_cache_write_replaces_a_symlink_and_is_0600(self):
|
|
145
|
+
target = self.tmp / "victim"
|
|
146
|
+
target.write_text("keep", encoding="utf-8")
|
|
147
|
+
self.cache.symlink_to(target)
|
|
148
|
+
update_check.skip(self.cache, "v0.10.0")
|
|
149
|
+
self.assertFalse(self.cache.is_symlink())
|
|
150
|
+
self.assertEqual(target.read_text(encoding="utf-8"), "keep")
|
|
151
|
+
if os.name != "nt": # Windows has no POSIX modes
|
|
152
|
+
self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class OfferTest(UpdateCheckCase):
|
|
156
|
+
def offer(self, answer, *, latest="v0.10.0", run=None, upgrade=None):
|
|
157
|
+
asked: list = []
|
|
158
|
+
|
|
159
|
+
def ask(found):
|
|
160
|
+
asked.append(found)
|
|
161
|
+
if isinstance(answer, BaseException):
|
|
162
|
+
raise answer
|
|
163
|
+
return answer
|
|
164
|
+
|
|
165
|
+
self.kit(latest)
|
|
166
|
+
started = update_check.start("0.9.1", cache_path=self.cache)
|
|
167
|
+
result = update_check.offer(
|
|
168
|
+
started, ask, cache_path=self.cache,
|
|
169
|
+
upgrade=upgrade or ["uv", "tool", "upgrade", "agent-toggle"],
|
|
170
|
+
run=run or (lambda *_a, **_k: self.fail("upgrade must not run")))
|
|
171
|
+
return result, asked
|
|
172
|
+
|
|
173
|
+
def test_offer_does_not_ask_when_nothing_is_newer(self):
|
|
174
|
+
result, asked = self.offer(update_check.UPDATE_NOW, latest=None)
|
|
175
|
+
self.assertIsNone(result)
|
|
176
|
+
self.assertEqual(asked, [])
|
|
177
|
+
|
|
178
|
+
def test_offer_asks_with_the_found_release(self):
|
|
179
|
+
_result, asked = self.offer(update_check.SKIP)
|
|
180
|
+
self.assertEqual(asked, [update_check.UpdateAvailable(current="0.9.1", latest="v0.10.0")])
|
|
181
|
+
|
|
182
|
+
def test_offer_update_now_runs_the_upgrade_command(self):
|
|
183
|
+
ran: list = []
|
|
184
|
+
result, _ = self.offer(update_check.UPDATE_NOW,
|
|
185
|
+
run=lambda cmd, **_k: ran.append(cmd) or _Done(0))
|
|
186
|
+
self.assertEqual(result, update_check.UPDATE_NOW)
|
|
187
|
+
self.assertEqual(ran, [["uv", "tool", "upgrade", "agent-toggle"]])
|
|
188
|
+
|
|
189
|
+
def test_offer_reports_an_upgrade_that_exits_nonzero(self):
|
|
190
|
+
result, _ = self.offer(update_check.UPDATE_NOW, run=lambda *_a, **_k: _Done(2))
|
|
191
|
+
self.assertEqual(result, update_check.UPGRADE_FAILED)
|
|
192
|
+
|
|
193
|
+
def test_offer_reports_a_missing_upgrade_tool(self):
|
|
194
|
+
def missing(*_a, **_k):
|
|
195
|
+
raise FileNotFoundError("uv")
|
|
196
|
+
|
|
197
|
+
result, _ = self.offer(update_check.UPDATE_NOW, run=missing)
|
|
198
|
+
self.assertEqual(result, update_check.UPGRADE_FAILED)
|
|
199
|
+
|
|
200
|
+
def test_offer_skip_leaves_the_next_run_asking(self):
|
|
201
|
+
result, _ = self.offer(update_check.SKIP)
|
|
202
|
+
self.assertEqual(result, update_check.SKIP)
|
|
203
|
+
self.assertIsNotNone(self.check("v0.10.0"))
|
|
204
|
+
|
|
205
|
+
def test_offer_skip_version_silences_that_version_until_a_newer_one(self):
|
|
206
|
+
result, _ = self.offer(update_check.SKIP_VERSION)
|
|
207
|
+
self.assertEqual(result, update_check.SKIP_VERSION)
|
|
208
|
+
self.assertIsNone(self.check("v0.10.0"))
|
|
209
|
+
self.assertEqual(self.check("v0.11.0").latest, "v0.11.0")
|
|
210
|
+
|
|
211
|
+
def test_offer_never_raises_when_the_ui_does(self):
|
|
212
|
+
result, _ = self.offer(RuntimeError("broken terminal"))
|
|
213
|
+
self.assertIsNone(result)
|
|
214
|
+
|
|
215
|
+
def test_offer_reads_ctrl_c_in_the_ui_as_skip(self):
|
|
216
|
+
result, _ = self.offer(KeyboardInterrupt())
|
|
217
|
+
self.assertEqual(result, update_check.SKIP)
|
|
218
|
+
|
|
219
|
+
def steps(self, returncodes):
|
|
220
|
+
ran: list = []
|
|
221
|
+
codes = iter(returncodes)
|
|
222
|
+
result, _ = self.offer(
|
|
223
|
+
update_check.UPDATE_NOW,
|
|
224
|
+
upgrade=lambda found: [["install", found.latest], ["reload"]],
|
|
225
|
+
run=lambda cmd, **_k: ran.append(cmd) or _Done(next(codes)))
|
|
226
|
+
return result, ran
|
|
227
|
+
|
|
228
|
+
def test_offer_builds_upgrade_steps_from_the_found_release(self):
|
|
229
|
+
result, ran = self.steps([0, 0])
|
|
230
|
+
self.assertEqual(result, update_check.UPDATE_NOW)
|
|
231
|
+
self.assertEqual(ran, [["install", "v0.10.0"], ["reload"]])
|
|
232
|
+
|
|
233
|
+
def test_offer_stops_at_the_first_failed_step(self):
|
|
234
|
+
result, ran = self.steps([1])
|
|
235
|
+
self.assertEqual(result, update_check.UPGRADE_FAILED)
|
|
236
|
+
self.assertEqual(ran, [["install", "v0.10.0"]])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
unittest.main()
|