pw_protobuf_compiler: Decouple proto packaging from directory

- Allow specifying prefix and strip_prefix arguments for proto files.
  The proto directory tree is built as specified in the out directory.
- Only invoke protoc from the default toolchain. This prevents duplicate
  protoc invocations.
- Prevent duplicate pw_proto_library Python package definitions anywhere
  in the build.
- Replace implicit handling of standalone external protos with a
  python_package_as_module option.

Change-Id: Id37d8b4d83294f7d3142a389e74ceea96dd4d620
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/34640
Reviewed-by: Alexei Frolov <frolv@google.com>
diff --git a/docs/BUILD.gn b/docs/BUILD.gn
index 23f527c..3941310 100644
--- a/docs/BUILD.gn
+++ b/docs/BUILD.gn
@@ -137,5 +137,6 @@
     ":core_docs",
     ":module_docs",
     ":target_docs",
+    "$dir_pw_env_setup:python.install",
   ]
 }
diff --git a/pw_build/python.gni b/pw_build/python.gni
index 031a7ba..9ecae0e 100644
--- a/pw_build/python.gni
+++ b/pw_build/python.gni
@@ -76,6 +76,15 @@
     _test_sources = []
   }
 
+  # The Python targets are always instantiated in the default toolchain. Use
+  # fully qualified labels so that the toolchain is not lost.
+  _other_deps = []
+  if (defined(invoker.other_deps)) {
+    foreach(dep, invoker.other_deps) {
+      _other_deps += [ get_label_info(dep, "label_with_toolchain") ]
+    }
+  }
+
   _all_py_files += _test_sources
 
   # pw_python_script uses pw_python_package, but with a limited set of features.
@@ -171,11 +180,7 @@
       inputs += invoker.inputs
     }
 
-    deps = _python_deps
-
-    if (defined(invoker.other_deps)) {
-      deps += invoker.other_deps
-    }
+    deps = _python_deps + _other_deps
   }
 
   if (_is_package) {
@@ -199,7 +204,8 @@
 
       deps = [ ":$_internal_target" ]
       foreach(dep, _python_deps) {
-        deps += [ "$dep.install" ]
+        _subtarget = get_label_info(dep, "label_no_toolchain") + ".install"
+        deps += [ "$_subtarget(" + get_label_info(dep, "toolchain") + ")" ]
       }
     }
 
diff --git a/pw_doctor/py/BUILD.gn b/pw_doctor/py/BUILD.gn
index 82c2d86..bec5e59 100644
--- a/pw_doctor/py/BUILD.gn
+++ b/pw_doctor/py/BUILD.gn
@@ -22,5 +22,6 @@
     "pw_doctor/__init__.py",
     "pw_doctor/doctor.py",
   ]
+  python_deps = [ "$dir_pw_cli/py" ]
   pylintrc = "$dir_pigweed/.pylintrc"
 }
diff --git a/pw_protobuf_compiler/docs.rst b/pw_protobuf_compiler/docs.rst
index 30bb37a..ac659fb 100644
--- a/pw_protobuf_compiler/docs.rst
+++ b/pw_protobuf_compiler/docs.rst
@@ -57,12 +57,19 @@
 
 **Arguments**
 
-* ``sources``: List of ``.proto`` files.
-* ``deps``: Other ``pw_proto_library`` targets that this one depends on.
+* ``sources``: List of input .proto files.
+* ``deps``: List of other pw_proto_library dependencies.
+* ``inputs``: Other files on which the protos depend (e.g. nanopb ``.options``
+  files).
+* ``prefix``: A prefix to add to the source protos prior to compilation. For
+  example, a source called ``"foo.proto"`` with ``prefix = "nested"`` will be
+  compiled with protoc as ``"nested/foo.proto"``.
+* ``strip_prefix``: Remove this prefix from the source protos. All source and
+  input files must be nested under this path.
 
 **Example**
 
-.. code::
+.. code-block::
 
   import("$dir_pw_protobuf_compiler/proto.gni")
 
@@ -74,7 +81,13 @@
   }
 
   pw_proto_library("my_other_protos") {
-    sources = [ "my_other_protos/baz.proto" ]  # imports foo.proto
+    sources = [ "some/other/path/baz.proto" ]  # imports foo.proto
+
+    # This removes the "some/other/path" prefix from the proto files.
+    strip_prefix = "some/other/path"
+
+    # This adds the "my_other_protos/" prefix to the proto files.
+    prefix = "my_other_protos"
 
     # Proto libraries depend on other proto libraries directly.
     deps = [ ":my_protos" ]
@@ -91,21 +104,146 @@
     deps = [ ":my_other_protos.pwpb" ]
   }
 
+From C++, ``baz.proto`` included as follows:
+
+.. code-block:: cpp
+
+  #include "my_other_protos/baz.pwpb.h"
+
+From Python, ``baz.proto`` is imported as follows:
+
+.. code-block:: python
+
+  from my_other_protos import baz_pb2
+
 Proto file structure
 --------------------
 Protobuf source files must be nested under another directory when they are
-listed in sources. This ensures that they can be packaged properly in Python.
-The first directory is used as the Python package name.
+compiled. This ensures that they can be packaged properly in Python. The first
+directory is used as the Python package name, so must be unique across the
+build. The ``prefix`` option may be used to set this directory.
 
-The requirements for proto file structure in the source tree will be relaxed in
-future updates.
+Using ``prefix`` and ``strip_prefix`` together allows remapping proto files to
+a completely different path. This can be useful when working with protos defined
+in external libraries. For example, consider this proto library:
+
+.. code-block::
+
+  pw_proto_library("external_protos") {
+    sources = [
+      "//other/external/some_library/src/protos/alpha.proto",
+      "//other/external/some_library/src/protos/beta.proto,
+      "//other/external/some_library/src/protos/internal/gamma.proto",
+    ]
+    strip_prefix = "//other/external/some_library/src/protos"
+    prefix = "some_library"
+  }
+
+These protos will be compiled by protoc as if they were in this file structure:
+
+.. code-block::
+
+  some_library/
+  ├── alpha.proto
+  ├── beta.proto
+  └── internal
+      └── gamma.proto
 
 Working with externally defined protos
 --------------------------------------
 ``pw_proto_library`` targets may be used to build ``.proto`` sources from
 existing projects. In these cases, it may be necessary to supply the
-``include_path`` argument, which specifies the protobuf include path to use for
+``strip_prefix`` argument, which specifies the protobuf include path to use for
 ``protoc``. If only a single external protobuf is being compiled, the
-requirement that the protobuf be nested under a directory is waived. This
-exception should only be used when absolutely necessary -- for example, to
-support proto files that includes ``import "nanopb.proto"`` in them.
+``python_module_as_package`` option can be used to override the requirement that
+the protobuf be nested under a directory. This option generates a Python package
+with the same name as the proto file, so that the generated proto can be
+imported as if it were a standalone Python module.
+
+For example, the ``pw_proto_library`` target for Nanopb sets
+``python_module_as_package`` to ``nanopb_pb2``.
+
+.. code-block::
+
+  pw_proto_library("proto") {
+    strip_prefix = "$dir_pw_third_party_nanopb/generator/proto"
+    sources = [ "$dir_pw_third_party_nanopb/generator/proto/nanopb.proto" ]
+    python_module_as_package = "nanopb_pb2"
+  }
+
+In Python, this makes ``nanopb.proto`` available as ``import nanopb_pb2`` via
+the ``nanopb_pb2`` Python package. In C++, ``nanopb.proto`` is accessed as
+``#include "nanopb.pwpb.h"``.
+
+The ``python_module_as_package`` feature should only be used when absolutely
+necessary --- for example, to support proto files that include
+``import "nanopb.proto"``.
+
+CMake
+=====
+CMake provides a ``pw_proto_library`` function with similar features as the
+GN template. The CMake build only supports building firmware code, so
+``pw_proto_library`` does not generate a Python package.
+
+**Arguments**
+
+* ``NAME``: the base name of the libraries to create
+* ``SOURCES``: .proto source files
+* ``DEPS``: dependencies on other ``pw_proto_library`` targets
+* ``PREFIX``: prefix add to the proto files
+* ``STRIP_PREFIX``: prefix to remove from the proto files
+* ``INPUTS``: files to include along with the .proto files (such as Nanopb
+  .options files)
+
+**Example**
+
+ .. code-block:: cmake
+
+  include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
+  include($ENV{PW_ROOT}/pw_protobuf_compiler/proto.cmake)
+
+  pw_proto_library(my_module.my_protos
+    SOURCES
+      my_protos/foo.proto
+      my_protos/bar.proto
+  )
+
+  pw_proto_library(my_module.my_protos
+    SOURCES
+      my_protos/foo.proto
+      my_protos/bar.proto
+  )
+
+  pw_proto_library(my_module.my_other_protos
+    SOURCES
+      some/other/path/baz.proto  # imports foo.proto
+
+    # This removes the "some/other/path" prefix from the proto files.
+    STRIP_PREFIX
+      some/other/path
+
+    # This adds the "my_other_protos/" prefix to the proto files.
+    PREFIX
+      my_other_protos
+
+    # Proto libraries depend on other proto libraries directly.
+    DEPS
+      my_module.my_protos
+  )
+
+  add_library(my_module.my_cc_code
+      foo.cc
+      bar.cc
+      baz.cc
+  )
+
+  # When depending on protos in a source_set, specify the generator suffix.
+  target_link_libraries(my_module.my_cc_code PUBLIC
+    my_module.my_other_protos.pwpb
+  )
+
+These proto files are accessed in C++ the same as in the GN build:
+
+.. code-block:: cpp
+
+  #include "my_other_protos/baz.pwpb"
diff --git a/pw_protobuf_compiler/proto.cmake b/pw_protobuf_compiler/proto.cmake
index 73b06b6..cc07ce0 100644
--- a/pw_protobuf_compiler/proto.cmake
+++ b/pw_protobuf_compiler/proto.cmake
@@ -30,11 +30,16 @@
 #   NAME - the base name of the libraries to create
 #   SOURCES - .proto source files
 #   DEPS - dependencies on other pw_proto_library targets
+#   PREFIX - prefix add to the proto files
+#   STRIP_PREFIX - prefix to remove from the proto files
+#   INPUTS - files to include along with the .proto files (such as Nanopb
+#       .options files
 #
 function(pw_proto_library NAME)
-  cmake_parse_arguments(PARSE_ARGV 1 arg "" "" "SOURCES;DEPS")
+  cmake_parse_arguments(PARSE_ARGV 1 arg "" "STRIP_PREFIX;PREFIX"
+      "SOURCES;INPUTS;DEPS")
 
-  set(out_dir "${CMAKE_CURRENT_BINARY_DIR}/protos")
+  set(out_dir "${CMAKE_CURRENT_BINARY_DIR}/${NAME}")
 
   # Use INTERFACE libraries to track the proto include paths that are passed to
   # protoc.
@@ -46,33 +51,77 @@
   target_link_libraries("${NAME}._includes" INTERFACE ${include_deps})
 
   # Generate a file with all include paths needed by protoc.
-  set(include_file "${out_dir}/${NAME}.include_paths.txt")
+  set(include_file "${out_dir}/include_paths.txt")
   file(GENERATE OUTPUT "${include_file}"
      CONTENT
        "$<TARGET_PROPERTY:${NAME}._includes,INTERFACE_INCLUDE_DIRECTORIES>")
 
+  if("${arg_STRIP_PREFIX}" STREQUAL "")
+    set(arg_STRIP_PREFIX "${CMAKE_CURRENT_SOURCE_DIR}")
+  endif()
+
+  foreach(path IN LISTS arg_SOURCES arg_INPUTS)
+    get_filename_component(abspath "${path}" ABSOLUTE)
+    list(APPEND files_to_mirror "${abspath}")
+  endforeach()
+
+  # Mirror the sources to the output directory with the specified prefix.
+  _pw_rebase_paths(
+      sources "${out_dir}/sources/${arg_PREFIX}" "${arg_STRIP_PREFIX}" "${arg_SOURCES}" "")
+  _pw_rebase_paths(
+      inputs "${out_dir}/sources/${arg_PREFIX}" "${arg_STRIP_PREFIX}" "${arg_INPUTS}" "")
+
+  add_custom_command(
+    COMMAND
+      python
+      "$ENV{PW_ROOT}/pw_build/py/pw_build/mirror_tree.py"
+      --source-root "${arg_STRIP_PREFIX}"
+      --directory "${out_dir}/sources/${arg_PREFIX}"
+      ${files_to_mirror}
+    DEPENDS
+      "$ENV{PW_ROOT}/pw_build/py/pw_build/mirror_tree.py"
+      ${files_to_mirror}
+      ${arg_DEPS}
+    OUTPUT
+      ${sources} ${inputs}
+  )
+
   # Create a protobuf target for each supported protobuf library.
   _pw_pwpb_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
   _pw_raw_rpc_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
   _pw_nanopb_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
   _pw_nanopb_rpc_library(
-      "${NAME}" "${arg_SOURCES}" "${arg_DEPS}" "${include_file}" "${out_dir}")
+      "${NAME}" "${sources}" "${inputs}" "${arg_DEPS}" "${include_file}" "${out_dir}")
 endfunction(pw_proto_library)
 
