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