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
@@ -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)
|
||||
Reference in new issue
Block a user