Skip to content

Instantly share code, notes, and snippets.

@m13253
Last active December 22, 2022 11:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save m13253/f3e418a556df9506f7494e0f9cd8e80f to your computer and use it in GitHub Desktop.
Save m13253/f3e418a556df9506f7494e0f9cd8e80f to your computer and use it in GitHub Desktop.
Organize submission archive downloaded from Gradescope using student’s names
#!/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()
#!/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