From c1e6f1cc692c2d645f52b47ba51c24897df457b3 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Tue, 6 Oct 2026 16:10:42 -0400 Subject: [PATCH 1/3] Ship a question's data files with CodeTailor's JOBE test runs A question whose program reads a file (open("dogs.txt"), or a data-datafile on a livecode question) failed every CodeTailor validation run: generated code was executed on JOBE with no file_list, so it died with a missing-file error. The LLM example never passed its unit tests, personalization fell back, and the student got empty help ("emptyHelpCode") on every file-reading question. parsons_scaffolding now collects candidate filenames -- the data-datafile attribute plus quoted filename literals in the question code, its tests and the student's code -- and resolves them against source_code, the same store Skulpt and livecode.js read datafiles from. The requesting course is tried first, then the question's own book, so cloned courses and cross-book selectquestions still find the file. A file named both by acid and by filename is shipped once. The files travel through get_parsons_help into every Python and Java JOBE run, including validation of the LLM-generated example solution: each is pushed to the JOBE file store and listed in the run_spec file_list, binary files verbatim. Co-Authored-By: Claude Opus 5.5 --- bases/rsptx/book_server_api/routers/coach.py | 89 +++++++++++++++++++ .../personalized_parsons/end_to_end.py | 36 +++++++- .../evaluate_fixed_code.py | 87 ++++++++++++++---- .../get_personalized_solution.py | 12 ++- 4 files changed, 203 insertions(+), 21 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 3c89efe53..2d8372307 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -39,6 +39,7 @@ from rsptx.db.crud.crud import fetch_api_token from rsptx.db.crud.course import fetch_course from rsptx.db.crud.question import fetch_question +from rsptx.db.crud import fetch_source_code # .. _APIRouter config: # @@ -209,6 +210,73 @@ def _extract_suffix_code_from_htmlsrc(htmlsrc): return "" +# A quoted name with a file extension, e.g. open("scores.csv") or +# new File("words.txt") -- a possible data file to look up in source_code. +_FILENAME_LITERAL_RE = re.compile(r"""["']([\w\-./]+\.[A-Za-z0-9]{1,8})["']""") +# Bounds the source_code lookups a single request can trigger. +_MAX_DATA_FILE_CANDIDATES = 20 + + +def _data_file_candidates(htmlsrc, *code_sources): + """ + Filenames the question's program may open. Livecode (JOBE) questions list + them in data-datafile, but Python questions don't declare them anywhere -- + Skulpt resolves open() at runtime -- so also take every quoted + filename-like literal from the question's code, its tests and the + student's code. Names that turn out not to be data files simply miss in + the source_code lookup. + """ + names = [] + htmlsrc = htmlsrc or "" + match = re.search(r'data-datafile\s*=\s*"([^"]*)"', htmlsrc) + if match: + names.extend(name.strip() for name in match.group(1).split(",")) + textarea = re.search(r"]*>(.*?)", htmlsrc, flags=re.DOTALL) + question_code = html.unescape(textarea.group(1)) if textarea else "" + for text in (question_code, *code_sources): + names.extend(_FILENAME_LITERAL_RE.findall(text or "")) + return list(dict.fromkeys(name for name in names if name))[ + :_MAX_DATA_FILE_CANDIDATES + ] + + +async def _fetch_data_files(candidates, course_scopes): + """ + Resolve candidate filenames against source_code -- the same store + Skulpt's file reader and livecode.js fall back to -- so the files can be + shipped to JOBE with every CodeTailor test run. + + course_scopes is a list of (base_course, course_name) pairs tried in + order: the requesting course first, then the book the question came from + (which differs for cloned courses and cross-book selectquestions). + Returns dicts in the shape evaluate_fixed_code._push_data_files expects. + """ + data_files = [] + for name in candidates: + for base_course, course_name in course_scopes: + try: + row = await fetch_source_code( + base_course=base_course, course_name=course_name, filename=name + ) + except Exception: + # fetch_source_code raises when nothing matches + row = None + if row and row.main_code is not None: + filename = row.filename or name + # data-datafile names the acid while the code names the file, + # so the same file can be resolved twice -- ship it once + if filename not in {data_file["filename"] for data_file in data_files}: + data_files.append( + { + "filename": filename, + "content": row.main_code, + "is_binary": bool(row.is_binary), + } + ) + break + return data_files + + def _build_static_parsons_response( parsonsexample_code, parsons_attrs, personalization_level ): @@ -467,6 +535,25 @@ async def parsons_scaffolding( if language and language.lower() == "python": internal_test_case = clean_python_testcase(internal_test_case) + # Files the program opens (datafiles) must travel with each JOBE test run, + # or every generated solution fails with a missing-file error. + data_files = await _fetch_data_files( + _data_file_candidates(question.htmlsrc, internal_test_case, student_code), + list( + dict.fromkeys( + [ + (basecourse, course_name), + (question.base_course, question.base_course), + ] + ) + ), + ) + if data_files: + rslogger.info( + f"CodeTailor: '{problem_id}' uses data files " + f"{[data_file['filename'] for data_file in data_files]}" + ) + print("start_to: get_parsons_help", api_token, language, personalization_level) adaptive_attr = 'data-adaptive="true"' @@ -512,6 +599,7 @@ def parsons_help( # this can be returned verbatim as the fixed/example code when # LLM personalization falls back (see end_to_end.generate_example_solution) "CF (Code)": student_code, + "Data_Files": data_files, } return get_parsons_help(api_token, language, input_dict, personalization_level) @@ -536,6 +624,7 @@ def parsons_help( language, problem_description, internal_test_case, + data_files, ) if not example_code: return ( diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py index 814defa75..6cadf1fe4 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py @@ -19,7 +19,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): Args: api_token: The API token for GenerativeAI -- now only support OpenAI language: The programming language of the code - dict_buggy_code: A dictionary containing the current buggy code, problem description, unittest code, and a predefined example + dict_buggy_code: A dictionary containing the current buggy code, problem description, unittest code, a predefined example, and (optionally) the question's data files personalize_level: The level of personalization to apply (e.g., "Solution", "Multiple") Returns: A tuple containing the final fixed code, the final Parsons block, the solution generation type, and the generation result type. @@ -32,6 +32,8 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): unittest_code = dict_buggy_code["Unittest_Code"].replace("\\n", "\n") # This can be "LLM-example" or a predefined example code predefined_example = dict_buggy_code["Example"].replace("\\n", "\n") + # files the program opens (e.g. datafiles) -- shipped with every test run + data_files = dict_buggy_code.get("Data_Files") # check if language is java, keep it as it is, otherwise, assign it to "python" as default if language.lower() == "java" or language.lower() == "python": language = language.lower() @@ -48,6 +50,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): default_test_code, predefined_example, unittest_code, + data_files=data_files, ) final_fixed_code = cleaned_fixed_code.lstrip() else: @@ -64,6 +67,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): default_start_code, default_test_code, unittest_code, + data_files=data_files, ) elif personalize_level == "Multiple": # generate Parsons puzzles with personalization at both solution and multiple levels with paired distractors and potentially settled block lines @@ -76,6 +80,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): default_start_code, default_test_code, unittest_code, + data_files=data_files, ) else: return "Error: invalid personalize_level" @@ -103,6 +108,7 @@ def request_fixed_code_from_openai( situation, failure_reason, unittest_result, + data_files=None, ): """ Request a fixed code from GenerativeAI based on the buggy code and other parameters. @@ -121,6 +127,7 @@ def request_fixed_code_from_openai( situation (str): The situation for the request, e.g., "a correct answer" failure_reason (str): The reason for the failure, e.g., "not correct" unittest_result (bool): The result of the unittest evaluation for the buggy code + data_files (list): The question's data files the code may open Outputs: cleaned_fixed_code (str): The cleaned fixed code generated by the LLM generation_result_type (str): The type of generation result, e.g., "AI_personalized", "example_more_personalized", "example_solution" @@ -147,6 +154,7 @@ def request_fixed_code_from_openai( default_start_code, default_test_code, unittest_code, + data_files=data_files, ) print("this-round-result:", unittest_result, cleaned_fixed_code) @@ -189,11 +197,17 @@ def request_fixed_code_from_openai( situation="a correct answer", failure_reason="not correct", unittest_result=False, + data_files=data_files, ) def generate_example_solution( - api_token, language, problem_description, unittest_code, predefined_example + api_token, + language, + problem_description, + unittest_code, + predefined_example, + data_files=None, ): """ Generate or retrieve an example solution based on the provided parameters. @@ -203,12 +217,13 @@ def generate_example_solution( problem_description (str): textual question description unittest_code (str): The unittest code for the question predefined_example (str): A predefined example solution or the keyword "LLM-example" to generate one using LLM + data_files (list): The question's data files the code may open Outputs: example_solution (str): The example solution, either generated by LLM or the predefined one """ if predefined_example == "LLM-example": example_solution = get_example_solution( - api_token, language, problem_description, unittest_code + api_token, language, problem_description, unittest_code, data_files ) else: example_solution = predefined_example @@ -225,6 +240,7 @@ def generate_personalized_fixed_code( predefined_example, unittest_code, API_attempt=0, + data_files=None, ): """ Generate a personalized fixed code based on the buggy code and other parameters. @@ -238,6 +254,7 @@ def generate_personalized_fixed_code( predefined_example (str): A predefined example solution or the keyword "LLM-example" to generate one using LLM unittest_code (str): The unittest code for the question API_attempt (int): The current API attempt number -- starts from 0 (reserved in case we want to use different API keys in the future) + data_files (list): The question's data files the code may open Outputs: cleaned_fixed_code (str): The cleaned fixed code generated by the LLM generation_result_type (str): The type of generation result, e.g., "AI_personalized", "example_more_personalized", "example_solution", "written_code_correct", "empty_beginning" @@ -251,6 +268,7 @@ def generate_personalized_fixed_code( default_start_code, default_test_code, unittest_case=unittest_code, + data_files=data_files, ) if unittest_result is not True: # If the code is not correct, we will TRY to get an example solution first @@ -260,6 +278,7 @@ def generate_personalized_fixed_code( problem_description, unittest_code, predefined_example, + data_files=data_files, ) try: @@ -279,6 +298,7 @@ def generate_personalized_fixed_code( situation="", failure_reason="", unittest_result="", + data_files=data_files, ) return result_personalized @@ -292,7 +312,12 @@ def generate_personalized_fixed_code( # If the code is empty, directly return the example solution else: example_solution = generate_example_solution( - api_token, language, problem_description, unittest_code, predefined_example + api_token, + language, + problem_description, + unittest_code, + predefined_example, + data_files=data_files, ) return example_solution.lstrip(), "example_solution" @@ -307,6 +332,7 @@ def generate_multi_personalized_Parsons_blocks( default_start_code, default_test_code, unittest_code, + data_files=None, ): """ Generate personalized Parsons blocks based on the student buggy code and fixed code. @@ -319,6 +345,7 @@ def generate_multi_personalized_Parsons_blocks( default_start_code (str): The default starting code for the question, current "" default_test_code (str): The default test code for the question, current "" unittest_code (str): The unittest code for the question + data_files (list): The question's data files the code may open Outputs: personalized_Parsons_block (str): The generated personalized Parsons block code """ @@ -360,6 +387,7 @@ def generate_multi_personalized_Parsons_blocks( default_start_code, default_test_code, unittest_code, + data_files=data_files, ) # If the code with distractors passes the unittest, we will remove the distractor from the distractors list if unittest_flag: diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py b/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py index f81052e39..5f745d0d3 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/evaluate_fixed_code.py @@ -67,8 +67,38 @@ def _ensure_file_on_jobe( CHECK_PROXY = "/ns/rsproxy/jobeCheckFile/" +def _push_data_files(sess, data_files): + """ + Upload the question's data files to JOBE so the program under test can + open() them, mirroring how livecode.js ships datafiles with a run. + Inputs: + sess: requests session with the JOBE headers set + data_files (list): dicts with "filename", "content" and "is_binary"; + binary contents are already base64 (as stored in source_code) + Output: list: [file_id, filename] pairs for the run_spec file_list + """ + file_list = [] + for data_file in data_files or []: + filename = data_file["filename"] + content = data_file["content"] + if data_file.get("is_binary"): + b64 = content + else: + b64 = _b64_text_utf8(content) + file_id = _runestone_file_id(filename, content) + file_url = settings.jobe_server + "/jobe/index.php/restapi/files/" + file_id + if sess.head(file_url, timeout=10).status_code != 204: + put = sess.put(file_url, json={"file_contents": b64}, timeout=10) + if put.status_code != 204: + raise RuntimeError( + f"Failed to push data file {filename} to JOBE: {put.status_code}" + ) + file_list.append([file_id, filename]) + return file_list + + # modified from rsproxy.py and livecode.js logic -def load_and_run_java_tests(java_code, test_code): +def load_and_run_java_tests(java_code, test_code, data_files=None): """ Compile and run Java code with test cases. Inputs: @@ -76,6 +106,7 @@ def load_and_run_java_tests(java_code, test_code): test_code (str): The Java test cases -- a JUnit test class (extends CodeTestHelper, methods annotated with @Test, no main()), matching the standard Runestone suffix_code convention used for regular activecode runs. + data_files (list): the question's data files to place beside the program (see _push_data_files) Output: bool: True if all tests pass, False otherwise. """ @@ -142,6 +173,8 @@ def extract_class_name(code): "body": put.text[:500], } + data_file_list = _push_data_files(sess, data_files) + # Mirrors livecode.js: run the JUnit test class via JUnitCore rather than # calling a main() the test class doesn't have. runner_code = f"""import org.junit.runner.JUnitCore; @@ -167,7 +200,8 @@ def extract_class_name(code): "file_list": [ [student_id, student_filename], [test_id, test_filename], - ], + ] + + data_file_list, } resp = sess.post(runs_url, json={"run_spec": runspec}, timeout=10) @@ -199,7 +233,7 @@ def wasSuccessful(self) -> bool: return self._passed -def load_and_run_tests(unittest_case, code_to_test, time_limit=6): +def load_and_run_tests(unittest_case, code_to_test, time_limit=6, data_files=None): """ Run Python test cases against the provided code via JOBE. @@ -207,6 +241,7 @@ def load_and_run_tests(unittest_case, code_to_test, time_limit=6): unittest_case (str): unittest source (class myTests(unittest.TestCase): ...) code_to_test (str): the Python solution code to validate time_limit (int): JOBE wall-clock time limit in seconds + data_files (list): the question's data files to place beside the program (see _push_data_files) Output: _JobeTestResult with wasSuccessful() method """ # Suppress __main__ guards in student code — JOBE runs as top-level script @@ -230,6 +265,8 @@ def load_and_run_tests(unittest_case, code_to_test, time_limit=6): "sourcefilename": "solution.py", "parameters": {"timelimitsecs": time_limit}, } + if data_files: + runspec["file_list"] = _push_data_files(sess, data_files) resp = sess.post(runs_url, json={"run_spec": runspec}, timeout=time_limit + 10) try: result = resp.json() @@ -372,20 +409,25 @@ def remove_java_comments(code): def unittest_evaluation( - language, fixed_code, starting_code, default_test_code, unittest_case + language, + fixed_code, + starting_code, + default_test_code, + unittest_case, + data_files=None, ): if language == "java": return java_unittest_evaluation( - fixed_code, starting_code, default_test_code, unittest_case + fixed_code, starting_code, default_test_code, unittest_case, data_files ) else: return python_unittest_evaluation( - fixed_code, starting_code, default_test_code, unittest_case + fixed_code, starting_code, default_test_code, unittest_case, data_files ) def java_unittest_evaluation( - fixed_code, starting_code, default_test_code, unittest_case + fixed_code, starting_code, default_test_code, unittest_case, data_files=None ): try: fixed_code.split("\n") @@ -397,7 +439,9 @@ def java_unittest_evaluation( try: print("fixed_code_test", fixed_code) - java_test_result = load_and_run_java_tests(fixed_code, unittest_case) + java_test_result = load_and_run_java_tests( + fixed_code, unittest_case, data_files + ) print("java_results\n", java_test_result) return java_test_result, fixed_code except Exception: @@ -405,7 +449,7 @@ def java_unittest_evaluation( def python_unittest_evaluation( - fixed_code, starting_code, default_test_code, unittest_case + fixed_code, starting_code, default_test_code, unittest_case, data_files=None ): """ Load and run Python test cases against the provided code. @@ -414,6 +458,7 @@ def python_unittest_evaluation( starting_code (str): The default starting code provided to the student. Now it's "" default_test_code (str): The default test code provided to the student. Now it's "". unittest_case (str): The Python test cases. The test code is automatically reformatted based on the unittest_code provided by instructors in the RST file. + data_files (list): The question's data files the code may open (see _push_data_files). Output: bool: True if all tests pass, False otherwise. str: The cleaned fixed code after removing comments and empty lines. @@ -429,13 +474,15 @@ def python_unittest_evaluation( return False, fixed_code try: ##print("fixed_code_first attempt", fixed_code) - results = load_and_run_tests(unittest_case, fixed_code) + results = load_and_run_tests(unittest_case, fixed_code, data_files=data_files) print("results.wasSuccessful()\n", results.wasSuccessful()) return results.wasSuccessful(), fixed_code except Exception: try: fixed_code = fix_indentation(fixed_code) - results = load_and_run_tests(unittest_case, fixed_code) + results = load_and_run_tests( + unittest_case, fixed_code, data_files=data_files + ) # print("fix_indentation", fixed_code) if contain_default_starting_code(starting_code, fixed_code): # print("results.wasSuccessful()\n", results.wasSuccessful()) @@ -447,7 +494,12 @@ def python_unittest_evaluation( def code_distractor_unittest_evaluation( - language, code_with_distrator, starting_code, default_test_code, unittest_case + language, + code_with_distrator, + starting_code, + default_test_code, + unittest_case, + data_files=None, ): """ Evaluate the code with distractors using unit tests. @@ -457,6 +509,7 @@ def code_distractor_unittest_evaluation( starting_code (str): The default starting code provided to the student. Now it's "" default_test_code (str): The default test code provided to the student. Now it's "". unittest_case (str): The Python/Java test cases. The test code is automatically reformatted based on the unittest_code provided by instructors in the RST file. + data_files (list): The question's data files the code may open (see _push_data_files). Output: bool: True if all tests pass, False otherwise. str: The cleaned code with distractors after removing comments and empty lines. @@ -464,14 +517,16 @@ def code_distractor_unittest_evaluation( if language == "java": try: java_test_result = load_and_run_java_tests( - code_with_distrator, unittest_case + code_with_distrator, unittest_case, data_files ) return java_test_result, code_with_distrator except Exception: return False, code_with_distrator else: try: - results = load_and_run_tests(unittest_case, code_with_distrator) + results = load_and_run_tests( + unittest_case, code_with_distrator, data_files=data_files + ) if contain_default_starting_code(starting_code, code_with_distrator): return results.wasSuccessful(), code_with_distrator else: @@ -479,7 +534,9 @@ def code_distractor_unittest_evaluation( except Exception: try: code_with_distrator = fix_indentation(code_with_distrator) - results = load_and_run_tests(unittest_case, code_with_distrator) + results = load_and_run_tests( + unittest_case, code_with_distrator, data_files=data_files + ) if contain_default_starting_code(starting_code, code_with_distrator): return results.wasSuccessful(), code_with_distrator else: diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py b/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py index 2048bab17..31db4223f 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py @@ -224,7 +224,9 @@ def get_fixed_code( return fixed_code_response -def get_example_solution(api_token, language, problem_description, unittest_code): +def get_example_solution( + api_token, language, problem_description, unittest_code, data_files=None +): """ Get an example solution for the coding problem using the LLM, called when we do not have an instructor-provided example solution. Inputs: @@ -232,6 +234,7 @@ def get_example_solution(api_token, language, problem_description, unittest_code language (str): The programming language of the code ("python" or "java"). problem_description (str): The description of the coding problem. unittest_code (str): The unittest code to validate the fixed code. + data_files (list): The question's data files the code may open. Output: str: The generated example solution code. Or an empty string if the LLM-generated code does not pass the unittest. """ @@ -278,7 +281,12 @@ def get_example_solution(api_token, language, problem_description, unittest_code # test if the LLM_example_code is correct remove all potential # LLM_example_code = LLM_example_code.lstrip("#").rstrip("#").strip() unittest_result, cleaned_LLM_example_code = unittest_evaluation( - language, LLM_example_code, "", "", unittest_case=unittest_code + language, + LLM_example_code, + "", + "", + unittest_case=unittest_code, + data_files=data_files, ) if unittest_result: # LLM_example_code is correct From 649e5c2212ae02c99e87e5e8b1768b4e21e3cb98 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Tue, 6 Oct 2026 16:58:59 -0400 Subject: [PATCH 2/3] Prefer a cloned course's own datafile over the base book's fetch_source_code matched course_id against base_course OR course_name and returned .first() with no ORDER BY, so when a cloned course has its own copy of a file with the same name as one in the base book, either row could come back. Order the requesting course's row first. This is the lookup both get_source_code (the student's program loading its datafiles) and CodeTailor's data-file resolution use, so the two keep agreeing on which file a program reads. Courses that aren't clones, or clones without an override, resolve exactly as before. Co-Authored-By: Claude Opus 5.5 --- components/rsptx/db/crud/rsfiles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/rsptx/db/crud/rsfiles.py b/components/rsptx/db/crud/rsfiles.py index f22a8db74..06fd3d1cb 100644 --- a/components/rsptx/db/crud/rsfiles.py +++ b/components/rsptx/db/crud/rsfiles.py @@ -172,6 +172,8 @@ async def fetch_source_code( ), ) ) + # A cloned course's own copy of a file overrides the base book's + query = query.order_by((SourceCode.course_id == course_name).desc()) async with async_session() as session: res = await session.execute(query) return SourceCodeValidator.from_orm(res.scalars().first()) From 6296b0c3e43ae92a5f40828aee058ba84f612bd7 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Fri, 9 Oct 2026 16:11:33 -0400 Subject: [PATCH 3/3] Keep every declared datafile; match spaced filenames; test clone precedence _data_file_candidates capped the combined list at 20, so a question declaring more than 20 files in data-datafile silently lost the rest even though livecode.js loads them all. The cap now applies only to filename literals guessed from the code; declared names are always kept. The filename-literal matcher rejected spaces, so open("my data.csv") on an undeclared Skulpt datafile was never looked up. Single internal spaces are now allowed. Add CRUD tests that a cloned course's copy of a file wins over the base book's (by filename and by acid), and that a clone without its own copy still gets the base book's file. Co-Authored-By: Claude Opus 5.5 --- bases/rsptx/book_server_api/routers/coach.py | 22 +++++---- test/components/rsptx/db/test_rsfiles.py | 48 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 2d8372307..621832bf6 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -212,8 +212,13 @@ def _extract_suffix_code_from_htmlsrc(htmlsrc): # A quoted name with a file extension, e.g. open("scores.csv") or # new File("words.txt") -- a possible data file to look up in source_code. -_FILENAME_LITERAL_RE = re.compile(r"""["']([\w\-./]+\.[A-Za-z0-9]{1,8})["']""") -# Bounds the source_code lookups a single request can trigger. +# Single spaces inside the name are allowed, since uploaded datafiles keep +# theirs (open("my data.csv")). +_FILENAME_LITERAL_RE = re.compile( + r"""["']([\w\-./]+(?: [\w\-./]+)*\.[A-Za-z0-9]{1,8})["']""" +) +# Bounds the source_code lookups that guessed filename literals can trigger. +# Names the question declares in data-datafile are always kept. _MAX_DATA_FILE_CANDIDATES = 20 @@ -226,18 +231,19 @@ def _data_file_candidates(htmlsrc, *code_sources): student's code. Names that turn out not to be data files simply miss in the source_code lookup. """ - names = [] + declared = [] htmlsrc = htmlsrc or "" match = re.search(r'data-datafile\s*=\s*"([^"]*)"', htmlsrc) if match: - names.extend(name.strip() for name in match.group(1).split(",")) + declared.extend(name.strip() for name in match.group(1).split(",")) + declared = list(dict.fromkeys(name for name in declared if name)) textarea = re.search(r"]*>(.*?)", htmlsrc, flags=re.DOTALL) question_code = html.unescape(textarea.group(1)) if textarea else "" + inferred = [] for text in (question_code, *code_sources): - names.extend(_FILENAME_LITERAL_RE.findall(text or "")) - return list(dict.fromkeys(name for name in names if name))[ - :_MAX_DATA_FILE_CANDIDATES - ] + inferred.extend(_FILENAME_LITERAL_RE.findall(text or "")) + inferred = [name for name in dict.fromkeys(inferred) if name not in declared] + return declared + inferred[:_MAX_DATA_FILE_CANDIDATES] async def _fetch_data_files(candidates, course_scopes): diff --git a/test/components/rsptx/db/test_rsfiles.py b/test/components/rsptx/db/test_rsfiles.py index 5497b4843..6710333bc 100644 --- a/test/components/rsptx/db/test_rsfiles.py +++ b/test/components/rsptx/db/test_rsfiles.py @@ -70,3 +70,51 @@ async def test_update_source_code_changes_is_binary_on_existing_row(init_test_db ) assert row.is_binary is True assert row.main_code == "second" + + +async def test_fetch_source_code_prefers_cloned_course_copy(init_test_db): + """A cloned course's own copy of a file wins over the base book's. + + The base row is inserted first, so an unordered query would most likely + return it. + """ + await update_source_code( + acid="clone_override.csv", + filename="clone_override.csv", + course_id="test_base_book", + main_code="base", + ) + await update_source_code( + acid="clone_override.csv", + filename="clone_override.csv", + course_id="test_clone_course", + main_code="clone", + ) + by_filename = await fetch_source_code( + base_course="test_base_book", + course_name="test_clone_course", + filename="clone_override.csv", + ) + assert by_filename.main_code == "clone" + by_acid = await fetch_source_code( + base_course="test_base_book", + course_name="test_clone_course", + acid="clone_override.csv", + ) + assert by_acid.main_code == "clone" + + +async def test_fetch_source_code_falls_back_to_base_book(init_test_db): + """A clone without its own copy still gets the base book's file.""" + await update_source_code( + acid="base_only.csv", + filename="base_only.csv", + course_id="test_base_book", + main_code="base", + ) + row = await fetch_source_code( + base_course="test_base_book", + course_name="test_clone_course", + filename="base_only.csv", + ) + assert row.main_code == "base"