Skip to content

Commit d39b96f

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

5 files changed

Lines changed: 148 additions & 149 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,18 @@ All source lives in `evaluation_function/`:
9494
}
9595

9696
# 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.
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.
103104
{
104105
"mode": "demo",
105106
"files": [
106-
{"key": "uploads/<question-id>/data.csv", "filename": "data.csv"},
107-
{"key": "uploads/<question-id>/helper.py", "filename": "helper.py"},
107+
{"url": "https://.../data.csv?X-Amz-Signature=...", "filename": "data.csv"},
108+
{"url": "https://.../helper.py?X-Amz-Signature=...", "filename": "helper.py"},
108109
]
109110
}
110111
```
@@ -167,8 +168,7 @@ CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-li
167168
| `FUNCTION_ARGS` | `-m,evaluation_function.main` | lf_toolkit runner |
168169
| `FUNCTION_RPC_TRANSPORT` | `ipc` | lf_toolkit transport |
169170
| `LOG_LEVEL` | `debug` | Logging verbosity |
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 |
171+
| `AWS_*` / boto3 credentials | Runtime env | Required for S3 plot uploads. Not needed for `params["files"]` downloads — those are fetched via plain HTTPS GET from a pre-signed/public URL |
172172

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

evaluation_function/evaluation.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
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
14+
from .s3_files import download_files
1515

1616
_TIMEOUT = 25
1717
_UPLOAD_FOLDER = "evaluatePython"
@@ -332,10 +332,7 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
332332
file_specs = params.get("files")
333333
if file_specs:
334334
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)]
335+
file_warnings = download_files(file_specs, files_dir)
339336

340337
try:
341338
if mode == "demo":

evaluation_function/evaluation_test.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ class TestFileDownloads(unittest.TestCase):
317317
@patch("evaluation_function.evaluation.download_files")
318318
def test_demo_mode_can_read_downloaded_file(self, mock_download):
319319
mock_download.side_effect = _stub_download({"data.csv": "1,2,3"})
320-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
320+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
321321
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
322322

323323
self.assertIn("1,2,3", result["feedback"])
@@ -327,7 +327,7 @@ def test_io_test_downloads_once_for_all_tests(self, mock_download):
327327
mock_download.side_effect = _stub_download({"data.csv": "42"})
328328
params = {
329329
"mode": "io_test",
330-
"files": [{"key": "k", "filename": "data.csv"}],
330+
"files": [{"url": "https://example.com/k", "filename": "data.csv"}],
331331
"tests": [_test("", "42\n"), _test("", "42\n")],
332332
}
333333
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
@@ -341,7 +341,7 @@ def test_answer_code_receives_same_files(self, mock_download):
341341
params = {
342342
"mode": "io_test",
343343
"use_answer_as_expected_output": True,
344-
"files": [{"key": "k", "filename": "data.csv"}],
344+
"files": [{"url": "https://example.com/k", "filename": "data.csv"}],
345345
"tests": [{"input": ""}],
346346
}
347347
code = "print(open('data.csv').read())"
@@ -352,15 +352,15 @@ def test_answer_code_receives_same_files(self, mock_download):
352352
@patch("evaluation_function.evaluation.download_files")
353353
def test_missing_file_reported_as_warning(self, mock_download):
354354
mock_download.return_value = ["File 'data.csv' could not be found."]
355-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
355+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
356356
result = evaluation_function("print('hi')", None, params).to_dict()
357357

358358
self.assertIn("could not be found", result["feedback"])
359359

360360
@patch("evaluation_function.evaluation.download_files")
361361
def test_import_of_uploaded_module(self, mock_download):
362362
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"}]}
363+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "helper.py"}]}
364364
result = evaluation_function("import helper\nprint(helper.square(4))", None, params).to_dict()
365365

366366
self.assertIn("16", result["feedback"])
@@ -376,31 +376,31 @@ class TestFileAccessSandbox(unittest.TestCase):
376376
@patch("evaluation_function.evaluation.download_files")
377377
def test_read_downloaded_file_succeeds(self, mock_download):
378378
mock_download.side_effect = _stub_download({"data.csv": "hello"})
379-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
379+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
380380
result = evaluation_function("print(open('data.csv').read())", None, params).to_dict()
381381

382382
self.assertIn("hello", result["feedback"])
383383

384384
@patch("evaluation_function.evaluation.download_files")
385385
def test_write_mode_to_provided_file_blocked(self, mock_download):
386386
mock_download.side_effect = _stub_download({"data.csv": "hello"})
387-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
387+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
388388
result = evaluation_function("open('data.csv', 'w')", None, params).to_dict()
389389

390390
self.assertIn("read-only", result["feedback"])
391391

392392
@patch("evaluation_function.evaluation.download_files")
393393
def test_write_new_file_in_run_dir_blocked(self, mock_download):
394394
mock_download.side_effect = _stub_download({"data.csv": "hello"})
395-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
395+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
396396
result = evaluation_function("open('output.txt', 'w')", None, params).to_dict()
397397

398398
self.assertIn("read-only", result["feedback"])
399399

400400
@patch("evaluation_function.evaluation.download_files")
401401
def test_pathlib_read_respects_sandbox(self, mock_download):
402402
mock_download.side_effect = _stub_download({"data.csv": "world"})
403-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
403+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
404404
code = "from pathlib import Path\nprint(Path('data.csv').read_text())"
405405
result = evaluation_function(code, None, params).to_dict()
406406

@@ -409,7 +409,7 @@ def test_pathlib_read_respects_sandbox(self, mock_download):
409409
@patch("evaluation_function.evaluation.download_files")
410410
def test_pathlib_write_respects_sandbox(self, mock_download):
411411
mock_download.side_effect = _stub_download({"data.csv": "world"})
412-
params = {"mode": "demo", "files": [{"key": "k", "filename": "data.csv"}]}
412+
params = {"mode": "demo", "files": [{"url": "https://example.com/k", "filename": "data.csv"}]}
413413
code = "from pathlib import Path\nPath('data.csv').write_text('nope')"
414414
result = evaluation_function(code, None, params).to_dict()
415415

evaluation_function/s3_files.py

Lines changed: 54 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,110 @@
11
import os
22
from typing import TypedDict
3+
from urllib.parse import urlparse
34

4-
import boto3
5-
from botocore.config import Config
6-
from botocore.exceptions import ClientError
5+
import requests
76

87
_MAX_FILE_BYTES = 5 * 1024 * 1024
98
_MAX_TOTAL_BYTES = 20 * 1024 * 1024
109
_DOWNLOAD_TIMEOUT = 10
10+
_CHUNK_SIZE = 65536
1111

1212

1313
class FileSpec(TypedDict):
14-
key: str
14+
url: str
1515
filename: str
1616

1717

18-
class FileDownloadError(Exception):
19-
"""Raised for whole-request configuration problems (e.g. missing bucket env var)."""
20-
pass
21-
18+
def _valid_filename(filename: str) -> bool:
19+
if not filename or filename in (".", ".."):
20+
return False
21+
return os.path.basename(filename) == filename
2222

23-
def _s3_client():
24-
return boto3.client(
25-
"s3",
26-
region_name=os.environ.get("AWS_REGION", "eu-west-2"),
27-
config=Config(
28-
connect_timeout=_DOWNLOAD_TIMEOUT,
29-
read_timeout=_DOWNLOAD_TIMEOUT,
30-
retries={"max_attempts": 2},
31-
),
32-
)
3323

24+
class _FileTooLarge(Exception):
25+
pass
3426

35-
def _get_bucket_name() -> str:
36-
bucket = os.environ.get("S3_FILES_BUCKET")
37-
if not bucket:
38-
raise FileDownloadError("S3_FILES_BUCKET environment variable is not set")
39-
return bucket
4027

28+
def _download_one(url: str, target: str, remaining_budget: int) -> int:
29+
"""Stream url into target. Returns bytes written.
4130
42-
def _valid_filename(filename: str) -> bool:
43-
if not filename or filename in (".", ".."):
44-
return False
45-
return os.path.basename(filename) == filename
31+
Raises _FileTooLarge (and removes any partial file) if the download
32+
exceeds _MAX_FILE_BYTES or remaining_budget, or requests.RequestException
33+
for network/HTTP errors — both handled by the caller.
34+
"""
35+
resp = requests.get(url, stream=True, timeout=_DOWNLOAD_TIMEOUT)
36+
resp.raise_for_status()
37+
38+
content_length = resp.headers.get("Content-Length")
39+
cap = min(_MAX_FILE_BYTES, remaining_budget)
40+
if content_length is not None and int(content_length) > cap:
41+
raise _FileTooLarge()
42+
43+
written = 0
44+
try:
45+
with open(target, "wb") as f:
46+
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
47+
written += len(chunk)
48+
if written > cap:
49+
raise _FileTooLarge()
50+
f.write(chunk)
51+
except _FileTooLarge:
52+
if os.path.exists(target):
53+
os.unlink(target)
54+
raise
55+
return written
4656

4757

4858
def download_files(files: list[FileSpec], dest_dir: str) -> list[str]:
4959
"""Download each file into dest_dir.
5060
51-
Returns a list of warning strings for files that were skipped (missing,
52-
too large, or errored) — never raises for per-file problems, only for
53-
whole-config problems (missing bucket env var).
61+
Returns a list of warning strings for files that were skipped (invalid
62+
filename/URL, too large, or errored) — never raises.
5463
"""
5564
if not files:
5665
return []
5766

58-
bucket = _get_bucket_name()
59-
client = _s3_client()
60-
67+
real_dest_dir = os.path.realpath(dest_dir)
6168
warnings: list[str] = []
6269
total_bytes = 0
6370

6471
for spec in files:
65-
key = spec["key"]
72+
url = spec["url"]
6673
filename = spec["filename"]
6774

6875
if not _valid_filename(filename):
6976
warnings.append(f"File '{filename}' has an invalid filename and was not made available.")
7077
continue
7178

72-
real_dest_dir = os.path.realpath(dest_dir)
7379
target = os.path.realpath(os.path.join(real_dest_dir, filename))
7480
if os.path.commonpath([target, real_dest_dir]) != real_dest_dir:
7581
warnings.append(f"File '{filename}' has an invalid filename and was not made available.")
7682
continue
7783

78-
try:
79-
head = client.head_object(Bucket=bucket, Key=key)
80-
except ClientError as e:
81-
warnings.append(f"File '{filename}' could not be found or accessed ({e}).")
84+
if urlparse(url).scheme != "https":
85+
warnings.append(f"File '{filename}' has an invalid URL and was not made available.")
8286
continue
8387

84-
size = head.get("ContentLength", 0)
85-
if size > _MAX_FILE_BYTES:
86-
warnings.append(
87-
f"File '{filename}' exceeds the {_MAX_FILE_BYTES // (1024 * 1024)}MB size limit "
88-
"and was not made available."
89-
)
90-
continue
91-
if total_bytes + size > _MAX_TOTAL_BYTES:
88+
remaining_budget = _MAX_TOTAL_BYTES - total_bytes
89+
if remaining_budget <= 0:
9290
warnings.append(
9391
f"File '{filename}' was skipped because it would exceed the total "
9492
f"{_MAX_TOTAL_BYTES // (1024 * 1024)}MB size limit for this run."
9593
)
9694
continue
9795

9896
try:
99-
client.download_file(bucket, key, target)
100-
except ClientError as e:
97+
written = _download_one(url, target, remaining_budget)
98+
except _FileTooLarge:
99+
warnings.append(
100+
f"File '{filename}' exceeds the {_MAX_FILE_BYTES // (1024 * 1024)}MB size limit "
101+
"and was not made available."
102+
)
103+
continue
104+
except requests.exceptions.RequestException as e:
101105
warnings.append(f"File '{filename}' could not be downloaded ({e}).")
102106
continue
103107

104-
total_bytes += size
108+
total_bytes += written
105109

106110
return warnings

0 commit comments

Comments
 (0)