kitty-logger 0.2.0.dev0__tar.gz → 0.2.0.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {kitty_logger-0.2.0.dev0/src/kitty_logger.egg-info → kitty_logger-0.2.0.dev2}/PKG-INFO +17 -8
  2. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/README.md +16 -7
  3. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/pyproject.toml +1 -1
  4. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger/__init__.py +6 -1
  5. kitty_logger-0.2.0.dev2/src/kitty_logger/_client.py +112 -0
  6. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger/_server.py +22 -0
  7. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger/_setup.py +48 -22
  8. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2/src/kitty_logger.egg-info}/PKG-INFO +17 -8
  9. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/tests/test_kitty_logger.py +125 -33
  10. kitty_logger-0.2.0.dev0/src/kitty_logger/_client.py +0 -102
  11. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/LICENSE +0 -0
  12. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/setup.cfg +0 -0
  13. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger/_env.py +0 -0
  14. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger/_formatters.py +0 -0
  15. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger.egg-info/SOURCES.txt +0 -0
  16. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger.egg-info/dependency_links.txt +0 -0
  17. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/src/kitty_logger.egg-info/requires.txt +0 -0
  18. {kitty_logger-0.2.0.dev0 → kitty_logger-0.2.0.dev2}/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.dev0
3
+ Version: 0.2.0.dev2
4
4
  Summary: Cross-process logging via a dedicated log server process and SocketHandler.
5
5
  Author: Kitty
6
6
  License: MIT
@@ -54,12 +54,20 @@ if __name__ == "__main__":
54
54
 
55
55
  ## API
56
56
 
57
- - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None, attach_main_logger=True) -> (host, port)`
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
58
  启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
59
59
  `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
60
60
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
61
+ 同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
62
+ `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
63
+ 在 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
64
+ 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
+ `if __name__ == "__main__":` 包裹。
61
66
  - `getLogger(name=None) -> logging.Logger`
62
- 返回一个挂好 `SocketHandler`、指向日志服务的 logger
67
+ 返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
68
+ `logging.getLogger("kitty.foo")`,`getLogger()` 拿到的是 `kitty` 本身。
69
+ 整个进程内只有 `kitty` 持有 `SocketHandler`,子 logger 通过标准库的祖先链
70
+ 冒泡上来,无需重复挂载。
63
71
  - `shutdown_logging()` — 显式停止日志服务子进程。
64
72
 
65
73
  ## 为什么只支持 spawn
@@ -74,11 +82,12 @@ if __name__ == "__main__":
74
82
 
75
83
  ## 注意事项
76
84
 
77
- - **不要**在 `setup_logging(attach_main_logger=True)` 之前调用
78
- `logging.basicConfig()`(或自行给 root logger 挂 `StreamHandler`)——
79
- 否则主进程会把每条记录输出两次:一次走本地 root handler,一次走日志
80
- 服务。要么让 kitty_logger 做唯一的配置入口,要么传
81
- `attach_main_logger=False` 自行管理主进程的 handler。
85
+ - **所有 logger 名都带 `kitty.` 前缀**,这是有意保留的标记:日志记录里
86
+ `%(name)s` 字段一眼可以和第三方库(urllib3、httpx 等)的输出区分。
87
+ `getLogger(__name__)` 在模块 `myapp.svc` 下实际得到的是 `kitty.myapp.svc`。
88
+ - `kitty` 子树**不向 root 冒泡**:第三方库挂在 root 上的 handler 不会收到
89
+ kitty_logger 的日志,反之 root 上输出的日志也不会被 kitty_logger 转发。
90
+ 两条输出渠道互不干扰。
82
91
  - `shutdown_logging()` 会卸载本进程内 kitty_logger 自己挂的
83
92
  `SocketHandler`,并清理 `KITTY_LOGGER_*` 环境变量,确保进程状态与
84
93
  `setup_logging` 对称。如果你额外挂了别的 handler,仍由你自己负责清理。
@@ -41,12 +41,20 @@ if __name__ == "__main__":
41
41
 
42
42
  ## API
43
43
 
44
- - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None, attach_main_logger=True) -> (host, port)`
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
45
  启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
46
46
  `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
47
47
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
48
+ 同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
49
+ `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
50
+ 在 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
51
+ 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
52
+ `if __name__ == "__main__":` 包裹。
48
53
  - `getLogger(name=None) -> logging.Logger`
49
- 返回一个挂好 `SocketHandler`、指向日志服务的 logger
54
+ 返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
55
+ `logging.getLogger("kitty.foo")`,`getLogger()` 拿到的是 `kitty` 本身。
56
+ 整个进程内只有 `kitty` 持有 `SocketHandler`,子 logger 通过标准库的祖先链
57
+ 冒泡上来,无需重复挂载。
50
58
  - `shutdown_logging()` — 显式停止日志服务子进程。
