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 ADDED
@@ -0,0 +1,3 @@
1
+ """modelferry: pack Hugging Face models into chunked, self-verifying bundles."""
2
+
3
+ __version__ = "0.1.0"
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)