[Python] setuptoolsを使用すると、スクリプト本体はパッケージのディレクトリ/EGG-INFO/scriptsにインストールされる。

はじめに

この記事の内容は、Ubuntu Linux 6.10, Python 2.4.4c1で確認しました。

結論

以下のように、setup.pyでsetuptoolsを用い、フロントエンドとなるスクリプトpydumpfsをsetup関数のscripts引数に指定したとします。

#! python
# -*- coding: utf-8 -*-

import ez_setup
ez_setup.use_setuptools()

from setuptools import setup, find_packages

setup(name="pydumpfs", version="0.1", packages=find_packages("src"), 
    package_dir={"": "src"}, test_suite="pydumpfs.tests", scripts=["pydumpfs"])

このとき、

$ sudo python setup.py install

を実行すると、/usr/bin/にpydumpfsというスクリプトがインストールされますが、これはsetuptoolsが作成したラッパーで、実際のスクリプトは/usr/lib/python2.4/site-packages/pydumpfs-0.1-py2.4.egg/EGG-INFO/scriptsにインストールされます。

詳細

/usr/bin/pydumpfsは、以下のようになっています。

#!/usr/bin/python
# EASY-INSTALL-SCRIPT: 'pydumpfs==0.1','pydumpfs'
__requires__ = 'pydumpfs==0.1'
import pkg_resources
pkg_resources.run_script('pydumpfs==0.1', 'pydumpfs')

/usr/lib/python2.4/site-packages/setuptools-0.6c6-py2.4.egg/pkg_resources.pyを読むと、run_scriptは以下のようになってます。

def run_script(dist_spec, script_name):
    """Locate distribution `dist_spec` and run its `script_name` script"""
    ns = sys._getframe(1).f_globals
    name = ns['__name__']
    ns.clear()
    ns['__name__'] = name
    require(dist_spec)[0].run_script(script_name, ns)

require関数は、パッケージの名前とバージョン番号から、「何か」(このコードではそれが何かわかりません)をリストとして返す関数のようです。同じファイルで、以下のように定義されています。

working_set = WorkingSet()
(中略)
require = working_set.require

WorkingSetクラスのrequireメソッドは、以下のように定義されています。

    def require(self, *requirements):
        """Ensure that distributions matching `requirements` are activated

        `requirements` must be a string or a (possibly-nested) sequence
        thereof, specifying the distributions and versions required.  The
        return value is a sequence of the distributions that needed to be
        activated to fulfill the requirements; all relevant distributions are
        included, even if they were already activated in this working set.
        """

        needed = self.resolve(parse_requirements(requirements))

        for dist in needed:
            self.add(dist)

        return needed

この関数の戻り値となるneededが何か、まだ分かりません。次にresolveメソッド(以下)を読んでみますが...。

    def resolve(self, requirements, env=None, installer=None):
        """List all distributions needed to (recursively) meet `requirements`

        `requirements` must be a sequence of ``Requirement`` objects.  `env`,
        if supplied, should be an ``Environment`` instance.  If
        not supplied, it defaults to all distributions available within any
        entry or distribution in the working set.  `installer`, if supplied,
        will be invoked with each requirement that cannot be met by an
        already-installed distribution; it should return a ``Distribution`` or
        ``None``.
        """

        requirements = list(requirements)[::-1]  # set up the stack
        processed = {}  # set of processed requirements
        best = {}  # key -> dist
        to_activate = []

        while requirements:
            req = requirements.pop(0)   # process dependencies breadth-first
            if req in processed:
                # Ignore cyclic or redundant dependencies
                continue
            dist = best.get(req.key)
            if dist is None:
                # Find the best distribution and add it to the map
                dist = self.by_key.get(req.key)
                if dist is None:
                    if env is None:
                        env = Environment(self.entries)
                    dist = best[req.key] = env.best_match(req, self, installer)
                    if dist is None:
                        raise DistributionNotFound(req)  # XXX put more info here
                to_activate.append(dist)
            if dist not in req:
                # Oops, the "best" so far conflicts with a dependency
                raise VersionConflict(dist,req) # XXX put more info here
            requirements.extend(dist.requires(req.extras)[::-1])
            processed[req] = True

        return to_activate    # return list of distros to activate

これを読んでみても、何をしているのか分かりません。どうやら、さらにEnvironmentクラスのbest_matchメソッドを読まないといけないようですが、これ以上は深追いしないことにします。

さて、require関数が返した「何か」のrun_scriptメソッドを呼び出すことで、実際のスクリプトを実行しているようですが、run_scriptメソッドを持っているクラスは、pkg_resources.pyの中に3つあります。

ひとつ目はIMetadataProviderクラスです。このクラスのrun_scriptメソッド(以下)は、実装がないので、これが呼び出されることはないと思われます。

class IMetadataProvider:
(中略)
    def run_script(script_name, namespace):
        """Execute the named script in the supplied namespace dictionary"""

ふたつ目はWorkingSetクラスです(以下)。

class WorkingSet(object):
(中略)
    def run_script(self, requires, script_name):
        """Locate distribution for `requires` and run `script_name` script"""
        ns = sys._getframe(1).f_globals
        name = ns['__name__']
        ns.clear()
        ns['__name__'] = name
        self.require(requires)[0].run_script(script_name, ns)

三つめはNullProviderクラスです。

class NullProvider:
(中略)
    def run_script(self,script_name,namespace):
        script = 'scripts/'+script_name
        if not self.has_metadata(script):
            raise ResolutionError("No script named %r" % script_name)
        script_text = self.get_metadata(script).replace('\r\n','\n')
        script_text = script_text.replace('\r','\n')
        script_filename = self._fn(self.egg_info,script)
        namespace['__file__'] = script_filename
        if os.path.exists(script_filename):
            execfile(script_filename, namespace, namespace)
        else:
            from linecache import cache
            cache[script_filename] = (
                len(script_text), 0, script_text.split('\n'), script_filename
            )
            script_code = compile(script_text,script_filename,'exec')
            exec script_code in namespace, namespace

どうもコードの内容からすると、最終的に呼び出されるのはNullProviderクラスのrun_scriptメソッドではないかという気がします。

どう動いているのかはっきりさせることはできませんでしたが、setuptoolsが/usr/binにインストールするスクリプトは「プロキシ」であって、実際のスクリプトはパッケージの中にインストールされるようです。