Files
FinDataHub/Scripts/bin/tests/test_paths_process_utils.py
T
FinDataHub fb76b10033 feat(cross-platform): 收敛路径与进程语义到 paths / process_utils
Windows 迁移 M0。纯新增两个模块 + 4 个脚本接线,**macOS 行为零漂移**。

背景:仓库此前把 /tmp/findatahub-*.pid、~/Library/Logs、
~/Library/Preferences/...plist 直接写死在十来个文件里;采集管线散落着
start_new_session=True、os.kill(pid,0)、signal.SIGHUP 等 POSIX 专属写法。
这些在 Windows 上分别是:路径不存在、ValueError、探测即杀进程、AttributeError。

- paths.py:temp_dir / pid_file / heartbeat_file / log_root / log_dir /
  app_support_dir / backups_dir / config_dir / user_defaults_plist / env_file
- process_utils.py:detach_kwargs / is_running / terminate_pid /
  available_signals / register_signal_handler / restore_default_and_die /
  utf8_subprocess_env

两个必须记住的坑(已写成测试断言):
1. tempfile.gettempdir() 在 macOS 返回 $TMPDIR(/var/folders/.../T),
   与现网 PID 文件所在的 /tmp **不是同一目录**。paths.temp_dir() 返回
   /tmp 是刻意的,混用会导致新旧进程互相看不见。
2. Windows 上 os.kill(pid, 0) 等价于 TerminateProcess,不是「探测」,
   必须用 OpenProcess。

接线:newsflash_collect(PID/心跳/日志/plist)、daily_summary(PID)、
migrate_date_format(备份目录)、install_collector_launchd(plist + 平台守卫)。

同时修 newsflash_collect.write_heartbeat 的静默失败:Swift 端以心跳
300s 未更新判定卡死并重启,而此处原为 except OSError: pass——重启理由
在日志里毫无痕迹。改为状态翻转才记录(心跳 60s 一次,无脑打日志会刷屏)。

验证:现网采集器 status 正常,5 个路径与旧硬编码逐字一致。
回归:test_paths_process_utils.py 32 条 + 心跳可观测性 5 条。
2026-10-26 20:55:31 +08:00

