Skip to content

Commit 580083e

Browse files
authored
Merge pull request #1 from lambda-feedback/feature/file_upload
Feature/file upload
1 parent 645b04a commit 580083e

8 files changed

Lines changed: 701 additions & 43 deletions

File tree

‎CLAUDE.md‎

Lines changed: 34 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["answer_files"]`/`params["response_files"]` objects 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. 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
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,41 @@ All source lives in `evaluation_function/`:
9092
"pep8_feedback": ["E225", "E231"], # custom rule list
9193
"tests": [...]
9294
}
95+
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.
108+
{
109+
"mode": "demo",
110+
"answer_files": [
111+
{"url": "https://.../data.csv?X-Amz-Signature=...", "name": "data.csv"},
112+
{"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"},
116+
]
117+
}
93118
```
94119

95120
### Security model (`preview.py`)
96121

97-
`_SecurityVisitor` walks the AST before any execution and blocks:
122+
`_SecurityVisitor` walks the AST and blocks:
98123

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

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.
129+
103130
## Key commands
104131

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

‎evaluation_function/evaluation.py‎

Lines changed: 115 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,97 @@ 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 may serialise 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 _collect_file_specs(params: Params) -> list:
347+
"""Gather the files to make available for this request.
348+
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.
354+
"""
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
359+
360+
301361
def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
302362
result = Result()
303363
mode = params.get("mode")
304364
if mode not in ("demo", "io_test", "unit_test"):
305365
result.add_feedback("error", f"Unknown or missing mode: {mode!r}. Expected 'demo', 'io_test', or 'unit_test'.")
306366
return result
307367

308-
violations = check_code_safety(str(response))
368+
code = str(response)
369+
file_specs = _collect_file_specs(params)
370+
371+
violations = check_code_safety(code)
309372
if violations:
310373
result.add_feedback(
311374
"error",
312375
"Unsafe code detected -- not executed:\n" + "\n".join(f"- {v}" for v in violations),
313376
)
314377
return result
315378

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)
379+
files_dir = None
380+
try:
381+
file_warnings: list[str] = []
382+
if file_specs:
383+
files_dir = tempfile.mkdtemp()
384+
file_warnings = download_files(file_specs, files_dir)
385+
386+
if mode == "demo":
387+
result = _evaluate_demo(code, result, files_dir)
388+
elif mode == "io_test":
389+
ans = str(answer) if params.get("use_answer_as_expected_output") else ""
390+
result = _evaluate_io(code, params.get("tests", []), result, answer=ans, files_dir=files_dir)
331391
else:
332-
body = "No style issues found."
333-
result.add_feedback("style", body)
392+
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
393+
result = _evaluate_unit(code, test_code, result, files_dir=files_dir)
394+
395+
for warning in file_warnings:
396+
result.add_feedback("error", warning)
397+
398+
pep8_param = params.get("pep8_feedback")
399+
if pep8_param:
400+
select = pep8_param if isinstance(pep8_param, list) else _PEP8_SELECT
401+
violations = _check_pep8(code, select)
402+
if violations:
403+
body = "Style suggestions (PEP8):\n" + "\n".join(f"- {v}" for v in violations)
404+
else:
405+
body = "No style issues found."
406+
result.add_feedback("style", body)
407+
except Exception:
408+
traceback.print_exc(file=sys.stderr)
409+
result = Result()
410+
result.add_feedback(
411+
"error",
412+
"An unexpected internal error occurred while evaluating this submission. "
413+
"Please contact a course organizer.",
414+
)
415+
finally:
416+
if files_dir is not None:
417+
shutil.rmtree(files_dir, ignore_errors=True)
334418

335419
return result

0 commit comments

Comments
 (0)