From 2e5a8d5eed07fcf7a2f423234dfb2a2dde8fed1a Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Mon, 13 Jul 2026 14:14:38 +0800 Subject: [PATCH] Add DATA_PATHS golden standard and resolve_data_path helpers. Document skill-owned file layout under user data dir, extend runtime_paths with standard subdir helpers, add POLICY-DATA-PATH-001, and update CONFIG/RUNTIME cross-references. Co-authored-by: Cursor --- .env.example | 9 ++ SKILL.md | 2 +- development/CONFIG.md | 15 ++- development/DATA_PATHS.md | 160 +++++++++++++++++++++++++ development/POLICY_MATRIX.md | 1 + development/README.md | 3 +- development/RUNTIME.md | 2 + scripts/util/constants.py | 2 +- scripts/util/runtime_paths.py | 113 ++++++++++++++++- tests/test_development_policy_guard.py | 53 ++++++++ tests/test_runtime_paths.py | 62 ++++++++++ 11 files changed, 415 insertions(+), 7 deletions(-) create mode 100644 development/DATA_PATHS.md diff --git a/.env.example b/.env.example index 2095c1c..c8570aa 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,12 @@ OPENCLAW_ARTIFACTS_ON_FAILURE=1 # 1=失败截图(默认) STEP_DELAY_MIN=1.0 # 步骤间随机等待下限(秒) STEP_DELAY_MAX=5.0 # 步骤间随机等待上限(秒) HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒) + +# ── 数据目录子路径(见 development/DATA_PATHS.md;留空则用 skill 数据目录下默认子文件夹)── +# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace / CWD +# SKILL_DOWNLOAD_DIR=downloads +# SKILL_IMPORT_DIR=imports +# SKILL_EXPORT_DIR=exports +# SKILL_UPLOAD_DIR=uploads +# SKILL_CACHE_DIR=cache +# SKILL_TEMP_DIR=temp diff --git a/SKILL.md b/SKILL.md index e8b6e2e..e111e3c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.38 +version: 1.0.39 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/development/CONFIG.md b/development/CONFIG.md index b4cb3f7..a73feaf 100644 --- a/development/CONFIG.md +++ b/development/CONFIG.md @@ -40,7 +40,15 @@ STEP_DELAY_MAX=5.0 HUMAN_WAIT_TIMEOUT=180 # 滑块/验证码/2FA 等人工超时(秒) ``` -**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。 +**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_DOWNLOAD_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。 + +### 数据目录子路径(下载 / 导入 / 导出) + +技能**自己写入**的文件默认在 `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` 下标准子目录(`downloads/`、`imports/`、`exports/` 等)。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 + +- 权威说明:[`DATA_PATHS.md`](DATA_PATHS.md) +- `.env.example` 中路径类配置**留空即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法 +- 可选 env:`SKILL_DOWNLOAD_DIR`、`SKILL_IMPORT_DIR`、`SKILL_EXPORT_DIR` 等(见 DATA_PATHS.md) ### 录屏开关(`OPENCLAW_RECORD_VIDEO`) @@ -91,7 +99,7 @@ config.get_float("STEP_DELAY_MIN", 1.0) ## health / config-path - **`health`**:输出 `collect_runtime_diagnostics` 字段(`platform_kit_version_ok`、`ffmpeg_path` 等),**不打印敏感值**;补充 `env_path` / `env_exists` / `example_path`。 -- **`config-path`**:输出 JSON,包含 `skill`、`env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径),便于排查落盘位置。 +- **`config-path`**:输出 JSON,包含 `skill`、`env_path`(用户数据目录 `.env`)、`example_path`(仓库 `.env.example` 绝对路径),便于排查落盘位置;业务 skill 建议附加 `list_resolved_data_paths()` 中的子目录路径(见 [`DATA_PATHS.md`](DATA_PATHS.md))。 ## doctor / setup 命令(可选) @@ -104,4 +112,5 @@ config.get_float("STEP_DELAY_MIN", 1.0) - `RPA.md` — 三端 RPA 标准与各开关含义 - `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式 -- `RUNTIME.md` — `JIANGCHANG_*` 环境变量与数据目录约定 +- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定 +- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范 diff --git a/development/DATA_PATHS.md b/development/DATA_PATHS.md new file mode 100644 index 0000000..bed806a --- /dev/null +++ b/development/DATA_PATHS.md @@ -0,0 +1,160 @@ +# 用户数据目录与文件路径黄金标准 + +> 本文是 skill **读写本地文件** 的权威约定。凡涉及下载、导入、导出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。 + +--- + +## 1. 根目录 + +所有技能持久化数据(配置、数据库、业务文件)默认落在: + +```text +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/ +``` + +示例: + +```text +D:\jiangchang-data\12500\download-video-baidu-haokan\ +``` + +实现:`util.runtime_paths.get_skill_data_dir()`。 + +**禁止**把技能自己写入的文件落到: + +- Agent / 宿主 **workspace**(进程 CWD) +- 技能安装目录(含 `.openclaw/skills/...`) +- 未在本文声明的任意相对 CWD 路径 + +--- + +## 2. 标准子目录树 + +| 子目录 | 用途 | 默认 helper | +|--------|------|-------------| +| `downloads/` | 从外部拉取的原始文件(视频、回单、PDF…) | `get_downloads_dir()` | +| `imports/` | 用户/Agent 放入的待处理清单(urls.txt、batch.json、CSV) | `get_imports_dir()` | +| `exports/` | 导出给用户的结果(xlsx/csv/json) | `get_exports_dir()` | +| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` | +| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` | +| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` | +| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` | +| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` | +| `{skill_slug}.db` | SQLite | `get_db_path()` | +| `.env` | 用户配置 | `config.get_env_file_path()` | + +复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。 + +--- + +## 3. 两类路径 + +### A. 技能拥有(skill-owned) + +技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。 + +- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。 +- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 + +### B. 用户显式输入(user-owned) + +用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。 + +- 技能**只读**,路径可以是用户磁盘任意位置。 +- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/imports/<相对路径>`,**不**相对 workspace/CWD。 + +--- + +## 4. 配置项(`.env`) + +可选覆盖项(**留空则用默认子目录**): + +```ini +# ── 数据目录子路径(见 development/DATA_PATHS.md)── +# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace +# SKILL_DOWNLOAD_DIR=downloads/videos +# SKILL_IMPORT_DIR=imports +# SKILL_EXPORT_DIR=exports +# SKILL_UPLOAD_DIR=uploads +# SKILL_CACHE_DIR=cache +# SKILL_TEMP_DIR=temp +``` + +| 规则 | 说明 | +|------|------| +| 未配置 | `{skill_data_dir}/{default_subdir}` | +| 相对值 `downloads/videos` | `{skill_data_dir}/downloads/videos` | +| 绝对值 `D:\nas\exports` | 高级用户/NAS 挂载,原样使用 | +| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 | + +业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。 + +--- + +## 5. 代码用法 + +```python +from util.runtime_paths import ( + get_downloads_dir, + get_imports_dir, + resolve_data_path, + resolve_input_path, + list_resolved_data_paths, +) + +# 下载保存(A 类) +dest = os.path.join(get_downloads_dir(), f"{vid}.mp4") + +# 或带 env 覆盖 +out_dir = resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads") + +# CLI -i urls.txt(B 类,相对路径 → imports/) +manifest = resolve_input_path(args.input_path) + +# health 诊断 +paths = list_resolved_data_paths() +``` + +**反模式(禁止):** + +```python +# ❌ 相对 CWD,Agent 从 workspace 执行时会写错盘 +os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR")) + +# ❌ .env.example 里写 ./outputs/videos +DOWNLOAD_OUTPUT_DIR=./outputs/videos +``` + +--- + +## 6. health / config-path + +`health` 或 `config-path` 建议输出 `list_resolved_data_paths()` 中的路径,便于排查「文件写到哪里去了」。展示路径须与 `run` 实际使用的一致。 + +--- + +## 7. 与 RPA 存证的关系 + +- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1) +- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3) + +业务 `downloads/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。 + +--- + +## 8. 测试 + +- 单元测试须用 `IsolatedDataRoot`,断言路径落在临时数据根下。 +- `tests/test_runtime_paths.py` 覆盖 `resolve_data_path` / `resolve_input_path`。 +- `POLICY-DATA-PATH-001`:禁止 `.env.example` 中 `./` 路径;禁止业务代码 `abspath(config.get(...))`。 + +--- + +## 9. 相关文档 + +| 文档 | 内容 | +|------|------| +| [`RUNTIME.md`](RUNTIME.md) | 数据根、共享 runtime、编码 | +| [`CONFIG.md`](CONFIG.md) | `.env` bootstrap 与三层优先级 | +| [`RPA.md`](RPA.md) | rpa-artifacts / videos 存证 | +| [`POLICY_MATRIX.md`](POLICY_MATRIX.md) | POLICY-DATA-PATH-001 | diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md index c3aa7e8..a7e56b9 100644 --- a/development/POLICY_MATRIX.md +++ b/development/POLICY_MATRIX.md @@ -27,6 +27,7 @@ | POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` | | POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` | | POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` | +| POLICY-DATA-PATH-001 | 技能拥有路径须经 `resolve_data_path` / `get_*_dir`;`.env.example` 禁止 `./` CWD 相对路径;业务代码禁止 `abspath(config.get(...))` | development/DATA_PATHS.md | hard | 扫描 `.env.example` 与 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyDataPath001` | --- diff --git a/development/README.md b/development/README.md index 49b90fd..b33029f 100644 --- a/development/README.md +++ b/development/README.md @@ -10,7 +10,8 @@ 6. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 7. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时 8. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 -9. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 +9. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读) +10. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。 diff --git a/development/RUNTIME.md b/development/RUNTIME.md index f1b32f9..82778fc 100644 --- a/development/RUNTIME.md +++ b/development/RUNTIME.md @@ -89,6 +89,8 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均 {...}/{skill_slug}.db ``` +**业务文件(下载、导入、导出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。 + - 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。 - 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。 diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 4703f7b..2c23c41 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.38" +SKILL_VERSION = "1.0.39" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.0" diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py index 8af0fa5..f97ec06 100644 --- a/scripts/util/runtime_paths.py +++ b/scripts/util/runtime_paths.py @@ -1,4 +1,4 @@ -"""数据根、技能目录、兄弟技能根路径。""" +"""数据根、技能目录、兄弟技能根路径与用户数据子目录解析。""" from __future__ import annotations @@ -10,6 +10,22 @@ from util.constants import SKILL_SLUG _SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STANDARD_SUBDIR_DOWNLOADS = "downloads" +STANDARD_SUBDIR_IMPORTS = "imports" +STANDARD_SUBDIR_EXPORTS = "exports" +STANDARD_SUBDIR_UPLOADS = "uploads" +STANDARD_SUBDIR_CACHE = "cache" +STANDARD_SUBDIR_TEMP = "temp" +STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts" +STANDARD_SUBDIR_VIDEOS = "videos" + +CONFIG_KEY_DOWNLOAD_DIR = "SKILL_DOWNLOAD_DIR" +CONFIG_KEY_IMPORT_DIR = "SKILL_IMPORT_DIR" +CONFIG_KEY_EXPORT_DIR = "SKILL_EXPORT_DIR" +CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR" +CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR" +CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR" + def get_skill_root() -> str: return os.path.dirname(_SCRIPTS_DIR) @@ -32,3 +48,98 @@ def get_skill_data_dir() -> str: def get_db_path(filename: str | None = None) -> str: name = filename or f"{SKILL_SLUG}.db" return os.path.join(get_skill_data_dir(), name) + + +def _normalize_subdir(value: str) -> str: + cleaned = value.strip().strip("/\\") + if not cleaned or cleaned.startswith(".."): + raise ValueError(f"invalid skill data subdir: {value!r}") + return cleaned.replace("/", os.sep).replace("\\", os.sep) + + +def resolve_data_path( + config_key: str | None, + default_subdir: str, + *, + create: bool = True, +) -> str: + """解析技能拥有路径:空配置 → 数据目录下 default_subdir;相对 → 相对数据目录;绝对 → 原样。""" + from jiangchang_skill_core import config + + base = get_skill_data_dir() + raw = (config.get(config_key) or "").strip() if config_key else "" + if not raw: + path = os.path.join(base, _normalize_subdir(default_subdir)) + elif os.path.isabs(raw): + path = os.path.abspath(os.path.expanduser(raw)) + else: + path = os.path.join(base, _normalize_subdir(raw)) + + if create: + os.makedirs(path, exist_ok=True) + return path + + +def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str: + """解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/imports/。""" + text = (raw_path or "").strip() + if not text: + return text + expanded = os.path.expanduser(text) + if os.path.isabs(expanded): + return os.path.abspath(expanded) + imports_dir = get_imports_dir(create=create_parent) + return os.path.abspath(os.path.join(imports_dir, os.path.normpath(expanded))) + + +def get_downloads_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_DOWNLOAD_DIR, STANDARD_SUBDIR_DOWNLOADS, create=create) + + +def get_imports_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_IMPORT_DIR, STANDARD_SUBDIR_IMPORTS, create=create) + + +def get_exports_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_EXPORT_DIR, STANDARD_SUBDIR_EXPORTS, create=create) + + +def get_uploads_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_UPLOAD_DIR, STANDARD_SUBDIR_UPLOADS, create=create) + + +def get_cache_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_CACHE_DIR, STANDARD_SUBDIR_CACHE, create=create) + + +def get_temp_dir(*, create: bool = True) -> str: + return resolve_data_path(CONFIG_KEY_TEMP_DIR, STANDARD_SUBDIR_TEMP, create=create) + + +def get_rpa_artifacts_dir(batch_id: str, *, create: bool = True) -> str: + batch = (batch_id or "").strip() or "default" + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_RPA_ARTIFACTS, batch) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def get_videos_dir(*, create: bool = True) -> str: + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_VIDEOS) + if create: + os.makedirs(path, exist_ok=True) + return path + + +def list_resolved_data_paths() -> dict[str, str]: + """供 health / config-path 输出已解析的数据子目录(不创建目录)。""" + return { + "skill_data_dir": get_skill_data_dir(), + "downloads_dir": get_downloads_dir(create=False), + "imports_dir": get_imports_dir(create=False), + "exports_dir": get_exports_dir(create=False), + "uploads_dir": get_uploads_dir(create=False), + "cache_dir": get_cache_dir(create=False), + "temp_dir": get_temp_dir(create=False), + "videos_dir": get_videos_dir(create=False), + } diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py index 903e541..09cd295 100644 --- a/tests/test_development_policy_guard.py +++ b/tests/test_development_policy_guard.py @@ -44,6 +44,7 @@ POLICY_IDS = ( "POLICY-CONTROL-001", "POLICY-CONTROL-002", "POLICY-CONTROL-003", + "POLICY-DATA-PATH-001", ) STRUCTURE_PATHS = ( @@ -681,6 +682,58 @@ class TestPolicyControl003(unittest.TestCase): ) +class TestPolicyDataPath001(unittest.TestCase): + def test_env_example_has_no_cwd_relative_paths(self) -> None: + path = os.path.join(get_skill_root(), ".env.example") + offenders: list[str] = [] + with open(path, encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + active = stripped.split("#", 1)[0].strip() + if re.search(r"=\s*\./", active): + offenders.append(f".env.example:{lineno}: {active}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-DATA-PATH-001", + "development/DATA_PATHS.md", + "\n".join(offenders) or "CWD-relative ./ paths in .env.example", + ), + ) + + def test_runtime_paths_exposes_resolve_data_path(self) -> None: + import util.runtime_paths as rp + + self.assertTrue(callable(getattr(rp, "resolve_data_path", None))) + self.assertTrue(callable(getattr(rp, "list_resolved_data_paths", None))) + + def test_business_scripts_do_not_abspath_config_get(self) -> None: + skill_root = get_skill_root() + pattern = re.compile(r"os\.path\.abspath\s*\(\s*config\.get\b") + offenders: list[str] = [] + for rel in _walk_files(skill_root, "scripts", suffix=".py"): + if rel.replace("\\", "/") == "scripts/util/runtime_paths.py": + continue + text = _read_text(os.path.join(skill_root, rel)) + for lineno, line in enumerate(text.splitlines(), 1): + if line.strip().startswith("#"): + continue + if pattern.search(line): + offenders.append(f"{rel}:{lineno}: {line.strip()}") + self.assertEqual( + offenders, + [], + msg=_policy_msg( + "POLICY-DATA-PATH-001", + "development/DATA_PATHS.md", + "\n".join(offenders), + ), + ) + + class TestPolicyDocs001(unittest.TestCase): def test_policy_matrix_exists_and_lists_policy_ids(self) -> None: skill_root = get_skill_root() diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py index 54d7736..ede930a 100644 --- a/tests/test_runtime_paths.py +++ b/tests/test_runtime_paths.py @@ -82,6 +82,68 @@ class TestRuntimePaths(unittest.TestCase): self.assertTrue(os.path.normpath(data_dir).startswith(os.path.normpath(tmp))) self.assertIn("_test", data_dir.replace("\\", "/")) + def test_resolve_data_path_defaults_under_skill_data_dir(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + path = rp.resolve_data_path(None, "downloads", create=True) + norm = os.path.normcase(os.path.normpath(path)) + norm_tmp = os.path.normcase(os.path.normpath(tmp)) + self.assertTrue(norm.startswith(norm_tmp)) + self.assertTrue(norm.endswith(os.path.normcase(os.path.join("downloads")))) + + def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + os.environ["SKILL_DOWNLOAD_DIR"] = "downloads/videos" + from jiangchang_skill_core import config + + config.reset_cache() + importlib.reload(rp) + path = rp.resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads", create=False) + norm = os.path.normcase(os.path.normpath(path)) + self.assertIn(os.path.normcase("downloads"), norm) + self.assertIn(os.path.normcase("videos"), norm) + self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) + os.environ.pop("SKILL_DOWNLOAD_DIR", None) + config.reset_cache() + + def test_resolve_input_path_relative_under_imports(self) -> None: + with IsolatedDataRoot() as tmp: + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + resolved = rp.resolve_input_path("urls.txt", create_parent=True) + norm = os.path.normcase(os.path.normpath(resolved)) + self.assertIn(os.path.normcase("imports"), norm) + self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) + + def test_list_resolved_data_paths_keys(self) -> None: + with IsolatedDataRoot(): + import importlib + + import util.runtime_paths as rp + + importlib.reload(rp) + paths = rp.list_resolved_data_paths() + for key in ( + "skill_data_dir", + "downloads_dir", + "imports_dir", + "exports_dir", + ): + self.assertIn(key, paths) + self.assertTrue(paths[key]) + if __name__ == "__main__": unittest.main()