Use decorator for build function registration.

It has the following benefits:
- prevents typos
- enables completion
- for the code to be ordered in topological order
- removes to logic

Change-Id: Ia3766d7459c172708a08e3921216e0406189efee
diff --git a/qemu/scripts/rebuild.py b/qemu/scripts/rebuild.py
index b76e9e6..f78e45e 100755
--- a/qemu/scripts/rebuild.py
+++ b/qemu/scripts/rebuild.py
@@ -15,7 +15,6 @@
 """A script to rebuild QEMU from scratch on Linux."""
 
 import argparse
-import inspect
 import os
 from pathlib import Path
 import shlex
@@ -28,8 +27,6 @@
 from typing import List
 from typing import Sequence
 
-sys.path.insert(0, os.path.dirname(__file__))
-
 
 def copy_file(src: Path, dst: Path) -> None:
   log("  COPY_FILE %s --> %s" % (src, dst))
@@ -287,12 +284,61 @@
 ##########################################################################
 ##########################################################################
 #####
+#####  B U I L D   S E Q U E N C  E R
+#####
+#####  A |Project| can register build tasks and their dependencies.
+#####  Then it can return a build plan to be executed in sequence.
+#####
+##########################################################################
+##########################################################################
+
+BuildTaskFn = Callable[[BuildConfig], None]
+
+
+class Project:
+  # Type of a build task function that takes a single |BuildConfig| argument.
+
+  def __init__(self):
+    self.tasks = {}
+
+  def task(self, deps: List[BuildTaskFn]):
+    """Decorator that registers a |BuildTaskFn| and its dependencies."""
+
+    def decorator(fn: BuildTaskFn) -> BuildTaskFn:
+      for dep in deps:
+        if dep not in self.tasks:
+          raise ValueError(
+              f"Task {fn} depends on {dep}, but {dep} is was not yet defined."
+              " Did you forgot to annotate it?"
+          )
+      if fn in self.tasks:
+        raise ValueError(f"Task {fn} already defined.")
+      self.tasks[fn] = deps
+      return fn
+
+    return decorator
+
+  def get_build_task_list(
+      self, task_function: BuildTaskFn
+  ) -> List[BuildTaskFn]:
+    """Returns the transitive dependency list of the current task."""
+    # Rely on the fact that:
+    # a - function are registered in topological order
+    # b - python dictionaries are iterated in insertion order.
+    task_list = list(self.tasks.keys())
+    return task_list[: task_list.index(task_function) + 1]
+
+
+project = Project()
+
+##########################################################################
+##########################################################################
+#####
 #####  I N D I V I D U A L   T A S K S
 #####
 #####  Each build_task_for_xxx() function below should only access a single
-#####  BuildConfig argument, and its docstring may contain a line
-#####  that looks like: "Requires: <depname>+" to list the other
-#####  tasks it depends on.
+#####  BuildConfig argument, be decorated with `project.task` and enumerate
+#####  the tasks it depends on.
 #####
 #####  These functions should also only use the BuildConfig methods
 #####  to do their work, i.e. they shall not directly modify the
@@ -302,6 +348,7 @@
 ##########################################################################
 
 
+@project.task([])
 def build_task_for_sysroot(build: BuildConfig):
   # populate_sysroot(build.build_dir / 'sysroot', build.prebuilts_dir),
   dst_sysroot = build.build_dir / "sysroot"
@@ -331,14 +378,15 @@
     build.copy_file(shared_libgcc_dir / lib, dst_sysroot_lib_dir / lib)
 
 
+@project.task([build_task_for_sysroot])
 def build_task_for_ninja(build: BuildConfig):
-  """Requires: sysroot"""
   build.copy_file(
       build.prebuilts_dir / "ninja" / "ninja",
       build.install_dir / "usr" / "bin" / "ninja",
   )
 
 
+@project.task([])
 def build_task_for_python(build: BuildConfig):
   src_python_dir = build.third_party_dir / "python"
   dst_python_dir = build.install_dir / "usr"
@@ -346,8 +394,10 @@
     build.copy_dir(src_python_dir / d, dst_python_dir / d)
 
 
+@project.task(
+    [build_task_for_sysroot, build_task_for_ninja, build_task_for_python]
+)
 def build_task_for_meson(build: BuildConfig):
