22

I am developing a slack bot with plugins using entry points. I want to dynamically add a plugin during runtime.

I have a project with this structure:

+ ~/my_project_dir/
    + my_projects_python_code/
    + plugins/
        - plugin1.py
        - plugin2.py
        - ...
        - pluginN.py
    - setup.py
    - venv/
    - install.sh

My setup.py file looks like this:

from setuptools import setup, find_packages

setup(
    name="My_Project_plugins",
    version="1.0",
    packages=['plugins'],
    entry_points="""
        [my_project.plugins]
        plugin1 = plugins.plugin1:plugin1_class
        plugin2 = plugins.plugin2:plugin2_class
        ...
        pluginN = plugins.pluginN:pluginN_class
    """
        )

Running sudo install.sh does the following:

  1. Copies the needed files to /usr/share/my_project_dir/

  2. Activate virtualenv at /usr/share/my_project_dir/venv/bin/activate

  3. Run: python setup.py develop

This works as expected and sets up my entry points correctly so that I can use them through the bot.

But I want to be able to add a plugin to setup.py and be able to use it while the bot is running. So I want to add a line: pluginN+1 = plugins.pluginN+1:pluginN+1_class and have pluginN+1 available to use.

What I've tried/learned:

  • After /usr/share/my_project_dir/venv/bin/activate I open a Python interactive shell and iterate through pkg_resources.iter_entry_points(), which lists everything that was loaded from the initial state of setup.py (i.e. plugin1 through pluginN)

  • If I add a line to setup.py and run sudo python setup.py develop and iterate again with the same Python shell, it doesn't pick up the new plugin but if I exit the shell and reopen it, the new plugin gets picked up.

  • I noticed that when I install the bot, part of the output says:

    • Copying My_Project_plugins-1.0-py2.7.egg to /usr/share/my_project-dir/venv/lib/python2.7/site-packages
  • When I cd /usr/share/my_project_dir/, activate my virtualenv, and run setup.py from the shell it says:

    • Creating /usr/local/lib/python2.7/dist-packages/My_Project-plugins.egg-link (link to .) My_Project-plugins 1.0 is already the active version in easy-install.pth
