9

I'm making an add-on that uses the websocket library from python. Is there a way to include it in the add-on .zip file? Or do I have to add it to the site packages folder in blenders included python folder?

  • Related: https://blender.stackexchange.com/questions/5287/using-3rd-party-python-modules, https://blender.stackexchange.com/questions/8509/including-3rd-party-modules-in-a-blender-addon – Ray Mairlot Feb 29 '20 at 17:17
  • For recent versions of Blender (2.81 and later), you can install them through pip. – Robert Gützkow Feb 29 '20 at 17:34
  • How would I make it install to the right place? It just installs to my user directory – Joshua Anderson Feb 29 '20 at 17:48
  • You need to use subprocess and pass it the correct path to Blender's python interpreter. Unless somebody is faster I will write an answer later this evening. – Robert Gützkow Feb 29 '20 at 17:53

1 Answers1

22

Blender includes pip on Windows since version 2.81, but not on other operating systems. Therefore, pip has to be installed through ensurepip.bootstrap() on Linux and macOS. This function can also be called on Windows, when pip is already installed. It will detect the existing installation of pip and won't modify it. Installing pip and Python packages through a script or an add-on may require that Blender is started with elevated privileges, for instance if Blender is located in C:\Program Files. Please note that this is generally not recommended for security reasons.

Important: ensurepip.bootstrap() calls pip during for the installation. pip sets the environment variable PIP_REQ_TRACKER which is used as a temporary directory. This directory doesn't exist anymore once ensurepip.bootstrap() finishes execution, but the environment variable PIP_REQ_TRACKER remains. This is a problem, because subsequent calls to pip will attempt to use the directory in PIP_REQ_TRACKER. It is necessary to remove PIP_REQ_TRACKER from the environment variables after calling ensurepip.bootstrap():

ensurepip.bootstrap()
os.environ.pop("PIP_REQ_TRACKER", None)

Once pip is installed, subprocess can be used to to install the required packages. The path to Blender's Python interpreter is bpy.app.binary_path_python. Installing a package, for example matplotlib*, would therefore be done in the following way:

subprocess.check_output([bpy.app.binary_path_python, '-m', 'pip', 'install', 'matplotlib'])

*matplotlib is used as an example, since I wasn't sure which websocket package you were referring to.


Important: It's common courtesy to not connect to the internet or install packages without the explicit consent of the user.

The following add-on has a button in the preferences that allows the user to install the required Python packages. If the dependencies aren't installed, a panel with instructions is displayed in place of the add-on's operators. This approach respects the user's privacy and doesn't install packages without their permission.

User Preferences User preferences

Instructions Instructions

After successful installation of dependencies After installation of dependencies

The import_module(module_name, global_name=None) functions allows to programmatically import modules based on their name and optionally specify a global_name under which the module can be accessed. For your own implementation it may not be necessary to use importlib and globals(), but it significantly reduces code redundancy if you need to import or install multiple modules. The installation of Python packages, as described above, is implemented in install_pip() and install_and_import_module(module_name, package_name=None, global_name=None).

Please check GitHub for the updated version of this example add-on.

#    Copyright (C) 2020  Robert Guetzkow
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>

bl_info = { "name": "Install Dependencies Example", "author": "Robert Guetzkow", "version": (1, 0, 2), "blender": (2, 81, 0), "location": "View3D > Sidebar > Example Tab", "description": "Example add-on that installs a Python package", "warning": "Requires installation of dependencies", "wiki_url": "https://github.com/robertguetzkow/blender-python-examples/add-ons/install-dependencies", "tracker_url": "https://github.com/robertguetzkow/blender-python-examples/issues", "support": "COMMUNITY", "category": "3D View"}

import bpy import subprocess from collections import namedtuple

Dependency = namedtuple("Dependency", ["module", "package", "name"])

Declare all modules that this add-on depends on. The package and (global) name can be set to None,

if they are equal to the module name. See import_module and ensure_and_import_module for the

explanation of the arguments.

dependencies = (Dependency(module="matplotlib", package=None, name=None),)

dependencies_installed = False

def import_module(module_name, global_name=None): """ Import a module. :param module_name: Module to import. :param global_name: (Optional) Name under which the module is imported. If None the module_name will be used. This allows to import under a different name with the same effect as e.g. "import numpy as np" where "np" is the global_name under which the module can be accessed. :raises: ImportError and ModuleNotFoundError """ import importlib

if global_name is None:
    global_name = module_name

# Attempt to import the module and assign it to globals dictionary. This allow to access the module under
# the given name, just like the regular import would.
globals()[global_name] = importlib.import_module(module_name)


