import pathlib
import tempfile

from cardlatex.__version__ import version

tempdir = pathlib.Path(tempfile.gettempdir()) / 'cardlatex'
tempdir.mkdir(exist_ok=True, parents=True)

cardlatex/__init__.py
snorthman/cardlatex
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Tuple

import click

from . import version, tempdir
from .pdf import grid_pdf, combine_pdf
from .tex import Tex


@click.command()
@click.argument('tex', nargs=-1, type=click.Path(exists=True))
@click.option('-a', '--all', 'build_all', is_flag=True,
              help=r'Override the \cardlatex[include] configuration to be undefined.')
@click.option('-c', '--combine', is_flag=True,
              help='Combine source tex files into a single PDF file.')
@click.option('-p', '--print', 'paper', is_flag=True,  # type=click.Choice([p.value for p in PaperEnum]), default=None,
              help='Arranges all cards in grids in either A4 or A3 sizes.')
@click.option('-d', '--draft', is_flag=True,
              help=r'Resample all images to a much smaller size to improve compilation speeds.')
@click.option('--debug', is_flag=True, hidden=True)
@click.version_option(version)
def build(tex: Tuple[Path, ...], build_all: bool, combine: bool, paper: bool, draft: bool, debug: bool):
    start = datetime.now()
    context = click.get_current_context()
    logging.info(f'cardlatex {version}\t{context.params}')

    logger = logging.getLogger()
    [logger.removeHandler(handler) for handler in logger.handlers]
    handler = logging.FileHandler(filename=(tempdir / 'cardlatex.log').as_posix(), mode='w')
    handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
    logger.addHandler(handler)
    logger.setLevel(logging.DEBUG if debug else logging.INFO)

    try:
        kwargs = {key: value for key, value in locals().items() if key in context.params and key != 'tex'}
        builds: list[Tex] = [Tex(path).build(**kwargs) for path in tex]

        if paper:
            [grid_pdf(b.output, b.has_back) for b in builds]

        if combine and len(builds) > 1:
            if not all([b.completed for b in builds]):
                raise RuntimeError('Not all .tex files have succesfully compiled.')
            combine_pdf(*[b.output for b in builds])
            builds[0].release()
        else:
            [b.release() for b in builds]
    except Exception as e:
        print(e, file=sys.stderr)
        logging.exception(e)
        if debug:
            raise e
        else:
            exit(1)
    finally:
        end = datetime.now() - start
        print(f'cardlatex ran for {end}')
        logging.info(f'process ended in {end}')


if __name__ == '__main__':
    build()

cardlatex/__main__.py
snorthman/cardlatex
import re
from typing import List


def cardlatexprop(prop: str = ''):
    return r'\cardlatex configuration object' + (f'" {prop}"' if prop else '')


required_props = set()