215 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# coding=utf-8
"""paths.py / process_utils.py 跨平台抽象层测试.
本套件的第一职责是**防止迁移期行为漂移**:macOS 上新抽象必须解析出
与旧硬编码**逐字相同**的路径。历史上采集器 PID 文件、心跳文件的位置
被 launchd plist、Swift 端探测逻辑、运行中进程三处同时引用,
任何一处变化都会表现为"采集器在跑、App 却报未运行"。
第二职责是覆盖 Windows 分支的路径语义(不依赖真的跑在 Windows 上,
用 monkeypatch 切 ``IS_WINDOWS`` / ``sys.platform``)。
"""
import os
import sys
from pathlib import Path
import pytest
_bin_dir = os.path.join(os.path.dirname(__file__), "..")
if _bin_dir not in sys.path:
sys.path.insert(0, _bin_dir)
import paths # noqa: E402
import process_utils # noqa: E402
# ══════════════════════════════════════════════════════════
# paths —— macOS 等价性(回归保护,最关键)
# ══════════════════════════════════════════════════════════
@pytest.mark.skipif(sys.platform != "darwin", reason="仅在 macOS 上校验等价性")
class TestMacOSEquivalence:
"""断言与迁移前硬编码完全一致。改这些断言前先想清楚兼容性。"""
def test_pid_file_matches_legacy_literal(self):
# 旧写法:PID_FILE = "/tmp/findatahub-newsflash.pid"
assert paths.pid_file("newsflash") == "/tmp/findatahub-newsflash.pid"
def test_backend_pid_file(self):
# 旧写法:"/tmp/findatahub-newsflash-backend.pid"
assert paths.pid_file("newsflash-backend") == "/tmp/findatahub-newsflash-backend.pid"
def test_heartbeat_file(self):
# 旧写法:"/tmp/findatahub-newsflash.heartbeat"
assert paths.heartbeat_file("newsflash") == "/tmp/findatahub-newsflash.heartbeat"
def test_temp_dir_is_tmp_not_gettempdir(self):
"""必须返回 /tmp,而不是 $TMPDIR。
tempfile.gettempdir() 在 macOS 返回 /var/folders/xx/.../T,
与现网 PID 文件所在 /tmp 不是同一个目录——混用即事故。
"""
assert paths.temp_dir() == Path("/tmp")
import tempfile
# 明确记录两者不同,避免后人"顺手"改回 gettempdir
assert Path(tempfile.gettempdir()) != paths.temp_dir()
def test_log_root(self):
assert paths.log_root() == Path.home() / "Library/Logs/FinDataHub"
def test_log_dir_with_sub(self):
assert paths.log_dir("newsflash") == (
Path.home() / "Library/Logs/FinDataHub/newsflash"
)
def test_app_support_dir(self):
assert paths.app_support_dir() == (
Path.home() / "Library/Application Support/FinDataHub"
)
def test_backups_dir(self):
assert paths.backups_dir() == (
Path.home() / "Library/Application Support/FinDataHub/backups"
)
def test_user_defaults_plist(self):
assert paths.user_defaults_plist() == (
Path.home() / "Library/Preferences/com.findatahub.app.plist"
)
def test_config_dir(self):
assert paths.config_dir() == Path.home() / ".config/findatahub"
def test_env_file(self):
assert paths.env_file() == Path.home() / ".config/findatahub/env"
# ══════════════════════════════════════════════════════════
# paths —— Windows 分支
# ══════════════════════════════════════════════════════════
class TestWindowsPaths:
@pytest.fixture
def win(self, monkeypatch):
monkeypatch.setattr(paths, "IS_WINDOWS", True)
monkeypatch.setattr(paths, "IS_MACOS", False)
monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\tester\AppData\Local")
monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming")
return paths
def test_log_root_under_localappdata(self, win):
assert win.log_root() == Path(r"C:\Users\tester\AppData\Local/FinDataHub/logs")
def test_app_support_under_localappdata(self, win):
assert win.app_support_dir() == Path(r"C:\Users\tester\AppData\Local/FinDataHub")
def test_config_under_appdata(self, win):
assert win.config_dir() == Path(r"C:\Users\tester\AppData\Roaming/FinDataHub")
def test_no_plist_on_windows(self, win):
"""Windows 无 plist——调用方必须处理 None,不能假设路径存在."""
assert win.user_defaults_plist() is None
def test_pid_file_uses_system_temp(self, win):
p = win.pid_file("newsflash")
assert p.endswith("findatahub-newsflash.pid")
assert "/tmp/" not in p
# ══════════════════════════════════════════════════════════
# process_utils
# ══════════════════════════════════════════════════════════
class TestDetach:
def test_posix_uses_start_new_session(self):
if process_utils.IS_WINDOWS:
pytest.skip("仅 POSIX")
assert process_utils.detach_kwargs() == {"start_new_session": True}
def test_windows_uses_creationflags(self, monkeypatch):
monkeypatch.setattr(process_utils, "IS_WINDOWS", True)
kw = process_utils.detach_kwargs()
assert "creationflags" in kw
assert "start_new_session" not in kw
# CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
assert kw["creationflags"] & 0x00000200
assert kw["creationflags"] & 0x00000008
def test_hidden_window_noop_on_posix(self):
if process_utils.IS_WINDOWS:
pytest.skip("仅 POSIX")
assert process_utils.hidden_window_kwargs() == {}
class TestIsRunning:
def test_current_process_alive(self):
assert process_utils.is_running(os.getpid()) is True
def test_nonexistent_pid(self):
# 取一个几乎不可能存在的 PID
assert process_utils.is_running(999_999) is False
@pytest.mark.parametrize("bad", [0, -1, -999])
def test_invalid_pid(self, bad):
"""非法 PID 直接判死,不得交给 os.kill 触发异常或误杀进程组."""
assert process_utils.is_running(bad) is False
class TestTerminate:
def test_invalid_pid_noop(self):
assert process_utils.terminate_pid(0) is False
assert process_utils.terminate_pid(-1) is False
def test_terminate_nonexistent_returns_false(self):
assert process_utils.terminate_pid(999_999) is False
class TestSignals:
def test_posix_has_core_signals(self):
if process_utils.IS_WINDOWS:
pytest.skip("仅 POSIX")
sigs = process_utils.available_signals()
assert "SIGTERM" in sigs
assert "SIGINT" in sigs
# SIGHUP 在 Windows 上不存在,POSIX 上应存在
assert "SIGHUP" in sigs
def test_register_skips_missing_signals(self):
"""平台不存在的信号必须跳过而不是 AttributeError 崩溃."""
called = []
ok = process_utils.register_signal_handler(
lambda s, f: called.append(s),
names=("SIGTERM", "SIG_THIS_DOES_NOT_EXIST"),
)
assert "SIGTERM" in ok
assert "SIG_THIS_DOES_NOT_EXIST" not in ok
def test_register_returns_registered_names(self):
original = process_utils.signal.getsignal(process_utils.signal.SIGINT)
try:
names = process_utils.register_signal_handler(
lambda s, f: None, names=("SIGINT",)
)
assert names == ["SIGINT"]
finally:
process_utils.signal.signal(process_utils.signal.SIGINT, original)
class TestUtf8Env:
def test_sets_utf8_vars(self):
env = process_utils.utf8_subprocess_env({"PATH": "/usr/bin"})
assert env["PYTHONUTF8"] == "1"
assert env["PYTHONIOENCODING"] == "utf-8"
assert env["PATH"] == "/usr/bin"
def test_does_not_mutate_input(self):
base = {"PATH": "/usr/bin"}
process_utils.utf8_subprocess_env(base)
assert "PYTHONUTF8" not in base, "必须返回副本,否则污染调用方环境"
def test_defaults_to_os_environ_copy(self):
env = process_utils.utf8_subprocess_env()
assert env is not os.environ
assert env["PYTHONUTF8"] == "1"