-  """Requires: sysroot ninja python"""
   meson_packager = (
       build.third_party_dir / "meson" / "packaging" / "create_zipapp.py"
   )
@@ -362,6 +412,7 @@
   ])
 
 
+@project.task([])
 def build_task_for_rust(build: BuildConfig):
   log("Install prebuilt rust.")
   src_rust_dir = build.prebuilts_dir / "rust" / "linux-x86" / "1.65.0"
@@ -372,14 +423,15 @@
     build.copy_dir(src_dir, dst_dir)
 
 
+@project.task([build_task_for_sysroot])
 def build_task_for_make(build: BuildConfig) -> None:
-  """Requires: sysroot"""
   build.copy_file(
       build.prebuilts_dir / "build-tools" / "linux-x86" / "bin" / "make",
       build.install_dir / "usr" / "bin" / "make",
   )
 
 
+@project.task([])
 def build_task_for_cmake(build: BuildConfig):
   log("Install Cmake prebuilt.")
   build.copy_file(
@@ -392,8 +444,8 @@
   )
 
 
+@project.task([build_task_for_make])
 def build_task_for_bzip2(build: BuildConfig):
-  """Requires: make"""
   build_dir = build.make_subdir(Path("bzip2"))
   build.copy_dir(build.third_party_dir / "bzip2", build_dir)
   env = build.env_copy()
@@ -418,8 +470,8 @@
   )
 
 
+@project.task([build_task_for_make])
 def build_task_for_pkg_config(build: BuildConfig):
-  """Requires: make"""
   build_dir = build.make_subdir(Path("pkg-config"))
   build.copy_dir(build.third_party_dir / "pkg-config", build_dir)
   build.run(
@@ -447,8 +499,8 @@
   build.run_make_install(build_dir)
 
 
+@project.task([build_task_for_make])
 def build_task_for_patchelf(build: BuildConfig):
-  """Requires: make"""
   build_dir = build.make_subdir(Path("patchelf"))
   build.copy_dir(build.third_party_dir / "patchelf", build_dir)
   # Run configure separately so that we can pass "--with-internal-glib".
@@ -464,8 +516,8 @@
   build.run_make_install(build_dir)
 
 
+@project.task([build_task_for_make, build_task_for_cmake])
 def build_task_for_zlib(build: BuildConfig):
-  """Requires: make cmake"""
   lib_name = "zlib"
   src_dir = build.third_party_dir / lib_name
   build_dir = build.make_subdir(Path(lib_name))
@@ -494,8 +546,8 @@
   build.run_make_install(build_dir, use_DESTDIR=False)
 
 
+@project.task([build_task_for_make, build_task_for_bzip2])
 def build_task_for_libpcre2(build: BuildConfig):
-  """Requires: make bzip2"""
   build_dir = build.make_subdir(Path("pcre"))
   build.copy_dir(build.third_party_dir / "pcre", build_dir)
 
@@ -509,8 +561,8 @@
   build.run_make_install(build_dir, use_DESTDIR=True)
 
 
+@project.task([build_task_for_make])
 def build_task_for_libffi(build: BuildConfig):
-  """Requires: make"""
   build_dir = build.make_subdir(Path("libffi"))
   build.copy_dir(build.third_party_dir / "libffi", build_dir)
 
@@ -526,8 +578,15 @@
   build.run_make_install(build_dir, use_DESTDIR=True)
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_meson,
+    build_task_for_libffi,
+    build_task_for_libpcre2,
+    build_task_for_zlib,
+    build_task_for_pkg_config,
+])
 def build_task_for_glib(build: BuildConfig):
-  """Requires: make meson libffi libpcre2 zlib pkg_config"""
   src_dir = build.third_party_dir / "glib"
   build_dir = build.make_subdir(Path("glib"))
 
@@ -555,8 +614,12 @@
   build.run(["ninja", "install"], build_dir)
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_meson,
+    build_task_for_pkg_config,
+])
 def build_task_for_pixman(build: BuildConfig):
-  """Requires: meson make pkg_config"""
 
   src_dir = build.third_party_dir / "pixman"
   build_dir = build.make_subdir(Path("pixman"))
@@ -591,8 +654,11 @@
   )
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_glib,
+])
 def build_task_for_libslirp(build: BuildConfig):
