# 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):``/Contents/Resources``(``paths.py`` 在其下一层 ``bin/``) - 源码树:仓库根(``paths.py`` 在 ``Scripts/bin/``,多一层 ``Scripts``) 判据是**内容而非层级**:仓库根下有 ``Services/``,bundle 的资源目录下没有 (build-app.sh 把各服务平铺拷进 ``Resources/``)。按层级硬算会在源码树里 解析成 ``/Scripts``——实测踩过。 """ bin_dir = Path(__file__).resolve().parent repo_candidate = bin_dir.parent.parent # (源码树) if (repo_candidate / "Services").is_dir(): return repo_candidate return bin_dir.parent # /Contents/Resources(bundle) def service_dir(name: str) -> Path: """某个服务的根目录(含 ``pyproject.toml`` / ``src`` / ``core``)。 自动适配两种形态:: bundle : /Contents/Resources/akshare_fetcher source : /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"