From a2cb35d4c5da25a90cb746e6b06a0c5ee7096b4b Mon Sep 17 00:00:00 2001 From: Julien Date: Sun, 31 Mar 2024 11:00:27 +0200 Subject: [PATCH] Improved error handling on social plugin (#6818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(social): CairoSVG OSError handling in social plugin Related issue: #6817 Co-authored-by: Guts <1596222+Guts@users.noreply.github.com> * feat(docs): Add troubleshooting guide for CairoSVG crash --------- Co-authored-by: Kamil Krzyśków Co-authored-by: Guts <1596222+Guts@users.noreply.github.com> Co-authored-by: Martin Donath --- docs/plugins/requirements/image-processing.md | 124 ++++++++++++++++++ includes/debug/cairo-lookup-linux.py | 111 ++++++++++++++++ includes/debug/cairo-lookup-macos.py | 49 +++++++ includes/debug/cairo-lookup-windows.py | 31 +++++ material/plugins/social/plugin.py | 35 +++-- src/plugins/social/plugin.py | 35 +++-- 6 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 includes/debug/cairo-lookup-linux.py create mode 100644 includes/debug/cairo-lookup-macos.py create mode 100644 includes/debug/cairo-lookup-windows.py diff --git a/docs/plugins/requirements/image-processing.md b/docs/plugins/requirements/image-processing.md index c53cbf6ad..01118c614 100644 --- a/docs/plugins/requirements/image-processing.md +++ b/docs/plugins/requirements/image-processing.md @@ -134,3 +134,127 @@ The following environments come with a preinstalled version of [pngquant]: [pngquant]: https://pngquant.org/ [built-in optimize plugin]: ../../plugins/optimize.md [pngquant-winbuild]: https://github.com/jibsen/pngquant-winbuild + +## Troubleshooting + +### Cairo library was not found + +After following the installation guide above it may happen that you still get +the following error: + +```bash +no library called "cairo-2" was found +no library called "cairo" was found +no library called "libcairo-2" was found +cannot load library 'libcairo.so.2': error 0x7e. Additionally, ctypes.util.find_library() did not manage to locate a library called 'libcairo.so.2' +cannot load library 'libcairo.2.dylib': error 0x7e. Additionally, ctypes.util.find_library() did not manage to locate a library called 'libcairo.2.dylib' +cannot load library 'libcairo-2.dll': error 0x7e. Additionally, ctypes.util.find_library() did not manage to locate a library called 'libcairo-2.dll' +``` + +This means that the [`cairosvg`][PyPi CairoSVG] package was installed, but the +underlying [`cairocffi`][PyPi CairoCFFI] dependency couldn't [find][cffi-dopen] +the installed library. Depending on the operating system the library lookup +process is different: + +!!! tip + Before proceeding remember to fully restart any open Terminal windows, and + their parent hosts like IDEs to reload any environmental variables, which + were altered during the installation process. This might be the quick fix. + +=== ":material-apple: macOS" + + On macOS the library lookup checks inside paths defined in [dyld][osx-dyld]. + Additionally each library `name` is checked in [three variants][find-library-macOS] + with the `libname.dylib`, `name.dylib` and `name.framework/name` format. + + [Homebrew] should set every needed variable to point at the installed + library directory, but if that didn't happen, you can use the debug script + below to see what paths are looked up. + + A [known workaround][cffi-issue] is to add the Homebrew lib path directly + before running MkDocs: + + ```bash + export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib + ``` + + View source code of [cairo-lookup-macos.py] + + ```bash title="Python Debug macOS Script" + curl "https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/includes/debug/cairo-lookup-macos.py" | python - + ``` + +=== ":fontawesome-brands-windows: Windows" + + On Windows the library lookup checks inside the paths defined in the + environmental `PATH` variable. Additionally each library `name` is checked + in [two variants][find-library-Windows] with the `name` and `name.dll` format. + + The default installation path of [GTK runtime] is: + + ```powershell + C:\Program Files\GTK3-Runtime Win64 + ``` + + and the libraries are in the `\lib` directory. Use the debug + script below to check if the path is included. If it isn't then: + + 1. Press ++windows+r++. + 2. Run the `SystemPropertiesAdvanced` applet. + 3. Select "Environmental Variables" at the bottom. + 4. Add the whole path to the `lib` directory to your `Path` variable. + 5. Click OK on all open windows to apply changes. + 6. Fully restart any open Terminal windows and their parent hosts like IDEs. + + ```powershell title="You can also list paths using PowerShell" + $env:Path -split ';' + ``` + + View source code of [cairo-lookup-windows.py] + + ```powershell title="PowerShell - Python Debug Windows Script" + (Invoke-WebRequest "https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/includes/debug/cairo-lookup-windows.py").Content | python - + ``` + +=== ":material-linux: Linux" + + On Linux the library lookup can [differ greatly][find-library-Linux] and is + dependant from the installed distribution. For tested Ubuntu and Manjaro + systems Python runs shell commands to check which libraries are available in + [`ldconfig`][ubuntu-ldconfig], in the [`gcc`][ubuntu-gcc]/`cc` compiler, and + in [`ld`][ubuntu-ld]. + + You can extend the `LD_LIBRARY_PATH` environmental variable with an absolute + path to a library directory containing `libcairo.so` etc. Run this directly + before MkDocs: + + ```bash + export LD_LIBRARY_PATH=/absolute/path/to/lib:$LD_LIBRARY_PATH + ``` + + You can also modify the `/etc/ld.so.conf` file. + + The Python script below shows, which function is being run to find installed + libraries. You can check the source to find out what specific commands are + executed on your system during library lookup. + + View source code of [cairo-lookup-linux.py] + + ```bash title="Python Debug Linux Script" + curl "https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/includes/debug/cairo-lookup-linux.py" | python - + ``` + + [PyPi CairoSVG]: https://pypi.org/project/CairoSVG + [PyPi CairoCFFI]: https://pypi.org/project/CairoCFFI + [osx-dyld]: https://www.unix.com/man-page/osx/1/dyld/ + [ubuntu-ldconfig]: https://manpages.ubuntu.com/manpages/focal/en/man8/ldconfig.8.html + [ubuntu-ld]: https://manpages.ubuntu.com/manpages/xenial/man1/ld.1.html + [ubuntu-gcc]: https://manpages.ubuntu.com/manpages/trusty/man1/gcc.1.html + [cffi-issue]: https://github.com/squidfunk/mkdocs-material/issues/5121 + [cffi-dopen]: https://github.com/Kozea/cairocffi/blob/f1984d644bbc462ef0ec33b97782cf05733d7b53/cairocffi/__init__.py#L24-L49 + [find-library-macOS]: https://github.com/python/cpython/blob/4d58a1d8fb27048c11bcbda3da1bebf78f979335/Lib/ctypes/util.py#L70-L81 + [find-library-Windows]: https://github.com/python/cpython/blob/4d58a1d8fb27048c11bcbda3da1bebf78f979335/Lib/ctypes/util.py#L59-L67 + [find-library-Linux]: https://github.com/python/cpython/blob/4d58a1d8fb27048c11bcbda3da1bebf78f979335/Lib/ctypes/util.py#L92 + [cairo-lookup-macos.py]: https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/includes/debug/cairo-lookup-macos.py + [cairo-lookup-windows.py]: https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/includes/debug/cairo-lookup-windows.py + [cairo-lookup-linux.py]: https://raw.githubusercontent.com/squidfunk/mkdocs-material/master/includes/debug/cairo-lookup-linux.py diff --git a/includes/debug/cairo-lookup-linux.py b/includes/debug/cairo-lookup-linux.py new file mode 100644 index 000000000..5f9fc557a --- /dev/null +++ b/includes/debug/cairo-lookup-linux.py @@ -0,0 +1,111 @@ +import inspect +import os +import shutil +import subprocess +from ctypes import util + + +class CustomPopen(subprocess.Popen): + + def __init__(self, *args, **kwargs): + print(f"Subprocess command:\n {' '.join(args[0])}") + super().__init__(*args, **kwargs) + + def communicate(self, *args, **kwargs): + out, _ = super().communicate(*args, **kwargs) + out = out.rstrip() + print("Subprocess output:") + if out: + print(f" {os.fsdecode(out)}") + else: + print(f" Output is empty") + return out, _ + + def __getattribute__(self, name_): + att = super().__getattribute__(name_) + if name_ == "stdout" and att is not None: + att.read = self.read_wrapper(att.read) + return att + + @staticmethod + def read_wrapper(func): + + if func.__name__ == "wrapper": + return func + + def wrapper(*args, **kwargs): + output = func(*args, **kwargs) + print("Subprocess output:") + for line_ in os.fsdecode(output).split("\n"): + print(line_) + return output + + return wrapper + + +subprocess.Popen = CustomPopen + +print("ctypes.util script with the find_library:") +print(inspect.getsourcefile(util.find_library), end="\n\n") + +print("find_library function:") +func_lines = list(map(str.rstrip, inspect.getsourcelines(util.find_library)[0])) +indent = len(func_lines[0]) - len(func_lines[0].lstrip()) +for line in func_lines: + print(line.replace(" " * indent, "", 1)) + +library_names = ("cairo-2", "cairo", "libcairo-2") +filenames = ("libcairo.so.2", "libcairo.2.dylib", "libcairo-2.dll") +c_compiler = shutil.which("gcc") or shutil.which("cc") +ld_env = os.environ.get("LD_LIBRARY_PATH") +first_found = "" + +print("\nLD_LIBRARY_PATH =", ld_env, end="\n\n") + +for name in library_names: + if hasattr(util, "_findSoname_ldconfig"): + result = util._findSoname_ldconfig(name) + print(f"_findSoname_ldconfig({name}) ->", result) + if result: + print(f"Found {result}") + if not first_found: + first_found = result + print("---") + if c_compiler and hasattr(util, "_findLib_gcc"): + result = util._findLib_gcc(name) + print(f"_findLib_gcc({name}) ->", result) + if result and hasattr(util, "_get_soname"): + result = util._get_soname(result) + if result: + print(f"Found {result}") + if not first_found: + first_found = result + print("---") + if hasattr(util, "_findLib_ld"): + result = util._findLib_ld(name) + print(f"_findLib_ld({name}) ->", result) + if result and hasattr(util, "_get_soname"): + result = util._get_soname(result) + if result: + print(f"Found {result}") + if not first_found: + first_found = result + print("---") + if hasattr(util, "_findLib_crle"): + result = util._findLib_crle(name, False) + print(f"_findLib_crle({name}) ->", result) + if result and hasattr(util, "_get_soname"): + result = util._get_soname(result) + if result: + print(f"Found {result}") + if not first_found: + first_found = result + print("---") + +if first_found: + filenames = (first_found,) + filenames + +print(f"The path is {first_found or 'not found'}") +print("List of files that FFI will try to load:") +for filename in filenames: + print("-", filename) diff --git a/includes/debug/cairo-lookup-macos.py b/includes/debug/cairo-lookup-macos.py new file mode 100644 index 000000000..7ec8367c9 --- /dev/null +++ b/includes/debug/cairo-lookup-macos.py @@ -0,0 +1,49 @@ +import os +from ctypes.macholib import dyld +from itertools import chain + +library_names = ("cairo-2", "cairo", "libcairo-2") +filenames = ("libcairo.so.2", "libcairo.2.dylib", "libcairo-2.dll") +first_found = "" +names = [] + +for name in library_names: + names += [ + "lib%s.dylib" % name, + "%s.dylib" % name, + "%s.framework/%s" % (name, name), + ] + +for name in names: + for path in dyld.dyld_image_suffix_search( + chain( + dyld.dyld_override_search(name), + dyld.dyld_executable_path_search(name), + dyld.dyld_default_search(name), + ) + ): + if os.path.isfile(path): + print(f"Found: {path}") + if not first_found: + first_found = path + continue + + try: + if dyld._dyld_shared_cache_contains_path(path): + print(f"Found: {path}") + if not first_found: + first_found = path + continue + except NotImplementedError: + pass + + print(f"Doesn't exist: {path}") + print("---") + +if first_found: + filenames = (first_found,) + filenames + +print(f"The path is {first_found or 'not found'}") +print("List of files that FFI will try to load:") +for filename in filenames: + print("-", filename) diff --git a/includes/debug/cairo-lookup-windows.py b/includes/debug/cairo-lookup-windows.py new file mode 100644 index 000000000..70ed286a8 --- /dev/null +++ b/includes/debug/cairo-lookup-windows.py @@ -0,0 +1,31 @@ +import os + +library_names = ("cairo-2", "cairo", "libcairo-2") +filenames = ("libcairo.so.2", "libcairo.2.dylib", "libcairo-2.dll") +first_found = "" +names = [] + +for name in library_names: + if name.lower().endswith(".dll"): + names += [name] + else: + names += [name, name + ".dll"] + +for name in names: + for path in os.environ["PATH"].split(os.pathsep): + resolved_path = os.path.join(path, name) + if os.path.exists(resolved_path): + print(f"Found: {resolved_path}") + if not first_found: + first_found = resolved_path + continue + print(f"Doesn't exist: {resolved_path}") + print("---") + +if first_found: + filenames = (first_found,) + filenames + +print(f"The path is {first_found or 'not found'}") +print("List of files that FFI will try to load:") +for filename in filenames: + print("-", filename) diff --git a/material/plugins/social/plugin.py b/material/plugins/social/plugin.py index b1be351a6..a89600830 100644 --- a/material/plugins/social/plugin.py +++ b/material/plugins/social/plugin.py @@ -49,14 +49,25 @@ from mkdocs.plugins import BasePlugin from mkdocs.utils import copy_file from shutil import copyfile from tempfile import NamedTemporaryFile -try: - from cairosvg import svg2png - from PIL import Image, ImageDraw, ImageFont -except ImportError: - pass from .config import SocialConfig +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError as e: + import_errors = {repr(e)} +else: + import_errors = set() + +cairosvg_error: str = "" + +try: + from cairosvg import svg2png +except ImportError as e: + import_errors.add(repr(e)) +except OSError as e: + cairosvg_error = str(e) + # ----------------------------------------------------------------------------- # Classes @@ -76,10 +87,18 @@ class SocialPlugin(BasePlugin[SocialConfig]): return # Check dependencies - if "Image" not in globals(): + if import_errors: raise PluginError( - "Required dependencies of \"social\" plugin not found. " - "Install with: pip install \"mkdocs-material[imaging]\"" + "Required dependencies of \"social\" plugin not found:\n" + + str("\n".join(map(lambda x: "- " + x, import_errors))) + + "\n\n--> Install with: pip install \"mkdocs-material[imaging]\"" + ) + + if cairosvg_error: + raise PluginError( + "\"cairosvg\" Python module is installed, but it crashed with:\n" + + cairosvg_error + + "\n\n--> Check out the troubleshooting guide: https://t.ly/MfX6u" ) # Move color options diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py index b1be351a6..a89600830 100644 --- a/src/plugins/social/plugin.py +++ b/src/plugins/social/plugin.py @@ -49,14 +49,25 @@ from mkdocs.plugins import BasePlugin from mkdocs.utils import copy_file from shutil import copyfile from tempfile import NamedTemporaryFile -try: - from cairosvg import svg2png - from PIL import Image, ImageDraw, ImageFont -except ImportError: - pass from .config import SocialConfig +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError as e: + import_errors = {repr(e)} +else: + import_errors = set() + +cairosvg_error: str = "" + +try: + from cairosvg import svg2png +except ImportError as e: + import_errors.add(repr(e)) +except OSError as e: + cairosvg_error = str(e) + # ----------------------------------------------------------------------------- # Classes @@ -76,10 +87,18 @@ class SocialPlugin(BasePlugin[SocialConfig]): return # Check dependencies - if "Image" not in globals(): + if import_errors: raise PluginError( - "Required dependencies of \"social\" plugin not found. " - "Install with: pip install \"mkdocs-material[imaging]\"" + "Required dependencies of \"social\" plugin not found:\n" + + str("\n".join(map(lambda x: "- " + x, import_errors))) + + "\n\n--> Install with: pip install \"mkdocs-material[imaging]\"" + ) + + if cairosvg_error: + raise PluginError( + "\"cairosvg\" Python module is installed, but it crashed with:\n" + + cairosvg_error + + "\n\n--> Check out the troubleshooting guide: https://t.ly/MfX6u" ) # Move color options