class Config:
    @staticmethod
    def required(prop):
        required_props.add(prop.__name__)
        return prop

    def __init__(self, tex: str):
        self._config = dict()

        props = set()
        matches: List[re.Match] = list(re.finditer(r'^(.*)\\cardlatex\[(\w+)]\{', tex, re.M))
        for m, match in enumerate(matches):
            if '%' in match.group(1):
                continue

            assert hasattr(Config, prop := match.group(2)), (
                KeyError(rf'unknown {cardlatexprop(prop)}'))
            assert prop not in props, (
                KeyError(rf'duplicate {cardlatexprop(prop)}'))
            props.add(prop)

            b = 1
            rb: re.Match | None = None
            for rb in re.finditer(r'(?<!\\)[{}]', tex[match.end():]):
                b = b + (1 if rb.group() == '{' else -1)
                if b == 0:
                    break
            assert b == 0 and rb, (
                ValueError(rf'no closing bracket found for {cardlatexprop(prop)}'))

            endpos = match.end() + rb.end() - 1
            if m < len(matches) - 1:
                assert endpos < matches[m + 1].start(), (
                    ValueError(rf'{cardlatexprop()} found inside {cardlatexprop(prop)}'))

            setattr(self, prop, tex[match.end():endpos])

        for prop in required_props:
            if prop not in self._config:
                raise ValueError(cardlatexprop(prop) + ' missing')

    def _set_length_prop(self, prop: str, value: str):
        value = str(value).strip()
        assert re.match(r'^\d+(\.\d+)?(cm|mm|in)?$', value), (
            ValueError(f'invalid value "{value}" for {prop}'))
        self._config[prop] = value

    def __getitem__(self, item):
        return self._config[item]

    def __contains__(self, item):
        return item in self._config

    @property
    @required
    def width(self) -> str:
        return self._config['width']

    @width.setter
    def width(self, value: str):
        self._set_length_prop('width', value)

    @property
    @required
    def height(self) -> str:
        return self._config['height']

    @height.setter
    def height(self, value: str):
        self._set_length_prop('height', value)

    @property
    def bleed(self) -> str:
        return self._config.get('bleed', '0cm')

    @bleed.setter
    def bleed(self, value: str):
        self._set_length_prop('bleed', value)

    @property
    def spacing(self) -> str:
        return self._config.get('spacing', '0cm')

    @spacing.setter
    def spacing(self, value: str):
        self._set_length_prop('spacing', value)

    @property
    def dpi(self) -> str:
        return self._config.get('dpi', '0')

    @dpi.setter
    def dpi(self, value: str):
        self._config['dpi'] = str(value)

    @property
    def include(self) -> List[int]:
        return self._config.get('include', None)

    @include.setter
    def include(self, value: str):
        values = value.replace(' ', '').split(',')
        include = []
        for v in values:
            if r := re.match(r'(\d+)\.{2,3}(\d+)', v):
                left, right = int(r.group(1)) - 1, int(r.group(2)) - 1
                assert left <= right, ValueError(
                    rf'{left} is larger than {right} in {v} for {cardlatexprop("include")}')
                include.extend(range(left, right + 1))
            else:
                include.append(int(v) - 1)
        for i in include:
            if i < 0:
                raise ValueError(f'Including page {i + 1} is invalid for {cardlatexprop("include")}')
        self._config['include'] = include

    @property
    @required
    def front(self) -> str:
        return self._config['front']

    @front.setter
    def front(self, value: str):
        self._config['front'] = value

    @property
    def back(self) -> str:
        return self._config.get('back', self.front)

    @back.setter
    def back(self, value: str):
        self._config['back'] = value

cardlatex/config.py
snorthman/cardlatex
import os
from pathlib import Path

from wand.image import Image as WandImage


def is_relative(file: str):
    return not file.startswith('/') or bool(Path(file).drive)


suffixes = '.pdf,.ai,.png,.jpg,.jpeg,.jp2,.jpf,.bmp,.ps,.eps,.mps'.split(',')
DRAFT_TARGET_SIZE = 51200  # in bytes


class Image:
    def __init__(self, tex_dir: Path, cache_dir: Path):
        self._tex_dir = tex_dir
        self._tex_path: Path | None = None
        self._cache_dir = cache_dir
        self._cache_path: Path | None = None

    def _set_source_from_path(self, file: Path):
        self._tex_path = file
        if self._tex_path.is_relative_to(self._tex_dir):
            self._cache_path = self._cache_dir / self._tex_path.relative_to(self._tex_dir)

    def find_source_from_directories(self, file: str, *directories: Path):
        if directories:
            for directory in directories:
                path = directory / file
                if path.suffix:
                    if path.exists():
                        return self._set_source_from_path(path)
                else:
                    for suffix in suffixes:
                        if path.with_suffix(suffix).exists():
                            return self._set_source_from_path(path.with_suffix(suffix))
        raise FileNotFoundError(f'{file} not found in any directory: {directories}')

    def find_source_from_cache(self, file: Path):
        file_info = file.with_suffix('')
        if file_info.exists():
            with open(file_info, 'r') as f:
                self._tex_path = Path(f.read())
            self._cache_path = file.with_suffix(self._tex_path.suffix)
        else:
            os.remove(file)
            raise FileNotFoundError(f'{file_info} cache object not found')

    @property
    def _cache_info(self) -> Path:
        return self._cache_path.with_suffix('')

    def resample(self):
        if self._tex_path is not None and self._tex_path.exists() and self._cache_path is not None:
            self._cache_path.parent.mkdir(parents=True, exist_ok=True)
            if self._cache_info.exists():
                if self._cache_info.stat().st_mtime_ns == self._tex_path.stat().st_mtime_ns:
                    return
            else:
                with open(self._cache_info, 'w', encoding='utf-8') as f:
                    f.write(str(self._tex_path.resolve().as_posix()))

            graphics_stat = self._tex_path.stat()
            with WandImage(filename=self._tex_path.as_posix()) as source:
                with source.convert(self._tex_path.suffix[1:]) as target:
                    st_size = graphics_stat.st_size
                    if st_size > 0 and st_size > DRAFT_TARGET_SIZE:
                        target.transform(resize=f'{round(100 * DRAFT_TARGET_SIZE / st_size)}%')
                    target.save(filename=self._cache_path)

            os.utime(self._cache_info, ns=(graphics_stat.st_atime_ns, graphics_stat.st_mtime_ns))
        elif self._cache_path.exists() and not self._tex_path.exists():
            os.remove(self._cache_path)
            os.remove(self._cache_info)

