• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2018 The Bazel Authors. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#    http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import argparse
16import base64
17import collections
18import hashlib
19import os
20import re
21import sys
22import zipfile
23from pathlib import Path
24
25
26def commonpath(path1, path2):
27    ret = []
28    for a, b in zip(path1.split(os.path.sep), path2.split(os.path.sep)):
29        if a != b:
30            break
31        ret.append(a)
32    return os.path.sep.join(ret)
33
34
35def escape_filename_segment(segment):
36    """Escapes a filename segment per https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode"""
37    return re.sub(r"[^\w\d.]+", "_", segment, re.UNICODE)
38
39
40class WheelMaker(object):
41    def __init__(
42        self,
43        name,
44        version,
45        build_tag,
46        python_tag,
47        abi,
48        platform,
49        outfile=None,
50        strip_path_prefixes=None,
51    ):
52        self._name = name
53        self._version = version
54        self._build_tag = build_tag
55        self._python_tag = python_tag
56        self._abi = abi
57        self._platform = platform
58        self._outfile = outfile
59        self._strip_path_prefixes = (
60            strip_path_prefixes if strip_path_prefixes is not None else []
61        )
62
63        self._distinfo_dir = (
64            escape_filename_segment(self._name)
65            + "-"
66            + escape_filename_segment(self._version)
67            + ".dist-info/"
68        )
69        self._zipfile = None
70        # Entries for the RECORD file as (filename, hash, size) tuples.
71        self._record = []
72
73    def __enter__(self):
74        self._zipfile = zipfile.ZipFile(
75            self.filename(), mode="w", compression=zipfile.ZIP_DEFLATED
76        )
77        return self
78
79    def __exit__(self, type, value, traceback):
80        self._zipfile.close()
81        self._zipfile = None
82
83    def wheelname(self) -> str:
84        components = [self._name, self._version]
85        if self._build_tag:
86            components.append(self._build_tag)
87        components += [self._python_tag, self._abi, self._platform]
88        return "-".join(components) + ".whl"
89
90    def filename(self) -> str:
91        if self._outfile:
92            return self._outfile
93        return self.wheelname()
94
95    def disttags(self):
96        return ["-".join([self._python_tag, self._abi, self._platform])]
97
98    def distinfo_path(self, basename):
99        return self._distinfo_dir + basename
100
101    def _serialize_digest(self, hash):
102        # https://www.python.org/dev/peps/pep-0376/#record
103        # "base64.urlsafe_b64encode(digest) with trailing = removed"
104        digest = base64.urlsafe_b64encode(hash.digest())
105        digest = b"sha256=" + digest.rstrip(b"=")
106        return digest
107
108    def add_string(self, filename, contents):
109        """Add given 'contents' as filename to the distribution."""
110        if sys.version_info[0] > 2 and isinstance(contents, str):
111            contents = contents.encode("utf-8", "surrogateescape")
112        self._zipfile.writestr(filename, contents)
113        hash = hashlib.sha256()
114        hash.update(contents)
115        self._add_to_record(filename, self._serialize_digest(hash), len(contents))
116
117    def add_file(self, package_filename, real_filename):
118        """Add given file to the distribution."""
119
120        def arcname_from(name):
121            # Always use unix path separators.
122            normalized_arcname = name.replace(os.path.sep, "/")
123            # Don't manipulate names filenames in the .distinfo directory.
124            if normalized_arcname.startswith(self._distinfo_dir):
125                return normalized_arcname
126            for prefix in self._strip_path_prefixes:
127                if normalized_arcname.startswith(prefix):
128                    return normalized_arcname[len(prefix) :]
129
130            return normalized_arcname
131
132        if os.path.isdir(real_filename):
133            directory_contents = os.listdir(real_filename)
134            for file_ in directory_contents:
135                self.add_file(
136                    "{}/{}".format(package_filename, file_),
137                    "{}/{}".format(real_filename, file_),
138                )
139            return
140
141        arcname = arcname_from(package_filename)
142
143        self._zipfile.write(real_filename, arcname=arcname)
144        # Find the hash and length
145        hash = hashlib.sha256()
146        size = 0
147        with open(real_filename, "rb") as f:
148            while True:
149                block = f.read(2**20)
150                if not block:
151                    break
152                hash.update(block)
153                size += len(block)
154        self._add_to_record(arcname, self._serialize_digest(hash), size)
155
156    def add_wheelfile(self):
157        """Write WHEEL file to the distribution"""
158        # TODO(pstradomski): Support non-purelib wheels.
159        wheel_contents = """\
160Wheel-Version: 1.0
161Generator: bazel-wheelmaker 1.0
162Root-Is-Purelib: {}
163""".format(
164            "true" if self._platform == "any" else "false"
165        )
166        for tag in self.disttags():
167            wheel_contents += "Tag: %s\n" % tag
168        self.add_string(self.distinfo_path("WHEEL"), wheel_contents)
169
170    def add_metadata(self, metadata, name, description, version):
171        """Write METADATA file to the distribution."""
172        # https://www.python.org/dev/peps/pep-0566/
173        # https://packaging.python.org/specifications/core-metadata/
174        metadata = re.sub("^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE)
175        metadata += "Version: %s\n\n" % version
176        # setuptools seems to insert UNKNOWN as description when none is
177        # provided.
178        metadata += description if description else "UNKNOWN"
179        metadata += "\n"
180        self.add_string(self.distinfo_path("METADATA"), metadata)
181
182    def add_recordfile(self):
183        """Write RECORD file to the distribution."""
184        record_path = self.distinfo_path("RECORD")
185        entries = self._record + [(record_path, b"", b"")]
186        entries.sort()
187        contents = b""
188        for filename, digest, size in entries:
189            if sys.version_info[0] > 2 and isinstance(filename, str):
190                filename = filename.lstrip("/").encode("utf-8", "surrogateescape")
191            contents += b"%s,%s,%s\n" % (filename, digest, size)
192        self.add_string(record_path, contents)
193
194    def _add_to_record(self, filename, hash, size):
195        size = str(size).encode("ascii")
196        self._record.append((filename, hash, size))
197
198
199def get_files_to_package(input_files):
200    """Find files to be added to the distribution.
201
202    input_files: list of pairs (package_path, real_path)
203    """
204    files = {}
205    for package_path, real_path in input_files:
206        files[package_path] = real_path
207    return files
208
209
210def resolve_argument_stamp(
211    argument: str, volatile_status_stamp: Path, stable_status_stamp: Path
212) -> str:
213    """Resolve workspace status stamps format strings found in the argument string
214
215    Args:
216        argument (str): The raw argument represenation for the wheel (may include stamp variables)
217        volatile_status_stamp (Path): The path to a volatile workspace status file
218        stable_status_stamp (Path): The path to a stable workspace status file
219
220    Returns:
221        str: A resolved argument string
222    """
223    lines = (
224        volatile_status_stamp.read_text().splitlines()
225        + stable_status_stamp.read_text().splitlines()
226    )
227    for line in lines:
228        if not line:
229            continue
230        key, value = line.split(" ", maxsplit=1)
231        stamp = "{" + key + "}"
232        argument = argument.replace(stamp, value)
233
234    return argument
235
236
237def parse_args() -> argparse.Namespace:
238    parser = argparse.ArgumentParser(description="Builds a python wheel")
239    metadata_group = parser.add_argument_group("Wheel name, version and platform")
240    metadata_group.add_argument(
241        "--name", required=True, type=str, help="Name of the distribution"
242    )
243    metadata_group.add_argument(
244        "--version", required=True, type=str, help="Version of the distribution"
245    )
246    metadata_group.add_argument(
247        "--build_tag",
248        type=str,
249        default="",
250        help="Optional build tag for the distribution",
251    )
252    metadata_group.add_argument(
253        "--python_tag",
254        type=str,
255        default="py3",
256        help="Python version, e.g. 'py2' or 'py3'",
257    )
258    metadata_group.add_argument("--abi", type=str, default="none")
259    metadata_group.add_argument(
260        "--platform", type=str, default="any", help="Target platform. "
261    )
262
263    output_group = parser.add_argument_group("Output file location")
264    output_group.add_argument(
265        "--out", type=str, default=None, help="Override name of ouptut file"
266    )
267    output_group.add_argument(
268        "--name_file",
269        type=Path,
270        help="A file where the canonical name of the " "wheel will be written",
271    )
272
273    output_group.add_argument(
274        "--strip_path_prefix",
275        type=str,
276        action="append",
277        default=[],
278        help="Path prefix to be stripped from input package files' path. "
279        "Can be supplied multiple times. Evaluated in order.",
280    )
281
282    wheel_group = parser.add_argument_group("Wheel metadata")
283    wheel_group.add_argument(
284        "--metadata_file",
285        type=Path,
286        help="Contents of the METADATA file (before appending contents of "
287        "--description_file)",
288    )
289    wheel_group.add_argument(
290        "--description_file", help="Path to the file with package description"
291    )
292    wheel_group.add_argument(
293        "--description_content_type", help="Content type of the package description"
294    )
295    wheel_group.add_argument(
296        "--entry_points_file",
297        help="Path to a correctly-formatted entry_points.txt file",
298    )
299
300    contents_group = parser.add_argument_group("Wheel contents")
301    contents_group.add_argument(
302        "--input_file",
303        action="append",
304        help="'package_path;real_path' pairs listing "
305        "files to be included in the wheel. "
306        "Can be supplied multiple times.",
307    )
308    contents_group.add_argument(
309        "--input_file_list",
310        action="append",
311        help="A file that has all the input files defined as a list to avoid "
312        "the long command",
313    )
314    contents_group.add_argument(
315        "--extra_distinfo_file",
316        action="append",
317        help="'filename;real_path' pairs listing extra files to include in"
318        "dist-info directory. Can be supplied multiple times.",
319    )
320
321    build_group = parser.add_argument_group("Building requirements")
322    build_group.add_argument(
323        "--volatile_status_file",
324        type=Path,
325        help="Pass in the stamp info file for stamping",
326    )
327    build_group.add_argument(
328        "--stable_status_file",
329        type=Path,
330        help="Pass in the stamp info file for stamping",
331    )
332
333    return parser.parse_args(sys.argv[1:])
334
335
336def main() -> None:
337    arguments = parse_args()
338
339    if arguments.input_file:
340        input_files = [i.split(";") for i in arguments.input_file]
341    else:
342        input_files = []
343
344    if arguments.extra_distinfo_file:
345        extra_distinfo_file = [i.split(";") for i in arguments.extra_distinfo_file]
346    else:
347        extra_distinfo_file = []
348
349    if arguments.input_file_list:
350        for input_file in arguments.input_file_list:
351            with open(input_file) as _file:
352                input_file_list = _file.read().splitlines()
353            for _input_file in input_file_list:
354                input_files.append(_input_file.split(";"))
355
356    all_files = get_files_to_package(input_files)
357    # Sort the files for reproducible order in the archive.
358    all_files = sorted(all_files.items())
359
360    strip_prefixes = [p for p in arguments.strip_path_prefix]
361
362    if arguments.volatile_status_file and arguments.stable_status_file:
363        name = resolve_argument_stamp(
364            arguments.name,
365            arguments.volatile_status_file,
366            arguments.stable_status_file,
367        )
368    else:
369        name = arguments.name
370
371    if arguments.volatile_status_file and arguments.stable_status_file:
372        version = resolve_argument_stamp(
373            arguments.version,
374            arguments.volatile_status_file,
375            arguments.stable_status_file,
376        )
377    else:
378        version = arguments.version
379
380    with WheelMaker(
381        name=name,
382        version=version,
383        build_tag=arguments.build_tag,
384        python_tag=arguments.python_tag,
385        abi=arguments.abi,
386        platform=arguments.platform,
387        outfile=arguments.out,
388        strip_path_prefixes=strip_prefixes,
389    ) as maker:
390        for package_filename, real_filename in all_files:
391            maker.add_file(package_filename, real_filename)
392        maker.add_wheelfile()
393
394        description = None
395        if arguments.description_file:
396            if sys.version_info[0] == 2:
397                with open(arguments.description_file, "rt") as description_file:
398                    description = description_file.read()
399            else:
400                with open(
401                    arguments.description_file, "rt", encoding="utf-8"
402                ) as description_file:
403                    description = description_file.read()
404
405        metadata = None
406        if sys.version_info[0] == 2:
407            with open(arguments.metadata_file, "rt") as metadata_file:
408                metadata = metadata_file.read()
409        else:
410            with open(arguments.metadata_file, "rt", encoding="utf-8") as metadata_file:
411                metadata = metadata_file.read()
412
413        maker.add_metadata(
414            metadata=metadata, name=name, description=description, version=version
415        )
416
417        if arguments.entry_points_file:
418            maker.add_file(
419                maker.distinfo_path("entry_points.txt"), arguments.entry_points_file
420            )
421
422        # Sort the files for reproducible order in the archive.
423        for filename, real_path in sorted(extra_distinfo_file):
424            maker.add_file(maker.distinfo_path(filename), real_path)
425
426        maker.add_recordfile()
427
428        # Since stamping may otherwise change the target name of the
429        # wheel, the canonical name (with stamps resolved) is written
430        # to a file so consumers of the wheel can easily determine
431        # the correct name.
432        arguments.name_file.write_text(maker.wheelname())
433
434
435if __name__ == "__main__":
436    main()
437