From 22aba33af7ae3a3851f5a0da5f53f7645e7e5254 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Thu, 7 Sep 2017 18:11:09 -0400 Subject: [PATCH 01/40] Initial commit + tests --- pycanvasgrader/pycanvasgrader.py | 70 ++++++++++++++++++++++++++++++++ pycanvasgrader/test_grader.py | 32 +++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 pycanvasgrader/pycanvasgrader.py create mode 100644 pycanvasgrader/test_grader.py diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py new file mode 100644 index 0000000..484f992 --- /dev/null +++ b/pycanvasgrader/pycanvasgrader.py @@ -0,0 +1,70 @@ +#! +""" +Automates the grading of programming assignments on Canvas. +MUST create an 'access.token' file in the same directory as this file with a valid Canvas OAuth2 token +""" + +# built-ins +import py +import json + +# 3rd-party +import requests + +# globals +RUN_WITH_TESTS = True + + +class PyCanvasGrader: + """ + A PyCanvasGrader object; responsible for communicating with the Canvas API + """ + def __init__(self): + self.token = self.authenticate() + if self.token == 'none': + print('Unable to retrieve OAuth2 token') + exit() + + self.session = requests.Session() + self.session.headers.update({'Authorization': 'Bearer ' + self.token}) + + @staticmethod + def authenticate() -> str: + """ + Responsible for retrieving the OAuth2 token for the session. + :return: The OAuth2 token + + TODO Talk about "proper" OAuth2 authentication + """ + + with open('access.token', 'r', encoding='UTF-8') as access_file: + for line in access_file: + token = line.strip() + if isinstance(token, str) and len(token) > 2: + return token + return 'none' + + def courses(self, enrollment_type: str=None) -> list: + """ + + :param enrollment_type: teacher, student, ta, observer, designer + :return: A list of the user's courses as dictionaries, optionally filtered by enrollment_type + """ + url = 'https://canvas.instructure.com/api/v1/courses' + if enrollment_type is not None: + url += '?enrollment_type=' + enrollment_type + + r = self.session.get(url) + response = json.loads(r.text) + return response + + +def main(): + g = PyCanvasGrader() + print(g.courses('ta')) + + +if __name__ == '__main__': + if RUN_WITH_TESTS: + py.test.cmdline.main() + main() diff --git a/pycanvasgrader/test_grader.py b/pycanvasgrader/test_grader.py new file mode 100644 index 0000000..fdd0e04 --- /dev/null +++ b/pycanvasgrader/test_grader.py @@ -0,0 +1,32 @@ +""" +Unit tests for PyCanvasGrader +""" +# built-ins +import json + +# 3rd-party +import requests +from requests_oauthlib import OAuth2 + +# package-specific +from .pycanvasgrader import PyCanvasGrader + + +class TestGrader: + def test_authenticate(self): + """ + Make sure the authentication function returns a valid key + """ + token = PyCanvasGrader().authenticate() + s = requests.Session() + r = s.get('https://canvas.instructure.com/api/v1/courses', headers={'Authorization': 'Bearer ' + token}) + resp = json.loads(r.text) + assert type(resp) == list and resp[0].get('id') is not None + + def test_courses(self): + """ + Make sure that courses always returns a list + """ + g = PyCanvasGrader() + courses = g.courses('designer') + assert type(courses) == list From 46cc1c6a1ed6793e054c4f13d7ccf549953d9902 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Thu, 7 Sep 2017 18:37:59 -0400 Subject: [PATCH 02/40] added requirements.txt --- pycanvasgrader/requirements.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 pycanvasgrader/requirements.txt diff --git a/pycanvasgrader/requirements.txt b/pycanvasgrader/requirements.txt new file mode 100644 index 0000000..a925c1e --- /dev/null +++ b/pycanvasgrader/requirements.txt @@ -0,0 +1,10 @@ +certifi==2017.7.27.1 +chardet==3.0.4 +colorama==0.3.9 +idna==2.6 +oauthlib==2.0.3 +py==1.4.34 +pytest==3.2.2 +requests==2.18.4 +requests-oauthlib==0.8.0 +urllib3==1.22 From 4aac6e10380a9f59bcab6b211e6f32bb732ef757 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Thu, 7 Sep 2017 19:39:15 -0400 Subject: [PATCH 03/40] updated requirements.txt --- pycanvasgrader/requirements.txt | 3 --- pycanvasgrader/test_grader.py | 1 - 2 files changed, 4 deletions(-) diff --git a/pycanvasgrader/requirements.txt b/pycanvasgrader/requirements.txt index a925c1e..ba79371 100644 --- a/pycanvasgrader/requirements.txt +++ b/pycanvasgrader/requirements.txt @@ -1,10 +1,7 @@ certifi==2017.7.27.1 chardet==3.0.4 -colorama==0.3.9 idna==2.6 -oauthlib==2.0.3 py==1.4.34 pytest==3.2.2 requests==2.18.4 -requests-oauthlib==0.8.0 urllib3==1.22 diff --git a/pycanvasgrader/test_grader.py b/pycanvasgrader/test_grader.py index fdd0e04..a68bc8f 100644 --- a/pycanvasgrader/test_grader.py +++ b/pycanvasgrader/test_grader.py @@ -6,7 +6,6 @@ # 3rd-party import requests -from requests_oauthlib import OAuth2 # package-specific from .pycanvasgrader import PyCanvasGrader From 673aec5f52e2d37b7061d320a694f5c2643d6e59 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Thu, 7 Sep 2017 19:43:50 -0400 Subject: [PATCH 04/40] added __init__.py --- pycanvasgrader/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pycanvasgrader/__init__.py diff --git a/pycanvasgrader/__init__.py b/pycanvasgrader/__init__.py new file mode 100644 index 0000000..e69de29 From bec9e16aad98a8c3b343d40f7fdbc986f88844fd Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Fri, 8 Sep 2017 01:46:43 -0400 Subject: [PATCH 05/40] Created skeleton of command line --- pycanvasgrader/pycanvasgrader.py | 165 +++++++++++++++++++++++++++++-- pycanvasgrader/test_grader.py | 19 +++- 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 484f992..27c758d 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -7,6 +7,8 @@ # built-ins import py import json +import os +from zipfile import ZipFile # 3rd-party import requests @@ -44,24 +46,173 @@ def authenticate() -> str: return token return 'none' + def close(self): + self.session.close() + def courses(self, enrollment_type: str=None) -> list: """ - - :param enrollment_type: teacher, student, ta, observer, designer + :param enrollment_type: (Optional) teacher, student, ta, observer, designer :return: A list of the user's courses as dictionaries, optionally filtered by enrollment_type """ url = 'https://canvas.instructure.com/api/v1/courses' if enrollment_type is not None: url += '?enrollment_type=' + enrollment_type - r = self.session.get(url) - response = json.loads(r.text) - return response + response = self.session.get(url) + return json.loads(response.text) + + def assignments(self, course_id: int, ungraded: bool=True) -> list: + """ + :param course_id: Course ID to filter by + :param ungraded: Whether to filter assignments by only those that have ungraded work. Default: True + :return: A list of the course's assignments + """ + url = 'https://canvas.instructure.com/api/v1/courses/' + str(course_id) + '/assignments' + if ungraded: + url += '?bucket=ungraded' + + response = self.session.get(url) + return json.loads(response.text) + + def submissions(self, course_id: int, assignment_id: int) -> list: + """ + :param course_id: The ID of the course containing the assignment + :param assignment_id: The ID of the assignment + :return: A list of the assignment's submissions + """ + url = 'https://canvas.instructure.com/api/v1/courses/' + str(course_id) + '/assignments/' + str(assignment_id) + '/submissions' + + response = self.session.get(url) + return json.loads(response.text) + + def user(self, user_id: int) -> dict: + """ + :param user_id: The ID of the user + :return: A dictionary with the user's information + """ + url = 'https://canvas.instructure.com/api/v1/users/' + str(user_id) + + response = self.session.get(url) + return json.loads(response.text) + + +def parse_zip(zip_file: str) -> dict: + """ + Maps file names to user IDs from a zip of downloaded Canvas submissions + :param zip_file: The name of the zip file to parse + :return: if the zip can be parsed, a dictionary containing the user ID's mapped to the parsed file name, otherwise an empty dict + """ + user_dict = {} + + with ZipFile('zips/' + zip_file, 'r') as z: + for name in z.namelist(): + if name.count('_') < 3: + print('Skipping file: ' + name + '. Invalid filename') + else: + (name, user_id, unknown, file) = name.split('_', maxsplit=3) + user_dict[user_id] = file + if len(user_dict) < 1: + print('Unable to read any files from the zip. Please check the file and try again') + return user_dict + + +def choose_val(hi_num: int) -> int: + val = 'none' + while not str.isdigit(val) or (int(val) > hi_num or int(val) <= 0): + val = input() + return int(val) + + +def choose_bool() -> bool: + val = 'none' + while not str.lower(val) in ['y', 'n', 'yes', 'no']: + val = input() + if val in ['y', 'yes']: + return True + else: + return False + + +def choose_zip() -> str: + name = input() + if name[-4:] != '.zip': + name += '.zip' + if not os.path.isfile('zips/' + name): + print('Could not find the file. Make sure it is in the zips directory and try again:') + return choose_zip() + else: + return name def main(): - g = PyCanvasGrader() - print(g.courses('ta')) + grader = PyCanvasGrader() + course_list = grader.courses('teacher') + + print('Choose a course from the following list:') + for count, course in enumerate(course_list): + print('%i.\t%s (%s)' % (count + 1, course.get('name'), course.get('course_code'))) + + course_choice = choose_val(len(course_list)) - 1 + + print('Show only ungraded assignments? (y or n):') + ungraded = choose_bool() + + course_id = course_list[course_choice].get('id') + assignment_list = grader.assignments(course_list[course_choice].get('id'), ungraded=ungraded) + + print('Choose an assignment to grade:') + for count, assignment in enumerate(assignment_list): + print('%i.\t%s' % (count + 1, assignment.get('name'))) + + assignment_choice = choose_val(len(assignment_list)) - 1 + assignment_id = assignment_list[assignment_choice].get('id') + print('If you haven\'t already, please download the most current submissions.zip for this assignment:\n' + + assignment_list[assignment_choice].get('submissions_download_url')) + + invalid_zip = True + user_file_dict = {} + while invalid_zip: + print('Place the zip in the \'zips\' directory, and then enter the zip\'s name here:') + zip_file = choose_zip() + + user_file_dict = parse_zip(zip_file) + if len(user_file_dict) > 0: + invalid_zip = False + + submission_list = grader.submissions(course_id, assignment_id) + if len(submission_list) < 1: + print('There are no submissions for this assignment.') + grader.close() + main() + exit(0) + + user_submission_dict = {} + for user_id, filename in user_file_dict.items(): + for submission in submission_list: + if user_id in str(submission.get('user_id')): + user_submission_dict['user_id'] = submission['id'] + + if len(user_submission_dict) < 1: + print('Could not match any file names in the zip to any online submissions.') + grader.close() + main() + exit(0) + + print('Successfully matched %i submission(s) to files in the zip file. Is this correct? (y or n):' % len(user_submission_dict)) + correct = choose_bool() + if not correct: + grader.close() + main() + exit(0) + + print('Students to grade: [Name (email)]') + for user_id in user_submission_dict: + user_data = grader.user(user_id) + print(str(user_data.get('name')) + '(%s)' % user_data.get('email')) + + input('Press enter to begin grading') + + print('done') if __name__ == '__main__': diff --git a/pycanvasgrader/test_grader.py b/pycanvasgrader/test_grader.py index a68bc8f..970fdfc 100644 --- a/pycanvasgrader/test_grader.py +++ b/pycanvasgrader/test_grader.py @@ -24,8 +24,25 @@ def test_authenticate(self): def test_courses(self): """ - Make sure that courses always returns a list + Make sure that courses() always returns a list """ g = PyCanvasGrader() courses = g.courses('designer') assert type(courses) == list + + def test_assignments(self): + """ + Make sure that assignments() always returns a list + """ + g = PyCanvasGrader() + course_id = g.courses('teacher')[0].get('id') + assert type(g.assignments(course_id, ungraded=False)) == list + + def test_submissions(self): + """ + Make sure that submissions() always returns a list + """ + g = PyCanvasGrader() + course_id = g.courses('teacher')[0].get('id') + assignment_id = g.assignments(course_id, ungraded=False)[0].get('id') + assert type(g.submissions(course_id, assignment_id)) == list From 06520a29774a223e470eca2acf841478b4431990 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Fri, 8 Sep 2017 02:17:42 -0400 Subject: [PATCH 06/40] Better zip file experience, added comments to main function --- pycanvasgrader/pycanvasgrader.py | 44 +++++++++++++++++++------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 27c758d..b143c74 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -133,52 +133,59 @@ def choose_bool() -> bool: return False -def choose_zip() -> str: - name = input() - if name[-4:] != '.zip': - name += '.zip' - if not os.path.isfile('zips/' + name): - print('Could not find the file. Make sure it is in the zips directory and try again:') - return choose_zip() - else: - return name - - def main(): + # Initialize grading session and fetch courses grader = PyCanvasGrader() course_list = grader.courses('teacher') + # Have user select course print('Choose a course from the following list:') for count, course in enumerate(course_list): print('%i.\t%s (%s)' % (count + 1, course.get('name'), course.get('course_code'))) - - course_choice = choose_val(len(course_list)) - 1 + course_choice = choose_val(len(course_list)) - 1 # the plus and minus 1 are to hide the 0-based numbering print('Show only ungraded assignments? (y or n):') ungraded = choose_bool() - course_id = course_list[course_choice].get('id') assignment_list = grader.assignments(course_list[course_choice].get('id'), ungraded=ungraded) + # Have user choose assignment print('Choose an assignment to grade:') for count, assignment in enumerate(assignment_list): print('%i.\t%s' % (count + 1, assignment.get('name'))) - assignment_choice = choose_val(len(assignment_list)) - 1 assignment_id = assignment_list[assignment_choice].get('id') + + # Remind user to get latest zip file print('If you haven\'t already, please download the most current submissions.zip for this assignment:\n' + assignment_list[assignment_choice].get('submissions_download_url')) + input('\nPress enter when this you have placed this zip file into the /zips/ directory') + + # Have user choose zip invalid_zip = True user_file_dict = {} while invalid_zip: - print('Place the zip in the \'zips\' directory, and then enter the zip\'s name here:') - zip_file = choose_zip() + zip_list = [] + print('Choose a zip file to use:') + sub = 0 # This is to keep indices visually correct while excluding non-zip files + for count, zip_name in enumerate(os.listdir('zips')): + if zip_name.split('.')[-1] != 'zip': + sub += 1 + continue + zip_list.append(zip_name) + print('%i.\t%s' % (count - sub + 1, zip_name)) # Again, the plus and minus 1 are to hide the 0-based numbering + + selection = choose_val(len(zip_list)) - 1 + zip_file = zip_list[selection] user_file_dict = parse_zip(zip_file) if len(user_file_dict) > 0: invalid_zip = False + else: + print('This zip is invalid. Make sure you do not change the names inside the zip and try again') + # Get list of submissions for this assignment submission_list = grader.submissions(course_id, assignment_id) if len(submission_list) < 1: print('There are no submissions for this assignment.') @@ -186,6 +193,7 @@ def main(): main() exit(0) + # Match the user IDs found in the zip with the IDs who submitted the assignment online user_submission_dict = {} for user_id, filename in user_file_dict.items(): for submission in submission_list: @@ -208,7 +216,7 @@ def main(): print('Students to grade: [Name (email)]') for user_id in user_submission_dict: user_data = grader.user(user_id) - print(str(user_data.get('name')) + '(%s)' % user_data.get('email')) + print(str(user_data.get('name')) + '\t(%s)' % user_data.get('email')) input('Press enter to begin grading') From 8bc7a8b479769bcd4c48fdd30eb395a707248caf Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Fri, 8 Sep 2017 03:30:15 -0400 Subject: [PATCH 07/40] Created backbone of file testing --- pycanvasgrader/pycanvasgrader.py | 72 +++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index b143c74..99148c8 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -7,16 +7,84 @@ # built-ins import py import json +import re import os +import subprocess from zipfile import ZipFile # 3rd-party import requests # globals -RUN_WITH_TESTS = True +RUN_WITH_TESTS = False +class TestSkeleton: + """ + An abstract skeleton to handle testing of a specific group of files + """ + def __init__(self, commands: list, extensions: str=None, file_regex: str=None): + """ + :param commands: A list of Command objects. These will be run in the order that they are added. + :param extensions: Which file extensions this skeleton applies to + :param file_regex: A regular expression to match files for this skeleton. Combines with extensions + """ + if not any((extensions, file_regex)): + raise ValueError('Either extensions or file_regex must be defined') + self.commands = commands + self.extensions = extensions + self.file_regex = file_regex + + # TODO Create tests + class Command: + """ + An abstract command to be run in a console + """ + def __init__(self, command: str, args: str, print_output: bool=True, output_match: str=None, output_regex: str=None, timeout: int=None): + """ + :param command: The command to be run. + :param args: The arguments to pass to the command. Use %s to denote the file name + :param print_output: Whether to visibly print the output + :param output_match: An exact string that the output should match. If this and output_regex are None, then this Command always 'matches' + :param output_regex: A regular expression that the string should match. Combines with output_match. + If this and output_match are None, then this Command always 'matches' + :param timeout: Time, in seconds, that this Command should run for before timing out + + """ + self.command = command + self.args = args + self.output_match = output_match + self.output_regex = re.compile(output_regex) + self.print_output = print_output + self.timeout = timeout + + def run(self): + """ + Runs the Command + :return: A dictonary containing its return code, stdout, and stderr + """ + proc = subprocess.run([self.command, self.args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8') + return {'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr} + + def run_and_match(self): + """ + Runs the command and matches the output to the output_match/regex. If neither are defined then this always returns true + :return: Whether the output matched or not + """ + result = self.run() + if self.print_output: + print(result['stdout']) + + if not any((self.output_match, self.output_regex)): + return True + + if self.output_match and self.output_match in result['stdout']: + return True + + if self.output_regex.match(result['stdout']): + return True + return False + class PyCanvasGrader: """ A PyCanvasGrader object; responsible for communicating with the Canvas API @@ -168,7 +236,7 @@ def main(): while invalid_zip: zip_list = [] print('Choose a zip file to use:') - sub = 0 # This is to keep indices visually correct while excluding non-zip files + sub = 0 # This is to keep indices visually correct while excluding non-zip files for count, zip_name in enumerate(os.listdir('zips')): if zip_name.split('.')[-1] != 'zip': sub += 1 From c81cabb7b6861fd5bee1f80b5ed1821997f09491 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Fri, 8 Sep 2017 03:30:40 -0400 Subject: [PATCH 08/40] type-o --- pycanvasgrader/pycanvasgrader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 99148c8..260d976 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -61,7 +61,7 @@ def __init__(self, command: str, args: str, print_output: bool=True, output_matc def run(self): """ Runs the Command - :return: A dictonary containing its return code, stdout, and stderr + :return: A dictionary containing its return code, stdout, and stderr """ proc = subprocess.run([self.command, self.args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8') return {'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr} From 7f3339b544bfb49c8d9c0af02a5aa5d51356650f Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Fri, 8 Sep 2017 03:31:01 -0400 Subject: [PATCH 09/40] spacing --- pycanvasgrader/pycanvasgrader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 260d976..8a652e5 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -85,6 +85,7 @@ def run_and_match(self): return True return False + class PyCanvasGrader: """ A PyCanvasGrader object; responsible for communicating with the Canvas API From 03db78c8f33289bac12930a2feb1ea2eb6e0d80e Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Fri, 8 Sep 2017 18:04:11 -0400 Subject: [PATCH 10/40] First release --- pycanvasgrader/pycanvasgrader.py | 302 ++++++++++++++++++++++------ pycanvasgrader/skeletons/java.json | 26 +++ pycanvasgrader/temp/git_include.dir | 0 pycanvasgrader/zips/submissions.zip | Bin 0 -> 1028 bytes 4 files changed, 268 insertions(+), 60 deletions(-) create mode 100644 pycanvasgrader/skeletons/java.json create mode 100644 pycanvasgrader/temp/git_include.dir create mode 100644 pycanvasgrader/zips/submissions.zip diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 8a652e5..ef3834b 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -2,6 +2,14 @@ """ Automates the grading of programming assignments on Canvas. MUST create an 'access.token' file in the same directory as this file with a valid Canvas OAuth2 token +MUST clear temp directory before running program (TODO will fix this) +REQUIRED File structure: +- pycanvasgrader + -- skeletons + -- temp + -- zips + access.token + pycanvasgrader.py """ # built-ins @@ -23,67 +31,184 @@ class TestSkeleton: """ An abstract skeleton to handle testing of a specific group of files """ - def __init__(self, commands: list, extensions: str=None, file_regex: str=None): + def __init__(self, descriptor: str, commands: list, extensions: str=None, file_regex: str=None): """ + :param descriptor: The description of this TestSkeleton :param commands: A list of Command objects. These will be run in the order that they are added. - :param extensions: Which file extensions this skeleton applies to - :param file_regex: A regular expression to match files for this skeleton. Combines with extensions + :param extensions: Which file extensions this skeleton applies to (Unimplemented) + :param file_regex: A regular expression to match files for this skeleton. Combines with extensions (Unimplemented) """ if not any((extensions, file_regex)): raise ValueError('Either extensions or file_regex must be defined') + self.descriptor = descriptor self.commands = commands self.extensions = extensions self.file_regex = file_regex - # TODO Create tests - class Command: + @classmethod + def from_file(cls, filename): + with open('skeletons/' + filename) as skeleton_file: + data = json.load(skeleton_file) + try: + descriptor = data['descriptor'] + commands = data['commands'] + extensions = data.get('extensions') + file_regex = data.get('file_regex') + if not any((extensions, file_regex)): + raise KeyError + except KeyError: + return None + else: + command_list = [] + for json_dict in commands: + command = Command.from_json_dict(commands[json_dict]) + if command is not None: + command_list.append(command) + + return TestSkeleton(descriptor, command_list, extensions, file_regex) + + def run_tests(self) -> int: + total_score = 0 + for count, command in enumerate(self.commands): + print('\n--Running command %i--' % count) + if command.run_and_match(): + total_score += command.point_val + print('Current score: %i' % total_score) + return total_score + + +# TODO Create tests +class Command: + """ + An abstract command to be run in a console + """ + def __init__(self, command: str, args: str=None, target_file: str=None, ask_for_target: bool=False, + include_filetype: bool=True, print_output: bool=True, output_match: str=None, output_regex: str=None, + negate_match: bool=False, timeout: int=None, point_val: int=0): """ - An abstract command to be run in a console + :param command: The command to be run. + :param args: The arguments to pass to the command. Use %s to denote a file name + :param target_file: The file to replace %s with + :param ask_for_target: Whether to prompt for a file in the current directory. Overrides file_target + :param include_filetype: Whether to include the filetype in the %s substitution + :param print_output: Whether to visibly print the output + :param output_match: An exact string that the output should match. If this and output_regex are None, then this Command always 'matches' + :param output_regex: A regular expression that the string should match. Combines with output_match. + If this and output_match are None, then this Command always 'matches' + :param negate_match: Whether to negate the result of checking output_match and output_regex + :param timeout: Time, in seconds, that this Command should run for before timing out + :param point_val: Amount of points that a successful match is worth (Can be negative) """ - def __init__(self, command: str, args: str, print_output: bool=True, output_match: str=None, output_regex: str=None, timeout: int=None): - """ - :param command: The command to be run. - :param args: The arguments to pass to the command. Use %s to denote the file name - :param print_output: Whether to visibly print the output - :param output_match: An exact string that the output should match. If this and output_regex are None, then this Command always 'matches' - :param output_regex: A regular expression that the string should match. Combines with output_match. - If this and output_match are None, then this Command always 'matches' - :param timeout: Time, in seconds, that this Command should run for before timing out - - """ - self.command = command - self.args = args - self.output_match = output_match + self.command = command + self.args = args + self.target_file = target_file + self.ask_for_target = ask_for_target + self.include_filetype = include_filetype + self.output_match = output_match + if output_regex is not None: self.output_regex = re.compile(output_regex) - self.print_output = print_output - self.timeout = timeout - - def run(self): - """ - Runs the Command - :return: A dictionary containing its return code, stdout, and stderr - """ - proc = subprocess.run([self.command, self.args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8') - return {'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr} - - def run_and_match(self): - """ - Runs the command and matches the output to the output_match/regex. If neither are defined then this always returns true - :return: Whether the output matched or not - """ - result = self.run() - if self.print_output: - print(result['stdout']) - - if not any((self.output_match, self.output_regex)): - return True + else: + self.output_regex = None + self.negate_match = negate_match + self.print_output = print_output + self.timeout = timeout + self.point_val = point_val + + @classmethod + def from_json_dict(cls, json_dict: dict): + try: + command = json_dict['command'] + except KeyError: + return None + else: + args = json_dict.get('args') + target_file = json_dict.get('target_file') + ask_for_target = json_dict.get('ask_for_target') + include_filetype = json_dict.get('include_filetype') + print_output = json_dict.get('print_output') + output_match = json_dict.get('output_match') + output_regex = json_dict.get('output_regex') + negate_match = json_dict.get('negate_match') + timeout = json_dict.get('timeout') + point_val = json_dict.get('point_val') + + vars_dict = {'command': command, 'args': args, 'target_file': target_file, + 'ask_for_target': ask_for_target, 'include_filetype': include_filetype, + 'print_output': print_output, 'output_match': output_match, 'output_regex': output_regex, + 'negate_match': negate_match, 'timeout': timeout, 'point_val': point_val} + args_dict = {} + for var_name, val in vars_dict.items(): + if val is not None: + args_dict[var_name] = val + return Command(**args_dict) + + @classmethod + def target_prompt(cls, command: str): + sub = 0 + file_list = [] + if len(os.listdir(os.getcwd())) < 1: + print('This directory is empty, unable to choose a file for "%s" command' % command) + return None + + print('Select a file for the "%s" command:' % command) + for count, file_name in enumerate(os.listdir(os.getcwd())): + if os.path.isdir(file_name): + sub += 1 + continue + file_list.append(file_name) + print('%i.\t%s' % (count - sub + 1, file_name)) # The plus and minus 1 are to hide the 0-based numbering - if self.output_match and self.output_match in result['stdout']: - return True + selection = choose_val(len(file_list)) - 1 + return file_list[selection] + + def run(self) -> dict: + """ + Runs the Command + :return: A dictionary containing the command's return code, stdout, and stderr + """ + command = self.command + args = self.args + filename = self.target_file + if self.ask_for_target: + filename = Command.target_prompt(self.command) + if not self.include_filetype: + filename = os.path.splitext(filename)[0] + if filename is not None: + command = self.command.replace('%s', filename) + args = self.args.replace('%s', filename) + elif '%s' in self.command + '|' + self.args: + print('No filename given, but this command contains filename wildcards (%s). This command will probably fail') + + proc = subprocess.run([command, args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8', shell=True) + return {'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr} + + def run_and_match(self) -> bool: + """ + Runs the command and matches the output to the output_match/regex. If neither are defined then this always returns true + :return: Whether the output matched or not + """ + result = self.run() + if self.print_output: + print(result['stdout']) + + if not any((self.output_match, self.output_regex)): + return True + if self.output_regex: if self.output_regex.match(result['stdout']): + if self.negate_match: + return False return True - return False + + if self.output_match: + if self.output_match in result['stdout']: + if self.negate_match: + return False + return True + + if self.negate_match: + return True + return False class PyCanvasGrader: @@ -165,24 +290,30 @@ def user(self, user_id: int) -> dict: return json.loads(response.text) -def parse_zip(zip_file: str) -> dict: +def parse_zip(zip_file: str) -> set: """ Maps file names to user IDs from a zip of downloaded Canvas submissions :param zip_file: The name of the zip file to parse :return: if the zip can be parsed, a dictionary containing the user ID's mapped to the parsed file name, otherwise an empty dict """ - user_dict = {} + user_ids = set() with ZipFile('zips/' + zip_file, 'r') as z: for name in z.namelist(): if name.count('_') < 3: print('Skipping file: ' + name + '. Invalid filename') else: - (name, user_id, unknown, file) = name.split('_', maxsplit=3) - user_dict[user_id] = file - if len(user_dict) < 1: + (username, user_id, unknown, file) = name.split('_', maxsplit=3) + user_ids.add(user_id) + + file_path = 'temp/%s/' % user_id + os.makedirs(os.path.dirname('temp/%s/' % user_id), exist_ok=True) + z.extract(name, path=file_path) + os.replace(file_path + name, file_path + file) + + if len(user_ids) < 1: print('Unable to read any files from the zip. Please check the file and try again') - return user_dict + return user_ids def choose_val(hi_num: int) -> int: @@ -202,11 +333,19 @@ def choose_bool() -> bool: return False +def parse_skeletons() -> list: + skeleton_list = [] + for skeleton_file in os.listdir('skeletons'): + skeleton = TestSkeleton.from_file(skeleton_file) + if skeleton is not None: + skeleton_list.append(skeleton) + return skeleton_list + + def main(): # Initialize grading session and fetch courses grader = PyCanvasGrader() course_list = grader.courses('teacher') - # Have user select course print('Choose a course from the following list:') for count, course in enumerate(course_list): @@ -233,7 +372,7 @@ def main(): # Have user choose zip invalid_zip = True - user_file_dict = {} + user_ids = [] while invalid_zip: zip_list = [] print('Choose a zip file to use:') @@ -248,8 +387,8 @@ def main(): selection = choose_val(len(zip_list)) - 1 zip_file = zip_list[selection] - user_file_dict = parse_zip(zip_file) - if len(user_file_dict) > 0: + user_ids = parse_zip(zip_file) + if len(user_ids) > 0: invalid_zip = False else: print('This zip is invalid. Make sure you do not change the names inside the zip and try again') @@ -262,12 +401,15 @@ def main(): main() exit(0) - # Match the user IDs found in the zip with the IDs who submitted the assignment online + # Match the user IDs found in the zip with the IDs in the online submission user_submission_dict = {} - for user_id, filename in user_file_dict.items(): + for user_id in user_ids: for submission in submission_list: - if user_id in str(submission.get('user_id')): - user_submission_dict['user_id'] = submission['id'] + long_id = submission.get('user_id') + if str(user_id) in str(long_id): + file_path = os.path.join(os.getcwd(), 'temp') + os.rename(os.path.join(file_path, str(user_id)), os.path.join(file_path, str(long_id))) + user_submission_dict[long_id] = submission['id'] if len(user_submission_dict) < 1: print('Could not match any file names in the zip to any online submissions.') @@ -275,19 +417,59 @@ def main(): main() exit(0) - print('Successfully matched %i submission(s) to files in the zip file. Is this correct? (y or n):' % len(user_submission_dict)) + s = '' + if len(user_submission_dict) > 1: + s = 's' + print('Successfully matched %i submission%s to files in the zip file. Is this correct? (y or n):' % (len(user_submission_dict), s)) correct = choose_bool() if not correct: grader.close() main() exit(0) + skeleton_list = parse_skeletons() + if len(skeleton_list) < 1: + print('Could not find any skeleton files in the skeletons directory. Would you like to create one now? (y or n):') + if choose_bool(): + print('unimplemented') + else: + pass + grader.close() + main() + exit(0) + + print('Choose a skeleton to use for grading this assignment:') + for count, skeleton in enumerate(skeleton_list): + print('%i.\t%s' % (count + 1, skeleton.descriptor)) + skeleton_choice = choose_val(len(skeleton_list)) - 1 + selected_skeleton = skeleton_list[skeleton_choice] + print('Students to grade: [Name (email)]') for user_id in user_submission_dict: user_data = grader.user(user_id) print(str(user_data.get('name')) + '\t(%s)' % user_data.get('email')) input('Press enter to begin grading') + for cur_user_id in user_submission_dict: + try: + os.chdir('temp/' + str(cur_user_id)) + except (WindowsError, OSError): + print('Could not access files for user %i. Skipping' % cur_user_id) + continue + + print('Grading user %i...' % cur_user_id) + score = selected_skeleton.run_tests() + + if score < 0: + score = 0 + action_list = ['Submit this grade', 'Modify this grade', 'Skip this submission', 'Re-grade this submission'] + + print('Grade for this assignment: %i' % score) + print('Choose an action:') + for count, action in enumerate(action_list): + print('%i.\t%s' % (count + 1, action)) + action_choice = choose_val(len(action_list)) - 1 + selected_action = action_list[skeleton_choice] print('done') diff --git a/pycanvasgrader/skeletons/java.json b/pycanvasgrader/skeletons/java.json new file mode 100644 index 0000000..52100b9 --- /dev/null +++ b/pycanvasgrader/skeletons/java.json @@ -0,0 +1,26 @@ +{ + "descriptor": "Example Java skeleton", + "extensions": ".java", + + "commands": { + + "command_1": { + "command": "C:\\Program Files\\Java\\jdk1.8.0_92\\bin\\javac.exe", + "args": "%s", + "print_output": true, + "point_val": 25, + "output_match": "error", + "negate_match": true, + "ask_for_target": true + }, + "command_2": { + "command": "java", + "args": "%s", + "print_output": true, + "output_match": "Variable Value :2", + "point_val": 75, + "ask_for_target": true, + "include_filetype": false + } + } +} \ No newline at end of file diff --git a/pycanvasgrader/temp/git_include.dir b/pycanvasgrader/temp/git_include.dir new file mode 100644 index 0000000..e69de29 diff --git a/pycanvasgrader/zips/submissions.zip b/pycanvasgrader/zips/submissions.zip new file mode 100644 index 0000000000000000000000000000000000000000..92df292a1d0f05ed9b9a45dbfb2cd63f40cc532d GIT binary patch literal 1028 zcmWIWW@Zs#U|`^25Y*G~c8V(!O&g|5+u!*Y5 zYmoKe_m7V{gMz8^%{u$EEwlU%?cc=A zek12_*ef0rF_ZU}b-L3(O!H#-%7Y8z$gx0bf-`st`OF8|r#WE#R(dA38 zrz}lBxtYD;M6WV$+&jg_+YU`XwJ!8cpT_6*?x0}x=FJJR#{9OoyVM&?cN=S6+_Wh! z@qmo6IMa^8L=ThlSr??goE8cX;}705@0En*Fa3a560eV|-#WwF9DCsOyV)$w8=hFY z9Zr%o-~Fg-S@k-{;Je#dIA<@)n3Ryc;A@N5*R{XBiZ-*G!e>9Tl5J`+oA`G}Q5iq707#S2m t6c7-Tr2@Qhn+S?{5P+CC513hTnTRDB1bDNufiyD#;c6i549w>Y3;+m|jy?bY literal 0 HcmV?d00001 From d5297392f16beb342f6f928ab245debefda61356 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Sat, 9 Sep 2017 14:15:59 -0400 Subject: [PATCH 11/40] Fixed name output, a lot of formatting --- pycanvasgrader/pycanvasgrader.py | 44 ++++++++++++++++++++---------- pycanvasgrader/skeletons/java.json | 4 +-- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index ef3834b..b66c351 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -25,6 +25,7 @@ # globals RUN_WITH_TESTS = False +ONLY_RUN_TESTS = False class TestSkeleton: @@ -70,10 +71,16 @@ def from_file(cls, filename): def run_tests(self) -> int: total_score = 0 for count, command in enumerate(self.commands): - print('\n--Running command %i--' % count) + print('\n--Running test %i--' % (count + 1)) if command.run_and_match(): + if command.point_val > 0: + print('--Adding %i points--' % command.point_val) + elif command.point_val == 0: + print('--No points set for this test--') + else: + print('--Subtracting %i points--' % abs(command.point_val)) total_score += command.point_val - print('Current score: %i' % total_score) + print('--Current score: %i--' % total_score) return total_score @@ -189,19 +196,22 @@ def run_and_match(self) -> bool: """ result = self.run() if self.print_output: + print('\t--OUTPUT--') print(result['stdout']) - + print('\t--END OUTPUT--') if not any((self.output_match, self.output_regex)): return True if self.output_regex: if self.output_regex.match(result['stdout']): + print('--Matched regular expression--') if self.negate_match: return False return True if self.output_match: if self.output_match in result['stdout']: + print('--Matched string comparison--') if self.negate_match: return False return True @@ -279,12 +289,13 @@ def submissions(self, course_id: int, assignment_id: int) -> list: response = self.session.get(url) return json.loads(response.text) - def user(self, user_id: int) -> dict: + def user(self, course_id: int, user_id: int) -> dict: """ + :param course_id: The class to search :param user_id: The ID of the user :return: A dictionary with the user's information """ - url = 'https://canvas.instructure.com/api/v1/users/' + str(user_id) + url = 'https://canvas.instructure.com/api/v1/courses/%i/users/%i' % (course_id, user_id) response = self.session.get(url) return json.loads(response.text) @@ -444,27 +455,29 @@ def main(): skeleton_choice = choose_val(len(skeleton_list)) - 1 selected_skeleton = skeleton_list[skeleton_choice] - print('Students to grade: [Name (email)]') + name_dict = {} + print('Students to grade: [Name (email)]\n----') for user_id in user_submission_dict: - user_data = grader.user(user_id) + user_data = grader.user(course_id, user_id) + if user_data.get('name') is not None: + name_dict[user_id] = user_data['name'] print(str(user_data.get('name')) + '\t(%s)' % user_data.get('email')) - - input('Press enter to begin grading') + print('----\n') + input('Press enter to begin grading\n') for cur_user_id in user_submission_dict: try: os.chdir('temp/' + str(cur_user_id)) except (WindowsError, OSError): - print('Could not access files for user %i. Skipping' % cur_user_id) + print('Could not access files for user "%i". Skipping' % cur_user_id) continue - - print('Grading user %i...' % cur_user_id) + print('--Grading user "%s"--' % name_dict.get(cur_user_id)) score = selected_skeleton.run_tests() if score < 0: score = 0 action_list = ['Submit this grade', 'Modify this grade', 'Skip this submission', 'Re-grade this submission'] - print('Grade for this assignment: %i' % score) + print('\n--All tests completed--\nGrade for this assignment: %i' % score) print('Choose an action:') for count, action in enumerate(action_list): print('%i.\t%s' % (count + 1, action)) @@ -475,6 +488,7 @@ def main(): if __name__ == '__main__': - if RUN_WITH_TESTS: + if RUN_WITH_TESTS or ONLY_RUN_TESTS: py.test.cmdline.main() - main() + if not ONLY_RUN_TESTS: + main() diff --git a/pycanvasgrader/skeletons/java.json b/pycanvasgrader/skeletons/java.json index 52100b9..3c26ec9 100644 --- a/pycanvasgrader/skeletons/java.json +++ b/pycanvasgrader/skeletons/java.json @@ -4,7 +4,7 @@ "commands": { - "command_1": { + "test_1": { "command": "C:\\Program Files\\Java\\jdk1.8.0_92\\bin\\javac.exe", "args": "%s", "print_output": true, @@ -13,7 +13,7 @@ "negate_match": true, "ask_for_target": true }, - "command_2": { + "test_2": { "command": "java", "args": "%s", "print_output": true, From abcb5c1ec49577d5f49d50e3e0c13f655c65dd62 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Sat, 9 Sep 2017 18:21:23 -0400 Subject: [PATCH 12/40] renamed Command class to AssignmentTest, added exact match option, added ability to grade submissions, made file handling more robust, properly clean temp directory, simplified various logic, --- pycanvasgrader/pycanvasgrader.py | 193 +++++++++++++++++----------- pycanvasgrader/skeletons/java.json | 2 +- pycanvasgrader/temp/git_include.dir | 0 3 files changed, 120 insertions(+), 75 deletions(-) delete mode 100644 pycanvasgrader/temp/git_include.dir diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index b66c351..991a234 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -13,13 +13,15 @@ """ # built-ins -import py +import glob import json -import re import os +import shutil +import re import subprocess from zipfile import ZipFile +import py # 3rd-party import requests @@ -32,19 +34,14 @@ class TestSkeleton: """ An abstract skeleton to handle testing of a specific group of files """ - def __init__(self, descriptor: str, commands: list, extensions: str=None, file_regex: str=None): + + def __init__(self, descriptor: str, tests: list): """ :param descriptor: The description of this TestSkeleton - :param commands: A list of Command objects. These will be run in the order that they are added. - :param extensions: Which file extensions this skeleton applies to (Unimplemented) - :param file_regex: A regular expression to match files for this skeleton. Combines with extensions (Unimplemented) + :param tests: A list of AssignmentTest objects. These will be run in the order that they are added. """ - if not any((extensions, file_regex)): - raise ValueError('Either extensions or file_regex must be defined') self.descriptor = descriptor - self.commands = commands - self.extensions = extensions - self.file_regex = file_regex + self.tests = tests @classmethod def from_file(cls, filename): @@ -52,46 +49,43 @@ def from_file(cls, filename): data = json.load(skeleton_file) try: descriptor = data['descriptor'] - commands = data['commands'] - extensions = data.get('extensions') - file_regex = data.get('file_regex') - if not any((extensions, file_regex)): - raise KeyError + tests = data['tests'] except KeyError: return None else: - command_list = [] - for json_dict in commands: - command = Command.from_json_dict(commands[json_dict]) - if command is not None: - command_list.append(command) + test_list = [] + for json_dict in tests: + test = AssignmentTest.from_json_dict(tests[json_dict]) + if test is not None: + test_list.append(test) - return TestSkeleton(descriptor, command_list, extensions, file_regex) + return TestSkeleton(descriptor, test_list) def run_tests(self) -> int: total_score = 0 - for count, command in enumerate(self.commands): + for count, test in enumerate(self.tests): print('\n--Running test %i--' % (count + 1)) - if command.run_and_match(): - if command.point_val > 0: - print('--Adding %i points--' % command.point_val) - elif command.point_val == 0: + if test.run_and_match(): + if test.point_val > 0: + print('--Adding %i points--' % test.point_val) + elif test.point_val == 0: print('--No points set for this test--') else: - print('--Subtracting %i points--' % abs(command.point_val)) - total_score += command.point_val + print('--Subtracting %i points--' % abs(test.point_val)) + total_score += test.point_val print('--Current score: %i--' % total_score) return total_score # TODO Create tests -class Command: +class AssignmentTest: """ - An abstract command to be run in a console + An abstract test to be run on an assignment submission """ - def __init__(self, command: str, args: str=None, target_file: str=None, ask_for_target: bool=False, - include_filetype: bool=True, print_output: bool=True, output_match: str=None, output_regex: str=None, - negate_match: bool=False, timeout: int=None, point_val: int=0): + + def __init__(self, command: str, args: str = None, target_file: str = None, ask_for_target: bool = False, + include_filetype: bool = True, print_output: bool = True, output_match: str = None, output_regex: str = None, + negate_match: bool = False, exact_match: bool = False, timeout: int = None, point_val: int = 0): """ :param command: The command to be run. :param args: The arguments to pass to the command. Use %s to denote a file name @@ -103,6 +97,7 @@ def __init__(self, command: str, args: str=None, target_file: str=None, ask_for_ :param output_regex: A regular expression that the string should match. Combines with output_match. If this and output_match are None, then this Command always 'matches' :param negate_match: Whether to negate the result of checking output_match and output_regex + :param exact_match: Whether the naive string match (output_match) should be an exact check or a substring check :param timeout: Time, in seconds, that this Command should run for before timing out :param point_val: Amount of points that a successful match is worth (Can be negative) """ @@ -117,6 +112,7 @@ def __init__(self, command: str, args: str=None, target_file: str=None, ask_for_ else: self.output_regex = None self.negate_match = negate_match + self.exact_match = exact_match self.print_output = print_output self.timeout = timeout self.point_val = point_val @@ -136,18 +132,19 @@ def from_json_dict(cls, json_dict: dict): output_match = json_dict.get('output_match') output_regex = json_dict.get('output_regex') negate_match = json_dict.get('negate_match') + exact_match = json_dict.get('exact_match') timeout = json_dict.get('timeout') point_val = json_dict.get('point_val') vars_dict = {'command': command, 'args': args, 'target_file': target_file, 'ask_for_target': ask_for_target, 'include_filetype': include_filetype, 'print_output': print_output, 'output_match': output_match, 'output_regex': output_regex, - 'negate_match': negate_match, 'timeout': timeout, 'point_val': point_val} + 'negate_match': negate_match, 'exact_match': exact_match, 'timeout': timeout, 'point_val': point_val} args_dict = {} for var_name, val in vars_dict.items(): if val is not None: args_dict[var_name] = val - return Command(**args_dict) + return AssignmentTest(**args_dict) @classmethod def target_prompt(cls, command: str): @@ -177,7 +174,7 @@ def run(self) -> dict: args = self.args filename = self.target_file if self.ask_for_target: - filename = Command.target_prompt(self.command) + filename = AssignmentTest.target_prompt(self.command) if not self.include_filetype: filename = os.path.splitext(filename)[0] if filename is not None: @@ -210,21 +207,25 @@ def run_and_match(self) -> bool: return True if self.output_match: - if self.output_match in result['stdout']: + if self.exact_match: + condition = self.output_match == result['stdout'] + else: + condition = self.output_match in result['stdout'] + + if condition: print('--Matched string comparison--') if self.negate_match: return False return True - if self.negate_match: - return True - return False + return self.negate_match class PyCanvasGrader: """ A PyCanvasGrader object; responsible for communicating with the Canvas API """ + def __init__(self): self.token = self.authenticate() if self.token == 'none': @@ -253,7 +254,7 @@ def authenticate() -> str: def close(self): self.session.close() - def courses(self, enrollment_type: str=None) -> list: + def courses(self, enrollment_type: str = None) -> list: """ :param enrollment_type: (Optional) teacher, student, ta, observer, designer :return: A list of the user's courses as dictionaries, optionally filtered by enrollment_type @@ -265,7 +266,7 @@ def courses(self, enrollment_type: str=None) -> list: response = self.session.get(url) return json.loads(response.text) - def assignments(self, course_id: int, ungraded: bool=True) -> list: + def assignments(self, course_id: int, ungraded: bool = True) -> list: """ :param course_id: Course ID to filter by :param ungraded: Whether to filter assignments by only those that have ungraded work. Default: True @@ -300,6 +301,13 @@ def user(self, course_id: int, user_id: int) -> dict: response = self.session.get(url) return json.loads(response.text) + def grade_submission(self, course_id, assignment_id, user_id, grade): + url = 'https://canvas.instructure.com/api/v1/courses/%i/assignments/%i/submissions/%i/?submission[posted_grade]=%i' \ + % (course_id, assignment_id, user_id, grade) + + response = self.session.put(url) + return json.loads(response.text) + def parse_zip(zip_file: str) -> set: """ @@ -309,7 +317,7 @@ def parse_zip(zip_file: str) -> set: """ user_ids = set() - with ZipFile('zips/' + zip_file, 'r') as z: + with ZipFile(os.path.join('zips', zip_file), 'r') as z: for name in z.namelist(): if name.count('_') < 3: print('Skipping file: ' + name + '. Invalid filename') @@ -317,19 +325,23 @@ def parse_zip(zip_file: str) -> set: (username, user_id, unknown, file) = name.split('_', maxsplit=3) user_ids.add(user_id) - file_path = 'temp/%s/' % user_id - os.makedirs(os.path.dirname('temp/%s/' % user_id), exist_ok=True) + file_path = os.path.join('temp', user_id, '') + os.makedirs(os.path.dirname(file_path), exist_ok=True) z.extract(name, path=file_path) - os.replace(file_path + name, file_path + file) + os.replace(os.path.join(file_path, name), os.path.join(file_path + file)) if len(user_ids) < 1: print('Unable to read any files from the zip. Please check the file and try again') return user_ids -def choose_val(hi_num: int) -> int: +def choose_val(hi_num: int, allow_zero: bool = False) -> int: val = 'none' - while not str.isdigit(val) or (int(val) > hi_num or int(val) <= 0): + + while True: + if str.isdigit(val) and int(val) <= hi_num: + if (allow_zero and int(val) >= 0) or (not allow_zero and int(val) > 0): + break val = input() return int(val) @@ -338,10 +350,7 @@ def choose_bool() -> bool: val = 'none' while not str.lower(val) in ['y', 'n', 'yes', 'no']: val = input() - if val in ['y', 'yes']: - return True - else: - return False + return val in ['y', 'yes'] def parse_skeletons() -> list: @@ -353,25 +362,47 @@ def parse_skeletons() -> list: return skeleton_list +def restart_program(grader: PyCanvasGrader): + grader.close() + clear_tempdir() + main() + exit(0) + + +def clear_tempdir(): + try: + shutil.rmtree(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')) + except Exception: + print('An error occurred while removing the "temp" directory. Please delete the directory manually and re-run the program') + exit(1) + + def main(): + clear_tempdir() # Initialize grading session and fetch courses grader = PyCanvasGrader() course_list = grader.courses('teacher') # Have user select course print('Choose a course from the following list:') - for count, course in enumerate(course_list): - print('%i.\t%s (%s)' % (count + 1, course.get('name'), course.get('course_code'))) - course_choice = choose_val(len(course_list)) - 1 # the plus and minus 1 are to hide the 0-based numbering + for count, course in enumerate(course_list, 1): + print('%i.\t%s (%s)' % (count, course.get('name'), course.get('course_code'))) + course_choice = choose_val(len(course_list)) - 1 # the minus 1 is to hide the 0-based numbering print('Show only ungraded assignments? (y or n):') ungraded = choose_bool() course_id = course_list[course_choice].get('id') assignment_list = grader.assignments(course_list[course_choice].get('id'), ungraded=ungraded) + if len(assignment_list) < 1: + input('No assignments were found. Press enter to restart') + grader.close() + main() + exit(0) + # Have user choose assignment print('Choose an assignment to grade:') - for count, assignment in enumerate(assignment_list): - print('%i.\t%s' % (count + 1, assignment.get('name'))) + for count, assignment in enumerate(assignment_list, 1): + print('%i.\t%s' % (count, assignment.get('name'))) assignment_choice = choose_val(len(assignment_list)) - 1 assignment_id = assignment_list[assignment_choice].get('id') @@ -379,7 +410,7 @@ def main(): print('If you haven\'t already, please download the most current submissions.zip for this assignment:\n' + assignment_list[assignment_choice].get('submissions_download_url')) - input('\nPress enter when this you have placed this zip file into the /zips/ directory') + input('\nPress enter when this you have placed this zip file into the "zips" directory') # Have user choose zip invalid_zip = True @@ -387,13 +418,10 @@ def main(): while invalid_zip: zip_list = [] print('Choose a zip file to use:') - sub = 0 # This is to keep indices visually correct while excluding non-zip files - for count, zip_name in enumerate(os.listdir('zips')): - if zip_name.split('.')[-1] != 'zip': - sub += 1 - continue + for count, zip_name in enumerate(glob.glob(os.path.join('zips', '*.zip')), 1): + zip_name = os.path.basename(zip_name) zip_list.append(zip_name) - print('%i.\t%s' % (count - sub + 1, zip_name)) # Again, the plus and minus 1 are to hide the 0-based numbering + print('%i.\t%s' % (count, zip_name)) # Again, the plus and minus 1 are to hide the 0-based numbering selection = choose_val(len(zip_list)) - 1 zip_file = zip_list[selection] @@ -412,10 +440,14 @@ def main(): main() exit(0) + print('Only grade currently ungraded submissions? (y or n):') + ungraded_only = choose_bool() # Match the user IDs found in the zip with the IDs in the online submission user_submission_dict = {} for user_id in user_ids: for submission in submission_list: + if ungraded_only and submission.get('grader_id') is not None: # Skip assignments that have been graded already + continue long_id = submission.get('user_id') if str(user_id) in str(long_id): file_path = os.path.join(os.getcwd(), 'temp') @@ -450,8 +482,8 @@ def main(): exit(0) print('Choose a skeleton to use for grading this assignment:') - for count, skeleton in enumerate(skeleton_list): - print('%i.\t%s' % (count + 1, skeleton.descriptor)) + for count, skeleton in enumerate(skeleton_list, 1): + print('%i.\t%s' % (count, skeleton.descriptor)) skeleton_choice = choose_val(len(skeleton_list)) - 1 selected_skeleton = skeleton_list[skeleton_choice] @@ -466,7 +498,7 @@ def main(): input('Press enter to begin grading\n') for cur_user_id in user_submission_dict: try: - os.chdir('temp/' + str(cur_user_id)) + os.chdir(os.path.join('temp', str(cur_user_id))) except (WindowsError, OSError): print('Could not access files for user "%i". Skipping' % cur_user_id) continue @@ -477,12 +509,25 @@ def main(): score = 0 action_list = ['Submit this grade', 'Modify this grade', 'Skip this submission', 'Re-grade this submission'] - print('\n--All tests completed--\nGrade for this assignment: %i' % score) - print('Choose an action:') - for count, action in enumerate(action_list): - print('%i.\t%s' % (count + 1, action)) - action_choice = choose_val(len(action_list)) - 1 - selected_action = action_list[skeleton_choice] + while True: + print('\n--All tests completed--\nGrade for this assignment: %i' % score) + print('Choose an action:') + for count, action in enumerate(action_list, 1): + print('%i.\t%s' % (count, action)) + action_choice = choose_val(len(action_list)) - 1 + selected_action = action_list[action_choice] + + if selected_action == 'Submit this grade': + grader.grade_submission(course_id, assignment_id, cur_user_id, score) + print('Grade submitted') + break + elif selected_action == 'Modify this grade': + print('Enter a new grade for this submission:') + score = choose_val(1000, allow_zero=True) + elif selected_action == 'Skip this submission': + break + elif selected_action == 'Re-grade this submission': + score = selected_skeleton.run_tests() print('done') diff --git a/pycanvasgrader/skeletons/java.json b/pycanvasgrader/skeletons/java.json index 3c26ec9..0b30816 100644 --- a/pycanvasgrader/skeletons/java.json +++ b/pycanvasgrader/skeletons/java.json @@ -2,7 +2,7 @@ "descriptor": "Example Java skeleton", "extensions": ".java", - "commands": { + "tests": { "test_1": { "command": "C:\\Program Files\\Java\\jdk1.8.0_92\\bin\\javac.exe", diff --git a/pycanvasgrader/temp/git_include.dir b/pycanvasgrader/temp/git_include.dir deleted file mode 100644 index e69de29..0000000 From 5e6be8b52ebd96c70dfa2d0bce3922e3bac8ca2a Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Sat, 9 Sep 2017 18:23:39 -0400 Subject: [PATCH 13/40] Use restart_program function where required --- pycanvasgrader/pycanvasgrader.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 991a234..1a3d8e8 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -2,7 +2,6 @@ """ Automates the grading of programming assignments on Canvas. MUST create an 'access.token' file in the same directory as this file with a valid Canvas OAuth2 token -MUST clear temp directory before running program (TODO will fix this) REQUIRED File structure: - pycanvasgrader -- skeletons @@ -395,9 +394,7 @@ def main(): if len(assignment_list) < 1: input('No assignments were found. Press enter to restart') - grader.close() - main() - exit(0) + restart_program(grader) # Have user choose assignment print('Choose an assignment to grade:') @@ -436,9 +433,7 @@ def main(): submission_list = grader.submissions(course_id, assignment_id) if len(submission_list) < 1: print('There are no submissions for this assignment.') - grader.close() - main() - exit(0) + restart_program(grader) print('Only grade currently ungraded submissions? (y or n):') ungraded_only = choose_bool() @@ -456,9 +451,7 @@ def main(): if len(user_submission_dict) < 1: print('Could not match any file names in the zip to any online submissions.') - grader.close() - main() - exit(0) + restart_program(grader) s = '' if len(user_submission_dict) > 1: @@ -466,9 +459,7 @@ def main(): print('Successfully matched %i submission%s to files in the zip file. Is this correct? (y or n):' % (len(user_submission_dict), s)) correct = choose_bool() if not correct: - grader.close() - main() - exit(0) + restart_program(grader) skeleton_list = parse_skeletons() if len(skeleton_list) < 1: @@ -477,9 +468,7 @@ def main(): print('unimplemented') else: pass - grader.close() - main() - exit(0) + restart_program(grader) print('Choose a skeleton to use for grading this assignment:') for count, skeleton in enumerate(skeleton_list, 1): From 6c1664098e2c3b0c269acbfd814823414ce13007 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Sat, 9 Sep 2017 18:27:55 -0400 Subject: [PATCH 14/40] Use proper exception type --- pycanvasgrader/pycanvasgrader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 1a3d8e8..3fec810 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -371,7 +371,7 @@ def restart_program(grader: PyCanvasGrader): def clear_tempdir(): try: shutil.rmtree(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')) - except Exception: + except BaseException: print('An error occurred while removing the "temp" directory. Please delete the directory manually and re-run the program') exit(1) From cc05b60f5e229816b5eece4d477b19a7e1551c1d Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Sat, 9 Sep 2017 21:38:06 -0400 Subject: [PATCH 15/40] Update comments --- pycanvasgrader/pycanvasgrader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 3fec810..f5f5664 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -159,7 +159,7 @@ def target_prompt(cls, command: str): sub += 1 continue file_list.append(file_name) - print('%i.\t%s' % (count - sub + 1, file_name)) # The plus and minus 1 are to hide the 0-based numbering + print('%i.\t%s' % (count - sub + 1, file_name)) # The minus 1 is to hide the 0-based numbering selection = choose_val(len(file_list)) - 1 return file_list[selection] @@ -418,7 +418,7 @@ def main(): for count, zip_name in enumerate(glob.glob(os.path.join('zips', '*.zip')), 1): zip_name = os.path.basename(zip_name) zip_list.append(zip_name) - print('%i.\t%s' % (count, zip_name)) # Again, the plus and minus 1 are to hide the 0-based numbering + print('%i.\t%s' % (count, zip_name)) # Again, the minus 1 is to hide the 0-based numbering selection = choose_val(len(zip_list)) - 1 zip_file = zip_list[selection] From d11c4714bdb4de223e10a05c1bc247ff04d4717a Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Tue, 12 Sep 2017 16:47:55 -0400 Subject: [PATCH 16/40] Updated temp directory handling --- pycanvasgrader/pycanvasgrader.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index f5f5664..e900253 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -363,21 +363,23 @@ def parse_skeletons() -> list: def restart_program(grader: PyCanvasGrader): grader.close() - clear_tempdir() + init_tempdir() main() exit(0) -def clear_tempdir(): - try: - shutil.rmtree(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')) - except BaseException: - print('An error occurred while removing the "temp" directory. Please delete the directory manually and re-run the program') - exit(1) +def init_tempdir(): + try: + if os.path.exists('temp'): + shutil.rmtree(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')) + os.makedirs('temp', exist_ok=True) + except BaseException as e: + print('An error occurred while initializing the "temp" directory. Please delete/create the directory manually and re-run the program') + exit(1) def main(): - clear_tempdir() + init_tempdir() # Initialize grading session and fetch courses grader = PyCanvasGrader() course_list = grader.courses('teacher') From a5ecb19cb2ba7e2c3fe6ba86faa19eef479d9ee4 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Tue, 12 Sep 2017 18:52:08 -0400 Subject: [PATCH 17/40] submissions are now downloaded directly from canvas, rather than taken from the submissions.zip --- pycanvasgrader/pycanvasgrader.py | 119 ++++++++++++++++++------------- 1 file changed, 71 insertions(+), 48 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index e900253..53a0af9 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -9,10 +9,12 @@ -- zips access.token pycanvasgrader.py + + TODO test temp directory deletion timing on Windows + TODO implement skeleton creation wizard """ # built-ins -import glob import json import os import shutil @@ -80,6 +82,7 @@ def run_tests(self) -> int: class AssignmentTest: """ An abstract test to be run on an assignment submission + TODO 'sequential' command requirement """ def __init__(self, command: str, args: str = None, target_file: str = None, ask_for_target: bool = False, @@ -258,7 +261,7 @@ def courses(self, enrollment_type: str = None) -> list: :param enrollment_type: (Optional) teacher, student, ta, observer, designer :return: A list of the user's courses as dictionaries, optionally filtered by enrollment_type """ - url = 'https://canvas.instructure.com/api/v1/courses' + url = 'https://sit.instructure.com/api/v1/courses' if enrollment_type is not None: url += '?enrollment_type=' + enrollment_type @@ -271,7 +274,7 @@ def assignments(self, course_id: int, ungraded: bool = True) -> list: :param ungraded: Whether to filter assignments by only those that have ungraded work. Default: True :return: A list of the course's assignments """ - url = 'https://canvas.instructure.com/api/v1/courses/' + str(course_id) + '/assignments' + url = 'https://sit.instructure.com/api/v1/courses/' + str(course_id) + '/assignments' if ungraded: url += '?bucket=ungraded' @@ -284,24 +287,52 @@ def submissions(self, course_id: int, assignment_id: int) -> list: :param assignment_id: The ID of the assignment :return: A list of the assignment's submissions """ - url = 'https://canvas.instructure.com/api/v1/courses/' + str(course_id) + '/assignments/' + str(assignment_id) + '/submissions' + url = 'https://sit.instructure.com/api/v1/courses/' + str(course_id) + '/assignments/' + str(assignment_id) + '/submissions' response = self.session.get(url) return json.loads(response.text) + def download_submission(self, submission: dict, filepath: str) -> bool: + """ + Attempts to download the attachments for a given submission into the requested directory. Creates the directory if it does not exist. + :param submission: The submission dictionary + :param filepath: the path where the submission attachments will be placed + :return: True if the request succeeded, False otherwise + """ + try: + user_id = submission['user_id'] + attachments = submission['attachments'] + except ValueError: + return False + + for attachment in attachments: + try: + url = attachment['url'] + filename = attachment['filename'] + except ValueError: + return False + + os.makedirs(os.path.join('temp', str(user_id)), exist_ok=True) + r = self.session.get(url, stream=True) + with open(os.path.join(filepath, filename), 'wb') as f: + for chunk in r.iter_content(chunk_size=1024): + if chunk: + f.write(chunk) + return True + def user(self, course_id: int, user_id: int) -> dict: """ :param course_id: The class to search :param user_id: The ID of the user :return: A dictionary with the user's information """ - url = 'https://canvas.instructure.com/api/v1/courses/%i/users/%i' % (course_id, user_id) + url = 'https://sit.instructure.com/api/v1/courses/%i/users/%i' % (course_id, user_id) response = self.session.get(url) return json.loads(response.text) def grade_submission(self, course_id, assignment_id, user_id, grade): - url = 'https://canvas.instructure.com/api/v1/courses/%i/assignments/%i/submissions/%i/?submission[posted_grade]=%i' \ + url = 'https://sit.instructure.com/api/v1/courses/%i/assignments/%i/submissions/%i/?submission[posted_grade]=%i' \ % (course_id, assignment_id, user_id, grade) response = self.session.put(url) @@ -353,6 +384,10 @@ def choose_bool() -> bool: def parse_skeletons() -> list: + """ + Responsible for validating and parsing the skeleton files in the "skeletons" directory + :return: A list of valid skeletons + """ skeleton_list = [] for skeleton_file in os.listdir('skeletons'): skeleton = TestSkeleton.from_file(skeleton_file) @@ -371,9 +406,10 @@ def restart_program(grader: PyCanvasGrader): def init_tempdir(): try: if os.path.exists('temp'): - shutil.rmtree(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')) + os.rename('temp', 'old-temp') + shutil.rmtree(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'old-temp')) os.makedirs('temp', exist_ok=True) - except BaseException as e: + except BaseException: print('An error occurred while initializing the "temp" directory. Please delete/create the directory manually and re-run the program') exit(1) @@ -382,7 +418,20 @@ def main(): init_tempdir() # Initialize grading session and fetch courses grader = PyCanvasGrader() - course_list = grader.courses('teacher') + + action_list = ['teacher', 'ta'] + + print('Choose a class role to filter by:') + for count, action in enumerate(action_list, 1): + print('%i.\t%s' % (count, action)) + action_choice = choose_val(len(action_list)) - 1 + selected_role = action_list[action_choice] + + course_list = grader.courses(selected_role) + if len(course_list) < 1: + input('No courses were found. Press enter to restart') + restart_program(grader) + # Have user select course print('Choose a course from the following list:') for count, course in enumerate(course_list, 1): @@ -405,60 +454,34 @@ def main(): assignment_choice = choose_val(len(assignment_list)) - 1 assignment_id = assignment_list[assignment_choice].get('id') - # Remind user to get latest zip file - print('If you haven\'t already, please download the most current submissions.zip for this assignment:\n' + - assignment_list[assignment_choice].get('submissions_download_url')) - - input('\nPress enter when this you have placed this zip file into the "zips" directory') - - # Have user choose zip - invalid_zip = True - user_ids = [] - while invalid_zip: - zip_list = [] - print('Choose a zip file to use:') - for count, zip_name in enumerate(glob.glob(os.path.join('zips', '*.zip')), 1): - zip_name = os.path.basename(zip_name) - zip_list.append(zip_name) - print('%i.\t%s' % (count, zip_name)) # Again, the minus 1 is to hide the 0-based numbering - - selection = choose_val(len(zip_list)) - 1 - zip_file = zip_list[selection] - - user_ids = parse_zip(zip_file) - if len(user_ids) > 0: - invalid_zip = False - else: - print('This zip is invalid. Make sure you do not change the names inside the zip and try again') - # Get list of submissions for this assignment submission_list = grader.submissions(course_id, assignment_id) if len(submission_list) < 1: - print('There are no submissions for this assignment.') + input('There are no submissions for this assignment. Press enter to restart') restart_program(grader) print('Only grade currently ungraded submissions? (y or n):') ungraded_only = choose_bool() # Match the user IDs found in the zip with the IDs in the online submission user_submission_dict = {} - for user_id in user_ids: - for submission in submission_list: - if ungraded_only and submission.get('grader_id') is not None: # Skip assignments that have been graded already - continue - long_id = submission.get('user_id') - if str(user_id) in str(long_id): - file_path = os.path.join(os.getcwd(), 'temp') - os.rename(os.path.join(file_path, str(user_id)), os.path.join(file_path, str(long_id))) - user_submission_dict[long_id] = submission['id'] + for submission in submission_list: + if ungraded_only and submission.get('grader_id') is not None: # Skip assignments that have been graded already + continue + user_id = submission.get('user_id') + if submission.get('attachments') is not None: + if grader.download_submission(submission, os.path.join('temp', str(user_id))): + user_submission_dict[user_id] = submission['id'] + else: + print('There was a problem downloading this user\'s submission. Skipping.') if len(user_submission_dict) < 1: - print('Could not match any file names in the zip to any online submissions.') + input('Could not download any submissions. Press enter to restart') restart_program(grader) s = '' if len(user_submission_dict) > 1: s = 's' - print('Successfully matched %i submission%s to files in the zip file. Is this correct? (y or n):' % (len(user_submission_dict), s)) + print('Successfully retrieved %i submission%s. Is this correct? (y or n):' % (len(user_submission_dict), s)) correct = choose_bool() if not correct: restart_program(grader) @@ -470,7 +493,7 @@ def main(): print('unimplemented') else: pass - restart_program(grader) + exit(0) print('Choose a skeleton to use for grading this assignment:') for count, skeleton in enumerate(skeleton_list, 1): From cf353388b9943dfc929933efe16a8094bcc00100 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Tue, 12 Sep 2017 20:08:04 -0400 Subject: [PATCH 18/40] removed unused parse_zip --- pycanvasgrader/pycanvasgrader.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 53a0af9..6aba5c1 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -21,8 +21,8 @@ import re import subprocess from zipfile import ZipFile - import py + # 3rd-party import requests @@ -339,32 +339,6 @@ def grade_submission(self, course_id, assignment_id, user_id, grade): return json.loads(response.text) -def parse_zip(zip_file: str) -> set: - """ - Maps file names to user IDs from a zip of downloaded Canvas submissions - :param zip_file: The name of the zip file to parse - :return: if the zip can be parsed, a dictionary containing the user ID's mapped to the parsed file name, otherwise an empty dict - """ - user_ids = set() - - with ZipFile(os.path.join('zips', zip_file), 'r') as z: - for name in z.namelist(): - if name.count('_') < 3: - print('Skipping file: ' + name + '. Invalid filename') - else: - (username, user_id, unknown, file) = name.split('_', maxsplit=3) - user_ids.add(user_id) - - file_path = os.path.join('temp', user_id, '') - os.makedirs(os.path.dirname(file_path), exist_ok=True) - z.extract(name, path=file_path) - os.replace(os.path.join(file_path, name), os.path.join(file_path + file)) - - if len(user_ids) < 1: - print('Unable to read any files from the zip. Please check the file and try again') - return user_ids - - def choose_val(hi_num: int, allow_zero: bool = False) -> int: val = 'none' From 6e7f60da90cab1c1ef572aae8261d7d8b10ca62a Mon Sep 17 00:00:00 2001 From: ThePyrotechnic Date: Wed, 13 Sep 2017 17:36:00 -0400 Subject: [PATCH 19/40] started numeric matching & more automation options --- pycanvasgrader/pycanvasgrader.py | 47 +++++++++++++++++++++++++------ pycanvasgrader/skeletons/C++.json | 11 ++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 pycanvasgrader/skeletons/C++.json diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 6aba5c1..a168d46 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -6,12 +6,12 @@ - pycanvasgrader -- skeletons -- temp - -- zips access.token pycanvasgrader.py TODO test temp directory deletion timing on Windows TODO implement skeleton creation wizard + TODO test/implement visual grading (ask for grade after test runs) """ # built-ins @@ -20,10 +20,9 @@ import shutil import re import subprocess -from zipfile import ZipFile -import py # 3rd-party +import py import requests # globals @@ -36,13 +35,15 @@ class TestSkeleton: An abstract skeleton to handle testing of a specific group of files """ - def __init__(self, descriptor: str, tests: list): + def __init__(self, descriptor: str, automation_level: str, tests: list): """ :param descriptor: The description of this TestSkeleton :param tests: A list of AssignmentTest objects. These will be run in the order that they are added. + :param automation_level: How many questions the grader should ask """ self.descriptor = descriptor self.tests = tests + self.automation_level = automation_level @classmethod def from_file(cls, filename): @@ -85,12 +86,14 @@ class AssignmentTest: TODO 'sequential' command requirement """ - def __init__(self, command: str, args: str = None, target_file: str = None, ask_for_target: bool = False, - include_filetype: bool = True, print_output: bool = True, output_match: str = None, output_regex: str = None, + def __init__(self, command: str, args: str = None, single_file: bool = False, target_file: str = None, + ask_for_target: bool = False, include_filetype: bool = True, print_output: bool = True, + output_match: str = None, output_regex: str = None, numeric_match: list = None, negate_match: bool = False, exact_match: bool = False, timeout: int = None, point_val: int = 0): """ :param command: The command to be run. :param args: The arguments to pass to the command. Use %s to denote a file name + :param single_file: Whether to assume the assignment is a single file and use the first file found as %s :param target_file: The file to replace %s with :param ask_for_target: Whether to prompt for a file in the current directory. Overrides file_target :param include_filetype: Whether to include the filetype in the %s substitution @@ -98,6 +101,7 @@ def __init__(self, command: str, args: str = None, target_file: str = None, ask_ :param output_match: An exact string that the output should match. If this and output_regex are None, then this Command always 'matches' :param output_regex: A regular expression that the string should match. Combines with output_match. If this and output_match are None, then this Command always 'matches' + :param numeric_match: Enables numeric matching. This overrides string and regex matching :param negate_match: Whether to negate the result of checking output_match and output_regex :param exact_match: Whether the naive string match (output_match) should be an exact check or a substring check :param timeout: Time, in seconds, that this Command should run for before timing out @@ -105,6 +109,7 @@ def __init__(self, command: str, args: str = None, target_file: str = None, ask_ """ self.command = command self.args = args + self.single_file = single_file self.target_file = target_file self.ask_for_target = ask_for_target self.include_filetype = include_filetype @@ -113,6 +118,7 @@ def __init__(self, command: str, args: str = None, target_file: str = None, ask_ self.output_regex = re.compile(output_regex) else: self.output_regex = None + self.numeric_match = numeric_match self.negate_match = negate_match self.exact_match = exact_match self.print_output = print_output @@ -127,21 +133,24 @@ def from_json_dict(cls, json_dict: dict): return None else: args = json_dict.get('args') + single_file = json_dict.get('single_file') target_file = json_dict.get('target_file') ask_for_target = json_dict.get('ask_for_target') include_filetype = json_dict.get('include_filetype') print_output = json_dict.get('print_output') output_match = json_dict.get('output_match') output_regex = json_dict.get('output_regex') + numeric_match = json_dict.get('numeric_match') negate_match = json_dict.get('negate_match') exact_match = json_dict.get('exact_match') timeout = json_dict.get('timeout') point_val = json_dict.get('point_val') - vars_dict = {'command': command, 'args': args, 'target_file': target_file, + vars_dict = {'command': command, 'args': args, 'single_file': single_file, 'target_file': target_file, 'ask_for_target': ask_for_target, 'include_filetype': include_filetype, 'print_output': print_output, 'output_match': output_match, 'output_regex': output_regex, - 'negate_match': negate_match, 'exact_match': exact_match, 'timeout': timeout, 'point_val': point_val} + 'numeric_match': numeric_match, 'negate_match': negate_match, 'exact_match': exact_match, + 'timeout': timeout, 'point_val': point_val} args_dict = {} for var_name, val in vars_dict.items(): if val is not None: @@ -201,6 +210,28 @@ def run_and_match(self) -> bool: if not any((self.output_match, self.output_regex)): return True + if self.numeric_match is not None: + numeric_match = list(self.numeric_match) # Clone the list + extracted_nums = [] + for t in result['stdout'].split(): + try: + extracted_nums.append(float(t)) + except ValueError: + pass + if len(extracted_nums) == 0: + return False + + for num in numeric_match: + for number in extracted_nums: + if isinstance(num, list): + if num[0] <= number <= num[1]: + numeric_match.remove(num) + elif isinstance(num, (int, float)): + if number == num: + numeric_match.remove(num) + + return len(numeric_match) == 0 + if self.output_regex: if self.output_regex.match(result['stdout']): print('--Matched regular expression--') diff --git a/pycanvasgrader/skeletons/C++.json b/pycanvasgrader/skeletons/C++.json new file mode 100644 index 0000000..f9c4721 --- /dev/null +++ b/pycanvasgrader/skeletons/C++.json @@ -0,0 +1,11 @@ +{ + "descriptor": "C++ high automation", + "automation_level": 3, + "tests": { + "compile_test": { + "command": "g++ -std=c++11", + "numeric_match": [[8.98,9],[9.98,10], 12], + "single_file": true + } + } +} \ No newline at end of file From 1e7cedb2a30cca201e0de193a6b67f9ec5cacf93 Mon Sep 17 00:00:00 2001 From: ThePyrotechnic Date: Wed, 13 Sep 2017 22:33:08 -0400 Subject: [PATCH 20/40] started numeric matching & more automation options --- pycanvasgrader/pycanvasgrader.py | 87 +++++++++++++++++++----------- pycanvasgrader/skeletons/C++.json | 19 +++++-- pycanvasgrader/skeletons/java.json | 2 - 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index a168d46..23bbc8f 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -28,14 +28,16 @@ # globals RUN_WITH_TESTS = False ONLY_RUN_TESTS = False - +NUM_REGEX = re.compile(r'[+-]?\d+\.\d+|\d+') +#r'[+-]?\d+\.\d+|\d+' +#r'[-+]?\d+(\.\d+)?' class TestSkeleton: """ An abstract skeleton to handle testing of a specific group of files """ - def __init__(self, descriptor: str, automation_level: str, tests: list): + def __init__(self, descriptor: str, tests: list, automation_level: int = 0): """ :param descriptor: The description of this TestSkeleton :param tests: A list of AssignmentTest objects. These will be run in the order that they are added. @@ -48,20 +50,27 @@ def __init__(self, descriptor: str, automation_level: str, tests: list): @classmethod def from_file(cls, filename): with open('skeletons/' + filename) as skeleton_file: - data = json.load(skeleton_file) + try: + data = json.load(skeleton_file) + except json.JSONDecodeError: + print('There is an error in the %s skeleton file. This skeleton will not be available' % filename) + return None try: descriptor = data['descriptor'] tests = data['tests'] except KeyError: return None else: + automation_level = data.get('automation_level') + if automation_level is None: + automation_level = 0 test_list = [] for json_dict in tests: test = AssignmentTest.from_json_dict(tests[json_dict]) if test is not None: test_list.append(test) - return TestSkeleton(descriptor, test_list) + return TestSkeleton(descriptor, test_list, automation_level) def run_tests(self) -> int: total_score = 0 @@ -79,7 +88,6 @@ def run_tests(self) -> int: return total_score -# TODO Create tests class AssignmentTest: """ An abstract test to be run on an assignment submission @@ -184,7 +192,11 @@ def run(self) -> dict: command = self.command args = self.args filename = self.target_file - if self.ask_for_target: + if self.single_file and len(os.listdir(os.getcwd())) > 0: + filename = os.listdir(os.getcwd())[0] + elif len(os.listdir(os.getcwd())) == 1: + filename = os.listdir(os.getcwd()[0]) + elif self.ask_for_target: filename = AssignmentTest.target_prompt(self.command) if not self.include_filetype: filename = os.path.splitext(filename)[0] @@ -202,27 +214,29 @@ def run_and_match(self) -> bool: Runs the command and matches the output to the output_match/regex. If neither are defined then this always returns true :return: Whether the output matched or not """ + global NUM_REGEX + result = self.run() if self.print_output: print('\t--OUTPUT--') print(result['stdout']) print('\t--END OUTPUT--') - if not any((self.output_match, self.output_regex)): + if not any((self.output_match, self.output_regex, self.numeric_match)): return True if self.numeric_match is not None: numeric_match = list(self.numeric_match) # Clone the list - extracted_nums = [] - for t in result['stdout'].split(): - try: - extracted_nums.append(float(t)) - except ValueError: - pass - if len(extracted_nums) == 0: - return False - - for num in numeric_match: - for number in extracted_nums: + extracted_nums = map(float, re.findall(NUM_REGEX, result['stdout'])) + + for number in extracted_nums: + for num in numeric_match: + if isinstance(num, str): + numeric_match.remove(num) + range_vals = list(map(float, re.findall(NUM_REGEX, num))) + if len(range_vals) != 2: + continue + num = [range_vals[0] - range_vals[1], range_vals[0] + range_vals[1]] + numeric_match.append(num) if isinstance(num, list): if num[0] <= number <= num[1]: numeric_match.remove(num) @@ -514,6 +528,10 @@ def main(): name_dict[user_id] = user_data['name'] print(str(user_data.get('name')) + '\t(%s)' % user_data.get('email')) print('----\n') + + print('Require confirmation before submitting grades? (y or n)') + grade_conf = choose_bool() + input('Press enter to begin grading\n') for cur_user_id in user_submission_dict: try: @@ -530,23 +548,28 @@ def main(): while True: print('\n--All tests completed--\nGrade for this assignment: %i' % score) - print('Choose an action:') - for count, action in enumerate(action_list, 1): - print('%i.\t%s' % (count, action)) - action_choice = choose_val(len(action_list)) - 1 - selected_action = action_list[action_choice] - - if selected_action == 'Submit this grade': + if not grade_conf: grader.grade_submission(course_id, assignment_id, cur_user_id, score) print('Grade submitted') break - elif selected_action == 'Modify this grade': - print('Enter a new grade for this submission:') - score = choose_val(1000, allow_zero=True) - elif selected_action == 'Skip this submission': - break - elif selected_action == 'Re-grade this submission': - score = selected_skeleton.run_tests() + else: + print('Choose an action:') + for count, action in enumerate(action_list, 1): + print('%i.\t%s' % (count, action)) + action_choice = choose_val(len(action_list)) - 1 + selected_action = action_list[action_choice] + + if selected_action == 'Submit this grade': + grader.grade_submission(course_id, assignment_id, cur_user_id, score) + print('Grade submitted') + break + elif selected_action == 'Modify this grade': + print('Enter a new grade for this submission:') + score = choose_val(1000, allow_zero=True) + elif selected_action == 'Skip this submission': + break + elif selected_action == 'Re-grade this submission': + score = selected_skeleton.run_tests() print('done') diff --git a/pycanvasgrader/skeletons/C++.json b/pycanvasgrader/skeletons/C++.json index f9c4721..e4840b3 100644 --- a/pycanvasgrader/skeletons/C++.json +++ b/pycanvasgrader/skeletons/C++.json @@ -1,11 +1,22 @@ { - "descriptor": "C++ high automation", - "automation_level": 3, + "descriptor": "Java high automation", "tests": { "compile_test": { - "command": "g++ -std=c++11", - "numeric_match": [[8.98,9],[9.98,10], 12], + "command": "C:\\Program Files\\Java\\jdk1.8.0_92\\bin\\javac.exe", + "args": "%s", + "print_output": false, + "point_val": 25, + "output_match": "error", + "negate_match": true, "single_file": true + }, + "run_test": { + "command": "java", + "args": "%s", + "numeric_match": ["2|0.1"], + "single_file": true, + "include_filetype": false, + "point_val": 75 } } } \ No newline at end of file diff --git a/pycanvasgrader/skeletons/java.json b/pycanvasgrader/skeletons/java.json index 0b30816..b53d82c 100644 --- a/pycanvasgrader/skeletons/java.json +++ b/pycanvasgrader/skeletons/java.json @@ -1,7 +1,5 @@ { "descriptor": "Example Java skeleton", - "extensions": ".java", - "tests": { "test_1": { From 2758f625ba34734e5173461349d013b0e2636faf Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Mon, 18 Sep 2017 12:24:52 -0400 Subject: [PATCH 21/40] fixed file handling bugs, added print file option, made args a proper list --- pycanvasgrader/pycanvasgrader.py | 46 +++++++++++++++++++---------- pycanvasgrader/skeletons/C++.json | 22 -------------- pycanvasgrader/skeletons/HW1c.json | 23 +++++++++++++++ pycanvasgrader/skeletons/HW2e.json | 21 +++++++++++++ pycanvasgrader/skeletons/java.json | 24 --------------- pycanvasgrader/test_files/HW1c.cpp | 25 ++++++++++++++++ pycanvasgrader/test_files/HW2e.cpp | 47 ++++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 62 deletions(-) delete mode 100644 pycanvasgrader/skeletons/C++.json create mode 100644 pycanvasgrader/skeletons/HW1c.json create mode 100644 pycanvasgrader/skeletons/HW2e.json delete mode 100644 pycanvasgrader/skeletons/java.json create mode 100644 pycanvasgrader/test_files/HW1c.cpp create mode 100644 pycanvasgrader/test_files/HW2e.cpp diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 23bbc8f..c3d102a 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -20,6 +20,7 @@ import shutil import re import subprocess +import sys # 3rd-party import py @@ -29,8 +30,9 @@ RUN_WITH_TESTS = False ONLY_RUN_TESTS = False NUM_REGEX = re.compile(r'[+-]?\d+\.\d+|\d+') -#r'[+-]?\d+\.\d+|\d+' -#r'[-+]?\d+(\.\d+)?' +# r'[+-]?\d+\.\d+|\d+' +# r'[-+]?\d+(\.\d+)?' + class TestSkeleton: """ @@ -94,13 +96,14 @@ class AssignmentTest: TODO 'sequential' command requirement """ - def __init__(self, command: str, args: str = None, single_file: bool = False, target_file: str = None, + def __init__(self, command: str, args: list = None, print_file: bool = False, single_file: bool = False, target_file: str = None, ask_for_target: bool = False, include_filetype: bool = True, print_output: bool = True, output_match: str = None, output_regex: str = None, numeric_match: list = None, negate_match: bool = False, exact_match: bool = False, timeout: int = None, point_val: int = 0): """ :param command: The command to be run. - :param args: The arguments to pass to the command. Use %s to denote a file name + :param args: List of arguments to pass to the command. Use %s to denote a file name + :param print_file: Whether to print the contents of the target_file being tested (Does nothing if no file is selected) :param single_file: Whether to assume the assignment is a single file and use the first file found as %s :param target_file: The file to replace %s with :param ask_for_target: Whether to prompt for a file in the current directory. Overrides file_target @@ -117,6 +120,7 @@ def __init__(self, command: str, args: str = None, single_file: bool = False, ta """ self.command = command self.args = args + self.print_file = print_file self.single_file = single_file self.target_file = target_file self.ask_for_target = ask_for_target @@ -141,6 +145,7 @@ def from_json_dict(cls, json_dict: dict): return None else: args = json_dict.get('args') + print_file = json_dict.get('print_file') single_file = json_dict.get('single_file') target_file = json_dict.get('target_file') ask_for_target = json_dict.get('ask_for_target') @@ -154,7 +159,7 @@ def from_json_dict(cls, json_dict: dict): timeout = json_dict.get('timeout') point_val = json_dict.get('point_val') - vars_dict = {'command': command, 'args': args, 'single_file': single_file, 'target_file': target_file, + vars_dict = {'command': command, 'args': args, 'print_file': print_file, 'single_file': single_file, 'target_file': target_file, 'ask_for_target': ask_for_target, 'include_filetype': include_filetype, 'print_output': print_output, 'output_match': output_match, 'output_regex': output_regex, 'numeric_match': numeric_match, 'negate_match': negate_match, 'exact_match': exact_match, @@ -192,21 +197,30 @@ def run(self) -> dict: command = self.command args = self.args filename = self.target_file - if self.single_file and len(os.listdir(os.getcwd())) > 0: - filename = os.listdir(os.getcwd())[0] - elif len(os.listdir(os.getcwd())) == 1: - filename = os.listdir(os.getcwd()[0]) - elif self.ask_for_target: - filename = AssignmentTest.target_prompt(self.command) - if not self.include_filetype: + if filename is None: + if self.single_file and len(os.listdir(os.getcwd())) > 0: + filename = os.listdir(os.getcwd())[0] + elif len(os.listdir(os.getcwd())) == 1: + filename = os.listdir(os.getcwd())[0] + elif self.ask_for_target: + filename = AssignmentTest.target_prompt(self.command) + + if not self.include_filetype and filename is not None: filename = os.path.splitext(filename)[0] if filename is not None: + if self.print_file: + print('--FILE--') + with open(filename, "r") as f: + shutil.copyfileobj(f, sys.stdout) + print('--END FILE--') command = self.command.replace('%s', filename) - args = self.args.replace('%s', filename) - elif '%s' in self.command + '|' + self.args: - print('No filename given, but this command contains filename wildcards (%s). This command will probably fail') + if args is not None: + args = [arg.replace('%s', filename) for arg in args] - proc = subprocess.run([command, args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8', shell=True) + if args is not None: + proc = subprocess.run([command] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8', shell=True) + else: + proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.timeout, encoding='UTF-8', shell=True) return {'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr} def run_and_match(self) -> bool: diff --git a/pycanvasgrader/skeletons/C++.json b/pycanvasgrader/skeletons/C++.json deleted file mode 100644 index e4840b3..0000000 --- a/pycanvasgrader/skeletons/C++.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "descriptor": "Java high automation", - "tests": { - "compile_test": { - "command": "C:\\Program Files\\Java\\jdk1.8.0_92\\bin\\javac.exe", - "args": "%s", - "print_output": false, - "point_val": 25, - "output_match": "error", - "negate_match": true, - "single_file": true - }, - "run_test": { - "command": "java", - "args": "%s", - "numeric_match": ["2|0.1"], - "single_file": true, - "include_filetype": false, - "point_val": 75 - } - } -} \ No newline at end of file diff --git a/pycanvasgrader/skeletons/HW1c.json b/pycanvasgrader/skeletons/HW1c.json new file mode 100644 index 0000000..2776232 --- /dev/null +++ b/pycanvasgrader/skeletons/HW1c.json @@ -0,0 +1,23 @@ +{ + "descriptor": "HW1c Grader", + "tests": { + + "test_1": { + "command": "g++", + "args": ["%s", "-o", "out"], + "single_file": true, + "print_output": true, + "print_file": true, + "output_match": "error", + "negate_match": true, + "point_val": 25 + }, + "test_2": { + "command": "out.exe", + "print_output": true, + "output_match": "5050\n5050\n", + "exact_match": false, + "point_val": 75 + } + } +} diff --git a/pycanvasgrader/skeletons/HW2e.json b/pycanvasgrader/skeletons/HW2e.json new file mode 100644 index 0000000..c5af1b5 --- /dev/null +++ b/pycanvasgrader/skeletons/HW2e.json @@ -0,0 +1,21 @@ +{ + "descriptor": "HW2e Grader", + "tests": { + + "test_1": { + "command": "g++", + "args": ["%s", "-o", "out"], + "print_output": true, + "point_val": 25, + "output_match": "error", + "negate_match": true + }, + "test_2": { + "command": "out.exe", + "print_output": true, + "output_match": "120 120\n2004310016 2004310016\n5 5\n233 233\n20358520\n", + "exact_match": true, + "point_val": 75 + } + } +} diff --git a/pycanvasgrader/skeletons/java.json b/pycanvasgrader/skeletons/java.json deleted file mode 100644 index b53d82c..0000000 --- a/pycanvasgrader/skeletons/java.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "descriptor": "Example Java skeleton", - "tests": { - - "test_1": { - "command": "C:\\Program Files\\Java\\jdk1.8.0_92\\bin\\javac.exe", - "args": "%s", - "print_output": true, - "point_val": 25, - "output_match": "error", - "negate_match": true, - "ask_for_target": true - }, - "test_2": { - "command": "java", - "args": "%s", - "print_output": true, - "output_match": "Variable Value :2", - "point_val": 75, - "ask_for_target": true, - "include_filetype": false - } - } -} \ No newline at end of file diff --git a/pycanvasgrader/test_files/HW1c.cpp b/pycanvasgrader/test_files/HW1c.cpp new file mode 100644 index 0000000..a22849c --- /dev/null +++ b/pycanvasgrader/test_files/HW1c.cpp @@ -0,0 +1,25 @@ +#include + +using namespace std; + +int sum(int); +int sum2(int); + +int main() +{ + cout << sum(100) << '\n'; + cout << sum2(100) << '\n'; + return 0; +} + +int sum(int n) { + int total = 0; + for (int a = 1; a <= n; a++) + total += a; + + return total; +} + +int sum2(int n) { + return n * (n + 1) / 2; +} diff --git a/pycanvasgrader/test_files/HW2e.cpp b/pycanvasgrader/test_files/HW2e.cpp new file mode 100644 index 0000000..6106105 --- /dev/null +++ b/pycanvasgrader/test_files/HW2e.cpp @@ -0,0 +1,47 @@ +#include + +using namespace std; + +int fact(int); +int fibo(int); +unsigned choose(unsigned, unsigned); + +int main() +{ + cout << fact(5) << ' ' << fact(5) << '\n'; + cout << fact(15) << ' ' << fact(15) << '\n'; + cout << fibo(5) << ' ' << fibo(5) << '\n'; + cout << fibo(13) << ' ' << fibo(13) << '\n'; + cout << choose(52,6) << '\n'; + + return 0; +} + +unsigned choose( unsigned n, unsigned k ) +{ + if (k > n) return 0; + if (k * 2 > n) k = n-k; + if (k == 0) return 1; + + int result = n; + for( int i = 2; i <= k; ++i ) { + result *= (n-i+1); + result /= i; + } + return result; +} + +int fibo(int n) +{ + if (n <= 1) + return n; + return fibo(n-1) + fibo(n-2); +} + +int fact(int n) +{ + if(n > 1) + return n * fact(n - 1); + else + return 1; +} From 686168aac7f5d278195c339c41170e62c6e67d75 Mon Sep 17 00:00:00 2001 From: Michael Manis Date: Mon, 18 Sep 2017 12:51:52 -0400 Subject: [PATCH 22/40] added message user JSON + API endpoint, still need to hook it up --- pycanvasgrader/pycanvasgrader.py | 12 ++++++++++++ pycanvasgrader/skeletons/HW1c.json | 21 ++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index c3d102a..73c3ea8 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -397,6 +397,18 @@ def grade_submission(self, course_id, assignment_id, user_id, grade): response = self.session.put(url) return json.loads(response.text) + def message_user(self, recipient_id: int, body: str, subject: str = None): + url = 'https://sit.instructure.com/api/v1/conversations/' + + data = { + 'recipients[]': recipient_id, + 'body': body, + 'subject': subject + } + + response = self.session.post(url, data) + return json.loads(response.text) + def choose_val(hi_num: int, allow_zero: bool = False) -> int: val = 'none' diff --git a/pycanvasgrader/skeletons/HW1c.json b/pycanvasgrader/skeletons/HW1c.json index 2776232..1668654 100644 --- a/pycanvasgrader/skeletons/HW1c.json +++ b/pycanvasgrader/skeletons/HW1c.json @@ -5,19 +5,34 @@ "test_1": { "command": "g++", "args": ["%s", "-o", "out"], + "single_file": true, "print_output": true, "print_file": true, + "output_match": "error", "negate_match": true, - "point_val": 25 + + "point_val": 25, + + "fail_notification": { + "subject": "HW1c compilation failed", + "body": "This is an automated message to inform you that compilation of your submission for HW1chas failed\nPlease resbumit this assigment or your grade will remain a 0." + } }, "test_2": { "command": "out.exe", "print_output": true, - "output_match": "5050\n5050\n", + + "output_match": "5050\n5050", "exact_match": false, - "point_val": 75 + + "point_val": 75, + + "fail_notification": { + "subject": "HW1c testing failed", + "body": "This is an automated message to inform you that testing of your submission for HW1c has failed.\nCheck to make sure your output conforms exactly to what is expected on Canvas." + } } } } From 9803c3b6f0a425203a76a8c27b4b7284b951057b Mon Sep 17 00:00:00 2001 From: ThePyrotechnic Date: Mon, 18 Sep 2017 13:47:30 -0400 Subject: [PATCH 23/40] messages implemented --- pycanvasgrader/pycanvasgrader.py | 43 +++++++++++++++++++++++------- pycanvasgrader/skeletons/HW1c.json | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/pycanvasgrader/pycanvasgrader.py b/pycanvasgrader/pycanvasgrader.py index 73c3ea8..9c3145b 100644 --- a/pycanvasgrader/pycanvasgrader.py +++ b/pycanvasgrader/pycanvasgrader.py @@ -9,7 +9,6 @@ access.token pycanvasgrader.py - TODO test temp directory deletion timing on Windows TODO implement skeleton creation wizard TODO test/implement visual grading (ask for grade after test runs) """ @@ -27,6 +26,10 @@ import requests # globals +DISARM_ALL = False +DISARM_MESSAGER = True +DISARM_GRADER = False + RUN_WITH_TESTS = False ONLY_RUN_TESTS = False NUM_REGEX = re.compile(r'[+-]?\d+\.\d+|\d+') @@ -74,7 +77,7 @@ def from_file(cls, filename): return TestSkeleton(descriptor, test_list, automation_level) - def run_tests(self) -> int: + def run_tests(self, grader: PyCanvasGrader, user_id: int) -> int: total_score = 0 for count, test in enumerate(self.tests): print('\n--Running test %i--' % (count + 1)) @@ -86,6 +89,15 @@ def run_tests(self) -> int: else: print('--Subtracting %i points--' % abs(test.point_val)) total_score += test.point_val + elif test.fail_notif: + try: + body = test.fail_notif['body'] + except ValueError: + pass + else: + subject = test.fail_notif.get('subject') + grader.message_user(user_id, body, subject) + print('--Current score: %i--' % total_score) return total_score @@ -99,7 +111,7 @@ class AssignmentTest: def __init__(self, command: str, args: list = None, print_file: bool = False, single_file: bool = False, target_file: str = None, ask_for_target: bool = False, include_filetype: bool = True, print_output: bool = True, output_match: str = None, output_regex: str = None, numeric_match: list = None, - negate_match: bool = False, exact_match: bool = False, timeout: int = None, point_val: int = 0): + negate_match: bool = False, exact_match: bool = False, timeout: int = None, fail_notif: dict = None, point_val: int = 0): """ :param command: The command to be run. :param args: List of arguments to pass to the command. Use %s to denote a file name @@ -116,6 +128,7 @@ def __init__(self, command: str, args: list = None, print_file: bool = False, si :param negate_match: Whether to negate the result of checking output_match and output_regex :param exact_match: Whether the naive string match (output_match) should be an exact check or a substring check :param timeout: Time, in seconds, that this Command should run for before timing out + :param fail_notif: Message to be sent to user when test fails :param point_val: Amount of points that a successful match is worth (Can be negative) """ self.command = command @@ -135,6 +148,7 @@ def __init__(self, command: str, args: list = None, print_file: bool = False, si self.exact_match = exact_match self.print_output = print_output self.timeout = timeout + self.fail_notif = fail_notif self.point_val = point_val @classmethod @@ -157,13 +171,14 @@ def from_json_dict(cls, json_dict: dict): negate_match = json_dict.get('negate_match') exact_match = json_dict.get('exact_match') timeout = json_dict.get('timeout') + fail_notif = json_dict.get('fail_notification') point_val = json_dict.get('point_val') vars_dict = {'command': command, 'args': args, 'print_file': print_file, 'single_file': single_file, 'target_file': target_file, 'ask_for_target': ask_for_target, 'include_filetype': include_filetype, 'print_output': print_output, 'output_match': output_match, 'output_regex': output_regex, 'numeric_match': numeric_match, 'negate_match': negate_match, 'exact_match': exact_match, - 'timeout': timeout, 'point_val': point_val} + 'timeout': timeout, 'fail_notif': fail_notif, 'point_val': point_val} args_dict = {} for var_name, val in vars_dict.items(): if val is not None: @@ -391,13 +406,19 @@ def user(self, course_id: int, user_id: int) -> dict: return json.loads(response.text) def grade_submission(self, course_id, assignment_id, user_id, grade): + global DISARM_ALL, DISARM_GRADER url = 'https://sit.instructure.com/api/v1/courses/%i/assignments/%i/submissions/%i/?submission[posted_grade]=%i' \ % (course_id, assignment_id, user_id, grade) - response = self.session.put(url) - return json.loads(response.text) + if DISARM_ALL or DISARM_GRADER: + print('Dummy: Grade submitted') + return 'dummy success' + else: + response = self.session.put(url) + return json.loads(response.text) def message_user(self, recipient_id: int, body: str, subject: str = None): + global DISARM_ALL, DISARM_MESSAGER url = 'https://sit.instructure.com/api/v1/conversations/' data = { @@ -406,8 +427,12 @@ def message_user(self, recipient_id: int, body: str, subject: str = None): 'subject': subject } - response = self.session.post(url, data) - return json.loads(response.text) + if DISARM_ALL or DISARM_MESSAGER: + print('Dummy: user messaged') + return 'dummy success' + else: + response = self.session.post(url, data) + return json.loads(response.text) def choose_val(hi_num: int, allow_zero: bool = False) -> int: @@ -566,7 +591,7 @@ def main(): print('Could not access files for user "%i". Skipping' % cur_user_id) continue print('--Grading user "%s"--' % name_dict.get(cur_user_id)) - score = selected_skeleton.run_tests() + score = selected_skeleton.run_tests(grader, cur_user_id) if score < 0: score = 0 diff --git a/pycanvasgrader/skeletons/HW1c.json b/pycanvasgrader/skeletons/HW1c.json index 1668654..93e2aeb 100644 --- a/pycanvasgrader/skeletons/HW1c.json +++ b/pycanvasgrader/skeletons/HW1c.json @@ -17,7 +17,7 @@ "fail_notification": { "subject": "HW1c compilation failed", - "body": "This is an automated message to inform you that compilation of your submission for HW1chas failed\nPlease resbumit this assigment or your grade will remain a 0." + "body": "This is an automated message to inform you that compilation of your submission for HW1c has failed\nPlease resbumit this assigment or your grade will remain a 0." } }, "test_2": { From db71de51b3bfa943185227f85cc74c883f75f737 Mon Sep 17 00:00:00 2001 From: ThePyrotechnic Date: Wed, 20 Sep 2017 15:04:29 -0400 Subject: [PATCH 24/40] string updates --- .idea/codeStyleSettings.xml | 9 + .idea/grading.iml | 11 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/workspace.xml | 593 ++++++++++++++++++ .../__pycache__/__init__.cpython-36.pyc | Bin 0 -> 148 bytes .../__pycache__/pycanvasgrader.cpython-36.pyc | Bin 0 -> 2886 bytes .../test_grader.cpython-36-PYTEST.pyc | Bin 0 -> 2785 bytes pycanvasgrader/access.token | 1 + pycanvasgrader/pycanvasgrader.py | 287 +++++---- pycanvasgrader/skeletons/HW1c.json | 4 +- pycanvasgrader/temp/27592/HW1c.cpp | 25 + 13 files changed, 800 insertions(+), 148 deletions(-) create mode 100644 .idea/codeStyleSettings.xml create mode 100644 .idea/grading.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 pycanvasgrader/__pycache__/__init__.cpython-36.pyc create mode 100644 pycanvasgrader/__pycache__/pycanvasgrader.cpython-36.pyc create mode 100644 pycanvasgrader/__pycache__/test_grader.cpython-36-PYTEST.pyc create mode 100644 pycanvasgrader/access.token create mode 100644 pycanvasgrader/temp/27592/HW1c.cpp diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml new file mode 100644 index 0000000..5555dd2 --- /dev/null +++ b/.idea/codeStyleSettings.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/.idea/grading.iml b/.idea/grading.iml new file mode 100644 index 0000000..e6258db --- /dev/null +++ b/.idea/grading.iml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..fe7494e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0b6948b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..69244b1 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,593 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + run_tests + automat + message_user + Grade Submitted + + + + + + + + + + + true + DEFINITION_ORDER + + + + + + + + + + + + + + Python + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +