pw_build: Support declaring standalone Python scripts

- Standalone Python scripts may be declared with pw_python_script. A
  pw_python_script supports a subset of pw_python_package features.
- Check for an __init__.py file when declaring a package.

Change-Id: I124a9d1f1b7992f619796910aa33b1eb3df382bc
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/22301
Commit-Queue: Wyatt Hepler <hepler@google.com>
Reviewed-by: Alexei Frolov <frolv@google.com>
diff --git a/BUILD.gn b/BUILD.gn
index ec3d18b..2ea1172 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -114,7 +114,8 @@
 }
 
 pw_python_group("python") {
-  _python_packages = [
+  _python_gn_targets = [
+    # Python packages
     "$dir_pw_allocator/py",
     "$dir_pw_arduino_build/py",
     "$dir_pw_build/py",
@@ -134,6 +135,9 @@
     "$dir_pw_unit_test/py",
     "$dir_pw_watch/py",
 
+    # Standalone scripts
+    "$dir_pw_hdlc_lite/rpc_example:example_script",
+
     # TODO(pwbug/239): Structure pw_docgen as Python packages.
     # "$dir_pw_bloat/py",
     # "$dir_pw_docgen/py",
@@ -142,7 +146,7 @@
   _toolchain = "$_default_toolchain_prefix$pw_default_optimization_level"
 
   python_deps = []
-  foreach(dep, _python_packages) {
+  foreach(dep, _python_gn_targets) {
     python_deps += [ "$dep($_toolchain)" ]
   }
 }
diff --git a/pw_build/python.gni b/pw_build/python.gni
index 080a601..0bdf059 100644
--- a/pw_build/python.gni
+++ b/pw_build/python.gni
@@ -41,9 +41,54 @@
 #   inputs: Other files to track, such as package_data.
 #
 template("pw_python_package") {
-  assert(defined(invoker.setup) && invoker.setup != [],
-         "pw_python_package requires 'setup' to point to a setup.py or " +
-             "pyproject.toml and setup.cfg file")
+  if (defined(invoker.sources)) {
+    _all_py_files = invoker.sources
+  } else {
+    _all_py_files = []
+  }
+
+  if (defined(invoker.tests)) {
+    _test_sources = invoker.tests
+  } else {
+    _test_sources = []
+  }
+
+  _all_py_files += _test_sources
+
+  assert(_all_py_files != [], "At least one source or test must be provided")
+
+  # pw_python_script uses pw_python_package, but with a limited set of features.
+  # _pw_standalone signals that this target is actually a pw_python_script.
+  _is_package = !(defined(invoker._pw_standalone) && invoker._pw_standalone)
+
+  if (_is_package) {
+    assert(defined(invoker.setup) && invoker.setup != [],
+           "pw_python_package requires 'setup' to point to a setup.py file " +
+               "or pyproject.toml and setup.cfg files")
+
+    _all_py_files += invoker.setup
+
+    # Get the directories of the setup files. All files must be in the same dir.
+    _setup_dirs = get_path_info(invoker.setup, "dir")
+    _setup_dir = _setup_dirs[0]
+
+    foreach(dir, _setup_dirs) {
+      assert(dir == _setup_dir,
+             "All files in 'setup' must be in the same directory")
+    }
+
+    # If sources are provided, make sure there is an __init__.py file.
+    if (defined(invoker.sources)) {
+      _found_init_py_in_sources = false
+      foreach(source, invoker.sources) {
+        if (get_path_info(source, "file") == "__init__.py") {
+          _found_init_py_in_sources = true
+        }
+      }
+      assert(_found_init_py_in_sources,
+             "Python packages must have at least one __init__.py file")
+    }
+  }
 
   _python_deps = []
   if (defined(invoker.python_deps)) {
@@ -53,36 +98,11 @@
     }
   }
 
-  if (defined(invoker.sources)) {
-    _all_sources = invoker.sources
-  } else {
-    _all_sources = []
-  }
-
-  if (defined(invoker.tests)) {
-    _test_sources = invoker.tests
-  } else {
-    _test_sources = []
-  }
-
-  _all_sources += _test_sources
-
-  assert(_all_sources != [], "At least one source or test must be provided")
-
-  # Get the directory of the setup files. All files must be in the same dir.
-  _setup_dirs = get_path_info(invoker.setup, "dir")
-  _setup_dir = _setup_dirs[0]
-
-  foreach(dir, _setup_dirs) {
-    assert(dir == _setup_dir,
-           "All files in 'setup' must be in the same directory")
-  }
-
   # Declare the main Python package group. This represents the Python files, but
   # does not take any actions. GN targets can depend on the package name to run
   # when any files in the package change.
   pw_input_group(target_name) {
-    inputs = _all_sources + invoker.setup
+    inputs = _all_py_files
     if (defined(invoker.inputs)) {
       inputs += invoker.inputs
     }
@@ -96,40 +116,56 @@
 
   _package_target = ":$target_name"
 
-  # Install this Python package and its dependencies in the current Python
-  # environment.
-  pw_python_action("$target_name.install") {
-    module = "pip"
-    args = [
-      "install",
-      "--editable",
-      rebase_path(_setup_dir),
-    ]
+  if (_is_package) {
+    # Install this Python package and its dependencies in the current Python
+    # environment.
+    pw_python_action("$target_name.install") {
+      module = "pip"
+      args = [
+        "install",
+        "--editable",
+        rebase_path(_setup_dir),
+      ]
 
-    stamp = true
+      stamp = true
 
-    # Parallel pip installations don't work, so serialize pip invocations.
-    pool = "$dir_pw_build:pip_pool"
+      # Parallel pip installations don't work, so serialize pip invocations.
+      pool = "$dir_pw_build:pip_pool"
 
-    deps = [ _package_target ]
-    foreach(dep, _python_deps) {
-      deps += [ "$dep.install" ]
+      deps = [ _package_target ]
+      foreach(dep, _python_deps) {
+        deps += [ "$dep.install" ]
+      }
     }
-  }
 
-  # TODO(pwbug/239): Add support for building groups of wheels. The code below
-  #     is incomplete and untested.
-  pw_python_action("$target_name.wheel") {
-    script = "$dir_pw_build/py/pw_build/python_wheels.py"
+    # TODO(pwbug/239): Add support for building groups of wheels. The code below
+    #     is incomplete and untested.
+    pw_python_action("$target_name.wheel") {
+      script = "$dir_pw_build/py/pw_build/python_wheels.py"
 
-    args = [
-      "--out_dir",
-      rebase_path(target_out_dir),
-    ]
-    args += rebase_path(_all_sources)
+      args = [
+        "--out_dir",
+        rebase_path(target_out_dir),
+      ]
+      args += rebase_path(_all_py_files)
 
-    deps = [ _package_target ]
-    stamp = true
+      deps = [ _package_target ]
+      stamp = true
+    }
+  } else {
+    # If this is not a package, install or build wheels for its deps only.
+    group("$target_name.install") {
+      deps = []
+      foreach(dep, _python_deps) {
+        deps += [ "$dep.install" ]
+      }
+    }
+    group("$target_name.wheel") {
+      deps = []
+      foreach(dep, _python_deps) {
+        deps += [ "$dep.wheel" ]
+      }
+    }
   }
 
   # Define the static analysis targets for this package.
@@ -146,8 +182,12 @@
       "--pretty",
       "--show-error-codes",
       "--color-output",
-      rebase_path(_setup_dir),
     ]
+    if (_is_package) {
+      args += [ rebase_path(_setup_dir) ]
+    } else {
+      args += rebase_path(_all_py_files)
+    }
 
     # Use this environment variable to force mypy to colorize output.
     # See https://github.com/python/mypy/issues/7771
@@ -174,7 +214,7 @@
       args += [ "--disable=unexpected-line-ending-format" ]
     }
 
-    sources = _all_sources
+    sources = _all_py_files
 
     stamp = "$target_gen_dir/{{source_target_relative}}.pylint.pw_pystamp"
 
@@ -249,3 +289,27 @@
     }
   }
 }
+
+# Declares Python scripts or tests that are not part of a Python package.
+# Similar to pw_python_package, but only supports a subset of its features.
+#
+# pw_python_script accepts the same arguments as pw_python_package, except
+# `setup` cannot be provided.
+#
+# pw_python_script provides the same subtargets as pw_python_package, but
+# $target_name.install and $target_name.wheel only affect the python_deps of
+# this GN target, not the target itself.
+template("pw_python_script") {
+  _supported_variables = [
+    "sources",
+    "tests",
+    "python_deps",
+    "other_deps",
+    "inputs",
+  ]
+
+  pw_python_package(target_name) {
+    _pw_standalone = true
+    forward_variables_from(invoker, _supported_variables)
+  }
+}
diff --git a/pw_hdlc_lite/rpc_example/BUILD.gn b/pw_hdlc_lite/rpc_example/BUILD.gn
index 375021f..495b168 100644
--- a/pw_hdlc_lite/rpc_example/BUILD.gn
+++ b/pw_hdlc_lite/rpc_example/BUILD.gn
@@ -14,6 +14,7 @@
 
 import("//build_overrides/pigweed.gni")
 
+import("$dir_pw_build/python.gni")
 import("$dir_pw_build/target_types.gni")
 import("$dir_pw_third_party/nanopb/nanopb.gni")
 
@@ -35,3 +36,7 @@
     ]
   }
 }
+
+pw_python_script("example_script") {
+  sources = [ "example_script.py" ]
+}
diff --git a/pw_hdlc_lite/rpc_example/example_script.py b/pw_hdlc_lite/rpc_example/example_script.py
index b8b2b0f..57726de 100755
--- a/pw_hdlc_lite/rpc_example/example_script.py
+++ b/pw_hdlc_lite/rpc_example/example_script.py
@@ -18,7 +18,7 @@
 import os
 from pathlib import Path
 
-import serial
+import serial  # type: ignore
 
 from pw_hdlc_lite.rpc import HdlcRpcClient