modelport-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modelport/golden.py ADDED
@@ -0,0 +1,108 @@
1
+ """Golden data: saved inputs and expected outputs that prove a device matches Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+ from .bundle import Bundle
11
+ from .errors import MissingDependencyError, ModelPortError
12
+ from .manifest import DType, FileRef, Golden, Manifest, TensorSpec
13
+ from .preprocess import preprocess_image, to_le_bytes
14
+ from .sources import SourceModel
15
+
16
+ NUMPY_DTYPES: dict[DType, str] = {
17
+ DType.FLOAT32: "<f4",
18
+ DType.FLOAT16: "<f2",
19
+ DType.INT64: "<i8",
20
+ DType.INT32: "<i4",
21
+ DType.INT8: "i1",
22
+ DType.UINT8: "u1",
23
+ DType.BOOL: "?",
24
+ }
25
+
26
+
27
+ def dtype_of(array: np.ndarray) -> DType:
28
+ for dtype, code in NUMPY_DTYPES.items():
29
+ if np.dtype(code) == array.dtype.newbyteorder("<"):
30
+ return dtype
31
+ raise ModelPortError(f"unsupported tensor dtype {array.dtype}")
32
+
33
+
34
+ def sample_image(width: int = 320, height: int = 240) -> Image.Image:
35
+ """A fixed, colourful test picture with gradients, a ring, and fine detail."""
36
+ y, x = np.mgrid[0:height, 0:width].astype(np.float64)
37
+ red = x * 255 / (width - 1)
38
+ green = y * 255 / (height - 1)
39
+ blue = 128 + 127 * np.sin(x / 9.0) * np.cos(y / 7.0)
40
+ distance = np.hypot(x - width * 0.6, y - height * 0.45)
41
+ ring = (distance > 40) & (distance < 60)
42
+ pixels = np.stack([red, green, blue], axis=-1)
43
+ pixels[ring] = (250, 240, 20)
44
+ return Image.fromarray(np.clip(np.rint(pixels), 0, 255).astype(np.uint8), "RGB")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class GoldenData:
49
+ golden: Golden
50
+ inputs: dict[str, np.ndarray]
51
+ outputs: dict[str, np.ndarray]
52
+
53
+
54
+ def make_golden(source: SourceModel, bundle: Bundle, image: Image.Image) -> GoldenData:
55
+ """Preprocess `image`, run the PyTorch model, and save both sides under golden/."""
56
+ try:
57
+ import torch
58
+ except ImportError as error:
59
+ raise MissingDependencyError("Creating golden data", "onnx") from error
60
+
61
+ inputs: dict[str, np.ndarray] = {}
62
+ for spec in source.inputs:
63
+ if spec.preprocess is None:
64
+ raise ModelPortError(f"input '{spec.name}' needs image preprocessing for golden data")
65
+ inputs[spec.name] = preprocess_image(image, spec)
66
+
67
+ with torch.no_grad():
68
+ result = source.module(*(torch.from_numpy(inputs[s.name]) for s in source.inputs))
69
+ results = result if isinstance(result, (tuple, list)) else (result,)
70
+ if len(results) != len(source.output_names):
71
+ raise ModelPortError(
72
+ f"model returned {len(results)} outputs, expected {len(source.output_names)}"
73
+ )
74
+ outputs = {
75
+ name: tensor.detach().cpu().numpy()
76
+ for name, tensor in zip(source.output_names, results, strict=True)
77
+ }
78
+
79
+ input_refs = {
80
+ name: bundle.write_bytes(f"golden/{name}.bin", to_le_bytes(a)) for name, a in inputs.items()
81
+ }
82
+ output_refs = {
83
+ name: bundle.write_bytes(f"golden/{name}.bin", to_le_bytes(a))
84
+ for name, a in outputs.items()
85
+ }
86
+ return GoldenData(Golden(inputs=input_refs, outputs=output_refs), inputs, outputs)
87
+
88
+
89
+ def read_tensor(bundle: Bundle, ref: FileRef, spec: TensorSpec) -> np.ndarray:
90
+ """Load a golden file as an array with the tensor's dtype and shape."""
91
+ if ref.path is None:
92
+ raise ModelPortError("golden files must be inside the bundle")
93
+ data = bundle.path(ref.path).read_bytes()
94
+ # Copy so the array is writable; torch warns when wrapping read-only buffers.
95
+ array = np.frombuffer(data, dtype=NUMPY_DTYPES[spec.dtype]).copy()
96
+ if spec.shape.count(-1) <= 1:
97
+ array = array.reshape(spec.shape)
98
+ return array
99
+
100
+
101
+ def golden_arrays(bundle: Bundle, manifest: Manifest) -> tuple[dict, dict]:
102
+ """Golden inputs and outputs of a manifest, keyed by tensor name."""
103
+ if manifest.golden is None:
104
+ raise ModelPortError(f"{manifest.id} has no golden data")
105
+ specs = {s.name: s for s in [*manifest.inputs, *manifest.outputs]}
106
+ inputs = {n: read_tensor(bundle, r, specs[n]) for n, r in manifest.golden.inputs.items()}
107
+ outputs = {n: read_tensor(bundle, r, specs[n]) for n, r in manifest.golden.outputs.items()}
108
+ return inputs, outputs
modelport/hashing.py ADDED
@@ -0,0 +1,17 @@
1
+ """File hashing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ _CHUNK = 1 << 20
9
+
10
+
11
+ def sha256_file(path: str | Path) -> str:
12
+ """Lowercase hex SHA-256 of a file, read in 1 MiB chunks."""
13
+ digest = hashlib.sha256()
14
+ with Path(path).open("rb") as handle:
15
+ while chunk := handle.read(_CHUNK):
16
+ digest.update(chunk)
17
+ return digest.hexdigest()
@@ -0,0 +1,87 @@
1
+ """Read inputs, outputs, and metadata from model files without running them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from ..errors import MissingDependencyError, ModelPortError
10
+ from ..hashing import sha256_file
11
+
12
+ ModelFormat = Literal["onnx", "executorch", "gguf"]
13
+
14
+
15
+ class InspectError(ModelPortError):
16
+ """A model file could not be inspected."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TensorInfo:
21
+ name: str
22
+ dtype: str
23
+ shape: list[int | str]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ModelInfo:
28
+ format: ModelFormat
29
+ path: Path
30
+ size: int
31
+ sha256: str
32
+ inputs: list[TensorInfo] = field(default_factory=list)
33
+ outputs: list[TensorInfo] = field(default_factory=list)
34
+ metadata: dict[str, str] = field(default_factory=dict)
35
+
36
+
37
+ def detect_format(path: Path) -> ModelFormat:
38
+ """Detect the format from magic bytes, falling back to the file extension."""
39
+ with path.open("rb") as handle:
40
+ head = handle.read(8)
41
+ if head[:4] == b"GGUF":
42
+ return "gguf"
43
+ if head[4:8] == b"ET12":
44
+ return "executorch"
45
+ if path.suffix.lower() == ".onnx":
46
+ return "onnx"
47
+ raise InspectError(f"{path}: not a recognized model file (.onnx, .pte, or .gguf)")
48
+
49
+
50
+ def inspect_model(path: str | Path) -> ModelInfo:
51
+ """Describe a model file. Raises InspectError if it cannot be read."""
52
+ path = Path(path)
53
+ if not path.is_file():
54
+ raise InspectError(f"{path}: file not found")
55
+ fmt = detect_format(path)
56
+ if fmt == "onnx":
57
+ from .onnx import read_onnx
58
+
59
+ inputs, outputs, metadata = read_onnx(path)
60
+ elif fmt == "executorch":
61
+ from .executorch import read_executorch
62
+
63
+ inputs, outputs, metadata = read_executorch(path)
64
+ else:
65
+ from .gguf import read_gguf
66
+
67
+ inputs, outputs, metadata = read_gguf(path)
68
+ return ModelInfo(
69
+ format=fmt,
70
+ path=path,
71
+ size=path.stat().st_size,
72
+ sha256=sha256_file(path),
73
+ inputs=inputs,
74
+ outputs=outputs,
75
+ metadata=metadata,
76
+ )
77
+
78
+
79
+ __all__ = [
80
+ "InspectError",
81
+ "MissingDependencyError",
82
+ "ModelFormat",
83
+ "ModelInfo",
84
+ "TensorInfo",
85
+ "detect_format",
86
+ "inspect_model",
87
+ ]
@@ -0,0 +1,32 @@
1
+ """ExecuTorch (.pte) program inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import MissingDependencyError, TensorInfo
8
+
9
+ # c10::ScalarType codes that map to manifest dtypes.
10
+ _DTYPES = {0: "uint8", 1: "int8", 3: "int32", 4: "int64", 5: "float16", 6: "float32", 11: "bool"}
11
+
12
+
13
+ def read_executorch(path: Path) -> tuple[list[TensorInfo], list[TensorInfo], dict[str, str]]:
14
+ try:
15
+ from executorch.runtime import Runtime
16
+ except ImportError as error:
17
+ raise MissingDependencyError("Inspecting .pte files", "executorch") from error
18
+
19
+ program = Runtime.get().load_program(str(path))
20
+ methods = sorted(program.method_names)
21
+ method = "forward" if "forward" in methods else methods[0]
22
+ meta = program.metadata(method)
23
+
24
+ def describe(prefix: str, info: object, index: int) -> TensorInfo:
25
+ dtype = int(info.dtype()) # type: ignore[attr-defined]
26
+ sizes = list(info.sizes()) # type: ignore[attr-defined]
27
+ return TensorInfo(f"{prefix}_{index}", _DTYPES.get(dtype, f"scalar_type_{dtype}"), sizes)
28
+
29
+ inputs = [describe("input", meta.input_tensor_meta(i), i) for i in range(meta.num_inputs())]
30
+ outputs = [describe("output", meta.output_tensor_meta(i), i) for i in range(meta.num_outputs())]
31
+ metadata = {"methods": ", ".join(methods), "inspected_method": method}
32
+ return inputs, outputs, metadata
@@ -0,0 +1,42 @@
1
+ """GGUF (llama.cpp) model inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import MissingDependencyError, TensorInfo
9
+
10
+
11
+ def read_gguf(path: Path) -> tuple[list[TensorInfo], list[TensorInfo], dict[str, str]]:
12
+ try:
13
+ import gguf
14
+ except ImportError as error:
15
+ raise MissingDependencyError("Inspecting .gguf files", "gguf") from error
16
+
17
+ reader = gguf.GGUFReader(str(path))
18
+
19
+ def value(key: str) -> Any:
20
+ field = reader.fields.get(key)
21
+ return field.contents() if field is not None else None
22
+
23
+ arch = value("general.architecture") or "unknown"
24
+ file_type = value("general.file_type")
25
+ if file_type is not None:
26
+ try:
27
+ quant = gguf.LlamaFileType(file_type).name.removeprefix("MOSTLY_").lower()
28
+ except ValueError:
29
+ quant = str(file_type)
30
+ else:
31
+ quant = "unknown"
32
+
33
+ metadata = {
34
+ "architecture": str(arch),
35
+ "name": str(value("general.name") or "unknown"),
36
+ "quantization": quant,
37
+ "context_length": str(value(f"{arch}.context_length") or "unknown"),
38
+ "tensors": str(len(reader.tensors)),
39
+ "chat_template": "yes" if "tokenizer.chat_template" in reader.fields else "no",
40
+ "gguf_version": str(value("GGUF.version") or "unknown"),
41
+ }
42
+ return [], [], metadata
@@ -0,0 +1,44 @@
1
+ """ONNX model inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import MissingDependencyError, TensorInfo
9
+
10
+ # onnx.TensorProto element types that map to manifest dtypes.
11
+ _DTYPES = {1: "float32", 10: "float16", 7: "int64", 6: "int32", 3: "int8", 2: "uint8", 9: "bool"}
12
+
13
+
14
+ def read_onnx(path: Path) -> tuple[list[TensorInfo], list[TensorInfo], dict[str, str]]:
15
+ try:
16
+ import onnx
17
+ except ImportError as error:
18
+ raise MissingDependencyError("Inspecting .onnx files", "onnx") from error
19
+
20
+ model = onnx.load(str(path), load_external_data=False)
21
+ graph = model.graph
22
+ initializers = {init.name for init in graph.initializer}
23
+
24
+ def describe(value: Any) -> TensorInfo:
25
+ tensor_type = value.type.tensor_type
26
+ elem = tensor_type.elem_type
27
+ dtype = _DTYPES.get(elem, onnx.TensorProto.DataType.Name(elem).lower())
28
+ shape: list[int | str] = [
29
+ dim.dim_value if dim.HasField("dim_value") else (dim.dim_param or "?")
30
+ for dim in tensor_type.shape.dim
31
+ ]
32
+ return TensorInfo(name=value.name, dtype=dtype, shape=shape)
33
+
34
+ inputs = [describe(v) for v in graph.input if v.name not in initializers]
35
+ outputs = [describe(v) for v in graph.output]
36
+ opset = next((o.version for o in model.opset_import if o.domain in ("", "ai.onnx")), None)
37
+ external = any(init.data_location == onnx.TensorProto.EXTERNAL for init in graph.initializer)
38
+ metadata = {
39
+ "ir_version": str(model.ir_version),
40
+ "opset": str(opset) if opset is not None else "unknown",
41
+ "producer": f"{model.producer_name} {model.producer_version}".strip() or "unknown",
42
+ "external_data": "yes" if external else "no",
43
+ }
44
+ return inputs, outputs, metadata
@@ -0,0 +1,39 @@
1
+ """The `modelport.json` manifest: the contract between the CLI and the Dart packages."""
2
+
3
+ from .files import FileRef
4
+ from .models import (
5
+ MANIFEST_FILENAME,
6
+ SCHEMA_VERSION,
7
+ GenerationDefaults,
8
+ Golden,
9
+ LlmConfig,
10
+ Manifest,
11
+ Runtime,
12
+ Task,
13
+ Tolerance,
14
+ Variant,
15
+ )
16
+ from .postprocess import ClassificationPostprocess, DetectionPostprocess
17
+ from .tensors import DType, ImagePreprocess, InputSpec, OutputSpec, ResizeSpec, TensorSpec
18
+
19
+ __all__ = [
20
+ "MANIFEST_FILENAME",
21
+ "SCHEMA_VERSION",
22
+ "ClassificationPostprocess",
23
+ "DType",
24
+ "DetectionPostprocess",
25
+ "FileRef",
26
+ "GenerationDefaults",
27
+ "Golden",
28
+ "ImagePreprocess",
29
+ "InputSpec",
30
+ "LlmConfig",
31
+ "Manifest",
32
+ "OutputSpec",
33
+ "ResizeSpec",
34
+ "Runtime",
35
+ "Task",
36
+ "TensorSpec",
37
+ "Tolerance",
38
+ "Variant",
39
+ ]
@@ -0,0 +1,22 @@
1
+ """Shared building blocks for manifest models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ Sha256 = Annotated[
10
+ str,
11
+ Field(pattern=r"^[0-9a-f]{64}$", description="Lowercase hex SHA-256 digest of the file."),
12
+ ]
13
+
14
+
15
+ class StrictModel(BaseModel):
16
+ """Base for all manifest models.
17
+
18
+ Unknown fields are rejected so that typos in hand-written manifests are caught early.
19
+ Models are immutable once validated.
20
+ """
21
+
22
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
@@ -0,0 +1,59 @@
1
+ """File references inside a model bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pydantic import Field, field_validator, model_validator
8
+
9
+ from .base import Sha256, StrictModel
10
+
11
+ _WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
12
+
13
+ # Same rules as `_check_relative_path`, for JSON Schema users such as editors and Dart.
14
+ # Python's validator does not use it because pydantic-core's regex engine has no lookahead.
15
+ SAFE_PATH_PATTERN = r"^(?!/)(?![A-Za-z]:)(?!.*\\)(?!.*//)(?!.*/$)(?!(?:.*/)?\.\.?(?:/|$)).+$"
16
+
17
+
18
+ class FileRef(StrictModel):
19
+ """A file that belongs to a model, such as weights, labels, or golden test data.
20
+
21
+ Exactly one of `path` or `url` must be set.
22
+ """
23
+
24
+ model_config = StrictModel.model_config | {
25
+ "json_schema_extra": {"oneOf": [{"required": ["path"]}, {"required": ["url"]}]}
26
+ }
27
+
28
+ path: str | None = Field(
29
+ default=None,
30
+ description="POSIX path relative to the manifest file. Use for files inside the bundle.",
31
+ examples=["onnx-fp32/model.onnx", "labels.txt"],
32
+ json_schema_extra={"pattern": SAFE_PATH_PATTERN},
33
+ )
34
+ url: str | None = Field(
35
+ default=None,
36
+ pattern=r"^https://\S+$",
37
+ description=(
38
+ "Absolute HTTPS URL. Use for files hosted elsewhere, such as an original GGUF repo."
39
+ ),
40
+ )
41
+ size: int = Field(gt=0, description="File size in bytes.")
42
+ sha256: Sha256
43
+
44
+ @field_validator("path")
45
+ @classmethod
46
+ def _check_relative_path(cls, value: str | None) -> str | None:
47
+ if value is None:
48
+ return value
49
+ if value.startswith("/") or "\\" in value or _WINDOWS_DRIVE.match(value):
50
+ raise ValueError("path must be a relative POSIX path, like 'onnx-fp32/model.onnx'")
51
+ if any(part in ("", ".", "..") for part in value.split("/")):
52
+ raise ValueError("path must not contain empty, '.' or '..' segments")
53
+ return value
54
+
55
+ @model_validator(mode="after")
56
+ def _check_exactly_one_location(self) -> FileRef:
57
+ if (self.path is None) == (self.url is None):
58
+ raise ValueError("set exactly one of 'path' or 'url'")
59
+ return self