diff --git a/canvas.py b/canvas.py index 78cff9d..a8e47ea 100644 --- a/canvas.py +++ b/canvas.py @@ -215,9 +215,31 @@ def students(self): class CourseSubObject(Canvas): + """A Canvas element that is owned (directly or indirectly) by a Course. + + Guaranteed to have instance fields: + - data (the dictionary of Canvas data associated with this object) + - id_field (the name of the field in data used as ID/key in Canvas) + - id (this object's specific ID; should be cached when recomputed) + - route_name (the Canvas API URL element specific to this type of object) + - url_prefix (the entire base for Canvas API URLs referring to this type of object; + should be cached when recomputed) + - request_param_name (the Canvas API URL element specific to this type of + object when referring to a single object (e.g., for updates)) + + Also supports direct dictionary-style indexing, which accesses/updates the data field. + """ # If not provided, the request_param_name defaults to the lower-cased class name. def __init__(self, parent, route_name, data, id_field='id', request_param_name=None): + """Construct a CourseSubObject with the given parent, Canvas API route_name, and Canvas data. + + The route_name is use to construct REST API URLs applying to this object. The data is + the object's content (as a dictionary) and used in updates to Canvas. The id_field is the + key used to identify this object in Canvas (also used for API calls). The request_param_name + is used for API calls as well (e.g., for PUT-based updates), defaulting to the lowercased + class name. + """ # MUST be available before calling self.get_course. self.parent = parent super().__init__(self.get_course().token) @@ -232,31 +254,46 @@ def __init__(self, parent, route_name, data, id_field='id', request_param_name=N self.request_param_name = request_param_name def get_course(self): + """Get the Course that owns this object. + + Traverses parents until it reaches a parent that is a course. + """ if isinstance(self.parent, Course): return self.parent else: return self.parent.get_course() def compute_id(self): + """Get the ID of this object.""" return self.data[self.id_field] def compute_base_url(self): + """Get the entire base for Canvas API URLs referring to this type of object""" return f'{self.parent.url_prefix}/{self.route_name}' def compute_url_prefix(self): + """Get the entire base for Canvas API URLs referring to this particular object.""" return f'{self.compute_base_url()}/{self.id}' def __getitem__(self, index): + """Index into self.data""" return self.data[index] def __setitem__(self, index, value): + """Update self.data""" self.data[index] = value def items(self): - """ docstring """ + """Get all items in self.data""" return self.data.items() def update(self, data=None): + """Update Canvas with new data for this object. + + Updates the stored data with the given data if it is non-None. + Then, updates Canvas (posting a new object if self.id is absent). + Returns self for chaining. + """ if data: self.data = data if self.id: @@ -271,13 +308,18 @@ def update(self, data=None): class Quiz(CourseSubObject): - """ Quiz """ + """A Canvas Quiz object.""" def __init__(self, course, quiz_data): + """Creates a new Quiz with a course as parent, and initial Canvas quiz data.""" super().__init__(course, "quizzes", quiz_data) def update_quiz(self, data=None): - """ docstring """ + """Update this quiz on Canvas. + + Updates the stored data with the given data if it is non-None. + Then updates Canvas with the stored data. Returns self for chaining. + """ return self.update(data) def question_group(self, group_id): @@ -396,8 +438,13 @@ def send_quiz_grade(self, quiz_submission, class QuizQuestion(CourseSubObject): - # If the quiz is not supplied, fetches it via quiz_question_data['quiz_id']. + """A Canvas object representing a Quiz Question.""" + def __init__(self, quiz_question_data, quiz=None): + """Create a new QuizQuestion with the given data and with the given quiz as parent. + + If no quiz is supplied, fetches it via quiz_question_data['quiz_id']. + """ if quiz is None: if 'quiz_id' not in quiz_question_data: raise RuntimeError( @@ -406,6 +453,14 @@ def __init__(self, quiz_question_data, quiz=None): super().__init__(quiz, "questions", quiz_question_data, request_param_name='question') def update(self, data=None): + """Update this QuizQuestion on Canvas. + + Updates the stored data with the given data if it is non-None. + Then updates Canvas with the stored data. Returns self for chaining. + + Attempts to handle differences in format between input and output + of quiz questions in the Canvas API. + """ if data: self.data = data @@ -429,16 +484,30 @@ def update(self, data=None): return super().update(self.data) def update_question(self, data=None): + """Update this QuizQuestion on Canvas. + + Updates the stored data with the given data if it is non-None. + Then updates Canvas with the stored data. Returns self for chaining. + + Attempts to handle differences in format between input and output + of quiz questions in the Canvas API. + """ return self.update(data) class Assignment(CourseSubObject): - """ Assignment """ + """A Canvas assignment object.""" def __init__(self, course, assg_data): + """Create a new Assignment with course as parent and the given assignment data.""" super().__init__(course, "assignments", assg_data) def update_assignment(self, data=None): + """Update this Assignment on Canvas. + + Updates the stored data with the given data if it is non-None. + Then updates Canvas with the stored data. Returns self for chaining. + """ return self.update(data) def rubric(self): @@ -468,10 +537,17 @@ def send_assig_grade(self, student, assessment): class Page(CourseSubObject): + """A Canvas page (wikipage) object.""" def __init__(self, course, page_data): + """Create a Page with course as parent and the given page data.""" super().__init__(course, "pages", page_data, id_field="url", request_param_name="wiki_page") def update_page(self, data=None): + """Update this page on Canvas. + + Updates the stored data with the given data if it is non-None. + Then updates Canvas with the stored data. Returns self for chaining. + """ return self.update(data) diff --git a/processtext.py b/processtext.py index 834b134..e6e8ff1 100644 --- a/processtext.py +++ b/processtext.py @@ -1,14 +1,19 @@ import canvas +from collections.abc import Callable +from typing import Any, Optional, Union import argparse import re import sys # TODO: help users with creating regular expressions (e.g., have a mode that displays the regexes and takes a test string to act on but doesn't act on Canvas?) # TODO: make optionally interactive with confirmation of changes (via diffing?) -# TODO: test _html versus regular versions of comment fields; test madness about field names changing (see QuizQuestion update and Jonatan's original code for this) +# TODO: test _html versus regular versions of comment fields; test madness about field names changing (see QuizQuestion update and Jonatan's original code for this) # TODO: carefully publish quizzes that were already published beforehand OR have an option to do this? (See: https://community.canvaslms.com/t5/Question-Forum/Saving-Quizzes-w-API/td-p/226406); beware of publishing previously unpublished quizzes, of publishing a quiz in its update but BEFORE the questions are updated (??), and the like. + class BetterErrorParser(argparse.ArgumentParser): + """ArgumentParser that shows the error and optionally help and then exits on errors.""" + def error(self, message, help=True): sys.stderr.write('error: %s\n' % message) if help: @@ -16,14 +21,31 @@ def error(self, message, help=True): sys.exit(2) -def update_objects(objects, type_name, update_fn): +def update_objects(objects: list[canvas.CourseSubObject], + type_name: str, + update_fn: Callable[[canvas.CourseSubObject], Any]) -> None: + """Call update_fn on each element of objects (which should be of the given type).""" print("Processing %s objects" % type_name) for obj in objects: update_fn(obj) -def make_regex_repl_text_process(regex, repl): + +def make_regex_repl_text_process(regex: str, repl: str) -> Callable[[Optional[str]], Optional[str]]: + """Return a function to search for regex and replace with repl. + + The returned function just returns None if its argument is None. + Otherwise, it globally searches for regex, making as many substitutions + as possible with repl. If at least one substitution occurs, returns the + resulting text. Else, returns None. + """ compiled_regex = re.compile(regex) - def text_process(text): + + def text_process(text: Optional[str]) -> Optional[str]: + """Returns the result of processing text with a built-in regex/replacement. + + If text is None, returns None. If no substitution happens, returns None. + Else, returns the substituted text. + """ if text is None: return None (new_text, count) = compiled_regex.subn(repl, text, count=0) @@ -38,16 +60,40 @@ def text_process(text): # # title_field is either None (for no title field) or a string naming the title field # (only for logging). A field can be both title_field and one of the text_fields. -def make_update_text(text_process_fn, text_fields, title_field=None): - text_fields = text_fields[:] if isinstance(text_fields, list) else[text_fields] - def update_text(obj): + + +def make_update_text(text_process_fn: Callable[[Optional[str]], Optional[str]], + text_fields: Union[str, list[str]], + title_field: Optional[str] = None) -> Callable[[canvas.CourseSubObject], None]: + """Return a function to update text fields in a CourseSubObject via text_process_fn. + + The returned function calls text_process_fn on each field indicated by + text_fields (which is just the one if text_fields is a str), updating + the value(s) of the field(s) with text_process_fn's return value whenever + it returns a str. (If a field is missing or has a None value, text_process_fn + receives None.) + + Updates may not occur after each call to text_process_fn (i.e., may be batched). + + The title_field is used for logging if given. A field can be both a title_field + and in text_fields. + """ + text_fields = text_fields[:] if isinstance( + text_fields, list) else[text_fields] + + def update_text(obj: canvas.CourseSubObject) -> None: + """Calls the built-in text_process_fn on text fields in obj, updating as needed. + + The specific text_fields, title_field for logging, and the processing function + are all created by an earlier call that generates this function. + """ print() print("---------------------------------------------------------------") if title_field: print("Processing object: %s" % obj[title_field]) else: print("Processing next object") - + update_needed = False for text_field in text_fields: print("Processing text field: %s" % text_field) @@ -67,7 +113,7 @@ def update_text(obj): print(new_value) print() print() - + if update_needed: print("Making update on Canvas...") obj.update() @@ -79,16 +125,23 @@ def update_text(obj): print("Done processing object") return update_text + parser = BetterErrorParser() canvas.Canvas.add_arguments(parser) -parser.add_argument("-a", "--assignments", help="Process assignments.", action="store_true") -parser.add_argument("-p", "--pages", help="Process pages.", action="store_true") -parser.add_argument("-q", "--quizzes", help="Process quizzes.", action="store_true") -parser.add_argument("-A", "--all", help="Process all types (assignments, pages, and quizzes).", action="store_true") -parser.add_argument("regex", help="The regular expression (using Python's re syntax) to search for.") -parser.add_argument("repl", help="The replacement string (using Python's syntax from re.sub) with which to replace regex.") +parser.add_argument("-a", "--assignments", + help="Process assignments.", action="store_true") +parser.add_argument("-p", "--pages", help="Process pages.", + action="store_true") +parser.add_argument("-q", "--quizzes", + help="Process quizzes.", action="store_true") +parser.add_argument( + "-A", "--all", help="Process all types (assignments, pages, and quizzes).", action="store_true") +parser.add_argument( + "regex", help="The regular expression (using Python's re syntax) to search for.") +parser.add_argument( + "repl", help="The replacement string (using Python's syntax from re.sub) with which to replace regex.") args = parser.parse_args() regex = args.regex @@ -98,46 +151,54 @@ def update_text(obj): process_pages = args.pages or args.all process_quizzes = args.quizzes or args.all if not (process_assns or process_pages or process_quizzes): - parser.error("You must use a flag to indicate processing of at least one type.") + parser.error( + "You must use a flag to indicate processing of at least one type.") std_text_process = make_regex_repl_text_process(regex, repl) update_assn_fn = make_update_text(std_text_process, "description", "name") update_page_fn = make_update_text(std_text_process, "body", "url") -# TODO: There is no QuizQuestion object (yet). So, I'm just getting dictionaries of values, which don't know how to update. -# TODO: Proposed solution is to change make_update_text above so that it takes the update function to use, but that defaults to looking for an update function already attached to the object. Then, we can just use a custom one for the quiz question and LATER can choose instead to instantiate QuizQuestion as an object. OR could manually attach that function to each quizquestion dict. update_quiz_text = make_update_text(std_text_process, "description", "title") -def update_quiz_and_questions(quiz): + + +def update_quiz_and_questions(quiz: canvas.Quiz) -> None: + """Perform regex search-and-replace on the quiz and its questions. + + Uses the script's global regex and replacement string. + """ print("Processing the quiz itself.") update_quiz_text(quiz) print("Fetching quiz questions from Canvas...") (quiz_question_dict, quiz_group_dict) = quiz.questions() - quiz_questions = [canvas.QuizQuestion(qq, quiz) for qq in list(quiz_question_dict.values())] + quiz_questions = [canvas.QuizQuestion( + qq, quiz) for qq in list(quiz_question_dict.values())] print("Done fetching quiz questions from Canvas.") # TODO: confirm html vs non-html variants are correct # TODO: account for answers! - update_quiz_question = make_update_text(std_text_process, \ - ["question_text", - "correct_comments", - "incorrect_comments", - "neutral_comments", - "correct_comments_html", - "incorrect_comments_html", - "neutral_comments_html"], - "question_name") - update_objects(quiz_questions, "quiz (%s) questions" % quiz["title"], update_quiz_question) + update_quiz_question = make_update_text(std_text_process, + ["question_text", + "correct_comments", + "incorrect_comments", + "neutral_comments", + "correct_comments_html", + "incorrect_comments_html", + "neutral_comments_html"], + "question_name") + update_objects(quiz_questions, "quiz (%s) questions" % + quiz["title"], update_quiz_question) + update_quiz_fn = update_quiz_and_questions canvasObj = canvas.Canvas(args=args) -print('Object types being processed: %s%s%s' % \ - ("assignments " if process_assns else "", - "pages " if process_pages else "", - "quizzes " if process_quizzes else "")) +print('Object types being processed: %s%s%s' % + ("assignments " if process_assns else "", + "pages " if process_pages else "", + "quizzes " if process_quizzes else "")) print('Reading data from Canvas...') @@ -171,5 +232,4 @@ def update_quiz_and_questions(quiz): update_objects(quizzes, "quiz", update_quiz_fn) - # Note: for some reason, Course.assignments filters out "online_quiz" assignment types. Should it?? Are those "Quiz" instead?