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.
- hfms_bridge/__init__.py +6 -0
- hfms_bridge/__main__.py +3 -0
- hfms_bridge/cli.py +252 -0
- hfms_bridge/http.py +327 -0
- hfms_bridge/hubs/__init__.py +5 -0
- hfms_bridge/hubs/common.py +67 -0
- hfms_bridge/hubs/hf.py +436 -0
- hfms_bridge/hubs/ms.py +401 -0
- hfms_bridge/progress.py +105 -0
- hfms_bridge/state.py +246 -0
- hfms_bridge/transfer.py +635 -0
- hfms_bridge-0.4.0.dist-info/METADATA +99 -0
- hfms_bridge-0.4.0.dist-info/RECORD +16 -0
- hfms_bridge-0.4.0.dist-info/WHEEL +4 -0
- hfms_bridge-0.4.0.dist-info/entry_points.txt +2 -0
- hfms_bridge-0.4.0.dist-info/licenses/LICENSE +21 -0
hfms_bridge/__init__.py
ADDED
hfms_bridge/__main__.py
ADDED
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,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)
|