AdithyaSK HF Staff commited on
Commit
f718aea
·
0 Parent(s):

Initial: Harbor Visualiser — Gradio Space for browsing Harbor task spec datasets

Browse files

Accepts:
owner/name → HF Hub
hf://owner/name[@rev] → HF Hub (explicit)
gh://owner/repo[@ref] → GitHub
https://github.com/owner/repo[.git] → GitHub (full URL)
harbor://name | harbor://org/name[@tag] → Harbor registry (via harbor CLI)
/local/abs/path → local directory

Per task tabs: Overview · Instruction · Patch · test.sh · Dockerfile ·
solve.sh · Raw task.toml. Recognizes Repo2RLEnv's `[metadata.repo2env]`
extension and renders it as a separate metadata table when present.

URL prefill via `?dataset=<uri>` (also `?d=...`) reads on page load.

Live-tested against all four sources before initial push.

Files changed (7) hide show
  1. .gitignore +14 -0
  2. README.md +91 -0
  3. app.py +362 -0
  4. requirements.txt +4 -0
  5. viewer/__init__.py +13 -0
  6. viewer/load.py +251 -0
  7. viewer/parse.py +159 -0
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .venv/
8
+ venv/
9
+ env/
10
+ .env
11
+ .env.local
12
+ .DS_Store
13
+ .harbor-cache/
14
+ *.egg-info/
README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Harbor Visualiser
3
+ emoji: 🔭
4
+ colorFrom: orange
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: "6.14.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ short_description: Browse Harbor task specs from HF Hub or GitHub in your browser
12
+ ---
13
+
14
+ # Harbor Visualiser
15
+
16
+ A tiny Gradio Space for browsing [Harbor](https://www.harborframework.com/) task spec directories — the dataset format used by Harbor for agent evaluation + RL environments.
17
+
18
+ Drop in a Hugging Face dataset id, a GitHub repo, or a local Harbor dataset directory; the viewer renders every task's metadata, instruction, oracle patch, test script, and Dockerfile side-by-side.
19
+
20
+ ## Use it
21
+
22
+ **Hosted (HF Space):** [https://huggingface.co/spaces/AdithyaSK/harbor-visualiser](https://huggingface.co/spaces/AdithyaSK/harbor-visualiser)
23
+
24
+ Prefill via URL param:
25
+ ```
26
+ https://huggingface.co/spaces/AdithyaSK/harbor-visualiser?dataset=<owner>/<dataset>
27
+ ```
28
+
29
+ **Inputs accepted:**
30
+
31
+ | Form | Source |
32
+ |---|---|
33
+ | `owner/name` | HF Hub dataset (default) |
34
+ | `hf://owner/name` | HF Hub (explicit) |
35
+ | `hf://owner/name@<rev>` | HF Hub revision pin |
36
+ | `gh://owner/repo` | GitHub repo |
37
+ | `gh://owner/repo@<ref>` | GitHub at branch / tag / SHA |
38
+ | `https://github.com/owner/repo` | Full GitHub URL |
39
+
40
+ ## Run locally
41
+
42
+ ```bash
43
+ pip install -r requirements.txt
44
+ python app.py
45
+ # → http://127.0.0.1:7860
46
+ ```
47
+
48
+ ## What it shows per task
49
+
50
+ | Tab | Source file |
51
+ |---|---|
52
+ | Overview | parsed `task.toml` ([task], [metadata]) + `[metadata.repo2env]` if present |
53
+ | Instruction | `instruction.md` |
54
+ | Patch (oracle) | `solution/patch.diff` |
55
+ | `test.sh` | `tests/test.sh` |
56
+ | Dockerfile | `environment/Dockerfile` |
57
+ | `solve.sh` | `solution/solve.sh` (when present) |
58
+ | Raw `task.toml` | full file |
59
+
60
+ ## Dataset layout it expects (Harbor's standard)
61
+
62
+ Either of these:
63
+
64
+ ```
65
+ # Layout A — flat (what Repo2RLEnv emits + most git repos use)
66
+ <dataset-root>/
67
+ ├── <task-id>/
68
+ │ ├── task.toml
69
+ │ ├── instruction.md
70
+ │ ├── solution/
71
+ │ │ ├── patch.diff
72
+ │ │ └── solve.sh
73
+ │ ├── tests/test.sh
74
+ │ └── environment/Dockerfile
75
+ └── <task-id>/...
76
+
77
+ # Layout B — nested (what `repo2rlenv push` stages on the Hub)
78
+ <dataset-root>/
79
+ ├── registry.json
80
+ ├── README.md
81
+ └── tasks/
82
+ └── <task-id>/
83
+ └── ... (same as Layout A)
84
+ ```
85
+
86
+ ## Stack
87
+
88
+ - [Gradio 5](https://www.gradio.app/) — UI
89
+ - [huggingface_hub](https://github.com/huggingface/huggingface_hub) — HF dataset download
90
+ - `git` (system binary) — GitHub clone
91
+ - Python stdlib `tomllib` — task.toml parsing
app.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Harbor Visualiser — a Gradio Space for browsing Harbor task specs.
2
+
3
+ Run locally:
4
+ pip install -r requirements.txt
5
+ python app.py
6
+
7
+ Or deploy to a Hugging Face Space — the `README.md` frontmatter pins
8
+ `sdk: gradio` and `app_file: app.py`, so the Space picks this up directly.
9
+
10
+ URL prefill:
11
+ https://<space>/?dataset=owner/name
12
+ https://<space>/?dataset=hf://owner/name@rev
13
+ https://<space>/?dataset=gh://owner/repo
14
+ https://<space>/?d=owner/name (short alias)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ import gradio as gr
23
+
24
+ from viewer import (
25
+ DatasetSource,
26
+ HarborTask,
27
+ fetch_dataset,
28
+ list_tasks,
29
+ load_task,
30
+ parse_dataset_uri,
31
+ )
32
+
33
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
34
+ logger = logging.getLogger("harbor-visualiser")
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Backend handlers
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def load_dataset_action(uri: str):
43
+ """Top-level "Load" button handler.
44
+
45
+ Returns a tuple matching the `outputs=` of the button binding:
46
+ (status_md, source_state, root_state, task_choices, first_task, *task_panel_outputs)
47
+ """
48
+ if not uri or not uri.strip():
49
+ return _empty_state("Enter a dataset URI to begin.")
50
+
51
+ try:
52
+ source = parse_dataset_uri(uri)
53
+ except ValueError as exc:
54
+ return _empty_state(f"❌ {exc}")
55
+
56
+ try:
57
+ root = fetch_dataset(source)
58
+ except Exception as exc:
59
+ logger.exception("fetch failed")
60
+ return _empty_state(f"❌ fetch failed: {exc}")
61
+
62
+ tasks = list_tasks(root)
63
+ if not tasks:
64
+ return _empty_state(
65
+ f"⚠ No `task.toml` files found in `{source.display}`. "
66
+ f"Looked under `{root}` for `<id>/task.toml` and `tasks/<id>/task.toml`."
67
+ )
68
+
69
+ first = tasks[0]
70
+ task = load_task(root, first)
71
+ status = (
72
+ f"✅ Loaded **{source.display}** — {len(tasks)} task"
73
+ f"{'s' if len(tasks) != 1 else ''} found."
74
+ )
75
+ return (
76
+ status,
77
+ source.display,
78
+ str(root),
79
+ gr.update(choices=tasks, value=first, label=f"Tasks ({len(tasks)})"),
80
+ *_render_task(task),
81
+ )
82
+
83
+
84
+ def select_task_action(task_id: str, root: str):
85
+ """Radio-button onchange — switch which task's detail tab content is shown."""
86
+ if not task_id or not root:
87
+ return _render_task(None)
88
+ try:
89
+ task = load_task(Path(root), task_id)
90
+ except Exception as exc:
91
+ logger.exception("load_task failed")
92
+ return _render_task(None, error=str(exc))
93
+ return _render_task(task)
94
+
95
+
96
+ def init_from_url(request: gr.Request):
97
+ """Read `?dataset=` (or `?d=`) on page load and prefill the input."""
98
+ if request is None:
99
+ return ""
100
+ qs = dict(request.query_params or {})
101
+ return (qs.get("dataset") or qs.get("d") or "").strip()
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Rendering helpers
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ _EMPTY_OUTPUTS_COUNT = 7 # overview + instruction + patch + test + dockerfile + solve + raw
110
+
111
+
112
+ def _empty_state(status: str):
113
+ """Reset the UI when no dataset is loaded."""
114
+ return (
115
+ status,
116
+ "", # source_state
117
+ "", # root_state
118
+ gr.update(choices=[], value=None, label="Tasks"),
119
+ *_render_task(None),
120
+ )
121
+
122
+
123
+ def _render_task(task: HarborTask | None, error: str | None = None):
124
+ """Compute the 7 panel outputs for a single selected task."""
125
+ if task is None:
126
+ msg = error or "Pick a task from the list."
127
+ return (msg, "", "", "", "", "", "")
128
+
129
+ overview_md = _overview_markdown(task)
130
+ instruction = task.instruction_md or task.instruction_inline or "_(no instruction.md)_"
131
+ patch = task.oracle_patch or "(no solution/patch.diff)"
132
+ test = task.test_sh or "(no tests/test.sh)"
133
+ dockerfile = task.dockerfile or "(no environment/Dockerfile)"
134
+ solve = task.solve_sh or "(no solution/solve.sh)"
135
+ raw = task.task_toml_raw or "(no task.toml)"
136
+ return (overview_md, instruction, patch, test, dockerfile, solve, raw)
137
+
138
+
139
+ def _overview_markdown(task: HarborTask) -> str:
140
+ """Render the task's metadata as a clean markdown table."""
141
+ rows: list[tuple[str, str]] = []
142
+ rows.append(("task id", f"`{task.id}`"))
143
+ if task.name:
144
+ rows.append(("name", f"`{task.name}`"))
145
+ if task.version:
146
+ rows.append(("spec version", task.version))
147
+ if task.description:
148
+ rows.append(("description", task.description))
149
+ if task.difficulty:
150
+ rows.append(("difficulty", task.difficulty))
151
+ if task.category:
152
+ rows.append(("category", task.category))
153
+ if task.keywords:
154
+ rows.append(("keywords", ", ".join(f"`{k}`" for k in task.keywords)))
155
+ if task.agent_timeout_sec is not None:
156
+ rows.append(("agent timeout", f"{task.agent_timeout_sec}s"))
157
+ if task.verifier_timeout_sec is not None:
158
+ rows.append(("verifier timeout", f"{task.verifier_timeout_sec}s"))
159
+
160
+ md = ["| Field | Value |", "|---|---|"]
161
+ for k, v in rows:
162
+ md.append(f"| **{k}** | {v} |")
163
+
164
+ if task.repo2env:
165
+ md.append("\n### `[metadata.repo2env]` extension (Repo2RLEnv)\n")
166
+ md.append("| Field | Value |")
167
+ md.append("|---|---|")
168
+ for k, v in sorted(task.repo2env.items()):
169
+ if isinstance(v, dict):
170
+ # Nested pipeline-specific block — flatten one level
171
+ md.append(f"| **{k}** | _(nested — see below)_ |")
172
+ for kk, vv in sorted(v.items()):
173
+ md.append(f"| &nbsp;&nbsp;`{kk}` | `{_short(vv)}` |")
174
+ else:
175
+ md.append(f"| **{k}** | `{_short(v)}` |")
176
+
177
+ return "\n".join(md)
178
+
179
+
180
+ def _short(v) -> str:
181
+ """Truncate long values for the metadata table cells."""
182
+ if isinstance(v, list):
183
+ return ", ".join(str(x) for x in v)
184
+ s = str(v)
185
+ return s if len(s) < 110 else s[:107] + "…"
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # UI
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ _INTRO_MD = """# Harbor Visualiser
194
+
195
+ Browse [Harbor](https://www.harborframework.com/) task spec datasets — Hugging Face, GitHub, Harbor registry, or local."""
196
+
197
+
198
+ _FOOTER_MD = """<sub>Built with [Gradio](https://www.gradio.app/) · Source on [GitHub](https://github.com/adithya-s-k/harbor-visualiser) · Harbor framework [docs](https://www.harborframework.com/)</sub>"""
199
+
200
+
201
+ # A small set of popular / known-working datasets surfaced as one-click examples.
202
+ # Each tuple is (label, uri). Order matters — most useful first.
203
+ _EXAMPLES: list[tuple[str, str]] = [
204
+ ("cookbook/test (Harbor)", "harbor://cookbook/test"),
205
+ ("SWE-Atlas QnA (Harbor)", "harbor://scale-ai/swe-atlas-qna"),
206
+ ("SWE-Bench Pro (Harbor)", "harbor://cais/swebenchpro"),
207
+ ("Click PRs (HF / Repo2RLEnv)", "AdithyaSK/click-r2e-v082post1"),
208
+ ("Click PRs (GitHub demo)", "https://github.com/adithya-s-k/harbor-tasks-demo"),
209
+ ]
210
+
211
+
212
+ # Minimal monochrome aesthetic — sharp, no rounded buttons, mono-font for code,
213
+ # slim borders. Tuned for the Soft-replacement requested in v0.1 design.
214
+ _CUSTOM_CSS = """
215
+ .gradio-container { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
216
+ .gradio-container * { letter-spacing: 0; }
217
+ h1, h2, h3 { font-weight: 600; }
218
+ button.primary { background: #111 !important; color: white !important; border: 1px solid #111 !important; }
219
+ button.primary:hover { background: #333 !important; }
220
+ .tabs { border: 1px solid #e5e5e5; }
221
+ #task-list label { font-weight: 500; }
222
+ footer { display: none !important; }
223
+ """
224
+
225
+
226
+ def build_ui() -> gr.Blocks:
227
+ with gr.Blocks(title="Harbor Visualiser") as demo:
228
+ gr.Markdown(_INTRO_MD)
229
+
230
+ with gr.Row():
231
+ uri_input = gr.Textbox(
232
+ label="Dataset",
233
+ placeholder="owner/name | gh://owner/repo | harbor://org/name | https://github.com/owner/repo",
234
+ lines=1,
235
+ scale=8,
236
+ )
237
+ load_btn = gr.Button("Load", variant="primary", scale=1, min_width=80)
238
+
239
+ # Quick-access popular examples. Click → populates the input + auto-loads.
240
+ with gr.Row():
241
+ example_btns: list[gr.Button] = []
242
+ for label, _ in _EXAMPLES:
243
+ example_btns.append(gr.Button(label, size="sm", variant="secondary"))
244
+
245
+ status = gr.Markdown("Enter a dataset URI to begin.")
246
+
247
+ source_state = gr.State("")
248
+ root_state = gr.State("")
249
+
250
+ with gr.Row():
251
+ with gr.Column(scale=2, min_width=240):
252
+ task_list = gr.Radio(
253
+ choices=[],
254
+ label="Tasks",
255
+ value=None,
256
+ interactive=True,
257
+ elem_id="task-list",
258
+ )
259
+ with gr.Column(scale=7):
260
+ with gr.Tabs():
261
+ with gr.Tab("Overview"):
262
+ overview_md = gr.Markdown()
263
+ with gr.Tab("Instruction"):
264
+ instruction_md = gr.Markdown()
265
+ with gr.Tab("Patch"):
266
+ patch_code = gr.Code(
267
+ language="python", # Gradio Prism has no "diff" but python catches +/- well
268
+ label="solution/patch.diff",
269
+ interactive=False,
270
+ )
271
+ with gr.Tab("test.sh"):
272
+ test_code = gr.Code(
273
+ language="shell",
274
+ label="tests/test.sh",
275
+ interactive=False,
276
+ )
277
+ with gr.Tab("Dockerfile"):
278
+ dockerfile_code = gr.Code(
279
+ language="dockerfile",
280
+ label="environment/Dockerfile",
281
+ interactive=False,
282
+ )
283
+ with gr.Tab("solve.sh"):
284
+ solve_code = gr.Code(
285
+ language="shell",
286
+ label="solution/solve.sh",
287
+ interactive=False,
288
+ )
289
+ with gr.Tab("Raw task.toml"):
290
+ raw_code = gr.Code(
291
+ language="yaml", # closest Prism token to TOML
292
+ label="task.toml",
293
+ interactive=False,
294
+ )
295
+
296
+ gr.Markdown(_FOOTER_MD)
297
+
298
+ # --- event wiring ---
299
+ panel_outputs = [
300
+ overview_md,
301
+ instruction_md,
302
+ patch_code,
303
+ test_code,
304
+ dockerfile_code,
305
+ solve_code,
306
+ raw_code,
307
+ ]
308
+
309
+ load_btn.click(
310
+ fn=load_dataset_action,
311
+ inputs=[uri_input],
312
+ outputs=[status, source_state, root_state, task_list, *panel_outputs],
313
+ )
314
+ uri_input.submit(
315
+ fn=load_dataset_action,
316
+ inputs=[uri_input],
317
+ outputs=[status, source_state, root_state, task_list, *panel_outputs],
318
+ )
319
+
320
+ task_list.change(
321
+ fn=select_task_action,
322
+ inputs=[task_list, root_state],
323
+ outputs=panel_outputs,
324
+ )
325
+
326
+ # Wire each example button → set the input AND auto-load.
327
+ # Capturing `uri` by default-arg avoids the classic late-binding trap.
328
+ for btn, (_, uri_value) in zip(example_btns, _EXAMPLES, strict=True):
329
+ btn.click(
330
+ fn=lambda u=uri_value: u,
331
+ outputs=uri_input,
332
+ ).then(
333
+ fn=load_dataset_action,
334
+ inputs=[uri_input],
335
+ outputs=[status, source_state, root_state, task_list, *panel_outputs],
336
+ )
337
+
338
+ # On page load: read ?dataset= from URL → prefill input → auto-load
339
+ demo.load(fn=init_from_url, inputs=None, outputs=uri_input).then(
340
+ fn=lambda u: load_dataset_action(u) if u else _empty_state("Enter a dataset URI to begin."),
341
+ inputs=[uri_input],
342
+ outputs=[status, source_state, root_state, task_list, *panel_outputs],
343
+ )
344
+
345
+ return demo
346
+
347
+
348
+ if __name__ == "__main__":
349
+ # Minimal black-monochrome aesthetic. Gradio's built-in Monochrome theme
350
+ # is grayscale by design; combined with our css overrides above it lands
351
+ # at "sharp, no color, mono-ish" without rolling our own theme from scratch.
352
+ theme = gr.themes.Monochrome(
353
+ radius_size=gr.themes.sizes.radius_sm,
354
+ spacing_size=gr.themes.sizes.spacing_md,
355
+ text_size=gr.themes.sizes.text_md,
356
+ )
357
+ demo = build_ui()
358
+ demo.queue(default_concurrency_limit=4).launch(
359
+ server_name="0.0.0.0",
360
+ theme=theme,
361
+ css=_CUSTOM_CSS,
362
+ )
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=6.0.0
2
+ huggingface_hub>=0.27.0
3
+ harbor>=0.6.0
4
+
viewer/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Harbor Visualiser — load + parse Harbor task spec datasets."""
2
+
3
+ from viewer.load import DatasetSource, fetch_dataset, parse_dataset_uri
4
+ from viewer.parse import HarborTask, list_tasks, load_task
5
+
6
+ __all__ = [
7
+ "DatasetSource",
8
+ "HarborTask",
9
+ "fetch_dataset",
10
+ "list_tasks",
11
+ "load_task",
12
+ "parse_dataset_uri",
13
+ ]
viewer/load.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """URI parsing + dataset fetching for the Harbor Visualiser.
2
+
3
+ Accepts the same URI shapes as `repo2rlenv pull`:
4
+ owner/name → HF Hub (default)
5
+ owner/name@<rev> → HF Hub, revision pinned
6
+ hf://owner/name[@rev] → HF Hub, explicit prefix
7
+ gh://owner/repo[@ref] → GitHub, optional branch/tag/sha
8
+ https://github.com/owner/repo[.git] → GitHub, full URL
9
+
10
+ All datasets land in a local cache dir; subsequent loads in the same Space
11
+ process are free.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # Where downloads are cached during a single Space-process lifetime.
28
+ # In Spaces, /tmp survives across requests for the same instance.
29
+ CACHE_ROOT = Path(os.environ.get("HARBOR_VIEWER_CACHE", "/tmp/.harbor-viewer-cache"))
30
+
31
+
32
+ @dataclass(slots=True, frozen=True)
33
+ class DatasetSource:
34
+ """A parsed dataset URI ready for fetch.
35
+
36
+ `kind` is one of "hf" | "gh" | "local". `ident` is the canonical id
37
+ (`owner/name` for HF/GH, absolute path for local). `revision` is the
38
+ optional @-suffix (tag / branch / commit / Hub revision).
39
+ """
40
+
41
+ kind: str
42
+ ident: str
43
+ revision: str | None
44
+
45
+ @property
46
+ def display(self) -> str:
47
+ rev = f"@{self.revision}" if self.revision else ""
48
+ if self.kind == "hf":
49
+ return f"hf://{self.ident}{rev}"
50
+ if self.kind == "gh":
51
+ return f"gh://{self.ident}{rev}"
52
+ if self.kind == "harbor":
53
+ return f"harbor://{self.ident}{rev}"
54
+ return str(self.ident)
55
+
56
+ @property
57
+ def cache_key(self) -> str:
58
+ rev = (self.revision or "head").replace("/", "_")
59
+ return f"{self.kind}__{self.ident.replace('/', '__')}__{rev}"
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # URI parsing
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _split_revision(s: str) -> tuple[str, str | None]:
68
+ """Split `name@rev` into (name, rev). Returns (name, None) if no `@`."""
69
+ if "@" in s:
70
+ name, _, rev = s.rpartition("@")
71
+ return name, (rev or None)
72
+ return s, None
73
+
74
+
75
+ def parse_dataset_uri(uri: str) -> DatasetSource:
76
+ """Classify a URI string into a DatasetSource. Raises ValueError on malformed input."""
77
+ s = (uri or "").strip()
78
+ if not s:
79
+ raise ValueError("empty dataset URI")
80
+
81
+ # Absolute / relative path → local
82
+ if s.startswith("/") or s.startswith("./") or s.startswith("../"):
83
+ path = Path(s).expanduser().resolve()
84
+ if not path.is_dir():
85
+ raise ValueError(f"local dataset directory not found: {path}")
86
+ return DatasetSource(kind="local", ident=str(path), revision=None)
87
+
88
+ # GitHub full URL
89
+ for prefix in ("https://github.com/", "http://github.com/", "git@github.com:"):
90
+ if s.startswith(prefix):
91
+ tail = s.removeprefix(prefix).removesuffix(".git")
92
+ base, rev = _split_revision(tail)
93
+ parts = [p for p in base.split("/") if p]
94
+ if len(parts) < 2:
95
+ raise ValueError(f"GitHub URL needs owner/repo, got {uri!r}")
96
+ return DatasetSource(kind="gh", ident=f"{parts[0]}/{parts[1]}", revision=rev)
97
+
98
+ if s.startswith("gh://"):
99
+ base, rev = _split_revision(s.removeprefix("gh://"))
100
+ parts = [p for p in base.split("/") if p]
101
+ if len(parts) != 2 or not all(parts):
102
+ raise ValueError(f"gh:// expects owner/repo, got {uri!r}")
103
+ return DatasetSource(kind="gh", ident=f"{parts[0]}/{parts[1]}", revision=rev)
104
+
105
+ if s.startswith("harbor://"):
106
+ base, rev = _split_revision(s.removeprefix("harbor://"))
107
+ parts = [p for p in base.split("/") if p]
108
+ # Harbor accepts both bare-name and org/name
109
+ if len(parts) == 2 and base.count("/") == 1:
110
+ return DatasetSource(kind="harbor", ident=f"{parts[0]}/{parts[1]}", revision=rev)
111
+ if len(parts) == 1 and "/" not in base:
112
+ return DatasetSource(kind="harbor", ident=parts[0], revision=rev)
113
+ raise ValueError(
114
+ f"harbor:// expects 'name' or 'org/name' (optionally @tag), got {uri!r}"
115
+ )
116
+
117
+ if s.startswith("hf://"):
118
+ s = s.removeprefix("hf://")
119
+ # fall through to HF parsing
120
+
121
+ base, rev = _split_revision(s)
122
+ parts = [p for p in base.split("/") if p]
123
+ if len(parts) == 2 and base.count("/") == 1:
124
+ return DatasetSource(kind="hf", ident=f"{parts[0]}/{parts[1]}", revision=rev)
125
+ raise ValueError(
126
+ f"unrecognized dataset URI {uri!r}. "
127
+ f"Accepted: owner/name, hf://owner/name[@rev], gh://owner/repo[@ref], "
128
+ f"https://github.com/owner/repo, or an absolute local path."
129
+ )
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Fetching
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def _fetch_hf(source: DatasetSource, force: bool) -> Path:
138
+ """Snapshot-download an HF Hub dataset into the cache."""
139
+ from huggingface_hub import snapshot_download
140
+
141
+ target = CACHE_ROOT / source.cache_key
142
+ if not force and target.exists() and any(target.iterdir()):
143
+ logger.info("hf cache hit: %s", target)
144
+ return target
145
+
146
+ if target.exists():
147
+ shutil.rmtree(target)
148
+ target.mkdir(parents=True, exist_ok=True)
149
+ # Public datasets work without a token; private ones rely on $HF_TOKEN
150
+ # being set in the Space's secrets.
151
+ token = os.environ.get("HF_TOKEN") or None
152
+ snapshot_download(
153
+ repo_id=source.ident,
154
+ repo_type="dataset",
155
+ revision=source.revision,
156
+ local_dir=str(target),
157
+ token=token,
158
+ )
159
+ return target
160
+
161
+
162
+ def _fetch_harbor(source: DatasetSource, force: bool) -> Path:
163
+ """Shell out to `harbor datasets download` to fetch a Harbor-registry dataset.
164
+
165
+ Harbor handles its own registry resolution, auth, and tag pinning via
166
+ `<org>/<name>@<tag>`. We just orchestrate + flatten the result into a
167
+ standard dataset layout.
168
+ """
169
+ target = CACHE_ROOT / source.cache_key
170
+ if not force and target.exists() and any(target.iterdir()):
171
+ logger.info("harbor cache hit: %s", target)
172
+ return target
173
+
174
+ if not shutil.which("harbor"):
175
+ raise RuntimeError(
176
+ "`harbor` CLI not on PATH. "
177
+ "It's listed in `requirements.txt` — on a Hugging Face Space it "
178
+ "installs automatically. Locally: `pip install harbor`."
179
+ )
180
+
181
+ if target.exists():
182
+ shutil.rmtree(target)
183
+ target.mkdir(parents=True, exist_ok=True)
184
+
185
+ selector = source.ident + (f"@{source.revision}" if source.revision else "")
186
+ with tempfile.TemporaryDirectory(prefix="harbor-viewer-harbor-") as tmp:
187
+ args = ["harbor", "datasets", "download", selector, "-o", tmp]
188
+ logger.info("running: %s", " ".join(args))
189
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=600, check=False)
190
+ if proc.returncode != 0:
191
+ shutil.rmtree(target, ignore_errors=True)
192
+ raise RuntimeError(
193
+ f"harbor download failed (exit {proc.returncode}): "
194
+ f"{proc.stderr.strip()[:400] or proc.stdout.strip()[:400]}"
195
+ )
196
+ # If the downloaded tree has exactly one subdirectory and no task.toml at the
197
+ # top, recurse one level — that's harbor's typical layout (`<tmp>/<name>/...`).
198
+ downloaded = Path(tmp)
199
+ children = [c for c in downloaded.iterdir() if c.is_dir()]
200
+ if len(children) == 1 and not (downloaded / "task.toml").exists():
201
+ downloaded = children[0]
202
+ # Move everything under the cache target
203
+ for child in downloaded.iterdir():
204
+ shutil.move(str(child), str(target / child.name))
205
+
206
+ return target
207
+
208
+
209
+ def _fetch_github(source: DatasetSource, force: bool) -> Path:
210
+ """git clone --depth 1 a GitHub repo into the cache."""
211
+ target = CACHE_ROOT / source.cache_key
212
+ if not force and target.exists() and any(target.iterdir()):
213
+ logger.info("gh cache hit: %s", target)
214
+ return target
215
+
216
+ if target.exists():
217
+ shutil.rmtree(target)
218
+ target.parent.mkdir(parents=True, exist_ok=True)
219
+
220
+ if not shutil.which("git"):
221
+ raise RuntimeError(
222
+ "`git` not found on PATH. Install git (or rely on the HF Space sandbox which ships it)."
223
+ )
224
+
225
+ with tempfile.TemporaryDirectory(prefix="harbor-viewer-clone-") as tmp:
226
+ tmp_clone = Path(tmp) / "clone"
227
+ args = ["git", "clone", "--depth", "1"]
228
+ if source.revision:
229
+ args += ["--branch", source.revision]
230
+ args += [f"https://github.com/{source.ident}.git", str(tmp_clone)]
231
+ logger.info("running: git clone --depth 1 [...] %s", source.ident)
232
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=300, check=False)
233
+ if proc.returncode != 0:
234
+ raise RuntimeError(
235
+ f"git clone failed (exit {proc.returncode}): {proc.stderr.strip()[:400]}"
236
+ )
237
+ shutil.move(str(tmp_clone), str(target))
238
+ return target
239
+
240
+
241
+ def fetch_dataset(source: DatasetSource, *, force: bool = False) -> Path:
242
+ """Materialize a dataset into the local cache. Returns the on-disk root."""
243
+ if source.kind == "hf":
244
+ return _fetch_hf(source, force=force)
245
+ if source.kind == "gh":
246
+ return _fetch_github(source, force=force)
247
+ if source.kind == "harbor":
248
+ return _fetch_harbor(source, force=force)
249
+ if source.kind == "local":
250
+ return Path(source.ident)
251
+ raise ValueError(f"unknown source kind: {source.kind!r}")
viewer/parse.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Walk a Harbor dataset directory + load each task's spec files.
2
+
3
+ A "Harbor task" is a directory containing `task.toml` at its root, plus
4
+ any of: `instruction.md`, `solution/patch.diff`, `solution/solve.sh`,
5
+ `tests/test.sh`, `environment/Dockerfile`. We tolerate missing files —
6
+ not every task ships every artifact.
7
+
8
+ Two dataset layouts are accepted:
9
+
10
+ flat: <root>/<task-id>/task.toml
11
+ nested: <root>/tasks/<task-id>/task.toml
12
+
13
+ We discover both transparently.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import tomllib
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class HarborTask:
29
+ """The full parsed view of a single Harbor task directory."""
30
+
31
+ # Identity
32
+ id: str # the directory name, e.g. "pallets__click-3373"
33
+ root: Path # absolute path to the task dir
34
+
35
+ # task.toml top-level fields
36
+ name: str | None = None
37
+ org: str | None = None
38
+ version: str | None = None
39
+
40
+ # [task]
41
+ description: str | None = None
42
+ instruction_inline: str | None = None # if task.toml has `instruction =`
43
+
44
+ # [metadata]
45
+ difficulty: str | None = None
46
+ category: str | None = None
47
+ keywords: list[str] = field(default_factory=list)
48
+
49
+ # [agent] / [verifier] timeouts (if set)
50
+ agent_timeout_sec: float | None = None
51
+ verifier_timeout_sec: float | None = None
52
+
53
+ # Repo2RLEnv extension — [metadata.repo2env] block, opaque dict
54
+ repo2env: dict[str, Any] | None = None
55
+
56
+ # File contents (None = file not present)
57
+ instruction_md: str | None = None
58
+ oracle_patch: str | None = None
59
+ solve_sh: str | None = None
60
+ test_sh: str | None = None
61
+ dockerfile: str | None = None
62
+ task_toml_raw: str = ""
63
+
64
+
65
+ def _read_text(path: Path) -> str | None:
66
+ """Read a text file, return None if it doesn't exist."""
67
+ try:
68
+ return path.read_text(encoding="utf-8")
69
+ except FileNotFoundError:
70
+ return None
71
+ except Exception as exc:
72
+ logger.warning("could not read %s: %s", path, exc)
73
+ return None
74
+
75
+
76
+ def _discover_task_roots(dataset_root: Path) -> list[Path]:
77
+ """Find every directory under `dataset_root` that contains a `task.toml`.
78
+
79
+ Handles both flat (`<root>/<id>/task.toml`) and nested
80
+ (`<root>/tasks/<id>/task.toml`) Harbor layouts. Prefers nested when
81
+ `<root>/tasks/` exists (that's what `repo2rlenv push` stages on HF).
82
+ Also handles the case where the dataset is itself a single task root.
83
+ """
84
+ if (dataset_root / "task.toml").exists():
85
+ return [dataset_root]
86
+
87
+ tasks_dir = dataset_root / "tasks"
88
+ if tasks_dir.is_dir():
89
+ return sorted(
90
+ [p for p in tasks_dir.iterdir() if p.is_dir() and (p / "task.toml").exists()]
91
+ )
92
+
93
+ # Flat layout — every immediate subdir that has task.toml is a task
94
+ return sorted(
95
+ [
96
+ p
97
+ for p in dataset_root.iterdir()
98
+ if p.is_dir() and not p.name.startswith(".") and (p / "task.toml").exists()
99
+ ]
100
+ )
101
+
102
+
103
+ def list_tasks(dataset_root: Path) -> list[str]:
104
+ """Return the task-id (directory name) of every task under `dataset_root`."""
105
+ return [p.name for p in _discover_task_roots(dataset_root)]
106
+
107
+
108
+ def _resolve_task_dir(dataset_root: Path, task_id: str) -> Path:
109
+ """Find the on-disk directory for a given task id (handles flat + nested)."""
110
+ flat = dataset_root / task_id
111
+ if (flat / "task.toml").exists():
112
+ return flat
113
+ nested = dataset_root / "tasks" / task_id
114
+ if (nested / "task.toml").exists():
115
+ return nested
116
+ if (dataset_root / "task.toml").exists() and dataset_root.name == task_id:
117
+ return dataset_root
118
+ raise FileNotFoundError(f"no task {task_id!r} under {dataset_root}")
119
+
120
+
121
+ def load_task(dataset_root: Path, task_id: str) -> HarborTask:
122
+ """Load every spec file for a single Harbor task. Tolerant of missing pieces."""
123
+ task_dir = _resolve_task_dir(dataset_root, task_id)
124
+ toml_path = task_dir / "task.toml"
125
+ raw = toml_path.read_text(encoding="utf-8")
126
+ data = tomllib.loads(raw)
127
+
128
+ task_block = data.get("task") or {}
129
+ metadata_block = data.get("metadata") or {}
130
+ agent_block = data.get("agent") or {}
131
+ verifier_block = data.get("verifier") or {}
132
+
133
+ # [metadata.repo2env] is the Repo2RLEnv extension — surfaced as a separate
134
+ # opaque dict so the UI can render it specially if present.
135
+ repo2env = metadata_block.get("repo2env")
136
+ if repo2env is not None and not isinstance(repo2env, dict):
137
+ repo2env = None
138
+
139
+ return HarborTask(
140
+ id=task_id,
141
+ root=task_dir,
142
+ name=task_block.get("name"),
143
+ org=task_block.get("org"),
144
+ version=data.get("version"),
145
+ description=task_block.get("description"),
146
+ instruction_inline=task_block.get("instruction"),
147
+ difficulty=metadata_block.get("difficulty"),
148
+ category=metadata_block.get("category"),
149
+ keywords=list(metadata_block.get("keywords") or []),
150
+ agent_timeout_sec=agent_block.get("timeout_sec"),
151
+ verifier_timeout_sec=verifier_block.get("timeout_sec"),
152
+ repo2env=repo2env,
153
+ instruction_md=_read_text(task_dir / "instruction.md"),
154
+ oracle_patch=_read_text(task_dir / "solution" / "patch.diff"),
155
+ solve_sh=_read_text(task_dir / "solution" / "solve.sh"),
156
+ test_sh=_read_text(task_dir / "tests" / "test.sh"),
157
+ dockerfile=_read_text(task_dir / "environment" / "Dockerfile"),
158
+ task_toml_raw=raw,
159
+ )