mirror of
https://github.com/JonasunderscoreJones/epr_grader.git
synced 2025-10-25 09:19:18 +02:00
Initial commit
This commit is contained in:
commit
84028239d8
3 changed files with 478 additions and 0 deletions
89
README.md
Normal file
89
README.md
Normal 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
69
eprcheck_2019.py
Normal 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
320
eprgrader.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue