kitty-logger 0.2.0.dev2__py3-none-any.whl → 0.2.0.dev4__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.
- kitty_logger/__init__.py +11 -2
- kitty_logger/_formatters.py +138 -9
- kitty_logger/_server.py +60 -110
- kitty_logger/_server_main.py +139 -0
- kitty_logger/_setup.py +190 -107
- {kitty_logger-0.2.0.dev2.dist-info → kitty_logger-0.2.0.dev4.dist-info}/METADATA +7 -5
- kitty_logger-0.2.0.dev4.dist-info/RECORD +12 -0
- kitty_logger-0.2.0.dev2.dist-info/RECORD +0 -11
- {kitty_logger-0.2.0.dev2.dist-info → kitty_logger-0.2.0.dev4.dist-info}/WHEEL +0 -0
- {kitty_logger-0.2.0.dev2.dist-info → kitty_logger-0.2.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {kitty_logger-0.2.0.dev2.dist-info → kitty_logger-0.2.0.dev4.dist-info}/top_level.txt +0 -0
kitty_logger/__init__.py
CHANGED
|
@@ -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__ = [
|
|
36
|
-
|
|
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"
|
kitty_logger/_formatters.py
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
控制台格式化器把不同字段染成不同颜色,便于在终端里快速分辨;
|
|
4
4
|
文件格式化器保持纯文本,避免日志文件被颜色码污染。两者都通过
|
|
5
|
-
:class:`_MillisecondTimeFormatter`
|
|
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=`` 参数选用。
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
import logging
|
|
9
15
|
import time
|
|
10
|
-
from
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Literal, override
|
|
11
18
|
|
|
12
19
|
# 256 色调色板,在深色终端下不刺眼。
|
|
13
20
|
_LEVEL_COLORS: dict[str, str] = {
|
|
@@ -18,12 +25,32 @@ _LEVEL_COLORS: dict[str, str] = {
|
|
|
18
25
|
"CRITICAL": "\033[1;38;5;196m",
|
|
19
26
|
}
|
|
20
27
|
_PROC_COLOR = "\033[38;5;141m"
|
|
28
|
+
_THREAD_COLOR = "\033[38;5;177m"
|
|
21
29
|
_NAME_COLOR = "\033[38;5;36m"
|
|
22
30
|
_FILE_COLOR = "\033[38;5;45m"
|
|
23
31
|
_FUNC_COLOR = "\033[38;5;208m"
|
|
24
32
|
_RESET = "\033[0m"
|
|
25
33
|
|
|
26
|
-
#
|
|
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
|
+
# ---------------------------------------------------------------------------
|
|
27
54
|
DEFAULT_CONSOLE_FMT = (
|
|
28
55
|
"%(asctime)s | "
|
|
29
56
|
"%(proc_color)s%(processName)s(%(process)d)%(reset)s | "
|
|
@@ -33,30 +60,122 @@ DEFAULT_CONSOLE_FMT = (
|
|
|
33
60
|
"%(color)s%(levelname)s%(reset)s | "
|
|
34
61
|
"%(message)s"
|
|
35
62
|
)
|
|
36
|
-
|
|
37
|
-
# 文件格式:字段一致,但不带任何 ANSI 颜色码。
|
|
38
63
|
DEFAULT_FILE_FMT = (
|
|
39
64
|
"%(asctime)s | %(processName)s(%(process)d) | %(name)s | "
|
|
40
65
|
"%(filename)s:%(lineno)d | %(funcName)s | %(levelname)s | %(message)s"
|
|
41
66
|
)
|
|
42
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
|
+
|
|
43
143
|
|
|
44
144
|
class _MillisecondTimeFormatter(logging.Formatter):
|
|
45
|
-
"""
|
|
145
|
+
"""带可选毫秒精度的时间格式化基类。
|
|
46
146
|
|
|
47
147
|
标准库 :meth:`logging.Formatter.formatTime` 在传入 ``datefmt`` 时
|
|
48
|
-
|
|
148
|
+
不会自动拼接毫秒,这里覆写以按 ``show_msec`` 决定是否追加 ``.毫秒``。
|
|
49
149
|
"""
|
|
50
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
|
+
|
|
51
167
|
@override
|
|
52
168
|
def formatTime(
|
|
53
169
|
self,
|
|
54
170
|
record: logging.LogRecord,
|
|
55
171
|
datefmt: str | None = None,
|
|
56
172
|
) -> str:
|
|
173
|
+
"""格式化时间戳;按 ``self._show_msec`` 决定是否追加 ``.毫秒``。"""
|
|
57
174
|
ct = self.converter(record.created)
|
|
58
175
|
s = time.strftime(datefmt or "%Y-%m-%d %H:%M:%S", ct)
|
|
59
|
-
|
|
176
|
+
if self._show_msec:
|
|
177
|
+
return f"{s}.{int(record.msecs):03d}"
|
|
178
|
+
return s
|
|
60
179
|
|
|
61
180
|
|
|
62
181
|
class ColorFormatter(_MillisecondTimeFormatter):
|
|
@@ -64,13 +183,23 @@ class ColorFormatter(_MillisecondTimeFormatter):
|
|
|
64
183
|
|
|
65
184
|
实现思路是把颜色码作为属性挂到 ``record`` 上,再在格式串里通过
|
|
66
185
|
``%(xxx_color)s ... %(reset)s`` 占位符把颜色串入对应字段,从而让
|
|
67
|
-
|
|
186
|
+
进程名、线程名、logger 名、文件位置、函数名、级别各自拥有独立的配色。
|
|
187
|
+
未在格式串中引用的占位符会被忽略,因此各 preset 共用同一份属性注入。
|
|
68
188
|
"""
|
|
69
189
|
|
|
70
190
|
@override
|
|
71
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
|
+
"""
|
|
72
200
|
record.color = _LEVEL_COLORS.get(record.levelname, "") # type: ignore[attr-defined]
|
|
73
201
|
record.proc_color = _PROC_COLOR # type: ignore[attr-defined]
|
|
202
|
+
record.thread_color = _THREAD_COLOR # type: ignore[attr-defined]
|
|
74
203
|
record.name_color = _NAME_COLOR # type: ignore[attr-defined]
|
|
75
204
|
record.file_color = _FILE_COLOR # type: ignore[attr-defined]
|
|
76
205
|
record.func_color = _FUNC_COLOR # type: ignore[attr-defined]
|
kitty_logger/_server.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""日志服务子进程的核心组件(与启动方式无关)。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
服务进程通过 ``python -m kitty_logger._server_main`` 由 :func:`kitty_logger.setup_logging`
|
|
4
|
+
间接拉起,本模块只提供与"具体如何起进程"无关的纯逻辑:
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
``LogRecord`` 字节流,再用本进程里配置的 handler(FileHandler /
|
|
10
|
-
StreamHandler)落地。
|
|
6
|
+
- :func:`_bind_loopback`:在 loopback 地址上绑定一个 ``AF_INET`` 监听 socket。
|
|
7
|
+
- :class:`_LogRecordSocketReceiver`:基于 :class:`socketserver.ThreadingTCPServer`
|
|
8
|
+
的接收器,复用外部已 ``bind`` 好的 socket。
|
|
9
|
+
- :func:`_build_handlers`:根据用户参数组装落地用的 handler 列表。
|
|
11
10
|
"""
|
|
12
11
|
|
|
13
12
|
import logging
|
|
@@ -16,19 +15,39 @@ import socket
|
|
|
16
15
|
import socketserver
|
|
17
16
|
import struct
|
|
18
17
|
import sys
|
|
19
|
-
import threading
|
|
20
18
|
from logging.handlers import TimedRotatingFileHandler
|
|
21
|
-
from multiprocessing.synchronize import Event as MpEvent
|
|
22
19
|
from typing import cast, final, override
|
|
23
20
|
|
|
24
21
|
from ._formatters import ColorFormatter, FileFormatter
|
|
25
22
|
|
|
23
|
+
# 这三个符号由同包内的 ``_server_main`` 模块导入复用;以下划线开头是因为它们
|
|
24
|
+
# 不属于库的公共 API(仅 kitty_logger 内部使用)。pyright 对下划线名默认按
|
|
25
|
+
# "模块内私有"处理,看不到跨模块引用,因此显式通过 ``__all__`` 声明为本模块
|
|
26
|
+
# 的对外导出,避免 reportUnusedFunction / reportUnusedClass 误报。
|
|
27
|
+
__all__ = ["_bind_loopback", "_LogRecordSocketReceiver", "_build_handlers"]
|
|
28
|
+
|
|
26
29
|
# 单条 LogRecord 序列化后字节数上限。任何正常 LogRecord 都远小于此值;
|
|
27
30
|
# 超过即视为协议错位或异常流量,直接断开连接,避免一次性分配巨大缓冲区
|
|
28
31
|
# 触发 OOM。
|
|
29
32
|
_MAX_RECORD_BYTES = 16 * 1024 * 1024
|
|
30
33
|
|
|
31
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
|
+
|
|
32
51
|
def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
|
|
33
52
|
"""从 ``conn`` 上读满 ``n`` 字节;对端已关闭则返回 ``None``。
|
|
34
53
|
|
|
@@ -84,7 +103,7 @@ class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
|
|
|
84
103
|
class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
|
|
85
104
|
"""基于 :class:`socketserver.ThreadingTCPServer` 的接收器。
|
|
86
105
|
|
|
87
|
-
|
|
106
|
+
复用外部已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
|
|
88
107
|
"""
|
|
89
108
|
|
|
90
109
|
allow_reuse_address: bool = True
|
|
@@ -95,7 +114,7 @@ class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
|
|
|
95
114
|
addr = cast(tuple[str, int], listening_sock.getsockname()[:2])
|
|
96
115
|
# 走父类正常流程以建立 BaseServer 内部状态(shutdown 标志等),
|
|
97
116
|
# 但通过 ``bind_and_activate=False`` 跳过它自己的 bind/listen——
|
|
98
|
-
# 这个 socket
|
|
117
|
+
# 这个 socket 已经由调用方绑好。
|
|
99
118
|
super().__init__(addr, _LogRecordStreamHandler, bind_and_activate=False)
|
|
100
119
|
# 父类构造时仍会 ``self.socket = socket.socket(...)`` 占坑,
|
|
101
120
|
# 这里替换成真正复用的 socket,并把临时占位关闭掉。
|
|
@@ -112,9 +131,26 @@ def _build_handlers(
|
|
|
112
131
|
stream: bool,
|
|
113
132
|
console_fmt: str,
|
|
114
133
|
file_fmt: str,
|
|
115
|
-
|
|
134
|
+
console_datefmt: str | None,
|
|
135
|
+
file_datefmt: str | None,
|
|
136
|
+
console_show_msec: bool,
|
|
137
|
+
file_show_msec: bool,
|
|
116
138
|
) -> list[logging.Handler]:
|
|
117
|
-
"""根据用户传入的参数组合出落地用的 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
|
+
"""
|
|
118
154
|
handlers: list[logging.Handler] = []
|
|
119
155
|
if log_file:
|
|
120
156
|
# 按天轮转,每天 0 点切一份,最多保留 365 天历史。
|
|
@@ -125,104 +161,18 @@ def _build_handlers(
|
|
|
125
161
|
backupCount=365,
|
|
126
162
|
encoding="utf-8",
|
|
127
163
|
)
|
|
128
|
-
fh.setFormatter(
|
|
164
|
+
fh.setFormatter(
|
|
165
|
+
FileFormatter(fmt=file_fmt, datefmt=file_datefmt, show_msec=file_show_msec)
|
|
166
|
+
)
|
|
129
167
|
handlers.append(fh)
|
|
130
168
|
if stream:
|
|
131
169
|
sh = logging.StreamHandler(stream=sys.stderr)
|
|
132
|
-
sh.setFormatter(
|
|
170
|
+
sh.setFormatter(
|
|
171
|
+
ColorFormatter(
|
|
172
|
+
fmt=console_fmt,
|
|
173
|
+
datefmt=console_datefmt,
|
|
174
|
+
show_msec=console_show_msec,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
133
177
|
handlers.append(sh)
|
|
134
178
|
return handlers
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def run_server(
|
|
138
|
-
listening_sock: socket.socket,
|
|
139
|
-
shutdown_event: MpEvent,
|
|
140
|
-
parent_alive_recv: socket.socket,
|
|
141
|
-
level: int,
|
|
142
|
-
log_file: str | None,
|
|
143
|
-
stream: bool,
|
|
144
|
-
console_fmt: str,
|
|
145
|
-
file_fmt: str,
|
|
146
|
-
datefmt: str | None,
|
|
147
|
-
) -> None:
|
|
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
|
-
# 但仍保险地清掉一遍以便重复初始化场景下的幂等性。
|
|
165
|
-
root = logging.getLogger()
|
|
166
|
-
for h in list(root.handlers):
|
|
167
|
-
root.removeHandler(h)
|
|
168
|
-
for h in _build_handlers(log_file, stream, console_fmt, file_fmt, datefmt):
|
|
169
|
-
root.addHandler(h)
|
|
170
|
-
root.setLevel(level)
|
|
171
|
-
|
|
172
|
-
# 防御 ``spawn`` bootstrap:服务子进程在 ``_fixup_main_from_path`` 重新
|
|
173
|
-
# import 用户主脚本时,主脚本顶层 import 的第三方库可能在模块级调用
|
|
174
|
-
# ``kitty_logger.getLogger(...)``。由于父进程已经把 KITTY_LOGGER_HOST/
|
|
175
|
-
# PORT/LEVEL 写进 ENV,本服务子进程里的 ``_ensure_kitty_handler()`` 会
|
|
176
|
-
# 顺利地往 ``kitty`` logger 上挂一个指向本服务自己监听端口的
|
|
177
|
-
# :class:`SocketHandler`,并把 ``propagate`` 关掉。结果是:本服务接收
|
|
178
|
-
# 到记录后调 ``logging.getLogger(record.name).handle(record)``,记录
|
|
179
|
-
# 沿祖先链冒泡到 ``kitty`` 时被这个 handler 捞走,原样发回给本服务
|
|
180
|
-
# 自身(形成网络回环),同时因为 ``propagate=False`` 而不会再到达
|
|
181
|
-
# root 上配置好的落地 handler,外部表现为日志凭空消失。
|
|
182
|
-
# 因此在开始 serve 之前,强制把 ``kitty`` logger 还原成"无 handler、
|
|
183
|
-
# propagate=True、level=NOTSET",让记录稳稳地冒泡到 root。
|
|
184
|
-
kitty = logging.getLogger("kitty")
|
|
185
|
-
for h in list(kitty.handlers):
|
|
186
|
-
kitty.removeHandler(h)
|
|
187
|
-
try:
|
|
188
|
-
h.close()
|
|
189
|
-
except OSError:
|
|
190
|
-
pass
|
|
191
|
-
kitty.propagate = True
|
|
192
|
-
kitty.setLevel(logging.NOTSET)
|
|
193
|
-
|
|
194
|
-
server = _LogRecordSocketReceiver(listening_sock)
|
|
195
|
-
|
|
196
|
-
def _wait_shutdown() -> None:
|
|
197
|
-
_ = shutdown_event.wait()
|
|
198
|
-
server.shutdown()
|
|
199
|
-
|
|
200
|
-
def _watch_parent_alive() -> None:
|
|
201
|
-
# 父进程持有 socketpair 的发送端;任意原因(正常退出 / SIGKILL /
|
|
202
|
-
# 段错误)使父进程消失,内核都会关闭它的 fd,本端 ``recv`` 会立刻
|
|
203
|
-
# 返回空字节(EOF)。借此触发自我退出,避免变成孤儿进程。
|
|
204
|
-
try:
|
|
205
|
-
while True:
|
|
206
|
-
data = parent_alive_recv.recv(64)
|
|
207
|
-
if not data:
|
|
208
|
-
break
|
|
209
|
-
except OSError:
|
|
210
|
-
pass
|
|
211
|
-
finally:
|
|
212
|
-
try:
|
|
213
|
-
parent_alive_recv.close()
|
|
214
|
-
except OSError:
|
|
215
|
-
pass
|
|
216
|
-
shutdown_event.set()
|
|
217
|
-
|
|
218
|
-
threading.Thread(target=_wait_shutdown, daemon=True).start()
|
|
219
|
-
threading.Thread(target=_watch_parent_alive, daemon=True).start()
|
|
220
|
-
|
|
221
|
-
try:
|
|
222
|
-
server.serve_forever()
|
|
223
|
-
finally:
|
|
224
|
-
try:
|
|
225
|
-
server.server_close()
|
|
226
|
-
except OSError:
|
|
227
|
-
pass
|
|
228
|
-
logging.shutdown()
|
|
@@ -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()
|
kitty_logger/_setup.py
CHANGED
|
@@ -6,13 +6,13 @@ import logging
|
|
|
6
6
|
import multiprocessing as mp
|
|
7
7
|
import os
|
|
8
8
|
import socket
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from typing import IO, Literal, TypedDict, TypeGuard, cast
|
|
12
13
|
|
|
13
14
|
from ._env import ENV_HOST, ENV_LEVEL, ENV_PORT
|
|
14
|
-
from ._formatters import
|
|
15
|
-
from ._server import run_server
|
|
15
|
+
from ._formatters import FormatPreset, FormatSpec, get_preset
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class _NotRunningState(TypedDict):
|
|
@@ -20,17 +20,13 @@ class _NotRunningState(TypedDict):
|
|
|
20
20
|
host: None
|
|
21
21
|
port: None
|
|
22
22
|
proc: None
|
|
23
|
-
shutdown_event: None
|
|
24
|
-
parent_alive_send: None
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
class _RunningState(TypedDict):
|
|
28
26
|
running: Literal[True]
|
|
29
27
|
host: str
|
|
30
28
|
port: int
|
|
31
|
-
proc:
|
|
32
|
-
shutdown_event: MpEvent
|
|
33
|
-
parent_alive_send: socket.socket
|
|
29
|
+
proc: subprocess.Popen[bytes]
|
|
34
30
|
|
|
35
31
|
|
|
36
32
|
type _ServerState = _NotRunningState | _RunningState
|
|
@@ -42,8 +38,6 @@ def _make_not_running_state() -> _NotRunningState:
|
|
|
42
38
|
"host": None,
|
|
43
39
|
"port": None,
|
|
44
40
|
"proc": None,
|
|
45
|
-
"shutdown_event": None,
|
|
46
|
-
"parent_alive_send": None,
|
|
47
41
|
}
|
|
48
42
|
|
|
49
43
|
|
|
@@ -103,20 +97,70 @@ def _check_loopback_only(host: str) -> None:
|
|
|
103
97
|
)
|
|
104
98
|
|
|
105
99
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
) -> tuple[socket.socket, str, int]:
|
|
109
|
-
"""创建并绑定一个 ``AF_INET`` TCP 监听 socket。
|
|
100
|
+
def _read_handshake(stdout: IO[bytes], proc: subprocess.Popen[bytes]) -> int:
|
|
101
|
+
"""从服务子进程的 stdout 读一行握手信息,返回服务真实绑定的端口。
|
|
110
102
|
|
|
111
|
-
|
|
112
|
-
|
|
103
|
+
协议在 :mod:`kitty_logger._server_main` 模块顶部说明。本函数把所有
|
|
104
|
+
非 ``"PORT <int>"`` 情况都收敛成 :class:`RuntimeError`:握手通道既然
|
|
105
|
+
被设计成 stdout,就不允许除握手以外的任何字节出现。
|
|
113
106
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
107
|
+
line = stdout.readline()
|
|
108
|
+
if not line:
|
|
109
|
+
# readline 返回空字节即对端 close 且没写任何东西——子进程在握手前
|
|
110
|
+
# 就退出了。等子进程结束以拿到 returncode 一并报告。
|
|
111
|
+
rc = proc.wait(timeout=2.0) if proc.poll() is None else proc.returncode
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"kitty_logger 服务子进程在握手前就退出了(exit code {rc})。"
|
|
114
|
+
)
|
|
115
|
+
text = line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
116
|
+
if text.startswith("PORT "):
|
|
117
|
+
try:
|
|
118
|
+
return int(text.split(" ", 1)[1])
|
|
119
|
+
except (ValueError, IndexError):
|
|
120
|
+
pass
|
|
121
|
+
if text.startswith("ERROR "):
|
|
122
|
+
raise RuntimeError(f"kitty_logger 服务子进程启动失败:{text[len('ERROR '):]}")
|
|
123
|
+
raise RuntimeError(f"kitty_logger 服务子进程握手协议异常,收到:{text!r}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_server_argv(
|
|
127
|
+
host: str,
|
|
128
|
+
port: int,
|
|
129
|
+
level: int,
|
|
130
|
+
log_file: str | None,
|
|
131
|
+
stream: bool,
|
|
132
|
+
console_spec: FormatSpec,
|
|
133
|
+
file_spec: FormatSpec,
|
|
134
|
+
) -> list[str]:
|
|
135
|
+
"""把 ``setup_logging`` 的参数翻译成服务子进程的命令行参数。"""
|
|
136
|
+
argv = [
|
|
137
|
+
sys.executable,
|
|
138
|
+
"-m",
|
|
139
|
+
"kitty_logger._server_main",
|
|
140
|
+
"--host",
|
|
141
|
+
host,
|
|
142
|
+
"--port",
|
|
143
|
+
str(port),
|
|
144
|
+
"--level",
|
|
145
|
+
str(level),
|
|
146
|
+
"--console-fmt",
|
|
147
|
+
console_spec.console_fmt,
|
|
148
|
+
"--file-fmt",
|
|
149
|
+
file_spec.file_fmt,
|
|
150
|
+
"--console-datefmt",
|
|
151
|
+
console_spec.datefmt,
|
|
152
|
+
"--file-datefmt",
|
|
153
|
+
file_spec.datefmt,
|
|
154
|
+
]
|
|
155
|
+
if log_file is not None:
|
|
156
|
+
argv += ["--log-file", log_file]
|
|
157
|
+
if stream:
|
|
158
|
+
argv += ["--stream"]
|
|
159
|
+
if console_spec.show_msec:
|
|
160
|
+
argv += ["--console-show-msec"]
|
|
161
|
+
if file_spec.show_msec:
|
|
162
|
+
argv += ["--file-show-msec"]
|
|
163
|
+
return argv
|
|
120
164
|
|
|
121
165
|
|
|
122
166
|
def setup_logging(
|
|
@@ -125,32 +169,27 @@ def setup_logging(
|
|
|
125
169
|
host: str = "127.0.0.1",
|
|
126
170
|
port: int = 0,
|
|
127
171
|
stream: bool = True,
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
preset: FormatPreset = "verbose",
|
|
173
|
+
console_preset: FormatPreset | None = None,
|
|
174
|
+
file_preset: FormatPreset | None = None,
|
|
175
|
+
console_fmt: str | None = None,
|
|
176
|
+
file_fmt: str | None = None,
|
|
130
177
|
datefmt: str | None = None,
|
|
131
178
|
) -> tuple[str, int]:
|
|
132
179
|
"""在主进程里启动跨进程日志服务,返回服务真实绑定到的 ``(host, port)``。
|
|
133
180
|
|
|
134
|
-
在一个独立的
|
|
181
|
+
在一个独立的 :class:`subprocess.Popen` 子进程里运行 TCP 接收器,把所有
|
|
135
182
|
:class:`logging.LogRecord` 写到 ``log_file`` 和/或 ``stderr``。同时把
|
|
136
183
|
``KITTY_LOGGER_HOST`` / ``KITTY_LOGGER_PORT`` / ``KITTY_LOGGER_LEVEL``
|
|
137
184
|
写入 :data:`os.environ`,使得后续 ``spawn`` 出来的子进程可以无配置地
|
|
138
185
|
通过 :func:`kitty_logger.getLogger` 连上来。
|
|
139
186
|
|
|
140
187
|
控制台输出使用带 ANSI 颜色的 :class:`ColorFormatter`,文件输出使用
|
|
141
|
-
无颜色的 :class:`FileFormatter
|
|
188
|
+
无颜色的 :class:`FileFormatter`。两侧的格式由四档预置之一决定:
|
|
189
|
+
``minimal`` / ``standard`` / ``verbose`` / ``debug``,每档同时封装
|
|
190
|
+
format 字符串、``datefmt`` 与是否显示毫秒。
|
|
142
191
|
|
|
143
192
|
幂等:重复调用直接返回已记录的 ``(host, port)``,不会重复启动子进程。
|
|
144
|
-
在 ``multiprocessing`` 启动的子进程里调用本函数时,会自动判断当前
|
|
145
|
-
不是原始主进程并直接返回从父进程继承的 ``(host, port)``,本身不再
|
|
146
|
-
启动新的日志服务——这意味着用户可以把 ``setup_logging()`` 直接放在
|
|
147
|
-
模块顶层而不必用 ``if __name__ == "__main__":`` 包裹。
|
|
148
|
-
|
|
149
|
-
本函数同时把主进程内 ``logging.getLogger("kitty")`` 这一个 logger
|
|
150
|
-
初始化好——挂上指向日志服务的 :class:`SocketHandler`、设置 level、
|
|
151
|
-
并把 ``propagate`` 关掉。所有通过 :func:`kitty_logger.getLogger`
|
|
152
|
-
取到的子 logger 都挂在 ``kitty`` 这棵子树下,借标准库的祖先链冒泡到
|
|
153
|
-
``kitty`` 的这个 handler 上,无需自己额外配置。
|
|
154
193
|
|
|
155
194
|
:param log_file: 日志文件路径;``None`` 表示不落盘。
|
|
156
195
|
:param level: 服务端 root logger 的级别,同时也是主进程 ``kitty``
|
|
@@ -159,9 +198,12 @@ def setup_logging(
|
|
|
159
198
|
绑定非 loopback 地址会直接 :class:`ValueError`。
|
|
160
199
|
:param port: 监听端口;``0`` 让 OS 自动选择空闲端口。
|
|
161
200
|
:param stream: 是否同时输出到 ``stderr``。
|
|
162
|
-
:param
|
|
163
|
-
:param
|
|
164
|
-
:param
|
|
201
|
+
:param preset: 同时作用于控制台与文件两侧的预置档位。
|
|
202
|
+
:param console_preset: 仅覆盖控制台侧的档位;不传则沿用 ``preset``。
|
|
203
|
+
:param file_preset: 仅覆盖文件侧的档位;不传则沿用 ``preset``。
|
|
204
|
+
:param console_fmt: 进一步覆盖控制台 handler 的 format 字符串。
|
|
205
|
+
:param file_fmt: 进一步覆盖文件 handler 的 format 字符串。
|
|
206
|
+
:param datefmt: 进一步覆盖时间戳的 ``strftime`` 格式(同时作用于两侧)。
|
|
165
207
|
:return: 服务真实绑定到的 ``(host, port)``。
|
|
166
208
|
|
|
167
209
|
.. note::
|
|
@@ -177,18 +219,20 @@ def setup_logging(
|
|
|
177
219
|
请使用 ``multiprocessing.get_context("spawn")``,或在顶层用
|
|
178
220
|
``if __name__ == "__main__":`` 守卫配合默认 ``spawn`` 行为。
|
|
179
221
|
"""
|
|
180
|
-
# ``multiprocessing``
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
222
|
+
# ``multiprocessing.Process`` 启动出来的 spawn 子进程在 bootstrap 阶段会
|
|
223
|
+
# 通过 ``_fixup_main_from_path`` 重新执行用户主脚本顶层;如果用户把
|
|
224
|
+
# ``setup_logging(...)`` 写在了模块顶层,本函数会在每个 worker 子进程里
|
|
225
|
+
# 也被调到。此时不应再起一份服务,而是直接返回从父进程继承的 ENV——
|
|
226
|
+
# 让 worker 的 getLogger 自动连上原始服务即可。
|
|
227
|
+
# 注意:本库自己 ``Popen`` 拉起的服务子进程不会触发这条路径,因为它
|
|
228
|
+
# 跑的是 ``-m kitty_logger._server_main``,并不会回头执行用户主脚本。
|
|
185
229
|
if mp.parent_process() is not None:
|
|
186
230
|
host_env = os.environ.get(ENV_HOST)
|
|
187
231
|
port_env = os.environ.get(ENV_PORT)
|
|
188
232
|
if not host_env or not port_env:
|
|
189
233
|
raise RuntimeError(
|
|
190
|
-
"kitty_logger
|
|
191
|
-
"
|
|
234
|
+
"kitty_logger 在 multiprocessing 子进程里被调用 setup_logging,"
|
|
235
|
+
"但没有从父进程继承到 KITTY_LOGGER_HOST / KITTY_LOGGER_PORT 环境变量。"
|
|
192
236
|
"请确保父进程已经先调用 setup_logging() 后再 spawn 本进程。"
|
|
193
237
|
)
|
|
194
238
|
return host_env, int(port_env)
|
|
@@ -199,57 +243,90 @@ def setup_logging(
|
|
|
199
243
|
|
|
200
244
|
_check_loopback_only(host)
|
|
201
245
|
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
|
|
246
|
+
# 解析每侧的 preset:单侧 preset 优先于全局 preset;具体的 fmt/datefmt
|
|
247
|
+
# 参数则在 preset 选定后再做字段级覆盖。
|
|
248
|
+
console_spec = get_preset(console_preset or preset)
|
|
249
|
+
file_spec = get_preset(file_preset or preset)
|
|
250
|
+
if console_fmt is not None:
|
|
251
|
+
console_spec = replace(console_spec, console_fmt=console_fmt)
|
|
252
|
+
if file_fmt is not None:
|
|
253
|
+
file_spec = replace(file_spec, file_fmt=file_fmt)
|
|
254
|
+
if datefmt is not None:
|
|
255
|
+
console_spec = replace(console_spec, datefmt=datefmt)
|
|
256
|
+
file_spec = replace(file_spec, datefmt=datefmt)
|
|
257
|
+
|
|
258
|
+
argv = _build_server_argv(
|
|
259
|
+
host=host,
|
|
260
|
+
port=port,
|
|
261
|
+
level=level,
|
|
262
|
+
log_file=log_file,
|
|
263
|
+
stream=stream,
|
|
264
|
+
console_spec=console_spec,
|
|
265
|
+
file_spec=file_spec,
|
|
266
|
+
)
|
|
267
|
+
# 把本包所在目录拼进子进程的 ``PYTHONPATH``,保证子进程的解释器能
|
|
268
|
+
# ``import`` 到 ``kitty_logger._server_main``。已经 ``pip install`` 装到
|
|
269
|
+
# site-packages 的场景下这步是冗余但无害的;开发场景(仅靠
|
|
270
|
+
# ``pytest --pythonpath=src`` 把 ``src/`` 临时拼到 pytest 自己的 sys.path)
|
|
271
|
+
# 则必须显式传递,否则全新启动的子解释器看不到包路径。
|
|
272
|
+
pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
273
|
+
child_env = os.environ.copy()
|
|
274
|
+
existing = child_env.get("PYTHONPATH", "")
|
|
275
|
+
child_env["PYTHONPATH"] = pkg_parent + (os.pathsep + existing if existing else "")
|
|
276
|
+
|
|
277
|
+
# 三条管道的角色见 :mod:`kitty_logger._server_main` 模块文档:
|
|
278
|
+
# stdin = 父→子,仅用于探活(父挂掉子读到 EOF);
|
|
279
|
+
# stdout = 子→父,仅用于一次握手;
|
|
280
|
+
# stderr = 子→父,直通父的 stderr/tty,由服务端 StreamHandler 落地日志。
|
|
281
|
+
proc: subprocess.Popen[bytes] = subprocess.Popen(
|
|
282
|
+
argv,
|
|
283
|
+
stdin=subprocess.PIPE,
|
|
284
|
+
stdout=subprocess.PIPE,
|
|
285
|
+
stderr=None,
|
|
286
|
+
env=child_env,
|
|
287
|
+
)
|
|
288
|
+
# ``readline`` 用 buffered IO;为保证我们看到子进程的 ``flush+close``
|
|
289
|
+
# 之后立刻拿到那一行,要走二进制接口手动解码。
|
|
290
|
+
stdout = cast(IO[bytes], proc.stdout)
|
|
291
|
+
try:
|
|
292
|
+
bound_port = _read_handshake(stdout, proc)
|
|
293
|
+
except BaseException:
|
|
294
|
+
# 握手失败时主动结束子进程,避免留下孤儿。
|
|
295
|
+
try:
|
|
296
|
+
if proc.stdin is not None:
|
|
297
|
+
proc.stdin.close()
|
|
298
|
+
except OSError:
|
|
299
|
+
pass
|
|
300
|
+
try:
|
|
301
|
+
proc.kill()
|
|
302
|
+
except OSError:
|
|
303
|
+
pass
|
|
304
|
+
try:
|
|
305
|
+
proc.wait(timeout=2.0)
|
|
306
|
+
except subprocess.TimeoutExpired:
|
|
307
|
+
pass
|
|
308
|
+
raise
|
|
309
|
+
# 握手通道在父侧也立刻关掉,子侧已 close,保留只是浪费 fd。
|
|
310
|
+
try:
|
|
311
|
+
stdout.close()
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
205
314
|
|
|
206
|
-
|
|
207
|
-
# 被 ``kill -9`` 也会让内核 close 它的 fd,子进程在另一端会读到 EOF。
|
|
208
|
-
parent_alive_send, parent_alive_recv = socket.socketpair()
|
|
315
|
+
bound_host = host
|
|
209
316
|
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
# ``
|
|
213
|
-
#
|
|
214
|
-
# 调用了 getLogger),此时子进程必须已经能从 ENV 读到服务地址,否则
|
|
215
|
-
# 子进程会在 import 阶段抛 RuntimeError 而异常退出。
|
|
317
|
+
# 这一段必须在 spawn 任何后代之前完成:后代要靠这些 ENV 找服务地址。
|
|
318
|
+
# ``Popen`` 不会像 ``multiprocessing.Process`` 那样去 re-import 主脚本,
|
|
319
|
+
# 因此本函数本身可以放在模块顶层,无需 ``if __name__ == "__main__":``
|
|
320
|
+
# 守卫——服务子进程不会回头执行用户主脚本的任何顶层代码。
|
|
216
321
|
os.environ[ENV_HOST] = bound_host
|
|
217
322
|
os.environ[ENV_PORT] = str(bound_port)
|
|
218
323
|
os.environ[ENV_LEVEL] = str(level)
|
|
219
324
|
|
|
220
|
-
ctx = mp.get_context("spawn")
|
|
221
|
-
shutdown_event = ctx.Event()
|
|
222
|
-
proc = ctx.Process(
|
|
223
|
-
target=run_server,
|
|
224
|
-
kwargs=dict(
|
|
225
|
-
listening_sock=listening_sock,
|
|
226
|
-
shutdown_event=shutdown_event,
|
|
227
|
-
parent_alive_recv=parent_alive_recv,
|
|
228
|
-
level=level,
|
|
229
|
-
log_file=log_file,
|
|
230
|
-
stream=stream,
|
|
231
|
-
console_fmt=console_fmt,
|
|
232
|
-
file_fmt=file_fmt,
|
|
233
|
-
datefmt=datefmt,
|
|
234
|
-
),
|
|
235
|
-
name="kitty-logger-server",
|
|
236
|
-
daemon=False,
|
|
237
|
-
)
|
|
238
|
-
proc.start()
|
|
239
|
-
|
|
240
|
-
# 父进程不再需要这两个 socket 副本:
|
|
241
|
-
# - listening_sock 已经完全交给子进程;
|
|
242
|
-
# - parent_alive_recv 必须只由子进程持有,否则 EOF 永远不会到来。
|
|
243
|
-
listening_sock.close()
|
|
244
|
-
parent_alive_recv.close()
|
|
245
|
-
|
|
246
325
|
new_state: _RunningState = {
|
|
247
326
|
"running": True,
|
|
248
327
|
"host": bound_host,
|
|
249
328
|
"port": bound_port,
|
|
250
329
|
"proc": proc,
|
|
251
|
-
"shutdown_event": shutdown_event,
|
|
252
|
-
"parent_alive_send": parent_alive_send,
|
|
253
330
|
}
|
|
254
331
|
_state = new_state
|
|
255
332
|
|
|
@@ -306,36 +383,42 @@ def shutdown_logging(timeout: float = 5.0) -> None:
|
|
|
306
383
|
if not _is_running(state=_state):
|
|
307
384
|
return
|
|
308
385
|
|
|
309
|
-
|
|
310
|
-
proc: BaseProcess = _state["proc"]
|
|
311
|
-
parent_alive_send: socket.socket = _state["parent_alive_send"]
|
|
386
|
+
proc: subprocess.Popen[bytes] = _state["proc"]
|
|
312
387
|
host: str = _state["host"]
|
|
313
388
|
port: int = _state["port"]
|
|
314
389
|
|
|
315
390
|
_state = _make_not_running_state()
|
|
316
391
|
|
|
392
|
+
# 关掉 stdin 的写端 = 子进程那侧 ``read()`` 立刻得到 EOF,
|
|
393
|
+
# 触发 ``server.shutdown()`` 走优雅退出路径。这是首选信号。
|
|
394
|
+
if proc.stdin is not None:
|
|
395
|
+
try:
|
|
396
|
+
proc.stdin.close()
|
|
397
|
+
except OSError:
|
|
398
|
+
pass
|
|
399
|
+
|
|
317
400
|
try:
|
|
318
|
-
|
|
319
|
-
except
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
401
|
+
proc.wait(timeout=timeout)
|
|
402
|
+
except subprocess.TimeoutExpired:
|
|
403
|
+
try:
|
|
404
|
+
proc.terminate()
|
|
405
|
+
except OSError:
|
|
406
|
+
pass
|
|
407
|
+
try:
|
|
408
|
+
proc.wait(timeout=1.0)
|
|
409
|
+
except subprocess.TimeoutExpired:
|
|
410
|
+
pass
|
|
327
411
|
|
|
328
|
-
proc.
|
|
329
|
-
if proc.is_alive():
|
|
330
|
-
proc.terminate()
|
|
331
|
-
proc.join(1.0)
|
|
332
|
-
if proc.is_alive():
|
|
412
|
+
if proc.poll() is None:
|
|
333
413
|
# ``terminate`` 没杀掉(被 IO 阻塞在不可中断状态):兜底 kill。
|
|
334
414
|
try:
|
|
335
415
|
proc.kill()
|
|
336
|
-
except
|
|
416
|
+
except OSError:
|
|
417
|
+
pass
|
|
418
|
+
try:
|
|
419
|
+
proc.wait(timeout=1.0)
|
|
420
|
+
except subprocess.TimeoutExpired:
|
|
337
421
|
pass
|
|
338
|
-
proc.join(1.0)
|
|
339
422
|
|
|
340
423
|
# 服务进程已经死了,本进程里指向它的 SocketHandler 必须全部取下,
|
|
341
424
|
# 否则后续 logging 调用会向一个失效端口发送,触发静默丢日志。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kitty_logger
|
|
3
|
-
Version: 0.2.0.
|
|
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
|
-
|
|
59
|
-
`
|
|
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
|
-
|
|
64
|
-
|
|
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")` 实际拿到的是
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
kitty_logger/__init__.py,sha256=YWcFIW_67cr68L2tg6hDTioRZy-caNLtQGb9U-rFw3g,1610
|
|
2
|
+
kitty_logger/_client.py,sha256=9lM9w7eEzu7muAinB7Ly2uC2qIpayRxhlMNnyD9CPzg,4243
|
|
3
|
+
kitty_logger/_env.py,sha256=Op_GA1mrtHVnMUzhnLp6TQRLHYcwZABg-8IlOx64DlI,475
|
|
4
|
+
kitty_logger/_formatters.py,sha256=VE6FjP3K43RWcVkgkSauzhKo_Cz-LJWh_ZID6VIs9f0,8283
|
|
5
|
+
kitty_logger/_server.py,sha256=VDEIp0IAq82UpKxDZhpJtDkq6cdhlor_16Pm91XC-Mo,7179
|
|
6
|
+
kitty_logger/_server_main.py,sha256=bMIBYcHRA_ZZ8poe9DwEdZS7G0dsCVJOIPUolL_lQ58,4907
|
|
7
|
+
kitty_logger/_setup.py,sha256=tPeOEY__4REV7-XWVjJND6D1gp_yyoN10fuNsQhSGPg,16993
|
|
8
|
+
kitty_logger-0.2.0.dev4.dist-info/licenses/LICENSE,sha256=_xhnQ19LbBdV6FieZ9OsZxOUBw_fQW4QrUPLDx031b4,1062
|
|
9
|
+
kitty_logger-0.2.0.dev4.dist-info/METADATA,sha256=4XSom-N2U6m8bXWaQtiWaaN5IcHVXYTaETUPahedhgg,4764
|
|
10
|
+
kitty_logger-0.2.0.dev4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
kitty_logger-0.2.0.dev4.dist-info/top_level.txt,sha256=OmkC-N3Zg_5NPlFWU9EkTd5eOM2A8qFGzY9ECVi-1Y8,13
|
|
12
|
+
kitty_logger-0.2.0.dev4.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
kitty_logger/__init__.py,sha256=g6cVnby-PYzQtl9sZdQFE429UsVb8rTpQyhyoVAiCGQ,1453
|
|
2
|
-
kitty_logger/_client.py,sha256=9lM9w7eEzu7muAinB7Ly2uC2qIpayRxhlMNnyD9CPzg,4243
|
|
3
|
-
kitty_logger/_env.py,sha256=Op_GA1mrtHVnMUzhnLp6TQRLHYcwZABg-8IlOx64DlI,475
|
|
4
|
-
kitty_logger/_formatters.py,sha256=OQjf_ou3KFKiGqIdaokXbIMS93ErlndXrybluxtk234,3093
|
|
5
|
-
kitty_logger/_server.py,sha256=MfRTyoemVqWar3-TvLEZuGIT-SiwY9Wgy_j_lyL47YI,8750
|
|
6
|
-
kitty_logger/_setup.py,sha256=Li2apQ7V2ZoTTm709T6qa76AL52KVk2x_xiqsCs1Ny8,14108
|
|
7
|
-
kitty_logger-0.2.0.dev2.dist-info/licenses/LICENSE,sha256=_xhnQ19LbBdV6FieZ9OsZxOUBw_fQW4QrUPLDx031b4,1062
|
|
8
|
-
kitty_logger-0.2.0.dev2.dist-info/METADATA,sha256=GSeXLER3bvMf1ZpBXLjBz-qiCxjSEDegbZa8OcNJlzE,4635
|
|
9
|
-
kitty_logger-0.2.0.dev2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
-
kitty_logger-0.2.0.dev2.dist-info/top_level.txt,sha256=OmkC-N3Zg_5NPlFWU9EkTd5eOM2A8qFGzY9ECVi-1Y8,13
|
|
11
|
-
kitty_logger-0.2.0.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|