mlog-util 0.1.2__py3-none-any.whl → 0.1.3__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.

Potentially problematic release.


This version of mlog-util might be problematic. Click here for more details.

mlog_util/__init__.py CHANGED
@@ -1,38 +1,3 @@
1
- import logging
2
- from rich.logging import RichHandler
1
+ from .log_manager import LogManager, get_logger
2
+ from .handlers import MultiProcessSafeSizeRotatingHandler, MultiProcessSafeTimeRotatingHandler
3
3
 
4
-
5
- def get_logger(name=None, log_file=None, add_console=True, level=logging.INFO):
6
- logger = logging.getLogger(name=name)
7
- logger.setLevel(level) # 日志级别
8
-
9
- logger.propagate = False # 不向 root logger 冒泡
10
-
11
- has_console = any(isinstance(h, RichHandler) for h in logger.handlers)
12
- has_file = any(isinstance(h, logging.FileHandler) for h in logger.handlers)
13
-
14
-
15
- if add_console and not has_console:
16
- # 控制台 Rich 日志
17
- console_handler = RichHandler(rich_tracebacks=True)
18
- console_formatter = logging.Formatter(
19
- "%(message)s [%(name)s - %(asctime)s]",
20
- datefmt="%Y-%m-%d %H:%M:%S"
21
- )
22
- console_handler.setFormatter(console_formatter)
23
- logger.addHandler(console_handler)
24
-
25
- # 文件日志
26
- if log_file and not has_file:
27
- file_handler = logging.FileHandler(log_file, encoding="utf-8")
28
- file_formatter = logging.Formatter(
29
- "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30
- datefmt="%Y-%m-%d %H:%M:%S"
31
- )
32
- file_handler.setFormatter(file_formatter)
33
- logger.addHandler(file_handler)
34
-
35
- return logger
36
-
37
-
38
- logger = get_logger()
mlog_util/handlers.py ADDED
@@ -0,0 +1,324 @@
1
+ import time
2
+ import os
3
+ import getpass
4
+ import glob
5
+ import errno
6
+ import logging
7
+ import portalocker
8
+ from abc import ABC, abstractmethod
9
+
10
+
11
+ # 常量:锁文件最大存活时间(秒)
12
+ LOCK_TIMEOUT = 120 # 2 minutes
13
+
14
+
15
+ class MultiProcessSafeRotatingHandlerBase(logging.Handler, ABC):
16
+ """
17
+ 日志轮转 Handler 基类
18
+ """
19
+ def __init__(self, filename, backupCount=3):
20
+ super().__init__()
21
+ self.filename = filename
22
+ self.backupCount = backupCount
23
+ self.lockfile = filename + ".lock"
24
+
25
+ # 本进程临时文件
26
+ pid = os.getpid()
27
+ user = getpass.getuser()
28
+ self.tmp_file = f"{filename}.tmp.{user}.{pid}"
29
+
30
+ self.stream = None
31
+
32
+ def emit(self, record):
33
+ try:
34
+ # ✅ 检查锁文件
35
+ if os.path.exists(self.lockfile):
36
+ if self._is_lock_expired():
37
+ # 清理过期锁
38
+ try:
39
+ os.remove(self.lockfile)
40
+ print(f"[MPLog] Removed stale lock: {self.lockfile}")
41
+ except Exception as e:
42
+ print(f"[MPLog] Failed to remove stale lock {self.lockfile}: {e}")
43
+ self._write_to_tmp(record)
44
+ return
45
+ else:
46
+ # 有效锁,写临时文件
47
+ self._write_to_tmp(record)
48
+ return
49
+
50
+ # ✅ 尝试写主文件
51
+ try:
52
+ self._open_log()
53
+ msg = self.format(record) + '\n'
54
+ self.stream.write(msg)
55
+ self.stream.flush()
56
+ except Exception:
57
+ self._write_to_tmp(record)
58
+ return
59
+
60
+ # ✅ 子类决定是否需要轮转
61
+ if self._should_rollover(record):
62
+ self.doRollover()
63
+
64
+ except Exception:
65
+ self.handleError(record)
66
+
67
+ def _write_to_tmp(self, record):
68
+ """写入本进程临时文件"""
69
+ msg = self.format(record) + '\n'
70
+ try:
71
+ with open(self.tmp_file, 'a', encoding='utf-8') as f:
72
+ f.write(msg)
73
+ except Exception as e:
74
+ print(f"Failed to write tmp: {e}")
75
+
76
+ @abstractmethod
77
+ def _should_rollover(self, record) -> bool:
78
+ """子类实现:判断是否需要轮转"""
79
+ pass
80
+
81
+ @abstractmethod
82
+ def _do_rollover_impl(self):
83
+ """子类实现:具体的轮转归档逻辑"""
84
+ pass
85
+
86
+ def _open_log(self):
87
+ """打开主日志文件"""
88
+ if self.stream is None:
89
+ try:
90
+ self.stream = open(self.filename, 'a', encoding='utf-8')
91
+ except Exception as e:
92
+ print(f"Failed to open {self.filename}: {e}")
93
+ raise
94
+
95
+ def _get_file_size(self, filepath):
96
+ """安全获取文件大小"""
97
+ try:
98
+ return os.path.getsize(filepath)
99
+ except (OSError, IOError) as e:
100
+ if e.errno == errno.ENOENT:
101
+ return 0
102
+ raise
103
+
104
+ def _is_lock_expired(self):
105
+ """检查锁文件是否存在且是否超时"""
106
+ try:
107
+ st = os.stat(self.lockfile)
108
+ return time.time() - st.st_mtime > LOCK_TIMEOUT
109
+ except (OSError, IOError) as e:
110
+ if e.errno == errno.ENOENT:
111
+ return False
112
+ return False
113
+
114
+ def doRollover(self):
115
+ """执行轮转(跨平台安全)"""
116
+ # ✅ 使用 portalocker 获取独占锁(非阻塞)
117
+ try:
118
+ lock_fd = os.open(self.lockfile, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
119
+ except Exception as e:
120
+ return # 无法创建锁文件
121
+
122
+ try:
123
+ # 尝试立即获得独占锁(非阻塞)
124
+ portalocker.lock(lock_fd, portalocker.LOCK_EX | portalocker.LOCK_NB)
125
+ # 加锁成功 → 写入锁信息(可选)
126
+ os.write(lock_fd, f"{os.getpid()}\n{time.time()}".encode())
127
+ os.close(lock_fd)
128
+ lock_fd = None # 已关闭
129
+ except portalocker.LockException:
130
+ # 无法获得锁 → 其他进程正在轮转
131
+ if lock_fd is not None:
132
+ os.close(lock_fd)
133
+ return
134
+ except Exception as e:
135
+ # 其他异常
136
+ if lock_fd is not None:
137
+ os.close(lock_fd)
138
+ return
139
+
140
+ try:
141
+ # ✅ 关闭主文件流
142
+ if self.stream:
143
+ self.stream.close()
144
+ self.stream = None
145
+
146
+ # ✅ 执行子类的具体轮转逻辑
147
+ self._do_rollover_impl()
148
+
149
+ # ✅ 合并所有临时文件
150
+ self._merge_temp_files()
151
+
152
+ finally:
153
+ # ✅ 删除锁文件(释放锁)
154
+ try:
155
+ if os.path.exists(self.lockfile):
156
+ os.remove(self.lockfile)
157
+ except Exception:
158
+ pass
159
+
160
+ def _merge_temp_files(self):
161
+ """合并所有临时文件到主日志"""
162
+ tmp_pattern = f"{self.filename}.tmp.*"
163
+ for tmp_path in glob.glob(tmp_pattern):
164
+ try:
165
+ with open(tmp_path, 'r', encoding='utf-8') as f:
166
+ data = f.read()
167
+ if data.strip():
168
+ with open(self.filename, 'a', encoding='utf-8') as logf:
169
+ logf.write(data)
170
+ os.remove(tmp_path)
171
+ except Exception as e:
172
+ print(f"Merge failed {tmp_path}: {e}")
173
+
174
+
175
+ # ========================================
176
+ # 子类:按文件大小轮转
177
+ # ========================================
178
+ def parse_bytes_size(size_str: str) -> int:
179
+ """
180
+ 将表示大小的字符串(如 '1 M', '5K', '2 G')解析为字节数。
181
+ 支持单位:K (KB), M (MB), G (GB)
182
+ 不区分大小写,空格可选。
183
+ """
184
+ size_str = size_str.strip().upper()
185
+
186
+ # 定义单位到字节数的映射(以 1024 为基数)
187
+ units = {
188
+ 'K': 1024,
189
+ 'M': 1024 * 1024,
190
+ 'G': 1024 * 1024 * 1024,
191
+ }
192
+
193
+ # 默认单位是字节(无单位时)
194
+ unit = 'B'
195
+ num_part = size_str
196
+
197
+ # 从后往前找单位
198
+ for u in units:
199
+ if size_str.endswith(u):
200
+ unit = u
201
+ num_part = size_str[:-len(u)].strip()
202
+ break
203
+
204
+ # 解析数值(支持整数和小数)
205
+ try:
206
+ value = float(num_part)
207
+ except ValueError:
208
+ raise ValueError(f"无法解析大小字符串: {size_str}")
209
+
210
+ # 计算总字节数
211
+ if unit == 'B':
212
+ return int(value) # 假设单位是字节
213
+ else:
214
+ return int(value * units[unit])
215
+
216
+ class MultiProcessSafeSizeRotatingHandler(MultiProcessSafeRotatingHandlerBase):
217
+ """
218
+ 使用案例
219
+ >>> handler = MultiProcessSafeSizeRotatingHandler(filename="a1.log", maxBytes="1 M")
220
+ >>> get_logger(custom_handler=handler)
221
+ """
222
+ def __init__(self, filename, maxBytes=5 * 1024 * 1024, backupCount=3):
223
+ super().__init__(filename, backupCount)
224
+ if isinstance(maxBytes, str):
225
+ maxBytes = parse_bytes_size(maxBytes)
226
+
227
+ if maxBytes <= 0:
228
+ raise ValueError("maxBytes must be positive")
229
+
230
+ self.maxBytes = maxBytes
231
+
232
+ def _should_rollover(self, record) -> bool:
233
+ return self._get_file_size(self.filename) >= self.maxBytes
234
+
235
+ def _do_rollover_impl(self):
236
+ # 轮转备份文件
237
+ for i in range(self.backupCount - 1, 0, -1):
238
+ sfn = f"{self.filename}.{i}"
239
+ dfn = f"{self.filename}.{i+1}"
240
+ if os.path.exists(sfn):
241
+ if os.path.exists(dfn):
242
+ os.remove(dfn)
243
+ os.rename(sfn, dfn)
244
+ if os.path.exists(self.filename):
245
+ dfn = f"{self.filename}.1"
246
+ if os.path.exists(dfn):
247
+ os.remove(dfn)
248
+ os.rename(self.filename, dfn)
249
+
250
+ # 重新创建空的日志文件
251
+ try:
252
+ with open(self.filename, 'w', encoding='utf-8') as f:
253
+ pass
254
+ except Exception as e:
255
+ print(f"Failed to recreate log file {self.filename}: {e}")
256
+
257
+
258
+ # ========================================
259
+ # 子类:按时间轮转
260
+ # ========================================
261
+ class MultiProcessSafeTimeRotatingHandler(MultiProcessSafeRotatingHandlerBase):
262
+ """
263
+ 使用案例
264
+ >>> handler = MultiProcessSafeTimeRotatingHandler(filename="a2.log", when='H')
265
+ >>> get_logger(custom_handler=handler)
266
+ """
267
+ def __init__(self, filename, when='D', interval= 1, backupCount=7):
268
+ super().__init__(filename, backupCount)
269
+ self.when = when.upper()
270
+ self.interval = max(1, int(interval)) # 至少为 1
271
+ self.last_rollover = int(time.time())
272
+
273
+ # 支持的单位映射
274
+ self.when_to_seconds = {
275
+ 'S': 10, # 最少 10s
276
+ 'M': 60, # 分钟
277
+ 'H': 3600, # 小时
278
+ 'D': 86400, # 天
279
+ }
280
+
281
+ def _should_rollover(self, record) -> bool:
282
+ now = int(time.time())
283
+
284
+ if self.when not in self.when_to_seconds:
285
+ return False
286
+
287
+ seconds_per_unit = self.when_to_seconds[self.when]
288
+ total_interval_seconds = seconds_per_unit * self.interval
289
+
290
+ # 计算当前属于第几个周期(从 0 开始)
291
+ current_cycle = now // total_interval_seconds
292
+ last_cycle = self.last_rollover // total_interval_seconds
293
+
294
+ return current_cycle > last_cycle
295
+
296
+ def _do_rollover_impl(self):
297
+ # 按日期重命名,如 log.txt -> log.txt.2025-09-16
298
+ date_str = time.strftime(self._get_rollover_format())
299
+ dfn = f"{self.filename}.{date_str}"
300
+ if os.path.exists(self.filename):
301
+ os.rename(self.filename, dfn)
302
+ with open(self.filename, 'w'): pass
303
+ self.last_rollover = int(time.time())
304
+
305
+ def _get_rollover_format(self):
306
+ """
307
+ 根据 when 和 interval 返回时间格式字符串
308
+ """
309
+ if self.when == 'S':
310
+ return "%Y-%m-%d-%H:%M:%S" # 精确到分钟
311
+ if self.when == 'M':
312
+ return "%Y-%m-%d-%H:%M" # 精确到分钟
313
+ elif self.when == 'H':
314
+ if self.interval >= 24:
315
+ return "%Y-%m-%d" # 每N小时但N>=24 → 按天
316
+ else:
317
+ return "%Y-%m-%d-%H" # 按小时
318
+ elif self.when == 'D':
319
+ if self.interval == 1:
320
+ return "%Y-%m-%d" # 每天
321
+ else:
322
+ return "%Y-%m-%d" # 每N天,仍用日期表示(如 2025-09-16)
323
+ else:
324
+ return "%Y-%m-%d" # 默认按天
@@ -0,0 +1,116 @@
1
+ import logging
2
+ import threading
3
+ from rich.logging import RichHandler
4
+ from typing import Type, List, Optional
5
+
6
+ # from .handlers import MultiProcessSafeSizeRotatingHandler, MultiProcessSafeTimeRotatingHandler
7
+
8
+ class LogManager:
9
+ _logger_cache = {}
10
+ _lock = threading.Lock() # 多线程安全
11
+
12
+ @classmethod
13
+ def get_logger(
14
+ cls,
15
+ name: str,
16
+ logger_cls: Type[logging.Logger] = logging.Logger,
17
+ log_file: str | None = None,
18
+ add_console: bool = True,
19
+ level: int = logging.INFO,
20
+ custom_handlers: list[logging.Handler] | None = None,
21
+ ) -> logging.Logger:
22
+ """
23
+ 获取或创建 logger。
24
+
25
+ :param name: logger 名称
26
+ :param logger_cls: logger 类
27
+ :param log_file: 日志文件路径
28
+ :param add_console: 是否添加控制台 RichHandler
29
+ :param level: 日志级别
30
+ :param custom_handlers: 自定义 Handler 列表
31
+ """
32
+ cache_key = (name, logger_cls)
33
+ with cls._lock:
34
+ if cache_key not in cls._logger_cache:
35
+ # 创建 logger
36
+ if logger_cls == logging.Logger:
37
+ logger = logging.getLogger(name)
38
+ else:
39
+ logger = logger_cls(name)
40
+
41
+ logger.setLevel(level)
42
+ logger.propagate = False # 不向 root logger 冒泡
43
+
44
+ # 添加控制台 handler
45
+ if add_console:
46
+ if not any(isinstance(h, RichHandler) for h in logger.handlers):
47
+ console_handler = RichHandler(rich_tracebacks=True)
48
+ console_formatter = logging.Formatter(
49
+ "%(message)s [%(name)s - %(asctime)s]",
50
+ datefmt="%Y-%m-%d %H:%M:%S"
51
+ )
52
+ console_handler.setFormatter(console_formatter)
53
+ logger.addHandler(console_handler)
54
+
55
+ # 添加文件 handler
56
+ if log_file:
57
+ if not any(isinstance(h, logging.FileHandler) for h in logger.handlers):
58
+ # handler = MultiProcessSafeSizeRotatingHandler(log_file, maxBytes=10*200, backupCount=3)
59
+ # file_handler = logging.FileHandler(log_file, encoding="utf-8")
60
+
61
+ handler = logging.FileHandler(log_file, encoding="utf-8")
62
+ file_formatter = logging.Formatter(
63
+ "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
64
+ datefmt="%Y-%m-%d %H:%M:%S"
65
+ )
66
+ handler.setFormatter(file_formatter)
67
+ logger.addHandler(handler)
68
+
69
+ # 添加自定义 handler
70
+ if custom_handlers:
71
+ h = custom_handlers
72
+ if h not in logger.handlers:
73
+ file_formatter = logging.Formatter(
74
+ "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
75
+ datefmt="%Y-%m-%d %H:%M:%S"
76
+ )
77
+ h.setFormatter(file_formatter)
78
+ logger.addHandler(h)
79
+ logger.addHandler(h)
80
+
81
+ cls._logger_cache[cache_key] = logger
82
+
83
+ return cls._logger_cache[cache_key]
84
+
85
+
86
+ # 全局可用的 get_logger 函数(无需引用 LogManager)
87
+ def get_logger(
88
+ name: str=None,
89
+ logger_cls: Type[logging.Logger] = logging.Logger,
90
+ log_file: Optional[str] = None,
91
+ add_console: bool = True,
92
+ level: int = logging.INFO,
93
+ custom_handlers: Optional[List[logging.Handler]] = None,
94
+ ):
95
+ """
96
+ 便捷函数:获取日志记录器,无需关心 LogManager 实例化。
97
+
98
+ 使用示例:
99
+ from log_manager import get_logger
100
+ logger = get_logger("my_module", log_file="app.log")
101
+ logger.info("Hello world")
102
+ """
103
+ if name is None:
104
+ name = "tmp_log"
105
+
106
+ return LogManager.get_logger(
107
+ name=name,
108
+ logger_cls=logger_cls,
109
+ log_file=log_file,
110
+ add_console=add_console,
111
+ level=level,
112
+ custom_handlers=custom_handlers
113
+ )
114
+
115
+ # logger = LogManager().get_logger("tmp_log")
116
+
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlog_util
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: 自用日志库
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: MIT License
7
7
  Classifier: Operating System :: OS Independent
8
8
  Requires-Python: >=3.6
9
9
  Requires-Dist: rich
10
+ Requires-Dist: portalocker
10
11
  Dynamic: classifier
11
12
  Dynamic: requires-dist
12
13
  Dynamic: requires-python
@@ -0,0 +1,7 @@
1
+ mlog_util/__init__.py,sha256=ZzcwRSCJDIHq_enQIRpvl8VRZz7izkL4VsnupRsR3AY,144
2
+ mlog_util/handlers.py,sha256=0EXqAlzxVlcfXEO478EdYJSDdmlZZp5lRame4hEGnmA,10638
3
+ mlog_util/log_manager.py,sha256=r4701jnTosT78lOVJzKnHGnqKnb5D3Yxq0VoN1oF2TI,4276
4
+ mlog_util-0.1.3.dist-info/METADATA,sha256=4qRMzTajA6aXuChtgqEC9Z3X24zd6Ysxxt4XZBoZcMk,379
5
+ mlog_util-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ mlog_util-0.1.3.dist-info/top_level.txt,sha256=-liqOVoloTs4GLglhy_pzBBpK-ltsV3IZpg0OQsoD_4,10
7
+ mlog_util-0.1.3.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- mlog_util/__init__.py,sha256=sEGvZvRJ8V4T9bN6DWbnIh8ajfFFyVgK-g81NwRYopo,1242
2
- mlog_util-0.1.2.dist-info/METADATA,sha256=WKk0GrD1QEeSG5azFKrrt8CZk5uZ_G6kVGvWkTbNESs,352
3
- mlog_util-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- mlog_util-0.1.2.dist-info/top_level.txt,sha256=-liqOVoloTs4GLglhy_pzBBpK-ltsV3IZpg0OQsoD_4,10
5
- mlog_util-0.1.2.dist-info/RECORD,,