modelferry 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.
- modelferry/__init__.py +3 -0
- modelferry/cli.py +139 -0
- modelferry/errors.py +25 -0
- modelferry/hf.py +156 -0
- modelferry/manifest.py +190 -0
- modelferry/offline.py +549 -0
- modelferry/pack.py +423 -0
- modelferry-0.1.0.dist-info/METADATA +235 -0
- modelferry-0.1.0.dist-info/RECORD +12 -0
- modelferry-0.1.0.dist-info/WHEEL +4 -0
- modelferry-0.1.0.dist-info/entry_points.txt +2 -0
- modelferry-0.1.0.dist-info/licenses/LICENSE +201 -0
modelferry/__init__.py
ADDED
modelferry/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Typer command-line surface for modelferry (SPEC.md section 3).
|
|
2
|
+
|
|
3
|
+
`pack` runs the connected-side pack orchestration. `verify`, `unpack`, and
|
|
4
|
+
`inspect` are thin wrappers over offline.main, the exact code that ships inside
|
|
5
|
+
every bundle, so the installed CLI and the bundled tool behave identically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import __version__, offline
|
|
15
|
+
from . import pack as pack_mod
|
|
16
|
+
from .errors import PackError
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
add_completion=False,
|
|
20
|
+
help="Pack Hugging Face models into chunked, self-verifying bundles for air-gapped transfer.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def pack(
|
|
27
|
+
repo_id: Annotated[
|
|
28
|
+
str, typer.Argument(help="Hugging Face repo id, e.g. Qwen/Qwen2.5-7B-Instruct.")
|
|
29
|
+
],
|
|
30
|
+
dest: Annotated[str, typer.Option("--dest", help="Directory to write the bundle into.")],
|
|
31
|
+
revision: Annotated[
|
|
32
|
+
str, typer.Option("--revision", help="Git revision to pin. Resolved to a commit SHA.")
|
|
33
|
+
] = "main",
|
|
34
|
+
chunk_size: Annotated[
|
|
35
|
+
str, typer.Option("--chunk-size", help="Max part size (e.g. 3900M, 16G) or 'none'.")
|
|
36
|
+
] = "3900M",
|
|
37
|
+
include: Annotated[
|
|
38
|
+
list[str] | None, typer.Option("--include", help="fnmatch include pattern (repeatable).")
|
|
39
|
+
] = None,
|
|
40
|
+
exclude: Annotated[
|
|
41
|
+
list[str] | None, typer.Option("--exclude", help="fnmatch exclude pattern (repeatable).")
|
|
42
|
+
] = None,
|
|
43
|
+
staging: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--staging", help="Local download directory. Defaults to ~/.cache/modelferry/."
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Pack a Hugging Face model repo into a bundle (connected side)."""
|
|
51
|
+
try:
|
|
52
|
+
bundle_dir = pack_mod.pack(
|
|
53
|
+
repo_id,
|
|
54
|
+
dest,
|
|
55
|
+
revision=revision,
|
|
56
|
+
chunk_size=chunk_size,
|
|
57
|
+
include=include,
|
|
58
|
+
exclude=exclude,
|
|
59
|
+
staging=staging,
|
|
60
|
+
)
|
|
61
|
+
except PackError as e:
|
|
62
|
+
typer.echo(f"error: {e}", err=True)
|
|
63
|
+
raise typer.Exit(e.exit_code) from None
|
|
64
|
+
except OSError as e:
|
|
65
|
+
typer.echo(
|
|
66
|
+
f"error: local filesystem error while packing: {e}. "
|
|
67
|
+
"Check permissions and free disk space, then re-run.",
|
|
68
|
+
err=True,
|
|
69
|
+
)
|
|
70
|
+
raise typer.Exit(4) from None
|
|
71
|
+
typer.echo(f"wrote bundle: {bundle_dir}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def verify(
|
|
76
|
+
bundle_dir: Annotated[str, typer.Argument(help="Bundle directory to verify.")],
|
|
77
|
+
quiet: Annotated[
|
|
78
|
+
bool, typer.Option("--quiet", help="Print only the summary line and failures.")
|
|
79
|
+
] = False,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Verify a bundle offline against its manifest."""
|
|
82
|
+
argv = ["verify", bundle_dir] + (["--quiet"] if quiet else [])
|
|
83
|
+
raise typer.Exit(offline.main(argv))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def unpack(
|
|
88
|
+
bundle_dir: Annotated[str, typer.Argument(help="Bundle directory to unpack.")],
|
|
89
|
+
dest_dir: Annotated[
|
|
90
|
+
str, typer.Argument(help="Destination directory for the reconstructed tree.")
|
|
91
|
+
],
|
|
92
|
+
no_verify: Annotated[
|
|
93
|
+
bool, typer.Option("--no-verify", help="Skip the verify pass before unpacking.")
|
|
94
|
+
] = False,
|
|
95
|
+
force: Annotated[
|
|
96
|
+
bool, typer.Option("--force", help="Allow unpacking into a non-empty destination.")
|
|
97
|
+
] = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Verify and unpack a bundle offline into a loadable model tree."""
|
|
100
|
+
argv = ["unpack", bundle_dir, dest_dir]
|
|
101
|
+
if no_verify:
|
|
102
|
+
argv.append("--no-verify")
|
|
103
|
+
if force:
|
|
104
|
+
argv.append("--force")
|
|
105
|
+
raise typer.Exit(offline.main(argv))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def inspect(
|
|
110
|
+
bundle_dir: Annotated[str, typer.Argument(help="Bundle directory to inspect.")],
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Print a bundle summary offline. No hashing."""
|
|
113
|
+
raise typer.Exit(offline.main(["inspect", bundle_dir]))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def version_callback(value: bool) -> None:
|
|
117
|
+
if value:
|
|
118
|
+
typer.echo(__version__)
|
|
119
|
+
raise typer.Exit()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.callback()
|
|
123
|
+
def _root(
|
|
124
|
+
version: Annotated[
|
|
125
|
+
bool,
|
|
126
|
+
typer.Option(
|
|
127
|
+
"--version", callback=version_callback, is_eager=True, help="Show version and exit."
|
|
128
|
+
),
|
|
129
|
+
] = False,
|
|
130
|
+
) -> None:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
app()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
modelferry/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Pack-side error types carrying SPEC section 10 exit codes.
|
|
2
|
+
|
|
3
|
+
offline.py has its own independent hierarchy (it imports nothing from this
|
|
4
|
+
package); these are for the connected-side pack path and the CLI wrappers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PackError(Exception):
|
|
11
|
+
"""Base for anticipated pack-side failures. Prints one line, no traceback."""
|
|
12
|
+
|
|
13
|
+
exit_code = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UsageError(PackError):
|
|
17
|
+
exit_code = 2 # bad arguments, pre-flight payload violations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SourceError(PackError):
|
|
21
|
+
exit_code = 3 # network failure, HF auth / 404 / gated-without-token
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LocalFsError(PackError):
|
|
25
|
+
exit_code = 4 # permissions, disk full, destination already exists
|
modelferry/hf.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Hugging Face hub access: resolve a revision to a commit, download at that
|
|
2
|
+
commit, and report source metadata (license, gated, endpoint) for the manifest.
|
|
3
|
+
|
|
4
|
+
HF_TOKEN is read from the environment only and is never returned, logged, or
|
|
5
|
+
written anywhere (§9). Hub failures map to SPEC exit code 3 (source error).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .errors import SourceError
|
|
14
|
+
|
|
15
|
+
DEFAULT_ENDPOINT = "https://huggingface.co"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _select(candidates, include, exclude):
|
|
19
|
+
"""Apply fnmatch include/exclude against repo-relative paths. Exclude wins."""
|
|
20
|
+
selected = []
|
|
21
|
+
for path in candidates:
|
|
22
|
+
if include and not any(fnmatch.fnmatch(path, pat) for pat in include):
|
|
23
|
+
continue
|
|
24
|
+
if exclude and any(fnmatch.fnmatch(path, pat) for pat in exclude):
|
|
25
|
+
continue
|
|
26
|
+
selected.append(path)
|
|
27
|
+
return selected
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_license(info):
|
|
31
|
+
card = getattr(info, "card_data", None)
|
|
32
|
+
if card is not None:
|
|
33
|
+
value = card.get("license") if hasattr(card, "get") else getattr(card, "license", None)
|
|
34
|
+
if value:
|
|
35
|
+
return str(value)
|
|
36
|
+
for tag in getattr(info, "tags", None) or []:
|
|
37
|
+
if isinstance(tag, str) and tag.startswith("license:"):
|
|
38
|
+
return tag.split(":", 1)[1]
|
|
39
|
+
return "UNKNOWN"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _staging_dir_for(staging, repo_id):
|
|
43
|
+
root = staging or os.path.join(os.path.expanduser("~"), ".cache", "modelferry")
|
|
44
|
+
return os.path.join(root, repo_id.replace("/", "__"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve(repo_id, revision, staging, include, exclude):
|
|
48
|
+
"""Resolve a repo to a commit and select files, without downloading anything.
|
|
49
|
+
|
|
50
|
+
Returns a dict: commit_sha, source (the §5 source block), files (list of
|
|
51
|
+
(repo_rel_path, size_bytes) for the selected files, sizes from hub metadata),
|
|
52
|
+
total_bytes, local_dir (the staging path the download will land in), endpoint.
|
|
53
|
+
Raises SourceError (exit 3). Separated from download() so pack can validate
|
|
54
|
+
--dest and free space against total_bytes before any bytes move.
|
|
55
|
+
"""
|
|
56
|
+
from huggingface_hub import HfApi
|
|
57
|
+
from huggingface_hub.utils import GatedRepoError, RepositoryNotFoundError
|
|
58
|
+
|
|
59
|
+
token = os.environ.get("HF_TOKEN")
|
|
60
|
+
endpoint = os.environ.get("HF_ENDPOINT", DEFAULT_ENDPOINT)
|
|
61
|
+
local_dir = _staging_dir_for(staging, repo_id)
|
|
62
|
+
api = HfApi(endpoint=endpoint, token=token)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
info = api.repo_info(repo_id, revision=revision, repo_type="model", files_metadata=True)
|
|
66
|
+
except GatedRepoError as e:
|
|
67
|
+
raise SourceError(
|
|
68
|
+
f"repo {repo_id!r} is gated. Request access and set HF_TOKEN to an account "
|
|
69
|
+
f"that has it, then re-run pack ({e})."
|
|
70
|
+
) from None
|
|
71
|
+
except RepositoryNotFoundError as e:
|
|
72
|
+
raise SourceError(
|
|
73
|
+
f"repo {repo_id!r} not found. Check the repo id, or set HF_TOKEN if it is "
|
|
74
|
+
f"private ({e})."
|
|
75
|
+
) from None
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise SourceError(
|
|
78
|
+
f"could not read repo {repo_id!r} from {endpoint}: {e}. Check your network "
|
|
79
|
+
f"connection and the repo id."
|
|
80
|
+
) from None
|
|
81
|
+
|
|
82
|
+
commit_sha = getattr(info, "sha", None)
|
|
83
|
+
if not commit_sha:
|
|
84
|
+
raise SourceError(
|
|
85
|
+
f"the hub returned no commit sha for {repo_id!r} at revision {revision!r}; "
|
|
86
|
+
f"try a different --revision."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
size_by_path = {}
|
|
90
|
+
for s in getattr(info, "siblings", None) or []:
|
|
91
|
+
name = getattr(s, "rfilename", None)
|
|
92
|
+
if name is not None:
|
|
93
|
+
size_by_path[name] = getattr(s, "size", None) or 0
|
|
94
|
+
wanted = _select(list(size_by_path), include, exclude)
|
|
95
|
+
if not wanted:
|
|
96
|
+
raise SourceError(
|
|
97
|
+
f"no files in {repo_id!r} matched the --include/--exclude patterns; widen them."
|
|
98
|
+
)
|
|
99
|
+
files = [(rel, size_by_path.get(rel, 0)) for rel in wanted]
|
|
100
|
+
|
|
101
|
+
gated_raw = getattr(info, "gated", False)
|
|
102
|
+
source = {
|
|
103
|
+
"type": "huggingface",
|
|
104
|
+
"endpoint": endpoint,
|
|
105
|
+
"repo_id": repo_id,
|
|
106
|
+
"repo_type": "model",
|
|
107
|
+
"revision_requested": revision,
|
|
108
|
+
"commit_sha": commit_sha,
|
|
109
|
+
"license": _extract_license(info),
|
|
110
|
+
"gated": bool(gated_raw) and gated_raw is not False,
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
"commit_sha": commit_sha,
|
|
114
|
+
"source": source,
|
|
115
|
+
"files": files,
|
|
116
|
+
"total_bytes": sum(size for _, size in files),
|
|
117
|
+
"local_dir": local_dir,
|
|
118
|
+
"endpoint": endpoint,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def download(repo_id, commit_sha, local_dir, endpoint, include, exclude, wanted):
|
|
123
|
+
"""Download the selected files at commit_sha into local_dir.
|
|
124
|
+
|
|
125
|
+
Returns (snapshot_dir, rel_files). Downloads in local_dir mode (§3): real
|
|
126
|
+
files under --staging, no symlinks, resumable. snapshot_dir also holds a
|
|
127
|
+
.cache/huggingface metadata dir that is never part of rel_files. Raises
|
|
128
|
+
SourceError (exit 3).
|
|
129
|
+
"""
|
|
130
|
+
from huggingface_hub import snapshot_download
|
|
131
|
+
|
|
132
|
+
token = os.environ.get("HF_TOKEN")
|
|
133
|
+
print(f"downloading {len(wanted)} file(s) from {repo_id} at {commit_sha[:7]} ...")
|
|
134
|
+
try:
|
|
135
|
+
snapshot_dir = snapshot_download(
|
|
136
|
+
repo_id,
|
|
137
|
+
repo_type="model",
|
|
138
|
+
revision=commit_sha,
|
|
139
|
+
local_dir=local_dir,
|
|
140
|
+
allow_patterns=include or None,
|
|
141
|
+
ignore_patterns=exclude or None,
|
|
142
|
+
token=token,
|
|
143
|
+
endpoint=endpoint,
|
|
144
|
+
)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
raise SourceError(
|
|
147
|
+
f"download of {repo_id!r} into {local_dir} failed: {e}. Check your network "
|
|
148
|
+
f"connection and free disk space, then re-run pack to resume."
|
|
149
|
+
) from None
|
|
150
|
+
|
|
151
|
+
rel_files = [
|
|
152
|
+
rel for rel in wanted if os.path.isfile(os.path.join(snapshot_dir, *rel.split("/")))
|
|
153
|
+
]
|
|
154
|
+
if not rel_files:
|
|
155
|
+
raise SourceError(f"nothing was downloaded for {repo_id!r} into {local_dir}.")
|
|
156
|
+
return snapshot_dir, rel_files
|
modelferry/manifest.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Manifest construction and MANIFEST.md rendering (pack side only).
|
|
2
|
+
|
|
3
|
+
The reader in offline.py has its own independent manifest parser; the §11
|
|
4
|
+
writer/reader round-trip test keeps the two honest. Serialization here is
|
|
5
|
+
deterministic: json.dumps(indent=2, sort_keys=True) plus a trailing newline, so
|
|
6
|
+
the same inputs always produce byte-identical manifest.json.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
MANIFEST_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_manifest(bundle_name, created_at, tool, source, chunk_size_bytes, files, verifier):
|
|
18
|
+
"""Assemble the manifest dict. Pure function of its inputs (see §5).
|
|
19
|
+
|
|
20
|
+
files is a list of file entries already carrying path/bytes/sha256 (and parts
|
|
21
|
+
for chunked files). total_bytes and file_count are derived here.
|
|
22
|
+
"""
|
|
23
|
+
ordered = sorted(files, key=lambda f: f["path"])
|
|
24
|
+
total_bytes = sum(f["bytes"] for f in ordered)
|
|
25
|
+
return {
|
|
26
|
+
"manifest_version": MANIFEST_VERSION,
|
|
27
|
+
"bundle_name": bundle_name,
|
|
28
|
+
"created_at": created_at,
|
|
29
|
+
"tool": tool,
|
|
30
|
+
"source": source,
|
|
31
|
+
"payload": {
|
|
32
|
+
"hash_algorithm": "sha256",
|
|
33
|
+
"chunk_size_bytes": int(chunk_size_bytes or 0),
|
|
34
|
+
"file_count": len(ordered),
|
|
35
|
+
"total_bytes": total_bytes,
|
|
36
|
+
"files": ordered,
|
|
37
|
+
},
|
|
38
|
+
"verifier": verifier,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def serialize(manifest):
|
|
43
|
+
"""Return the canonical manifest.json bytes."""
|
|
44
|
+
return (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sidecar_text(manifest_bytes):
|
|
48
|
+
"""Return the manifest.sha256 sidecar line, sha256sum format (two spaces)."""
|
|
49
|
+
return f"{hashlib.sha256(manifest_bytes).hexdigest()} manifest.json\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def human_bytes(n):
|
|
53
|
+
"""Human-readable size with the exact byte count, e.g. '3.8 GiB (4089446400 bytes)'."""
|
|
54
|
+
if not isinstance(n, int):
|
|
55
|
+
return str(n)
|
|
56
|
+
size = float(n)
|
|
57
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
|
|
58
|
+
if size < 1024.0 or unit == "PiB":
|
|
59
|
+
if unit == "B":
|
|
60
|
+
return f"{n} B"
|
|
61
|
+
return f"{size:.1f} {unit} ({n} bytes)"
|
|
62
|
+
size /= 1024.0
|
|
63
|
+
return f"{n} B"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _md_cell(text):
|
|
67
|
+
"""Escape a value for a Markdown table cell so a '|' in it can't add columns."""
|
|
68
|
+
return str(text).replace("\\", "\\\\").replace("|", "\\|")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _object_count(entry):
|
|
72
|
+
"""Number of payload objects on media for one file: 1 whole, else one per part.
|
|
73
|
+
|
|
74
|
+
Matches how offline.py verify counts objects, so the MANIFEST.md Parts column
|
|
75
|
+
sums to what verify reports.
|
|
76
|
+
"""
|
|
77
|
+
parts = entry.get("parts")
|
|
78
|
+
return len(parts) if parts else 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_manifest_md(manifest, manifest_sha256):
|
|
82
|
+
"""Render the officer-facing MANIFEST.md per §6. Plain prose, no marketing."""
|
|
83
|
+
src = manifest["source"]
|
|
84
|
+
payload = manifest["payload"]
|
|
85
|
+
tool = manifest["tool"]
|
|
86
|
+
verifier = manifest.get("verifier") or {}
|
|
87
|
+
chunk_bytes = payload.get("chunk_size_bytes") or 0
|
|
88
|
+
chunk_display = "none" if chunk_bytes == 0 else human_bytes(chunk_bytes)
|
|
89
|
+
|
|
90
|
+
files = payload["files"]
|
|
91
|
+
file_count = payload.get("file_count")
|
|
92
|
+
if file_count is None:
|
|
93
|
+
file_count = len(files)
|
|
94
|
+
object_count = sum(_object_count(e) for e in files)
|
|
95
|
+
|
|
96
|
+
gated = "yes" if src.get("gated") else "no"
|
|
97
|
+
lines = [
|
|
98
|
+
f"# modelferry bundle: {manifest.get('bundle_name')}",
|
|
99
|
+
"",
|
|
100
|
+
]
|
|
101
|
+
if src.get("license") == "UNKNOWN":
|
|
102
|
+
lines += [
|
|
103
|
+
"> WARNING: the license for this model could not be determined from repo",
|
|
104
|
+
"> metadata. Confirm the license terms before using or redistributing this",
|
|
105
|
+
"> model.",
|
|
106
|
+
"",
|
|
107
|
+
]
|
|
108
|
+
lines += [
|
|
109
|
+
"This document is the approval record for this bundle. It gets used twice.",
|
|
110
|
+
"",
|
|
111
|
+
"Before transfer: review the details below and approve them, then keep a copy of",
|
|
112
|
+
"this file. The manifest checksum in your copy is what the receiving side checks",
|
|
113
|
+
"against.",
|
|
114
|
+
"",
|
|
115
|
+
"On arrival: run the commands in the Verify section. If the checksum inspect",
|
|
116
|
+
"prints differs from the one in your approved copy, or if verify reports anything",
|
|
117
|
+
"other than OK, this is not the bundle that was approved. Do not use it.",
|
|
118
|
+
"",
|
|
119
|
+
"## Source",
|
|
120
|
+
"",
|
|
121
|
+
f"- Repo: {src.get('repo_id')}",
|
|
122
|
+
f"- Commit: {src.get('commit_sha')}",
|
|
123
|
+
f"- Revision requested: {src.get('revision_requested')}",
|
|
124
|
+
f"- License: {src.get('license')}",
|
|
125
|
+
f"- Gated: {gated}",
|
|
126
|
+
f"- Endpoint: {src.get('endpoint')}",
|
|
127
|
+
f"- Created: {manifest.get('created_at')}",
|
|
128
|
+
f"- Tool: {tool.get('name')} {tool.get('version')}",
|
|
129
|
+
"",
|
|
130
|
+
"## Totals",
|
|
131
|
+
"",
|
|
132
|
+
f"- Files: {file_count}",
|
|
133
|
+
f"- Payload objects on media: {object_count}",
|
|
134
|
+
f"- Total size: {human_bytes(payload.get('total_bytes'))}",
|
|
135
|
+
f"- Chunk size: {chunk_display}",
|
|
136
|
+
"",
|
|
137
|
+
]
|
|
138
|
+
if object_count > file_count:
|
|
139
|
+
lines += [
|
|
140
|
+
"Files larger than the chunk size are split into parts, so the media holds more",
|
|
141
|
+
"objects than the model has files. The Parts column below shows the split.",
|
|
142
|
+
"",
|
|
143
|
+
]
|
|
144
|
+
lines += [
|
|
145
|
+
"## Manifest checksum",
|
|
146
|
+
"",
|
|
147
|
+
"The sha256 of manifest.json is:",
|
|
148
|
+
"",
|
|
149
|
+
f" {manifest_sha256}",
|
|
150
|
+
"",
|
|
151
|
+
"This is the checksum your approved copy carries. The Verify section explains how",
|
|
152
|
+
"the receiving side checks the arrived bundle against it.",
|
|
153
|
+
"",
|
|
154
|
+
"## Verifier",
|
|
155
|
+
"",
|
|
156
|
+
f"The bundled verifier is {verifier.get('path')}. Its sha256 is:",
|
|
157
|
+
"",
|
|
158
|
+
f" {verifier.get('sha256')}",
|
|
159
|
+
"",
|
|
160
|
+
'This hash is also recorded in manifest.json under "verifier". Compare it to the',
|
|
161
|
+
"canonical hash published with the modelferry release, or bring your own copy of",
|
|
162
|
+
"the verifier, to check the verifier out-of-band (see the trust model in the",
|
|
163
|
+
"README).",
|
|
164
|
+
"",
|
|
165
|
+
"## Verify",
|
|
166
|
+
"",
|
|
167
|
+
"Run these on the receiving side. They need Python 3.9 or newer, no network and",
|
|
168
|
+
"no packages.",
|
|
169
|
+
"",
|
|
170
|
+
" cd <bundle directory>",
|
|
171
|
+
" python3 tools/modelferry_offline.py inspect .",
|
|
172
|
+
" python3 tools/modelferry_offline.py verify .",
|
|
173
|
+
"",
|
|
174
|
+
"inspect recomputes the manifest checksum from the file on disk and prints it as",
|
|
175
|
+
"manifest_sha256. Compare that to the checksum in the approved copy of this",
|
|
176
|
+
"document. verify then checks every object on the media against the manifest and",
|
|
177
|
+
'prints "verify OK" only if all of them match.',
|
|
178
|
+
"",
|
|
179
|
+
"## Files",
|
|
180
|
+
"",
|
|
181
|
+
"| Path | Size (bytes) | Parts | sha256 |",
|
|
182
|
+
"| --- | --- | --- | --- |",
|
|
183
|
+
]
|
|
184
|
+
for entry in files:
|
|
185
|
+
lines.append(
|
|
186
|
+
f"| {_md_cell(entry['path'])} | {entry['bytes']} | "
|
|
187
|
+
f"{_object_count(entry)} | {entry['sha256']} |"
|
|
188
|
+
)
|
|
189
|
+
lines.append("")
|
|
190
|
+
return "\n".join(lines)
|