Source code for hbat.utilities.graphviz_utils

"""
GraphViz detection and utility functions for HBAT.

This module provides functionality to detect GraphViz installation,
check available engines, and validate GraphViz executables on the system.
"""

import logging
import os
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Dict, List, Optional, Tuple

# Set up logging
logger = logging.getLogger(__name__)

# GraphViz executable names to check
GRAPHVIZ_EXECUTABLES = [
    "dot",
    "neato",
    "fdp",
    "sfdp",
    "circo",
    "twopi",
    "osage",
    "patchwork",
]

# Common GraphViz installation paths by platform
PLATFORM_PATHS: Dict[str, List[str]] = {
    "win32": [
        r"C:\Program Files\Graphviz\bin",
        r"C:\Program Files (x86)\Graphviz\bin",
        r"C:\Graphviz\bin",
    ],
    "darwin": [
        "/usr/local/bin",
        "/opt/local/bin",
        "/opt/homebrew/bin",
        "/usr/bin",
    ],
    "linux": [
        "/usr/bin",
        "/usr/local/bin",
        "/opt/bin",
    ],
}


[docs] class GraphVizDetector: """Detects and validates GraphViz installation. This class provides static methods to check for GraphViz availability, version information, and available layout engines. """ # Cache for detection results (cleared between sessions) _detection_cache: Optional[bool] = None _version_cache: Optional[str] = None _engines_cache: Optional[List[str]] = None
[docs] @staticmethod @lru_cache(maxsize=1) def is_graphviz_available() -> bool: """Check if GraphViz executables are in PATH. :returns: True if GraphViz is available, False otherwise :rtype: bool """ if GraphVizDetector._detection_cache is not None: return GraphVizDetector._detection_cache logger.debug("Detecting GraphViz installation...") # First try to find 'dot' in PATH result = GraphVizDetector._check_executable_in_path("dot") # If not found in PATH, check platform-specific locations if not result and sys.platform in PLATFORM_PATHS: for path in PLATFORM_PATHS[sys.platform]: if GraphVizDetector._check_executable_in_directory("dot", path): result = True break GraphVizDetector._detection_cache = result logger.info( f"GraphViz detection result: {'Available' if result else 'Not Available'}" ) return result
[docs] @staticmethod def get_graphviz_version() -> Optional[str]: """Get installed GraphViz version. :returns: Version string if available, None otherwise :rtype: Optional[str] """ if GraphVizDetector._version_cache is not None: return GraphVizDetector._version_cache if not GraphVizDetector.is_graphviz_available(): return None try: # Run 'dot -V' to get version information result = subprocess.run( ["dot", "-V"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=5, ) if result.returncode == 0: # GraphViz outputs version to stderr output = ( result.stdout.strip() if result.stdout else result.stderr.strip() ) # Extract version from output like "dot - graphviz version 2.40.1" if "version" in output.lower(): version_parts = output.split() for i, part in enumerate(version_parts): if part.lower() == "version" and i + 1 < len(version_parts): GraphVizDetector._version_cache = version_parts[i + 1] return GraphVizDetector._version_cache except (subprocess.SubprocessError, OSError) as e: logger.warning(f"Failed to get GraphViz version: {e}") return None
[docs] @staticmethod def get_available_engines() -> List[str]: """Get list of available GraphViz layout engines. :returns: List of available engine names :rtype: List[str] """ if GraphVizDetector._engines_cache is not None: return GraphVizDetector._engines_cache if not GraphVizDetector.is_graphviz_available(): return [] available_engines = [] for engine in GRAPHVIZ_EXECUTABLES: if GraphVizDetector._check_executable_in_path(engine): available_engines.append(engine) else: # Check platform-specific paths if sys.platform in PLATFORM_PATHS: for path in PLATFORM_PATHS[sys.platform]: if GraphVizDetector._check_executable_in_directory( engine, path ): available_engines.append(engine) break GraphVizDetector._engines_cache = available_engines logger.debug(f"Available GraphViz engines: {available_engines}") return available_engines
[docs] @staticmethod def validate_engine(engine: str) -> bool: """Validate if a specific engine is available. :param engine: Engine name to validate (e.g., 'dot', 'neato') :type engine: str :returns: True if engine is available, False otherwise :rtype: bool """ return engine in GraphVizDetector.get_available_engines()
[docs] @staticmethod def get_engine_path(engine: str) -> Optional[str]: """Get the full path to a GraphViz engine executable. :param engine: Engine name (e.g., 'dot', 'neato') :type engine: str :returns: Full path to executable if found, None otherwise :rtype: Optional[str] """ # Try to find in PATH first path = GraphVizDetector._which(engine) if path: return path # Check platform-specific locations if sys.platform in PLATFORM_PATHS: for directory in PLATFORM_PATHS[sys.platform]: full_path = Path(directory) / engine if sys.platform == "win32": full_path = full_path.with_suffix(".exe") if full_path.exists() and full_path.is_file(): return str(full_path) return None
[docs] @staticmethod def clear_cache() -> None: """Clear all cached detection results. Useful for re-detecting after GraphViz installation/removal. """ GraphVizDetector._detection_cache = None GraphVizDetector._version_cache = None GraphVizDetector._engines_cache = None GraphVizDetector.is_graphviz_available.cache_clear() logger.debug("GraphViz detection cache cleared")
# Private helper methods @staticmethod def _check_executable_in_path(executable: str) -> bool: """Check if an executable exists in PATH. :param executable: Name of the executable :type executable: str :returns: True if found in PATH :rtype: bool """ return GraphVizDetector._which(executable) is not None @staticmethod def _check_executable_in_directory(executable: str, directory: str) -> bool: """Check if an executable exists in a specific directory. :param executable: Name of the executable :type executable: str :param directory: Directory path to check :type directory: str :returns: True if found in directory :rtype: bool """ path = Path(directory) / executable if sys.platform == "win32": path = path.with_suffix(".exe") return path.exists() and path.is_file() @staticmethod def _which(executable: str) -> Optional[str]: """Find executable in PATH (cross-platform which command). :param executable: Name of the executable :type executable: str :returns: Full path if found, None otherwise :rtype: Optional[str] """ # Python 3.3+ has shutil.which, but we implement our own for compatibility if sys.platform == "win32": executable = f"{executable}.exe" for path in os.environ.get("PATH", "").split(os.pathsep): full_path = Path(path) / executable if full_path.exists() and full_path.is_file(): return str(full_path) return None
[docs] def get_graphviz_info() -> Dict[str, any]: """Get comprehensive GraphViz installation information. :returns: Dictionary with GraphViz info :rtype: Dict[str, any] """ return { "available": GraphVizDetector.is_graphviz_available(), "version": GraphVizDetector.get_graphviz_version(), "engines": GraphVizDetector.get_available_engines(), "platform": sys.platform, }