-  """Requires: make glib"""
   src_dir = build.third_party_dir / "libslirp"
   build_dir = build.make_subdir(Path("libslirp"))
 
@@ -611,8 +677,11 @@
   build.run(["ninja", "install"], build_dir)
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_cmake,
+])
 def build_task_for_googletest(build: BuildConfig):
-  """Requires: cmake make"""
   dir_name = Path("googletest")
   build.make_subdir(dir_name)
   cmd_args = [
@@ -625,8 +694,12 @@
   build.run_make_install(dir_name, use_DESTDIR=False)
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_cmake,
+    build_task_for_googletest,
+])
 def build_task_for_aemu_base(build: BuildConfig):
-  """Requires: cmake make googletest"""
   dir_name = Path("aemu")
   build.make_subdir(dir_name)
   # Options from third_party/aemu/rebuild.sh
@@ -643,8 +716,11 @@
   build.run_make_install(dir_name, use_DESTDIR=False)
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_cmake,
+])
 def build_task_for_flatbuffers(build: BuildConfig):
-  """Requires: cmake make"""
   dir_name = Path("flatbuffers")
   build.make_subdir(dir_name)
   cmd_args = [
@@ -657,8 +733,11 @@
   build.run_make_install(dir_name, use_DESTDIR=False)
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_meson,
+])
 def build_task_for_libpciaccess(build: BuildConfig):
-  """Requires: make meson"""
   dir_name = Path("libpciaccess")
   src_dir = build.third_party_dir / dir_name
   build_dir = build.make_subdir(dir_name)
@@ -688,8 +767,12 @@
   )
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_meson,
+    build_task_for_libpciaccess,
+])
 def build_task_for_libdrm(build: BuildConfig):
-  """Requires: make meson libpciaccess"""
   dir_name = Path("libdrm")
   src_dir = build.third_party_dir / dir_name
   build_dir = build.make_subdir(dir_name)
@@ -719,8 +802,14 @@
   )
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_meson,
+    build_task_for_aemu_base,
+    build_task_for_flatbuffers,
+    build_task_for_libdrm,
+])
 def build_task_for_gfxstream(build: BuildConfig):
-  """Requires: cmake make aemu_base flatbuffers libdrm"""
   dir_name = Path("gfxstream-build")
   out_dir = build.make_subdir(dir_name)
   cmd_args = [
@@ -740,8 +829,12 @@
   )
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_rust,
+    build_task_for_gfxstream,
+])
 def build_task_for_rutabaga(build: BuildConfig):
-  """Requires: make rust gfxstream"""
   out_dir = build.make_subdir(Path("rutabaga"))
   cmd_args = [
       build.install_dir / "usr/bin/cargo",
@@ -781,8 +874,17 @@
   )
 
 
+@project.task([
+    build_task_for_make,
+    build_task_for_libslirp,
+    build_task_for_glib,
+    build_task_for_pixman,
+    build_task_for_zlib,
+    build_task_for_pkg_config,
+    build_task_for_rutabaga,
+    build_task_for_gfxstream,
+])
 def build_task_for_qemu(build: BuildConfig):
