fix(rules): make the srcs trully optional (#2768)

With this PR we mark the srcs attribute as optional as we can
leverage the `main_module` to just run things from the deps.

This also removes a long-standing `TODO` note.

Fixes #2765

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d9b648..33d99df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -76,6 +76,8 @@
 * (pypi) The PyPI extension will no longer write the lock file entries as the
   extension has been marked reproducible.
   Fixes [#2434](https://github.com/bazel-contrib/rules_python/issues/2434).
+* (rules) {attr}`py_binary.srcs` and {attr}`py_test.srcs` is no longer mandatory when
+  `main_module` is specified (for `--bootstrap_impl=script`)
 
 [20250317]: https://github.com/astral-sh/python-build-standalone/releases/tag/20250317
 
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl
index e6f4700..dd3ad86 100644
--- a/python/private/py_executable.bzl
+++ b/python/private/py_executable.bzl
@@ -786,6 +786,8 @@
         )
         template = runtime.bootstrap_template
         subs["%shebang%"] = runtime.stub_shebang
+    elif not ctx.files.srcs:
+        fail("mandatory 'srcs' files have not been provided")
     else:
         if (ctx.configuration.coverage_enabled and
             runtime and
@@ -1888,7 +1890,6 @@
         ),
         **kwargs
     )
-    builder.attrs.get("srcs").set_mandatory(True)
     return builder
 
 def cc_configure_features(
diff --git a/tests/base_rules/py_executable_base_tests.bzl b/tests/base_rules/py_executable_base_tests.bzl
index 3cc6dfb..3770783 100644
--- a/tests/base_rules/py_executable_base_tests.bzl
+++ b/tests/base_rules/py_executable_base_tests.bzl
@@ -24,7 +24,7 @@
 load("//tests/base_rules:base_tests.bzl", "create_base_tests")
 load("//tests/base_rules:util.bzl", "WINDOWS_ATTR", pt_util = "util")
 load("//tests/support:py_executable_info_subject.bzl", "PyExecutableInfoSubject")
-load("//tests/support:support.bzl", "CC_TOOLCHAIN", "CROSSTOOL_TOP", "LINUX_X86_64", "WINDOWS_X86_64")
+load("//tests/support:support.bzl", "BOOTSTRAP_IMPL", "CC_TOOLCHAIN", "CROSSTOOL_TOP", "LINUX_X86_64", "WINDOWS_X86_64")
 
 _tests = []
 
@@ -342,6 +342,53 @@
         matching.str_matches("name must not end in*.py"),
     )
 
+def _test_main_module_bootstrap_system_python(name, config):
+    rt_util.helper_target(
+        config.rule,
+        name = name + "_subject",
+        main_module = "dummy",
+    )
+    analysis_test(
+        name = name,
+        impl = _test_main_module_bootstrap_system_python_impl,
+        target = name + "_subject",
+        config_settings = {
+            BOOTSTRAP_IMPL: "system_python",
+            "//command_line_option:platforms": [LINUX_X86_64],
+        },
+        expect_failure = True,
+    )
+
+def _test_main_module_bootstrap_system_python_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("mandatory*srcs"),
+    )
+
+_tests.append(_test_main_module_bootstrap_system_python)
+
+def _test_main_module_bootstrap_script(name, config):
+    rt_util.helper_target(
+        config.rule,
+        name = name + "_subject",
+        main_module = "dummy",
+    )
+    analysis_test(
+        name = name,
+        impl = _test_main_module_bootstrap_script_impl,
+        target = name + "_subject",
+        config_settings = {
+            BOOTSTRAP_IMPL: "script",
+            "//command_line_option:platforms": [LINUX_X86_64],
+        },
+    )
+
+def _test_main_module_bootstrap_script_impl(env, target):
+    env.expect.that_target(target).default_outputs().contains(
+        "{package}/{test_name}_subject",
+    )
+
+_tests.append(_test_main_module_bootstrap_script)
+
 def _test_py_runtime_info_provided(name, config):
     rt_util.helper_target(
         config.rule,
@@ -365,29 +412,6 @@
 
 _tests.append(_test_py_runtime_info_provided)
 
-# Can't test this -- mandatory validation happens before analysis test
-# can intercept it
-# TODO(#1069): Once re-implemented in Starlark, modify rule logic to make this
-# testable.
-# def _test_srcs_is_mandatory(name, config):
-#     rt_util.helper_target(
-#         config.rule,
-#         name = name + "_subject",
-#     )
-#     analysis_test(
-#         name = name,
-#         impl = _test_srcs_is_mandatory,
-#         target = name + "_subject",
-#         expect_failure = True,
-#     )
-#
-# _tests.append(_test_srcs_is_mandatory)
-#
-# def _test_srcs_is_mandatory_impl(env, target):
-#     env.expect.that_target(target).failures().contains_predicate(
-#         matching.str_matches("mandatory*srcs"),
-#     )
-
 # =====
 # You were gonna add a test at the end, weren't you?
 # Nope. Please keep them sorted; put it in its alphabetical location.
diff --git a/tests/support/support.bzl b/tests/support/support.bzl
index 2b67038..6330155 100644
--- a/tests/support/support.bzl
+++ b/tests/support/support.bzl
@@ -35,6 +35,7 @@
 # str() around Label() is necessary because rules_testing's config_settings
 # doesn't accept yet Label objects.
 ADD_SRCS_TO_RUNFILES = str(Label("//python/config_settings:add_srcs_to_runfiles"))
+BOOTSTRAP_IMPL = str(Label("//python/config_settings:bootstrap_impl"))
 EXEC_TOOLS_TOOLCHAIN = str(Label("//python/config_settings:exec_tools_toolchain"))
 PRECOMPILE = str(Label("//python/config_settings:precompile"))
 PRECOMPILE_SOURCE_RETENTION = str(Label("//python/config_settings:precompile_source_retention"))