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()
asked Jun 6, 2021 at 17:44
\$\endgroup\$
9
  • 2
    \$\begingroup\$ You're brave using tex. I would have used html. \$\endgroup\$ Commented Jun 7, 2021 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\$ Commented Jun 7, 2021 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\$ Commented Jun 7, 2021 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\$ Commented Jun 7, 2021 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\$ Commented Jun 7, 2021 at 22:27

1 Answer 1

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.
answered Jun 9, 2021 at 16:51
\$\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\$ Commented Jun 9, 2021 at 16:58
  • \$\begingroup\$ Are you absolutely sure? Path('foo.txt').with_suffix('.bar') > WindowsPath('foo.bar') \$\endgroup\$ Commented Jun 9, 2021 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\$ Commented Jun 9, 2021 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\$ Commented Jun 9, 2021 at 17:22

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.