kitty-logger 0.2.0.dev2__tar.gz → 0.2.0.dev4__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 (20) hide show
  1. {kitty_logger-0.2.0.dev2/src/kitty_logger.egg-info → kitty_logger-0.2.0.dev4}/PKG-INFO +7 -5
  2. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/README.md +6 -4
  3. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/pyproject.toml +1 -1
  4. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger/__init__.py +11 -2
  5. kitty_logger-0.2.0.dev4/src/kitty_logger/_formatters.py +211 -0
  6. kitty_logger-0.2.0.dev4/src/kitty_logger/_server.py +178 -0
  7. kitty_logger-0.2.0.dev4/src/kitty_logger/_server_main.py +139 -0
  8. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger/_setup.py +190 -107
  9. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4/src/kitty_logger.egg-info}/PKG-INFO +7 -5
  10. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger.egg-info/SOURCES.txt +1 -0
  11. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/tests/test_kitty_logger.py +41 -44
  12. kitty_logger-0.2.0.dev2/src/kitty_logger/_formatters.py +0 -82
  13. kitty_logger-0.2.0.dev2/src/kitty_logger/_server.py +0 -228
  14. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/LICENSE +0 -0
  15. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/setup.cfg +0 -0
  16. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger/_client.py +0 -0
  17. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger/_env.py +0 -0
  18. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger.egg-info/dependency_links.txt +0 -0
  19. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger.egg-info/requires.txt +0 -0
  20. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev4}/src/kitty_logger.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitty_logger
3
- Version: 0.2.0.dev2
3
+ Version: 0.2.0.dev4
4
4
  Summary: Cross-process logging via a dedicated log server process and SocketHandler.
5
5
  Author: Kitty
6
6
  License: MIT
@@ -55,13 +55,15 @@ if __name__ == "__main__":
55
55
  ## API
56
56
 
57
57
  - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None) -> (host, port)`
58
- 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
59
- `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
58
+ 通过 `subprocess.Popen` 启动一个独立的日志服务子进程(不依赖
59
+ `multiprocessing`,因此**不会回头执行用户主脚本的任何顶层代码**)。
60
+ 幂等。已通过 `atexit` 注册清理。`port=0` 让操作系统挑选空闲端口;返回
61
+ 真实绑定到的 `(host, port)`。
60
62
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
61
63
  同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
62
64
  `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
63
- `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
64
- 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
+ 在用户用 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op
66
+ 仅返回从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
67
  `if __name__ == "__main__":` 包裹。
66
68
  - `getLogger(name=None) -> logging.Logger`
67
69
  返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
@@ -42,13 +42,15 @@ if __name__ == "__main__":
42
42
  ## API
43
43
 
44
44
  - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None) -> (host, port)`
45
- 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
46
- `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
45
+ 通过 `subprocess.Popen` 启动一个独立的日志服务子进程(不依赖
46
+ `multiprocessing`,因此**不会回头执行用户主脚本的任何顶层代码**)。
47
+ 幂等。已通过 `atexit` 注册清理。`port=0` 让操作系统挑选空闲端口;返回
48
+ 真实绑定到的 `(host, port)`。
47
49
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
48
50
  同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
49
51
  `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
50
- `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
51
- 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
52
+ 在用户用 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op
53
+ 仅返回从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
52
54
  `if __name__ == "__main__":` 包裹。
53
55
  - `getLogger(name=None) -> logging.Logger`
54
56
  返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitty_logger"
