mlog-util 0.1.2__tar.gz → 0.1.5__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.

Potentially problematic release.


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

@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: mlog-util
3
+ Version: 0.1.5
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: rich>=14.2.0
8
+ Requires-Dist: portalocker
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "mlog-util"
3
+ version = "0.1.5"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
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,3 @@
1
+ from .log_manager import LogManager, get_logger
2
+ from .handlers import MultiProcessSafeSizeRotatingHandler, MultiProcessSafeTimeRotatingHandler
3
+
@@ -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,108 @@
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
+ """
10
+ 一个线程安全的日志管理器,用于获取和配置具有 Rich 控制台输出的 Logger。
11
+ """
12
+ _logger_cache: dict[str, logging.Logger] = {}
13
+ _lock = threading.Lock()
14
+
15
+ @classmethod
16
+ def get_logger(
17
+ cls,
18
+ name: str,
19
+ log_file: Optional[str] = None,
20
+ add_console: bool = True,
21
+ level: int = logging.INFO,
22
+ custom_handlers: Optional[List[logging.Handler]] = None,
23
+ ) -> logging.Logger:
24
+ """
25
+ 获取或创建一个配置好的 logger。
26
+
27
+ 注意:Logger 实例按 name 缓存。重复调用会返回同一个实例,
28
+ 但会确保其配置(如 level 和 handlers)符合当前调用参数。
29
+
30
+ :param name: logger 名称。
31
+ :param log_file: 日志文件路径,如果为 None 则不写入文件。
32
+ :param add_console: 是否添加带 Rich 格式的控制台 Handler。
33
+ :param level: 日志级别。
34
+ :param custom_handlers: 自定义 Handler 列表。
35
+ """
36
+ with cls._lock:
37
+ # 1. 获取或创建 Logger 实例 (利用 logging 模块自身的缓存)
38
+ logger = logging.getLogger(name)
39
+
40
+ # 2. 确保基本配置
41
+ logger.setLevel(level)
42
+ logger.propagate = False
43
+
44
+ # 3. 配置控制台 Handler
45
+ if add_console and not any(isinstance(h, RichHandler) for h in logger.handlers):
46
+ console_handler = RichHandler(rich_tracebacks=True, show_time=False, show_path=False)
47
+ # 注意:RichHandler 默认有自己的时间格式,我们可以在 Formatter 中覆盖
48
+ console_formatter = logging.Formatter(
49
+ "[%(name)s - %(asctime)s] %(message)s",
50
+ datefmt="%Y-%m-%d %H:%M:%S"
51
+ )
52
+ console_handler.setFormatter(console_formatter)
53
+ logger.addHandler(console_handler)
54
+
55
+ # 4. 配置文件 Handler
56
+ if log_file and not any(isinstance(h, logging.FileHandler) for h in logger.handlers):
57
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
58
+ file_formatter = logging.Formatter(
59
+ "%(asctime)s | %(name)s | %(levelname)-8s | %(message)s",
60
+ datefmt="%Y-%m-%d %H:%M:%S"
61
+ )
62
+ file_handler.setFormatter(file_formatter)
63
+ logger.addHandler(file_handler)
64
+
65
+ # 5. 配置自定义 Handlers (修正了原代码的 Bug)
66
+ if custom_handlers:
67
+ # 为所有自定义 handlers 设置一个统一的格式
68
+ custom_formatter = logging.Formatter(
69
+ "%(asctime)s | %(name)s | %(levelname)-8s | %(message)s",
70
+ datefmt="%Y-%m-%d %H:%M:%S"
71
+ )
72
+ for handler in custom_handlers:
73
+ if handler not in logger.handlers:
74
+ handler.setFormatter(custom_formatter)
75
+ logger.addHandler(handler)
76
+
77
+ return logger
78
+
79
+
80
+ # 全局可用的 get_logger 函数(无需引用 LogManager)
81
+ def get_logger(
82
+ name: str=None,
83
+ log_file: Optional[str] = None,
84
+ add_console: bool = True,
85
+ level: int = logging.INFO,
86
+ custom_handlers: Optional[List[logging.Handler]] = None,
87
+ ):
88
+ """
89
+ 便捷函数:获取日志记录器,无需关心 LogManager 实例化。
90
+
91
+ 使用示例:
92
+ from log_manager import get_logger
93
+ logger = get_logger("my_module", log_file="app.log")
94
+ logger.info("Hello world")
95
+ """
96
+ if name is None:
97
+ name = "tmp_log"
98
+
99
+ return LogManager.get_logger(
100
+ name=name,
101
+ log_file=log_file,
102
+ add_console=add_console,
103
+ level=level,
104
+ custom_handlers=custom_handlers
105
+ )
106
+
107
+ # logger = LogManager().get_logger("tmp_log")
108
+
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: mlog-util
3
+ Version: 0.1.5
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: rich>=14.2.0
8
+ Requires-Dist: portalocker
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ src/mlog-util/__init__.py
3
+ src/mlog-util/handlers.py
4
+ src/mlog-util/log_manager.py
5
+ src/mlog_util.egg-info/PKG-INFO
6
+ src/mlog_util.egg-info/SOURCES.txt
7
+ src/mlog_util.egg-info/dependency_links.txt
8
+ src/mlog_util.egg-info/requires.txt
9
+ src/mlog_util.egg-info/top_level.txt
10
+ tests/test_mlog.py
@@ -0,0 +1,2 @@
1
+ rich>=14.2.0
2
+ portalocker
@@ -0,0 +1 @@
1
+ mlog-util
@@ -0,0 +1,37 @@
1
+ import time
2
+ import os
3
+ import mlog
4
+ from mlog import LogManager
5
+ from multiprocessing import Pool
6
+
7
+ log_file = "logs/a1.log"
8
+
9
+ log_manager = LogManager()
10
+ logger_a1 = log_manager.get_logger("a1", log_file=log_file, add_console=False)
11
+ # logger_a2 = log_manager.get_logger("a2", log_file=log_file, add_console=False)
12
+ # logger_a3 = log_manager.get_logger("a3", log_file=log_file, add_console=False)
13
+
14
+
15
+ # 测试 num 个 日志耗时
16
+ def test_speed_time(num = 500):
17
+ import time
18
+ _st = time.time()
19
+ for i in range(num):
20
+ logger_a1.info(i)
21
+ logger_a1.info(f"{num} --- {time.time() - _st}")
22
+
23
+ # 测试多进程
24
+ # 1000 个日志有没有
25
+ def test_logger(x):
26
+ _pid = os.getpid()
27
+ logger_a1.info(f"{_pid} -- {x}")
28
+
29
+
30
+ if __name__ == "__main__":
31
+ with open(log_file, "w") as f:
32
+ pass
33
+ with Pool(2) as pool:
34
+ pool.map(test_logger, range(0, 5000))
35
+
36
+
37
+
mlog_util-0.1.2/PKG-INFO DELETED
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: mlog_util
3
- Version: 0.1.2
4
- Summary: 自用日志库
5
- Classifier: Programming Language :: Python :: 3
6
- Classifier: License :: OSI Approved :: MIT License
7
- Classifier: Operating System :: OS Independent
8
- Requires-Python: >=3.6
9
- Requires-Dist: rich
10
- Dynamic: classifier
11
- Dynamic: requires-dist
12
- Dynamic: requires-python
13
- Dynamic: summary
@@ -1,38 +0,0 @@
1
- import logging
2
- from rich.logging import RichHandler
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()
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: mlog_util
3
- Version: 0.1.2
4
- Summary: 自用日志库
5
- Classifier: Programming Language :: Python :: 3
6
- Classifier: License :: OSI Approved :: MIT License
7
- Classifier: Operating System :: OS Independent
8
- Requires-Python: >=3.6
9
- Requires-Dist: rich
10
- Dynamic: classifier
11
- Dynamic: requires-dist
12
- Dynamic: requires-python
13
- Dynamic: summary
@@ -1,7 +0,0 @@
1
- setup.py
2
- mlog_util/__init__.py
3
- mlog_util.egg-info/PKG-INFO
4
- mlog_util.egg-info/SOURCES.txt
5
- mlog_util.egg-info/dependency_links.txt
6
- mlog_util.egg-info/requires.txt
7
- mlog_util.egg-info/top_level.txt
@@ -1 +0,0 @@
1
- rich
@@ -1 +0,0 @@
1
- mlog_util
mlog_util-0.1.2/setup.py DELETED
@@ -1,20 +0,0 @@
1
- from setuptools import setup, find_packages
2
-
3
- setup(
4
- name="mlog_util",
5
- version="0.1.2",
6
- packages=find_packages(),
7
- install_requires=["rich"], # 依赖库
8
- # author="may",
9
- # author_email="no",
10
- description="自用日志库",
11
- # long_description=open("README.md", encoding="utf-8").read(),
12
- # long_description_content_type="text/markdown",
13
-
14
- classifiers=[
15
- "Programming Language :: Python :: 3",
16
- "License :: OSI Approved :: MIT License",
17
- "Operating System :: OS Independent",
18
- ],
19
- python_requires='>=3.6',
20
- )
File without changes