feat: get_data_dir() three-tier fallback (env -> ~/.cmbot -> dev root)

Packaged builds now resolve the data root to ~/.cmbot (%USERPROFILE%\.cmbot)
instead of the program dir, so user config/templates/output stay writable and
per-user regardless of where the program is installed or whether it is launched
via Launcher.exe (docs/10-lan-update.md §5). CMBOT_DATA_DIR still overrides.

- tests/test_file_service.py: 5 tests covering env override, frozen->~/.cmbot,
  dev->project root, blank-env handling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:39:52 +08:00
co-authored by Claude Opus 4.8
parent 0c9415fbcd
commit 3d01bed98c
3 changed files with 69 additions and 10 deletions
+12 -9
View File
@@ -22,9 +22,9 @@ def is_supported_image(path):
def get_app_dir():
"""Return the program root directory (read-only program files) as a Path.
PyInstaller onedir: directory that contains the .exe. In the versioned
install layout (docs/10-lan-update.md) this is versions/<x.y.z>/, which is
replaced wholesale on update.
PyInstaller onedir: directory that contains the .exe. In the launcher
layout (docs/10-lan-update.md) this is the app\\ folder, replaced wholesale
on update.
Development: project root (three levels above this file:
src/services/file_service.py -> src/services -> src -> project root).
@@ -39,17 +39,20 @@ def get_data_dir():
"""Return the writable data root (config, templates, logs, output) as a Path.
Kept separate from the program root so that replacing the program on update
never touches user data (docs/10-lan-update.md §5).
never touches user data (docs/10-lan-update.md §5). Resolution order:
Resolution order:
1. CMBOT_DATA_DIR environment variable, when set. The launcher points this
at the install-wide data/ folder in the versioned layout.
2. Fallback to get_app_dir() — the flat layout used in development and in
the current onedir release, where data sits next to the program.
1. CMBOT_DATA_DIR environment variable, when set — explicit override for
tests or special deployments.
2. Packaged (sys.frozen) → ~/.cmbot (%USERPROFILE%\\.cmbot): always writable,
per-user, independent of where the program is installed, so it works even
when launched directly rather than through Launcher.exe.
3. Development → project root, so dev runs don't pollute the home directory.
"""
env = os.environ.get("CMBOT_DATA_DIR", "").strip()
if env:
return Path(env)
if getattr(sys, "frozen", False):
return Path.home() / ".cmbot"
return get_app_dir()
+1 -1
View File
@@ -932,7 +932,7 @@
任务:
- [x] 文档:`docs/10` 改为便携 + `Launcher.exe` + `~/.cmbot` 模型(§3/§4/§5/§8/§9/§11/§16)
- [ ] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;更新单测
- [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测
- [ ] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot`
- [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\`
- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`
+56
View File
@@ -0,0 +1,56 @@
"""Tests for services.file_service path resolution — no GUI dependency."""
import os
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import services.file_service as fs
class TestGetDataDir(unittest.TestCase):
"""get_data_dir() three-tier fallback (docs/10-lan-update.md §5)."""
def setUp(self):
self._env = os.environ.get("CMBOT_DATA_DIR")
self._frozen = getattr(sys, "frozen", None)
os.environ.pop("CMBOT_DATA_DIR", None)
if hasattr(sys, "frozen"):
del sys.frozen
def tearDown(self):
if self._env is None:
os.environ.pop("CMBOT_DATA_DIR", None)
else:
os.environ["CMBOT_DATA_DIR"] = self._env
if self._frozen is None:
if hasattr(sys, "frozen"):
del sys.frozen
else:
sys.frozen = self._frozen
def test_env_override_wins(self):
os.environ["CMBOT_DATA_DIR"] = r"X:\custom\data"
self.assertEqual(fs.get_data_dir(), Path(r"X:\custom\data"))
def test_env_override_wins_even_when_frozen(self):
sys.frozen = True
os.environ["CMBOT_DATA_DIR"] = r"X:\custom\data"
self.assertEqual(fs.get_data_dir(), Path(r"X:\custom\data"))
def test_packaged_uses_home_dotcmbot(self):
sys.frozen = True
self.assertEqual(fs.get_data_dir(), Path.home() / ".cmbot")
def test_dev_uses_project_root(self):
# not frozen, no env -> same as program/app dir (project root in dev)
self.assertEqual(fs.get_data_dir(), fs.get_app_dir())
def test_blank_env_is_ignored(self):
os.environ["CMBOT_DATA_DIR"] = " "
self.assertEqual(fs.get_data_dir(), fs.get_app_dir())
if __name__ == "__main__":
unittest.main()