"""
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,
}