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"""Tests common to py_binary and py_test (executable rules).""" 15 16load("@rules_python//python:py_runtime_info.bzl", RulesPythonPyRuntimeInfo = "PyRuntimeInfo") 17load("@rules_python_internal//:rules_python_config.bzl", rp_config = "config") 18load("@rules_testing//lib:analysis_test.bzl", "analysis_test") 19load("@rules_testing//lib:truth.bzl", "matching") 20load("@rules_testing//lib:util.bzl", rt_util = "util") 21load("//python:py_executable_info.bzl", "PyExecutableInfo") 22load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER") # buildifier: disable=bzl-visibility 23load("//tests/base_rules:base_tests.bzl", "create_base_tests") 24load("//tests/base_rules:util.bzl", "WINDOWS_ATTR", pt_util = "util") 25load("//tests/support:py_executable_info_subject.bzl", "PyExecutableInfoSubject") 26load("//tests/support:support.bzl", "CC_TOOLCHAIN", "CROSSTOOL_TOP", "LINUX_X86_64", "WINDOWS_X86_64") 27 28_BuiltinPyRuntimeInfo = PyRuntimeInfo 29 30_tests = [] 31 32def _test_basic_windows(name, config): 33 if rp_config.enable_pystar: 34 target_compatible_with = [] 35 else: 36 target_compatible_with = ["@platforms//:incompatible"] 37 rt_util.helper_target( 38 config.rule, 39 name = name + "_subject", 40 srcs = ["main.py"], 41 main = "main.py", 42 ) 43 analysis_test( 44 name = name, 45 impl = _test_basic_windows_impl, 46 target = name + "_subject", 47 config_settings = { 48 # NOTE: The default for this flag is based on the Bazel host OS, not 49 # the target platform. For windows, it defaults to true, so force 50 # it to that to match behavior when this test runs on other 51 # platforms. 52 "//command_line_option:build_python_zip": "true", 53 "//command_line_option:cpu": "windows_x86_64", 54 "//command_line_option:crosstool_top": CROSSTOOL_TOP, 55 "//command_line_option:extra_toolchains": [CC_TOOLCHAIN], 56 "//command_line_option:platforms": [WINDOWS_X86_64], 57 }, 58 attr_values = {"target_compatible_with": target_compatible_with}, 59 ) 60 61def _test_basic_windows_impl(env, target): 62 target = env.expect.that_target(target) 63 target.executable().path().contains(".exe") 64 target.runfiles().contains_predicate(matching.str_endswith( 65 target.meta.format_str("/{name}.zip"), 66 )) 67 target.runfiles().contains_predicate(matching.str_endswith( 68 target.meta.format_str("/{name}.exe"), 69 )) 70 71_tests.append(_test_basic_windows) 72 73def _test_basic_zip(name, config): 74 if rp_config.enable_pystar: 75 target_compatible_with = select({ 76 # Disable the new test on windows because we have _test_basic_windows. 77 "@platforms//os:windows": ["@platforms//:incompatible"], 78 "//conditions:default": [], 79 }) 80 else: 81 target_compatible_with = ["@platforms//:incompatible"] 82 rt_util.helper_target( 83 config.rule, 84 name = name + "_subject", 85 srcs = ["main.py"], 86 main = "main.py", 87 ) 88 analysis_test( 89 name = name, 90 impl = _test_basic_zip_impl, 91 target = name + "_subject", 92 config_settings = { 93 # NOTE: The default for this flag is based on the Bazel host OS, not 94 # the target platform. For windows, it defaults to true, so force 95 # it to that to match behavior when this test runs on other 96 # platforms. 97 "//command_line_option:build_python_zip": "true", 98 "//command_line_option:cpu": "linux_x86_64", 99 "//command_line_option:crosstool_top": CROSSTOOL_TOP, 100 "//command_line_option:extra_toolchains": [CC_TOOLCHAIN], 101 "//command_line_option:platforms": [LINUX_X86_64], 102 }, 103 attr_values = {"target_compatible_with": target_compatible_with}, 104 ) 105 106def _test_basic_zip_impl(env, target): 107 target = env.expect.that_target(target) 108 target.runfiles().contains_predicate(matching.str_endswith( 109 target.meta.format_str("/{name}.zip"), 110 )) 111 target.runfiles().contains_predicate(matching.str_endswith( 112 target.meta.format_str("/{name}"), 113 )) 114 115_tests.append(_test_basic_zip) 116 117def _test_executable_in_runfiles(name, config): 118 rt_util.helper_target( 119 config.rule, 120 name = name + "_subject", 121 srcs = [name + "_subject.py"], 122 ) 123 analysis_test( 124 name = name, 125 impl = _test_executable_in_runfiles_impl, 126 target = name + "_subject", 127 attrs = WINDOWS_ATTR, 128 ) 129 130_tests.append(_test_executable_in_runfiles) 131 132def _test_executable_in_runfiles_impl(env, target): 133 if pt_util.is_windows(env): 134 exe = ".exe" 135 else: 136 exe = "" 137 env.expect.that_target(target).runfiles().contains_at_least([ 138 "{workspace}/{package}/{test_name}_subject" + exe, 139 ]) 140 141 if rp_config.enable_pystar: 142 py_exec_info = env.expect.that_target(target).provider(PyExecutableInfo, factory = PyExecutableInfoSubject.new) 143 py_exec_info.main().path().contains("_subject.py") 144 py_exec_info.interpreter_path().contains("python") 145 py_exec_info.runfiles_without_exe().contains_none_of([ 146 "{workspace}/{package}/{test_name}_subject" + exe, 147 "{workspace}/{package}/{test_name}_subject", 148 ]) 149 150def _test_default_main_can_be_generated(name, config): 151 rt_util.helper_target( 152 config.rule, 153 name = name + "_subject", 154 srcs = [rt_util.empty_file(name + "_subject.py")], 155 ) 156 analysis_test( 157 name = name, 158 impl = _test_default_main_can_be_generated_impl, 159 target = name + "_subject", 160 ) 161 162_tests.append(_test_default_main_can_be_generated) 163 164def _test_default_main_can_be_generated_impl(env, target): 165 env.expect.that_target(target).default_outputs().contains( 166 "{package}/{test_name}_subject.py", 167 ) 168 169def _test_default_main_can_have_multiple_path_segments(name, config): 170 rt_util.helper_target( 171 config.rule, 172 name = name + "/subject", 173 srcs = [name + "/subject.py"], 174 ) 175 analysis_test( 176 name = name, 177 impl = _test_default_main_can_have_multiple_path_segments_impl, 178 target = name + "/subject", 179 ) 180 181_tests.append(_test_default_main_can_have_multiple_path_segments) 182 183def _test_default_main_can_have_multiple_path_segments_impl(env, target): 184 env.expect.that_target(target).default_outputs().contains( 185 "{package}/{test_name}/subject.py", 186 ) 187 188def _test_default_main_must_be_in_srcs(name, config): 189 # Bazel 5 will crash with a Java stacktrace when the native Python 190 # rules have an error. 191 if not pt_util.is_bazel_6_or_higher(): 192 rt_util.skip_test(name = name) 193 return 194 rt_util.helper_target( 195 config.rule, 196 name = name + "_subject", 197 srcs = ["other.py"], 198 ) 199 analysis_test( 200 name = name, 201 impl = _test_default_main_must_be_in_srcs_impl, 202 target = name + "_subject", 203 expect_failure = True, 204 ) 205 206_tests.append(_test_default_main_must_be_in_srcs) 207 208def _test_default_main_must_be_in_srcs_impl(env, target): 209 env.expect.that_target(target).failures().contains_predicate( 210 matching.str_matches("default*does not appear in srcs"), 211 ) 212 213def _test_default_main_cannot_be_ambiguous(name, config): 214 # Bazel 5 will crash with a Java stacktrace when the native Python 215 # rules have an error. 216 if not pt_util.is_bazel_6_or_higher(): 217 rt_util.skip_test(name = name) 218 return 219 rt_util.helper_target( 220 config.rule, 221 name = name + "_subject", 222 srcs = [name + "_subject.py", "other/{}_subject.py".format(name)], 223 ) 224 analysis_test( 225 name = name, 226 impl = _test_default_main_cannot_be_ambiguous_impl, 227 target = name + "_subject", 228 expect_failure = True, 229 ) 230 231_tests.append(_test_default_main_cannot_be_ambiguous) 232 233def _test_default_main_cannot_be_ambiguous_impl(env, target): 234 env.expect.that_target(target).failures().contains_predicate( 235 matching.str_matches("default main*matches multiple files"), 236 ) 237 238def _test_explicit_main(name, config): 239 rt_util.helper_target( 240 config.rule, 241 name = name + "_subject", 242 srcs = ["custom.py"], 243 main = "custom.py", 244 ) 245 analysis_test( 246 name = name, 247 impl = _test_explicit_main_impl, 248 target = name + "_subject", 249 ) 250 251_tests.append(_test_explicit_main) 252 253def _test_explicit_main_impl(env, target): 254 # There isn't a direct way to ask what main file was selected, so we 255 # rely on it being in the default outputs. 256 env.expect.that_target(target).default_outputs().contains( 257 "{package}/custom.py", 258 ) 259 260def _test_explicit_main_cannot_be_ambiguous(name, config): 261 # Bazel 5 will crash with a Java stacktrace when the native Python 262 # rules have an error. 263 if not pt_util.is_bazel_6_or_higher(): 264 rt_util.skip_test(name = name) 265 return 266 rt_util.helper_target( 267 config.rule, 268 name = name + "_subject", 269 srcs = ["x/foo.py", "y/foo.py"], 270 main = "foo.py", 271 ) 272 analysis_test( 273 name = name, 274 impl = _test_explicit_main_cannot_be_ambiguous_impl, 275 target = name + "_subject", 276 expect_failure = True, 277 ) 278 279_tests.append(_test_explicit_main_cannot_be_ambiguous) 280 281def _test_explicit_main_cannot_be_ambiguous_impl(env, target): 282 env.expect.that_target(target).failures().contains_predicate( 283 matching.str_matches("foo.py*matches multiple"), 284 ) 285 286def _test_files_to_build(name, config): 287 rt_util.helper_target( 288 config.rule, 289 name = name + "_subject", 290 srcs = [name + "_subject.py"], 291 ) 292 analysis_test( 293 name = name, 294 impl = _test_files_to_build_impl, 295 target = name + "_subject", 296 attrs = WINDOWS_ATTR, 297 ) 298 299_tests.append(_test_files_to_build) 300 301def _test_files_to_build_impl(env, target): 302 default_outputs = env.expect.that_target(target).default_outputs() 303 if pt_util.is_windows(env): 304 default_outputs.contains("{package}/{test_name}_subject.exe") 305 else: 306 default_outputs.contains_exactly([ 307 "{package}/{test_name}_subject", 308 "{package}/{test_name}_subject.py", 309 ]) 310 311 if IS_BAZEL_7_OR_HIGHER: 312 # As of Bazel 7, the first default output is the executable, so 313 # verify that is the case. rules_testing 314 # DepsetFileSubject.contains_exactly doesn't provide an in_order() 315 # call, nor access to the underlying depset, so we have to do things 316 # manually. 317 first_default_output = target[DefaultInfo].files.to_list()[0] 318 executable = target[DefaultInfo].files_to_run.executable 319 env.expect.that_file(first_default_output).equals(executable) 320 321def _test_name_cannot_end_in_py(name, config): 322 # Bazel 5 will crash with a Java stacktrace when the native Python 323 # rules have an error. 324 if not pt_util.is_bazel_6_or_higher(): 325 rt_util.skip_test(name = name) 326 return 327 rt_util.helper_target( 328 config.rule, 329 name = name + "_subject.py", 330 srcs = ["main.py"], 331 ) 332 analysis_test( 333 name = name, 334 impl = _test_name_cannot_end_in_py_impl, 335 target = name + "_subject.py", 336 expect_failure = True, 337 ) 338 339_tests.append(_test_name_cannot_end_in_py) 340 341def _test_name_cannot_end_in_py_impl(env, target): 342 env.expect.that_target(target).failures().contains_predicate( 343 matching.str_matches("name must not end in*.py"), 344 ) 345 346def _test_py_runtime_info_provided(name, config): 347 rt_util.helper_target( 348 config.rule, 349 name = name + "_subject", 350 srcs = [name + "_subject.py"], 351 ) 352 analysis_test( 353 name = name, 354 impl = _test_py_runtime_info_provided_impl, 355 target = name + "_subject", 356 ) 357 358def _test_py_runtime_info_provided_impl(env, target): 359 # Make sure that the rules_python loaded symbol is provided. 360 env.expect.that_target(target).has_provider(RulesPythonPyRuntimeInfo) 361 362 # For compatibility during the transition, the builtin PyRuntimeInfo should 363 # also be provided. 364 env.expect.that_target(target).has_provider(_BuiltinPyRuntimeInfo) 365 366_tests.append(_test_py_runtime_info_provided) 367 368# Can't test this -- mandatory validation happens before analysis test 369# can intercept it 370# TODO(#1069): Once re-implemented in Starlark, modify rule logic to make this 371# testable. 372# def _test_srcs_is_mandatory(name, config): 373# rt_util.helper_target( 374# config.rule, 375# name = name + "_subject", 376# ) 377# analysis_test( 378# name = name, 379# impl = _test_srcs_is_mandatory, 380# target = name + "_subject", 381# expect_failure = True, 382# ) 383# 384# _tests.append(_test_srcs_is_mandatory) 385# 386# def _test_srcs_is_mandatory_impl(env, target): 387# env.expect.that_target(target).failures().contains_predicate( 388# matching.str_matches("mandatory*srcs"), 389# ) 390 391# ===== 392# You were gonna add a test at the end, weren't you? 393# Nope. Please keep them sorted; put it in its alphabetical location. 394# Here's the alphabet so you don't have to sing that song in your head: 395# A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 396# ===== 397 398def create_executable_tests(config): 399 def _executable_with_srcs_wrapper(name, **kwargs): 400 if not kwargs.get("srcs"): 401 kwargs["srcs"] = [name + ".py"] 402 config.rule(name = name, **kwargs) 403 404 config = pt_util.struct_with(config, base_test_rule = _executable_with_srcs_wrapper) 405 return pt_util.create_tests(_tests, config = config) + create_base_tests(config = config) 406