evm-cli 2.5.0__tar.gz → 2.6.0__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.
Files changed (41) hide show
  1. {evm_cli-2.5.0 → evm_cli-2.6.0}/PKG-INFO +41 -2
  2. {evm_cli-2.5.0 → evm_cli-2.6.0}/README.md +40 -1
  3. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/__init__.py +1 -1
  4. evm_cli-2.6.0/evm/_upgrade.py +164 -0
  5. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/cli.py +86 -4
  6. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/manager.py +3 -1
  7. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/PKG-INFO +41 -2
  8. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/SOURCES.txt +2 -0
  9. evm_cli-2.6.0/tests/test_upgrade.py +488 -0
  10. {evm_cli-2.5.0 → evm_cli-2.6.0}/LICENSE +0 -0
  11. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/__main__.py +0 -0
  12. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_completion.py +0 -0
  13. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_crypto.py +0 -0
  14. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_groups.py +0 -0
  15. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_history.py +0 -0
  16. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_io.py +0 -0
  17. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_json.py +0 -0
  18. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_schema.py +0 -0
  19. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/_typing.py +0 -0
  20. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/exceptions.py +0 -0
  21. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm/formatters.py +0 -0
  22. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/dependency_links.txt +0 -0
  23. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/entry_points.txt +0 -0
  24. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/not-zip-safe +0 -0
  25. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/requires.txt +0 -0
  26. {evm_cli-2.5.0 → evm_cli-2.6.0}/evm_cli.egg-info/top_level.txt +0 -0
  27. {evm_cli-2.5.0 → evm_cli-2.6.0}/pyproject.toml +0 -0
  28. {evm_cli-2.5.0 → evm_cli-2.6.0}/setup.cfg +0 -0
  29. {evm_cli-2.5.0 → evm_cli-2.6.0}/setup.py +0 -0
  30. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_cli_additional.py +0 -0
  31. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_cli_boundary.py +0 -0
  32. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_cli_coverage.py +0 -0
  33. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_coverage_gap.py +0 -0
  34. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_formatters.py +0 -0
  35. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_inject.py +0 -0
  36. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_io_boundary.py +0 -0
  37. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_main.py +0 -0
  38. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_main_entry.py +0 -0
  39. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_main_module.py +0 -0
  40. {evm_cli-2.5.0 → evm_cli-2.6.0}/tests/test_shell_integration.py +0 -0
  41. {evm_cli-2.5.0 → evm_cli-2.6.0}/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.5.0
3
+ Version: 2.6.0
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.5.0
45
+ **Version**: 2.6.0
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.0
5
+ **Version**: 2.6.0
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).
@@ -3,7 +3,7 @@ EVM - Environment Variable Manager
3
3
  A command-line tool for managing environment variables.
4
4
  """
5
5
 
6
- __version__ = "2.5.0"
6
+ __version__ = "2.6.0"
7
7
  __author__ = "EVM Tool"
8
8
 
9
9
  from .exceptions import (
@@ -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('--version', action='version', version='%(prog)s 2.3.0')
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': '2.3.0',
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.5.0
3
+ Version: 2.6.0
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.5.0
45
+ **Version**: 2.6.0
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