Files
FinDataHub/Scripts/bin/paths.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

159 lines
5.5 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
"""跨平台路径解析 —— 收敛所有 macOS 专属硬编码路径.
背景
----
仓库此前把 ``/tmp/findatahub-*.pid``、``~/Library/Logs/FinDataHub``、
``~/Library/Preferences/com.findatahub.app.plist`` 等路径直接写死在十来个
文件里。这在 macOS 上工作正常,但在 Windows 上:
* 没有 ``/tmp`` 目录(``Path.home()/"Library"`` 也不存在);
* 日志应落 ``%LOCALAPPDATA%``,而非 ``~/Library/Logs``;
* 用户偏好不在 plist,而在注册表 / 应用数据目录。
本模块是这些路径的**唯一事实来源**。迁移到其他平台只需改这里,
不必再全仓搜字符串。
设计约束(重要)
----------------
**macOS 上必须解析出与旧硬编码完全相同的字符串。** 原因是:
1. 现网采集器已经把这些路径写进了 launchd plist、Swift 端探测逻辑与
正在运行的 PID 文件里;路径一变,新旧进程会互相看不见,
表现为"采集器明明在跑,App 却报未运行"。
2. 特别注意 ``temp_dir()`` 在 macOS 返回 ``/tmp`` 而**不是**
``tempfile.gettempdir()``(后者是 ``$TMPDIR``,形如
``/var/folders/xx/.../T/``)。这两个路径不同,混用即事故。
用例::
from paths import pid_file, log_dir, app_support_dir
PID_FILE = pid_file("newsflash") # /tmp/findatahub-newsflash.pid
LOG_DIR = log_dir("newsflash") # ~/Library/Logs/FinDataHub/newsflash
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
APP_NAME = "FinDataHub"
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
# ── 临时目录 ────────────────────────────────────────────────
def temp_dir() -> Path:
"""临时目录。
macOS/Linux 固定 ``/tmp``(与现网 PID/心跳文件位置保持一致,**不要**
换成 ``tempfile.gettempdir()``);Windows 用系统 TEMP。
"""
if IS_WINDOWS:
return Path(tempfile.gettempdir())
return Path("/tmp")
def pid_file(name: str) -> str:
"""PID 文件路径,如 ``pid_file("newsflash")`` → ``/tmp/findatahub-newsflash.pid``."""
return str(temp_dir() / f"findatahub-{name}.pid")
def heartbeat_file(name: str) -> str:
"""心跳文件路径(Swift 端用它免 spawn 判断采集器是否活跃)."""
return str(temp_dir() / f"findatahub-{name}.heartbeat")
# ── 日志目录 ────────────────────────────────────────────────
def log_root() -> Path:
"""日志根目录。
macOS: ``~/Library/Logs/FinDataHub``(Console.app 可直接查看)
Windows: ``%LOCALAPPDATA%\\FinDataHub\\logs``
"""
if IS_WINDOWS:
base = os.getenv("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
return Path(base) / APP_NAME / "logs"
if IS_MACOS:
return Path.home() / "Library" / "Logs" / APP_NAME
# Linux 走 XDG
base = os.getenv("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
return Path(base) / APP_NAME / "logs"
def log_dir(sub: str = "", *, create: bool = False) -> Path:
"""日志子目录,如 ``log_dir("newsflash")``。``create=True`` 时顺带建目录."""
d = log_root() / sub if sub else log_root()
if create:
d.mkdir(parents=True, exist_ok=True)
return d
# ── 应用数据 / 配置 ─────────────────────────────────────────
def app_support_dir(*, create: bool = False) -> Path:
"""应用数据目录。
macOS: ``~/Library/Application Support/FinDataHub``
Windows: ``%LOCALAPPDATA%\\FinDataHub``
Linux: ``~/.local/share/FinDataHub``
"""
if IS_WINDOWS:
base = os.getenv("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
d = Path(base) / APP_NAME
elif IS_MACOS:
d = Path.home() / "Library" / "Application Support" / APP_NAME
else:
base = os.getenv("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
d = Path(base) / APP_NAME
if create:
d.mkdir(parents=True, exist_ok=True)
return d
def backups_dir(*, create: bool = False) -> Path:
"""备份目录(迁移脚本、数据库快照的落点)."""
d = app_support_dir() / "backups"
if create:
d.mkdir(parents=True, exist_ok=True)
return d
def config_dir(*, create: bool = False) -> Path:
"""配置文件目录(``env`` 文件、API Key 等)。
macOS: ``~/.config/findatahub``(与 ``~/.config/findatahub/env`` 一致)
Windows: ``%APPDATA%\\FinDataHub``
"""
if IS_WINDOWS:
base = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming")
d = Path(base) / APP_NAME
else:
base = os.getenv("XDG_CONFIG_HOME") or str(Path.home() / ".config")
d = Path(base) / "findatahub"
if create:
d.mkdir(parents=True, exist_ok=True)
return d
def user_defaults_plist() -> Path | None:
"""macOS 用户偏好 plist 路径;非 macOS 返回 ``None``。
调用方必须处理 ``None``——Windows/Linux 上没有 plist,
偏好读取应回退到环境变量或配置文件。
"""
if not IS_MACOS:
return None
return Path.home() / "Library" / "Preferences" / "com.findatahub.app.plist"
def env_file() -> Path:
"""生产环境变量文件(TUSHARE_TOKEN / MYSQL_PASSWORD 等,权限 0600)."""
return config_dir() / "env"