kitty-logger 0.2.0.dev2__tar.gz → 0.2.0.dev3__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.
- {kitty_logger-0.2.0.dev2/src/kitty_logger.egg-info → kitty_logger-0.2.0.dev3}/PKG-INFO +7 -5
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/README.md +6 -4
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/pyproject.toml +1 -1
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/__init__.py +1 -1
- kitty_logger-0.2.0.dev3/src/kitty_logger/_server.py +153 -0
- kitty_logger-0.2.0.dev3/src/kitty_logger/_server_main.py +129 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_setup.py +158 -100
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3/src/kitty_logger.egg-info}/PKG-INFO +7 -5
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/SOURCES.txt +1 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/tests/test_kitty_logger.py +0 -44
- kitty_logger-0.2.0.dev2/src/kitty_logger/_server.py +0 -228
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/LICENSE +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/setup.cfg +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_client.py +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_env.py +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_formatters.py +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/dependency_links.txt +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/requires.txt +0 -0
- {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/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.
|
|
3
|
+
Version: 0.2.0.dev3
|
|
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")` 实际拿到的是
|
|
@@ -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
|
-
|
|
46
|
-
`
|
|
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
|
-
|
|
51
|
-
|
|
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.
|
|
7
|
+
version = "0.2.0.dev3"
|
|
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"
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
datefmt: str | None,
|
|
135
|
+
) -> list[logging.Handler]:
|
|
136
|
+
"""根据用户传入的参数组合出落地用的 handler 列表。"""
|
|
137
|
+
handlers: list[logging.Handler] = []
|
|
138
|
+
if log_file:
|
|
139
|
+
# 按天轮转,每天 0 点切一份,最多保留 365 天历史。
|
|
140
|
+
fh = TimedRotatingFileHandler(
|
|
141
|
+
log_file,
|
|
142
|
+
when="midnight",
|
|
143
|
+
interval=1,
|
|
144
|
+
backupCount=365,
|
|
145
|
+
encoding="utf-8",
|
|
146
|
+
)
|
|
147
|
+
fh.setFormatter(FileFormatter(fmt=file_fmt, datefmt=datefmt))
|
|
148
|
+
handlers.append(fh)
|
|
149
|
+
if stream:
|
|
150
|
+
sh = logging.StreamHandler(stream=sys.stderr)
|
|
151
|
+
sh.setFormatter(ColorFormatter(fmt=console_fmt, datefmt=datefmt))
|
|
152
|
+
handlers.append(sh)
|
|
153
|
+
return handlers
|
|
@@ -0,0 +1,129 @@
|
|
|
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("--datefmt", default=None)
|
|
42
|
+
return ap.parse_args()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _emit_handshake(port: int) -> None:
|
|
46
|
+
"""把 ``"PORT <port>\\n"`` 写到 stdout 并主动 close。
|
|
47
|
+
|
|
48
|
+
主动 close 等于显式宣告"握手结束"——之后任何对 ``sys.stdout`` 的
|
|
49
|
+
误用都不会再污染父进程的读端,父进程的 ``readline()`` 也只会看到
|
|
50
|
+
我们刚写的这一行。
|
|
51
|
+
"""
|
|
52
|
+
sys.stdout.write(f"PORT {port}\n")
|
|
53
|
+
sys.stdout.flush()
|
|
54
|
+
try:
|
|
55
|
+
sys.stdout.close()
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _emit_handshake_error(msg: str) -> None:
|
|
61
|
+
"""启动失败时通过 stdout 报错,让父端 ``readline()`` 看到非 ``PORT`` 行。"""
|
|
62
|
+
try:
|
|
63
|
+
sys.stdout.write(f"ERROR {msg}\n")
|
|
64
|
+
sys.stdout.flush()
|
|
65
|
+
sys.stdout.close()
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
args = _parse_args()
|
|
72
|
+
|
|
73
|
+
# 先 bind+listen,这一步任何失败都通过 stdout 上报给父进程,让 setup_logging
|
|
74
|
+
# 能在主进程线程中以 ``RuntimeError`` 形式抛出,而不是父子双方各自卡住。
|
|
75
|
+
try:
|
|
76
|
+
sock, _bound_host, bound_port = _bind_loopback(args.host, args.port)
|
|
77
|
+
except OSError as e:
|
|
78
|
+
_emit_handshake_error(f"bind failed: {e}")
|
|
79
|
+
sys.exit(2)
|
|
80
|
+
|
|
81
|
+
_emit_handshake(bound_port)
|
|
82
|
+
|
|
83
|
+
# spawn 出来的全新解释器,root logger 默认无 handler;保险起见仍清一遍
|
|
84
|
+
# 以便重复初始化场景下保持幂等。
|
|
85
|
+
root = logging.getLogger()
|
|
86
|
+
for h in list(root.handlers):
|
|
87
|
+
root.removeHandler(h)
|
|
88
|
+
for h in _build_handlers(
|
|
89
|
+
args.log_file, args.stream, args.console_fmt, args.file_fmt, args.datefmt
|
|
90
|
+
):
|
|
91
|
+
root.addHandler(h)
|
|
92
|
+
root.setLevel(args.level)
|
|
93
|
+
|
|
94
|
+
server = _LogRecordSocketReceiver(sock)
|
|
95
|
+
|
|
96
|
+
def _watch_parent_alive() -> None:
|
|
97
|
+
"""父进程持有 stdin 的写端;任何原因(正常退出 / SIGKILL / 段错误)
|
|
98
|
+
让父进程消失,内核都会关掉它的 fd,本端 ``read()`` 会立刻返回
|
|
99
|
+
``b""``(EOF)。借此触发自我退出,避免变成孤儿进程。
|
|
100
|
+
|
|
101
|
+
正常生命周期下父进程也通过 ``proc.stdin.close()`` 主动触发同一
|
|
102
|
+
条 EOF 路径,把"父退出 / 父主动 shutdown" 收敛为同一种处理。
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
while True:
|
|
106
|
+
data = sys.stdin.buffer.read(64)
|
|
107
|
+
if not data:
|
|
108
|
+
break
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
pass
|
|
111
|
+
finally:
|
|
112
|
+
server.shutdown()
|
|
113
|
+
|
|
114
|
+
threading.Thread(
|
|
115
|
+
target=_watch_parent_alive, name="kitty-logger-parent-watch", daemon=True
|
|
116
|
+
).start()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
server.serve_forever()
|
|
120
|
+
finally:
|
|
121
|
+
try:
|
|
122
|
+
server.server_close()
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
logging.shutdown()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -6,13 +6,12 @@ import logging
|
|
|
6
6
|
import multiprocessing as mp
|
|
7
7
|
import os
|
|
8
8
|
import socket
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from typing import Literal, TypedDict, TypeGuard, cast
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import IO, Literal, TypedDict, TypeGuard, cast
|
|
12
12
|
|
|
13
13
|
from ._env import ENV_HOST, ENV_LEVEL, ENV_PORT
|
|
14
14
|
from ._formatters import DEFAULT_CONSOLE_FMT, DEFAULT_FILE_FMT
|
|
15
|
-
from ._server import run_server
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
class _NotRunningState(TypedDict):
|
|
@@ -20,17 +19,13 @@ class _NotRunningState(TypedDict):
|
|
|
20
19
|
host: None
|
|
21
20
|
port: None
|
|
22
21
|
proc: None
|
|
23
|
-
shutdown_event: None
|
|
24
|
-
parent_alive_send: None
|
|
25
22
|
|
|
26
23
|
|
|
27
24
|
class _RunningState(TypedDict):
|
|
28
25
|
running: Literal[True]
|
|
29
26
|
host: str
|
|
30
27
|
port: int
|
|
31
|
-
proc:
|
|
32
|
-
shutdown_event: MpEvent
|
|
33
|
-
parent_alive_send: socket.socket
|
|
28
|
+
proc: subprocess.Popen[bytes]
|
|
34
29
|
|
|
35
30
|
|
|
36
31
|
type _ServerState = _NotRunningState | _RunningState
|
|
@@ -42,8 +37,6 @@ def _make_not_running_state() -> _NotRunningState:
|
|
|
42
37
|
"host": None,
|
|
43
38
|
"port": None,
|
|
44
39
|
"proc": None,
|
|
45
|
-
"shutdown_event": None,
|
|
46
|
-
"parent_alive_send": None,
|
|
47
40
|
}
|
|
48
41
|
|
|
49
42
|
|
|
@@ -103,20 +96,65 @@ def _check_loopback_only(host: str) -> None:
|
|
|
103
96
|
)
|
|
104
97
|
|
|
105
98
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
) -> tuple[socket.socket, str, int]:
|
|
109
|
-
"""创建并绑定一个 ``AF_INET`` TCP 监听 socket。
|
|
99
|
+
def _read_handshake(stdout: IO[bytes], proc: subprocess.Popen[bytes]) -> int:
|
|
100
|
+
"""从服务子进程的 stdout 读一行握手信息,返回服务真实绑定的端口。
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
协议在 :mod:`kitty_logger._server_main` 模块顶部说明。本函数把所有
|
|
103
|
+
非 ``"PORT <int>"`` 情况都收敛成 :class:`RuntimeError`:握手通道既然
|
|
104
|
+
被设计成 stdout,就不允许除握手以外的任何字节出现。
|
|
113
105
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
106
|
+
line = stdout.readline()
|
|
107
|
+
if not line:
|
|
108
|
+
# readline 返回空字节即对端 close 且没写任何东西——子进程在握手前
|
|
109
|
+
# 就退出了。等子进程结束以拿到 returncode 一并报告。
|
|
110
|
+
rc = proc.wait(timeout=2.0) if proc.poll() is None else proc.returncode
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
f"kitty_logger 服务子进程在握手前就退出了(exit code {rc})。"
|
|
113
|
+
)
|
|
114
|
+
text = line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
115
|
+
if text.startswith("PORT "):
|
|
116
|
+
try:
|
|
117
|
+
return int(text.split(" ", 1)[1])
|
|
118
|
+
except (ValueError, IndexError):
|
|
119
|
+
pass
|
|
120
|
+
if text.startswith("ERROR "):
|
|
121
|
+
raise RuntimeError(f"kitty_logger 服务子进程启动失败:{text[len('ERROR '):]}")
|
|
122
|
+
raise RuntimeError(f"kitty_logger 服务子进程握手协议异常,收到:{text!r}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_server_argv(
|
|
126
|
+
host: str,
|
|
127
|
+
port: int,
|
|
128
|
+
level: int,
|
|
129
|
+
log_file: str | None,
|
|
130
|
+
stream: bool,
|
|
131
|
+
console_fmt: str,
|
|
132
|
+
file_fmt: str,
|
|
133
|
+
datefmt: str | None,
|
|
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_fmt,
|
|
148
|
+
"--file-fmt",
|
|
149
|
+
file_fmt,
|
|
150
|
+
]
|
|
151
|
+
if log_file is not None:
|
|
152
|
+
argv += ["--log-file", log_file]
|
|
153
|
+
if stream:
|
|
154
|
+
argv += ["--stream"]
|
|
155
|
+
if datefmt is not None:
|
|
156
|
+
argv += ["--datefmt", datefmt]
|
|
157
|
+
return argv
|
|
120
158
|
|
|
121
159
|
|
|
122
160
|
def setup_logging(
|
|
@@ -131,7 +169,7 @@ def setup_logging(
|
|
|
131
169
|
) -> tuple[str, int]:
|
|
132
170
|
"""在主进程里启动跨进程日志服务,返回服务真实绑定到的 ``(host, port)``。
|
|
133
171
|
|
|
134
|
-
在一个独立的
|
|
172
|
+
在一个独立的 :class:`subprocess.Popen` 子进程里运行 TCP 接收器,把所有
|
|
135
173
|
:class:`logging.LogRecord` 写到 ``log_file`` 和/或 ``stderr``。同时把
|
|
136
174
|
``KITTY_LOGGER_HOST`` / ``KITTY_LOGGER_PORT`` / ``KITTY_LOGGER_LEVEL``
|
|
137
175
|
写入 :data:`os.environ`,使得后续 ``spawn`` 出来的子进程可以无配置地
|
|
@@ -141,16 +179,6 @@ def setup_logging(
|
|
|
141
179
|
无颜色的 :class:`FileFormatter`,两者均使用毫秒级时间戳。
|
|
142
180
|
|
|
143
181
|
幂等:重复调用直接返回已记录的 ``(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
182
|
|
|
155
183
|
:param log_file: 日志文件路径;``None`` 表示不落盘。
|
|
156
184
|
:param level: 服务端 root logger 的级别,同时也是主进程 ``kitty``
|
|
@@ -177,18 +205,20 @@ def setup_logging(
|
|
|
177
205
|
请使用 ``multiprocessing.get_context("spawn")``,或在顶层用
|
|
178
206
|
``if __name__ == "__main__":`` 守卫配合默认 ``spawn`` 行为。
|
|
179
207
|
"""
|
|
180
|
-
# ``multiprocessing``
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
208
|
+
# ``multiprocessing.Process`` 启动出来的 spawn 子进程在 bootstrap 阶段会
|
|
209
|
+
# 通过 ``_fixup_main_from_path`` 重新执行用户主脚本顶层;如果用户把
|
|
210
|
+
# ``setup_logging(...)`` 写在了模块顶层,本函数会在每个 worker 子进程里
|
|
211
|
+
# 也被调到。此时不应再起一份服务,而是直接返回从父进程继承的 ENV——
|
|
212
|
+
# 让 worker 的 getLogger 自动连上原始服务即可。
|
|
213
|
+
# 注意:本库自己 ``Popen`` 拉起的服务子进程不会触发这条路径,因为它
|
|
214
|
+
# 跑的是 ``-m kitty_logger._server_main``,并不会回头执行用户主脚本。
|
|
185
215
|
if mp.parent_process() is not None:
|
|
186
216
|
host_env = os.environ.get(ENV_HOST)
|
|
187
217
|
port_env = os.environ.get(ENV_PORT)
|
|
188
218
|
if not host_env or not port_env:
|
|
189
219
|
raise RuntimeError(
|
|
190
|
-
"kitty_logger
|
|
191
|
-
"
|
|
220
|
+
"kitty_logger 在 multiprocessing 子进程里被调用 setup_logging,"
|
|
221
|
+
"但没有从父进程继承到 KITTY_LOGGER_HOST / KITTY_LOGGER_PORT 环境变量。"
|
|
192
222
|
"请确保父进程已经先调用 setup_logging() 后再 spawn 本进程。"
|
|
193
223
|
)
|
|
194
224
|
return host_env, int(port_env)
|
|
@@ -199,57 +229,79 @@ def setup_logging(
|
|
|
199
229
|
|
|
200
230
|
_check_loopback_only(host)
|
|
201
231
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
232
|
+
argv = _build_server_argv(
|
|
233
|
+
host=host,
|
|
234
|
+
port=port,
|
|
235
|
+
level=level,
|
|
236
|
+
log_file=log_file,
|
|
237
|
+
stream=stream,
|
|
238
|
+
console_fmt=console_fmt,
|
|
239
|
+
file_fmt=file_fmt,
|
|
240
|
+
datefmt=datefmt,
|
|
241
|
+
)
|
|
242
|
+
# 把本包所在目录拼进子进程的 ``PYTHONPATH``,保证子进程的解释器能
|
|
243
|
+
# ``import`` 到 ``kitty_logger._server_main``。已经 ``pip install`` 装到
|
|
244
|
+
# site-packages 的场景下这步是冗余但无害的;开发场景(仅靠
|
|
245
|
+
# ``pytest --pythonpath=src`` 把 ``src/`` 临时拼到 pytest 自己的 sys.path)
|
|
246
|
+
# 则必须显式传递,否则全新启动的子解释器看不到包路径。
|
|
247
|
+
pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
248
|
+
child_env = os.environ.copy()
|
|
249
|
+
existing = child_env.get("PYTHONPATH", "")
|
|
250
|
+
child_env["PYTHONPATH"] = pkg_parent + (os.pathsep + existing if existing else "")
|
|
251
|
+
|
|
252
|
+
# 三条管道的角色见 :mod:`kitty_logger._server_main` 模块文档:
|
|
253
|
+
# stdin = 父→子,仅用于探活(父挂掉子读到 EOF);
|
|
254
|
+
# stdout = 子→父,仅用于一次握手;
|
|
255
|
+
# stderr = 子→父,直通父的 stderr/tty,由服务端 StreamHandler 落地日志。
|
|
256
|
+
proc: subprocess.Popen[bytes] = subprocess.Popen(
|
|
257
|
+
argv,
|
|
258
|
+
stdin=subprocess.PIPE,
|
|
259
|
+
stdout=subprocess.PIPE,
|
|
260
|
+
stderr=None,
|
|
261
|
+
env=child_env,
|
|
262
|
+
)
|
|
263
|
+
# ``readline`` 用 buffered IO;为保证我们看到子进程的 ``flush+close``
|
|
264
|
+
# 之后立刻拿到那一行,要走二进制接口手动解码。
|
|
265
|
+
stdout = cast(IO[bytes], proc.stdout)
|
|
266
|
+
try:
|
|
267
|
+
bound_port = _read_handshake(stdout, proc)
|
|
268
|
+
except BaseException:
|
|
269
|
+
# 握手失败时主动结束子进程,避免留下孤儿。
|
|
270
|
+
try:
|
|
271
|
+
if proc.stdin is not None:
|
|
272
|
+
proc.stdin.close()
|
|
273
|
+
except OSError:
|
|
274
|
+
pass
|
|
275
|
+
try:
|
|
276
|
+
proc.kill()
|
|
277
|
+
except OSError:
|
|
278
|
+
pass
|
|
279
|
+
try:
|
|
280
|
+
proc.wait(timeout=2.0)
|
|
281
|
+
except subprocess.TimeoutExpired:
|
|
282
|
+
pass
|
|
283
|
+
raise
|
|
284
|
+
# 握手通道在父侧也立刻关掉,子侧已 close,保留只是浪费 fd。
|
|
285
|
+
try:
|
|
286
|
+
stdout.close()
|
|
287
|
+
except OSError:
|
|
288
|
+
pass
|
|
205
289
|
|
|
206
|
-
|
|
207
|
-
# 被 ``kill -9`` 也会让内核 close 它的 fd,子进程在另一端会读到 EOF。
|
|
208
|
-
parent_alive_send, parent_alive_recv = socket.socketpair()
|
|
290
|
+
bound_host = host
|
|
209
291
|
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
# ``
|
|
213
|
-
#
|
|
214
|
-
# 调用了 getLogger),此时子进程必须已经能从 ENV 读到服务地址,否则
|
|
215
|
-
# 子进程会在 import 阶段抛 RuntimeError 而异常退出。
|
|
292
|
+
# 这一段必须在 spawn 任何后代之前完成:后代要靠这些 ENV 找服务地址。
|
|
293
|
+
# ``Popen`` 不会像 ``multiprocessing.Process`` 那样去 re-import 主脚本,
|
|
294
|
+
# 因此本函数本身可以放在模块顶层,无需 ``if __name__ == "__main__":``
|
|
295
|
+
# 守卫——服务子进程不会回头执行用户主脚本的任何顶层代码。
|
|
216
296
|
os.environ[ENV_HOST] = bound_host
|
|
217
297
|
os.environ[ENV_PORT] = str(bound_port)
|
|
218
298
|
os.environ[ENV_LEVEL] = str(level)
|
|
219
299
|
|
|
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
300
|
new_state: _RunningState = {
|
|
247
301
|
"running": True,
|
|
248
302
|
"host": bound_host,
|
|
249
303
|
"port": bound_port,
|
|
250
304
|
"proc": proc,
|
|
251
|
-
"shutdown_event": shutdown_event,
|
|
252
|
-
"parent_alive_send": parent_alive_send,
|
|
253
305
|
}
|
|
254
306
|
_state = new_state
|
|
255
307
|
|
|
@@ -306,36 +358,42 @@ def shutdown_logging(timeout: float = 5.0) -> None:
|
|
|
306
358
|
if not _is_running(state=_state):
|
|
307
359
|
return
|
|
308
360
|
|
|
309
|
-
|
|
310
|
-
proc: BaseProcess = _state["proc"]
|
|
311
|
-
parent_alive_send: socket.socket = _state["parent_alive_send"]
|
|
361
|
+
proc: subprocess.Popen[bytes] = _state["proc"]
|
|
312
362
|
host: str = _state["host"]
|
|
313
363
|
port: int = _state["port"]
|
|
314
364
|
|
|
315
365
|
_state = _make_not_running_state()
|
|
316
366
|
|
|
367
|
+
# 关掉 stdin 的写端 = 子进程那侧 ``read()`` 立刻得到 EOF,
|
|
368
|
+
# 触发 ``server.shutdown()`` 走优雅退出路径。这是首选信号。
|
|
369
|
+
if proc.stdin is not None:
|
|
370
|
+
try:
|
|
371
|
+
proc.stdin.close()
|
|
372
|
+
except OSError:
|
|
373
|
+
pass
|
|
374
|
+
|
|
317
375
|
try:
|
|
318
|
-
|
|
319
|
-
except
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
376
|
+
proc.wait(timeout=timeout)
|
|
377
|
+
except subprocess.TimeoutExpired:
|
|
378
|
+
try:
|
|
379
|
+
proc.terminate()
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
382
|
+
try:
|
|
383
|
+
proc.wait(timeout=1.0)
|
|
384
|
+
except subprocess.TimeoutExpired:
|
|
385
|
+
pass
|
|
327
386
|
|
|
328
|
-
proc.
|
|
329
|
-
if proc.is_alive():
|
|
330
|
-
proc.terminate()
|
|
331
|
-
proc.join(1.0)
|
|
332
|
-
if proc.is_alive():
|
|
387
|
+
if proc.poll() is None:
|
|
333
388
|
# ``terminate`` 没杀掉(被 IO 阻塞在不可中断状态):兜底 kill。
|
|
334
389
|
try:
|
|
335
390
|
proc.kill()
|
|
336
|
-
except
|
|
391
|
+
except OSError:
|
|
392
|
+
pass
|
|
393
|
+
try:
|
|
394
|
+
proc.wait(timeout=1.0)
|
|
395
|
+
except subprocess.TimeoutExpired:
|
|
337
396
|
pass
|
|
338
|
-
proc.join(1.0)
|
|
339
397
|
|
|
340
398
|
# 服务进程已经死了,本进程里指向它的 SocketHandler 必须全部取下,
|
|
341
399
|
# 否则后续 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.dev3
|
|
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")` 实际拿到的是
|
|
@@ -310,50 +310,6 @@ def test_child_logger_propagates_to_kitty(tmp_path: Path):
|
|
|
310
310
|
_wait_for_lines(log_path, lambda t: "from-foo-bar" in t and "kitty.foo.bar" in t)
|
|
311
311
|
|
|
312
312
|
|
|
313
|
-
def test_server_resilient_to_module_level_getlogger_in_user_script(tmp_path: Path):
|
|
314
|
-
"""回归:用户主脚本顶层 import 的库在模块级调用 kitty_logger.getLogger(...)
|
|
315
|
-
时,服务子进程在 ``spawn`` bootstrap 阶段也会触发该模块级调用,从而把
|
|
316
|
-
服务自身的 ``kitty`` logger 挂上一个指向本服务监听端口的 SocketHandler,
|
|
317
|
-
使得接收到的 record 顺着祖先链被发回服务自身、永远不落地。
|
|
318
|
-
``run_server`` 必须在开始 serve 之前把 ``kitty`` logger 还原成无 handler、
|
|
319
|
-
propagate=True 的状态。
|
|
320
|
-
"""
|
|
321
|
-
import subprocess
|
|
322
|
-
import sys as _sys
|
|
323
|
-
|
|
324
|
-
fake_lib = tmp_path / "fake_lib.py"
|
|
325
|
-
fake_lib.write_text(
|
|
326
|
-
"import kitty_logger\n" "log = kitty_logger.getLogger(__name__)\n",
|
|
327
|
-
encoding="utf-8",
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
log_file = tmp_path / "app.log"
|
|
331
|
-
main_py = tmp_path / "main.py"
|
|
332
|
-
main_py.write_text(
|
|
333
|
-
"import sys, logging, time\n"
|
|
334
|
-
f"sys.path.insert(0, {str(tmp_path)!r})\n"
|
|
335
|
-
"from kitty_logger import setup_logging, getLogger\n"
|
|
336
|
-
"if __name__ == '__main__':\n"
|
|
337
|
-
f" setup_logging(log_file={str(log_file)!r}, stream=False, level=logging.DEBUG)\n"
|
|
338
|
-
"import fake_lib # noqa: E402,F401 -- 模块级 getLogger 触发回归路径\n"
|
|
339
|
-
"if __name__ == '__main__':\n"
|
|
340
|
-
" getLogger('user').info('real-user-log')\n"
|
|
341
|
-
" time.sleep(1)\n",
|
|
342
|
-
encoding="utf-8",
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
result = subprocess.run(
|
|
346
|
-
[_sys.executable, str(main_py)],
|
|
347
|
-
capture_output=True,
|
|
348
|
-
text=True,
|
|
349
|
-
timeout=20,
|
|
350
|
-
)
|
|
351
|
-
assert result.returncode == 0, f"stderr={result.stderr}"
|
|
352
|
-
|
|
353
|
-
text = _wait_for_lines(log_file, lambda t: "real-user-log" in t)
|
|
354
|
-
assert "kitty.user" in text
|
|
355
|
-
|
|
356
|
-
|
|
357
313
|
def _spawn_worker_calling_setup(idx: int, log_file: str) -> None:
|
|
358
314
|
"""子进程里也调一次 setup_logging:应该是 no-op,仅返回从 ENV 继承的端点。"""
|
|
359
315
|
h, p = kitty_logger.setup_logging(log_file=log_file, stream=False)
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
"""日志服务子进程实现。
|
|
2
|
-
|
|
3
|
-
工作模型:
|
|
4
|
-
|
|
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)落地。
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import logging
|
|
14
|
-
import pickle
|
|
15
|
-
import socket
|
|
16
|
-
import socketserver
|
|
17
|
-
import struct
|
|
18
|
-
import sys
|
|
19
|
-
import threading
|
|
20
|
-
from logging.handlers import TimedRotatingFileHandler
|
|
21
|
-
from multiprocessing.synchronize import Event as MpEvent
|
|
22
|
-
from typing import cast, final, override
|
|
23
|
-
|
|
24
|
-
from ._formatters import ColorFormatter, FileFormatter
|
|
25
|
-
|
|
26
|
-
# 单条 LogRecord 序列化后字节数上限。任何正常 LogRecord 都远小于此值;
|
|
27
|
-
# 超过即视为协议错位或异常流量,直接断开连接,避免一次性分配巨大缓冲区
|
|
28
|
-
# 触发 OOM。
|
|
29
|
-
_MAX_RECORD_BYTES = 16 * 1024 * 1024
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
|
|
33
|
-
"""从 ``conn`` 上读满 ``n`` 字节;对端已关闭则返回 ``None``。
|
|
34
|
-
|
|
35
|
-
单次 ``recv`` 不保证读够请求长度(TCP 半包),且对端关闭时
|
|
36
|
-
``recv`` 会立刻返回 ``b""``。任何"按长度循环 recv"的实现都必须显式
|
|
37
|
-
处理 EOF,否则在客户端中途断连时会陷入死循环。
|
|
38
|
-
"""
|
|
39
|
-
buf = bytearray()
|
|
40
|
-
while len(buf) < n:
|
|
41
|
-
try:
|
|
42
|
-
chunk = conn.recv(n - len(buf))
|
|
43
|
-
except (ConnectionError, OSError):
|
|
44
|
-
return None
|
|
45
|
-
if not chunk:
|
|
46
|
-
return None
|
|
47
|
-
buf.extend(chunk)
|
|
48
|
-
return bytes(buf)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@final
|
|
52
|
-
class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
|
|
53
|
-
"""处理一个客户端连接上的连续 LogRecord 流。
|
|
54
|
-
|
|
55
|
-
协议沿用标准库 :class:`logging.handlers.SocketHandler`:
|
|
56
|
-
``[uint32 大端长度][pickle 字节]``,可在同一连接上重复出现。
|
|
57
|
-
"""
|
|
58
|
-
|
|
59
|
-
@override
|
|
60
|
-
def handle(self) -> None:
|
|
61
|
-
conn = cast(socket.socket, self.connection)
|
|
62
|
-
while True:
|
|
63
|
-
header = _recv_exact(conn, 4)
|
|
64
|
-
if header is None:
|
|
65
|
-
break
|
|
66
|
-
slen = cast(int, struct.unpack(">L", header)[0])
|
|
67
|
-
if slen == 0 or slen > _MAX_RECORD_BYTES:
|
|
68
|
-
break
|
|
69
|
-
body = _recv_exact(conn, slen)
|
|
70
|
-
if body is None:
|
|
71
|
-
break
|
|
72
|
-
try:
|
|
73
|
-
obj = cast(object, pickle.loads(body))
|
|
74
|
-
except Exception:
|
|
75
|
-
# 单条记录损坏不应连累整条连接;继续读下一条。
|
|
76
|
-
continue
|
|
77
|
-
if not isinstance(obj, dict):
|
|
78
|
-
continue
|
|
79
|
-
record = logging.makeLogRecord(cast(dict[str, object], obj))
|
|
80
|
-
logging.getLogger(record.name).handle(record)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@final
|
|
84
|
-
class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
|
|
85
|
-
"""基于 :class:`socketserver.ThreadingTCPServer` 的接收器。
|
|
86
|
-
|
|
87
|
-
复用父进程已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
|
|
88
|
-
"""
|
|
89
|
-
|
|
90
|
-
allow_reuse_address: bool = True
|
|
91
|
-
daemon_threads: bool = True
|
|
92
|
-
|
|
93
|
-
@override
|
|
94
|
-
def __init__(self, listening_sock: socket.socket) -> None:
|
|
95
|
-
addr = cast(tuple[str, int], listening_sock.getsockname()[:2])
|
|
96
|
-
# 走父类正常流程以建立 BaseServer 内部状态(shutdown 标志等),
|
|
97
|
-
# 但通过 ``bind_and_activate=False`` 跳过它自己的 bind/listen——
|
|
98
|
-
# 这个 socket 已经由父进程绑好。
|
|
99
|
-
super().__init__(addr, _LogRecordStreamHandler, bind_and_activate=False)
|
|
100
|
-
# 父类构造时仍会 ``self.socket = socket.socket(...)`` 占坑,
|
|
101
|
-
# 这里替换成真正复用的 socket,并把临时占位关闭掉。
|
|
102
|
-
try:
|
|
103
|
-
self.socket.close()
|
|
104
|
-
except OSError:
|
|
105
|
-
pass
|
|
106
|
-
self.socket = listening_sock
|
|
107
|
-
self.server_address = addr
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _build_handlers(
|
|
111
|
-
log_file: str | None,
|
|
112
|
-
stream: bool,
|
|
113
|
-
console_fmt: str,
|
|
114
|
-
file_fmt: str,
|
|
115
|
-
datefmt: str | None,
|
|
116
|
-
) -> list[logging.Handler]:
|
|
117
|
-
"""根据用户传入的参数组合出落地用的 handler 列表。"""
|
|
118
|
-
handlers: list[logging.Handler] = []
|
|
119
|
-
if log_file:
|
|
120
|
-
# 按天轮转,每天 0 点切一份,最多保留 365 天历史。
|
|
121
|
-
fh = TimedRotatingFileHandler(
|
|
122
|
-
log_file,
|
|
123
|
-
when="midnight",
|
|
124
|
-
interval=1,
|
|
125
|
-
backupCount=365,
|
|
126
|
-
encoding="utf-8",
|
|
127
|
-
)
|
|
128
|
-
fh.setFormatter(FileFormatter(fmt=file_fmt, datefmt=datefmt))
|
|
129
|
-
handlers.append(fh)
|
|
130
|
-
if stream:
|
|
131
|
-
sh = logging.StreamHandler(stream=sys.stderr)
|
|
132
|
-
sh.setFormatter(ColorFormatter(fmt=console_fmt, datefmt=datefmt))
|
|
133
|
-
handlers.append(sh)
|
|
134
|
-
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|