• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""A PEP 517 interface to setuptools
2
3Previously, when a user or a command line tool (let's call it a "frontend")
4needed to make a request of setuptools to take a certain action, for
5example, generating a list of installation requirements, the frontend would
6would call "setup.py egg_info" or "setup.py bdist_wheel" on the command line.
7
8PEP 517 defines a different method of interfacing with setuptools. Rather
9than calling "setup.py" directly, the frontend should:
10
11  1. Set the current directory to the directory with a setup.py file
12  2. Import this module into a safe python interpreter (one in which
13     setuptools can potentially set global variables or crash hard).
14  3. Call one of the functions defined in PEP 517.
15
16What each function does is defined in PEP 517. However, here is a "casual"
17definition of the functions (this definition should not be relied on for
18bug reports or API stability):
19
20  - `build_wheel`: build a wheel in the folder and return the basename
21  - `get_requires_for_build_wheel`: get the `setup_requires` to build
22  - `prepare_metadata_for_build_wheel`: get the `install_requires`
23  - `build_sdist`: build an sdist in the folder and return the basename
24  - `get_requires_for_build_sdist`: get the `setup_requires` to build
25
26Again, this is not a formal definition! Just a "taste" of the module.
27"""
28
29import io
30import os
31import sys
32import tokenize
33import shutil
34import contextlib
35import tempfile
36import warnings
37
38import setuptools
39import distutils
40from ._reqs import parse_strings
41from .extern.more_itertools import always_iterable
42
43
44__all__ = ['get_requires_for_build_sdist',
45           'get_requires_for_build_wheel',
46           'prepare_metadata_for_build_wheel',
47           'build_wheel',
48           'build_sdist',
49           '__legacy__',
50           'SetupRequirementsError']
51
52
53class SetupRequirementsError(BaseException):
54    def __init__(self, specifiers):
55        self.specifiers = specifiers
56
57
58class Distribution(setuptools.dist.Distribution):
59    def fetch_build_eggs(self, specifiers):
60        specifier_list = list(parse_strings(specifiers))
61
62        raise SetupRequirementsError(specifier_list)
63
64    @classmethod
65    @contextlib.contextmanager
66    def patch(cls):
67        """
68        Replace
69        distutils.dist.Distribution with this class
70        for the duration of this context.
71        """
72        orig = distutils.core.Distribution
73        distutils.core.Distribution = cls
74        try:
75            yield
76        finally:
77            distutils.core.Distribution = orig
78
79
80@contextlib.contextmanager
81def no_install_setup_requires():
82    """Temporarily disable installing setup_requires
83
84    Under PEP 517, the backend reports build dependencies to the frontend,
85    and the frontend is responsible for ensuring they're installed.
86    So setuptools (acting as a backend) should not try to install them.
87    """
88    orig = setuptools._install_setup_requires
89    setuptools._install_setup_requires = lambda attrs: None
90    try:
91        yield
92    finally:
93        setuptools._install_setup_requires = orig
94
95
96def _get_immediate_subdirectories(a_dir):
97    return [name for name in os.listdir(a_dir)
98            if os.path.isdir(os.path.join(a_dir, name))]
99
100
101def _file_with_extension(directory, extension):
102    matching = (
103        f for f in os.listdir(directory)
104        if f.endswith(extension)
105    )
106    try:
107        file, = matching
108    except ValueError:
109        raise ValueError(
110            'No distribution was found. Ensure that `setup.py` '
111            'is not empty and that it calls `setup()`.')
112    return file
113
114
115def _open_setup_script(setup_script):
116    if not os.path.exists(setup_script):
117        # Supply a default setup.py
118        return io.StringIO(u"from setuptools import setup; setup()")
119
120    return getattr(tokenize, 'open', open)(setup_script)
121
122
123@contextlib.contextmanager
124def suppress_known_deprecation():
125    with warnings.catch_warnings():
126        warnings.filterwarnings('ignore', 'setup.py install is deprecated')
127        yield
128
129
130class _BuildMetaBackend:
131
132    @staticmethod
133    def _fix_config(config_settings):
134        """
135        Ensure config settings meet certain expectations.
136
137        >>> fc = _BuildMetaBackend._fix_config
138        >>> fc(None)
139        {'--global-option': []}
140        >>> fc({})
141        {'--global-option': []}
142        >>> fc({'--global-option': 'foo'})
143        {'--global-option': ['foo']}
144        >>> fc({'--global-option': ['foo']})
145        {'--global-option': ['foo']}
146        """
147        config_settings = config_settings or {}
148        config_settings['--global-option'] = list(always_iterable(
149            config_settings.get('--global-option')))
150        return config_settings
151
152    def _get_build_requires(self, config_settings, requirements):
153        config_settings = self._fix_config(config_settings)
154
155        sys.argv = sys.argv[:1] + ['egg_info'] + \
156            config_settings["--global-option"]
157        try:
158            with Distribution.patch():
159                self.run_setup()
160        except SetupRequirementsError as e:
161            requirements += e.specifiers
162
163        return requirements
164
165    def run_setup(self, setup_script='setup.py'):
166        # Note that we can reuse our build directory between calls
167        # Correctness comes first, then optimization later
168        __file__ = setup_script
169        __name__ = '__main__'
170
171        with _open_setup_script(__file__) as f:
172            code = f.read().replace(r'\r\n', r'\n')
173
174        exec(compile(code, __file__, 'exec'), locals())
175
176    def get_requires_for_build_wheel(self, config_settings=None):
177        return self._get_build_requires(
178            config_settings, requirements=['wheel'])
179
180    def get_requires_for_build_sdist(self, config_settings=None):
181        return self._get_build_requires(config_settings, requirements=[])
182
183    def prepare_metadata_for_build_wheel(self, metadata_directory,
184                                         config_settings=None):
185        sys.argv = sys.argv[:1] + [
186            'dist_info', '--egg-base', metadata_directory]
187        with no_install_setup_requires():
188            self.run_setup()
189
190        dist_info_directory = metadata_directory
191        while True:
192            dist_infos = [f for f in os.listdir(dist_info_directory)
193                          if f.endswith('.dist-info')]
194
195            if (
196                len(dist_infos) == 0 and
197                len(_get_immediate_subdirectories(dist_info_directory)) == 1
198            ):
199
200                dist_info_directory = os.path.join(
201                    dist_info_directory, os.listdir(dist_info_directory)[0])
202                continue
203
204            assert len(dist_infos) == 1
205            break
206
207        # PEP 517 requires that the .dist-info directory be placed in the
208        # metadata_directory. To comply, we MUST copy the directory to the root
209        if dist_info_directory != metadata_directory:
210            shutil.move(
211                os.path.join(dist_info_directory, dist_infos[0]),
212                metadata_directory)
213            shutil.rmtree(dist_info_directory, ignore_errors=True)
214
215        return dist_infos[0]
216
217    def _build_with_temp_dir(self, setup_command, result_extension,
218                             result_directory, config_settings):
219        config_settings = self._fix_config(config_settings)
220        result_directory = os.path.abspath(result_directory)
221
222        # Build in a temporary directory, then copy to the target.
223        os.makedirs(result_directory, exist_ok=True)
224        with tempfile.TemporaryDirectory(dir=result_directory) as tmp_dist_dir:
225            sys.argv = (sys.argv[:1] + setup_command +
226                        ['--dist-dir', tmp_dist_dir] +
227                        config_settings["--global-option"])
228            with no_install_setup_requires():
229                self.run_setup()
230
231            result_basename = _file_with_extension(
232                tmp_dist_dir, result_extension)
233            result_path = os.path.join(result_directory, result_basename)
234            if os.path.exists(result_path):
235                # os.rename will fail overwriting on non-Unix.
236                os.remove(result_path)
237            os.rename(os.path.join(tmp_dist_dir, result_basename), result_path)
238
239        return result_basename
240
241    def build_wheel(self, wheel_directory, config_settings=None,
242                    metadata_directory=None):
243        with suppress_known_deprecation():
244            return self._build_with_temp_dir(['bdist_wheel'], '.whl',
245                                             wheel_directory, config_settings)
246
247    def build_sdist(self, sdist_directory, config_settings=None):
248        return self._build_with_temp_dir(['sdist', '--formats', 'gztar'],
249                                         '.tar.gz', sdist_directory,
250                                         config_settings)
251
252
253class _BuildMetaLegacyBackend(_BuildMetaBackend):
254    """Compatibility backend for setuptools
255
256    This is a version of setuptools.build_meta that endeavors
257    to maintain backwards
258    compatibility with pre-PEP 517 modes of invocation. It
259    exists as a temporary
260    bridge between the old packaging mechanism and the new
261    packaging mechanism,
262    and will eventually be removed.
263    """
264    def run_setup(self, setup_script='setup.py'):
265        # In order to maintain compatibility with scripts assuming that
266        # the setup.py script is in a directory on the PYTHONPATH, inject
267        # '' into sys.path. (pypa/setuptools#1642)
268        sys_path = list(sys.path)           # Save the original path
269
270        script_dir = os.path.dirname(os.path.abspath(setup_script))
271        if script_dir not in sys.path:
272            sys.path.insert(0, script_dir)
273
274        # Some setup.py scripts (e.g. in pygame and numpy) use sys.argv[0] to
275        # get the directory of the source code. They expect it to refer to the
276        # setup.py script.
277        sys_argv_0 = sys.argv[0]
278        sys.argv[0] = setup_script
279
280        try:
281            super(_BuildMetaLegacyBackend,
282                  self).run_setup(setup_script=setup_script)
283        finally:
284            # While PEP 517 frontends should be calling each hook in a fresh
285            # subprocess according to the standard (and thus it should not be
286            # strictly necessary to restore the old sys.path), we'll restore
287            # the original path so that the path manipulation does not persist
288            # within the hook after run_setup is called.
289            sys.path[:] = sys_path
290            sys.argv[0] = sys_argv_0
291
292
293# The primary backend
294_BACKEND = _BuildMetaBackend()
295
296get_requires_for_build_wheel = _BACKEND.get_requires_for_build_wheel
297get_requires_for_build_sdist = _BACKEND.get_requires_for_build_sdist
298prepare_metadata_for_build_wheel = _BACKEND.prepare_metadata_for_build_wheel
299build_wheel = _BACKEND.build_wheel
300build_sdist = _BACKEND.build_sdist
301
302
303# The legacy backend
304__legacy__ = _BuildMetaLegacyBackend()
305