Source code for pyaota.cli

"""
Command-line interface for pyaota: build and grade multiple-choice exams.
"""

from __future__ import annotations

import argparse as ap
import sys, os, shutil
import yaml

from .generator.manager import (
    make_exams_subcommand,
    make_answersheet_subcommand,
    compile_dump_subcommand,
    tune_answersheetreader_subcommand,
    autograde_subcommand,
    return_subcommand,
)
from .generator.wordexport2rawyaml import convert_subcommand
from .util.bundle import bundle_subcommand
from .util.text import banner, oxford

import logging
logger = logging.getLogger(__name__)
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'

[docs] def setup_logging(args): loglevel_numeric = getattr(logging, args.logging_level.upper()) if args.log: if os.path.exists(args.log): shutil.copyfile(args.log, args.log+'.bak') logging.basicConfig(filename=args.log, filemode='w', format='%(asctime)s %(name)s %(message)s', level=loglevel_numeric ) console = logging.StreamHandler() console.setLevel(logging.INFO) formatter = logging.Formatter('%(levelname)s> %(message)s') console.setFormatter(formatter) logging.getLogger('').addHandler(console)
[docs] def save_args(args, filepath): """ Save argparse namespace including subcommand to YAML Parameters ---------- args : argparse.Namespace The argparse namespace to save. filepath : str The path to the YAML file to save the args to. """ args_dict = vars(args).copy() args_dict.pop('func', None) args_dict.pop('save_config', None) args_dict.pop('config', None) with open(filepath, 'w') as f: yaml.dump(args_dict, f, default_flow_style=False)
[docs] def load_args_from_yaml(filepath): """ Load args from YAML Parameters ---------- filepath : str The path to the YAML file to load the args from. """ with open(filepath, 'r') as f: return yaml.safe_load(f)
[docs] def main(argv: list[str] | None = None) -> int: """ Entry point for the pyaota command-line interface. """ subcommands = { 'build': dict( func = make_exams_subcommand, help = 'build documents', ), 'compile-dump': dict( func = compile_dump_subcommand, help = 'compile full dump of questions into a document', ), 'grade': dict( func = autograde_subcommand, help = 'grade exams from scanned answer sheets', ), 'make-answersheet': dict( func = make_answersheet_subcommand, help = 'make a blank answer sheet PDF', ), 'tune-answersheetreader': dict( func = tune_answersheetreader_subcommand, help = 'tune the answer sheet reader parameters', ), 'bundle': dict( func = bundle_subcommand, help = 'bundle PDFs from a directory into uniform-sized bundles', ), 'return': dict( func = return_subcommand, help = 'email graded answer sheets and exam PDFs to students via Outlook', ), 'convert': dict( func = convert_subcommand, help = 'convert a ZyBooks-exported zip (QTI XML or Word) into a YAML question bank', ), } parser = ap.ArgumentParser( prog='pyaota', description='pyaota: build and grade multiple-choice/true-false exams', epilog='(c) 2025-2026 Cameron F. Abrams <cfa22@drexel.edu>' ) parser.add_argument('--config', type=str, help='Load config from YAML') parser.add_argument('--save-config', type=str, help='Save full config to YAML') parser.add_argument( '-b', '--banner', default=False, action=ap.BooleanOptionalAction, help='toggle banner message' ) parser.add_argument( '--logging-level', type=str, default='debug', choices=[None, 'info', 'debug', 'warning'], help='Logging level for messages written to diagnostic log' ) parser.add_argument( '-l', '--log', type=str, default='pyaota-diagnostics.log', help='File to which diagnostic log messages are written' ) subparsers = parser.add_subparsers( title="subcommands", dest="subcommand", metavar="<subcommand>", required=True, ) command_parsers={} for k, specs in subcommands.items(): command_parsers[k] = subparsers.add_parser( k, help=specs['help'], formatter_class=ap.RawDescriptionHelpFormatter ) command_parsers["build"].add_argument( "-od", "--output-dir", help="Output directory", ) command_parsers["build"].add_argument( "--institution", type=str, help="Institution name", default="Drexel University" ) command_parsers["build"].add_argument( "--course", type=str, help="Course name", default="ENGR-131" ) command_parsers["build"].add_argument( "--term", type=str, help="Term name", default="202526" ) command_parsers["build"].add_argument( "--cleanup", action=ap.BooleanOptionalAction, help="Cleanup intermediate files after LaTeX compilation", ) command_parsers["build"].add_argument( "--seed", type=int, help="Random seed for reproducible generation", ) command_parsers["build"].add_argument( "-n", "--num-exams", type=int, default=1, help="Number of exams to generate (default: 1)", ) command_parsers["build"].add_argument( "-t", "--topics", nargs="+", help="Topics to include in the exam (questions will be drawn from these topics and distributed evenly)", ) command_parsers["build"].add_argument( "-nq", "--num-questions", type=int, help="Number of questions on each exam", ) command_parsers["build"].add_argument( "-q", "--question-banks", nargs="+", help="Paths to question banks (YAML/JSON)", ) command_parsers["build"].add_argument( "-nc", "--num-cols", type=int, default=3, help="Number of columns in the answer sheet (default: 3)", ) command_parsers["build"].add_argument( "-sq", "--shuffle-questions", default=False, action=ap.BooleanOptionalAction, help="Shuffle question order on each exam", ) command_parsers["build"].add_argument( "-sc", "--shuffle-choices", default=False, action=ap.BooleanOptionalAction, help="Shuffle answer choice order on each exam", ) command_parsers["build"].add_argument( "-en", "--exam-name", type=str, default="Exam", help="Name of the exam (used in header)", ) command_parsers["build"].add_argument( "-ow", "--overwrite", default=False, action=ap.BooleanOptionalAction, help="Overwrite output directory if it exists", ) command_parsers["build"].add_argument( "-if", "--instructions-file", type=str, default=None, help="Path to file containing custom exam instructions in LaTeX format (if not provided, default instructions will be used)", ) command_parsers["build"].add_argument( "--font-size", type=str, default="12pt", choices=["10pt", "11pt", "12pt"], help="Font size for the generated PDF (default: 12pt)", ) command_parsers["build"].add_argument( "--question-spacing", type=str, default="24pt", metavar="LENGTH", help="Vertical space between questions as a LaTeX length (default: 24pt)", ) command_parsers["build"].add_argument( "--bubble-font-size", type=float, default=8.0, metavar="PT", help="Font size in points for text inside answer bubbles (default: 8.0)", ) command_parsers["build"].add_argument( "--odd-page-answersheet", default=False, action=ap.BooleanOptionalAction, help="Force the answer sheet to start on an odd-numbered page (for double-sided printing)", ) command_parsers["build"].add_argument( "--rasterize", default=False, action=ap.BooleanOptionalAction, help="Rasterize output PDFs at 300 DPI for improved printer compatibility", ) command_parsers["build"].add_argument( "--use-listings-from", type=str, default=None, metavar="TEX_FILE", help="Path to a .tex file defining an lstdefinestyle; enables listings/\\inl rendering instead of verbatim", ) command_parsers["build"].add_argument( "--balance-difficulty", default=False, action=ap.BooleanOptionalAction, help=( "Select questions proportionally across difficulty levels (1–5) so " "each exam version has roughly the same difficulty distribution. " "Questions without a 'difficulty' attribute are treated as difficulty 1." ), ) command_parsers["compile-dump"].add_argument( "-od", "--output-dir", default=".", help="Output directory", ) command_parsers["compile-dump"].add_argument( "-q", "--question-banks", nargs="+", default=[], help="Paths to question banks (YAML/JSON)", ) command_parsers["compile-dump"].add_argument( "--institution", type=str, help="Institution name", default="Drexel University" ) command_parsers["compile-dump"].add_argument( "--course", type=str, help="Course name", default="ENGR-131" ) command_parsers["compile-dump"].add_argument( "--term", type=str, help="Term name", default="202526" ) command_parsers["compile-dump"].add_argument( "-if", "--instructions-file", type=str, default=None, help="Path to file containing custom instructions in LaTeX format (if not provided, default instructions will be used)", ) command_parsers["compile-dump"].add_argument( "--font-size", type=str, default="12pt", choices=["10pt", "11pt", "12pt"], help="Font size for the generated PDF (default: 12pt)", ) command_parsers["compile-dump"].add_argument( "--question-spacing", type=str, default="24pt", metavar="LENGTH", help="Vertical space between questions as a LaTeX length (default: 24pt)", ) command_parsers["compile-dump"].add_argument( "--keep-latex", default=False, action=ap.BooleanOptionalAction, help="Keep intermediate LaTeX file after compilation", ) command_parsers["compile-dump"].add_argument( "--use-listings-from", type=str, default=None, metavar="TEX_FILE", help="Path to a .tex file defining an lstdefinestyle; enables listings/\\inl rendering instead of verbatim", ) command_parsers["grade"].add_argument( "-i", "--input-pdf", help="PDF containing one or more answer sheets (scantron-like)", ) command_parsers["grade"].add_argument( # can handle multiple files "-k", "--keyfiles", nargs="+", help="CSV file(s) containing answer keys for each exam version", ) command_parsers["grade"].add_argument( "-alj", "--answersheet-layout-json", help="JSON file specifying the answer sheet layout configuration", ) command_parsers["grade"].add_argument( "-od", "--output-dir", default=".", help="Path to output directory for graded results", ) command_parsers["grade"].add_argument( "--debug-output-dir", default = "debug-autograder", help="Path to output directory for debug images", ) command_parsers["grade"].add_argument( "-fp", "--failed-pages-dir", default=None, help="Directory for failed/unreadable page PDFs and report (default: same as --output-dir)", ) command_parsers["grade"].add_argument( "-gb", "--gradebooks", nargs="+", help="One or more gradebook CSV files (must contain a 'Student ID' column)", ) command_parsers["grade"].add_argument( "-sc", "--score-column", default="score", help="Column name in the gradebook(s) for the score (default: 'score')", ) command_parsers["grade"].add_argument( "--column-id", default=None, metavar="SUBSTRING", help="Substring to match against gradebook column names for score output " "(e.g. 'Final' matches 'Final Exam [Total Pts: 100 Score] |3766016'). " "Takes precedence over --score-column when a unique match is found.", ) command_parsers["grade"].add_argument( "-nc", "--num-counted", type=int, default=None, help="Number of questions counted toward the score (default: all questions). Must be <= total questions.", ) command_parsers["grade"].add_argument( "-qt", "--question-tally", help="Path to output CSV file summarizing question tallies", ) command_parsers["grade"].add_argument( "--interactive", default=False, action=ap.BooleanOptionalAction, help="Pause and prompt for manual input when no fill is detected on a question", ) command_parsers["make-answersheet"].add_argument( "-o", "--output-pdf", default="answersheet", help="Path to output answer sheet PDF", ) command_parsers["make-answersheet"].add_argument( "-nc", "--num-cols", type=int, default=3, help="Number of columns in the answer sheet", ) command_parsers["make-answersheet"].add_argument( "-nq", "--num-questions", type=int, default=50, help="Number of questions on the answer sheet", ) command_parsers["make-answersheet"].add_argument( "-od", "--output-dir", default=".", help="Output directory", ) command_parsers["make-answersheet"].add_argument( "-idl", "--student-id-num-digits", type=int, default=8, help="Number of digits in the student ID field", ) command_parsers["make-answersheet"].add_argument( "--bubble-font-size", type=float, default=8.0, metavar="PT", help="Font size in points for text inside answer bubbles (default: 8.0)", ) command_parsers["make-answersheet"].add_argument( "--odd-page-answersheet", default=False, action=ap.BooleanOptionalAction, help="Force the answer sheet to start on an odd-numbered page (for double-sided printing)", ) command_parsers["make-answersheet"].add_argument( "--rasterize", default=False, action=ap.BooleanOptionalAction, help="Rasterize output PDF at 300 DPI for improved printer compatibility", ) command_parsers["tune-answersheetreader"].add_argument( "-i", "--sample-pdf", help="Sample PDF for tuning results", ) command_parsers["tune-answersheetreader"].add_argument( "-nc", "--num-cols", type=int, default=3, help="Number of columns in the answer sheet", ) command_parsers["tune-answersheetreader"].add_argument( "-nq", "--num-questions", type=int, default=50, help="Number of questions on the answer sheet", ) command_parsers["tune-answersheetreader"].add_argument( "-o", "--output-image", default="answersheetreader_tuning_overlay.png", help="Path to output image file showing tuning overlay", ) command_parsers["bundle"].add_argument( "-i", "--input-dir", required=True, help="Directory containing PDF files to bundle", ) command_parsers["bundle"].add_argument( "-od", "--output-dir", default="bundles", help="Output directory for bundle PDFs (default: bundles/)", ) command_parsers["bundle"].add_argument( "-n", "--bundle-size", type=int, required=True, help="Number of documents per bundle", ) command_parsers["bundle"].add_argument( "--co-bundle", default=False, action=ap.BooleanOptionalAction, help="Also generate a co-bundle PDF of the last page from each document", ) command_parsers["return"].add_argument( "-gb", "--gradebooks", nargs="+", required=True, help="Gradebook CSV file(s) with 'Student ID' and email columns", ) command_parsers["return"].add_argument( "-gd", "--graded-dir", required=True, help="Directory containing graded answer sheet PDFs (graded_<id>_<version>.pdf)", ) command_parsers["return"].add_argument( "-ed", "--exams-dir", required=True, help="Directory containing exam version PDFs (exam-<version>.pdf)", ) command_parsers["return"].add_argument( "--email-column", default="Username", help="Gradebook column containing email or email prefix (default: 'Username')", ) command_parsers["return"].add_argument( "--email-suffix", default="@drexel.edu", help="Suffix appended to email column value (default: '@drexel.edu'). " "Set to '' if the column already has full addresses.", ) command_parsers["return"].add_argument( "--subject", default="Your graded exam", help="Email subject line (default: 'Your graded exam')", ) command_parsers["return"].add_argument( "--body", default=( "Attached are your graded answer sheet and a copy of the exam you took.\n" "If you have questions about your grade, please contact your instructor." ), help="Plain-text email body", ) command_parsers["return"].add_argument( "--dry-run", default=False, action=ap.BooleanOptionalAction, help="Preview what would be sent without actually emailing", ) command_parsers["return"].add_argument( "--sleep-seconds", type=float, default=0.5, help="Number of seconds to sleep between sending emails (default: 0.5 seconds)", ) command_parsers["convert"].add_argument( "-i", "--input-zip", required=True, help="Path to a ZyBooks-exported zip file containing Word documents", ) command_parsers["convert"].add_argument( "-o", "--output-yaml", required=True, help="Path to the output YAML question bank file", ) # ---- dispatch ------------------------------------------------ args = parser.parse_args(argv) # If config specified, load and override if args.config: config_dict = load_args_from_yaml(args.config) logger.debug(f'Loaded config from {args.config}') logger.debug(f'Config contents: {config_dict}') # Only override if not set on command line for key, value in config_dict.items(): if not hasattr(args, key) or getattr(args, key) is None or isinstance(getattr(args, key), bool): setattr(args, key, value) logger.debug(f'Args after loading config: {args}') # Save if requested if args.save_config: save_args(args, args.save_config) setup_logging(args) logger.debug(f'Command line: {" ".join(sys.argv)}') if args.banner: banner(print) func = subcommands.get(args.subcommand, {}).get('func', None) if func: return func(args) else: logger.debug(f'{args}') my_list = oxford(list(subcommands.keys())) print(f'No subcommand found. Expected one of {my_list}') return 1 logger.info('Thanks for using pyaota!')
if __name__ == "__main__": raise SystemExit(main())