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(): def get_app_dir():
"""Return the program root directory (read-only program files) as a Path. """Return the program root directory (read-only program files) as a Path.
PyInstaller onedir: directory that contains the .exe. In the versioned PyInstaller onedir: directory that contains the .exe. In the launcher
install layout (docs/10-lan-update.md) this is versions/<x.y.z>/, which is layout (docs/10-lan-update.md) this is the app\\ folder, replaced wholesale
replaced wholesale on update. on update.
Development: project root (three levels above this file: Development: project root (three levels above this file:
src/services/file_service.py -> src/services -> src -> project root). 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. """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 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 — explicit override for
1. CMBOT_DATA_DIR environment variable, when set. The launcher points this tests or special deployments.
at the install-wide data/ folder in the versioned layout. 2. Packaged (sys.frozen) → ~/.cmbot (%USERPROFILE%\\.cmbot): always writable,
2. Fallback to get_app_dir() — the flat layout used in development and in per-user, independent of where the program is installed, so it works even
the current onedir release, where data sits next to the program. 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() env = os.environ.get("CMBOT_DATA_DIR", "").strip()
if env: if env:
return Path(env) return Path(env)
if getattr(sys, "frozen", False):
return Path.home() / ".cmbot"
return get_app_dir() 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) - [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` - [ ] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot`
- [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\` - [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\`
- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1` - [ ] 退休 `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()