Skip to content

Commit ea9c064

Browse files
committed
Revert "Refactor file-handling logic to unify answer_files and response_files handling"
This reverts commit ee80ab9.
1 parent ee80ab9 commit ea9c064

3 files changed

Lines changed: 187 additions & 82 deletions

File tree

‎CLAUDE.md‎

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ All source lives in `evaluation_function/`:
1111
| `main.py` | IPC server entry point; registers `evaluation_function` and `preview_function` with lf_toolkit |
1212
| `evaluation.py` | Core evaluation pipeline: security check → subprocess execution → output comparison → plot upload (GCS/S3 via lf_toolkit) → structured feedback |
1313
| `preview.py` | AST-based pre-execution security validator (`_SecurityVisitor`) |
14-
| `s3_files.py` | Downloads `params["answer_files"]`/`params["response_files"]` objects into the per-request working directory |
14+
| `s3_files.py` | Downloads `params["files"]` objects from S3 into the per-request working directory |
1515
| `dev.py` | CLI wrapper for local manual testing |
1616

1717
### Evaluation pipeline (`evaluation.py`)
1818

1919
1. Run AST security check on student code
20-
2. Gather file specs from params via `_collect_file_specs`: `params["answer_files"]` (teacher, plus legacy `params["files"]`) and `params["response_files"]` (student); teacher files win on a name clash. The response and answer are plain code strings. If any files are listed, download the listed objects once into a per-request working directory (see `s3_files.py`), used as the subprocess `cwd` for every run in this request
20+
2. Resolve the submission into `(code, file_specs)` via `_resolve_submission`: the response may be a bare code string, or a `{"code", "files"}` object (or JSON string of one) as sent by the LF web client's upload widget. If `file_specs` (from `response["files"]`, else `params["files"]`) is non-empty, download the listed objects once into a per-request working directory (see `s3_files.py`), used as the subprocess `cwd` for every run in this request
2121
3. Dispatch by `params["mode"]` (required):
2222
- **`demo`**: execute code with no stdin; return stdout/plots as `output` feedback (no pass/fail)
2323
- **`io_test`**: for each test in `params["tests"]`, execute with `test["input"]` as stdin and compare stdout against `test["expected_output"]`; upload matplotlib plots on pass or fail
@@ -93,28 +93,32 @@ All source lives in `evaluation_function/`:
9393
"tests": [...]
9494
}
9595