cardlatex/image.py
snorthman/cardlatex
import math
from decimal import Decimal
from pathlib import Path
from typing import List

from pikepdf import Pdf, Page, Rectangle


def unit_to_cm(unit: Decimal):
    return float(unit) * (1 / 72) * 2.54


def cm_to_unit(cm: float):
    return Decimal((cm / 2.54) * 72)


A4 = Rectangle(0, 0, cm_to_unit(21), cm_to_unit(29.7))
A3 = Rectangle(0, 0, cm_to_unit(29.7), cm_to_unit(42))


def find_crop(paper_sz, box_sz):
    crop = 0
    m = math.ceil(paper_sz / box_sz)
    while paper_sz / (box_sz - float(crop * 2)) < m:
        crop += cm_to_unit(0.1)
    if crop > cm_to_unit(0.5):
        m = m - 1
    return float(crop), m


def combine_pdf(*files: Path) -> Path:
    pdfs = [Pdf.open(file) for file in files]
    pdf_output = Pdf.new()

    for pdf in pdfs:
        for page in pdf.pages:
            pdf_output.pages.append(page)
        pdf.close()

    pdf_output.save(files[0])
    return files[0]


def grid_pdf(file: Path, has_back: bool = False):
    if not file.exists():
        raise FileNotFoundError(f'input pdf not found: {file}')

    pdf = Pdf.open(file)
    pages: List[Page] = list(pdf.pages)

    if len({Rectangle(p.mediabox) for p in pages}) > 1:
        raise NotImplementedError('Cannot handle more than one card size.')

    box = Rectangle(pages[0].mediabox)
    for paper in [A4, A3]:
        x_crop, x_max = find_crop(paper.width, box.width)
        y_crop, y_max = find_crop(paper.height, box.height)
        if x_max == 0 or y_max == 0:
            [p.rotate(90, True) for p in pages]
            x_crop, x_max = find_crop(paper.width, box.height)
            y_crop, y_max = find_crop(paper.height, box.width)
        if x_max > 0 and y_max > 0:
            break

    if x_max == 0 or y_max == 0:
        raise ValueError('does not fit on A4 or A3')

    grid = x_max * y_max
    front, back = [[]], [[]]
    sheets = []
    for pp in range(len(pages)):
        item = back if pp % 2 == 1 and has_back else front
        if len(item[-1]) >= grid:
            sheets.append(item[-1])
            item.append([])
        item[-1].append(pp)
        pp += 1
    sheets.extend([f_b for f_b in [front[-1], back[-1]] if f_b])

    pdf_output = Pdf.new()

    rect_width = box.width - x_crop * 2
    rect_height = box.height - y_crop * 2
    x_offset = rect_width * (paper.width / rect_width - x_max) / 2
    y_offset = rect_height * (paper.height / rect_height - y_max) / 2
    for sheet in sheets:
        x, y = x_offset, y_offset
        new_page = pdf_output.add_blank_page(page_size=(paper.width, paper.height))
        for i, p in enumerate(sheet, 1):
            rect = Rectangle(x, y, x + rect_width, y + rect_height)
            new_page.add_overlay(pages[p], rect, push_stack=True, shrink=False, expand=False)
            x += rect_width
            if i % x_max == 0:
                x = x_offset
                y += rect_height

    pdf.close()
    pdf_output.save(file)

cardlatex/pdf.py
snorthman/cardlatex
import importlib.resources

