Michael Rabinovich commited on
Commit
0501689
·
1 Parent(s): 91244c6

submit: move handle_submit into its own module + add validation pipeline

Browse files

Step 6 (E) chunk 2 of the async submit work. Lifts the UI-only stub
out of app.py into submit.py and grows it into the real cheap-sync
validation pipeline. Still no Hub writes and no eval kick-off; the
handler returns "would queue here" placeholder text. Async write +
worker thread land in later chunks.

Validation gates, in order:
1. Form: zip attached, agree-checkbox ticked.
2. Zip safety: valid zip, no absolute / parent-traversing entry
names, no symlinks (Python 3.12's extractall normalises unsafe
paths silently; we reject so submitters see a clear error).
3. meta.json schema: required keys, types, agree_to_publish is
literal true, notes within the 500-char cap. meta.json is the
source of truth for submitter / submission identity; the other
form fields are advisory for now and kept only so the gr.click
signature stays stable.
4. Fixture set: top-level dirs in the zip equal the fixture set
reported by cadgenbench.common.paths.data_inputs_dir(), no
missing, no extras.
5. STEP parseability: each <fixture>/output.step loads via the new
cadgenbench parse_step() helper. Per-fixture validity (BRepCheck,
watertight, mesh-manifold) stays the evaluator's job, where
class-3 ("submission accepted, fixture invalid, scored zero")
is the right failure mode.

submit.py is side-effect-free at this point so the validation logic
can be shaken out against real uploads without committing rows to
results.jsonl. Sanity-checked locally against 13 scenarios (happy
path, missing/extra fixtures, corrupted/empty STEPs, missing/bad
meta.json, oversized notes, zip-slip, bad-zip) before pushing.

Dockerfile bumps CADGENBENCH_SHA to d88e39c to pull in the new
cadgenbench.common.validity.parse_step (separate commit on the
benchmark repo). The cadgenbench layer rebuild costs ~3 min cold.

Files changed (3) hide show
  1. Dockerfile +1 -1
  2. app.py +7 -34
  3. submit.py +261 -0
Dockerfile CHANGED
@@ -44,7 +44,7 @@ RUN pip install --no-cache-dir playwright \
44
 
45
  # cadgenbench from the Public GitHub repo, pinned to a commit. Bumping
46
  # CADGENBENCH_SHA is the one-line path to picking up a new cadgenbench.
47
- ARG CADGENBENCH_SHA=ed31274
48
  RUN pip install --no-cache-dir \
49
  "cadgenbench @ git+https://github.com/huggingface/cadgenbench.git@${CADGENBENCH_SHA}"
50
 
 
44
 
45
  # cadgenbench from the Public GitHub repo, pinned to a commit. Bumping
46
  # CADGENBENCH_SHA is the one-line path to picking up a new cadgenbench.
47
+ ARG CADGENBENCH_SHA=d88e39c
48
  RUN pip install --no-cache-dir \
49
  "cadgenbench @ git+https://github.com/huggingface/cadgenbench.git@${CADGENBENCH_SHA}"
50
 
app.py CHANGED
@@ -1,13 +1,10 @@
1
  """CADGenBench Leaderboard Space - Gradio UI assembly.
2
 
3
- Read path lives in :mod:`leaderboard`. The submit handler is a UI-only
4
- stub here; the real validation + async eval lands in :mod:`submit` as
5
- part of Step 6 (E).
6
  """
7
  from __future__ import annotations
8
 
9
- from pathlib import Path
10
-
11
  import gradio as gr
12
 
13
  from leaderboard import (
@@ -15,32 +12,7 @@ from leaderboard import (
15
  HF_SUBMISSIONS_REPO,
16
  load_leaderboard,
17
  )
18
-
19
-
20
- def handle_submit(
21
- zip_file,
22
- submission_name: str,
23
- submitter: str,
24
- agent_url: str,
25
- notes: str,
26
- agree: bool,
27
- ) -> str:
28
- if zip_file is None:
29
- return "**Error:** please attach a submission zip."
30
- if not submission_name.strip():
31
- return "**Error:** please fill in the Submission name."
32
- if not submitter.strip():
33
- return "**Error:** please fill in your Submitter name."
34
- if not agree:
35
- return "**Error:** you must agree to publish before submitting."
36
-
37
- name = Path(zip_file.name).name
38
- return (
39
- f"Received `{name}` - submission `{submission_name}` by `{submitter}`.\n\n"
40
- f"_Evaluation is not wired yet (Step 6 of the build plan). Once it "
41
- f"is, this submission will run the CPU eval inline and append a row "
42
- f"to `{HF_SUBMISSIONS_REPO}`._"
43
- )
44
 
45
 
46
  ABOUT_MD = f"""## About
@@ -102,9 +74,10 @@ stack, put it here or in `notes`.
102
  and stripped to a single line. Shown in the per-submission detail view,
103
  not in the main leaderboard table.
104
 
105
- The Space runs the CPU eval inline and appends a row to
106
- `{HF_SUBMISSIONS_REPO}`. You can fill the fields below to override
107
- `meta.json` for a quick test.
 
108
  """
109
  )
