diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index b9c94fe..5ab345c 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -215,6 +215,28 @@ scripts/ `health` 应委托 `collect_runtime_diagnostics`,不要在 `scripts/util/` 自建 `runtime_diagnostics.py`。 +### 3.4 发布打包约束(PyArmor 与编码) + +release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前发布链路可能运行在 **PyArmor 免费/试用模式**,因此有两条**发布硬约束**: + +**单文件行数(仅 `scripts/**/*.py`)** + +- 每个 `scripts/` 下 Python 文件必须 **小于 1000 行**(`>= 1000` 可能在 CI 加密阶段失败)。 +- 推荐单文件控制在 **900 行以内**;接近上限时应拆分模块。 +- 不要把完整 RPA 主流程、DOM 选择器、数据库、CLI、日志全部堆进一个大文件。 +- 正确做法是拆分到 `scripts/cli/`、`scripts/service/`、`scripts/db/`、`scripts/util/`。 + +`examples/` 里的 Python 示例也建议保持模块化,但**行数限制不作为 examples 的发布硬约束**;不要对 README、HTML、测试数据等文档/资源套用 PyArmor 行数规则。 + +**文本编码(UTF-8 without BOM)** + +- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**。 +- Python 文件**绝对不能**带 UTF-8 BOM;否则 PyArmor/CI 可能报:`invalid non-printable character U+FEFF`。 +- Windows 编辑器容易保存成 UTF-8 BOM,提交前须确认文件开头无 `U+FEFF`。 +- 不要只写「UTF-8」,必须明确 **UTF-8 without BOM**。 + +本地可用 `python tests/run_tests.py release_packaging_constraints -v` 自检;默认必跑套件中的 `test_release_packaging_constraints.py` 会守护上述规则。 + ## 4. 开发一个新 skill 的标准步骤 下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 @@ -666,6 +688,8 @@ uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yam - [ ] `release.ps1` 存在 - [ ] `.github/workflows/release_skill.yaml` 存在 - [ ] `python tests/run_tests.py -v` 全部通过 +- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行) +- [ ] 源码/文档/配置为 **UTF-8 without BOM**(Python 文件不得含 `U+FEFF`) - [ ] 没有新增默认必跑测试访问真实网络或浏览器 - [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀 diff --git a/development/README.md b/development/README.md index 7764881..7e1f607 100644 --- a/development/README.md +++ b/development/README.md @@ -8,7 +8,7 @@ 4. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 5. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时 6. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 -7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径与诊断约定 +7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。 用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。 diff --git a/development/RUNTIME.md b/development/RUNTIME.md index f5e5688..33c6c07 100644 --- a/development/RUNTIME.md +++ b/development/RUNTIME.md @@ -108,6 +108,17 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均 ## 编码与输出 +- 所有提交到 skill 的源码、文档、配置文本文件必须是 **UTF-8 without BOM**(不要只写「UTF-8」)。 +- Python 文件**绝对不能**带 UTF-8 BOM;否则 release CI 的 PyArmor 阶段可能报:`invalid non-printable character U+FEFF`。 +- Windows 编辑器容易保存成 UTF-8 BOM;提交前确认文件开头无 `U+FEFF`。 - Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装 - 机读输出优先单行 JSON - 错误前缀建议统一 `ERROR:` + +## 发布打包约束(`scripts/**/*.py`) + +release workflow 加密 `scripts/` 源码时,可能运行在 PyArmor 免费/试用模式: + +- `scripts/**/*.py` 单文件必须 **< 1000 行**(`>= 1000` 可能在 CI 失败);推荐 **< 900 行**。 +- 接近上限时拆分到 `cli/`、`service/`、`db/`、`util/`,不要把 RPA 主流程、选择器、DB、CLI 堆进单文件。 +- 此行数限制**仅适用于** `scripts/**/*.py`;不限制 README 等文档行数,也不把 examples 里的 HTML/README 纳入 PyArmor 行数约束。 diff --git a/development/TESTING.md b/development/TESTING.md index 9a185a1..324438f 100644 --- a/development/TESTING.md +++ b/development/TESTING.md @@ -29,6 +29,7 @@ - `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py); - SQLite 骨架:`task_logs` 创建幂等与仓储读写; - **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。 +- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。 **原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。 @@ -224,6 +225,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t - [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py` - [ ] pytest **不会**误收集 `tests/` 下的 `.txt` / 日志 / 结果文件 - [ ] `python tests/run_tests.py -v` 全部通过 +- [ ] `scripts/**/*.py` 单文件 < 1000 行(`test_release_packaging_constraints.py`) +- [ ] 文本文件为 UTF-8 without BOM,无 `U+FEFF`(同上) --- diff --git a/tests/test_release_packaging_constraints.py b/tests/test_release_packaging_constraints.py new file mode 100644 index 0000000..93c4a4d --- /dev/null +++ b/tests/test_release_packaging_constraints.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""发布打包守护:PyArmor 单文件行数与 UTF-8 without BOM。""" +from __future__ import annotations + +import os +import unittest + +from _support import get_skill_root + +SCRIPTS_MAX_LINES = 1000 +SCRIPTS_RECOMMENDED_MAX_LINES = 900 + +TEXT_EXTENSIONS = frozenset( + { + ".py", + ".md", + ".yml", + ".yaml", + ".json", + ".toml", + ".ini", + ".txt", + ".ps1", + ".sh", + } +) + +SPECIAL_TEXT_FILES = frozenset({".env.example", ".gitignore"}) + +SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".pytest_cache", + "rpa-artifacts", + "videos", + } +) + +BINARY_EXTENSIONS = frozenset( + { + ".db", + ".sqlite", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".mp4", + ".zip", + ".tar", + ".gz", + } +) + + +def _is_text_candidate(path: str) -> bool: + basename = os.path.basename(path) + if basename in SPECIAL_TEXT_FILES: + return True + ext = os.path.splitext(basename)[1].lower() + if ext in BINARY_EXTENSIONS: + return False + return ext in TEXT_EXTENSIONS + + +def _iter_text_files(skill_root: str) -> list[str]: + found: list[str] = [] + for root, dirnames, filenames in os.walk(skill_root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + full = os.path.join(root, name) + if _is_text_candidate(full): + found.append(full) + return sorted(found) + + +def _iter_scripts_py_files(skill_root: str) -> list[str]: + scripts_dir = os.path.join(skill_root, "scripts") + found: list[str] = [] + if not os.path.isdir(scripts_dir): + return found + for root, dirnames, filenames in os.walk(scripts_dir): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + if name.endswith(".py"): + found.append(os.path.join(root, name)) + return sorted(found) + + +class TestReleasePackagingConstraints(unittest.TestCase): + def test_scripts_py_files_under_pyarmor_line_limit(self) -> None: + skill_root = get_skill_root() + offenders: list[str] = [] + for path in _iter_scripts_py_files(skill_root): + with open(path, encoding="utf-8") as f: + line_count = sum(1 for _ in f) + if line_count >= SCRIPTS_MAX_LINES: + rel = os.path.relpath(path, skill_root).replace("\\", "/") + offenders.append( + f"{rel}: {line_count} lines (limit < {SCRIPTS_MAX_LINES})" + ) + self.assertEqual( + offenders, + [], + msg=( + "PyArmor trial/free mode may fail when a single Python file reaches " + f"{SCRIPTS_MAX_LINES} lines; split this file into smaller modules. " + f"Recommended keep each file < {SCRIPTS_RECOMMENDED_MAX_LINES} lines. " + f"Offenders: {offenders}" + ), + ) + + def test_text_files_are_utf8_without_bom(self) -> None: + skill_root = get_skill_root() + bom_failures: list[str] = [] + decode_failures: list[str] = [] + feff_failures: list[str] = [] + + for path in _iter_text_files(skill_root): + rel = os.path.relpath(path, skill_root).replace("\\", "/") + with open(path, "rb") as f: + raw = f.read() + + if raw.startswith(b"\xef\xbb\xbf"): + bom_failures.append(rel) + continue + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + decode_failures.append(f"{rel}: {exc}") + continue + + if "\ufeff" in text: + feff_failures.append(rel) + + self.assertEqual( + bom_failures, + [], + msg=( + "Files must be saved as UTF-8 without BOM (remove UTF-8 BOM prefix). " + f"Offenders: {bom_failures}" + ), + ) + self.assertEqual( + decode_failures, + [], + msg=( + "Files must be valid UTF-8 without BOM. " + f"Offenders: {decode_failures}" + ), + ) + self.assertEqual( + feff_failures, + [], + msg=( + "Files must not contain U+FEFF; save as UTF-8 without BOM. " + f"Offenders: {feff_failures}" + ), + ) + + +if __name__ == "__main__": + unittest.main()