## 问题
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。
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
# 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"
|