Spaces:
Running on Zero
Running on Zero
Simplify Space layout and use CPU-only ONNX runtime
Browse files- .gitattributes +0 -2
- .gitignore +2 -0
- README.md +23 -32
- VALIDATION.md +0 -74
- app.py +1 -1
- benchmark_space.py +0 -137
- mineru_space/__init__.py +3 -0
- bootstrap.py → mineru_space/bootstrap.py +15 -39
- pdf_sidecar.py → mineru_space/pdf_sidecar.py +0 -0
- runtime-requirements.txt → mineru_space/runtime-requirements.txt +1 -1
- runtime_preflight.py → mineru_space/runtime_preflight.py +6 -12
- space_app.py → mineru_space/space_app.py +2 -2
- space_runtime.py → mineru_space/space_runtime.py +1 -3
- tests/test_bootstrap.py +0 -112
- tests/test_space.py +0 -244
.gitattributes
CHANGED
|
@@ -48,6 +48,4 @@ examples/epub_01.epub filter=lfs diff=lfs merge=lfs -text
|
|
| 48 |
examples/epub_02.epub filter=lfs diff=lfs merge=lfs -text
|
| 49 |
examples/pptx_01.odp filter=lfs diff=lfs merge=lfs -text
|
| 50 |
examples/xlsx_01.ods filter=lfs diff=lfs merge=lfs -text
|
| 51 |
-
docx_01.doc filter=lfs diff=lfs merge=lfs -text
|
| 52 |
-
docx_01.docx filter=lfs diff=lfs merge=lfs -text
|
| 53 |
examples/docx_01.odt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 48 |
examples/epub_02.epub filter=lfs diff=lfs merge=lfs -text
|
| 49 |
examples/pptx_01.odp filter=lfs diff=lfs merge=lfs -text
|
| 50 |
examples/xlsx_01.ods filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 51 |
examples/docx_01.odt filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -4,3 +4,5 @@ __pycache__/
|
|
| 4 |
output/
|
| 5 |
tmp/
|
| 6 |
.ruff_cache/
|
|
|
|
|
|
|
|
|
| 4 |
output/
|
| 5 |
tmp/
|
| 6 |
.ruff_cache/
|
| 7 |
+
.DS_Store
|
| 8 |
+
.zcode/
|
README.md
CHANGED
|
@@ -23,35 +23,26 @@ Paper: https://huggingface.co/papers/2409.18839
|
|
| 23 |
|
| 24 |
## ZeroGPU runtime
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
The maximum is Basic and
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
`MINERU_TABLE_DEVICE=cpu`. Set the latter to `cuda` to test table CUDA inference;
|
| 50 |
-
sessions are created only inside the GPU worker and report their actual provider.
|
| 51 |
-
`MINERU_PROFILE_STAGES=1` enables wall-clock stage logs (no extra CUDA synchronization).
|
| 52 |
-
The `dispatch_to_entry_s` metric includes scheduling and worker preparation; it
|
| 53 |
-
is not a pure GPU queue-time measurement. GPU return and parent completion
|
| 54 |
-
timestamps expose result-transfer overhead.
|
| 55 |
-
|
| 56 |
-
Cancellation stops delivering the old result and waits for the underlying GPU
|
| 57 |
-
call before releasing its slot. It does not claim to interrupt native inference.
|
|
|
|
| 23 |
|
| 24 |
## ZeroGPU runtime
|
| 25 |
|
| 26 |
+
`app.py` launches the code in `mineru_space/`. Flash TXT and native Office/HTML/OFD
|
| 27 |
+
inputs run in a CPU/PDFium sidecar; Flash OCR and all Basic PDF requests use
|
| 28 |
+
ZeroGPU. The maximum is Basic, with 20 pages and a 60-second GPU parse watchdog.
|
| 29 |
+
Cancellation waits for native work to finish before releasing the request slot.
|
| 30 |
+
|
| 31 |
+
The Space image supplies Python 3.12. The launcher reuses that interpreter to
|
| 32 |
+
create an isolated environment, installs MinerU's exact dev HEAD, verifies it,
|
| 33 |
+
and activates it only after success. Failed updates keep an existing compatible
|
| 34 |
+
runtime; no usable runtime means startup fails clearly. The cache defaults to
|
| 35 |
+
`~/.cache/mineru-space-runtime` (`MINERU_RUNTIME_CACHE` overrides it), outside
|
| 36 |
+
the checkout, and follows the Space's storage lifetime. Current and previous
|
| 37 |
+
runtime manifests are retained for rollback.
|
| 38 |
+
|
| 39 |
+
The root `requirements.txt` installs launcher dependencies. The application
|
| 40 |
+
pins are in `mineru_space/runtime-requirements.txt`. Only CPU `onnxruntime` is
|
| 41 |
+
installed; table inference is fixed to CPU with default intra/inter-op threads
|
| 42 |
+
of 4/1 (`MINERU_INTRA_OP_NUM_THREADS` / `MINERU_INTER_OP_NUM_THREADS`). Torch
|
| 43 |
+
models still use CUDA and preload through ZeroGPU's emulation at startup.
|
| 44 |
+
PDFium and ONNX sessions are not initialized in that parent process.
|
| 45 |
+
|
| 46 |
+
`MINERU_PROFILE_STAGES=1` enables stage timings. Dispatch-to-entry time includes
|
| 47 |
+
GPU scheduling and worker preparation. GPU-worker PDF rendering is serial;
|
| 48 |
+
its deadline is the whole-request watchdog, not a separate render timeout.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
VALIDATION.md
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 1 |
-
# ZeroGPU 验证记录(2026-09-20)
|
| 2 |
-
|
| 3 |
-
性能对照的 MinerU 核心提交:`9168ac7b178035d77c7be2338b09fe6d281c81fb`。
|
| 4 |
-
在 opendatalab/MinerU devmode 的独立进程、隔离 Python 环境和测试端口执行;性能测试期间正式服务保持原版本。
|
| 5 |
-
|
| 6 |
-
## 环境与性能
|
| 7 |
-
|
| 8 |
-
实际 GPU:NVIDIA RTX PRO 6000 Blackwell Server Edition MIG 2g.48gb。
|
| 9 |
-
容器 CPU quota 为 16 核,进程可见 192 个逻辑核。Torch 2.13.0 CUDA 13,spaces 0.51.3。
|
| 10 |
-
基线为 Python 3.10.13 / CPU ORT 1.23.2;候选为 Python 3.12.14 / ORT GPU 1.29.0 / DocVortex 0.4.15。
|
| 11 |
-
CPU 和 CUDA 候选使用同一套新环境。下表包含调度、worker 准备、解析和结果 IPC,不包含启动期预加载与输出持久化。
|
| 12 |
-
|
| 13 |
-
`demo1.pdf`,13 页,Basic + Auto(实际 TXT):
|
| 14 |
-
|
| 15 |
-
| 配置 | 第一次 GPU 调用 | 第二次 | 第三次 |
|
| 16 |
-
| --- | ---: | ---: | ---: |
|
| 17 |
-
| 原始版本 | 38.453 s | 36.108 s | 35.264 s |
|
| 18 |
-
| 原环境,仅限制 ONNX 为 4 线程 | 23.078 s | 9.390 s | 9.559 s |
|
| 19 |
-
| 新运行时 + 4 线程 + Torch 预加载 | 13.930 s | 7.984 s | 7.842 s |
|
| 20 |
-
| 同一候选,表格 CUDA | 20.382 s | 8.046 s | 7.595 s |
|
| 21 |
-
|
| 22 |
-
基线三个请求使用不同 worker;其他组本次均复用了各自的 worker。不能保证未来请求一定复用。
|
| 23 |
-
CUDA 第一次调用有 9.346 s 的调度/worker 准备时间,不能将这部分归因于模型推理。
|
| 24 |
-
去掉该部分后首次解析分别为 CPU 12.788 s、CUDA 10.859 s,仍存在不同阶段冷缓存的影响。
|
| 25 |
-
|
| 26 |
-
更直接的表格阶段对照:首次 CPU 1.120 s、CUDA 1.383 s;后两次 CPU 0.460/0.507 s、CUDA 0.486/0.453 s。
|
| 27 |
-
未观察到稳定的整份文档收益达到既定启用门槛,所以 Space 默认 `MINERU_TABLE_DEVICE=cpu`,保留 CUDA 开关。
|
| 28 |
-
新配置的正文回填约 3.2–3.5 s,是后续进一步分析的主要 CPU 阶段之一。
|
| 29 |
-
|
| 30 |
-
## CUDA 确认与质量
|
| 31 |
-
|
| 32 |
-
完整请求的 ONNX profiling 已确认三个表格模型真实执行 CUDA 节点:
|
| 33 |
-
|
| 34 |
-
| 模型 | CUDA 节点执行数 | CPU 节点执行数 |
|
| 35 |
-
| --- | ---: | ---: |
|
| 36 |
-
| 表格分类 | 102 | 2 |
|
| 37 |
-
| SLANet | 21209 | 1822 |
|
| 38 |
-
| UNet | 198 | 30 |
|
| 39 |
-
|
| 40 |
-
CPU 节点为图中保留的 CPU 运算;不是整个会话静默回退。
|
| 41 |
-
四组全部 12 次解析的 ModelJson pages、MiddleJson pages、布局元数据和 Markdown 与基线完全相同。
|
| 42 |
-
三组首次结果生成 PDF 后,13 页逐页以 900 px 栅格化,CPU 候选和 CUDA 候选均与基线像素完全一致。
|
| 43 |
-
人工检查了第五页旋转表格及布局标框,方向和归属保持一致。
|
| 44 |
-
已有基线限制:第五页竖向表格说明在重建 PDF 中仍有窄列换行,本次未改变其排版。
|
| 45 |
-
|
| 46 |
-
## 路由、界面与故障
|
| 47 |
-
|
| 48 |
-
- 28 个 Space 测试在 macOS 和 Linux Python 3.12 均通过;补充启动器显式选择 Python 3.12 的测试后,macOS 共 29 个通过。覆盖完整分流矩阵、混合 PDF 选页分类、20 页约束、取消及更新事务。
|
| 49 |
-
- 173 个相关 MinerU 测试通过,包含 ONNX 模型栈、表格自动选择及回退、Flash 调度、模型预加载、内存生命周期、公式/表格路由及异步运行时。其中自动选择变更后重跑受影响组 66 个通过,其余 107 个已在前一提交验证。
|
| 50 |
-
- 新增代码 Ruff 通过;旧表格模块原有的 91 个类型注解告警数量和规则均未增加。
|
| 51 |
-
- 浏览器实际完成 Flash TXT,日志 `use_gpu=false`,没有 GPU 入口事件;经过 bootstrap 启动的 Basic 默认路径也完成,界面显示约 14.9 s。
|
| 52 |
-
- `caibao1.pdf` 前 20 页 Basic 完成,GPU 调用约 16.23 s;页号保持 0–19。Flash OCR 和 Basic OCR 扫描件分别完成。
|
| 53 |
-
- 真实候选环境安装并校验精确 dev SHA;同 SHA 再启动跳过安装;注入网络失败和安装失败均返回同一个可用旧环境。
|
| 54 |
-
- 平台 `duration=1` 的测试仍运行约 10.6 s 才返回,说明不能将该参数当成严格的应用超时。
|
| 55 |
-
- 新增看门狗在真实 GPU worker 中用 0.2 s 测试预算触发退出,主进程继续存活,后续请求成功重建 worker。
|
| 56 |
-
- 另行注入 worker 异常退出,平台返回 `GPU task aborted` 后下一次调用成功。
|
| 57 |
-
|
| 58 |
-
## 表格自动选择补充
|
| 59 |
-
|
| 60 |
-
按后续确认的范围,核心提交 `bf8a518d235eef420275e446ecfc59a598a0260a` 将表格默认设备改为 `auto`,仅覆盖表格分类、SLANet 和 UNet。安装兼容 GPU ORT 后优先选择可用 CUDA provider,失败记录原因并回退 CPU;Layout、OCR、公式的 ONNX 后端保持 CPU。
|
| 61 |
-
|
| 62 |
-
独立 ZeroGPU 请求未设置 `MINERU_TABLE_DEVICE`,三个实际模型均返回 `CUDAExecutionProvider`,并完成真实分类推理;正式 Space 仍显式设置 `cpu`。Space 重启已自动安装该精确 dev SHA,通过依赖校验并完成 Torch 预加载。Devmode 保持开启,SSH 可用。
|
| 63 |
-
|
| 64 |
-
## 复现
|
| 65 |
-
|
| 66 |
-
```bash
|
| 67 |
-
python -m pytest -q tests
|
| 68 |
-
python benchmark_space.py --variant preload --pdf examples/demo1.pdf --repeats 3 --output output/bench-cpu
|
| 69 |
-
python benchmark_space.py --variant cuda --pdf examples/demo1.pdf --repeats 3 --output output/bench-cuda
|
| 70 |
-
python benchmark_space.py --variant cuda --pdf examples/demo1.pdf --repeats 1 --profile --output output/bench-profile
|
| 71 |
-
```
|
| 72 |
-
|
| 73 |
-
每个性能组使用独立父进程,并串行运行。profiling 单独执行,不把其额外开销算进性能对照。
|
| 74 |
-
完整本地日志和样例产物保存在工作区 `output/validation/`,未提交大体积生成文件。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Space 启动入口;spawn 导入本模块时不会更新依赖或初始化模型。"""
|
| 2 |
|
| 3 |
if __name__ == "__main__":
|
| 4 |
-
from bootstrap import main
|
| 5 |
|
| 6 |
main()
|
|
|
|
| 1 |
"""Space 启动入口;spawn 导入本模块时不会更新依赖或初始化模型。"""
|
| 2 |
|
| 3 |
if __name__ == "__main__":
|
| 4 |
+
from mineru_space.bootstrap import main
|
| 5 |
|
| 6 |
main()
|
benchmark_space.py
DELETED
|
@@ -1,137 +0,0 @@
|
|
| 1 |
-
"""在独立进程中对照 ZeroGPU 冷启动、连续请求和表格 provider,不修改正式服务。"""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import argparse
|
| 6 |
-
import json
|
| 7 |
-
import os
|
| 8 |
-
import time
|
| 9 |
-
from pathlib import Path
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def parse_and_profile(
|
| 13 |
-
path: str,
|
| 14 |
-
*,
|
| 15 |
-
tier: str,
|
| 16 |
-
ocr_mode: str,
|
| 17 |
-
page_range: str,
|
| 18 |
-
request_id: str,
|
| 19 |
-
submitted_at: float,
|
| 20 |
-
):
|
| 21 |
-
"""在 GPU 租约内解析并收集已创建 ONNX 会话的执行轨迹。"""
|
| 22 |
-
import torch
|
| 23 |
-
from space_runtime import gpu_task_entry, log_event
|
| 24 |
-
|
| 25 |
-
log_event("benchmark.device", device=torch.cuda.get_device_name())
|
| 26 |
-
result = gpu_task_entry(
|
| 27 |
-
path,
|
| 28 |
-
tier=tier,
|
| 29 |
-
ocr_mode=ocr_mode,
|
| 30 |
-
page_range=page_range,
|
| 31 |
-
request_id=request_id,
|
| 32 |
-
submitted_at=submitted_at,
|
| 33 |
-
)
|
| 34 |
-
if os.getenv("MINERU_ONNX_PROFILE_DIR"):
|
| 35 |
-
from mineru.model.runtime.hybrid import AtomModelSingleton
|
| 36 |
-
|
| 37 |
-
for model in AtomModelSingleton._models.values():
|
| 38 |
-
# 三类表格包装器拥有不同属性,按确定的对象结构取出真实 ORT session。
|
| 39 |
-
if hasattr(model, "sess"):
|
| 40 |
-
session = model.sess
|
| 41 |
-
elif hasattr(model, "table_model"):
|
| 42 |
-
session = model.table_model.table_structure.session.session
|
| 43 |
-
elif hasattr(model, "wired_table_model"):
|
| 44 |
-
session = model.wired_table_model.table_structure.session.session
|
| 45 |
-
else:
|
| 46 |
-
continue
|
| 47 |
-
if hasattr(session, "end_profiling"):
|
| 48 |
-
log_event(
|
| 49 |
-
"benchmark.onnx_profile",
|
| 50 |
-
path=session.end_profiling(),
|
| 51 |
-
providers=session.get_providers(),
|
| 52 |
-
)
|
| 53 |
-
return result
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def main() -> None:
|
| 57 |
-
"""每个对照组使用新父进程;PDF 持久化始终放在 sidecar 中避免污染后续 fork。"""
|
| 58 |
-
parser = argparse.ArgumentParser()
|
| 59 |
-
parser.add_argument(
|
| 60 |
-
"--variant", choices=["baseline", "threads", "preload", "cuda"], required=True
|
| 61 |
-
)
|
| 62 |
-
parser.add_argument("--pdf", default="/app/examples/demo1.pdf")
|
| 63 |
-
parser.add_argument("--tier", default="basic")
|
| 64 |
-
parser.add_argument("--ocr-mode", default="auto")
|
| 65 |
-
parser.add_argument("--page-range", default="")
|
| 66 |
-
parser.add_argument("--repeats", type=int, default=3)
|
| 67 |
-
parser.add_argument("--output", required=True)
|
| 68 |
-
parser.add_argument("--profile", action="store_true")
|
| 69 |
-
args = parser.parse_args()
|
| 70 |
-
from space_runtime import configure_environment, preload_torch_models
|
| 71 |
-
|
| 72 |
-
configure_environment()
|
| 73 |
-
os.environ["MINERU_TABLE_DEVICE"] = "cuda" if args.variant == "cuda" else "cpu"
|
| 74 |
-
if args.variant == "baseline":
|
| 75 |
-
os.environ.pop("MINERU_INTRA_OP_NUM_THREADS", None)
|
| 76 |
-
os.environ.pop("MINERU_INTER_OP_NUM_THREADS", None)
|
| 77 |
-
output = Path(args.output).resolve()
|
| 78 |
-
output.mkdir(parents=True, exist_ok=True)
|
| 79 |
-
if args.profile:
|
| 80 |
-
os.environ["MINERU_ONNX_PROFILE_DIR"] = str(output / "onnx")
|
| 81 |
-
import spaces
|
| 82 |
-
import pdf_sidecar
|
| 83 |
-
from space_app import sidecar_call, shutdown_sidecar
|
| 84 |
-
|
| 85 |
-
try:
|
| 86 |
-
sidecar_call(pdf_sidecar.read_page_metadata, None)
|
| 87 |
-
if args.variant in {"preload", "cuda"}:
|
| 88 |
-
preload_torch_models()
|
| 89 |
-
task = spaces.GPU(duration=60)(parse_and_profile)
|
| 90 |
-
records = []
|
| 91 |
-
for index in range(args.repeats):
|
| 92 |
-
started = time.monotonic()
|
| 93 |
-
result = task(
|
| 94 |
-
args.pdf,
|
| 95 |
-
tier=args.tier,
|
| 96 |
-
ocr_mode=args.ocr_mode,
|
| 97 |
-
page_range=args.page_range,
|
| 98 |
-
request_id=f"{args.variant}-{index}",
|
| 99 |
-
submitted_at=started,
|
| 100 |
-
)
|
| 101 |
-
elapsed = time.monotonic() - started
|
| 102 |
-
persist_started = time.monotonic()
|
| 103 |
-
artifacts = sidecar_call(
|
| 104 |
-
pdf_sidecar.persist,
|
| 105 |
-
result,
|
| 106 |
-
args.pdf,
|
| 107 |
-
output_root=str(output / str(index)),
|
| 108 |
-
page_range=args.page_range,
|
| 109 |
-
)
|
| 110 |
-
record = {
|
| 111 |
-
"run": index,
|
| 112 |
-
"gpu_call_s": elapsed,
|
| 113 |
-
"persist_s": time.monotonic() - persist_started,
|
| 114 |
-
"artifacts": artifacts.as_state(),
|
| 115 |
-
}
|
| 116 |
-
records.append(record)
|
| 117 |
-
(output / "report.json").write_text(
|
| 118 |
-
json.dumps({"variant": args.variant, "records": records}, indent=2),
|
| 119 |
-
encoding="utf-8",
|
| 120 |
-
)
|
| 121 |
-
print(
|
| 122 |
-
json.dumps(
|
| 123 |
-
{
|
| 124 |
-
"benchmark": args.variant,
|
| 125 |
-
"run": index,
|
| 126 |
-
"gpu_call_s": elapsed,
|
| 127 |
-
"persist_s": record["persist_s"],
|
| 128 |
-
}
|
| 129 |
-
),
|
| 130 |
-
flush=True,
|
| 131 |
-
)
|
| 132 |
-
finally:
|
| 133 |
-
shutdown_sidecar()
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
if __name__ == "__main__":
|
| 137 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mineru_space/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Space 运行模块;导入时不初始化模型或更新环境。"""
|
| 2 |
+
|
| 3 |
+
__all__ = []
|
bootstrap.py → mineru_space/bootstrap.py
RENAMED
|
@@ -14,8 +14,6 @@ import uuid
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
DEV_REPOSITORY = "https://github.com/myhloli/Magic-PDF.git"
|
| 17 |
-
ORT_VERSION = "1.29.0"
|
| 18 |
-
PYTHON_VERSION = "3.12"
|
| 19 |
|
| 20 |
|
| 21 |
def run(command: list[str], *, timeout: int = 900, capture: bool = False) -> str:
|
|
@@ -53,7 +51,11 @@ def write_json(path: Path, value: dict) -> None:
|
|
| 53 |
def validate(root: Path, environment: Path, commit: str) -> None:
|
| 54 |
"""在独立进程校验包来源、依赖、CPU/GPU 包冲突及所需适配接口。"""
|
| 55 |
run(
|
| 56 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
timeout=90,
|
| 58 |
)
|
| 59 |
|
|
@@ -76,8 +78,8 @@ def active_environment(cache: Path) -> tuple[Path, dict] | None:
|
|
| 76 |
def build_candidate(
|
| 77 |
root: Path, cache: Path, commit: str, fingerprint: str
|
| 78 |
) -> tuple[Path, dict]:
|
| 79 |
-
"""
|
| 80 |
-
name = f"py{
|
| 81 |
environment = cache / name
|
| 82 |
python = environment / "bin/python"
|
| 83 |
try:
|
|
@@ -88,7 +90,7 @@ def build_candidate(
|
|
| 88 |
"uv",
|
| 89 |
"venv",
|
| 90 |
"--python",
|
| 91 |
-
|
| 92 |
str(environment),
|
| 93 |
],
|
| 94 |
timeout=120,
|
|
@@ -103,37 +105,10 @@ def build_candidate(
|
|
| 103 |
"--python",
|
| 104 |
str(python),
|
| 105 |
"-r",
|
| 106 |
-
str(root / "runtime-requirements.txt"),
|
| 107 |
f"mineru[torch] @ git+{DEV_REPOSITORY}@{commit}",
|
| 108 |
]
|
| 109 |
)
|
| 110 |
-
# MinerU 的基础依赖会安装 CPU 包;先统一移除,随后重装 GPU 包避免共享文件残留。
|
| 111 |
-
run(
|
| 112 |
-
[
|
| 113 |
-
sys.executable,
|
| 114 |
-
"-m",
|
| 115 |
-
"uv",
|
| 116 |
-
"pip",
|
| 117 |
-
"uninstall",
|
| 118 |
-
"--python",
|
| 119 |
-
str(python),
|
| 120 |
-
"onnxruntime",
|
| 121 |
-
"onnxruntime-gpu",
|
| 122 |
-
]
|
| 123 |
-
)
|
| 124 |
-
run(
|
| 125 |
-
[
|
| 126 |
-
sys.executable,
|
| 127 |
-
"-m",
|
| 128 |
-
"uv",
|
| 129 |
-
"pip",
|
| 130 |
-
"install",
|
| 131 |
-
"--python",
|
| 132 |
-
str(python),
|
| 133 |
-
"--no-deps",
|
| 134 |
-
f"onnxruntime-gpu=={ORT_VERSION}",
|
| 135 |
-
]
|
| 136 |
-
)
|
| 137 |
validate(root, environment, commit)
|
| 138 |
state = {"environment": name, "commit": commit, "fingerprint": fingerprint}
|
| 139 |
write_json(environment / "ready.json", state)
|
|
@@ -165,9 +140,9 @@ def select_environment(root: Path, cache: Path) -> Path:
|
|
| 165 |
fcntl.flock(lock, fcntl.LOCK_EX)
|
| 166 |
previous = active_environment(cache)
|
| 167 |
fingerprint = hashlib.sha256(
|
| 168 |
-
(root / "runtime-requirements.txt").read_bytes()
|
| 169 |
-
+ (root / "runtime_preflight.py").read_bytes()
|
| 170 |
-
+
|
| 171 |
).hexdigest()
|
| 172 |
try:
|
| 173 |
commit = dev_head()
|
|
@@ -203,7 +178,8 @@ def select_environment(root: Path, cache: Path) -> Path:
|
|
| 203 |
|
| 204 |
def main() -> None:
|
| 205 |
"""只由 app.py 主入口更新环境,再以 exec 替换为真实应用进程。"""
|
| 206 |
-
root = Path(__file__).resolve().
|
|
|
|
| 207 |
cache = Path(
|
| 208 |
os.getenv(
|
| 209 |
"MINERU_RUNTIME_CACHE", str(Path.home() / ".cache/mineru-space-runtime")
|
|
@@ -214,5 +190,5 @@ def main() -> None:
|
|
| 214 |
os.environ["PATH"] = f"{environment / 'bin'}:{os.environ.get('PATH', '')}"
|
| 215 |
os.execv(
|
| 216 |
str(environment / "bin/python"),
|
| 217 |
-
[str(environment / "bin/python"),
|
| 218 |
)
|
|
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
DEV_REPOSITORY = "https://github.com/myhloli/Magic-PDF.git"
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def run(command: list[str], *, timeout: int = 900, capture: bool = False) -> str:
|
|
|
|
| 51 |
def validate(root: Path, environment: Path, commit: str) -> None:
|
| 52 |
"""在独立进程校验包来源、依赖、CPU/GPU 包冲突及所需适配接口。"""
|
| 53 |
run(
|
| 54 |
+
[
|
| 55 |
+
str(environment / "bin/python"),
|
| 56 |
+
str(root / "mineru_space" / "runtime_preflight.py"),
|
| 57 |
+
commit,
|
| 58 |
+
],
|
| 59 |
timeout=90,
|
| 60 |
)
|
| 61 |
|
|
|
|
| 78 |
def build_candidate(
|
| 79 |
root: Path, cache: Path, commit: str, fingerprint: str
|
| 80 |
) -> tuple[Path, dict]:
|
| 81 |
+
"""沿用宿主解释器,在新 venv 中一次安装依赖,不触碰旧环境。"""
|
| 82 |
+
name = f"py{sys.version_info.major}{sys.version_info.minor}-{commit[:12]}-{uuid.uuid4().hex[:8]}"
|
| 83 |
environment = cache / name
|
| 84 |
python = environment / "bin/python"
|
| 85 |
try:
|
|
|
|
| 90 |
"uv",
|
| 91 |
"venv",
|
| 92 |
"--python",
|
| 93 |
+
sys.executable,
|
| 94 |
str(environment),
|
| 95 |
],
|
| 96 |
timeout=120,
|
|
|
|
| 105 |
"--python",
|
| 106 |
str(python),
|
| 107 |
"-r",
|
| 108 |
+
str(root / "mineru_space" / "runtime-requirements.txt"),
|
| 109 |
f"mineru[torch] @ git+{DEV_REPOSITORY}@{commit}",
|
| 110 |
]
|
| 111 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
validate(root, environment, commit)
|
| 113 |
state = {"environment": name, "commit": commit, "fingerprint": fingerprint}
|
| 114 |
write_json(environment / "ready.json", state)
|
|
|
|
| 140 |
fcntl.flock(lock, fcntl.LOCK_EX)
|
| 141 |
previous = active_environment(cache)
|
| 142 |
fingerprint = hashlib.sha256(
|
| 143 |
+
(root / "mineru_space" / "runtime-requirements.txt").read_bytes()
|
| 144 |
+
+ (root / "mineru_space" / "runtime_preflight.py").read_bytes()
|
| 145 |
+
+ sys.version.encode()
|
| 146 |
).hexdigest()
|
| 147 |
try:
|
| 148 |
commit = dev_head()
|
|
|
|
| 178 |
|
| 179 |
def main() -> None:
|
| 180 |
"""只由 app.py 主入口更新环境,再以 exec 替换为真实应用进程。"""
|
| 181 |
+
root = Path(__file__).resolve().parents[1]
|
| 182 |
+
os.chdir(root)
|
| 183 |
cache = Path(
|
| 184 |
os.getenv(
|
| 185 |
"MINERU_RUNTIME_CACHE", str(Path.home() / ".cache/mineru-space-runtime")
|
|
|
|
| 190 |
os.environ["PATH"] = f"{environment / 'bin'}:{os.environ.get('PATH', '')}"
|
| 191 |
os.execv(
|
| 192 |
str(environment / "bin/python"),
|
| 193 |
+
[str(environment / "bin/python"), "-m", "mineru_space.space_app"],
|
| 194 |
)
|
pdf_sidecar.py → mineru_space/pdf_sidecar.py
RENAMED
|
File without changes
|
runtime-requirements.txt → mineru_space/runtime-requirements.txt
RENAMED
|
@@ -6,4 +6,4 @@ docvortex==0.4.15
|
|
| 6 |
numpy==2.2.6
|
| 7 |
pydantic==2.12.5
|
| 8 |
transformers==5.17.0
|
| 9 |
-
onnxruntime
|
|
|
|
| 6 |
numpy==2.2.6
|
| 7 |
pydantic==2.12.5
|
| 8 |
transformers==5.17.0
|
| 9 |
+
onnxruntime==1.29.0
|
runtime_preflight.py → mineru_space/runtime_preflight.py
RENAMED
|
@@ -9,19 +9,15 @@ import sys
|
|
| 9 |
|
| 10 |
|
| 11 |
def main(commit: str) -> None:
|
| 12 |
-
"""校验
|
| 13 |
from packaging.requirements import Requirement
|
| 14 |
from packaging.utils import canonicalize_name
|
| 15 |
|
| 16 |
-
if sys.version_info[:2] != (3, 12):
|
| 17 |
-
raise RuntimeError("The application runtime must use Python 3.12")
|
| 18 |
-
|
| 19 |
installed = {
|
| 20 |
canonicalize_name(d.metadata["Name"]): d for d in metadata.distributions()
|
| 21 |
}
|
| 22 |
-
if "onnxruntime" in installed:
|
| 23 |
-
raise RuntimeError("
|
| 24 |
-
assert metadata.version("onnxruntime-gpu") == "1.29.0"
|
| 25 |
info = json.loads(
|
| 26 |
metadata.distribution("mineru").read_text("direct_url.json") or "{}"
|
| 27 |
)
|
|
@@ -40,8 +36,6 @@ def main(commit: str) -> None:
|
|
| 40 |
):
|
| 41 |
continue
|
| 42 |
name = canonicalize_name(requirement.name)
|
| 43 |
-
if name == "onnxruntime":
|
| 44 |
-
name = "onnxruntime-gpu"
|
| 45 |
found = installed.get(name)
|
| 46 |
if found is None or (
|
| 47 |
requirement.specifier and found.version not in requirement.specifier
|
|
@@ -58,8 +52,8 @@ def main(commit: str) -> None:
|
|
| 58 |
from mineru.parser import parse
|
| 59 |
|
| 60 |
assert spaces is not None and callable(table_ort_session)
|
| 61 |
-
assert torch.version.cuda
|
| 62 |
-
assert "
|
| 63 |
assert callable(images._load_images_from_pdf_worker)
|
| 64 |
assert "max_pages" in inspect.signature(build_gradio_app).parameters
|
| 65 |
assert {"tier", "ocr_mode", "page_range"} <= set(
|
|
@@ -79,7 +73,7 @@ def main(commit: str) -> None:
|
|
| 79 |
"spaces",
|
| 80 |
"gradio",
|
| 81 |
"docvortex",
|
| 82 |
-
"onnxruntime
|
| 83 |
]
|
| 84 |
},
|
| 85 |
"cuda": torch.version.cuda,
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
def main(commit: str) -> None:
|
| 12 |
+
"""校验提交来源、常规依赖和 CPU ORT,确保候选满足应用运行条件。"""
|
| 13 |
from packaging.requirements import Requirement
|
| 14 |
from packaging.utils import canonicalize_name
|
| 15 |
|
|
|
|
|
|
|
|
|
|
| 16 |
installed = {
|
| 17 |
canonicalize_name(d.metadata["Name"]): d for d in metadata.distributions()
|
| 18 |
}
|
| 19 |
+
if "onnxruntime-gpu" in installed or "onnxruntime" not in installed:
|
| 20 |
+
raise RuntimeError("The Space requires CPU onnxruntime without onnxruntime-gpu")
|
|
|
|
| 21 |
info = json.loads(
|
| 22 |
metadata.distribution("mineru").read_text("direct_url.json") or "{}"
|
| 23 |
)
|
|
|
|
| 36 |
):
|
| 37 |
continue
|
| 38 |
name = canonicalize_name(requirement.name)
|
|
|
|
|
|
|
| 39 |
found = installed.get(name)
|
| 40 |
if found is None or (
|
| 41 |
requirement.specifier and found.version not in requirement.specifier
|
|
|
|
| 52 |
from mineru.parser import parse
|
| 53 |
|
| 54 |
assert spaces is not None and callable(table_ort_session)
|
| 55 |
+
assert torch.version.cuda, "ZeroGPU requires a CUDA build of Torch"
|
| 56 |
+
assert "CPUExecutionProvider" in ort.get_available_providers()
|
| 57 |
assert callable(images._load_images_from_pdf_worker)
|
| 58 |
assert "max_pages" in inspect.signature(build_gradio_app).parameters
|
| 59 |
assert {"tier", "ocr_mode", "page_range"} <= set(
|
|
|
|
| 73 |
"spaces",
|
| 74 |
"gradio",
|
| 75 |
"docvortex",
|
| 76 |
+
"onnxruntime",
|
| 77 |
]
|
| 78 |
},
|
| 79 |
"cuda": torch.version.cuda,
|
space_app.py → mineru_space/space_app.py
RENAMED
|
@@ -13,8 +13,8 @@ from concurrent.futures import ProcessPoolExecutor
|
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import Any, Callable
|
| 15 |
|
| 16 |
-
import pdf_sidecar
|
| 17 |
-
from space_runtime import (
|
| 18 |
configure_environment,
|
| 19 |
gpu_task_entry,
|
| 20 |
log_event,
|
|
|
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import Any, Callable
|
| 15 |
|
| 16 |
+
from . import pdf_sidecar
|
| 17 |
+
from .space_runtime import (
|
| 18 |
configure_environment,
|
| 19 |
gpu_task_entry,
|
| 20 |
log_event,
|
space_runtime.py → mineru_space/space_runtime.py
RENAMED
|
@@ -42,18 +42,16 @@ def timed(event: str, **fields: Any) -> Iterator[None]:
|
|
| 42 |
def configure_environment() -> None:
|
| 43 |
"""主进程在导入 spaces/Torch/MinerU 前建立统一运行配置。"""
|
| 44 |
os.environ["MINERU_DEVICE_MODE"] = "cuda"
|
|
|
|
| 45 |
os.environ["GRADIO_SSR_MODE"] = "False"
|
| 46 |
for name, value in {
|
| 47 |
"MINERU_MODEL_SOURCE": "huggingface",
|
| 48 |
"MINERU_INTRA_OP_NUM_THREADS": "4",
|
| 49 |
"MINERU_INTER_OP_NUM_THREADS": "1",
|
| 50 |
-
"MINERU_TABLE_DEVICE": "cpu",
|
| 51 |
"MINERU_PROFILE_STAGES": "1",
|
| 52 |
"MINERU_ZEROGPU_DURATION": "60",
|
| 53 |
}.items():
|
| 54 |
os.environ.setdefault(name, value)
|
| 55 |
-
if os.environ["MINERU_TABLE_DEVICE"] not in {"cpu", "cuda"}:
|
| 56 |
-
raise ValueError("MINERU_TABLE_DEVICE must be cpu or cuda")
|
| 57 |
|
| 58 |
|
| 59 |
def preload_torch_models() -> None:
|
|
|
|
| 42 |
def configure_environment() -> None:
|
| 43 |
"""主进程在导入 spaces/Torch/MinerU 前建立统一运行配置。"""
|
| 44 |
os.environ["MINERU_DEVICE_MODE"] = "cuda"
|
| 45 |
+
os.environ["MINERU_TABLE_DEVICE"] = "cpu"
|
| 46 |
os.environ["GRADIO_SSR_MODE"] = "False"
|
| 47 |
for name, value in {
|
| 48 |
"MINERU_MODEL_SOURCE": "huggingface",
|
| 49 |
"MINERU_INTRA_OP_NUM_THREADS": "4",
|
| 50 |
"MINERU_INTER_OP_NUM_THREADS": "1",
|
|
|
|
| 51 |
"MINERU_PROFILE_STAGES": "1",
|
| 52 |
"MINERU_ZEROGPU_DURATION": "60",
|
| 53 |
}.items():
|
| 54 |
os.environ.setdefault(name, value)
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def preload_torch_models() -> None:
|
tests/test_bootstrap.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 1 |
-
"""用隔离缓存验证更新事务,失败不会删除或替换上次可用环境。"""
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import pytest
|
| 5 |
-
|
| 6 |
-
import bootstrap
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def prepare(tmp_path):
|
| 10 |
-
"""构造可运行缓存记录与最小配置,不安装实际依赖。"""
|
| 11 |
-
root, cache = tmp_path / "app", tmp_path / "cache"
|
| 12 |
-
root.mkdir()
|
| 13 |
-
cache.mkdir()
|
| 14 |
-
(root / "runtime-requirements.txt").write_text("test\n")
|
| 15 |
-
(root / "runtime_preflight.py").write_text("test\n")
|
| 16 |
-
environment = cache / "old"
|
| 17 |
-
(environment / "bin").mkdir(parents=True)
|
| 18 |
-
(environment / "bin/python").touch()
|
| 19 |
-
state = {"environment": "old", "commit": "a" * 40, "fingerprint": "old"}
|
| 20 |
-
bootstrap.write_json(environment / "ready.json", state)
|
| 21 |
-
bootstrap.write_json(cache / "current.json", state)
|
| 22 |
-
return root, cache, environment, state
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
@pytest.mark.parametrize("failure", ["network", "installation"])
|
| 26 |
-
def test_failure_keeps_last_working_environment(monkeypatch, tmp_path, failure):
|
| 27 |
-
"""网络或安装失败均保留当前清单,并在回退前验证旧环境。"""
|
| 28 |
-
root, cache, environment, state = prepare(tmp_path)
|
| 29 |
-
validations = []
|
| 30 |
-
|
| 31 |
-
def fail(*args, **kwargs):
|
| 32 |
-
"""模拟候选更新失败。"""
|
| 33 |
-
raise RuntimeError("offline")
|
| 34 |
-
|
| 35 |
-
monkeypatch.setattr(
|
| 36 |
-
bootstrap, "dev_head", fail if failure == "network" else lambda: "b" * 40
|
| 37 |
-
)
|
| 38 |
-
monkeypatch.setattr(bootstrap, "build_candidate", fail)
|
| 39 |
-
monkeypatch.setattr(bootstrap, "validate", lambda *args: validations.append(args))
|
| 40 |
-
assert bootstrap.select_environment(root, cache) == environment
|
| 41 |
-
assert json.loads((cache / "current.json").read_text()) == state
|
| 42 |
-
assert validations == [(root, environment, "a" * 40)]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def test_no_cache_fails_clearly(monkeypatch, tmp_path):
|
| 46 |
-
"""没有可用缓存时不能假装更新成功。"""
|
| 47 |
-
root, cache, _, _ = prepare(tmp_path)
|
| 48 |
-
(cache / "current.json").unlink()
|
| 49 |
-
monkeypatch.setattr(bootstrap, "dev_head", lambda: "b" * 40)
|
| 50 |
-
|
| 51 |
-
def fail(*args):
|
| 52 |
-
"""模拟首次安装失败。"""
|
| 53 |
-
raise RuntimeError("install failed")
|
| 54 |
-
|
| 55 |
-
monkeypatch.setattr(bootstrap, "build_candidate", fail)
|
| 56 |
-
with pytest.raises(RuntimeError, match="No verified"):
|
| 57 |
-
bootstrap.select_environment(root, cache)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def test_activation_preserves_previous(monkeypatch, tmp_path):
|
| 61 |
-
"""只有候选构建成功后才替换 current,并保存上一份完整清单。"""
|
| 62 |
-
root, cache, old, state = prepare(tmp_path)
|
| 63 |
-
new = cache / "new"
|
| 64 |
-
new_state = {"environment": "new", "commit": "b" * 40, "fingerprint": "new"}
|
| 65 |
-
monkeypatch.setattr(bootstrap, "dev_head", lambda: "b" * 40)
|
| 66 |
-
monkeypatch.setattr(bootstrap, "build_candidate", lambda *a: (new, new_state))
|
| 67 |
-
assert bootstrap.select_environment(root, cache) == new
|
| 68 |
-
assert json.loads((cache / "previous.json").read_text()) == state
|
| 69 |
-
assert json.loads((cache / "current.json").read_text()) == new_state
|
| 70 |
-
assert (old / "bin/python").is_file()
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def test_rejects_environment_outside_cache(tmp_path):
|
| 74 |
-
"""损坏清单不能指向缓存以外的 Python 环境。"""
|
| 75 |
-
_, cache, _, _ = prepare(tmp_path)
|
| 76 |
-
bootstrap.write_json(cache / "current.json", {"environment": "../outside"})
|
| 77 |
-
assert bootstrap.active_environment(cache) is None
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def test_cleanup_only_removes_owned_retired_environments(tmp_path):
|
| 81 |
-
"""清理仅作用于带成功标记的历史环境,保留当前版本和其他文件。"""
|
| 82 |
-
current = tmp_path / ("py312-" + "a" * 12 + "-" + "1" * 8)
|
| 83 |
-
retired = tmp_path / ("py312-" + "b" * 12 + "-" + "2" * 8)
|
| 84 |
-
unrelated = tmp_path / "notes"
|
| 85 |
-
for directory in (current, retired, unrelated):
|
| 86 |
-
directory.mkdir()
|
| 87 |
-
bootstrap.write_json(directory / "ready.json", {"environment": directory.name})
|
| 88 |
-
bootstrap.prune_environments(tmp_path, {current.name})
|
| 89 |
-
assert current.is_dir() and unrelated.is_dir() and not retired.exists()
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def test_candidate_python_does_not_follow_launcher(monkeypatch, tmp_path):
|
| 93 |
-
"""HF devmode 的系统 Python 可能固定为 3.10,候选环境必须显式选择 3.12。"""
|
| 94 |
-
commands = []
|
| 95 |
-
|
| 96 |
-
def fake_run(command, **kwargs):
|
| 97 |
-
"""模拟环境创建,仅保存命令和 Python 路径用于验证安装边界。"""
|
| 98 |
-
commands.append(command)
|
| 99 |
-
if "venv" in command:
|
| 100 |
-
environment = tmp_path / command[-1]
|
| 101 |
-
(environment / "bin").mkdir(parents=True)
|
| 102 |
-
(environment / "bin/python").touch()
|
| 103 |
-
return ""
|
| 104 |
-
|
| 105 |
-
monkeypatch.setattr(bootstrap, "run", fake_run)
|
| 106 |
-
monkeypatch.setattr(bootstrap, "validate", lambda *args: None)
|
| 107 |
-
environment, _ = bootstrap.build_candidate(tmp_path, tmp_path, "a" * 40, "test")
|
| 108 |
-
assert commands[0][commands[0].index("--python") + 1] == "3.12"
|
| 109 |
-
assert environment.name.startswith("py312-")
|
| 110 |
-
assert any(
|
| 111 |
-
f"@{'a' * 40}" in argument for command in commands for argument in command
|
| 112 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_space.py
DELETED
|
@@ -1,244 +0,0 @@
|
|
| 1 |
-
"""验证 GPU 分流、spawn 无副作用与取消后的真实任务串行边界。"""
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
import subprocess
|
| 5 |
-
import sys
|
| 6 |
-
import threading
|
| 7 |
-
import time
|
| 8 |
-
import multiprocessing
|
| 9 |
-
from contextvars import ContextVar
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
import pytest
|
| 13 |
-
|
| 14 |
-
import pdf_sidecar
|
| 15 |
-
import space_app
|
| 16 |
-
from space_runtime import gpu_deadline
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def slow_gpu_worker():
|
| 20 |
-
"""模拟卡住的独立推理进程,验证看门狗不会结束测试主进程。"""
|
| 21 |
-
with gpu_deadline(0.1, "test-timeout"):
|
| 22 |
-
time.sleep(5)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def test_watchdog_terminates_only_daemon_worker():
|
| 26 |
-
"""超时 worker 以明确状态退出,父进程仍能继续处理其他任务。"""
|
| 27 |
-
process = multiprocessing.get_context("spawn").Process(
|
| 28 |
-
target=slow_gpu_worker, daemon=True
|
| 29 |
-
)
|
| 30 |
-
process.start()
|
| 31 |
-
process.join(timeout=4)
|
| 32 |
-
try:
|
| 33 |
-
assert process.exitcode == 124
|
| 34 |
-
finally:
|
| 35 |
-
if process.is_alive():
|
| 36 |
-
process.terminate()
|
| 37 |
-
process.join()
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
@pytest.mark.parametrize(
|
| 41 |
-
"tier,mode,gpu",
|
| 42 |
-
[
|
| 43 |
-
("flash", "txt", False),
|
| 44 |
-
("flash", "ocr", True),
|
| 45 |
-
("basic", "txt", True),
|
| 46 |
-
("basic", "ocr", True),
|
| 47 |
-
("basic", "auto", True),
|
| 48 |
-
],
|
| 49 |
-
)
|
| 50 |
-
def test_route_matrix(monkeypatch, tier, mode, gpu):
|
| 51 |
-
"""显式模式与 Basic 均不分类,防止 TXT 请求意外触发 OCR。"""
|
| 52 |
-
from mineru.parser import file_type
|
| 53 |
-
|
| 54 |
-
monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: "pdf")
|
| 55 |
-
monkeypatch.setattr(
|
| 56 |
-
pdf_sidecar,
|
| 57 |
-
"classify_pdf",
|
| 58 |
-
lambda *args: pytest.fail("unexpected classification"),
|
| 59 |
-
)
|
| 60 |
-
assert pdf_sidecar.resolve_route("input.pdf", tier, mode, "").use_gpu is gpu
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
@pytest.mark.parametrize("mode", ["txt", "ocr"])
|
| 64 |
-
def test_auto_is_resolved_once(monkeypatch, mode):
|
| 65 |
-
"""分类结果直接成为后续 parse 的显式模式。"""
|
| 66 |
-
from mineru.parser import file_type
|
| 67 |
-
|
| 68 |
-
calls = []
|
| 69 |
-
monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: "pdf")
|
| 70 |
-
monkeypatch.setattr(
|
| 71 |
-
pdf_sidecar, "classify_pdf", lambda *args: calls.append(args) or mode
|
| 72 |
-
)
|
| 73 |
-
route = pdf_sidecar.resolve_route("input.pdf", "flash", "auto", "2-4")
|
| 74 |
-
assert route == pdf_sidecar.ParseRoute("flash", mode, mode == "ocr")
|
| 75 |
-
assert calls == [("input.pdf", "2-4")]
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
@pytest.mark.parametrize("suffix", ["docx", "ofd", "html", "csv", "epub"])
|
| 79 |
-
def test_native_formats_stay_cpu(monkeypatch, suffix):
|
| 80 |
-
"""即使传入残留 Basic/OCR 选项,原生格式仍为 Flash CPU。"""
|
| 81 |
-
from mineru.parser import file_type
|
| 82 |
-
|
| 83 |
-
monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: suffix)
|
| 84 |
-
assert pdf_sidecar.resolve_route(
|
| 85 |
-
"input", "basic", "ocr", ""
|
| 86 |
-
) == pdf_sidecar.ParseRoute("flash", "txt", False)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def test_image_auto_routes_gpu(monkeypatch):
|
| 90 |
-
"""图片 auto 无需打开 PDF 即能确定 OCR 路由。"""
|
| 91 |
-
from mineru.parser import file_type
|
| 92 |
-
|
| 93 |
-
monkeypatch.setattr(file_type, "guess_suffix_by_path", lambda path: "png")
|
| 94 |
-
assert pdf_sidecar.resolve_route("input", "flash", "auto", "").use_gpu
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def test_cpu_entry_rejects_models():
|
| 98 |
-
"""CPU sidecar 在导入 parser 前拒绝模型路径。"""
|
| 99 |
-
with pytest.raises(ValueError, match="Flash TXT"):
|
| 100 |
-
pdf_sidecar.parse_cpu(
|
| 101 |
-
"missing.pdf", tier="basic", ocr_mode="txt", page_range=""
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def test_mixed_pdf_classifies_selected_pages(tmp_path):
|
| 106 |
-
"""混合 PDF 的文本页与空白页选区分别固定为 TXT/OCR,不能按整本文档误分流。"""
|
| 107 |
-
from reportlab.pdfgen.canvas import Canvas
|
| 108 |
-
|
| 109 |
-
path = tmp_path / "mixed.pdf"
|
| 110 |
-
canvas = Canvas(str(path))
|
| 111 |
-
for row in range(20):
|
| 112 |
-
canvas.drawString(
|
| 113 |
-
40,
|
| 114 |
-
780 - row * 25,
|
| 115 |
-
"This document contains selectable text for native PDF extraction.",
|
| 116 |
-
)
|
| 117 |
-
canvas.showPage()
|
| 118 |
-
canvas.showPage()
|
| 119 |
-
canvas.save()
|
| 120 |
-
assert pdf_sidecar.classify_pdf(str(path), "1") == "txt"
|
| 121 |
-
assert pdf_sidecar.classify_pdf(str(path), "2") == "ocr"
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def test_twenty_page_limit_is_enforced(tmp_path):
|
| 125 |
-
"""真实 21 页输入默认截到 20 页,显式超限范围在 GPU 调用前被拒绝。"""
|
| 126 |
-
from reportlab.pdfgen.canvas import Canvas
|
| 127 |
-
from mineru.errors import InvalidRequestError
|
| 128 |
-
|
| 129 |
-
path = tmp_path / "long.pdf"
|
| 130 |
-
canvas = Canvas(str(path))
|
| 131 |
-
for _ in range(21):
|
| 132 |
-
canvas.drawString(40, 100, "Page")
|
| 133 |
-
canvas.showPage()
|
| 134 |
-
canvas.save()
|
| 135 |
-
assert pdf_sidecar.resolve_page_range(path, "", max_pages=20) == "1-20"
|
| 136 |
-
with pytest.raises(InvalidRequestError):
|
| 137 |
-
pdf_sidecar.resolve_page_range(path, "1-21", max_pages=20)
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def test_import_has_no_heavy_or_update_side_effects():
|
| 141 |
-
"""全新解释器导入 spawn 入口不会导入模型、PDFium 或执行启动更新。"""
|
| 142 |
-
root = Path(__file__).resolve().parents[1]
|
| 143 |
-
subprocess.run(
|
| 144 |
-
[
|
| 145 |
-
sys.executable,
|
| 146 |
-
"-c",
|
| 147 |
-
"import sys, app, space_app, pdf_sidecar; "
|
| 148 |
-
"assert not {'mineru', 'torch', 'spaces', 'docvortex', 'bootstrap'} & sys.modules.keys()",
|
| 149 |
-
],
|
| 150 |
-
cwd=root,
|
| 151 |
-
check=True,
|
| 152 |
-
timeout=20,
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def test_sidecar_device_is_cpu(monkeypatch):
|
| 157 |
-
"""sidecar 初始化强制清除从主进程继承的两个 CUDA 选择。"""
|
| 158 |
-
monkeypatch.setenv("MINERU_DEVICE_MODE", "cuda")
|
| 159 |
-
monkeypatch.setenv("MINERU_TABLE_DEVICE", "cuda")
|
| 160 |
-
pdf_sidecar.initialize()
|
| 161 |
-
import os
|
| 162 |
-
|
| 163 |
-
assert (
|
| 164 |
-
os.environ["MINERU_DEVICE_MODE"] == os.environ["MINERU_TABLE_DEVICE"] == "cpu"
|
| 165 |
-
)
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
def test_flash_txt_never_calls_gpu(monkeypatch):
|
| 169 |
-
"""经客户端实际分派的 Flash TXT 不调用装饰后的 GPU 函数。"""
|
| 170 |
-
|
| 171 |
-
def fake_sidecar(fn, *args, **kwargs):
|
| 172 |
-
"""返回预定 CPU 路由,并核实 parse 不会再次使用 auto。"""
|
| 173 |
-
if fn is pdf_sidecar.resolve_route:
|
| 174 |
-
return pdf_sidecar.ParseRoute("flash", "txt", False)
|
| 175 |
-
assert fn is pdf_sidecar.parse_cpu and kwargs["ocr_mode"] == "txt"
|
| 176 |
-
return "cpu result"
|
| 177 |
-
|
| 178 |
-
monkeypatch.setattr(space_app, "sidecar_call", fake_sidecar)
|
| 179 |
-
client = space_app.InProcessArtifactClient(
|
| 180 |
-
lambda *args, **kwargs: pytest.fail("GPU entered")
|
| 181 |
-
)
|
| 182 |
-
assert (
|
| 183 |
-
asyncio.run(
|
| 184 |
-
client.parse_file("input.pdf", tier="flash", ocr_mode="auto", page_range="")
|
| 185 |
-
)
|
| 186 |
-
== "cpu result"
|
| 187 |
-
)
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
def test_cancellation_keeps_slot_and_context(monkeypatch):
|
| 191 |
-
"""重复取消不能放行第二个 GPU 请求,to_thread 仍传播原请求上下文。"""
|
| 192 |
-
started, release = threading.Event(), threading.Event()
|
| 193 |
-
calls = []
|
| 194 |
-
visitor = ContextVar("visitor", default="missing")
|
| 195 |
-
monkeypatch.setattr(
|
| 196 |
-
space_app,
|
| 197 |
-
"sidecar_call",
|
| 198 |
-
lambda *a, **k: pdf_sidecar.ParseRoute("basic", "txt", True),
|
| 199 |
-
)
|
| 200 |
-
|
| 201 |
-
def gpu(*args, **kwargs):
|
| 202 |
-
"""模拟无法被 asyncio 取消的同步 GPU 推理。"""
|
| 203 |
-
calls.append(visitor.get())
|
| 204 |
-
started.set()
|
| 205 |
-
assert release.wait(5)
|
| 206 |
-
return "done"
|
| 207 |
-
|
| 208 |
-
async def scenario():
|
| 209 |
-
"""取消第一个调用后,第二个调用必须等待真正的同步推理结束。"""
|
| 210 |
-
visitor.set("visitor-1")
|
| 211 |
-
client = space_app.InProcessArtifactClient(gpu)
|
| 212 |
-
first = asyncio.create_task(client.parse_file("a", tier="basic", page_range=""))
|
| 213 |
-
assert await asyncio.to_thread(started.wait, 3)
|
| 214 |
-
first.cancel()
|
| 215 |
-
await asyncio.sleep(0)
|
| 216 |
-
first.cancel()
|
| 217 |
-
second = asyncio.create_task(
|
| 218 |
-
client.parse_file("b", tier="basic", page_range="")
|
| 219 |
-
)
|
| 220 |
-
try:
|
| 221 |
-
await asyncio.sleep(0.05)
|
| 222 |
-
assert calls == ["visitor-1"] and not first.done() and not second.done()
|
| 223 |
-
finally:
|
| 224 |
-
release.set()
|
| 225 |
-
with pytest.raises(asyncio.CancelledError):
|
| 226 |
-
await first
|
| 227 |
-
assert await second == "done"
|
| 228 |
-
assert calls == ["visitor-1", "visitor-1"]
|
| 229 |
-
|
| 230 |
-
asyncio.run(scenario())
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def test_parse_errors_are_wrapped(monkeypatch):
|
| 234 |
-
"""真正解析阶段的错误统一转换为客户端错误,而非只包围 return。"""
|
| 235 |
-
from mineru.kit.gradio.client import V1ArtifactError
|
| 236 |
-
|
| 237 |
-
def fail(*args, **kwargs):
|
| 238 |
-
"""模拟分类期间 PDFium 抛出的解析错误。"""
|
| 239 |
-
raise RuntimeError("broken PDF")
|
| 240 |
-
|
| 241 |
-
monkeypatch.setattr(space_app, "sidecar_call", fail)
|
| 242 |
-
client = space_app.InProcessArtifactClient()
|
| 243 |
-
with pytest.raises(V1ArtifactError, match="broken PDF"):
|
| 244 |
-
asyncio.run(client.parse_file("input", tier="basic", page_range=""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|