+function(_pw_rebase_paths VAR OUT_DIR ROOT FILES EXTENSIONS)
+  foreach(file IN LISTS FILES)
+    get_filename_component(file "${file}" ABSOLUTE)
+    file(RELATIVE_PATH file "${ROOT}" "${file}")
+
+    if ("${EXTENSIONS}" STREQUAL "")
+      list(APPEND mirrored_files "${OUT_DIR}/${file}")
+    else()
+      foreach(ext IN LISTS EXTENSIONS)
+        get_filename_component(dir "${file}" DIRECTORY)
+        get_filename_component(name "${file}" NAME_WE)
+        list(APPEND mirrored_files "${OUT_DIR}/${dir}/${name}${ext}")
+      endforeach()
+    endif()
+  endforeach()
+
+  set("${VAR}" "${mirrored_files}" PARENT_SCOPE)
+endfunction(_pw_rebase_paths)
+
 # Internal function that invokes protoc through generate_protos.py.
 function(_pw_generate_protos
-      TARGET LANGUAGE PLUGIN OUTPUT_EXTS INCLUDE_FILE OUT_DIR SOURCES DEPS)
-  # Determine the names of the output files.
-  foreach(extension IN LISTS OUTPUT_EXTS)
-    foreach(source_file IN LISTS SOURCES)
-      get_filename_component(dir "${source_file}" DIRECTORY)
-      get_filename_component(name "${source_file}" NAME_WE)
-      list(APPEND outputs "${OUT_DIR}/${dir}/${name}${extension}")
-    endforeach()
-  endforeach()
+    TARGET LANGUAGE PLUGIN OUTPUT_EXTS INCLUDE_FILE OUT_DIR SOURCES INPUTS DEPS)
+  # Determine the names of the compiled output files.
+  _pw_rebase_paths(outputs
+      "${OUT_DIR}/${LANGUAGE}" "${OUT_DIR}/sources" "${SOURCES}" "${OUTPUT_EXTS}")
 
   # Export the output files to the caller's scope so it can use them if needed.
   set(generated_outputs "${outputs}" PARENT_SCOPE)
@@ -90,14 +139,14 @@
       "${script}"
       --language "${LANGUAGE}"
       --plugin-path "${PLUGIN}"
-      --include-path "${CMAKE_CURRENT_SOURCE_DIR}"
       --include-file "${INCLUDE_FILE}"
-      --out-dir "${OUT_DIR}"
-      ${ARGN}
-      ${SOURCES}
+      --compile-dir "${OUT_DIR}/sources"
+      --out-dir "${OUT_DIR}/${LANGUAGE}"
+      --sources ${SOURCES}
     DEPENDS
-      ${SOURCES}
       ${script}
+      ${SOURCES}
+      ${INPUTS}
       ${DEPS}
     OUTPUT
       ${outputs}
@@ -106,7 +155,7 @@
 endfunction(_pw_generate_protos)
 
 # Internal function that creates a pwpb proto library.
-function(_pw_pwpb_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_pwpb_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   list(TRANSFORM DEPS APPEND .pwpb)
 
   _pw_generate_protos("${NAME}.generate.pwpb"
@@ -116,18 +165,19 @@
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.pwpb" INTERFACE)
-  target_include_directories("${NAME}.pwpb" INTERFACE "${OUT_DIR}")
+  target_include_directories("${NAME}.pwpb" INTERFACE "${OUT_DIR}/pwpb")
   target_link_libraries("${NAME}.pwpb" INTERFACE pw_protobuf ${DEPS})
   add_dependencies("${NAME}.pwpb" "${NAME}.generate.pwpb")
 endfunction(_pw_pwpb_library)
 
 # Internal function that creates a raw_rpc proto library.
-function(_pw_raw_rpc_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_raw_rpc_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   list(TRANSFORM DEPS APPEND .raw_rpc)
 
   _pw_generate_protos("${NAME}.generate.raw_rpc"
@@ -137,12 +187,13 @@
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.raw_rpc" INTERFACE)
-  target_include_directories("${NAME}.raw_rpc" INTERFACE "${OUT_DIR}")
+  target_include_directories("${NAME}.raw_rpc" INTERFACE "${OUT_DIR}/raw_rpc")
   target_link_libraries("${NAME}.raw_rpc"
     INTERFACE
       pw_rpc.raw
@@ -153,7 +204,7 @@
 endfunction(_pw_raw_rpc_library)
 
 # Internal function that creates a nanopb proto library.
-function(_pw_nanopb_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_nanopb_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   list(TRANSFORM DEPS APPEND .nanopb)
 
   set(nanopb_dir "$<TARGET_PROPERTY:$<IF:$<TARGET_EXISTS:protobuf-nanopb-static>,protobuf-nanopb-static,pw_build.empty>,SOURCE_DIR>")
@@ -167,18 +218,19 @@
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.nanopb" EXCLUDE_FROM_ALL ${generated_outputs})
-  target_include_directories("${NAME}.nanopb" PUBLIC "${OUT_DIR}")
+  target_include_directories("${NAME}.nanopb" PUBLIC "${OUT_DIR}/nanopb")
   target_link_libraries("${NAME}.nanopb" PUBLIC pw_third_party.nanopb ${DEPS})
   add_dependencies("${NAME}.nanopb" "${NAME}.generate.nanopb")
 endfunction(_pw_nanopb_library)
 
 # Internal function that creates a nanopb_rpc library.
-function(_pw_nanopb_rpc_library NAME SOURCES DEPS INCLUDE_FILE OUT_DIR)
+function(_pw_nanopb_rpc_library NAME SOURCES INPUTS DEPS INCLUDE_FILE OUT_DIR)
   # Determine the names of the output files.
   list(TRANSFORM DEPS APPEND .nanopb_rpc)
 
@@ -189,12 +241,16 @@
       "${INCLUDE_FILE}"
       "${OUT_DIR}"
       "${SOURCES}"
+      "${INPUTS}"
       "${DEPS}"
   )
 
   # Create the library with the generated source files.
   add_library("${NAME}.nanopb_rpc" INTERFACE)
-  target_include_directories("${NAME}.nanopb_rpc" INTERFACE "${OUT_DIR}")
+  target_include_directories("${NAME}.nanopb_rpc"
+    INTERFACE
+      "${OUT_DIR}/nanopb_rpc"
+  )
   target_link_libraries("${NAME}.nanopb_rpc"
     INTERFACE
       "${NAME}.nanopb"
diff --git a/pw_protobuf_compiler/proto.gni b/pw_protobuf_compiler/proto.gni
index ce297c6..1e8e598 100644
--- a/pw_protobuf_compiler/proto.gni
+++ b/pw_protobuf_compiler/proto.gni
@@ -16,6 +16,7 @@
 
 import("$dir_pw_build/error.gni")
 import("$dir_pw_build/input_group.gni")
+import("$dir_pw_build/mirror_tree.gni")
 import("$dir_pw_build/python.gni")
 import("$dir_pw_build/python_action.gni")
 import("$dir_pw_build/target_types.gni")
@@ -34,7 +35,18 @@
 # This creates the internal GN target $target_name.$language._gen that compiles
 # proto files with protoc.
 template("_pw_invoke_protoc") {
-  _output = rebase_path(get_target_outputs(":${invoker.base_target}._metadata"))
+  if (defined(invoker.out_dir)) {
+    _out_dir = invoker.out_dir
+  } else {
+    _out_dir = "${invoker.base_out_dir}/${invoker.language}"
+    if (defined(invoker.module_as_package) && invoker.module_as_package != "") {
+      assert(invoker.language == "python")
+      _out_dir = "$_out_dir/${invoker.module_as_package}"
+    }
+  }
+
+  _includes =
+      rebase_path(get_target_outputs(":${invoker.base_target}._includes"))
 
   pw_python_action("$target_name._gen") {
     forward_variables_from(invoker, [ "metadata" ])
@@ -47,43 +59,40 @@
     }
 
     deps = [
-             ":${invoker.base_target}._metadata",
-             ":${invoker.base_target}._inputs",
-           ] + invoker.deps
+      ":${invoker.base_target}._includes",
+      ":${invoker.base_target}._sources",
+    ]
+
+    foreach(dep, invoker.deps) {
+      deps += [ get_label_info(dep, "label_no_toolchain") + "._gen" ]
+    }
+
+    if (defined(invoker.plugin_deps)) {
+      deps += invoker.plugin_deps
+    }
 
     args = [
              "--language",
              invoker.language,
-             "--include-path",
-             rebase_path(invoker.include_path),
              "--include-file",
-             _output[0],
+             _includes[0],
+             "--compile-dir",
+             rebase_path(invoker.compile_dir),
              "--out-dir",
-             rebase_path(invoker.gen_dir),
+             rebase_path(_out_dir),
+             "--sources",
            ] + rebase_path(invoker.sources)
 
-    inputs = invoker.sources
-
     if (defined(invoker.plugin)) {
-      inputs += [ invoker.plugin ]
+      inputs = [ invoker.plugin ]
       args += [ "--plugin-path=" + rebase_path(invoker.plugin) ]
     }
 
-    outputs = []
-    foreach(extension, invoker.output_extensions) {
-      foreach(proto,
-              rebase_path(invoker.sources,
-                          get_path_info(invoker.include_path, "abspath"))) {
-        _output = string_replace(proto, ".proto", extension)
-        outputs += [ "${invoker.gen_dir}/$_output" ]
-      }
-    }
-
-    if (outputs == []) {
+    if (defined(invoker.outputs)) {
+      outputs = invoker.outputs
+    } else {
       stamp = true
     }
-
-    visibility = [ ":*" ]
   }
 }
 
@@ -96,16 +105,20 @@
     language = "pwpb"
     plugin = "$dir_pw_protobuf/py/pw_protobuf/plugin.py"
     python_deps = [ "$dir_pw_protobuf/py" ]
-    output_extensions = [ ".pwpb.h" ]
   }
 
   # Create a library with the generated source files.
+  config("$target_name._include_path") {
+    include_dirs = [ "${invoker.base_out_dir}/pwpb" ]
+    visibility = [ ":*" ]
+  }
+
   pw_source_set(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
+    public_configs = [ ":$target_name._include_path" ]
+    deps = [ ":$target_name._gen($default_toolchain)" ]
     public_deps = [ dir_pw_protobuf ] + invoker.deps
-    sources = get_target_outputs(":$target_name._gen")
+    sources = invoker.outputs
     public = filter_include(sources, [ "*.pwpb.h" ])
   }
 }
@@ -121,21 +134,25 @@
     language = "nanopb_rpc"
     plugin = "$dir_pw_rpc/py/pw_rpc/plugin_nanopb.py"
     python_deps = [ "$dir_pw_rpc/py" ]
-    output_extensions = [ ".rpc.pb.h" ]
   }
 
   # Create a library with the generated source files.
+  config("$target_name._include_path") {
+    include_dirs = [ "${invoker.base_out_dir}/nanopb_rpc" ]
+    visibility = [ ":*" ]
+  }
+
   pw_source_set(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
+    public_configs = [ ":$target_name._include_path" ]
+    deps = [ ":$target_name._gen($default_toolchain)" ]
     public_deps = [
                     ":${invoker.base_target}.nanopb",
                     "$dir_pw_rpc:server",
                     "$dir_pw_rpc/nanopb:method_union",
                     "$dir_pw_third_party/nanopb",
                   ] + invoker.deps
-    public = get_target_outputs(":$target_name._gen")
+    public = invoker.outputs
   }
 }
 
@@ -143,26 +160,39 @@
 # files. This is internal and should not be used outside of this file. Use
 # pw_proto_library instead.
 template("_pw_nanopb_proto_library") {
-  # Create a target which runs protoc configured with the nanopb plugin to
-  # generate the C proto sources.
-  _pw_invoke_protoc(target_name) {
-    forward_variables_from(invoker, "*", _forwarded_vars)
-    language = "nanopb"
-    plugin = "$dir_pw_third_party_nanopb/generator/protoc-gen-nanopb"
-    output_extensions = [
-      ".pb.h",
-      ".pb.c",
-    ]
-  }
+  # When compiling with the Nanopb plugin, the nanopb.proto file is already
+  # compiled internally, so skip recompiling it with protoc.
+  if (rebase_path(invoker.sources, invoker.compile_dir) == [ "nanopb.proto" ]) {
+    group("$target_name._gen") {
+      deps = [ ":${invoker.base_target}._sources" ]
+    }
 
-  # Create a library with the generated source files.
-  pw_source_set(target_name) {
-    forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
-    public_deps = [ "$dir_pw_third_party/nanopb" ] + invoker.deps
-    sources = get_target_outputs(":$target_name._gen")
-    public = filter_include(sources, [ "*.pb.h" ])
+    group("$target_name") {
+      deps = invoker.deps + [ ":$target_name._gen($default_toolchain)" ]
+    }
+  } else {
+    # Create a target which runs protoc configured with the nanopb plugin to
+    # generate the C proto sources.
+    _pw_invoke_protoc(target_name) {
+      forward_variables_from(invoker, "*", _forwarded_vars)
+      language = "nanopb"
+      plugin = "$dir_pw_third_party_nanopb/generator/protoc-gen-nanopb"
+    }
+
+    # Create a library with the generated source files.
+    config("$target_name._include_path") {
+      include_dirs = [ "${invoker.base_out_dir}/nanopb" ]
+      visibility = [ ":*" ]
+    }
+
+    pw_source_set(target_name) {
+      forward_variables_from(invoker, _forwarded_vars)
+      public_configs = [ ":$target_name._include_path" ]
+      deps = [ ":$target_name._gen($default_toolchain)" ]
+      public_deps = [ "$dir_pw_third_party/nanopb" ] + invoker.deps
+      sources = invoker.outputs
+      public = filter_include(sources, [ "*.pb.h" ])
+    }
   }
 }
 
@@ -177,19 +207,23 @@
     language = "raw_rpc"
     plugin = "$dir_pw_rpc/py/pw_rpc/plugin_raw.py"
     python_deps = [ "$dir_pw_rpc/py" ]
-    output_extensions = [ ".raw_rpc.pb.h" ]
   }
 
   # Create a library with the generated source files.
+  config("$target_name._include_path") {
+    include_dirs = [ "${invoker.base_out_dir}/raw_rpc" ]
+    visibility = [ ":*" ]
+  }
+
   pw_source_set(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
-    public_configs = [ ":${invoker.base_target}._include_path" ]
-    deps = [ ":$target_name._gen" ]
+    public_configs = [ ":$target_name._include_path" ]
+    deps = [ ":$target_name._gen($default_toolchain)" ]
     public_deps = [
                     "$dir_pw_rpc:server",
                     "$dir_pw_rpc/raw:method_union",
                   ] + invoker.deps
-    public = get_target_outputs(":$target_name._gen")
+    public = invoker.outputs
   }
 }
 
@@ -208,12 +242,13 @@
         "google.golang.org/grpc",
       ]
     }
-    output_extensions = []  # Don't enumerate the generated .go files.
-    gen_dir = "$_proto_gopath/src"
+
+    # Override the default "$base_out_dir/$language" output path.
+    out_dir = "$_proto_gopath/src"
   }
 
   group(target_name) {
-    deps = [ ":$target_name._gen" ]
+    deps = invoker.deps + [ ":$target_name._gen($default_toolchain)" ]
   }
 }
 
@@ -223,28 +258,13 @@
 template("_pw_python_proto_library") {
   _target = target_name
 
-  # For standalone protos (e.g. `import "nanopb.proto"`), nest the proto file in
-  # a directory with the same name for Python packaging purposes.
-  if (invoker.standalone_proto) {
-    _source_name = get_path_info(invoker.sources, "name")
-    _proto_gen_dir = "${invoker.gen_dir}/${_source_name[0]}_pb2"
-  } else {
-    _proto_gen_dir = invoker.gen_dir
-  }
-
   _pw_invoke_protoc(target_name) {
     forward_variables_from(invoker, "*", _forwarded_vars)
-    gen_dir = _proto_gen_dir
     language = "python"
-    output_extensions = [
-      "_pb2.py",
-      "_pb2.pyi",
-    ]
-    deps += [ "$dir_pw_protobuf_compiler:protobuf_requirements.install" ]
+    plugin_deps = [ "$dir_pw_protobuf_compiler:protobuf_requirements.install" ]
   }
 
-  _setup_py = "${invoker.gen_dir}/setup.py"
-  _generated_files = get_target_outputs(":$target_name._gen")
+  _setup_py = "${invoker.base_out_dir}/python/setup.py"
 
   # Create the setup and init files for the Python package.
   action(target_name + "._package_gen") {
@@ -254,13 +274,13 @@
              rebase_path(_setup_py),
              "--package",
              invoker._package_dir,
-           ] + rebase_path(_generated_files, invoker.gen_dir)
+           ] + rebase_path(invoker.outputs, "${invoker.base_out_dir}/python")
 
-    if (invoker.standalone_proto) {
-      args += [ "--standalone" ]
+    if (invoker.module_as_package != "") {
+      args += [ "--module-as-package" ]
     }
 
-    public_deps = [ ":$_target._gen" ]
+    public_deps = [ ":$_target._gen($default_toolchain)" ]
     outputs = [ _setup_py ]
   }
 
@@ -268,9 +288,9 @@
   pw_python_package(target_name) {
     forward_variables_from(invoker, _forwarded_vars)
     setup = [ _setup_py ]
-    sources = get_target_outputs(":$target_name._gen")
+    sources = invoker.outputs
     python_deps = invoker.deps
-    other_deps = [ ":$_target._package_gen" ]
+    other_deps = [ ":$_target._package_gen($default_toolchain)" ]
     _pw_generated = true
   }
 }
@@ -280,74 +300,117 @@
 #
 #   <target_name>.<generator>
 #
+# pw_protobuf_library targets generate Python packages. As such, they must have
+# globally unique package names. The first directory of the prefix or the first
+# common directory of the sources is used as the Python package.
+#
 # Args:
