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/_schema.py ADDED
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ EVM Schema Mixin
4
+
5
+ 变量 schema 定义和校验功能。
6
+ 支持格式:url, email, port, integer, boolean, path, ipv4, ipv6
7
+ """
8
+
9
+ import ipaddress
10
+ import json
11
+ import os
12
+ import re
13
+ import warnings
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ._typing import EnvironmentManagerProtocol
18
+ from .exceptions import SchemaError
19
+
20
+ # 内置格式校验正则
21
+ FORMAT_PATTERNS = {
22
+ 'url': re.compile(
23
+ r'^https?://[^\s/$.?#].[^\s]*$', re.IGNORECASE
24
+ ),
25
+ 'email': re.compile(
26
+ r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
27
+ ),
28
+ 'port': re.compile(r'^[0-9]{1,5}$'),
29
+ 'integer': re.compile(r'^-?[0-9]+$'),
30
+ 'boolean': re.compile(r'^(true|false|yes|no|1|0)$', re.IGNORECASE),
31
+ 'path': re.compile(r'^[/~.].*|^[a-zA-Z]:\\.*'),
32
+ 'ipv4': re.compile(
33
+ r'^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}'
34
+ r'(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$'
35
+ ),
36
+ }
37
+
38
+ # 环境变量 key 名校验正则
39
+ VALID_KEY_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*(?::[A-Za-z_][A-Za-z0-9_]*)*$')
40
+
41
+
42
+ def validate_ipv6(value: str) -> bool:
43
+ """#7: 使用标准库 ipaddress 校验 IPv6 地址"""
44
+ try:
45
+ ipaddress.IPv6Address(value)
46
+ return True
47
+ except (ipaddress.AddressValueError, ValueError):
48
+ return False
49
+
50
+
51
+ class SchemaMixin(EnvironmentManagerProtocol):
52
+ """Schema mixin — 变量格式定义和校验"""
53
+
54
+ def _get_schema_file(self) -> Path:
55
+ """获取 schema 文件路径"""
56
+ return self.env_file.parent / 'schema.json'
57
+
58
+ def _load_schema(self) -> dict:
59
+ """加载 schema 定义
60
+
61
+ 损坏时使用 warnings.warn() 通知调用者,而非直接 print(),
62
+ 保持 mixin 不做 I/O 的设计原则。
63
+ """
64
+ schema_file = self._get_schema_file()
65
+ if not schema_file.exists():
66
+ return {}
67
+ try:
68
+ with open(schema_file, encoding='utf-8') as f:
69
+ return json.load(f) # type: ignore[no-any-return]
70
+ except json.JSONDecodeError as e:
71
+ warnings.warn(
72
+ f"Schema file is corrupted ({e}). "
73
+ f"All schema definitions will be ignored until fixed.",
74
+ RuntimeWarning,
75
+ stacklevel=2,
76
+ )
77
+ return {}
78
+ except OSError as e:
79
+ warnings.warn(
80
+ f"Cannot read schema file ({e}). "
81
+ f"All schema definitions will be ignored.",
82
+ RuntimeWarning,
83
+ stacklevel=2,
84
+ )
85
+ return {}
86
+
87
+ def _save_schema(self, schema: dict) -> None:
88
+ """保存 schema 定义"""
89
+ schema_file = self._get_schema_file()
90
+ try:
91
+ with open(schema_file, 'w', encoding='utf-8') as f:
92
+ json.dump(schema, f, indent=2, ensure_ascii=False)
93
+ os.chmod(str(schema_file), 0o600)
94
+ except OSError as e:
95
+ raise SchemaError(f"Failed to save schema: {e}")
96
+
97
+ def set_schema(
98
+ self,
99
+ key: str,
100
+ format: Optional[str] = None,
101
+ required: Optional[bool] = None,
102
+ pattern: Optional[str] = None,
103
+ description: Optional[str] = None,
104
+ ) -> str:
105
+ """为变量设置 schema 定义
106
+
107
+ Args:
108
+ key: 变量名
109
+ format: 内置格式 (url/email/port/integer/boolean/path/ipv4/ipv6)
110
+ required: 是否必填
111
+ pattern: 自定义正则
112
+ description: 描述
113
+
114
+ Returns:
115
+ 确认消息
116
+ """
117
+ available_formats = list(FORMAT_PATTERNS.keys()) + ['ipv6']
118
+ if format and format not in available_formats:
119
+ raise SchemaError(
120
+ f"Unknown format '{format}'. "
121
+ f"Available: {', '.join(sorted(available_formats))}",
122
+ key,
123
+ )
124
+
125
+ schema = self._load_schema()
126
+ entry = schema.get(key, {})
127
+
128
+ if format is not None:
129
+ entry['format'] = format
130
+ if required is not None:
131
+ entry['required'] = required
132
+ if pattern is not None:
133
+ # 验证正则是否合法
134
+ try:
135
+ re.compile(pattern)
136
+ except re.error as e:
137
+ raise SchemaError(f"Invalid regex pattern: {e}", key)
138
+ entry['pattern'] = pattern
139
+ if description is not None:
140
+ entry['description'] = description
141
+
142
+ if not entry:
143
+ raise SchemaError("No schema properties specified", key)
144
+
145
+ schema[key] = entry
146
+ self._save_schema(schema)
147
+ return f"Schema set for '{key}': {json.dumps(entry, ensure_ascii=False)}"
148
+
149
+ def get_schema(self, key: Optional[str] = None) -> dict:
150
+ """获取 schema 定义
151
+
152
+ Args:
153
+ key: 指定变量名(None 返回全部)
154
+ """
155
+ schema = self._load_schema()
156
+ if key is not None:
157
+ if key not in schema:
158
+ raise SchemaError(f"No schema defined for '{key}'", key)
159
+ return {key: schema[key]}
160
+ return schema
161
+
162
+ def delete_schema(self, key: str) -> str:
163
+ """删除变量的 schema 定义"""
164
+ schema = self._load_schema()
165
+ if key not in schema:
166
+ raise SchemaError(f"No schema defined for '{key}'", key)
167
+ del schema[key]
168
+ self._save_schema(schema)
169
+ return f"Schema removed for '{key}'"
170
+
171
+ def validate(
172
+ self, key: str, value: Optional[str] = None
173
+ ) -> dict:
174
+ """校验变量值是否符合 schema
175
+
176
+ Args:
177
+ key: 变量名
178
+ value: 待校验值(None 则使用当前存储的值)
179
+
180
+ Returns:
181
+ {'valid': bool, 'errors': [...], 'warnings': [...]}
182
+ """
183
+ schema = self._load_schema()
184
+ if key not in schema:
185
+ raise SchemaError(f"No schema defined for '{key}'", key)
186
+
187
+ if value is None:
188
+ if key not in self._env_vars:
189
+ entry = schema[key]
190
+ if entry.get('required', False):
191
+ return {
192
+ 'valid': False,
193
+ 'errors': [f"Required variable '{key}' is not set"],
194
+ 'warnings': [],
195
+ }
196
+ return {
197
+ 'valid': True, 'errors': [],
198
+ 'warnings': ['Variable not set (not required)'],
199
+ }
200
+ value = self._env_vars[key]
201
+
202
+ return self._validate_value(key, str(value), schema[key])
203
+
204
+ def validate_all(self) -> dict[str, dict]:
205
+ """校验所有有 schema 定义的变量"""
206
+ schema = self._load_schema()
207
+ results = {}
208
+
209
+ for key, entry in schema.items():
210
+ if key in self._env_vars:
211
+ results[key] = self._validate_value(
212
+ key, str(self._env_vars[key]), entry
213
+ )
214
+ elif entry.get('required', False):
215
+ results[key] = {
216
+ 'valid': False,
217
+ 'errors': [f"Required variable '{key}' is not set"],
218
+ 'warnings': [],
219
+ }
220
+ else:
221
+ results[key] = {
222
+ 'valid': True,
223
+ 'errors': [],
224
+ 'warnings': ['Variable not set (not required)'],
225
+ }
226
+
227
+ return results
228
+
229
+ def _validate_value(
230
+ self, key: str, value: str, entry: dict
231
+ ) -> dict:
232
+ """内部:校验单个值"""
233
+ errors: list[str] = []
234
+ warnings: list[str] = []
235
+
236
+ # 格式校验
237
+ fmt = entry.get('format')
238
+ if fmt == 'ipv6':
239
+ # #7: 使用 ipaddress 标准库校验
240
+ if not validate_ipv6(value):
241
+ errors.append(
242
+ f"Value '{value}' does not match format 'ipv6'"
243
+ )
244
+ elif fmt and fmt in FORMAT_PATTERNS:
245
+ if not FORMAT_PATTERNS[fmt].match(value):
246
+ errors.append(
247
+ f"Value '{value}' does not match format '{fmt}'"
248
+ )
249
+
250
+ # 自定义正则校验
251
+ pattern = entry.get('pattern')
252
+ if pattern:
253
+ try:
254
+ if not re.match(pattern, value):
255
+ errors.append(
256
+ f"Value '{value}' does not match pattern '{pattern}'"
257
+ )
258
+ except re.error:
259
+ errors.append(f"Invalid schema regex: '{pattern}'")
260
+
261
+ return {
262
+ 'valid': len(errors) == 0,
263
+ 'errors': errors,
264
+ 'warnings': warnings,
265
+ }
evm/_typing.py ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ EVM 类型定义
4
+
5
+ 定义 Mixin 类期望的接口协议。
6
+ Mixin 继承此协议后可消除 type: ignore[attr-defined] 注释,
7
+ 并让 mypy 正确校验跨 mixin 的属性访问。
8
+ """
9
+
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+
14
+ class EnvironmentManagerProtocol(Protocol):
15
+ """EnvironmentManager 的协议定义,供 Mixin 类使用。
16
+
17
+ 所有 mixin(IOMixin、GroupMixin、HistoryMixin、SchemaMixin)
18
+ 在运行时期望宿主类提供以下属性和方法。
19
+ """
20
+
21
+ env_file: Path
22
+ _env_vars: dict[str, str]
23
+ lock_timeout: float
24
+
25
+ def _save_env_vars(self, dry_run: bool = False) -> None:
26
+ """保存环境变量到存储文件"""
27
+ ...
28
+
29
+ def log_operation(
30
+ self,
31
+ operation: str,
32
+ key: str = '',
33
+ details: str = '',
34
+ status: str = 'success',
35
+ ) -> None:
36
+ """记录操作日志"""
37
+ ...
38
+
39
+
40
+ __all__ = ['EnvironmentManagerProtocol']