Skip to content

Commit

Permalink
hooks: rewrite pygraphviz hook
Browse files Browse the repository at this point in the history
Rewrite `pygraphviz` hook to fix discovery and collection of `graphviz`
files under various Linux distributions, in Anaconda environments
(Windows, Linux, and macOS), and msys2 environments (Windows).

We now discover active `graphviz` installation on all OSes by looking
for `dot` program in `PATH`. To find the location of shared libraries
and the plugin directory on non-Windows, we now perform binary
dependency analysis on the `dot` executable and look for known
`graphviz` shared libraries in the results.

This improves the discovery on various platforms, and also fixes the
issue where system-installed (on Linux) or Homebrew-installed
(on macOS) `graphviz` shared libraries and plugins would end up being
collected  instead of Anaconda-packaged ones when using Anaconda
environment with Anaconda-packaged `graphviz`.
  • Loading branch information
rokm committed Jan 6, 2025
1 parent f6fac2a commit 5d40b8a
Show file tree
Hide file tree
Showing 2 changed files with 133 additions and 51 deletions.
181 changes: 130 additions & 51 deletions _pyinstaller_hooks_contrib/stdhooks/hook-pygraphviz.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,137 @@
#
# SPDX-License-Identifier: GPL-2.0-or-later
# ------------------------------------------------------------------

import glob
import os
import pathlib
import shutil

from PyInstaller.compat import is_win, is_darwin
from PyInstaller.depend.bindepend import findLibrary

binaries = []
datas = []

# List of binaries agraph.py may invoke.
progs = [
"neato",
"dot",
"twopi",
"circo",
"fdp",
"nop",
"acyclic",
"gvpr",
"gvcolor",
"ccomps",
"sccmap",
"tred",
"sfdp",
"unflatten",
]

if is_win:
for prog in progs:
for binary in glob.glob("c:/Program Files/Graphviz*/bin/" + prog + ".exe"):
binaries.append((binary, "."))
for binary in glob.glob("c:/Program Files/Graphviz*/bin/*.dll"):
binaries.append((binary, "."))
for data in glob.glob("c:/Program Files/Graphviz*/bin/config*"):
datas.append((data, "."))
else:
# The dot binary in PATH is typically a symlink, handle that.
# graphviz_bindir is e.g. /usr/local/Cellar/graphviz/2.46.0/bin
graphviz_bindir = os.path.dirname(os.path.realpath(shutil.which("dot")))
for binary in progs:
binaries.append((graphviz_bindir + "/" + binary, "."))
if is_darwin:
suffix = "dylib"
# graphviz_libdir is e.g. /usr/local/Cellar/graphviz/2.46.0/lib/graphviz
graphviz_libdir = os.path.realpath(graphviz_bindir + "/../lib/graphviz")
from PyInstaller import compat
from PyInstaller.depend import bindepend
from PyInstaller.utils.hooks import logger


def _collect_graphviz_files():
binaries = []
datas = []

# A working `pygraphviz` installation requires graphviz programs in PATH. Attempt to resolve the `dot` executable to
# see if this is the case.
dot_binary = shutil.which('dot')
if not dot_binary:
logger.warning(
"hook-pygraphviz: 'dot' program not found in PATH!"
)
return binaries, datas
logger.info("hook-pygraphviz: found 'dot' program: %r", dot_binary)
bin_dir = pathlib.Path(dot_binary).parent

# Collect graphviz programs that might be called from `pygaphviz.agraph.AGraph`:
# https://github.com/pygraphviz/pygraphviz/blob/pygraphviz-1.14/pygraphviz/agraph.py#L1330-L1348
# On macOS and on Linux, most of these are symbolic links to a single executable.
progs = (
"neato",
"dot",
"twopi",
"circo",
"fdp",
"nop",
"osage",
"patchwork",
"gc",
"acyclic",
"gvpr",
"gvcolor",
"ccomps",
"sccmap",
"tred",
"sfdp",
"unflatten",
)

logger.debug("hook-pygraphviz: collecting graphviz program executables...")
for program_name in progs:
program_binary = shutil.which(program_name)
if not program_binary:
logger.debug("hook-pygaphviz: graphviz program %r not found!", program_name)
continue

