Last active
December 22, 2022 11:33
-
-
Save m13253/f3e418a556df9506f7494e0f9cd8e80f to your computer and use it in GitHub Desktop.
Organize submission archive downloaded from Gradescope using student’s names
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import itertools | |
import os | |
import unicodedata | |
# pip3 install -U PyYAML | |
import yaml | |
# Windows disallows the following characters in filenames: "*/:<>?\| | |
# as well as anything between U+0000 and U+001F. | |
# Additionally we disallow these to prevent ambiguity: .&() | |
FORBIDDEN_CHARS = set('"&()*./:<>?\\|') | |
# Windows disallows these filenames because they map to I/O ports. | |
# Any program trying to use these filenames may freeze or behave strangely. | |
# We need to prevent students from naming themselves such for security reasons. | |
FORBIDDEN_NAMES = {'aux', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'con', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', 'nul', 'prn'} | |
def main() -> None: | |
print('Loading submission_metadata.yml... ', end='', flush=True) | |
with open('submission_metadata.yml', encoding='UTF-8') as f: | |
data = dict(yaml.safe_load(f)) | |
print(f'{len(data)} submissions loaded.') | |
print('Creating renaming plan... ', end='', flush=True) | |
# | |
# This list comprehension one-liner creates a name map from submission ID to actual submitter's name. | |
# | |
# 1. If a submitter's name contains FORBIDDEN_CHARS or anything between U+0000 and U+001F, replace them with '_'. | |
# 2. If a submission has multiple submitters, combine their names into one name with ' & '. | |
# 3. If the resulting name is FORBIDDEN_NAMES or have case-insensitive duplicates, append with ' (1)', ' (2)', etc. | |
# | |
renaming_plan = sorted((id, names if len(submissions) == 1 and names_fold not in FORBIDDEN_NAMES else f'{names} ({names_idx + 1})') for names_fold, submissions in itertools.groupby(sorted((names_fold, id, names) for id, submission in data.items() for names in [' & '.join(''.join('_' if c < ' ' or c in FORBIDDEN_CHARS else c for c in name) for submitters in submission[':submitters'] for name in [unicodedata.normalize('NFC', str(submitters[':name'] or id))])] for names_fold, id in [(unicodedata.normalize('NFKD', unicodedata.normalize('NFKD', unicodedata.normalize('NFD', names).casefold()).casefold()), str(id))]), lambda x: x[0]) for submissions in [list(submissions)] for names_idx, (_, id, names) in enumerate(submissions)) | |
print('Done.', flush=True) | |
for move_from, move_to in renaming_plan: | |
print(f'{move_from} -> {move_to}', end='') | |
try: | |
os.rename(move_from, move_to) | |
except FileNotFoundError: | |
print(' (skipped)') | |
except Exception as e: | |
print(f' (failed: {e})') | |
else: | |
print() | |
if __name__ == '__main__': | |
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
from __future__ import annotations | |
import sys | |
import os | |
import pickle | |
from typing import Any, Optional | |
# pip3 install -U rich | |
import rich.box | |
import rich.console | |
import rich.table | |
from rich.markup import escape | |
# pip3 install -U PyYAML | |
import yaml | |
def failure_filter(tests: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
return [test for test in tests if test['status'] != 'passed'] | |
def main(argv: list[str]) -> None: | |
if 'MANPAGER' not in os.environ and 'PAGER' not in os.environ: | |
os.environ['PAGER'] = 'less -c -r' | |
console = rich.console.Console(highlight=False) | |
if len(argv) == 0: | |
skip_to_name: Optional[str] = None | |
else: | |
skip_to_name = ' '.join(argv).strip('/') | |
messages: list[rich.console.RenderableType] = [] | |
try: | |
with console.status('Loading [blue]submission_metadata.yml.cache[/]...'): | |
with open('submission_metadata.yml.cache', 'rb') as f: | |
data = dict(pickle.load(f)) | |
messages.append(f'Loaded [blue]{len(data)}[/] submissions from [blue]submission_metadata.yml.cache[/].') | |
except FileNotFoundError: | |
with console.status('Loading [blue]submission_metadata.yml[/]...'): | |
with open('submission_metadata.yml', encoding='UTF-8') as f: | |
data = dict(yaml.safe_load(f)) | |
messages.append(f'Loaded [blue]{len(data)}[/] submissions from [blue]submission_metadata.yml[/].') | |
with console.status('Saving to [blue]submission_metadata.yml.cache[/]...'): | |
with open('submission_metadata.yml.cache', 'wb') as f: | |
pickle.dump(data, f) | |
submissions = list(data.items()) | |
missing_tests = [(submission_id, submission) for submission_id, submission in submissions if not submission[':results'].get('tests')] | |
if len(missing_tests) != 0: | |
messages.append('') | |
messages.append('[red]These submissions have tests missing:[/]') | |
table = rich.table.Table(box=None, padding=0) | |
table.add_column(width=4) | |
table.add_column('Submission ID', no_wrap=True) | |
table.add_column(width=1) | |
table.add_column('Submitter') | |
for submission_id, submission in missing_tests: | |
table.add_row(None, escape(submission_id), None, '[grey46], [/]'.join(f'[blue]{escape(str(submitter[":name"]))}[/]' for submitter in submission[':submitters'])) | |
messages.append(table) | |
messages.append('') | |
start_idx = 0 | |
for i, (submission_id, submission) in enumerate(submissions): | |
if skip_to_name is not None and skip_to_name not in (str(submitter[':name']) for submitter in submission[':submitters']): | |
continue | |
if skip_to_name is not None: | |
start_idx = i | |
messages.append(f'Skip to name: [blue]{escape(skip_to_name)}[/]') | |
skip_to_name = None | |
if skip_to_name is None: | |
del submissions[:start_idx] | |
else: | |
messages.append(f'Can’t skip to name: [red]{escape(skip_to_name)}[/]') | |
submissions = [] | |
for i, (submission_id, submission) in enumerate(submissions): | |
with console.pager(styles=True, links=True): | |
if len(messages) != 0: | |
for message in messages: | |
console.print(message) | |
messages = [] | |
console.rule(f'[blue]{start_idx + i + 1}[/][grey46] / [/]{len(submissions) + start_idx}') | |
table = rich.table.Table(box=None, padding=0, show_header=False) | |
table.add_column(no_wrap=True, style='bold grey0') | |
table.add_column() | |
table.add_row('Submission ID: ', escape(submission_id)) | |
table.add_row('Submitter: ', '[grey46], [/]'.join(f'[blue]{escape(str(submitter[":name"]))}[/]' for submitter in submission[':submitters'])) | |
tests = submission[':results'].get('tests', []) | |
failed_tests = failure_filter(tests) | |
if len(failed_tests) == 0: | |
table.add_row('Tests: ', f'[green]All {len(tests)} tests passed.[/]') | |
else: | |
for test in failed_tests: | |
table.add_row('Failed Test: ', f'[red]{escape(str(test["name"]))}[/]') | |
if test['output']: | |
table.add_row(None, escape(str(test['output']).lstrip('\n').rstrip())) | |
console.print(table) | |
console.rule(f'[blue]{start_idx + i + 1}[/][grey46] / [/]{len(submissions) + start_idx}') | |
console.print('Press [blue]Q[/] to proceed...') | |
for message in messages: | |
console.print(message) | |
if __name__ == '__main__': | |
main(sys.argv[1:]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment