7
\$\begingroup\$

Background

I am teaching a course at university level, and have lately been working on creating a home exam for my students. However, the institute has given clear instructions that collaboration is not allowed. In order to comply with this, I saw no other way than to give each student their own variant of the exam (minor changes to numbers, variables etc).

Brief overview

I did it as follows:

  • The exams are generated from a LaTeX document that looks something like the following

    \documentclass[12p,A4paper]{article}
    
    includeSolution = {true}
    
    % This locks the seed
    \ExplSyntaxOn
      \sys_gset_rand_seed:n {5}
    \ExplSyntaxOff
    
    \begin{document}
    
    Lorem Lipsum 
    
    \end{document}
    

    (Do note that the document above is just a sketch of my real document, if you want to compile it you need to comment out the line includeSolution = {true} and add the package xparse)

  • Python is then used to access the lines includeSolution = {true} and \sys_gset_rand_seed:n {5} and change them.

  • Changing the lines and compiling the LaTeX document generates the new variants of the questions.

  • This process is repeated for every student in the course and the resulting pdf's are neatly placed in a seperate subfolder.

The interface for the code is something along the lines of

python generate_variants.py -f "test.tex" -n 5 -lf "true" -m 2021    

I've tried to add an argparse to explain all the options and how to use them.

Questions

I am really just asking for feedback on my implementation as a whole.

  • Is the code well written and understandable
  • Does my usage of a class make sense or is there a cleaner implementation?
  • Could be parser for command line arguments have been implemented better?
  • I decided to go for absolute paths using pathlib, but I am not sure if I covered all the corner cases. E.g running the python file from another directory etc
  • would my implementation run on Windows vs Linux vs Mac?

If this question is too broad I could try to split it into smaller questions. E.g only asking about the parser

Full code

This question is only about the Python portion of the code

import re
import os
import time

from pathlib import Path

import shutil

import sys
import argparse

LATEXMK = "latexmk -xelatex -shell-escape -pdf -interaction=batchmode"
MAX_CHARACTER_WiDTH = 79
SYMBOL = "-="
DELIMITER = "-"
INDENT = 2
INDENT_WIDTH = " "
INDENT_STR = INDENT_WIDTH * INDENT


def regex_2_find(text, regex):
    return text.strip() + " {" + regex.strip() + "}"


DIGITS = r"[0-9]+"
BOOLEAN = r"(\btrue|false\b)"
SEED_TXT = "sys_gset_rand_seed:n".strip()
SEED_REGEX = re.compile(regex_2_find(SEED_TXT, DIGITS))
SOLUTION_TXT = "includeSolution ="
SOLUTION_REGEX = re.compile(regex_2_find(SOLUTION_TXT, BOOLEAN))


def get_terminal_width() -> int:
    return shutil.get_terminal_size()[0]


def get_max_terminal_width(max_width: int = MAX_CHARACTER_WiDTH) -> int:
    return min(get_terminal_width(), max_width)


def line_break(
        width: int = None, max_width: int = MAX_CHARACTER_WiDTH, symbol: str = SYMBOL
) -> str:
    if not len(symbol):
        return ""
    max_terminal_width = get_max_terminal_width(max_width)
    width = min(max_terminal_width, width) if width else max_terminal_width
    repeats, remainder = divmod(width, len(symbol))
    linebreak = repeats * symbol + symbol[:remainder]
    return linebreak


def word_wrap(
        text, width: int = None, indent: int = INDENT, max_width: int = MAX_CHARACTER_WiDTH
) -> str:
    max_terminal_width = get_max_terminal_width(max_width)
    width = min(max_terminal_width, width) if width else max_terminal_width
    indent_width = INDENT_WIDTH * indent
    lines = []
    current_line = ""
    for word in text.replace("\n", "").split(" "):
        if len(current_line) + len(word) > width:
            lines.append(indent_width + current_line.strip())
            current_line = word
        else:
            current_line += " " + word
    lines.append(indent_width + current_line.strip())
    return "\n".join(lines)


def replace_text_w_regex(
        file_path: str, compiled_text, regex
) -> None:
    with open(file_path, "r+") as f:
        file_contents = f.read()
        file_contents = compiled_text.sub(regex, file_contents)
        f.seek(0)
        f.truncate()
        f.write(file_contents)


def compile_latex(filename: str, latexmk: str):
    """
    Runs the compilation for the latex file, example:
      latexmk -xelatex -shell-escape
      pdflatex
    etc
    """
    compile_command = f"{latexmk} {filename}"
    os.system(compile_command)


