Initial commit

This commit is contained in:
Lukas Horst 2024-10-03 22:44:26 +02:00
commit 84028239d8
3 changed files with 478 additions and 0 deletions

89
README.md Normal file
View file

@ -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]

69
eprcheck_2019.py Normal file
View file

@ -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': '<y_or_n>',
'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))

320
eprgrader.py Normal file
View file

@ -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=<y_or_n>', # 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()