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 packagexparse
)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()
1 Answer 1
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 notregex_2_find
get_terminal_size
returns a 2-tuple. Rather than[0]
, unpacking tocolumns, 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 tof.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 whatTypeError
is for. output_dir
inprint(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()
; prefersubprocess
. When I ran this,latexmk
failed becauseperl
was not found, but this failure was glossed over and execution continued when it should not have. You may be tempted to usesubprocess
withshell=True
but don't do this either, due to security reasons. Ask for an absolute directory tolatexmk
, or maybe look intogetenv('PATH')
and do a heuristic search for the usual suspects.
-
\$\begingroup\$ I used
with_suffix("")
becausewith_suffix
is actually not a replacement but an addition despite what the name suggests soPath("temp.txt").with_suffix(".pdf")
will returnPath(temp.txt.pdf)
. \$\endgroup\$N3buchadnezzar– N3buchadnezzar2021年06月09日 16:58:41 +00:00Commented Jun 9, 2021 at 16:58 -
\$\begingroup\$ Are you absolutely sure?
Path('foo.txt').with_suffix('.bar') > WindowsPath('foo.bar')
\$\endgroup\$Reinderien– Reinderien2021年06月09日 16:59:37 +00:00Commented 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 onlypdf
ordvi
, similarly the input file must be atex
file so some additional checks are needed, woops.. I forgot to comment away the actual compilation usinglatexmk
as I can't expect a review having TeX installed on their system, but i'll switch it topdflatex
just in case. \$\endgroup\$N3buchadnezzar– N3buchadnezzar2021年06月09日 17:16:59 +00:00Commented 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\$Reinderien– Reinderien2021年06月09日 17:22:19 +00:00Commented Jun 9, 2021 at 17:22
Explore related questions
See similar questions with these tags.
self.
references for those methods, anddirectory
andfilepath
are not defined - perhapsdirectory
also missing aself.
, and forfilepath
I have no idea. \$\endgroup\$self
arguments. I will remove my previous comments to clean up the comment section. \$\endgroup\$\rand
to generate random but reproducible exam variants. Put the seed on the exam variant, just in case. \$\endgroup\$