26

The Twisted Plugin System is the preferred way to write extensible twisted applications.

However, due to the way the plugin system is structured (plugins go into a twisted/plugins directory which should not be a Python package), writing a proper setup.py for installing those plugins appears to be non-trivial.

I've seen some attempts that add 'twisted.plugins' to the 'packages' key of the distutils setup command, but since it is not really a package, bad things happen (for example, an __init__.py is helpfully added by some tools).

Other attempts seem to use 'package_data' instead (eg, http://bazaar.launchpad.net/~glyph/divmod.org/trunk/view/head:/Epsilon/epsilon/setuphelper.py), but that can also fail in weird ways.

The question is: has anyone successfully written a setup.py for installing twisted plugins which works in all cases?

Glyph
  • 31,152
  • 11
  • 87
  • 129
Sidnei
  • 531
  • 4
  • 8
  • 4
    A description of the `package_data` failure would be helpful. – Glyph Sep 01 '11 at 23:16
  • This doesn't answer the explicit part of this question, i.e. how to *specify* the files to be installed, but it does address a somewhat cleaner way of (re-) generating the plugin cache (which is implied as part of it) http://stackoverflow.com/questions/1321270/how-to-extend-distutils-with-a-simple-post-install-script – Glyph Aug 20 '13 at 21:21

4 Answers4

17

I document a setup.py below that is needed only if you have users with pip < 1.2 (e.g. on Ubuntu 12.04). If everyone has pip 1.2 or newer, the only thing you need is packages=[..., 'twisted.plugins'].

By preventing pip from writing the line "twisted" to .egg-info/top_level.txt, you can keep using packages=[..., 'twisted.plugins'] and have a working pip uninstall that doesn't remove all of twisted/. This involves monkeypatching setuptools/distribute near the top of your setup.py. Here is a sample setup.py:

from distutils.core import setup

# When pip installs anything from packages, py_modules, or ext_modules that
# includes a twistd plugin (which are installed to twisted/plugins/),
# setuptools/distribute writes a Package.egg-info/top_level.txt that includes
# "twisted".  If you later uninstall Package with `pip uninstall Package`,
# pip <1.2 removes all of twisted/ instead of just Package's twistd plugins.
# See https://github.com/pypa/pip/issues/355 (now fixed)
#
# To work around this problem, we monkeypatch
# setuptools.command.egg_info.write_toplevel_names to not write the line
# "twisted".  This fixes the behavior of `pip uninstall Package`.  Note that
# even with this workaround, `pip uninstall Package` still correctly uninstalls
# Package's twistd plugins from twisted/plugins/, since pip also uses
# Package.egg-info/installed-files.txt to determine what to uninstall,
# and the paths to the plugin files are indeed listed in installed-files.txt.
try:
    from setuptools.command import egg_info
    egg_info.write_toplevel_names
except (ImportError, AttributeError):
    pass
else:
    def _top_level_package(name):
        return name.split('.', 1)[0]

    def _hacked_write_toplevel_names(cmd, basename, filename):
        pkgs = dict.fromkeys(
            [_top_level_package(k)
                for k in cmd.distribution.iter_distribution_names()
                if _top_level_package(k) != "twisted"
            ]
        )
        cmd.write_file("top-level names", filename, '\n'.join(pkgs) + '\n')

    egg_info.write_toplevel_names = _hacked_write_toplevel_names

setup(
    name='MyPackage',
    version='1.0',
    description="You can do anything with MyPackage, anything at all.",
    url="http://example.com/",
    author="John Doe",
    author_email="jdoe@example.com",
    packages=['mypackage', 'twisted.plugins'],
    # You may want more options here, including install_requires=,
    # package_data=, and classifiers=
)

# Make Twisted regenerate the dropin.cache, if possible.  This is necessary
# because in a site-wide install, dropin.cache cannot be rewritten by
# normal users.
try:
    from twisted.plugin import IPlugin, getPlugins
except ImportError:
    pass
else:
    list(getPlugins(IPlugin))

I've tested this with pip install, pip install --user, and easy_install. With any install method, the above monkeypatch and pip uninstall work fine.

You might be wondering: do I need to clear the monkeypatch to avoid messing up the next install? (e.g. pip install --no-deps MyPackage Twisted; you wouldn't want to affect Twisted's top_level.txt.) The answer is no; the monkeypatch does not affect another install because pip spawns a new python for each install.

Related: keep in mind that in your project, you must not have a file twisted/plugins/__init__.py. If you see this warning during installation:

package init file 'twisted/plugins/__init__.py' not found (or not a regular file)

it is completely normal and you should not try to fix it by adding an __init__.py.

Ivan Kozik
  • 845
  • 8
  • 12
  • 1
    Hacktastic! The uninstall of the plugin file will only occur in this case if pip was used to install the plugin-containing project (as opposed to easy_install or "python setup.py install"), because only pip writes installed-files.txt at install time. I think that's a drawback that any solution to this problem can't avoid, though, since having a full list of installed files is the only way to uninstall correctly if projects are allowed to go dropping files into other projects' packages. – Carl Meyer Sep 23 '11 at 07:27
  • This could use a *little* more explaining to be a complete answer: what does the `setup.py` look like? How does this work with a raw setup.py? What about with `easy_install`? How about with a `--user` install, etc. But, as Carl put it: hacktastic! (Hopefully this proves that it *is* possible to fix https://github.com/pypa/pip/issues/355 - at least in the case where pip was doing the installing ...) – Glyph Sep 23 '11 at 07:49
  • 1
    Updated answer per Glyph's comment – Ivan Kozik Sep 23 '11 at 09:20
  • Now: how many of these systems support a proper post-install hook that properly generates dropin.cache as per http://twistedmatrix.com/documents/11.0.0/core/howto/plugin.html#auto3 :) – Glyph Sep 23 '11 at 09:25
  • 2
    Updated with dropin.cache regeneration – Ivan Kozik Sep 23 '11 at 09:56
  • I should note that this dropin.cache regeneration is wrong for debian (you need a postinst/postrm) and wrong for bdist_wininst (which is more subtle and I don't quite understand it). I think it will probably work with pip, and ... in some cases, with easy_install, although I don't recall which cases caused a problem. – Glyph Sep 23 '11 at 21:06
  • Reference for bdist_wininst is here - http://docs.python.org/distutils/builtdist.html#the-postinstallation-script - I don't think this works with bdist_msi though. – Glyph Sep 23 '11 at 21:08
  • @Glyph - Oh, there's never been any doubt that it's _possible_ to fix #355 by ignoring top_level.txt if we have installed-files.txt (which is still not quite the same as this hack, since we're not going to special-case the "twisted" package in pip, as this does). The question was always whether this was a good idea or not :-) As you can see on that bug, I've reconsidered my take on that. – Carl Meyer Sep 23 '11 at 21:30
  • I just wanted to note that the dropin.cache regeneration is indeed "wrong" for Debian (as it needs to be done at package installation / removal time), but harmless as it won't cause any problems during building of the source package. – mithrandi Nov 29 '11 at 06:13
  • Is there somewhere that we can put the cache regeneration where _some_ standard debian tool will find it? Perhaps stdeb has a way to find post-install post-rm hooks that is an extension to distutils? – Glyph May 04 '12 at 19:29
  • 1
    The relevant stdeb issue appears to be here: https://github.com/astraw/stdeb/issues/46 – Glyph May 09 '12 at 22:15
  • 1
    Wokkel has a problem which is tangentially related, although I'm not sure if its setup.py is really following this advice: http://wokkel.ik.nu/ticket/76 – Glyph May 09 '12 at 22:24
  • Note that Pip bug #355 has now been fixed, although I'm not sure if it has been included in a release yet. – mithrandi Aug 04 '12 at 11:34
  • By now I'm pretty sure that it's been in a release. (Wouldn't it be nice if Github could aggregate this information for you? Oh well.) – Glyph Jun 28 '13 at 17:46
  • This is fixed as of Pip 1.2 – mithrandi Dec 19 '16 at 13:10
3

Here is a blog entry which describes doing it with 'package_data':

http://chrismiles.livejournal.com/23399.html

In what weird ways can that fail? It could fail if the installation of the package doesn't put the package data into a directory which is on the sys.path. In that case the Twisted plugin loader wouldn't find it. However, all installations of Python packages that I know of will put it into the same directory where they are installing the Python modules or packages themselves, so that won't be a problem.

Zooko
  • 2,342
  • 1
  • 18
  • 12
  • 3
    One of the problems with using package_data like that is that you still need to list `'twisted.plugins'` in the the list of packages; this results in `pip uninstall` blowing away your entire Twisted installation. – mithrandi Sep 02 '11 at 09:32
  • Is there any way to inform `pip` not to do this? Is there a bug report open in the Pip bugtracker? – Glyph Sep 04 '11 at 18:13
  • 1
    I haven't opened a bug report, but it does seem like a bug in pip; it has a record of exactly what files it installed, so I don't see why it needs to remove other files/directories that it didn't install (with the possible caveat of .py[co] files) – mithrandi Sep 04 '11 at 20:47
  • Aside from this bug in pip, are there any other issues? It sounds like this may be the right option. – Glyph Sep 10 '11 at 06:01
  • 1
    I've now filed a bug report here: https://github.com/pypa/pip/issues/355 -- the fact that `twisted` ends up in `top_level.txt` when installing a plugin module like this does suggest that the this approach may not be the right thing to do. – mithrandi Sep 20 '11 at 09:17
  • The linked post ends by saying "Unfortunately Twisted and setuptools don't play nicely together, so I'm not able to ... install it using easy_install", which sounds like a weird failure (but he doesn't describe what the failure is). Have things improved since then? I know we have some setuptools compatibility cruft now but I don't know how well it works. – Glyph Sep 23 '11 at 08:32
  • Additionally, the comment that Ivan had me put on Eric's answer may also apply here. – Glyph Sep 23 '11 at 08:33
  • Note that Pip bug #355 has now been fixed, although I'm not sure if it has been included in a release yet. – mithrandi Aug 04 '12 at 11:35
  • With the fix to Pip bug #355, Chris Miles's approach seems to work. I used it to package oauth-proxy just now and it appeared to work: https://github.com/mojodna/oauth-proxy/pull/5 – Zooko Sep 27 '13 at 04:15
2

Maybe you could adapt the package_data idea to use data_files instead: it wouldn’t require you to list twisted.plugins as package, as it uses absolute paths. It would still be a kludge, though.

My tests with pure distutils have told me that its is possible to overwrite files from another distribution. I wanted to test poor man’s namespace packages using pkgutil.extend_path and distutils, and it turns out that I can install spam/ham/__init__.py with spam.ham/setup.py and spam/eggs/__init__.py with spam.eggs/setup.py. Directories are not a problem, but files will be happily overwritten. I think this is actually undefined behavior in distutils which trickles up to setuptools and pip, so pip could IMO close as wontfix.

What is the usual way to install Twisted plugins? Drop-it-here by hand?

merwok
  • 6,779
  • 1
  • 28
  • 42
  • There is no "usual" way to install Twisted plugins - there are a couple of different random ideas floating around, each with their own drawbacks. This question is part of an effort to nail down one "right" way to do it, and at least have some concept of what the drawbacks are and how to cope with them. – Glyph Sep 23 '11 at 05:14
  • Pip has closed as wontfix :-) Does the data_files approach work? If so it seems like the most promising approach listed here. It would have the same drawback as the "patch write_toplevel_names" approach: the plugin would only be uninstalled correctly if pip had been used to install it. – Carl Meyer Sep 23 '11 at 07:24
  • 2
    Ivan tells me (but cannot comment due to SO's weird reputation limits) - One problem with this approach is that `pip uninstall` will not remove the corresponding .pyc for your .py data_file. You can't add the .pyc to your `data_files=` either, because `PYTHON_DONT_WRITE_BYTECODE` might be set. – Glyph Sep 23 '11 at 08:28
  • The .pyc/.pyo issue is a big one. If pip supports uninstall hooks, it could be done here (distutils2 will have such hooks). If not, it only shows that you must use py_modules or packages to include Python files. – merwok Sep 23 '11 at 17:12
  • [cont.] So it seems hard to use distutils with Twisted plugins, as they’re not regular public modules. The best that could probably be done would be to add a custom setup function in Twisted for use in plugins’ setup scripts. The function would get the path to the plugins directory and coerce or monkey-patch distutils into installing there. For uninstall, I don’t know; distutils only supports the --record option, but apparently pip does more. – merwok Sep 23 '11 at 17:16
  • 1
    In the future, I hope PEP 402 is accepted, then distutils2 will add support for it and Twisted will be able to change its custom idea of plugins for a namespace package (think mercurial and hgext). – merwok Sep 23 '11 at 17:16
  • For now, what's the final word here? – fiorix Jun 13 '12 at 07:39
1

I use this approach:

  1. Put '.py' and '.pyc' versions of your file to "twisted/plugins/" folder inside your package. Note that '.pyc' file can be empty, it just should exist.
  2. In setup.py specify copying both files to a library folder (make sure that you will not overwrite existing plugins!). For example:

    # setup.py
    
    from distutils import sysconfig
    
    LIB_PATH = sysconfig.get_python_lib()
    
    # ...
    
    plugin_name = '<your_package>/twisted/plugins/<plugin_name>'
    # '.pyc' extension is necessary for correct plugins removing
    data_files = [
      (os.path.join(LIB_PATH, 'twisted', 'plugins'),
       [''.join((plugin_name, extension)) for extension in ('.py', '.pyc')])
    ]
    
    setup(
          # ...
          data_files=data_files
    )
    
Oleksandr Fedorov
  • 1,213
  • 10
  • 17
  • What platforms, configurations, `bdist_*` plugins, etc, have you tested this with? – Glyph Nov 29 '13 at 03:38
  • 1
    Windows 7 and Red Hat 6; Python 2.7; bdist_dumb (gztar format); installing with `pip`. I believe this approach works for all `Python` versions less than `3.2` because of [this](http://docs.python.org/3.2/whatsnew/3.2.html#pep-3147-pyc-repository-directories). – Oleksandr Fedorov Nov 29 '13 at 13:25