diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdd039a5d..e331a8613d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +:::{default-domain} bzl +::: + # rules_python Changelog This is a human-friendly changelog in a keepachangelog.com style format. @@ -31,7 +34,7 @@ A brief description of the categories of changes: marked as `reproducible` and will not include any lock file entries from now on. -* (gazelle): Remove gazelle plugin's python deps and make it hermetic. +* (gazelle): Remove gazelle plugin's python deps and make it hermetic. Introduced a new Go-based helper leveraging tree-sitter for syntax analysis. Implemented the use of `pypi/stdlib-list` for standard library module verification. @@ -80,6 +83,16 @@ A brief description of the categories of changes: invalid usage previously but we were not failing the build. From now on this is explicitly disallowed. * (toolchains) Added riscv64 platform definition for python toolchains. +* (rules) A new bootstrap implementation that doesn't require a system Python + is available. It can be enabled by setting + {obj}`--@rules_python//python:config_settings:bootstrap_impl=two_phase`. It + will become the default in a subsequent release. + ([#691](https://github.com/bazelbuild/rules_python/issues/691)) +* (providers) `PyRuntimeInfo` has two new attributes: + {obj}`PyRuntimeInfo.stage2_bootstrap_template` and + {obj}`PyRuntimeInfo.zip_main_template`. +* (toolchains) A replacement for the Bazel-builtn autodetecting toolchain is + available. The `//python:autodetecting_toolchain` alias now uses it. [precompile-docs]: /precompiling diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d1149cc7..cb123bfee0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -175,6 +175,7 @@ Issues should be triaged as follows: functionality, should also be filed in this repository but without the `core-rules` label. +(breaking-changes)= ## Breaking Changes Breaking changes are generally permitted, but we follow a 3-step process for diff --git a/docs/sphinx/api/python/config_settings/index.md b/docs/sphinx/api/python/config_settings/index.md index 82a5b2a520..29779fd813 100644 --- a/docs/sphinx/api/python/config_settings/index.md +++ b/docs/sphinx/api/python/config_settings/index.md @@ -1,3 +1,5 @@ +:::{default-domain} bzl +::: :::{bzl:currentfile} //python/config_settings:BUILD.bazel ::: @@ -66,3 +68,32 @@ Values: * `include_pyc`: Include `PyInfo.transitive_pyc_files` as part of the binary. * `disabled`: Don't include `PyInfo.transitive_pyc_files` as part of the binary. ::: + +::::{bzl:flag} bootstrap_impl +Determine how programs implement their startup process. + +Values: +* `system_python`: Use a bootstrap that requires a system Python available + in order to start programs. This requires + {obj}`PyRuntimeInfo.bootstrap_template` to be a Python program. +* `script`: Use a bootstrap that uses an arbitrary executable script (usually a + shell script) instead of requiring it be a Python program. + +:::{note} +The `script` bootstrap requires the toolchain to provide the `PyRuntimeInfo` +provider from `rules_python`. This loosely translates to using Bazel 7+ with a +toolchain created by rules_python. Most notably, WORKSPACE builds default to +using a legacy toolchain built into Bazel itself which doesn't support the +script bootstrap. If not available, the `system_python` bootstrap will be used +instead. +::: + +:::{seealso} +{obj}`PyRuntimeInfo.bootstrap_template` and +{obj}`PyRuntimeInfo.stage2_bootstrap_template` +::: + +:::{versionadded} 0.33.0 +::: + +:::: diff --git a/docs/sphinx/api/python/index.md b/docs/sphinx/api/python/index.md index 8026a7f145..494e7b4a02 100644 --- a/docs/sphinx/api/python/index.md +++ b/docs/sphinx/api/python/index.md @@ -1,3 +1,5 @@ +:::{default-domain} bzl +::: :::{bzl:currentfile} //python:BUILD.bazel ::: @@ -21,3 +23,21 @@ provides: * `PyRuntimeInfo`: The consuming target's target toolchain information ::: + +::::{target} autodetecting_toolchain + +A simple toolchain that simply uses `python3` from the runtime environment. + +Note that this toolchain provides no build-time information, which makes it of +limited utility. + +This is only provided to aid migration off the builtin Bazel toolchain +(`@bazel_tools//python:autodetecting_toolchain`), and is largely only applicable +to WORKSPACE builds. + +:::{deprecated} unspecified + +Switch to using a hermetic toolchain or manual toolchain configuration instead. +::: + +:::: diff --git a/docs/sphinx/bazel_inventory.txt b/docs/sphinx/bazel_inventory.txt index 62cbdf8926..c4aaabc074 100644 --- a/docs/sphinx/bazel_inventory.txt +++ b/docs/sphinx/bazel_inventory.txt @@ -10,7 +10,7 @@ bool bzl:type 1 rules/lib/bool - int bzl:type 1 rules/lib/int - depset bzl:type 1 rules/lib/depset - dict bzl:type 1 rules/lib/dict - -label bzl:doc 1 concepts/labels - +label bzl:type 1 concepts/labels - attr.bool bzl:type 1 rules/lib/toplevel/attr#bool - attr.int bzl:type 1 rules/lib/toplevel/attr#int - attr.label bzl:type 1 rules/lib/toplevel/attr#label - @@ -21,6 +21,7 @@ list bzl:type 1 rules/lib/list - python bzl:doc 1 reference/be/python - str bzl:type 1 rules/lib/string - struct bzl:type 1 rules/lib/builtins/struct - -target-name bzl:doc 1 concepts/labels#target-names - +Name bzl:type 1 concepts/labels#target-names - CcInfo bzl:provider 1 rules/lib/providers/CcInfo - CcInfo.linking_context bzl:provider-field 1 rules/lib/providers/CcInfo#linking_context - +ToolchainInfo bzl:type 1 rules/lib/providers/ToolchainInfo.html - diff --git a/docs/sphinx/pip.md b/docs/sphinx/pip.md index e1c8e343f0..fc29e41b5e 100644 --- a/docs/sphinx/pip.md +++ b/docs/sphinx/pip.md @@ -150,7 +150,7 @@ ARG=$1 # but we don't do anything with it as it's always "get" # formatting is optional echo '{' echo ' "headers": {' -echo ' "Authorization": ["Basic dGVzdDoxMjPCow=="] +echo ' "Authorization": ["Basic dGVzdDoxMjPCow=="]' echo ' }' echo '}' ``` diff --git a/docs/sphinx/support.md b/docs/sphinx/support.md index a2b8e3ae20..ea099650bd 100644 --- a/docs/sphinx/support.md +++ b/docs/sphinx/support.md @@ -46,7 +46,8 @@ incremental fashion. Breaking changes are allowed, but follow a process to introduce them over a series of releases to so users can still incrementally upgrade. See the -[Breaking Changes](contributing#breaking-changes) doc for the process. +[Breaking Changes](#breaking-changes) doc for the process. + ## Experimental Features diff --git a/docs/sphinx/toolchains.md b/docs/sphinx/toolchains.md index bac89660bb..e3be22f97b 100644 --- a/docs/sphinx/toolchains.md +++ b/docs/sphinx/toolchains.md @@ -1,3 +1,6 @@ +:::{default-domain} bzl +::: + # Configuring Python toolchains and runtimes This documents how to configure the Python toolchain and runtimes for different @@ -193,7 +196,7 @@ load("@rules_python//python:repositories.bzl", "py_repositories") py_repositories() ``` -#### Workspace toolchain registration +### Workspace toolchain registration To register a hermetic Python toolchain rather than rely on a system-installed interpreter for runtime execution, you can add to the `WORKSPACE` file: @@ -221,3 +224,21 @@ pip_parse( After registration, your Python targets will use the toolchain's interpreter during execution, but a system-installed interpreter is still used to 'bootstrap' Python targets (see https://github.com/bazelbuild/rules_python/issues/691). You may also find some quirks while using this toolchain. Please refer to [python-build-standalone documentation's _Quirks_ section](https://gregoryszorc.com/docs/python-build-standalone/main/quirks.html). + +## Autodetecting toolchain + +The autodetecting toolchain is a deprecated toolchain that is built into Bazel. +It's name is a bit misleading: it doesn't autodetect anything. All it does is +use `python3` from the environment a binary runs within. This provides extremely +limited functionality to the rules (at build time, nothing is knowable about +the Python runtime). + +Bazel itself automatically registers `@bazel_tools//python:autodetecting_toolchain` +as the lowest priority toolchain. For WORKSPACE builds, if no other toolchain +is registered, that toolchain will be used. For bzlmod builds, rules_python +automatically registers a higher-priority toolchain; it won't be used unless +there is a toolchain misconfiguration somewhere. + +To aid migration off the Bazel-builtin toolchain, rules_python provides +{obj}`@rules_python//python:autodetecting_toolchain`. This is an equivalent +toolchain, but is implemented using rules_python's objects. diff --git a/examples/bzlmod/test.py b/examples/bzlmod/test.py index 533187557d..950c002919 100644 --- a/examples/bzlmod/test.py +++ b/examples/bzlmod/test.py @@ -14,6 +14,7 @@ import os import pathlib +import re import sys import unittest @@ -63,16 +64,47 @@ def test_coverage_sys_path(self): first_item.endswith("coverage"), f"Expected the first item in sys.path '{first_item}' to not be related to coverage", ) + + # We're trying to make sure that the coverage library added by the + # toolchain is _after_ any user-provided dependencies. This lets users + # override what coverage version they're using. + first_coverage_index = None + last_user_dep_index = None + for i, path in enumerate(sys.path): + if re.search("rules_python.*~pip~", path): + last_user_dep_index = i + if first_coverage_index is None and re.search( + ".*rules_python.*~python~.*coverage.*", path + ): + first_coverage_index = i + if os.environ.get("COVERAGE_MANIFEST"): + self.assertIsNotNone( + first_coverage_index, + "Expected to find toolchain coverage, but " + + f"it was not found.\nsys.path:\n{all_paths}", + ) + self.assertIsNotNone( + first_coverage_index, + "Expected to find at least one uiser dep, " + + "but none were found.\nsys.path:\n{all_paths}", + ) # we are running under the 'bazel coverage :test' - self.assertTrue( - "_coverage" in last_item, - f"Expected {last_item} to be related to coverage", + self.assertGreater( + first_coverage_index, + last_user_dep_index, + "Expected coverage provided by the toolchain to be after " + + "user provided dependencies.\n" + + f"Found coverage at index: {first_coverage_index}\n" + + f"Last user dep at index: {last_user_dep_index}\n" + + f"Full sys.path:\n{all_paths}", ) - self.assertEqual(pathlib.Path(last_item).name, "coverage") else: - self.assertFalse( - "coverage" in last_item, f"Expected coverage tooling to not be present" + self.assertIsNone( + first_coverage_index, + "Expected toolchain coverage to not be present\n" + + f"Found coverage at index: {first_coverage_index}\n" + + f"Full sys.path:\n{all_paths}", ) def test_main(self): diff --git a/python/BUILD.bazel b/python/BUILD.bazel index 5d31df5e9a..cbf29964fb 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -24,6 +24,7 @@ that @rules_python//python is only concerned with the core rules. """ load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("//python/private:autodetecting_toolchain.bzl", "define_autodetecting_toolchain") load(":current_py_toolchain.bzl", "current_py_toolchain") package(default_visibility = ["//visibility:public"]) @@ -318,14 +319,11 @@ toolchain_type( # safe if you know for a fact that your build is completely compatible with the # version of the `python` command installed on the target platform. -alias( - name = "autodetecting_toolchain", - actual = "@bazel_tools//tools/python:autodetecting_toolchain", -) +define_autodetecting_toolchain(name = "autodetecting_toolchain") alias( name = "autodetecting_toolchain_nonstrict", - actual = "@bazel_tools//tools/python:autodetecting_toolchain_nonstrict", + actual = ":autodetecting_toolchain", ) # ========= Packaging rules ========= diff --git a/python/config_settings/BUILD.bazel b/python/config_settings/BUILD.bazel index a0e59f70c0..9dab53c039 100644 --- a/python/config_settings/BUILD.bazel +++ b/python/config_settings/BUILD.bazel @@ -1,6 +1,7 @@ load("@bazel_skylib//rules:common_settings.bzl", "string_flag") load( "//python/private:flags.bzl", + "BootstrapImplFlag", "PrecompileAddToRunfilesFlag", "PrecompileFlag", "PrecompileSourceRetentionFlag", @@ -52,3 +53,11 @@ string_flag( # NOTE: Only public because its an implicit dependency visibility = ["//visibility:public"], ) + +string_flag( + name = "bootstrap_impl", + build_setting_default = BootstrapImplFlag.SYSTEM_PYTHON, + values = sorted(BootstrapImplFlag.__members__.values()), + # NOTE: Only public because its an implicit dependency + visibility = ["//visibility:public"], +) diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 3e56208859..1dc6c88ae8 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -376,9 +376,54 @@ exports_files( visibility = ["//visibility:public"], ) +filegroup( + name = "stage1_bootstrap_template", + srcs = ["stage1_bootstrap_template.sh"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +filegroup( + name = "stage2_bootstrap_template", + srcs = ["stage2_bootstrap_template.py"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +filegroup( + name = "zip_main_template", + srcs = ["zip_main_template.py"], + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + +# NOTE: Windows builds don't use this bootstrap. Instead, a native Windows +# program locates some Python exe and runs `python.exe foo.zip` which +# runs the __main__.py in the zip file. +alias( + name = "bootstrap_template", + actual = select({ + ":is_script_bootstrap_enabled": "stage1_bootstrap_template.sh", + "//conditions:default": "python_bootstrap_template.txt", + }), + # Not actually public. Only public because it's an implicit dependency of + # py_runtime. + visibility = ["//visibility:public"], +) + # Used to determine the use of `--stamp` in Starlark rules stamp_build_setting(name = "stamp") +config_setting( + name = "is_script_bootstrap_enabled", + flag_values = { + "//python/config_settings:bootstrap_impl": "script", + }, +) + print_toolchains_checksums(name = "print_toolchains_checksums") # Used for py_console_script_gen rule diff --git a/python/private/autodetecting_toolchain.bzl b/python/private/autodetecting_toolchain.bzl index 3caa5aa8ca..55c95699c9 100644 --- a/python/private/autodetecting_toolchain.bzl +++ b/python/private/autodetecting_toolchain.bzl @@ -32,7 +32,7 @@ def define_autodetecting_toolchain(name): # buildifier: disable=native-py py_runtime( name = "_autodetecting_py3_runtime", - interpreter = ":py3wrapper.sh", + interpreter = "//python/private:autodetecting_toolchain_interpreter.sh", python_version = "PY3", stub_shebang = "#!/usr/bin/env python3", visibility = ["//visibility:private"], diff --git a/python/private/common/common.bzl b/python/private/common/common.bzl index cfa7db7a2d..0ac9187b79 100644 --- a/python/private/common/common.bzl +++ b/python/private/common/common.bzl @@ -182,7 +182,7 @@ def create_cc_details_struct( cc_toolchain = cc_toolchain, ) -def create_executable_result_struct(*, extra_files_to_build, output_groups): +def create_executable_result_struct(*, extra_files_to_build, output_groups, extra_runfiles = None): """Creates a `CreateExecutableResult` struct. This is the return value type of the semantics create_executable function. @@ -192,6 +192,7 @@ def create_executable_result_struct(*, extra_files_to_build, output_groups): included as default outputs. output_groups: dict[str, depset[File]]; additional output groups that should be returned. + extra_runfiles: A runfiles object of additional runfiles to include. Returns: A `CreateExecutableResult` struct. @@ -199,6 +200,7 @@ def create_executable_result_struct(*, extra_files_to_build, output_groups): return struct( extra_files_to_build = extra_files_to_build, output_groups = output_groups, + extra_runfiles = extra_runfiles, ) def union_attrs(*attr_dicts, allow_none = False): diff --git a/python/private/common/providers.bzl b/python/private/common/providers.bzl index 5b84549185..e1876ff9d3 100644 --- a/python/private/common/providers.bzl +++ b/python/private/common/providers.bzl @@ -18,7 +18,7 @@ load("//python/private:util.bzl", "IS_BAZEL_6_OR_HIGHER") DEFAULT_STUB_SHEBANG = "#!/usr/bin/env python3" -DEFAULT_BOOTSTRAP_TEMPLATE = Label("//python/private:python_bootstrap_template.txt") +DEFAULT_BOOTSTRAP_TEMPLATE = Label("//python/private:bootstrap_template") _PYTHON_VERSION_VALUES = ["PY2", "PY3"] @@ -78,7 +78,9 @@ def _PyRuntimeInfo_init( python_version, stub_shebang = None, bootstrap_template = None, - interpreter_version_info = None): + interpreter_version_info = None, + stage2_bootstrap_template = None, + zip_main_template = None): if (interpreter_path and interpreter) or (not interpreter_path and not interpreter): fail("exactly one of interpreter or interpreter_path must be specified") @@ -126,7 +128,9 @@ def _PyRuntimeInfo_init( "interpreter_version_info": interpreter_version_info_struct_from_dict(interpreter_version_info), "pyc_tag": pyc_tag, "python_version": python_version, + "stage2_bootstrap_template": stage2_bootstrap_template, "stub_shebang": stub_shebang, + "zip_main_template": zip_main_template, } # TODO(#15897): Rename this to PyRuntimeInfo when we're ready to replace the Java @@ -147,7 +151,45 @@ the same conventions as the standard CPython interpreter. "bootstrap_template": """ :type: File -See py_runtime_rule.bzl%py_runtime.bootstrap_template for docs. +A template of code responsible for the initial startup of a program. + +This code is responsible for: + +* Locating the target interpreter. Typically it is in runfiles, but not always. +* Setting necessary environment variables, command line flags, or other + configuration that can't be modified after the interpreter starts. +* Invoking the appropriate entry point. This is usually a second-stage bootstrap + that performs additional setup prior to running a program's actual entry point. + +The {obj}`--bootstrap_impl` flag affects how this stage 1 bootstrap +is expected to behave and the substutitions performed. + +* `--bootstrap_impl=system_python` substitutions: `%is_zipfile%`, `%python_binary%`, + `%target%`, `%workspace_name`, `%coverage_tool%`, `%import_all%`, `%imports%`, + `%main%`, `%shebang%` +* `--bootstrap_impl=script` substititions: `%is_zipfile%`, `%python_binary%`, + `%target%`, `%workspace_name`, `%shebang%, `%stage2_bootstrap%` + +Substitution definitions: + +* `%shebang%`: The shebang to use with the bootstrap; the bootstrap template + may choose to ignore this. +* `%stage2_bootstrap%`: A runfiles-relative path to the stage 2 bootstrap. +* `%python_binary%`: The path to the target Python interpreter. There are three + types of paths: + * An absolute path to a system interpreter (e.g. begins with `/`). + * A runfiles-relative path to an interpreter (e.g. `somerepo/bin/python3`) + * A program to search for on PATH, i.e. a word without spaces, e.g. `python3`. +* `%workspace_name%`: The name of the workspace the target belongs to. +* `%is_zipfile%`: The string `1` if this template is prepended to a zipfile to + create a self-executable zip file. The string `0` otherwise. + +For the other substitution definitions, see the {obj}`stage2_bootstrap_template` +docs. + +:::{versionchanged} 0.33.0 +The set of substitutions depends on {obj}`--bootstrap_impl` +::: """, "coverage_files": """ :type: depset[File] | None @@ -216,6 +258,30 @@ correctly. Indicates whether this runtime uses Python major version 2 or 3. Valid values are (only) `"PY2"` and `"PY3"`. +""", + "stage2_bootstrap_template": """ +:type: File + +A template of Python code that runs under the desired interpreter and is +responsible for orchestrating calling the program's actual main code. This +bootstrap is responsible for affecting the current runtime's state, such as +import paths or enabling coverage, so that, when it runs the program's actual +main code, it works properly under Bazel. + +The following substitutions are made during template expansion: +* `%main%`: A runfiles-relative path to the program's actual main file. This + can be a `.py` or `.pyc` file, depending on precompile settings. +* `%coverage_tool%`: Runfiles-relative path to the coverage library's entry point. + If coverage is not enabled or available, an empty string. +* `%import_all%`: The string `True` if all repositories in the runfiles should + be added to sys.path. The string `False` otherwise. +* `%imports%`: A colon-delimited string of runfiles-relative paths to add to + sys.path. +* `%target%`: The name of the target this is for. +* `%workspace_name%`: The name of the workspace the target belongs to. + +:::{versionadded} 0.33.0 +::: """, "stub_shebang": """ :type: str @@ -223,6 +289,27 @@ are (only) `"PY2"` and `"PY3"`. "Shebang" expression prepended to the bootstrapping Python stub script used when executing {obj}`py_binary` targets. Does not apply to Windows. +""", + "zip_main_template": """ +:type: File + +A template of Python code that becomes a zip file's top-level `__main__.py` +file. The top-level `__main__.py` file is used when the zip file is explicitly +passed to a Python interpreter. See PEP 441 for more information about zipapp +support. Note that py_binary-generated zip files are self-executing and +skip calling `__main__.py`. + +The following substitutions are made during template expansion: +* `%stage2_bootstrap%`: A runfiles-relative string to the stage 2 bootstrap file. +* `%python_binary%`: The path to the target Python interpreter. There are three + types of paths: + * An absolute path to a system interpreter (e.g. begins with `/`). + * A runfiles-relative path to an interpreter (e.g. `somerepo/bin/python3`) + * A program to search for on PATH, i.e. a word without spaces, e.g. `python3`. +* `%workspace_name%`: The name of the workspace for the built target. + +:::{versionadded} 0.33.0 +::: """, }, ) diff --git a/python/private/common/py_executable.bzl b/python/private/common/py_executable.bzl index cf7d6fad50..ff1f74de99 100644 --- a/python/private/common/py_executable.bzl +++ b/python/private/common/py_executable.bzl @@ -118,6 +118,10 @@ Valid values are: values = ["PY2", "PY3"], doc = "Defunct, unused, does nothing.", ), + "_bootstrap_impl_flag": attr.label( + default = "//python/config_settings:bootstrap_impl", + providers = [BuildSettingInfo], + ), "_pyc_collection_flag": attr.label( default = "//python/config_settings:pyc_collection", providers = [BuildSettingInfo], @@ -212,7 +216,9 @@ def py_executable_base_impl(ctx, *, semantics, is_test, inherited_environment = runfiles_details = runfiles_details, ) - extra_exec_runfiles = ctx.runfiles(transitive_files = exec_result.extra_files_to_build) + extra_exec_runfiles = exec_result.extra_runfiles.merge( + ctx.runfiles(transitive_files = exec_result.extra_files_to_build), + ) runfiles_details = struct( default_runfiles = runfiles_details.default_runfiles.merge(extra_exec_runfiles), data_runfiles = runfiles_details.data_runfiles.merge(extra_exec_runfiles), diff --git a/python/private/common/py_executable_bazel.bzl b/python/private/common/py_executable_bazel.bzl index 1c41fc15e5..53d70f00b9 100644 --- a/python/private/common/py_executable_bazel.bzl +++ b/python/private/common/py_executable_bazel.bzl @@ -15,6 +15,7 @@ load("@bazel_skylib//lib:dicts.bzl", "dicts") load("@bazel_skylib//lib:paths.bzl", "paths") +load("//python/private:flags.bzl", "BootstrapImplFlag") load(":attributes_bazel.bzl", "IMPORTS_ATTRS") load( ":common.bzl", @@ -166,12 +167,6 @@ def _create_executable( runfiles_details): _ = is_test, cc_details, native_deps_details # @unused - common_bootstrap_template_kwargs = dict( - main_py = main_py, - imports = imports, - runtime_details = runtime_details, - ) - is_windows = target_platform_has_any_constraint(ctx, ctx.attr._windows_constraints) if is_windows: @@ -181,21 +176,47 @@ def _create_executable( else: base_executable_name = executable.basename - zip_bootstrap = ctx.actions.declare_file(base_executable_name + ".temp", sibling = executable) - zip_file = ctx.actions.declare_file(base_executable_name + ".zip", sibling = executable) + # The check for stage2_bootstrap_template is to support legacy + # BuiltinPyRuntimeInfo providers, which is likely to come from + # @bazel_tools//tools/python:autodetecting_toolchain, the toolchain used + # for workspace builds when no rules_python toolchain is configured. + if (BootstrapImplFlag.get_value(ctx) == BootstrapImplFlag.SCRIPT and + runtime_details.effective_runtime and + hasattr(runtime_details.effective_runtime, "stage2_bootstrap_template")): + stage2_bootstrap = _create_stage2_bootstrap( + ctx, + output_prefix = base_executable_name, + output_sibling = executable, + main_py = main_py, + imports = imports, + runtime_details = runtime_details, + ) + extra_runfiles = ctx.runfiles([stage2_bootstrap]) + zip_main = _create_zip_main( + ctx, + stage2_bootstrap = stage2_bootstrap, + runtime_details = runtime_details, + ) + else: + stage2_bootstrap = None + extra_runfiles = ctx.runfiles() + zip_main = ctx.actions.declare_file(base_executable_name + ".temp", sibling = executable) + _create_stage1_bootstrap( + ctx, + output = zip_main, + main_py = main_py, + imports = imports, + is_for_zip = True, + runtime_details = runtime_details, + ) - _expand_bootstrap_template( - ctx, - output = zip_bootstrap, - is_for_zip = True, - **common_bootstrap_template_kwargs - ) + zip_file = ctx.actions.declare_file(base_executable_name + ".zip", sibling = executable) _create_zip_file( ctx, output = zip_file, original_nonzip_executable = executable, - executable_for_zip_file = zip_bootstrap, - runfiles = runfiles_details.default_runfiles, + zip_main = zip_main, + runfiles = runfiles_details.default_runfiles.merge(extra_runfiles), ) extra_files_to_build = [] @@ -244,13 +265,23 @@ def _create_executable( if bootstrap_output != None: fail("Should not occur: bootstrap_output should not be used " + "when creating an executable zip") - _create_executable_zip_file(ctx, output = executable, zip_file = zip_file) + _create_executable_zip_file( + ctx, + output = executable, + zip_file = zip_file, + python_binary_path = runtime_details.executable_interpreter_path, + stage2_bootstrap = stage2_bootstrap, + runtime_details = runtime_details, + ) elif bootstrap_output: - _expand_bootstrap_template( + _create_stage1_bootstrap( ctx, output = bootstrap_output, - is_for_zip = build_zip_enabled, - **common_bootstrap_template_kwargs + stage2_bootstrap = stage2_bootstrap, + runtime_details = runtime_details, + is_for_zip = False, + imports = imports, + main_py = main_py, ) else: # Otherwise, this should be the Windows case of launcher + zip. @@ -268,16 +299,40 @@ def _create_executable( return create_executable_result_struct( extra_files_to_build = depset(extra_files_to_build), output_groups = {"python_zip_file": depset([zip_file])}, + extra_runfiles = extra_runfiles, + ) + +def _create_zip_main(ctx, *, stage2_bootstrap, runtime_details): + # The location of this file doesn't really matter. It's added to + # the zip file as the top-level __main__.py file and not included + # elsewhere. + output = ctx.actions.declare_file(ctx.label.name + "_zip__main__.py") + ctx.actions.expand_template( + template = runtime_details.effective_runtime.zip_main_template, + output = output, + substitutions = { + "%python_binary%": runtime_details.executable_interpreter_path, + "%stage2_bootstrap%": "{}/{}".format( + ctx.workspace_name, + stage2_bootstrap.short_path, + ), + "%workspace_name%": ctx.workspace_name, + }, ) + return output -def _expand_bootstrap_template( +def _create_stage2_bootstrap( ctx, *, - output, + output_prefix, + output_sibling, main_py, imports, - is_for_zip, runtime_details): + output = ctx.actions.declare_file( + "{}_stage2_bootstrap.py".format(output_prefix), + sibling = output_sibling, + ) runtime = runtime_details.effective_runtime if (ctx.configuration.coverage_enabled and runtime and @@ -289,12 +344,7 @@ def _expand_bootstrap_template( else: coverage_tool_runfiles_path = "" - if runtime: - shebang = runtime.stub_shebang - template = runtime.bootstrap_template - else: - shebang = DEFAULT_STUB_SHEBANG - template = ctx.file._bootstrap_template + template = runtime.stage2_bootstrap_template ctx.actions.expand_template( template = template, @@ -303,18 +353,66 @@ def _expand_bootstrap_template( "%coverage_tool%": coverage_tool_runfiles_path, "%import_all%": "True" if ctx.fragments.bazel_py.python_import_all_repositories else "False", "%imports%": ":".join(imports.to_list()), - "%is_zipfile%": "True" if is_for_zip else "False", - "%main%": "{}/{}".format( - ctx.workspace_name, - main_py.short_path, - ), - "%python_binary%": runtime_details.executable_interpreter_path, - "%shebang%": shebang, + "%main%": "{}/{}".format(ctx.workspace_name, main_py.short_path), "%target%": str(ctx.label), "%workspace_name%": ctx.workspace_name, }, is_executable = True, ) + return output + +def _create_stage1_bootstrap( + ctx, + *, + output, + main_py = None, + stage2_bootstrap = None, + imports = None, + is_for_zip, + runtime_details): + runtime = runtime_details.effective_runtime + + subs = { + "%is_zipfile%": "1" if is_for_zip else "0", + "%python_binary%": runtime_details.executable_interpreter_path, + "%target%": str(ctx.label), + "%workspace_name%": ctx.workspace_name, + } + + if stage2_bootstrap: + subs["%stage2_bootstrap%"] = "{}/{}".format( + ctx.workspace_name, + stage2_bootstrap.short_path, + ) + template = runtime.bootstrap_template + subs["%shebang%"] = runtime.stub_shebang + else: + if (ctx.configuration.coverage_enabled and + runtime and + runtime.coverage_tool): + coverage_tool_runfiles_path = "{}/{}".format( + ctx.workspace_name, + runtime.coverage_tool.short_path, + ) + else: + coverage_tool_runfiles_path = "" + if runtime: + subs["%shebang%"] = runtime.stub_shebang + template = runtime.bootstrap_template + else: + subs["%shebang%"] = DEFAULT_STUB_SHEBANG + template = ctx.file._bootstrap_template + + subs["%coverage_tool%"] = coverage_tool_runfiles_path + subs["%import_all%"] = ("True" if ctx.fragments.bazel_py.python_import_all_repositories else "False") + subs["%imports%"] = ":".join(imports.to_list()) + subs["%main%"] = "{}/{}".format(ctx.workspace_name, main_py.short_path) + + ctx.actions.expand_template( + template = template, + output = output, + substitutions = subs, + ) def _create_windows_exe_launcher( ctx, @@ -346,7 +444,7 @@ def _create_windows_exe_launcher( use_default_shell_env = True, ) -def _create_zip_file(ctx, *, output, original_nonzip_executable, executable_for_zip_file, runfiles): +def _create_zip_file(ctx, *, output, original_nonzip_executable, zip_main, runfiles): workspace_name = ctx.workspace_name legacy_external_runfiles = _py_builtins.get_legacy_external_runfiles(ctx) @@ -354,7 +452,7 @@ def _create_zip_file(ctx, *, output, original_nonzip_executable, executable_for_ manifest.use_param_file("@%s", use_always = True) manifest.set_param_file_format("multiline") - manifest.add("__main__.py={}".format(executable_for_zip_file.path)) + manifest.add("__main__.py={}".format(zip_main.path)) manifest.add("__init__.py=") manifest.add( "{}=".format( @@ -375,7 +473,7 @@ def _create_zip_file(ctx, *, output, original_nonzip_executable, executable_for_ manifest.add_all(runfiles.files, map_each = map_zip_runfiles, allow_closure = True) - inputs = [executable_for_zip_file] + inputs = [zip_main] if _py_builtins.is_bzlmod_enabled(ctx): zip_repo_mapping_manifest = ctx.actions.declare_file( output.basename + ".repo_mapping", @@ -424,17 +522,32 @@ def _get_zip_runfiles_path(path, workspace_name, legacy_external_runfiles): zip_runfiles_path = paths.normalize("{}/{}".format(workspace_name, path)) return "{}/{}".format(_ZIP_RUNFILES_DIRECTORY_NAME, zip_runfiles_path) -def _create_executable_zip_file(ctx, *, output, zip_file): +def _create_executable_zip_file(ctx, *, output, zip_file, stage2_bootstrap, runtime_details): + prelude = ctx.actions.declare_file( + "{}_zip_prelude.sh".format(output.basename), + sibling = output, + ) + if stage2_bootstrap: + _create_stage1_bootstrap( + ctx, + output = prelude, + stage2_bootstrap = stage2_bootstrap, + runtime_details = runtime_details, + is_for_zip = True, + ) + else: + ctx.actions.write(prelude, "#!/usr/bin/env python3\n") + ctx.actions.run_shell( - command = "echo '{shebang}' | cat - {zip} > {output}".format( - shebang = "#!/usr/bin/env python3", + command = "cat {prelude} {zip} > {output}".format( + prelude = prelude.path, zip = zip_file.path, output = output.path, ), - inputs = [zip_file], + inputs = [prelude, zip_file], outputs = [output], use_default_shell_env = True, - mnemonic = "BuildBinary", + mnemonic = "PyBuildExecutableZip", progress_message = "Build Python zip executable: %{label}", ) diff --git a/python/private/common/py_runtime_rule.bzl b/python/private/common/py_runtime_rule.bzl index 53d925cdba..a7eeb7e3ec 100644 --- a/python/private/common/py_runtime_rule.bzl +++ b/python/private/common/py_runtime_rule.bzl @@ -102,19 +102,20 @@ def _py_runtime_impl(ctx): files = runtime_files if hermetic else None, coverage_tool = coverage_tool, coverage_files = coverage_files, - pyc_tag = pyc_tag, python_version = python_version, stub_shebang = ctx.attr.stub_shebang, bootstrap_template = ctx.file.bootstrap_template, - interpreter_version_info = interpreter_version_info, - implementation_name = ctx.attr.implementation_name, ) builtin_py_runtime_info_kwargs = dict(py_runtime_info_kwargs) - # Pop these because they don't exist on BuiltinPyRuntimeInfo - builtin_py_runtime_info_kwargs.pop("interpreter_version_info") - builtin_py_runtime_info_kwargs.pop("pyc_tag") - builtin_py_runtime_info_kwargs.pop("implementation_name") + # There are all args that BuiltinPyRuntimeInfo doesn't support + py_runtime_info_kwargs.update(dict( + implementation_name = ctx.attr.implementation_name, + interpreter_version_info = interpreter_version_info, + pyc_tag = pyc_tag, + stage2_bootstrap_template = ctx.file.stage2_bootstrap_template, + zip_main_template = ctx.file.zip_main_template, + )) if not IS_BAZEL_7_OR_HIGHER: builtin_py_runtime_info_kwargs.pop("bootstrap_template") @@ -290,6 +291,17 @@ However, in the future this attribute will be mandatory and have no default value. """, ), + "stage2_bootstrap_template": attr.label( + default = "//python/private:stage2_bootstrap_template", + allow_single_file = True, + doc = """ +The template to use when two stage bootstrapping is enabled + +:::{seealso} +{obj}`PyRuntimeInfo.stage2_bootstrap_template` and {obj}`--bootstrap_impl` +::: +""", + ), "stub_shebang": attr.string( default = DEFAULT_STUB_SHEBANG, doc = """ @@ -300,6 +312,19 @@ See https://github.com/bazelbuild/bazel/issues/8685 for motivation. Does not apply to Windows. +""", + ), + "zip_main_template": attr.label( + default = "//python/private:zip_main_template", + allow_single_file = True, + doc = """ +The template to use for a zip's top-level `__main__.py` file. + +This becomes the entry point executed when `python foo.zip` is run. + +:::{seealso} +The {obj}`PyRuntimeInfo.zip_main_template` field. +::: """, ), }), diff --git a/python/private/flags.bzl b/python/private/flags.bzl index 36d305da8a..d141f72eee 100644 --- a/python/private/flags.bzl +++ b/python/private/flags.bzl @@ -21,6 +21,16 @@ unnecessary files when all that are needed are flag definitions. load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load("//python/private:enum.bzl", "enum") +def _bootstrap_impl_flag_get_value(ctx): + return ctx.attr._bootstrap_impl_flag[BuildSettingInfo].value + +# buildifier: disable=name-conventions +BootstrapImplFlag = enum( + SYSTEM_PYTHON = "system_python", + SCRIPT = "script", + get_value = _bootstrap_impl_flag_get_value, +) + def _precompile_flag_get_effective_value(ctx): value = ctx.attr._precompile_flag[BuildSettingInfo].value if value == PrecompileFlag.AUTO: diff --git a/python/private/python_bootstrap_template.txt b/python/private/python_bootstrap_template.txt index 8eaedbc4dc..0f9c90b3b3 100644 --- a/python/private/python_bootstrap_template.txt +++ b/python/private/python_bootstrap_template.txt @@ -91,7 +91,7 @@ def FindPythonBinary(module_space): def PrintVerbose(*args): if os.environ.get("RULES_PYTHON_BOOTSTRAP_VERBOSE"): - print("bootstrap:", *args, file=sys.stderr) + print("bootstrap:", *args, file=sys.stderr, flush=True) def PrintVerboseCoverage(*args): """Print output if VERBOSE_COVERAGE is non-empty in the environment.""" diff --git a/python/private/stage1_bootstrap_template.sh b/python/private/stage1_bootstrap_template.sh new file mode 100644 index 0000000000..fb46cc696c --- /dev/null +++ b/python/private/stage1_bootstrap_template.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +set -e + +if [[ -n "${RULES_PYTHON_BOOTSTRAP_VERBOSE:-}" ]]; then + set -x +fi + +# runfiles-relative path +STAGE2_BOOTSTRAP="%stage2_bootstrap%" + +# runfiles-relative path, absolute path, or single word +PYTHON_BINARY='%python_binary%' + +# 0 or 1 +IS_ZIPFILE="%is_zipfile%" + +if [[ "$IS_ZIPFILE" == "1" ]]; then + zip_dir=$(mktemp -d --suffix Bazel.runfiles_) + + if [[ -n "$zip_dir" && -z "${RULES_PYTHON_BOOTSTRAP_VERBOSE:-}" ]]; then + trap 'rm -fr "$zip_dir"' EXIT + fi + # unzip emits a warning and exits with code 1 when there is extraneous data, + # like this bootstrap prelude code, but otherwise successfully extracts, so + # we have to ignore its exit code and suppress stderr. + # The alternative requires having to copy ourselves elsewhere with the prelude + # stripped (because zip can't extract from a stream). We avoid that because + # it's wasteful. + ( unzip -q -d "$zip_dir" "$0" 2>/dev/null || /bin/true ) + + RUNFILES_DIR="$zip_dir/runfiles" + if [[ ! -d "$RUNFILES_DIR" ]]; then + echo "Runfiles dir not found: zip extraction likely failed" + echo "Run with RULES_PYTHON_BOOTSTRAP_VERBOSE=1 to aid debugging" + exit 1 + fi + +else + function find_runfiles_root() { + if [[ -n "${RUNFILES_DIR:-}" ]]; then + echo "$RUNFILES_DIR" + return 0 + elif [[ "${RUNFILES_MANIFEST_FILE:-}" = *".runfiles_manifest" ]]; then + echo "${RUNFILES_MANIFEST_FILE%%.runfiles_manifest}" + return 0 + elif [[ "${RUNFILES_MANIFEST_FILE:-}" = *".runfiles/MANIFEST" ]]; then + echo "${RUNFILES_MANIFEST_FILE%%.runfiles/MANIFEST}" + return 0 + fi + + stub_filename="$1" + # A relative path to our executable, as happens with + # a build action or bazel-bin/ invocation + if [[ "$stub_filename" != /* ]]; then + stub_filename="$PWD/$stub_filename" + fi + + while true; do + module_space="${stub_filename}.runfiles" + if [[ -d "$module_space" ]]; then + echo "$module_space" + return 0 + fi + if [[ "$stub_filename" == *.runfiles/* ]]; then + echo "${stub_filename%.runfiles*}.runfiles" + return 0 + fi + if [[ ! -L "$stub_filename" ]]; then + break + fi + target=$(realpath $maybe_runfiles_root) + stub_filename="$target" + done + echo >&2 "Unable to find runfiles directory for $1" + exit 1 + } + RUNFILES_DIR=$(find_runfiles_root $0) +fi + + +function find_python_interpreter() { + runfiles_root="$1" + interpreter_path="$2" + if [[ "$interpreter_path" == /* ]]; then + # An absolute path, i.e. platform runtime + echo "$interpreter_path" + elif [[ "$interpreter_path" == */* ]]; then + # A runfiles-relative path + echo "$runfiles_root/$interpreter_path" + else + # A plain word, e.g. "python3". Rely on searching PATH + echo "$interpreter_path" + fi +} + +python_exe=$(find_python_interpreter $RUNFILES_DIR $PYTHON_BINARY) +stage2_bootstrap="$RUNFILES_DIR/$STAGE2_BOOTSTRAP" + +declare -a interpreter_env +declare -a interpreter_args + +# Don't prepend a potentially unsafe path to sys.path +# See: https://docs.python.org/3.11/using/cmdline.html#envvar-PYTHONSAFEPATH +# NOTE: Only works for 3.11+ +interpreter_env+=("PYTHONSAFEPATH=1") + +export RUNFILES_DIR +# NOTE: We use <(...) to pass the Python program as a file so that stdin can +# still be passed along as normal. +env \ + "${interpreter_env[@]}" \ + "$python_exe" \ + "${interpreter_args[@]}" \ + "$stage2_bootstrap" \ + "$@" + +exit $? diff --git a/python/private/stage2_bootstrap_template.py b/python/private/stage2_bootstrap_template.py new file mode 100644 index 0000000000..69c0dec0e5 --- /dev/null +++ b/python/private/stage2_bootstrap_template.py @@ -0,0 +1,510 @@ +# This is a "stage 2" bootstrap. We can assume we've running under the desired +# interpreter, with some of the basic interpreter options/envvars set. +# However, more setup is required to make the app's real main file runnable. + +import sys + +# The Python interpreter unconditionally prepends the directory containing this +# script (following symlinks) to the import path. This is the cause of #9239, +# and is a special case of #7091. We therefore explicitly delete that entry. +# TODO(#7091): Remove this hack when no longer necessary. +# TODO: Use sys.flags.safe_path to determine whether this removal should be +# performed +del sys.path[0] + +import contextlib +import os +import re +import runpy +import subprocess +import uuid + +# ===== Template substitutions start ===== +# We just put them in one place so its easy to tell which are used. + +# Runfiles-relative path to the main Python source file. +MAIN = "%main%" +# Colon-delimited string of runfiles-relative import paths to add +IMPORTS_STR = "%imports%" +WORKSPACE_NAME = "%workspace_name%" +# Though the import all value is the correct literal, we quote it +# so this file is parsable by tools. +IMPORT_ALL = True if "%import_all%" == "True" else False +# Runfiles-relative path to the coverage tool entry point, if any. +COVERAGE_TOOL = "%coverage_tool%" + +# ===== Template substitutions end ===== + + +# Return True if running on Windows +def is_windows(): + return os.name == "nt" + + +def get_windows_path_with_unc_prefix(path): + path = path.strip() + + # No need to add prefix for non-Windows platforms. + if not is_windows() or sys.version_info[0] < 3: + return path + + # Starting in Windows 10, version 1607(OS build 14393), MAX_PATH limitations have been + # removed from common Win32 file and directory functions. + # Related doc: https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd#enable-long-paths-in-windows-10-version-1607-and-later + import platform + + if platform.win32_ver()[1] >= "10.0.14393": + return path + + # import sysconfig only now to maintain python 2.6 compatibility + import sysconfig + + if sysconfig.get_platform() == "mingw": + return path + + # Lets start the unicode fun + if path.startswith(unicode_prefix): + return path + + # os.path.abspath returns a normalized absolute path + return unicode_prefix + os.path.abspath(path) + + +def search_path(name): + """Finds a file in a given search path.""" + search_path = os.getenv("PATH", os.defpath).split(os.pathsep) + for directory in search_path: + if directory: + path = os.path.join(directory, name) + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return None + + +def is_verbose(): + return bool(os.environ.get("RULES_PYTHON_BOOTSTRAP_VERBOSE")) + + +def print_verbose(*args, mapping=None, values=None): + if is_verbose(): + if mapping is not None: + for key, value in sorted((mapping or {}).items()): + print( + "bootstrap: stage 2:", + *args, + f"{key}={value!r}", + file=sys.stderr, + flush=True, + ) + elif values is not None: + for i, v in enumerate(values): + print( + "bootstrap: stage 2:", + *args, + f"[{i}] {v!r}", + file=sys.stderr, + flush=True, + ) + else: + print("bootstrap: stage 2:", *args, file=sys.stderr, flush=True) + + +def print_verbose_coverage(*args): + """Print output if VERBOSE_COVERAGE is non-empty in the environment.""" + if os.environ.get("VERBOSE_COVERAGE"): + print(*args, file=sys.stderr, flush=True) + + +def is_verbose_coverage(): + """Returns True if VERBOSE_COVERAGE is non-empty in the environment.""" + return os.environ.get("VERBOSE_COVERAGE") or is_verbose() + + +def find_coverage_entry_point(module_space): + cov_tool = COVERAGE_TOOL + if cov_tool: + print_verbose_coverage("Using toolchain coverage_tool %r" % cov_tool) + else: + cov_tool = os.environ.get("PYTHON_COVERAGE") + if cov_tool: + print_verbose_coverage("PYTHON_COVERAGE: %r" % cov_tool) + if cov_tool: + return find_binary(module_space, cov_tool) + return None + + +def find_binary(module_space, bin_name): + """Finds the real binary if it's not a normal absolute path.""" + if not bin_name: + return None + if bin_name.startswith("//"): + # Case 1: Path is a label. Not supported yet. + raise AssertionError( + "Bazel does not support execution of Python interpreters via labels yet" + ) + elif os.path.isabs(bin_name): + # Case 2: Absolute path. + return bin_name + # Use normpath() to convert slashes to os.sep on Windows. + elif os.sep in os.path.normpath(bin_name): + # Case 3: Path is relative to the repo root. + return os.path.join(module_space, bin_name) + else: + # Case 4: Path has to be looked up in the search path. + return search_path(bin_name) + + +def create_python_path_entries(python_imports, module_space): + parts = python_imports.split(":") + return [module_space] + ["%s/%s" % (module_space, path) for path in parts] + + +def find_runfiles_root(main_rel_path): + """Finds the runfiles tree.""" + # When the calling process used the runfiles manifest to resolve the + # location of this stub script, the path may be expanded. This means + # argv[0] may no longer point to a location inside the runfiles + # directory. We should therefore respect RUNFILES_DIR and + # RUNFILES_MANIFEST_FILE set by the caller. + runfiles_dir = os.environ.get("RUNFILES_DIR", None) + if not runfiles_dir: + runfiles_manifest_file = os.environ.get("RUNFILES_MANIFEST_FILE", "") + if runfiles_manifest_file.endswith( + ".runfiles_manifest" + ) or runfiles_manifest_file.endswith(".runfiles/MANIFEST"): + runfiles_dir = runfiles_manifest_file[:-9] + # Be defensive: the runfiles dir should contain our main entry point. If + # it doesn't, then it must not be our runfiles directory. + if runfiles_dir and os.path.exists(os.path.join(runfiles_dir, main_rel_path)): + return runfiles_dir + + stub_filename = sys.argv[0] + if not os.path.isabs(stub_filename): + stub_filename = os.path.join(os.getcwd(), stub_filename) + + while True: + module_space = stub_filename + (".exe" if is_windows() else "") + ".runfiles" + if os.path.isdir(module_space): + return module_space + + runfiles_pattern = r"(.*\.runfiles)" + (r"\\" if is_windows() else "/") + ".*" + matchobj = re.match(runfiles_pattern, stub_filename) + if matchobj: + return matchobj.group(1) + + if not os.path.islink(stub_filename): + break + target = os.readlink(stub_filename) + if os.path.isabs(target): + stub_filename = target + else: + stub_filename = os.path.join(os.path.dirname(stub_filename), target) + + raise AssertionError("Cannot find .runfiles directory for %s" % sys.argv[0]) + + +# Returns repository roots to add to the import path. +def get_repositories_imports(module_space, import_all): + if import_all: + repo_dirs = [os.path.join(module_space, d) for d in os.listdir(module_space)] + repo_dirs.sort() + return [d for d in repo_dirs if os.path.isdir(d)] + return [os.path.join(module_space, WORKSPACE_NAME)] + + +def runfiles_envvar(module_space): + """Finds the runfiles manifest or the runfiles directory. + + Returns: + A tuple of (var_name, var_value) where var_name is either 'RUNFILES_DIR' or + 'RUNFILES_MANIFEST_FILE' and var_value is the path to that directory or + file, or (None, None) if runfiles couldn't be found. + """ + # If this binary is the data-dependency of another one, the other sets + # RUNFILES_MANIFEST_FILE or RUNFILES_DIR for our sake. + runfiles = os.environ.get("RUNFILES_MANIFEST_FILE", None) + if runfiles: + return ("RUNFILES_MANIFEST_FILE", runfiles) + + runfiles = os.environ.get("RUNFILES_DIR", None) + if runfiles: + return ("RUNFILES_DIR", runfiles) + + # Look for the runfiles "output" manifest, argv[0] + ".runfiles_manifest" + runfiles = module_space + "_manifest" + if os.path.exists(runfiles): + return ("RUNFILES_MANIFEST_FILE", runfiles) + + # Look for the runfiles "input" manifest, argv[0] + ".runfiles/MANIFEST" + # Normally .runfiles_manifest and MANIFEST are both present, but the + # former will be missing for zip-based builds or if someone copies the + # runfiles tree elsewhere. + runfiles = os.path.join(module_space, "MANIFEST") + if os.path.exists(runfiles): + return ("RUNFILES_MANIFEST_FILE", runfiles) + + # If running in a sandbox and no environment variables are set, then + # Look for the runfiles next to the binary. + if module_space.endswith(".runfiles") and os.path.isdir(module_space): + return ("RUNFILES_DIR", module_space) + + return (None, None) + + +def deduplicate(items): + """Efficiently filter out duplicates, keeping the first element only.""" + seen = set() + for it in items: + if it not in seen: + seen.add(it) + yield it + + +def instrumented_file_paths(): + """Yields tuples of realpath of each instrumented file with the relative path.""" + manifest_filename = os.environ.get("COVERAGE_MANIFEST") + if not manifest_filename: + return + with open(manifest_filename, "r") as manifest: + for line in manifest: + filename = line.strip() + if not filename: + continue + try: + realpath = os.path.realpath(filename) + except OSError: + print( + "Could not find instrumented file {}".format(filename), + file=sys.stderr, + flush=True, + ) + continue + if realpath != filename: + print_verbose_coverage("Fixing up {} -> {}".format(realpath, filename)) + yield (realpath, filename) + + +def unresolve_symlinks(output_filename): + # type: (str) -> None + """Replace realpath of instrumented files with the relative path in the lcov output. + + Though we are asking coveragepy to use relative file names, currently + ignore that for purposes of generating the lcov report (and other reports + which are not the XML report), so we need to go and fix up the report. + + This function is a workaround for that issue. Once that issue is fixed + upstream and the updated version is widely in use, this should be removed. + + See https://github.com/nedbat/coveragepy/issues/963. + """ + substitutions = list(instrumented_file_paths()) + if substitutions: + unfixed_file = output_filename + ".tmp" + os.rename(output_filename, unfixed_file) + with open(unfixed_file, "r") as unfixed: + with open(output_filename, "w") as output_file: + for line in unfixed: + if line.startswith("SF:"): + for realpath, filename in substitutions: + line = line.replace(realpath, filename) + output_file.write(line) + os.unlink(unfixed_file) + + +def _run_py(main_filename, *, args, cwd=None): + # type: (str, str, list[str], dict[str, str]) -> ... + """Executes the given Python file using the various environment settings.""" + + orig_argv = sys.argv + orig_cwd = os.getcwd() + try: + sys.argv = [main_filename] + args + if cwd: + os.chdir(cwd) + print_verbose("run_py: cwd:", os.getcwd()) + print_verbose("run_py: sys.argv: ", values=sys.argv) + print_verbose("run_py: os.environ:", mapping=os.environ) + print_verbose("run_py: sys.path:", values=sys.path) + runpy.run_path(main_filename, run_name="__main__") + finally: + os.chdir(orig_cwd) + sys.argv = orig_argv + + +@contextlib.contextmanager +def _maybe_collect_coverage(enable): + if not enable: + yield + return + + import uuid + + import coverage + + coverage_dir = os.environ["COVERAGE_DIR"] + unique_id = uuid.uuid4() + + # We need for coveragepy to use relative paths. This can only be configured + rcfile_name = os.path.join(coverage_dir, ".coveragerc_{}".format(unique_id)) + with open(rcfile_name, "w") as rcfile: + rcfile.write( + """[run] +relative_files = True +""" + ) + try: + cov = coverage.Coverage( + config_file=rcfile_name, + branch=True, + # NOTE: The messages arg controls what coverage prints to stdout/stderr, + # which can interfere with the Bazel coverage command. Enabling message + # output is only useful for debugging coverage support. + messages=is_verbose_coverage(), + omit=[ + # Pipes can't be read back later, which can cause coverage to + # throw an error when trying to get its source code. + "/dev/fd/*", + ], + ) + cov.start() + try: + yield + finally: + cov.stop() + lcov_path = os.path.join(coverage_dir, "pylcov.dat") + cov.lcov_report( + outfile=lcov_path, + # Ignore errors because sometimes instrumented files aren't + # readable afterwards. e.g. if they come from /dev/fd or if + # they were transient code-under-test in /tmp + ignore_errors=True, + ) + if os.path.isfile(lcov_path): + unresolve_symlinks(lcov_path) + finally: + try: + os.unlink(rcfile_name) + except OSError as err: + # It's possible that the profiled program might execute another Python + # binary through a wrapper that would then delete the rcfile. Not much + # we can do about that, besides ignore the failure here. + print_verbose_coverage("Error removing temporary coverage rc file:", err) + + +def main(): + print_verbose("initial argv:", values=sys.argv) + print_verbose("initial cwd:", os.getcwd()) + print_verbose("initial environ:", mapping=os.environ) + print_verbose("initial sys.path:", values=sys.path) + + main_rel_path = MAIN + if is_windows(): + main_rel_path = main_rel_path.replace("/", os.sep) + + module_space = find_runfiles_root(main_rel_path) + print_verbose("runfiles root:", module_space) + + # Recreate the "add main's dir to sys.path[0]" behavior to match the + # system-python bootstrap / typical Python behavior. + # + # Without safe path enabled, when `python foo/bar.py` is run, python will + # resolve the foo/bar.py symlink to its real path, then add the directory + # of that path to sys.path. But, the resolved directory for the symlink + # depends on if the file is generated or not. + # + # When foo/bar.py is a source file, then it's a symlink pointing + # back to the client source directory. This means anything from that source + # directory becomes importable, i.e. most code is importable. + # + # When foo/bar.py is a generated file, then it's a symlink pointing to + # somewhere under bazel-out/.../bin, i.e. where generated files are. This + # means only other generated files are importable (not source files). + # + # To replicate this behavior, we add main's directory within the runfiles + # when safe path isn't enabled. + if not getattr(sys.flags, "safe_path", False): + prepend_path_entries = [ + os.path.join(module_space, os.path.dirname(main_rel_path)) + ] + else: + prepend_path_entries = [] + python_path_entries = create_python_path_entries(IMPORTS_STR, module_space) + python_path_entries += get_repositories_imports(module_space, IMPORT_ALL) + python_path_entries = [ + get_windows_path_with_unc_prefix(d) for d in python_path_entries + ] + + # Remove duplicates to avoid overly long PYTHONPATH (#10977). Preserve order, + # keep first occurrence only. + python_path_entries = deduplicate(python_path_entries) + + if is_windows(): + python_path_entries = [p.replace("/", os.sep) for p in python_path_entries] + else: + # deduplicate returns a generator, but we need a list after this. + python_path_entries = list(python_path_entries) + + # We're emulating PYTHONPATH being set, so we insert at the start + # This isn't a great idea (it can shadow the stdlib), but is the historical + # behavior. + runfiles_envkey, runfiles_envvalue = runfiles_envvar(module_space) + if runfiles_envkey: + os.environ[runfiles_envkey] = runfiles_envvalue + + main_filename = os.path.join(module_space, main_rel_path) + main_filename = get_windows_path_with_unc_prefix(main_filename) + assert os.path.exists(main_filename), ( + "Cannot exec() %r: file not found." % main_filename + ) + assert os.access(main_filename, os.R_OK), ( + "Cannot exec() %r: file not readable." % main_filename + ) + + # COVERAGE_DIR is set if coverage is enabled and instrumentation is configured + # for something, though it could be another program executing this one or + # one executed by this one (e.g. an extension module). + if os.environ.get("COVERAGE_DIR"): + cov_tool = find_coverage_entry_point(module_space) + if cov_tool is None: + print_verbose_coverage( + "Coverage was enabled, but python coverage tool was not configured." + + "To enable coverage, consult the docs at " + + "https://rules-python.readthedocs.io/en/latest/coverage.html" + ) + else: + # Inhibit infinite recursion: + if "PYTHON_COVERAGE" in os.environ: + del os.environ["PYTHON_COVERAGE"] + + if not os.path.exists(cov_tool): + raise EnvironmentError( + "Python coverage tool %r not found. " + "Try running with VERBOSE_COVERAGE=1 to collect more information." + % cov_tool + ) + + # coverage library expects sys.path[0] to contain the library, and replaces + # it with the directory of the program it starts. Our actual sys.path[0] is + # the runfiles directory, which must not be replaced. + # CoverageScript.do_execute() undoes this sys.path[0] setting. + # + # Update sys.path such that python finds the coverage package. The coverage + # entry point is coverage.coverage_main, so we need to do twice the dirname. + coverage_dir = os.path.dirname(os.path.dirname(cov_tool)) + print_verbose("coverage: adding to sys.path:", coverage_dir) + python_path_entries.append(coverage_dir) + python_path_entries = deduplicate(python_path_entries) + else: + cov_tool = None + + sys.stdout.flush() + # NOTE: The sys.path must be modified before coverage is imported/activated + sys.path[0:0] = prepend_path_entries + sys.path.extend(python_path_entries) + with _maybe_collect_coverage(enable=cov_tool is not None): + # The first arg is this bootstrap, so drop that for the re-invocation. + _run_py(main_filename, args=sys.argv[1:]) + sys.exit(0) + + +main() diff --git a/python/private/zip_main_template.py b/python/private/zip_main_template.py new file mode 100644 index 0000000000..18eaed9630 --- /dev/null +++ b/python/private/zip_main_template.py @@ -0,0 +1,292 @@ +# Template for the __main__.py file inserted into zip files +# +# NOTE: This file is a "stage 1" bootstrap, so it's responsible for locating the +# desired runtime and having it run the stage 2 bootstrap. This means it can't +# assume much about the current runtime and environment. e.g, the current +# runtime may not be the correct one, the zip may not have been extract, the +# runfiles env vars may not be set, etc. +# +# NOTE: This program must retain compatibility with a wide variety of Python +# versions since it is run by an unknown Python interpreter. + +import sys + +# The Python interpreter unconditionally prepends the directory containing this +# script (following symlinks) to the import path. This is the cause of #9239, +# and is a special case of #7091. We therefore explicitly delete that entry. +# TODO(#7091): Remove this hack when no longer necessary. +del sys.path[0] + +import os +import shutil +import subprocess +import tempfile +import zipfile + +_STAGE2_BOOTSTRAP = "%stage2_bootstrap%" +_PYTHON_BINARY = "%python_binary%" +_WORKSPACE_NAME = "%workspace_name%" + + +# Return True if running on Windows +def is_windows(): + return os.name == "nt" + + +def get_windows_path_with_unc_prefix(path): + """Adds UNC prefix after getting a normalized absolute Windows path. + + No-op for non-Windows platforms or if running under python2. + """ + path = path.strip() + + # No need to add prefix for non-Windows platforms. + # And \\?\ doesn't work in python 2 or on mingw + if not is_windows() or sys.version_info[0] < 3: + return path + + # Starting in Windows 10, version 1607(OS build 14393), MAX_PATH limitations have been + # removed from common Win32 file and directory functions. + # Related doc: https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd#enable-long-paths-in-windows-10-version-1607-and-later + import platform + + if platform.win32_ver()[1] >= "10.0.14393": + return path + + # import sysconfig only now to maintain python 2.6 compatibility + import sysconfig + + if sysconfig.get_platform() == "mingw": + return path + + # Lets start the unicode fun + unicode_prefix = "\\\\?\\" + if path.startswith(unicode_prefix): + return path + + # os.path.abspath returns a normalized absolute path + return unicode_prefix + os.path.abspath(path) + + +def has_windows_executable_extension(path): + return path.endswith(".exe") or path.endswith(".com") or path.endswith(".bat") + + +if is_windows() and not has_windows_executable_extension(_PYTHON_BINARY): + _PYTHON_BINARY = _PYTHON_BINARY + ".exe" + + +def search_path(name): + """Finds a file in a given search path.""" + search_path = os.getenv("PATH", os.defpath).split(os.pathsep) + for directory in search_path: + if directory: + path = os.path.join(directory, name) + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return None + + +def find_python_binary(module_space): + """Finds the real Python binary if it's not a normal absolute path.""" + return find_binary(module_space, _PYTHON_BINARY) + + +def print_verbose(*args, mapping=None, values=None): + if bool(os.environ.get("RULES_PYTHON_BOOTSTRAP_VERBOSE")): + if mapping is not None: + for key, value in sorted((mapping or {}).items()): + print( + "bootstrap: stage 1:", + *args, + f"{key}={value!r}", + file=sys.stderr, + flush=True, + ) + elif values is not None: + for i, v in enumerate(values): + print( + "bootstrap: stage 1:", + *args, + f"[{i}] {v!r}", + file=sys.stderr, + flush=True, + ) + else: + print("bootstrap: stage 1:", *args, file=sys.stderr, flush=True) + + +def find_binary(module_space, bin_name): + """Finds the real binary if it's not a normal absolute path.""" + if not bin_name: + return None + if bin_name.startswith("//"): + # Case 1: Path is a label. Not supported yet. + raise AssertionError( + "Bazel does not support execution of Python interpreters via labels yet" + ) + elif os.path.isabs(bin_name): + # Case 2: Absolute path. + return bin_name + # Use normpath() to convert slashes to os.sep on Windows. + elif os.sep in os.path.normpath(bin_name): + # Case 3: Path is relative to the repo root. + return os.path.join(module_space, bin_name) + else: + # Case 4: Path has to be looked up in the search path. + return search_path(bin_name) + + +def extract_zip(zip_path, dest_dir): + """Extracts the contents of a zip file, preserving the unix file mode bits. + + These include the permission bits, and in particular, the executable bit. + + Ideally the zipfile module should set these bits, but it doesn't. See: + https://bugs.python.org/issue15795. + + Args: + zip_path: The path to the zip file to extract + dest_dir: The path to the destination directory + """ + zip_path = get_windows_path_with_unc_prefix(zip_path) + dest_dir = get_windows_path_with_unc_prefix(dest_dir) + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + zf.extract(info, dest_dir) + # UNC-prefixed paths must be absolute/normalized. See + # https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation + file_path = os.path.abspath(os.path.join(dest_dir, info.filename)) + # The Unix st_mode bits (see "man 7 inode") are stored in the upper 16 + # bits of external_attr. Of those, we set the lower 12 bits, which are the + # file mode bits (since the file type bits can't be set by chmod anyway). + attrs = info.external_attr >> 16 + if attrs != 0: # Rumor has it these can be 0 for zips created on Windows. + os.chmod(file_path, attrs & 0o7777) + + +# Create the runfiles tree by extracting the zip file +def create_module_space(): + temp_dir = tempfile.mkdtemp("", "Bazel.runfiles_") + extract_zip(os.path.dirname(__file__), temp_dir) + # IMPORTANT: Later code does `rm -fr` on dirname(module_space) -- it's + # important that deletion code be in sync with this directory structure + return os.path.join(temp_dir, "runfiles") + + +def execute_file( + python_program, + main_filename, + args, + env, + module_space, + workspace, +): + # type: (str, str, list[str], dict[str, str], str, str|None, str|None) -> ... + """Executes the given Python file using the various environment settings. + + This will not return, and acts much like os.execv, except is much + more restricted, and handles Bazel-related edge cases. + + Args: + python_program: (str) Path to the Python binary to use for execution + main_filename: (str) The Python file to execute + args: (list[str]) Additional args to pass to the Python file + env: (dict[str, str]) A dict of environment variables to set for the execution + module_space: (str) Path to the module space/runfiles tree directory + workspace: (str|None) Name of the workspace to execute in. This is expected to be a + directory under the runfiles tree. + """ + # We want to use os.execv instead of subprocess.call, which causes + # problems with signal passing (making it difficult to kill + # Bazel). However, these conditions force us to run via + # subprocess.call instead: + # + # - On Windows, os.execv doesn't handle arguments with spaces + # correctly, and it actually starts a subprocess just like + # subprocess.call. + # - When running in a workspace or zip file, we need to clean up the + # workspace after the process finishes so control must return here. + try: + subprocess_argv = [python_program, main_filename] + args + print_verbose("subprocess argv:", values=subprocess_argv) + print_verbose("subprocess env:", mapping=env) + print_verbose("subprocess cwd:", workspace) + ret_code = subprocess.call(subprocess_argv, env=env, cwd=workspace) + sys.exit(ret_code) + finally: + # NOTE: dirname() is called because create_module_space() creates a + # sub-directory within a temporary directory, and we want to remove the + # whole temporary directory. + shutil.rmtree(os.path.dirname(module_space), True) + + +def main(): + print_verbose("running zip main bootstrap") + print_verbose("initial argv:", values=sys.argv) + print_verbose("initial environ:", mapping=os.environ) + print_verbose("initial sys.executable", sys.executable) + print_verbose("initial sys.version", sys.version) + + args = sys.argv[1:] + + new_env = {} + + # The main Python source file. + # The magic string percent-main-percent is replaced with the runfiles-relative + # filename of the main file of the Python binary in BazelPythonSemantics.java. + main_rel_path = _STAGE2_BOOTSTRAP + if is_windows(): + main_rel_path = main_rel_path.replace("/", os.sep) + + module_space = create_module_space() + print_verbose("extracted runfiles to:", module_space) + + new_env["RUNFILES_DIR"] = module_space + + # Don't prepend a potentially unsafe path to sys.path + # See: https://docs.python.org/3.11/using/cmdline.html#envvar-PYTHONSAFEPATH + new_env["PYTHONSAFEPATH"] = "1" + + main_filename = os.path.join(module_space, main_rel_path) + main_filename = get_windows_path_with_unc_prefix(main_filename) + assert os.path.exists(main_filename), ( + "Cannot exec() %r: file not found." % main_filename + ) + assert os.access(main_filename, os.R_OK), ( + "Cannot exec() %r: file not readable." % main_filename + ) + + program = python_program = find_python_binary(module_space) + if python_program is None: + raise AssertionError("Could not find python binary: " + _PYTHON_BINARY) + + # Some older Python versions on macOS (namely Python 3.7) may unintentionally + # leave this environment variable set after starting the interpreter, which + # causes problems with Python subprocesses correctly locating sys.executable, + # which subsequently causes failure to launch on Python 3.11 and later. + if "__PYVENV_LAUNCHER__" in os.environ: + del os.environ["__PYVENV_LAUNCHER__"] + + new_env.update((key, val) for key, val in os.environ.items() if key not in new_env) + + workspace = None + # If RUN_UNDER_RUNFILES equals 1, it means we need to + # change directory to the right runfiles directory. + # (So that the data files are accessible) + if os.environ.get("RUN_UNDER_RUNFILES") == "1": + workspace = os.path.join(module_space, _WORKSPACE_NAME) + + sys.stdout.flush() + execute_file( + python_program, + main_filename, + args, + new_env, + module_space, + workspace, + ) + + +if __name__ == "__main__": + main() diff --git a/python/repositories.bzl b/python/repositories.bzl index 26081a6b48..4ffadd050a 100644 --- a/python/repositories.bzl +++ b/python/repositories.bzl @@ -185,7 +185,10 @@ def _python_repository_impl(rctx): elif rctx.attr.distutils_content: rctx.file(distutils_path, rctx.attr.distutils_content) - # Make the Python installation read-only. + # Make the Python installation read-only. This is to prevent issues due to + # pycs being generated at runtime: + # * The pycs are not deterministic (they contain timestamps) + # * Multiple processes trying to write the same pycs can result in errors. if not rctx.attr.ignore_root_user_error: if "windows" not in platform: lib_dir = "lib" if "windows" not in platform else "Lib" @@ -200,6 +203,9 @@ def _python_repository_impl(rctx): op = "python_repository.TestReadOnly", arguments = [repo_utils.which_checked(rctx, "touch"), "{}/.test".format(lib_dir)], ) + + # The issue with running as root is the installation is no longer + # read-only, so the problems due to pyc can resurface. if exec_result.return_code == 0: stdout = repo_utils.execute_checked_stdout( rctx, diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl index b6f28026db..43e800a99f 100644 --- a/tests/base_rules/py_executable_base_tests.bzl +++ b/tests/base_rules/py_executable_base_tests.bzl @@ -20,7 +20,7 @@ load("@rules_testing//lib:truth.bzl", "matching") load("@rules_testing//lib:util.bzl", rt_util = "util") load("//tests/base_rules:base_tests.bzl", "create_base_tests") load("//tests/base_rules:util.bzl", "WINDOWS_ATTR", pt_util = "util") -load("//tests/support:support.bzl", "WINDOWS") +load("//tests/support:support.bzl", "WINDOWS_X86_64") _BuiltinPyRuntimeInfo = PyRuntimeInfo @@ -50,7 +50,7 @@ def _test_basic_windows(name, config): "//command_line_option:cpu": "windows_x86_64", "//command_line_option:crosstool_top": Label("//tests/cc:cc_toolchain_suite"), "//command_line_option:extra_toolchains": [str(Label("//tests/cc:all"))], - "//command_line_option:platforms": [WINDOWS], + "//command_line_option:platforms": [WINDOWS_X86_64], }, attr_values = {"target_compatible_with": target_compatible_with}, ) diff --git a/tests/base_rules/py_test/py_test_tests.bzl b/tests/base_rules/py_test/py_test_tests.bzl index 50c1db27cf..c77bd7eb04 100644 --- a/tests/base_rules/py_test/py_test_tests.bzl +++ b/tests/base_rules/py_test/py_test_tests.bzl @@ -21,13 +21,26 @@ load( "create_executable_tests", ) load("//tests/base_rules:util.bzl", pt_util = "util") -load("//tests/support:support.bzl", "LINUX", "MAC") +load("//tests/support:support.bzl", "LINUX_X86_64", "MAC_X86_64") # Explicit Label() calls are required so that it resolves in @rules_python # context instead of @rules_testing context. _FAKE_CC_TOOLCHAIN = Label("//tests/cc:cc_toolchain_suite") _FAKE_CC_TOOLCHAINS = [str(Label("//tests/cc:all"))] +# The Windows CI currently runs as root, which breaks when +# the analysis tests try to install (but not use, because +# these are analysis tests) a runtime for another platform. +# This is because the toolchain install has an assert to +# verify the runtime install is read-only, which it can't +# be when running as root. +_SKIP_WINDOWS = { + "target_compatible_with": select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +} + _tests = [] def _test_mac_requires_darwin_for_execution(name, config): @@ -52,8 +65,9 @@ def _test_mac_requires_darwin_for_execution(name, config): "//command_line_option:cpu": "darwin_x86_64", "//command_line_option:crosstool_top": _FAKE_CC_TOOLCHAIN, "//command_line_option:extra_toolchains": _FAKE_CC_TOOLCHAINS, - "//command_line_option:platforms": [MAC], + "//command_line_option:platforms": [MAC_X86_64], }, + attr_values = _SKIP_WINDOWS, ) def _test_mac_requires_darwin_for_execution_impl(env, target): @@ -84,8 +98,9 @@ def _test_non_mac_doesnt_require_darwin_for_execution(name, config): "//command_line_option:cpu": "k8", "//command_line_option:crosstool_top": _FAKE_CC_TOOLCHAIN, "//command_line_option:extra_toolchains": _FAKE_CC_TOOLCHAINS, - "//command_line_option:platforms": [LINUX], + "//command_line_option:platforms": [LINUX_X86_64], }, + attr_values = _SKIP_WINDOWS, ) def _test_non_mac_doesnt_require_darwin_for_execution_impl(env, target): diff --git a/tests/support/support.bzl b/tests/support/support.bzl index 14a743b8a2..4bcc554854 100644 --- a/tests/support/support.bzl +++ b/tests/support/support.bzl @@ -20,8 +20,11 @@ # places. MAC = Label("//tests/support:mac") +MAC_X86_64 = Label("//tests/support:mac_x86_64") LINUX = Label("//tests/support:linux") +LINUX_X86_64 = Label("//tests/support:linux_x86_64") WINDOWS = Label("//tests/support:windows") +WINDOWS_X86_64 = Label("//tests/support:windows_x86_64") PLATFORM_TOOLCHAIN = str(Label("//tests/support:platform_toolchain")) CC_TOOLCHAIN = str(Label("//tests/cc:all"))