From 421ae780357f94d570585157b2b8b00a3018af8d Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 18 Jun 2026 14:29:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20default=20export=20dir=20to=20\=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=E5=9B=BE=E7=89=87?= =?UTF-8?q?=20when=20packaged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_output_dir() now returns \合并后的图片 (parent of app\) for frozen builds — next to Launcher.exe, easy to find, not replaced on update. Falls back to /output if the install root is read-only; dev mode unchanged. The export panel already uses get_output_dir(), so its default follows automatically. - tests/test_file_service.py: +3 (dev, frozen install-root, fallback) Co-Authored-By: Claude Opus 4.8 --- src/services/file_service.py | 30 ++++++++++++++++++++- tasks.md | 4 +-- tests/test_file_service.py | 51 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/services/file_service.py b/src/services/file_service.py index ad7d122..916f733 100644 --- a/src/services/file_service.py +++ b/src/services/file_service.py @@ -79,8 +79,36 @@ def get_log_dir(): return d +def _is_dir_writable(path): + """Create *path* and probe it; True if a file can be written there.""" + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / ".write_test" + probe.write_text("x", encoding="ascii") + probe.unlink() + return True + except OSError: + return False + + +# Default export folder name, placed next to Launcher.exe in the packaged layout. +_OUTPUT_DIR_NAME = "合并后的图片" + + def get_output_dir(): - """Return /output/ as a Path, creating the directory if absent.""" + """Return the default export directory as a Path, creating it if absent. + + Packaged: \\合并后的图片 (next to Launcher.exe — easy to find + and NOT replaced on update, unlike app\\). Install root is the parent of the + app\\ folder (get_app_dir()). Falls back to /output if the install + root is not writable. Development: /output (the project dir). + + This is only the default; the user's chosen output_dir takes precedence. + """ + if getattr(sys, "frozen", False): + candidate = get_app_dir().parent / _OUTPUT_DIR_NAME + if _is_dir_writable(candidate): + return candidate d = get_data_dir() / "output" d.mkdir(parents=True, exist_ok=True) return d diff --git a/tasks.md b/tasks.md index 2137ed5..93c6d5d 100644 --- a/tasks.md +++ b/tasks.md @@ -1002,8 +1002,8 @@ 任务: - [x] 文档:`docs/10` §4/§5、`docs/02` 配置说明、`docs/07` §7.5 对齐 -- [ ] `file_service.get_output_dir()`:打包态优先 `<安装根>\合并后的图片`(安装根 = `get_app_dir().parent`),不可写回退 `get_data_dir()/output`;开发态用项目目录;补单测 -- [ ] 导出面板默认值随之变化(沿用 `get_output_dir()`,无需单独改) +- [x] `file_service.get_output_dir()`:打包态优先 `<安装根>\合并后的图片`(安装根 = `get_app_dir().parent`),不可写回退 `get_data_dir()/output`;开发态用项目目录;`tests/test_file_service.py` 3 个单测 +- [x] 导出面板默认值随之变化(沿用 `get_output_dir()`,无需单独改) - [ ] GUI 实测:打包运行后默认导出到 `<安装根>\合并后的图片`,且更新后仍在 ## 18. 后续暂缓任务 diff --git a/tests/test_file_service.py b/tests/test_file_service.py index f88164c..002dc38 100644 --- a/tests/test_file_service.py +++ b/tests/test_file_service.py @@ -52,5 +52,56 @@ class TestGetDataDir(unittest.TestCase): self.assertEqual(fs.get_data_dir(), fs.get_app_dir()) +class TestGetOutputDir(unittest.TestCase): + """get_output_dir() default location (docs/10-lan-update.md §5).""" + + def setUp(self): + import tempfile + self.tmp = Path(tempfile.mkdtemp()) + self._env = os.environ.get("CMBOT_DATA_DIR") + self._frozen = getattr(sys, "frozen", None) + os.environ["CMBOT_DATA_DIR"] = str(self.tmp / "data") + if hasattr(sys, "frozen"): + del sys.frozen + self._orig_app_dir = fs.get_app_dir + + def tearDown(self): + import shutil + fs.get_app_dir = self._orig_app_dir + 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 + shutil.rmtree(str(self.tmp), ignore_errors=True) + + def test_dev_uses_data_output(self): + # not frozen -> /output + self.assertEqual(fs.get_output_dir(), Path(os.environ["CMBOT_DATA_DIR"]) / "output") + + def test_frozen_uses_install_root(self): + app = self.tmp / "install" / "app" + app.mkdir(parents=True) + fs.get_app_dir = lambda: app + sys.frozen = True + self.assertEqual(fs.get_output_dir(), self.tmp / "install" / "合并后的图片") + + def test_frozen_falls_back_when_not_writable(self): + app = self.tmp / "install" / "app" + app.mkdir(parents=True) + fs.get_app_dir = lambda: app + sys.frozen = True + orig = fs._is_dir_writable + fs._is_dir_writable = lambda p: False + try: + self.assertEqual(fs.get_output_dir(), Path(os.environ["CMBOT_DATA_DIR"]) / "output") + finally: + fs._is_dir_writable = orig + + if __name__ == "__main__": unittest.main()