mlog-util 2025.12.3__tar.gz

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.
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: mlog-util
3
+ Version: 2025.12.3
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: rich>=14.2.0
8
+ Requires-Dist: portalocker
9
+
10
+
11
+ ```bash
12
+ uv build
13
+ # 会在 dist/ 目录下生成两个文件 .tar.gz 文件(源码分发包) .whl 文件(二进制分发包,也叫 Wheel)
14
+ twine upload dist/*
15
+ # 注意清理旧的文件
16
+ ```
17
+
18
+ ```txt
19
+ 🧹 优化/重构
20
+ 📝 更新文档
21
+ 🐛 修复 Bug
22
+ 🔥 移除代码或文件
23
+ ♻️ 重构代码
24
+ ✨ 添加新功能
25
+ ```
26
+
27
+ ## [v0.1.4] - 2025-10-17
28
+ ### ♻️ 优化/重构
29
+ - 调整 `log_manager` 模块结构
30
+
31
+ ## [v0.1.5] [v0.1.6] - 2025-10-17
32
+ ### ♻️ 调整结构
33
+ - 调整 整体结构, 修复 v0.1.4 不可用效果
34
+
35
+ ## [v0.1.7] - 2025-10-28
36
+ ### ✨ 添加新功能 🐛 修改Bug
37
+ - 调整 时间轮询基准时间(从 UTC 改成 本地时间)
38
+ - 新增 * *, 轮询时间和文件大小(默认按 1天 5M)
39
+
40
+ ## [v2025.12.03]
41
+ - 🐛 删除 时间轮询方式
42
+ - 🐛 修改 文件大小轮询方式
@@ -0,0 +1,33 @@
1
+
2
+ ```bash
3
+ uv build
4
+ # 会在 dist/ 目录下生成两个文件 .tar.gz 文件(源码分发包) .whl 文件(二进制分发包,也叫 Wheel)
5
+ twine upload dist/*
6
+ # 注意清理旧的文件
7
+ ```
8
+
9
+ ```txt
10
+ 🧹 优化/重构
11
+ 📝 更新文档
12
+ 🐛 修复 Bug
13
+ 🔥 移除代码或文件
14
+ ♻️ 重构代码
15
+ ✨ 添加新功能
16
+ ```
17
+
18
+ ## [v0.1.4] - 2025-10-17
19
+ ### ♻️ 优化/重构
20
+ - 调整 `log_manager` 模块结构
21
+
22
+ ## [v0.1.5] [v0.1.6] - 2025-10-17
23
+ ### ♻️ 调整结构
24
+ - 调整 整体结构, 修复 v0.1.4 不可用效果
25
+
26
+ ## [v0.1.7] - 2025-10-28
27
+ ### ✨ 添加新功能 🐛 修改Bug
28
+ - 调整 时间轮询基准时间(从 UTC 改成 本地时间)
29
+ - 新增 * *, 轮询时间和文件大小(默认按 1天 5M)
30
+
31
+ ## [v2025.12.03]
32
+ - 🐛 删除 时间轮询方式
33
+ - 🐛 修改 文件大小轮询方式
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "mlog-util"
3
+ version = "v2025.12.03"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "rich>=14.2.0",
9
+ "portalocker"
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "setuptools>=80.9.0",
15
+ "twine>=6.2.0",
16
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .log_manager import LogManager, get_logger
2
+ from .handlers import MultiProcessSafeSizeRotatingHandler #, MultiProcessSafeTimeRotatingHandler
3
+
4
+ __version__ = "0.1.7"
5
+
@@ -0,0 +1,400 @@
1
+ import time
2
+ import os
3
+ import getpass
4
+ import glob
5
+ import errno
6
+ import logging
7
+ import portalocker
8
+ from pathlib import Path
9
+ import re
10
+ from abc import ABC, abstractmethod
11
+
12
+
13
+ # 常量:锁文件最大存活时间(秒)
14
+ LOCK_TIMEOUT = 120 # 2 minutes
15
+
16
+
17
+ class MultiProcessSafeRotatingHandlerBase(logging.Handler, ABC):
18
+ """
19
+ 日志轮转 Handler 基类
20
+ """
21
+ def __init__(self, filename, backupCount=3):
22
+ super().__init__()
23
+ self.filename = filename
24
+ self.backupCount = backupCount
25
+ self.lockfile = filename + ".lock"
26
+
27
+ # 本进程临时文件
28
+ pid = os.getpid()
29
+ user = getpass.getuser()
30
+ self.tmp_file = f"{filename}.tmp.{user}.{pid}"
31
+
32
+ self.stream = None
33
+
34
+ def emit(self, record):
35
+ try:
36
+ # ✅ 检查锁文件
37
+ if os.path.exists(self.lockfile):
38
+ if self._is_lock_expired():
39
+ # 清理过期锁
40
+ try:
41
+ os.remove(self.lockfile)
42
+ print(f"[MPLog] Removed stale lock: {self.lockfile}")
43
+ except Exception as e:
44
+ print(f"[MPLog] Failed to remove stale lock {self.lockfile}: {e}")
45
+ self._write_to_tmp(record)
46
+ return
47
+ else:
48
+ # 有效锁,写临时文件
49
+ self._write_to_tmp(record)
50
+ return
51
+
52
+ # ✅ 尝试写主文件
53
+ try:
54
+ self._open_log()
55
+ msg = self.format(record) + '\n'
56
+ self.stream.write(msg)
57
+ self.stream.flush()
58
+ except Exception:
59
+ self._write_to_tmp(record)
60
+ return
61
+
62
+ # ✅ 子类决定是否需要轮转
63
+ if self._should_rollover(record):
64
+ self.doRollover()
65
+
66
+ except Exception:
67
+ self.handleError(record)
68
+
69
+ def _write_to_tmp(self, record):
70
+ """写入本进程临时文件"""
71
+ msg = self.format(record) + '\n'
72
+ try:
73
+ with open(self.tmp_file, 'a', encoding='utf-8') as f:
74
+ f.write(msg)
75
+ except Exception as e:
76
+ print(f"Failed to write tmp: {e}")
77
+
78
+ @abstractmethod
79
+ def _should_rollover(self, record) -> bool:
80
+ """子类实现:判断是否需要轮转"""
81
+ pass
82
+
83
+ @abstractmethod
84
+ def _do_rollover_impl(self):
85
+ """子类实现:具体的轮转归档逻辑"""
86
+ pass
87
+
88
+ def _open_log(self):
89
+ """打开主日志文件"""
90
+ if self.stream is None:
91
+ try:
92
+ self.stream = open(self.filename, 'a', encoding='utf-8')
93
+ except Exception as e:
94
+ print(f"Failed to open {self.filename}: {e}")
95
+ raise
96
+
97
+ def _get_file_size(self, filepath):
98
+ """安全获取文件大小"""
99
+ try:
100
+ return os.path.getsize(filepath)
101
+ except (OSError, IOError) as e:
102
+ if e.errno == errno.ENOENT:
103
+ return 0
104
+ raise
105
+
106
+ def _is_lock_expired(self):
107
+ """检查锁文件是否存在且是否超时"""
108
+ try:
109
+ st = os.stat(self.lockfile)
110
+ return time.time() - st.st_mtime > LOCK_TIMEOUT
111
+ except (OSError, IOError) as e:
112
+ if e.errno == errno.ENOENT:
113
+ return False
114
+ return False
115
+
116
+ def doRollover(self):
117
+ """执行轮转(跨平台安全)"""
118
+ # ✅ 使用 portalocker 获取独占锁(非阻塞)
119
+ try:
120
+ lock_fd = os.open(self.lockfile, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
121
+ except Exception as e:
122
+ return # 无法创建锁文件
123
+
124
+ try:
125
+ # 尝试立即获得独占锁(非阻塞)
126
+ portalocker.lock(lock_fd, portalocker.LOCK_EX | portalocker.LOCK_NB)
127
+ # 加锁成功 → 写入锁信息(可选)
128
+ os.write(lock_fd, f"{os.getpid()}\n{time.time()}".encode())
129
+ os.close(lock_fd)
130
+ lock_fd = None # 已关闭
131
+ except portalocker.LockException:
132
+ # 无法获得锁 → 其他进程正在轮转
133
+ if lock_fd is not None:
134
+ os.close(lock_fd)
135
+ return
136
+ except Exception as e:
137
+ # 其他异常
138
+ if lock_fd is not None:
139
+ os.close(lock_fd)
140
+ return
141
+
142
+ try:
143
+ # ✅ 关闭主文件流
144
+ if self.stream:
145
+ self.stream.close()
146
+ self.stream = None
147
+
148
+ # ✅ 执行子类的具体轮转逻辑
149
+ self._do_rollover_impl()
150
+
151
+ # ✅ 合并所有临时文件
152
+ self._merge_temp_files()
153
+
154
+ finally:
155
+ # ✅ 删除锁文件(释放锁)
156
+ try:
157
+ if os.path.exists(self.lockfile):
158
+ os.remove(self.lockfile)
159
+ except Exception:
160
+ pass
161
+
162
+ def _merge_temp_files(self):
163
+ """合并所有临时文件到主日志"""
164
+ tmp_pattern = f"{self.filename}.tmp.*"
165
+ for tmp_path in glob.glob(tmp_pattern):
166
+ try:
167
+ with open(tmp_path, 'r', encoding='utf-8') as f:
168
+ data = f.read()
169
+ if data.strip():
170
+ with open(self.filename, 'a', encoding='utf-8') as logf:
171
+ logf.write(data)
172
+ os.remove(tmp_path)
173
+ except Exception as e:
174
+ print(f"Merge failed {tmp_path}: {e}")
175
+
176
+
177
+ # ========================================
178
+ # 子类:按文件大小轮转
179
+ # ========================================
180
+ def parse_bytes_size(size_str: str) -> int:
181
+ """
182
+ 将表示大小的字符串(如 '1 M', '5K', '2 G')解析为字节数。
183
+ 支持单位:K (KB), M (MB), G (GB)
184
+ 不区分大小写,空格可选。
185
+ """
186
+ size_str = size_str.strip().upper()
187
+
188
+ # 定义单位到字节数的映射(以 1024 为基数)
189
+ units = {
190
+ 'K': 1024,
191
+ 'M': 1024 * 1024,
192
+ 'G': 1024 * 1024 * 1024,
193
+ }
194
+
195
+ # 默认单位是字节(无单位时)
196
+ unit = 'B'
197
+ num_part = size_str
198
+
199
+ # 从后往前找单位
200
+ for u in units:
201
+ if size_str.endswith(u):
202
+ unit = u
203
+ num_part = size_str[:-len(u)].strip()
204
+ break
205
+
206
+ # 解析数值(支持整数和小数)
207
+ try:
208
+ value = float(num_part)
209
+ except ValueError:
210
+ raise ValueError(f"无法解析大小字符串: {size_str}")
211
+
212
+ # 计算总字节数
213
+ if unit == 'B':
214
+ return int(value) # 假设单位是字节
215
+ else:
216
+ return int(value * units[unit])
217
+
218
+ class MultiProcessSafeSizeRotatingHandler(MultiProcessSafeRotatingHandlerBase):
219
+ """
220
+ 使用案例
221
+ >>> handler = MultiProcessSafeSizeRotatingHandler(filename="a1.log", maxBytes="1 M")
222
+ >>> get_logger(custom_handler=handler)
223
+ """
224
+ def __init__(self, filename, maxBytes=5 * 1024 * 1024, backupCount=3):
225
+ super().__init__(filename, backupCount)
226
+ if isinstance(maxBytes, str):
227
+ maxBytes = parse_bytes_size(maxBytes)
228
+
229
+ if maxBytes <= 0:
230
+ raise ValueError("maxBytes must be positive")
231
+
232
+ self.maxBytes = maxBytes
233
+
234
+ def _should_rollover(self, record) -> bool:
235
+ return self._get_file_size(self.filename) >= self.maxBytes
236
+
237
+ def _do_rollover_impl(self):
238
+ # 轮转备份文件
239
+ log_path = Path(self.filename)
240
+
241
+ # 1. 轮转已存在的备份文件 (例如 .3 -> .4, .2 -> .3, .1 -> .2)
242
+ # 倒序处理,避免覆盖
243
+ for i in range(self.backupCount - 1, 0, -1):
244
+ sfn = log_path.with_suffix(f'.log.{i}')
245
+ dfn = log_path.with_suffix(f'.log.{i+1}')
246
+
247
+ if sfn.exists():
248
+ try:
249
+ # 直接尝试重命名,如果目标文件已存在会失败
250
+ os.rename(sfn, dfn)
251
+ except FileExistsError:
252
+ # 如果失败,说明目标文件已存在,先删除再重命名
253
+ # 这比 "先检查再操作" 更能避免竞态条件
254
+ dfn.unlink() # pathlib 的删除方法
255
+ os.rename(sfn, dfn)
256
+
257
+ # 2. 将当前日志文件重命名为第一个备份 .1
258
+ if log_path.exists():
259
+ dfn = log_path.with_suffix('.log.1')
260
+ try:
261
+ os.rename(log_path, dfn)
262
+ except FileExistsError:
263
+ dfn.unlink()
264
+ os.rename(log_path, dfn)
265
+
266
+ # 重新创建空的日志文件 占位
267
+ try:
268
+ with open(self.filename, 'w', encoding='utf-8') as f:
269
+ pass
270
+ except Exception as e:
271
+ print(f"Failed to recreate log file {self.filename}: {e}")
272
+
273
+
274
+ # ========================================
275
+ # 子类:按时间轮转
276
+ # ========================================
277
+ class MultiProcessSafeTimeRotatingHandler(MultiProcessSafeRotatingHandlerBase):
278
+ """
279
+ 使用案例
280
+ >>> handler = MultiProcessSafeTimeRotatingHandler(filename="a2.log", when='H')
281
+ >>> get_logger(custom_handler=handler)
282
+ """
283
+ def __init__(self, filename, when='D', interval= 1, backupCount=7):
284
+ super().__init__(filename, backupCount)
285
+ self.when = when.upper()
286
+ self.interval = max(1, int(interval)) # 至少为 1
287
+
288
+ # 支持的单位映射
289
+ self.when_to_seconds = {
290
+ 'S': 10, # 最少 10s
291
+ 'M': 60, # 分钟
292
+ 'H': 3600, # 小时
293
+ 'D': 86400, # 天
294
+ }
295
+
296
+ if self.when not in self.when_to_seconds:
297
+ raise ValueError(f"Invalid rollover interval specified: {self.when}")
298
+
299
+ # 在初始化时就计算好下一个轮转时间点
300
+ self.rolloverAt = self._compute_next_rollover_time(int(time.time()))
301
+
302
+ def _compute_next_rollover_time(self, current_time):
303
+ """
304
+ 计算下一个轮转的时间点(时间戳)。
305
+ 这个方法的核心是使用本地时间来计算,确保轮转发生在正确的本地时间。
306
+ """
307
+ t = time.localtime(current_time)
308
+
309
+ # 根据轮转单位,找到当前周期的起始时间点
310
+ if self.when == 'S':
311
+ current_period_start = time.mktime((t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, t.tm_wday, t.tm_yday, t.tm_isdst))
312
+ elif self.when == 'M':
313
+ current_period_start = time.mktime((t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, 0, t.tm_wday, t.tm_yday, t.tm_isdst))
314
+ elif self.when == 'H':
315
+ current_period_start = time.mktime((t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, 0, 0, t.tm_wday, t.tm_yday, t.tm_isdst))
316
+ else: # 'D' or any other
317
+ current_period_start = time.mktime((t.tm_year, t.tm_mon, t.tm_mday, 0, 0, 0, t.tm_wday, t.tm_yday, t.tm_isdst))
318
+
319
+ # 下一个轮转时间点 = 当前周期起始 + N个周期
320
+ next_rollover_time = current_period_start + (self.interval * self.when_to_seconds[self.when])
321
+
322
+ # 如果计算出的时间点已经过了(例如,程序刚好在边界点启动),则再推后一个周期
323
+ if next_rollover_time <= current_time:
324
+ next_rollover_time += self.interval * self.when_to_seconds[self.when]
325
+
326
+ return next_rollover_time
327
+
328
+ def _should_rollover(self, record) -> bool:
329
+ """
330
+ 修正3: 判断逻辑改为与下一个轮转时间点比较
331
+ """
332
+ # 获取日志记录产生的时间戳
333
+ record_time = int(record.created)
334
+
335
+ # 如果记录的时间已经超过了我们预定的下一个轮转时间点,则触发轮转
336
+ return record_time >= self.rolloverAt
337
+
338
+ def _do_rollover_impl(self):
339
+ # 1. 执行轮转:将当前日志文件重命名为带时间戳的文件
340
+ date_str = time.strftime(self._get_rollover_format())
341
+ log_path = Path(self.filename)
342
+ dfn = log_path.with_name(f"{log_path.name}.{date_str}")
343
+
344
+ if log_path.exists():
345
+ try:
346
+ log_path.rename(dfn)
347
+ except FileExistsError:
348
+ dfn.unlink()
349
+ log_path.rename(dfn)
350
+
351
+ # 2. 重新创建空的日志文件(使用你指定的方式)
352
+ try:
353
+ with open(self.filename, 'w', encoding='utf-8') as f:
354
+ pass
355
+ except Exception as e:
356
+ print(f"Failed to recreate log file {self.filename}: {e}")
357
+
358
+ # 3. 更新下一个轮转的时间点(核心逻辑)
359
+ current_time = int(time.time())
360
+ self.rolloverAt = self._compute_next_rollover_time(current_time)
361
+
362
+ # --- 清理旧备份的逻辑 ---
363
+
364
+ # 4. 查找所有匹配的备份文件
365
+ backup_pattern = log_path.with_name(f"{log_path.name}.*")
366
+ backup_files = glob.glob(str(backup_pattern))
367
+
368
+ # 5. 如果备份文件数量超过限制,则进行清理
369
+ if len(backup_files) > self.backupCount:
370
+ # 6. 按文件名(即时间戳)排序,找出最旧的文件
371
+ backup_files.sort()
372
+
373
+ # 7. 计算需要删除的文件数量并删除
374
+ files_to_delete = backup_files[:-self.backupCount]
375
+ for file_to_delete in files_to_delete:
376
+ try:
377
+ Path(file_to_delete).unlink()
378
+ except OSError as e:
379
+ print(f"Error deleting old log file {file_to_delete}: {e}")
380
+
381
+ def _get_rollover_format(self):
382
+ """
383
+ 根据 when 和 interval 返回时间格式字符串
384
+ """
385
+ if self.when == 'S':
386
+ return "%Y-%m-%d-%H:%M:%S" # 精确到分钟
387
+ if self.when == 'M':
388
+ return "%Y-%m-%d-%H:%M" # 精确到分钟
389
+ elif self.when == 'H':
390
+ if self.interval >= 24:
391
+ return "%Y-%m-%d" # 每N小时但N>=24 → 按天
392
+ else:
393
+ return "%Y-%m-%d-%H" # 按小时
394
+ elif self.when == 'D':
395
+ if self.interval == 1:
396
+ return "%Y-%m-%d" # 每天
397
+ else:
398
+ return "%Y-%m-%d" # 每N天,仍用日期表示(如 2025-09-16)
399
+ else:
400
+ return "%Y-%m-%d" # 默认按天