hfms-bridge 0.4.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.
@@ -0,0 +1,6 @@
1
+ """Streaming repository transfer without storing payloads on disk."""
2
+
3
+ from .transfer import Transfer, TransferOptions
4
+
5
+ __all__ = ["Transfer", "TransferOptions"]
6
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
hfms_bridge/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """The hfms_bridge console entry point."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ import requests
12
+ from filelock import Timeout
13
+
14
+ from . import __version__
15
+ from .http import TransferError
16
+ from .transfer import Transfer, TransferOptions
17
+
18
+ log = logging.getLogger("hfms-bridge")
19
+
20
+
21
+ def byte_size(value):
22
+ match = re.fullmatch(r"([0-9]+)\s*(B|KiB|MiB|GiB|KB|MB|GB)?", value, re.IGNORECASE)
23
+ if not match:
24
+ raise argparse.ArgumentTypeError("Use bytes or an integer with KiB/MiB/GiB (e.g. 256MiB)")
25
+ unit = (match[2] or "B").upper()
26
+ multiplier = {
27
+ "B": 1,
28
+ "KIB": 1024,
29
+ "MIB": 1024**2,
30
+ "GIB": 1024**3,
31
+ "KB": 1000,
32
+ "MB": 1000**2,
33
+ "GB": 1000**3,
34
+ }[unit]
35
+ result = int(match[1]) * multiplier
36
+ if result <= 0:
37
+ raise argparse.ArgumentTypeError("Size must be positive")
38
+ return result
39
+
40
+
41
+ def nonempty_token(value):
42
+ if not value.strip() or any(c in value for c in "\r\n"):
43
+ raise argparse.ArgumentTypeError("Token must be non-empty and contain no line breaks")
44
+ return value
45
+
46
+
47
+ def build_parser():
48
+ parser = argparse.ArgumentParser(
49
+ prog="hfms-bridge",
50
+ description="Stream models and datasets between Hugging Face (HF) and ModelScope (MS) "
51
+ "without saving file contents to disk.",
52
+ )
53
+ parser.add_argument(
54
+ "--version", action="version", version=__version__, help="Show the package version and exit"
55
+ )
56
+ commands = parser.add_subparsers(
57
+ dest="direction",
58
+ required=True,
59
+ title="transfer directions",
60
+ help="Choose source and target",
61
+ )
62
+ for direction in ("hf2ms", "ms2hf"):
63
+ source, target = (
64
+ ("Hugging Face", "ModelScope")
65
+ if direction == "hf2ms"
66
+ else ("ModelScope", "Hugging Face")
67
+ )
68
+ command = commands.add_parser(
69
+ direction,
70
+ help=f"Transfer from {source} to {target}",
71
+ description=f"Stream models or datasets from {source} to {target}. "
72
+ "Run the same command again to continue an interrupted transfer.",
73
+ epilog="Quote glob patterns and place SOURCE DESTINATION before multi-value filters. "
74
+ "Sizes accept bytes or KiB/MiB/GiB and KB/MB/GB suffixes. "
75
+ "Process restarts resume at file granularity; unfinished files may restart from zero.",
76
+ )
77
+ _transfer_arguments(command, direction)
78
+ return parser
79
+
80
+
81
+ def _transfer_arguments(parser, direction):
82
+ source, target = ("HF", "MS") if direction == "hf2ms" else ("MS", "HF")
83
+ parser.add_argument("source", help=f"{source} source owner/repository; must be accessible")
84
+ parser.add_argument(
85
+ "destination",
86
+ help=f"{target} destination owner/repository; created if missing, updated if it exists",
87
+ )
88
+ for hub, env in (("hf", "HF_TOKEN"), ("ms", "MODELSCOPE_API_TOKEN")):
89
+ is_target = (hub == "ms") == (direction == "hf2ms")
90
+ parser.add_argument(
91
+ f"--{hub}-token",
92
+ type=nonempty_token,
93
+ required=is_target,
94
+ default=None if is_target else os.getenv(env) or None,
95
+ help=f"Required {hub.upper()} token with destination write/create permission; "
96
+ "must be passed explicitly"
97
+ if is_target
98
+ else f"Optional {hub.upper()} source read token (default: {env} environment variable); "
99
+ "required for private or restricted sources",
100
+ )
101
+ parser.add_argument(
102
+ "--repo-type",
103
+ choices=("auto", "model", "dataset"),
104
+ default="auto",
105
+ help="Source repository type (default: auto). Auto probes source models and datasets, "
106
+ "uses the unique accessible match, and requires an explicit type if both exist.",
107
+ )
108
+ for hub, default in (("hf", "main"), ("ms", "master")):
109
+ is_target = (hub == "ms") == (direction == "hf2ms")
110
+ parser.add_argument(
111
+ f"--{hub}-revision",
112
+ default=default,
113
+ help=(
114
+ f"{hub.upper()} destination branch (default: {default}); custom branches must "
115
+ "already exist; new repositories start on the default branch"
116
+ if is_target
117
+ else f"{hub.upper()} source branch, tag or commit (default: {default}); "
118
+ "file versions are pinned for unfinished transfers"
119
+ ),
120
+ )
121
+ parser.add_argument(
122
+ "--visibility",
123
+ dest="visibility",
124
+ choices=("auto", "private", "public"),
125
+ default="auto",
126
+ help="New destination visibility (default: auto): public sources create public repos; "
127
+ "private, gated or unknown visibility creates private repos; existing repos are unchanged",
128
+ )
129
+ parser.add_argument(
130
+ "--cache-dir",
131
+ type=Path,
132
+ default=Path.home() / ".cache" / "hfms-bridge",
133
+ help="Metadata checkpoint directory (default: ~/.cache/hfms-bridge); "
134
+ "stores progress, not file contents",
135
+ )
136
+ parser.add_argument(
137
+ "--cache-max-size",
138
+ type=byte_size,
139
+ default=256 * 1024**2,
140
+ help="Metadata cache budget (default: 256MiB; minimum: 64KiB); evict oldest completed "
141
+ "tasks first, protect unfinished tasks, fail if protected state cannot fit",
142
+ )
143
+ parser.add_argument(
144
+ "--hf-endpoint",
145
+ default=os.getenv("HF_ENDPOINT", "https://huggingface.co"),
146
+ help="HF service base URL (default: HF_ENDPOINT environment variable or "
147
+ "https://huggingface.co); ms2hf routes hf-mirror.com to official HF for destination requests",
148
+ )
149
+ parser.add_argument(
150
+ "--ms-endpoint",
151
+ default=os.getenv("MODELSCOPE_ENDPOINT", "https://modelscope.cn"),
152
+ help="MS service base URL (default: MODELSCOPE_ENDPOINT environment variable or "
153
+ "https://modelscope.cn)",
154
+ )
155
+ parser.add_argument(
156
+ "--chunk-size",
157
+ type=byte_size,
158
+ default=1024**2,
159
+ help="Maximum source read buffer per active file (default: 1MiB); "
160
+ "does not control server upload part size",
161
+ )
162
+ parser.add_argument(
163
+ "--attempts",
164
+ type=int,
165
+ default=3,
166
+ help="Maximum attempts per operation, including the first (default: 3)",
167
+ )
168
+ parser.add_argument(
169
+ "--workers",
170
+ type=int,
171
+ default=4,
172
+ help="Concurrent file transfers (default: 4; use 1 for serial transfers)",
173
+ )
174
+ parser.add_argument(
175
+ "--progress",
176
+ choices=("auto", "on", "off"),
177
+ default="auto",
178
+ help="Overall file count and temporary per-file byte bars on stderr "
179
+ "(default: auto, terminals only); on forces bars, off uses ordinary logs",
180
+ )
181
+ parser.add_argument(
182
+ "--timeout",
183
+ type=int,
184
+ default=120,
185
+ help="Network connect/read timeout in seconds (default: 120); not a whole-file deadline",
186
+ )
187
+ parser.add_argument(
188
+ "--include",
189
+ action="extend",
190
+ nargs="+",
191
+ default=[],
192
+ help="Transfer paths matching any case-sensitive repository-relative glob; repeatable "
193
+ "(default: all files); quote patterns such as '*.safetensors'",
194
+ )
195
+ parser.add_argument(
196
+ "--exclude",
197
+ action="extend",
198
+ nargs="+",
199
+ default=[],
200
+ help="Exclude paths matching any case-sensitive repository-relative glob; repeatable "
201
+ "and takes priority over --include (default: none)",
202
+ )
203
+ parser.add_argument(
204
+ "--on-conflict",
205
+ choices=("overwrite", "error", "skip"),
206
+ default="overwrite",
207
+ help="Handle different or unverifiable existing content (default: overwrite): "
208
+ "overwrite updates it, error rejects conflicts before writes, skip leaves it unchanged; "
209
+ "identical files are always skipped",
210
+ )
211
+ parser.add_argument(
212
+ "--restart",
213
+ action="store_true",
214
+ help="Ignore local progress and resolve source versions again (default: resume); "
215
+ "identical remote files/blobs can still be reused; does not force re-upload",
216
+ )
217
+ parser.add_argument(
218
+ "--dry-run",
219
+ action="store_true",
220
+ help="Print a read-only transfer plan and JSON summary; no payload downloads, remote "
221
+ "writes or cache changes; missing hashes may leave content equality unknown",
222
+ )
223
+ return parser
224
+
225
+
226
+ def main(argv=None):
227
+ args = vars(build_parser().parse_args(argv))
228
+ ms_token, hf_token = args.pop("ms_token"), args.pop("hf_token")
229
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
230
+ try:
231
+ result = Transfer(TransferOptions(**args), ms_token=ms_token, hf_token=hf_token).run()
232
+ except KeyboardInterrupt:
233
+ log.info("Interrupted. Run the same command to resume.")
234
+ return 130
235
+ except Timeout:
236
+ log.error("Another process is using this task/cache; retry after it finishes")
237
+ return 1
238
+ except requests.RequestException:
239
+ log.error("Network request failed. Run the same command to resume.")
240
+ return 1
241
+ except sqlite3.Error:
242
+ log.error("Checkpoint database error; check cache permissions and available disk space")
243
+ return 1
244
+ except (TransferError, ValueError, OSError) as exc:
245
+ log.error("%s", exc)
246
+ log.info("Progress is saved. Run the same command to resume.")
247
+ return 1
248
+ if result.get("dry_run"):
249
+ print(json.dumps(result, ensure_ascii=False, indent=2))
250
+ else:
251
+ log.info("Done: %s committed, %s skipped", result["committed"], result["skipped"])
252
+ return 0
hfms_bridge/http.py ADDED
@@ -0,0 +1,327 @@
1
+ """Explicit HTTP error handling and bounded, integrity-checked request bodies."""
2
+
3
+ import hashlib
4
+ import re
5
+ from contextlib import contextmanager
6
+ from urllib.parse import urljoin, urlparse
7
+
8
+ import requests
9
+ from urllib3.exceptions import HTTPError as UrllibError
10
+
11
+
12
+ class TransferError(Exception):
13
+ pass
14
+
15
+
16
+ class RetryableTransferError(TransferError):
17
+ """A response that can be retried without trusting incomplete metadata."""
18
+
19
+
20
+ class HTTPError(TransferError):
21
+ def __init__(self, status, retry_after=None, context=None):
22
+ # Do not put signed URLs, tokens, or server-echoed bodies in logs.
23
+ label = "HTTP request" if not context else "HTTP " + context
24
+ super().__init__(f"{label} failed (status {status})")
25
+ self.status = status
26
+ self.retry_after = retry_after
27
+
28
+
29
+ @contextmanager
30
+ def error_stage(stage):
31
+ """Label failures without exposing request URLs, headers or exception bodies."""
32
+ try:
33
+ yield
34
+ except TransferError as exc:
35
+ if not getattr(exc, "stage", None):
36
+ exc.stage = stage
37
+ exc.args = (f"{stage}: {exc}",)
38
+ raise
39
+ except (requests.RequestException, UrllibError) as exc:
40
+ failure = RetryableTransferError(f"{stage}: network {type(exc).__name__}")
41
+ failure.stage = stage
42
+ raise failure from exc
43
+
44
+
45
+ def check(response):
46
+ if not 200 <= response.status_code < 300:
47
+ raise HTTPError(
48
+ response.status_code, response.headers.get("Retry-After"), response_context(response)
49
+ )
50
+
51
+
52
+ def response_context(response):
53
+ method = getattr(getattr(response, "request", None), "method", "request")
54
+ return f"{method} {urlparse(response.url or '').hostname or 'unknown host'}"
55
+
56
+
57
+ def payload(response):
58
+ check(response)
59
+ body = response.json()
60
+ if isinstance(body, dict):
61
+ if str(body.get("Code")) in ("401", "403", "404", "409", "429", "500", "502", "503", "504"):
62
+ raise HTTPError(
63
+ int(body["Code"]), response.headers.get("Retry-After"), response_context(response)
64
+ )
65
+ if body.get("Success") is False or body.get("success") is False:
66
+ raise TransferError("API rejected the operation")
67
+ if body.get("Code") not in (None, 0, 200, "200"):
68
+ raise TransferError("API returned an unsuccessful business status")
69
+ return body.get("Data", body)
70
+ return body
71
+
72
+
73
+ def origin(url):
74
+ p = urlparse(url)
75
+ return p.scheme, p.hostname, p.port or (443 if p.scheme == "https" else 80)
76
+
77
+
78
+ def trusted_api_redirect(source, destination):
79
+ """Permit same-origin and explicit official aliases, never arbitrary subdomains."""
80
+ validate_url(destination)
81
+ if origin(source) == origin(destination):
82
+ return True
83
+ left, right = origin(source), origin(destination)
84
+ if left[0] != "https" or right[0] != "https" or left[2] != 443 or right[2] != 443:
85
+ return False
86
+ # The mirror may hand API requests back to official HF. Never allow the
87
+ # reverse direction to forward official-HF credentials to a third party.
88
+ if left[1] == "hf-mirror.com" and right[1] in ("huggingface.co", "www.huggingface.co"):
89
+ return True
90
+ aliases = (
91
+ {"modelscope.cn", "www.modelscope.cn"},
92
+ {"modelscope.ai", "www.modelscope.ai"},
93
+ {"huggingface.co", "www.huggingface.co", "hf.co"},
94
+ )
95
+ return any(left[1] in hosts and right[1] in hosts for hosts in aliases)
96
+
97
+
98
+ def validate_url(url):
99
+ p = urlparse(url)
100
+ if p.username or p.password or not p.hostname or p.fragment:
101
+ raise ValueError("Invalid endpoint or transfer URL")
102
+ if p.scheme != "https" and not (
103
+ p.scheme == "http" and p.hostname in ("localhost", "127.0.0.1", "::1")
104
+ ):
105
+ raise ValueError("HTTPS is required except for local test servers")
106
+ return url.rstrip("/")
107
+
108
+
109
+ class HTTP:
110
+ def __init__(self, timeout=120):
111
+ self.session = requests.Session()
112
+ self.session.headers["User-Agent"] = "hfms-bridge/0.4.0"
113
+ self.timeout = (15, timeout)
114
+
115
+ def close(self):
116
+ self.session.close()
117
+
118
+ def request(self, method, url, **kwargs):
119
+ validate_url(url)
120
+ # No automatic retries: consumed streaming bodies cannot be replayed.
121
+ return self.session.request(method, url, timeout=self.timeout, **kwargs)
122
+
123
+ def api_request(self, method, url, **kwargs):
124
+ """Follow API redirects for replayable metadata requests only.
125
+
126
+ Streamed PUT bodies deliberately do not use this method: replaying a
127
+ consumed stream could silently upload truncated content.
128
+ """
129
+ if "data" in kwargs and not isinstance(kwargs["data"], bytes):
130
+ raise ValueError("api_request does not accept streaming request bodies")
131
+ for value in (kwargs.get("files") or {}).values():
132
+ if not (
133
+ isinstance(value, tuple)
134
+ and len(value) == 2
135
+ and value[0] is None
136
+ and isinstance(value[1], str)
137
+ ):
138
+ raise ValueError("Only replayable text form fields are allowed")
139
+ kwargs.pop("allow_redirects", None)
140
+ visited = set()
141
+ for _ in range(10):
142
+ if url in visited:
143
+ raise TransferError("API redirect loop detected")
144
+ visited.add(url)
145
+ response = self.request(method, url, allow_redirects=False, **kwargs)
146
+ if response.status_code not in (301, 302, 303, 307, 308):
147
+ return response
148
+ status = response.status_code
149
+ location = response.headers.get("Location")
150
+ actual_url = response.url or url
151
+ response.close()
152
+ if not location:
153
+ raise TransferError("API redirect omitted Location")
154
+ if method.upper() not in ("GET", "HEAD") and status not in (307, 308):
155
+ raise TransferError("Refusing API redirect that could change the request method")
156
+ target = urljoin(actual_url, location)
157
+ if not trusted_api_redirect(actual_url, target):
158
+ raise TransferError(
159
+ "Refusing API redirect to an untrusted origin; "
160
+ "check --hf-endpoint / --ms-endpoint"
161
+ )
162
+ url = target
163
+ # Location is the complete redirect target; never append old query params twice.
164
+ kwargs.pop("params", None)
165
+ raise TransferError("Too many API redirects")
166
+
167
+ def download(self, url, token=None, offset=0, total=None):
168
+ headers = {"Accept-Encoding": "identity"}
169
+ if offset:
170
+ headers["Range"] = f"bytes={offset}-"
171
+ if token:
172
+ headers["Authorization"] = "Bearer " + token
173
+ # Handle redirects ourselves so cookies and auth cannot cross origins.
174
+ for _ in range(10):
175
+ response = self.request("GET", url, headers=headers, stream=True, allow_redirects=False)
176
+ if response.status_code in (301, 302, 303, 307, 308):
177
+ next_url = urljoin(url, response.headers.get("Location", ""))
178
+ response.close()
179
+ if origin(next_url) != origin(url):
180
+ headers.pop("Authorization", None)
181
+ self.session.cookies.clear()
182
+ url = next_url
183
+ continue
184
+ try:
185
+ if offset and response.status_code == 416:
186
+ raise RetryableTransferError("Range response rejected the requested offset")
187
+ check(response)
188
+ if offset:
189
+ match = re.fullmatch(
190
+ r"bytes (\d+)-(\d+)/(\d+)",
191
+ response.headers.get("Content-Range", ""),
192
+ )
193
+ if (
194
+ response.status_code != 206
195
+ or not match
196
+ or tuple(map(int, match.groups())) != (offset, total - 1, total)
197
+ ):
198
+ raise RetryableTransferError("Range response has invalid status or range")
199
+ length = response.headers.get("Content-Length")
200
+ if length is not None and length != str(total - offset):
201
+ raise RetryableTransferError("Range response has invalid length")
202
+ elif response.status_code != 200:
203
+ raise TransferError("Expected a full-file HTTP 200 response")
204
+ if response.headers.get("Content-Encoding", "identity") != "identity":
205
+ if offset:
206
+ raise RetryableTransferError(
207
+ "Range response ignored Accept-Encoding: identity"
208
+ )
209
+ raise TransferError("Server ignored Accept-Encoding: identity")
210
+ return response
211
+ except BaseException:
212
+ response.close()
213
+ raise
214
+ raise TransferError("Too many source redirects")
215
+
216
+
217
+ class CheckedStream:
218
+ """Non-seekable body with a known length; read() is always bounded.
219
+
220
+ Check the digest BEFORE returning the last bytes to the uploader. This
221
+ prevents a corrupt source from completing an upload under the expected OID.
222
+ """
223
+
224
+ def __init__(self, raw, size, sha256, chunk_size=1024 * 1024, on_read=None, stage="HF read"):
225
+ self.stage = stage
226
+ self.raw, self.size, self.expected = raw, size, sha256
227
+ self.on_read = on_read
228
+ self.chunk_size = chunk_size
229
+ self.count = 0
230
+ self.hash = hashlib.sha256()
231
+ self.git_hash = hashlib.sha1(f"blob {size}\0".encode(), usedforsecurity=False)
232
+
233
+ def __len__(self):
234
+ return self.size
235
+
236
+ def read(self, size=-1):
237
+ with error_stage(self.stage):
238
+ return self._read(size)
239
+
240
+ def _read(self, size):
241
+ remaining = self.size - self.count
242
+ if not remaining:
243
+ return b""
244
+ amount = min(remaining, self.chunk_size, size if size >= 0 else self.chunk_size)
245
+ if amount == 0:
246
+ return b""
247
+ data = self._read_raw(amount)
248
+ if not data:
249
+ raise TransferError("Source ended before its declared size")
250
+ if len(data) > amount:
251
+ raise TransferError("Source exceeded requested read size")
252
+ self.count += len(data)
253
+ self.hash.update(data)
254
+ self.git_hash.update(data)
255
+ if self.count == self.size:
256
+ if self._read_raw(1):
257
+ raise TransferError("Source exceeded its declared size")
258
+ self.verify()
259
+ if self.on_read is not None:
260
+ self.on_read(len(data))
261
+ return data
262
+
263
+ def _read_raw(self, size):
264
+ try:
265
+ return self.raw.read(size)
266
+ except UrllibError as exc:
267
+ raise requests.ConnectionError("Source connection interrupted") from exc
268
+
269
+ def verify(self):
270
+ if self.count != self.size:
271
+ raise TransferError("Upload did not consume the complete source")
272
+ if self.expected and self.hash.hexdigest() != self.expected:
273
+ raise TransferError("Source SHA-256 mismatch")
274
+
275
+
276
+ class ResumingReader:
277
+ """Reconnect the pinned source while keeping the destination PUT alive.
278
+
279
+ Only returned bytes count: a raw.read() that raises may have consumed partial
280
+ bytes internally, but those bytes must be fetched again. No payload is saved.
281
+ The retry budget covers the entire reader, not each successful chunk.
282
+ """
283
+
284
+ def __init__(self, opener, size, attempts, backoff, cancelled, on_resume, stage="HF read"):
285
+ self.stage = stage
286
+ self.opener, self.size = opener, size
287
+ self.attempts, self.backoff = attempts, backoff
288
+ self.cancelled, self.on_resume = cancelled, on_resume
289
+ self.response = None
290
+ self.offset = 0
291
+ self.reconnects = 0
292
+
293
+ def __enter__(self):
294
+ with error_stage(self.stage):
295
+ self.cancelled()
296
+ self.response = self.opener(0)
297
+ return self
298
+
299
+ def __exit__(self, *args):
300
+ if self.response is not None:
301
+ self.response.close()
302
+
303
+ def read(self, size):
304
+ with error_stage(self.stage):
305
+ while True:
306
+ self.cancelled()
307
+ try:
308
+ if self.response is None:
309
+ self.response = self.opener(self.offset)
310
+ data = self.response.raw.read(size)
311
+ if not data and self.offset < self.size:
312
+ raise requests.ConnectionError("Source ended early")
313
+ self.offset += len(data)
314
+ return data
315
+ except (requests.RequestException, UrllibError) as exc:
316
+ if self.response is not None:
317
+ self.response.close()
318
+ self.response = None
319
+ # At EOF no remaining range exists; do not fabricate bytes.
320
+ if self.reconnects >= self.attempts - 1 or self.offset >= self.size:
321
+ raise RetryableTransferError(
322
+ f"Source recovery exhausted ({type(exc).__name__}); "
323
+ "restarting file if attempts remain"
324
+ ) from exc
325
+ self.reconnects += 1
326
+ self.on_resume(self.offset, self.reconnects)
327
+ self.backoff(min(2 ** (self.reconnects - 1), 30))
@@ -0,0 +1,5 @@
1
+ from .common import File
2
+ from .hf import HuggingFace
3
+ from .ms import ModelScope
4
+
5
+ __all__ = ["File", "HuggingFace", "ModelScope"]
@@ -0,0 +1,67 @@
1
+ """Shared file metadata and streamed LFS transport."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from ..http import check, payload, error_stage
6
+
7
+ INLINE_LIMIT = 1024 * 1024
8
+ METADATA = {"README.MD", ".GITATTRIBUTES", "CONFIGURATION.JSON"}
9
+
10
+
11
+ def repo_name(value):
12
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", value, flags=re.ASCII) or any(
13
+ part in (".", "..") for part in value.split("/")
14
+ ):
15
+ raise ValueError("Repository must be owner/name")
16
+ return value
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class File:
21
+ path: str
22
+ size: int
23
+ sha256: str = ""
24
+ revision: str = ""
25
+ lfs: bool = True
26
+
27
+ def __post_init__(self):
28
+ if (
29
+ not self.path
30
+ or self.path.startswith("/")
31
+ or "\\" in self.path
32
+ or any(p in ("", ".", "..") for p in self.path.split("/"))
33
+ ):
34
+ raise ValueError("Invalid repository file path")
35
+ if self.size < 0:
36
+ raise ValueError("Negative file size")
37
+ if self.sha256 and not re.fullmatch("[0-9a-f]{64}", self.sha256):
38
+ raise ValueError("Invalid SHA-256 metadata")
39
+
40
+
41
+ class BlobTarget:
42
+ def upload(self, actions, stream, sha256, size):
43
+ action = actions["upload"]
44
+ headers = self._action_headers(action)
45
+ headers["Content-Length"] = str(size)
46
+ headers.pop("Transfer-Encoding", None)
47
+ # Redirect replay is unsafe for a non-seekable stream. Renegotiate on failure.
48
+ with self.blob_http.request(
49
+ "PUT", action["href"], data=stream, headers=headers, allow_redirects=False
50
+ ) as response:
51
+ check(response)
52
+ if response.content:
53
+ payload(response)
54
+ stream.verify()
55
+ if "verify" in actions:
56
+ action = actions["verify"]
57
+ with error_stage(f"{self.label} verify"):
58
+ with self.blob_http.request(
59
+ "POST",
60
+ action["href"],
61
+ headers=self._action_headers(action),
62
+ json={"oid": sha256, "size": size},
63
+ allow_redirects=False,
64
+ ) as response:
65
+ check(response)
66
+ if response.content:
67
+ payload(response)