def ask_user_2_confirm() -> bool:
    return input(INDENT_STR + "[yes/no]: ").lower().strip() in ["yes", "ja", "ok", "y"]


def replace_suffix(filepath: Path, suffix: str = None) -> Path:
    return filepath.with_suffix("").with_suffix(suffix)


class Exam:
    def __init__(
            self,
            filename: str,  # Input path for exam
            directory: str,  # Output path for exam
            students: int,
            solution: bool = False,
            latexmk: str = LATEXMK,
            suffix: str = ".pdf",
            multiplier: int = 1,
    ):

        self.filename = filename
        self.path_in = self.get_filepath(filename)
        self.directory = directory
        self.students = int(students)
        self.compiler = latexmk
        self.suffix = suffix if suffix else self.path_in.suffix
        self.multiplier = int(multiplier)
        self.path_out = self.get_dir(self.directory)
        self.include_solution = solution

    def get_filepath(self, filename: str = None) -> Path:
        """Checks if filepath exists and creates an absolute path"""
        filename = filename if filename else self.filename
        filepath = Path(filename)
        if not filepath.is_file():
            raise NameError(f"The path '{filepath}' does not exist")
        if not filepath.is_absolute():
            filepath = Path(Path.cwd(), filepath)
        return filepath

    def get_dir(self, filepath=None, directory=None) -> Path:
        """Checks if directory exists, if not it asks to create it"""

        filepath = filepath if filepath else self.path_in
        if isinstance(filepath, str):
            filepath = Path(filepath)

        directory = directory if directory else self.directory
        if not directory:
            directory = Path(filepath.parent, filepath.stem)
        elif isinstance(directory, str):
            directory = Path(directory)
        elif isinstance(directory, Path):
            pass
        else:
            raise TypeError(f"Expected directory to be of type 'str' or 'Path' got {type(directory)}")

        if not directory.is_absolute():
            directory = directory.absolute()

        if not directory.is_dir():
            print(f"Looks like: '{output_dir}' is not a directory")
            print("Would you like to create it?")
            if ask_user_2_confirm():
                os.mkdir(directory)
            if not output_dir.is_dir():
                raise NameError(f"The path '{directory}' does not exist")
        return directory

    def last_modified_pdf(self) -> float:
        return os.stat(self.path_out).st_mtime

    def last_modified_tex(self) -> float:
        return os.stat(self.path_in).st_mtime

    def last_modified_str(self) -> str:

        parts = self.path_out.parts
        root = "".join(self.path_out.parts[0:2])

        path_in_str = f"{self.path_in.name}"
        if len(parts) > 3:
            path_out_str = f"{root}/../{self.path_out.name}/"
        else:
            path_out_str = f"{self.path_out.name}/"
        max_path = max(len(path_out_str), len(path_in_str))

        modi_path_in = time.ctime(self.last_modified_tex())
        modi_path_out = time.ctime(self.last_modified_pdf())

        filepath_modified_str = f"Filepath: {path_in_str: >{max_path}}  last modified  {modi_path_in}"
        folder_modified_str = f"Folder:   {path_out_str: >{max_path}}  last modified  {modi_path_out}"
        return INDENT_STR + filepath_modified_str + "\n" + INDENT_STR + folder_modified_str

    def pdf(
            self,
            student_id: int = 1,
            path_in: Path = None,
            include_solution: bool = None,
            suffix: str = None,
    ) -> str:
        """Generates the output pdf name"""

        path_in = path_in if path_in else self.path_in
        suffix = suffix if suffix else self.suffix
        suffix = f".{suffix}" if suffix[0] != "." else suffix
        include_solution = include_solution if include_solution else self.include_solution

        solution_str = DELIMITER + "LF" if include_solution else ""
        # student_id = student_id if student_id else self.students

        return f"{path_in.stem}{DELIMITER}{student_id:04d}{solution_str}{suffix}"

    def create_pdf(
            self,
            student_id: int,
            path_in: Path = None,
            path_out: Path = None,
            include_solution: bool = None,
            compiler: str = None,
            suffix: str = None,
            multiplier: int = None,
    ) -> None:
        # This is where the student pdfs are generated

        path_in = path_in if path_in else self.path_in
        path_out = path_out if path_out else self.path_out
        include_solution = include_solution if include_solution else self.include_solution
        suffix = suffix if suffix else self.suffix
        compiler = compiler if compiler else self.compiler
        multiplier = multiplier if multiplier else self.multiplier

        # Updates the seed in the pdf
        subs = regex_2_find(SEED_TXT, str(multiplier * student_id))
        replace_text_w_regex(str(path_in), SEED_REGEX, subs)

        # Makes sure the pdf includes/excludes the solutions
        subs = regex_2_find(SOLUTION_TXT, str(include_solution).lower())
        replace_text_w_regex(str(path_in), SOLUTION_REGEX, subs)

        # compiles the LaTeX
        compile_latex(str(path_in), compiler)

        # Copies the compiled pdf to another dir
        input_pdf = replace_suffix(path_in, suffix)
        output_pdf = Path(path_out, self.pdf(student_id, path_out, include_solution, suffix))
        shutil.copy2(input_pdf, output_pdf)

    def generate_variants(
            self,
            students: int = None,
            filename: str = None,
            directory: str = None,
            solution: str = None,
            latexmk: str = LATEXMK,
            suffix: str = None,
            multiplier: int = None,
    ) -> None:
        students = students if students else self.students
        if not filename:
            filename = self.filename
            path_in = self.path_in
        else:
            path_in = get_filepath(filename)
        if not directory:
            path_out = self.path_out
        else:
            path_out = self.get_output_dir(directory, filename)

        suffix = suffix if suffix else self.suffix
        compiler = latexmk if latexmk else self.compiler
        multiplier = multiplier if multiplier else self.multiplier

        linebreak = line_break()

        if self.last_modified_tex() > self.last_modified_pdf():
            generate_pdfs = True
        else:
            print(linebreak)
            print(self.last_modified_str())
            print(linebreak)
            print(
                word_wrap(
                    "It looks like the solutions / exams have been modified after the last time the tex file was "
                    + "modified. Are you sure you want to regenerate the PDF files?"
                )
            )
            generate_pdfs = ask_user_2_confirm()

        if not generate_pdfs:
            return
        for student in range(1, students + 1):
            print(linebreak)
            print(INDENT_STR + str(student))
            print(linebreak)
            self.create_pdf(
                student,
                path_in,
                path_out,
                solution,
                compiler,
                suffix,
                multiplier,
            )

        Path(self.path_out).touch()  # makes sure pdfs are updated after tex file

    def __str__(self):

        linebreak = line_break()
        return_str = "\n" + linebreak

        output_dict = {
            "Students": self.students,
            "Include solution": self.include_solution,
            "TEX compiler": self.compiler,
            "Output suffix": self.suffix,
            "RNG seed multiplier": self.multiplier,
        }

        key_len = max(map(len, output_dict))
        for key, val in output_dict.items():
            return_str += f"\n{INDENT_STR}{key: >{key_len}}: {val}"

        return_str += f"\n{linebreak}\n{self.last_modified_str()}\n{linebreak}"
        return return_str


