Skip to content

Commit b9f1c99

Browse files
committed
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.
1 parent 83ed482 commit b9f1c99

7 files changed

Lines changed: 464 additions & 31 deletions

File tree

CLAUDE.md

Lines changed: 27 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 → S3 plot upload → 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. If `params["files"]` is set, download the listed S3 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 to S3 (`_UPLOAD_FOLDER = "evaluatePython"`)
24-
4. Return a `Result` with feedback tags: `pass`, `fail`, `hidden_fail`, `error`, `output`, `summary`
25+
4. Upload any captured matplotlib figures to S3 (`_UPLOAD_FOLDER = "evaluatePython"`)
26+
5. Return a `Result` with feedback tags: `pass`, `fail`, `hidden_fail`, `error`, `output`, `summary`
2527

2628
### Request shape
2729

@@ -90,16 +92,33 @@ 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 objects from S3 into a per-request working directory (the
98+
# subprocess's cwd) before student code runs. Data files can be read with
99+
# open()/pandas.read_csv()/etc.; .py files are importable by student code
100+
# since they're co-located with the generated script. The same files are
101+
# also available to the answer code when use_answer_as_expected_output /
102+
# use_answer_as_test_code is set. Requires the S3_FILES_BUCKET env var.
103+
{
104+
"mode": "demo",
105+
"files": [
106+
{"key": "uploads/<question-id>/data.csv", "filename": "data.csv"},
107+
{"key": "uploads/<question-id>/helper.py", "filename": "helper.py"},
108+
]
109+
}
93110
```
94111

95112
### Security model (`preview.py`)
96113

97-
`_SecurityVisitor` walks the AST before any execution and blocks:
114+
`_SecurityVisitor` walks the AST and blocks:
98115

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__`
116+
- **Modules**: `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, `shutil`, `ftplib`, `smtplib`, `ctypes`, `multiprocessing`, `threading`, `importlib`, `pickle`, `builtins`
117+
- **Builtins**: `exec`, `eval`, `compile`, `__import__`
101118
- **Dunder attribute access**: any `__attr__` style attribute
102119

120+
`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.
121+
103122
## Key commands
104123

105124
```bash
@@ -148,7 +167,8 @@ CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-li
148167
| `FUNCTION_ARGS` | `-m,evaluation_function.main` | lf_toolkit runner |
149168
| `FUNCTION_RPC_TRANSPORT` | `ipc` | lf_toolkit transport |
150169
| `LOG_LEVEL` | `debug` | Logging verbosity |
151-
| `AWS_*` / boto3 credentials | Runtime env | Required for S3 plot uploads |
170+
| `AWS_*` / boto3 credentials | Runtime env | Required for S3 plot uploads and `files` downloads |
171+
| `S3_FILES_BUCKET` | Runtime env | Bucket name (not URI) that `params["files"]` object keys are resolved against |
152172

153173
Dependencies managed via Poetry; `.venv` is created in-project (`poetry.toml`).
154174

evaluation_function/evaluation.py

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from lf_toolkit.evaluation import Result, Params
1212
from lf_toolkit.evaluation.image_upload import upload_image, ImageUploadError
1313

14+
from .s3_files import download_files, FileDownloadError
15+
1416
_TIMEOUT = 25
1517
_UPLOAD_FOLDER = "evaluatePython"
1618

@@ -30,10 +32,27 @@ def error(self, line_number, offset, text, check):
3032

3133
_PREAMBLE_TEMPLATE = """\
3234
import os as _os
35+
import io as _io
36+
import builtins as _builtins
3337
3438
_plot_dir = {plot_dir!r}
3539
_plot_idx = [0]
3640
41+
_files_dir = _os.path.realpath({files_dir!r})
42+
_real_open = _builtins.open
43+
44+
def _safe_open(file, mode="r", *args, **kwargs):
45+
if isinstance(file, (str, _os.PathLike)) and any(m in mode for m in ("w", "a", "x", "+")):
46+
_target = _os.path.realpath(_os.path.join(_files_dir, _os.fspath(file)))
47+
if _os.path.commonpath([_target, _files_dir]) == _files_dir:
48+
raise PermissionError("Provided files are read-only and cannot be modified.")
49+
return _real_open(file, mode, *args, **kwargs)
50+
51+
# pathlib.Path.open()/read_text()/write_text() call io.open(...) directly,
52+
# not the builtins.open name, so both bindings must be patched.
53+
_builtins.open = _safe_open
54+
_io.open = _safe_open
55+
3756
def _capture_plots():
3857
import sys as _sys
3958
if 'matplotlib.pyplot' not in _sys.modules:
@@ -107,19 +126,22 @@ def _add_repl_print(code: str) -> str:
107126
return code + f"\nprint(repr({ast.unparse(node)}))"
108127

109128

110-
def _run_code(code: str, stdin: str) -> tuple[str, str, bool, list[Image.Image]]:
129+
def _run_code(code: str, stdin: str, files_dir: str | None = None) -> tuple[str, str, bool, list[Image.Image]]:
111130
plot_dir = tempfile.mkdtemp()
112-
preamble = _PREAMBLE_TEMPLATE.format(plot_dir=plot_dir)
113-
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
131+
own_run_dir = files_dir is None
132+
run_dir = files_dir if files_dir is not None else tempfile.mkdtemp()
133+
preamble = _PREAMBLE_TEMPLATE.format(plot_dir=plot_dir, files_dir=run_dir)
134+
script_path = os.path.join(run_dir, "_submission.py")
135+
with open(script_path, "w") as f:
114136
f.write(preamble + "\n" + code + "\n" + _CAPTURE_CALL)
115-
tmpfile = f.name
116137
try:
117138
proc = subprocess.run(
118-
["python", tmpfile],
139+
["python", "_submission.py"],
119140
input=stdin,
120141
capture_output=True,
121142
text=True,
122143
timeout=_TIMEOUT,
144+
cwd=run_dir,
123145
env={**os.environ, "MPLBACKEND": "Agg", "MPLCONFIGDIR": "/tmp"},
124146
)
125147
images = []
@@ -133,8 +155,10 @@ def _run_code(code: str, stdin: str) -> tuple[str, str, bool, list[Image.Image]]
133155
except subprocess.TimeoutExpired:
134156
return "", "", True, []
135157
finally:
136-
os.unlink(tmpfile)
158+
os.unlink(script_path)
137159
shutil.rmtree(plot_dir, ignore_errors=True)
160+
if own_run_dir:
161+
shutil.rmtree(run_dir, ignore_errors=True)
138162

139163

140164
def _code_block(label: str, content: str) -> str:
@@ -167,9 +191,9 @@ def _check_pep8(code: str, select: list[str]) -> list[str]:
167191
return [f"Line {ln}: {text}" for ln, text in checker.report.violations]
168192

169193

170-
def _evaluate_demo(response: str, result: Result) -> Result:
194+
def _evaluate_demo(response: str, result: Result, files_dir: str | None = None) -> Result:
171195
response = _add_repl_print(response)
172-
stdout, stderr, timed_out, images = _run_code(response, "")
196+
stdout, stderr, timed_out, images = _run_code(response, "", files_dir)
173197
if timed_out:
174198
result.add_feedback("error", f"Code timed out after {_TIMEOUT}s.")
175199
elif stderr and not stdout:
@@ -181,7 +205,7 @@ def _evaluate_demo(response: str, result: Result) -> Result:
181205
return result
182206

183207

184-
def _evaluate_io(response: str, tests: list, result: Result, answer: str = "") -> Result:
208+
def _evaluate_io(response: str, tests: list, result: Result, answer: str = "", files_dir: str | None = None) -> Result:
185209
passed = 0
186210
response = _add_repl_print(response)
187211

@@ -204,12 +228,12 @@ def _evaluate_io(response: str, tests: list, result: Result, answer: str = "") -
204228
if answer:
205229
ans_code = _add_repl_print(answer)
206230
ans_run_code = (prefix + ans_code) if inject else ans_code
207-
ans_stdout, _, _, _ = _run_code(ans_run_code, run_stdin)
231+
ans_stdout, _, _, _ = _run_code(ans_run_code, run_stdin, files_dir)
208232
expected = ans_stdout.rstrip()
209233
else:
210234
expected = test.get("expected_output", "").rstrip()
211235

212-
stdout, stderr, timed_out, images = _run_code(run_code, run_stdin)
236+
stdout, stderr, timed_out, images = _run_code(run_code, run_stdin, files_dir)
213237
actual = stdout.rstrip()
214238
label = f"Hidden test {i}" if hidden else f"Test {i}"
215239

@@ -246,15 +270,15 @@ def _evaluate_io(response: str, tests: list, result: Result, answer: str = "") -
246270
return result
247271

248272

249-
def _evaluate_unit(response: str, test_code: str, result: Result) -> Result:
273+
def _evaluate_unit(response: str, test_code: str, result: Result, files_dir: str | None = None) -> Result:
250274
if not test_code.strip():
251275
result.add_feedback("error", "No test code provided for unit_test mode.")
252276
return result
253277

254278
results_path = tempfile.mktemp(suffix=".json")
255279
runner = _UNIT_RUNNER_TEMPLATE.format(results_path=results_path)
256280
combined = _add_repl_print(response) + "\n\n" + test_code + runner
257-
stdout, stderr, timed_out, _ = _run_code(combined, "")
281+
stdout, stderr, timed_out, _ = _run_code(combined, "", files_dir)
258282

259283
test_results = None
260284
try:
@@ -303,14 +327,31 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
303327
result.add_feedback("error", f"Unknown or missing mode: {mode!r}. Expected 'demo', 'io_test', or 'unit_test'.")
304328
return result
305329

306-
if mode == "demo":
307-
result = _evaluate_demo(str(response), result)
308-
elif mode == "io_test":
309-
ans = str(answer) if params.get("use_answer_as_expected_output") else ""
310-
result = _evaluate_io(str(response), params.get("tests", []), result, answer=ans)
311-
else:
312-
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
313-
result = _evaluate_unit(str(response), test_code, result)
330+
files_dir = None
331+
file_warnings: list[str] = []
332+
file_specs = params.get("files")
333+
if file_specs:
334+
files_dir = tempfile.mkdtemp()
335+
try:
336+
file_warnings = download_files(file_specs, files_dir)
337+
except FileDownloadError as e:
338+
file_warnings = [str(e)]
339+
340+
try:
341+
if mode == "demo":
342+
result = _evaluate_demo(str(response), result, files_dir)
343+
elif mode == "io_test":
344+
ans = str(answer) if params.get("use_answer_as_expected_output") else ""
345+
result = _evaluate_io(str(response), params.get("tests", []), result, answer=ans, files_dir=files_dir)
346+
else:
347+
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
348+
result = _evaluate_unit(str(response), test_code, result, files_dir=files_dir)
349+
finally:
350+
if files_dir is not None:
351+
shutil.rmtree(files_dir, ignore_errors=True)
352+
353+
for warning in file_warnings:
354+
result.add_feedback("error", warning)
314355

315356
pep8_param = params.get("pep8_feedback")
316357
if pep8_param:

evaluation_function/evaluation_test.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import unittest
23
from unittest.mock import patch
34

@@ -302,6 +303,119 @@ def test_hypothesis_fail_shows_minimal_example(self):
302303
self.assertIn("square(", result["feedback"])
303304

304305

306+
def _stub_download(content_by_filename):
307+
def fake_download(files, dest_dir):
308+
for filename, content in content_by_filename.items():
309+
with open(os.path.join(dest_dir, filename), "w") as f:
310+
f.write(content)
311+
return []
312+
return fake_download
313+
314+
315+
class TestFileDownloads(unittest.TestCase):
316+
317+
@patch("evaluation_function.evaluation.download_files")
318+
def test_demo_mode_can_read_downloaded_file(self, mock_download):
319+
mock_download.side_effect = _stub_download({"data.csv": "1,2,3"})
320+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
321+
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
322+
323+
self.assertIn("1,2,3", result["feedback"])
324+
325+
@patch("evaluation_function.evaluation.download_files")
326+
def test_io_test_downloads_once_for_all_tests(self, mock_download):
327+
mock_download.side_effect = _stub_download({"data.csv": "42"})
328+
params = {
329+
"mode": "io_test",
330+
"files": [{"key": "k", "filename": "data.csv"}],
331+
"tests": [_test("", "42\n"), _test("", "42\n")],
332+
}
333+
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
334+
335+
self.assertTrue(result["is_correct"])
336+
mock_download.assert_called_once()
337+
338+
@patch("evaluation_function.evaluation.download_files")
339+
def test_answer_code_receives_same_files(self, mock_download):
340+
mock_download.side_effect = _stub_download({"data.csv": "7"})
341+
params = {
342+
"mode": "io_test",
343+
"use_answer_as_expected_output": True,
344+
"files": [{"key": "k", "filename": "data.csv"}],
345+
"tests": [{"input": ""}],
346+
}
347+
code = "print(open('data.csv').read())"
348+
result = evaluation_function(code, code, params).to_dict()
349+
350+
self.assertTrue(result["is_correct"])
351+
352+
@patch("evaluation_function.evaluation.download_files")
353+
def test_missing_file_reported_as_warning(self, mock_download):
354+
mock_download.return_value = ["File 'data.csv' could not be found."]
355+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
356+
result = evaluation_function("print('hi')", None, params).to_dict()
357+
358+
self.assertIn("could not be found", result["feedback"])
359+
360+
@patch("evaluation_function.evaluation.download_files")
361+
def test_import_of_uploaded_module(self, mock_download):
362+
mock_download.side_effect = _stub_download({"helper.py": "def square(n):\n return n * n\n"})
363+
params = {"mode": "demo", "files": [{"key": "k", "filename": "helper.py"}]}
364+
result = evaluation_function("import helper\nprint(helper.square(4))", None, params).to_dict()
365+
366+
self.assertIn("16", result["feedback"])
367+
368+
def test_no_files_param_no_download_call(self):
369+
with patch("evaluation_function.evaluation.download_files") as mock_download:
370+
evaluation_function("print('hi')", None, {"mode": "demo"})
371+
mock_download.assert_not_called()
372+
373+
374+
class TestFileAccessSandbox(unittest.TestCase):
375+
376+
@patch("evaluation_function.evaluation.download_files")
377+
def test_read_downloaded_file_succeeds(self, mock_download):
378+
mock_download.side_effect = _stub_download({"data.csv": "hello"})
379+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
380+
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
381+
382+
self.assertIn("hello", result["feedback"])
383+
384+
@patch("evaluation_function.evaluation.download_files")
385+
def test_write_mode_to_provided_file_blocked(self, mock_download):
386+
mock_download.side_effect = _stub_download({"data.csv": "hello"})
387+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
388+
result = evaluation_function("open('data.csv', 'w')", None, params).to_dict()
389+
390+
self.assertIn("read-only", result["feedback"])
391+
392+
@patch("evaluation_function.evaluation.download_files")
393+
def test_write_new_file_in_run_dir_blocked(self, mock_download):
394+
mock_download.side_effect = _stub_download({"data.csv": "hello"})
395+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
396+
result = evaluation_function("open('output.txt', 'w')", None, params).to_dict()
397+
398+
self.assertIn("read-only", result["feedback"])
399+
400+
@patch("evaluation_function.evaluation.download_files")
401+
def test_pathlib_read_respects_sandbox(self, mock_download):
402+
mock_download.side_effect = _stub_download({"data.csv": "world"})
403+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
404+
code = "from pathlib import Path\nprint(Path('data.csv').read_text())"
405+
result = evaluation_function(code, None, params).to_dict()
406+
407+
self.assertIn("world", result["feedback"])
408+
409+
@patch("evaluation_function.evaluation.download_files")
410+
def test_pathlib_write_respects_sandbox(self, mock_download):
411+
mock_download.side_effect = _stub_download({"data.csv": "world"})
412+
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
413+
code = "from pathlib import Path\nPath('data.csv').write_text('nope')"
414+
result = evaluation_function(code, None, params).to_dict()
415+
416+
self.assertIn("read-only", result["feedback"])
417+
418+
305419
class TestPep8Feedback(unittest.TestCase):
306420

307421
def test_violations_reported(self):

evaluation_function/preview.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
_BLOCKED_MODULES = {
66
"os", "sys", "subprocess", "socket", "urllib", "http",
7-
"requests", "shutil", "pathlib", "ftplib", "smtplib",
7+
"requests", "shutil", "ftplib", "smtplib",
88
"ctypes", "multiprocessing", "threading", "importlib",
99
"pickle", "builtins",
1010
}
1111

12-
_BLOCKED_BUILTINS = {"exec", "eval", "compile", "open", "__import__"}
12+
_BLOCKED_BUILTINS = {"exec", "eval", "compile", "__import__"}
1313

1414

1515
class _SecurityVisitor(ast.NodeVisitor):

0 commit comments

Comments
 (0)