#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Copyright (C) 1998-2026 Stephane Galland # # This program is free library; you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation; either version 3 of the # License, or any later version. # # This library is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; see the file COPYING. If not, # write to the Free Software Foundation, Inc., 59 Temple Place - Suite # 330, Boston, MA 02111-1307, USA. import logging from argparse import Namespace from collections import deque from dataclasses import field from dataclasses import dataclass from enum import IntEnum, unique from typing import override from sortedcontainers import SortedSet from autolatex2.cli.abstract_actions import AbstractMakerAction from autolatex2.make.filedescription import FileDescription from autolatex2.utils.extprint import eprint import autolatex2.utils.utilfunctions as genutils from autolatex2.utils.i18n import T @unique class _DependencyStatus(IntEnum): """ Status of a node during DFS traversal """ UNVISITED = 0 VISITING = 1 VISITED = 2 @dataclass class _TreeNode: """ Node in the dependency tree. """ filename: str timestamp : float | None dependencies : list['_TreeNode'] = field(default_factory=list) depth: int = 0 is_cycle: bool = False info: str | None = None class _DependencyTreeBuilder: """ Builds a dependency tree from a dictionary of file descriptions """ def __init__(self, dependencies : dict[str, FileDescription]): """ Initialize the dependency tree builder. :param dependencies: the list of all known file descriptions. :type dependencies: dict[str, FileDescription] """ self._dependencies = dependencies self._status : dict[str,_DependencyStatus] = {} self._tree_nodes : dict[str,_TreeNode] = {} self._cycles: list[list[str]] = [] def build_tree(self, root_filename: str) -> _TreeNode | None: """ Build a dependency tree starting from root_filename. :param root_filename: The root file to build the tree from. :type root_filename: str :return: The root of the dependency tree, or None if root not found. :rtype: Optional[_TreeNode] """ if root_filename not in self._dependencies: raise ValueError(T("Root file '%s' not found in computed dependencies") % root_filename) # Reset state self._status = {name: _DependencyStatus.UNVISITED for name in self._dependencies} self._tree_nodes = {} self._cycles = [] # Build tree with cycle detection root_node = self._build_node(root_filename, depth=0, path=[root_filename]) return root_node def _build_node(self, filename: str, depth: int, path: list[str]) -> _TreeNode: """ Recursively build a tree node with cycle detection. :param filename: Current filename to process. :type filename: str :param depth: Current depth in the tree. :type depth: int :param path: Current path for cycle detection. :type path: list[str] :return: Node representing this file :rtype: _TreeNode """ # Check for cycles if self._status.get(filename) == _DependencyStatus.VISITING: # Cycle detected cycle_start = path.index(filename) cycle = path[cycle_start:] + [filename] self._cycles.append(cycle) # Create a node marking the cycle node = _TreeNode( filename=filename, timestamp=genutils.get_file_last_change(filename), dependencies=[], depth=depth, is_cycle=True, #info=T("Cycle detected: %s") % {' -> '.join(cycle)} ) self._tree_nodes[filename] = node return node # If already processed and not a cycle, return cached node if filename in self._tree_nodes: return self._tree_nodes[filename] # Mark as visiting self._status[filename] = _DependencyStatus.VISITING # Get file description file_desc = self._dependencies[filename] # Create node node = _TreeNode( filename=filename, timestamp=genutils.get_file_last_change(filename), dependencies=[], depth=depth ) # Cache node before processing dependencies (handles self-references) self._tree_nodes[filename] = node # Process dependencies seen_deps: set[str] = set() # Track to avoid duplicates at same level for dep in file_desc.dependencies: if dep not in self._dependencies: # Dependency not found - create a placeholder node missing_node = _TreeNode( filename=dep, timestamp=genutils.get_file_last_change(dep), dependencies=[], depth=depth + 1, is_cycle=False, info=T('not defined in file descriptions') ) node.dependencies.append(missing_node) continue # Skip if already processed at this level (duplicate) if dep in seen_deps: continue seen_deps.add(dep) # Recursively build dependency node dep_node = self._build_node(dep, depth + 1, path + [filename]) node.dependencies.append(dep_node) # Mark as visited self._status[filename] = _DependencyStatus.VISITED return node def get_cycles(self) -> list[list[str]]: """ Return all detected cycles in the dependency graph. """ return self._cycles def has_cycles(self) -> bool: """ Check if any cycles were detected. """ return len(self._cycles) > 0 class MakerAction(AbstractMakerAction): id : str = 'showdependencies' alias : list[str] = [ 'dependencies', 'dependency', 'deps' ] help : str = T('Show the dependency relationships of files from the main LaTeX document.') @override def _add_command_cli_arguments(self, command_name : str, command_help : str | None, command_aliases : list[str] | None): """ Callback for creating the CLI arguments (positional and optional). :param command_name: The name of the command. :type command_name: str :param command_help: The help text for the command. :type command_help: str | None """ super()._add_command_cli_arguments(command_name, command_help, command_aliases) self.parse_cli.add_argument('--noauxfile', action='store_true', help=T('Don\'t read the auxiliary files for building the dependency tree')) self.parse_cli.add_argument('--times', action='store_true', help=T('Show the change times for each file')) self.parse_cli.add_argument('--list', action='store_true', help=T('Show the dependency as a list')) @override def run(self, cli_arguments : Namespace) -> bool: """ Callback for running the command. :param cli_arguments: the successfully parsed CLI arguments. :type cli_arguments: Namespace :return: True if the process could continue. False if an error occurred and the process should stop. :rtype: bool """ try: maker = self._internal_create_maker() for root_file in maker.root_files: dependencies = maker.compute_dependencies(root_file, not cli_arguments.noauxfile) if cli_arguments.list: deps, problems = self._build_dependency_set(dependencies) self._show_dependency_set(deps, problems) else: deps = self._build_dependency_tree(dependencies) self._show_dependency_tree(deps, cli_arguments.times) except BaseException as ex: logging.error(str(ex)) return False return True # noinspection PyMethodMayBeStatic def _build_dependency_set(self, dependencies : tuple[str, dict[str, FileDescription]]) -> tuple[SortedSet,dict[str,str]]: """ Build the set of all the files that are required for building the document. :param dependencies: the detailed description of the dependency relationships per file. :type dependencies: tuple[str, dict[str, FileDescription]] :return: the set of filenames of the dependencies. :rtype: tuple[str,dict[str,FileDescription]] """ problems : dict[str,str] = dict() deps = SortedSet() queue = deque() queue.append(dependencies[0]) while queue: current_file = queue.popleft() if current_file in dependencies[1]: if current_file not in deps: description = dependencies[1][current_file] deps.add(description.output_filename) for d in description.dependencies: queue.append(d) else: problems[current_file] = T('not defined in file descriptions') return deps, problems # noinspection PyMethodMayBeStatic def _build_dependency_tree(self, dependencies : tuple[str, dict[str, FileDescription]]) -> _TreeNode | None: """ Build the dependency tree of all the files that are required for building the document. :param dependencies: the detailed description of the dependency relationships per file. The first element of the tuple is the root file in the dependency tree. :type dependencies: tuple[str, dict[str, FileDescription]] :return: the root of the dependency tree :rtype: Optional[_TreeNode] """ builder = _DependencyTreeBuilder(dependencies[1]) return builder.build_tree(dependencies[0]) # noinspection PyMethodMayBeStatic def _show_dependency_set(self, dependencies : SortedSet, problems : dict[str,str]): """ Show the set of all the files that are required for building the document. :param dependencies: the set of filenames of the dependencies. :type dependencies: set[str] :param problems: the set of problems that were found for the dependencies. :type problems: dict[str,str] """ for dep in dependencies: error_marker = f" ❌ {problems[dep]}" if dep in problems and problems[dep] else "" eprint(f"{dep}{error_marker}") # noinspection PyMethodMayBeStatic def _show_dependency_tree(self, node : _TreeNode|None, show_timestamps : bool, indent: str = "", is_last: bool = True): """ Show the provided dependency tree. :param node: the node to show. :type node: _TreeNode | None :param indent: Current indentation string. :type indent: str :param is_last: Whether this is the last child. :type is_last: bool """ if node is not None: # Print current node marker = "└── " if is_last else "├── " cycle_marker = " 🔄" if node.is_cycle else "" error_marker = f" ❌ {node.info}" if node.info is not None else "" timestamp = f" ({node.timestamp})" if show_timestamps else "" print(f"{indent}{marker}{node.filename}{timestamp}{cycle_marker}{error_marker}") # Process children if node.dependencies: child_indent = indent + (" " if is_last else "│ ") for i, child in enumerate(node.dependencies): is_last_child = (i == len(node.dependencies) - 1) self._show_dependency_tree(child, show_timestamps, child_indent, is_last_child)