feat: 添加自启动机制,移除 --advice 参数

- 创建 bootstrap.py 承载实际 CLI 逻辑
- 重写 lyxy_document_reader.py 为轻量入口,自动检测依赖并启动
- 使用 subprocess.run() 实现跨平台兼容的自启动
- 移除 --advice 参数及相关测试
- 更新文档和规范,简化使用方式
This commit is contained in:
2026-03-11 23:49:39 +08:00
parent e67ec24dfd
commit 229f17bfee
8 changed files with 252 additions and 202 deletions

View File

@@ -10,17 +10,18 @@
- 使用 uv 运行脚本和测试,禁用主机 Python
- 依赖管理:使用 `uv run --with` 按需加载依赖
- 快速获取建议:使用 `-a/--advice` 参数查看执行命令
- 自启动机制:脚本自动检测依赖并用正确的 uv 命令执行
## 项目架构
```
scripts/
├── lyxy_document_reader.py # CLI 入口
├── lyxy_document_reader.py # CLI 入口(自启动)
├── bootstrap.py # 实际执行模块
├── config.py # 配置(含 DEPENDENCIES 依赖配置)
├── core/ # 核心模块
│ ├── parser.py # 解析调度
│ ├── advice_generator.py # --advice 执行建议生成
│ ├── advice_generator.py # 依赖检测和配置生成
│ ├── markdown.py # Markdown 工具
│ └── exceptions.py # 异常定义
├── readers/ # 格式阅读器
@@ -94,9 +95,9 @@ DEPENDENCIES = {
}
```
### --advice 生成机制
### 自启动机制
`--advice` 参数根据文件扩展名识别类型,检测当前平台,从 `config.DEPENDENCIES` 读取对应配置,生成 `uv run --with``pip install` 命令。
入口脚本根据文件扩展名识别类型,检测当前平台,从 `config.DEPENDENCIES` 读取对应配置,自动生成并执行正确的 `uv run --with` 命令。
## 快速开始
@@ -105,8 +106,8 @@ DEPENDENCIES = {
首先验证项目可以正常运行:
```bash
# 测试 --advice 功能(无需额外依赖
uv run python scripts/lyxy_document_reader.py test.pdf --advice
# 测试解析功能(自动检测依赖并执行
python scripts/lyxy_document_reader.py "https://example.com"
```
### 运行基础测试
@@ -115,7 +116,7 @@ uv run python scripts/lyxy_document_reader.py test.pdf --advice
# 运行 CLI 测试(验证项目基本功能)
uv run \
--with pytest \
pytest tests/test_cli/test_main.py::TestCLIAdviceOption -v
pytest tests/test_cli/ -v
```
## 开发指南
@@ -242,11 +243,6 @@ uv run \
--with pytest \
pytest tests/test_cli/test_main.py
# 仅运行 --advice 相关测试(不需要额外依赖)
uv run \
--with pytest \
pytest tests/test_cli/test_main.py::TestCLIAdviceOption
# 运行特定测试类或方法
uv run \
--with pytest \

View File

@@ -11,16 +11,17 @@ compatibility: Requires Python 3.11+。优先使用 lyxy-runner-python skill
### 执行路径选择(按优先级顺序)
1. **lyxy-runner-python skill首选** - 自动管理依赖
2. **uv run --with** - 按需加载依赖
3. **主机 Python + pip install** - 手动安装依赖
2. **python scripts/lyxy_document_reader.py** - 自启动,自动检测依赖
3. **uv run --with** - 手动指定依赖
4. **主机 Python + pip install** - 手动安装依赖
### 第一步:获取执行建议
### 推荐用法
```bash
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py --advice <文件路径或URL>
# 直接运行(自动检测依赖并执行)
python scripts/lyxy_document_reader.py <文件路径或URL>
```
这会输出准确的执行命令,包含所需的依赖配置。
*也可以使用:`python scripts/lyxy_document_reader.py --advice <文件路径或URL>`*
脚本会自动检测文件类型、当前平台,并用正确的 uv 命令执行。
## Purpose
@@ -50,7 +51,6 @@ PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py --advi
| 参数 | 说明 |
|------|------|
| `-a/--advice` | 仅显示执行建议(**必须先运行此命令** |
| (无) | 输出完整 Markdown |
| `-c/--count` | 字数统计 |
| `-l/--lines` | 行数统计 |
@@ -62,33 +62,28 @@ PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py --advi
## 参数使用示例
```bash
# 获取执行建议
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py --advice document.docx
# 读取全文
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx
# 读取全文(自动检测依赖)
python scripts/lyxy_document_reader.py document.docx
# 统计字数
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx -c
python scripts/lyxy_document_reader.py document.docx -c
# 提取标题
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx -t
python scripts/lyxy_document_reader.py document.docx -t
# 提取指定章节
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx -tc "第三章"
python scripts/lyxy_document_reader.py document.docx -tc "第三章"
# 搜索内容
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx -s "关键词"
python scripts/lyxy_document_reader.py document.docx -s "关键词"
# 正则搜索
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx -s "\d{4}-\d{2}-\d{2}"
python scripts/lyxy_document_reader.py document.docx -s "\d{4}-\d{2}-\d{2}"
# 指定搜索上下文行数
PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py document.docx -s "关键词" -n 5
python scripts/lyxy_document_reader.py document.docx -s "关键词" -n 5
```
*也可以使用纯 python 命令:`python scripts/lyxy_document_reader.py ...`*
## 错误处理
| 错误 | 原因 | 解决 |
@@ -98,4 +93,4 @@ PYTHONPATH=. uv run --with pyarmor python scripts/lyxy_document_reader.py docume
| 所有解析方法均失败 | 所有解析器失败 | 检查文件是否损坏 |
| 错误: 无效的正则表达式 | 正则语法错误 | 检查正则语法 |
| 错误: 未找到匹配 | 搜索无结果 | 检查搜索词或正则 |
| ModuleNotFoundError | 缺少依赖 | 使用 --advice 获取正确的依赖命令 |
| ModuleNotFoundError | 缺少依赖 | 脚本会自动检测并安装依赖 |

View File

@@ -1,11 +1,11 @@
## Purpose
CLI 执行建议生成功能,根据文件类型返回 uv 和 python 命令,帮助 AI 快速获取准确的执行建议,无需翻阅文档
CLI 自启动机制,自动检测文件类型、平台和依赖,用正确的 uv 命令执行脚本
## Requirements
### Requirement: 依赖配置结构
依赖配置必须同时包含 python 版本要求和依赖包列表,按文件类型和平台组织。
依赖配置必须同时包含 python 版本要求和依赖包列表,按文件类型和平台组织,供自启动逻辑内部使用
#### Scenario: 配置结构包含 python 和 dependencies
- **WHEN** 访问 `config.DEPENDENCIES`
@@ -19,17 +19,8 @@ CLI 执行建议生成功能,根据文件类型返回 uv 和 python 命令,
---
### Requirement: CLI 支持 --advice 参数
命令行工具必须支持 `-a/--advice` 参数,当指定该参数时不执行实际解析,仅输出执行建议。
#### Scenario: 用户指定 --advice 参数
- **WHEN** 用户执行 `scripts/lyxy_document_reader.py --advice <input_path>`
- **THEN** 工具输出执行建议,不解析文件内容
---
### Requirement: 轻量文件类型检测
`--advice` 参数必须复用 Reader 实例的 supports 方法识别文件类型,不打开文件。
自启动必须复用 Reader 实例的 supports 方法识别文件类型,不打开文件。
#### Scenario: 复用 Reader 实例
- **WHEN** 检测文件类型时
@@ -69,72 +60,70 @@ CLI 执行建议生成功能,根据文件类型返回 uv 和 python 命令,
#### Scenario: 不验证文件存在
- **WHEN** 输入路径指向不存在的文件
- **THEN** 仍根据 reader.supports() 返回建议,不报错
- **THEN** 仍根据 reader.supports() 识别类型,不报错
---
### Requirement: 平台检测
必须检测当前平台并返回适配的命令
必须检测当前平台并选择适配的依赖配置
#### Scenario: 检测平台格式
- **WHEN** 工具执行时
- **THEN** 返回格式为 `{system}-{machine}`,例如 `Darwin-arm64``Linux-x86_64``Windows-AMD64`
#### Scenario: macOS x86_64 PDF 特殊命令
#### Scenario: macOS x86_64 PDF 特殊配置
- **WHEN** 平台为 `Darwin-x86_64` 且文件类型为 PDF
- **THEN** 返回包含 `--python 3.12` 和特定版本依赖的命令
- **THEN** 使用包含 `--python 3.12` 和特定版本依赖的配置
---
### Requirement: 输出 uv 命令
必须输出使用 `uv run --with ...` 格式的命令
### Requirement: 自启动检测
脚本必须自动检测文件类型、当前平台和 uv 可用性,如 uv 可用则用正确的 uv 命令启动 bootstrap.py
#### Scenario: 检测文件类型
- **WHEN** 脚本启动时
- **THEN** 复用 Reader 的 supports() 方法识别文件类型
- **AND** 不打开文件,仅做轻量检测
#### Scenario: 检测平台
- **WHEN** 脚本启动时
- **THEN** 检测当前平台,格式为 `{system}-{machine}`
- **AND** 根据平台选择正确的依赖配置
#### Scenario: 检测 uv 是否可用
- **WHEN** 准备自启动前
- **THEN** 使用 `shutil.which("uv")` 检测 uv 是否在 PATH 中
- **AND** 如果 uv 不可用,降级为直接执行 bootstrap.py
---
### Requirement: 自启动执行
脚本必须使用 `subprocess.run()` 启动子进程,用正确的 uv 命令启动 bootstrap.py。
#### Scenario: 生成 uv 命令
- **WHEN** 检测到文件类型
- **THEN** 输出格式为:`uv run [--python X.Y] --with <dep1> --with <dep2> ... scripts/lyxy_document_reader.py <input_path>`
- **WHEN** 脚本确定需要自启动
- **THEN** 根据文件类型和平台获取依赖配置
- **AND** 生成 `uv run [--python X.Y] --with <dep1> --with <dep2> ... scripts/bootstrap.py <input_path>` 命令
- **AND** 目标脚本是 bootstrap.py不是 lyxy_document_reader.py
#### Scenario: 自启动设置环境变量
- **WHEN** 执行 `subprocess.run()` 自启动
- **THEN** 必须设置 `PYTHONPATH=.`
- **AND** 不需要设置 `LYXY_IN_UV`(自启动直接调用 bootstrap.py
- **AND** 必须传递退出码给父进程
#### Scenario: 静默自启动
- **WHEN** 脚本执行自启动
- **THEN** 不输出任何额外提示信息
- **AND** 不干扰正常的 Markdown 输出
---
### Requirement: 输出 python 命令
必须输出直接使用 python 的命令及 pip 安装命令
### Requirement: 降级执行
当 uv 不可用时,脚本必须降级为直接导入并执行 bootstrap.py
#### Scenario: 生成 python 命令
- **WHEN** 检测到文件类型
- **THEN** 输出 python 命令:`python scripts/lyxy_document_reader.py <input_path>`
- **AND** 输出 pip 安装命令:`pip install <dep1> <dep2> ...`
---
### Requirement: 输出格式规范
输出必须包含文件类型、输入路径、平台如需要、uv 命令、python 命令和 pip 安装命令。
#### Scenario: 普通平台输出格式
- **WHEN** 平台无特殊配置
- **THEN** 输出格式为:
```
文件类型: <type>
输入路径: <input>
[uv 命令]
<uv_command>
[python 命令]
python scripts/lyxy_document_reader.py <input>
pip install <deps>
```
#### Scenario: 特殊平台输出格式
- **WHEN** 平台有特殊配置
- **THEN** 输出格式为:
```
文件类型: <type>
输入路径: <input>
平台: <system-machine>
[uv 命令]
<uv_command>
[python 命令]
python scripts/lyxy_document_reader.py <input>
pip install <deps>
```
#### Scenario: uv 不可用时降级
- **WHEN** uv 不在 PATH 中
- **THEN** 脚本直接导入 bootstrap 模块
- **AND** 调用 bootstrap.run_normal() 执行
- **AND** 如果缺少依赖,输出正常的 `ModuleNotFoundError`

111
scripts/bootstrap.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""文档解析器实际执行模块,承载业务逻辑。"""
import argparse
import logging
import os
import sys
import warnings
from pathlib import Path
# 将 scripts/ 目录添加到 sys.path支持从任意位置执行脚本
scripts_dir = Path(__file__).resolve().parent
if str(scripts_dir) not in sys.path:
sys.path.append(str(scripts_dir))
# 抑制第三方库的进度条和日志,仅保留解析结果输出
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
os.environ["TQDM_DISABLE"] = "1"
warnings.filterwarnings("ignore")
# 配置日志系统,只输出 ERROR 级别
logging.basicConfig(level=logging.ERROR, format='%(levelname)s: %(message)s')
# 设置第三方库日志等级
logging.getLogger('docling').setLevel(logging.ERROR)
logging.getLogger('unstructured').setLevel(logging.ERROR)
from core import (
FileDetectionError,
ReaderNotFoundError,
output_result,
parse_input,
process_content,
)
from readers import READERS
def run_normal(args) -> None:
"""正常执行模式:解析文件并输出结果"""
# 实例化所有 readers
readers = [ReaderCls() for ReaderCls in READERS]
try:
content, failures = parse_input(args.input_path, readers)
except FileDetectionError as e:
print(f"错误: {e}")
sys.exit(1)
except ReaderNotFoundError as e:
print(f"错误: {e}")
sys.exit(1)
if content is None:
print("所有解析方法均失败:")
for failure in failures:
print(failure)
sys.exit(1)
# 处理内容
content = process_content(content)
# 输出结果
output_result(content, args)
def main() -> None:
"""主函数:解析命令行参数并执行"""
parser = argparse.ArgumentParser(
description="将 DOCX、XLS、XLSX、PPTX、PDF、HTML 文件或 URL 解析为 Markdown"
)
parser.add_argument("input_path", help="DOCX、XLS、XLSX、PPTX、PDF、HTML 文件或 URL")
parser.add_argument(
"-n",
"--context",
type=int,
default=2,
help="与 -s 配合使用,指定每个检索结果包含的前后行数(不包含空行)",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-c", "--count", action="store_true", help="返回解析后的 markdown 文档的总字数"
)
group.add_argument(
"-l", "--lines", action="store_true", help="返回解析后的 markdown 文档的总行数"
)
group.add_argument(
"-t",
"--titles",
action="store_true",
help="返回解析后的 markdown 文档的标题行1-6级",
)
group.add_argument(
"-tc",
"--title-content",
help="指定标题名称,输出该标题及其下级内容(不包含#号)",
)
group.add_argument(
"-s",
"--search",
help="使用正则表达式搜索文档,返回所有匹配结果(用---分隔)",
)
args = parser.parse_args()
run_normal(args)
if __name__ == "__main__":
main()

View File

@@ -1,56 +1,31 @@
#!/usr/bin/env python3
"""文档解析器命令行交互模块,提供命令行接口。支持 DOCX、XLS、XLSX、PPTX、PDF、HTML 和 URL。"""
"""文档解析器入口 - 环境检测和自启动"""
import argparse
import logging
import os
import shutil
import subprocess
import sys
import warnings
from pathlib import Path
# 将 scripts/ 目录添加到 sys.path,支持从任意位置执行脚本
# 将 scripts/ 目录添加到 sys.path
scripts_dir = Path(__file__).resolve().parent
if str(scripts_dir) not in sys.path:
sys.path.append(str(scripts_dir))
# 抑制第三方库的进度条和日志,仅保留解析结果输出
# 抑制第三方库日志
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
os.environ["TQDM_DISABLE"] = "1"
warnings.filterwarnings("ignore")
# 配置日志系统,只输出 ERROR 级别
logging.basicConfig(level=logging.ERROR, format='%(levelname)s: %(message)s')
# 设置第三方库日志等级
logging.getLogger('docling').setLevel(logging.ERROR)
logging.getLogger('unstructured').setLevel(logging.ERROR)
from core import (
FileDetectionError,
ReaderNotFoundError,
output_result,
parse_input,
process_content,
generate_advice,
)
from readers import READERS
def main() -> None:
def main():
"""主函数:环境检测和决策"""
# 解析命令行参数(轻量,仅识别必要参数)
parser = argparse.ArgumentParser(
description="将 DOCX、XLS、XLSX、PPTX、PDF、HTML 文件或 URL 解析为 Markdown"
)
parser.add_argument("input_path", help="DOCX、XLS、XLSX、PPTX、PDF、HTML 文件或 URL")
parser.add_argument(
"-a",
"--advice",
action="store_true",
help="仅显示执行建议,不实际解析文件",
)
parser.add_argument(
"-n",
"--context",
@@ -58,7 +33,6 @@ def main() -> None:
default=2,
help="与 -s 配合使用,指定每个检索结果包含的前后行数(不包含空行)",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-c", "--count", action="store_true", help="返回解析后的 markdown 文档的总字数"
@@ -85,39 +59,64 @@ def main() -> None:
args = parser.parse_args()
# 实例化所有 readers
readers = [ReaderCls() for ReaderCls in READERS]
# 检测 uv 是否可用
uv_path = shutil.which("uv")
# --advice 模式:仅显示建议,不解析
if args.advice:
advice = generate_advice(args.input_path, readers, "scripts/lyxy_document_reader.py")
if advice:
print(advice)
else:
print(f"错误: 无法识别文件类型: {args.input_path}")
sys.exit(1)
if not uv_path:
# uv 不可用,降级为直接执行 bootstrap.py
import bootstrap
bootstrap.run_normal(args)
return
try:
content, failures = parse_input(args.input_path, readers)
except FileDetectionError as e:
print(f"错误: {e}")
sys.exit(1)
except ReaderNotFoundError as e:
print(f"错误: {e}")
sys.exit(1)
# uv 可用,需要自启动
# 导入依赖检测模块
from config import DEPENDENCIES
from core.advice_generator import (
detect_file_type_light,
get_platform,
get_dependencies,
)
from readers import READERS
if content is None:
print("所有解析方法均失败:")
for failure in failures:
print(failure)
sys.exit(1)
# 检测文件类型
readers = [ReaderCls() for ReaderCls in READERS]
reader_cls = detect_file_type_light(args.input_path, readers)
# 处理内容
content = process_content(content)
if not reader_cls:
# 无法识别文件类型,降级执行让它报错
import bootstrap
bootstrap.run_normal(args)
return
# 输出结果
output_result(content, args)
# 获取平台和依赖配置
platform_id = get_platform()
python_version, dependencies = get_dependencies(reader_cls, platform_id)
# 生成 uv 命令参数列表
uv_args = ["uv", "run"]
if python_version:
uv_args.extend(["--python", python_version])
# 始终添加 pyarmor 依赖(混淆后脚本需要)
uv_args.extend(["--with", "pyarmor"])
for dep in dependencies:
uv_args.extend(["--with", dep])
# 目标脚本是 bootstrap.py
uv_args.append("scripts/bootstrap.py")
# 添加所有命令行参数
uv_args.extend(sys.argv[1:])
# 设置环境变量
env = os.environ.copy()
env["PYTHONPATH"] = "."
# 自启动:使用 subprocess 替代 execvpeWindows 兼容)
result = subprocess.run(uv_args, env=env)
sys.exit(result.returncode)
if __name__ == "__main__":

View File

@@ -29,7 +29,9 @@ def cli_runner():
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from lyxy_document_reader import main
# 直接调用 bootstrap.main() 而不是 lyxy_document_reader.main()
# 因为 lyxy_document_reader 会调用 subprocess无法捕获输出
from bootstrap import main
# 保存原始 sys.argv 和 sys.exit
original_argv = sys.argv
@@ -46,7 +48,7 @@ def cli_runner():
try:
# 设置命令行参数
sys.argv = ['lyxy_document_reader'] + args
sys.argv = ['bootstrap'] + args
sys.exit = mock_exit
# 捕获输出

View File

@@ -4,48 +4,6 @@ import pytest
import os
class TestCLIAdviceOption:
"""测试 CLI --advice 参数功能。"""
def test_advice_option_pdf(self, cli_runner):
"""测试 -a/--advice 选项对 PDF 文件。"""
stdout, stderr, exit_code = cli_runner(["test.pdf", "-a"])
assert exit_code == 0
assert "文件类型: PDF" in stdout
assert "[uv 命令]" in stdout
assert "[python 命令]" in stdout
def test_advice_option_docx(self, cli_runner):
"""测试 --advice 选项对 DOCX 文件。"""
stdout, stderr, exit_code = cli_runner(["test.docx", "--advice"])
assert exit_code == 0
assert "文件类型: DOCX" in stdout
def test_advice_option_url(self, cli_runner):
"""测试 --advice 选项对 URL。"""
stdout, stderr, exit_code = cli_runner(["https://example.com", "--advice"])
assert exit_code == 0
assert "文件类型: HTML" in stdout
def test_advice_option_unknown(self, cli_runner):
"""测试 --advice 选项对未知文件类型。"""
stdout, stderr, exit_code = cli_runner(["test.xyz", "--advice"])
assert exit_code != 0
output = stdout + stderr
assert "无法识别" in output or "错误" in output
def test_advice_option_xls(self, cli_runner):
"""测试 --advice 选项对 XLS 文件。"""
stdout, stderr, exit_code = cli_runner(["test.xls", "--advice"])
assert exit_code == 0
assert "文件类型: XLS" in stdout
class TestCLIDefaultOutput:
"""测试 CLI 默认输出功能。"""

View File

@@ -131,7 +131,7 @@ class TestGeneratePythonCommand:
script_path="scripts/lyxy_document_reader.py"
)
assert python_cmd == "python scripts/lyxy_document_reader.py input.pdf"
assert pip_cmd == "pip install pkg1 pkg2"
assert pip_cmd == "pip install pyarmor pkg1 pkg2"
class TestFormatAdvice: