From 90d7c2ff69cf5480ac28989c4babdeed0ba97992 Mon Sep 17 00:00:00 2001 From: Vesa Lappalainen Date: Wed, 9 Oct 2024 15:25:51 +0300 Subject: [PATCH] idesupport: IDE languages to use user code (#3725) * Tide languages * Run black * Add partial type hints to ide_languages * CsPlugin: restore imports * hack for jypeli rerun missing arguments * TODO comment * Clean up, add docs * Revert package-lock.json * Suplementary files: Do not indent too much * Tide-language: Correct spelling * tide: error handling * tide: use usercode if available * tide: use usercode if available, correct spelling * Update timApp/idesupport/ide_languages.py * Update timApp/idesupport/ide_languages.py * Update timApp/idesupport/utils.py --------- Co-authored-by: sijualle Co-authored-by: dezhidki --- timApp/idesupport/files.py | 17 +++ timApp/idesupport/ide_languages.py | 78 +++++++++--- timApp/idesupport/utils.py | 195 ++++++++++++++--------------- 3 files changed, 175 insertions(+), 115 deletions(-) diff --git a/timApp/idesupport/files.py b/timApp/idesupport/files.py index e04b7f146d..dd51ef30d7 100644 --- a/timApp/idesupport/files.py +++ b/timApp/idesupport/files.py @@ -5,6 +5,8 @@ import re from dataclasses import dataclass +from tim_common.utils import type_splitter + @dataclass class SupplementaryFile: @@ -44,3 +46,18 @@ def is_in_filename(files: list[dict[str, str]], regexp: str) -> bool: if name and re.match(regexp, name): return True return False + + +def get_task_language(task_type: str | None) -> str | None: + """ + Get the language of the task + :param task_type: Type of the task + :return: Language of the task + """ + + if task_type is not None: + type_split = type_splitter.split(task_type.lower()) + if len(type_split) > 0: + return type_split[0] + + return None diff --git a/timApp/idesupport/ide_languages.py b/timApp/idesupport/ide_languages.py index 7c7e6b0887..0895b11a0c 100644 --- a/timApp/idesupport/ide_languages.py +++ b/timApp/idesupport/ide_languages.py @@ -7,7 +7,7 @@ import textwrap from typing import Any -from timApp.idesupport.files import SupplementaryFile, is_in_filename +from timApp.idesupport.files import SupplementaryFile, is_in_filename, get_task_language from tim_common.cs_utils import populated DOTNET_VERSION = "net$(NETCoreAppMaximumVersion)" # "net8.0" @@ -28,8 +28,13 @@ class Language: Should be equivalent to csPlugin language types. """ - def __init__(self, plugin_json: dict): - self.fileext = "" + def __init__(self, plug_json: dict | None = None): + if plug_json: + plugin_json = plug_json + else: + plugin_json = {} + ext = plugin_json.get("markup", {}).get("type", "") + self.fileext = get_task_language(ext) """ File extension to use for the source code files. """ @@ -41,6 +46,8 @@ def __init__(self, plugin_json: dict): self.plugin_json = plugin_json self.ide_task_id = "" self.filename = self.init_filename() + if self.filename is None: + self.filename = self.filename_from_id() def find_comment_line_characters(self) -> str: return self.comment_syntax_lookup @@ -51,7 +58,15 @@ def init_filename(self) -> str: :return: The file name to use for the main file of the task. """ - return self.plugin_json["markup"].get("filename") + return self.plugin_json.get("markup", {}).get("filename") + + def filename_from_id(self) -> str: + """ + Give file name from taskID + :return: filename or main + """ + tid = self.plugin_json.get("taskID", "1.main").split(".", 2)[1] + return tid def get_filename(self) -> str: """ @@ -61,6 +76,11 @@ def get_filename(self) -> str: @staticmethod def get_classname(s: str | None) -> str | None: + """ + Tries to find classname from source code + :param s: source code to look + :return: classname if found + """ if s is None: return None @@ -70,12 +90,30 @@ def get_classname(s: str | None) -> str | None: return None return match.group(1) - def try_to_get_classname(self) -> str | None: - clsname = Language.get_classname(self.plugin_json.get("program")) + @staticmethod + def try_to_get_classname_from(d: dict) -> str | None: + """ + Tries to get classname from dict + :param d: dict to look + :return: classname if found + """ + clsname = Language.get_classname(d.get("program")) if clsname is None: - clsname = Language.get_classname(self.plugin_json.get("by")) + clsname = Language.get_classname(d.get("by")) if clsname is None: - clsname = Language.get_classname(self.plugin_json.get("byCode")) + clsname = Language.get_classname(d.get("byCode")) + return clsname + + def try_to_get_classname(self) -> str | None: + """ + Tries to get classname from plugin_json os markup + :return: classname if found + """ + clsname = Language.try_to_get_classname_from(self.plugin_json) + if clsname is None: + clsname = Language.try_to_get_classname_from( + self.plugin_json.get("markup", {}) + ) return clsname def generate_supplementary_files( @@ -91,7 +129,7 @@ def generate_supplementary_files( return [] @staticmethod - def make_language(ttype: str, plugin_json: Any, ide_task_id: str) -> "Language": + def make_language(ttype: str, plugin_json: dict, ide_task_id: str) -> "Language": """ Initialize the language handler for a specific language type. @@ -114,10 +152,18 @@ def all_subclasses(cls) -> Any: return subclasses + [i for sc in subclasses for i in sc.all_subclasses()] +class Text(Language): + ttype: str | list[str] = ["text"] + + def __init__(self, plugin_json: dict): + super().__init__(plugin_json) + self.fileext = "txt" + + class CS(Language): ttype: str | list[str] = ["cs", "c#", "csharp"] - def __init__(self, plugin_json: Any): + def __init__(self, plugin_json: dict): super().__init__(plugin_json) self.fileext = "cs" clsname = self.try_to_get_classname() @@ -129,8 +175,6 @@ def init_filename(self) -> str: filename = super().init_filename() if filename is None: filename = self.try_to_get_classname() - if filename is None: - filename = "Main.cs" # TODO return filename def generate_supplementary_files( @@ -157,7 +201,7 @@ def generate_supplementary_files( class Jypeli(CS): ttype = "jypeli" - def __init__(self, plugin_json: Any): + def __init__(self, plugin_json: dict): super().__init__(plugin_json) def generate_supplementary_files( @@ -218,7 +262,7 @@ def generate_supplementary_files( class PY3(Language): ttype = ["py", "py3", "python", "python3"] - def __init__(self, plugin_json: Any): + def __init__(self, plugin_json: dict): super().__init__(plugin_json) self.comment_syntax_lookup = "#" self.fileext = "py" @@ -227,7 +271,7 @@ def __init__(self, plugin_json: Any): class CC(Language): ttype: str | list[str] = "cc" - def __init__(self, plugin_json: Any): + def __init__(self, plugin_json: dict): super().__init__(plugin_json) self.fileext = "c" @@ -235,7 +279,7 @@ def __init__(self, plugin_json: Any): class CPP(CC): ttype = ["c++", "cpp"] - def __init__(self, plugin_json: Any): + def __init__(self, plugin_json: dict): super().__init__(plugin_json) self.fileext = "cpp" @@ -243,7 +287,7 @@ def __init__(self, plugin_json: Any): class Java(Language): ttype = "java" - def __init__(self, plugin_json: Any): + def __init__(self, plugin_json: dict): super().__init__(plugin_json) self.fileext = "java" diff --git a/timApp/idesupport/utils.py b/timApp/idesupport/utils.py index 3b6f975b90..4b98ce70ad 100644 --- a/timApp/idesupport/utils.py +++ b/timApp/idesupport/utils.py @@ -17,17 +17,17 @@ from timApp.document.docparagraph import DocParagraph from timApp.document.usercontext import UserContext from timApp.document.viewcontext import default_view_ctx -from timApp.idesupport.files import SupplementaryFile +from timApp.idesupport.files import SupplementaryFile, get_task_language from timApp.idesupport.ide_languages import Language from timApp.plugin.containerLink import render_plugin_multi -from timApp.plugin.plugin import Plugin, PluginRenderOptions, PluginWrap +from timApp.plugin.plugin import Plugin, PluginRenderOptions, PluginWrap, find_task_ids +from timApp.plugin.pluginControl import get_answers, AnswerMap from timApp.plugin.pluginOutputFormat import PluginOutputFormat from timApp.plugin.taskid import TaskId from timApp.printing.printsettings import PrintFormat from timApp.user.user import User from timApp.util.flask.requesthelper import NotExist, RouteException from tim_common.marshmallow_dataclass import class_schema -from tim_common.utils import type_splitter IDE_TASK_TAG = "ideTask" # Identification tag for the TIDE-task @@ -59,17 +59,22 @@ class IdeFile: userargs: str | None = "" """ User arguments for the file """ + # usercode: str | None = "" + # """ User code for the file """ + content: str | None = field(init=False) """File contents provided for IDE""" - language: Language | None = field(init=False) + # TODO: Maybe should be field(default_factory=Language) + language: Language = Language() def __post_init__(self) -> None: self.content = "" - def set_combined_code(self) -> None: + def set_combined_code(self, usercode: str) -> None: """Combine the code blocks and set the combined code to the content var. + :param usercode: None or user last answer """ # Program can be considered as a boilerplate boilerplate = self.program @@ -83,11 +88,15 @@ def set_combined_code(self) -> None: if self.byCode is not None: user_code = self.byCode - if boilerplate is None and user_code is None: - raise RouteException("No code found in the plugin") + if usercode is not None: + user_code = usercode + + # TODO: think if there is need for code? Why empty file is not ok? + # if boilerplate is None and user_code is None: + # raise RouteException("No code found in the plugin") if boilerplate is None: - self.content = user_code + self.content = user_code or "" return # If no dedicated user editable code, the whole boilerplate is editable @@ -99,7 +108,7 @@ def set_combined_code(self) -> None: raise RouteException("File name not provided") # Find the "comment line" characters based on the file extension and create messages. - comment_line_characters = find_comment_line_characters(filename.split(".")[-1]) + comment_line_characters = self.language.find_comment_line_characters() user_code_begins_message = ( comment_line_characters + " --- Write your code below this line. ---" @@ -138,7 +147,7 @@ def generate_file_extension(self, task_type: str) -> None: # PLEASE NOTE: task_info.type could be fancy like c++/input/comtest if self.language is None: raise RouteException("Misconfigured task language") - self.filename += "." + self.language.fileext + self.filename += "." + str(self.language.fileext) # Convert to json and set code based on 'by' or 'byCode' def to_json(self) -> dict[str, str | None]: @@ -322,25 +331,6 @@ class TIDECourse: """ -def find_comment_line_characters(file_extension: str) -> str: - """ - Find the comment line syntax based on the type. - - :param file_extension: Type of the file - :return: Comment line syntax - """ - comment_syntax_lookup = { - "cpp": "//", - "c": "//", - "cs": "//", - "java": "//", - "py": "#", - "js": "//", - } - - return comment_syntax_lookup.get(file_extension, "//") - - def get_user_ide_courses(user: User) -> list[TIDECourse]: """ Gets all courses that have parameter for Ide course in course settings and are bookmarked by the user @@ -453,18 +443,20 @@ def get_ide_task_set_documents_by_doc( return paths -def get_ide_tasks( - user: User, doc_id: int | None = None, doc_path: str | None = None +def get_ide_tasks_implementation( + user: User, + doc_id: int | None = None, + doc_path: str | None = None, + ide_task_id: str | None = None, ) -> list[TIDEPluginData]: """ - Get all TIDE-tasks from the task set document + Find ide tasks from pars and get user answers :param user: Logged-in user :param doc_id: Document id :param doc_path: Path to the task set document - :return: List of TIDEPluginData from the task set document or RouteException - :raise In case of an error raises RouteException + :param ide_task_id: ideTask to find or get all if None + :return: list of ideTasks """ - if doc_path is not None: doc = DocEntry.find_by_path(path=doc_path) @@ -486,18 +478,58 @@ def get_ide_tasks( tasks = [] for p in pars: - if p.attrs is not None: - tag = p.attrs.get(IDE_TASK_TAG) - if tag is not None: - if tag == "": - tag = p.attrs.get("taskId") - if tag is None: - raise RouteException("Missing taskID!") - task = get_ide_user_plugin_data( - doc=doc, par=p, user_ctx=user_ctx, ide_task_id=tag - ) - if task: - tasks.append(task) + tag: str | None = "???" + try: + if p.attrs is not None: + tag = p.attrs.get(IDE_TASK_TAG) + if tag is not None: + if tag == "": + tag = p.attrs.get("taskId") + if tag is None: + raise RouteException("Missing taskID!") + if ide_task_id and ide_task_id != tag: + continue + task = get_ide_user_plugin_data( + doc=doc, par=p, user_ctx=user_ctx, ide_task_id=tag + ) + if task: + tasks.append(task) + except Exception as e: + task = TIDEPluginData( + task_files=[], + supplementary_files=[ + SupplementaryFile( + filename="error.txt", + content="Error: " + str(e), + ) + ], + header="Error: " + str(e), + stem="Error: " + str(e), + type="error", + path=doc.path, + task_id=(p.attrs or {}).get("taskId"), + doc_id=doc.id, + par_id=p.id, + ide_task_id="error_" + (tag or "???"), + ) + tasks.append(task) + + return tasks + + +def get_ide_tasks( + user: User, doc_id: int | None = None, doc_path: str | None = None +) -> list[TIDEPluginData]: + """ + Get all TIDE-tasks from the task set document + :param user: Logged-in user + :param doc_id: Document id + :param doc_path: Path to the task set document + :return: List of TIDEPluginData from the task set document or RouteException + :raise In case of an error raises RouteException + """ + + tasks = get_ide_tasks_implementation(user, doc_id, doc_path) if len(tasks) == 0: raise NotExist( @@ -523,39 +555,7 @@ def get_ide_task_by_id( :raises In case of an error raises RouteException """ - if doc_id is not None: - doc = DocEntry.find_by_id(doc_id=doc_id) - elif doc_path is not None: - doc = DocEntry.find_by_path(path=doc_path) - else: - raise RouteException("No document id or path given") - # If the document does not exist, raise NotExist - if doc is None: - raise NotExist("No document found") - - # Check if the user has edit access to the document - verify_view_access(doc, user=user) - - user_ctx = UserContext.from_one_user(u=user) - - pars = doc.document.get_paragraphs() - - if pars is None: - raise NotExist("No paragraphs found") - - tasks = [] - - for p in pars: - if p.attrs is not None: - id = p.attrs.get(IDE_TASK_TAG) - if id == "": - id = p.attrs.get("taskId") - if id == ide_task_id: - task = get_ide_user_plugin_data( - doc=doc, par=p, user_ctx=user_ctx, ide_task_id=ide_task_id - ) - if task: - tasks.append(task) + tasks = get_ide_tasks_implementation(user, doc_id, doc_path, ide_task_id) if len(tasks) == 0: raise RouteException( @@ -570,21 +570,6 @@ def get_ide_task_by_id( raise RouteException("Multiple tasks found, support not implemented yet") -def get_task_language(task_type: str | None) -> str | None: - """ - Get the language of the task - :param task_type: Type of the task - :return: Language of the task - """ - - if task_type is not None: - type_split = type_splitter.split(task_type) - if len(type_split) > 0: - return type_split[0] - - return None - - def get_ide_user_plugin_data( doc: DocInfo, par: DocParagraph, @@ -620,7 +605,19 @@ def get_ide_user_plugin_data( viewmode=view_ctx.viewmode, ) + # Get user answer for the task + task_ids, _, _ = find_task_ids( + [par], view_ctx, user_ctx, check_access=user_ctx.is_different + ) + answer_map: AnswerMap = {} + answer = None + get_answers(user_ctx.user, task_ids, answer_map) + if answer_map: # If there was an answer + first_key = next(iter(answer_map)) + answer = answer_map[first_key][0] + plugin.set_render_options(None, plugin_opts) + plugin.answer = answer res = render_plugin_multi(doc.document.get_settings(), "csPlugin", [plugin]) plugin_htmls = json.loads(res) plugin_html = BeautifulSoup(plugin_htmls[0], features="lxml") @@ -663,15 +660,17 @@ def get_ide_user_plugin_data( # if the plugin has no code, look from markup if ide_file.by is None and ide_file.byCode is None and ide_file.program is None: ide_file = IdeFileSchema.load(plugin_json["markup"], unknown=EXCLUDE) + ide_file.language = language if ide_file.taskIDExt is None: if plugin_json.get("taskIDExt"): ide_file.taskIDExt = plugin_json["taskIDExt"] else: return None + # TODO: Think if error is needed? # If the plugin still has no code, return error - if ide_file.by is None and ide_file.byCode is None and ide_file.program is None: - raise RouteException("No code found in the plugin") + # if ide_file.by is None and ide_file.byCode is None and ide_file.program is None: + # raise RouteException("No code found in the plugin") # if the ide_file has no filename, try to look it from the markup if ide_file.filename is None: @@ -681,7 +680,7 @@ def get_ide_user_plugin_data( if task_info.type is not None: ide_file.generate_file_extension(task_info.type) - ide_file.set_combined_code() + ide_file.set_combined_code(plugin_json.get("usercode")) json_ide_files = [ide_file.to_json()] ide_extra_files = plugin_json["markup"].get("ide_extra_files") or []