commit 84028239d80271b45f6409d45c2cf720ed83e676 Author: Lukas Horst Date: Thu Oct 3 22:44:26 2024 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfb1b41 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# eprgrader + +Ein Tool, um die Bewertung von EPR-Abgaben (und auch GPR-Abgaben) zu beschleunigen. + +## Installation + +1. Legt `eprgrader.py` (das eigentliche Programm) und `eprcheck_2019.py` (das pylint-Plugin für die +Author-Variable) im selben Verzeichnis ab. +2. Wenn ihr den automatischen Style-Check benutzen wollt, installiert `pylint`, `pycodestyle` + und `astroid` via `pip`: + `pip install pylint==2.15.0 pycodestyle==2.8.0 astroid==2.13.5` + +## Zu Beginn + +Ladet die ZIP-Datei(en) mit den Abgaben für eure Gruppe(n) sowie die Bewertungstabellle herunter. +Legt ein Verzeichnis für das Übungsblatt an, und dann einen Unterordner für jedes eurer Tutorien. +Legt die Abgaben-Zips in die jeweiligen Ordner. Das ganze sollte jetzt in etwa so aussehen: + +``` +blatt0 +|-- Bewertungstabelle_EPR_0.xlsx +|-- EPR01 +| `-- EPR-2021-Abgabe zu EPR_00-EPR 01 - H 7 - Adrian-88422.zip +`-- EPR02 + `-- EPR-2021-Abgabe zu EPR_00-EPR 02 - H 6 - Adrian-88422.zip +``` + +Führt jetzt den Startbefehl aus: +```cmd +cd ...\Tutorium\blatt0 +python eprgrader.py begin --table Bewertungstabelle_EPR_08.xlsx --no-stylecheck --tests +``` + +Zusätzliche Optionen: +* `--no-stylecheck`: Überspringt die PEP8-Prüfung und verhindert das Anlegen der `stylecheck.txt`-Dateien für jede Abgabe. +* `--pairs`: Überprüft die `__author__`-Variable nach dem Format für Paaraufgaben. + +Hierdurch werden alle zip-Archive entpackt, die Bewertungstabellen kopiert und für jeden Teilnehmer +entsprechend umbenannt, und ggf. der Stylchecker ausgeführt. + +## Style-Prüfung erneut ausführen + +Bei Bedarf kann die Style-Prüfung erneut ausgeführt werden. Dabei werden alle bestehenden +`stylecheck.txt`-Dateien überschrieben. + +```cmd +cd ...\Tutorium\blatt0 +python eprgrader.py relint +``` + +Zusätzliche Optionen: +* `--pairs`: Überprüft die `__author__`-Variable nach dem Format für Paaraufgaben. + +## Abschluss + +Am Ende können die Bewertungsdateien (Glob-Pattern `Bewertung *`) sowie die `stylecheck.txt` +für jeden Teilnehmer zusammengesammelt und für den Upload als Feedback-Datei wieder zusammengepackt +werden. + +Achtung: das funktioniert nur für die Einzelabgaben sinnvoll! + +```cmd +cd ...\Tutorium\blatt0 +python eprgrader.py finalise +``` + +Nun sollte sich in jedem Tutoriums-Unterordner eine neue Zip-Datei finden, die den Namen +des Tutoriums trägt (z. B. `EPR02.zip`). Diese kann über die Moodle-Option "Mehrere Feedbackdateien +in einer Zip-Datei hochladen" hochgeladen werden. + +## Änderung der Style-Einstellungen + +Die Style-Einstellungen (welche Checks aktiviert bzw. deaktiviert sind) habe ich letztes Jahr +mal nach eigenem Empfinden zusammengestellt. (Es wird deutlich mehr überprüft, als laut unseren +Richtlinien zu Punktabzug führt.) Die aktivierten Checker sind relativ weit oben in `eprgrader.py` +konfiguriert, in den Listen `PYLINT_OPTIONS` und `PYCODESTYLE_SELECT`. + +## Bei Problemen + +`eprgrader.py` gibt sich Mühe, auch zip-Dateien mit vergurksten Dateinamen zu entpacken (passiert +meistens, wenn Mac- oder Linux-Nutzer Umlaute in ihren Dateinamen haben), und die Dateinamen +dabei zu reparieren. Manche sind aber so kaputt, dass das Tool einfach abstürzt. In dem Fall +hilft nur, die betroffene Datei aus dem heruntergeladenen Zip zu entfernen und nochmal von vorne +anzufangen. Danach kann man die kaputte Datei von Hand ergänzen und ggf. den `relint`-Befehl nutzen. + +Bei anderen Problemen, schreibt entweder in das Forum des Tutorenkurses, oder schreibt mir eine +E-Mail: [welcker@em.uni-frankfurt.de](mailto:welcker@em.uni-frankfurt.de). + +-- Adrian [2021-11-01] \ No newline at end of file diff --git a/eprcheck_2019.py b/eprcheck_2019.py new file mode 100644 index 0000000..ca8dfb6 --- /dev/null +++ b/eprcheck_2019.py @@ -0,0 +1,69 @@ +import re +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import IAstroidChecker + + +class EPRAuthorVariableChecker(BaseChecker): + __implements__ = IAstroidChecker + + name = "epr-author-variable" + priority = -10 + msgs = { + "C2100": ( + "__author__ variable missing", + "missing-author-variable", + "All EPR modules need an author variable." + ), + "C2101": ( + "__author__ variable malformed", + "malformed-author-variable", + "Author varible string incorrect." + ), + "C2102": ( + "__author__ variable assigned incorrectly", + "incorrectly-assigned-author-variable", + "The author variable must be assigned a constant string." + ) + } + options = ( + ( + 'use-pairs', { + 'default': False, 'type': 'yn', 'metavar': '', + 'help': 'Check author variable for pairs rather than individuals' + } + ), + ) + + def __init__(self, linter): + super(EPRAuthorVariableChecker, self).__init__(linter) + self._found_author = False + + def visit_assign(self, node): + if self._found_author: + return + if isinstance(node.targets[0], astroid.Subscript): + return + if node.targets[0].name != '__author__': + return + + self._found_author = True + if isinstance(node.value, astroid.node_classes.Const) \ + and node.value.pytype() == 'builtins.str': + if self.linter.config.use_pairs: + exp = re.compile(r'^[0-9]{7}, ?.+?, ?[0-9]{7}, ?.+') + else: + exp = re.compile(r'^[0-9]{7}, ?.+') + if not exp.fullmatch(node.value.value): + self.add_message('malformed-author-variable', node=node.value) + else: + self.add_message('incorrectly-assigned-author-variable', node=node.value) + + def leave_module(self, node): + if not self._found_author: + self.add_message('missing-author-variable', node=node) + self._found_author = False + + +def register(linter): + linter.register_checker(EPRAuthorVariableChecker(linter)) diff --git a/eprgrader.py b/eprgrader.py new file mode 100644 index 0000000..5d75c73 --- /dev/null +++ b/eprgrader.py @@ -0,0 +1,320 @@ +""" +Tools for streamlining the EPR/GPR grading process. + +Unpacking files downloaded from Moodle, unpacking the archives within, +running pylint on all the python files, and distributing the grading +table to all the directories. +Later on, collecting all the stylecheck files and grading tables into +neat little archives for upload. +""" + +__author__ = "Adrian Welcker" + +import argparse +import contextlib +import copy +import io +import itertools +import os +import pathlib +import platform +import shutil +import sys +import unicodedata +import zipfile + +from datetime import datetime + +from pylint.lint import Run as RunPylint +import pycodestyle + +PYLINT_ARGS = [ + '--exit-zero', # always exit with code 0, even when problems are found + '--load-plugins=eprcheck_2019', # load plugin for checking __author__ variable + '--persistent=n', # don't store results on disk + '--score=n', # don't output a score + '--use-pairs=', # lint_files will set this according to command line settings + '--disable=all', # disable all checks for now, individual ones enabled below + # C2100: missing-author-variable + # C2101: malformed-author-variable + # C2102: incorrectly-assigned-author-variable + # C0102: blacklisted-name + # C0103: invalid-name [e.g. variable not snake_case] + # C0112: empty-docstring + # C0114: missing-module-docstring + # C0115: missing-class-docstring + # C0116: missing-function-docstring + # C0121: singleton-comparison + # C0144: non-ascii-name + "--enable=C2100,C2101,C2102,C0102,C0103,C0114,C0115,C0116,C0112,C0121,C0144" + + # C0321: multiple-statements + # C0325: superfluous-parens + # C0410: multiple-imports + # C0411: wrong-import-order + # C0412: ungrouped-imports + # C0413: wrong-import-position + "C0321,C0325,C0410,C0411,C0412,C0413," + + # E0001: syntax-error + # E0102: function-redefined + # E0211: no-method-argument [when it should at least have self] + "E0001,E0102,E0211," + + # I0011: locally-disabled [to notify of 'pylint disable=...' comments] + # I0013: file-ignored + # I0020: suppressed-message + "I0011,I0013,I0020," + + # W0104: pointless-statement + # W0106: expression-not-assigned + # W0201: attribute-defined-outside-init + # W0231: super-init-not-called + # W0232: no-init + # W0301: unnecessary-semicolon + # W0311: bad-indentation + # W0401: wildcard-import + # W0402: deprecated-module + # W0404: reimported + # W0603: global-statement + # W0622: redefined-builtin + # W0702: bare-except + # W0705: duplicate-except + # W0706: try-except-raise + "W0104,W0106,W0201,W0231,W0232,W0301,W0311,W0401,W0402,W0404,W0603,W0622,W0702,W0705,W0706" +] + +PYCODESTYLE_SELECT = [ + # E117: over-indented + 'E117', + # E201: whitespace after '(' + 'E201', + # E202: whitespace before ')' + 'E202', + # E203: whitespace before ':' + 'E203', + # E211: whitespace before '(' + 'E211', + # E221: multiple spaces before operator + 'E221', + # E222: multiple spaces after operator + 'E222', + # E223: tab before operator + 'E223', + # E224: tab after operator + 'E224', + # E225: missing whitespace around operator + 'E225', + # E231: missing whitespace after ',', ';', or ':' + 'E231', + # E251: unexpected spaces around kwarg '=' + 'E251', + # E261: at least two spaces before inline comment + 'E261', + # E262: inline comment should start with '# ' + # E265: block comment should start with '#' + 'E262', 'E265', + 'E271', 'E272', 'E273', 'E274', 'E275', + # E302: expected 2 blank lines + 'E302', + # E501: line-too-long + # E502: backslash redundant between brackets + 'E501', 'E502', + # E713: negative membership test should use 'not in' + 'E713', + # E714: negative identitiy test should use 'is not' + 'E714', + # E721: use 'isinstance' instead of comparing types + 'E721', + # E731: use 'def' instead of assigning lambdas + 'E731', + # E741-3: ambiguous single-character names (I, l, O) for var/class/func + 'E741', 'E742', 'E743', +] +tmp_storage = {} + + +@contextlib.contextmanager +def pylint_context(stdout, workdir): + """Temporarily change stdout and working directory.""" + sys.stdout = stdout + tmp_storage['argv'] = sys.argv + tmp_storage['workdir'] = os.getcwd() + tmp_storage['path'] = copy.copy(sys.path) + sys.path.append(str(pathlib.Path(__file__).parent.absolute())) + os.chdir(workdir) + yield + os.chdir(tmp_storage['workdir']) + sys.argv = tmp_storage['argv'] + sys.path = tmp_storage['path'] + sys.stdout = sys.__stdout__ + + +def lint_files(folders, author_pairs): + """Run pylint and pycodestyle on all Python files anywhere within `folders'.""" + count = 0 + total = len(folders) + style = pycodestyle.StyleGuide(select=PYCODESTYLE_SELECT, show_source=True) + PYLINT_ARGS[4] = '--use-pairs=y' if author_pairs else '--use-pairs=n' + for folder in folders: + count += 1 + print(f" ({str(count).rjust(len(str(total)))}/{total}) Checking {folder.name}") + pythons = list(map(pathlib.Path.resolve, filter(lambda p: "__MACOSX" not in p.parts, folder.glob('**/*.py')))) + if not pythons: + continue + pycount = 0 + pytotal = len(pythons) * 2 + lintcache = io.StringIO() + for file in pythons: + pycount += 1 + print(f" ({str(pycount).rjust(len(str(pytotal)))}/{pytotal}) Running pylint for {file.name}") + with pylint_context(lintcache, folder): + try: + RunPylint(PYLINT_ARGS + [str(file)]) + except SystemExit as e: + if e.code: + print(f" [Pylint attempted to exit with code {e.code}]", file=sys.stderr) + raise RuntimeError from e + pycount += 1 + print(f" ({str(pycount).rjust(len(str(pytotal)))}/{pytotal}) Running pycodestyle for {file.name}", + file=sys.__stdout__) + print('\n') + result = style.check_files([file]) + if result.total_errors > 0: + print('\n') + with open(folder / 'stylecheck.txt', 'w', encoding='utf-8') as outfile: + if lintcache.tell() > 0: + outfile.write(lintcache.getvalue()) + else: + outfile.write("Alles sieht gut aus -- weiter so!\n") + + +def fix_path(path: str) -> str: + return unicodedata.normalize('NFC', path).replace('U╠ê', 'Ü').replace('u╠ê', 'ü').replace( + '*', '').replace('"', '') + + +def safe_extract_zip(zip_obj: zipfile.ZipFile, parent: pathlib.Path): + parent.mkdir(parents=True, exist_ok=True) + files = [x for x in zip_obj.infolist() if not x.is_dir()] + for f in files: + f_out = parent / pathlib.Path(fix_path(f.filename)) + f_out.parent.mkdir(parents=True, exist_ok=True) + with zip_obj.open(f) as fin: + with open(f_out, 'wb') as fout: + fout.write(fin.read()) + + +def begin_grading(folder: pathlib.Path, ratings_file: pathlib.Path, check_style: bool, author_pairs: bool): + print("Extracting downloads...") + downloads = list(folder.glob('**/*.zip')) + count = 0 + total = len(downloads) + for file in downloads: + count += 1 + print(f" ({str(count).rjust(len(str(total)))}/{total}) Extracting {file.name}") + with zipfile.ZipFile(file, 'r') as zip_obj: + # zip_obj.extractall(file.parent / 'abgaben') + safe_extract_zip(zip_obj, file.parent / 'abgaben') + print("Extracting archives...") + archives = list(folder.glob("**/abgaben/**/*.zip")) + count = 0 + total = len(archives) + for file in archives: + count += 1 + print(f" ({str(count).rjust(len(str(total)))}/{total}) Extracting {file.name}") + with zipfile.ZipFile(file, 'r') as zip_obj: + # zip_obj.extractall(file.parent) + safe_extract_zip(zip_obj, file.parent) + target_folders = [f for f in itertools.chain.from_iterable((group.iterdir() for group in folder.glob('**/abgaben'))) + if f.is_dir()] + if check_style: + print("Running style check...") + lint_files(target_folders, author_pairs) + else: + print("(Style check skipped.)") + print("Copying ratings table...") + sheet = folder.resolve().name + for f in target_folders: + target_name = "Bewertung " + sheet + " " + f.name.split('_')[0] + ratings_file.suffix + shutil.copy(ratings_file, f / target_name) + # adding a feedback.txt + with open(f"{f}/Feedback {f.name.split('_')[0]}.txt", "w") as my_file: + pass + print("Done!") + + +def finalise_grading(folder: pathlib.Path): + issues = 0 + print("Copying grades...") + folders = list(folder.glob("**/abgaben")) + for f in folders: + target = f.parent / 'korrekturen' + target.mkdir() + feedback_parent = f.parent / 'feedback' # file for the feedbacks + feedback_parent.mkdir() + for handin in (x for x in f.iterdir() if x.name != '.DS_Store'): + this_target = target / handin.name + this_target.mkdir() + # copy the stylecheck datas + if (handin / 'stylecheck.txt').exists(): + shutil.copy(handin / 'stylecheck.txt', this_target) + # copy the feedback datas + feedback_files = list(handin.glob('Feedback*')) + if feedback_files: + for feedback_file in feedback_files: + shutil.copy(feedback_file, feedback_parent) + # copy the grading datas + glob = list(handin.glob('Bewertung *')) + if len(glob) == 1: + shutil.copy(glob[0], this_target) + elif not glob: + print(f" ! {handin.name}: no grading file") + issues += 1 + else: + print(f" ! {handin.name}: too many grading files") + issues += 1 + if issues: + print(f"Issues occurred ({issues}), not building final upload file(s).") + return + print("Building upload files...") + folders = list(folder.glob("**/korrekturen")) + count = 0 + total = len(folders) + for f in folders: + count += 1 + print(f" ({str(count).rjust(len(str(total)))}/{total}) Building {f.parent.name}") + with zipfile.ZipFile(f.parent / (f.parent.name + ".zip"), 'w') as outfile: + for person in f.iterdir(): + for file in person.iterdir(): + outfile.write(file, pathlib.PurePath(person.name) / file.name) + + +def main(): + """The function main is where execution begins.""" + print('EPRgrader v3/221031 running on ', datetime.now(), ' [', platform.platform(terse=True), ' ', + platform.machine(), ']', sep='') + parser = argparse.ArgumentParser(description="Assist in grading EPR assignments.") + # parser.add_argument('verb', type=str, choices=('begin', 'relint', 'finalise')) + parser.add_argument('-f', '--folder', type=str, help='the folder in which to operate (default: the current folder)', + default='.') + subparsers = parser.add_subparsers(metavar='verb', dest='verb', required=True) + begin_parser = subparsers.add_parser('begin', help='begin a new grading process') + begin_parser.add_argument('--table', metavar='file', help='Ratings table file to copy to each folder', + required=True) + begin_parser.add_argument('--stylecheck', action=argparse.BooleanOptionalAction, default=True, + help='whether or not to run style checks') + begin_parser.add_argument('--pairs', action=argparse.BooleanOptionalAction, default=False, + help='whether or not to validate __author__ variables for pairs') + lint_parser = subparsers.add_parser('relint', help='re-run pylint') + lint_parser.add_argument('--pairs', action=argparse.BooleanOptionalAction, default=False, + help='whether or not to validate __author__ variables for pairs') + subparsers.add_parser('finalise', help='package results for upload') + args = parser.parse_args() + if args.verb == 'begin': + begin_grading(pathlib.Path(args.folder), pathlib.Path(args.table), args.stylecheck, args.pairs) + elif args.verb == 'relint': + lint_files([f for f in itertools.chain.from_iterable( + (group.iterdir() for group in pathlib.Path(args.folder).glob('**/abgaben'))) if f.is_dir()], args.pairs) + elif args.verb == 'finalise': + finalise_grading(pathlib.Path(args.folder)) + + +if __name__ == "__main__": + main()