bitool 0.1.2__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.
- bitool/__init__.py +27 -0
- bitool/cmd/__init__.py +65 -0
- bitool/cmd/_base.py +105 -0
- bitool/cmd/_condition.py +60 -0
- bitool/cmd/_scheduler.py +548 -0
- bitool/cmd/env.py +454 -0
- bitool/cmd/git.py +123 -0
- bitool/cmd/io.py +248 -0
- bitool/cmd/pdf.py +385 -0
- bitool/cmd/run.py +300 -0
- bitool/cmd/toml.py +237 -0
- bitool/cmd/version.py +630 -0
- bitool/consts.py +14 -0
- bitool/core/__init__.py +7 -0
- bitool/core/app.py +142 -0
- bitool/core/commands.py +194 -0
- bitool/core/config.py +647 -0
- bitool/core/env.py +18 -0
- bitool/core/logger.py +237 -0
- bitool/core/plugin.py +117 -0
- bitool/core/workspace.py +76 -0
- bitool/models/__init__.py +3 -0
- bitool/models/version.py +173 -0
- bitool/scripts/__init__.py +1 -0
- bitool/scripts/bumpversion.py +189 -0
- bitool/scripts/clearscreen.py +37 -0
- bitool/scripts/envpy.py +161 -0
- bitool/scripts/envrs.py +119 -0
- bitool/scripts/filedate.py +246 -0
- bitool/scripts/filelevel.py +191 -0
- bitool/scripts/gittool.py +178 -0
- bitool/scripts/img2pdf.py +151 -0
- bitool/scripts/pdf2img.py +139 -0
- bitool/scripts/piptool.py +130 -0
- bitool/scripts/pymake.py +345 -0
- bitool/scripts/sshcopyid.py +491 -0
- bitool/scripts/taskkill.py +366 -0
- bitool/scripts/which.py +227 -0
- bitool/types.py +7 -0
- bitool/utils/__init__.py +9 -0
- bitool/utils/cli_parser.py +412 -0
- bitool/utils/executor.py +881 -0
- bitool/utils/profiler.py +369 -0
- bitool/utils/task.py +133 -0
- bitool/utils/task_group.py +668 -0
- bitool/utils/tests/__init__.py +0 -0
- bitool/utils/tests/test_profiler.py +487 -0
- bitool-0.1.2.dist-info/METADATA +154 -0
- bitool-0.1.2.dist-info/RECORD +51 -0
- bitool-0.1.2.dist-info/WHEEL +4 -0
- bitool-0.1.2.dist-info/entry_points.txt +15 -0
bitool/core/config.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""通用配置模块 - 简化的 JSON 配置基类."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from functools import cached_property
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable, ClassVar, Union
|
|
19
|
+
|
|
20
|
+
# 配置值类型定义
|
|
21
|
+
ConfigValue = Union[str, int, float, bool, list, dict, Decimal, complex, bytes, None]
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import fcntl
|
|
25
|
+
|
|
26
|
+
HAS_FCNTL = True
|
|
27
|
+
LOCK_EX = (
|
|
28
|
+
fcntl.LOCK_EX # ty:ignore[unresolved-attribute]
|
|
29
|
+
)
|
|
30
|
+
except ImportError:
|
|
31
|
+
HAS_FCNTL = False
|
|
32
|
+
LOCK_EX = 2 # Windows 上的占位符
|
|
33
|
+
|
|
34
|
+
__all__ = ["ConfigMixin"]
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _SystemConfig:
|
|
40
|
+
"""系统配置类, 提供一些系统相关的属性."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def _HOME_DIR(self) -> Path:
|
|
46
|
+
"""获取用户主目录."""
|
|
47
|
+
return Path.home()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def _EXTENSION(self) -> str:
|
|
51
|
+
"""获取可执行文件扩展名."""
|
|
52
|
+
return ".exe" if self._IS_WINDOWS else ""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def _IS_WINDOWS(self) -> bool:
|
|
56
|
+
"""判断是否为 Windows 系统."""
|
|
57
|
+
return platform.system() == "Windows"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _IS_WINDOWS_7(self) -> bool:
|
|
61
|
+
"""判断是否为 Windows 7 系统."""
|
|
62
|
+
return self._IS_WINDOWS and platform.release() == "7"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def _WINDOWS_VERSION_NUMBER(self) -> int:
|
|
66
|
+
"""获取 Windows 系统版本号.
|
|
67
|
+
|
|
68
|
+
在 Windows 系统上返回主版本号(如 Windows 10 返回 10), 在非 Windows 系统上返回 0.
|
|
69
|
+
"""
|
|
70
|
+
return int(platform.version().split(".")[0]) if self._IS_WINDOWS else 0
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def _CPU_COUNT(self) -> int:
|
|
74
|
+
"""获取 CPU 核心数."""
|
|
75
|
+
return os.cpu_count() or 1
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def _MAX_WORKERS(self) -> int:
|
|
79
|
+
"""获取最大工作线程数."""
|
|
80
|
+
return min(8, max(1, self._CPU_COUNT * 2))
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def _BATCH_SIZE(self) -> int:
|
|
84
|
+
"""获取批量处理大小."""
|
|
85
|
+
return min(20, max(5, self._CPU_COUNT * 2))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ConfigMixin(_SystemConfig):
|
|
89
|
+
"""简化的 JSON 配置混入基类.
|
|
90
|
+
|
|
91
|
+
核心功能:
|
|
92
|
+
- 自动从 JSON 文件加载配置
|
|
93
|
+
- 支持动态设置属性并保存
|
|
94
|
+
- 简单的属性访问代理
|
|
95
|
+
|
|
96
|
+
安全特性:
|
|
97
|
+
- 线程安全的配置读写
|
|
98
|
+
- 循环引用检测和清理
|
|
99
|
+
- 保存失败时的备份恢复机制
|
|
100
|
+
- 多进程安全的文件锁机制
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
NAME: str = ""
|
|
104
|
+
ROOT_DIR: ClassVar[Path] = Path.home() / ".bitool" # 设置默认的根目录,可被子类重写
|
|
105
|
+
|
|
106
|
+
_file_attrs: dict[str, ConfigValue]
|
|
107
|
+
_attrs: dict[str, ConfigValue]
|
|
108
|
+
_lock: threading.RLock
|
|
109
|
+
_observers: list[Callable[[str, ConfigValue, ConfigValue], None]]
|
|
110
|
+
_is_shutting_down: bool = False
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
"""初始化配置混入类."""
|
|
114
|
+
self._file_attrs: dict[str, ConfigValue] = {}
|
|
115
|
+
self._attrs: dict[str, ConfigValue] = {}
|
|
116
|
+
self._observers: list[Callable[[str, ConfigValue, ConfigValue], None]] = []
|
|
117
|
+
self._is_shutting_down = False
|
|
118
|
+
|
|
119
|
+
# 使用可重入锁, 允许同一线程多次获取
|
|
120
|
+
self._lock = threading.RLock()
|
|
121
|
+
|
|
122
|
+
self.load_from_json()
|
|
123
|
+
|
|
124
|
+
root_dir = Path(self.ROOT_DIR)
|
|
125
|
+
if not root_dir.exists():
|
|
126
|
+
logger.debug(f"创建设置目录 {root_dir}")
|
|
127
|
+
root_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
# 类名以_开头则不在退出时保存json, 避免内部类干扰
|
|
130
|
+
if not type(self).__name__.startswith("_"):
|
|
131
|
+
atexit.register(self.save_to_json)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def attrs(self) -> dict[str, ConfigValue]:
|
|
135
|
+
"""获取所有配置属性."""
|
|
136
|
+
return self._filter_serializable_attrs(include_private=False)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def attrs_with_private(self) -> dict[str, ConfigValue]:
|
|
140
|
+
"""获取所有配置属性, 包括私有属性."""
|
|
141
|
+
return self._filter_serializable_attrs(include_private=True)
|
|
142
|
+
|
|
143
|
+
def __getattribute__(self, name: str) -> object:
|
|
144
|
+
"""获取属性, 优先从实例属性中查找."""
|
|
145
|
+
# 私有属性和特殊属性直接访问
|
|
146
|
+
if name.startswith("_") or (name.startswith("__") and name.endswith("__")):
|
|
147
|
+
return object.__getattribute__(self, name)
|
|
148
|
+
|
|
149
|
+
# 从 _attrs 中查找配置属性
|
|
150
|
+
attrs = object.__getattribute__(self, "_attrs")
|
|
151
|
+
if name in attrs:
|
|
152
|
+
value = attrs[name]
|
|
153
|
+
# 只在调试级别输出详细日志
|
|
154
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
155
|
+
logger.debug(f"获取配置属性 {name} = {value!r}")
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
return object.__getattribute__(self, name)
|
|
159
|
+
|
|
160
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
161
|
+
"""设置属性."""
|
|
162
|
+
if name.startswith("_"):
|
|
163
|
+
object.__setattr__(self, name, value)
|
|
164
|
+
else:
|
|
165
|
+
with self._lock:
|
|
166
|
+
old_value = self._attrs.get(name)
|
|
167
|
+
# 将 object 类型的值转换为 ConfigValue 类型
|
|
168
|
+
# 由于 ConfigValue 是一个宽泛的联合类型,大多数值都可以赋值
|
|
169
|
+
self._attrs[name] = value # ty:ignore[invalid-assignment]
|
|
170
|
+
|
|
171
|
+
# 通知观察者
|
|
172
|
+
if old_value != value:
|
|
173
|
+
self._notify_observers(name, old_value, value) # ty:ignore[invalid-argument-type]
|
|
174
|
+
|
|
175
|
+
def add_observer(
|
|
176
|
+
self, callback: Callable[[str, ConfigValue, ConfigValue], None]
|
|
177
|
+
) -> None:
|
|
178
|
+
"""添加配置变更观察者."""
|
|
179
|
+
with self._lock:
|
|
180
|
+
if callback not in self._observers:
|
|
181
|
+
self._observers.append(callback)
|
|
182
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
183
|
+
callback_name = (
|
|
184
|
+
callback.__name__
|
|
185
|
+
if hasattr(callback, "__name__")
|
|
186
|
+
else "anonymous"
|
|
187
|
+
)
|
|
188
|
+
logger.debug(f"添加配置观察者 {callback_name}")
|
|
189
|
+
|
|
190
|
+
def remove_observer(
|
|
191
|
+
self, callback: Callable[[str, ConfigValue, ConfigValue], None]
|
|
192
|
+
) -> None:
|
|
193
|
+
"""移除配置变更观察者
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
callback: 要移除的回调函数
|
|
197
|
+
"""
|
|
198
|
+
with self._lock:
|
|
199
|
+
if callback in self._observers:
|
|
200
|
+
self._observers.remove(callback)
|
|
201
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
202
|
+
callback_name = (
|
|
203
|
+
callback.__name__
|
|
204
|
+
if hasattr(callback, "__name__")
|
|
205
|
+
else "anonymous"
|
|
206
|
+
)
|
|
207
|
+
logger.debug(f"移除配置观察者 {callback_name}")
|
|
208
|
+
|
|
209
|
+
def _notify_observers(
|
|
210
|
+
self, key: str, old_value: ConfigValue, new_value: ConfigValue
|
|
211
|
+
) -> None:
|
|
212
|
+
"""通知所有观察者配置变更."""
|
|
213
|
+
if not self._observers:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
217
|
+
logger.debug(f"通知 {len(self._observers)} 个观察者配置变更 {key}")
|
|
218
|
+
|
|
219
|
+
for observer in list(self._observers):
|
|
220
|
+
self._safe_execute_observer(observer, key, old_value, new_value)
|
|
221
|
+
|
|
222
|
+
def _safe_execute_observer(
|
|
223
|
+
self,
|
|
224
|
+
observer: Callable[[str, ConfigValue, ConfigValue], None],
|
|
225
|
+
key: str,
|
|
226
|
+
old_value: ConfigValue,
|
|
227
|
+
new_value: ConfigValue,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""安全执行单个观察者, 避免 PERF203 循环异常处理警告."""
|
|
230
|
+
try:
|
|
231
|
+
observer(key, old_value, new_value)
|
|
232
|
+
except OSError:
|
|
233
|
+
logger.exception(msg="观察者执行失败 OSError")
|
|
234
|
+
except RuntimeError:
|
|
235
|
+
logger.exception("观察者执行失败 RuntimeError")
|
|
236
|
+
except ValueError:
|
|
237
|
+
logger.exception("观察者执行失败 ValueError")
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def _config_file(self) -> Path:
|
|
241
|
+
"""配置文件路径."""
|
|
242
|
+
name = self.NAME or self._default_name
|
|
243
|
+
return Path(self.ROOT_DIR) / f"{name}.json"
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def _backup_file(self) -> Path:
|
|
247
|
+
"""备份文件路径."""
|
|
248
|
+
return self._config_file.with_suffix(".json.bak")
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def _default_name(self) -> str:
|
|
252
|
+
"""默认配置名称, 从类名转换."""
|
|
253
|
+
import re
|
|
254
|
+
|
|
255
|
+
class_name = type(self).__name__
|
|
256
|
+
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", class_name)
|
|
257
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).lower()
|
|
258
|
+
return name.replace("_config", "")
|
|
259
|
+
|
|
260
|
+
@cached_property
|
|
261
|
+
def cls_attrs(self) -> dict[str, ConfigValue]:
|
|
262
|
+
"""类属性字典."""
|
|
263
|
+
return {
|
|
264
|
+
attr: value
|
|
265
|
+
for attr, value in self.__class__.__dict__.items()
|
|
266
|
+
if not attr.startswith("_") and not callable(value)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def load_from_json(self) -> None:
|
|
270
|
+
"""从 JSON 文件加载配置
|
|
271
|
+
|
|
272
|
+
加载优先级:
|
|
273
|
+
1. 文件中的配置值 最高优先级
|
|
274
|
+
2. 类属性默认值 最低优先级
|
|
275
|
+
"""
|
|
276
|
+
# 清空现有属性
|
|
277
|
+
self._attrs.clear()
|
|
278
|
+
self._file_attrs.clear()
|
|
279
|
+
|
|
280
|
+
# 先加载类属性作为默认值
|
|
281
|
+
self._attrs.update(self.cls_attrs)
|
|
282
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
283
|
+
logger.debug(f"加载类属性默认值 {self.cls_attrs}")
|
|
284
|
+
|
|
285
|
+
# 如果配置文件不存在, 直接返回
|
|
286
|
+
if not self._config_file.exists():
|
|
287
|
+
logger.debug(f"配置文件不存在 使用默认值 {self._config_file}")
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
with self._config_file.open("r", encoding="utf-8") as f:
|
|
292
|
+
loaded_data: dict[str, ConfigValue] = json.load(f)
|
|
293
|
+
|
|
294
|
+
# 处理 null 值
|
|
295
|
+
if loaded_data is None:
|
|
296
|
+
logger.warning(f"配置文件内容为 null 忽略文件配置 {self._config_file}")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# 验证数据格式
|
|
300
|
+
if not isinstance(loaded_data, dict):
|
|
301
|
+
logger.error(
|
|
302
|
+
f"配置文件格式错误 期望 dict 类型但得到 {type(loaded_data).__name__} {self._config_file}"
|
|
303
|
+
)
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
self._file_attrs = loaded_data
|
|
307
|
+
# 文件配置覆盖类属性, 这是用户期望的行为
|
|
308
|
+
self._attrs.update(self._file_attrs)
|
|
309
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
310
|
+
logger.debug(f"从配置文件加载并覆盖属性 {self._file_attrs}")
|
|
311
|
+
|
|
312
|
+
except json.JSONDecodeError:
|
|
313
|
+
logger.exception(f"配置文件 JSON 解析失败 {self._config_file}")
|
|
314
|
+
self._restore_from_backup()
|
|
315
|
+
except OSError:
|
|
316
|
+
logger.exception(f"读取配置文件失败 {self._config_file}")
|
|
317
|
+
|
|
318
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
319
|
+
logger.debug(f"最终加载的属性 {self._attrs}")
|
|
320
|
+
|
|
321
|
+
def _restore_from_backup(self) -> None:
|
|
322
|
+
"""从备份文件恢复配置."""
|
|
323
|
+
if self._backup_file.exists():
|
|
324
|
+
# 注意: 在atexit回调中避免使用logger
|
|
325
|
+
# logger.info(f"尝试从备份文件恢复 {self._backup_file}")
|
|
326
|
+
try:
|
|
327
|
+
with self._backup_file.open("r", encoding="utf-8") as f:
|
|
328
|
+
self._file_attrs = json.load(f) or {}
|
|
329
|
+
# logger.info("成功从备份文件恢复配置")
|
|
330
|
+
except (json.JSONDecodeError, OSError):
|
|
331
|
+
# logger.exception("从备份文件恢复失败")
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
def _atomic_write_config(
|
|
335
|
+
self, data: dict[str, ConfigValue], *, is_exiting: bool = False
|
|
336
|
+
) -> None:
|
|
337
|
+
"""原子写入配置文件 支持多进程并发安全
|
|
338
|
+
|
|
339
|
+
使用临时文件 + 重命名的方式确保写入的原子性:
|
|
340
|
+
1. 在同一目录创建临时文件
|
|
341
|
+
2. 写入数据到临时文件
|
|
342
|
+
3. 使用原子重命名替换原文件
|
|
343
|
+
|
|
344
|
+
在 Unix 系统上使用文件锁防止并发冲突
|
|
345
|
+
在 Windows 系统上使用更安全的文件替换策略
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
data: 要写入的配置数据
|
|
349
|
+
is_exiting: 是否在程序退出时调用
|
|
350
|
+
"""
|
|
351
|
+
config_dir = self._config_file.parent
|
|
352
|
+
fd = None
|
|
353
|
+
temp_file = None
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
# 创建临时文件
|
|
357
|
+
fd, temp_path = tempfile.mkstemp(suffix=".tmp", dir=config_dir)
|
|
358
|
+
temp_file = Path(temp_path)
|
|
359
|
+
|
|
360
|
+
# 写入临时文件
|
|
361
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
362
|
+
json.dump(
|
|
363
|
+
data, f, indent=4, ensure_ascii=False, default=self._default_encoder
|
|
364
|
+
)
|
|
365
|
+
f.flush()
|
|
366
|
+
os.fsync(f.fileno()) # 确保数据刷入磁盘
|
|
367
|
+
fd = None # fd 已被 os.fdopen 接管
|
|
368
|
+
|
|
369
|
+
# 在 Unix 系统上对临时文件加锁
|
|
370
|
+
if HAS_FCNTL:
|
|
371
|
+
with contextlib.suppress(OSError), temp_file.open("r+") as lock_f:
|
|
372
|
+
fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) # ty:ignore[unresolved-attribute]
|
|
373
|
+
|
|
374
|
+
# 原子替换原配置文件
|
|
375
|
+
# Windows 上 replace 可能失败如果文件被占用 需要重试
|
|
376
|
+
max_retries = 5
|
|
377
|
+
for attempt in range(max_retries):
|
|
378
|
+
try:
|
|
379
|
+
# Windows 上需要先删除目标文件再重命名
|
|
380
|
+
if self._IS_WINDOWS and self._config_file.exists():
|
|
381
|
+
# 尝试删除原文件
|
|
382
|
+
try:
|
|
383
|
+
self._config_file.unlink()
|
|
384
|
+
except PermissionError:
|
|
385
|
+
# 如果删除失败 可能是文件被占用 等待后重试
|
|
386
|
+
if attempt < max_retries - 1:
|
|
387
|
+
delay = 0.1 * (attempt + 1)
|
|
388
|
+
time.sleep(delay)
|
|
389
|
+
continue
|
|
390
|
+
else:
|
|
391
|
+
raise
|
|
392
|
+
|
|
393
|
+
# 使用 os.replace 进行原子替换
|
|
394
|
+
os.replace(str(temp_file), str(self._config_file))
|
|
395
|
+
temp_file = None # 替换成功 临时文件已被重命名
|
|
396
|
+
break
|
|
397
|
+
except (PermissionError, OSError):
|
|
398
|
+
if attempt < max_retries - 1:
|
|
399
|
+
delay = 0.1 * (attempt + 1)
|
|
400
|
+
time.sleep(delay)
|
|
401
|
+
else:
|
|
402
|
+
# 最后一次重试失败 抛出原始异常
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
except OSError:
|
|
406
|
+
# 清理临时文件
|
|
407
|
+
if temp_file is not None and temp_file.exists():
|
|
408
|
+
with contextlib.suppress(OSError):
|
|
409
|
+
temp_file.unlink()
|
|
410
|
+
raise
|
|
411
|
+
finally:
|
|
412
|
+
# 确保文件描述符被关闭
|
|
413
|
+
if fd is not None:
|
|
414
|
+
with contextlib.suppress(OSError):
|
|
415
|
+
os.close(fd)
|
|
416
|
+
|
|
417
|
+
def _default_encoder(self, obj: object) -> object:
|
|
418
|
+
"""自定义 JSON 编码器, 处理不可序列化的类型."""
|
|
419
|
+
# 处理复数类型
|
|
420
|
+
if isinstance(obj, complex):
|
|
421
|
+
return {"__complex__": True, "real": obj.real, "imag": obj.imag}
|
|
422
|
+
# 处理 Decimal 类型
|
|
423
|
+
if isinstance(obj, Decimal):
|
|
424
|
+
return float(obj)
|
|
425
|
+
# 处理集合类型
|
|
426
|
+
if isinstance(obj, (set, frozenset)):
|
|
427
|
+
return list(obj)
|
|
428
|
+
# 处理字节类型
|
|
429
|
+
if isinstance(obj, bytes):
|
|
430
|
+
return obj.decode("utf-8", errors="replace")
|
|
431
|
+
# 处理其他不可序列化类型 返回字符串表示
|
|
432
|
+
return str(obj)
|
|
433
|
+
|
|
434
|
+
def _remove_circular_refs(
|
|
435
|
+
self,
|
|
436
|
+
obj: object,
|
|
437
|
+
seen: set[int] | None = None,
|
|
438
|
+
depth: int = 0,
|
|
439
|
+
max_depth: int = 50,
|
|
440
|
+
) -> object:
|
|
441
|
+
"""递归移除循环引用, 限制最大深度避免栈溢出."""
|
|
442
|
+
if seen is None:
|
|
443
|
+
seen = set()
|
|
444
|
+
|
|
445
|
+
# 限制递归深度, 防止栈溢出
|
|
446
|
+
if depth > max_depth:
|
|
447
|
+
return "<max depth exceeded>"
|
|
448
|
+
|
|
449
|
+
# 如果是可变对象, 检查循环引用, 避免无限递归
|
|
450
|
+
if isinstance(obj, dict):
|
|
451
|
+
obj_id = id(obj)
|
|
452
|
+
if obj_id in seen:
|
|
453
|
+
return "<circular reference>"
|
|
454
|
+
seen.add(obj_id)
|
|
455
|
+
result = {
|
|
456
|
+
k: self._remove_circular_refs(v, seen, depth + 1, max_depth)
|
|
457
|
+
for k, v in obj.items()
|
|
458
|
+
}
|
|
459
|
+
seen.discard(obj_id)
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
if isinstance(obj, (list, tuple)):
|
|
463
|
+
obj_id = id(obj)
|
|
464
|
+
if obj_id in seen:
|
|
465
|
+
return "<circular reference>"
|
|
466
|
+
seen.add(obj_id)
|
|
467
|
+
result = [
|
|
468
|
+
self._remove_circular_refs(item, seen, depth + 1, max_depth)
|
|
469
|
+
for item in obj
|
|
470
|
+
]
|
|
471
|
+
seen.discard(obj_id)
|
|
472
|
+
return result
|
|
473
|
+
return obj
|
|
474
|
+
|
|
475
|
+
def _is_basic_serializable(self, obj: object) -> bool:
|
|
476
|
+
"""检查对象是否是基本可序列化类型
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
obj: 要检查的对象
|
|
480
|
+
|
|
481
|
+
Returns
|
|
482
|
+
-------
|
|
483
|
+
如果是基本可序列化类型返回 True, 否则返回 False
|
|
484
|
+
"""
|
|
485
|
+
return isinstance(
|
|
486
|
+
obj, (str, int, float, bool, type(None), Decimal, complex, bytes)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def _validate_container(
|
|
490
|
+
self,
|
|
491
|
+
obj: dict | list | tuple,
|
|
492
|
+
path: str,
|
|
493
|
+
seen: set[int],
|
|
494
|
+
max_depth: int,
|
|
495
|
+
current_depth: int,
|
|
496
|
+
*,
|
|
497
|
+
is_exiting: bool = False,
|
|
498
|
+
) -> bool:
|
|
499
|
+
"""验证容器类型 dict, list, tuple 的可序列化性
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
obj: 要验证的容器对象
|
|
503
|
+
path: 当前对象的路径
|
|
504
|
+
seen: 已访问对象的 ID 集合
|
|
505
|
+
max_depth: 最大嵌套深度
|
|
506
|
+
current_depth: 当前嵌套深度
|
|
507
|
+
is_exiting: 是否在程序退出时调用
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
如果可序列化返回 True, 否则返回 False
|
|
512
|
+
"""
|
|
513
|
+
obj_id = id(obj)
|
|
514
|
+
if obj_id in seen:
|
|
515
|
+
# 检测到循环引用, 直接返回 False, 不再继续遍历
|
|
516
|
+
return False
|
|
517
|
+
seen.add(obj_id)
|
|
518
|
+
|
|
519
|
+
if isinstance(obj, dict):
|
|
520
|
+
for k, v in obj.items():
|
|
521
|
+
if not isinstance(k, str):
|
|
522
|
+
if not is_exiting:
|
|
523
|
+
with contextlib.suppress(ValueError, RuntimeError, OSError):
|
|
524
|
+
logger.warning(f"字典键不是字符串 {k!r} at '{path}'")
|
|
525
|
+
return False
|
|
526
|
+
if not self._validate_serializable(
|
|
527
|
+
v,
|
|
528
|
+
f"{path}.{k}",
|
|
529
|
+
seen.copy(),
|
|
530
|
+
max_depth,
|
|
531
|
+
current_depth=current_depth + 1,
|
|
532
|
+
is_exiting=is_exiting,
|
|
533
|
+
):
|
|
534
|
+
return False
|
|
535
|
+
else: # list or tuple
|
|
536
|
+
for i, item in enumerate(obj):
|
|
537
|
+
if not self._validate_serializable(
|
|
538
|
+
item,
|
|
539
|
+
f"{path}[{i}]",
|
|
540
|
+
seen.copy(),
|
|
541
|
+
max_depth,
|
|
542
|
+
current_depth=current_depth + 1,
|
|
543
|
+
is_exiting=is_exiting,
|
|
544
|
+
):
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
def _validate_serializable(
|
|
550
|
+
self,
|
|
551
|
+
obj: object,
|
|
552
|
+
path: str = "",
|
|
553
|
+
seen: set[int] | None = None,
|
|
554
|
+
max_depth: int = 50,
|
|
555
|
+
current_depth: int = 0,
|
|
556
|
+
*,
|
|
557
|
+
is_exiting: bool = False,
|
|
558
|
+
) -> bool:
|
|
559
|
+
"""验证对象是否可以 JSON 序列化
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
obj: 要验证的对象
|
|
563
|
+
path: 当前对象的路径, 用于错误报告
|
|
564
|
+
seen: 已访问对象的 ID 集合, 用于检测循环引用
|
|
565
|
+
max_depth: 最大嵌套深度
|
|
566
|
+
current_depth: 当前嵌套深度
|
|
567
|
+
is_exiting: 是否在程序退出时调用
|
|
568
|
+
"""
|
|
569
|
+
if seen is None:
|
|
570
|
+
seen = set()
|
|
571
|
+
|
|
572
|
+
# 限制递归深度, 防止栈溢出
|
|
573
|
+
if current_depth > max_depth:
|
|
574
|
+
if not is_exiting:
|
|
575
|
+
with contextlib.suppress(ValueError, RuntimeError, OSError):
|
|
576
|
+
logger.error(
|
|
577
|
+
f"对象嵌套过深 ({current_depth}/{max_depth}) at '{path}'"
|
|
578
|
+
)
|
|
579
|
+
return False
|
|
580
|
+
|
|
581
|
+
# 检查基本类型
|
|
582
|
+
if self._is_basic_serializable(obj):
|
|
583
|
+
return True
|
|
584
|
+
|
|
585
|
+
# 检查容器类型
|
|
586
|
+
if isinstance(obj, (dict, list, tuple)):
|
|
587
|
+
result = self._validate_container(
|
|
588
|
+
obj,
|
|
589
|
+
path,
|
|
590
|
+
seen,
|
|
591
|
+
max_depth,
|
|
592
|
+
current_depth=current_depth,
|
|
593
|
+
is_exiting=is_exiting,
|
|
594
|
+
)
|
|
595
|
+
if not result and not is_exiting:
|
|
596
|
+
with contextlib.suppress(ValueError, RuntimeError, OSError):
|
|
597
|
+
logger.error(f"配置包含不可序列化的数据或循环引用 at '{path}'")
|
|
598
|
+
return result
|
|
599
|
+
|
|
600
|
+
# 其他类型会在编码时转换为字符串
|
|
601
|
+
return True
|
|
602
|
+
|
|
603
|
+
def save_to_json(self) -> None:
|
|
604
|
+
"""保存配置到 JSON 文件, 支持多进程并发安全."""
|
|
605
|
+
# 检测是否正在退出 避免在atexit回调中使用已关闭的日志系统
|
|
606
|
+
is_exiting = getattr(sys, "_is_finalizing", lambda: False)()
|
|
607
|
+
|
|
608
|
+
with self._lock:
|
|
609
|
+
try:
|
|
610
|
+
self._config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
611
|
+
|
|
612
|
+
# 过滤掉可调用对象
|
|
613
|
+
serializable_attrs = self._filter_serializable_attrs(
|
|
614
|
+
include_private=True
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# 先处理循环引用
|
|
618
|
+
cleaned_attrs = self._remove_circular_refs(serializable_attrs)
|
|
619
|
+
|
|
620
|
+
# 使用原子写入方式, 先写入临时文件, 再替换原文件
|
|
621
|
+
# 这样可以避免多进程同时写入时的文件锁定问题
|
|
622
|
+
self._atomic_write_config(data=cleaned_attrs, is_exiting=is_exiting) # ty:ignore[invalid-argument-type]
|
|
623
|
+
|
|
624
|
+
except (OSError, json.JSONDecodeError):
|
|
625
|
+
# 尝试从备份恢复
|
|
626
|
+
if self._backup_file.exists():
|
|
627
|
+
self._restore_from_backup()
|
|
628
|
+
|
|
629
|
+
def _filter_serializable_attrs(
|
|
630
|
+
self, *, include_private: bool = False
|
|
631
|
+
) -> dict[str, ConfigValue]:
|
|
632
|
+
"""过滤可序列化的配置属性
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
include_private: 是否包含私有属性
|
|
636
|
+
|
|
637
|
+
Returns
|
|
638
|
+
-------
|
|
639
|
+
过滤后的配置属性字典
|
|
640
|
+
"""
|
|
641
|
+
if include_private:
|
|
642
|
+
return {k: v for k, v in self._attrs.items() if not callable(v)}
|
|
643
|
+
return {
|
|
644
|
+
k: v
|
|
645
|
+
for k, v in self._attrs.items()
|
|
646
|
+
if not k.startswith("_") and not callable(v)
|
|
647
|
+
}
|
bitool/core/env.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
_ENABLE_TAGS: set[str] = {
|
|
6
|
+
"1",
|
|
7
|
+
"true",
|
|
8
|
+
"yes",
|
|
9
|
+
"on",
|
|
10
|
+
"enabled",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def env_check_enabled(name: str) -> bool:
|
|
15
|
+
"""检查环境变量是否启用."""
|
|
16
|
+
|
|
17
|
+
enabled = os.getenv(name, "0").strip().lower() in _ENABLE_TAGS
|
|
18
|
+
return enabled
|