def install_pip(): """ Installs pip if not already present. Please note that ensurepip.bootstrap() also calls pip, which adds the environment variable PIP_REQ_TRACKER. After ensurepip.bootstrap() finishes execution, the directory doesn't exist anymore. However, when subprocess is used to call pip, in order to install a package, the environment variables still contain PIP_REQ_TRACKER with the now nonexistent path. This is a problem since pip checks if PIP_REQ_TRACKER is set and if it is, attempts to use it as temp directory. This would result in an error because the directory can't be found. Therefore, PIP_REQ_TRACKER needs to be removed from environment variables. :return: """

try:
    # Check if pip is already installed
    subprocess.run([bpy.app.binary_path_python, &quot;-m&quot;, &quot;pip&quot;, &quot;--version&quot;], check=True)
except subprocess.CalledProcessError:
    import os
    import ensurepip

    ensurepip.bootstrap()
    os.environ.pop(&quot;PIP_REQ_TRACKER&quot;, None)


def install_and_import_module(module_name, package_name=None, global_name=None): """ Installs the package through pip and attempts to import the installed module. :param module_name: Module to import. :param package_name: (Optional) Name of the package that needs to be installed. If None it is assumed to be equal to the module_name. :param global_name: (Optional) Name under which the module is imported. If None the module_name will be used. This allows to import under a different name with the same effect as e.g. "import numpy as np" where "np" is the global_name under which the module can be accessed. :raises: subprocess.CalledProcessError and ImportError """ import importlib

if package_name is None:
    package_name = module_name

if global_name is None:
    global_name = module_name

# Try to install the package. This may fail with subprocess.CalledProcessError
subprocess.run([bpy.app.binary_path_python, &quot;-m&quot;, &quot;pip&quot;, &quot;install&quot;, package_name], check=True)

# The installation succeeded, attempt to import the module again
import_module(module_name, global_name)


class EXAMPLE_OT_dummy_operator(bpy.types.Operator): bl_idname = "example.dummy_operator" bl_label = "Dummy Operator" bl_description = "This operator tries to use matplotlib." bl_options = {"REGISTER"}

def execute(self, context):
    print(matplotlib.get_backend())
    return {&quot;FINISHED&quot;}


class EXAMPLE_PT_panel(bpy.types.Panel): bl_label = "Example Panel" bl_category = "Example Tab" bl_space_type = "VIEW_3D" bl_region_type = "UI"

def draw(self, context):
    layout = self.layout

    for dependency in dependencies:
        if dependency.name is None:
            layout.label(text=f&quot;{dependency.module} {globals()[dependency.module].__version__}&quot;)
        else:
            layout.label(text=f&quot;{dependency.module} {globals()[dependency.name].__version__}&quot;)

    layout.operator(EXAMPLE_OT_dummy_operator.bl_idname)


classes = (EXAMPLE_OT_dummy_operator, EXAMPLE_PT_panel)

class EXAMPLE_PT_warning_panel(bpy.types.Panel): bl_label = "Example Warning" bl_category = "Example Tab" bl_space_type = "VIEW_3D" bl_region_type = "UI"

@classmethod
def poll(self, context):
    return not dependencies_installed

def draw(self, context):
    layout = self.layout

    lines = [f&quot;Please install the missing dependencies for the \&quot;{bl_info.get('name')}\&quot; add-on.&quot;,
             f&quot;1. Open the preferences (Edit &gt; Preferences &gt; Add-ons).&quot;,
             f&quot;2. Search for the \&quot;{bl_info.get('name')}\&quot; add-on.&quot;,
             f&quot;3. Open the details section of the add-on.&quot;,
             f&quot;4. Click on the \&quot;{EXAMPLE_OT_install_dependencies.bl_label}\&quot; button.&quot;,
             f&quot;   This will download and install the missing Python packages, if Blender has the required&quot;,
             f&quot;   permissions.&quot;,
             f&quot;If you're attempting to run the add-on from the text editor, you won't see the options described&quot;,
             f&quot;above. Please install the add-on properly through the preferences.&quot;,
             f&quot;1. Open the add-on preferences (Edit &gt; Preferences &gt; Add-ons).&quot;,
             f&quot;2. Press the \&quot;Install\&quot; button.&quot;,
             f&quot;3. Search for the add-on file.&quot;,
             f&quot;4. Confirm the selection by pressing the \&quot;Install Add-on\&quot; button in the file browser.&quot;]

    for line in lines:
        layout.label(text=line)


class EXAMPLE_OT_install_dependencies(bpy.types.Operator): bl_idname = "example.install_dependencies" bl_label = "Install dependencies" bl_description = ("Downloads and installs the required python packages for this add-on. " "Internet connection is required. Blender may have to be started with " "elevated permissions in order to install the package") bl_options = {"REGISTER", "INTERNAL"}

@classmethod
def poll(self, context):
    # Deactivate when dependencies have been installed
    return not dependencies_installed