# Ensure that the program executable was found in the same directory as the `dot` executable. This should
# prevent us from falling back to other graphviz installations that happen to be in PATH.
if pathlib.Path(program_binary).parent != bin_dir:
logger.debug(
"hook-pygraphviz: found program %r (%r) outside of directory %r - ignoring!",
program_name, program_binary, str(bin_dir)
)
continue

logger.debug("hook-pygraphviz: collecting graphviz program %r: %r", program_name, program_binary)
binaries += [(program_binary, '.')]

# Graphviz shared libraries should be automatically collected when PyInstaller performs binary dependency
# analysis of the collected program executables. However, we need to collect plugins and their accompanying
# config file.
logger.debug("hook-pygraphviz: looking for graphviz plugin directory...")
if compat.is_win:
# Under Windows, we have several installation variants:
# - official installer
# - chocolatey
# - msys2
# - Anaconda
# In all variants, the plugins and the config file are located in the `bin` directory, next to the program
# executables.
plugin_dir = bin_dir
plugin_dest_dir = '.' # Collect into top-level application directory
# Official installer and Anaconda use unversioned `gvplugin-{name}.dll` plugin names, while msys2 uses
# versioned `libgvplugin-{name}-{version}.dll` plugin names.
plugin_pattern = '*gvplugin*.dll'
else:
suffix = "so"
# graphviz_libdir is e.g. /usr/lib64/graphviz
graphviz_libdir = os.path.join(os.path.dirname(findLibrary('libcdt')), 'graphviz')
for binary in glob.glob(graphviz_libdir + "/*." + suffix):
binaries.append((binary, "graphviz"))
for data in glob.glob(graphviz_libdir + "/config*"):
datas.append((data, "graphviz"))
# Perform binary dependency analysis on the `dot` executable to obtain path to the graphiz shared libraries.
# These need to be in library search path for the programs to work, or discoverable via run-paths (e.g.,
# Anaconda on Linux and macOS, Homebrew on macOS).
graphviz_lib_candidates = ['cdt', 'gvc', 'cgraph']

if hasattr(bindepend, 'get_imports'):
# PyInstaller >= 6.0
dot_imports = [path for name, path in bindepend.get_imports(dot_binary) if path is not None]
else:
# PyInstaller < 6.0
dot_imports = bindepend.getImports(dot_binary)

graphviz_lib_paths = [
path for path in dot_imports
if any(candidate in os.path.basename(path) for candidate in graphviz_lib_candidates)
]

if not graphviz_lib_paths:
logger.warning("hook-pygraphviz: could not determine location of graphviz shared libraries!")
return binaries, datas

graphviz_lib_dir = pathlib.Path(graphviz_lib_paths[0]).parent
logger.debug("hook-pygraphviz: location of graphviz shared libraries: %r", str(graphviz_lib_dir))

# Plugins should be located in `graphviz` directory next to shared libraries.
plugin_dir = graphviz_lib_dir / 'graphviz'
plugin_dest_dir = 'graphviz' # Colelct into graphviz sub-directory

if compat.is_darwin:
plugin_pattern = '*gvplugin*.dylib'
else:
# Collect only versioned .so library files (for example, `/lib64/graphviz/libgvplugin_core.so.6` and
# `/lib64/graphviz/libgvplugin_core.so.6.0.0`). The unversioned .so library files (for example`,
# lib64/graphviz/libgvplugin_core.so`), if available, are meant for linking (and are usually installed
# as part of development package).
plugin_pattern = '*gvplugin*.so.*'

if not plugin_dir.is_dir():
logger.warning("hook-pygraphviz: could not determine location of graphviz plugins!")
return binaries, datas

logger.info("hook-pygraphviz: collecting graphviz plugins directory: %r", str(plugin_dir))

binaries += [(str(file), plugin_dest_dir) for file in plugin_dir.glob(plugin_pattern)]
datas += [(str(file), plugin_dest_dir) for file in plugin_dir.glob("config*")]

return binaries, datas


binaries, datas = _collect_graphviz_files()
3 changes: 3 additions & 0 deletions news/849.update.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Rewrite ``pygraphviz`` hook to fix discovery and collection of ``graphviz``
files under various Linux distributions, in Anaconda environments
(Windows, Linux, and macOS), and msys2 environments (Windows).

0 comments on commit 5d40b8a

Please sign in to comment.