Spaces:
Running
Running
Commit ·
f718aea
0
Parent(s):
Initial: Harbor Visualiser — Gradio Space for browsing Harbor task spec datasets
Browse filesAccepts:
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.
- .gitignore +14 -0
- README.md +91 -0
- app.py +362 -0
- requirements.txt +4 -0
- viewer/__init__.py +13 -0
- viewer/load.py +251 -0
- 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"| `{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 |
+
)
|