evm-cli 2.4.0__py3-none-any.whl
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/__init__.py +55 -0
- evm/__main__.py +9 -0
- evm/_completion.py +253 -0
- evm/_crypto.py +177 -0
- evm/_groups.py +127 -0
- evm/_history.py +141 -0
- evm/_io.py +421 -0
- evm/_json.py +52 -0
- evm/_schema.py +265 -0
- evm/_typing.py +40 -0
- evm/cli.py +961 -0
- evm/exceptions.py +150 -0
- evm/formatters.py +285 -0
- evm/manager.py +644 -0
- evm_cli-2.4.0.dist-info/METADATA +748 -0
- evm_cli-2.4.0.dist-info/RECORD +20 -0
- evm_cli-2.4.0.dist-info/WHEEL +5 -0
- evm_cli-2.4.0.dist-info/entry_points.txt +2 -0
- evm_cli-2.4.0.dist-info/licenses/LICENSE +21 -0
- evm_cli-2.4.0.dist-info/top_level.txt +1 -0
evm/_history.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EVM 操作历史 Mixin
|
|
4
|
+
|
|
5
|
+
记录操作日志到 history.jsonl(JSON Lines 格式),与 env.json 同目录。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ._typing import EnvironmentManagerProtocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HistoryMixin(EnvironmentManagerProtocol):
|
|
18
|
+
"""操作历史 mixin — 记录和查看操作日志"""
|
|
19
|
+
|
|
20
|
+
MAX_HISTORY_ENTRIES = 1000
|
|
21
|
+
|
|
22
|
+
def _get_history_file(self) -> Path:
|
|
23
|
+
"""获取历史文件路径(与 env.json 同目录)"""
|
|
24
|
+
return self.env_file.parent / 'history.jsonl'
|
|
25
|
+
|
|
26
|
+
def log_operation(
|
|
27
|
+
self,
|
|
28
|
+
operation: str,
|
|
29
|
+
key: str = '',
|
|
30
|
+
details: str = '',
|
|
31
|
+
status: str = 'success',
|
|
32
|
+
) -> None:
|
|
33
|
+
"""记录操作日志(静默失败,不影响主流程)
|
|
34
|
+
|
|
35
|
+
仅捕获 OSError,避免吞没编程错误。
|
|
36
|
+
创建文件时设置 chmod 600。
|
|
37
|
+
使用文件锁防止并发追加时行交错。
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
history_file = self._get_history_file()
|
|
41
|
+
is_new = not history_file.exists()
|
|
42
|
+
|
|
43
|
+
entry = {
|
|
44
|
+
'timestamp': datetime.now().isoformat(),
|
|
45
|
+
'operation': operation,
|
|
46
|
+
'key': key,
|
|
47
|
+
'details': details,
|
|
48
|
+
'status': status,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fd = os.open(str(history_file), os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
|
|
52
|
+
try:
|
|
53
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
54
|
+
try:
|
|
55
|
+
line = json.dumps(entry, ensure_ascii=False) + '\n'
|
|
56
|
+
os.write(fd, line.encode('utf-8'))
|
|
57
|
+
finally:
|
|
58
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
59
|
+
finally:
|
|
60
|
+
os.close(fd)
|
|
61
|
+
|
|
62
|
+
# 新建文件时设置权限
|
|
63
|
+
if is_new:
|
|
64
|
+
os.chmod(str(history_file), 0o600)
|
|
65
|
+
|
|
66
|
+
# 惰性裁切:仅在超过 1.5 倍上限时触发,避免每次 O(N) 扫描
|
|
67
|
+
self._trim_history_if_needed()
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def get_history(
|
|
72
|
+
self, limit: int = 20, offset: int = 0
|
|
73
|
+
) -> list[dict]:
|
|
74
|
+
"""获取操作历史(最新在前)"""
|
|
75
|
+
history_file = self._get_history_file()
|
|
76
|
+
if not history_file.exists():
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
entries = []
|
|
80
|
+
try:
|
|
81
|
+
with open(history_file, encoding='utf-8') as f:
|
|
82
|
+
for line in f:
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if line:
|
|
85
|
+
try:
|
|
86
|
+
entries.append(json.loads(line))
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
continue
|
|
89
|
+
except OSError:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
# 最新在前
|
|
93
|
+
entries.reverse()
|
|
94
|
+
return entries[offset:offset + limit]
|
|
95
|
+
|
|
96
|
+
def clear_history(self) -> str:
|
|
97
|
+
"""清空操作历史"""
|
|
98
|
+
history_file = self._get_history_file()
|
|
99
|
+
if history_file.exists():
|
|
100
|
+
os.unlink(history_file)
|
|
101
|
+
return "History cleared"
|
|
102
|
+
return "No history to clear"
|
|
103
|
+
|
|
104
|
+
def _trim_history_if_needed(self) -> None:
|
|
105
|
+
"""惰性裁切:仅在行数超过 1.5 倍上限时触发。
|
|
106
|
+
|
|
107
|
+
使用原子写入(先写临时文件,再 rename)防止崩溃导致历史丢失。
|
|
108
|
+
"""
|
|
109
|
+
history_file = self._get_history_file()
|
|
110
|
+
if not history_file.exists():
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
threshold = int(self.MAX_HISTORY_ENTRIES * 1.5)
|
|
114
|
+
tmp_path = str(history_file) + '.trim.tmp'
|
|
115
|
+
try:
|
|
116
|
+
with open(history_file, encoding='utf-8') as f:
|
|
117
|
+
lines = f.readlines()
|
|
118
|
+
|
|
119
|
+
if len(lines) <= threshold:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# 保留最新的一半
|
|
123
|
+
keep = lines[len(lines) // 2:]
|
|
124
|
+
|
|
125
|
+
# 原子写入:先写临时文件,再 rename
|
|
126
|
+
with open(tmp_path, 'w', encoding='utf-8') as f:
|
|
127
|
+
f.writelines(keep)
|
|
128
|
+
f.flush()
|
|
129
|
+
os.fsync(f.fileno())
|
|
130
|
+
os.replace(tmp_path, str(history_file))
|
|
131
|
+
try:
|
|
132
|
+
os.chmod(str(history_file), 0o600)
|
|
133
|
+
except OSError:
|
|
134
|
+
pass # chmod 失败不影响功能,权限问题由 log_operation 下次写入时自愈
|
|
135
|
+
except OSError:
|
|
136
|
+
# 清理临时文件
|
|
137
|
+
if os.path.exists(tmp_path):
|
|
138
|
+
try:
|
|
139
|
+
os.unlink(tmp_path)
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
evm/_io.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EVM 导入导出 Mixin
|
|
4
|
+
|
|
5
|
+
从 manager.py 中提取的 IO 功能,load() 已重构为多个独立方法。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shlex
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ._schema import VALID_KEY_PATTERN
|
|
14
|
+
from ._typing import EnvironmentManagerProtocol
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
BackupError,
|
|
17
|
+
ExportError,
|
|
18
|
+
GroupNotFoundError,
|
|
19
|
+
ImportFailedError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_env_value(raw: str) -> str:
|
|
24
|
+
"""#8: 解析 .env 值,正确处理平衡引号
|
|
25
|
+
|
|
26
|
+
支持: "double quoted", 'single quoted', unquoted
|
|
27
|
+
不平衡引号(如 "value')按字面量处理。
|
|
28
|
+
"""
|
|
29
|
+
stripped = raw.strip()
|
|
30
|
+
if len(stripped) >= 2:
|
|
31
|
+
if stripped[0] == '"' and stripped[-1] == '"':
|
|
32
|
+
# 平衡双引号:去掉引号,处理转义
|
|
33
|
+
inner = stripped[1:-1]
|
|
34
|
+
return inner.replace('\\"', '"').replace('\\\\', '\\')
|
|
35
|
+
if stripped[0] == "'" and stripped[-1] == "'":
|
|
36
|
+
# 平衡单引号:去掉引号,不处理转义
|
|
37
|
+
return stripped[1:-1]
|
|
38
|
+
# 无引号或不平衡:原样返回(仅去除首尾空白)
|
|
39
|
+
return stripped
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_key_name(key: str) -> bool:
|
|
43
|
+
"""#9: 校验环境变量 key 名是否安全"""
|
|
44
|
+
return bool(VALID_KEY_PATTERN.match(key))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class IOMixin(EnvironmentManagerProtocol):
|
|
48
|
+
"""导入导出 mixin — 提供 load/export/backup/restore/diff"""
|
|
49
|
+
|
|
50
|
+
# ── 格式检测与加载辅助方法 ────────────────────────────
|
|
51
|
+
|
|
52
|
+
def _detect_format(self, path: Path, format_type: Optional[str]) -> str:
|
|
53
|
+
"""检测文件格式
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
path: 文件路径
|
|
57
|
+
format_type: 强制指定的格式
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
'json', 'env', 或 'backup'
|
|
61
|
+
"""
|
|
62
|
+
if format_type:
|
|
63
|
+
return format_type.lower()
|
|
64
|
+
if path.suffix in ['.json', '.backup']:
|
|
65
|
+
return 'json'
|
|
66
|
+
if path.suffix == '.env':
|
|
67
|
+
return 'env'
|
|
68
|
+
# 内容嗅探
|
|
69
|
+
try:
|
|
70
|
+
with open(path, encoding='utf-8') as f:
|
|
71
|
+
content = f.read(100)
|
|
72
|
+
return 'json' if content.strip().startswith('{') else 'env'
|
|
73
|
+
except OSError:
|
|
74
|
+
return 'json'
|
|
75
|
+
|
|
76
|
+
def _load_json_file(self, path: Path) -> dict:
|
|
77
|
+
"""加载 JSON 文件
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ImportFailedError: JSON 解析失败
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
with open(path, encoding='utf-8') as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ImportFailedError(
|
|
87
|
+
"Invalid JSON format: expected a dictionary",
|
|
88
|
+
str(path),
|
|
89
|
+
)
|
|
90
|
+
return data
|
|
91
|
+
except json.JSONDecodeError as e:
|
|
92
|
+
raise ImportFailedError(
|
|
93
|
+
f"JSON parse error: {e}", str(path)
|
|
94
|
+
) from e
|
|
95
|
+
|
|
96
|
+
def _load_env_file(self, path: Path) -> tuple[dict[str, str], list[str]]:
|
|
97
|
+
"""加载 .env 文件
|
|
98
|
+
|
|
99
|
+
#8: 使用平衡引号解析
|
|
100
|
+
#9: 校验 key 名安全性
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
(loaded_vars, skipped_keys) — 跳过无效 key 时返回其列表。
|
|
104
|
+
"""
|
|
105
|
+
loaded: dict[str, str] = {}
|
|
106
|
+
skipped: list[str] = []
|
|
107
|
+
with open(path, encoding='utf-8') as f:
|
|
108
|
+
for line in f:
|
|
109
|
+
line = line.strip()
|
|
110
|
+
if line and not line.startswith('#'):
|
|
111
|
+
if '=' in line:
|
|
112
|
+
key, raw_value = line.split('=', 1)
|
|
113
|
+
key = key.strip()
|
|
114
|
+
if not _validate_key_name(key):
|
|
115
|
+
skipped.append(key)
|
|
116
|
+
continue
|
|
117
|
+
value = _parse_env_value(raw_value)
|
|
118
|
+
loaded[key] = value
|
|
119
|
+
return loaded, skipped
|
|
120
|
+
|
|
121
|
+
def _load_nested(self, data: dict) -> tuple:
|
|
122
|
+
"""处理嵌套 JSON(一级 key 作为分组名)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
(loaded_vars, groups_detected)
|
|
126
|
+
"""
|
|
127
|
+
loaded_vars = {}
|
|
128
|
+
groups_detected = 0
|
|
129
|
+
for group_name, group_data in data.items():
|
|
130
|
+
if isinstance(group_data, dict):
|
|
131
|
+
groups_detected += 1
|
|
132
|
+
for key, value in group_data.items():
|
|
133
|
+
loaded_vars[f"{group_name}:{key}"] = value
|
|
134
|
+
else:
|
|
135
|
+
loaded_vars[group_name] = str(group_data)
|
|
136
|
+
return loaded_vars, groups_detected
|
|
137
|
+
|
|
138
|
+
def _apply_group_prefix(
|
|
139
|
+
self, vars_dict: dict[str, str], group: Optional[str]
|
|
140
|
+
) -> dict[str, str]:
|
|
141
|
+
"""为变量添加分组前缀"""
|
|
142
|
+
if not group:
|
|
143
|
+
return vars_dict
|
|
144
|
+
result = {}
|
|
145
|
+
prefix = f"{group}:"
|
|
146
|
+
for key, value in vars_dict.items():
|
|
147
|
+
if not key.startswith(prefix):
|
|
148
|
+
result[f"{prefix}{key}"] = value
|
|
149
|
+
else:
|
|
150
|
+
result[key] = value
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
# ── 导出 ──────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def export(
|
|
156
|
+
self,
|
|
157
|
+
format_type: str = 'json',
|
|
158
|
+
output_file: Optional[str] = None,
|
|
159
|
+
group: Optional[str] = None,
|
|
160
|
+
dry_run: bool = False,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""导出环境变量"""
|
|
163
|
+
if group:
|
|
164
|
+
export_vars = {
|
|
165
|
+
k: v for k, v in self._env_vars.items()
|
|
166
|
+
if k.startswith(f"{group}:")
|
|
167
|
+
}
|
|
168
|
+
if not export_vars:
|
|
169
|
+
raise GroupNotFoundError(group)
|
|
170
|
+
else:
|
|
171
|
+
export_vars = dict(self._env_vars)
|
|
172
|
+
|
|
173
|
+
if not export_vars:
|
|
174
|
+
return "No environment variables to export"
|
|
175
|
+
|
|
176
|
+
if output_file:
|
|
177
|
+
output_path = Path(output_file)
|
|
178
|
+
else:
|
|
179
|
+
output_path = Path.cwd() / f"env.{format_type}"
|
|
180
|
+
|
|
181
|
+
if dry_run:
|
|
182
|
+
return (
|
|
183
|
+
f"[DRY-RUN] Would export {len(export_vars)} variables "
|
|
184
|
+
f"to {output_path} (format={format_type})"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
if format_type == 'json':
|
|
189
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
190
|
+
json.dump(export_vars, f, indent=2, ensure_ascii=False)
|
|
191
|
+
elif format_type == 'env':
|
|
192
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
193
|
+
for key, value in sorted(export_vars.items()):
|
|
194
|
+
# #13: 值含换行时用双引号包裹
|
|
195
|
+
if '\n' in str(value) or '\r' in str(value):
|
|
196
|
+
escaped = str(value).replace(
|
|
197
|
+
'\\', '\\\\'
|
|
198
|
+
).replace('"', '\\"').replace('\n', '\\n')
|
|
199
|
+
f.write(f'{key}="{escaped}"\n')
|
|
200
|
+
else:
|
|
201
|
+
f.write(f'{key}={value}\n')
|
|
202
|
+
elif format_type == 'sh':
|
|
203
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
204
|
+
f.write('#!/bin/bash\n\n')
|
|
205
|
+
for key, value in sorted(export_vars.items()):
|
|
206
|
+
# #9: key 也用 shlex.quote 转义
|
|
207
|
+
safe_key = shlex.quote(key)
|
|
208
|
+
f.write(
|
|
209
|
+
f'export {safe_key}={shlex.quote(str(value))}\n'
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
raise ExportError(f"Unsupported format: {format_type}")
|
|
213
|
+
return f"Environment variables exported to: {output_path}"
|
|
214
|
+
except OSError as e:
|
|
215
|
+
raise ExportError(f"Error exporting: {e}") from e
|
|
216
|
+
|
|
217
|
+
# ── 导入(重构后)────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def load(
|
|
220
|
+
self,
|
|
221
|
+
input_file: str,
|
|
222
|
+
format_type: Optional[str] = None,
|
|
223
|
+
replace: bool = False,
|
|
224
|
+
group: Optional[str] = None,
|
|
225
|
+
nest: bool = False,
|
|
226
|
+
dry_run: bool = False,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""从文件导入环境变量"""
|
|
229
|
+
input_path = Path(input_file)
|
|
230
|
+
if not input_path.exists():
|
|
231
|
+
raise ImportFailedError(
|
|
232
|
+
f"File not found: {input_file}", input_file
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
fmt = self._detect_format(input_path, format_type)
|
|
237
|
+
|
|
238
|
+
# 加载原始数据
|
|
239
|
+
backup_timestamp = None
|
|
240
|
+
groups_detected = 0
|
|
241
|
+
skipped_keys: list[str] = []
|
|
242
|
+
|
|
243
|
+
if fmt in ['json', 'backup']:
|
|
244
|
+
data = self._load_json_file(input_path)
|
|
245
|
+
|
|
246
|
+
if nest:
|
|
247
|
+
loaded_vars, groups_detected = self._load_nested(data)
|
|
248
|
+
elif 'variables' in data:
|
|
249
|
+
# 备份文件格式
|
|
250
|
+
loaded_vars = data['variables']
|
|
251
|
+
backup_timestamp = data.get('timestamp', 'unknown')
|
|
252
|
+
else:
|
|
253
|
+
loaded_vars = data
|
|
254
|
+
elif fmt == 'env':
|
|
255
|
+
loaded_vars, skipped_keys = self._load_env_file(input_path)
|
|
256
|
+
else:
|
|
257
|
+
raise ImportFailedError(
|
|
258
|
+
f"Unsupported format: {fmt}", input_file
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# 添加分组前缀
|
|
262
|
+
if group and not nest:
|
|
263
|
+
loaded_vars = self._apply_group_prefix(loaded_vars, group)
|
|
264
|
+
|
|
265
|
+
# 构建消息
|
|
266
|
+
parts = []
|
|
267
|
+
if backup_timestamp:
|
|
268
|
+
parts.append(
|
|
269
|
+
f"Detected backup file (timestamp: {backup_timestamp})"
|
|
270
|
+
)
|
|
271
|
+
if nest and groups_detected > 0:
|
|
272
|
+
parts.append(
|
|
273
|
+
f"Detected and imported {groups_detected} groups "
|
|
274
|
+
f"from nested structure"
|
|
275
|
+
)
|
|
276
|
+
if skipped_keys:
|
|
277
|
+
parts.append(
|
|
278
|
+
f"Skipped {len(skipped_keys)} invalid key(s): "
|
|
279
|
+
f"{', '.join(skipped_keys)}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if dry_run:
|
|
283
|
+
parts.append(
|
|
284
|
+
f"[DRY-RUN] Would {'replace' if replace else 'merge'} "
|
|
285
|
+
f"{len(loaded_vars)} variables from {input_file}"
|
|
286
|
+
)
|
|
287
|
+
return '\n'.join(parts) if parts else (
|
|
288
|
+
f"[DRY-RUN] Would load {len(loaded_vars)} variables"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# 应用到环境变量
|
|
292
|
+
if replace:
|
|
293
|
+
self._env_vars = loaded_vars
|
|
294
|
+
parts.append(
|
|
295
|
+
f"Replaced environment variables ({len(loaded_vars)} total)"
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
self._env_vars.update(loaded_vars)
|
|
299
|
+
parts.append(
|
|
300
|
+
f"Loaded {len(loaded_vars)} environment variables "
|
|
301
|
+
f"from {input_file}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
self._save_env_vars()
|
|
305
|
+
|
|
306
|
+
if group:
|
|
307
|
+
parts.append(f"Variables added to group '{group}'")
|
|
308
|
+
|
|
309
|
+
return '\n'.join(parts) if parts else (
|
|
310
|
+
f"Loaded {len(loaded_vars)} variables"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
except (ImportFailedError, ExportError):
|
|
314
|
+
raise
|
|
315
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
316
|
+
raise ImportFailedError(
|
|
317
|
+
f"Error loading: {e}", input_file
|
|
318
|
+
) from e
|
|
319
|
+
except OSError as e:
|
|
320
|
+
raise ImportFailedError(
|
|
321
|
+
f"IO error loading: {e}", input_file
|
|
322
|
+
) from e
|
|
323
|
+
|
|
324
|
+
# ── 备份恢复 ──────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
def backup(self, backup_file: Optional[str] = None) -> str:
|
|
327
|
+
"""创建备份"""
|
|
328
|
+
import os
|
|
329
|
+
from datetime import datetime
|
|
330
|
+
|
|
331
|
+
if backup_file is None:
|
|
332
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
333
|
+
backup_path = (
|
|
334
|
+
self.env_file.parent
|
|
335
|
+
/ f"backup_{timestamp}.json"
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
backup_path = Path(backup_file)
|
|
339
|
+
|
|
340
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
backup_data = {
|
|
342
|
+
'timestamp': datetime.now().isoformat(),
|
|
343
|
+
'variables': self._env_vars,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
with open(backup_path, 'w', encoding='utf-8') as f:
|
|
348
|
+
json.dump(backup_data, f, indent=2, ensure_ascii=False)
|
|
349
|
+
os.chmod(str(backup_path), 0o600)
|
|
350
|
+
return f"Backup created: {backup_path}"
|
|
351
|
+
except OSError as e:
|
|
352
|
+
raise BackupError(f"Error creating backup: {e}") from e
|
|
353
|
+
|
|
354
|
+
def restore(self, backup_file: str, merge: bool = False) -> str:
|
|
355
|
+
"""从备份恢复"""
|
|
356
|
+
backup_path = Path(backup_file)
|
|
357
|
+
if not backup_path.exists():
|
|
358
|
+
raise BackupError(f"Backup file not found: {backup_file}")
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
with open(backup_path, encoding='utf-8') as f:
|
|
362
|
+
backup_data = json.load(f)
|
|
363
|
+
|
|
364
|
+
if 'variables' not in backup_data:
|
|
365
|
+
raise BackupError("Invalid backup file format")
|
|
366
|
+
|
|
367
|
+
restored_vars = backup_data['variables']
|
|
368
|
+
if merge:
|
|
369
|
+
self._env_vars.update(restored_vars)
|
|
370
|
+
msg = f"Merged {len(restored_vars)} variables from backup"
|
|
371
|
+
else:
|
|
372
|
+
self._env_vars = restored_vars
|
|
373
|
+
msg = f"Restored {len(restored_vars)} variables from backup"
|
|
374
|
+
|
|
375
|
+
self._save_env_vars()
|
|
376
|
+
|
|
377
|
+
timestamp = backup_data.get('timestamp', '')
|
|
378
|
+
if timestamp:
|
|
379
|
+
msg += f"\nBackup timestamp: {timestamp}"
|
|
380
|
+
return msg
|
|
381
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
382
|
+
raise BackupError(
|
|
383
|
+
f"Error restoring from backup: {e}"
|
|
384
|
+
) from e
|
|
385
|
+
|
|
386
|
+
# ── Diff 比较 ─────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
def diff(self, backup_file: str) -> dict[str, dict]:
|
|
389
|
+
"""比较当前状态与备份文件的差异"""
|
|
390
|
+
backup_path = Path(backup_file)
|
|
391
|
+
if not backup_path.exists():
|
|
392
|
+
raise BackupError(f"File not found: {backup_file}")
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
with open(backup_path, encoding='utf-8') as f:
|
|
396
|
+
backup_data = json.load(f)
|
|
397
|
+
|
|
398
|
+
if 'variables' in backup_data:
|
|
399
|
+
backup_vars = backup_data['variables']
|
|
400
|
+
elif isinstance(backup_data, dict):
|
|
401
|
+
backup_vars = backup_data
|
|
402
|
+
else:
|
|
403
|
+
raise BackupError("Invalid file format for diff")
|
|
404
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
405
|
+
raise BackupError(f"Error reading file: {e}") from e
|
|
406
|
+
|
|
407
|
+
current = self._env_vars
|
|
408
|
+
added = {k: v for k, v in current.items() if k not in backup_vars}
|
|
409
|
+
removed = {k: v for k, v in backup_vars.items() if k not in current}
|
|
410
|
+
changed = {
|
|
411
|
+
k: {'current': current[k], 'backup': backup_vars[k]}
|
|
412
|
+
for k in current
|
|
413
|
+
if k in backup_vars and current[k] != backup_vars[k]
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
'added': added,
|
|
418
|
+
'removed': removed,
|
|
419
|
+
'changed': changed,
|
|
420
|
+
'backup_timestamp': backup_data.get('timestamp', ''),
|
|
421
|
+
}
|
evm/_json.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EVM JSON 输出模块
|
|
4
|
+
|
|
5
|
+
为 Agent/程序化调用提供结构化 JSON 输出。
|
|
6
|
+
设计原则: stdout 是数据 (JSON),stderr 是日志/人类可读信息。
|
|
7
|
+
|
|
8
|
+
JSON 信封格式:
|
|
9
|
+
成功: {"status": "ok", "data": {...}}
|
|
10
|
+
错误: {"status": "error", "error": "...", "error_code": N}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def json_output(data: Any, quiet: bool = False) -> None:
|
|
19
|
+
"""输出成功 JSON 到 stdout
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: 要输出的数据(dict/list/str 等 JSON 可序列化对象)
|
|
23
|
+
quiet: 若为 True 则不输出(静默模式)
|
|
24
|
+
"""
|
|
25
|
+
if quiet:
|
|
26
|
+
return
|
|
27
|
+
envelope = {"status": "ok", "data": data}
|
|
28
|
+
print(json.dumps(envelope, ensure_ascii=False, indent=None, default=str))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def json_error(message: str, error_code: int = 1, quiet: bool = False) -> None:
|
|
32
|
+
"""输出错误 JSON 到 stderr
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
message: 错误信息
|
|
36
|
+
error_code: 退出码
|
|
37
|
+
quiet: 若为 True 则不输出
|
|
38
|
+
"""
|
|
39
|
+
if quiet:
|
|
40
|
+
return
|
|
41
|
+
envelope = {
|
|
42
|
+
"status": "error",
|
|
43
|
+
"error": message,
|
|
44
|
+
"error_code": error_code,
|
|
45
|
+
}
|
|
46
|
+
print(
|
|
47
|
+
json.dumps(envelope, ensure_ascii=False, indent=None),
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ['json_output', 'json_error']
|