96-
# answer_files / response_files — optional, work with all modes
97-
# answer_files: the teacher's files, saved in the response area's gradeParams.
98-
# response_files: the student's uploads, sent with each check as additionalParams.
99-
# params["files"] is still accepted as a legacy alias for answer_files.
100-
# All listed files are downloaded into one per-request working directory
101-
# (the subprocess's cwd) before student code runs, given a pre-signed or
102-
# public HTTPS URL per file (fetched directly with a GET — no AWS
103-
# credentials needed here). On a name clash the teacher's file wins. Entries
104-
# may be dicts or JSON strings of dicts. Data files can be read with
105-
# open()/pandas.read_csv()/etc.; .py files are importable since they're
106-
# co-located with the generated script. The same files are also available
107-
# to the answer code when use_answer_as_expected_output/use_answer_as_test_code is set.
96+
# files — optional, works with all modes
97+
# Downloads files into a per-request working directory (the subprocess's
98+
# cwd) before student code runs, given a pre-signed or public HTTPS URL per
99+
# file (fetched directly with a GET — no AWS credentials needed here). Data
100+
# files can be read with open()/pandas.read_csv()/etc.; .py files are
101+
# importable by student code since they're co-located with the generated
102+
# script. The same files are also available to the answer code when
103+
# use_answer_as_expected_output/use_answer_as_test_code is set.
108104
{
109105
"mode": "demo",
110-
"answer_files": [
106+
"files": [
111107
{"url": "https://.../data.csv?X-Amz-Signature=...", "name": "data.csv"},
112108
{"url": "https://.../helper.py?X-Amz-Signature=...", "name": "helper.py"},
113-
],
114-
"response_files": [
115-
{"url": "https://.../mine.csv?X-Amz-Signature=...", "name": "mine.csv"},
116109
]
117110
}
111+
112+
# files in the response payload (how the LF web client sends uploads)
113+
# When the response area has a file-upload widget, the client delivers the
114+
# submission as {"code": ..., "files": [...]} (sometimes as a JSON string of
115+
# that object), with each file entry itself possibly a JSON string.
116+
# evaluation_function unpacks this: response["code"] becomes the student
117+
# code, response["files"] becomes the file list. Files in the response take
118+
# precedence over params["files"], which stays as a fallback. Entry shape is
119+
# the same {"url", "name"} as params["files"].
120+
{"code": "print(open('data.csv').read())",
121+
"files": [{"url": "https://.../data.csv?...", "name": "data.csv"}]}
118122
```
119123

120124
### Security model (`preview.py`)
@@ -125,7 +129,7 @@ All source lives in `evaluation_function/`:
125129
- **Builtins**: `exec`, `eval`, `compile`, `__import__`
126130
- **Dunder attribute access**: any `__attr__` style attribute
127131

128-
`open`/`pathlib` are intentionally **not** blocked here — they're needed to read files loaded via `params["answer_files"]`/`params["response_files"]` (see above). **Important caveat**: `preview_function` (this check) and `evaluation_function` (actual grading) are registered as two independent RPC methods in `main.py`; `evaluation.py` never calls `preview.py`. This check only powers editor-time linting feedback — it does not gate what code can do at grading time. The real, load-bearing control for file access is a runtime-injected restricted `open`/`io.open` in `evaluation.py`'s subprocess preamble (`_safe_open`), which blocks *write* access to anything inside the per-run files directory. It is not a hard sandbox boundary — since `os`/`subprocess` remain fully importable and runnable at grading time regardless of this feature, a student can bypass file restrictions entirely via `os`. Treat this as scoping the intended file-access path, not as isolation.
132+
`open`/`pathlib` are intentionally **not** blocked here — they're needed to read files loaded via `params["files"]` (see above). **Important caveat**: `preview_function` (this check) and `evaluation_function` (actual grading) are registered as two independent RPC methods in `main.py`; `evaluation.py` never calls `preview.py`. This check only powers editor-time linting feedback — it does not gate what code can do at grading time. The real, load-bearing control for file access is a runtime-injected restricted `open`/`io.open` in `evaluation.py`'s subprocess preamble (`_safe_open`), which blocks *write* access to anything inside the per-run files directory. It is not a hard sandbox boundary — since `os`/`subprocess` remain fully importable and runnable at grading time regardless of this feature, a student can bypass file restrictions entirely via `os`. Treat this as scoping the intended file-access path, not as isolation.
129133

130134
## Key commands
131135

@@ -180,7 +184,7 @@ CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-li
180184
| `LOG_LEVEL` | `debug` | Logging verbosity |
181185
| `IMAGE_UPLOAD_BACKEND` | `gcs` | Plot upload backend in lf_toolkit (`gcs` set in Dockerfile; override to `s3` on the service to use AWS) |
182186
| `GCS_BUCKET` | Runtime env | Target bucket for matplotlib plot uploads; set per-environment on the Cloud Run service. Auth is via the runtime service account (ADC) — no keys |
183-
| `AWS_*` / `S3_BUCKET_URI` | Runtime env | Only for the legacy S3 plot-upload backend (`IMAGE_UPLOAD_BACKEND=s3`). Not needed for `answer_files` / `response_files` downloads — those are plain HTTPS GETs from a pre-signed/public URL |
187+
| `AWS_*` / `S3_BUCKET_URI` | Runtime env | Only for the legacy S3 plot-upload backend (`IMAGE_UPLOAD_BACKEND=s3`). Not needed for `params["files"]` / response-payload file downloads — those are plain HTTPS GETs from a pre-signed/public URL |
184188
| `SANDBOX_ENABLED` | `true` | Wrap the worker in shimmy's nsjail sandbox (needs `--privileged` at run time) |
185189
| `SANDBOX_SECCOMP` | `true` | nsjail seccomp syscall filter |
186190
| `SANDBOX_RO_BINDS` | `/usr:/lib:/lib64:/bin:/sbin:/etc:/app` | Read-only bind mounts visible inside the jail |

‎evaluation_function/evaluation.py‎

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def _coerce_file_specs(raw: Any) -> list:
327327
"""Normalise a raw files value into a list of {url, name} dicts.
328328
329329
Entries may already be dicts, or JSON-encoded strings — the LF web
330-
client may serialise each upload entry to a string.
330+
client currently serialises each upload entry to a string.
331331
"""
332332
if not isinstance(raw, (list, tuple)):
333333
return []
@@ -343,19 +343,45 @@ def _coerce_file_specs(raw: Any) -> list:
343343
return specs
344344

345345

346-
def _collect_file_specs(params: Params) -> list:
347-
"""Gather the files to make available for this request.
346+
def _unwrap_payload(value: Any) -> tuple[str, list]:
347+
"""Return (code, file_specs) from a submission or answer value.
348348
349-
The LF client passes the teacher's files as params["answer_files"]
350-
(saved in the response area's grade params) and the student's uploads
351-
as params["response_files"] (sent with each check). params["files"] is
352-
accepted as a legacy alias for answer_files. All files land in one
353-
working directory; on a name clash the teacher's file wins.
349+
When file upload is enabled, the LF web client delivers the value as
350+
{"code": ..., "files": [...]} (sometimes as a JSON string of that
351+
object) rather than a bare code string. A plain string is returned
352+
unchanged with no files.
354353
"""
355-
teacher = _coerce_file_specs(params.get("answer_files")) + _coerce_file_specs(params.get("files"))
356-
student = _coerce_file_specs(params.get("response_files"))
357-
teacher_names = {spec.get("name") for spec in teacher}
358-
return [spec for spec in student if spec.get("name") not in teacher_names] + teacher
354+
payload = value
355+
if isinstance(payload, str):
356+
try:
357+
parsed = json.loads(payload)
358+
except (ValueError, TypeError):
359+
parsed = None
360+
if isinstance(parsed, dict) and ("code" in parsed or "files" in parsed):
361+
payload = parsed
362+
363+
if isinstance(payload, dict):
364+
return str(payload.get("code") or ""), _coerce_file_specs(payload.get("files"))
365+
if isinstance(payload, str):
366+
return payload, []
367+
return str(payload), []
368+
369+
370+
def _resolve_submission(response: Any, params: Params) -> tuple[str, list]:
371+
"""Split the submission into (code, file_specs).
372+
373+
Files listed in the response take precedence; params["files"] is the
374+
fallback.
375+
"""
376+
code, response_files = _unwrap_payload(response)
377+
file_specs = response_files or _coerce_file_specs(params.get("files"))
378+
return code, file_specs
379+
380+
381+
def _answer_code(answer: Any) -> str:
382+
"""The code string from the answer field, unwrapping a {code, files}
383+
payload the same way the submission is unwrapped."""
384+
return _unwrap_payload(answer)[0]
359385

360386

361387
def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
@@ -365,8 +391,7 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
365391
result.add_feedback("error", f"Unknown or missing mode: {mode!r}. Expected 'demo', 'io_test', or 'unit_test'.")
366392
return result
367393

368-
code = str(response)
369-
file_specs = _collect_file_specs(params)
394+
code, file_specs = _resolve_submission(response, params)
370395

371396
violations = check_code_safety(code)
372397
if violations:
@@ -386,10 +411,10 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
386411
if mode == "demo":
387412
result = _evaluate_demo(code, result, files_dir)
388413
elif mode == "io_test":
389-
ans = str(answer) if params.get("use_answer_as_expected_output") else ""
414+
ans = _answer_code(answer) if params.get("use_answer_as_expected_output") else ""
390415
result = _evaluate_io(code, params.get("tests", []), result, answer=ans, files_dir=files_dir)
391416
else:
392-
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
417+
test_code = _answer_code(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
393418
result = _evaluate_unit(code, test_code, result, files_dir=files_dir)
394419

395420
for warning in file_warnings:

0 commit comments

Comments
 (0)