fix: download optional Python runtime resources explicitly
This commit is contained in:
1 parent
15c10e0e87
commit
89f35fe2e9
34 files changed
+1159
-259
No files matched your search
@@ -5,6 +5,9 @@
|
||||
/deepseek-harness-python-sdk:
|
||||
en: 43f7de064985e731
|
||||
zh: 9ad59629660b1f13
|
||||
/deepseek-harness-python-sdk/optional-office-operations:
|
||||
en: ddf4dabea526cef1
|
||||
zh: 6e31cab10d2f3ed8
|
||||
/deepseek-harness-python-sdk/start-a-runtime:
|
||||
en: 9a069c1a7cbb0779
|
||||
zh: 7f625a802cd1e9ea
|
||||
|
||||
@@ -8,6 +8,24 @@ Python subprocess SDK for driving DeepSeek Harness over newline-delimited JSON-R
|
||||
python -m pip install deepseek-harness-sdk
|
||||
```
|
||||
|
||||
## Optional Office operations
|
||||
|
||||
Download the Office runtime explicitly, then call the local API without an agent, Session, model request, API key, or Harness home:
|
||||
|
||||
```py
|
||||
from deepseek_harness_runtime import download_office
|
||||
from deepseek_harness.office import convert, recalculate, render_images
|
||||
|
||||
download_office()
|
||||
pdf = convert("report.docx", "report.pdf")
|
||||
workbook = recalculate("forecast.xlsx", "calculated.xlsx")
|
||||
preview = render_images("report.docx", "report-preview", pages=[1])
|
||||
```
|
||||
|
||||
The output file or image directory must not exist. Successful outputs belong to the caller; Kit removes failed outputs. `OfficeOptions` supplies operation limits and font preferences. Results expose typed paths, missing fonts, and image metadata. `OfficeError` retains CLI failure codes; malformed results raise `SdkProtocolError`. Recalculation does not verify business logic or Excel fidelity. Ctrl+C waits for CLI cleanup; a second interrupt forces termination and may leave partial output. Windows requires an absolute `SystemRoot` and a shared console for Ctrl+C delivery.
|
||||
|
||||
The authoring environment is independently optional through `deepseek_harness_runtime.download_primary_runtime()`. See the [runtime reference](../sdk-runtime/README.md) for cache selection, resource activation, and download behavior. SDK startup and Office operations never acquire missing resources automatically.
|
||||
|
||||
## Start a runtime
|
||||
|
||||
The Python SDK has no separate application entrypoint. It launches the bundled `dsh` CLI with `--profile sdk`; the selected profile owns the JSON-RPC server, agent composition, credentials, persistence, tools, and shutdown behavior.
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
python -m pip install deepseek-harness-sdk
|
||||
```
|
||||
|
||||
## 可选 Office 操作
|
||||
|
||||
显式下载 Office 运行时后,即可调用本地 API,无需 agent、Session、模型请求、API key 或 Harness home:
|
||||
|
||||
```py
|
||||
from deepseek_harness_runtime import download_office
|
||||
from deepseek_harness.office import convert, recalculate, render_images
|
||||
|
||||
download_office()
|
||||
pdf = convert("report.docx", "report.pdf")
|
||||
workbook = recalculate("forecast.xlsx", "calculated.xlsx")
|
||||
preview = render_images("report.docx", "report-preview", pages=[1])
|
||||
```
|
||||
|
||||
输出文件或图片目录必须不存在。调用方拥有成功输出;Kit 删除失败输出。`OfficeOptions` 提供操作限制和字体偏好。结果包含类型化路径、缺失字体和图片元数据。`OfficeError` 保留 CLI 失败代码;无效结果抛出 `SdkProtocolError`。重算不验证业务逻辑或 Excel 兼容性。Ctrl+C 会等待 CLI 清理;再次中断会强制终止,可能留下未完成输出。Windows 要求绝对 `SystemRoot` 路径,并通过共享控制台传递 Ctrl+C。
|
||||
|
||||
创作环境可通过 `deepseek_harness_runtime.download_primary_runtime()` 独立选择。缓存选择、资源启用与下载行为见[运行时参考](../sdk-runtime/README.zh.md)。SDK 启动和 Office 操作都不会自动获取缺失资源。
|
||||
|
||||
## 启动运行时
|
||||
|
||||
Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的 `dsh` CLI(命令行界面);所选 profile 负责 JSON-RPC 服务器、agent(智能体)组合、凭据、持久化、工具和关闭流程。
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Local Office operations through the runtime wheel's dsoffice CLI.
|
||||
|
||||
Calls are synchronous and do not create agents, sessions, or model requests.
|
||||
The CLI owns conversion deadlines, engine termination, and failed-output cleanup.
|
||||
Console Ctrl+C waits for CLI cleanup before raising KeyboardInterrupt; a second
|
||||
interrupt forces termination and can leave partial output. On Windows, callers
|
||||
must share the CLI's console for Ctrl+C delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypeVar
|
||||
|
||||
import deepseek_harness_runtime
|
||||
from deepseek_harness_runtime._resources import run_office_process
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from .errors import HarnessError, SdkProtocolError
|
||||
|
||||
|
||||
class OfficeError(HarnessError):
|
||||
"""A failed Office operation, including its CLI error code and exit status."""
|
||||
|
||||
def __init__(self, code: str, message: str, exit_code: int) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class _OfficeModel(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
|
||||
class ConversionResult(_OfficeModel):
|
||||
"""Saved conversion or recalculation output and missing declared fonts."""
|
||||
|
||||
backend: Literal["native", "wasm"]
|
||||
output_path: Path = Field(alias="outputPath")
|
||||
missing_fonts: list[str] = Field(alias="missingFonts")
|
||||
|
||||
|
||||
class ImageRectangle(_OfficeModel):
|
||||
"""Source rectangle in CSS pixels at 96 DPI, before raster scaling."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class RenderedImage(_OfficeModel):
|
||||
"""One saved PNG and its source page, worksheet, or worksheet fragment."""
|
||||
|
||||
index: int
|
||||
path: Path
|
||||
width: int
|
||||
height: int
|
||||
rectangle: ImageRectangle
|
||||
byte_length: int = Field(alias="byteLength")
|
||||
page: int | None = None
|
||||
sheet: str | None = None
|
||||
range: str | None = None
|
||||
rtl: bool | None = None
|
||||
|
||||
|
||||
class RenderImagesResult(_OfficeModel):
|
||||
"""Image manifest for one saved input; page_count includes unselected pages."""
|
||||
|
||||
schema_version: Literal[1] = Field(alias="schemaVersion")
|
||||
backend: Literal["native", "wasm"]
|
||||
raster_engine: Literal["libreoffice", "pdfium"] = Field(alias="rasterEngine")
|
||||
source: Literal["saved"]
|
||||
input_path: Path = Field(alias="inputPath")
|
||||
source_sha256: str = Field(alias="sourceSha256")
|
||||
dpi: float
|
||||
page_count: int = Field(alias="pageCount")
|
||||
images: list[RenderedImage]
|
||||
missing_fonts: list[str] = Field(alias="missingFonts")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OfficeOptions:
|
||||
"""Kit limits and fonts; omitted values retain the installed kit defaults.
|
||||
|
||||
timeout_ms bounds the engine operation, including font work and output,
|
||||
after explicit resource download. The call waits for engine exit and cleanup.
|
||||
Font paths may be relative to cwd; fallback groups replace the kit defaults.
|
||||
"""
|
||||
|
||||
timeout_ms: int | None = None
|
||||
max_input_bytes: int | None = None
|
||||
max_output_bytes: int | None = None
|
||||
max_image_resolution: int | None = None
|
||||
max_archive_entries: int | None = None
|
||||
max_uncompressed_bytes: int | None = None
|
||||
max_font_files: int | None = None
|
||||
max_font_file_bytes: int | None = None
|
||||
max_loaded_font_bytes: int | None = None
|
||||
font_directories: Sequence[str | os.PathLike[str]] | None = None
|
||||
initial_font_families: Sequence[str] | None = None
|
||||
font_fallbacks: Sequence[Sequence[str]] | None = None
|
||||
|
||||
def _arguments(self) -> list[str]:
|
||||
arguments: list[str] = []
|
||||
for field in fields(self):
|
||||
value = getattr(self, field.name)
|
||||
if value is None:
|
||||
continue
|
||||
if field.name == "font_directories":
|
||||
for path in value:
|
||||
arguments.extend(("--font-directory", os.fspath(path)))
|
||||
elif field.name == "initial_font_families":
|
||||
for family in value:
|
||||
arguments.extend(("--initial-font-family", family))
|
||||
elif field.name == "font_fallbacks":
|
||||
arguments.extend(("--font-fallbacks", json.dumps(value)))
|
||||
else:
|
||||
arguments.extend(("--" + field.name.replace("_", "-"), str(value)))
|
||||
return arguments
|
||||
|
||||
|
||||
class _CliFailure(_OfficeModel):
|
||||
code: str
|
||||
error: str
|
||||
|
||||
|
||||
_Result = TypeVar("_Result", bound=_OfficeModel)
|
||||
|
||||
|
||||
def _run(arguments: list[str], options: OfficeOptions | None, result_type: type[_Result]) -> _Result:
|
||||
process = run_office_process(
|
||||
[*deepseek_harness_runtime.resolve_office_launch_args(), *arguments,
|
||||
*(options._arguments() if options is not None else [])],
|
||||
capture_output=True,
|
||||
)
|
||||
stdout, stderr = process.stdout, process.stderr
|
||||
if process.returncode != 0:
|
||||
try:
|
||||
failure = _CliFailure.model_validate_json(stderr)
|
||||
except ValidationError:
|
||||
raise OfficeError("failed", stderr.decode("utf-8", errors="replace").strip()
|
||||
or "Office CLI exited without a diagnostic.",
|
||||
process.returncode) from None
|
||||
raise OfficeError(failure.code, failure.error, process.returncode)
|
||||
try:
|
||||
return result_type.model_validate_json(stdout)
|
||||
except ValidationError as error:
|
||||
raise SdkProtocolError("Office CLI returned an invalid result.") from error
|
||||
|
||||
|
||||
def render_images(
|
||||
input_path: str | os.PathLike[str],
|
||||
output_dir: str | os.PathLike[str],
|
||||
*,
|
||||
pages: Literal["all"] | Sequence[int] | None = None,
|
||||
sheet: str | None = None,
|
||||
range: str | None = None,
|
||||
dpi: int | None = None,
|
||||
max_pages: int | None = None,
|
||||
max_pixels: int | None = None,
|
||||
max_dimension: int | None = None,
|
||||
options: OfficeOptions | None = None,
|
||||
) -> RenderImagesResult:
|
||||
"""Render Office or PDF to PNG files and manifest.json in a fresh directory.
|
||||
|
||||
Pages are one-based document pages or slides. Worksheets use an exact
|
||||
sheet name and optional A1 range instead. Paths may be relative to cwd.
|
||||
The caller owns successful output; failed batches remove the new directory.
|
||||
Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON.
|
||||
"""
|
||||
arguments = ["render", "--input", os.fspath(input_path), "--output-dir", os.fspath(output_dir)]
|
||||
selections = {
|
||||
"pages": pages if isinstance(pages, str) or pages is None else ",".join(map(str, pages)),
|
||||
"sheet": sheet, "range": range, "dpi": dpi, "max-pages": max_pages,
|
||||
"max-pixels": max_pixels, "max-dimension": max_dimension,
|
||||
}
|
||||
for name, value in selections.items():
|
||||
if value is not None:
|
||||
arguments.extend(("--" + name, str(value)))
|
||||
return _run(arguments, options, RenderImagesResult)
|
||||
|
||||
|
||||
def convert(
|
||||
input_path: str | os.PathLike[str],
|
||||
output_path: str | os.PathLike[str],
|
||||
*,
|
||||
sheet: str | None = None,
|
||||
options: OfficeOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Export Office to the format named by a fresh output file's extension.
|
||||
|
||||
Paths may be relative to cwd. CSV export requires an exact sheet name for
|
||||
multi-sheet input. Existing output is rejected; failed output is removed.
|
||||
Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON.
|
||||
"""
|
||||
arguments = ["convert", "--input", os.fspath(input_path), "--output", os.fspath(output_path)]
|
||||
if sheet is not None:
|
||||
arguments.extend(("--sheet", sheet))
|
||||
return _run(arguments, options, ConversionResult)
|
||||
|
||||
|
||||
def recalculate(
|
||||
input_path: str | os.PathLike[str],
|
||||
output_path: str | os.PathLike[str],
|
||||
*,
|
||||
options: OfficeOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Refresh workbook formula caches into a distinct, fresh XLSX or ODS file.
|
||||
|
||||
Input accepts XLS, XLSX, or ODS. Paths may be relative to cwd. Formulas
|
||||
survive, but recalculation does not verify business logic or Excel fidelity.
|
||||
Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON.
|
||||
"""
|
||||
return _run(["recalculate", "--input", os.fspath(input_path), "--output", os.fspath(output_path)],
|
||||
options, ConversionResult)
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
"""Office JSON subprocess peer; engine behavior belongs to the real kit smoke."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
receipt = Path(sys.argv[1])
|
||||
arguments = sys.argv[2:]
|
||||
receipt.write_text(json.dumps(arguments), encoding="utf-8")
|
||||
command = arguments[0]
|
||||
values = dict(zip(arguments[1::2], arguments[2::2]))
|
||||
source = Path(values["--input"])
|
||||
if source.name == "error.docx":
|
||||
print(json.dumps({"code": "invalid-document", "error": "Document cannot be loaded."}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if source.name == "crash.docx":
|
||||
print("engine process unavailable", file=sys.stderr)
|
||||
sys.exit(7)
|
||||
if source.name == "invalid.docx":
|
||||
print(json.dumps({"backend": "native", "outputPath": 3, "missingFonts": []}))
|
||||
sys.exit(0)
|
||||
if source.name == "invalid-bytes.docx":
|
||||
sys.stdout.buffer.write(b"\xff")
|
||||
sys.exit(0)
|
||||
if source.name == "timeout.docx":
|
||||
output = Path(values["--output"])
|
||||
output.write_text("partial output", encoding="utf-8")
|
||||
output.unlink()
|
||||
print(json.dumps({"code": "timeout", "error": "Conversion timed out."}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if command == "render":
|
||||
output = Path(values["--output-dir"])
|
||||
output.mkdir()
|
||||
image = output / "page-1.png"
|
||||
image.write_bytes(b"png fixture")
|
||||
result = {
|
||||
"schemaVersion": 1, "backend": "native", "rasterEngine": "libreoffice", "source": "saved",
|
||||
"inputPath": str(source.resolve()), "sourceSha256": "abc123", "dpi": 144, "pageCount": 3,
|
||||
"images": [{"index": 0, "page": 1, "path": str(image.resolve()), "width": 64, "height": 48,
|
||||
"rectangle": {"x": 0, "y": 0, "width": 32, "height": 24}, "byteLength": 11}],
|
||||
"missingFonts": ["Fixture Sans"],
|
||||
}
|
||||
(output / "manifest.json").write_text(json.dumps(result), encoding="utf-8")
|
||||
else:
|
||||
output = Path(values["--output"])
|
||||
output.write_text("converted fixture", encoding="utf-8")
|
||||
result = {"backend": "wasm", "outputPath": str(output.resolve()), "missingFonts": []}
|
||||
print(json.dumps(result))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Exercise Office argv and JSON through an actual subprocess without a model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import deepseek_harness_runtime
|
||||
import pytest
|
||||
|
||||
from deepseek_harness.errors import SdkProtocolError
|
||||
from deepseek_harness.office import ConversionResult, OfficeError, OfficeOptions, convert, recalculate, render_images
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def office_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
receipt = tmp_path / "arguments.json"
|
||||
script = Path(__file__).parent / "fixtures" / "office_cli.py"
|
||||
monkeypatch.setattr(deepseek_harness_runtime, "resolve_office_launch_args",
|
||||
lambda: (sys.executable, str(script), str(receipt)))
|
||||
return receipt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", [convert, recalculate])
|
||||
def test_saved_output_and_literal_path_arguments(
|
||||
operation: Callable[..., ConversionResult], office_cli: Path, tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "工作簿 $name with spaces.xlsx"
|
||||
output = tmp_path / "saved workbook.xlsx"
|
||||
result = operation(source, output)
|
||||
assert result.output_path == output
|
||||
assert output.read_text() == "converted fixture"
|
||||
assert result.backend == "wasm"
|
||||
assert result.missing_fonts == []
|
||||
assert json.loads(office_cli.read_text()) == [operation.__name__, "--input", str(source), "--output", str(output)]
|
||||
|
||||
|
||||
def test_render_images_reads_the_saved_manifest(office_cli: Path, tmp_path: Path) -> None:
|
||||
output = tmp_path / "preview"
|
||||
result = render_images(tmp_path / "deck.pptx", output, pages=[1, 3], dpi=144, max_pages=10,
|
||||
max_pixels=1_000_000, max_dimension=4096)
|
||||
assert result.page_count == 3
|
||||
assert result.images[0].path.read_bytes() == b"png fixture"
|
||||
assert result.images[0].rectangle.width == 32
|
||||
assert result.images[0].byte_length == 11
|
||||
assert result.missing_fonts == ["Fixture Sans"]
|
||||
assert result.input_path == tmp_path / "deck.pptx"
|
||||
assert json.loads(office_cli.read_text())[5:] == [
|
||||
"--pages", "1,3", "--dpi", "144", "--max-pages", "10", "--max-pixels", "1000000", "--max-dimension", "4096",
|
||||
]
|
||||
|
||||
|
||||
def test_worksheet_selection_and_csv_sheet_keep_exact_names(office_cli: Path, tmp_path: Path) -> None:
|
||||
sheet = "收入 $Values"
|
||||
render_images("workbook.xlsx", tmp_path / "preview", sheet=sheet, range="A1:E9")
|
||||
assert json.loads(office_cli.read_text())[5:] == ["--sheet", sheet, "--range", "A1:E9"]
|
||||
convert("workbook.xlsx", tmp_path / "data.csv", sheet=sheet)
|
||||
assert json.loads(office_cli.read_text())[5:] == ["--sheet", sheet]
|
||||
|
||||
|
||||
def test_limits_and_fonts_reach_the_cli(office_cli: Path, tmp_path: Path) -> None:
|
||||
convert("report.docx", tmp_path / "report.pdf", options=OfficeOptions(
|
||||
timeout_ms=4567, max_input_bytes=1024, max_output_bytes=2048, max_image_resolution=72,
|
||||
max_archive_entries=10, max_uncompressed_bytes=4096, max_font_files=20,
|
||||
max_font_file_bytes=8192, max_loaded_font_bytes=16384,
|
||||
font_directories=[tmp_path / "Fonts One", tmp_path / "Fonts Two"],
|
||||
initial_font_families=["Fixture Sans", "中文字体"], font_fallbacks=[["Preferred", "Fallback"]],
|
||||
))
|
||||
assert json.loads(office_cli.read_text())[5:] == [
|
||||
"--timeout-ms", "4567", "--max-input-bytes", "1024", "--max-output-bytes", "2048",
|
||||
"--max-image-resolution", "72", "--max-archive-entries", "10", "--max-uncompressed-bytes", "4096",
|
||||
"--max-font-files", "20", "--max-font-file-bytes", "8192", "--max-loaded-font-bytes", "16384",
|
||||
"--font-directory", str(tmp_path / "Fonts One"), "--font-directory", str(tmp_path / "Fonts Two"),
|
||||
"--initial-font-family", "Fixture Sans", "--initial-font-family", "中文字体",
|
||||
"--font-fallbacks", '[["Preferred", "Fallback"]]',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("filename", "code", "exit_code", "message"), [
|
||||
("error.docx", "invalid-document", 1, "Document cannot be loaded"),
|
||||
("crash.docx", "failed", 7, "engine process unavailable"),
|
||||
("timeout.docx", "timeout", 1, "Conversion timed out"),
|
||||
])
|
||||
def test_cli_failures_preserve_diagnostics_after_exit(
|
||||
office_cli: Path, tmp_path: Path, filename: str, code: str, exit_code: int, message: str,
|
||||
) -> None:
|
||||
output = tmp_path / "result.pdf"
|
||||
with pytest.raises(OfficeError, match=message) as failure:
|
||||
convert(filename, output, options=OfficeOptions(timeout_ms=100))
|
||||
assert failure.value.code == code
|
||||
assert failure.value.exit_code == exit_code
|
||||
assert not output.exists()
|
||||
assert json.loads(office_cli.read_text())[-2:] == ["--timeout-ms", "100"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["invalid.docx", "invalid-bytes.docx"])
|
||||
def test_invalid_cli_result_fails_at_the_json_reader(office_cli: Path, tmp_path: Path, filename: str) -> None:
|
||||
with pytest.raises(SdkProtocolError, match="Office CLI returned an invalid result"):
|
||||
convert(filename, tmp_path / "result.pdf")
|
||||
@@ -145,7 +145,7 @@ def test_stage_runtime_copies_platform_payload(
|
||||
spawn_helper.write_bytes(b"helper")
|
||||
spawn_helper.chmod(0o755)
|
||||
expected[spawn_helper.name] = b"helper"
|
||||
office = executable.parent / build_python_release.office_sidecar_name(executable.name)
|
||||
office = executable.parent / f"{executable.name.removesuffix('.exe')}-office"
|
||||
office_asset = office / "node_modules" / "@deepseek-ai" / "libreoffice-kit-wasm" / "assets" / "soffice.data"
|
||||
office_asset.parent.mkdir(parents=True)
|
||||
office_asset.write_bytes(b"office data")
|
||||
@@ -163,7 +163,7 @@ def test_stage_runtime_copies_platform_payload(
|
||||
for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*")
|
||||
if path.is_file()
|
||||
} == expected
|
||||
assert (runtime_dir / office.name / office_asset.relative_to(office)).read_bytes() == b"office data"
|
||||
assert not (runtime_dir / office.name).exists()
|
||||
assert (runtime_dir / resources.name / resource.relative_to(resources)).read_text() == "checker"
|
||||
pyproject = (destination / "pyproject.toml").read_text()
|
||||
assert 'license = "MIT"' in pyproject
|
||||
@@ -191,43 +191,9 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) ->
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_tag,selected", [
|
||||
("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"),
|
||||
("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"),
|
||||
("manylinux_2_28_x86_64", "linux-x64"), ("macosx_14_0_arm64", "wasm"),
|
||||
])
|
||||
@pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"])
|
||||
def test_office_wheel_requires_only_target_engine(
|
||||
tmp_path: Path, platform_tag: str, selected: str, invalid: str | None,
|
||||
) -> None:
|
||||
root = "runtime-office/node_modules"
|
||||
wheel = tmp_path / "office.zip"
|
||||
native = selected != "wasm"
|
||||
if invalid == "helper-mode" and (not native or platform_tag == "win_amd64"):
|
||||
pytest.skip("executable mode applies to POSIX native helpers")
|
||||
engine = ({"kind": "native", "executable": "bin/helper"} if native else
|
||||
{"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"})
|
||||
with zipfile.ZipFile(wheel, "w") as archive:
|
||||
archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", json.dumps({
|
||||
"optionalDependencies": {f"@deepseek-ai/libreoffice-kit-{selected}": "0.0.1"},
|
||||
}))
|
||||
base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}"
|
||||
if invalid != "missing-engine":
|
||||
archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine}))
|
||||
for field in (("executable",) if native else ("loader", "wasm", "data", "metadata")):
|
||||
if invalid == "asset" and field == ("executable" if native else "data"):
|
||||
continue
|
||||
asset = zipfile.ZipInfo(f"{base}/{engine[field]}")
|
||||
asset.external_attr = (0o644 if invalid == "helper-mode" else 0o755) << 16
|
||||
archive.writestr(asset, b"payload")
|
||||
if invalid == "foreign-engine":
|
||||
foreign = "wasm" if native else "darwin-arm64"
|
||||
archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit-{foreign}/prebuilds.json", "{}")
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
if invalid is None:
|
||||
build_python_release.verify_office_payload(archive, root, platform_tag)
|
||||
else:
|
||||
message = {"asset": "asset is missing", "missing-engine": "dependency is missing",
|
||||
"foreign-engine": "Unexpected Office engine", "helper-mode": "executable bit"}[invalid]
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
build_python_release.verify_office_payload(archive, root, platform_tag)
|
||||
def test_wheel_verification_rejects_oversized_artifacts(tmp_path: Path) -> None:
|
||||
wheel = tmp_path / "oversized.whl"
|
||||
with wheel.open("wb") as output:
|
||||
output.truncate(100_000_001)
|
||||
with pytest.raises(RuntimeError, match="PyPI single-file limit"):
|
||||
build_python_release.verify_wheel(wheel, "runtime", "1.2.3", ("win_amd64", "runtime.exe"))
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,11 @@ def _resource_sidecars(executable: Path, native_targets: tuple[str, ...] = ("dar
|
||||
f"@deepseek-ai/libreoffice-kit-{target}": "0.0.1" for target in (*native_targets, "wasm")
|
||||
}}), encoding="utf-8")
|
||||
resources = executable.with_name(tag)
|
||||
resources.mkdir(parents=True, exist_ok=True)
|
||||
(resources / "downloads.json").write_text(json.dumps({
|
||||
"target": tag.replace("macos-", "mac-"), "identity": "a" * 64,
|
||||
"office": [{"name": "fixture"}], "pnpm": {},
|
||||
}))
|
||||
manifest = resources / "primary-runtime/runtime.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text(json.dumps({"platform": native.rsplit("-", 1)[0], "arch": tag.rsplit("-", 1)[1],
|
||||
@@ -195,19 +201,20 @@ def test_runtime_requires_complete_resource_sidecars(
|
||||
monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64")
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Office sidecar"):
|
||||
with pytest.raises(FileNotFoundError, match="downloads.json"):
|
||||
runtime.bundled_runtime_path()
|
||||
office = _resource_sidecars(executable)
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
(office / "node_modules/@deepseek-ai/libreoffice-kit-wasm/prebuilds.json").unlink()
|
||||
with pytest.raises(FileNotFoundError, match="Office sidecar"):
|
||||
runtime.bundled_runtime_path()
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
shutil.rmtree(executable.with_name("linux-x64") / "primary-runtime")
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
|
||||
|
||||
def test_node_mode_runs_the_deployed_dsh_cli(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
bin_js = tmp_path / "runtime" / "node" / "node_modules" / "@deepseek-ai" / "dsh" / "lib" / "bin.js"
|
||||
bin_js = tmp_path / "runtime" / "node" / "runtime-bootstrap.mjs"
|
||||
bin_js.parent.mkdir(parents=True)
|
||||
bin_js.touch()
|
||||
monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
|
||||
@@ -303,7 +310,7 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path:
|
||||
("macos-arm64", ("darwin-arm64",)), ("macos-x64", ("darwin-x64",)), ("win-x64", ("win32-x64",)),
|
||||
("linux-x64", ("linux-x64",)), ("macos-arm64", ()),
|
||||
])
|
||||
def test_runtime_requires_its_platform_office_engine(
|
||||
def test_runtime_starts_independently_of_office_engine(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str, native_targets: tuple[str, ...],
|
||||
) -> None:
|
||||
extension = ".exe" if target.startswith("win-") else ""
|
||||
@@ -322,5 +329,41 @@ def test_runtime_requires_its_platform_office_engine(
|
||||
foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if engine.parent.name == "libreoffice-kit-wasm" else "libreoffice-kit-wasm") / "prebuilds.json"
|
||||
foreign.parent.mkdir(parents=True, exist_ok=True)
|
||||
foreign.write_text("{}")
|
||||
with pytest.raises(FileNotFoundError, match="Office sidecar"):
|
||||
runtime.bundled_runtime_path()
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("function,flag", [
|
||||
(runtime.download_office, "--download-office"),
|
||||
(runtime.download_primary_runtime, "--download-primary-runtime"),
|
||||
])
|
||||
def test_explicit_download_uses_runtime_without_a_harness_home(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, function, flag: str,
|
||||
) -> None:
|
||||
script = tmp_path / "download.py"
|
||||
destination = tmp_path / "downloaded"
|
||||
script.write_text("import json,sys\n" + f"assert sys.argv[1] == {flag!r}\nprint(json.dumps({str(destination)!r}))\n")
|
||||
monkeypatch.delenv("DSH_HOME", raising=False)
|
||||
monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: (sys.executable, str(script)))
|
||||
assert function() == destination
|
||||
script.write_text("import sys\nprint('checksum mismatch', file=sys.stderr)\nsys.exit(2)\n")
|
||||
with pytest.raises(RuntimeError, match="checksum mismatch"):
|
||||
function()
|
||||
|
||||
|
||||
def test_office_resolution_requires_explicit_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from deepseek_harness_runtime._resources import office_launch_args
|
||||
|
||||
executable = tmp_path / "deepseek-harness-sdk-runtime-linux-x64"
|
||||
_resource_sidecars(executable)
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setenv("DSH_RESOURCE_CACHE", str(cache))
|
||||
monkeypatch.delenv("DSH_OFFICE_SIDECAR", raising=False)
|
||||
with pytest.raises(FileNotFoundError, match=r"download_office\(\)"):
|
||||
office_launch_args(tmp_path / "linux-x64", "linux-x64")
|
||||
office = cache / ("a" * 64) / "office"
|
||||
node = office / "node/bin/node"
|
||||
cli = office / "node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js"
|
||||
for path in (node, cli):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
assert office_launch_args(tmp_path / "linux-x64", "linux-x64") == (str(node), str(cli))
|
||||
Reference in new issue
Block a user