Skip to content

Commit 80774bc

Browse files
committed
Revert "Merge pull request #1 from lambda-feedback/feature/file_upload"
This reverts commit 580083e.
1 parent 580083e commit 80774bc

8 files changed

Lines changed: 43 additions & 701 deletions

File tree

‎CLAUDE.md‎

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

1716
### Evaluation pipeline (`evaluation.py`)
1817

1918
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
21-
3. Dispatch by `params["mode"]` (required):
19+
2. Dispatch by `params["mode"]` (required):
2220
- **`demo`**: execute code with no stdin; return stdout/plots as `output` feedback (no pass/fail)
2321
- **`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
2422
- **`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
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`
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`
2725

2826
### Request shape
2927

@@ -92,41 +90,16 @@ All source lives in `evaluation_function/`:
9290
"pep8_feedback": ["E225", "E231"], # custom rule list
9391
"tests": [...]
9492
}
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-
}
11893
```
11994

12095
### Security model (`preview.py`)
12196

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

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

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-
130103
## Key commands
131104

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

‎evaluation_function/evaluation.py‎

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

119
import pycodestyle
1210
from PIL import Image
1311
from lf_toolkit.evaluation import Result, Params
1412
from lf_toolkit.evaluation.image_upload import upload_image, ImageUploadError
1513

16-
from .s3_files import download_files
1714
from .security import check_code_safety
1815

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

3633
_PREAMBLE_TEMPLATE = """\
3734
import os as _os
38-
import io as _io
39-
import builtins as _builtins
4035
4136
_plot_dir = {plot_dir!r}
4237
_plot_idx = [0]
4338
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-
5939
def _capture_plots():
6040
import sys as _sys
6141
if 'matplotlib.pyplot' not in _sys.modules:
@@ -129,22 +109,19 @@ def _add_repl_print(code: str) -> str:
129109
return code + f"\nprint(repr({ast.unparse(node)}))"
130110

131111

132-
def _run_code(code: str, stdin: str, files_dir: str | None = None) -> tuple[str, str, bool, list[Image.Image]]:
112+
def _run_code(code: str, stdin: str) -> tuple[str, str, bool, list[Image.Image]]:
133113
plot_dir = tempfile.mkdtemp()
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:
114+
preamble = _PREAMBLE_TEMPLATE.format(plot_dir=plot_dir)
115+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
139116
f.write(preamble + "\n" + code + "\n" + _CAPTURE_CALL)
117+
tmpfile = f.name
140118
try:
141119
proc = subprocess.run(
142-
["python", "_submission.py"],
120+
["python", tmpfile],
143121
input=stdin,
144122
capture_output=True,
145123
text=True,
146124
timeout=_TIMEOUT,
147-
cwd=run_dir,
148125
env={**os.environ, "MPLBACKEND": "Agg", "MPLCONFIGDIR": "/tmp"},
149126
)
150127
images = []
@@ -158,10 +135,8 @@ def _run_code(code: str, stdin: str, files_dir: str | None = None) -> tuple[str,
158135
except subprocess.TimeoutExpired:
159136
return "", "", True, []
160137
finally:
161-
os.unlink(script_path)
138+
os.unlink(tmpfile)
162139
shutil.rmtree(plot_dir, ignore_errors=True)
163-
if own_run_dir:
164-
shutil.rmtree(run_dir, ignore_errors=True)
165140

166141

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

196171

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

210185

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

@@ -231,12 +206,12 @@ def _evaluate_io(response: str, tests: list, result: Result, answer: str = "", f
231206
if answer:
232207
ans_code = _add_repl_print(answer)
233208
ans_run_code = (prefix + ans_code) if inject else ans_code
234-
ans_stdout, _, _, _ = _run_code(ans_run_code, run_stdin, files_dir)
209+
ans_stdout, _, _, _ = _run_code(ans_run_code, run_stdin)
235210
expected = ans_stdout.rstrip()
236211
else:
237212
expected = test.get("expected_output", "").rstrip()
238213

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

@@ -273,15 +248,15 @@ def _evaluate_io(response: str, tests: list, result: Result, answer: str = "", f
273248
return result
274249

275250

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

281256
results_path = tempfile.mktemp(suffix=".json")
282257
runner = _UNIT_RUNNER_TEMPLATE.format(results_path=results_path)
283258
combined = _add_repl_print(response) + "\n\n" + test_code + runner
284-
stdout, stderr, timed_out, _ = _run_code(combined, "", files_dir)
259+
stdout, stderr, timed_out, _ = _run_code(combined, "")
285260

286261
test_results = None
287262
try:
@@ -323,97 +298,38 @@ def _evaluate_unit(response: str, test_code: str, result: Result, files_dir: str
323298
return result
324299

325300

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-
361301
def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
362302
result = Result()
363303
mode = params.get("mode")
364304
if mode not in ("demo", "io_test", "unit_test"):
365305
result.add_feedback("error", f"Unknown or missing mode: {mode!r}. Expected 'demo', 'io_test', or 'unit_test'.")
366306
return result
367307

368-
code = str(response)
369-
file_specs = _collect_file_specs(params)
370-
371-
violations = check_code_safety(code)
308+
violations = check_code_safety(str(response))
372309
if violations:
373310
result.add_feedback(
374311
"error",
375312
"Unsafe code detected -- not executed:\n" + "\n".join(f"- {v}" for v in violations),
376313
)
377314
return result
378315

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)
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)
391331
else:
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)
332+
body = "No style issues found."
333+
result.add_feedback("style", body)
418334

419335
return result

0 commit comments

Comments
 (0)