template = importlib.resources.read_text(__package__, 'template.tex')

cardlatex/template.py
snorthman/cardlatex
import hashlib
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path

import numpy as np
import pandas as pd

from . import tempdir
from .config import Config
from .image import Image, is_relative
from .template import template as template_tex


def sha256(encode: str) -> str:
    obj = hashlib.sha1()
    obj.update(encode.encode('utf-8'))
    return obj.hexdigest()


def prepare_template(template: str, config: Config):
    """
    Apply config options to the static resources/template.tex
    """
    for r in re.finditer(r'<\$(\w+)\$>', template):
        value = getattr(config, r.group(1))
        template = template.replace(r.group(), value)
    return template


def prepare_inputs(tex: str, tex_dir: Path):
    r"""
    Insert recursively any \input{} directives into the document
    """
    while matches := list(re.finditer(r'^(.*)(\\input\{([\w.]+)})', tex, re.M)):
        for r in matches:
            input_tex = r.group(2)
            input_path = (tex_dir / r.group(3)).with_suffix('.tex')
            if '%' in r.group(1) or not input_path.exists():
                tex = tex.replace(input_tex, '')
            else:
                with open(input_path, 'r') as f:
                    tex = tex.replace(input_tex, f.read())
    return tex


class Tex:
    def __init__(self, tex: Path | str):
        self._path = Path(tex)
        self._template = self.template()

        with open(self._path, 'r') as f:
            self._tex = f.read()

        self._config = Config(self._tex)
        self._variables = sorted(
            list(({r.group(1) for r in re.finditer(r'<\$(\w+)\$>', self._config.front + self._config.back)})))
        self._cache_dir = self.get_cache_dir(self._path)
        self._cache_output_pdf = (self.cache_dir / self._path.name).with_suffix('.pdf')
        self._completed = False

    @staticmethod
    def template() -> str:
        return template_tex
        # with resources.open_text(cardlatex.resources, 'template.tex') as f:
        #     return f.read()
        # template_path = pkg_resources.resource_filename('cardlatex.resources', 'template.tex')
        # with open(template_path) as f:
        #     return f.read()

    @staticmethod
    def get_cache_dir(tex: Path | str):
        return tempdir / sha256(Path(tex).resolve().as_posix())

    @property
    def cache_dir(self) -> Path:
        return self._cache_dir

    @property
    def has_back(self) -> bool:
        return 'back' in self._config

    @property
    def output(self) -> Path:
        return self._cache_output_pdf

    @property
    def completed(self) -> bool:
        return self._completed

    def _load_or_generate_xlsx(self):
        if self._variables:
            path_xlsx = self._path.with_suffix('.xlsx')
            if path_xlsx.exists():
                try:
                    data_existing = pd.read_excel(path_xlsx, sheet_name='cardlatex')
                except ValueError as e:
                    raise ValueError(f'{e}, ensure your .xlsx file contains a worksheet named \'cardlatex\'')

                data_columns = pd.Index(
                    [*data_existing.columns] + [c for c in self._variables if c not in data_existing])
                data_existing = data_existing.reindex(columns=data_columns)
            else:
                data_columns = pd.Index([*sorted(self._variables)])
                data_existing = pd.DataFrame().reindex(columns=data_columns)

            if self._config.include:
                rows = len(data_existing)
                rows_expected = max(self._config.include) + 1
                if rows_expected - rows > 0:
                    rows_extra = pd.DataFrame(np.nan, columns=data_existing.columns, index=range(rows, rows_expected))
                    data_existing = pd.concat([data_existing, rows_extra])

            try:
                pd.DataFrame(data_existing).to_excel(path_xlsx, index=False, sheet_name='cardlatex')
            except PermissionError:
                pass

            return data_existing
        else:
            return pd.DataFrame()

    def _prepare_tex(self, data: pd.DataFrame, **kwargs):
        """
        Prepare contents of the cardlatex.tex document
        """
        build_all = kwargs.get('build_all', False)

        template = prepare_template(self._template, self._config)
        tex = prepare_inputs(self._tex, self._path.parent)

        # \begin{tikzcard}[dpi]{width}{height}{
        tikz = '\n\\begin{tikzcard}[' + self._config.dpi + ']{' + self._config.width + '}{' + self._config.height + '}%\n\n'
        edges = [self._config.front]
        if self.has_back:
            edges.append(self._config.back)

        content = []
        toggles = set()
        for row in range(len(data)) if build_all or self._config.include is None else self._config.include:
            for edge in edges:
                edge_toggles = ['']
                for key in self._variables:
                    item = data[key][row]
                    value = '' if pd.isna(item) else item

                    if re.search(r'\\if<\$' + key + r'\$>', edge):
                        # add any unique <$variables$> to 'toggles', in case we use \if<$variable$>
                        toggles.add(key)
                        edge_toggles.append((r'\toggletrue{' if bool(value) else r'\togglefalse{') + key + '}')

                    edge = edge.replace(f'\\if<${key}$>', r'\ifvar{' + key + '}')
                    edge = edge.replace(f'<${key}$>', str(value))

                # any toggles, \begin{tikzcard}...{content}\end{tikzcard}
                content.append('\n'.join(edge_toggles) + tikz + edge + '\n\\end{tikzcard}%\n')

        content = '\n'.join(content)
        toggles = '\n'.join([r'\newtoggle{' + value + '}' for value in toggles])

        graphicpaths = r"""
            \makeatletter
            \typeout{cardlatex@graphicpaths}
            \typeout{\Ginput@path}
            \makeatother
        """

        tex_blocks = [
            (None, template),
            ('user tex input', tex),
            ('newtoggles', toggles),
            ('graphicspaths', graphicpaths),
            ('document', '\\begin{document}\n'),
            (None, content.replace('\n', '\n\t')),
            (None, '\n\\end{document}')
        ]

        tex = ''
        for header, block in tex_blocks:
            if header:
                tex += '\n\n' + '%' * 68 + '\n% ' + header.upper() + '\n\n'
            tex += block

        return tex

    @staticmethod
    def _xelatex(tex_file: Path, tex_log: Path, cache_log: Path):
        cmd = f'xelatex.exe -interaction=nonstopmode "{tex_file.stem}".tex'
        result = subprocess.run(cmd, cwd=tex_file.parent,
                                capture_output=False, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)

        def error():                     # stderr may have a specific and irrelevant error
            if result.returncode == 0 or (result.stderr and len(result.stderr) == 124):
                logging.info(result)
                return
            else:
                logging.error(result)
                shutil.copy(cache_log, tex_log)
                raise subprocess.SubprocessError(f'XeLaTeX compilation error(s), see {tex_log.resolve()}')

        return error

    def build(self, **kwargs) -> 'Tex':
        if self.completed:
            return self

        self.cache_dir.mkdir(exist_ok=True, parents=True)

        data = self._load_or_generate_xlsx()
        logging.info(f'{self._path}: xlsx loaded:\n\n{data.to_string()}\n')
        tex = self._prepare_tex(data, **kwargs)
        logging.info(f'{self._path}: tex content:\n\n{tex}\n')

        path_log = self._path.with_suffix('.log')
        cache_tex = self.cache_dir / self._path.name
        cache_log = cache_tex.with_suffix('.log')

        logging.info(f'{self._path}: resampled missing images')
        if kwargs.get('draft', False):
            with open(cache_tex, 'w') as f:
                f.write(tex)
            logging.info(f'{self._path}: wrote tex contents to {cache_tex}')

            # resample existing images
            for directory, _, filenames in os.walk(self._cache_dir):
                if (root := Path(directory)) != self._cache_dir:
                    for file in filenames:
                        img = Image(self._path.parent, self.cache_dir)
                        try:
                            img.find_source_from_cache(root / file)
                            img.resample()
                        except FileNotFoundError as e:
                            logging.error(f'{self._path}: {e}')
            logging.info(f'{self._path}: resampled existing images')

            xelatex_error_func = self._xelatex(cache_tex, path_log, cache_log)
            with open(cache_log, 'r') as f:
                log = f.read()
            logging.info(f'{self._path}: reading log contents at {cache_log}')

            # gather \graphicspath items from log
            graphicspaths = [self._path.parent]
            tex_graphicspaths = re.search(r'cardlatex@graphicpaths\n(.+?)\n', log).group(1)
            for path in tex_graphicspaths[1:-1].split('}{'):
                if is_relative(path):
                    graphicspaths.append(graphicspaths[0] / path)

            # gather missing images from log
            not_found = []
            for pattern in [r'! LaTeX Error: File `(.+)\' not found', r'LaTeX Warning: File `(.+)\' not found']:
                not_found.extend(r.group(1) for r in re.finditer(pattern, log))

            # resample missing images
            if not_found:
                for file in not_found:
                    img = Image(self._path.parent, self.cache_dir)
                    img.find_source_from_directories(file, *graphicspaths)
                    img.resample()
                logging.info(f'{self._path}: resampled missing images')

                xelatex_error_func = self._xelatex(cache_tex, path_log, cache_log)

            xelatex_error_func()
        else:
            with open(path_tex := self._path.with_suffix('.cardlatex.tex'), 'w') as f:
                f.write(tex)
            xelatex_error_func = self._xelatex(path_tex, path_log, cache_log)

            # delete, copy or move output to cache_dir to prepare for self.release()
            for suffix, action, args in [('.synctex.gz', os.remove, ()),
                                         ('.aux', os.remove, ()),
                                         ('.log', shutil.move, (cache_log,)),
                                         ('.pdf', shutil.move, (self._cache_output_pdf,)),
                                         ('.tex', shutil.copy, (cache_tex,))]:
                path = path_tex.with_suffix(suffix)
                if path.exists():
                    action(*(path, *args))
            logging.info(f'{self._path}: moved output files to cache at {self._cache_dir}')

            xelatex_error_func()

        self._completed = True
        return self

    def release(self):
        if self.completed:
            output = self.cache_dir / self._path.name
            log, pdf = output.with_suffix('.log'), output.with_suffix('.pdf')

            shutil.copy(output.with_suffix('.tex'), path_tex := self._path.with_suffix('.cardlatex.tex'))
            logging.info(f'{self._path}: copied tex to {path_tex}')
            if log.exists():
                shutil.copy(output.with_suffix('.log'), path_log := self._path.with_suffix('.log'))
                logging.info(f'{self._path}: copied tex to {path_log}')
            if pdf.exists():
                shutil.move(output.with_suffix('.pdf'), path_pdf := self._path.with_suffix('.pdf'))
                logging.info(f'{self._path}: copied tex to {path_pdf}')

            logging.info(f'{self._path}: released')