110
  zip_in = gr.File(label="Submission ZIP", file_types=[".zip"])
 
1
  """CADGenBench Leaderboard Space - Gradio UI assembly.
2
 
3
+ Read path lives in :mod:`leaderboard`. Submit-tab validation lives in
4
+ :mod:`submit`. Both are wired into the Gradio app below.
 
5
  """
6
  from __future__ import annotations
7
 
 
 
8
  import gradio as gr
9
 
10
  from leaderboard import (
 
12
  HF_SUBMISSIONS_REPO,
13
  load_leaderboard,
14
  )
15
+ from submit import handle_submit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  ABOUT_MD = f"""## About
 
74
  and stripped to a single line. Shown in the per-submission detail view,
75
  not in the main leaderboard table.
76
 
77
+ `meta.json` inside the zip is the source of truth for all five
78
+ submission fields. The form below ticks the "agree to publish" gate
79
+ and lets you smoke-test the UI; the other text fields aren't read by
80
+ the handler today.
81
  """
82
  )
83
  zip_in = gr.File(label="Submission ZIP", file_types=[".zip"])
submit.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Submit-tab handler for the CADGenBench leaderboard Space.
2
+
3
+ Step 6 (E) chunk 2: the cheap-sync validation pipeline. The handler
4
+ validates the upload and returns a placeholder message describing the
5
+ submission it would have queued. No Hub writes, no eval kick-off, no
6
+ background work. The async write + worker thread land in later chunks
7
+ of Step 6 (E); see ``space-setup/step-6e-async.md``.
8
+
9
+ Validation gates, in order:
10
+
11
+ 1. Form-level: a file was attached, the agree checkbox is ticked.
12
+ 2. Zip safety: parseable as a zip, no absolute / parent-traversing
13
+ entry names, no symlinks.
14
+ 3. ``meta.json`` schema: required keys present, types sane,
15
+ ``agree_to_publish`` is literally ``true``, ``notes`` is non-empty
16
+ when present and within the per-submission cap. Single source of
17
+ truth for submitter / submission identity; the form fields above
18
+ are advisory for chunk 2 and ignored except for the agree gate.
19
+ 4. Fixture-set match: the set of folders inside the zip equals the
20
+ set of fixture directories in :func:`cadgenbench.common.paths.data_inputs_dir`
21
+ (no missing, no extras).
22
+ 5. STEP parseability: each ``<fixture>/output.step`` loads as STEP
23
+ geometry. Per-fixture validity (watertight, manifold, etc) is
24
+ *not* checked here, that's the evaluator's job and contributes to
25
+ the per-fixture score; this gate only rejects "not actually STEP".
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import tempfile
32
+ import zipfile
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from cadgenbench.common.paths import data_inputs_dir
38
+ from cadgenbench.common.validity import parse_step
39
+
40
+ NOTES_MAX_CHARS = 500
41
+ REQUIRED_META_KEYS: tuple[str, ...] = (
42
+ "submitter_name",
43
+ "submission_name",
44
+ "agent_url",
45
+ "notes",
46
+ "agree_to_publish",
47
+ )
48
+ SUBMISSION_ID_SLUG_MAX = 40
49
+
50
+
51
+ class _ValidationError(Exception):
52
+ """Internal sentinel that maps to a user-facing rejection message."""
53
+
54
+
55
+ def handle_submit(
56
+ zip_file,
57
+ submission_name: str, # noqa: ARG001 - kept for UI compat; meta.json wins
58
+ submitter: str, # noqa: ARG001 - kept for UI compat; meta.json wins
59
+ agent_url: str, # noqa: ARG001 - kept for UI compat; meta.json wins
60
+ notes: str, # noqa: ARG001 - kept for UI compat; meta.json wins
61
+ agree: bool,
62
+ ) -> str:
63
+ """Validate a submission upload and return a markdown UI message.
64
+
65
+ Returns one of:
66
+ - An error string starting with ``**Error:**`` for form-level
67
+ rejects (missing zip, unticked agree box).
68
+ - A rejection string starting with ``**Submission rejected.**``
69
+ for any of the deeper validation gates.
70
+ - A success string starting with ``**Validation OK.**`` carrying
71
+ the would-be ``submission_id``.
72
+
73
+ The form fields other than ``agree`` are accepted to keep the
74
+ Gradio click signature stable, but ``meta.json`` inside the zip
75
+ is the source of truth for submitter / submission identity.
76
+ """
77
+ form_err = _validate_form(zip_file, agree)
78
+ if form_err is not None:
79
+ return form_err
80
+
81
+ zip_path = Path(zip_file.name)
82
+ with tempfile.TemporaryDirectory(prefix="cadgenbench-validate-") as tmp:
83
+ unpacked = Path(tmp) / "unpacked"
84
+ unpacked.mkdir()
85
+ try:
86
+ _extract_zip(zip_path, unpacked)
87
+ meta = _load_and_validate_meta(unpacked)
88
+ fixture_names = _validate_fixture_set(unpacked)
89
+ _validate_steps_parseable(unpacked, fixture_names)
90
+ except _ValidationError as e:
91
+ return f"**Submission rejected.** {e}"
92
+
93
+ submission_id = _mint_submission_id(
94
+ meta["submitter_name"], meta["submission_name"]
95
+ )
96
+ return (
97
+ f"**Validation OK.** Would queue submission `{submission_id}` "
98
+ f"(submitter: `{meta['submitter_name']}`, system: "
99
+ f"`{meta['submission_name']}`, {len(fixture_names)} fixtures).\n\n"
100
+ f"_Chunk 2 of Step 6 (E): validation only. Hub write + eval "
101
+ f"kick-off land in the next chunk._"
102
+ )
103
+
104
+
105
+ def _validate_form(zip_file, agree: bool) -> str | None:
106
+ if zip_file is None:
107
+ return "**Error:** please attach a submission zip."
108
+ if not agree:
109
+ return "**Error:** you must agree to publish before submitting."
110
+ return None
111
+
112
+
113
+ def _extract_zip(zip_path: Path, target: Path) -> None:
114
+ """Extract *zip_path* into *target* with zip-slip + symlink rejection.
115
+
116
+ Python's ``ZipFile.extractall`` since 3.12 normalises away unsafe
117
+ paths silently; we'd rather reject the upload outright so the
118
+ submitter sees a clear error instead of getting a "fixture set
119
+ mismatch" downstream because half their files were dropped.
120
+ """
121
+ try:
122
+ with zipfile.ZipFile(zip_path) as zf:
123
+ for info in zf.infolist():
124
+ if info.is_dir():
125
+ continue
126
+ name = Path(info.filename)
127
+ if name.is_absolute() or ".." in name.parts:
128
+ raise _ValidationError(
129
+ f"Zip contains an unsafe path: {info.filename!r}."
130
+ )
131
+ # Unix mode lives in the high 16 bits of external_attr;
132
+ # symlinks are mode 0o120000 (S_IFLNK).
133
+ mode = info.external_attr >> 16
134
+ if mode and (mode & 0o170000) == 0o120000:
135
+ raise _ValidationError(
136
+ f"Zip contains a symlink ({info.filename!r}); "
137
+ f"submissions must be plain files."
138
+ )
139
+ zf.extractall(target)
140
+ except zipfile.BadZipFile as e:
141
+ raise _ValidationError(f"Upload is not a valid zip file: {e}") from e
142
+
143
+
144
+ def _load_and_validate_meta(unpacked: Path) -> dict[str, Any]:
145
+ meta_path = unpacked / "meta.json"
146
+ if not meta_path.is_file():
147
+ raise _ValidationError(
148
+ "Zip is missing top-level `meta.json` (expected at the root of "
149
+ "the zip, alongside the per-fixture folders)."
150
+ )
151
+ try:
152
+ meta = json.loads(meta_path.read_text())
153
+ except json.JSONDecodeError as e:
154
+ raise _ValidationError(
155
+ f"`meta.json` is not valid JSON: {e.msg} (line {e.lineno})."
156
+ ) from e
157
+ if not isinstance(meta, dict):
158
+ raise _ValidationError(
159
+ "`meta.json` must be a JSON object at the top level."
160
+ )
161
+
162
+ missing = [k for k in REQUIRED_META_KEYS if k not in meta]
163
+ if missing:
164
+ raise _ValidationError(
165
+ f"`meta.json` is missing required key(s): {', '.join(missing)}."
166
+ )
167
+
168
+ for k in ("submitter_name", "submission_name"):
169
+ v = meta[k]
170
+ if not isinstance(v, str) or not v.strip():
171
+ raise _ValidationError(
172
+ f"`meta.json` field `{k}` must be a non-empty string."
173
+ )
174
+
175
+ for k in ("agent_url", "notes"):
176
+ v = meta[k]
177
+ if v is not None and not isinstance(v, str):
178
+ raise _ValidationError(
179
+ f"`meta.json` field `{k}` must be a string or null."
180
+ )
181
+
182
+ if meta["agree_to_publish"] is not True:
183
+ raise _ValidationError(
184
+ "`meta.json` field `agree_to_publish` must be the literal boolean "
185
+ "`true`."
186
+ )
187
+
188
+ if meta["notes"] is not None:
189
+ meta["notes"] = _normalize_notes(meta["notes"])
190
+
191
+ return meta
192
+
193
+
194
+ def _normalize_notes(raw: str) -> str:
195
+ """Collapse newlines + tabs to spaces, strip, enforce the char cap."""
196
+ one_line = re.sub(r"[\r\n\t]+", " ", raw).strip()
197
+ if len(one_line) > NOTES_MAX_CHARS:
198
+ raise _ValidationError(
199
+ f"`meta.json` field `notes` exceeds the {NOTES_MAX_CHARS}-char "
200
+ f"cap (got {len(one_line)} after stripping). Trim and resubmit."
201
+ )
202
+ return one_line
203
+
204
+
205
+ def _validate_fixture_set(unpacked: Path) -> set[str]:
206
+ """Compare unpacked top-level dirs to the inputs dataset's fixture set."""
207
+ actual = {p.name for p in unpacked.iterdir() if p.is_dir()}
208
+
209
+ try:
210
+ inputs_root = data_inputs_dir()
211
+ except Exception as e: # noqa: BLE001 - paths.py raises a few types
212
+ raise _ValidationError(
213
+ f"Server-side error resolving the fixture set "
214
+ f"({type(e).__name__}: {e})."
215
+ ) from e
216
+ expected = {p.name for p in inputs_root.iterdir() if p.is_dir()}
217
+
218
+ missing = expected - actual
219
+ extras = actual - expected
220
+ if missing or extras:
221
+ parts: list[str] = []
222
+ if missing:
223
+ parts.append(f"missing fixture(s): {', '.join(sorted(missing))}")
224
+ if extras:
225
+ parts.append(f"unexpected folder(s): {', '.join(sorted(extras))}")
226
+ raise _ValidationError(
227
+ "Fixture set does not match the dataset. " + "; ".join(parts) + "."
228
+ )
229
+ return expected
230
+
231
+
232
+ def _validate_steps_parseable(unpacked: Path, fixture_names: set[str]) -> None:
233
+ for name in sorted(fixture_names):
234
+ step = unpacked / name / "output.step"
235
+ if not step.is_file():
236
+ raise _ValidationError(
237
+ f"Fixture `{name}` is missing its `output.step` file."
238
+ )
239
+ if step.stat().st_size == 0:
240
+ raise _ValidationError(
241
+ f"Fixture `{name}` has an empty `output.step`."
242
+ )
243
+ try:
244
+ parse_step(step)
245
+ except RuntimeError as e:
246
+ raise _ValidationError(
247
+ f"Fixture `{name}` has an `output.step` that is not loadable "
248
+ f"as STEP geometry: {e}"
249
+ ) from e
250
+
251
+
252
+ def _mint_submission_id(submitter_name: str, submission_name: str) -> str:
253
+ """Build the basename used for ``submissions/<id>.zip`` and ``reports/<id>.*``."""
254
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
255
+ return f"{_slug(submitter_name)}_{_slug(submission_name)}_{ts}"
256
+
257
+
258
+ def _slug(s: str) -> str:
259
+ """Filesystem-safe slug. Lowercase, ``[a-z0-9-]``, collapsed dashes."""
260
+ cleaned = re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-").lower()
261
+ return cleaned[:SUBMISSION_ID_SLUG_MAX] or "unnamed"