Lightweight ninja writer in Python

Summary:
- Create python classes for ninja vocbulary in `ninja_syntax.py`. These
  classes will be serialized to a ninja file
- Create a Writer class in `ninja_writer.py`. The current API supports
  adding variables,rules,build actions, etc. This can be extended in the
  future (See `test_ninja_writer.py` for examples)

Future Work:
 - Update the `Subninja` class once chDir is supported (aosp/2064612)
 - Support a width parameter that will be used to wrap long lines of
   text. This will improve readability of the generated files

Expected Use Case: Multi-tree build orchestrator

Test: python ./test_ninja_syntax.py
Test: python ./test_ninja_writer.py

Change-Id: I90c7ee69ddeb7c20c3fd4fca5a911dddbf2253bd
diff --git a/orchestrator/ninja/ninja_syntax.py b/orchestrator/ninja/ninja_syntax.py
new file mode 100644
index 0000000..328c99c
--- /dev/null
+++ b/orchestrator/ninja/ninja_syntax.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2022 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from abc import ABC, abstractmethod
+
+from collections.abc import Iterator
+from typing import List
+
+TAB = "  "
+
+class Node(ABC):
+  '''An abstract class that can be serialized to a ninja file
+  All other ninja-serializable classes inherit from this class'''
+
+  @abstractmethod
+  def stream(self) -> Iterator[str]:
+    pass
+
+class Variable(Node):
+  '''A ninja variable that can be reused across build actions
+  https://ninja-build.org/manual.html#_variables'''
+
+  def __init__(self, name:str, value:str, indent=0):
+    self.name = name
+    self.value = value
+    self.indent = indent
+
+  def stream(self) -> Iterator[str]:
+    indent = TAB * self.indent
+    yield f"{indent}{self.name} = {self.value}"
+
+class RuleException(Exception):
+  pass
+
+# Ninja rules recognize a limited set of variables
+# https://ninja-build.org/manual.html#ref_rule
+# Keep this list sorted
+RULE_VARIABLES = ["command",
+                  "depfile",
+                  "deps",
+                  "description",
+                  "dyndep",
+                  "generator",
+                  "msvc_deps_prefix",
+                  "restat",
+                  "rspfile",
+                  "rspfile_content"]
+
+class Rule(Node):
+  '''A shorthand for a command line that can be reused
+  https://ninja-build.org/manual.html#_rules'''
+
+  def __init__(self, name:str):
+    self.name = name
+    self.variables = []
+
+  def add_variable(self, name: str, value: str):
+    if name not in RULE_VARIABLES:
+      raise RuleException(f"{name} is not a recognized variable in a ninja rule")
+
+    self.variables.append(Variable(name=name, value=value, indent=1))
+
+  def stream(self) -> Iterator[str]:
+    self._validate_rule()
+
+    yield f"rule {self.name}"
+    # Yield rule variables sorted by `name`
+    for var in sorted(self.variables, key=lambda x: x.name):
+      # variables yield a single item, next() is sufficient
+      yield next(var.stream())
+
+  def _validate_rule(self):
+    # command is a required variable in a ninja rule
+    self._assert_variable_is_not_empty(variable_name="command")
+
+  def _assert_variable_is_not_empty(self, variable_name: str):
+    if not any(var.name == variable_name for var in self.variables):
+      raise RuleException(f"{variable_name} is required in a ninja rule")
+
+class BuildActionException(Exception):
+  pass
+
+class BuildAction(Node):
+  '''Describes the dependency edge between inputs and output
+  https://ninja-build.org/manual.html#_build_statements'''
+
+  def __init__(self, output: str, rule: str, inputs: List[str]=None, implicits: List[str]=None, order_only: List[str]=None):
+    self.output = output
+    self.rule = rule
+    self.inputs = self._as_list(inputs)
+    self.implicits = self._as_list(implicits)
+    self.order_only = self._as_list(order_only)
+    self.variables = []
+
+  def add_variable(self, name: str, value: str):
+    '''Variables limited to the scope of this build action'''
+    self.variables.append(Variable(name=name, value=value, indent=1))
+
+  def stream(self) -> Iterator[str]:
+    self._validate()
+
+    build_statement = f"build {self.output}: {self.rule}"
+    if len(self.inputs) > 0:
+      build_statement += " "
+      build_statement += " ".join(self.inputs)
+    if len(self.implicits) > 0:
+      build_statement += " | "
+      build_statement += " ".join(self.implicits)
+    if len(self.order_only) > 0:
+      build_statement += " || "
+      build_statement += " ".join(self.order_only)
+    yield build_statement
+    # Yield variables sorted by `name`
+    for var in sorted(self.variables, key=lambda x: x.name):
+      # variables yield a single item, next() is sufficient
+      yield next(var.stream())
+
+  def _validate(self):
+    if not self.output:
+      raise BuildActionException("Output is required in a ninja build statement")
+    if not self.rule:
+      raise BuildActionException("Rule is required in a ninja build statement")
+
+  def _as_list(self, list_like):
+    if list_like is None:
+      return []
+    if isinstance(list_like, list):
+      return list_like
+    return [list_like]
+
+class Pool(Node):
+  '''https://ninja-build.org/manual.html#ref_pool'''
+
+  def __init__(self, name: str, depth: int):
+    self.name = name
+    self.depth = Variable(name="depth", value=depth, indent=1)
+
+  def stream(self) -> Iterator[str]:
+    yield f"pool {self.name}"
+    yield next(self.depth.stream())
+
+class Subninja(Node):
+
+  def __init__(self, subninja: str, chDir: str):
+    self.subninja = subninja
+    self.chDir = chDir
+
+  # TODO(spandandas): Update the syntax when aosp/2064612 lands
+  def stream() -> Iterator[str]:
+    yield f"subninja {self.subninja}"
+
+class Line(Node):
+  '''Generic class that can be used for comments/newlines/default_target etc'''
+
+  def __init__(self, value:str):
+    self.value = value
+
+  def stream(self) -> Iterator[str]:
+    yield self.value