Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

hooks: rewrite pygraphviz hook #849

Merged
merged 4 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ jobs:
libxinerama1 libgomp1
# Required by PyGObject (dependency of toga/toga-gtk)
sudo apt-get install -y gir1.2-gtk-3.0 libgirepository1.0-dev
# Required by pygraphviz
sudo apt-get install -y graphviz graphviz-dev

- name: Install brew dependencies
if: startsWith(matrix.os, 'macos')
Expand All @@ -114,6 +116,8 @@ jobs:
# This one is required by eccodes (binary wheels with bundled eccodes
# library are provided only for macOS 13+).
brew install eccodes
# Requires by pygraphviz
brew install graphviz

- name: Install dependencies
shell: bash
Expand Down
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, several 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 as part of the main build process. However, we need to manually
# 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 installers and builds from https://gitlab.com/graphviz/graphviz/-/releases
# - 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 builds and Anaconda use unversioned `gvplugin-{name}.dll` plugin names, while msys2 uses
# versioned `libgvplugin-{name}-{version}.dll` plugin names (with "lib" prefix).
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 the path to graphiz shared libraries.
# These need to be in the 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' # Collect 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 former usually being a symbolic link to the latter).
# The unversioned .so library files (such as `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 from 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*")] # e.g., `config6`

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).
3 changes: 3 additions & 0 deletions requirements-test-libraries.txt
Original file line number Diff line number Diff line change
Expand Up @@ -271,5 +271,8 @@ pylsl==1.17.6; sys_platform == "darwin" and python_version >= '3.9'
# PyTaskbarProgress only runs on Windows
PyTaskbarProgress==0.0.8; sys_platform == 'win32'

# pygraphviz requires graphviz to be provided by the environment (linux distribution, homebrew, or Anaconda).
pygraphviz==1.14; (sys_platform == 'darwin' or sys_platform == 'linux') and python_version >= '3.10'

# Include the requirements for testing
-r requirements-test.txt
42 changes: 42 additions & 0 deletions tests/test_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,48 @@ def test_pygraphviz_bundled_programs(pyi_builder):
""")


@importorskip("pygraphviz")
def test_pygraphviz_functional(pyi_builder, tmp_path):
# Functional test for pygraphviz that tries to use different programs and output formats to ensure that graphviz
# programs and plugins are properly collected.
pyi_builder.test_source("""
import sys
import os
import pygraphviz as pgv

output_dir = sys.argv[1] if len(sys.argv) >= 2 else '.'

print("Setting up graph...")
G = pgv.AGraph(strict=False, directed=True)

# Set default node attributes
G.graph_attr["label"] = "Name of graph"
G.node_attr["shape"] = "circle"
G.edge_attr["color"] = "red"

G.add_node("a")
G.add_edge("b", "c") # add edge (and the nodes)

print("Dumping graph to string...")
s = G.string()
print(s)

print("Test layout with default program (= neato)")
G.layout()

print("Test layout with 'dot' program")
G.layout(prog="dot")

print("Writing previously positioned graph to PNG file...")
G.draw(os.path.join(output_dir, "file.png"))

print("Using 'circo' to position, writing PS file...")
G.draw(os.path.join(output_dir, "file.ps"), prog="circo")

print("Done!")
""", app_args=[str(tmp_path)])


@importorskip("pypsexec")
def test_pypsexec(pyi_builder):
pyi_builder.test_source("""
Expand Down
Loading