Skip to content

Commit ee80ab9

Browse files
committed
Refactor file-handling logic to unify answer_files and response_files handling
Replaces `_resolve_submission` and `_unwrap_payload` with `_collect_file_specs` to streamline file normalization and prioritization. Updates test suite to validate the new logic and modifies documentation to reflect the updated file specification and submission flow.
1 parent abb596c commit ee80ab9

3 files changed

Lines changed: 82 additions & 187 deletions

File tree

‎CLAUDE.md‎

Lines changed: 20 additions & 24 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["files"]` objects from S3 into the per-request working directory |
14+
| `s3_files.py` | Downloads `params["answer_files"]`/`params["response_files"]` objects 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. 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
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
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,32 +93,28 @@ All source lives in `evaluation_function/`:
9393
"tests": [...]
9494
}
9595

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.
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.
104108
{
105109
"mode": "demo",
106-
"files": [
110+
"answer_files": [
107111
{"url": "https://.../data.csv?X-Amz-Signature=...", "name": "data.csv"},
108112
{"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"},
109116
]
110117
}
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"}]}
122118
```
123119

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

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.
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.
133129

134130
## Key commands
135131

@@ -184,7 +180,7 @@ CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-li
184180
| `LOG_LEVEL` | `debug` | Logging verbosity |
185181
| `IMAGE_UPLOAD_BACKEND` | `gcs` | Plot upload backend in lf_toolkit (`gcs` set in Dockerfile; override to `s3` on the service to use AWS) |
186182
| `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 |
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 |
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 |
188184
| `SANDBOX_ENABLED` | `true` | Wrap the worker in shimmy's nsjail sandbox (needs `--privileged` at run time) |
189185
| `SANDBOX_SECCOMP` | `true` | nsjail seccomp syscall filter |
190186
| `SANDBOX_RO_BINDS` | `/usr:/lib:/lib64:/bin:/sbin:/etc:/app` | Read-only bind mounts visible inside the jail |

‎evaluation_function/evaluation.py‎

Lines changed: 16 additions & 41 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 currently serialises each upload entry to a string.
330+
client may serialise each upload entry to a string.
331331
"""
332332
if not isinstance(raw, (list, tuple)):
333333
return []
@@ -343,45 +343,19 @@ def _coerce_file_specs(raw: Any) -> list:
343343
return specs
344344

345345

346-
def _unwrap_payload(value: Any) -> tuple[str, list]:
347-
"""Return (code, file_specs) from a submission or answer value.
346+
def _collect_file_specs(params: Params) -> list:
347+
"""Gather the files to make available for this request.
348348
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.
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.
353354
"""
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]
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
385359

386360

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

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

396371
violations = check_code_safety(code)
397372
if violations:
@@ -411,10 +386,10 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
411386
if mode == "demo":
412387
result = _evaluate_demo(code, result, files_dir)
413388
elif mode == "io_test":
414-
ans = _answer_code(answer) if params.get("use_answer_as_expected_output") else ""
389+
ans = str(answer) if params.get("use_answer_as_expected_output") else ""
415390
result = _evaluate_io(code, params.get("tests", []), result, answer=ans, files_dir=files_dir)
416391
else:
417-
test_code = _answer_code(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
392+
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
418393
result = _evaluate_unit(code, test_code, result, files_dir=files_dir)
419394

420395
for warning in file_warnings:

0 commit comments

Comments
 (0)