book-to-skill tests quality 测试与质量门
把一本技术书或文档文件夹转成 Claude Code skill 的工具。值得拆的不是 README 里的承诺,而是它怎样处理多格式抽取、fallback、Unicode 清洗、输出目录权限和生成 skill 的扫描门禁。
测试覆盖的是格式边界,不是只有 smoke test
测试入口导入了核心函数和解析器:sources/book-to-skill/tests/test_book_to_skill.py:10 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:11 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:13 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:15 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:17,说明它不是纯 CLI 黑盒测试。EPUB、DOCX、RTF、PDF 和解析器 fallback 都有可单测接口,适合维护回归。
1import json\n2import sys\n3import textwrap\n4import zipfile\n5from pathlib import Path\n6from unittest import mock\n7import pytest\n8ROOT_DIR = Path(__file__).resolve().parent.parent\n9sys.path.insert(0, str(ROOT_DIR))\n10from book_to_skill.exceptions import ExtractionError\n11from book_to_skill.utils import resolve_input_files, extract_single_file, parse_arguments, estimate_tokens, detect_structure, _cn_numeral_to_int, main\n12from book_to_skill.config import SUPPORTED_EXTENSIONS\n13from book_to_skill.parsers import pdf as pdf_parser\n14from book_to_skill.parsers.text import read_text_file\n15from book_to_skill.parsers.docx import extract_docx_with_zipfile\n16from book_to_skill.parsers.rtf import strip_rtf_fallback\n17from book_to_skill.parsers.epub import extract_with_zipfile\n18
EPUB OPF 和 spine 顺序测试
▸构造最小 EPUB 和 OEBPS EPUB
sources/book-to-skill/tests/test_book_to_skill.py:31 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:32 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:34 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:50 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:53 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:54 直接构造 zip 内部结构,避免依赖外部 fixture;sources/book-to-skill/tests/test_book_to_skill.py:87 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:90 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:91 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:104 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:109 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:111 验证 OEBPS 路径、container.xml 和章节计数。
31def _make_minimal_epub(path: Path) -> Path:\n32 with zipfile.ZipFile(path, 'w') as zf:\n33 zf.writestr('mimetype', 'application/epub+zip')\n34 zf.writestr('content.opf', textwrap.dedent(' <?xml version="1.0"?>\n <package xmlns="http://www.idpf.org/2007/opf" version="3.0">\n <metadata/>\n <manifest>\n <item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>\n </manifest>\n <spine>\n <itemref idref="ch1"/>\n </spine>\n </package>\n '))\n35 zf.writestr('chapter1.xhtml', '<html><body><p>EPUB chapter one content.</p></body></html>')\n36 return path\n37\n38def _make_minimal_docx(path: Path) -> Path:\n39 ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'\n40 xml = textwrap.dedent(f' <?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n <w:document xmlns:w="{ns}">\n <w:body>\n <w:p><w:r><w:t>DOCX test paragraph</w:t></w:r></w:p>\n </w:body>\n </w:document>\n ')\n41 with zipfile.ZipFile(path, 'w') as zf:\n42 zf.writestr('word/document.xml', xml)\n43 zf.writestr('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>')\n44 return path\n45\n46def _make_unsupported_file(path: Path) -> Path:\n47 path.write_bytes(b'unsupported binary junk data')\n48 return path\n49\n50def _make_oebps_epub(path: Path) -> Path:\n51 with zipfile.ZipFile(path, 'w') as zf:\n52 zf.writestr('mimetype', 'application/epub+zip')\n53 zf.writestr('META-INF/container.xml', textwrap.dedent(' <?xml version="1.0"?>\n <container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"\n version="1.0">\n <rootfiles>\n <rootfile full-path="OEBPS/content.opf"\n media-type="application/oebps-package+xml"/>\n </rootfiles>\n </container>\n '))\n54 zf.writestr('OEBPS/content.opf', textwrap.dedent(' <?xml version="1.0"?>\n <package xmlns="http://www.idpf.org/2007/opf" version="3.0">\n <metadata/>\n <manifest>\n <item id="ch1" href="sections/ch1.xhtml" media-type="application/xhtml+xml"/>\n <item id="ch2" href="sections/ch2.xhtml" media-type="application/xhtml+xml"/>\n </manifest>\n <spine>\n <itemref idref="ch1"/>\n <itemref idref="ch2"/>\n </spine>\n </package>\n '))\n55 zf.writestr('OEBPS/sections/ch1.xhtml', '<html><body><p>Chapter one from OEBPS.</p></body></html>')\n56 zf.writestr('OEBPS/sections/ch2.xhtml', '<html><body><p>Chapter two from OEBPS.</p></body></html>')\n57 return path
▸fallback 不再 tuple unpack
sources/book-to-skill/tests/test_book_to_skill.py:66 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:69 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:70 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:76 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:80 这一处之后,sources/book-to-skill/tests/test_book_to_skill.py:82 锁定此前的 EPUB regression;这类测试对格式 parser 工具比 benchmark 更重要,因为用户输入经常来自真实电子书。
59class TestEpubExtractionFix:\n60\n61 def test_epub_extract_with_ebooklib_returns_str_or_none(self):\n62 from book_to_skill.parsers.epub import extract_with_ebooklib\n63 result = extract_with_ebooklib('nonexistent.epub')\n64 assert result is None or isinstance(result, str), f'extract_with_ebooklib should return str|None, got {type(result)}'\n65\n66 def test_epub_extraction_via_zipfile_fallback(self, tmp_path):\n67 epub_path = _make_minimal_epub(tmp_path / 'test.epub')\n68 with mock.patch('book_to_skill.utils.prepare_dependencies'):\n69 result = extract_single_file(epub_path, 'text', 'no')\n70 assert result['format'] == 'epub'\n71 assert result['extraction_method'] in ('ebooklib', 'zipfile')\n72 assert 'EPUB chapter one content' in result['text']\n73 assert result['chars'] > 0\n74 assert result['words'] > 0\n75\n76 def test_epub_no_tuple_unpack_error(self, tmp_path):\n77 epub_path = _make_minimal_epub(tmp_path / 'test.epub')\n78 with mock.patch('book_to_skill.utils.prepare_dependencies'):\n79 try:\n80 result = extract_single_file(epub_path, 'text', 'no')\n81 except (TypeError, ValueError) as exc:\n82 pytest.fail(f'Tuple-unpack regression! Got: {exc}')\n83 assert result['text']
Unicode 和输出目录安全测试
▸清洗前后指标一致
sources/book-to-skill/tests/test_sanitize_extracted_text.py:10 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:11 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:13 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:22 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:27 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:31 确认隐形字符被移除、stderr 会报告数量,且 chars/words 用清洗后的文本计算;sources/book-to-skill/tests/test_sanitize_extracted_text.py:40 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:51 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:55 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:56 这一处之后,sources/book-to-skill/tests/test_sanitize_extracted_text.py:57 还验证主流程写出的 full_text 与 metadata 不含隐形字符。
10def test_sanitizer_removes_zero_width_and_tag_block_codepoints():\n11 sanitized, removed = sanitize_extracted_text(f'before{INVISIBLE_CODEPOINTS}after')\n12 assert sanitized == 'beforeafter'\n13 assert removed == len(INVISIBLE_CODEPOINTS)\n14\n15def test_sanitizer_preserves_normal_multilingual_text_byte_for_byte():\n16 original = 'Café - 中文 - 🙂 - plain ASCII\n'\n17 sanitized, removed = sanitize_extracted_text(original)\n18 assert sanitized == original\n19 assert sanitized.encode('utf-8') == original.encode('utf-8')\n20 assert removed == 0\n21\n22def test_extract_single_file_sanitizes_before_metrics(tmp_path, capsys):\n23 source = tmp_path / 'source.txt'\n24 source.write_text('No customer PII\u200b leaves the repo.\U000e0069', encoding='utf-8')\n25 with mock.patch('book_to_skill.utils.prepare_dependencies'):\n26 result = extract_single_file(source, 'text', 'no')\n27 assert result['text'] == 'No customer PII leaves the repo.'\n28 assert result['chars'] == len(result['text'])\n29 assert result['words'] == 6\n30 captured = capsys.readouterr()\n31 assert 'removed 2 invisible Unicode code point(s)' in captured.err\n32\n33def test_extract_single_file_rejects_invisible_only_source(tmp_path):\n34 source = tmp_path / 'invisible.txt'\n35 source.write_text(INVISIBLE_CODEPOINTS, encoding='utf-8')\n36 with mock.patch('book_to_skill.utils.prepare_dependencies'):\n37 with pytest.raises(ExtractionError, match='no visible content'):\n38 extract_single_file(source, 'text', 'no')\n39\n40def test_main_writes_only_sanitized_text_and_metrics(tmp_path, monkeypatch):\n41 source = tmp_path / 'source.txt'\n42 source.write_text('Chapter 1\nPolicy\u200b text.\U000e0069', encoding='utf-8')\n43 output_dir = tmp_path / 'output'\n44 output_text = output_dir / 'full_text.txt'\n45 output_meta = output_dir / 'metadata.json'\n46 monkeypatch.setattr(sys, 'argv', ['extract.py', str(source)])\n47 monkeypatch.setattr('book_to_skill.utils.OUTPUT_DIR', output_dir)\n48 monkeypatch.setattr('book_to_skill.utils.OUTPUT_TEXT', output_text)\n49 monkeypatch.setattr('book_to_skill.utils.OUTPUT_META', output_meta)\n50 monkeypatch.setattr('book_to_skill.utils.prepare_dependencies', lambda *args: None)\n51 main()\n52 extracted = output_text.read_text(encoding='utf-8')\n53 metadata = json.loads(output_meta.read_text(encoding='utf-8'))\n54 expected_source, _ = sanitize_extracted_text(source.read_bytes().decode('utf-8'))\n55 assert 'Policy text.' in extracted\n56 assert all((character not in extracted for character in INVISIBLE_CODEPOINTS))\n57 assert metadata['chars'] == len(extracted)\n58 assert metadata['sources'][0]['chars'] == len(expected_source)
▸输出目录不是随便写
sources/book-to-skill/tests/test_output_dir_security.py:7 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:11 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:13 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:18 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:21 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:24 覆盖 0700 权限、symlink 拒绝和非目录拒绝;sources/book-to-skill/tests/test_output_dir_security.py:27 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:31 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:35 这一处之后,sources/book-to-skill/tests/test_output_dir_security.py:40 覆盖已有目录权限收紧和异主目录拒绝。
7def test_prepare_output_dir_creates_dir_with_restrictive_permissions(tmp_path):\n8 target = tmp_path / 'work'\n9 prepare_output_dir(target)\n10 assert target.is_dir()\n11 assert stat.S_IMODE(target.stat().st_mode) == 448\n12\n13def test_prepare_output_dir_rejects_symlink(tmp_path):\n14 real_dir = tmp_path / 'real'\n15 real_dir.mkdir()\n16 link = tmp_path / 'work'\n17 link.symlink_to(real_dir)\n18 with pytest.raises(ExtractionError, match='symbolic link'):\n19 prepare_output_dir(link)\n20\n21def test_prepare_output_dir_rejects_non_directory(tmp_path):\n22 target = tmp_path / 'work'\n23 target.write_text('not a directory')\n24 with pytest.raises(ExtractionError, match='not a directory'):\n25 prepare_output_dir(target)\n26\n27def test_prepare_output_dir_tightens_permissions_on_existing_own_dir(tmp_path):\n28 target = tmp_path / 'work'\n29 target.mkdir()\n30 os.chmod(target, 511)\n31 prepare_output_dir(target)\n32 assert stat.S_IMODE(target.stat().st_mode) == 448\n33\n34@pytest.mark.skipif(not hasattr(os, 'getuid'), reason='POSIX-only ownership check')\n35def test_prepare_output_dir_rejects_directory_owned_by_another_user(tmp_path, monkeypatch):\n36 target = tmp_path / 'work'\n37 target.mkdir()\n38 real_getuid = os.getuid\n39 monkeypatch.setattr(os, 'getuid', lambda: real_getuid() + 1)\n40 with pytest.raises(ExtractionError, match='owned by a different user'):\n41 prepare_output_dir(target)
生成 skill 的扫描门禁
sources/book-to-skill/tools/scan_generated_skill.py:16 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:17 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:18 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:19 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:113 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:122 定义 prompt override、fake system prefix、tool_call 和 exfiltration shape;sources/book-to-skill/tools/scan_generated_skill.py:134 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:140 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:149 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:159 这一处之后,sources/book-to-skill/tools/scan_generated_skill.py:166 汇总 finding 并以非零退出提醒用户复查。
113def _scan_text(relative_path: str, text: str) -> list[Finding]:\n114 findings: list[Finding] = []\n115 lines = text.splitlines()\n116 frontmatter_lines = _frontmatter_line_numbers(lines)\n117 for line_number, line in enumerate(lines, start=1):\n118 invisible = sorted({ord(char) for char in line if _is_invisible(ord(char))})\n119 if invisible:\n120 codepoints = ', '.join((f'U+{value:04X}' for value in invisible))\n121 findings.append(Finding(relative_path, line_number, 'unicode.invisible', f'contains invisible Unicode code point(s): {codepoints}'))\n122 for rule_id, pattern, message in _CONTENT_RULES:\n123 if pattern.search(line):\n124 findings.append(Finding(relative_path, line_number, rule_id, message))\n125 if _EXFILTRATION_TERM.search(line) or (_OUTBOUND_TERM.search(line) and _SENSITIVE_TERM.search(line)):\n126 findings.append(Finding(relative_path, line_number, 'tool.exfiltration_shape', 'contains exfiltration-shaped tool or sensitive-data language'))\n127 if line_number in frontmatter_lines:\n128 if re.match('^\\s*allowed-tools\\s*:', line, re.IGNORECASE):\n129 findings.append(Finding(relative_path, line_number, 'frontmatter.allowed_tools', 'generated frontmatter declares or widens tool authority'))\n130 if re.match('^\\s*disable-model-invocation\\s*:\\s*[\\"\']?(?:false|no|0)[\\"\']?\\s*(?:#.*)?$', line, re.IGNORECASE):\n131 findings.append(Finding(relative_path, line_number, 'frontmatter.model_invocation_enabled', 'generated frontmatter explicitly enables model invocation'))\n132 return findings\n133\n134def scan_generated_skill(path: Path) -> list[Finding]:\n135 requested = path.expanduser()\n136 skill_dir = requested.parent if requested.name.lower() == 'skill.md' else requested\n137 files = _collect_skill_files(requested)\n138 root = skill_dir.resolve(strict=True)\n139 findings: list[Finding] = []\n140 for relative_path, text in _read_skill_files(root, files):\n141 findings.extend(_scan_text(relative_path, text))\n142 return sorted(findings, key=lambda item: (item.path.lower(), item.line, item.rule_id))\n143\n144def main(argv: Sequence[str] | None=None) -> int:\n145 parser = argparse.ArgumentParser(description=__doc__)\n146 parser.add_argument('path', help='Generated skill directory or its SKILL.md')\n147 args = parser.parse_args(argv)\n148 try:\n149 findings = scan_generated_skill(Path(args.path))\n150 skipped = unscanned_markdown(Path(args.path))\n151 except ScanError as exc:\n152 print(f'ERROR generated-skill scan incomplete: {exc}', file=sys.stderr)\n153 return 2\n154 if skipped:\n155 print(f'Note: {len(skipped)} Markdown file(s) in the skill directory are outside the generated-skill scope and were NOT scanned:')\n156 for relative in skipped:\n157 print(f' SKIP {_terminal_safe(relative)}')\n158 print(' Scope is SKILL.md, glossary/patterns/cheatsheet, and chapters/. Move generated content there to have it scanned.')\n159 if findings:\n160 print(f'Generated-skill scan found {len(findings)} advisory finding(s):')\n161 for finding in findings:\n162 print(f' WARN {_terminal_safe(finding.path)}:{finding.line} [{finding.rule_id}] {finding.message}')\n163 print('Review the generated files before loading, installing, or publishing them.')\n164 print('Rules are intentionally broad and may match legitimate AI/LLM or systems-topic text; review each finding in context.')\n165 print('No files were modified by this scan.')\n166 return 1\n167 scope = ' in the scanned scope' if skipped else ''\n168 print(f'Generated-skill scan passed: no known injection or authority patterns found{scope}.')\n169 return 0
关联模块
sources/book-to-skill/book_to_skill/utils.py:414 是 CLI 转换主入口。sources/book-to-skill/tools/validate_skill.py:48 校验生成 skill 的 frontmatter。