alexpouliquen HF Staff commited on
Commit
5b9eedd
·
1 Parent(s): c2d238d

feat: buckets transfer without region

Browse files
Files changed (5) hide show
  1. .gitignore +1 -0
  2. README.md +49 -4
  3. app.py +244 -0
  4. requirements.txt +2 -0
  5. worker.py +93 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .cursor
README.md CHANGED
@@ -1,14 +1,59 @@
1
  ---
2
  title: Duplicate Repo V2
3
- emoji: 🦀
4
  colorFrom: gray
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.13.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
- short_description: Duplicate hugging face repo across region
 
 
 
 
 
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Duplicate Repo V2
3
+ emoji: 🤗
4
  colorFrom: gray
5
  colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 6.0.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
+ short_description: Duplicate a Hugging Face Bucket via an HF Job
12
+ hf_oauth: true
13
+ hf_oauth_scopes:
14
+ - read-repos
15
+ - write-repos
16
+ - manage-repos
17
+ - jobs
18
  ---
19
 
20
+ # Duplicate your bucket
21
+
22
+ UI for duplicating a [Hugging Face Bucket](https://huggingface.co/docs/hub/storage-buckets).
23
+ The actual file transfer runs as a [Hugging Face Job](https://huggingface.co/docs/huggingface_hub/guides/jobs)
24
+ in the logged-in user's namespace, so users need a plan that supports Jobs
25
+ (Pro / Team / Enterprise).
26
+
27
+ ## Architecture
28
+
29
+ ```
30
+ Gradio SDK Space HF Job (per request)
31
+ ┌──────────────────────┐ ┌───────────────────────────┐
32
+ │ Gradio UI │ submit │ uv run worker.py │
33
+ │ Sign in with HF │ ──────► │ --src /data/src │
34
+ │ Submit / Cancel │ │ --dst /data/dst │
35
+ │ Live log viewer │ ◄────── │ (volumes mounted by HF) │
36
+ └──────────────────────┘ poll └───────────────────────────┘
37
+ ```
38
+
39
+ - The Space is a stock Gradio SDK Space — no Dockerfile, no system packages,
40
+ no `hf-mount`. OAuth and the runtime are managed by HF.
41
+ - On submit, `huggingface_hub.run_uv_job` uploads `worker.py` to the Job and
42
+ attaches both buckets as `Volume(type="bucket", …)`. The Hub mounts them at
43
+ `/data/src` (read-only) and `/data/dst` (writable) inside the Job container.
44
+ - `worker.py` is stdlib-only; the Space tails its stdout via `fetch_job_logs`.
45
+
46
+ ## Files
47
+
48
+ - `app.py` — Gradio Space (UI + Job orchestration).
49
+ - `worker.py` — runs inside the Job; copies files between mounted volumes.
50
+ - `requirements.txt` — `huggingface_hub`, `gradio[oauth]`.
51
+
52
+ ## Configuration
53
+
54
+ | Env var | Default | Purpose |
55
+ | ------------- | ------------- | --------------------------------------- |
56
+ | `JOB_FLAVOR` | `cpu-upgrade` | Hardware flavor passed to `run_uv_job`. |
57
+ | `JOB_TIMEOUT` | `2h` | Max Job runtime. |
58
+
59
+ Configuration reference: <https://huggingface.co/docs/hub/spaces-config-reference>.
app.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio Space UI that delegates the actual bucket copy to an HF Job.
2
+
3
+ The Job is launched via `huggingface_hub.run_uv_job` in the logged-in user's
4
+ namespace. `worker.py` is uploaded with the Job (via `LOCAL_FILES_ENCODED`) and
5
+ runs against buckets mounted as `Volume(type="bucket", ...)`. The Space submits,
6
+ polls logs, and offers a cancel button — no `hf-mount`, no Dockerfile.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import gradio as gr
14
+ import requests
15
+ from huggingface_hub import (
16
+ Volume,
17
+ cancel_job,
18
+ fetch_job_logs,
19
+ inspect_job,
20
+ run_uv_job,
21
+ )
22
+
23
+ JOB_FLAVOR = os.environ.get("JOB_FLAVOR", "cpu-upgrade")
24
+ JOB_TIMEOUT = os.environ.get("JOB_TIMEOUT", "2h")
25
+
26
+ HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co")
27
+ BUCKET_REGIONS = ("us", "eu")
28
+
29
+ WORKER_SCRIPT = "worker.py"
30
+ SRC_MOUNT = "/data/src"
31
+ DST_MOUNT = "/data/dst"
32
+
33
+
34
+ def _ensure_destination_bucket(
35
+ bucket_id: str,
36
+ *,
37
+ private: bool,
38
+ region: str,
39
+ token: str,
40
+ ) -> None:
41
+ """Create the destination bucket if it doesn't exist (no-op if it does).
42
+
43
+ Calls the HF buckets REST API directly so we can pass the (currently
44
+ undocumented) `region` parameter that `huggingface_hub.create_bucket` does
45
+ not yet expose. Both `private` and `region` only take effect on initial
46
+ creation; pre-existing buckets keep their settings.
47
+ """
48
+ if "/" not in bucket_id:
49
+ namespace, name = "me", bucket_id
50
+ else:
51
+ namespace, name = bucket_id.split("/", 1)
52
+
53
+ url = f"{HF_ENDPOINT}/api/buckets/{namespace}/{name}"
54
+ payload = {"private": private, "region": region}
55
+ headers = {"Authorization": f"Bearer {token}"}
56
+
57
+ resp = requests.post(url, headers=headers, json=payload, timeout=30)
58
+
59
+ # 409 = bucket already exists; treat as success (exist_ok behavior).
60
+ if resp.status_code == 409:
61
+ return
62
+ if not resp.ok:
63
+ # Surface the server's error message verbatim — most useful for users.
64
+ raise RuntimeError(f"HTTP {resp.status_code}: {resp.text.strip() or resp.reason}")
65
+
66
+
67
+ def submit_transfer(
68
+ source: str,
69
+ destination: str,
70
+ private: bool,
71
+ region: str,
72
+ oauth_token: gr.OAuthToken | None,
73
+ ):
74
+ if oauth_token is None:
75
+ raise gr.Error("Please log in using the button at the top to start a transfer.")
76
+
77
+ source = (source or "").strip()
78
+ destination = (destination or "").strip()
79
+ if not source or not destination:
80
+ raise gr.Error("Source and destination buckets are required (e.g. `username/my-bucket`).")
81
+ if source == destination:
82
+ raise gr.Error("Source and destination must be different.")
83
+ if region not in BUCKET_REGIONS:
84
+ raise gr.Error(f"Region must be one of {BUCKET_REGIONS}.")
85
+
86
+ try:
87
+ _ensure_destination_bucket(
88
+ destination,
89
+ private=private,
90
+ region=region,
91
+ token=oauth_token.token,
92
+ )
93
+ except Exception as e:
94
+ raise gr.Error(f"Failed to ensure destination bucket `{destination}` exists: {e}")
95
+
96
+ try:
97
+ job = run_uv_job(
98
+ script=WORKER_SCRIPT,
99
+ script_args=["--src", SRC_MOUNT, "--dst", DST_MOUNT],
100
+ volumes=[
101
+ Volume(type="bucket", source=source, mount_path=SRC_MOUNT, read_only=True),
102
+ Volume(type="bucket", source=destination, mount_path=DST_MOUNT, read_only=False),
103
+ ],
104
+ secrets={"HF_TOKEN": oauth_token.token},
105
+ flavor=JOB_FLAVOR,
106
+ timeout=JOB_TIMEOUT,
107
+ token=oauth_token.token,
108
+ )
109
+ except Exception as e:
110
+ raise gr.Error(f"Failed to submit job: {e}")
111
+
112
+ status_md = (
113
+ f"**Status**: submitted\n\n"
114
+ f"Job: [`{job.id}`]({job.url}) on `{JOB_FLAVOR}` (timeout `{JOB_TIMEOUT}`)"
115
+ )
116
+ return job.id, status_md, ""
117
+
118
+
119
+ def poll_job(job_id: str, oauth_token: gr.OAuthToken | None):
120
+ if not job_id or oauth_token is None:
121
+ return gr.update(), gr.update()
122
+
123
+ try:
124
+ info = inspect_job(job_id=job_id, token=oauth_token.token)
125
+ log_lines = list(fetch_job_logs(job_id=job_id, follow=False, token=oauth_token.token))
126
+ except Exception as e:
127
+ return gr.update(), f"**Status**: poll error — {e}"
128
+
129
+ stage = info.status.stage
130
+ badge = {
131
+ "RUNNING": "🟢 RUNNING",
132
+ "COMPLETED": "✅ COMPLETED",
133
+ "CANCELED": "🟠 CANCELED",
134
+ "ERROR": "🔴 ERROR",
135
+ "DELETED": "⚫ DELETED",
136
+ }.get(stage, stage)
137
+
138
+ status_md = f"**Status**: {badge} — [`{info.id}`]({info.url})"
139
+ if info.status.message:
140
+ status_md += f"\n\n> {info.status.message}"
141
+
142
+ logs = "\n".join(log_lines)
143
+ return logs, status_md
144
+
145
+
146
+ def cancel_transfer(job_id: str, oauth_token: gr.OAuthToken | None):
147
+ if not job_id:
148
+ return job_id, "**Status**: nothing to cancel"
149
+ if oauth_token is None:
150
+ raise gr.Error("Please log in.")
151
+ try:
152
+ cancel_job(job_id=job_id, token=oauth_token.token)
153
+ except Exception as e:
154
+ raise gr.Error(f"Failed to cancel job: {e}")
155
+ return "", f"**Status**: cancel requested for `{job_id}`"
156
+
157
+
158
+ def update_login_state(profile: gr.OAuthProfile | None):
159
+ """Refresh the login banner and toggle the form's visibility.
160
+
161
+ Triggered on demo.load (page open, including the post-OAuth redirect).
162
+ Returns updates for: (login_md, form_group).
163
+ """
164
+ if profile is not None:
165
+ return (
166
+ f"Signed in as **@{profile.username}**.",
167
+ gr.update(visible=True),
168
+ )
169
+ return (
170
+ "Please sign in with Hugging Face to start a transfer.",
171
+ gr.update(visible=False),
172
+ )
173
+
174
+
175
+ with gr.Blocks(title="Duplicate your bucket") as demo:
176
+ gr.Markdown(
177
+ "# Duplicate your bucket\n"
178
+ "Copy a Hugging Face Bucket to another Bucket. The transfer runs as an "
179
+ "[HF Job](https://huggingface.co/docs/huggingface_hub/guides/jobs) in your "
180
+ "namespace — your account needs to support Jobs (Pro / Team / Enterprise)."
181
+ )
182
+ gr.LoginButton()
183
+ login_md = gr.Markdown("Please sign in with Hugging Face to start a transfer.")
184
+
185
+ with gr.Group(visible=False) as form_group:
186
+ with gr.Row():
187
+ src_input = gr.Textbox(
188
+ label="Source bucket",
189
+ placeholder="name/source-bucket",
190
+ scale=1,
191
+ )
192
+ dst_input = gr.Textbox(
193
+ label="Destination bucket",
194
+ placeholder="name/destination-bucket",
195
+ scale=1,
196
+ )
197
+
198
+ with gr.Accordion(
199
+ "Destination creation options — only applied if the destination bucket doesn't exist yet",
200
+ open=True,
201
+ ):
202
+ with gr.Row():
203
+ private_input = gr.Checkbox(label="Private", value=False, scale=1)
204
+ region_input = gr.Dropdown(
205
+ label="Region",
206
+ choices=list(BUCKET_REGIONS),
207
+ value="us",
208
+ scale=1,
209
+ )
210
+
211
+ with gr.Row():
212
+ submit_btn = gr.Button("Start transfer", variant="primary", scale=1)
213
+ cancel_btn = gr.Button("Cancel", variant="stop", scale=0)
214
+
215
+ status_md = gr.Markdown(value="**Status**: idle")
216
+ log_box = gr.Textbox(
217
+ label="Job logs",
218
+ interactive=False,
219
+ lines=20,
220
+ max_lines=200,
221
+ elem_id="log-box",
222
+ )
223
+
224
+ job_id_state = gr.State("")
225
+
226
+ submit_btn.click(
227
+ fn=submit_transfer,
228
+ inputs=[src_input, dst_input, private_input, region_input],
229
+ outputs=[job_id_state, status_md, log_box],
230
+ )
231
+ cancel_btn.click(
232
+ fn=cancel_transfer,
233
+ inputs=[job_id_state],
234
+ outputs=[job_id_state, status_md],
235
+ )
236
+
237
+ log_timer = gr.Timer(value=2)
238
+ log_timer.tick(fn=poll_job, inputs=[job_id_state], outputs=[log_box, status_md])
239
+
240
+ demo.load(fn=update_login_state, outputs=[login_md, form_group])
241
+
242
+
243
+ demo.queue()
244
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ huggingface_hub==1.11.0
2
+ gradio[oauth]>=6.0,<7
worker.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF Job worker: copy every file from --src to --dst.
2
+
3
+ Both directories are mounted by the HF Jobs platform via `Volume(type="bucket", ...)`,
4
+ so this script only needs the Python stdlib and progress logging to stdout (the
5
+ Space tails the Job logs to render the live progress).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ CHUNK_SIZE = 64 * 1024 * 1024 # 64 MiB
16
+
17
+
18
+ def log(msg: str) -> None:
19
+ print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
20
+
21
+
22
+ def parse_args() -> argparse.Namespace:
23
+ p = argparse.ArgumentParser(description="Bucket-to-bucket file copy.")
24
+ p.add_argument("--src", required=True, help="Source mount path (read-only).")
25
+ p.add_argument("--dst", required=True, help="Destination mount path (writable).")
26
+ return p.parse_args()
27
+
28
+
29
+ def main() -> int:
30
+ args = parse_args()
31
+ src = Path(args.src)
32
+ dst = Path(args.dst)
33
+
34
+ if not src.is_dir():
35
+ log(f"source mount not found at {src}")
36
+ return 1
37
+ if not dst.is_dir():
38
+ log(f"destination mount not found at {dst}")
39
+ return 1
40
+
41
+ log(f"scanning {src} ...")
42
+ files = [f for f in src.rglob("*") if f.is_file()]
43
+ total = len(files)
44
+ bytes_total = sum(f.stat().st_size for f in files)
45
+ log(f"found {total} file(s), {bytes_total / 1e9:.2f} GB")
46
+
47
+ if total == 0:
48
+ log("nothing to copy.")
49
+ return 0
50
+
51
+ bytes_copied = 0
52
+ failures = 0
53
+ started = time.monotonic()
54
+
55
+ for i, src_file in enumerate(files, 1):
56
+ rel = src_file.relative_to(src)
57
+ dst_file = dst / rel
58
+ size = src_file.stat().st_size
59
+ log(f"[{i}/{total}] {rel} ({size / 1e6:.1f} MB)")
60
+
61
+ try:
62
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
63
+ with open(src_file, "rb") as sf, open(dst_file, "wb") as df:
64
+ while True:
65
+ data = sf.read(CHUNK_SIZE)
66
+ if not data:
67
+ break
68
+ df.write(data)
69
+ except Exception as e:
70
+ log(f" error copying {rel}: {e}")
71
+ failures += 1
72
+ continue
73
+
74
+ bytes_copied += size
75
+ pct = bytes_copied / bytes_total * 100 if bytes_total else 100.0
76
+ elapsed = time.monotonic() - started
77
+ rate = bytes_copied / elapsed / 1e6 if elapsed > 0 else 0
78
+ log(f" done ({pct:.1f}% overall, {rate:.1f} MB/s avg)")
79
+
80
+ elapsed = time.monotonic() - started
81
+ log(
82
+ f"transfer complete: {total - failures}/{total} file(s), "
83
+ f"{bytes_copied / 1e9:.2f} GB in {elapsed:.0f}s "
84
+ f"({(bytes_copied / elapsed / 1e6) if elapsed > 0 else 0:.1f} MB/s)"
85
+ )
86
+ if failures:
87
+ log(f"{failures} file(s) failed — see errors above")
88
+ return 2
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ sys.exit(main())