51
59
 
52
60
  ## 为什么只支持 spawn
@@ -61,11 +69,12 @@ if __name__ == "__main__":
61
69
 
62
70
  ## 注意事项
63
71
 
64
- - **不要**在 `setup_logging(attach_main_logger=True)` 之前调用
65
- `logging.basicConfig()`(或自行给 root logger 挂 `StreamHandler`)——
66
- 否则主进程会把每条记录输出两次:一次走本地 root handler,一次走日志
67
- 服务。要么让 kitty_logger 做唯一的配置入口,要么传
68
- `attach_main_logger=False` 自行管理主进程的 handler。
72
+ - **所有 logger 名都带 `kitty.` 前缀**,这是有意保留的标记:日志记录里
73
+ `%(name)s` 字段一眼可以和第三方库(urllib3、httpx 等)的输出区分。
74
+ `getLogger(__name__)` 在模块 `myapp.svc` 下实际得到的是 `kitty.myapp.svc`。
75
+ - `kitty` 子树**不向 root 冒泡**:第三方库挂在 root 上的 handler 不会收到
76
+ kitty_logger 的日志,反之 root 上输出的日志也不会被 kitty_logger 转发。
77
+ 两条输出渠道互不干扰。
69
78
  - `shutdown_logging()` 会卸载本进程内 kitty_logger 自己挂的
70
79
  `SocketHandler`,并清理 `KITTY_LOGGER_*` 环境变量,确保进程状态与
71
80
  `setup_logging` 对称。如果你额外挂了别的 handler,仍由你自己负责清理。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitty_logger"
7
- version = "0.2.0.dev0"
7
+ version = "0.2.0.dev2"
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"
@@ -6,6 +6,11 @@
6
6
  日志收敛到主进程指定的文件 / ``stderr`` 中,不会因多进程并发写文件而
7
7
  错行或丢失。
8
8
 
9
+ 命名空间:所有由本库返回的 logger 都挂在 ``kitty`` 这棵子树下。
10
+ ``getLogger("foo")`` 实际拿到的是 ``logging.getLogger("kitty.foo")``,
11
+ 日志记录里 ``%(name)s`` 会带 ``kitty.`` 前缀,便于和第三方库的日志区分。
12
+ ``kitty`` 子树独立运行,不会向 root 冒泡,也不会被 root 上的 handler 干扰。
13
+
9
14
  典型用法::
10
15
 
11
16
  # main.py
@@ -28,4 +33,4 @@ from ._client import getLogger
28
33
  from ._setup import setup_logging, shutdown_logging
29
34
 
30
35
  __all__ = ["setup_logging", "shutdown_logging", "getLogger"]