Hebron George
  • 329
  • 1
  • 6
  • 21
  • If you have multiple plugins for a common program, is doing something like `pip install`, `pip uninstall`, `pip search` an unwanted choice? – Nick T Nov 14 '16 at 20:28
  • The goal is to have the bot install plugins during runtime via a slack message. So it will need to be updated dynamically and each of these plugins will be proprietary. – Hebron George Nov 14 '16 at 21:11
  • Could you describe in more details how does it work? 1. Bot gets command to install some plugin? What is "install"? Should the bot download or copy this file from some place? Or it just have to call some function from already present file? 2. Bot calls a function or it has to launch a script? How? – Eugene Lisitsky Nov 15 '16 at 12:14
  • @EugeneLisitsky sure thing: 1. Within slack, we upload a zip file of all the necessary files needed for a plugin. It gets unzipped to `/usr/share/my_project_dir/plugins/`. 2. The bot calls a function to launch the new plugin - every plugin is required to have a function `process` within it's main script. – Hebron George Nov 15 '16 at 17:18
  • 1
    So you have running bot. In this case no need to make additional entry points. You may just make a folder to keep all plugins, add this folder to module search path with `os.path.append('/usr/share/my_project_dir/plugins')`. When you want to run it just `import pluginA` and then use as a standard module, for ex: `pluginA.process(some, data)`. Please take a look at [my answer to similar problem](http://stackoverflow.com/questions/35906523/python-create-an-exe-that-runs-code-as-written-not-as-it-was-when-compiled/36112722#36112722) - add code to program after it was installed. – Eugene Lisitsky Nov 16 '16 at 11:40
  • You can eval, or call import directly, since that is python function. However... if something changes you wouldn't get it. You can signal your bot process somehow, but still that is quite complicated task. I would stick with subprocesses. At start of the bot you can start plugins, and later you can start them, and register in main process. Restarting is much simpler that way. – Michał Zaborowski Nov 17 '16 at 13:27
  • 1
    Code change is uploading new zip. Just put code to the same place and make `importlib.reload(module)` or `del module; import module` – Eugene Lisitsky Nov 17 '16 at 14:18
  • Aside: by the definition of development mode, one would theoretically be interested in being able to modify the entry points (script names, corresponding functions within the package) defined in `setup.py`, and the code within the package, and have all this be automatically noticed by Python (at each separate run--not in a contiguous run as requested in this answer--so in principle it might have been easier). However, this does not work (well, for one, `setup.py` is outside the package, so Python would not know where to look for the updated definition of entry points). – 0 _ Apr 20 '21 at 05:36
  • Relevant to entry points in development mode (discussed in my previous comment to this question): https://stackoverflow.com/q/58842090/1959808 – 0 _ Apr 20 '21 at 05:37

3 Answers3

11

I needed to do something similar to load a dummy plugin for test purposes. This differs slightly from your use-case in that I was specifically trying to avoid needing to define the entry points in the package (as it is just test code).

I found I could dynamically insert entries into the pkg_resources data structures as follows:

import pkg_resources
# Create the fake entry point definition
ep = pkg_resources.EntryPoint.parse('dummy = dummy_module:DummyPlugin')

# Create a fake distribution to insert into the global working_set
d = pkg_resources.Distribution()

# Add the mapping to the fake EntryPoint
d._ep_map = {'namespace': {'dummy': ep}}

# Add the fake distribution to the global working_set
pkg_resources.working_set.add(d, 'dummy')

This, at run time, added an entry point called 'dummy' to 'namespace', which would be the class 'DummyPlugin' in 'dummy_module.py'.

This was determined through use of the setuptools docs, and dir() on the objects to get more info as needed.

Docs are here: http://setuptools.readthedocs.io/en/latest/pkg_resources.html

You might especially look at http://setuptools.readthedocs.io/en/latest/pkg_resources.html#locating-plugins if all you need to do is load a plugin that you have just stored to your local filesystem.

j3p0uk
  • 111
  • 1
  • 5
  • Is there an updated version of this answer given that `_ep_map` is private/not necessarily forwards compatible, and `pkg_resources` has the warning: "Use of pkg_resources is deprecated in favor of `importlib.resources`, `importlib.metadata` and their backports (`importlib_resources`, `importlib_metadata`). Some useful APIs are also provided by `packaging`"? – Jake Stevens-Haas Aug 23 '23 at 19:12
3

It's more than at least 5 years, since the time when I first asked myself almost the same question, and your question now is an impulse to finally find it out.

For me it was as well interesting, if one can add entry points from the same directory as the script without installation of a package. Though I always knew that the only contents of the package might be some meta with entry points looking at some other packages.

Anyway, here is some setup of my directory:

ep_test newtover$ tree
.
├── foo-0.1.0.dist-info
│   ├── METADATA
│   └── entry_points.txt
└── foo.py

1 directory, 3 files

Here is the contents of foo.py:

ep_test newtover$ cat foo.py
def foo1():
    print 'foo1'

def foo2():
    print 'foo2'

Now let's open ipython:

In [1]: def write_ep(lines):  # a helper to update entry points file
   ...:     with open('foo-0.1.0.dist-info/entry_points.txt', 'w') as f1:
   ...:         print >> f1, '\n'.join(lines)
   ...:        

In [2]: write_ep([  # only one entry point under foo.test
   ...: "[foo.test]",
   ...: "foo_1 = foo:foo1",
   ...: ])

In [3]: !cat foo-0.1.0.dist-info/entry_points.txt
[foo.test]
foo1 = foo:foo1

In [4]: import pkg_resources

In [5]: ws = pkg_resources.WorkingSet()  # here is the answer on the question

In [6]: list(ws.iter_entry_points('foo.test'))
Out[6]: [EntryPoint.parse('foo_1 = foo:foo1')]

In [7]: write_ep([  # two entry points
   ...: "[foo.test]",
   ...: "foo_1 = foo:foo1",
   ...: "foo_2 = foo:foo2"
   ...: ])

In [8]: ws = pkg_resources.WorkingSet()  # a new instance of WorkingSet

With default parameters WorkingSet just revisits each entry in sys.path, but you can narrow the list. pkg_resources.iter_entry_points is bound to a global instance of WorkingSet.

In [9]: list(ws.iter_entry_points('foo.test'))  # both are visible
Out[9]: [EntryPoint.parse('foo_1 = foo:foo1'), EntryPoint.parse('foo_2 = foo:foo2')]

In [10]: foos = [ep.load() for ep in ws.iter_entry_points('foo.test')]

In [11]: for func in foos: print 'name is {}'.format(func.__name__); func()
name is foo1
foo1
name is foo2
foo2

And the contents of METADATA as well:

ep_test newtover$ cat foo-0.1.0.dist-info/METADATA
Metadata-Version: 1.2
Name: foo
Version: 0.1.0
Summary: entry point test

UPD1: I thought it over once again and now understand that you need an additional step before using the new plugins: you need to reload the modules.

This might be as easy as:

In [33]: modules_to_reload = {ep1.module_name for ep1 in ws.iter_entry_points('foo.test')}

In [34]: for module_name in modules_to_reload:
   ....:     reload(__import__(module_name))
   ....:

But if a new version of your plugins package is based on significant changes in other used modules, you might need a particular order of reloading and reloading of those changed modules. This might become a cumbersome task, so that restarting the bot would be the only way to go.

newtover
  • 31,286
  • 11
  • 84
  • 89
2

I had to change a bit solution by @j3p0uk to work for me. I wanted to use this inside an unit test (unittest framework). What I did is:

def test_entry_point(self):
    distribution = pkg_resources.Distribution(__file__)
    entry_point = pkg_resources.EntryPoint.parse('plugin1 = plugins.plugin1:plugin1_class', dist=distribution)
    distribution._ep_map = {'my_project.plugins': {'plugin1': entry_point}}
    pkg_resources.working_set.add(distribution)

This made also entry_point.load() work for me the in the code which is being tested, to really load the symbol referenced by the entry point. In my test I also have my_project.plugins having the name of the test file, and then the symbol to load is at the global scope of that file.

Mitar
  • 6,756
  • 5
  • 54
  • 86
  • This worked well for me and the distribution can be reused, just had to do `distribution._ep_map.clear()` between tests. – aviso Jul 08 '18 at 04:25
  • I have everything inside `try/finally` and I before it store `pkg_resources.working_set.entries`, `pkg_resources.working_set.entry_keys`, and `pkg_resources.working_set.by_key`. And then restore them in `finally`. – Mitar Jul 08 '18 at 17:47