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 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__ = ["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"
@@ -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 typing import override
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
- # 控制台格式:每个字段独立着色,进程信息排在最前以契合 kitty_logger 的跨进程定位。
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
- return f"{s}.{int(record.msecs):03d}"
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
- 进程名、logger 名、文件位置、函数名、级别各自拥有独立的配色。
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
- 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)落地。
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
- 复用父进程已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
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
- datefmt: str | None,
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(FileFormatter(fmt=file_fmt, datefmt=datefmt))
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(ColorFormatter(fmt=console_fmt, datefmt=datefmt))
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
- from multiprocessing.process import BaseProcess
10
- from multiprocessing.synchronize import Event as MpEvent
11
- from typing import Literal, TypedDict, TypeGuard, cast
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 DEFAULT_CONSOLE_FMT, DEFAULT_FILE_FMT
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: BaseProcess
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 _bind_ipv4_listener(
107
- host: str, port: int, backlog: int = 128
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
- 把"AF_INET ``getsockname`` 一定返回 ``tuple[str, int]``"这个静态
112
- 事实集中在这里收敛,调用方就不再需要写 ``cast``。
103
+ 协议在 :mod:`kitty_logger._server_main` 模块顶部说明。本函数把所有
104
+ ``"PORT <int>"`` 情况都收敛成 :class:`RuntimeError`:握手通道既然
105
+ 被设计成 stdout,就不允许除握手以外的任何字节出现。
113
106
  """
114
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
116
- sock.bind((host, port))
117
- sock.listen(backlog)
118
- bound_host, bound_port = cast(tuple[str, int], sock.getsockname())
119
- return sock, bound_host, bound_port
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
- console_fmt: str = DEFAULT_CONSOLE_FMT,
129
- file_fmt: str = DEFAULT_FILE_FMT,
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
- 在一个独立的 ``spawn`` 子进程里运行 TCP 接收器,把所有
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 console_fmt: 控制台 handler 的 format 字符串。
163
- :param file_fmt: 文件 handler 的 format 字符串。
164
- :param datefmt: 时间戳的 ``strftime`` 格式;``None`` 走默认值。
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`` 启动出来的子进程里再次执行 setup_logging(典型
181
- # 场景:spawn bootstrap 通过 ``_fixup_main_from_path`` 重新 import 主
182
- # 脚本,从而触发主脚本顶层那行 ``kitty_logger.setup_logging(...)``)
183
- # 一律走 no-op:日志服务只该在原始主进程里跑一份;子进程通过继承的
184
- # ENV 找服务地址,``getLogger`` 第一次被调用时再按需挂 SocketHandler。
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 在子进程里被调用 setup_logging,但没有从父进程"
191
- "继承到 KITTY_LOGGER_HOST / KITTY_LOGGER_PORT 环境变量。"
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
- # 在父进程里直接 bind,使 ``port=0`` 也能同步拿到真实端口;然后把
203
- # 已绑定的 socket 交给服务子进程使用。
204
- listening_sock, bound_host, bound_port = _bind_ipv4_listener(host, port)
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
- # 父进程存活监测:父进程持有 send 端,子进程持有 recv 端。父进程哪怕
207
- # 被 ``kill -9`` 也会让内核 close 它的 fd,子进程在另一端会读到 EOF。
208
- parent_alive_send, parent_alive_recv = socket.socketpair()
315
+ bound_host = host
209
316
 
210
- # ENV 必须在 ``proc.start()`` 之前写入:spawn 出的服务子进程虽然通过
211
- # kwargs 拿到了所有运行参数,但它在 bootstrap 阶段会用
212
- # ``_fixup_main_from_path`` 重新 import 主脚本,主脚本顶层若间接触发
213
- # ``kitty_logger.getLogger(...)``(例如某个被顶层 import 的库在模块级
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
- event: MpEvent = _state["shutdown_event"]
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
- event.set()
319
- except Exception:
320
- pass
321
- # 主动关闭父端 socket,作为 mp.Event 的兜底关闭信号——子进程的
322
- # ``_watch_parent_alive`` 线程会立刻读到 EOF。
323
- try:
324
- parent_alive_send.close()
325
- except OSError:
326
- pass
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.join(timeout)
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 Exception:
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.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")` 实际拿到的是
@@ -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,,