7
- version = "0.2.0.dev2"
7
+ version = "0.2.0.dev4"
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"
@@ -30,7 +30,16 @@
30
30
  """
31
31
 
32
32
  from ._client import getLogger
33
+ from ._formatters import PRESETS, FormatPreset, FormatSpec, get_preset
33
34
  from ._setup import setup_logging, shutdown_logging
34
35
 
35
- __all__ = ["setup_logging", "shutdown_logging", "getLogger"]
36
- __version__ = "0.2.0.dev2"
36
+ __all__ = [
37
+ "setup_logging",
38
+ "shutdown_logging",
39
+ "getLogger",
40
+ "FormatPreset",
41
+ "FormatSpec",
42
+ "PRESETS",
43
+ "get_preset",
44
+ ]
45
+ __version__ = "0.2.0.dev4"
@@ -0,0 +1,211 @@
1
+ """日志格式化器:控制台版(按字段着色 ANSI)与文件版(无颜色)。
2
+
3
+ 控制台格式化器把不同字段染成不同颜色,便于在终端里快速分辨;
4
+ 文件格式化器保持纯文本,避免日志文件被颜色码污染。两者都通过
5
+ :class:`_MillisecondTimeFormatter` 共享毫秒精度的时间戳实现,
6
+ 可通过 ``show_msec=False`` 切换为秒级精度。
7
+
8
+ 模块同时提供四档预置格式 :data:`PRESETS`:``minimal`` / ``standard``
9
+ / ``verbose`` / ``debug``,每档封装好 ``console_fmt`` / ``file_fmt``
10
+ / ``datefmt`` / ``show_msec``,由 :func:`kitty_logger.setup_logging`
11
+ 通过 ``preset=`` 参数选用。
12
+ """
13
+
14
+ import logging
15
+ import time
16
+ from dataclasses import dataclass
17
+ from typing import Literal, override
18
+
19
+ # 256 色调色板,在深色终端下不刺眼。
20
+ _LEVEL_COLORS: dict[str, str] = {
21
+ "DEBUG": "\033[38;5;32m",
22
+ "INFO": "\033[38;5;70m",
23
+ "WARNING": "\033[38;5;186m",
24
+ "ERROR": "\033[38;5;206m",
25
+ "CRITICAL": "\033[1;38;5;196m",
26
+ }
27
+ _PROC_COLOR = "\033[38;5;141m"
28
+ _THREAD_COLOR = "\033[38;5;177m"
29
+ _NAME_COLOR = "\033[38;5;36m"
30
+ _FILE_COLOR = "\033[38;5;45m"
31
+ _FUNC_COLOR = "\033[38;5;208m"
32
+ _RESET = "\033[0m"
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # minimal:仅时间 + 级别 + 消息,时间戳到秒,适合脚本类小工具或交互式调试。
36
+ # ---------------------------------------------------------------------------
37
+ _MINIMAL_CONSOLE_FMT = "%(asctime)s %(color)s%(levelname)s%(reset)s %(message)s"
38
+ _MINIMAL_FILE_FMT = "%(asctime)s %(levelname)s %(message)s"
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # standard:时间 + 级别 + logger 名 + 消息,毫秒精度,足够定位大部分问题。
42
+ # ---------------------------------------------------------------------------
43
+ _STANDARD_CONSOLE_FMT = (
44
+ "%(asctime)s | "
45
+ "%(color)s%(levelname)-8s%(reset)s | "
46
+ "%(name_color)s%(name)s%(reset)s | "
47
+ "%(message)s"
48
+ )
49
+ _STANDARD_FILE_FMT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # verbose:进程信息 + 文件位置 + 函数名,毫秒精度。本库的历史默认值。
53
+ # ---------------------------------------------------------------------------
54
+ DEFAULT_CONSOLE_FMT = (
55
+ "%(asctime)s | "
56
+ "%(proc_color)s%(processName)s(%(process)d)%(reset)s | "
57
+ "%(name_color)s%(name)s%(reset)s | "
58
+ "%(file_color)s%(filename)s:%(lineno)d%(reset)s | "
59
+ "%(func_color)s%(funcName)s%(reset)s | "
60
+ "%(color)s%(levelname)s%(reset)s | "
61
+ "%(message)s"
62
+ )
63
+ DEFAULT_FILE_FMT = (
64
+ "%(asctime)s | %(processName)s(%(process)d) | %(name)s | "
65
+ "%(filename)s:%(lineno)d | %(funcName)s | %(levelname)s | %(message)s"
66
+ )
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # debug:在 verbose 基础上加入线程名,便于排查多线程时序问题。
70
+ # ---------------------------------------------------------------------------
71
+ _DEBUG_CONSOLE_FMT = (
72
+ "%(asctime)s | "
73
+ "%(proc_color)s%(processName)s(%(process)d)%(reset)s | "
74
+ "%(thread_color)s%(threadName)s%(reset)s | "
75
+ "%(name_color)s%(name)s%(reset)s | "
76
+ "%(file_color)s%(filename)s:%(lineno)d%(reset)s | "
77
+ "%(func_color)s%(funcName)s%(reset)s | "
78
+ "%(color)s%(levelname)s%(reset)s | "
79
+ "%(message)s"
80
+ )
81
+ _DEBUG_FILE_FMT = (
82
+ "%(asctime)s | %(processName)s(%(process)d) | %(threadName)s | %(name)s | "
83
+ "%(filename)s:%(lineno)d | %(funcName)s | %(levelname)s | %(message)s"
84
+ )
85
+
86
+
87
+ FormatPreset = Literal["minimal", "standard", "verbose", "debug"]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class FormatSpec:
92
+ """单档预置格式的完整规格。
93
+
94
+ :param console_fmt: 控制台 handler 的 format 字符串(含 ANSI 占位符)。
95
+ :param file_fmt: 文件 handler 的 format 字符串(无 ANSI)。
96
+ :param datefmt: ``strftime`` 格式串。
97
+ :param show_msec: 是否在时间戳尾部追加 ``.毫秒``。
98
+ """
99
+
100
+ console_fmt: str
101
+ file_fmt: str
102
+ datefmt: str
103
+ show_msec: bool
104
+
105
+
106
+ PRESETS: dict[FormatPreset, FormatSpec] = {
107
+ "minimal": FormatSpec(
108
+ console_fmt=_MINIMAL_CONSOLE_FMT,
109
+ file_fmt=_MINIMAL_FILE_FMT,
110
+ datefmt="%H:%M:%S",
111
+ show_msec=False,
112
+ ),
113
+ "standard": FormatSpec(
114
+ console_fmt=_STANDARD_CONSOLE_FMT,
115
+ file_fmt=_STANDARD_FILE_FMT,
116
+ datefmt="%Y-%m-%d %H:%M:%S",
117
+ show_msec=True,
118
+ ),
119
+ "verbose": FormatSpec(
120
+ console_fmt=DEFAULT_CONSOLE_FMT,
121
+ file_fmt=DEFAULT_FILE_FMT,
122
+ datefmt="%Y-%m-%d %H:%M:%S",
123
+ show_msec=True,
124
+ ),
125
+ "debug": FormatSpec(
126
+ console_fmt=_DEBUG_CONSOLE_FMT,
127
+ file_fmt=_DEBUG_FILE_FMT,
128
+ datefmt="%Y-%m-%d %H:%M:%S",
129
+ show_msec=True,
130
+ ),
131
+ }
132
+
133
+
134
+ def get_preset(name: FormatPreset) -> FormatSpec:
135
+ """返回 ``name`` 档预置格式规格;未知名抛 :class:`ValueError`。"""
136
+ try:
137
+ return PRESETS[name]
138
+ except KeyError:
139
+ raise ValueError(
140
+ f"未知的 format preset: {name!r},可选: {list(PRESETS)}"
141
+ ) from None
142
+
143
+
144
+ class _MillisecondTimeFormatter(logging.Formatter):
145
+ """带可选毫秒精度的时间格式化基类。
146
+
147
+ 标准库 :meth:`logging.Formatter.formatTime` 在传入 ``datefmt`` 时
148
+ 不会自动拼接毫秒,这里覆写以按 ``show_msec`` 决定是否追加 ``.毫秒``。
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ fmt: str | None = None,
154
+ datefmt: str | None = None,
155
+ *,
156
+ show_msec: bool = True,
157
+ ) -> None:
158
+ """
159
+ :param fmt: 透传给 :class:`logging.Formatter` 的 format 字符串。
160
+ :param datefmt: 透传给 :class:`logging.Formatter` 的 ``strftime`` 格式。
161
+ :param show_msec: 是否在时间戳尾部追加 ``.毫秒``。
162
+ ``minimal`` 档为秒级精度,因此传入 ``False``;其余档位均为 ``True``。
163
+ """
164
+ super().__init__(fmt=fmt, datefmt=datefmt)
165
+ self._show_msec = show_msec
166
+
167
+ @override
168
+ def formatTime(
169
+ self,
170
+ record: logging.LogRecord,
171
+ datefmt: str | None = None,
172
+ ) -> str:
173
+ """格式化时间戳;按 ``self._show_msec`` 决定是否追加 ``.毫秒``。"""
174
+ ct = self.converter(record.created)
175
+ s = time.strftime(datefmt or "%Y-%m-%d %H:%M:%S", ct)
176
+ if self._show_msec:
177
+ return f"{s}.{int(record.msecs):03d}"
178
+ return s
179
+
180
+
181
+ class ColorFormatter(_MillisecondTimeFormatter):
182
+ """控制台格式化器:按字段分别添加 ANSI 颜色。
183
+
184
+ 实现思路是把颜色码作为属性挂到 ``record`` 上,再在格式串里通过
185
+ ``%(xxx_color)s ... %(reset)s`` 占位符把颜色串入对应字段,从而让
186
+ 进程名、线程名、logger 名、文件位置、函数名、级别各自拥有独立的配色。
187
+ 未在格式串中引用的占位符会被忽略,因此各 preset 共用同一份属性注入。
188
+ """
189
+
190
+ @override
191
+ def format(self, record: logging.LogRecord) -> str:
192
+ """把各字段对应的 ANSI 颜色码作为属性挂到 ``record`` 上再走父类格式化。
193
+
194
+ 注入的属性涵盖 level / 进程 / 线程 / logger 名 / 文件位置 / 函数名
195
+ 各自的颜色,以及统一的 ``reset``。具体格式串引用其中哪几个由 preset
196
+ 决定(例如 ``debug`` 档会用到 ``thread_color``,``minimal`` 档则只
197
+ 用到 ``color`` / ``reset``);未被引用的属性由标准库
198
+ :class:`logging.Formatter` 自动忽略。
199
+ """
200
+ record.color = _LEVEL_COLORS.get(record.levelname, "") # type: ignore[attr-defined]
201
+ record.proc_color = _PROC_COLOR # type: ignore[attr-defined]
202
+ record.thread_color = _THREAD_COLOR # type: ignore[attr-defined]
203
+ record.name_color = _NAME_COLOR # type: ignore[attr-defined]
204
+ record.file_color = _FILE_COLOR # type: ignore[attr-defined]
205
+ record.func_color = _FUNC_COLOR # type: ignore[attr-defined]
206
+ record.reset = _RESET # type: ignore[attr-defined]
207
+ return super().format(record)
208
+
209
+
210
+ class FileFormatter(_MillisecondTimeFormatter):
211
+ """文件格式化器:与控制台版字段一致但不带任何 ANSI 颜色码。"""
@@ -0,0 +1,178 @@
1
+ """日志服务子进程的核心组件(与启动方式无关)。
2
+
3
+ 服务进程通过 ``python -m kitty_logger._server_main`` 由 :func:`kitty_logger.setup_logging`
4
+ 间接拉起,本模块只提供与"具体如何起进程"无关的纯逻辑:
5
+
6
+ - :func:`_bind_loopback`:在 loopback 地址上绑定一个 ``AF_INET`` 监听 socket。
7
+ - :class:`_LogRecordSocketReceiver`:基于 :class:`socketserver.ThreadingTCPServer`
8
+ 的接收器,复用外部已 ``bind`` 好的 socket。
9
+ - :func:`_build_handlers`:根据用户参数组装落地用的 handler 列表。
10
+ """
11
+
12
+ import logging
13
+ import pickle
14
+ import socket
15
+ import socketserver
16
+ import struct
17
+ import sys
18
+ from logging.handlers import TimedRotatingFileHandler
19
+ from typing import cast, final, override
20
+
21
+ from ._formatters import ColorFormatter, FileFormatter
22
+
23
+ # 这三个符号由同包内的 ``_server_main`` 模块导入复用;以下划线开头是因为它们
24
+ # 不属于库的公共 API(仅 kitty_logger 内部使用)。pyright 对下划线名默认按
25
+ # "模块内私有"处理,看不到跨模块引用,因此显式通过 ``__all__`` 声明为本模块
26
+ # 的对外导出,避免 reportUnusedFunction / reportUnusedClass 误报。
27
+ __all__ = ["_bind_loopback", "_LogRecordSocketReceiver", "_build_handlers"]
28
+
29
+ # 单条 LogRecord 序列化后字节数上限。任何正常 LogRecord 都远小于此值;
30
+ # 超过即视为协议错位或异常流量,直接断开连接,避免一次性分配巨大缓冲区
31
+ # 触发 OOM。
32
+ _MAX_RECORD_BYTES = 16 * 1024 * 1024
33
+
34
+
35
+ def _bind_loopback(
36
+ host: str, port: int, backlog: int = 128
37
+ ) -> tuple[socket.socket, str, int]:
38
+ """创建并绑定一个 ``AF_INET`` TCP 监听 socket,返回 ``(sock, bound_host, bound_port)``。
39
+
40
+ 把"AF_INET 的 ``getsockname`` 一定返回 ``tuple[str, int]``"这个静态
41
+ 事实集中收敛在这里,调用方不必再写 ``cast``。
42
+ """
43
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
44
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
45
+ sock.bind((host, port))
46
+ sock.listen(backlog)
47
+ bound_host, bound_port = cast(tuple[str, int], sock.getsockname())
48
+ return sock, bound_host, bound_port
49
+
50
+
51
+ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
52
+ """从 ``conn`` 上读满 ``n`` 字节;对端已关闭则返回 ``None``。
53
+
54
+ 单次 ``recv`` 不保证读够请求长度(TCP 半包),且对端关闭时
55
+ ``recv`` 会立刻返回 ``b""``。任何"按长度循环 recv"的实现都必须显式
56
+ 处理 EOF,否则在客户端中途断连时会陷入死循环。
57
+ """
58
+ buf = bytearray()
59
+ while len(buf) < n:
60
+ try:
61
+ chunk = conn.recv(n - len(buf))
62
+ except (ConnectionError, OSError):
63
+ return None
64
+ if not chunk:
65
+ return None
66
+ buf.extend(chunk)
67
+ return bytes(buf)
68
+
69
+
70
+ @final
71
+ class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
72
+ """处理一个客户端连接上的连续 LogRecord 流。
73
+
74
+ 协议沿用标准库 :class:`logging.handlers.SocketHandler`:
75
+ ``[uint32 大端长度][pickle 字节]``,可在同一连接上重复出现。
76
+ """
77
+
78
+ @override
79
+ def handle(self) -> None:
80
+ conn = cast(socket.socket, self.connection)
81
+ while True:
82
+ header = _recv_exact(conn, 4)
83
+ if header is None:
84
+ break
85
+ slen = cast(int, struct.unpack(">L", header)[0])
86
+ if slen == 0 or slen > _MAX_RECORD_BYTES:
87
+ break
88
+ body = _recv_exact(conn, slen)
89
+ if body is None:
90
+ break
91
+ try:
92
+ obj = cast(object, pickle.loads(body))
93
+ except Exception:
94
+ # 单条记录损坏不应连累整条连接;继续读下一条。
95
+ continue
96
+ if not isinstance(obj, dict):
97
+ continue
98
+ record = logging.makeLogRecord(cast(dict[str, object], obj))
99
+ logging.getLogger(record.name).handle(record)
100
+
101
+
102
+ @final
103
+ class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
104
+ """基于 :class:`socketserver.ThreadingTCPServer` 的接收器。
105
+
106
+ 复用外部已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
107
+ """
108
+
109
+ allow_reuse_address: bool = True
110
+ daemon_threads: bool = True
111
+
112
+ @override
113
+ def __init__(self, listening_sock: socket.socket) -> None:
114
+ addr = cast(tuple[str, int], listening_sock.getsockname()[:2])
115
+ # 走父类正常流程以建立 BaseServer 内部状态(shutdown 标志等),
116
+ # 但通过 ``bind_and_activate=False`` 跳过它自己的 bind/listen——
117
+ # 这个 socket 已经由调用方绑好。
118
+ super().__init__(addr, _LogRecordStreamHandler, bind_and_activate=False)
119
+ # 父类构造时仍会 ``self.socket = socket.socket(...)`` 占坑,
120
+ # 这里替换成真正复用的 socket,并把临时占位关闭掉。
121
+ try:
122
+ self.socket.close()
123
+ except OSError:
124
+ pass
125
+ self.socket = listening_sock
126
+ self.server_address = addr
127
+
128
+
129
+ def _build_handlers(
130
+ log_file: str | None,
131
+ stream: bool,
132
+ console_fmt: str,
133
+ file_fmt: str,
134
+ console_datefmt: str | None,
135
+ file_datefmt: str | None,
136
+ console_show_msec: bool,
137
+ file_show_msec: bool,
138
+ ) -> list[logging.Handler]:
139
+ """根据用户传入的参数组合出落地用的 handler 列表。
140
+
141
+ 控制台与文件两侧的 ``datefmt`` / ``show_msec`` 各自独立——上层允许
142
+ 分别选择不同的 preset,因此服务端不再共用同一份时间设置。
143
+
144
+ :param log_file: 日志文件路径;``None`` 表示不挂文件 handler。
145
+ :param stream: 是否同时挂上指向 ``sys.stderr`` 的 :class:`StreamHandler`。
146
+ :param console_fmt: 控制台 handler 的 format 字符串(含 ANSI 占位符)。
147
+ :param file_fmt: 文件 handler 的 format 字符串(无 ANSI)。
148
+ :param console_datefmt: 控制台时间戳的 ``strftime`` 格式;``None`` 走默认值。
149
+ :param file_datefmt: 文件时间戳的 ``strftime`` 格式;``None`` 走默认值。
150
+ :param console_show_msec: 控制台时间戳是否在尾部追加 ``.毫秒``。
151
+ :param file_show_msec: 文件时间戳是否在尾部追加 ``.毫秒``。
152
+ :return: 顺序为 ``[file?, stream?]`` 的 handler 列表,调用方按需挂载。
153
+ """
154
+ handlers: list[logging.Handler] = []
155
+ if log_file:
156
+ # 按天轮转,每天 0 点切一份,最多保留 365 天历史。
157
+ fh = TimedRotatingFileHandler(
158
+ log_file,
159
+ when="midnight",
160
+ interval=1,
161
+ backupCount=365,
162
+ encoding="utf-8",
163
+ )
164
+ fh.setFormatter(
165
+ FileFormatter(fmt=file_fmt, datefmt=file_datefmt, show_msec=file_show_msec)
166
+ )
167
+ handlers.append(fh)
168
+ if stream:
169
+ sh = logging.StreamHandler(stream=sys.stderr)
170
+ sh.setFormatter(
171
+ ColorFormatter(
172
+ fmt=console_fmt,
173
+ datefmt=console_datefmt,
174
+ show_msec=console_show_msec,
175
+ )
176
+ )
177
+ handlers.append(sh)
178
+ return handlers
@@ -0,0 +1,139 @@
1
+ """kitty_logger 服务子进程入口。
2
+
3
+ 通过 ``python -m kitty_logger._server_main`` 启动,由
4
+ :func:`kitty_logger.setup_logging` 内部用 :class:`subprocess.Popen` 拉起。
5
+ 本模块不是给最终用户的接口。
6
+
7
+ 握手 / 生命周期协议
8
+ --------------------
9
+
10
+ 整套机制只用三条标准管道,不需要 ``pass_fds``,所以天然跨平台:
11
+
12
+ * ``stdin`` — 父→子,**parent-alive**:父进程保持 write 端打开但不写。
13
+ 父进程任何原因退出(``exit`` / ``kill -9`` / 段错误),内核会关掉它持有
14
+ 的写端 fd,子进程在本端 ``read()`` 立刻返回 ``b""`` → 触发优雅退出。
15
+ * ``stdout`` — 子→父,**启动握手**:子进程 ``bind+listen`` 成功后写出
16
+ ``"PORT <port>\\n"`` 然后 ``close``,握手即结束;之后不再使用 stdout
17
+ 以避免后续误用 ``print`` 污染父进程的读端。
18
+ * ``stderr`` — 子→父,**日志落地**:直接继承父进程的 stderr/tty。
19
+ ``--stream`` 控制是否给 root logger 挂上 :class:`logging.StreamHandler`。
20
+
21
+ 命令行参数对应 :func:`kitty_logger.setup_logging` 的同名参数。
22
+ """
23
+
24
+ import argparse
25
+ import logging
26
+ import sys
27
+ import threading
28
+
29
+ from ._server import _LogRecordSocketReceiver, _bind_loopback, _build_handlers
30
+
31
+
32
+ def _parse_args() -> argparse.Namespace:
33
+ ap = argparse.ArgumentParser(prog="kitty_logger._server_main", add_help=False)
34
+ ap.add_argument("--host", required=True)
35
+ ap.add_argument("--port", type=int, required=True)
36
+ ap.add_argument("--level", type=int, required=True)
37
+ ap.add_argument("--log-file", default=None)
38
+ ap.add_argument("--stream", action="store_true")
39
+ ap.add_argument("--console-fmt", required=True)
40
+ ap.add_argument("--file-fmt", required=True)
41
+ ap.add_argument("--console-datefmt", default=None)
42
+ ap.add_argument("--file-datefmt", default=None)
43
+ ap.add_argument("--console-show-msec", action="store_true")
44
+ ap.add_argument("--file-show-msec", action="store_true")
45
+ return ap.parse_args()
46
+
47
+
48
+ def _emit_handshake(port: int) -> None:
49
+ """把 ``"PORT <port>\\n"`` 写到 stdout 并主动 close。
50
+
51
+ 主动 close 等于显式宣告"握手结束"——之后任何对 ``sys.stdout`` 的
52
+ 误用都不会再污染父进程的读端,父进程的 ``readline()`` 也只会看到
53
+ 我们刚写的这一行。
54
+ """
55
+ sys.stdout.write(f"PORT {port}\n")
56
+ sys.stdout.flush()
57
+ try:
58
+ sys.stdout.close()
59
+ except OSError:
60
+ pass
61
+
62
+
63
+ def _emit_handshake_error(msg: str) -> None:
64
+ """启动失败时通过 stdout 报错,让父端 ``readline()`` 看到非 ``PORT`` 行。"""
65
+ try:
66
+ sys.stdout.write(f"ERROR {msg}\n")
67
+ sys.stdout.flush()
68
+ sys.stdout.close()
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ def main() -> None:
74
+ args = _parse_args()
75
+
76
+ # 先 bind+listen,这一步任何失败都通过 stdout 上报给父进程,让 setup_logging
77
+ # 能在主进程线程中以 ``RuntimeError`` 形式抛出,而不是父子双方各自卡住。
78
+ try:
79
+ sock, _bound_host, bound_port = _bind_loopback(args.host, args.port)
80
+ except OSError as e:
81
+ _emit_handshake_error(f"bind failed: {e}")
82
+ sys.exit(2)
83
+
84
+ _emit_handshake(bound_port)
85
+
86
+ # spawn 出来的全新解释器,root logger 默认无 handler;保险起见仍清一遍
87
+ # 以便重复初始化场景下保持幂等。
88
+ root = logging.getLogger()
89
+ for h in list(root.handlers):
90
+ root.removeHandler(h)
91
+ for h in _build_handlers(
92
+ args.log_file,
93
+ args.stream,
94
+ args.console_fmt,
95
+ args.file_fmt,
96
+ args.console_datefmt,
97
+ args.file_datefmt,
98
+ args.console_show_msec,
99
+ args.file_show_msec,
100
+ ):
101
+ root.addHandler(h)
102
+ root.setLevel(args.level)
103
+
104
+ server = _LogRecordSocketReceiver(sock)
105
+
106
+ def _watch_parent_alive() -> None:
107
+ """父进程持有 stdin 的写端;任何原因(正常退出 / SIGKILL / 段错误)
108
+ 让父进程消失,内核都会关掉它的 fd,本端 ``read()`` 会立刻返回
109
+ ``b""``(EOF)。借此触发自我退出,避免变成孤儿进程。
110
+
111
+ 正常生命周期下父进程也通过 ``proc.stdin.close()`` 主动触发同一
112
+ 条 EOF 路径,把"父退出 / 父主动 shutdown" 收敛为同一种处理。
113
+ """
114
+ try:
115
+ while True:
116
+ data = sys.stdin.buffer.read(64)
117
+ if not data:
118
+ break
119
+ except (OSError, ValueError):
120
+ pass
121
+ finally:
122
+ server.shutdown()
123
+
124
+ threading.Thread(
125
+ target=_watch_parent_alive, name="kitty-logger-parent-watch", daemon=True
126
+ ).start()
127
+
128
+ try:
129
+ server.serve_forever()
130
+ finally:
131
+ try:
132
+ server.server_close()
133
+ except OSError:
134
+ pass
135
+ logging.shutdown()
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()