• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2023 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
15"Implementation of py_wheel rule"
16
17load("//python/private:stamp.bzl", "is_stamping_enabled")
18load(":py_package.bzl", "py_package_lib")
19
20PyWheelInfo = provider(
21    doc = "Information about a wheel produced by `py_wheel`",
22    fields = {
23        "name_file": (
24            "File: A file containing the canonical name of the wheel (after " +
25            "stamping, if enabled)."
26        ),
27        "wheel": "File: The wheel file itself.",
28    },
29)
30
31_distribution_attrs = {
32    "abi": attr.string(
33        default = "none",
34        doc = "Python ABI tag. 'none' for pure-Python wheels.",
35    ),
36    "distribution": attr.string(
37        mandatory = True,
38        doc = """\
39Name of the distribution.
40
41This should match the project name onm PyPI. It's also the name that is used to
42refer to the package in other packages' dependencies.
43
44Workspace status keys are expanded using `{NAME}` format, for example:
45 - `distribution = "package.{CLASSIFIER}"`
46 - `distribution = "{DISTRIBUTION}"`
47
48For the available keys, see https://bazel.build/docs/user-manual#workspace-status
49""",
50    ),
51    "platform": attr.string(
52        default = "any",
53        doc = """\
54Supported platform. Use 'any' for pure-Python wheel.
55
56If you have included platform-specific data, such as a .pyd or .so
57extension module, you will need to specify the platform in standard
58pip format. If you support multiple platforms, you can define
59platform constraints, then use a select() to specify the appropriate
60specifier, eg:
61
62`
63platform = select({
64    "//platforms:windows_x86_64": "win_amd64",
65    "//platforms:macos_x86_64": "macosx_10_7_x86_64",
66    "//platforms:linux_x86_64": "manylinux2014_x86_64",
67})
68`
69""",
70    ),
71    "python_tag": attr.string(
72        default = "py3",
73        doc = "Supported Python version(s), eg `py3`, `cp35.cp36`, etc",
74    ),
75    "stamp": attr.int(
76        doc = """\
77Whether to encode build information into the wheel. Possible values:
78
79- `stamp = 1`: Always stamp the build information into the wheel, even in \
80[--nostamp](https://docs.bazel.build/versions/main/user-manual.html#flag--stamp) builds. \
81This setting should be avoided, since it potentially kills remote caching for the target and \
82any downstream actions that depend on it.
83
84- `stamp = 0`: Always replace build information by constant values. This gives good build result caching.
85
86- `stamp = -1`: Embedding of build information is controlled by the \
87[--[no]stamp](https://docs.bazel.build/versions/main/user-manual.html#flag--stamp) flag.
88
89Stamped targets are not rebuilt unless their dependencies change.
90        """,
91        default = -1,
92        values = [1, 0, -1],
93    ),
94    "version": attr.string(
95        mandatory = True,
96        doc = """\
97Version number of the package.
98
99Note that this attribute supports stamp format strings as well as 'make variables'.
100For example:
101  - `version = "1.2.3-{BUILD_TIMESTAMP}"`
102  - `version = "{BUILD_EMBED_LABEL}"`
103  - `version = "$(VERSION)"`
104
105Note that Bazel's output filename cannot include the stamp information, as outputs must be known
106during the analysis phase and the stamp data is available only during the action execution.
107
108The [`py_wheel`](/docs/packaging.md#py_wheel) macro produces a `.dist`-suffix target which creates a
109`dist/` folder containing the wheel with the stamped name, suitable for publishing.
110
111See [`py_wheel_dist`](/docs/packaging.md#py_wheel_dist) for more info.
112""",
113    ),
114    "_stamp_flag": attr.label(
115        doc = "A setting used to determine whether or not the `--stamp` flag is enabled",
116        default = Label("//python/private:stamp"),
117    ),
118}
119
120_requirement_attrs = {
121    "extra_requires": attr.string_list_dict(
122        doc = "List of optional requirements for this package",
123    ),
124    "requires": attr.string_list(
125        doc = ("List of requirements for this package. See the section on " +
126               "[Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) " +
127               "for details and examples of the format of this argument."),
128    ),
129}
130
131_entrypoint_attrs = {
132    "console_scripts": attr.string_dict(
133        doc = """\
134Deprecated console_script entry points, e.g. `{'main': 'examples.wheel.main:main'}`.
135
136Deprecated: prefer the `entry_points` attribute, which supports `console_scripts` as well as other entry points.
137""",
138    ),
139    "entry_points": attr.string_list_dict(
140        doc = """\
141entry_points, e.g. `{'console_scripts': ['main = examples.wheel.main:main']}`.
142""",
143    ),
144}
145
146_other_attrs = {
147    "author": attr.string(
148        doc = "A string specifying the author of the package.",
149        default = "",
150    ),
151    "author_email": attr.string(
152        doc = "A string specifying the email address of the package author.",
153        default = "",
154    ),
155    "classifiers": attr.string_list(
156        doc = "A list of strings describing the categories for the package. For valid classifiers see https://pypi.org/classifiers",
157    ),
158    "description_content_type": attr.string(
159        doc = ("The type of contents in description_file. " +
160               "If not provided, the type will be inferred from the extension of description_file. " +
161               "Also see https://packaging.python.org/en/latest/specifications/core-metadata/#description-content-type"),
162    ),
163    "description_file": attr.label(
164        doc = "A file containing text describing the package.",
165        allow_single_file = True,
166    ),
167    "extra_distinfo_files": attr.label_keyed_string_dict(
168        doc = "Extra files to add to distinfo directory in the archive.",
169        allow_files = True,
170    ),
171    "homepage": attr.string(
172        doc = "A string specifying the URL for the package homepage.",
173        default = "",
174    ),
175    "license": attr.string(
176        doc = "A string specifying the license of the package.",
177        default = "",
178    ),
179    "project_urls": attr.string_dict(
180        doc = ("A string dict specifying additional browsable URLs for the project and corresponding labels, " +
181               "where label is the key and url is the value. " +
182               'e.g `{{"Bug Tracker": "http://bitbucket.org/tarek/distribute/issues/"}}`'),
183    ),
184    "python_requires": attr.string(
185        doc = (
186            "Python versions required by this distribution, e.g. '>=3.5,<3.7'"
187        ),
188        default = "",
189    ),
190    "strip_path_prefixes": attr.string_list(
191        default = [],
192        doc = "path prefixes to strip from files added to the generated package",
193    ),
194    "summary": attr.string(
195        doc = "A one-line summary of what the distribution does",
196    ),
197}
198
199_PROJECT_URL_LABEL_LENGTH_LIMIT = 32
200_DESCRIPTION_FILE_EXTENSION_TO_TYPE = {
201    "md": "text/markdown",
202    "rst": "text/x-rst",
203}
204_DEFAULT_DESCRIPTION_FILE_TYPE = "text/plain"
205
206def _escape_filename_segment(segment):
207    """Escape a segment of the wheel filename.
208
209    See https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode
210    """
211
212    # TODO: this is wrong, isalnum replaces non-ascii letters, while we should
213    # not replace them.
214    # TODO: replace this with a regexp once starlark supports them.
215    escaped = ""
216    for character in segment.elems():
217        # isalnum doesn't handle unicode characters properly.
218        if character.isalnum() or character == ".":
219            escaped += character
220        elif not escaped.endswith("_"):
221            escaped += "_"
222    return escaped
223
224def _replace_make_variables(flag, ctx):
225    """Replace $(VERSION) etc make variables in flag"""
226    if "$" in flag:
227        for varname, varsub in ctx.var.items():
228            flag = flag.replace("$(%s)" % varname, varsub)
229    return flag
230
231def _input_file_to_arg(input_file):
232    """Converts a File object to string for --input_file argument to wheelmaker"""
233    return "%s;%s" % (py_package_lib.path_inside_wheel(input_file), input_file.path)
234
235def _py_wheel_impl(ctx):
236    abi = _replace_make_variables(ctx.attr.abi, ctx)
237    python_tag = _replace_make_variables(ctx.attr.python_tag, ctx)
238    version = _replace_make_variables(ctx.attr.version, ctx)
239
240    outfile = ctx.actions.declare_file("-".join([
241        _escape_filename_segment(ctx.attr.distribution),
242        _escape_filename_segment(version),
243        _escape_filename_segment(python_tag),
244        _escape_filename_segment(abi),
245        _escape_filename_segment(ctx.attr.platform),
246    ]) + ".whl")
247
248    name_file = ctx.actions.declare_file(ctx.label.name + ".name")
249
250    inputs_to_package = depset(
251        direct = ctx.files.deps,
252    )
253
254    # Inputs to this rule which are not to be packaged.
255    # Currently this is only the description file (if used).
256    other_inputs = []
257
258    # Wrap the inputs into a file to reduce command line length.
259    packageinputfile = ctx.actions.declare_file(ctx.attr.name + "_target_wrapped_inputs.txt")
260    content = ""
261    for input_file in inputs_to_package.to_list():
262        content += _input_file_to_arg(input_file) + "\n"
263    ctx.actions.write(output = packageinputfile, content = content)
264    other_inputs.append(packageinputfile)
265
266    args = ctx.actions.args()
267    args.add("--name", ctx.attr.distribution)
268    args.add("--version", version)
269    args.add("--python_tag", python_tag)
270    args.add("--abi", abi)
271    args.add("--platform", ctx.attr.platform)
272    args.add("--out", outfile)
273    args.add("--name_file", name_file)
274    args.add_all(ctx.attr.strip_path_prefixes, format_each = "--strip_path_prefix=%s")
275
276    # Pass workspace status files if stamping is enabled
277    if is_stamping_enabled(ctx.attr):
278        args.add("--volatile_status_file", ctx.version_file)
279        args.add("--stable_status_file", ctx.info_file)
280        other_inputs.extend([ctx.version_file, ctx.info_file])
281
282    args.add("--input_file_list", packageinputfile)
283
284    # Note: Description file and version are not embedded into metadata.txt yet,
285    # it will be done later by wheelmaker script.
286    metadata_file = ctx.actions.declare_file(ctx.attr.name + ".metadata.txt")
287    metadata_contents = ["Metadata-Version: 2.1"]
288    metadata_contents.append("Name: %s" % ctx.attr.distribution)
289
290    if ctx.attr.author:
291        metadata_contents.append("Author: %s" % ctx.attr.author)
292    if ctx.attr.author_email:
293        metadata_contents.append("Author-email: %s" % ctx.attr.author_email)
294    if ctx.attr.homepage:
295        metadata_contents.append("Home-page: %s" % ctx.attr.homepage)
296    if ctx.attr.license:
297        metadata_contents.append("License: %s" % ctx.attr.license)
298    if ctx.attr.description_content_type:
299        metadata_contents.append("Description-Content-Type: %s" % ctx.attr.description_content_type)
300    elif ctx.attr.description_file:
301        # infer the content type from description file extension.
302        description_file_type = _DESCRIPTION_FILE_EXTENSION_TO_TYPE.get(
303            ctx.file.description_file.extension,
304            _DEFAULT_DESCRIPTION_FILE_TYPE,
305        )
306        metadata_contents.append("Description-Content-Type: %s" % description_file_type)
307    if ctx.attr.summary:
308        metadata_contents.append("Summary: %s" % ctx.attr.summary)
309
310    for label, url in sorted(ctx.attr.project_urls.items()):
311        if len(label) > _PROJECT_URL_LABEL_LENGTH_LIMIT:
312            fail("`label` {} in `project_urls` is too long. It is limited to {} characters.".format(len(label), _PROJECT_URL_LABEL_LENGTH_LIMIT))
313        metadata_contents.append("Project-URL: %s, %s" % (label, url))
314
315    for c in ctx.attr.classifiers:
316        metadata_contents.append("Classifier: %s" % c)
317
318    if ctx.attr.python_requires:
319        metadata_contents.append("Requires-Python: %s" % ctx.attr.python_requires)
320    for requirement in ctx.attr.requires:
321        metadata_contents.append("Requires-Dist: %s" % requirement)
322
323    for option, option_requirements in sorted(ctx.attr.extra_requires.items()):
324        metadata_contents.append("Provides-Extra: %s" % option)
325        for requirement in option_requirements:
326            metadata_contents.append(
327                "Requires-Dist: %s; extra == '%s'" % (requirement, option),
328            )
329    ctx.actions.write(
330        output = metadata_file,
331        content = "\n".join(metadata_contents) + "\n",
332    )
333    other_inputs.append(metadata_file)
334    args.add("--metadata_file", metadata_file)
335
336    # Merge console_scripts into entry_points.
337    entrypoints = dict(ctx.attr.entry_points)  # Copy so we can mutate it
338    if ctx.attr.console_scripts:
339        # Copy a console_scripts group that may already exist, so we can mutate it.
340        console_scripts = list(entrypoints.get("console_scripts", []))
341        entrypoints["console_scripts"] = console_scripts
342        for name, ref in ctx.attr.console_scripts.items():
343            console_scripts.append("{name} = {ref}".format(name = name, ref = ref))
344
345    # If any entry_points are provided, construct the file here and add it to the files to be packaged.
346    # see: https://packaging.python.org/specifications/entry-points/
347    if entrypoints:
348        lines = []
349        for group, entries in sorted(entrypoints.items()):
350            if lines:
351                # Blank line between groups
352                lines.append("")
353            lines.append("[{group}]".format(group = group))
354            lines += sorted(entries)
355        entry_points_file = ctx.actions.declare_file(ctx.attr.name + "_entry_points.txt")
356        content = "\n".join(lines)
357        ctx.actions.write(output = entry_points_file, content = content)
358        other_inputs.append(entry_points_file)
359        args.add("--entry_points_file", entry_points_file)
360
361    if ctx.attr.description_file:
362        description_file = ctx.file.description_file
363        args.add("--description_file", description_file)
364        other_inputs.append(description_file)
365
366    for target, filename in ctx.attr.extra_distinfo_files.items():
367        target_files = target.files.to_list()
368        if len(target_files) != 1:
369            fail(
370                "Multi-file target listed in extra_distinfo_files %s",
371                filename,
372            )
373        other_inputs.extend(target_files)
374        args.add(
375            "--extra_distinfo_file",
376            filename + ";" + target_files[0].path,
377        )
378
379    ctx.actions.run(
380        inputs = depset(direct = other_inputs, transitive = [inputs_to_package]),
381        outputs = [outfile, name_file],
382        arguments = [args],
383        executable = ctx.executable._wheelmaker,
384        progress_message = "Building wheel {}".format(ctx.label),
385    )
386    return [
387        DefaultInfo(
388            files = depset([outfile]),
389            runfiles = ctx.runfiles(files = [outfile]),
390        ),
391        PyWheelInfo(
392            wheel = outfile,
393            name_file = name_file,
394        ),
395    ]
396
397def _concat_dicts(*dicts):
398    result = {}
399    for d in dicts:
400        result.update(d)
401    return result
402
403py_wheel_lib = struct(
404    implementation = _py_wheel_impl,
405    attrs = _concat_dicts(
406        {
407            "deps": attr.label_list(
408                doc = """\
409Targets to be included in the distribution.
410
411The targets to package are usually `py_library` rules or filesets (for packaging data files).
412
413Note it's usually better to package `py_library` targets and use
414`entry_points` attribute to specify `console_scripts` than to package
415`py_binary` rules. `py_binary` targets would wrap a executable script that
416tries to locate `.runfiles` directory which is not packaged in the wheel.
417""",
418            ),
419            "_wheelmaker": attr.label(
420                executable = True,
421                cfg = "exec",
422                default = "//tools:wheelmaker",
423            ),
424        },
425        _distribution_attrs,
426        _requirement_attrs,
427        _entrypoint_attrs,
428        _other_attrs,
429    ),
430)
431
432py_wheel = rule(
433    implementation = py_wheel_lib.implementation,
434    doc = """\
435Internal rule used by the [py_wheel macro](/docs/packaging.md#py_wheel).
436
437These intentionally have the same name to avoid sharp edges with Bazel macros.
438For example, a `bazel query` for a user's `py_wheel` macro expands to `py_wheel` targets,
439in the way they expect.
440""",
441    attrs = py_wheel_lib.attrs,
442)
443