def get_commandline_args():
    # Create the parser
    parser = argparse.ArgumentParser(
        description="Generates pdf-variants and places them in a sub directory, -h for help"
    )

    req_grp = parser.add_argument_group(title="Required")
    req_grp.add_argument(
        "-f", "--file_name", required=True, type=str, help="the path to the tex file"
    )
    parser.add_argument(
        "-d",
        "--directory",
        nargs="?",
        default="",
        help="enter desired output dir. default = dir of tex file",
    )
    parser.add_argument(
        "-c",
        "--compiler",
        type=str,
        nargs="?",
        default=LATEXMK,
        help="which command to run on the tex file. default = " + LATEXMK,
    )
    parser.add_argument(
        "--suffix",
        type=str,
        nargs="?",
        default=".pdf",
        help="File ending for the output. default = .pdf",
    )
    parser.add_argument(
        "-sol",
        "--include_solution",
        type=lambda x: (str(x).lower() == "true"),
        default=False,
        help="Boolean value: whether to include solutions or not. default = false"
    )
    parser.add_argument(
        "-n",
        "--students",
        type=int,
        nargs="?",
        default=1,
        help="Number of pdfs to generate. default = 1",
    )
    parser.add_argument(
        "-m",
        "--multiplier",
        type=int,
        nargs="?",
        default=1,
        help="Number to multiply the seed generation with, common to use exam year. default = 1",
    )

    return parser


# Temporary fix YOU SHOULD RUN THE FILE FROM COMMANDLINE
ARGUMENTS = [
    "file_name", "directory", "students", "compiler", "include_solution", "suffix", "multiplier"
]
DEFAULTS = ["", "", LATEXMK, "false", 1, ".pdf", 1]