cardlatex/tex.py
snorthman/cardlatex
from setuptools import setup

version = '0.4.2'

if __name__ == '__main__':
    setup(
        name='cardlatex',
        version=version,
        packages=['cardlatex'],
        package_data={"cardlatex": ["*.tex"]},
        url=r'https://github.com/snorthman/cardlatex',
        license='MIT License',
        author='Stan Noordman',
        author_email='snorthman1@gmail.com',
        description='cardlatex is a XeLaTeX wrapper which compiles TeX from specific templated .tex and .xlsx files.',
        python_requires='>=3.10, <4',
        install_requires=[
            'click',
            'openpyxl',
            'pandas',
            'wand',
            'pikepdf'
        ],
        extras_require={
            'dev': [
                'pytest',
                'coverage',
                'flake8'
            ]
        },
        entry_points={
            'console_scripts': [
                'cardlatex = cardlatex.__main__:build',
            ],
        }
    )

setup.py
snorthman/cardlatex
import os
import shutil
import traceback
from itertools import combinations, chain
from pathlib import Path
from typing import Callable

import pytest
from click import BaseCommand
from click.testing import CliRunner
from pikepdf import Pdf

from cardlatex.__main__ import build
from cardlatex.tex import Tex

args_build_params = [['all'], ['combine'], ['print'], ['draft']]


