Files
FinDataHub f112e9d277 feat(paths): 服务资源定位支持 bundle 与源码树两种布局(关闭 L2)
## 问题

backfill_stock_daily.py 把部署路径写死在 sys.path.insert 里:

  sys.path.insert(0, "/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src")

macOS 现网能跑,换平台或换安装位置即 ImportError。这是待办清单里长期挂着的 L2。

## 为什么不在脚本里写 try/except 双路径

那样只解决 bundle 形态,源码树运行时仍走 hardcode。正确做法是让「资源在哪」
由 paths.py 统一回答——它已经是路径的唯一事实来源。

## 新增三个函数

- resources_root():bundle → <App>/Contents/Resources;源码树 → 仓库根
- service_dir(name):<root>/<name> 或 <root>/Services/<name>
- service_src_dir(name):service_dir(name)/"src",可直接塞进 sys.path

**判据是内容而非层级**:仓库根下有 Services/,bundle 的资源目录下没有
(build-app.sh 把各服务平铺拷进 Resources/)。首版按「两级往上」硬算,
源码树里解析成 <repo>/Scripts——被 test_real_repo_layout_resolves 当场抓出。

## 验证(两形态实跑)

  源码树:<repo>/Services/akshare_fetcher/src                      存在 ✓
  bundle:/Applications/…/Resources/akshare_fetcher/src            存在 ✓
          import akshare_fetcher 成功 ✓

bundle 侧解析结果与旧硬编码**逐字一致**,故行为零漂移。

## 回归

TestServiceLayout 6 条:bundle / 源码树 / 两者共存时优先 bundle /
都不存在时返回 bundle 形态(错误信息才能指向期望位置)/ src 后缀 / 真实仓库解析。
scripts 257 → 263。
2026-10-27 23:23:06 +08:00

208 lines
7.5 KiB
Python
Raw Permalink 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"
# ── 仓库 / 资源定位 ─────────────────────────────────────────
def resources_root() -> Path:
"""资源根目录,自动区分两种布局。
- 部署位(bundle):``<App>/Contents/Resources``(``paths.py`` 在其下一层 ``bin/``)
- 源码树:仓库根(``paths.py`` 在 ``Scripts/bin/``,多一层 ``Scripts``)
判据是**内容而非层级**:仓库根下有 ``Services/``,bundle 的资源目录下没有
(build-app.sh 把各服务平铺拷进 ``Resources/``)。按层级硬算会在源码树里
解析成 ``<repo>/Scripts``——实测踩过。
"""
bin_dir = Path(__file__).resolve().parent
repo_candidate = bin_dir.parent.parent # <repo>(源码树)
if (repo_candidate / "Services").is_dir():
return repo_candidate
return bin_dir.parent # <App>/Contents/Resources(bundle)
def service_dir(name: str) -> Path:
"""某个服务的根目录(含 ``pyproject.toml`` / ``src`` / ``core``)。
自动适配两种形态::
bundle : <App>/Contents/Resources/akshare_fetcher
source : <repo>/Services/akshare_fetcher
两个都不存在时返回 bundle 形态——错误信息能直接指向期望位置,
比返回源码树的路径更容易定位「构建时漏拷了哪个服务」。
背景:``backfill_stock_daily.py`` 曾把
``/Applications/FinDataHub.app/Contents/Resources/akshare_fetcher/src``
写死在 ``sys.path.insert`` 里,macOS 上能跑、换平台或换安装位置即 ImportError。
"""
root = resources_root()
bundled = root / name
if bundled.is_dir():
return bundled
in_tree = root / "Services" / name
if in_tree.is_dir():
return in_tree
return bundled
def service_src_dir(name: str) -> Path:
"""服务的 ``src`` 目录(可直接塞进 ``sys.path``)."""
return service_dir(name) / "src"