evm-cli 2.5.0__tar.gz → 2.6.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.
- {evm_cli-2.5.0 → evm_cli-2.6.1}/PKG-INFO +41 -2
- {evm_cli-2.5.0 → evm_cli-2.6.1}/README.md +40 -1
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/__init__.py +1 -1
- evm_cli-2.6.1/evm/_upgrade.py +164 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/cli.py +86 -4
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/manager.py +3 -1
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/PKG-INFO +41 -2
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/SOURCES.txt +2 -0
- evm_cli-2.6.1/tests/test_upgrade.py +488 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/LICENSE +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/__main__.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_completion.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_crypto.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_groups.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_history.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_io.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_json.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_schema.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/_typing.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/exceptions.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm/formatters.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/dependency_links.txt +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/entry_points.txt +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/not-zip-safe +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/requires.txt +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/evm_cli.egg-info/top_level.txt +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/pyproject.toml +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/setup.cfg +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/setup.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_cli_additional.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_cli_boundary.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_cli_coverage.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_coverage_gap.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_formatters.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_inject.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_io_boundary.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_main.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_main_entry.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_main_module.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_shell_integration.py +0 -0
- {evm_cli-2.5.0 → evm_cli-2.6.1}/tests/test_v230_fixes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evm-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: A command-line tool for managing environment variables
|
|
5
5
|
Author-email: EVM Tool <evm@example.com>
|
|
6
6
|
License: MIT
|
|
@@ -42,7 +42,7 @@ Dynamic: license-file
|
|
|
42
42
|
|
|
43
43
|
A powerful command-line tool for managing environment variables on macOS and Linux systems.
|
|
44
44
|
|
|
45
|
-
**Version**: 2.
|
|
45
|
+
**Version**: 2.6.1
|
|
46
46
|
|
|
47
47
|
## Features
|
|
48
48
|
|
|
@@ -63,6 +63,7 @@ A powerful command-line tool for managing environment variables on macOS and Lin
|
|
|
63
63
|
- ✅ **History**: Operation audit log with JSONL storage
|
|
64
64
|
- ✅ **Shell Completion**: bash, zsh, fish completion script generation
|
|
65
65
|
- ✅ **Shell Integration**: `evm init` auto-installs `evm-load` + completion into your rc on first use
|
|
66
|
+
- ✅ **Self-Upgrade**: `evm upgrade` checks PyPI and pip-installs the latest version
|
|
66
67
|
- ✅ **Interactive Safety**: Confirmation prompts for destructive operations (`--force` to skip)
|
|
67
68
|
- ✅ **JSON Output**: `--json` flag for structured output (agent-friendly, stdout=data, stderr=errors)
|
|
68
69
|
- ✅ **Quiet Mode**: `--quiet` suppresses all human-readable output
|
|
@@ -97,6 +98,8 @@ evm/
|
|
|
97
98
|
│ ├── _completion.py # Shell completion generators (bash/zsh/fish)
|
|
98
99
|
│ ├── _json.py # JSON output helpers (agent-friendly)
|
|
99
100
|
│ ├── _crypto.py # HKDF + HMAC-CTR encryption module
|
|
101
|
+
│ ├── _upgrade.py # Self-upgrade: PyPI version check + pip install
|
|
102
|
+
│ ├── _typing.py # Shared typing helpers (Protocol mixins)
|
|
100
103
|
│ ├── formatters.py # Terminal output formatting
|
|
101
104
|
│ └── exceptions.py # Custom exception hierarchy (17 classes)
|
|
102
105
|
├── examples/ # Example scripts
|
|
@@ -104,8 +107,13 @@ evm/
|
|
|
104
107
|
│ ├── test_main.py # Unit + integration tests
|
|
105
108
|
│ ├── test_inject.py # `evm inject` + `evm-load` tests
|
|
106
109
|
│ ├── test_shell_integration.py # `evm init` + auto-install tests
|
|
110
|
+
│ ├── test_upgrade.py # `evm upgrade` tests
|
|
107
111
|
│ ├── test_io_boundary.py # _io.py boundary tests
|
|
108
112
|
│ ├── test_cli_boundary.py # cli.py boundary tests
|
|
113
|
+
│ ├── test_cli_additional.py # cli.py additional coverage
|
|
114
|
+
│ ├── test_cli_coverage.py # cli.py coverage gap tests
|
|
115
|
+
│ ├── test_main_entry.py # `evm` entry-point tests
|
|
116
|
+
│ ├── test_main_module.py # `python -m evm` entry tests
|
|
109
117
|
│ ├── test_v230_fixes.py # v2.3.0 code review fix tests
|
|
110
118
|
│ ├── test_coverage_gap.py # Coverage gap tests (98% target)
|
|
111
119
|
│ ├── test_formatters.py # Formatter output tests
|
|
@@ -327,6 +335,8 @@ evm exec -- COMMAND # Run with env vars
|
|
|
327
335
|
eval "$(evm inject)" # Load vars into current shell
|
|
328
336
|
evm backup # Create backup
|
|
329
337
|
evm validate # Check schemas
|
|
338
|
+
evm upgrade # Upgrade to latest PyPI release
|
|
339
|
+
evm upgrade --check # Check for updates only
|
|
330
340
|
```
|
|
331
341
|
|
|
332
342
|
## Usage
|
|
@@ -600,6 +610,35 @@ evm history --limit 50
|
|
|
600
610
|
evm history --clear
|
|
601
611
|
```
|
|
602
612
|
|
|
613
|
+
### Self-Upgrade (`evm upgrade`)
|
|
614
|
+
|
|
615
|
+
Check PyPI for a newer `evm` release and pip-install it in one step. Uses only the standard library — no extra dependencies.
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
# Check whether a newer version is available (no changes made)
|
|
619
|
+
evm upgrade --check
|
|
620
|
+
# exit 0 = up to date, exit 1 = update available (or network error)
|
|
621
|
+
|
|
622
|
+
# Upgrade to the latest release
|
|
623
|
+
evm upgrade
|
|
624
|
+
# → Upgraded from 2.5.0 to 2.6.0.
|
|
625
|
+
|
|
626
|
+
# Preview the pip command without running it
|
|
627
|
+
evm upgrade --dry-run
|
|
628
|
+
|
|
629
|
+
# Skip the pre-check and run pip directly
|
|
630
|
+
evm upgrade --force
|
|
631
|
+
|
|
632
|
+
# Structured JSON output (stdout = data, stderr = errors)
|
|
633
|
+
evm upgrade --check --json
|
|
634
|
+
# stdout: {"status": "ok", "data": {"current": "2.5.0", "latest": "2.6.0", "update_available": true}}
|
|
635
|
+
|
|
636
|
+
evm upgrade --json
|
|
637
|
+
# stdout: {"status": "ok", "data": {"current": "2.5.0", "new_version": "2.6.0", "action": "upgraded", "upgraded": true, "message": "Upgraded from 2.5.0 to 2.6.0."}}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`evm upgrade` calls `pip install --upgrade evm-cli` using the **same Python interpreter** that runs `evm`, so it upgrades the correct installation. If the network is unreachable, `--check` reports `unknown` and exits 1; a plain `evm upgrade` aborts before touching pip.
|
|
641
|
+
|
|
603
642
|
### Shell Integration (`evm init`)
|
|
604
643
|
|
|
605
644
|
EVM can install a shell-integration snippet into your rc file (`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config.fish`). The snippet is **one line** that re-evaluates `evm init` on every shell start — so `evm-load`, tab completion, and any future integration stay in sync with the installed `evm` version automatically (no need to re-install after an upgrade).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A powerful command-line tool for managing environment variables on macOS and Linux systems.
|
|
4
4
|
|
|
5
|
-
**Version**: 2.
|
|
5
|
+
**Version**: 2.6.1
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -23,6 +23,7 @@ A powerful command-line tool for managing environment variables on macOS and Lin
|
|
|
23
23
|
- ✅ **History**: Operation audit log with JSONL storage
|
|
24
24
|
- ✅ **Shell Completion**: bash, zsh, fish completion script generation
|
|
25
25
|
- ✅ **Shell Integration**: `evm init` auto-installs `evm-load` + completion into your rc on first use
|
|
26
|
+
- ✅ **Self-Upgrade**: `evm upgrade` checks PyPI and pip-installs the latest version
|
|
26
27
|
- ✅ **Interactive Safety**: Confirmation prompts for destructive operations (`--force` to skip)
|
|
27
28
|
- ✅ **JSON Output**: `--json` flag for structured output (agent-friendly, stdout=data, stderr=errors)
|
|
28
29
|
- ✅ **Quiet Mode**: `--quiet` suppresses all human-readable output
|
|
@@ -57,6 +58,8 @@ evm/
|
|
|
57
58
|
│ ├── _completion.py # Shell completion generators (bash/zsh/fish)
|
|
58
59
|
│ ├── _json.py # JSON output helpers (agent-friendly)
|
|
59
60
|
│ ├── _crypto.py # HKDF + HMAC-CTR encryption module
|
|
61
|
+
│ ├── _upgrade.py # Self-upgrade: PyPI version check + pip install
|
|
62
|
+
│ ├── _typing.py # Shared typing helpers (Protocol mixins)
|
|
60
63
|
│ ├── formatters.py # Terminal output formatting
|
|
61
64
|
│ └── exceptions.py # Custom exception hierarchy (17 classes)
|
|
62
65
|
├── examples/ # Example scripts
|
|
@@ -64,8 +67,13 @@ evm/
|
|
|
64
67
|
│ ├── test_main.py # Unit + integration tests
|
|
65
68
|
│ ├── test_inject.py # `evm inject` + `evm-load` tests
|
|
66
69
|
│ ├── test_shell_integration.py # `evm init` + auto-install tests
|
|
70
|
+
│ ├── test_upgrade.py # `evm upgrade` tests
|
|
67
71
|
│ ├── test_io_boundary.py # _io.py boundary tests
|
|
68
72
|
│ ├── test_cli_boundary.py # cli.py boundary tests
|
|
73
|
+
│ ├── test_cli_additional.py # cli.py additional coverage
|
|
74
|
+
│ ├── test_cli_coverage.py # cli.py coverage gap tests
|
|
75
|
+
│ ├── test_main_entry.py # `evm` entry-point tests
|
|
76
|
+
│ ├── test_main_module.py # `python -m evm` entry tests
|
|
69
77
|
│ ├── test_v230_fixes.py # v2.3.0 code review fix tests
|
|
70
78
|
│ ├── test_coverage_gap.py # Coverage gap tests (98% target)
|
|
71
79
|
│ ├── test_formatters.py # Formatter output tests
|
|
@@ -287,6 +295,8 @@ evm exec -- COMMAND # Run with env vars
|
|
|
287
295
|
eval "$(evm inject)" # Load vars into current shell
|
|
288
296
|
evm backup # Create backup
|
|
289
297
|
evm validate # Check schemas
|
|
298
|
+
evm upgrade # Upgrade to latest PyPI release
|
|
299
|
+
evm upgrade --check # Check for updates only
|
|
290
300
|
```
|
|
291
301
|
|
|
292
302
|
## Usage
|
|
@@ -560,6 +570,35 @@ evm history --limit 50
|
|
|
560
570
|
evm history --clear
|
|
561
571
|
```
|
|
562
572
|
|
|
573
|
+
### Self-Upgrade (`evm upgrade`)
|
|
574
|
+
|
|
575
|
+
Check PyPI for a newer `evm` release and pip-install it in one step. Uses only the standard library — no extra dependencies.
|
|
576
|
+
|
|
577
|
+
```bash
|
|
578
|
+
# Check whether a newer version is available (no changes made)
|
|
579
|
+
evm upgrade --check
|
|
580
|
+
# exit 0 = up to date, exit 1 = update available (or network error)
|
|
581
|
+
|
|
582
|
+
# Upgrade to the latest release
|
|
583
|
+
evm upgrade
|
|
584
|
+
# → Upgraded from 2.5.0 to 2.6.0.
|
|
585
|
+
|
|
586
|
+
# Preview the pip command without running it
|
|
587
|
+
evm upgrade --dry-run
|
|
588
|
+
|
|
589
|
+
# Skip the pre-check and run pip directly
|
|
590
|
+
evm upgrade --force
|
|
591
|
+
|
|
592
|
+
# Structured JSON output (stdout = data, stderr = errors)
|
|
593
|
+
evm upgrade --check --json
|
|
594
|
+
# stdout: {"status": "ok", "data": {"current": "2.5.0", "latest": "2.6.0", "update_available": true}}
|
|
595
|
+
|
|
596
|
+
evm upgrade --json
|
|
597
|
+
# stdout: {"status": "ok", "data": {"current": "2.5.0", "new_version": "2.6.0", "action": "upgraded", "upgraded": true, "message": "Upgraded from 2.5.0 to 2.6.0."}}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
`evm upgrade` calls `pip install --upgrade evm-cli` using the **same Python interpreter** that runs `evm`, so it upgrades the correct installation. If the network is unreachable, `--check` reports `unknown` and exits 1; a plain `evm upgrade` aborts before touching pip.
|
|
601
|
+
|
|
563
602
|
### Shell Integration (`evm init`)
|
|
564
603
|
|
|
565
604
|
EVM can install a shell-integration snippet into your rc file (`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config.fish`). The snippet is **one line** that re-evaluates `evm init` on every shell start — so `evm-load`, tab completion, and any future integration stay in sync with the installed `evm` version automatically (no need to re-install after an upgrade).
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EVM 自升级模块
|
|
4
|
+
|
|
5
|
+
通过 PyPI JSON API 检查最新版本,必要时调用 pip 升级。
|
|
6
|
+
纯标准库实现(urllib + subprocess),无第三方依赖。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
# pyproject.toml 中定义的 PyPI 包名
|
|
19
|
+
PACKAGE_NAME = 'evm-cli'
|
|
20
|
+
PYPI_JSON_URL = f'https://pypi.org/pypi/{PACKAGE_NAME}/json'
|
|
21
|
+
# pip 升级命令:始终使用当前解释器的 pip 模块,避免找不到 pip
|
|
22
|
+
PIP_UPGRADE_CMD = [
|
|
23
|
+
sys.executable, '-m', 'pip', 'install', '--upgrade', PACKAGE_NAME
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
27
|
+
_DEFAULT_PIP_TIMEOUT = 120.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_current_version() -> str:
|
|
31
|
+
"""返回当前运行的 evm 版本"""
|
|
32
|
+
return __version__
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_latest_version(timeout: float = _DEFAULT_TIMEOUT) -> Optional[str]:
|
|
36
|
+
"""向 PyPI 查询最新发布版本。
|
|
37
|
+
|
|
38
|
+
失败(网络异常 / 解析异常)时返回 None。
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
req = urllib.request.Request(
|
|
42
|
+
PYPI_JSON_URL,
|
|
43
|
+
headers={'User-Agent': f'evm/{__version__}'},
|
|
44
|
+
)
|
|
45
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
46
|
+
data = json.loads(resp.read().decode('utf-8'))
|
|
47
|
+
return str(data['info']['version'])
|
|
48
|
+
except (urllib.error.URLError, OSError, ValueError, KeyError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_version(version: str) -> tuple[int, ...]:
|
|
53
|
+
"""把 '2.5.0' 解析成 (2, 5, 0)。非数字段视为 0。"""
|
|
54
|
+
parts: list[int] = []
|
|
55
|
+
for seg in version.split('.'):
|
|
56
|
+
try:
|
|
57
|
+
parts.append(int(seg))
|
|
58
|
+
except ValueError:
|
|
59
|
+
parts.append(0)
|
|
60
|
+
return tuple(parts)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_newer(remote: str, local: str) -> bool:
|
|
64
|
+
"""remote 是否比 local 更新(按数字段逐段比较)"""
|
|
65
|
+
rem = _parse_version(remote)
|
|
66
|
+
loc = _parse_version(local)
|
|
67
|
+
max_len = max(len(rem), len(loc))
|
|
68
|
+
rem = rem + (0,) * (max_len - len(rem))
|
|
69
|
+
loc = loc + (0,) * (max_len - len(loc))
|
|
70
|
+
return rem > loc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_for_update(
|
|
74
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
75
|
+
) -> tuple[Optional[str], Optional[bool]]:
|
|
76
|
+
"""检查是否有新版本。
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
(latest, update_available)
|
|
80
|
+
- latest: 最新版本号字符串;无法获取时为 None
|
|
81
|
+
- update_available: True=有更新, False=已最新, None=无法判定
|
|
82
|
+
"""
|
|
83
|
+
latest = fetch_latest_version(timeout=timeout)
|
|
84
|
+
if latest is None:
|
|
85
|
+
return None, None
|
|
86
|
+
return latest, is_newer(latest, get_current_version())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def perform_upgrade(
|
|
90
|
+
force: bool = False,
|
|
91
|
+
dry_run: bool = False,
|
|
92
|
+
timeout: float = _DEFAULT_PIP_TIMEOUT,
|
|
93
|
+
) -> tuple[str, str, str]:
|
|
94
|
+
"""执行升级。
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
force: 跳过预检查,直接运行 pip(即使可能已是最新)
|
|
98
|
+
dry_run: 仅打印将要执行的命令,不实际升级
|
|
99
|
+
timeout: pip 子进程超时秒数
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
(action, message, new_version)
|
|
103
|
+
- action ∈ {'upgraded', 'already_latest', 'dry_run',
|
|
104
|
+
'failed', 'network_error'}
|
|
105
|
+
- new_version 为升级后版本号;未知/未升级时为空串
|
|
106
|
+
"""
|
|
107
|
+
current = get_current_version()
|
|
108
|
+
|
|
109
|
+
if not force:
|
|
110
|
+
latest, available = check_for_update()
|
|
111
|
+
if available is None:
|
|
112
|
+
return (
|
|
113
|
+
'network_error',
|
|
114
|
+
'Unable to check latest version (network unreachable or PyPI error).',
|
|
115
|
+
'',
|
|
116
|
+
)
|
|
117
|
+
if not available:
|
|
118
|
+
return 'already_latest', f'Already up to date ({current}).', current
|
|
119
|
+
|
|
120
|
+
if dry_run:
|
|
121
|
+
return (
|
|
122
|
+
'dry_run',
|
|
123
|
+
f'[DRY-RUN] Would run: {" ".join(PIP_UPGRADE_CMD)}',
|
|
124
|
+
'',
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
PIP_UPGRADE_CMD,
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=timeout,
|
|
133
|
+
)
|
|
134
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
135
|
+
return 'failed', f'Upgrade failed: {e}', ''
|
|
136
|
+
|
|
137
|
+
if result.returncode != 0:
|
|
138
|
+
lines = (result.stderr or result.stdout or '').strip().splitlines()
|
|
139
|
+
tail = lines[-1] if lines else ''
|
|
140
|
+
detail = f' — {tail}' if tail else ''
|
|
141
|
+
return (
|
|
142
|
+
'failed',
|
|
143
|
+
f'pip upgrade failed (exit {result.returncode}){detail}',
|
|
144
|
+
'',
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# pip 成功后重新查询 PyPI,取得升级后的版本号
|
|
148
|
+
latest = fetch_latest_version()
|
|
149
|
+
new_ver = latest if latest else ''
|
|
150
|
+
if new_ver and is_newer(new_ver, current):
|
|
151
|
+
return 'upgraded', f'Upgraded from {current} to {new_ver}.', new_ver
|
|
152
|
+
return 'upgraded', f'Upgrade complete (now {new_ver or current}).', new_ver
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
'PACKAGE_NAME',
|
|
157
|
+
'PYPI_JSON_URL',
|
|
158
|
+
'PIP_UPGRADE_CMD',
|
|
159
|
+
'get_current_version',
|
|
160
|
+
'fetch_latest_version',
|
|
161
|
+
'is_newer',
|
|
162
|
+
'check_for_update',
|
|
163
|
+
'perform_upgrade',
|
|
164
|
+
]
|
|
@@ -28,6 +28,7 @@ import os
|
|
|
28
28
|
import sys
|
|
29
29
|
from typing import Optional
|
|
30
30
|
|
|
31
|
+
from . import __version__
|
|
31
32
|
from ._completion import (
|
|
32
33
|
SHELL_GENERATORS,
|
|
33
34
|
install_integration,
|
|
@@ -98,7 +99,7 @@ ALL_COMMANDS = [
|
|
|
98
99
|
'search', 'rename', 'copy',
|
|
99
100
|
'exec', 'loadmemory', 'inject',
|
|
100
101
|
'edit', 'info', 'diff', 'expand',
|
|
101
|
-
'validate', 'history', 'schema', 'completion', 'init',
|
|
102
|
+
'validate', 'history', 'schema', 'completion', 'init', 'upgrade',
|
|
102
103
|
]
|
|
103
104
|
|
|
104
105
|
|
|
@@ -169,7 +170,10 @@ Exit Codes:
|
|
|
169
170
|
""",
|
|
170
171
|
)
|
|
171
172
|
|
|
172
|
-
parser.add_argument(
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
'--version', action='version',
|
|
175
|
+
version=f'%(prog)s {__version__}',
|
|
176
|
+
)
|
|
173
177
|
parser.add_argument('-v', '--verbose', action='store_true',
|
|
174
178
|
help='Show detailed version information')
|
|
175
179
|
parser.add_argument('--env-file',
|
|
@@ -416,6 +420,17 @@ Exit Codes:
|
|
|
416
420
|
help='Report whether integration is installed (exit 0=yes, 1=no)',
|
|
417
421
|
)
|
|
418
422
|
|
|
423
|
+
# upgrade: 检查并升级到 PyPI 上的最新版本
|
|
424
|
+
up_p = _sp(
|
|
425
|
+
'upgrade',
|
|
426
|
+
help='Check for and install the latest evm version from PyPI',
|
|
427
|
+
)
|
|
428
|
+
up_p.add_argument(
|
|
429
|
+
'--check', action='store_true',
|
|
430
|
+
help='Only check for updates; report and exit '
|
|
431
|
+
'(0=up-to-date, 1=update available)',
|
|
432
|
+
)
|
|
433
|
+
|
|
419
434
|
return parser
|
|
420
435
|
|
|
421
436
|
|
|
@@ -456,8 +471,8 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
456
471
|
parser.print_help()
|
|
457
472
|
return 0
|
|
458
473
|
|
|
459
|
-
# 自动安装 shell 集成(跳过 init/completion 自身,避免递归/噪声)
|
|
460
|
-
if args.command not in ('init', 'completion'):
|
|
474
|
+
# 自动安装 shell 集成(跳过 init/completion/upgrade 自身,避免递归/噪声)
|
|
475
|
+
if args.command not in ('init', 'completion', 'upgrade'):
|
|
461
476
|
_ensure_shell_integration(quiet)
|
|
462
477
|
|
|
463
478
|
dry_run = getattr(args, 'dry_run', False)
|
|
@@ -1004,6 +1019,72 @@ def _cmd_init(mgr, args, dry_run, force, json_mode, quiet):
|
|
|
1004
1019
|
return 0
|
|
1005
1020
|
|
|
1006
1021
|
|
|
1022
|
+
def _cmd_upgrade(mgr, args, dry_run, force, json_mode, quiet):
|
|
1023
|
+
"""处理 upgrade 命令 —— 检查并升级到最新版本
|
|
1024
|
+
|
|
1025
|
+
- `evm upgrade` 检查并在有新版本时通过 pip 升级
|
|
1026
|
+
- `evm upgrade --check` 仅检查,不升级(0=已最新,1=有更新)
|
|
1027
|
+
- `--dry-run` 预览将要执行的 pip 命令
|
|
1028
|
+
- `--force` 跳过预检查,直接运行 pip
|
|
1029
|
+
"""
|
|
1030
|
+
from . import _upgrade
|
|
1031
|
+
|
|
1032
|
+
current = _upgrade.get_current_version()
|
|
1033
|
+
|
|
1034
|
+
if getattr(args, 'check', False):
|
|
1035
|
+
latest, available = _upgrade.check_for_update()
|
|
1036
|
+
if available is None:
|
|
1037
|
+
msg = (
|
|
1038
|
+
'Unable to check latest version '
|
|
1039
|
+
'(network unreachable or PyPI error).'
|
|
1040
|
+
)
|
|
1041
|
+
if json_mode:
|
|
1042
|
+
json_error(msg, 1, quiet)
|
|
1043
|
+
elif not quiet:
|
|
1044
|
+
print(f"Current version: {current}")
|
|
1045
|
+
print("Latest version: unknown (unable to reach PyPI)")
|
|
1046
|
+
return 1
|
|
1047
|
+
if json_mode:
|
|
1048
|
+
json_output(
|
|
1049
|
+
{
|
|
1050
|
+
'current': current,
|
|
1051
|
+
'latest': latest,
|
|
1052
|
+
'update_available': available,
|
|
1053
|
+
},
|
|
1054
|
+
quiet,
|
|
1055
|
+
)
|
|
1056
|
+
elif not quiet:
|
|
1057
|
+
print(f"Current version: {current}")
|
|
1058
|
+
print(f"Latest version: {latest}")
|
|
1059
|
+
if available:
|
|
1060
|
+
print("Update available! Run `evm upgrade` to install.")
|
|
1061
|
+
else:
|
|
1062
|
+
print("Already up to date.")
|
|
1063
|
+
return 0 if not available else 1
|
|
1064
|
+
|
|
1065
|
+
action, msg, new_ver = _upgrade.perform_upgrade(
|
|
1066
|
+
force=force, dry_run=dry_run
|
|
1067
|
+
)
|
|
1068
|
+
ok = action in ('upgraded', 'already_latest', 'dry_run')
|
|
1069
|
+
if json_mode:
|
|
1070
|
+
if ok:
|
|
1071
|
+
json_output(
|
|
1072
|
+
{
|
|
1073
|
+
'current': current,
|
|
1074
|
+
'new_version': new_ver,
|
|
1075
|
+
'action': action,
|
|
1076
|
+
'upgraded': action == 'upgraded',
|
|
1077
|
+
'message': msg,
|
|
1078
|
+
},
|
|
1079
|
+
quiet,
|
|
1080
|
+
)
|
|
1081
|
+
else:
|
|
1082
|
+
json_error(msg, 1, quiet)
|
|
1083
|
+
elif not quiet:
|
|
1084
|
+
print(msg)
|
|
1085
|
+
return 0 if ok else 1
|
|
1086
|
+
|
|
1087
|
+
|
|
1007
1088
|
def _ensure_shell_integration(quiet: bool) -> None:
|
|
1008
1089
|
"""在任意 evm 命令启动时检查并自动安装 shell 集成。
|
|
1009
1090
|
|
|
@@ -1066,6 +1147,7 @@ COMMAND_HANDLERS = {
|
|
|
1066
1147
|
'schema': _cmd_schema,
|
|
1067
1148
|
'completion': _cmd_completion,
|
|
1068
1149
|
'init': _cmd_init,
|
|
1150
|
+
'upgrade': _cmd_upgrade,
|
|
1069
1151
|
}
|
|
1070
1152
|
|
|
1071
1153
|
|
|
@@ -507,6 +507,8 @@ class EnvironmentManager(IOMixin, GroupMixin, HistoryMixin, SchemaMixin):
|
|
|
507
507
|
|
|
508
508
|
def info(self) -> dict[str, object]:
|
|
509
509
|
"""返回工具元信息"""
|
|
510
|
+
from . import __version__
|
|
511
|
+
|
|
510
512
|
groups = self.list_groups()
|
|
511
513
|
total_vars = len(self._env_vars)
|
|
512
514
|
secret_count = sum(
|
|
@@ -519,7 +521,7 @@ class EnvironmentManager(IOMixin, GroupMixin, HistoryMixin, SchemaMixin):
|
|
|
519
521
|
)
|
|
520
522
|
|
|
521
523
|
return {
|
|
522
|
-
'version':
|
|
524
|
+
'version': __version__,
|
|
523
525
|
'author': 'EVM Tool',
|
|
524
526
|
'license': 'MIT',
|
|
525
527
|
'python': sys.version.split()[0],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evm-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: A command-line tool for managing environment variables
|
|
5
5
|
Author-email: EVM Tool <evm@example.com>
|
|
6
6
|
License: MIT
|
|
@@ -42,7 +42,7 @@ Dynamic: license-file
|
|
|
42
42
|
|
|
43
43
|
A powerful command-line tool for managing environment variables on macOS and Linux systems.
|
|
44
44
|
|
|
45
|
-
**Version**: 2.
|
|
45
|
+
**Version**: 2.6.1
|
|
46
46
|
|
|
47
47
|
## Features
|
|
48
48
|
|
|
@@ -63,6 +63,7 @@ A powerful command-line tool for managing environment variables on macOS and Lin
|
|
|
63
63
|
- ✅ **History**: Operation audit log with JSONL storage
|
|
64
64
|
- ✅ **Shell Completion**: bash, zsh, fish completion script generation
|
|
65
65
|
- ✅ **Shell Integration**: `evm init` auto-installs `evm-load` + completion into your rc on first use
|
|
66
|
+
- ✅ **Self-Upgrade**: `evm upgrade` checks PyPI and pip-installs the latest version
|
|
66
67
|
- ✅ **Interactive Safety**: Confirmation prompts for destructive operations (`--force` to skip)
|
|
67
68
|
- ✅ **JSON Output**: `--json` flag for structured output (agent-friendly, stdout=data, stderr=errors)
|
|
68
69
|
- ✅ **Quiet Mode**: `--quiet` suppresses all human-readable output
|
|
@@ -97,6 +98,8 @@ evm/
|
|
|
97
98
|
│ ├── _completion.py # Shell completion generators (bash/zsh/fish)
|
|
98
99
|
│ ├── _json.py # JSON output helpers (agent-friendly)
|
|
99
100
|
│ ├── _crypto.py # HKDF + HMAC-CTR encryption module
|
|
101
|
+
│ ├── _upgrade.py # Self-upgrade: PyPI version check + pip install
|
|
102
|
+
│ ├── _typing.py # Shared typing helpers (Protocol mixins)
|
|
100
103
|
│ ├── formatters.py # Terminal output formatting
|
|
101
104
|
│ └── exceptions.py # Custom exception hierarchy (17 classes)
|
|
102
105
|
├── examples/ # Example scripts
|
|
@@ -104,8 +107,13 @@ evm/
|
|
|
104
107
|
│ ├── test_main.py # Unit + integration tests
|
|
105
108
|
│ ├── test_inject.py # `evm inject` + `evm-load` tests
|
|
106
109
|
│ ├── test_shell_integration.py # `evm init` + auto-install tests
|
|
110
|
+
│ ├── test_upgrade.py # `evm upgrade` tests
|
|
107
111
|
│ ├── test_io_boundary.py # _io.py boundary tests
|
|
108
112
|
│ ├── test_cli_boundary.py # cli.py boundary tests
|
|
113
|
+
│ ├── test_cli_additional.py # cli.py additional coverage
|
|
114
|
+
│ ├── test_cli_coverage.py # cli.py coverage gap tests
|
|
115
|
+
│ ├── test_main_entry.py # `evm` entry-point tests
|
|
116
|
+
│ ├── test_main_module.py # `python -m evm` entry tests
|
|
109
117
|
│ ├── test_v230_fixes.py # v2.3.0 code review fix tests
|
|
110
118
|
│ ├── test_coverage_gap.py # Coverage gap tests (98% target)
|
|
111
119
|
│ ├── test_formatters.py # Formatter output tests
|
|
@@ -327,6 +335,8 @@ evm exec -- COMMAND # Run with env vars
|
|
|
327
335
|
eval "$(evm inject)" # Load vars into current shell
|
|
328
336
|
evm backup # Create backup
|
|
329
337
|
evm validate # Check schemas
|
|
338
|
+
evm upgrade # Upgrade to latest PyPI release
|
|
339
|
+
evm upgrade --check # Check for updates only
|
|
330
340
|
```
|
|
331
341
|
|
|
332
342
|
## Usage
|
|
@@ -600,6 +610,35 @@ evm history --limit 50
|
|
|
600
610
|
evm history --clear
|
|
601
611
|
```
|
|
602
612
|
|
|
613
|
+
### Self-Upgrade (`evm upgrade`)
|
|
614
|
+
|
|
615
|
+
Check PyPI for a newer `evm` release and pip-install it in one step. Uses only the standard library — no extra dependencies.
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
# Check whether a newer version is available (no changes made)
|
|
619
|
+
evm upgrade --check
|
|
620
|
+
# exit 0 = up to date, exit 1 = update available (or network error)
|
|
621
|
+
|
|
622
|
+
# Upgrade to the latest release
|
|
623
|
+
evm upgrade
|
|
624
|
+
# → Upgraded from 2.5.0 to 2.6.0.
|
|
625
|
+
|
|
626
|
+
# Preview the pip command without running it
|
|
627
|
+
evm upgrade --dry-run
|
|
628
|
+
|
|
629
|
+
# Skip the pre-check and run pip directly
|
|
630
|
+
evm upgrade --force
|
|
631
|
+
|
|
632
|
+
# Structured JSON output (stdout = data, stderr = errors)
|
|
633
|
+
evm upgrade --check --json
|
|
634
|
+
# stdout: {"status": "ok", "data": {"current": "2.5.0", "latest": "2.6.0", "update_available": true}}
|
|
635
|
+
|
|
636
|
+
evm upgrade --json
|
|
637
|
+
# stdout: {"status": "ok", "data": {"current": "2.5.0", "new_version": "2.6.0", "action": "upgraded", "upgraded": true, "message": "Upgraded from 2.5.0 to 2.6.0."}}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`evm upgrade` calls `pip install --upgrade evm-cli` using the **same Python interpreter** that runs `evm`, so it upgrades the correct installation. If the network is unreachable, `--check` reports `unknown` and exits 1; a plain `evm upgrade` aborts before touching pip.
|
|
641
|
+
|
|
603
642
|
### Shell Integration (`evm init`)
|
|
604
643
|
|
|
605
644
|
EVM can install a shell-integration snippet into your rc file (`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config.fish`). The snippet is **one line** that re-evaluates `evm init` on every shell start — so `evm-load`, tab completion, and any future integration stay in sync with the installed `evm` version automatically (no need to re-install after an upgrade).
|
|
@@ -12,6 +12,7 @@ evm/_io.py
|
|
|
12
12
|
evm/_json.py
|
|
13
13
|
evm/_schema.py
|
|
14
14
|
evm/_typing.py
|
|
15
|
+
evm/_upgrade.py
|
|
15
16
|
evm/cli.py
|
|
16
17
|
evm/exceptions.py
|
|
17
18
|
evm/formatters.py
|
|
@@ -34,4 +35,5 @@ tests/test_main.py
|
|
|
34
35
|
tests/test_main_entry.py
|
|
35
36
|
tests/test_main_module.py
|
|
36
37
|
tests/test_shell_integration.py
|
|
38
|
+
tests/test_upgrade.py
|
|
37
39
|
tests/test_v230_fixes.py
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
evm upgrade 命令测试
|
|
4
|
+
|
|
5
|
+
覆盖:
|
|
6
|
+
- evm._upgrade 模块的纯函数(版本比较、PyPI 查询、升级执行)
|
|
7
|
+
- cli.py 的 evm upgrade 命令(--check / --dry-run / --json / --quiet / --force)
|
|
8
|
+
- upgrade 命令不触发 shell 集成自动安装
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import urllib.error
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from evm import __version__, _upgrade
|
|
19
|
+
from evm.cli import main
|
|
20
|
+
|
|
21
|
+
# ══════════════════════════════════════════════════════════════
|
|
22
|
+
# 辅助
|
|
23
|
+
# ══════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
class _FakeUrllibResponse:
|
|
26
|
+
"""模拟 urllib.request.urlopen 返回的上下文管理器"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, payload: bytes):
|
|
29
|
+
self._payload = payload
|
|
30
|
+
|
|
31
|
+
def __enter__(self):
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, *exc):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def read(self) -> bytes:
|
|
38
|
+
return self._payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pypi_payload(version: str) -> bytes:
|
|
42
|
+
return json.dumps({'info': {'version': version}}).encode('utf-8')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _env_file(tmp_path):
|
|
46
|
+
return str(tmp_path / 'env.json')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ══════════════════════════════════════════════════════════════
|
|
50
|
+
# 纯函数单元测试
|
|
51
|
+
# ══════════════════════════════════════════════════════════════
|
|
52
|
+
|
|
53
|
+
class TestVersionCompare:
|
|
54
|
+
"""_parse_version / is_newer"""
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize('remote,local,expected', [
|
|
57
|
+
('2.6.0', '2.5.0', True),
|
|
58
|
+
('2.5.0', '2.5.0', False),
|
|
59
|
+
('2.4.0', '2.5.0', False),
|
|
60
|
+
('2.5.1', '2.5.0', True),
|
|
61
|
+
('3.0.0', '2.9.9', True),
|
|
62
|
+
('2.5', '2.5.0', False), # 短版本补零后相等
|
|
63
|
+
('2.5.0.1', '2.5.0', True), # 多一段更新
|
|
64
|
+
('2.5.0', '2.5.0.1', False),
|
|
65
|
+
])
|
|
66
|
+
def test_is_newer(self, remote, local, expected):
|
|
67
|
+
assert _upgrade.is_newer(remote, local) is expected
|
|
68
|
+
|
|
69
|
+
def test_parse_version_non_numeric_segments_become_zero(self):
|
|
70
|
+
assert _upgrade._parse_version('1.2.x') == (1, 2, 0)
|
|
71
|
+
assert _upgrade._parse_version('1') == (1,)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestGetCurrentVersion:
|
|
75
|
+
def test_returns_package_version(self):
|
|
76
|
+
assert _upgrade.get_current_version() == __version__
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ══════════════════════════════════════════════════════════════
|
|
80
|
+
# fetch_latest_version — 网络层
|
|
81
|
+
# ══════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
class TestFetchLatestVersion:
|
|
84
|
+
def test_success_returns_version(self):
|
|
85
|
+
with patch('evm._upgrade.urllib.request.urlopen') as mock_open:
|
|
86
|
+
mock_open.return_value = _FakeUrllibResponse(_pypi_payload('9.9.9'))
|
|
87
|
+
assert _upgrade.fetch_latest_version() == '9.9.9'
|
|
88
|
+
|
|
89
|
+
def test_url_error_returns_none(self):
|
|
90
|
+
with patch('evm._upgrade.urllib.request.urlopen',
|
|
91
|
+
side_effect=urllib.error.URLError('boom')):
|
|
92
|
+
assert _upgrade.fetch_latest_version() is None
|
|
93
|
+
|
|
94
|
+
def test_oserror_returns_none(self):
|
|
95
|
+
with patch('evm._upgrade.urllib.request.urlopen',
|
|
96
|
+
side_effect=OSError('boom')):
|
|
97
|
+
assert _upgrade.fetch_latest_version() is None
|
|
98
|
+
|
|
99
|
+
def test_bad_json_returns_none(self):
|
|
100
|
+
with patch('evm._upgrade.urllib.request.urlopen') as mock_open:
|
|
101
|
+
mock_open.return_value = _FakeUrllibResponse(b'not json')
|
|
102
|
+
assert _upgrade.fetch_latest_version() is None
|
|
103
|
+
|
|
104
|
+
def test_missing_version_key_returns_none(self):
|
|
105
|
+
with patch('evm._upgrade.urllib.request.urlopen') as mock_open:
|
|
106
|
+
mock_open.return_value = _FakeUrllibResponse(b'{"info": {}}')
|
|
107
|
+
assert _upgrade.fetch_latest_version() is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ══════════════════════════════════════════════════════════════
|
|
111
|
+
# check_for_update
|
|
112
|
+
# ══════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
class TestCheckForUpdate:
|
|
115
|
+
def test_update_available(self, monkeypatch):
|
|
116
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
117
|
+
lambda timeout=10.0: '9.9.9')
|
|
118
|
+
latest, available = _upgrade.check_for_update()
|
|
119
|
+
assert latest == '9.9.9'
|
|
120
|
+
assert available is True
|
|
121
|
+
|
|
122
|
+
def test_already_latest(self, monkeypatch):
|
|
123
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
124
|
+
lambda timeout=10.0: __version__)
|
|
125
|
+
latest, available = _upgrade.check_for_update()
|
|
126
|
+
assert latest == __version__
|
|
127
|
+
assert available is False
|
|
128
|
+
|
|
129
|
+
def test_network_unavailable(self, monkeypatch):
|
|
130
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
131
|
+
lambda timeout=10.0: None)
|
|
132
|
+
latest, available = _upgrade.check_for_update()
|
|
133
|
+
assert latest is None
|
|
134
|
+
assert available is None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ══════════════════════════════════════════════════════════════
|
|
138
|
+
# perform_upgrade — 子进程层
|
|
139
|
+
# ══════════════════════════════════════════════════════════════
|
|
140
|
+
|
|
141
|
+
def _completed(returncode=0, stdout='', stderr=''):
|
|
142
|
+
return subprocess.CompletedProcess(
|
|
143
|
+
args=['pip'], returncode=returncode,
|
|
144
|
+
stdout=stdout, stderr=stderr,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestPerformUpgrade:
|
|
149
|
+
def test_already_latest(self, monkeypatch):
|
|
150
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
151
|
+
lambda timeout=10.0: __version__)
|
|
152
|
+
action, msg, new_ver = _upgrade.perform_upgrade()
|
|
153
|
+
assert action == 'already_latest'
|
|
154
|
+
assert __version__ in msg
|
|
155
|
+
assert new_ver == __version__
|
|
156
|
+
|
|
157
|
+
def test_network_error(self, monkeypatch):
|
|
158
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
159
|
+
lambda timeout=10.0: None)
|
|
160
|
+
action, msg, new_ver = _upgrade.perform_upgrade()
|
|
161
|
+
assert action == 'network_error'
|
|
162
|
+
assert new_ver == ''
|
|
163
|
+
|
|
164
|
+
def test_dry_run_with_update(self, monkeypatch):
|
|
165
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
166
|
+
lambda timeout=10.0: '9.9.9')
|
|
167
|
+
action, msg, new_ver = _upgrade.perform_upgrade(dry_run=True)
|
|
168
|
+
assert action == 'dry_run'
|
|
169
|
+
assert '[DRY-RUN]' in msg
|
|
170
|
+
assert 'pip' in msg
|
|
171
|
+
assert new_ver == ''
|
|
172
|
+
|
|
173
|
+
def test_dry_run_force_skips_check(self, monkeypatch):
|
|
174
|
+
# force=True 应跳过 check_for_update,直接 dry-run
|
|
175
|
+
called = {'check': False}
|
|
176
|
+
|
|
177
|
+
def fake_check(timeout=10.0):
|
|
178
|
+
called['check'] = True
|
|
179
|
+
return None, None
|
|
180
|
+
|
|
181
|
+
monkeypatch.setattr(_upgrade, 'check_for_update', fake_check)
|
|
182
|
+
action, msg, new_ver = _upgrade.perform_upgrade(force=True, dry_run=True)
|
|
183
|
+
assert action == 'dry_run'
|
|
184
|
+
assert called['check'] is False
|
|
185
|
+
|
|
186
|
+
def test_upgrade_success(self, monkeypatch):
|
|
187
|
+
# 第一次 fetch(预检查)返回更新版本,第二次(升级后)也返回该版本
|
|
188
|
+
versions = iter(['9.9.9', '9.9.9'])
|
|
189
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
190
|
+
lambda timeout=10.0: next(versions))
|
|
191
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run',
|
|
192
|
+
lambda *a, **kw: _completed(returncode=0))
|
|
193
|
+
action, msg, new_ver = _upgrade.perform_upgrade()
|
|
194
|
+
assert action == 'upgraded'
|
|
195
|
+
assert '9.9.9' in msg
|
|
196
|
+
assert new_ver == '9.9.9'
|
|
197
|
+
|
|
198
|
+
def test_upgrade_pip_failure(self, monkeypatch):
|
|
199
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
200
|
+
lambda timeout=10.0: '9.9.9')
|
|
201
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run',
|
|
202
|
+
lambda *a, **kw: _completed(
|
|
203
|
+
returncode=1, stderr='nope'))
|
|
204
|
+
action, msg, new_ver = _upgrade.perform_upgrade()
|
|
205
|
+
assert action == 'failed'
|
|
206
|
+
assert 'exit 1' in msg
|
|
207
|
+
assert 'nope' in msg
|
|
208
|
+
assert new_ver == ''
|
|
209
|
+
|
|
210
|
+
def test_upgrade_oserror(self, monkeypatch):
|
|
211
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
212
|
+
lambda timeout=10.0: '9.9.9')
|
|
213
|
+
|
|
214
|
+
def raise_oserror(*a, **kw):
|
|
215
|
+
raise OSError('pip missing')
|
|
216
|
+
|
|
217
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run', raise_oserror)
|
|
218
|
+
action, msg, new_ver = _upgrade.perform_upgrade()
|
|
219
|
+
assert action == 'failed'
|
|
220
|
+
assert 'pip missing' in msg
|
|
221
|
+
|
|
222
|
+
def test_upgrade_timeout(self, monkeypatch):
|
|
223
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
224
|
+
lambda timeout=10.0: '9.9.9')
|
|
225
|
+
|
|
226
|
+
def raise_timeout(*a, **kw):
|
|
227
|
+
raise subprocess.TimeoutExpired(cmd='pip', timeout=1)
|
|
228
|
+
|
|
229
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run', raise_timeout)
|
|
230
|
+
action, msg, new_ver = _upgrade.perform_upgrade()
|
|
231
|
+
assert action == 'failed'
|
|
232
|
+
|
|
233
|
+
def test_force_runs_pip_without_precheck(self, monkeypatch):
|
|
234
|
+
called = {'check': False, 'pip': False}
|
|
235
|
+
|
|
236
|
+
def fake_check(timeout=10.0):
|
|
237
|
+
called['check'] = True
|
|
238
|
+
return None, None
|
|
239
|
+
|
|
240
|
+
def fake_run(*a, **kw):
|
|
241
|
+
called['pip'] = True
|
|
242
|
+
return _completed(returncode=0)
|
|
243
|
+
|
|
244
|
+
monkeypatch.setattr(_upgrade, 'check_for_update', fake_check)
|
|
245
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
246
|
+
lambda timeout=10.0: '9.9.9')
|
|
247
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run', fake_run)
|
|
248
|
+
action, msg, new_ver = _upgrade.perform_upgrade(force=True)
|
|
249
|
+
assert action == 'upgraded'
|
|
250
|
+
assert called['check'] is False
|
|
251
|
+
assert called['pip'] is True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ══════════════════════════════════════════════════════════════
|
|
255
|
+
# CLI: evm upgrade --check
|
|
256
|
+
# ══════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
class TestCmdUpgradeCheck:
|
|
259
|
+
"""evm upgrade --check 子命令"""
|
|
260
|
+
|
|
261
|
+
def test_check_up_to_date(self, capsys, monkeypatch, tmp_path):
|
|
262
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
263
|
+
lambda timeout=10.0: __version__)
|
|
264
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade', '--check'])
|
|
265
|
+
out, _ = capsys.readouterr()
|
|
266
|
+
assert code == 0
|
|
267
|
+
assert 'Already up to date.' in out
|
|
268
|
+
assert __version__ in out
|
|
269
|
+
|
|
270
|
+
def test_check_update_available(self, capsys, monkeypatch, tmp_path):
|
|
271
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
272
|
+
lambda timeout=10.0: '9.9.9')
|
|
273
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade', '--check'])
|
|
274
|
+
out, _ = capsys.readouterr()
|
|
275
|
+
assert code == 1
|
|
276
|
+
assert 'Update available!' in out
|
|
277
|
+
assert '9.9.9' in out
|
|
278
|
+
|
|
279
|
+
def test_check_network_error(self, capsys, monkeypatch, tmp_path):
|
|
280
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
281
|
+
lambda timeout=10.0: None)
|
|
282
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade', '--check'])
|
|
283
|
+
out, _ = capsys.readouterr()
|
|
284
|
+
assert code == 1
|
|
285
|
+
assert 'unknown' in out
|
|
286
|
+
|
|
287
|
+
def test_check_json_up_to_date(self, capsys, monkeypatch, tmp_path):
|
|
288
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
289
|
+
lambda timeout=10.0: __version__)
|
|
290
|
+
code = main(['--json', '--env-file', _env_file(tmp_path),
|
|
291
|
+
'upgrade', '--check'])
|
|
292
|
+
out, _ = capsys.readouterr()
|
|
293
|
+
assert code == 0
|
|
294
|
+
data = json.loads(out)
|
|
295
|
+
assert data['status'] == 'ok'
|
|
296
|
+
assert data['data']['current'] == __version__
|
|
297
|
+
assert data['data']['update_available'] is False
|
|
298
|
+
|
|
299
|
+
def test_check_json_update_available(self, capsys, monkeypatch, tmp_path):
|
|
300
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
301
|
+
lambda timeout=10.0: '9.9.9')
|
|
302
|
+
code = main(['--json', '--env-file', _env_file(tmp_path),
|
|
303
|
+
'upgrade', '--check'])
|
|
304
|
+
out, _ = capsys.readouterr()
|
|
305
|
+
assert code == 1
|
|
306
|
+
data = json.loads(out)
|
|
307
|
+
assert data['data']['update_available'] is True
|
|
308
|
+
assert data['data']['latest'] == '9.9.9'
|
|
309
|
+
|
|
310
|
+
def test_check_json_network_error(self, capsys, monkeypatch, tmp_path):
|
|
311
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
312
|
+
lambda timeout=10.0: None)
|
|
313
|
+
code = main(['--json', '--env-file', _env_file(tmp_path),
|
|
314
|
+
'upgrade', '--check'])
|
|
315
|
+
out, err = capsys.readouterr()
|
|
316
|
+
assert code == 1
|
|
317
|
+
assert out == ''
|
|
318
|
+
data = json.loads(err)
|
|
319
|
+
assert data['status'] == 'error'
|
|
320
|
+
|
|
321
|
+
def test_check_quiet_no_output(self, capsys, monkeypatch, tmp_path):
|
|
322
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
323
|
+
lambda timeout=10.0: '9.9.9')
|
|
324
|
+
code = main(['--quiet', '--env-file', _env_file(tmp_path),
|
|
325
|
+
'upgrade', '--check'])
|
|
326
|
+
out, _ = capsys.readouterr()
|
|
327
|
+
assert code == 1
|
|
328
|
+
assert out == ''
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ══════════════════════════════════════════════════════════════
|
|
332
|
+
# CLI: evm upgrade(实际升级 / dry-run)
|
|
333
|
+
# ══════════════════════════════════════════════════════════════
|
|
334
|
+
|
|
335
|
+
class TestCmdUpgrade:
|
|
336
|
+
"""evm upgrade(非 --check)"""
|
|
337
|
+
|
|
338
|
+
def test_dry_run(self, capsys, monkeypatch, tmp_path):
|
|
339
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
340
|
+
lambda timeout=10.0: '9.9.9')
|
|
341
|
+
ran = {'pip': False}
|
|
342
|
+
|
|
343
|
+
def fake_run(*a, **kw):
|
|
344
|
+
ran['pip'] = True
|
|
345
|
+
return _completed(returncode=0)
|
|
346
|
+
|
|
347
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run', fake_run)
|
|
348
|
+
code = main(['--env-file', _env_file(tmp_path),
|
|
349
|
+
'upgrade', '--dry-run'])
|
|
350
|
+
out, _ = capsys.readouterr()
|
|
351
|
+
assert code == 0
|
|
352
|
+
assert '[DRY-RUN]' in out
|
|
353
|
+
assert 'pip' in out
|
|
354
|
+
assert ran['pip'] is False # dry-run 不能真正调用 pip
|
|
355
|
+
|
|
356
|
+
def test_already_latest(self, capsys, monkeypatch, tmp_path):
|
|
357
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
358
|
+
lambda timeout=10.0: __version__)
|
|
359
|
+
ran = {'pip': False}
|
|
360
|
+
|
|
361
|
+
def fake_run(*a, **kw):
|
|
362
|
+
ran['pip'] = True
|
|
363
|
+
return _completed(returncode=0)
|
|
364
|
+
|
|
365
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run', fake_run)
|
|
366
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade'])
|
|
367
|
+
out, _ = capsys.readouterr()
|
|
368
|
+
assert code == 0
|
|
369
|
+
assert 'Already up to date' in out
|
|
370
|
+
assert ran['pip'] is False # 已最新则不调用 pip
|
|
371
|
+
|
|
372
|
+
def test_upgrade_success(self, capsys, monkeypatch, tmp_path):
|
|
373
|
+
versions = iter(['9.9.9', '9.9.9'])
|
|
374
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
375
|
+
lambda timeout=10.0: next(versions))
|
|
376
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run',
|
|
377
|
+
lambda *a, **kw: _completed(returncode=0))
|
|
378
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade'])
|
|
379
|
+
out, _ = capsys.readouterr()
|
|
380
|
+
assert code == 0
|
|
381
|
+
assert 'Upgraded' in out
|
|
382
|
+
assert '9.9.9' in out
|
|
383
|
+
|
|
384
|
+
def test_upgrade_pip_failure(self, capsys, monkeypatch, tmp_path):
|
|
385
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
386
|
+
lambda timeout=10.0: '9.9.9')
|
|
387
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run',
|
|
388
|
+
lambda *a, **kw: _completed(
|
|
389
|
+
returncode=1, stderr='permission denied'))
|
|
390
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade'])
|
|
391
|
+
out, _ = capsys.readouterr()
|
|
392
|
+
assert code == 1
|
|
393
|
+
assert 'failed' in out.lower()
|
|
394
|
+
|
|
395
|
+
def test_upgrade_network_error(self, capsys, monkeypatch, tmp_path):
|
|
396
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
397
|
+
lambda timeout=10.0: None)
|
|
398
|
+
code = main(['--env-file', _env_file(tmp_path), 'upgrade'])
|
|
399
|
+
out, _ = capsys.readouterr()
|
|
400
|
+
assert code == 1
|
|
401
|
+
assert 'Unable' in out
|
|
402
|
+
|
|
403
|
+
def test_force_skips_precheck(self, capsys, monkeypatch, tmp_path):
|
|
404
|
+
called = {'check': False, 'pip': False}
|
|
405
|
+
|
|
406
|
+
def fake_check(timeout=10.0):
|
|
407
|
+
called['check'] = True
|
|
408
|
+
return None, None
|
|
409
|
+
|
|
410
|
+
def fake_run(*a, **kw):
|
|
411
|
+
called['pip'] = True
|
|
412
|
+
return _completed(returncode=0)
|
|
413
|
+
|
|
414
|
+
monkeypatch.setattr(_upgrade, 'check_for_update', fake_check)
|
|
415
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
416
|
+
lambda timeout=10.0: '9.9.9')
|
|
417
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run', fake_run)
|
|
418
|
+
code = main(['--env-file', _env_file(tmp_path),
|
|
419
|
+
'upgrade', '--force'])
|
|
420
|
+
out, _ = capsys.readouterr()
|
|
421
|
+
assert code == 0
|
|
422
|
+
assert called['check'] is False
|
|
423
|
+
assert called['pip'] is True
|
|
424
|
+
|
|
425
|
+
def test_upgrade_json_success(self, capsys, monkeypatch, tmp_path):
|
|
426
|
+
versions = iter(['9.9.9', '9.9.9'])
|
|
427
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
428
|
+
lambda timeout=10.0: next(versions))
|
|
429
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run',
|
|
430
|
+
lambda *a, **kw: _completed(returncode=0))
|
|
431
|
+
code = main(['--json', '--env-file', _env_file(tmp_path), 'upgrade'])
|
|
432
|
+
out, _ = capsys.readouterr()
|
|
433
|
+
assert code == 0
|
|
434
|
+
data = json.loads(out)
|
|
435
|
+
assert data['status'] == 'ok'
|
|
436
|
+
assert data['data']['action'] == 'upgraded'
|
|
437
|
+
assert data['data']['upgraded'] is True
|
|
438
|
+
assert data['data']['new_version'] == '9.9.9'
|
|
439
|
+
|
|
440
|
+
def test_upgrade_json_failure(self, capsys, monkeypatch, tmp_path):
|
|
441
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
442
|
+
lambda timeout=10.0: '9.9.9')
|
|
443
|
+
monkeypatch.setattr(_upgrade.subprocess, 'run',
|
|
444
|
+
lambda *a, **kw: _completed(returncode=1))
|
|
445
|
+
code = main(['--json', '--env-file', _env_file(tmp_path), 'upgrade'])
|
|
446
|
+
out, err = capsys.readouterr()
|
|
447
|
+
assert code == 1
|
|
448
|
+
assert out == ''
|
|
449
|
+
data = json.loads(err)
|
|
450
|
+
assert data['status'] == 'error'
|
|
451
|
+
|
|
452
|
+
def test_upgrade_quiet_no_output(self, capsys, monkeypatch, tmp_path):
|
|
453
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
454
|
+
lambda timeout=10.0: __version__)
|
|
455
|
+
code = main(['--quiet', '--env-file', _env_file(tmp_path), 'upgrade'])
|
|
456
|
+
out, _ = capsys.readouterr()
|
|
457
|
+
assert code == 0
|
|
458
|
+
assert out == ''
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ══════════════════════════════════════════════════════════════
|
|
462
|
+
# upgrade 不触发 shell 集成自动安装
|
|
463
|
+
# ══════════════════════════════════════════════════════════════
|
|
464
|
+
|
|
465
|
+
class TestUpgradeSkipsAutoInstall:
|
|
466
|
+
"""evm upgrade 命令自身不应触发 shell 集成自动安装"""
|
|
467
|
+
|
|
468
|
+
def test_no_auto_install_on_check(self, capsys, monkeypatch, tmp_path):
|
|
469
|
+
from evm._completion import is_integration_installed
|
|
470
|
+
monkeypatch.setenv('HOME', str(tmp_path))
|
|
471
|
+
monkeypatch.setenv('SHELL', '/bin/zsh')
|
|
472
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
473
|
+
lambda timeout=10.0: __version__)
|
|
474
|
+
main(['--env-file', _env_file(tmp_path), 'upgrade', '--check'])
|
|
475
|
+
_, err = capsys.readouterr()
|
|
476
|
+
assert 'Installed' not in err
|
|
477
|
+
assert not is_integration_installed('zsh')
|
|
478
|
+
|
|
479
|
+
def test_no_auto_install_on_upgrade(self, capsys, monkeypatch, tmp_path):
|
|
480
|
+
from evm._completion import is_integration_installed
|
|
481
|
+
monkeypatch.setenv('HOME', str(tmp_path))
|
|
482
|
+
monkeypatch.setenv('SHELL', '/bin/zsh')
|
|
483
|
+
monkeypatch.setattr(_upgrade, 'fetch_latest_version',
|
|
484
|
+
lambda timeout=10.0: __version__)
|
|
485
|
+
main(['--env-file', _env_file(tmp_path), 'upgrade'])
|
|
486
|
+
_, err = capsys.readouterr()
|
|
487
|
+
assert 'Installed' not in err
|
|
488
|
+
assert not is_integration_installed('zsh')
|
|
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
|