helppeer 0.1.0__tar.gz

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.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: helppeer
3
+ Version: 0.1.0
4
+ Summary: Asynchronous ephemeral file transfer for AI model weights
5
+ Author: JenD86
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JenD86/help-peer
8
+ Project-URL: Repository, https://github.com/JenD86/help-peer
9
+ Project-URL: Issues, https://github.com/JenD86/help-peer/issues
10
+ Keywords: file-transfer,ai,model-weights,safetensors,p2p,decentralized,ephemeral
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: cryptography>=41.0
24
+ Requires-Dist: blake3>=0.3
25
+ Requires-Dist: requests>=2.28
26
+ Requires-Dist: reedsolo>=1.7
27
+ Requires-Dist: hkdf>=0.0.3
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0; extra == "dev"
30
+
31
+ # Help Peer Python SDK
32
+
33
+ A Python SDK for asynchronously sharing large file dumps (AI model weights) via the Help Peer network.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install helppeer
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ import helppeer
45
+
46
+ # Send a model directory
47
+ code = helppeer.send("./my-model", name="Llama-3-70B")
48
+ print(f"Transfer code: {code}")
49
+ # Share this code with the recipient. Data lives for 24 hours.
50
+
51
+ # Receive a transfer
52
+ helppeer.receive("38-vortex-xenon", output_dir="./received")
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ ```python
58
+ import helppeer
59
+
60
+ # Use custom relay and storage nodes
61
+ helppeer.configure(
62
+ relay="https://relay.helppeer.dev",
63
+ storage_nodes=["https://node1.helppeer.dev", "https://node2.helppeer.dev"],
64
+ )
65
+
66
+ # Or use environment variables:
67
+ # HELPEER_RELAY_URL, HELPEER_STORAGE_NODES (comma-separated)
68
+ ```
69
+
70
+ ## Agent Integration
71
+
72
+ ```python
73
+ import helppeer
74
+ import json
75
+
76
+ # Send and get structured result
77
+ result = helppeer.send("./model-weights", name="my-model", return_details=True)
78
+ # result = {"code": "38-vortex-xenon", "transfer_name": "my-model", "files": 4, "total_bytes": 1056}
79
+
80
+ # Receive with progress callback
81
+ def on_progress(file_path, segment, total_segments):
82
+ print(f" {file_path}: {segment}/{total_segments} segments")
83
+
84
+ helppeer.receive("38-vortex-xenon", output_dir="./received", progress=on_progress)
85
+ ```
86
+
87
+ ## CLI
88
+
89
+ The SDK also installs a `helppeer` CLI:
90
+
91
+ ```bash
92
+ helppeer send ./my-model --name "Llama-3-70B"
93
+ helppeer receive 38-vortex-xenon --output ./received
94
+ ```
@@ -0,0 +1,64 @@
1
+ # Help Peer Python SDK
2
+
3
+ A Python SDK for asynchronously sharing large file dumps (AI model weights) via the Help Peer network.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install helppeer
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ import helppeer
15
+
16
+ # Send a model directory
17
+ code = helppeer.send("./my-model", name="Llama-3-70B")
18
+ print(f"Transfer code: {code}")
19
+ # Share this code with the recipient. Data lives for 24 hours.
20
+
21
+ # Receive a transfer
22
+ helppeer.receive("38-vortex-xenon", output_dir="./received")
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ ```python
28
+ import helppeer
29
+
30
+ # Use custom relay and storage nodes
31
+ helppeer.configure(
32
+ relay="https://relay.helppeer.dev",
33
+ storage_nodes=["https://node1.helppeer.dev", "https://node2.helppeer.dev"],
34
+ )
35
+
36
+ # Or use environment variables:
37
+ # HELPEER_RELAY_URL, HELPEER_STORAGE_NODES (comma-separated)
38
+ ```
39
+
40
+ ## Agent Integration
41
+
42
+ ```python
43
+ import helppeer
44
+ import json
45
+
46
+ # Send and get structured result
47
+ result = helppeer.send("./model-weights", name="my-model", return_details=True)
48
+ # result = {"code": "38-vortex-xenon", "transfer_name": "my-model", "files": 4, "total_bytes": 1056}
49
+
50
+ # Receive with progress callback
51
+ def on_progress(file_path, segment, total_segments):
52
+ print(f" {file_path}: {segment}/{total_segments} segments")
53
+
54
+ helppeer.receive("38-vortex-xenon", output_dir="./received", progress=on_progress)
55
+ ```
56
+
57
+ ## CLI
58
+
59
+ The SDK also installs a `helppeer` CLI:
60
+
61
+ ```bash
62
+ helppeer send ./my-model --name "Llama-3-70B"
63
+ helppeer receive 38-vortex-xenon --output ./received
64
+ ```
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "helppeer"
7
+ version = "0.1.0"
8
+ description = "Asynchronous ephemeral file transfer for AI model weights"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "JenD86"},
14
+ ]
15
+ keywords = ["file-transfer", "ai", "model-weights", "safetensors", "p2p", "decentralized", "ephemeral"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ ]
28
+ dependencies = [
29
+ "cryptography>=41.0",
30
+ "blake3>=0.3",
31
+ "requests>=2.28",
32
+ "reedsolo>=1.7",
33
+ "hkdf>=0.0.3",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=7.0"]
38
+
39
+ [project.scripts]
40
+ helppeer = "helppeer.cli:main"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/JenD86/help-peer"
44
+ Repository = "https://github.com/JenD86/help-peer"
45
+ Issues = "https://github.com/JenD86/help-peer/issues"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """Help Peer — Asynchronous ephemeral file transfer for AI model weights."""
2
+
3
+ from .client import send, receive
4
+ from .config import configure, Config
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["send", "receive", "configure", "Config", "__version__"]
@@ -0,0 +1,2 @@
1
+ [project.scripts]
2
+ helppeer = "helppeer.cli:main"
@@ -0,0 +1,68 @@
1
+ """Help Peer CLI entry point."""
2
+ import sys
3
+ import argparse
4
+ from . import send, receive, configure
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(
9
+ prog="helppeer",
10
+ description="Asynchronous ephemeral file transfer for AI model weights",
11
+ )
12
+ parser.add_argument("--relay", default="http://127.0.0.1:7000", help="Relay server URL")
13
+ parser.add_argument("--nodes", default="http://127.0.0.1:7001",
14
+ help="Storage node URLs (comma-separated)")
15
+
16
+ sub = parser.add_subcommands(dest="command", required=True)
17
+
18
+ send_cmd = sub.add_parser("send", help="Send a file or directory")
19
+ send_cmd.add_argument("path", help="Path to send")
20
+ send_cmd.add_argument("--name", default="untitled-transfer", help="Transfer name")
21
+
22
+ recv_cmd = sub.add_parser("receive", help="Receive a transfer")
23
+ recv_cmd.add_argument("code", help="Transfer code (e.g., 38-vortex-xenon)")
24
+ recv_cmd.add_argument("--output", default="./received", help="Output directory")
25
+
26
+ args = parser.parse_args()
27
+
28
+ # Configure from CLI args
29
+ nodes = [n.strip() for n in args.nodes.split(",") if n.strip()]
30
+ configure(relay=args.relay, storage_nodes=nodes)
31
+
32
+ if args.command == "send":
33
+ print(f"Help Peer — Sending: {args.path}")
34
+ print(f"Transfer name: {args.name}")
35
+ print(f"Relay: {args.relay}")
36
+ print(f"Storage nodes: {len(nodes)} (total)")
37
+ print()
38
+
39
+ code = send(args.path, name=args.name)
40
+ print()
41
+ print("✓ Upload complete!")
42
+ print()
43
+ print(f" Transfer code: {code}")
44
+ print()
45
+ print(" Share this code with the recipient.")
46
+ print(" The data will be available for 24 hours.")
47
+
48
+ elif args.command == "receive":
49
+ print(f"Help Peer — Receiving with code: {args.code}")
50
+ print(f"Output directory: {args.output}")
51
+ print(f"Relay: {args.relay}")
52
+ print()
53
+
54
+ result = receive(args.code, output_dir=args.output)
55
+ print()
56
+ print("✓ Download complete!")
57
+ print()
58
+ print(f" Transfer: {result['transfer_name']}")
59
+ print(f" Files: {result['files']}")
60
+ print(f" Total size: {result['total_bytes']} bytes")
61
+ print()
62
+ print(" File hashes (BLAKE3):")
63
+ for path, h in result["file_hashes"].items():
64
+ print(f" {path} → {h}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
@@ -0,0 +1,278 @@
1
+ """Help Peer client — send and receive file transfers."""
2
+ import os
3
+ import random
4
+ import requests
5
+ from typing import Optional, Callable, List, Dict, Any
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+
8
+ from .config import get_config
9
+ from . import crypto
10
+ from . import erasure
11
+ from .manifest import (
12
+ Manifest, ManifestFile, ManifestSegment, ManifestShard,
13
+ build_manifest, read_file_segment,
14
+ )
15
+ from .validator import ValidatorRegistry
16
+
17
+
18
+ def send(
19
+ path: str,
20
+ name: str = "untitled-transfer",
21
+ *,
22
+ return_details: bool = False,
23
+ ) -> str | Dict[str, Any]:
24
+ """Send a file or directory. Returns the transfer code.
25
+
26
+ Args:
27
+ path: Path to the file or directory to send.
28
+ name: Human-readable name for the transfer.
29
+ return_details: If True, return a dict with full transfer details.
30
+
31
+ Returns:
32
+ Transfer code string (e.g., "38-vortex-xenon"), or a dict if return_details=True.
33
+ """
34
+ config = get_config()
35
+ code = _generate_code()
36
+ k_data, k_index = crypto.derive_keys(code)
37
+ r_hash = crypto.relay_hash(k_index)
38
+
39
+ if os.path.isfile(path):
40
+ # Single file: wrap in a temporary directory structure
41
+ manifest = _send_single_file(path, name, k_data, config)
42
+ else:
43
+ manifest = _send_directory(path, name, k_data, config)
44
+
45
+ # Encrypt and upload manifest
46
+ manifest_json = manifest.to_json()
47
+ encrypted_manifest = crypto.encrypt_segment(k_data, manifest_json)
48
+ _upload_manifest(config.relay_url, r_hash, encrypted_manifest)
49
+
50
+ if return_details:
51
+ return {
52
+ "code": code,
53
+ "transfer_name": name,
54
+ "files": len(manifest.files),
55
+ "total_bytes": manifest.total_bytes,
56
+ }
57
+ return code
58
+
59
+
60
+ def receive(
61
+ code: str,
62
+ output_dir: str = "./received",
63
+ *,
64
+ progress: Optional[Callable[[str, int, int], None]] = None,
65
+ ) -> Dict[str, Any]:
66
+ """Receive a transfer using a code.
67
+
68
+ Args:
69
+ code: The transfer code (e.g., "38-vortex-xenon").
70
+ output_dir: Directory to save received files.
71
+ progress: Optional callback(file_path, segment_index, total_segments).
72
+
73
+ Returns:
74
+ Dict with transfer details: transfer_name, files, total_bytes, file_hashes.
75
+ """
76
+ config = get_config()
77
+ k_data, k_index = crypto.derive_keys(code)
78
+ r_hash = crypto.relay_hash(k_index)
79
+
80
+ # Download encrypted manifest from relay
81
+ encrypted_manifest = _download_manifest(config.relay_url, r_hash)
82
+ manifest_json = crypto.decrypt_segment(k_data, encrypted_manifest)
83
+ manifest = Manifest.from_json(manifest_json)
84
+
85
+ # Pre-allocate files
86
+ os.makedirs(output_dir, exist_ok=True)
87
+ for f in manifest.files:
88
+ file_path = os.path.join(output_dir, f.path)
89
+ os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
90
+ with open(file_path, "wb") as fh:
91
+ fh.truncate(f.size)
92
+
93
+ # Download and reconstruct each file
94
+ registry = ValidatorRegistry()
95
+ file_hashes = {}
96
+
97
+ for f in manifest.files:
98
+ file_path = os.path.join(output_dir, f.path)
99
+ total_segments = len(f.segments)
100
+
101
+ for seg_idx, segment in enumerate(f.segments):
102
+ # Download shards
103
+ shard_data = _download_shards(segment)
104
+
105
+ # Reconstruct encrypted segment
106
+ encrypted = erasure.decode_segment(shard_data, segment.encrypted_size)
107
+
108
+ # Decrypt
109
+ plaintext = crypto.decrypt_segment(k_data, encrypted)
110
+
111
+ # Validate first segment
112
+ if seg_idx == 0:
113
+ validator = registry.validator_for(f.path)
114
+ validator.validate_first_segment(plaintext)
115
+
116
+ # Write to file at offset
117
+ offset = seg_idx * config.segment_size
118
+ with open(file_path, "r+b") as fh:
119
+ fh.seek(offset)
120
+ fh.write(plaintext)
121
+
122
+ if progress:
123
+ progress(f.path, seg_idx + 1, total_segments)
124
+
125
+ # Compute file hash
126
+ file_hashes[f.path] = _compute_file_hash(file_path)
127
+
128
+ return {
129
+ "transfer_name": manifest.transfer_name,
130
+ "files": len(manifest.files),
131
+ "total_bytes": manifest.total_bytes,
132
+ "file_hashes": file_hashes,
133
+ }
134
+
135
+
136
+ def _send_directory(dir_path: str, name: str, k_data: bytes, config) -> Manifest:
137
+ """Send a directory."""
138
+ manifest = build_manifest(dir_path, name, config.segment_size)
139
+ manifest.erasure_data_shards = config.data_shards
140
+ manifest.erasure_parity_shards = config.parity_shards
141
+
142
+ for f in manifest.files:
143
+ file_path = os.path.join(dir_path, f.path)
144
+ for seg in f.segments:
145
+ seg_idx = int(seg.id.split("_")[1])
146
+ plaintext = read_file_segment(file_path, seg_idx, config.segment_size)
147
+ encrypted = crypto.encrypt_segment(k_data, plaintext)
148
+ shards = erasure.encode_segment(encrypted)
149
+
150
+ shard_infos = []
151
+ for shard_idx, shard in enumerate(shards):
152
+ h = crypto.content_hash(shard)
153
+ node = config.storage_nodes[shard_idx % len(config.storage_nodes)]
154
+ _upload_shard(node, h, shard)
155
+ shard_infos.append(ManifestShard(
156
+ index=shard_idx, hash=h, node=node,
157
+ ))
158
+
159
+ seg.encrypted_size = len(encrypted)
160
+ seg.shards = shard_infos
161
+
162
+ return manifest
163
+
164
+
165
+ def _send_single_file(file_path: str, name: str, k_data: bytes, config) -> Manifest:
166
+ """Send a single file by treating it as a one-file directory."""
167
+ dir_path = os.path.dirname(file_path) or "."
168
+ filename = os.path.basename(file_path)
169
+ manifest = build_manifest(dir_path, name, config.segment_size)
170
+ manifest.erasure_data_shards = config.data_shards
171
+ manifest.erasure_parity_shards = config.parity_shards
172
+
173
+ # Filter to only the requested file
174
+ manifest.files = [f for f in manifest.files if f.path == filename]
175
+ manifest.total_bytes = sum(f.size for f in manifest.files)
176
+
177
+ for f in manifest.files:
178
+ full_path = os.path.join(dir_path, f.path)
179
+ for seg in f.segments:
180
+ seg_idx = int(seg.id.split("_")[1])
181
+ plaintext = read_file_segment(full_path, seg_idx, config.segment_size)
182
+ encrypted = crypto.encrypt_segment(k_data, plaintext)
183
+ shards = erasure.encode_segment(encrypted)
184
+
185
+ shard_infos = []
186
+ for shard_idx, shard in enumerate(shards):
187
+ h = crypto.content_hash(shard)
188
+ node = config.storage_nodes[shard_idx % len(config.storage_nodes)]
189
+ _upload_shard(node, h, shard)
190
+ shard_infos.append(ManifestShard(
191
+ index=shard_idx, hash=h, node=node,
192
+ ))
193
+
194
+ seg.encrypted_size = len(encrypted)
195
+ seg.shards = shard_infos
196
+
197
+ return manifest
198
+
199
+
200
+ def _upload_shard(node_url: str, hash_hex: str, data: bytes) -> None:
201
+ """Upload a shard to a storage node."""
202
+ url = f"{node_url}/shard/{hash_hex}"
203
+ resp = requests.put(url, data=data, headers={"Content-Type": "application/octet-stream"})
204
+ if not resp.status_code in (200, 201):
205
+ raise RuntimeError(f"shard upload to {node_url} returned {resp.status_code}")
206
+
207
+
208
+ def _upload_manifest(relay_url: str, r_hash: str, data: bytes) -> None:
209
+ """Upload encrypted manifest to relay."""
210
+ url = f"{relay_url}/manifest/{r_hash}"
211
+ resp = requests.put(url, data=data, headers={"Content-Type": "application/octet-stream"})
212
+ if not resp.status_code in (200, 201):
213
+ raise RuntimeError(f"manifest upload returned {resp.status_code}")
214
+
215
+
216
+ def _download_manifest(relay_url: str, r_hash: str) -> bytes:
217
+ """Download encrypted manifest from relay (one-time retrieval)."""
218
+ url = f"{relay_url}/manifest/{r_hash}"
219
+ resp = requests.get(url)
220
+ if resp.status_code != 200:
221
+ raise RuntimeError(f"manifest download returned {resp.status_code}")
222
+ return resp.content
223
+
224
+
225
+ def _download_shards(segment: ManifestSegment) -> List[Optional[bytes]]:
226
+ """Download all shards for a segment concurrently."""
227
+ config = get_config()
228
+ total_shards = config.data_shards + config.parity_shards
229
+ shards: List[Optional[bytes]] = [None] * total_shards
230
+
231
+ def fetch_shard(shard: ManifestShard) -> tuple[int, Optional[bytes]]:
232
+ url = f"{shard.node}/shard/{shard.hash}"
233
+ try:
234
+ resp = requests.get(url)
235
+ if resp.status_code == 200:
236
+ return shard.index, resp.content
237
+ except Exception:
238
+ pass
239
+ return shard.index, None
240
+
241
+ with ThreadPoolExecutor(max_workers=12) as executor:
242
+ futures = [executor.submit(fetch_shard, s) for s in segment.shards]
243
+ for future in as_completed(futures):
244
+ idx, data = future.result()
245
+ if data is not None:
246
+ shards[idx] = data
247
+
248
+ return shards
249
+
250
+
251
+ def _compute_file_hash(filepath: str) -> str:
252
+ """Compute BLAKE3 hash of a file."""
253
+ import blake3
254
+ h = blake3.blake3()
255
+ with open(filepath, "rb") as f:
256
+ while True:
257
+ chunk = f.read(1024 * 1024)
258
+ if not chunk:
259
+ break
260
+ h.update(chunk)
261
+ return h.hexdigest()
262
+
263
+
264
+ _WORDS = [
265
+ "orbit", "velvet", "guitar", "battery", "candle", "dragon", "ember",
266
+ "falcon", "galaxy", "harbor", "iris", "jungle", "kettle", "lantern",
267
+ "meadow", "nebula", "ocean", "phoenix", "quartz", "raven", "silver",
268
+ "thunder", "umbra", "vortex", "willow", "xenon", "yonder", "zephyr",
269
+ "amber", "breeze",
270
+ ]
271
+
272
+
273
+ def _generate_code() -> str:
274
+ """Generate a human-readable transfer code: number-word-word."""
275
+ num = random.randint(1, 99)
276
+ w1 = random.choice(_WORDS)
277
+ w2 = random.choice(_WORDS)
278
+ return f"{num}-{w1}-{w2}"
@@ -0,0 +1,45 @@
1
+ """Configuration for Help Peer SDK."""
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from typing import List
5
+
6
+
7
+ @dataclass
8
+ class Config:
9
+ """SDK configuration. Modify via helppeer.configure()."""
10
+ relay_url: str = os.environ.get("HELPEER_RELAY_URL", "http://127.0.0.1:7000")
11
+ storage_nodes: List[str] = field(default_factory=lambda: [
12
+ n.strip() for n in os.environ.get(
13
+ "HELPEER_STORAGE_NODES", "http://127.0.0.1:7001"
14
+ ).split(",") if n.strip()
15
+ ])
16
+ segment_size: int = 64 * 1024 * 1024 # 64 MB
17
+ data_shards: int = 8
18
+ parity_shards: int = 4
19
+
20
+
21
+ _default_config = Config()
22
+
23
+
24
+ def get_config() -> Config:
25
+ return _default_config
26
+
27
+
28
+ def configure(
29
+ relay: str = None,
30
+ storage_nodes: List[str] = None,
31
+ segment_size: int = None,
32
+ data_shards: int = None,
33
+ parity_shards: int = None,
34
+ ):
35
+ """Configure the SDK. Only provided values are updated."""
36
+ if relay is not None:
37
+ _default_config.relay_url = relay
38
+ if storage_nodes is not None:
39
+ _default_config.storage_nodes = storage_nodes
40
+ if segment_size is not None:
41
+ _default_config.segment_size = segment_size
42
+ if data_shards is not None:
43
+ _default_config.data_shards = data_shards
44
+ if parity_shards is not None:
45
+ _default_config.parity_shards = parity_shards
@@ -0,0 +1,59 @@
1
+ """Cryptographic operations for Help Peer."""
2
+ import os
3
+ import struct
4
+ import hmac
5
+ import hashlib
6
+ import hkdf
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+
9
+ NONCE_SIZE = 12
10
+ KEY_SIZE = 32
11
+
12
+
13
+ def derive_keys(code: str) -> tuple[bytes, bytes]:
14
+ """Derive K_data and K_index from a transfer code using HKDF-SHA256."""
15
+ ikm = code.encode("utf-8")
16
+ k_data = hkdf.Hkdf(None, ikm, hashlib.sha256).expand(b"help-peer-data-key", KEY_SIZE)
17
+ k_index = hkdf.Hkdf(None, ikm, hashlib.sha256).expand(b"help-peer-index-key", KEY_SIZE)
18
+ return k_data, k_index
19
+
20
+
21
+ def relay_hash(k_index: bytes) -> str:
22
+ """Compute the relay hash: hex(BLAKE3(K_index))."""
23
+ import blake3
24
+ return blake3.blake3(k_index).hexdigest()
25
+
26
+
27
+ def encrypt_segment(k_data: bytes, plaintext: bytes) -> bytes:
28
+ """Encrypt a segment with AES-256-GCM. Returns nonce || ciphertext."""
29
+ nonce = os.urandom(NONCE_SIZE)
30
+ aesgcm = AESGCM(k_data)
31
+ ciphertext = aesgcm.encrypt(nonce, plaintext, None)
32
+ return nonce + ciphertext
33
+
34
+
35
+ def decrypt_segment(k_data: bytes, encrypted: bytes) -> bytes:
36
+ """Decrypt a segment (nonce || ciphertext) with AES-256-GCM."""
37
+ if len(encrypted) < NONCE_SIZE:
38
+ raise ValueError("encrypted data too short")
39
+ nonce = encrypted[:NONCE_SIZE]
40
+ ciphertext = encrypted[NONCE_SIZE:]
41
+ aesgcm = AESGCM(k_data)
42
+ return aesgcm.decrypt(nonce, ciphertext, None)
43
+
44
+
45
+ def content_hash(data: bytes) -> str:
46
+ """Compute BLAKE3 hash of data, return hex string."""
47
+ import blake3
48
+ return blake3.blake3(data).hexdigest()
49
+
50
+
51
+ def shard_hmac(k_data: bytes, shard: bytes) -> bytes:
52
+ """Compute HMAC-SHA256 of shard data."""
53
+ return hmac.new(k_data, shard, hashlib.sha256).digest()
54
+
55
+
56
+ def verify_shard_hmac(k_data: bytes, shard: bytes, expected: bytes) -> bool:
57
+ """Verify shard HMAC."""
58
+ actual = shard_hmac(k_data, shard)
59
+ return hmac.compare_digest(actual, expected)
@@ -0,0 +1,130 @@
1
+ """Reed-Solomon erasure coding for Help Peer."""
2
+ from reedsolo import RSCodec
3
+
4
+ from .config import get_config
5
+
6
+
7
+ def encode_segment(data: bytes) -> list[bytes]:
8
+ """Split an encrypted segment into (8 data + 4 parity) shards."""
9
+ config = get_config()
10
+ return _encode_interleaved(data, config.data_shards, config.parity_shards)
11
+
12
+
13
+ def _encode_interleaved(data: bytes, data_shards: int, parity_shards: int) -> list[bytes]:
14
+ """Encode data using interleaved Reed-Solomon: split into data_shards pieces,
15
+ compute parity_shards parity pieces, return data_shards + parity_shards shards."""
16
+ shard_size = (len(data) + data_shards - 1) // data_shards
17
+ # Pad data to fill all data shards
18
+ padded = data + b"\x00" * (shard_size * data_shards - len(data))
19
+
20
+ # Split into data shards
21
+ data_shards_list = [
22
+ padded[i * shard_size:(i + 1) * shard_size]
23
+ for i in range(data_shards)
24
+ ]
25
+
26
+ # For each byte position, compute parity across data_shards bytes
27
+ rs = RSCodec(parity_shards, nsize=data_shards + parity_shards)
28
+
29
+ parity_shards_list = [bytearray(shard_size) for _ in range(parity_shards)]
30
+
31
+ for pos in range(shard_size):
32
+ # Gather one byte from each data shard
33
+ data_bytes = bytes(data_shards_list[i][pos] for i in range(data_shards))
34
+ # Encode: produces data_bytes + parity_bytes
35
+ encoded = rs.encode(data_bytes)
36
+ # Extract parity bytes
37
+ for j in range(parity_shards):
38
+ parity_shards_list[j][pos] = encoded[data_shards + j]
39
+
40
+ return data_shards_list + [bytes(p) for p in parity_shards_list]
41
+
42
+
43
+ def decode_segment(shards: list[bytes | None], original_len: int) -> bytes:
44
+ """Reconstruct the original segment from at least data_shards of total_shards shards.
45
+ Missing shards should be None."""
46
+ config = get_config()
47
+ data_shards = config.data_shards
48
+ parity_shards = config.parity_shards
49
+ total_shards = data_shards + parity_shards
50
+
51
+ if len(shards) != total_shards:
52
+ raise ValueError(f"expected {total_shards} shards, got {len(shards)}")
53
+
54
+ # Find shard size from available shards
55
+ shard_size = None
56
+ for s in shards:
57
+ if s is not None:
58
+ shard_size = len(s)
59
+ break
60
+
61
+ if shard_size is None:
62
+ raise ValueError("no shards available")
63
+
64
+ # Count available shards
65
+ available = sum(1 for s in shards if s is not None)
66
+ if available < data_shards:
67
+ raise ValueError(f"insufficient shards: have {available}, need {data_shards}")
68
+
69
+ rs = RSCodec(parity_shards, nsize=data_shards + parity_shards)
70
+
71
+ # For each byte position, reconstruct missing bytes
72
+ data_shards_list = [None] * data_shards
73
+ parity_shards_list = [None] * parity_shards
74
+
75
+ for i in range(total_shards):
76
+ if shards[i] is not None:
77
+ if i < data_shards:
78
+ data_shards_list[i] = shards[i]
79
+ else:
80
+ parity_shards_list[i - data_shards] = shards[i]
81
+
82
+ result_shards = [bytearray(shard_size) for _ in range(data_shards)]
83
+
84
+ for pos in range(shard_size):
85
+ # Build the received codeword for this byte position
86
+ received = [None] * total_shards
87
+ for i in range(data_shards):
88
+ if data_shards_list[i] is not None:
89
+ received[i] = data_shards_list[i][pos]
90
+ for j in range(parity_shards):
91
+ if parity_shards_list[j] is not None:
92
+ received[data_shards + j] = parity_shards_list[j][pos]
93
+
94
+ # Convert to list with 0 for missing (erasure positions)
95
+ codeword = []
96
+ erasure_pos = []
97
+ for i in range(total_shards):
98
+ if received[i] is not None:
99
+ codeword.append(received[i])
100
+ else:
101
+ codeword.append(0)
102
+ erasure_pos.append(i)
103
+
104
+ if erasure_pos:
105
+ # Decode with erasures
106
+ try:
107
+ decoded = rs.decode(bytearray(codeword), erase_pos=erasure_pos)
108
+ # reedsolo returns (msg, full_corrected_codeword) when there are erasures
109
+ if isinstance(decoded, tuple):
110
+ full_codeword = decoded[1]
111
+ else:
112
+ full_codeword = decoded
113
+ # Extract corrected data bytes
114
+ for i in range(data_shards):
115
+ result_shards[i][pos] = full_codeword[i]
116
+ except Exception:
117
+ # If decode fails, use available data bytes directly
118
+ for i in range(data_shards):
119
+ if data_shards_list[i] is not None:
120
+ result_shards[i][pos] = data_shards_list[i][pos]
121
+ else:
122
+ result_shards[i][pos] = 0
123
+ else:
124
+ # No erasures, just copy data bytes
125
+ for i in range(data_shards):
126
+ result_shards[i][pos] = codeword[i]
127
+
128
+ # Concatenate data shards and trim to original length
129
+ result = b"".join(bytes(s) for s in result_shards)
130
+ return result[:original_len]
@@ -0,0 +1,139 @@
1
+ """Manifest building and parsing for Help Peer."""
2
+ import os
3
+ import json
4
+ from dataclasses import dataclass, field, asdict
5
+ from typing import List, Optional
6
+
7
+
8
+ @dataclass
9
+ class ManifestShard:
10
+ index: int
11
+ hash: str
12
+ node: str
13
+
14
+
15
+ @dataclass
16
+ class ManifestSegment:
17
+ id: str
18
+ original_size: int
19
+ encrypted_size: int
20
+ shards: List[ManifestShard] = field(default_factory=list)
21
+
22
+
23
+ @dataclass
24
+ class ManifestFile:
25
+ path: str
26
+ size: int
27
+ segments: List[ManifestSegment] = field(default_factory=list)
28
+
29
+
30
+ @dataclass
31
+ class Manifest:
32
+ version: int = 1
33
+ transfer_name: str = ""
34
+ total_bytes: int = 0
35
+ segment_size: int = 67108864
36
+ erasure_data_shards: int = 8
37
+ erasure_parity_shards: int = 4
38
+ files: List[ManifestFile] = field(default_factory=list)
39
+
40
+ def to_json(self) -> bytes:
41
+ return json.dumps({
42
+ "version": self.version,
43
+ "transfer_name": self.transfer_name,
44
+ "total_bytes": self.total_bytes,
45
+ "segment_size": self.segment_size,
46
+ "erasure_data_shards": self.erasure_data_shards,
47
+ "erasure_parity_shards": self.erasure_parity_shards,
48
+ "files": [
49
+ {
50
+ "path": f.path,
51
+ "size": f.size,
52
+ "segments": [
53
+ {
54
+ "id": s.id,
55
+ "original_size": s.original_size,
56
+ "encrypted_size": s.encrypted_size,
57
+ "shards": [
58
+ {"index": sh.index, "hash": sh.hash, "node": sh.node}
59
+ for sh in s.shards
60
+ ],
61
+ }
62
+ for s in f.segments
63
+ ],
64
+ }
65
+ for f in self.files
66
+ ],
67
+ }).encode("utf-8")
68
+
69
+ @classmethod
70
+ def from_json(cls, data: bytes) -> "Manifest":
71
+ d = json.loads(data)
72
+ manifest = cls(
73
+ version=d["version"],
74
+ transfer_name=d["transfer_name"],
75
+ total_bytes=d["total_bytes"],
76
+ segment_size=d["segment_size"],
77
+ erasure_data_shards=d["erasure_data_shards"],
78
+ erasure_parity_shards=d["erasure_parity_shards"],
79
+ )
80
+ for f in d["files"]:
81
+ mf = ManifestFile(path=f["path"], size=f["size"])
82
+ for s in f["segments"]:
83
+ ms = ManifestSegment(
84
+ id=s["id"],
85
+ original_size=s["original_size"],
86
+ encrypted_size=s["encrypted_size"],
87
+ )
88
+ for sh in s["shards"]:
89
+ ms.shards.append(ManifestShard(
90
+ index=sh["index"], hash=sh["hash"], node=sh["node"]
91
+ ))
92
+ mf.segments.append(ms)
93
+ manifest.files.append(mf)
94
+ return manifest
95
+
96
+
97
+ def build_manifest(dir_path: str, transfer_name: str, segment_size: int) -> Manifest:
98
+ """Build a manifest by crawling a directory."""
99
+ manifest = Manifest(
100
+ transfer_name=transfer_name,
101
+ segment_size=segment_size,
102
+ )
103
+
104
+ for root, dirs, files in os.walk(dir_path):
105
+ dirs.sort()
106
+ for filename in sorted(files):
107
+ filepath = os.path.join(root, filename)
108
+ rel_path = os.path.relpath(filepath, dir_path)
109
+
110
+ size = os.path.getsize(filepath)
111
+ manifest.total_bytes += size
112
+
113
+ num_segments = 0
114
+ if size > 0:
115
+ num_segments = (size + segment_size - 1) // segment_size
116
+
117
+ segments = []
118
+ for i in range(num_segments):
119
+ seg_size = min(segment_size, size - i * segment_size)
120
+ segments.append(ManifestSegment(
121
+ id=f"seg_{i:06d}",
122
+ original_size=seg_size,
123
+ encrypted_size=0,
124
+ ))
125
+
126
+ manifest.files.append(ManifestFile(
127
+ path=rel_path,
128
+ size=size,
129
+ segments=segments,
130
+ ))
131
+
132
+ return manifest
133
+
134
+
135
+ def read_file_segment(filepath: str, segment_index: int, segment_size: int) -> bytes:
136
+ """Read a specific segment from a file."""
137
+ with open(filepath, "rb") as f:
138
+ f.seek(segment_index * segment_size)
139
+ return f.read(segment_size)
@@ -0,0 +1,66 @@
1
+ """Pluggable file validators for Help Peer."""
2
+ import json
3
+ from typing import Protocol
4
+
5
+
6
+ class ValidationError(Exception):
7
+ pass
8
+
9
+
10
+ class FileValidator(Protocol):
11
+ def file_extensions(self) -> list[str]: ...
12
+ def validate_first_segment(self, data: bytes) -> None: ...
13
+
14
+
15
+ class SafetensorsValidator:
16
+ """Validates safetensors files by parsing the JSON header."""
17
+
18
+ def file_extensions(self) -> list[str]:
19
+ return ["safetensors"]
20
+
21
+ def validate_first_segment(self, data: bytes) -> None:
22
+ if len(data) < 8:
23
+ raise ValidationError("file too small to be a valid safetensors file")
24
+
25
+ header_len = int.from_bytes(data[:8], "little")
26
+
27
+ if header_len > 100_000_000:
28
+ raise ValidationError(f"safetensors header length {header_len} is unreasonably large")
29
+
30
+ if len(data) < 8 + header_len:
31
+ raise ValidationError("safetensors header extends beyond first segment")
32
+
33
+ header_json = data[8:8 + header_len]
34
+ try:
35
+ parsed = json.loads(header_json)
36
+ except json.JSONDecodeError as e:
37
+ raise ValidationError(f"safetensors header is not valid JSON: {e}")
38
+
39
+ if not isinstance(parsed, dict):
40
+ raise ValidationError("safetensors header is not a JSON object")
41
+
42
+ if len(parsed) == 0:
43
+ raise ValidationError("safetensors header is empty")
44
+
45
+
46
+ class GenericValidator:
47
+ """Passthrough validator that accepts all data."""
48
+
49
+ def file_extensions(self) -> list[str]:
50
+ return []
51
+
52
+ def validate_first_segment(self, data: bytes) -> None:
53
+ pass
54
+
55
+
56
+ class ValidatorRegistry:
57
+ def __init__(self):
58
+ self.validators: list[FileValidator] = [SafetensorsValidator()]
59
+ self.fallback: FileValidator = GenericValidator()
60
+
61
+ def validator_for(self, path: str) -> FileValidator:
62
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
63
+ for v in self.validators:
64
+ if ext in v.file_extensions():
65
+ return v
66
+ return self.fallback
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: helppeer
3
+ Version: 0.1.0
4
+ Summary: Asynchronous ephemeral file transfer for AI model weights
5
+ Author: JenD86
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JenD86/help-peer
8
+ Project-URL: Repository, https://github.com/JenD86/help-peer
9
+ Project-URL: Issues, https://github.com/JenD86/help-peer/issues
10
+ Keywords: file-transfer,ai,model-weights,safetensors,p2p,decentralized,ephemeral
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: cryptography>=41.0
24
+ Requires-Dist: blake3>=0.3
25
+ Requires-Dist: requests>=2.28
26
+ Requires-Dist: reedsolo>=1.7
27
+ Requires-Dist: hkdf>=0.0.3
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0; extra == "dev"
30
+
31
+ # Help Peer Python SDK
32
+
33
+ A Python SDK for asynchronously sharing large file dumps (AI model weights) via the Help Peer network.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install helppeer
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ import helppeer
45
+
46
+ # Send a model directory
47
+ code = helppeer.send("./my-model", name="Llama-3-70B")
48
+ print(f"Transfer code: {code}")
49
+ # Share this code with the recipient. Data lives for 24 hours.
50
+
51
+ # Receive a transfer
52
+ helppeer.receive("38-vortex-xenon", output_dir="./received")
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ ```python
58
+ import helppeer
59
+
60
+ # Use custom relay and storage nodes
61
+ helppeer.configure(
62
+ relay="https://relay.helppeer.dev",
63
+ storage_nodes=["https://node1.helppeer.dev", "https://node2.helppeer.dev"],
64
+ )
65
+
66
+ # Or use environment variables:
67
+ # HELPEER_RELAY_URL, HELPEER_STORAGE_NODES (comma-separated)
68
+ ```
69
+
70
+ ## Agent Integration
71
+
72
+ ```python
73
+ import helppeer
74
+ import json
75
+
76
+ # Send and get structured result
77
+ result = helppeer.send("./model-weights", name="my-model", return_details=True)
78
+ # result = {"code": "38-vortex-xenon", "transfer_name": "my-model", "files": 4, "total_bytes": 1056}
79
+
80
+ # Receive with progress callback
81
+ def on_progress(file_path, segment, total_segments):
82
+ print(f" {file_path}: {segment}/{total_segments} segments")
83
+
84
+ helppeer.receive("38-vortex-xenon", output_dir="./received", progress=on_progress)
85
+ ```
86
+
87
+ ## CLI
88
+
89
+ The SDK also installs a `helppeer` CLI:
90
+
91
+ ```bash
92
+ helppeer send ./my-model --name "Llama-3-70B"
93
+ helppeer receive 38-vortex-xenon --output ./received
94
+ ```
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/helppeer/__init__.py
4
+ src/helppeer/__main__.py
5
+ src/helppeer/cli.py
6
+ src/helppeer/client.py
7
+ src/helppeer/config.py
8
+ src/helppeer/crypto.py
9
+ src/helppeer/erasure.py
10
+ src/helppeer/manifest.py
11
+ src/helppeer/validator.py
12
+ src/helppeer.egg-info/PKG-INFO
13
+ src/helppeer.egg-info/SOURCES.txt
14
+ src/helppeer.egg-info/dependency_links.txt
15
+ src/helppeer.egg-info/entry_points.txt
16
+ src/helppeer.egg-info/requires.txt
17
+ src/helppeer.egg-info/top_level.txt
18
+ tests/test_sdk.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ helppeer = helppeer.cli:main
@@ -0,0 +1,8 @@
1
+ cryptography>=41.0
2
+ blake3>=0.3
3
+ requests>=2.28
4
+ reedsolo>=1.7
5
+ hkdf>=0.0.3
6
+
7
+ [dev]
8
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ helppeer
@@ -0,0 +1,160 @@
1
+ """Tests for Help Peer SDK."""
2
+ import os
3
+ import json
4
+ import struct
5
+ import tempfile
6
+ import shutil
7
+ import pytest
8
+
9
+ from helppeer import crypto, erasure, manifest, validator
10
+ from helppeer.config import configure, get_config
11
+
12
+
13
+ class TestCrypto:
14
+ def test_encrypt_decrypt_roundtrip(self):
15
+ k_data = b"\x00" * 32
16
+ plaintext = b"Hello, Help Peer!"
17
+ encrypted = crypto.encrypt_segment(k_data, plaintext)
18
+ decrypted = crypto.decrypt_segment(k_data, encrypted)
19
+ assert plaintext == decrypted
20
+
21
+ def test_key_derivation_consistency(self):
22
+ k1_data, k1_index = crypto.derive_keys("7-orbit-velvet")
23
+ k2_data, k2_index = crypto.derive_keys("7-orbit-velvet")
24
+ assert k1_data == k2_data
25
+ assert k1_index == k2_index
26
+
27
+ k3_data, _ = crypto.derive_keys("8-orbit-velvet")
28
+ assert k1_data != k3_data
29
+
30
+ def test_relay_hash_consistency(self):
31
+ _, k_index = crypto.derive_keys("7-orbit-velvet")
32
+ h1 = crypto.relay_hash(k_index)
33
+ h2 = crypto.relay_hash(k_index)
34
+ assert h1 == h2
35
+ assert len(h1) == 64 # 32 bytes hex
36
+
37
+ def test_hmac_verification(self):
38
+ k_data = b"\x00" * 32
39
+ shard = b"some shard data"
40
+ h = crypto.shard_hmac(k_data, shard)
41
+ assert crypto.verify_shard_hmac(k_data, shard, h)
42
+ assert not crypto.verify_shard_hmac(k_data, b"tampered", h)
43
+
44
+
45
+ class TestErasure:
46
+ def test_encode_decode_roundtrip(self):
47
+ data = b"Hello, Help Peer! This is a test segment for erasure coding."
48
+ shards = erasure.encode_segment(data)
49
+ assert len(shards) == 12
50
+
51
+ shard_opts = list(shards)
52
+ reconstructed = erasure.decode_segment(shard_opts, len(data))
53
+ assert data == reconstructed
54
+
55
+ def test_decode_with_missing_parity(self):
56
+ data = b"Test data for erasure coding with missing shards!"
57
+ shards = erasure.encode_segment(data)
58
+
59
+ # Drop 4 parity shards
60
+ shard_opts = list(shards)
61
+ for i in range(8, 12):
62
+ shard_opts[i] = None
63
+ reconstructed = erasure.decode_segment(shard_opts, len(data))
64
+ assert data == reconstructed
65
+
66
+ def test_decode_with_missing_data_shards(self):
67
+ data = b"Test data for erasure coding with some data shards missing!"
68
+ shards = erasure.encode_segment(data)
69
+
70
+ # Drop 2 data + 2 parity
71
+ shard_opts = list(shards)
72
+ shard_opts[1] = None
73
+ shard_opts[3] = None
74
+ shard_opts[9] = None
75
+ shard_opts[11] = None
76
+ reconstructed = erasure.decode_segment(shard_opts, len(data))
77
+ assert data == reconstructed
78
+
79
+ def test_decode_insufficient_shards(self):
80
+ data = b"Too many missing shards!"
81
+ shards = erasure.encode_segment(data)
82
+
83
+ shard_opts = list(shards)
84
+ for i in range(5):
85
+ shard_opts[i] = None
86
+
87
+ with pytest.raises(ValueError, match="insufficient"):
88
+ erasure.decode_segment(shard_opts, len(data))
89
+
90
+
91
+ class TestManifest:
92
+ def test_build_from_dir(self, tmp_path):
93
+ (tmp_path / "config.json").write_text("{}")
94
+ (tmp_path / "model.safetensors").write_text("weights")
95
+
96
+ m = manifest.build_manifest(str(tmp_path), "test-transfer", 67108864)
97
+ assert m.transfer_name == "test-transfer"
98
+ assert m.version == 1
99
+ assert len(m.files) == 2
100
+ assert m.total_bytes > 0
101
+
102
+ def test_json_roundtrip(self):
103
+ m = manifest.Manifest(
104
+ transfer_name="test",
105
+ total_bytes=100,
106
+ segment_size=67108864,
107
+ files=[
108
+ manifest.ManifestFile(
109
+ path="test.txt",
110
+ size=100,
111
+ segments=[
112
+ manifest.ManifestSegment(
113
+ id="seg_000000",
114
+ original_size=100,
115
+ encrypted_size=0,
116
+ )
117
+ ],
118
+ )
119
+ ],
120
+ )
121
+ json_bytes = m.to_json()
122
+ restored = manifest.Manifest.from_json(json_bytes)
123
+ assert restored.transfer_name == "test"
124
+ assert len(restored.files) == 1
125
+ assert restored.files[0].path == "test.txt"
126
+
127
+
128
+ class TestValidator:
129
+ def test_safetensors_valid(self):
130
+ header = json.dumps({
131
+ "__metadata__": {"format": "pt"},
132
+ "weight1": {"dtype": "F32", "shape": [10, 10], "data_offsets": [0, 400]},
133
+ }).encode()
134
+ data = struct.pack("<Q", len(header)) + header
135
+ v = validator.SafetensorsValidator()
136
+ v.validate_first_segment(data) # should not raise
137
+
138
+ def test_safetensors_invalid_json(self):
139
+ data = struct.pack("<Q", 5) + b"NOT{JSON"
140
+ v = validator.SafetensorsValidator()
141
+ with pytest.raises(validator.ValidationError):
142
+ v.validate_first_segment(data)
143
+
144
+ def test_safetensors_too_small(self):
145
+ v = validator.SafetensorsValidator()
146
+ with pytest.raises(validator.ValidationError):
147
+ v.validate_first_segment(b"tiny")
148
+
149
+ def test_generic_accepts_all(self):
150
+ v = validator.GenericValidator()
151
+ v.validate_first_segment(b"anything")
152
+ v.validate_first_segment(b"")
153
+
154
+ def test_registry_lookup(self):
155
+ reg = validator.ValidatorRegistry()
156
+ v = reg.validator_for("model.safetensors")
157
+ assert "safetensors" in v.file_extensions()
158
+
159
+ v = reg.validator_for("config.json")
160
+ assert len(v.file_extensions()) == 0 # fallback