Skip to content

Commit d7fe44c

Browse files
m-messerclaude
andauthored
Feature/file upload (#2)
* Add S3 file download support to evaluation pipeline Implements `params["files"]` feature to allow downloading S3 objects into a per-request working directory. Updates `evaluation.py` and security logic to enable read-only file access. Introduces `s3_files.py` for managing S3 interactions and accompanying unit tests in `s3_files_test.py`. Expands documentation in `CLAUDE.md` and adds integration tests to verify functionality. * Replace S3-based file downloads with HTTPS support Replaces S3 bucket-based file download logic with direct downloads via HTTPS URLs, removing AWS dependency. Updates `evaluation.py`, rewrites `s3_files.py` to handle streaming downloads securely, and adjusts tests and documentation (`CLAUDE.md`, `evaluation_test.py`, `s3_files_test.py`) to reflect the changes. * Update Dockerfile to use the latest Python base image Switches from a version-specific base image to the `latest` tag for the evaluation function, ensuring up-to-date dependencies and compatibility. * Refactor file handling to replace `filename` with `name` key and improve validation Standardizes file specification by replacing the `filename` key with `name`. Enhances validation to handle malformed or incomplete file specifications gracefully. Adds error handling to catch unexpected exceptions and ensures temporary directories are cleaned up. Updates tests and documentation to reflect changes. * Support file uploads via response payload and prioritize over `params["files"]` Adds `_resolve_submission` to handle responses containing code and file specifications in `"code"` and `"files"` keys. Updates `evaluation.py` to normalize and prioritize file sources from the response payload over `params["files"]`. Enhances tests to validate this logic. Updates documentation to reflect the new behavior. * Add support for unwrapping `{"code", "files"}` payloads in answers and submissions Refactors `_resolve_submission` into `_unwrap_payload` and updates answer handling to support the `{"code", "files"}` structure. Adds unit tests for evaluation behavior with structured answers. * 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. * Revert "Refactor file-handling logic to unify `answer_files` and `response_files` handling" This reverts commit ee80ab9. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 80774bc commit d7fe44c

8 files changed

Lines changed: 806 additions & 43 deletions

File tree

‎CLAUDE.md‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,19 @@ 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 |
1415
| `dev.py` | CLI wrapper for local manual testing |
1516

1617
### Evaluation pipeline (`evaluation.py`)
1718

1819
1. Run AST security check on student code
19-
2. Dispatch by `params["mode"]` (required):
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
21+
3. Dispatch by `params["mode"]` (required):
2022
- **`demo`**: execute code with no stdin; return stdout/plots as `output` feedback (no pass/fail)
2123
- **`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
2224
- **`unit_test`**: append `params["test_code"]` + unit-runner harness to student code; execute once; parse JSON results; supports plain `test_*` functions, `unittest.TestCase` subclasses, and Hypothesis-based tests
23-
3. Upload any captured matplotlib figures via `lf_toolkit` `upload_image` (`_UPLOAD_FOLDER = "evaluatePython"`); backend is GCS or S3 per `IMAGE_UPLOAD_BACKEND`
24-
4. Return a `Result` with feedback tags: `pass`, `fail`, `hidden_fail`, `error`, `output`, `summary`
25+
4. Upload any captured matplotlib figures via `lf_toolkit` `upload_image` (`_UPLOAD_FOLDER = "evaluatePython"`); backend is GCS or S3 per `IMAGE_UPLOAD_BACKEND`
26+
5. Return a `Result` with feedback tags: `pass`, `fail`, `hidden_fail`, `error`, `output`, `summary`
2527

2628
### Request shape
2729

@@ -90,16 +92,45 @@ All source lives in `evaluation_function/`:
9092
"pep8_feedback": ["E225", "E231"], # custom rule list
9193
"tests": [...]
9294
}
95+
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.
104+
{
105+
"mode": "demo",
106+
"files": [
107+
{"url": "https://.../data.csv?X-Amz-Signature=...", "name": "data.csv"},
108+
{"url": "https://.../helper.py?X-Amz-Signature=...", "name": "helper.py"},
109+
]
110+
}
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"}]}
93122
```
94123

95124
### Security model (`preview.py`)
96125

97-
`_SecurityVisitor` walks the AST before any execution and blocks:
126+
`_SecurityVisitor` walks the AST and blocks:
98127

99-
- **Modules**: `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, `shutil`, `pathlib`, `ftplib`, `smtplib`, `ctypes`, `multiprocessing`, `threading`, `importlib`, `pickle`, `builtins`
100-
- **Builtins**: `exec`, `eval`, `compile`, `open`, `__import__`
128+
- **Modules**: `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, `shutil`, `ftplib`, `smtplib`, `ctypes`, `multiprocessing`, `threading`, `importlib`, `pickle`, `builtins`
129+
- **Builtins**: `exec`, `eval`, `compile`, `__import__`
101130
- **Dunder attribute access**: any `__attr__` style attribute
102131

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.
133+
103134
## Key commands
104135

105136
```bash
@@ -153,7 +184,7 @@ CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-li
153184
| `LOG_LEVEL` | `debug` | Logging verbosity |
154185
| `IMAGE_UPLOAD_BACKEND` | `gcs` | Plot upload backend in lf_toolkit (`gcs` set in Dockerfile; override to `s3` on the service to use AWS) |
155186
| `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 |
156-
| `AWS_*` / `S3_BUCKET_URI` | Runtime env | Only for the legacy S3 plot-upload backend (`IMAGE_UPLOAD_BACKEND=s3`) |
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 |
157188
| `SANDBOX_ENABLED` | `true` | Wrap the worker in shimmy's nsjail sandbox (needs `--privileged` at run time) |
158189
| `SANDBOX_SECCOMP` | `true` | nsjail seccomp syscall filter |
159190
| `SANDBOX_RO_BINDS` | `/usr:/lib:/lib64:/bin:/sbin:/etc:/app` | Read-only bind mounts visible inside the jail |

‎evaluation_function/evaluation.py‎

Lines changed: 140 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
import os
44
import shutil
55
import subprocess
6+
import sys
67
import tempfile
8+
import traceback
79
from typing import Any
810

911
import pycodestyle
1012
from PIL import Image
1113
from lf_toolkit.evaluation import Result, Params
1214
from lf_toolkit.evaluation.image_upload import upload_image, ImageUploadError
1315

16+
from .s3_files import download_files
1417
from .security import check_code_safety
1518

1619
_TIMEOUT = 25
@@ -32,10 +35,27 @@ def error(self, line_number, offset, text, check):
3235

3336
_PREAMBLE_TEMPLATE = """\
3437
import os as _os
38+
import io as _io
39+
import builtins as _builtins
3540
3641
_plot_dir = {plot_dir!r}
3742
_plot_idx = [0]
3843
44+
_files_dir = _os.path.realpath({files_dir!r})
45+
_real_open = _builtins.open
46+
47+
def _safe_open(file, mode="r", *args, **kwargs):
48+
if isinstance(file, (str, _os.PathLike)) and any(m in mode for m in ("w", "a", "x", "+")):
49+
_target = _os.path.realpath(_os.path.join(_files_dir, _os.fspath(file)))
50+
if _os.path.commonpath([_target, _files_dir]) == _files_dir:
51+
raise PermissionError("Provided files are read-only and cannot be modified.")
52+
return _real_open(file, mode, *args, **kwargs)
53+
54+
# pathlib.Path.open()/read_text()/write_text() call io.open(...) directly,
55+
# not the builtins.open name, so both bindings must be patched.
56+
_builtins.open = _safe_open
57+
_io.open = _safe_open
58+
3959
def _capture_plots():
4060
import sys as _sys
4161
if 'matplotlib.pyplot' not in _sys.modules:
@@ -109,19 +129,22 @@ def _add_repl_print(code: str) -> str:
109129
return code + f"\nprint(repr({ast.unparse(node)}))"
110130

111131

112-
def _run_code(code: str, stdin: str) -> tuple[str, str, bool, list[Image.Image]]:
132+
def _run_code(code: str, stdin: str, files_dir: str | None = None) -> tuple[str, str, bool, list[Image.Image]]:
113133
plot_dir = tempfile.mkdtemp()
114-
preamble = _PREAMBLE_TEMPLATE.format(plot_dir=plot_dir)
115-
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
134+
own_run_dir = files_dir is None
135+
run_dir = files_dir if files_dir is not None else tempfile.mkdtemp()
136+
preamble = _PREAMBLE_TEMPLATE.format(plot_dir=plot_dir, files_dir=run_dir)
137+
script_path = os.path.join(run_dir, "_submission.py")
138+
with open(script_path, "w") as f:
116139
f.write(preamble + "\n" + code + "\n" + _CAPTURE_CALL)
117-
tmpfile = f.name
118140
try:
119141
proc = subprocess.run(
120-
["python", tmpfile],
142+
["python", "_submission.py"],
121143
input=stdin,
122144
capture_output=True,
123145
text=True,
124146
timeout=_TIMEOUT,
147+
cwd=run_dir,
125148
env={**os.environ, "MPLBACKEND": "Agg", "MPLCONFIGDIR": "/tmp"},
126149
)
127150
images = []
@@ -135,8 +158,10 @@ def _run_code(code: str, stdin: str) -> tuple[str, str, bool, list[Image.Image]]
135158
except subprocess.TimeoutExpired:
136159
return "", "", True, []
137160
finally:
138-
os.unlink(tmpfile)
161+
os.unlink(script_path)
139162
shutil.rmtree(plot_dir, ignore_errors=True)
163+
if own_run_dir:
164+
shutil.rmtree(run_dir, ignore_errors=True)
140165

141166

142167
def _code_block(label: str, content: str) -> str:
@@ -169,9 +194,9 @@ def _check_pep8(code: str, select: list[str]) -> list[str]:
169194
return [f"Line {ln}: {text}" for ln, text in checker.report.violations]
170195

171196

172-
def _evaluate_demo(response: str, result: Result) -> Result:
197+
def _evaluate_demo(response: str, result: Result, files_dir: str | None = None) -> Result:
173198
response = _add_repl_print(response)
174-
stdout, stderr, timed_out, images = _run_code(response, "")
199+
stdout, stderr, timed_out, images = _run_code(response, "", files_dir)
175200
if timed_out:
176201
result.add_feedback("error", f"Code timed out after {_TIMEOUT}s.")
177202
elif stderr and not stdout:
@@ -183,7 +208,7 @@ def _evaluate_demo(response: str, result: Result) -> Result:
183208
return result
184209

185210

186-
def _evaluate_io(response: str, tests: list, result: Result, answer: str = "") -> Result:
211+
def _evaluate_io(response: str, tests: list, result: Result, answer: str = "", files_dir: str | None = None) -> Result:
187212
passed = 0
188213
response = _add_repl_print(response)
189214

@@ -206,12 +231,12 @@ def _evaluate_io(response: str, tests: list, result: Result, answer: str = "") -
206231
if answer:
207232
ans_code = _add_repl_print(answer)
208233
ans_run_code = (prefix + ans_code) if inject else ans_code
209-
ans_stdout, _, _, _ = _run_code(ans_run_code, run_stdin)
234+
ans_stdout, _, _, _ = _run_code(ans_run_code, run_stdin, files_dir)
210235
expected = ans_stdout.rstrip()
211236
else:
212237
expected = test.get("expected_output", "").rstrip()
213238

214-
stdout, stderr, timed_out, images = _run_code(run_code, run_stdin)
239+
stdout, stderr, timed_out, images = _run_code(run_code, run_stdin, files_dir)
215240
actual = stdout.rstrip()
216241
label = f"Hidden test {i}" if hidden else f"Test {i}"
217242

@@ -248,15 +273,15 @@ def _evaluate_io(response: str, tests: list, result: Result, answer: str = "") -
248273
return result
249274

250275

251-
def _evaluate_unit(response: str, test_code: str, result: Result) -> Result:
276+
def _evaluate_unit(response: str, test_code: str, result: Result, files_dir: str | None = None) -> Result:
252277
if not test_code.strip():
253278
result.add_feedback("error", "No test code provided for unit_test mode.")
254279
return result
255280

256281
results_path = tempfile.mktemp(suffix=".json")
257282
runner = _UNIT_RUNNER_TEMPLATE.format(results_path=results_path)
258283
combined = _add_repl_print(response) + "\n\n" + test_code + runner
259-
stdout, stderr, timed_out, _ = _run_code(combined, "")
284+
stdout, stderr, timed_out, _ = _run_code(combined, "", files_dir)
260285

261286
test_results = None
262287
try:
@@ -298,38 +323,122 @@ def _evaluate_unit(response: str, test_code: str, result: Result) -> Result:
298323
return result
299324

300325

326+
def _coerce_file_specs(raw: Any) -> list:
327+
"""Normalise a raw files value into a list of {url, name} dicts.
328+
329+
Entries may already be dicts, or JSON-encoded strings — the LF web
330+
client currently serialises each upload entry to a string.
331+
"""
332+
if not isinstance(raw, (list, tuple)):
333+
return []
334+
specs = []
335+
for entry in raw:
336+
if isinstance(entry, str):
337+
try:
338+
entry = json.loads(entry)
339+
except (ValueError, TypeError):
340+
continue
341+
if isinstance(entry, dict):
342+
specs.append(entry)
343+
return specs
344+
345+
346+
def _unwrap_payload(value: Any) -> tuple[str, list]:
347+
"""Return (code, file_specs) from a submission or answer value.
348+
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.
353+
"""
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]
385+
386+
301387
def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
302388
result = Result()
303389
mode = params.get("mode")
304390
if mode not in ("demo", "io_test", "unit_test"):
305391
result.add_feedback("error", f"Unknown or missing mode: {mode!r}. Expected 'demo', 'io_test', or 'unit_test'.")
306392
return result
307393

308-
violations = check_code_safety(str(response))
394+
code, file_specs = _resolve_submission(response, params)
395+
396+
violations = check_code_safety(code)
309397
if violations:
310398
result.add_feedback(
311399
"error",
312400
"Unsafe code detected -- not executed:\n" + "\n".join(f"- {v}" for v in violations),
313401
)
314402
return result
315403

316-
if mode == "demo":
317-
result = _evaluate_demo(str(response), result)
318-
elif mode == "io_test":
319-
ans = str(answer) if params.get("use_answer_as_expected_output") else ""
320-
result = _evaluate_io(str(response), params.get("tests", []), result, answer=ans)
321-
else:
322-
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
323-
result = _evaluate_unit(str(response), test_code, result)
324-
325-
pep8_param = params.get("pep8_feedback")
326-
if pep8_param:
327-
select = pep8_param if isinstance(pep8_param, list) else _PEP8_SELECT
328-
violations = _check_pep8(str(response), select)
329-
if violations:
330-
body = "Style suggestions (PEP8):\n" + "\n".join(f"- {v}" for v in violations)
404+
files_dir = None
405+
try:
406+
file_warnings: list[str] = []
407+
if file_specs:
408+
files_dir = tempfile.mkdtemp()
409+
file_warnings = download_files(file_specs, files_dir)
410+
411+
if mode == "demo":
412+
result = _evaluate_demo(code, result, files_dir)
413+
elif mode == "io_test":
414+
ans = _answer_code(answer) if params.get("use_answer_as_expected_output") else ""
415+
result = _evaluate_io(code, params.get("tests", []), result, answer=ans, files_dir=files_dir)
331416
else:
332-
body = "No style issues found."
333-
result.add_feedback("style", body)
417+
test_code = _answer_code(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
418+
result = _evaluate_unit(code, test_code, result, files_dir=files_dir)
419+
420+
for warning in file_warnings:
421+
result.add_feedback("error", warning)
422+
423+
pep8_param = params.get("pep8_feedback")
424+
if pep8_param:
425+
select = pep8_param if isinstance(pep8_param, list) else _PEP8_SELECT
426+
violations = _check_pep8(code, select)
427+
if violations:
428+
body = "Style suggestions (PEP8):\n" + "\n".join(f"- {v}" for v in violations)
429+
else:
430+
body = "No style issues found."
431+
result.add_feedback("style", body)
432+
except Exception:
433+
traceback.print_exc(file=sys.stderr)
434+
result = Result()
435+
result.add_feedback(
436+
"error",
437+
"An unexpected internal error occurred while evaluating this submission. "
438+
"Please contact a course organizer.",
439+
)
440+
finally:
441+
if files_dir is not None:
442+
shutil.rmtree(files_dir, ignore_errors=True)
334443

335444
return result

0 commit comments

Comments
 (0)