Skip to content

jettex.packages

Automatic package detection and installation.

Module Overview

This module provides the core auto-install functionality that makes jettex similar to R's tinytex.

from jettex import (
    compile_tex,
    compile_with_auto_install,
    parse_log_file,
    find_packages_for_files,
    install_missing_packages,
    PackageInfo
)

Classes

PackageInfo(filename, package, error_message) dataclass

Information about a missing package.

Attributes

filename instance-attribute

The missing file (e.g., 'geometry.sty').

package instance-attribute

The package that provides this file, if found.

error_message instance-attribute

The original error message.

Functions

Functions

compile_with_auto_install(input_file, engine='pdflatex', max_attempts=3, output_dir=None, quiet=False, **kwargs)

Compile a LaTeX file with automatic package installation.

This is the main function that provides R-tinytex parity. It: 1. Attempts to compile the document 2. If compilation fails, parses the log for missing packages 3. Installs missing packages 4. Retries compilation

Parameters:

Name Type Description Default
input_file Union[str, Path]

Path to .tex file

required
engine str

LaTeX engine ('pdflatex', 'xelatex', or 'lualatex')

'pdflatex'
max_attempts int

Maximum compilation attempts

3
output_dir Optional[Union[str, Path]]

Output directory for generated files

None
quiet bool

Suppress progress output

False
**kwargs

Additional arguments passed to the compiler

{}

Returns:

Name Type Description
CompileResult CompileResult

Final compilation result

Example

result = compile_with_auto_install("document.tex") if result.success: ... print(f"PDF: {result.output_file}") ... else: ... print("Compilation failed")

Source code in jettex/packages.py
def compile_with_auto_install(
    input_file: Union[str, Path],
    engine: str = "pdflatex",
    max_attempts: int = 3,
    output_dir: Optional[Union[str, Path]] = None,
    quiet: bool = False,
    **kwargs,
) -> CompileResult:
    """Compile a LaTeX file with automatic package installation.

    This is the main function that provides R-tinytex parity. It:
    1. Attempts to compile the document
    2. If compilation fails, parses the log for missing packages
    3. Installs missing packages
    4. Retries compilation

    Args:
        input_file: Path to .tex file
        engine: LaTeX engine ('pdflatex', 'xelatex', or 'lualatex')
        max_attempts: Maximum compilation attempts
        output_dir: Output directory for generated files
        quiet: Suppress progress output
        **kwargs: Additional arguments passed to the compiler

    Returns:
        CompileResult: Final compilation result

    Example:
        >>> result = compile_with_auto_install("document.tex")
        >>> if result.success:
        ...     print(f"PDF: {result.output_file}")
        ... else:
        ...     print("Compilation failed")
    """
    input_path = Path(input_file).resolve()

    # Select engine function
    engines = {
        "pdflatex": pdflatex,
        "xelatex": xelatex,
        "lualatex": lualatex,
        "latexmk": latexmk,
    }

    if engine not in engines:
        raise ValueError(f"Unknown engine: {engine}. Use: {list(engines.keys())}")

    compile_func = engines[engine]
    installed_packages: List[str] = []

    # Attempt pre-installation of packages from source
    # This helps avoid multiple compilation cycles for simple dependencies
    try:
        pre_installed = install_packages_from_source(input_path, quiet=quiet)
        if pre_installed:
            installed_packages.extend(pre_installed)
    except Exception:
        # Ignore pre-install errors and fall back to reactive mode
        pass

    for attempt in range(max_attempts):
        # Ignore pre-install errors and fall back to reactive mode
        pass

    # Main compilation loop with auto-installation
    # We allow more iterations for package installation cycles than pure compilation retries.
    MAX_INSTALL_CYCLES = 30
    compilation_retries = 0
    final_result = None

    for iteration in range(MAX_INSTALL_CYCLES):
        # We only count it as a "retry" if we didn't install anything last time,
        # but for simplicity, let's just use the max_attempts argument to control
        # how many times we try to compile *without* fixing anything.

        # However, purely relying on max_attempts (default 3) is too low if we have 10 packages to install one-by-one.
        # So we use a hybrid approach:
        # - Continue if we installed packages (doesn't consume 'max_attempts' quota effectively)
        # - Stop if we fail max_attempts times consecutively without any installations?
        # Simpler: Just run compilation. 

        if not quiet and not is_quiet():
            logger = get_logger()
            if iteration == 0:
                logger.info(f"Compilation attempt 1/{max_attempts}...")
            else:
                logger.info(f"Compilation attempt {iteration + 1}...")

        result = compile_func(
            input_path,
            output_dir=output_dir,
            quiet=quiet,
            **kwargs,
        )
        final_result = result

        if result.success:
            if installed_packages and not quiet and not is_quiet():
                logger = get_logger()
                logger.info(f"Installed packages: {', '.join(installed_packages)}")
            return result

        # Parse log for missing files
        if result.log_file and result.log_file.exists():
            missing_files = parse_log_file(result.log_file)
        else:
            # Try to extract from stdout/stderr
            missing_files = parse_log_for_missing_files(
                result.stdout + "\n" + result.stderr
            )

        if not missing_files:
            # No identifiable missing packages, can't auto-install.
            # Counting this as a failed attempt.
            compilation_retries += 1
            if compilation_retries >= max_attempts:
                if not quiet and not is_quiet():
                    logger = get_logger()
                    logger.warning("Compilation failed but no missing packages detected.")
                return result
            # Retry blindly? No, usually if no missing files, it's a syntax error.
            # But let's check max_attempts anyway for consistency with original behavior.
            continue 

        if not quiet and not is_quiet():
            logger = get_logger()
            logger.info(f"Missing files detected: {', '.join(missing_files)}")

        # Find packages for missing files
        package_info = find_packages_for_files(missing_files)

        # Filter to packages we found
        installable = [p for p in package_info if p.package]

        if not installable:
            if not quiet and not is_quiet():
                logger = get_logger()
                logger.warning("Could not find packages for missing files.")
            return result

        if not quiet and not is_quiet():
            logger = get_logger()
            packages = [p.package for p in installable]
            logger.info(f"Installing packages: {', '.join(packages)}")

        # Install packages
        newly_installed = install_missing_packages(installable)
        installed_packages.extend(newly_installed)

        if not newly_installed:
            if not quiet and not is_quiet():
                logger = get_logger()
                logger.error("Failed to install packages.")
            # If we couldn't install anything, we should count this as a retry or stop.
            compilation_retries += 1
            if compilation_retries >= max_attempts:
                return result
        else:
            # We successfully installed something!
            # Reset compilation_retries because we changed the environment.
            # This ensures "3 attempts" applies to the final phase where no more packages are missing.
            compilation_retries = 0

    # If we get here, we've exhausted everything
    return final_result

