From 56968a138d779a3d91c6d1bb2b2d1c4ca975840a Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Wed, 5 Nov 2025 16:20:58 +0100 Subject: [PATCH 1/2] Rewrite latex template handling to not require write access to parent directory --- problemtools/template.py | 122 +++++++++++--------- problemtools/templates/latex/problemset.cls | 25 ++-- problemtools/templates/latex/template.tex | 1 + 3 files changed, 80 insertions(+), 68 deletions(-) diff --git a/problemtools/template.py b/problemtools/template.py index df48bf24..79a62be3 100644 --- a/problemtools/template.py +++ b/problemtools/template.py @@ -1,5 +1,4 @@ import os.path -import glob import tempfile import shutil from pathlib import Path @@ -10,41 +9,53 @@ class Template: Our problemset.cls latex class was originally written to make it easy to render a problemset pdf from a bunch of problems for a contest. When we - want to render a pdf for a single problem, we need to dump a small, - temporary tex file in the parent directory (essentially a minified - problemset with just one problem). This class deals with creating and - cleaning up that template. The template has to be written in the parent - directory of problem_root. + want to render a pdf for a single problem, we essentially create a minified + problemset with a single problem. + + This class creates a temporary directory where it writes a .tex file and a + problemset.cls file. Run latex on that tex file to render the problem statement. + The temporary directory and its contents are removed on exit. + + We still support the user providing their own problemset.cls in the parent + directory of the problem. This will likely be removed at some point (I don't + think anyone uses this). It can be turned off by setting ignore_parent_cls=True Usage: with Template(problem_root, texfile) as templ: - texfile = templ.get_file_name() - os.chdir(os.path.dirname(texfile)) - subprocess.call(['pdflatex', texfile]) + texfile_path = templ.get_file_name() + os.chdir(os.path.dirname(texfile_path)) + subprocess.call(['pdflatex', texfile_path]) + # Copy the resulting pdf elsewhere before closing the context """ - def __init__(self, problem_root: Path, texfile: Path, language: str, force_copy_cls=False): + TEMPLATE_FILENAME = 'template.tex' + CLS_FILENAME = 'problemset.cls' + + def __init__(self, problem_root: Path, texfile: Path, language: str, ignore_parent_cls=False): assert texfile.suffix == '.tex', f'Template asked to render {texfile}, which does not end in .tex' assert texfile.is_relative_to(problem_root), f'Template called with tex {texfile} outside of problem {problem_root}' self.problem_root = problem_root self.statement_directory = texfile.relative_to(problem_root).parent self.statement_filename = texfile.name - self.templatefile = 'template.tex' - self.clsfile = 'problemset.cls' self.language = language - templatepaths = [ - os.path.join(os.path.dirname(__file__), 'templates/latex'), - os.path.join(os.path.dirname(__file__), '../templates/latex'), - '/usr/lib/problemtools/templates/latex', - ] + self._tempdir: tempfile.TemporaryDirectory | None = None + self.filename: Path | None = None + + templatepaths = map( + Path, + [ + os.path.join(os.path.dirname(__file__), 'templates/latex'), + os.path.join(os.path.dirname(__file__), '../templates/latex'), + '/usr/lib/problemtools/templates/latex', + ], + ) try: - self.templatepath = next( - (p for p in templatepaths if os.path.isdir(p) and os.path.isfile(os.path.join(p, self.templatefile))) - ) + templatepath = next(p for p in templatepaths if p.is_dir() and (p / self.TEMPLATE_FILENAME).is_file()) except StopIteration: - raise Exception('Could not find directory with latex template "%s"' % self.templatefile) + raise Exception('Could not find directory with latex template "%s"' % self.TEMPLATE_FILENAME) + self.templatefile = templatepath / self.TEMPLATE_FILENAME sample_dir = problem_root / 'data' / 'sample' if sample_dir.is_dir(): @@ -52,47 +63,44 @@ def __init__(self, problem_root: Path, texfile: Path, language: str, force_copy_ else: self.samples = [] - self.problemset_cls = problem_root.parent / 'problemset.cls' - self.copy_cls = True - if self.problemset_cls.is_file() and not force_copy_cls: - print(f'{self.problemset_cls} exists, will not copy it -- in case of weirdness this is likely culprit') - self.copy_cls = False + problemset_cls_parent = problem_root.parent / 'problemset.cls' + if not ignore_parent_cls and problemset_cls_parent.is_file(): + print(f'{problemset_cls_parent} exists, using it -- in case of weirdness this is likely culprit') + self.clsfile = problemset_cls_parent + else: + self.clsfile = templatepath / self.CLS_FILENAME def __enter__(self): - if self.copy_cls: - shutil.copyfile(os.path.join(self.templatepath, self.clsfile), self.problemset_cls) - - (templfd, self.filename) = tempfile.mkstemp(suffix='.tex', dir=self.problem_root.parent) - templout = os.fdopen(templfd, 'w') - templin = open(os.path.join(self.templatepath, self.templatefile)) - data = { - 'directory': self.problem_root.name, - 'statement_directory': self.statement_directory, - 'statement_filename': self.statement_filename, - 'language': self.language, - } - for line in templin: - try: - templout.write(line % data) - except KeyError: - # This is a bit ugly I guess - for sample in self.samples: - data['sample'] = sample + self._tempdir = tempfile.TemporaryDirectory(prefix='problemtools-') + temp_dir_path = Path(self._tempdir.name) + + shutil.copyfile(self.clsfile, temp_dir_path / self.CLS_FILENAME) + + self.filename = temp_dir_path / 'main.tex' + with open(self.filename, 'w') as templout, open(self.templatefile) as templin: + data = { + 'problemparent': str(self.problem_root.parent.resolve()), + 'directory': self.problem_root.name, + 'statement_directory': self.statement_directory.as_posix(), + 'statement_filename': self.statement_filename, + 'language': self.language, + } + for line in templin: + try: templout.write(line % data) - if self.samples: - del data['sample'] - templout.close() - templin.close() + except KeyError: + # This is a bit ugly I guess + for sample in self.samples: + data['sample'] = sample + templout.write(line % data) + if self.samples: + del data['sample'] return self def __exit__(self, exc_type, exc_value, exc_traceback): - if self.problemset_cls is not None and self.copy_cls and os.path.isfile(self.problemset_cls): - os.remove(self.problemset_cls) - if self.filename is not None: - for f in glob.glob(os.path.splitext(self.filename)[0] + '.*'): - if os.path.isfile(f): - os.remove(f) + if self._tempdir: + self._tempdir.cleanup() def get_file_name(self) -> str: # We should later change this to a Path - assert os.path.isfile(self.filename) - return self.filename + assert self.filename and self.filename.is_file() + return str(self.filename) diff --git a/problemtools/templates/latex/problemset.cls b/problemtools/templates/latex/problemset.cls index e6215d43..3d72c3b8 100644 --- a/problemtools/templates/latex/problemset.cls +++ b/problemtools/templates/latex/problemset.cls @@ -65,6 +65,7 @@ \newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} \newcommand*{\statementdirectory}[1]{\def\@statementdirectory{#1}} \newcommand*{\statementfilename}[1]{\def\@statementfilename{#1}} +\newcommand*{\problemparentpath}[1]{\def\@problemparentpath{#1}} % \problemlanguge is solely for backwards compatibility on the off chance someone external uses problemset.cls. Probably not needed \newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}\statementfilename{problem#1.tex}} \contestname{} @@ -74,8 +75,10 @@ \licenseblurb{} \statementdirectory{problem_statement} % Default to the old standard directory on the off chance someone external uses problemset.cls \statementfilename{} +\problemparentpath{} \problemlanguage{} +\newcommand{\@problempath}[1]{\ifx\@problemparentpath\@empty#1\else\@problemparentpath/#1\fi} % Command to set a header logo \newsavebox{\PS@headerbox} @@ -173,17 +176,17 @@ %% Problem inclusion \newcommand{\includeproblem}[1]{ \startproblem{#1} - \import{#1/\@statementdirectory/}{\@statementfilename} + \import{\@problempath{#1}/\@statementdirectory/}{\@statementfilename} %% Automatically include samples 1..9, if enabled \ifplastex\else \if@autoincludesamples \foreach \SampleNum in {1,...,9} { - \IfFileExists{\@problemid/data/sample/\SampleNum.interaction}{ - \displaysampleinteraction{\@problemid/data/sample/\SampleNum} - }{\IfFileExists{\@problemid/data/sample/\SampleNum.in}{ - \displaysample{\@problemid/data/sample/\SampleNum} + \IfFileExists{\@problempath{\@problemid}/data/sample/\SampleNum.interaction}{ + \displaysampleinteraction{\@problempath{\@problemid}/data/sample/\SampleNum} + }{\IfFileExists{\@problempath{\@problemid}/data/sample/\SampleNum.in}{ + \displaysample{\@problempath{\@problemid}/data/sample/\SampleNum} }{} } } @@ -215,8 +218,8 @@ \if@problemnumbers {\huge Problem \problemnumber\\[3mm]} \fi {\LARGE #1} \if@problemids {\\[2mm]{\Large Problem ID: #2}} \fi - \IfFileExists{#2/.timelimit}{ - \openin\ps@timelimitfile=#2/.timelimit + \IfFileExists{\@problempath{#2}/.timelimit}{ + \openin\ps@timelimitfile=\@problempath{#2}/.timelimit \read\ps@timelimitfile to\ps@timelimit \\[2mm]{\Large Time limit:\ps@formattime{\ps@timelimit}} \closein\ps@timelimitfile @@ -246,11 +249,11 @@ %% Define the command used to give sample data %% Takes filename as parameter \newcommand{\includesample}[1]{ - \IfFileExists{\@problemid/data/sample/#1.interaction}{ - \displaysampleinteraction{\@problemid/data/sample/#1} + \IfFileExists{\@problempath{\@problemid}/data/sample/#1.interaction}{ + \displaysampleinteraction{\@problempath{\@problemid}/data/sample/#1} }{ - \IfFileExists{\@problemid/data/sample/#1.in}{ - \displaysample{\@problemid/data/sample/#1} + \IfFileExists{\@problempath{\@problemid}/data/sample/#1.in}{ + \displaysample{\@problempath{\@problemid}/data/sample/#1} }{ \ClassError{problemset}{Can't find any sample named #1}{} } diff --git a/problemtools/templates/latex/template.tex b/problemtools/templates/latex/template.tex index c893ee0f..e0b2898d 100644 --- a/problemtools/templates/latex/template.tex +++ b/problemtools/templates/latex/template.tex @@ -2,6 +2,7 @@ %% If you want to add comments in this file, you need to use %%, as it must be compatible with python's templates \problemlanguage{%(language)s} %% We inject problemlanguage to be backwards compatible with custom problemset.cls +\problemparentpath{%(problemparent)s} \statementdirectory{%(statement_directory)s} \statementfilename{%(statement_filename)s} From c21517c2c08d49de1fb46d2fabf5ab7c909915d5 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Wed, 5 Nov 2025 16:33:21 +0100 Subject: [PATCH 2/2] Let Template.get_file_name() return a Path --- problemtools/problem2pdf.py | 6 +++--- problemtools/template.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 077c18c0..b5dce1d4 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -100,7 +100,7 @@ def latex2pdf(options: argparse.Namespace, statement_file: Path) -> bool: origcwd = os.getcwd() - os.chdir(os.path.dirname(texfile)) + os.chdir(texfile.parent) params = ['pdflatex', '-interaction=nonstopmode'] output = None if options.quiet: @@ -108,7 +108,7 @@ def latex2pdf(options: argparse.Namespace, statement_file: Path) -> bool: if options.nopdf: params.append('-draftmode') - params.append(texfile) + params.append(str(texfile.name)) status = subprocess.call(params, stdout=output) if status == 0: @@ -120,7 +120,7 @@ def latex2pdf(options: argparse.Namespace, statement_file: Path) -> bool: os.chdir(origcwd) if status == 0 and not options.nopdf: - shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) + shutil.move(texfile.with_suffix('.pdf'), destfile) if status: return False diff --git a/problemtools/template.py b/problemtools/template.py index 79a62be3..6b844692 100644 --- a/problemtools/template.py +++ b/problemtools/template.py @@ -41,7 +41,7 @@ def __init__(self, problem_root: Path, texfile: Path, language: str, ignore_pare self.language = language self._tempdir: tempfile.TemporaryDirectory | None = None - self.filename: Path | None = None + self.texfile: Path | None = None templatepaths = map( Path, @@ -76,8 +76,8 @@ def __enter__(self): shutil.copyfile(self.clsfile, temp_dir_path / self.CLS_FILENAME) - self.filename = temp_dir_path / 'main.tex' - with open(self.filename, 'w') as templout, open(self.templatefile) as templin: + self.texfile = temp_dir_path / 'main.tex' + with open(self.texfile, 'w') as templout, open(self.templatefile) as templin: data = { 'problemparent': str(self.problem_root.parent.resolve()), 'directory': self.problem_root.name, @@ -101,6 +101,6 @@ def __exit__(self, exc_type, exc_value, exc_traceback): if self._tempdir: self._tempdir.cleanup() - def get_file_name(self) -> str: # We should later change this to a Path - assert self.filename and self.filename.is_file() - return str(self.filename) + def get_file_name(self) -> Path: + assert self.texfile and self.texfile.is_file() + return self.texfile