kitty-logger 0.1.0__tar.gz → 0.2.0.dev0__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.
Files changed (25) hide show
  1. kitty_logger-0.2.0.dev0/PKG-INFO +92 -0
  2. kitty_logger-0.2.0.dev0/README.md +79 -0
  3. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/pyproject.toml +3 -2
  4. kitty_logger-0.2.0.dev0/src/kitty_logger/__init__.py +31 -0
  5. kitty_logger-0.2.0.dev0/src/kitty_logger/_client.py +102 -0
  6. kitty_logger-0.2.0.dev0/src/kitty_logger/_env.py +13 -0
  7. kitty_logger-0.2.0.dev0/src/kitty_logger/_formatters.py +82 -0
  8. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/src/kitty_logger/_server.py +66 -31
  9. kitty_logger-0.2.0.dev0/src/kitty_logger/_setup.py +320 -0
  10. kitty_logger-0.2.0.dev0/src/kitty_logger.egg-info/PKG-INFO +92 -0
  11. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/tests/test_kitty_logger.py +60 -20
  12. kitty_logger-0.1.0/PKG-INFO +0 -86
  13. kitty_logger-0.1.0/README.md +0 -74
  14. kitty_logger-0.1.0/src/kitty_logger/__init__.py +0 -19
  15. kitty_logger-0.1.0/src/kitty_logger/_client.py +0 -83
  16. kitty_logger-0.1.0/src/kitty_logger/_env.py +0 -8
  17. kitty_logger-0.1.0/src/kitty_logger/_formatters.py +0 -60
  18. kitty_logger-0.1.0/src/kitty_logger/_setup.py +0 -282
  19. kitty_logger-0.1.0/src/kitty_logger.egg-info/PKG-INFO +0 -86
  20. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/LICENSE +0 -0
  21. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/setup.cfg +0 -0
  22. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/src/kitty_logger.egg-info/SOURCES.txt +0 -0
  23. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/src/kitty_logger.egg-info/dependency_links.txt +0 -0
  24. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/src/kitty_logger.egg-info/requires.txt +0 -0
  25. {kitty_logger-0.1.0 → kitty_logger-0.2.0.dev0}/src/kitty_logger.egg-info/top_level.txt +0 -0
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: kitty_logger
3
+ Version: 0.2.0.dev0
4
+ Summary: Cross-process logging via a dedicated log server process and SocketHandler.
5
+ Author: Kitty
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=7; extra == "test"
12
+ Dynamic: license-file
13
+
14
+ # kitty_logger
15
+
16
+ 单机跨进程的 Python 日志库。主进程启动一个独立的"日志服务"子进程,所有
17
+ 通过 `spawn` 创建的子进程把 `LogRecord` 经 `logging.handlers.SocketHandler`
18
+ 发到这里,由它统一落盘 / 输出到 `stderr`,**不会出现多进程并发写文件的
19
+ 错行或丢失问题**。
20
+
21
+ **适用范围。** 公司内部研发协作环境:单台主机、本机所有进程都互相信任、
22
+ 追求"用起来简单"而不是"对抗外部威胁"。**不适用于**生产部署或多租户机器
23
+ 等不能信任本机其他进程的场景。
24
+
25
+ ## 安装
26
+
27
+ ```bash
28
+ pip install kitty-logger
29
+ ```
30
+
31
+ ## 用法
32
+
33
+ ```python
34
+ # main.py
35
+ import multiprocessing as mp
36
+ import kitty_logger
37
+
38
+ def worker(i):
39
+ log = kitty_logger.getLogger(f"worker.{i}")
40
+ log.info("hello from worker %d", i)
41
+
42
+ if __name__ == "__main__":
43
+ kitty_logger.setup_logging(log_file="app.log")
44
+ log = kitty_logger.getLogger("main")
45
+ log.info("starting")
46
+
47
+ with mp.get_context("spawn").Pool(4) as pool:
48
+ pool.map(worker, range(4))
49
+ ```
50
+
51
+ `setup_logging` 会写入环境变量
52
+ `KITTY_LOGGER_HOST` / `KITTY_LOGGER_PORT` / `KITTY_LOGGER_LEVEL`;任意层级
53
+ `spawn` 出来的后代进程会自动继承,从而能在零配置的情况下连上日志服务。
54
+
55
+ ## API
56
+
57
+ - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None, attach_main_logger=True) -> (host, port)`
58
+ 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
59
+ `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
60
+ **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
61
+ - `getLogger(name=None) -> logging.Logger`
62
+ 返回一个挂好 `SocketHandler`、指向日志服务的 logger。
63
+ - `shutdown_logging()` — 显式停止日志服务子进程。
64
+
65
+ ## 为什么只支持 spawn
66
+
67
+ `fork` 出来的子进程会继承父进程已经建立的 `SocketHandler` 及其底层 TCP
68
+ 连接。父子进程会在同一个 socket 上交叉写 pickle 字节流,导致服务端反
69
+ 序列化必然失败。此外 `fork` 在多线程父进程中不安全(其他线程持有的锁
70
+ 会原样留在子进程里,引发死锁),且在 Windows 上不被支持。
71
+
72
+ 请使用 `multiprocessing.get_context("spawn")`,或在顶层用
73
+ `if __name__ == "__main__":` 守卫配合 Python 默认行为。
74
+
75
+ ## 注意事项
76
+
77
+ - **不要**在 `setup_logging(attach_main_logger=True)` 之前调用
78
+ `logging.basicConfig()`(或自行给 root logger 挂 `StreamHandler`)——
79
+ 否则主进程会把每条记录输出两次:一次走本地 root handler,一次走日志
80
+ 服务。要么让 kitty_logger 做唯一的配置入口,要么传
81
+ `attach_main_logger=False` 自行管理主进程的 handler。
82
+ - `shutdown_logging()` 会卸载本进程内 kitty_logger 自己挂的
83
+ `SocketHandler`,并清理 `KITTY_LOGGER_*` 环境变量,确保进程状态与
84
+ `setup_logging` 对称。如果你额外挂了别的 handler,仍由你自己负责清理。
85
+
86
+ ## 安全性
87
+
88
+ 服务端用 `pickle.loads` 反序列化收到的字节流——这等价于在本机上对攻击者
89
+ 可控的输入做"任意代码执行"。为了让这个能力**永远不会跨出本机**,
90
+ `setup_logging` 在打开 socket 之前就会**直接拒绝任何非 loopback 绑定**
91
+ (`ValueError`)。监听端口仅本机可达;请把它视为受信任的内部接口,**不要**
92
+ 在不能信任本机其他进程的多租户机器上运行。
@@ -0,0 +1,79 @@
1
+ # kitty_logger
2
+
3
+ 单机跨进程的 Python 日志库。主进程启动一个独立的"日志服务"子进程,所有
4
+ 通过 `spawn` 创建的子进程把 `LogRecord` 经 `logging.handlers.SocketHandler`
5
+ 发到这里,由它统一落盘 / 输出到 `stderr`,**不会出现多进程并发写文件的
6
+ 错行或丢失问题**。
7
+
8
+ **适用范围。** 公司内部研发协作环境:单台主机、本机所有进程都互相信任、
9
+ 追求"用起来简单"而不是"对抗外部威胁"。**不适用于**生产部署或多租户机器
10
+ 等不能信任本机其他进程的场景。
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ pip install kitty-logger
16
+ ```
17
+
18
+ ## 用法
19
+
20
+ ```python
21
+ # main.py
22
+ import multiprocessing as mp
23
+ import kitty_logger
24
+
25
+ def worker(i):
26
+ log = kitty_logger.getLogger(f"worker.{i}")
27
+ log.info("hello from worker %d", i)
28
+
29
+ if __name__ == "__main__":
30
+ kitty_logger.setup_logging(log_file="app.log")
31
+ log = kitty_logger.getLogger("main")
32
+ log.info("starting")
33
+
34
+ with mp.get_context("spawn").Pool(4) as pool:
35
+ pool.map(worker, range(4))
36
+ ```
37
+
38
+ `setup_logging` 会写入环境变量
39
+ `KITTY_LOGGER_HOST` / `KITTY_LOGGER_PORT` / `KITTY_LOGGER_LEVEL`;任意层级
40
+ `spawn` 出来的后代进程会自动继承,从而能在零配置的情况下连上日志服务。
41
+
42
+ ## API
43
+
44
+ - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None, attach_main_logger=True) -> (host, port)`
45
+ 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
46
+ `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
47
+ **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
48
+ - `getLogger(name=None) -> logging.Logger`
49
+ 返回一个挂好 `SocketHandler`、指向日志服务的 logger。
50
+ - `shutdown_logging()` — 显式停止日志服务子进程。
51
+
52
+ ## 为什么只支持 spawn
53
+
54
+ `fork` 出来的子进程会继承父进程已经建立的 `SocketHandler` 及其底层 TCP
55
+ 连接。父子进程会在同一个 socket 上交叉写 pickle 字节流,导致服务端反
56
+ 序列化必然失败。此外 `fork` 在多线程父进程中不安全(其他线程持有的锁
57
+ 会原样留在子进程里,引发死锁),且在 Windows 上不被支持。
58
+
59
+ 请使用 `multiprocessing.get_context("spawn")`,或在顶层用
60
+ `if __name__ == "__main__":` 守卫配合 Python 默认行为。
61
+
62
+ ## 注意事项
63
+
64
+ - **不要**在 `setup_logging(attach_main_logger=True)` 之前调用
65
+ `logging.basicConfig()`(或自行给 root logger 挂 `StreamHandler`)——
66
+ 否则主进程会把每条记录输出两次:一次走本地 root handler,一次走日志
67
+ 服务。要么让 kitty_logger 做唯一的配置入口,要么传
68
+ `attach_main_logger=False` 自行管理主进程的 handler。
69
+ - `shutdown_logging()` 会卸载本进程内 kitty_logger 自己挂的
70
+ `SocketHandler`,并清理 `KITTY_LOGGER_*` 环境变量,确保进程状态与
71
+ `setup_logging` 对称。如果你额外挂了别的 handler,仍由你自己负责清理。
72
+
73
+ ## 安全性
74
+
75
+ 服务端用 `pickle.loads` 反序列化收到的字节流——这等价于在本机上对攻击者
76
+ 可控的输入做"任意代码执行"。为了让这个能力**永远不会跨出本机**,
77
+ `setup_logging` 在打开 socket 之前就会**直接拒绝任何非 loopback 绑定**
78
+ (`ValueError`)。监听端口仅本机可达;请把它视为受信任的内部接口,**不要**
79
+ 在不能信任本机其他进程的多租户机器上运行。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitty_logger"
7
- version = "0.1.0"
7
+ version = "0.2.0.dev0"
8
8
  description = "Cross-process logging via a dedicated log server process and SocketHandler."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -19,6 +19,7 @@ test = ["pytest>=7"]
19
19
 
20
20
  [tool.pytest.ini_options]
21
21
  testpaths = ["tests"]
22
+ pythonpath = ["src"]
22
23
 
23
24
  [tool.black]
24
- line-length = 2048
25
+ line-length = 88
@@ -0,0 +1,31 @@
1
+ """kitty_logger:单机跨进程的 Python 日志库。
2
+
3
+ 面向内部研发协作环境(**不针对生产 / 多租户场景**)。主进程调用一次
4
+ :func:`setup_logging`,之后任意 ``spawn`` 出来的子进程直接调用
5
+ :func:`getLogger` 即可——后代进程会自动通过环境变量找到日志服务,所有
6
+ 日志收敛到主进程指定的文件 / ``stderr`` 中,不会因多进程并发写文件而
7
+ 错行或丢失。
8
+
9
+ 典型用法::
10
+
11
+ # main.py
12
+ import multiprocessing as mp
13
+ import kitty_logger
14
+
15
+ def worker(i: int) -> None:
16
+ log = kitty_logger.getLogger(f"worker.{i}")
17
+ log.info("hello from worker %d", i)
18
+
19
+ if __name__ == "__main__":
20
+ kitty_logger.setup_logging(log_file="app.log")
21
+ log = kitty_logger.getLogger("main")
22
+ log.info("starting")
23
+ with mp.get_context("spawn").Pool(4) as pool:
24
+ pool.map(worker, range(4))
25
+ """
26
+
27
+ from ._client import getLogger
28
+ from ._setup import setup_logging, shutdown_logging
29
+
30
+ __all__ = ["setup_logging", "shutdown_logging", "getLogger"]
31
+ __version__ = "0.2.0.dev0"
@@ -0,0 +1,102 @@
1
+ """子进程侧 API::func:`getLogger`。
2
+
3
+ 子进程通过环境变量
4
+ ``KITTY_LOGGER_HOST`` / ``KITTY_LOGGER_PORT`` / ``KITTY_LOGGER_LEVEL``
5
+ 找到日志服务地址;这些环境变量是父进程在 :func:`setup_logging` 时写入的,
6
+ ``spawn`` 出来的后代进程会自动继承。
7
+ """
8
+
9
+ import logging
10
+ import logging.handlers
11
+ import os
12
+ import threading
13
+
14
+ from ._env import ENV_HOST, ENV_LEVEL, ENV_PORT
15
+
16
+ # 保护下面这个模块级集合的并发访问。
17
+ _lock = threading.Lock()
18
+
19
+ # 已挂过 SocketHandler 的 logger 名集合,用于在同一进程内做去重。
20
+ # root logger 在集合里以空字符串 ``""`` 作为 key。
21
+ _configured_loggers: set[str] = set()
22
+
23
+
24
+ def _read_endpoint() -> tuple[str, int]:
25
+ """从环境变量读出 ``(host, port)``;未设置则抛出友好错误。"""
26
+ host = os.environ.get(ENV_HOST)
27
+ port = os.environ.get(ENV_PORT)
28
+ if not host or not port:
29
+ raise RuntimeError(
30
+ "kitty_logger 尚未在当前进程树中初始化。"
31
+ + "请先在主进程调用 kitty_logger.setup_logging(),"
32
+ + "再创建(spawn)子进程或调用 getLogger()。"
33
+ )
34
+ return host, int(port)
35
+
36
+
37
+ def _read_level() -> int:
38
+ """从环境变量解析默认 level。优先按整数解析,失败则按级别名解析。"""
39
+ raw = os.environ.get(ENV_LEVEL)
40
+ if raw is None:
41
+ return logging.INFO
42
+ try:
43
+ return int(raw)
44
+ except ValueError:
45
+ pass
46
+ return logging.getLevelNamesMapping().get(raw.upper(), logging.INFO)
47
+
48
+
49
+ def _attach_socket_handler(
50
+ logger: logging.Logger,
51
+ host: str,
52
+ port: int,
53
+ level: int,
54
+ ) -> None:
55
+ """给 ``logger`` 挂上指向日志服务的 :class:`SocketHandler`。
56
+
57
+ .. note::
58
+ :class:`logging.handlers.SocketHandler` 直接 pickle ``LogRecord``
59
+ 发送,由接收端格式化;在这里给 handler 设置 ``formatter`` 没有
60
+ 任何效果,会被服务端忽略。
61
+ """
62
+ SocketHandler = logging.handlers.SocketHandler
63
+
64
+ # 同一 logger 上避免重复挂指向相同端点的 SocketHandler——例如模块被
65
+ # 重新 import、或测试用例反复 setup 的场景。
66
+ for h in logger.handlers:
67
+ if isinstance(h, SocketHandler) and (h.host, h.port) == (host, port):
68
+ return
69
+
70
+ logger.addHandler(SocketHandler(host, port))
71
+
72
+ # 仅在 logger 仍是默认 ``NOTSET`` 时才设置 level,避免悄悄覆盖
73
+ # 用户已经显式设置过的级别。
74
+ if logger.level == logging.NOTSET:
75
+ logger.setLevel(level)
76
+
77
+ # 关闭向 root 的传播,避免与已存在的 root handler(比如调用过
78
+ # ``basicConfig``)造成重复输出。root 自身没有父级,跳过此项。
79
+ if logger is not logging.getLogger():
80
+ logger.propagate = False
81
+
82
+
83
+ def getLogger(name: str | None = None) -> logging.Logger:
84
+ """返回一个会把日志通过 :class:`SocketHandler` 发送到日志服务的 logger。
85
+
86
+ 必须在父进程已经调用过 :func:`kitty_logger.setup_logging` 之后才能
87
+ 使用。本库只支持 ``spawn`` 启动方式;spawn 出来的子进程模块级状态
88
+ 天然为空,因此不需要处理"继承自父进程的 SocketHandler"问题。
89
+
90
+ :param name: logger 名;``None`` 表示 root logger。
91
+ :raises RuntimeError: 当前进程树尚未调用 ``setup_logging``。
92
+ """
93
+ host, port = _read_endpoint()
94
+ level = _read_level()
95
+ logger = logging.getLogger(name)
96
+
97
+ with _lock:
98
+ key = name or ""
99
+ if key not in _configured_loggers:
100
+ _attach_socket_handler(logger, host, port, level)
101
+ _configured_loggers.add(key)
102
+ return logger
@@ -0,0 +1,13 @@
1
+ """共享的环境变量名常量。
2
+
3
+ 将这几个常量单独放在一个不依赖任何业务模块的位置,避免 ``_setup`` 与
4
+ ``_client`` 之间出现循环导入。
5
+
6
+ 这些环境变量由主进程的 :func:`kitty_logger.setup_logging` 写入;通过
7
+ ``spawn`` 启动的子进程会自然继承,从而能在不显式传参的情况下找到日志
8
+ 服务地址。
9
+ """
10
+
11
+ ENV_HOST = "KITTY_LOGGER_HOST"
12
+ ENV_PORT = "KITTY_LOGGER_PORT"
13
+ ENV_LEVEL = "KITTY_LOGGER_LEVEL"
@@ -0,0 +1,82 @@
1
+ """日志格式化器:控制台版(按字段着色 ANSI)与文件版(无颜色)。
2
+
3
+ 控制台格式化器把不同字段染成不同颜色,便于在终端里快速分辨;
4
+ 文件格式化器保持纯文本,避免日志文件被颜色码污染。两者都通过
5
+ :class:`_MillisecondTimeFormatter` 共享毫秒精度的时间戳实现。
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from typing import override
11
+
12
+ # 256 色调色板,在深色终端下不刺眼。
13
+ _LEVEL_COLORS: dict[str, str] = {
14
+ "DEBUG": "\033[38;5;32m",
15
+ "INFO": "\033[38;5;70m",
16
+ "WARNING": "\033[38;5;186m",
17
+ "ERROR": "\033[38;5;206m",
18
+ "CRITICAL": "\033[1;38;5;196m",
19
+ }
20
+ _PROC_COLOR = "\033[38;5;141m"
21
+ _NAME_COLOR = "\033[38;5;36m"
22
+ _FILE_COLOR = "\033[38;5;45m"
23
+ _FUNC_COLOR = "\033[38;5;208m"
24
+ _RESET = "\033[0m"
25
+
26
+ # 控制台格式:每个字段独立着色,进程信息排在最前以契合 kitty_logger 的跨进程定位。
27
+ DEFAULT_CONSOLE_FMT = (
28
+ "%(asctime)s | "
29
+ "%(proc_color)s%(processName)s(%(process)d)%(reset)s | "
30
+ "%(name_color)s%(name)s%(reset)s | "
31
+ "%(file_color)s%(filename)s:%(lineno)d%(reset)s | "
32
+ "%(func_color)s%(funcName)s%(reset)s | "
33
+ "%(color)s%(levelname)s%(reset)s | "
34
+ "%(message)s"
35
+ )
36
+
37
+ # 文件格式:字段一致,但不带任何 ANSI 颜色码。
38
+ DEFAULT_FILE_FMT = (
39
+ "%(asctime)s | %(processName)s(%(process)d) | %(name)s | "
40
+ "%(filename)s:%(lineno)d | %(funcName)s | %(levelname)s | %(message)s"
41
+ )
42
+
43
+
44
+ class _MillisecondTimeFormatter(logging.Formatter):
45
+ """带毫秒精度的时间格式化基类。
46
+
47
+ 标准库 :meth:`logging.Formatter.formatTime` 在传入 ``datefmt`` 时
48
+ 不会自动拼接毫秒,这里覆写以始终带上 ``.毫秒`` 后缀。
49
+ """
50
+
51
+ @override
52
+ def formatTime(
53
+ self,
54
+ record: logging.LogRecord,
55
+ datefmt: str | None = None,
56
+ ) -> str:
57
+ ct = self.converter(record.created)
58
+ s = time.strftime(datefmt or "%Y-%m-%d %H:%M:%S", ct)
59
+ return f"{s}.{int(record.msecs):03d}"
60
+
61
+
62
+ class ColorFormatter(_MillisecondTimeFormatter):
63
+ """控制台格式化器:按字段分别添加 ANSI 颜色。
64
+
65
+ 实现思路是把颜色码作为属性挂到 ``record`` 上,再在格式串里通过
66
+ ``%(xxx_color)s ... %(reset)s`` 占位符把颜色串入对应字段,从而让
67
+ 进程名、logger 名、文件位置、函数名、级别各自拥有独立的配色。
68
+ """
69
+
70
+ @override
71
+ def format(self, record: logging.LogRecord) -> str:
72
+ record.color = _LEVEL_COLORS.get(record.levelname, "") # type: ignore[attr-defined]
73
+ record.proc_color = _PROC_COLOR # type: ignore[attr-defined]
74
+ record.name_color = _NAME_COLOR # type: ignore[attr-defined]
75
+ record.file_color = _FILE_COLOR # type: ignore[attr-defined]
76
+ record.func_color = _FUNC_COLOR # type: ignore[attr-defined]
77
+ record.reset = _RESET # type: ignore[attr-defined]
78
+ return super().format(record)
79
+
80
+
81
+ class FileFormatter(_MillisecondTimeFormatter):
82
+ """文件格式化器:与控制台版字段一致但不带任何 ANSI 颜色码。"""
@@ -1,11 +1,13 @@
1
- """日志记录接收进程。
1
+ """日志服务子进程实现。
2
2
 
3
- 运行在一个独立的子进程中。接收父进程预先 bind 好的监听 socket,
4
- 反序列化客户端进程通过 ``logging.handlers.SocketHandler`` 发来的
5
- ``logging.LogRecord``,然后交给本进程内配置的日志 handler(文件、
6
- stderr 等)输出。
3
+ 工作模型:
7
4
 
8
- 实现参考自 Python ``logging`` cookbook。
5
+ 1. 父进程在 :func:`kitty_logger.setup_logging` 中先 ``bind`` 一个 TCP
6
+ 监听 socket,再连同其他参数一起传给一个 ``spawn`` 启动的子进程。
7
+ 2. 子进程在 :func:`run_server` 里基于这个 socket 跑 ``ThreadingTCPServer``,
8
+ 反序列化客户端通过 :class:`logging.handlers.SocketHandler` 发来的
9
+ ``LogRecord`` 字节流,再用本进程里配置的 handler(FileHandler /
10
+ StreamHandler)落地。
9
11
  """
10
12
 
11
13
  import logging
@@ -15,24 +17,24 @@ import socketserver
15
17
  import struct
16
18
  import sys
17
19
  import threading
18
- from typing import cast, final, override
19
-
20
+ from logging.handlers import TimedRotatingFileHandler
20
21
  from multiprocessing.synchronize import Event as MpEvent
22
+ from typing import cast, final, override
21
23
 
22
24
  from ._formatters import ColorFormatter, FileFormatter
23
25
 
24
- # 单条 LogRecord 序列化后的最大字节数。超过即视为协议错乱或恶意流量,
25
- # 直接断开连接,避免 ``recv`` 一口气分配几个 GB 的 buffer 把进程打爆。
26
- # 16 MiB 已经远大于任何正常的 ``LogRecord``(含 traceback / extra)。
26
+ # 单条 LogRecord 序列化后字节数上限。任何正常 LogRecord 都远小于此值;
27
+ # 超过即视为协议错位或异常流量,直接断开连接,避免一次性分配巨大缓冲区
28
+ # 触发 OOM。
27
29
  _MAX_RECORD_BYTES = 16 * 1024 * 1024
28
30
 
29
31
 
30
32
  def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
31
- """从 ``conn`` 上**读满** ``n`` 字节,对端关闭则返回 ``None``。
33
+ """从 ``conn`` 上读满 ``n`` 字节;对端已关闭则返回 ``None``。
32
34
 
33
- 单次 ``recv`` 不保证读满指定长度(TCP 半包),而当对端关闭后 ``recv``
34
- 会立刻返回 ``b""`` —— 任何"按长度循环 recv"的实现都必须显式处理 EOF,
35
- 否则会在客户端中途断连时陷入死循环。
35
+ 单次 ``recv`` 不保证读够请求长度(TCP 半包),且对端关闭时
36
+ ``recv`` 会立刻返回 ``b""``。任何"按长度循环 recv"的实现都必须显式
37
+ 处理 EOF,否则在客户端中途断连时会陷入死循环。
36
38
  """
37
39
  buf = bytearray()
38
40
  while len(buf) < n:
@@ -48,7 +50,11 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
48
50
 
49
51
  @final
50
52
  class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
51
- """处理一个客户端连接上的流式日志请求。"""
53
+ """处理一个客户端连接上的连续 LogRecord 流。
54
+
55
+ 协议沿用标准库 :class:`logging.handlers.SocketHandler`:
56
+ ``[uint32 大端长度][pickle 字节]``,可在同一连接上重复出现。
57
+ """
52
58
 
53
59
  @override
54
60
  def handle(self) -> None:
@@ -59,8 +65,6 @@ class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
59
65
  break
60
66
  slen = cast(int, struct.unpack(">L", header)[0])
61
67
  if slen == 0 or slen > _MAX_RECORD_BYTES:
62
- # 长度异常的请求多半是协议不匹配或恶意流量,直接断连而不是
63
- # 试图继续读,避免被诱导分配巨大 buffer。
64
68
  break
65
69
  body = _recv_exact(conn, slen)
66
70
  if body is None:
@@ -68,29 +72,36 @@ class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
68
72
  try:
69
73
  obj = cast(object, pickle.loads(body))
70
74
  except Exception:
75
+ # 单条记录损坏不应连累整条连接;继续读下一条。
71
76
  continue
72
77
  if not isinstance(obj, dict):
73
78
  continue
74
79
  record = logging.makeLogRecord(cast(dict[str, object], obj))
75
- logger = logging.getLogger(record.name)
76
- logger.handle(record)
80
+ logging.getLogger(record.name).handle(record)
77
81
 
78
82
 
79
83
  @final
80
84
  class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
85
+ """基于 :class:`socketserver.ThreadingTCPServer` 的接收器。
86
+
87
+ 复用父进程已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
88
+ """
89
+
81
90
  allow_reuse_address: bool = True
82
91
  daemon_threads: bool = True
83
92
 
84
93
  @override
85
94
  def __init__(self, listening_sock: socket.socket) -> None:
86
- # 父类正常初始化(含 _BaseServer 的 shutdown 标志位等内部状态),
87
- # 但跳过 bind/listen——监听 socket 是父进程已经绑好的。
88
95
  addr = cast(tuple[str, int], listening_sock.getsockname()[:2])
96
+ # 走父类正常流程以建立 BaseServer 内部状态(shutdown 标志等),
97
+ # 但通过 ``bind_and_activate=False`` 跳过它自己的 bind/listen——
98
+ # 这个 socket 已经由父进程绑好。
89
99
  super().__init__(addr, _LogRecordStreamHandler, bind_and_activate=False)
90
- # 关闭 ``TCPServer.__init__`` 默认创建的那个空 socket,再换成外部传入的。
100
+ # 父类构造时仍会 ``self.socket = socket.socket(...)`` 占坑,
101
+ # 这里替换成真正复用的 socket,并把临时占位关闭掉。
91
102
  try:
92
103
  self.socket.close()
93
- except Exception:
104
+ except OSError:
94
105
  pass
95
106
  self.socket = listening_sock
96
107
  self.server_address = addr
@@ -103,9 +114,17 @@ def _build_handlers(
103
114
  file_fmt: str,
104
115
  datefmt: str | None,
105
116
  ) -> list[logging.Handler]:
117
+ """根据用户传入的参数组合出落地用的 handler 列表。"""
106
118
  handlers: list[logging.Handler] = []
107
119
  if log_file:
108
- fh = logging.FileHandler(log_file, encoding="utf-8")
120
+ # 按天轮转,每天 0 点切一份,最多保留 365 天历史。
121
+ fh = TimedRotatingFileHandler(
122
+ log_file,
123
+ when="midnight",
124
+ interval=1,
125
+ backupCount=365,
126
+ encoding="utf-8",
127
+ )
109
128
  fh.setFormatter(FileFormatter(fmt=file_fmt, datefmt=datefmt))
110
129
  handlers.append(fh)
111
130
  if stream:
@@ -126,7 +145,23 @@ def run_server(
126
145
  file_fmt: str,
127
146
  datefmt: str | None,
128
147
  ) -> None:
129
- """日志服务子进程的入口函数。"""
148
+ """日志服务子进程入口函数。
149
+
150
+ 通过 :mod:`multiprocessing` 在 ``spawn`` 出来的全新解释器里运行;
151
+ 所有依赖参数显式从 ``kwargs`` 进入,不依赖父进程的全局状态。
152
+
153
+ :param listening_sock: 已经由父进程 ``bind`` + ``listen`` 好的 socket。
154
+ :param shutdown_event: 父进程通知子进程正常退出用的事件。
155
+ :param parent_alive_recv: ``socketpair`` 的接收端,用来侦测父进程死亡。
156
+ :param level: 服务端 root logger 级别。
157
+ :param log_file: 日志文件路径;``None`` 表示不落盘。
158
+ :param stream: 是否同时输出到 ``stderr``。
159
+ :param console_fmt: 控制台 handler 的 format 字符串。
160
+ :param file_fmt: 文件 handler 的 format 字符串。
161
+ :param datefmt: 时间戳的 ``strftime`` 格式;``None`` 走默认值。
162
+ """
163
+ # 子进程是 ``spawn`` 出来的全新解释器,root logger 默认无 handler;
164
+ # 但仍保险地清掉一遍以便重复初始化场景下的幂等性。
130
165
  root = logging.getLogger()
131
166
  for h in list(root.handlers):
132
167
  root.removeHandler(h)
@@ -141,9 +176,9 @@ def run_server(
141
176
  server.shutdown()
142
177
 
143
178
  def _watch_parent_alive() -> None:
144
- # 父进程持有 socketpair send 端;任何原因导致父进程消失(正常退出、
145
- # SIGKILL、段错误等),内核都会关闭它的 fd,本端 ``recv`` 会立刻得到
146
- # 空字节(EOF)。借此触发服务自我退出,避免变成孤儿进程。
179
+ # 父进程持有 socketpair 的发送端;任意原因(正常退出 / SIGKILL /
180
+ # 段错误)使父进程消失,内核都会关闭它的 fd,本端 ``recv`` 会立刻
181
+ # 返回空字节(EOF)。借此触发自我退出,避免变成孤儿进程。
147
182
  try:
148
183
  while True:
149
184
  data = parent_alive_recv.recv(64)
@@ -154,7 +189,7 @@ def run_server(
154
189
  finally:
155
190
  try:
156
191
  parent_alive_recv.close()
157
- except Exception:
192
+ except OSError:
158
193
  pass
159
194
  shutdown_event.set()
160
195
 
@@ -166,6 +201,6 @@ def run_server(
166
201
  finally:
167
202
  try:
168
203
  server.server_close()
169
- except Exception:
204
+ except OSError:
170
205
  pass
171
206
  logging.shutdown()