31
- __version__ = "0.2.0.dev0"
36
+ __version__ = "0.2.0.dev2"
@@ -0,0 +1,112 @@
1
+ """子进程侧 API::func:`getLogger`。
2
+
3
+ 子进程通过环境变量
4
+ ``KITTY_LOGGER_HOST`` / ``KITTY_LOGGER_PORT`` / ``KITTY_LOGGER_LEVEL``
5
+ 找到日志服务地址;这些环境变量是父进程在 :func:`setup_logging` 时写入的,
6
+ ``spawn`` 出来的后代进程会自动继承。
7
+
8
+ 命名空间约定
9
+ ------------
10
+
11
+ 所有由本库返回的 logger 都挂在 ``kitty`` 这棵子树下:
12
+
13
+ - ``getLogger()`` → ``logging.getLogger("kitty")``
14
+ - ``getLogger("foo.bar")`` → ``logging.getLogger("kitty.foo.bar")``
15
+
16
+ 整个进程内只有 ``kitty`` 这一个 logger 持有指向日志服务的
17
+ :class:`SocketHandler`,子 logger 通过标准库的祖先链冒泡上来。
18
+ ``kitty`` 自身的 ``propagate`` 被关闭,与 root 上的第三方库日志互不干扰。
19
+ """
20
+
21
+ import logging
22
+ import logging.handlers
23
+ import os
24
+ import threading
25
+
26
+ from ._env import ENV_HOST, ENV_LEVEL, ENV_PORT
27
+
28
+ # 本库使用的 logger 命名空间根。所有用户拿到的 logger 都在这棵子树下。
29
+ KITTY_NAMESPACE = "kitty"
30
+
31
+ # 保护 ``kitty`` logger 上的 handler 初始化。
32
+ _lock = threading.Lock()
33
+
34
+
35
+ def _read_endpoint() -> tuple[str, int]:
36
+ """从环境变量读出 ``(host, port)``;未设置则抛出友好错误。"""
37
+ host = os.environ.get(ENV_HOST)
38
+ port = os.environ.get(ENV_PORT)
39
+ if not host or not port:
40
+ raise RuntimeError(
41
+ "kitty_logger 尚未在当前进程树中初始化。"
42
+ + "请先在主进程调用 kitty_logger.setup_logging(),"
43
+ + "再创建(spawn)子进程或调用 getLogger()。"
44
+ )
45
+ return host, int(port)
46
+
47
+
48
+ def _read_level() -> int:
49
+ """从环境变量解析默认 level。优先按整数解析,失败则按级别名解析。"""
50
+ raw = os.environ.get(ENV_LEVEL)
51
+ if raw is None:
52
+ return logging.INFO
53
+ try:
54
+ return int(raw)
55
+ except ValueError:
56
+ pass
57
+ return logging.getLevelNamesMapping().get(raw.upper(), logging.INFO)
58
+
59
+
60
+ def _ensure_kitty_handler() -> logging.Logger:
61
+ """保证本进程内 ``kitty`` logger 已挂上指向日志服务的 SocketHandler。
62
+
63
+ 幂等:已挂过则直接返回;``spawn`` 出来的全新解释器里第一次调用时
64
+ 才会真正去读 ENV、建 SocketHandler。
65
+
66
+ .. note::
67
+ :class:`logging.handlers.SocketHandler` 直接 pickle ``LogRecord``
68
+ 发送,由接收端格式化;在这里给 handler 设置 ``formatter`` 没有
69
+ 任何效果,会被服务端忽略。
70
+ """
71
+ SocketHandler = logging.handlers.SocketHandler
72
+ kitty = logging.getLogger(KITTY_NAMESPACE)
73
+
74
+ with _lock:
75
+ for h in kitty.handlers:
76
+ if isinstance(h, SocketHandler):
77
+ return kitty
78
+
79
+ host, port = _read_endpoint()
80
+ level = _read_level()
81
+
82
+ kitty.addHandler(SocketHandler(host, port))
83
+
84
+ # 仅在仍是默认 ``NOTSET`` 时才设置 level,避免悄悄覆盖
85
+ # 用户已经显式设置过的级别。
86
+ if kitty.level == logging.NOTSET:
87
+ kitty.setLevel(level)
88
+
89
+ # ``kitty`` 子树自成一系,不向 root 冒泡:这样和第三方库挂在 root
90
+ # 上的 handler 互不串扰;用户控制 root 的输出渠道时也不会无意中
91
+ # 把 kitty_logger 的日志带过去(反之亦然)。
92
+ kitty.propagate = False
93
+
94
+ return kitty
95
+
96
+
97
+ def getLogger(name: str | None = None) -> logging.Logger:
98
+ """返回挂在 ``kitty`` 命名空间下的 logger,自动连到日志服务。
99
+
100
+ 必须在父进程已经调用过 :func:`kitty_logger.setup_logging` 之后才能
101
+ 使用。本库只支持 ``spawn`` 启动方式;spawn 出来的子进程模块级状态
102
+ 天然为空,第一次调用时会按需完成初始化。
103
+
104
+ :param name: 业务名;``None`` 表示直接返回 ``kitty`` 本身。``"foo"``
105
+ 会被转换为 ``logging.getLogger("kitty.foo")``——日志记录里
106
+ ``%(name)s`` 字段会带 ``kitty.`` 前缀,这是有意保留的标记,方便
107
+ 和第三方库日志区分。
108
+ :raises RuntimeError: 当前进程树尚未调用 ``setup_logging``。
109
+ """
110
+ _ensure_kitty_handler()
111
+ full = KITTY_NAMESPACE if not name else f"{KITTY_NAMESPACE}.{name}"
112
+ return logging.getLogger(full)
@@ -169,6 +169,28 @@ def run_server(
169
169
  root.addHandler(h)
170
170
  root.setLevel(level)
171
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
+
172
194
  server = _LogRecordSocketReceiver(listening_sock)
173
195
 
174
196
  def _wait_shutdown() -> None:
@@ -128,7 +128,6 @@ def setup_logging(
128
128
  console_fmt: str = DEFAULT_CONSOLE_FMT,
129
129
  file_fmt: str = DEFAULT_FILE_FMT,
130
130
  datefmt: str | None = None,
131
- attach_main_logger: bool = True,
132
131
  ) -> tuple[str, int]:
133
132
  """在主进程里启动跨进程日志服务,返回服务真实绑定到的 ``(host, port)``。
134
133
 
@@ -142,9 +141,20 @@ def setup_logging(
142
141
  无颜色的 :class:`FileFormatter`,两者均使用毫秒级时间戳。
143
142
 
144
143
  幂等:重复调用直接返回已记录的 ``(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 上,无需自己额外配置。
145
154
 
146
155
  :param log_file: 日志文件路径;``None`` 表示不落盘。
147
- :param level: 服务端 root logger 的级别。
156
+ :param level: 服务端 root logger 的级别,同时也是主进程 ``kitty``
157
+ logger 的初始 level(仅在它仍是 ``NOTSET`` 时设置)。
148
158
  :param host: 监听地址,默认 ``127.0.0.1``。**必须是 loopback**:
149
159
  绑定非 loopback 地址会直接 :class:`ValueError`。
150
160
  :param port: 监听端口;``0`` 让 OS 自动选择空闲端口。
@@ -152,8 +162,6 @@ def setup_logging(
152
162
  :param console_fmt: 控制台 handler 的 format 字符串。
153
163
  :param file_fmt: 文件 handler 的 format 字符串。
154
164
  :param datefmt: 时间戳的 ``strftime`` 格式;``None`` 走默认值。
155
- :param attach_main_logger: 是否给主进程的 root logger 也挂一个
156
- :class:`SocketHandler`,让主进程自身的日志走同一个服务。
157
165
  :return: 服务真实绑定到的 ``(host, port)``。
158
166
 
159
167
  .. note::
@@ -169,6 +177,22 @@ def setup_logging(
169
177
  请使用 ``multiprocessing.get_context("spawn")``,或在顶层用
170
178
  ``if __name__ == "__main__":`` 守卫配合默认 ``spawn`` 行为。
171
179
  """
180
+ # ``multiprocessing`` 启动出来的子进程里再次执行 setup_logging(典型
181
+ # 场景:spawn bootstrap 通过 ``_fixup_main_from_path`` 重新 import 主
182
+ # 脚本,从而触发主脚本顶层那行 ``kitty_logger.setup_logging(...)``)
183
+ # 一律走 no-op:日志服务只该在原始主进程里跑一份;子进程通过继承的
184
+ # ENV 找服务地址,``getLogger`` 第一次被调用时再按需挂 SocketHandler。
185
+ if mp.parent_process() is not None:
186
+ host_env = os.environ.get(ENV_HOST)
187
+ port_env = os.environ.get(ENV_PORT)
188
+ if not host_env or not port_env:
189
+ raise RuntimeError(
190
+ "kitty_logger 在子进程里被调用 setup_logging,但没有从父进程"
191
+ "继承到 KITTY_LOGGER_HOST / KITTY_LOGGER_PORT 环境变量。"
192
+ "请确保父进程已经先调用 setup_logging() 后再 spawn 本进程。"
193
+ )
194
+ return host_env, int(port_env)
195
+
172
196
  global _state
173
197
  if _is_running(state=_state):
174
198
  return _state["host"], _state["port"]
@@ -183,6 +207,16 @@ def setup_logging(
183
207
  # 被 ``kill -9`` 也会让内核 close 它的 fd,子进程在另一端会读到 EOF。
184
208
  parent_alive_send, parent_alive_recv = socket.socketpair()
185
209
 
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 而异常退出。
216
+ os.environ[ENV_HOST] = bound_host
217
+ os.environ[ENV_PORT] = str(bound_port)
218
+ os.environ[ENV_LEVEL] = str(level)
219
+
186
220
  ctx = mp.get_context("spawn")
187
221
  shutdown_event = ctx.Event()
188
222
  proc = ctx.Process(
@@ -209,13 +243,6 @@ def setup_logging(
209
243
  listening_sock.close()
210
244
  parent_alive_recv.close()
211
245
 
212
- # ENV 是给"日后由本进程 spawn 出来的工作子进程"用的——它们靠 ENV
213
- # 找到服务地址。服务子进程本身已经通过 kwargs 拿到所有参数,因此在
214
- # ``proc.start()`` 之后再写 ENV 没有竞态问题。
215
- os.environ[ENV_HOST] = bound_host
216
- os.environ[ENV_PORT] = str(bound_port)
217
- os.environ[ENV_LEVEL] = str(level)
218
-
219
246
  new_state: _RunningState = {
220
247
  "running": True,
221
248
  "host": bound_host,
@@ -226,11 +253,12 @@ def setup_logging(
226
253
  }
227
254
  _state = new_state
228
255
 
229
- if attach_main_logger:
230
- # 让主进程自身的日志也走同一个服务,避免输出渠道分裂。
231
- from ._client import _attach_socket_handler
256
+ # 主进程也要把 ``kitty`` logger 初始化好——挂上 SocketHandler,让
257
+ # 主进程里 ``kitty_logger.getLogger(...)`` 拿到的子 logger 直接可用,
258
+ # 不需要再额外配置。
259
+ from ._client import _ensure_kitty_handler
232
260
 
233
- _attach_socket_handler(logging.getLogger(), bound_host, bound_port, level)
261
+ _ensure_kitty_handler()
234
262
 
235
263
  _ = atexit.register(shutdown_logging)
236
264
  return bound_host, bound_port
@@ -244,6 +272,10 @@ def _detach_all_socket_handlers(host: str, port: int) -> None:
244
272
  (:mod:`logging` 静默吞掉 socket 错误,外部表现为日志凭空消失)。
245
273
  在 shutdown 时主动卸载,保证状态对称——``setup_logging`` 装上去什么,
246
274
  ``shutdown_logging`` 全部取下来。
275
+
276
+ 新架构下整个进程理论上只有 ``kitty`` logger 持有 SocketHandler,但
277
+ 仍按"扫所有 logger"的方式做兜底,便于早期版本里残留的 handler 也
278
+ 能在 shutdown 时被清理掉。
247
279
  """
248
280
  from logging.handlers import SocketHandler
249
281
 
@@ -309,12 +341,6 @@ def shutdown_logging(timeout: float = 5.0) -> None:
309
341
  # 否则后续 logging 调用会向一个失效端口发送,触发静默丢日志。
310
342
  _detach_all_socket_handlers(host, port)
311
343
 
312
- # 同步清理 _client 的 dedup 集合与 ENV,让"二次 setup"能真正回到
313
- # 干净状态。
314
- from . import _client
315
-
316
- with _client._lock:
317
- _client._configured_loggers.clear()
318
-
344
+ # 清掉环境变量,让"二次 setup"能真正回到干净状态。
319
345
  for k in (ENV_HOST, ENV_PORT, ENV_LEVEL):
320
346
  os.environ.pop(k, None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitty_logger
3
- Version: 0.2.0.dev0
3
+ Version: 0.2.0.dev2
4
4
  Summary: Cross-process logging via a dedicated log server process and SocketHandler.
5
5
  Author: Kitty
6
6
  License: MIT
@@ -54,12 +54,20 @@ if __name__ == "__main__":
54
54
 
55
55
  ## API
56
56
 
57
- - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None, attach_main_logger=True) -> (host, port)`
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
58
  启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
59
59
  `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
60
60
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
61
+ 同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
62
+ `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
63
+ 在 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
64
+ 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
+ `if __name__ == "__main__":` 包裹。
61
66
  - `getLogger(name=None) -> logging.Logger`
62
- 返回一个挂好 `SocketHandler`、指向日志服务的 logger
67
+ 返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
68
+ `logging.getLogger("kitty.foo")`,`getLogger()` 拿到的是 `kitty` 本身。
69
+ 整个进程内只有 `kitty` 持有 `SocketHandler`,子 logger 通过标准库的祖先链
70
+ 冒泡上来,无需重复挂载。
63
71
  - `shutdown_logging()` — 显式停止日志服务子进程。
64
72
 
65
73
  ## 为什么只支持 spawn
@@ -74,11 +82,12 @@ if __name__ == "__main__":
74
82
 
75
83
  ## 注意事项
76
84
 
77
- - **不要**在 `setup_logging(attach_main_logger=True)` 之前调用
78
- `logging.basicConfig()`(或自行给 root logger 挂 `StreamHandler`)——
79
- 否则主进程会把每条记录输出两次:一次走本地 root handler,一次走日志
80
- 服务。要么让 kitty_logger 做唯一的配置入口,要么传
81
- `attach_main_logger=False` 自行管理主进程的 handler。
85
+ - **所有 logger 名都带 `kitty.` 前缀**,这是有意保留的标记:日志记录里
86
+ `%(name)s` 字段一眼可以和第三方库(urllib3、httpx 等)的输出区分。
87
+ `getLogger(__name__)` 在模块 `myapp.svc` 下实际得到的是 `kitty.myapp.svc`。
88
+ - `kitty` 子树**不向 root 冒泡**:第三方库挂在 root 上的 handler 不会收到
89
+ kitty_logger 的日志,反之 root 上输出的日志也不会被 kitty_logger 转发。
90
+ 两条输出渠道互不干扰。
82
91
  - `shutdown_logging()` 会卸载本进程内 kitty_logger 自己挂的
83
92
  `SocketHandler`,并清理 `KITTY_LOGGER_*` 环境变量,确保进程状态与
84
93
  `setup_logging` 对称。如果你额外挂了别的 handler,仍由你自己负责清理。
@@ -4,6 +4,7 @@
4
4
  - 半包 / 中途断连不会导致服务端死循环或丢失后续记录。
5
5
  - 重复调用 ``setup_logging`` / ``shutdown_logging`` 是幂等的。
6
6
  - ``setup_logging`` 绑定非 loopback 地址会直接 ``ValueError``。
7
+ - 新架构:``kitty`` 子树的 ``propagate`` 行为、子 logger 自动冒泡。
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -43,15 +44,14 @@ def _spawn_worker(idx: int) -> None:
43
44
  @pytest.fixture(autouse=True)
44
45
  def _isolate_state(monkeypatch):
45
46
  """确保每个用例都从干净的全局状态出发。"""
46
- # setup_logging 是模块级单例,跑完后必须 shutdown 干净。
47
47
  yield
48
48
  try:
49
49
  kitty_logger.shutdown_logging()
50
50
  except Exception:
51
51
  pass
52
- # 重置 _client 模块状态,避免集合泄漏到下一个用例。
53
- _client._configured_loggers = set()
54
- # 清掉测试期间挂上去的 SocketHandler。
52
+ # 清掉测试期间挂上去的 SocketHandler,并把 ``kitty`` 子树相关 logger
53
+ # 的状态(level / propagate / handlers)复位到默认值——避免前一个用例
54
+ # 残留的 ``propagate=False`` 把后一个用例的日志吞掉。
55
55
  for lg in [logging.getLogger()] + [
56
56
  obj
57
57
  for obj in logging.Logger.manager.loggerDict.values()
@@ -60,6 +60,9 @@ def _isolate_state(monkeypatch):
60
60
  for h in list(lg.handlers):
61
61
  if isinstance(h, logging.handlers.SocketHandler):
62
62
  lg.removeHandler(h)
63
+ if lg.name == "kitty" or lg.name.startswith("kitty."):
64
+ lg.setLevel(logging.NOTSET)
65
+ lg.propagate = True
63
66
  # 清环境变量。
64
67
  for k in ("KITTY_LOGGER_HOST", "KITTY_LOGGER_PORT", "KITTY_LOGGER_LEVEL"):
65
68
  os.environ.pop(k, None)
@@ -89,6 +92,9 @@ def test_end_to_end_spawn(tmp_path: Path):
89
92
  )
90
93
  # 进程名应当出现在每行里,便于跨进程定位。
91
94
  assert "MainProcess" in text and "SpawnProcess" in text
95
+ # logger 名都带 ``kitty.`` 前缀。
96
+ assert "kitty.main" in text
97
+ assert "kitty.worker.0" in text
92
98
 
93
99
 
94
100
  def test_setup_logging_idempotent(tmp_path: Path):
@@ -215,32 +221,46 @@ def test_level_env_accepts_name(tmp_path: Path):
215
221
  assert _client._read_level() == logging.INFO
216
222
 
217
223
 
224
+ def test_setup_attaches_handler_to_kitty_only(tmp_path: Path):
225
+ """setup_logging 只动 ``kitty`` logger,不去碰 root。"""
226
+ root = logging.getLogger()
227
+ before = list(root.handlers)
228
+ kitty_logger.setup_logging(
229
+ log_file=str(tmp_path / "app.log"),
230
+ stream=False,
231
+ )
232
+ after = list(root.handlers)
233
+ assert after == before, "setup_logging 不应改动 root logger 的 handlers"
234
+
235
+ kitty = logging.getLogger("kitty")
236
+ sock_handlers = [
237
+ h for h in kitty.handlers if isinstance(h, logging.handlers.SocketHandler)
238
+ ]
239
+ assert len(sock_handlers) == 1, "kitty logger 应当恰好挂一个 SocketHandler"
240
+ assert kitty.propagate is False, "kitty 不应向 root 冒泡"
241
+
242
+
218
243
  def test_shutdown_cleans_state(tmp_path: Path):
219
- """shutdown 后:root 上的 SocketHandler 被卸载、ENV 被清、_configured_loggers 清空。"""
244
+ """shutdown 后:kitty 上的 SocketHandler 被卸载、ENV 被清。"""
220
245
  log_path = tmp_path / "app.log"
221
246
  kitty_logger.setup_logging(log_file=str(log_path), stream=False)
222
247
  _ = kitty_logger.getLogger("svc.a")
223
248
 
249
+ kitty = logging.getLogger("kitty")
224
250
  assert os.environ.get("KITTY_LOGGER_HOST")
225
- assert any(
226
- isinstance(h, logging.handlers.SocketHandler)
227
- for h in logging.getLogger().handlers
228
- )
229
- assert _client._configured_loggers # 至少包含 "svc.a"
251
+ assert any(isinstance(h, logging.handlers.SocketHandler) for h in kitty.handlers)
230
252
 
231
253
  kitty_logger.shutdown_logging()
232
254
 
233
255
  assert not any(
234
- isinstance(h, logging.handlers.SocketHandler)
235
- for h in logging.getLogger().handlers
256
+ isinstance(h, logging.handlers.SocketHandler) for h in kitty.handlers
236
257
  )
237
258
  for k in ("KITTY_LOGGER_HOST", "KITTY_LOGGER_PORT", "KITTY_LOGGER_LEVEL"):
238
259
  assert k not in os.environ
239
- assert not _client._configured_loggers
240
260
 
241
261
 
242
262
  def test_setup_after_shutdown_uses_new_port(tmp_path: Path):
243
- """shutdown 之后再 setup,新端口必须真正生效(旧 dedup 不能阻止重新挂 handler)。"""
263
+ """shutdown 之后再 setup,新端口必须真正生效。"""
244
264
  log_path = tmp_path / "app.log"
245
265
  h1, p1 = kitty_logger.setup_logging(log_file=str(log_path), stream=False)
246
266
  _ = kitty_logger.getLogger("svc.b")
@@ -251,37 +271,109 @@ def test_setup_after_shutdown_uses_new_port(tmp_path: Path):
251
271
  log = kitty_logger.getLogger("svc.b")
252
272
  log.info("after-restart")
253
273
 
254
- # logger SocketHandler 必须指向新端口,不能还指向旧端口。
274
+ # ``kitty`` 上的 SocketHandler 必须指向新端口。
275
+ kitty = logging.getLogger("kitty")
255
276
  sock_handlers = [
256
- h for h in log.handlers if isinstance(h, logging.handlers.SocketHandler)
277
+ h for h in kitty.handlers if isinstance(h, logging.handlers.SocketHandler)
257
278
  ]
258
- assert sock_handlers, "expected a SocketHandler attached to svc.b after restart"
279
+ assert sock_handlers, "expected a SocketHandler attached to kitty after restart"
259
280
  assert all(h.port == p2 for h in sock_handlers)
260
281
  assert p1 != p2 or h1 == h2 # 端口可能被 OS 复用,只要 handler 指向当前端口即可
261
282
 
262
283
  _wait_for_lines(log_path2, lambda t: "after-restart" in t)
263
284
 
264
285
 
265
- def test_attach_main_logger_false_leaves_root_alone(tmp_path: Path):
266
- root = logging.getLogger()
267
- before = list(root.handlers)
286
+ def test_attach_does_not_override_explicit_level(tmp_path: Path):
287
+ """用户已显式给 ``kitty`` 设置过 level,setup_logging 不应悄悄覆盖。"""
288
+ kitty = logging.getLogger("kitty")
289
+ kitty.setLevel(logging.DEBUG)
268
290
  kitty_logger.setup_logging(
269
291
  log_file=str(tmp_path / "app.log"),
292
+ level=logging.WARNING,
270
293
  stream=False,
271
- attach_main_logger=False,
272
294
  )
273
- after = list(root.handlers)
274
- assert after == before, "attach_main_logger=False 不应改动 root logger 的 handlers"
295
+ assert kitty.level == logging.DEBUG
275
296
 
276
297
 
277
- def test_attach_does_not_override_explicit_level(tmp_path: Path):
278
- """用户已显式设置的 level 不应被悄悄覆盖。"""
279
- custom = logging.getLogger("explicit-level-user")
280
- custom.setLevel(logging.DEBUG)
281
- kitty_logger.setup_logging(
282
- log_file=str(tmp_path / "app.log"),
283
- level=logging.WARNING,
284
- stream=False,
298
+ def test_child_logger_propagates_to_kitty(tmp_path: Path):
299
+ """``getLogger("foo")`` 返回的 logger 名是 ``kitty.foo``,且会冒泡到 ``kitty``。"""
300
+ log_path = tmp_path / "app.log"
301
+ kitty_logger.setup_logging(log_file=str(log_path), stream=False)
302
+
303
+ log = kitty_logger.getLogger("foo.bar")
304
+ assert log.name == "kitty.foo.bar"
305
+ # 子 logger 自身没挂 SocketHandler——它靠 propagate 冒到 kitty。
306
+ assert not any(isinstance(h, logging.handlers.SocketHandler) for h in log.handlers)
307
+ assert log.propagate is True
308
+
309
+ log.info("from-foo-bar")
310
+ _wait_for_lines(log_path, lambda t: "from-foo-bar" in t and "kitty.foo.bar" in t)
311
+
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
+ def _spawn_worker_calling_setup(idx: int, log_file: str) -> None:
358
+ """子进程里也调一次 setup_logging:应该是 no-op,仅返回从 ENV 继承的端点。"""
359
+ h, p = kitty_logger.setup_logging(log_file=log_file, stream=False)
360
+ log = kitty_logger.getLogger(f"child.{idx}")
361
+ log.info("child says host=%s port=%s", h, p)
362
+
363
+
364
+ def test_setup_logging_in_child_is_noop(tmp_path: Path):
365
+ """子进程里调用 setup_logging() 不应再起监听器,仅返回从 ENV 继承的端点。"""
366
+ log_path = tmp_path / "app.log"
367
+ host, port = kitty_logger.setup_logging(log_file=str(log_path), stream=False)
368
+
369
+ ctx = mp.get_context("spawn")
370
+ p = ctx.Process(target=_spawn_worker_calling_setup, args=(0, str(log_path)))
371
+ p.start()
372
+ p.join(timeout=10)
373
+ assert p.exitcode == 0
374
+
375
+ text = _wait_for_lines(
376
+ log_path,
377
+ lambda t: f"host={host} port={port}" in t and "kitty.child.0" in t,
285
378
  )
286
- _ = kitty_logger.getLogger("explicit-level-user")
287
- assert custom.level == logging.DEBUG
379
+ assert "SpawnProcess" in text
@@ -1,102 +0,0 @@
1
- """子进程侧 API::func:`getLogger`。
2
-
3
- 子进程通过环境变量
4
- ``KITTY_LOGGER_HOST`` / ``KITTY_LOGGER_PORT`` / ``KITTY_LOGGER_LEVEL``
5
- 找到日志服务地址;这些环境变量是父进程在 :func:`setup_logging` 时写入的,
6
- ``spawn`` 出来的后代进程会自动继承。
7
- """
8
-
9
- import logging
10
- import logging.handlers
11
- import os
12
- import threading
13
-
14
- from ._env import ENV_HOST, ENV_LEVEL, ENV_PORT
15
-
16
- # 保护下面这个模块级集合的并发访问。
17
- _lock = threading.Lock()
18
-
19
- # 已挂过 SocketHandler 的 logger 名集合,用于在同一进程内做去重。
20
- # root logger 在集合里以空字符串 ``""`` 作为 key。
21
- _configured_loggers: set[str] = set()
22
-
23
-
24
- def _read_endpoint() -> tuple[str, int]:
25
- """从环境变量读出 ``(host, port)``;未设置则抛出友好错误。"""
26
- host = os.environ.get(ENV_HOST)
27
- port = os.environ.get(ENV_PORT)
28
- if not host or not port:
29
- raise RuntimeError(
30
- "kitty_logger 尚未在当前进程树中初始化。"
31
- + "请先在主进程调用 kitty_logger.setup_logging(),"
32
- + "再创建(spawn)子进程或调用 getLogger()。"
33
- )
34
- return host, int(port)
35
-
36
-
37
- def _read_level() -> int:
38
- """从环境变量解析默认 level。优先按整数解析,失败则按级别名解析。"""
39
- raw = os.environ.get(ENV_LEVEL)
40
- if raw is None:
41
- return logging.INFO
42
- try:
43
- return int(raw)
44
- except ValueError:
45
- pass
46
- return logging.getLevelNamesMapping().get(raw.upper(), logging.INFO)
47
-
48
-
49
- def _attach_socket_handler(
50
- logger: logging.Logger,
51
- host: str,
52
- port: int,
53
- level: int,
54
- ) -> None:
55
- """给 ``logger`` 挂上指向日志服务的 :class:`SocketHandler`。
56
-
57
- .. note::
58
- :class:`logging.handlers.SocketHandler` 直接 pickle ``LogRecord``
59
- 发送,由接收端格式化;在这里给 handler 设置 ``formatter`` 没有
60
- 任何效果,会被服务端忽略。
61
- """
62
- SocketHandler = logging.handlers.SocketHandler
63
-
64
- # 同一 logger 上避免重复挂指向相同端点的 SocketHandler——例如模块被
65
- # 重新 import、或测试用例反复 setup 的场景。
66
- for h in logger.handlers:
67
- if isinstance(h, SocketHandler) and (h.host, h.port) == (host, port):
68
- return
69
-
70
- logger.addHandler(SocketHandler(host, port))
71
-
72
- # 仅在 logger 仍是默认 ``NOTSET`` 时才设置 level,避免悄悄覆盖
73
- # 用户已经显式设置过的级别。
74
- if logger.level == logging.NOTSET:
75
- logger.setLevel(level)
76
-
77
- # 关闭向 root 的传播,避免与已存在的 root handler(比如调用过
78
- # ``basicConfig``)造成重复输出。root 自身没有父级,跳过此项。
79
- if logger is not logging.getLogger():
80
- logger.propagate = False
81
-
82
-
83
- def getLogger(name: str | None = None) -> logging.Logger:
84
- """返回一个会把日志通过 :class:`SocketHandler` 发送到日志服务的 logger。
85
-
86
- 必须在父进程已经调用过 :func:`kitty_logger.setup_logging` 之后才能
87
- 使用。本库只支持 ``spawn`` 启动方式;spawn 出来的子进程模块级状态
88
- 天然为空,因此不需要处理"继承自父进程的 SocketHandler"问题。
89
-
90
- :param name: logger 名;``None`` 表示 root logger。
91
- :raises RuntimeError: 当前进程树尚未调用 ``setup_logging``。
92
- """
93
- host, port = _read_endpoint()
94
- level = _read_level()
95
- logger = logging.getLogger(name)
96
-
97
- with _lock:
98
- key = name or ""
99
- if key not in _configured_loggers:
100
- _attach_socket_handler(logger, host, port, level)
101
- _configured_loggers.add(key)
102
- return logger