1"""
2Create a wheel (.whl) distribution.
3
4A wheel is a built archive format.
5"""
6
7import distutils
8import os
9import shutil
10import stat
11import sys
12import re
13import warnings
14from collections import OrderedDict
15from distutils.core import Command
16from distutils import log as logger
17from io import BytesIO
18from glob import iglob
19from shutil import rmtree
20from sysconfig import get_config_var
21from zipfile import ZIP_DEFLATED, ZIP_STORED
22
23import pkg_resources
24
25from .pkginfo import write_pkg_info
26from .macosx_libfile import calculate_macosx_platform_tag
27from .metadata import pkginfo_to_metadata
28from .vendored.packaging import tags
29from .wheelfile import WheelFile
30from . import __version__ as wheel_version
31
32if sys.version_info < (3,):
33    from email.generator import Generator as BytesGenerator
34else:
35    from email.generator import BytesGenerator
36
37safe_name = pkg_resources.safe_name
38safe_version = pkg_resources.safe_version
39
40PY_LIMITED_API_PATTERN = r'cp3\d'
41
42
43def python_tag():
44    return 'py{}'.format(sys.version_info[0])
45
46
47def get_platform(archive_root):
48    """Return our platform name 'win32', 'linux_x86_64'"""
49    # XXX remove distutils dependency
50    result = distutils.util.get_platform()
51    if result.startswith("macosx") and archive_root is not None:
52        result = calculate_macosx_platform_tag(archive_root, result)
53    if result == "linux_x86_64" and sys.maxsize == 2147483647:
54        # pip pull request #3497
55        result = "linux_i686"
56    return result
57
58
59def get_flag(var, fallback, expected=True, warn=True):
60    """Use a fallback value for determining SOABI flags if the needed config
61    var is unset or unavailable."""
62    val = get_config_var(var)
63    if val is None:
64        if warn:
65            warnings.warn("Config variable '{0}' is unset, Python ABI tag may "
66                          "be incorrect".format(var), RuntimeWarning, 2)
67        return fallback
68    return val == expected
69
70
71def get_abi_tag():
72    """Return the ABI tag based on SOABI (if available) or emulate SOABI
73    (CPython 2, PyPy)."""
74    soabi = get_config_var('SOABI')
75    impl = tags.interpreter_name()
76    if not soabi and impl in ('cp', 'pp') and hasattr(sys, 'maxunicode'):
77        d = ''
78        m = ''
79        u = ''
80        if get_flag('Py_DEBUG',
81                    hasattr(sys, 'gettotalrefcount'),
82                    warn=(impl == 'cp')):
83            d = 'd'
84        if get_flag('WITH_PYMALLOC',
85                    impl == 'cp',
86                    warn=(impl == 'cp' and
87                          sys.version_info < (3, 8))) \
88                and sys.version_info < (3, 8):
89            m = 'm'
90        if get_flag('Py_UNICODE_SIZE',
91                    sys.maxunicode == 0x10ffff,
92                    expected=4,
93                    warn=(impl == 'cp' and
94                          sys.version_info < (3, 3))) \
95                and sys.version_info < (3, 3):
96            u = 'u'
97        abi = '%s%s%s%s%s' % (impl, tags.interpreter_version(), d, m, u)
98    elif soabi and soabi.startswith('cpython-'):
99        abi = 'cp' + soabi.split('-')[1]
100    elif soabi and soabi.startswith('pypy-'):
101        # we want something like pypy36-pp73
102        abi = '-'.join(soabi.split('-')[:2])
103        abi = abi.replace('.', '_').replace('-', '_')
104    elif soabi:
105        abi = soabi.replace('.', '_').replace('-', '_')
106    else:
107        abi = None
108    return abi
109
110
111def safer_name(name):
112    return safe_name(name).replace('-', '_')
113
114
115def safer_version(version):
116    return safe_version(version).replace('-', '_')
117
118
119def remove_readonly(func, path, excinfo):
120    print(str(excinfo[1]))
121    os.chmod(path, stat.S_IWRITE)
122    func(path)
123
124
125class bdist_wheel(Command):
126
127    description = 'create a wheel distribution'
128
129    supported_compressions = OrderedDict([
130        ('stored', ZIP_STORED),
131        ('deflated', ZIP_DEFLATED)
132    ])
133
134    user_options = [('bdist-dir=', 'b',
135                     "temporary directory for creating the distribution"),
136                    ('plat-name=', 'p',
137                     "platform name to embed in generated filenames "
138                     "(default: %s)" % get_platform(None)),
139                    ('keep-temp', 'k',
140                     "keep the pseudo-installation tree around after " +
141                     "creating the distribution archive"),
142                    ('dist-dir=', 'd',
143                     "directory to put final built distributions in"),
144                    ('skip-build', None,
145                     "skip rebuilding everything (for testing/debugging)"),
146                    ('relative', None,
147                     "build the archive using relative paths "
148                     "(default: false)"),
149                    ('owner=', 'u',
150                     "Owner name used when creating a tar file"
151                     " [default: current user]"),
152                    ('group=', 'g',
153                     "Group name used when creating a tar file"
154                     " [default: current group]"),
155                    ('universal', None,
156                     "make a universal wheel"
157                     " (default: false)"),
158                    ('compression=', None,
159                     "zipfile compression (one of: {})"
160                     " (default: 'deflated')"
161                     .format(', '.join(supported_compressions))),
162                    ('python-tag=', None,
163                     "Python implementation compatibility tag"
164                     " (default: '%s')" % (python_tag())),
165                    ('build-number=', None,
166                     "Build number for this particular version. "
167                     "As specified in PEP-0427, this must start with a digit. "
168                     "[default: None]"),
169                    ('py-limited-api=', None,
170                     "Python tag (cp32|cp33|cpNN) for abi3 wheel tag"
171                     " (default: false)"),
172                    ]
173
174    boolean_options = ['keep-temp', 'skip-build', 'relative', 'universal']
175
176    def initialize_options(self):
177        self.bdist_dir = None
178        self.data_dir = None
179        self.plat_name = None
180        self.plat_tag = None
181        self.format = 'zip'
182        self.keep_temp = False
183        self.dist_dir = None
184        self.egginfo_dir = None
185        self.root_is_pure = None
186        self.skip_build = None
187        self.relative = False
188        self.owner = None
189        self.group = None
190        self.universal = False
191        self.compression = 'deflated'
192        self.python_tag = python_tag()
193        self.build_number = None
194        self.py_limited_api = False
195        self.plat_name_supplied = False
196
197    def finalize_options(self):
198        if self.bdist_dir is None:
199            bdist_base = self.get_finalized_command('bdist').bdist_base
200            self.bdist_dir = os.path.join(bdist_base, 'wheel')
201
202        self.data_dir = self.wheel_dist_name + '.data'
203        self.plat_name_supplied = self.plat_name is not None
204
205        try:
206            self.compression = self.supported_compressions[self.compression]
207        except KeyError:
208            raise ValueError('Unsupported compression: {}'.format(self.compression))
209
210        need_options = ('dist_dir', 'plat_name', 'skip_build')
211
212        self.set_undefined_options('bdist',
213                                   *zip(need_options, need_options))
214
215        self.root_is_pure = not (self.distribution.has_ext_modules()
216                                 or self.distribution.has_c_libraries())
217
218        if self.py_limited_api and not re.match(PY_LIMITED_API_PATTERN, self.py_limited_api):
219            raise ValueError("py-limited-api must match '%s'" % PY_LIMITED_API_PATTERN)
220
221        # Support legacy [wheel] section for setting universal
222        wheel = self.distribution.get_option_dict('wheel')
223        if 'universal' in wheel:
224            # please don't define this in your global configs
225            logger.warn('The [wheel] section is deprecated. Use [bdist_wheel] instead.')
226            val = wheel['universal'][1].strip()
227            if val.lower() in ('1', 'true', 'yes'):
228                self.universal = True
229
230        if self.build_number is not None and not self.build_number[:1].isdigit():
231            raise ValueError("Build tag (build-number) must start with a digit.")
232
233    @property
234    def wheel_dist_name(self):
235        """Return distribution full name with - replaced with _"""
236        components = (safer_name(self.distribution.get_name()),
237                      safer_version(self.distribution.get_version()))
238        if self.build_number:
239            components += (self.build_number,)
240        return '-'.join(components)
241
242    def get_tag(self):
243        # bdist sets self.plat_name if unset, we should only use it for purepy
244        # wheels if the user supplied it.
245        if self.plat_name_supplied:
246            plat_name = self.plat_name
247        elif self.root_is_pure:
248            plat_name = 'any'
249        else:
250            # macosx contains system version in platform name so need special handle
251            if self.plat_name and not self.plat_name.startswith("macosx"):
252                plat_name = self.plat_name
253            else:
254                # on macosx always limit the platform name to comply with any
255                # c-extension modules in bdist_dir, since the user can specify
256                # a higher MACOSX_DEPLOYMENT_TARGET via tools like CMake
257
258                # on other platforms, and on macosx if there are no c-extension
259                # modules, use the default platform name.
260                plat_name = get_platform(self.bdist_dir)
261
262            if plat_name in ('linux-x86_64', 'linux_x86_64') and sys.maxsize == 2147483647:
263                plat_name = 'linux_i686'
264
265        plat_name = plat_name.lower().replace('-', '_').replace('.', '_')
266
267        if self.root_is_pure:
268            if self.universal:
269                impl = 'py2.py3'
270            else:
271                impl = self.python_tag
272            tag = (impl, 'none', plat_name)
273        else:
274            impl_name = tags.interpreter_name()
275            impl_ver = tags.interpreter_version()
276            impl = impl_name + impl_ver
277            # We don't work on CPython 3.1, 3.0.
278            if self.py_limited_api and (impl_name + impl_ver).startswith('cp3'):
279                impl = self.py_limited_api
280                abi_tag = 'abi3'
281            else:
282                abi_tag = str(get_abi_tag()).lower()
283            tag = (impl, abi_tag, plat_name)
284            # issue gh-374: allow overriding plat_name
285            supported_tags = [(t.interpreter, t.abi, plat_name)
286                              for t in tags.sys_tags()]
287            assert tag in supported_tags, "would build wheel with unsupported tag {}".format(tag)
288        return tag
289
290    def run(self):
291        build_scripts = self.reinitialize_command('build_scripts')
292        build_scripts.executable = 'python'
293        build_scripts.force = True
294
295        build_ext = self.reinitialize_command('build_ext')
296        build_ext.inplace = False
297
298        if not self.skip_build:
299            self.run_command('build')
300
301        install = self.reinitialize_command('install',
302                                            reinit_subcommands=True)
303        install.root = self.bdist_dir
304        install.compile = False
305        install.skip_build = self.skip_build
306        install.warn_dir = False
307
308        # A wheel without setuptools scripts is more cross-platform.
309        # Use the (undocumented) `no_ep` option to setuptools'
310        # install_scripts command to avoid creating entry point scripts.
311        install_scripts = self.reinitialize_command('install_scripts')
312        install_scripts.no_ep = True
313
314        # Use a custom scheme for the archive, because we have to decide
315        # at installation time which scheme to use.
316        for key in ('headers', 'scripts', 'data', 'purelib', 'platlib'):
317            setattr(install,
318                    'install_' + key,
319                    os.path.join(self.data_dir, key))
320
321        basedir_observed = ''
322
323        if os.name == 'nt':
324            # win32 barfs if any of these are ''; could be '.'?
325            # (distutils.command.install:change_roots bug)
326            basedir_observed = os.path.normpath(os.path.join(self.data_dir, '..'))
327            self.install_libbase = self.install_lib = basedir_observed
328
329        setattr(install,
330                'install_purelib' if self.root_is_pure else 'install_platlib',
331                basedir_observed)
332
333        logger.info("installing to %s", self.bdist_dir)
334
335        self.run_command('install')
336
337        impl_tag, abi_tag, plat_tag = self.get_tag()
338        archive_basename = "{}-{}-{}-{}".format(self.wheel_dist_name, impl_tag, abi_tag, plat_tag)
339        if not self.relative:
340            archive_root = self.bdist_dir
341        else:
342            archive_root = os.path.join(
343                self.bdist_dir,
344                self._ensure_relative(install.install_base))
345
346        self.set_undefined_options('install_egg_info', ('target', 'egginfo_dir'))
347        distinfo_dirname = '{}-{}.dist-info'.format(
348            safer_name(self.distribution.get_name()),
349            safer_version(self.distribution.get_version()))
350        distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname)
351        self.egg2dist(self.egginfo_dir, distinfo_dir)
352
353        self.write_wheelfile(distinfo_dir)
354
355        # Make the archive
356        if not os.path.exists(self.dist_dir):
357            os.makedirs(self.dist_dir)
358
359        wheel_path = os.path.join(self.dist_dir, archive_basename + '.whl')
360        with WheelFile(wheel_path, 'w', self.compression) as wf:
361            wf.write_files(archive_root)
362
363        # Add to 'Distribution.dist_files' so that the "upload" command works
364        getattr(self.distribution, 'dist_files', []).append(
365            ('bdist_wheel',
366             '{}.{}'.format(*sys.version_info[:2]),  # like 3.7
367             wheel_path))
368
369        if not self.keep_temp:
370            logger.info('removing %s', self.bdist_dir)
371            if not self.dry_run:
372                rmtree(self.bdist_dir, onerror=remove_readonly)
373
374    def write_wheelfile(self, wheelfile_base, generator='bdist_wheel (' + wheel_version + ')'):
375        from email.message import Message
376
377        # Workaround for Python 2.7 for when "generator" is unicode
378        if sys.version_info < (3,) and not isinstance(generator, str):
379            generator = generator.encode('utf-8')
380
381        msg = Message()
382        msg['Wheel-Version'] = '1.0'  # of the spec
383        msg['Generator'] = generator
384        msg['Root-Is-Purelib'] = str(self.root_is_pure).lower()
385        if self.build_number is not None:
386            msg['Build'] = self.build_number
387
388        # Doesn't work for bdist_wininst
389        impl_tag, abi_tag, plat_tag = self.get_tag()
390        for impl in impl_tag.split('.'):
391            for abi in abi_tag.split('.'):
392                for plat in plat_tag.split('.'):
393                    msg['Tag'] = '-'.join((impl, abi, plat))
394
395        wheelfile_path = os.path.join(wheelfile_base, 'WHEEL')
396        logger.info('creating %s', wheelfile_path)
397        buffer = BytesIO()
398        BytesGenerator(buffer, maxheaderlen=0).flatten(msg)
399        with open(wheelfile_path, 'wb') as f:
400            f.write(buffer.getvalue().replace(b'\r\n', b'\r'))
401
402    def _ensure_relative(self, path):
403        # copied from dir_util, deleted
404        drive, path = os.path.splitdrive(path)
405        if path[0:1] == os.sep:
406            path = drive + path[1:]
407        return path
408
409    @property
410    def license_paths(self):
411        metadata = self.distribution.get_option_dict('metadata')
412        files = set()
413        patterns = sorted({
414            option for option in metadata.get('license_files', ('', ''))[1].split()
415        })
416
417        if 'license_file' in metadata:
418            warnings.warn('The "license_file" option is deprecated. Use '
419                          '"license_files" instead.', DeprecationWarning)
420            files.add(metadata['license_file'][1])
421
422        if 'license_file' not in metadata and 'license_files' not in metadata:
423            patterns = ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*')
424
425        for pattern in patterns:
426            for path in iglob(pattern):
427                if path.endswith('~'):
428                    logger.debug('ignoring license file "%s" as it looks like a backup', path)
429                    continue
430
431                if path not in files and os.path.isfile(path):
432                    logger.info('adding license file "%s" (matched pattern "%s")', path, pattern)
433                    files.add(path)
434
435        return files
436
437    def egg2dist(self, egginfo_path, distinfo_path):
438        """Convert an .egg-info directory into a .dist-info directory"""
439        def adios(p):
440            """Appropriately delete directory, file or link."""
441            if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):
442                shutil.rmtree(p)
443            elif os.path.exists(p):
444                os.unlink(p)
445
446        adios(distinfo_path)
447
448        if not os.path.exists(egginfo_path):
449            # There is no egg-info. This is probably because the egg-info
450            # file/directory is not named matching the distribution name used
451            # to name the archive file. Check for this case and report
452            # accordingly.
453            import glob
454            pat = os.path.join(os.path.dirname(egginfo_path), '*.egg-info')
455            possible = glob.glob(pat)
456            err = "Egg metadata expected at %s but not found" % (egginfo_path,)
457            if possible:
458                alt = os.path.basename(possible[0])
459                err += " (%s found - possible misnamed archive file?)" % (alt,)
460
461            raise ValueError(err)
462
463        if os.path.isfile(egginfo_path):
464            # .egg-info is a single file
465            pkginfo_path = egginfo_path
466            pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path)
467            os.mkdir(distinfo_path)
468        else:
469            # .egg-info is a directory
470            pkginfo_path = os.path.join(egginfo_path, 'PKG-INFO')
471            pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path)
472
473            # ignore common egg metadata that is useless to wheel
474            shutil.copytree(egginfo_path, distinfo_path,
475                            ignore=lambda x, y: {'PKG-INFO', 'requires.txt', 'SOURCES.txt',
476                                                 'not-zip-safe'}
477                            )
478
479            # delete dependency_links if it is only whitespace
480            dependency_links_path = os.path.join(distinfo_path, 'dependency_links.txt')
481            with open(dependency_links_path, 'r') as dependency_links_file:
482                dependency_links = dependency_links_file.read().strip()
483            if not dependency_links:
484                adios(dependency_links_path)
485
486        write_pkg_info(os.path.join(distinfo_path, 'METADATA'), pkg_info)
487
488        for license_path in self.license_paths:
489            filename = os.path.basename(license_path)
490            shutil.copy(license_path, os.path.join(distinfo_path, filename))
491
492        adios(egginfo_path)
493