-  """Requires: make libslirp glib pixman zlib pkg_config rutabaga gfxstream"""
   target_list = [
       "riscv64-softmmu",
       # "x86_64-softmmu",
@@ -803,8 +905,11 @@
   build.run_make_build(build_dir)
 
 
+@project.task([
+    build_task_for_qemu,
+    build_task_for_patchelf,
+])
 def build_task_for_qemu_portable(build: BuildConfig):
-  """Requires: qemu patchelf"""
   package_dir = build.make_subdir(Path("qemu-portable"))
   # Install to a new directory rather than to the common taks install dir.
   build.run_make_install(
@@ -823,8 +928,10 @@
   build.run(["tar", "-czvf", "qemu-portable.tar.gz", package_dir])
 
 
+@project.task([
+    build_task_for_qemu_portable,
+])
 def build_task_for_qemu_test(build: BuildConfig):
-  """Requires: qemu_portable"""
   build.run(["make", "test"], build.build_dir / "qemu")
 
 
@@ -837,117 +944,6 @@
 ##########################################################################
 
 
-class BuildTask(object):
-  """Encapsulate a build task and its dependencies.
-
-  Do not use directly, use a BuildTaskSet instead.
-  """
-
-  def __init__(self, name: str, build_func: Callable, deps: List[str] = []):
-    """Create instance.
-
-    Args:
-      name: Unique name for this task.
-      build_func: A lambda used to perform the task. It must take a single
-        BuildConfig argument.
-      deps: List of direct dependency names.
-    """
-    self._name = name
-    self._builder = build_func
-    self._deps = deps
-
-  @property
-  def name(self):
-    return self._name
-
-  @property
-  def deps(self):
-    return self._deps
-
-  def build(self, bc: BuildConfig):
-    self._builder(bc)
-
-
-class BuildTaskSet(object):
-  # The prefix of all functions in this file that will be returned by
-  # the get_build_functions() method below.
-  BUILD_FUNCTION_PREFIX = "build_task_for_"
-
-  """A complete set of BuildTasks and their dependencies.
-
-    Usage is:
-        1) Create instance.
-
-        2) Call add() or add_build_function() to add new tasks.
-
-        3) Call get_build_task_list() to retrieve list of BuildTask instances
-           in reverse topological order.
-    """
-
-  def __init__(self):
-    self._tasks: Dict[str, BuildTask] = {}
-
-  def add_task(
-      self, name: str, build_func: Callable, deps: List[str] | None = None
-  ):
-    self._tasks[name] = BuildTask(name, build_func, deps)
-
-  def add_build_function(self, f: Callable):
-    name = f.__name__
-    assert name.startswith(self.BUILD_FUNCTION_PREFIX)
-    name = name[len(self.BUILD_FUNCTION_PREFIX) :]
-    deps = []
-    doc = f.__doc__
-    if doc is not None:
-      for line in doc.splitlines():
-        line = line.strip()
-        if line.startswith("Requires: "):
-          deps.extend(line[10:].split(" "))
-    self.add_task(name, f, deps)
-
-  @staticmethod
-  def get_build_functions():
-    """Return the list of all build_task_for_xxx() functions in this file."""
-    return [
-        f
-        for name, f in inspect.getmembers(sys.modules[__name__])
-        if inspect.isfunction(f)
-        and name.startswith(BuildTaskSet.BUILD_FUNCTION_PREFIX)
-    ]
-
-  def get_build_task_list(self, task_name: str):
-    """Compute the transitive dependency list of the current task.
-
-    Args:
-        task_name: Top-level task name.
-
-    Returns:
-        A list of BuildTask instance, in reverse dependency order
-        (i.e. dependencies appear before their dependents). Note that
-        this always includes |self| as the last item.
-    """
-    result = []
-    visited = set()
-    result = []
-    task = self._tasks[task_name]
-    stack = [(task, 0)]
-    visited.add(task)
-    while stack:
-      task, child_index = stack[-1]
-      stack = stack[:-1]
-      if child_index >= len(task.deps):
-        result.append(task)
-      else:
-        dep_name = task.deps[child_index]
-        child = self._tasks.get(dep_name)
-        assert child is not None, f"Unknown {task.name} dependency: {dep_name}"
-        stack.append((task, child_index + 1))
-        if child not in visited:
-          visited.add(child)
-          stack.append((child, 0))
-    return result
-
-
 def main() -> int:
   parser = argparse.ArgumentParser(description=__doc__)
   parser.add_argument("--build-dir", required=True, help="Build directory.")
@@ -989,23 +985,17 @@
     else:
       os.makedirs(build_dir)
 
-    # Create build task set
-    all_tasks = BuildTaskSet()
-
-    # Grab all build_task_for_xxx() functions in this file and add them as build tasks.
-    # Their docstring will be parsed for `Requires: [dependency]+'
-    for f in all_tasks.get_build_functions():
-      all_tasks.add_build_function(f)
-
     # Compute the build plan to get 'qemu'
-    build_plan = all_tasks.get_build_task_list(
-        "qemu_test" if args.run_tests else "qemu_portable"
+    build_tasks = project.get_build_task_list(
+        build_task_for_qemu_test
+        if args.run_tests
+        else build_task_for_qemu_portable
     )
 
-    print("BUILD PLAN: %s" % ", ".join([t.name for t in build_plan]))
+    print("BUILD PLAN: %s" % ", ".join([t.__name__ for t in build_tasks]))
 
-    for t in build_plan:
-      t.build(build_config)
+    for task in build_tasks:
+      task(build_config)
 
   return 0