@pytest.fixture(params=chain(*[combinations(args_build_params, n) for n in range(len(args_build_params) + 1)]))
def kwargs_build(request):
    return {args[0]: args[1] if len(args) == 2 else None for args in request.param}


@pytest.fixture(params=[
    (['default'], 'default'),
    (['back'], 'default'),
    (['back', 'combine'], 'default')
])
def args_build(request) -> tuple[list[str], str]:
    return request.param


@pytest.fixture(params=[
    (['missing'], 'default', ValueError),
    (['default'], 'invalid', ValueError)
])
def args_build_fail(request) -> tuple[list[str], str, Exception]:
    return request.param


def prepare(xlsx_name: str, *tex_files) -> list[str]:
    root = Path('./tests/')
    root_input = root / 'input'
    root_output = root / 'output'

    if root_output.exists():
        shutil.rmtree(root_output)
    root_output.mkdir()

    shutil.copytree(root_input / 'art', root_output / 'art', copy_function=shutil.copy)
    shutil.copy(root_input / 'copyme.tex', root_output / 'copyme.tex')
    shutil.copy(root_input / 'copymenot.tex', root_output / 'copymenot.tex')

    tex_paths = []
    for i, tex_file in enumerate(tex_files):
        if xlsx_name:
            shutil.copy(root_input / f'{xlsx_name}.xlsx', root_output / f'test_{i}.xlsx')
        shutil.copy(root_input / f'{tex_file}.tex', tex := root_output / f'test_{i}.tex')
        tex_paths.append(tex.as_posix())
        cache_dir = Tex.get_cache_dir(tex)
        if cache_dir.exists():
            shutil.rmtree(cache_dir)

    return tex_paths