def execute(self, context):
    try:
        install_pip()
        for dependency in dependencies:
            install_and_import_module(module_name=dependency.module,
                                      package_name=dependency.package,
                                      global_name=dependency.name)
    except (subprocess.CalledProcessError, ImportError) as err:
        self.report({&quot;ERROR&quot;}, str(err))
        return {&quot;CANCELLED&quot;}

    global dependencies_installed
    dependencies_installed = True

    # Register the panels, operators, etc. since dependencies are installed
    for cls in classes:
        bpy.utils.register_class(cls)

    return {&quot;FINISHED&quot;}


class EXAMPLE_preferences(bpy.types.AddonPreferences): bl_idname = name

def draw(self, context):
    layout = self.layout
    layout.operator(EXAMPLE_OT_install_dependencies.bl_idname, icon=&quot;CONSOLE&quot;)


preference_classes = (EXAMPLE_PT_warning_panel, EXAMPLE_OT_install_dependencies, EXAMPLE_preferences)

def register(): global dependencies_installed dependencies_installed = False

for cls in preference_classes:
    bpy.utils.register_class(cls)

try:
    for dependency in dependencies:
        import_module(module_name=dependency.module, global_name=dependency.name)
    dependencies_installed = True
except ModuleNotFoundError:
    # Don't register other panels, operators etc.
    return

for cls in classes:
    bpy.utils.register_class(cls)


def unregister(): for cls in preference_classes: bpy.utils.unregister_class(cls)

if dependencies_installed:
    for cls in classes:
        bpy.utils.unregister_class(cls)


if name == "main": register()

Robert Gützkow
  • 25,622
  • 3
  • 47
  • 78
  • I will probably update the example add-on soon so nobody copies this into their add-on without reading the note. – Robert Gützkow May 15 '20 at 09:59
  • 1
    Suggest something like loader = importlib.util.find_spec(module_name) to check if install required. – batFINGER May 15 '20 at 11:43
  • @batFINGER Sounds good, will improve/update the answer this weekend. – Robert Gützkow May 15 '20 at 11:47
  • Or ModuleNotFoundError Look forward to it. Been coming at this from a different direction re checking for dependencies. (a deps tuple in bl_info dict would be handy, since it appears blender greps this out for fake module) This looks very handy for the next step. Consider it stolen, lol. – batFINGER May 15 '20 at 12:33
  • @batFINGER Posted the updated version. – Robert Gützkow May 18 '20 at 13:31
  • 2
    This is cool and I want to star it. Would you consider hosting it on your github? – Leander May 18 '20 at 13:33
  • @Leander Sure, why not. I will create a repository this evening. – Robert Gützkow May 18 '20 at 13:46
  • 2
    @Leander https://github.com/robertguetzkow/blender-python-examples – Robert Gützkow May 18 '20 at 19:57
  • 1
    The answer currently does not include the trick to install without elevated permissions through --user and appending the user site-packages to sys.path because loading of the user site-packages was intentionally disabled (see T76993 and D6962). It still works though, in case somebody needs this. – Robert Gützkow Sep 15 '20 at 18:46
  • Thank you for this very detailed answer! Is it possible to specify the version of the dependent library to be installed? For instance matplotlib 3.1.0 instead matplotlib 3.2.1 – Alexis.Rolland Aug 08 '21 at 00:28
  • 1
    @Alexis.Rolland Yes, that is possible. You would have to modify install_and_import_module to take another optional argument, the version number. If the value is different from the default, then you can run subprocess.run([sys.executable, "-m", "pip", "install", package_name_with_version], check=True, env=environ_copy) where package_name_with_version is the concatenation of package_name, == and the version number. – Robert Gützkow Aug 08 '21 at 08:20
  • 1
    @Alexis.Rolland Please keep in mind that this will cause trouble when there is already a more recent matplotlib version installed by another add-on though. Since there is no isolation between add-ons they all share the same modules and dependencies. Uninstalling the newer version is not an option as then the other add-on will likely fail to work. – Robert Gützkow Aug 08 '21 at 08:22
  • Thank you @RobertGützkow for your answers. I'm encountering another issue. I tried pip install with subprocess, with both sys.executable and bpy.app.binary_path_python and my dependencies are being installed in c:\users\my.user\appdata\roaming\python\python39\site-packages instead of c:\program files\blender foundation\blender 2.93\2.93\python\lib\site-packages. Blender is running as administrator... any idea why? :-/ Thanks again! – Alexis.Rolland Aug 26 '21 at 04:44
  • 1
    @Alexis.Rolland Could it be that the dependency was already installed in your user site-packages? You need to exclude the user site-packages otherwise pip will think the requirements are already satisfied (see https://github.com/robertguetzkow/blender-python-examples/tree/master/add_ons/install_dependencies#installing-the-package). – Robert Gützkow Aug 26 '21 at 13:19
  • You're the best! – Alexis.Rolland Aug 26 '21 at 14:21