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/_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']