def run(func: Callable | BaseCommand, expected_exception: Exception | None, *args, **kwargs):
    arguments = list(args)
    for key, value in kwargs.items():
        arguments.append(f'--{key}')
        if value:
            arguments.append(value)

    if len(args) < 2 and 'combine' in kwargs:
        pytest.skip()

    runner = CliRunner()
    result = runner.invoke(func, arguments + ['--debug'])
    if result.exit_code != 0:
        exception = result.exc_info[0]
        if exception != expected_exception:
            raise result.exception
    else:
        assert expected_exception is None


def test_build(args_build: tuple[str, str], kwargs_build: dict):
    tex_files, xlsx_name = args_build
    run(build, None, *prepare(xlsx_name, *tex_files), **kwargs_build)
    output_dir = Path('./tests/output')
    outputs = [output_dir / 'test_0.pdf']

    expected = -1
    if len(tex_files) == 1:
        if 'default' in tex_files:
            expected = 3
            if 'all' in kwargs_build:
                expected = 4
            if 'print' in kwargs_build:
                expected = 1
        elif 'back' in tex_files:
            expected = 8
            if 'print' in kwargs_build:
                expected = 2
    elif len(tex_files):
        outputs.append(output_dir / 'test_1.pdf')
        expected = 12
        if 'combine' in kwargs_build:
            outputs.pop()
        if 'print' in kwargs_build:
            expected = 4 if 'mirror' in kwargs_build else 3

    actual = 0
    for output in outputs:
        with Pdf.open(output) as pdf:
            actual += len(pdf.pages)
    assert actual == expected


def test_build_expected_exception(args_build_fail: tuple[str, str, Exception]):
    tex_files, xlsx_name, expected_exception = args_build_fail
    run(build, expected_exception, *prepare(xlsx_name, *tex_files))


def test_cache(args_build: tuple[str, str]):
    tex_files, xlsx_name = args_build

    run(build, None, *(tex_files_prepared := prepare(xlsx_name, *tex_files)))

    stats = {}
    for cache in [Tex.get_cache_dir(tex_file) / 'art' for tex_file in tex_files_prepared]:
        for directory, _, filenames in os.walk(cache):
            for fn in filenames:
                file = Path(directory) / fn
                if file.suffix:
                    stats[file] = file.stat().st_mtime_ns

    run(build, None, *tex_files_prepared)

    for cache in [Tex.get_cache_dir(tex_file) / 'art' for tex_file in tex_files_prepared]:
        for directory, _, filenames in os.walk(cache):
            for fn in filenames:
                file = Path(directory) / fn
                if file.suffix:
                    assert file.stat().st_mtime_ns == stats[file]


def test_build_specific():
    run(build, None, *prepare('default', *['back']), **{})


temp = Path('./tests/input/temp')


@pytest.mark.skipif(temp.exists() and len(list(temp.iterdir())) == 0, reason='No temp files found')
def test_build_temp():
    run(build, None, './tests/input/temp/hero_card.tex', '--draft')

tests/test_build.py
snorthman/cardlatex
from cardlatex import version as cardlatex_version
from setup import version as setup_version


def test_version():
    assert setup_version == cardlatex_version
    
tests/test_version.py
snorthman/cardlatex