def main():
    command_args = get_commandline_args()

    # if len(sys.argv) > 1:
    args = command_args.parse_args()
    # print(args)
    tek2021 = Exam(
        args.file_name,
        args.directory,
        args.students,
        args.include_solution,
        args.compiler,
        args.suffix,
        args.multiplier,
    )
    # else:
    # Temporary fix removed, might add option to run from file alter
    #    print("")
    #    args = []
    #    for index, option in enumerate(ARGUMENTS):
    #        user_input = input(f"{option}: ")
    #        if option in ["students", "multiplier"]:
    #            user_input = int(user_input)
    #        args.append(user_input if user_input else DEFAULTS[index])
    #    Tek2021 = Exam(*args)

    print(tek2021)
    tek2021.generate_variants()


if __name__ == "__main__":
    main()
\$\endgroup\$
9
  • 2
    \$\begingroup\$ You're brave using tex. I would have used html. \$\endgroup\$
    – Reinderien
    Jun 7 '21 at 0:36
  • \$\begingroup\$ Lines 250-252 will not interpret. You've missed two self. references for those methods, and directory and filepath are not defined - perhaps directory also missing a self., and for filepath I have no idea. \$\endgroup\$
    – Reinderien
    Jun 7 '21 at 0:39
  • 4
    \$\begingroup\$ It's allowed (and encouraged) to edit a question for correctness - so long as that's done before any answers are submitted. \$\endgroup\$
    – Reinderien
    Jun 7 '21 at 13:05
  • \$\begingroup\$ @Reinderien I've updated the code to be able to be run without any command line interface, but I really do not think this is desirable as this throws the entire help portion of the code out the window =) I've also fixed the missing self arguments. I will remove my previous comments to clean up the comment section. \$\endgroup\$ Jun 7 '21 at 20:59
  • \$\begingroup\$ I haven't used Tex since the dark ages, but I think you could pass in a seed as a command line argument and then use \rand to generate random but reproducible exam variants. Put the seed on the exam variant, just in case. \$\endgroup\$
    – RootTwo
    Jun 7 '21 at 22:27
1
\$\begingroup\$

OK. You still have undefined symbols but I'm going to push on.

  • For your function signatures, consider PEP484 type hints. You've done this on, for example, get_max_terminal_width but not regex_2_find
  • get_terminal_size returns a 2-tuple. Rather than [0], unpacking to columns, lines = get_terminal_size() is I think clearer.
  • You've written word_wrap yourself; but have you read about textwrap?
  • f.seek(0) / f.truncate() should be equivalent to f.truncate(0).
  • For ["yes", "ja", "ok", "y"] consider using a set instead. If I were you I'd simplify this to .lower().startswith('y').
  • The first with_suffix in .with_suffix("").with_suffix(suffix) is redundant; it's a replacement and not an append operation
  • For your raise NameError(f"The path '{filepath}' does not exist"), FileNotFoundError would be more appropriate. Similarly, raise TypeError(f"Expected directory... is not what TypeError is for.
  • output_dir in print(f"Looks like: '{output_dir}' is not a directory") is (still) undefined. I'm not joking :) get_filepath is (still) similarly undefined.
  • enter desired output dir. default = dir of tex file is a lie. It does not default to the directory of the tex file: it defaults to a new directory whose name is based on the stem of the tex file.
  • For so many reasons, don't use os.system(); prefer subprocess. When I ran this, latexmk failed because perl was not found, but this failure was glossed over and execution continued when it should not have. You may be tempted to use subprocess with shell=True but don't do this either, due to security reasons. Ask for an absolute directory to latexmk, or maybe look into getenv('PATH') and do a heuristic search for the usual suspects.
\$\endgroup\$
4
  • \$\begingroup\$ I used with_suffix("") because with_suffix is actually not a replacement but an addition despite what the name suggests so Path("temp.txt").with_suffix(".pdf") will return Path(temp.txt.pdf). \$\endgroup\$ Jun 9 '21 at 16:58
  • \$\begingroup\$ Are you absolutely sure? Path('foo.txt').with_suffix('.bar') > WindowsPath('foo.bar') \$\endgroup\$
    – Reinderien
    Jun 9 '21 at 16:59
  • \$\begingroup\$ I am absolute sure I am wrong ;-) However there is something worse with my implementation. with_suffix only replaces the last suffix. In addition all suffixes are not possible only pdf or dvi, similarly the input file must be a tex file so some additional checks are needed, woops.. I forgot to comment away the actual compilation using latexmk as I can't expect a review having TeX installed on their system, but i'll switch it to pdflatex just in case. \$\endgroup\$ Jun 9 '21 at 17:16
  • 1
    \$\begingroup\$ No worries :) Feel free to take a stab at these issues and seek another round of review in a subsequent question. I'm actually curious to see some realistic output, even if it means installing a small tex instance locally. \$\endgroup\$
    – Reinderien
    Jun 9 '21 at 17:22

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.