parse_log_file(log_path)

Parse a LaTeX log file for missing files.

Parameters:

Name Type Description Default
log_path Union[str, Path]

Path to .log file

required

Returns:

Type Description
List[str]

List of missing filenames

Source code in jettex/packages.py
def parse_log_file(log_path: Union[str, Path]) -> List[str]:
    """Parse a LaTeX log file for missing files.

    Args:
        log_path: Path to .log file

    Returns:
        List of missing filenames
    """
    log_path = Path(log_path)
    if not log_path.exists():
        return []

    try:
        content = log_path.read_text(encoding="utf-8", errors="replace")
        return parse_log_for_missing_files(content)
    except Exception:
        return []

parse_log_for_missing_files(log_content)

Parse LaTeX log content for missing files.

Parameters:

Name Type Description Default
log_content str

Content of the .log file

required

Returns:

Type Description
List[str]

List of missing filenames

Source code in jettex/packages.py
def parse_log_for_missing_files(log_content: str) -> List[str]:
    """Parse LaTeX log content for missing files.

    Args:
        log_content: Content of the .log file

    Returns:
        List of missing filenames
    """
    missing_files: Set[str] = set()

    for pattern in MISSING_FILE_PATTERNS:
        matches = re.findall(pattern, log_content, re.MULTILINE | re.IGNORECASE)
        for match in matches:
            if match:
                # Clean up the filename
                filename = match.strip()
                if filename:
                    missing_files.add(filename)

    return list(missing_files)

find_packages_for_files(filenames)

Find packages that provide the given files.

Parameters:

Name Type Description Default
filenames List[str]

List of missing filenames

required

Returns:

Type Description
List[PackageInfo]

List of PackageInfo with package information

Source code in jettex/packages.py
def find_packages_for_files(filenames: List[str]) -> List[PackageInfo]:
    """Find packages that provide the given files.

    Args:
        filenames: List of missing filenames

    Returns:
        List of PackageInfo with package information
    """
    results = []

    for filename in filenames:
        package = find_package_for_file(filename)
        results.append(
            PackageInfo(
                filename=filename,
                package=package,
                error_message=f"File '{filename}' not found",
            )
        )

    return results

install_missing_packages(missing)

Install packages for missing files.

Parameters:

Name Type Description Default
missing List[PackageInfo]

List of PackageInfo from find_packages_for_files

required

Returns:

Type Description
List[str]

List of successfully installed package names

Source code in jettex/packages.py
def install_missing_packages(missing: List[PackageInfo]) -> List[str]:
    """Install packages for missing files.

    Args:
        missing: List of PackageInfo from find_packages_for_files

    Returns:
        List of successfully installed package names
    """
    # Collect unique packages to install
    packages_to_install = set()
    for info in missing:
        if info.package:
            packages_to_install.add(info.package)

    if not packages_to_install:
        return []

    # Install all packages at once
    packages_list = list(packages_to_install)
    success = tlmgr_install(packages_list)

    if success:
        return packages_list
    return []

Examples

Basic Auto-Install

from jettex import compile_tex

# Compile with automatic package installation
result = compile_tex("document.tex")

if result.success:
    print(f"PDF: {result.output_file}")

Custom Engine

from jettex import compile_tex

# Use xelatex with more attempts
result = compile_tex(
    "document.tex",
    engine="xelatex",
    max_attempts=5,
    quiet=False
)

Manual Package Detection

from jettex import parse_log_file, find_packages_for_files

# Parse log file for missing files
missing_files = parse_log_file("document.log")
print(f"Missing: {missing_files}")

# Find packages
package_info = find_packages_for_files(missing_files)
for info in package_info:
    print(f"{info.filename} -> {info.package}")

Custom Installation Logic

from jettex import (
    pdflatex,
    parse_log_file,
    find_packages_for_files,
    install_missing_packages
)

# Compile
result = pdflatex("document.tex")

if not result.success and result.log_file:
    # Find missing files
    missing = parse_log_file(result.log_file)

    # Get package info
    packages = find_packages_for_files(missing)

    # Filter and install
    to_install = [p for p in packages if p.package]
    installed = install_missing_packages(to_install)

    print(f"Installed: {installed}")

    # Retry
    result = pdflatex("document.tex")

Error Patterns

The module detects these error patterns:

Pattern Example
Missing .sty ! LaTeX Error: File 'geometry.sty' not found.
Missing .cls ! LaTeX Error: File 'beamer.cls' not found.
Missing font ! Font TU/lmr/m/n/10=... not loadable
Missing file ! I can't find file 'missing.tex'.
fontspec error ! Package fontspec Error: The font "X" cannot be found.