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/manager.py ADDED
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ EVM 核心业务逻辑
4
+
5
+ EnvironmentManager 类通过 mixin 组合提供所有环境变量管理功能。
6
+ 所有方法返回数据或抛出异常,不做任何 print() 或 sys.exit()。
7
+
8
+ 模块拆分:
9
+ - _io.py → IOMixin(导入导出/备份恢复/diff)
10
+ - _groups.py → GroupMixin(分组管理)
11
+ - _history.py → HistoryMixin(操作日志)
12
+ - _schema.py → SchemaMixin(变量 schema)
13
+ """
14
+
15
+ import base64
16
+ import fcntl
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import os
21
+ import platform
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ from ._crypto import decrypt_v3, encrypt_v3
32
+ from ._groups import GroupMixin
33
+ from ._history import HistoryMixin
34
+ from ._io import IOMixin
35
+ from ._schema import SchemaMixin
36
+ from .exceptions import (
37
+ CommandNotFoundError,
38
+ CorruptedStorageError,
39
+ DecryptionError,
40
+ EditorError,
41
+ EVMError,
42
+ KeyAlreadyExistsError,
43
+ KeyNotFoundError,
44
+ LockTimeoutError,
45
+ StorageError,
46
+ )
47
+
48
+
49
+ class EnvironmentManager(IOMixin, GroupMixin, HistoryMixin, SchemaMixin):
50
+ """环境变量管理器核心类
51
+
52
+ 通过 mixin 组合获得分组、IO、历史、schema 功能。
53
+ """
54
+
55
+ # 加密前缀标识
56
+ SECRET_PREFIX = "ENC:"
57
+ SECRET_V2_PREFIX = "ENCv2:"
58
+ SECRET_V3_PREFIX = "ENCv3:"
59
+ # 模板引用模式 {{VAR_NAME}}
60
+ TEMPLATE_PATTERN = re.compile(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}')
61
+ # 文件锁默认超时(秒)
62
+ LOCK_TIMEOUT = 5.0
63
+
64
+ def __init__(
65
+ self,
66
+ env_file: Optional[str] = None,
67
+ lock_timeout: float = LOCK_TIMEOUT,
68
+ ):
69
+ """初始化环境管理器
70
+
71
+ Args:
72
+ env_file: 存储文件路径,默认 ~/.evm/env.json
73
+ lock_timeout: 文件锁超时秒数
74
+ """
75
+ if env_file is None:
76
+ self.env_file = Path.home() / '.evm' / 'env.json'
77
+ else:
78
+ self.env_file = Path(env_file)
79
+
80
+ self.lock_timeout = lock_timeout
81
+ self._secret_warning_shown = False
82
+ self.env_file.parent.mkdir(parents=True, exist_ok=True)
83
+ self._env_vars = self._load_env_vars()
84
+
85
+ # ── 内部存储 ──────────────────────────────────────────
86
+
87
+ def _load_env_vars(self) -> dict[str, str]:
88
+ """从存储文件加载环境变量
89
+
90
+ Raises:
91
+ CorruptedStorageError: JSON 文件损坏
92
+ StorageError: IO 或权限错误
93
+ """
94
+ if not self.env_file.exists():
95
+ return {}
96
+ try:
97
+ with open(self.env_file, encoding='utf-8') as f:
98
+ content = f.read().strip()
99
+ if not content:
100
+ return {}
101
+ return json.loads(content) # type: ignore[no-any-return]
102
+ except json.JSONDecodeError as e:
103
+ raise CorruptedStorageError(
104
+ f"Storage file is corrupted: {e}. File: {self.env_file}"
105
+ ) from e
106
+ except PermissionError as e:
107
+ raise StorageError(
108
+ f"Permission denied reading storage file: {self.env_file}"
109
+ ) from e
110
+ except OSError as e:
111
+ raise StorageError(
112
+ f"IO error reading storage file: {e}"
113
+ ) from e
114
+
115
+ def _save_env_vars(self, dry_run: bool = False) -> None:
116
+ """保存环境变量到存储文件(原子写入 + 共享锁文件 + chmod 600)
117
+
118
+ #1 fix: 使用独立的 .lock 文件加锁,而非锁临时文件。
119
+ 两个并发进程争夺同一个 .lock 文件的排他锁,
120
+ 确保 write + move 操作的原子性。
121
+ """
122
+ if dry_run:
123
+ return
124
+
125
+ lock_path = str(self.env_file) + '.lock'
126
+ lock_fd = None
127
+ try:
128
+ # 打开共享锁文件(所有进程竞争同一资源)
129
+ lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
130
+ self._acquire_lock(lock_fd)
131
+ try:
132
+ tmp_fd, tmp_path = tempfile.mkstemp(
133
+ dir=str(self.env_file.parent),
134
+ suffix='.tmp',
135
+ prefix='.env_',
136
+ )
137
+ try:
138
+ with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
139
+ json.dump(
140
+ self._env_vars, f, indent=2, ensure_ascii=False
141
+ )
142
+ f.flush()
143
+ os.fsync(f.fileno())
144
+ except Exception:
145
+ if os.path.exists(tmp_path):
146
+ os.unlink(tmp_path)
147
+ raise
148
+
149
+ # 原子替换(在锁保护下)
150
+ shutil.move(tmp_path, str(self.env_file))
151
+ os.chmod(str(self.env_file), 0o600)
152
+ finally:
153
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
154
+
155
+ except LockTimeoutError:
156
+ raise
157
+ except PermissionError as e:
158
+ raise StorageError(
159
+ f"Permission denied writing to: {self.env_file}"
160
+ ) from e
161
+ except OSError as e:
162
+ raise StorageError(
163
+ f"IO error writing storage file: {e}"
164
+ ) from e
165
+ finally:
166
+ if lock_fd is not None:
167
+ try:
168
+ os.close(lock_fd)
169
+ except OSError:
170
+ pass
171
+
172
+ def _acquire_lock(self, fd: int) -> None:
173
+ """获取排他文件锁,带超时重试
174
+
175
+ Raises:
176
+ LockTimeoutError: 超时未获取锁
177
+ """
178
+ deadline = time.monotonic() + self.lock_timeout
179
+ while time.monotonic() < deadline:
180
+ try:
181
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
182
+ return
183
+ except OSError:
184
+ time.sleep(0.05)
185
+ raise LockTimeoutError(str(self.env_file), self.lock_timeout)
186
+
187
+ # ── 基本 CRUD ────────────────────────────────────────
188
+
189
+ def set(self, key: str, value: str, dry_run: bool = False) -> str:
190
+ """设置环境变量
191
+
192
+ #6 fix: 不记录 value 到操作日志,防止敏感信息泄露。
193
+ """
194
+ if dry_run:
195
+ return f"[DRY-RUN] Would set: {key}={value}"
196
+ self._env_vars[key] = value
197
+ self._save_env_vars()
198
+ self.log_operation('set', key)
199
+ return f"Set: {key}={value}"
200
+
201
+ def get(self, key: str) -> str:
202
+ """获取环境变量值
203
+
204
+ Raises:
205
+ KeyNotFoundError: 变量不存在
206
+ """
207
+ value = self._env_vars.get(key)
208
+ if value is None:
209
+ raise KeyNotFoundError(key)
210
+ return value # type: ignore[no-any-return]
211
+
212
+ def delete(self, key: str, dry_run: bool = False) -> str:
213
+ """删除环境变量
214
+
215
+ Raises:
216
+ KeyNotFoundError: 变量不存在
217
+ """
218
+ if key not in self._env_vars:
219
+ raise KeyNotFoundError(key)
220
+ if dry_run:
221
+ return f"[DRY-RUN] Would delete: {key} (value={self._env_vars[key]})"
222
+ del self._env_vars[key]
223
+ self._save_env_vars()
224
+ self.log_operation('delete', key)
225
+ return f"Deleted: {key}"
226
+
227
+ def exists(self, key: str) -> bool:
228
+ """检查环境变量是否存在"""
229
+ return key in self._env_vars
230
+
231
+ def list_vars(
232
+ self,
233
+ pattern: Optional[str] = None,
234
+ group: Optional[str] = None,
235
+ show_groups: bool = False,
236
+ no_prefix: bool = False,
237
+ ) -> dict[str, str]:
238
+ """列出环境变量
239
+
240
+ Raises:
241
+ GroupNotFoundError: 指定的分组不存在
242
+ """
243
+ from .exceptions import GroupNotFoundError
244
+
245
+ if group:
246
+ prefix = f"{group}:"
247
+ filtered = {
248
+ k: v for k, v in self._env_vars.items()
249
+ if k.startswith(prefix)
250
+ }
251
+ if not filtered:
252
+ raise GroupNotFoundError(group)
253
+ if no_prefix:
254
+ result = {}
255
+ for key, value in filtered.items():
256
+ new_key = key[len(prefix):] if key.startswith(prefix) else key
257
+ result[new_key] = value
258
+ return result
259
+ return filtered
260
+ elif pattern:
261
+ return {
262
+ k: v for k, v in self._env_vars.items()
263
+ if pattern.lower() in k.lower()
264
+ }
265
+ else:
266
+ return dict(self._env_vars)
267
+
268
+ def clear(self, dry_run: bool = False, force: bool = False) -> str:
269
+ """清空所有环境变量
270
+
271
+ Args:
272
+ force: 跳过确认(CLI 层处理确认逻辑)
273
+ """
274
+ if not self._env_vars:
275
+ return "No environment variables to clear"
276
+ count = len(self._env_vars)
277
+ if dry_run:
278
+ return f"[DRY-RUN] Would clear {count} variables"
279
+ self._env_vars.clear()
280
+ self._save_env_vars()
281
+ self.log_operation('clear', details=f'{count} variables')
282
+ return f"All environment variables cleared ({count} variables)"
283
+
284
+ # ── 搜索 ─────────────────────────────────────────────
285
+
286
+ def search(self, pattern: str, search_value: bool = False) -> dict[str, str]:
287
+ """搜索环境变量"""
288
+ results = {}
289
+ for key, value in self._env_vars.items():
290
+ if pattern.lower() in key.lower():
291
+ results[key] = value
292
+ elif search_value and pattern.lower() in str(value).lower():
293
+ results[key] = value
294
+ return results
295
+
296
+ # ── 重命名/复制 ──────────────────────────────────────
297
+
298
+ def rename(
299
+ self, old_key: str, new_key: str, dry_run: bool = False
300
+ ) -> str:
301
+ """重命名环境变量"""
302
+ if old_key not in self._env_vars:
303
+ raise KeyNotFoundError(old_key)
304
+ if new_key in self._env_vars:
305
+ raise KeyAlreadyExistsError(new_key)
306
+ if dry_run:
307
+ return f"[DRY-RUN] Would rename: {old_key} -> {new_key}"
308
+ value = self._env_vars.pop(old_key)
309
+ self._env_vars[new_key] = value
310
+ self._save_env_vars()
311
+ self.log_operation('rename', old_key, f'-> {new_key}')
312
+ return f"Renamed: {old_key} -> {new_key}"
313
+
314
+ def copy(self, src_key: str, dst_key: str, dry_run: bool = False) -> str:
315
+ """复制环境变量"""
316
+ if src_key not in self._env_vars:
317
+ raise KeyNotFoundError(src_key)
318
+ if dry_run:
319
+ return f"[DRY-RUN] Would copy: {src_key} -> {dst_key}"
320
+ self._env_vars[dst_key] = self._env_vars[src_key]
321
+ self._save_env_vars()
322
+ self.log_operation('copy', src_key, f'-> {dst_key}')
323
+ return f"Copied: {src_key} -> {dst_key}"
324
+
325
+ # ── 内存加载 ──────────────────────────────────────────
326
+
327
+ def load_to_memory(
328
+ self,
329
+ filter_prefix: Optional[str] = None,
330
+ add_evm_prefix: bool = True,
331
+ ) -> tuple[int, bool, Optional[str]]:
332
+ """加载环境变量到 os.environ"""
333
+ loaded_count = 0
334
+ for key, value in self._env_vars.items():
335
+ if filter_prefix and not key.startswith(filter_prefix):
336
+ continue
337
+ final_key = f"EVM:{key}" if add_evm_prefix else key
338
+ os.environ[final_key] = str(value)
339
+ loaded_count += 1
340
+ return loaded_count, add_evm_prefix, filter_prefix
341
+
342
+ # ── 执行命令 ──────────────────────────────────────────
343
+
344
+ def execute(self, command: list[str]) -> int:
345
+ """使用环境变量执行命令
346
+
347
+ P1: 改用 subprocess.run 替代 os.execvpe,
348
+ 以便 Agent 可以捕获退出码。
349
+
350
+ Returns:
351
+ 子进程的退出码
352
+ """
353
+ if not command:
354
+ raise EVMError("No command specified")
355
+
356
+ env_copy = os.environ.copy()
357
+ for key, value in self._env_vars.items():
358
+ env_copy[key] = str(value)
359
+
360
+ try:
361
+ result = subprocess.run(command, env=env_copy)
362
+ return result.returncode
363
+ except FileNotFoundError:
364
+ raise CommandNotFoundError(command[0])
365
+ except KeyboardInterrupt:
366
+ return 130
367
+ except Exception as e:
368
+ raise EVMError(f"Error executing command: {e}")
369
+
370
+ # ── 编辑器编辑 ────────────────────────────────────────
371
+
372
+ def edit(self, key: str) -> str:
373
+ """使用 $EDITOR 编辑变量值"""
374
+ if key not in self._env_vars:
375
+ raise KeyNotFoundError(key)
376
+
377
+ editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'vi'))
378
+ current_value = self._env_vars[key]
379
+
380
+ with tempfile.NamedTemporaryFile(
381
+ mode='w', suffix='.txt', prefix='evm_edit_', delete=False
382
+ ) as tmp:
383
+ tmp.write(current_value)
384
+ tmp_path = tmp.name
385
+
386
+ try:
387
+ try:
388
+ result = subprocess.run([editor, tmp_path])
389
+ except FileNotFoundError:
390
+ raise EditorError(
391
+ f"Editor not found: '{editor}'. "
392
+ f"Set $EDITOR or $VISUAL to a valid editor path."
393
+ )
394
+ if result.returncode != 0:
395
+ raise EditorError(
396
+ f"Editor exited with code {result.returncode}"
397
+ )
398
+
399
+ with open(tmp_path, encoding='utf-8') as f:
400
+ new_value = f.read().rstrip('\n')
401
+
402
+ if new_value == current_value:
403
+ return f"No changes made to '{key}'"
404
+
405
+ self._env_vars[key] = new_value
406
+ self._save_env_vars()
407
+ self.log_operation('edit', key)
408
+ return f"Updated: {key}"
409
+ finally:
410
+ if os.path.exists(tmp_path):
411
+ os.unlink(tmp_path)
412
+
413
+ # ── 工具信息 ──────────────────────────────────────────
414
+
415
+ def info(self) -> dict[str, object]:
416
+ """返回工具元信息"""
417
+ groups = self.list_groups()
418
+ total_vars = len(self._env_vars)
419
+ secret_count = sum(
420
+ 1 for v in self._env_vars.values()
421
+ if isinstance(v, str) and (
422
+ v.startswith(self.SECRET_PREFIX)
423
+ or v.startswith(self.SECRET_V2_PREFIX)
424
+ or v.startswith(self.SECRET_V3_PREFIX)
425
+ )
426
+ )
427
+
428
+ return {
429
+ 'version': '2.3.0',
430
+ 'author': 'EVM Tool',
431
+ 'license': 'MIT',
432
+ 'python': sys.version.split()[0],
433
+ 'platform': platform.system(),
434
+ 'storage_path': str(self.env_file),
435
+ 'storage_exists': self.env_file.exists(),
436
+ 'total_variables': total_vars,
437
+ 'total_groups': len(groups),
438
+ 'secret_variables': secret_count,
439
+ 'groups': groups,
440
+ 'repository': 'https://github.com/zxygithub/evm',
441
+ }
442
+
443
+ # ── 模板展开 ──────────────────────────────────────────
444
+
445
+ def expand(self, key: str, depth: int = 0, max_depth: int = 10) -> str:
446
+ """展开变量值中的模板引用 {{OTHER_VAR}}"""
447
+ if key not in self._env_vars:
448
+ raise KeyNotFoundError(key)
449
+ if depth > max_depth:
450
+ raise EVMError(
451
+ f"Template expansion exceeded max depth ({max_depth}). "
452
+ f"Possible circular reference."
453
+ )
454
+
455
+ value = self._env_vars[key]
456
+
457
+ def replace_match(match):
458
+ ref_key = match.group(1)
459
+ if ref_key in self._env_vars:
460
+ ref_val = self._env_vars[ref_key]
461
+ if self.TEMPLATE_PATTERN.search(ref_val):
462
+ return self._expand_value(
463
+ ref_val, depth + 1, max_depth
464
+ )
465
+ return ref_val
466
+ return match.group(0)
467
+
468
+ return self.TEMPLATE_PATTERN.sub(replace_match, value)
469
+
470
+ def _expand_value(
471
+ self, value: str, depth: int, max_depth: int
472
+ ) -> str:
473
+ """内部:展开字符串中的模板引用"""
474
+ if depth > max_depth:
475
+ return value
476
+
477
+ def replace_match(match):
478
+ ref_key = match.group(1)
479
+ if ref_key in self._env_vars:
480
+ return self._expand_value(
481
+ self._env_vars[ref_key], depth + 1, max_depth
482
+ )
483
+ return match.group(0)
484
+
485
+ return self.TEMPLATE_PATTERN.sub(replace_match, value)
486
+
487
+ # ── 加密变量(v3: HKDF + HMAC-CTR + Encrypt-then-MAC)──────
488
+
489
+ @staticmethod
490
+ def _get_machine_salt() -> bytes:
491
+ """获取机器相关的盐值
492
+
493
+ #2: 此盐值与机器绑定。hostname/uid/arch 变化会导致密钥不同。
494
+ 用户应知晓此限制。
495
+ """
496
+ machine_id = (
497
+ platform.node()
498
+ + str(os.getuid() if hasattr(os, 'getuid') else '')
499
+ + platform.machine()
500
+ )
501
+ return machine_id.encode('utf-8')
502
+
503
+ def _derive_master_key(self, salt: bytes) -> bytes:
504
+ """#4+#15: PBKDF2 派生主密钥,供 _crypto.py 使用"""
505
+ return hashlib.pbkdf2_hmac(
506
+ 'sha256', self._get_machine_salt(), salt, 100000, dklen=32
507
+ )
508
+
509
+ # ── v2 兼容(保留旧版解密,供自动迁移使用)────────────
510
+
511
+ def _derive_key_v2(self, salt: bytes) -> bytes:
512
+ """v2 兼容:PBKDF2 密钥派生(与 v3 共用参数)"""
513
+ return hashlib.pbkdf2_hmac(
514
+ 'sha256', self._get_machine_salt(), salt, 100000, dklen=32
515
+ )
516
+
517
+ def _decrypt_v2(self, encoded: str) -> str:
518
+ """v2 兼容解密(重复密钥 XOR + HMAC)"""
519
+ parts = encoded.split(':')
520
+ if len(parts) != 3:
521
+ raise DecryptionError("Invalid v2 encrypted data format")
522
+
523
+ try:
524
+ salt = base64.b64decode(parts[0])
525
+ stored_mac = base64.b64decode(parts[1])
526
+ ciphertext = base64.b64decode(parts[2])
527
+ except Exception as e:
528
+ raise DecryptionError(
529
+ f"Failed to decode v2 data: {e}"
530
+ ) from e
531
+
532
+ key = self._derive_key_v2(salt)
533
+
534
+ computed_mac = hmac.new(
535
+ key, salt + ciphertext, hashlib.sha256
536
+ ).digest()
537
+ if not hmac.compare_digest(stored_mac, computed_mac):
538
+ raise DecryptionError(
539
+ "Data integrity check failed (v2)"
540
+ )
541
+
542
+ plaintext = bytes(
543
+ d ^ key[i % len(key)] for i, d in enumerate(ciphertext)
544
+ )
545
+ try:
546
+ return plaintext.decode('utf-8')
547
+ except UnicodeDecodeError as e:
548
+ raise DecryptionError(
549
+ f"Decrypted data is not valid UTF-8: {e}"
550
+ ) from e
551
+
552
+ def _decrypt_v1(self, encoded: str) -> str:
553
+ """v1 兼容解密(简单 XOR + base64,无盐无 MAC)"""
554
+ machine_id = (
555
+ platform.node()
556
+ + str(os.getuid() if hasattr(os, 'getuid') else '')
557
+ + platform.machine()
558
+ )
559
+ key = hashlib.sha256(machine_id.encode()).digest()
560
+ try:
561
+ encrypted = base64.b64decode(encoded.encode('ascii'))
562
+ decrypted = bytes(
563
+ d ^ key[i % len(key)] for i, d in enumerate(encrypted)
564
+ )
565
+ return decrypted.decode('utf-8')
566
+ except Exception as e:
567
+ raise DecryptionError(
568
+ f"Failed to decrypt (v1): {e}"
569
+ ) from e
570
+
571
+ def set_secret(
572
+ self, key: str, value: str, dry_run: bool = False
573
+ ) -> str:
574
+ """加密存储变量
575
+
576
+ 使用 v3 格式: HKDF 密钥分离 + HMAC-CTR + Encrypt-then-MAC。
577
+
578
+ #2: 首次使用时打印机器绑定警告。
579
+ """
580
+ if dry_run:
581
+ return f"[DRY-RUN] Would set secret: {key}=*** (encrypted)"
582
+
583
+ # #2: 首次使用加密功能时发出警告
584
+ warning = ''
585
+ if not self._secret_warning_shown:
586
+ self._secret_warning_shown = True
587
+ warning = (
588
+ " [WARNING: Encryption key is derived from machine identity "
589
+ "(hostname + uid + arch). Changing hostname or migrating to "
590
+ "another machine will make secrets unrecoverable.]"
591
+ )
592
+
593
+ encrypted = encrypt_v3(value, self._derive_master_key)
594
+ self._env_vars[key] = encrypted
595
+ self._save_env_vars()
596
+ self.log_operation('set_secret', key)
597
+ return f"Set secret: {key}=*** (encrypted){warning}"
598
+
599
+ def get_secret(self, key: str) -> str:
600
+ """获取并解密变量
601
+
602
+ 支持 v1/v2/v3 三种格式。
603
+ #16: 读取 v1/v2 格式时自动迁移到 v3。
604
+ """
605
+ value = self._env_vars.get(key)
606
+ if value is None:
607
+ raise KeyNotFoundError(key)
608
+
609
+ if isinstance(value, str) and value.startswith(self.SECRET_V3_PREFIX):
610
+ # v3: 直接解密
611
+ return decrypt_v3(
612
+ value[len(self.SECRET_V3_PREFIX):],
613
+ self._derive_master_key,
614
+ )
615
+
616
+ elif isinstance(value, str) and value.startswith(self.SECRET_V2_PREFIX):
617
+ # #16: v2 → 解密后自动迁移到 v3
618
+ plaintext = self._decrypt_v2(
619
+ value[len(self.SECRET_V2_PREFIX):]
620
+ )
621
+ self._env_vars[key] = encrypt_v3(
622
+ plaintext, self._derive_master_key
623
+ )
624
+ self._save_env_vars()
625
+ self.log_operation('migrate_secret', key, 'v2 -> v3')
626
+ return plaintext
627
+
628
+ elif isinstance(value, str) and value.startswith(self.SECRET_PREFIX):
629
+ # #16: v1 → 解密后自动迁移到 v3
630
+ plaintext = self._decrypt_v1(
631
+ value[len(self.SECRET_PREFIX):]
632
+ )
633
+ self._env_vars[key] = encrypt_v3(
634
+ plaintext, self._derive_master_key
635
+ )
636
+ self._save_env_vars()
637
+ self.log_operation('migrate_secret', key, 'v1 -> v3')
638
+ return plaintext
639
+
640
+ else:
641
+ raise DecryptionError(f"'{key}' is not an encrypted variable")
642
+
643
+
644
+ __all__ = ['EnvironmentManager']