-#  sources: List of input .proto files.
-#  deps: List of other pw_proto_library dependencies.
-#  inputs: Other files on which the protos depend (e.g. nanopb .options files).
-#  include_path: Sets the proto include path. The default is ".". It is not
-#      recommended to set this, unless pulling in an externally defined proto.
+#   sources: List of input .proto files.
+#   deps: List of other pw_proto_library dependencies.
+#   inputs: Other files on which the protos depend (e.g. nanopb .options files).
+#   prefix: A prefix to add to the source protos prior to compilation. For
+#       example, a source called "foo.proto" with prefix = "nested" will be
+#       compiled with protoc as "nested/foo.proto".
+#   strip_prefix: Remove this prefix from the source protos. All source and
+#       input files must be nested under this path.
 #
 template("pw_proto_library") {
   assert(defined(invoker.sources) && invoker.sources != [],
          "pw_proto_library requires .proto source files")
 
+  if (defined(invoker.python_module_as_package)) {
+    _module_as_package = invoker.python_module_as_package
+
+    _must_be_one_source = invoker.sources
+    assert([ _must_be_one_source[0] ] == _must_be_one_source,
+           "'python_module_as_package' requires exactly one source file")
+    assert(_module_as_package != "",
+           "'python_module_as_package' cannot be be empty")
+    assert(string_split(_module_as_package, "/") == [ _module_as_package ],
+           "'python_module_as_package' cannot contain slashes")
+    assert(!defined(invoker.prefix),
+           "'prefix' cannot be provided with 'python_module_as_package'")
+  } else {
+    _module_as_package = ""
+  }
+
+  if (defined(invoker.strip_prefix)) {
+    _source_root = get_path_info(invoker.strip_prefix, "abspath")
+  } else {
+    _source_root = get_path_info(".", "abspath")
+  }
+
+  if (defined(invoker.prefix)) {
+    _prefix = invoker.prefix
+  } else {
+    _prefix = ""
+  }
+
   _common = {
     base_target = target_name
-    gen_dir = "$target_gen_dir/$target_name"
-    sources = invoker.sources
 
-    if (defined(invoker.include_path)) {
-      include_path = invoker.include_path
-    } else {
-      include_path = "."
+    # This is the output directory for all files related to this proto library.
+    # Sources are mirrored to "$base_out_dir/sources" and protoc puts outputs in
+    # "$base_out_dir/$language" by default.
+    base_out_dir = get_label_info(":$target_name($default_toolchain)",
+                                  "target_gen_dir") + "/$target_name"
+
+    compile_dir = "$base_out_dir/sources"
+
+    # Refer to the source files as the are mirrored to the output directory.
+    sources = []
+    foreach(file, rebase_path(invoker.sources, _source_root)) {
+      sources += [ "$compile_dir/$_prefix/$file" ]
     }
   }
 
-  _rebased_sources = rebase_path(invoker.sources, _common.include_path)
-
-  # The pw_proto_library GN target requires protos to be nested under the
-  # include directory unless three conditions are met:
-  #
-  #   1. There is only one .proto file.
-  #   2. The file is in a different directory (an externally defined proto).
-  #   3. The include path is the .proto's include directory. Since there are no
-  #      nested directories, the proto cannot be packaged properly in Python.
-  #
-  # When these conditions are met, the proto library is allowed, even though the
-  # proto file is not nested. The Python package for it uses the Python module's
-  # name. This is a special exception to the typical pattern to allow for
-  # working with single, external, standalone protobuf not set up for Python
-  # packaging (such as nanopb.proto).
-  _standalone_proto =
-      _rebased_sources == [ _rebased_sources[0] ] &&
-      _common.include_path != "." &&
-      string_split(_rebased_sources[0], "/") == [ _rebased_sources[0] ]
-
   _package_dir = ""
+  _source_names = []
 
-  foreach(_rebased_source, _rebased_sources) {
+  # Determine the Python package name to use for these protos. If there is no
+  # prefix, the first directory the sources are nested under is used.
+  foreach(source, rebase_path(invoker.sources, _source_root)) {
     _path_components = []
-    _path_components = string_split(_rebased_source, "/")
-
-    assert((_standalone_proto || _path_components != [ _rebased_source ]) &&
-               _path_components[0] != "..",
-           "Sources in a pw_proto_library must live in subdirectories " +
-               "of where it is defined")
+    _path_components = string_split(source, "/")
 
     if (_package_dir == "") {
       _package_dir = _path_components[0]
     } else {
-      assert(_path_components[0] == _package_dir,
-             "All .proto sources in a pw_proto_library must live in the same " +
-                 "directory tree")
+      assert(_prefix != "" || _path_components[0] == _package_dir,
+             "Unless 'prefix' is supplied, all .proto sources in a " +
+                 "pw_proto_library must be in the same directory tree")
     }
+
+    _source_names +=
+        [ get_path_info(source, "dir") + "/" + get_path_info(source, "name") ]
   }
 
-  # Create a group with the package directory in the name. This prevents
-  # multiple pw_proto_libraries from generating the same setup.py file, which
-  # results in awkward ninja errors that require manually re-running gn gen.
-  group("pw_proto_library.$_package_dir") {
+  # If the 'prefix' was supplied, use that for the package directory.
+  if (_prefix != "") {
+    _prefix_path_components = string_split(_prefix, "/")
+    _package_dir = _prefix_path_components[0]
+  }
+
+  assert(_package_dir != "" && _package_dir != "." && _package_dir != "..",
+         "Either a 'prefix' must be specified or all sources must be nested " +
+             "under a common directory")
+
+  # Define an action that is never executed to prevent duplicate proto packages
+  # from being declared. The target name and the output file include only the
+  # package directory, so different targets that use the same proto package name
+  # will conflict.
+  action("pw_proto_library.$_package_dir") {
+    script = "$dir_pw_build/py/pw_build/nop.py"
     visibility = []
+
+    # Place an error message in the output path (which is never created). If the
+    # package name conflicts occur in different BUILD.gn files, this results in
+    # an otherwise cryptic Ninja error, rather than a GN error.
+    outputs = [ "$root_out_dir/ " +
+                "ERROR - Multiple pw_proto_library targets create the " +
+                "'$_package_dir' package. Change the package name by setting " +
+                "the \"prefix\" arg or move the protos to a different " +
+                "directory, then re-run gn gen." ]
   }
 
   if (defined(invoker.deps)) {
@@ -358,35 +421,29 @@
 
   # For each proto target, create a file which collects the base directories of
   # all of its dependencies to list as include paths to protoc.
-  generated_file("$target_name._metadata") {
+  generated_file("$target_name._includes") {
     # Collect metadata from the include path files of each dependency.
-    deps = process_file_template(_deps, "{{source}}._metadata")
+    deps = process_file_template(_deps, "{{source}}._includes")
 
     data_keys = [ "protoc_includes" ]
-    outputs = [ "$target_gen_dir/${_common.base_target}_includes.txt" ]
+    outputs = [ "$target_gen_dir/${_common.base_target}/includes.txt" ]
 
     # Indicate this library's base directory for its dependents.
     metadata = {
-      protoc_includes = [ rebase_path(_common.include_path) ]
+      protoc_includes = [ rebase_path(_common.compile_dir) ]
     }
   }
 
-  # Toss any additional inputs into an input group dependency.
-  if (defined(invoker.inputs)) {
-    pw_input_group("$target_name._inputs") {
-      inputs = invoker.inputs
-      visibility = [ ":*" ]
-    }
-  } else {
-    group("$target_name._inputs") {
-      visibility = [ ":*" ]
-    }
-  }
+  # Mirror the proto sources to the output directory with the prefix added.
+  pw_mirror_tree("$target_name._sources") {
+    source_root = _source_root
+    sources = invoker.sources
 
-  # Create a config with the generated proto directory, which is used for C++.
-  config("$target_name._include_path") {
-    include_dirs = [ _common.gen_dir ]
-    visibility = [ ":*" ]
+    if (defined(invoker.inputs)) {
+      sources += invoker.inputs
+    }
+
+    directory = "${_common.compile_dir}/$_prefix"
   }
 
   # Enumerate all of the protobuf generator targets.
@@ -394,28 +451,52 @@
   _pw_pwpb_proto_library("$target_name.pwpb") {
     forward_variables_from(invoker, _forwarded_vars)
     forward_variables_from(_common, "*")
-    deps = process_file_template(_deps, "{{source}}.pwpb")
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.pwpb(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    outputs = []
+    foreach(name, _source_names) {
+      outputs += [ "$base_out_dir/pwpb/$_prefix/${name}.pwpb.h" ]
+    }
   }
 
   if (dir_pw_third_party_nanopb != "") {
     _pw_nanopb_rpc_proto_library("$target_name.nanopb_rpc") {
       forward_variables_from(invoker, _forwarded_vars)
       forward_variables_from(_common, "*")
-      deps = process_file_template(_deps, "{{source}}.nanopb_rpc")
+
+      deps = []
+      foreach(dep, _deps) {
+        _lbl = get_label_info(dep, "label_no_toolchain")
+        deps += [ "$_lbl.nanopb_rpc(" + get_label_info(dep, "toolchain") + ")" ]
+      }
+
+      outputs = []
+      foreach(name, _source_names) {
+        outputs += [ "$base_out_dir/nanopb_rpc/$_prefix/${name}.rpc.pb.h" ]
+      }
     }
 
-    # When compiling with the Nanopb plugin, the nanopb.proto file is already
-    # compiled internally, so skip recompiling it here.
-    if (invoker.sources ==
-        [ "$dir_pw_third_party_nanopb/generator/proto/nanopb.proto" ]) {
-      pw_input_group("$target_name.nanopb") {
-        sources = invoker.sources
+    _pw_nanopb_proto_library("$target_name.nanopb") {
+      forward_variables_from(invoker, _forwarded_vars)
+      forward_variables_from(_common, "*")
+
+      deps = []
+      foreach(dep, _deps) {
+        _base = get_label_info(dep, "label_no_toolchain")
+        deps += [ "$_base.nanopb(" + get_label_info(dep, "toolchain") + ")" ]
       }
-    } else {
-      _pw_nanopb_proto_library("$target_name.nanopb") {
-        forward_variables_from(invoker, _forwarded_vars)
-        forward_variables_from(_common, "*")
-        deps = process_file_template(_deps, "{{source}}.nanopb")
+
+      outputs = []
+      foreach(name, _source_names) {
+        outputs += [
+          "$base_out_dir/nanopb/$_prefix/${name}.pb.h",
+          "$base_out_dir/nanopb/$_prefix/${name}.pb.c",
+        ]
       }
     }
   } else {
@@ -432,23 +513,55 @@
 
   _pw_raw_rpc_proto_library("$target_name.raw_rpc") {
     forward_variables_from(invoker, _forwarded_vars)
-    forward_variables_from(_common, "*", [ "deps" ])
-    deps = process_file_template(_deps, "{{source}}.raw_rpc")
+    forward_variables_from(_common, "*")
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.raw_rpc(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    outputs = []
+    foreach(name, _source_names) {
+      outputs += [ "$base_out_dir/raw_rpc/$_prefix/${name}.raw_rpc.pb.h" ]
+    }
   }
 
   _pw_go_proto_library("$target_name.go") {
-    sources = invoker.sources
-    deps = process_file_template(_deps, "{{source}}.go")
-    base_target = _common.base_target
-    include_path = _common.include_path
+    sources = _common.sources
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.go(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    forward_variables_from(_common, "*")
   }
 
   _pw_python_proto_library("$target_name.python") {
-    sources = invoker.sources
     forward_variables_from(_common, "*")
-    deps = process_file_template(_deps, "{{source}}.python")
-    base_target = _common.base_target
-    standalone_proto = _standalone_proto
+    module_as_package = _module_as_package
+
+    deps = []
+    foreach(dep, _deps) {
+      _base = get_label_info(dep, "label_no_toolchain")
+      deps += [ "$_base.python(" + get_label_info(dep, "toolchain") + ")" ]
+    }
+
+    if (module_as_package == "") {
+      _python_prefix = "$base_out_dir/python/$_prefix"
+    } else {
+      _python_prefix = "$base_out_dir/python/$module_as_package"
+    }
+
+    outputs = []
+    foreach(name, _source_names) {
+      outputs += [
+        "$_python_prefix/${name}_pb2.py",
+        "$_python_prefix/${name}_pb2.pyi",
+      ]
+    }
   }
 
   # All supported pw_protobuf generators.
diff --git a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
index 5db2f2c..10c0bb2 100644
--- a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
+++ b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_protos.py
@@ -20,8 +20,7 @@
 import subprocess
 import sys
 import tempfile
-
-from typing import Callable, Dict, Optional, Tuple
+from typing import Callable, Dict, Optional, Tuple, Union
 
 # Make sure dependencies are optional, since this script may be run when
 # installing Python package dependencies through GN.
@@ -35,13 +34,10 @@
 _COMMON_FLAGS = ('--experimental_allow_proto3_optional', )
 
 
-def argument_parser(
-    parser: Optional[argparse.ArgumentParser] = None
-) -> argparse.ArgumentParser:
+def _argument_parser() -> argparse.ArgumentParser:
     """Registers the script's arguments on an argument parser."""
 
-    if parser is None:
-        parser = argparse.ArgumentParser(description=__doc__)
+    parser = argparse.ArgumentParser(description=__doc__)
 
     parser.add_argument('--language',
                         required=True,
@@ -50,17 +46,19 @@
     parser.add_argument('--plugin-path',
                         type=Path,
                         help='Path to the protoc plugin')
-    parser.add_argument('--include-path',
-                        required=True,
-                        help='Include path for proto compilation')
     parser.add_argument('--include-file',
                         type=argparse.FileType('r'),
                         help='File containing additional protoc include paths')
     parser.add_argument('--out-dir',
+                        type=Path,
                         required=True,
                         help='Output directory for generated code')
-    parser.add_argument('protos',
-                        metavar='PROTO',
+    parser.add_argument('--compile-dir',
+                        type=Path,
+                        required=True,
+                        help='Root path for compilation')
+    parser.add_argument('--sources',
+                        type=Path,
                         nargs='+',
                         help='Input protobuf files')
 
@@ -89,8 +87,10 @@
         '--plugin',
         f'protoc-gen-nanopb={args.plugin_path}',
         # nanopb_opt provides the flags to use for nanopb_out. Windows doesn't
-        # like when you merge the two using the `flag,...:out` syntax.
-        f'--nanopb_opt=-I{args.include_path}',
+        # like when you merge the two using the `flag,...:out` syntax. Use
+        # Posix-style paths since backslashes on Windows are treated like
+        # escape characters.
+        f'--nanopb_opt=-I{args.compile_dir.as_posix()}',
         f'--nanopb_out={args.out_dir}',
     )
 
@@ -142,14 +142,14 @@
 def main() -> int:
     """Runs protoc as configured by command-line arguments."""
 
-    parser = argument_parser()
+    parser = _argument_parser()
     args = parser.parse_args()
 
     if args.plugin_path is None and args.language not in BUILTIN_PROTOC_LANGS:
         parser.error(
             f'--plugin-path is required for --language {args.language}')
 
-    os.makedirs(args.out_dir, exist_ok=True)
+    args.out_dir.mkdir(parents=True, exist_ok=True)
 
     include_paths = [f'-I{line.strip()}' for line in args.include_file]
 
@@ -169,23 +169,25 @@
             args.plugin_path = wrapper_script = Path(file.name)
             _LOG.debug('Using generated plugin wrapper %s', args.plugin_path)
 
+    cmd: Tuple[Union[str, Path], ...] = (
+        'protoc',
+        f'-I{args.compile_dir}',
+        *include_paths,
+        *DEFAULT_PROTOC_ARGS[args.language](args),
+        *args.sources,
+    )
+
     try:
-        process = subprocess.run(
-            [
-                'protoc',
-                f'-I{args.include_path}',
-                *include_paths,
-                *DEFAULT_PROTOC_ARGS[args.language](args),
-                *args.protos,
-            ],
-            stdout=subprocess.PIPE,
-            stderr=subprocess.STDOUT,
-        )
+        process = subprocess.run(cmd,
+                                 stdout=subprocess.PIPE,
+                                 stderr=subprocess.STDOUT)
     finally:
         if wrapper_script:
             wrapper_script.unlink()
 
     if process.returncode != 0:
+        _LOG.error('Protocol buffer compilation failed!\n%s',
+                   ' '.join(str(c) for c in cmd))
         sys.stderr.buffer.write(process.stdout)
         sys.stderr.flush()
 
diff --git a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_python_package.py b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_python_package.py
index b79d770..9147f08 100644
--- a/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_python_package.py
+++ b/pw_protobuf_compiler/py/pw_protobuf_compiler/generate_python_package.py
@@ -54,7 +54,7 @@
                         required=True,
                         type=Path,
                         help='Path to setup.py file')
-    parser.add_argument('--standalone',
+    parser.add_argument('--module-as-package',
                         action='store_true',
                         help='The package is a standalone external proto')
     parser.add_argument('sources',
@@ -64,10 +64,10 @@
     return parser.parse_args()
 
 
-def main(package: str, setup: Path, standalone: bool,
+def main(package: str, setup: Path, module_as_package: bool,
          sources: List[Path]) -> int:
     """Generates __init__.py and py.typed files and a setup.py."""
-    assert not standalone or len(sources) == 2
+    assert not module_as_package or len(sources) == 2
 
     base = setup.parent.resolve()
     base.mkdir(exist_ok=True)
@@ -95,7 +95,7 @@
         package_name = pkg.relative_to(base).as_posix().replace('/', '.')
         pkg_data[package_name].append(mypy_stub.name)
 
-        if standalone:
+        if module_as_package:
             pkg.joinpath('__init__.py').write_text(
                 f'from {mypy_stub.stem}.{mypy_stub.stem} import *\n')
 
diff --git a/pw_rpc/BUILD.gn b/pw_rpc/BUILD.gn
index 06ba545..0424f1e 100644
--- a/pw_rpc/BUILD.gn
+++ b/pw_rpc/BUILD.gn
@@ -134,17 +134,18 @@
 
 pw_proto_library("protos") {
   sources = [
-    "pw_rpc_protos/echo.proto",
-    "pw_rpc_protos/internal/packet.proto",
+    "echo.proto",
+    "internal/packet.proto",
   ]
-  inputs = [ "pw_rpc_protos/echo.options" ]
+  inputs = [ "echo.options" ]
+  prefix = "pw_rpc_protos"
 }
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
   inputs = [
-    "pw_rpc_protos/echo.proto",
-    "pw_rpc_protos/internal/packet.proto",
+    "echo.proto",
+    "internal/packet.proto",
   ]
   group_deps = [
     "nanopb:docs",
diff --git a/pw_rpc/CMakeLists.txt b/pw_rpc/CMakeLists.txt
index cba62ca..a832b07 100644
--- a/pw_rpc/CMakeLists.txt
+++ b/pw_rpc/CMakeLists.txt
@@ -69,8 +69,12 @@
 
 pw_proto_library(pw_rpc.protos
   SOURCES
-    pw_rpc_protos/internal/packet.proto
-    pw_rpc_protos/echo.proto
+    internal/packet.proto
+    echo.proto
+  INPUTS
+    echo.options
+  PREFIX
+    pw_rpc_protos
 )
 
 pw_proto_library(pw_rpc.test_protos
diff --git a/pw_rpc/docs.rst b/pw_rpc/docs.rst
index 4f76398..9ec4c77 100644
--- a/pw_rpc/docs.rst
+++ b/pw_rpc/docs.rst
@@ -246,9 +246,9 @@
 ============================
 After setting up a ``pw_rpc`` server in your project, you can test that it is
 working as intended by registering the provided ``EchoService``, defined in
-``pw_rpc_protos/echo.proto``, which echoes back a message that it receives.
+``echo.proto``, which echoes back a message that it receives.
 
-.. literalinclude:: pw_rpc_protos/echo.proto
+.. literalinclude:: echo.proto
   :language: protobuf
   :lines: 14-
 
@@ -282,7 +282,7 @@
 encoded as protocol buffers. The full packet format is described in
 ``pw_rpc/pw_rpc_protos/internal/packet.proto``.
 
-.. literalinclude:: pw_rpc_protos/internal/packet.proto
+.. literalinclude:: internal/packet.proto
   :language: protobuf
   :lines: 14-
 
diff --git a/pw_rpc/pw_rpc_protos/echo.options b/pw_rpc/echo.options
similarity index 100%
rename from pw_rpc/pw_rpc_protos/echo.options
rename to pw_rpc/echo.options
diff --git a/pw_rpc/pw_rpc_protos/echo.proto b/pw_rpc/echo.proto
similarity index 100%
rename from pw_rpc/pw_rpc_protos/echo.proto
rename to pw_rpc/echo.proto
diff --git a/pw_rpc/pw_rpc_protos/internal/packet.proto b/pw_rpc/internal/packet.proto
similarity index 100%
rename from pw_rpc/pw_rpc_protos/internal/packet.proto
rename to pw_rpc/internal/packet.proto
diff --git a/pw_trace_tokenized/BUILD.gn b/pw_trace_tokenized/BUILD.gn
index 8468e8a..86403f8 100644
--- a/pw_trace_tokenized/BUILD.gn
+++ b/pw_trace_tokenized/BUILD.gn
@@ -64,7 +64,7 @@
   ]
   public_deps = [
     ":config",
-    ":pw_trace_tokenized_core",
+    ":core",
     "$dir_pw_tokenizer",
   ]
   if (pw_trace_tokenizer_time != "") {
@@ -77,7 +77,7 @@
 pw_test("trace_tokenized_test") {
   enable_if = pw_trace_tokenizer_time != ""
   deps = [
-    ":pw_trace_tokenized_core",
+    ":core",
     "$dir_pw_trace",
   ]
 
@@ -97,6 +97,7 @@
   public_configs = [ ":public_include_path" ]
   public_deps = [ ":trace_rpc_service_proto.nanopb_rpc" ]
   deps = [
+    ":core",
     ":tokenized_trace_buffer",
     "$dir_pw_log",
     "$dir_pw_trace",
@@ -108,7 +109,7 @@
 }
 
 pw_source_set("tokenized_trace_buffer") {
-  deps = [ ":pw_trace_tokenized_core" ]
+  deps = [ ":core" ]
   public_deps = [
     ":config",
     "$dir_pw_ring_buffer",
@@ -154,16 +155,16 @@
 }
 
 pw_source_set("fake_trace_time") {
-  deps = [ ":pw_trace_tokenized_core" ]
+  deps = [ ":core" ]
   sources = [ "fake_trace_time.cc" ]
 }
 
 pw_source_set("host_trace_time") {
-  deps = [ ":pw_trace_tokenized_core" ]
+  deps = [ ":core" ]
   sources = [ "host_trace_time.cc" ]
 }
 
-pw_source_set("pw_trace_tokenized_core") {
+pw_source_set("core") {
   public_configs = [
     ":backend_config",
     ":public_include_path",
@@ -185,6 +186,7 @@
     "public/pw_trace_tokenized/trace_tokenized.h",
   ]
   sources = [ "trace.cc" ]
+  visibility = [ ":*" ]
 }
 
 pw_doc_group("docs") {
diff --git a/third_party/nanopb/BUILD.gn b/third_party/nanopb/BUILD.gn
index 695ce18..77e2453 100644
--- a/third_party/nanopb/BUILD.gn
+++ b/third_party/nanopb/BUILD.gn
@@ -43,8 +43,9 @@
   }
 
   pw_proto_library("proto") {
-    include_path = "$dir_pw_third_party_nanopb/generator/proto"
+    strip_prefix = "$dir_pw_third_party_nanopb/generator/proto"
     sources = [ "$dir_pw_third_party_nanopb/generator/proto/nanopb.proto" ]
+    python_module_as_package = "nanopb_pb2"
   }
 } else {
   group("nanopb") {