Skip to content

Commit 208bf57

Browse files
committed
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.
1 parent adcd99a commit 208bf57

5 files changed

Lines changed: 170 additions & 44 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ All source lives in `evaluation_function/`:
104104
{
105105
"mode": "demo",
106106
"files": [
107-
{"url": "https://.../data.csv?X-Amz-Signature=...", "filename": "data.csv"},
108-
{"url": "https://.../helper.py?X-Amz-Signature=...", "filename": "helper.py"},
107+
{"url": "https://.../data.csv?X-Amz-Signature=...", "name": "data.csv"},
108+
{"url": "https://.../helper.py?X-Amz-Signature=...", "name": "helper.py"},
109109
]
110110
}
111111
```

evaluation_function/evaluation.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
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
@@ -328,13 +330,13 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
328330
return result
329331

330332
files_dir = None
331-
file_warnings: list[str] = []
332-
file_specs = params.get("files")
333-
if file_specs:
334-
files_dir = tempfile.mkdtemp()
335-
file_warnings = download_files(file_specs, files_dir)
336-
337333
try:
334+
file_warnings: list[str] = []
335+
file_specs = params.get("files")
336+
if file_specs:
337+
files_dir = tempfile.mkdtemp()
338+
file_warnings = download_files(file_specs, files_dir)
339+
338340
if mode == "demo":
339341
result = _evaluate_demo(str(response), result, files_dir)
340342
elif mode == "io_test":
@@ -343,21 +345,29 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
343345
else:
344346
test_code = str(answer) if params.get("use_answer_as_test_code") else params.get("test_code", "")
345347
result = _evaluate_unit(str(response), test_code, result, files_dir=files_dir)
348+
349+
for warning in file_warnings:
350+
result.add_feedback("error", warning)
351+
352+
pep8_param = params.get("pep8_feedback")
353+
if pep8_param:
354+
select = pep8_param if isinstance(pep8_param, list) else _PEP8_SELECT
355+
violations = _check_pep8(str(response), select)
356+
if violations:
357+
body = "Style suggestions (PEP8):\n" + "\n".join(f"- {v}" for v in violations)
358+
else:
359+
body = "No style issues found."
360+
result.add_feedback("style", body)
361+
except Exception:
362+
traceback.print_exc(file=sys.stderr)
363+
result = Result()
364+
result.add_feedback(
365+
"error",
366+
"An unexpected internal error occurred while evaluating this submission. "
367+
"Please contact a course organizer.",
368+
)
346369
finally:
347370
if files_dir is not None:
348371
shutil.rmtree(files_dir, ignore_errors=True)
349372

350-
for warning in file_warnings:
351-
result.add_feedback("error", warning)
352-
353-
pep8_param = params.get("pep8_feedback")
354-
if pep8_param:
355-
select = pep8_param if isinstance(pep8_param, list) else _PEP8_SELECT
356-
violations = _check_pep8(str(response), select)
357-
if violations:
358-
body = "Style suggestions (PEP8):\n" + "\n".join(f"- {v}" for v in violations)
359-
else:
360-
body = "No style issues found."
361-
result.add_feedback("style", body)
362-
363373
return result

evaluation_function/evaluation_test.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import tempfile
23
import unittest
34
from unittest.mock import patch
45

@@ -317,7 +318,7 @@ class TestFileDownloads(unittest.TestCase):
317318
@patch("evaluation_function.evaluation.download_files")
318319
def test_demo_mode_can_read_downloaded_file(self, mock_download):
319320
mock_download.side_effect = _stub_download({"data.csv": "1,2,3"})
320-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
321+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
321322
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
322323

323324
self.assertIn("1,2,3", result["feedback"])
@@ -327,7 +328,7 @@ def test_io_test_downloads_once_for_all_tests(self, mock_download):
327328
mock_download.side_effect = _stub_download({"data.csv": "42"})
328329
params = {
329330
"mode": "io_test",
330-
"files": [{"url": "https://example.com/k", "filename": "data.csv"}],
331+
"files": [{"url": "https://example.com/k", "name": "data.csv"}],
331332
"tests": [_test("", "42\n"), _test("", "42\n")],
332333
}
333334
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
@@ -341,7 +342,7 @@ def test_answer_code_receives_same_files(self, mock_download):
341342
params = {
342343
"mode": "io_test",
343344
"use_answer_as_expected_output": True,
344-
"files": [{"url": "https://example.com/k", "filename": "data.csv"}],
345+
"files": [{"url": "https://example.com/k", "name": "data.csv"}],
345346
"tests": [{"input": ""}],
346347
}
347348
code = "print(open('data.csv').read())"
@@ -352,15 +353,15 @@ def test_answer_code_receives_same_files(self, mock_download):
352353
@patch("evaluation_function.evaluation.download_files")
353354
def test_missing_file_reported_as_warning(self, mock_download):
354355
mock_download.return_value = ["File 'data.csv' could not be found."]
355-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
356+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
356357
result = evaluation_function("print('hi')", None, params).to_dict()
357358

358359
self.assertIn("could not be found", result["feedback"])
359360

360361
@patch("evaluation_function.evaluation.download_files")
361362
def test_import_of_uploaded_module(self, mock_download):
362363
mock_download.side_effect = _stub_download({"helper.py": "def square(n):\n return n * n\n"})
363-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "helper.py"}]}
364+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "helper.py"}]}
364365
result = evaluation_function("import helper\nprint(helper.square(4))", None, params).to_dict()
365366

366367
self.assertIn("16", result["feedback"])
@@ -371,36 +372,97 @@ def test_no_files_param_no_download_call(self):
371372
mock_download.assert_not_called()
372373

373374

375+
class TestMalformedFileSpec(unittest.TestCase):
376+
377+
def test_legacy_filename_key_does_not_crash(self):
378+
# Reproduces the real-world crash report shape: a client sending the
379+
# old/wrong "filename" key instead of "name". Must not crash.
380+
params = {
381+
"mode": "demo",
382+
"files": [{
383+
"url": "https://example.com/k",
384+
"filename": "score_utils.py",
385+
"type": "text/x-python-script",
386+
"size": 237,
387+
}],
388+
}
389+
result = evaluation_function("print('hi')", None, params).to_dict()
390+
391+
self.assertIn("hi", result["feedback"])
392+
self.assertIn("missing", result["feedback"].lower())
393+
394+
395+
class TestUnexpectedExceptionHandling(unittest.TestCase):
396+
397+
@patch("evaluation_function.evaluation._run_code")
398+
def test_unexpected_exception_during_evaluation_is_caught(self, mock_run):
399+
mock_run.side_effect = RuntimeError("boom")
400+
401+
result = evaluation_function("print('hi')", None, {"mode": "demo"}).to_dict(include_test_data=True)
402+
403+
self.assertFalse(result["is_correct"])
404+
self.assertIn("error", result["tags"])
405+
406+
@patch("evaluation_function.evaluation.download_files")
407+
def test_exception_in_download_files_becomes_error_result(self, mock_download):
408+
# Simulates a bug in download_files() itself (defense-in-depth,
409+
# independent of the s3_files.py validation fix).
410+
mock_download.side_effect = KeyError("name")
411+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
412+
413+
result = evaluation_function("print('hi')", None, params).to_dict(include_test_data=True)
414+
415+
self.assertFalse(result["is_correct"])
416+
self.assertIn("error", result["tags"])
417+
418+
def test_files_dir_cleaned_up_even_on_exception(self):
419+
created_dirs = []
420+
real_mkdtemp = tempfile.mkdtemp
421+
422+
def tracking_mkdtemp(*args, **kwargs):
423+
d = real_mkdtemp(*args, **kwargs)
424+
created_dirs.append(d)
425+
return d
426+
427+
with patch("evaluation_function.evaluation.download_files", side_effect=RuntimeError("boom")), \
428+
patch("evaluation_function.evaluation.tempfile.mkdtemp", side_effect=tracking_mkdtemp):
429+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
430+
evaluation_function("print('hi')", None, params)
431+
432+
self.assertTrue(created_dirs)
433+
self.assertFalse(os.path.exists(created_dirs[0]))
434+
435+
374436
class TestFileAccessSandbox(unittest.TestCase):
375437

376438
@patch("evaluation_function.evaluation.download_files")
377439
def test_read_downloaded_file_succeeds(self, mock_download):
378440
mock_download.side_effect = _stub_download({"data.csv": "hello"})
379-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
441+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
380442
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
381443

382444
self.assertIn("hello", result["feedback"])
383445

384446
@patch("evaluation_function.evaluation.download_files")
385447
def test_write_mode_to_provided_file_blocked(self, mock_download):
386448
mock_download.side_effect = _stub_download({"data.csv": "hello"})
387-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
449+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
388450
result = evaluation_function("open('data.csv', 'w')", None, params).to_dict()
389451

390452
self.assertIn("read-only", result["feedback"])
391453

392454
@patch("evaluation_function.evaluation.download_files")
393455
def test_write_new_file_in_run_dir_blocked(self, mock_download):
394456
mock_download.side_effect = _stub_download({"data.csv": "hello"})
395-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
457+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
396458
result = evaluation_function("open('output.txt', 'w')", None, params).to_dict()
397459

398460
self.assertIn("read-only", result["feedback"])
399461

400462
@patch("evaluation_function.evaluation.download_files")
401463
def test_pathlib_read_respects_sandbox(self, mock_download):
402464
mock_download.side_effect = _stub_download({"data.csv": "world"})
403-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
465+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
404466
code = "from pathlib import Path\nprint(Path('data.csv').read_text())"
405467
result = evaluation_function(code, None, params).to_dict()
406468

@@ -409,7 +471,7 @@ def test_pathlib_read_respects_sandbox(self, mock_download):
409471
@patch("evaluation_function.evaluation.download_files")
410472
def test_pathlib_write_respects_sandbox(self, mock_download):
411473
mock_download.side_effect = _stub_download({"data.csv": "world"})
412-
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
474+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "name": "data.csv"}]}
413475
code = "from pathlib import Path\nPath('data.csv').write_text('nope')"
414476
result = evaluation_function(code, None, params).to_dict()
415477

evaluation_function/s3_files.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
class FileSpec(TypedDict):
1414
url: str
15-
filename: str
15+
name: str
1616

1717

1818
def _valid_filename(filename: str) -> bool:
@@ -69,8 +69,25 @@ def download_files(files: list[FileSpec], dest_dir: str) -> list[str]:
6969
total_bytes = 0
7070

7171
for spec in files:
72-
url = spec["url"]
73-
filename = spec["filename"]
72+
if not isinstance(spec, dict):
73+
warnings.append(
74+
"One of the provided files is missing required information (url/name) and was skipped."
75+
)
76+
continue
77+
78+
url = spec.get("url")
79+
filename = spec.get("name")
80+
has_url = isinstance(url, str) and bool(url)
81+
has_filename = isinstance(filename, str) and bool(filename)
82+
83+
if not has_url or not has_filename:
84+
if has_filename:
85+
warnings.append(f"File '{filename}' is missing a valid URL and was not made available.")
86+
else:
87+
warnings.append(
88+
"One of the provided files is missing required information (url/name) and was skipped."
89+
)
90+
continue
7491

7592
if not _valid_filename(filename):
7693
warnings.append(f"File '{filename}' has an invalid filename and was not made available.")

evaluation_function/s3_files_test.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_no_files_returns_empty(self):
4545
def test_successful_download_writes_file(self, mock_get):
4646
mock_get.return_value = _fake_response(b"hello", content_length=5)
4747

48-
warnings = download_files([{"url": _URL, "filename": "data.csv"}], self.dest_dir)
48+
warnings = download_files([{"url": _URL, "name": "data.csv"}], self.dest_dir)
4949

5050
self.assertEqual(warnings, [])
5151
with open(os.path.join(self.dest_dir, "data.csv"), "rb") as f:
@@ -55,7 +55,7 @@ def test_successful_download_writes_file(self, mock_get):
5555
def test_oversized_via_header_skipped(self, mock_get):
5656
mock_get.return_value = _fake_response(b"x" * 10, content_length=_MAX_FILE_BYTES + 1)
5757

58-
warnings = download_files([{"url": _URL, "filename": "big.csv"}], self.dest_dir)
58+
warnings = download_files([{"url": _URL, "name": "big.csv"}], self.dest_dir)
5959

6060
self.assertEqual(len(warnings), 1)
6161
self.assertIn("big.csv", warnings[0])
@@ -67,7 +67,7 @@ def test_oversized_via_streaming_skipped(self, mock_get):
6767
big_content = b"x" * (_MAX_FILE_BYTES + 1)
6868
mock_get.return_value = _fake_response(big_content, content_length=10)
6969

70-
warnings = download_files([{"url": _URL, "filename": "big.csv"}], self.dest_dir)
70+
warnings = download_files([{"url": _URL, "name": "big.csv"}], self.dest_dir)
7171

7272
self.assertEqual(len(warnings), 1)
7373
self.assertIn("big.csv", warnings[0])
@@ -79,7 +79,7 @@ def test_total_size_cap_skips_later_files(self, mock_get):
7979
# _MAX_TOTAL_BYTES (allowed), the 5th is skipped without a request.
8080
mock_get.return_value = _fake_response(b"x" * _MAX_FILE_BYTES, content_length=_MAX_FILE_BYTES)
8181

82-
files = [{"url": _URL, "filename": f"{i}.csv"} for i in range(5)]
82+
files = [{"url": _URL, "name": f"{i}.csv"} for i in range(5)]
8383
warnings = download_files(files, self.dest_dir)
8484

8585
self.assertEqual(mock_get.call_count, 4)
@@ -96,8 +96,8 @@ def side_effect(url, stream, timeout):
9696
mock_get.side_effect = side_effect
9797

9898
files = [
99-
{"url": "https://example.com/missing", "filename": "missing.csv"},
100-
{"url": "https://example.com/ok", "filename": "ok.csv"},
99+
{"url": "https://example.com/missing", "name": "missing.csv"},
100+
{"url": "https://example.com/ok", "name": "ok.csv"},
101101
]
102102
warnings = download_files(files, self.dest_dir)
103103

@@ -109,20 +109,57 @@ def side_effect(url, stream, timeout):
109109
def test_network_error_skipped(self, mock_get):
110110
mock_get.side_effect = requests.exceptions.ConnectionError("boom")
111111

112-
warnings = download_files([{"url": _URL, "filename": "data.csv"}], self.dest_dir)
112+
warnings = download_files([{"url": _URL, "name": "data.csv"}], self.dest_dir)
113113

114114
self.assertEqual(len(warnings), 1)
115115
self.assertIn("data.csv", warnings[0])
116116

117117
def test_rejects_non_https_url(self):
118118
for bad_url in ("http://example.com/data.csv", "file:///etc/passwd", "ftp://example.com/data.csv"):
119-
warnings = download_files([{"url": bad_url, "filename": "data.csv"}], self.dest_dir)
119+
warnings = download_files([{"url": bad_url, "name": "data.csv"}], self.dest_dir)
120120
self.assertEqual(len(warnings), 1, f"expected a warning for url={bad_url!r}")
121121

122122
def test_filename_validation_rejects_traversal(self):
123123
for bad_name in ("../evil.py", "/etc/passwd", "", ".", ".."):
124-
warnings = download_files([{"url": _URL, "filename": bad_name}], self.dest_dir)
125-
self.assertEqual(len(warnings), 1, f"expected a warning for filename={bad_name!r}")
124+
warnings = download_files([{"url": _URL, "name": bad_name}], self.dest_dir)
125+
self.assertEqual(len(warnings), 1, f"expected a warning for name={bad_name!r}")
126+
127+
def test_legacy_filename_key_skipped_not_raised(self):
128+
# Reproduces the real-world crash report: client actually sends "name",
129+
# not the old "filename" key. A spec using the wrong/legacy key must
130+
# not raise KeyError — it should be skipped with a warning.
131+
warnings = download_files(
132+
[{"url": _URL, "filename": "score_utils.py", "type": "text/x-python-script", "size": 237}],
133+
self.dest_dir,
134+
)
135+
self.assertEqual(len(warnings), 1)
136+
self.assertIn("missing", warnings[0].lower())
137+
138+
def test_missing_url_key_skipped_not_raised(self):
139+
warnings = download_files([{"name": "data.csv"}], self.dest_dir)
140+
self.assertEqual(len(warnings), 1)
141+
self.assertIn("data.csv", warnings[0])
142+
143+
def test_non_dict_spec_skipped_not_raised(self):
144+
warnings = download_files(["not-a-dict", 42, None], self.dest_dir)
145+
self.assertEqual(len(warnings), 3)
146+
147+
def test_empty_spec_generic_message(self):
148+
warnings = download_files([{}], self.dest_dir)
149+
self.assertEqual(len(warnings), 1)
150+
self.assertIn("missing", warnings[0].lower())
151+
152+
@patch("evaluation_function.s3_files.requests.get")
153+
def test_malformed_spec_skipped_others_continue(self, mock_get):
154+
mock_get.return_value = _fake_response(b"ok", content_length=2)
155+
files = [
156+
{"url": _URL, "filename": "score_utils.py"}, # wrong/legacy key, missing "name"
157+
{"url": _URL, "name": "ok.csv"},
158+
]
159+
warnings = download_files(files, self.dest_dir)
160+
161+
self.assertEqual(len(warnings), 1)
162+
self.assertTrue(os.path.exists(os.path.join(self.dest_dir, "ok.csv")))
126163

127164

128165
if __name__ == "__main__":

0 commit comments

Comments
 (0)