diff --git a/Doc/packaging/setupcfg.rst b/Doc/packaging/setupcfg.rst index 463522ba422..2b01ffb0212 100644 --- a/Doc/packaging/setupcfg.rst +++ b/Doc/packaging/setupcfg.rst @@ -176,15 +176,19 @@ compilers compilers = hotcompiler.SmartCCompiler -setup_hook - defines a callable that will be called right after the - :file:`setup.cfg` file is read. The callable receives the configuration - in form of a mapping and can make some changes to it. *optional* +setup_hooks + Defines a list of callables to be called right after the :file:`setup.cfg` + file is read, before any other processing. The callables are executed in the + order they're found in the file; if one of them cannot be found, tools should + not stop, but for example produce a warning and continue with the next line. + Each callable receives the configuration as a dictionary (keys are + :file:`setup.cfg` sections, values are dictionaries of fields) and can make + any changes to it. *optional*, *multi* Example:: [global] - setup_hook = package.setup.customize_dist + setup_hooks = package.setup.customize_dist Metadata diff --git a/Lib/packaging/config.py b/Lib/packaging/config.py index 3427d9ac354..21bbcf8567a 100644 --- a/Lib/packaging/config.py +++ b/Lib/packaging/config.py @@ -61,17 +61,15 @@ def get_resources_dests(resources_root, rules): class Config: - """Reads configuration files and work with the Distribution instance - """ + """Class used to work with configuration files""" def __init__(self, dist): self.dist = dist - self.setup_hook = None + self.setup_hooks = [] - def run_hook(self, config): - if self.setup_hook is None: - return - # the hook gets only the config - self.setup_hook(config) + def run_hooks(self, config): + """Run setup hooks in the order defined in the spec.""" + for hook in self.setup_hooks: + hook(config) def find_config_files(self): """Find as many configuration files as should be processed for this @@ -131,17 +129,20 @@ class Config: for section in parser.sections(): content[section] = dict(parser.items(section)) - # global:setup_hook is called *first* + # global setup hooks are called first if 'global' in content: - if 'setup_hook' in content['global']: - setup_hook = content['global']['setup_hook'] - try: - self.setup_hook = resolve_name(setup_hook) - except ImportError as e: - logger.warning('could not import setup_hook: %s', - e.args[0]) - else: - self.run_hook(content) + if 'setup_hooks' in content['global']: + setup_hooks = split_multiline(content['global']['setup_hooks']) + + for line in setup_hooks: + try: + hook = resolve_name(line) + except ImportError as e: + logger.warning('cannot find setup hook: %s', e.args[0]) + else: + self.setup_hooks.append(hook) + + self.run_hooks(content) metadata = self.dist.metadata diff --git a/Lib/packaging/tests/test_config.py b/Lib/packaging/tests/test_config.py index 1669862fdfa..6be63ebbeb7 100644 --- a/Lib/packaging/tests/test_config.py +++ b/Lib/packaging/tests/test_config.py @@ -90,7 +90,7 @@ commands = compilers = packaging.tests.test_config.DCompiler -setup_hook = %(setup-hook)s +setup_hooks = %(setup-hooks)s @@ -135,8 +135,16 @@ class DCompiler: pass -def hook(content): - content['metadata']['version'] += '.dev1' +def version_hook(config): + config['metadata']['version'] += '.dev1' + + +def first_hook(config): + config['files']['modules'] += '\n first' + + +def third_hook(config): + config['files']['modules'] += '\n third' class FooBarBazTest: @@ -186,7 +194,7 @@ class ConfigTestCase(support.TempdirManager, def write_setup(self, kwargs=None): opts = {'description-file': 'README', 'extra-files': '', - 'setup-hook': 'packaging.tests.test_config.hook'} + 'setup-hooks': 'packaging.tests.test_config.version_hook'} if kwargs: opts.update(kwargs) self.write_file('setup.cfg', SETUP_CFG % opts, encoding='utf-8') @@ -318,13 +326,27 @@ class ConfigTestCase(support.TempdirManager, self.assertEqual(ext.extra_compile_args, cargs) self.assertEqual(ext.language, 'cxx') - def test_missing_setuphook_warns(self): - self.write_setup({'setup-hook': 'this.does._not.exist'}) + def test_missing_setup_hook_warns(self): + self.write_setup({'setup-hooks': 'this.does._not.exist'}) self.write_file('README', 'yeah') dist = self.get_dist() logs = self.get_logs(logging.WARNING) self.assertEqual(1, len(logs)) - self.assertIn('could not import setup_hook', logs[0]) + self.assertIn('cannot find setup hook', logs[0]) + + def test_multiple_setup_hooks(self): + self.write_setup({ + 'setup-hooks': '\n packaging.tests.test_config.first_hook' + '\n packaging.tests.test_config.missing_hook' + '\n packaging.tests.test_config.third_hook' + }) + self.write_file('README', 'yeah') + dist = self.get_dist() + + self.assertEqual(['haven', 'first', 'third'], dist.py_modules) + logs = self.get_logs(logging.WARNING) + self.assertEqual(1, len(logs)) + self.assertIn('cannot find setup hook', logs[0]) def test_metadata_requires_description_files_missing(self): self.write_setup({'description-file': 'README README2'}) diff --git a/Lib/packaging/tests/test_util.py b/Lib/packaging/tests/test_util.py index 68ad8ebeb3c..f657ab25fa2 100644 --- a/Lib/packaging/tests/test_util.py +++ b/Lib/packaging/tests/test_util.py @@ -495,7 +495,7 @@ class UtilTestCase(support.EnvironRestorer, def test_cfg_to_args(self): opts = {'description-file': 'README', 'extra-files': '', - 'setup-hook': 'packaging.tests.test_config.hook'} + 'setup-hooks': 'packaging.tests.test_config.version_hook'} self.write_file('setup.cfg', SETUP_CFG % opts) self.write_file('README', 'loooong description') diff --git a/Misc/NEWS b/Misc/NEWS index 5b01308122f..47f3b0e7aa7 100644 --- a/Misc/NEWS +++ b/Misc/NEWS @@ -187,6 +187,9 @@ Core and Builtins Library ------- +- Issue #12240: Allow multiple setup hooks in packaging's setup.cfg files. + Original patch by Erik Bray. + - Issue #11595: Fix assorted bugs in packaging.util.cfg_to_args, a compatibility helper for the distutils-packaging transition. Original patch by Erik Bray.