From d04573fef0346ee9a131e0c63d18ab9fbd12ea63 Mon Sep 17 00:00:00 2001 From: Fred Drake Date: Tue, 3 Aug 2004 16:37:40 +0000 Subject: This allows additional commands to be provided for existing setup.py scripts without modifying either the distutils installation or the setup.py scripts of packages with which the new commands will be used. Specifically, an option is added to distutils that allows additional packages to be searched for command implementations in addition to distutils.command. The additional packages can be specified on the command line or via the installation or personal configuration files already loaded by distutils. For discussion, see the thread starting with: http://mail.python.org/pipermail/distutils-sig/2004-August/004112.html This closes SF patch #102241. --- Doc/dist/dist.tex | 31 ++++++++++++ Lib/distutils/dist.py | 84 +++++++++++++++++++++++--------- Lib/distutils/tests/test_dist.py | 100 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 Lib/distutils/tests/test_dist.py diff --git a/Doc/dist/dist.tex b/Doc/dist/dist.tex index 5a95d78..784c908 100644 --- a/Doc/dist/dist.tex +++ b/Doc/dist/dist.tex @@ -1946,6 +1946,37 @@ This approach is most valuable if the new implementations must be used to use a particular package, as everyone interested in the package will need to have the new command implementation. +Beginning with Python 2.4, a third option is available, intended to +allow new commands to be added which can support existing +\file{setup.py} scripts without requiring modifications to the Python +installation. This is expected to allow third-party extensions to +provide support for additional packaging systems, but the commands can +be used for anything distutils commands can be used for. A new +configuration option, \option{command\_packages} (command-line option +\longprogramopt{command-packages}), can be used to specify additional +packages to be searched for modules implementing commands. Like all +distutils options, this can be specified on the command line or in a +configuration file. This option can only be set in the +\code{[global]} section of a configuration file, or before any +commands on the command line. If set in a configuration file, it can +be overridden from the command line; setting it to an empty string on +the command line causes the default to be used. This should never be +set in a configuration file provided with a package. + +This new option can be used to add any number of packages to the list +of packages searched for command implementations; multiple package +names should be separated by commas. When not specified, the search +is only performed in the \module{distutils.command} package. When +\file{setup.py} is run with the option +\longprogramopt{command-packages} \programopt{distcmds,buildcmds}, +however, the packages \module{distutils.command}, \module{distcmds}, +and \module{buildcmds} will be searched in that order. New commands +are expected to be implemented in modules of the same name as the +command by classes sharing the same name. Given the example command +line option above, the command \command{bdist\_openpkg} could be +implemented by the class \class{distcmds.bdist_openpkg.bdist_openpkg} +or \class{buildcmds.bdist_openpkg.bdist_openpkg}. + \chapter{Command Reference} \label{reference} diff --git a/Lib/distutils/dist.py b/Lib/distutils/dist.py index b4dd0b9..53846e9 100644 --- a/Lib/distutils/dist.py +++ b/Lib/distutils/dist.py @@ -141,6 +141,14 @@ class Distribution: # for the setup script to override command classes self.cmdclass = {} + # 'command_packages' is a list of packages in which commands + # are searched for. The factory for command 'foo' is expected + # to be named 'foo' in the module 'foo' in one of the packages + # named here. This list is searched from the left; an error + # is raised if no named package provides the command being + # searched for. (Always access using get_command_packages().) + self.command_packages = None + # 'script_name' and 'script_args' are usually set to sys.argv[0] # and sys.argv[1:], but they can be overridden when the caller is # not necessarily a setup script run from the command-line. @@ -406,6 +414,8 @@ class Distribution: setattr(self, alias, not strtobool(val)) elif opt in ('verbose', 'dry_run'): # ugh! setattr(self, opt, strtobool(val)) + else: + setattr(self, opt, val) except ValueError, msg: raise DistutilsOptionError, msg @@ -437,11 +447,12 @@ class Distribution: # We now have enough information to show the Macintosh dialog # that allows the user to interactively specify the "command line". # + toplevel_options = self._get_toplevel_options() if sys.platform == 'mac': import EasyDialogs cmdlist = self.get_command_list() self.script_args = EasyDialogs.GetArgv( - self.global_options + self.display_options, cmdlist) + toplevel_options + self.display_options, cmdlist) # We have to parse the command line a bit at a time -- global # options, then the first command, then its options, and so on -- @@ -451,7 +462,7 @@ class Distribution: # until we know what the command is. self.commands = [] - parser = FancyGetopt(self.global_options + self.display_options) + parser = FancyGetopt(toplevel_options + self.display_options) parser.set_negative_aliases(self.negative_opt) parser.set_aliases({'licence': 'license'}) args = parser.getopt(args=self.script_args, object=self) @@ -488,6 +499,17 @@ class Distribution: # parse_command_line() + def _get_toplevel_options (self): + """Return the non-display options recognized at the top level. + + This includes options that are recognized *only* at the top + level as well as options recognized for commands. + """ + return self.global_options + [ + ("command-packages=", None, + "list of packages that provide distutils commands"), + ] + def _parse_command_opts (self, parser, args): """Parse the command-line options for a single command. 'parser' must be a FancyGetopt instance; 'args' must be the list @@ -586,7 +608,6 @@ class Distribution: # _parse_command_opts () - def finalize_options (self): """Set final values for all the options on the Distribution instance, analogous to the .finalize_options() method of Command @@ -627,7 +648,11 @@ class Distribution: from distutils.cmd import Command if global_options: - parser.set_option_table(self.global_options) + if display_options: + options = self._get_toplevel_options() + else: + options = self.global_options + parser.set_option_table(options) parser.print_help("Global options:") print @@ -791,6 +816,19 @@ class Distribution: # -- Command class/object methods ---------------------------------- + def get_command_packages (self): + """Return a list of packages from which commands are loaded.""" + pkgs = self.command_packages + if not isinstance(pkgs, type([])): + pkgs = string.split(pkgs or "", ",") + for i in range(len(pkgs)): + pkgs[i] = string.strip(pkgs[i]) + pkgs = filter(None, pkgs) + if "distutils.command" not in pkgs: + pkgs.insert(0, "distutils.command") + self.command_packages = pkgs + return pkgs + def get_command_class (self, command): """Return the class that implements the Distutils command named by 'command'. First we check the 'cmdclass' dictionary; if the @@ -807,26 +845,28 @@ class Distribution: if klass: return klass - module_name = 'distutils.command.' + command - klass_name = command + for pkgname in self.get_command_packages(): + module_name = "%s.%s" % (pkgname, command) + klass_name = command - try: - __import__ (module_name) - module = sys.modules[module_name] - except ImportError: - raise DistutilsModuleError, \ - "invalid command '%s' (no module named '%s')" % \ - (command, module_name) + try: + __import__ (module_name) + module = sys.modules[module_name] + except ImportError: + continue + + try: + klass = getattr(module, klass_name) + except AttributeError: + raise DistutilsModuleError, \ + "invalid command '%s' (no class '%s' in module '%s')" \ + % (command, klass_name, module_name) + + self.cmdclass[command] = klass + return klass + + raise DistutilsModuleError("invalid command '%s'" % command) - try: - klass = getattr(module, klass_name) - except AttributeError: - raise DistutilsModuleError, \ - "invalid command '%s' (no class '%s' in module '%s')" \ - % (command, klass_name, module_name) - - self.cmdclass[command] = klass - return klass # get_command_class () diff --git a/Lib/distutils/tests/test_dist.py b/Lib/distutils/tests/test_dist.py new file mode 100644 index 0000000..695f6d8 --- /dev/null +++ b/Lib/distutils/tests/test_dist.py @@ -0,0 +1,100 @@ +"""Tests for distutils.dist.""" + +import distutils.cmd +import distutils.dist +import os +import shutil +import sys +import tempfile +import unittest + +from test.test_support import TESTFN + + +class test_dist(distutils.cmd.Command): + """Sample distutils extension command.""" + + user_options = [ + ("sample-option=", "S", "help text"), + ] + + def initialize_options(self): + self.sample_option = None + + +class TestDistribution(distutils.dist.Distribution): + """Distribution subclasses that avoids the default search for + configuration files. + + The ._config_files attribute must be set before + .parse_config_files() is called. + """ + + def find_config_files(self): + return self._config_files + + +class DistributionTestCase(unittest.TestCase): + + def setUp(self): + self.argv = sys.argv[:] + del sys.argv[1:] + + def tearDown(self): + sys.argv[:] = self.argv + + def create_distribution(self, configfiles=()): + d = TestDistribution() + d._config_files = configfiles + d.parse_config_files() + d.parse_command_line() + return d + + def test_command_packages_unspecified(self): + sys.argv.append("build") + d = self.create_distribution() + self.assertEqual(d.get_command_packages(), ["distutils.command"]) + + def test_command_packages_cmdline(self): + sys.argv.extend(["--command-packages", + "foo.bar,distutils.tests", + "test_dist", + "-Ssometext", + ]) + d = self.create_distribution() + # let's actually try to load our test command: + self.assertEqual(d.get_command_packages(), + ["distutils.command", "foo.bar", "distutils.tests"]) + cmd = d.get_command_obj("test_dist") + self.assert_(isinstance(cmd, test_dist)) + self.assertEqual(cmd.sample_option, "sometext") + + def test_command_packages_configfile(self): + sys.argv.append("build") + f = open(TESTFN, "w") + try: + print >>f, "[global]" + print >>f, "command_packages = foo.bar, splat" + f.close() + d = self.create_distribution([TESTFN]) + self.assertEqual(d.get_command_packages(), + ["distutils.command", "foo.bar", "splat"]) + + # ensure command line overrides config: + sys.argv[1:] = ["--command-packages", "spork", "build"] + d = self.create_distribution([TESTFN]) + self.assertEqual(d.get_command_packages(), + ["distutils.command", "spork"]) + + # Setting --command-packages to '' should cause the default to + # be used even if a config file specified something else: + sys.argv[1:] = ["--command-packages", "", "build"] + d = self.create_distribution([TESTFN]) + self.assertEqual(d.get_command_packages(), ["distutils.command"]) + + finally: + os.unlink(TESTFN) + + +def test_suite(): + return unittest.makeSuite(DistributionTestCase) -- cgit v0.12