Source code for pygacity.generate.block
# Author: Cameron F. Abrams, <cfa22@drexel.edu>
"""
LaTeX compound block class for pygacity
"""
from __future__ import annotations
import logging
import os
import re
from copy import deepcopy
from importlib.resources import files
from pathlib import Path
from shutil import copy2
logger = logging.getLogger(__name__)
[docs]
def path_resolver(filename: str, search_paths: list[Path] = [], ext: str ='') -> Path:
"""
Resolves a filename to a **Path** object, checking the local directory first,
then searching the provided search paths. If the file is not found, raises
a **FileNotFoundError**.
Parameters
----------
filename : str
the name of the file to locate
search_paths : list of **Path**, optional
list of directories to search if the file is not found locally
ext : str, optional
file extension to append if not already present
Returns
-------
Path
the resolved **Path** object for the file
"""
local_filename = filename if filename.endswith(ext) else filename + ext
# check local directory first
local_path = Path(local_filename)
if local_path.exists():
return local_path
# check search path next
else:
for search_path in search_paths:
template_path = search_path / local_filename
if template_path.exists():
return template_path
spm = ':'.join([str(sp) for sp in search_paths])
raise FileNotFoundError((f'Could not locate source file {local_filename} in cwd ({os.getcwd()}) '
f'or search path {spm}.'))
[docs]
class LatexBlock:
"""
A LaTeX block with support for external source files, substitutions, and embedded pythontex code.
"""
resources_root: Path = files('pygacity') / 'resources'
""" The root path for pygacity resources """
templates_dir: Path = resources_root / 'templates'
""" The directory containing LaTeX template files """
# pythontex_dir = resources_root / 'pythontex'
""" The directory containing pythontex source files """
substitution_delimiters: tuple = (r'<<<', r'>>>')
""" The delimiters for substitution keys in LaTeX text """
[docs]
@classmethod
def from_specs(cls, block_specs: dict) -> list[LatexBlock]:
"""
Factory that returns a list of LatexBlocks. Handles the old-style
'enumerate' shorthand: if block_specs has an 'enumerate' key whose value
is a list, each item is treated as a question block with question_number
assigned sequentially starting from 1.
"""
if 'enumerate' in block_specs and isinstance(block_specs['enumerate'], list):
blocks = []
for q_num, item_specs in enumerate(block_specs['enumerate'], start=1):
specs = deepcopy(item_specs)
specs.setdefault('question_number', q_num)
blocks.append(cls(block_specs=specs).load())
return blocks
return [cls(block_specs=block_specs).load()]
def __init__(self, block_specs: dict):
self.block_specs = block_specs
self.textcontents: str = block_specs.get('text', '')
self.sourcename: str = block_specs.get('source', None)
self.question_number: int = block_specs.get('question_number', None)
raw_substitutions = block_specs.get('substitutions', {})
if isinstance(raw_substitutions, list):
self.substitution_map: dict = {item['search']: item['replace'] for item in raw_substitutions}
else:
self.substitution_map: dict = raw_substitutions
self.points: int = block_specs.get('points', 0)
self.config_filename: str = block_specs.get('config', None)
self.group: int = block_specs.get('group', 0)
self.toplevel: bool = block_specs.get('toplevel', False)
self.pythontex: list[str] = block_specs.get('pythontex', [])
self.sourcepath = None
self.config_path = Path(self.config_filename) if self.config_filename else None
self.processedcontents: str = ''
self.has_pycode: bool = False
self.embedded_graphics: list[str | Path] = []
self._check_schema()
logger.debug(f'LatexBlock.__init__ substitution_map: {self.substitution_map}')
def _check_schema(self):
# cannot specify both text content and a source file
if self.textcontents != '' and self.sourcename is not None:
raise ValueError('Block cannot specify both "text" content and a "source" file.')
# if list of pythontext files is non-empty, cannot specify text or source
if len(self.pythontex) > 0:
if self.textcontents != '' or self.sourcename is not None:
raise ValueError('Block cannot specify "pythontex" files along with "text" content or a "source" file.')
[docs]
def load(self) -> LatexBlock:
"""
Loads the block's text contents from source files if specified,
processes substitutions to identify keys, and recursively loads
child blocks.
Returns
-------
LatexCompoundBlock
the loaded block instance
"""
if self.sourcename:
self.sourcepath = path_resolver(self.sourcename, search_paths=[self.templates_dir])
logger.debug(f'Block resolved source file {self.sourcename} to {self.sourcepath}')
with open(self.sourcepath, 'r', encoding='utf-8') as f:
self.textcontents = f.read()
header_contents = r'\begin{pycode}' + '\n'
header_contents += '### BLOCK GLOBALS BEGIN ####\n'
if self.question_number is not None:
header_contents += f'points = {self.points}\n'
header_contents += f'group = {self.group}\n'
header_contents += f'qno = {self.question_number}\n'
header_contents += f'sourcename = r"""{self.sourcename}"""\n'
header_contents += f'if "AnsSet" in locals(): AnsSet.set_source(qno, sourcename)\n'
if self.points > 0:
header_contents += f'print(f"({self.points} pts.)")\n'
if self.config_path:
header_contents += f'configfilename = r"""{self.config_path.as_posix()}"""\n'
if self.toplevel:
header_contents += 'first_ordinal = 1\n'
header_contents += '### BLOCK GLOBALS END ####\n'
header_contents += r'\end{pycode}' + '\n'
self.textcontents = header_contents + self.textcontents
elif len(self.pythontex) > 0:
self.textcontents = r'\begin{pycode}' + '\n'
for ptfile in self.pythontex:
if ptfile == 'setup':
## document-specific substitutions; manager does this via string replacement later
self.textcontents += '### DOCUMENT GLOBALS BEGIN ####\n'
self.textcontents += '### These should be resolved by subsitution prior to execution ###\n'
self.textcontents += 'serial = <<<serial>>>\n'
self.textcontents += 'serialstr = "<<<serialstr>>>"\n'
self.textcontents += 'serials = <<<serials>>>\n'
self.textcontents += 'serialstrs = <<<serialstrs>>>\n'
self.textcontents += '_build_dir = "<<<build_dir>>>"\n'
self.textcontents += '_cache_dir = "<<<cache_dir>>>"\n'
self.textcontents += 'solutions = <<<solutions>>>\n'
## block-specific substitutions; can do these now
self.textcontents += '### These are block-specific ###\n'
self.textcontents += '### DOCUMENT GLOBALS END ####\n'
self.textcontents += f'from pygacity.pythontex.{ptfile} import *\n'
self.textcontents += r'\end{pycode}' + '\n'
self.has_pycode = r'\begin{pycode}' in self.textcontents or self.has_pycode
# check contents for substitution keys and embedded graphics files
KEY_RE = re.compile(rf'{self.substitution_delimiters[0]}\s*([A-Za-z0-9_-]+)\s*{self.substitution_delimiters[1]}')
for line in self.textcontents.split('\n'):
keys = set(KEY_RE.findall(line))
for key in keys:
if not key in self.substitution_map:
self.substitution_map[key] = None
# check for embedded graphics
GRAPHICS_RE = re.compile(r'\\includegraphics(?:\[[^\]]*\])?\{([^\}]+)\}')
graphics_files = GRAPHICS_RE.findall(line)
for gf in graphics_files:
if gf not in self.embedded_graphics:
self.embedded_graphics.append(gf)
self.processedcontents = self.textcontents[:]
if self.config_path:
if not self.config_path.exists():
raise FileNotFoundError(f'Configuration file {self.config_path.as_posix()} does not exist.')
self.substitution_map['config'] = self.config_path.as_posix()
if self.question_number is not None:
self.substitution_map['qno'] = self.question_number
if self.points:
self.substitution_map['points'] = self.points
if self.group:
self.substitution_map['group'] = self.group
logger.debug(f'LatexBlock.load completed with substitutions: {self.substitution_map}')
return self
[docs]
def substitute(self, super_substitutions: dict = {}, match_all: bool = True):
"""
Applies substitutions to the block's text contents and recursively to its children.
Parameters
----------
super_substitutions : dict, optional
substitutions from parent blocks
match_all : bool, optional
if True, raises KeyError if any substitution key has no associated value
"""
self.processedcontents = self.textcontents[:]
substitutions = deepcopy(super_substitutions)
logger.debug(f'block incoming substitutions: {substitutions}')
logger.debug(f'block own substitution_map: {self.substitution_map}')
substitutions.update({k: v for k, v in self.substitution_map.items() if v is not None})
logger.debug(f'block substitutions: {substitutions}')
# apply substitutions to the contents
for key, value in substitutions.items():
if value is not None:
self.processedcontents = self.processedcontents.replace(f'{self.substitution_delimiters[0]}{key}{self.substitution_delimiters[1]}', str(value))
elif match_all:
raise KeyError(f'Substitution key {key} has no associated value for text {self.textcontents[:30]}...')
[docs]
def copy_referenced_configs(self, output_dir: str):
"""
Copies any referenced configuration files to the specified output directory.
Parameters
----------
output_dir : str
the directory to copy configuration files to
Returns
-------
list of str
list of paths to the copied configuration files
"""
config_paths = []
if self.config_path and self.config_path.exists():
dest_path = Path(output_dir) / self.config_path.name
if not dest_path.exists():
copy2(self.config_path, dest_path)
logger.debug(f'Copied config file {self.config_path} to {dest_path}')
config_paths.append(str(dest_path))
return config_paths
def __str__(self):
"""
Returns the processed contents of the block as a string.
Returns
-------
str
the processed LaTeX contents of the block
"""
contents = self.processedcontents
return contents