From af6f67954c8a6e00757e492b3e95e805d6d3c42d Mon Sep 17 00:00:00 2001 From: Niko Strijbol Date: Mon, 25 May 2026 19:00:30 +0200 Subject: [PATCH] Implement file linking --- tested/dodona.py | 10 ++++- tested/judge/core.py | 20 ++++++++- tested/judge/evaluation.py | 13 ++---- tested/languages/generation.py | 80 ++-------------------------------- 4 files changed, 35 insertions(+), 88 deletions(-) diff --git a/tested/dodona.py b/tested/dodona.py index 90e5f7683..098bd9c5a 100644 --- a/tested/dodona.py +++ b/tested/dodona.py @@ -34,12 +34,20 @@ class ExtendedMessage: permission: Permission | None = None +@define +class LinkedFile: + type: Literal["path", "inline"] + # Either a path (type "file") or the content (type "inline") + content: str + + @define class Metadata: - """Currently only used for the Python tutor""" + """Used for the debugger and file linking""" statements: str | None stdin: str | None + files: dict[str, LinkedFile] | None Message = ExtendedMessage | str diff --git a/tested/judge/core.py b/tested/judge/core.py index 6c54c9fb9..795617bd3 100644 --- a/tested/judge/core.py +++ b/tested/judge/core.py @@ -10,6 +10,7 @@ CloseContext, CloseJudgement, CloseTab, + LinkedFile, Metadata, StartContext, StartJudgement, @@ -46,7 +47,7 @@ generate_statement, ) from tested.serialisation import Statement -from tested.testsuite import LanguageLiterals, MainInput, TextData +from tested.testsuite import ContentPath, LanguageLiterals, MainInput, TextData _logger = logging.getLogger(__name__) @@ -367,11 +368,14 @@ def _process_results( # Don't add empty statements meta_statements = None + meta_files = _get_meta_files(bundle, planned) + collector.add( CloseContext( data=Metadata( statements=meta_statements, stdin=meta_stdin, + files=meta_files, ) ), planned.context_index, @@ -382,3 +386,17 @@ def _process_results( return continue_, currently_open_tab return None, currently_open_tab + + +def _get_meta_files(bundle: Bundle, planned: PlannedContext) -> dict[str, LinkedFile]: + meta_files = {} + for f in planned.context.get_input_files(): + display_path = f.get_display_path() + + if display_path is not None: + meta_files[f.path] = LinkedFile(type="path", content=display_path) + else: + contents = f.get_data_as_string(bundle.config.resources) + meta_files[f.path] = LinkedFile(type="inline", content=contents) + + return meta_files diff --git a/tested/judge/evaluation.py b/tested/judge/evaluation.py index 4542272e8..4bd96200f 100644 --- a/tested/judge/evaluation.py +++ b/tested/judge/evaluation.py @@ -384,14 +384,8 @@ def link_files_message( ) -> AppendMessage | None: link_list = [] for link_file in link_files: - # TODO: handle inline files somehow. - link_url = link_file.get_display_path() - if link_file.path is not None and link_url is not None: - the_url = urllib.parse.quote(link_url) - link_list.append( - f'' - f'{html.escape(link_file.path)}' - ) + if link_file.path is not None: + link_list.append(link_file.path) if len(link_list) == 0: return None # Do not append any message if there are no files. @@ -400,8 +394,7 @@ def link_files_message( file_list_str = get_i18n_string( "judge.evaluation.files", count=len(link_list), files=file_list ) - description = f"

{file_list_str}

" - message = ExtendedMessage(description=description, format="html") + message = ExtendedMessage(description=file_list_str, format="text") return AppendMessage(message=message) diff --git a/tested/languages/generation.py b/tested/languages/generation.py index 36de5a36c..640c110cb 100644 --- a/tested/languages/generation.py +++ b/tested/languages/generation.py @@ -2,15 +2,10 @@ Translates items from the test suite into the actual programming language. """ -import html -import json import logging import re import shlex -import urllib.parse -from collections.abc import Iterable from pathlib import Path -from re import Match from typing import TYPE_CHECKING, TypeAlias from pygments import highlight @@ -29,16 +24,8 @@ prepare_execution_unit, prepare_expression, ) -from tested.parsing import get_converter from tested.serialisation import Expression, Statement, VariableType -from tested.testsuite import ( - ContentPath, - Context, - LanguageLiterals, - MainInput, - Testcase, - TextData, -) +from tested.testsuite import Context, LanguageLiterals, MainInput, Testcase, TextData from tested.utils import is_statement_strict if TYPE_CHECKING: @@ -77,22 +64,6 @@ def generate_execution_unit( return bundle.language.generate_execution_unit(prepared_execution) -def _handle_link_files( - link_files: Iterable[TextData], language: str -) -> tuple[str, str]: - dict_links = dict( - (link_file.path, get_converter().unstructure(link_file)) - for link_file in link_files - ) - files = json.dumps(dict_links) - return ( - f"
",
-        "
", - ) - - def _get_heredoc_token(stdin: str) -> str: delimiter = "STDIN" while delimiter in stdin: @@ -163,10 +134,6 @@ def get_readable_input( if case.line_comment: text = f"{text} {bundle.language.comment(case.line_comment)}" - # If there are no files, return now. This means we don't need to do ugly stuff. - if not case.input_files: - return ExtendedMessage(description=text, format=format_), set() - # We have potential files. # Check if the file names are present in the string. # If not, we can also stop before doing ugly things. @@ -174,50 +141,11 @@ def get_readable_input( file_paths = [re.escape(x.path) for x in case.input_files if x.path is not None] if not file_paths: - # No files to match, so bail now. return ExtendedMessage(description=text, format=format_), set() - simple_regex = re.compile("|".join(file_paths)) - - if not simple_regex.search(text): - # There is no match, so bail now. - return ExtendedMessage(description=text, format=format_), set() - - # Now we need to do ugly stuff. - # Begin by compiling the HTML that will be displayed. - if format_ == "text": - generated_html = html.escape(text) - elif format_ == "console": - generated_html = highlight_code(text) - else: - generated_html = highlight_code(text, bundle.config.programming_language) - - # Map of file URLs. - url_map = {html.escape(x.path): x for x in case.input_files if x.path is not None} - - seen = set() - escaped_regex = re.compile("|".join(url_map.keys())) - - # Replaces the match with the corresponding link. - def replace_link(match: Match) -> str: - filename = match.group() - the_file = url_map[filename] - the_url = the_file.get_display_path() - if the_url is None: - # TODO: how to handle inline files? - return filename - - quoted_url = urllib.parse.quote(the_url) - the_replacement = ( - f'{filename}' - ) - seen.add(the_file) - return the_replacement - - generated_html = escaped_regex.sub(replace_link, generated_html) - prefix, suffix = _handle_link_files(seen, format_) - generated_html = f"{prefix}{generated_html}{suffix}" - return ExtendedMessage(description=generated_html, format="html"), seen + path_map = {x.path: x for x in case.input_files if x.path is not None} + seen = {path_map[m.group()] for m in re.finditer("|".join(file_paths), text)} + return ExtendedMessage(description=text, format=format_), seen def attempt_readable_input(bundle: Bundle, context: Context) -> ExtendedMessage: