Repository: google/pytype

Issue:
pytype-all should stop analyzing a cycle if all source files have been checked.
As the title says - if the final source file is in a cycle, pytype-all continues doing unnecessary work after the file has been checked. Fix should be as easy as tracking seen filenames and returning early in yield_sorted_modules(): https://github.com/google/pytype/blob/master/pytype/tools/analyze_project/pytype_runner.py


Discussion:

Resolving patch:
diff --git a/pytype/tools/analyze_project/pytype_runner.py b/pytype/tools/analyze_project/pytype_runner.py
--- a/pytype/tools/analyze_project/pytype_runner.py
+++ b/pytype/tools/analyze_project/pytype_runner.py
@@ -4,12 +4,9 @@
 
 import logging
 import os
-import sys
+import subprocess
 
-from pytype import config as pytype_config
-from pytype import debug
 from pytype import file_utils
-from pytype import io
 from pytype import module_utils
 from pytype.tools.analyze_project import config
 
@@ -22,9 +19,15 @@ def __getattr__(name) -> Any: ...
 
 
 class Action(object):
-  CHECK = 1
-  INFER = 2
-  GENERATE_DEFAULT = 3
+  CHECK = 'check'
+  INFER = 'infer'
+  GENERATE_DEFAULT = 'generate default'
+
+
+class Stage(object):
+  SINGLE_PASS = 'single pass'
+  FIRST_PASS = 'first pass'
+  SECOND_PASS = 'second pass'
 
 
 def deps_from_import_graph(import_graph):
@@ -53,20 +56,6 @@ def make_module(mod):
           for files in import_graph.sorted_source_files()]
 
 
-def _print_transient(msg):
-  """Prints an overwritable terminal message.
-
-  Prints a message that will be overwritten by the next one. Be warned that if
-  the next message is shorter, then part of this message will still be visible
-  on the right.
-
-  Args:
-    msg: A msg
-  """
-  if sys.stdout.isatty():
-    print(msg + '\r', end='')
-
-
 def _module_to_output_path(mod):
   """Convert a module to an output path."""
   path, _ = os.path.splitext(mod.target)
@@ -90,7 +79,10 @@ def __init__(self, conf, sorted_sources):
     self.sorted_sources = sorted_sources  # all source modules
     self.pythonpath = conf.pythonpath
     self.python_version = conf.python_version
-    self.pyi_dir = conf.output
+    self.pyi_dir = os.path.join(conf.output, 'pyi')
+    # directory for first-pass pyi files, for cycle resolution
+    self.pyi_1_dir = os.path.join(conf.output, 'pyi_1')
+    self.ninja_file = os.path.join(conf.output, 'build.ninja')
     self.custom_options = [
         (k, getattr(conf, k)) for k in set(conf.__slots__) - set(config.ITEMS)]
 
@@ -108,13 +100,14 @@ def set_custom_options(self, flags_with_values, binary_flags):
       elif value:
         flags_with_values[arg_info.flag] = str(value)
 
-  def get_pytype_args(self, module, report_errors):
-    """Get the options for running pytype on the given module."""
+  def get_pytype_command_for_ninja(self, report_errors):
+    """Get the command line for running pytype."""
+    exe = 'pytype-single'
     flags_with_values = {
-        '-P': self.pyi_dir,
+        '-P': '$pythonpath',
         '-V': self.python_version,
-        '-o': self._output_file(module),
-        '--module-name': module.name,
+        '-o': '$out',
+        '--module-name': '$module',
     }
     binary_flags = {
         '--quick',
@@ -124,68 +117,30 @@ def get_pytype_args(self, module, report_errors):
     if report_errors:
       self.set_custom_options(flags_with_values, binary_flags)
     return (
+        [exe] +
+        list(sum(flags_with_values.items(), ())) +
         sum(([k, v] for k, v in flags_with_values.items()), []) +
         list(binary_flags) +
-        [module.full_path]
+        ['$in']
     )
 
-  def _output_file(self, module):
+  def _output_file(self, module, pyi_dir):
     filename = _module_to_output_path(module) + '.pyi'
-    return os.path.join(self.pyi_dir, filename)
+    return os.path.join(pyi_dir, filename)
 
-  def create_output_dir(self, module):
+  def generate_default_pyi(self, module):
+    """Write a default pyi file for the module."""
+    output = self._output_file(module, self.pyi_dir)
     # Create the output subdirectory for this file.
-    target_dir = os.path.dirname(self._output_file(module))
+    output_dir = os.path.dirname(output)
     try:
-      file_utils.makedirs(target_dir)
+      file_utils.makedirs(output_dir)
     except OSError:
-      logging.error('Could not create output directory: %s', target_dir)
+      logging.error('Could not create output directory: %s', output_dir)
       return
-    return target_dir
-
-  def write_default_pyi(self, module):
-    """Write a default pyi file for the module."""
-    self.create_output_dir(module)
-    output = self._output_file(module)
-    logging.info('Generating default pyi: %s', output)
     with open(output, 'w') as f:
       f.write(DEFAULT_PYI)
 
-  def run_pytype(self, module, report_errors):
-    """Run pytype over a single module."""
-    self.create_output_dir(module)
-    args = self.get_pytype_args(module, report_errors)
-    logging.info('Running: pytype %s', ' '.join(args))
-    # TODO(rechen): Do we want to get rid of the --nofail option and use a
-    # try/except here instead? We'd control the failure behavior (e.g. we could
-    # potentially bring back the .errors file, or implement an "abort on first
-    # error" flag for quick iterative typechecking).
-    with debug.save_logging_level():  # pytype_config changes the logging level
-      return io.process_one_file(pytype_config.Options(args))
-
-  def process_module(self, module, action):
-    """Process a single module with the given action."""
-    ret = None
-    if action == Action.CHECK:
-      msg = '%s' % module.target
-      _print_transient(msg)
-      ret = self.run_pytype(module, True)
-    elif action == Action.INFER:
-      msg = '%s*' % module.target
-      _print_transient(msg)
-      ret = self.run_pytype(module, False)
-    elif action == Action.GENERATE_DEFAULT:
-      msg = '%s#' % module.target
-      _print_transient(msg)
-      self.write_default_pyi(module)
-      ret = 0
-    else:
-      logging.fatal('Unexpected action %r', action)
-      return 1
-    # Clears the message by overwriting it with whitespace.
-    _print_transient(' ' * len(msg))
-    return ret
-
   def get_module_action(self, module):
     """Get the action for the given module.
 
@@ -204,14 +159,14 @@ def get_module_action(self, module):
       action = Action.INFER
       report = logging.info
     if not f.endswith('.py'):
-      report('Skipping non-Python file: %s', f)
+      report('skipped: non-Python file %s', f)
       return None
     # For builtin and system files, do not attempt to generate a pyi.
     # TODO(rechen): We can skip files in the dependencies of other builtin or
     # system files.
     if module.kind in ('Builtin', 'System'):
-      report('Generating default pyi: %s module %s', module.kind, module.name)
       action = Action.GENERATE_DEFAULT
+      report('%s: %s module %s', action, module.kind, module.name)
     return action
 
   def yield_sorted_modules(self):
@@ -223,30 +178,112 @@ def yield_sorted_modules(self):
         if action:
           modules.append((module, action))
       if len(modules) == 1:
-        yield modules[0]
+        yield modules[0] + (Stage.SINGLE_PASS,)
       else:
         # If we have a cycle we run pytype over the files twice, ignoring errors
         # the first time so that we don't fail on missing dependencies.
         for module, action in modules:
           if action == Action.CHECK:
             action = Action.INFER
-          yield module, action
+          yield module, action, Stage.FIRST_PASS
         for module, action in modules:
           # We don't need to run generate_default twice
           if action != Action.GENERATE_DEFAULT:
-            yield module, action
+            yield module, action, Stage.SECOND_PASS
+
+  def write_ninja_preamble(self):
+    """Write out the pytype-single commands that the build will call."""
+    with open(self.ninja_file, 'w') as f:
+      for action, report_errors in ((Action.INFER, False),
+                                    (Action.CHECK, True)):
+        command = ' '.join(
+            self.get_pytype_command_for_ninja(report_errors=report_errors))
+        logging.info('%s command: %s', action, command)
+        f.write('rule %s\n  command = %s\n' % (action, command))
+
+  def write_build_statement(self, module, action, deps, output_dir=None,
+                            additional_pythonpath_dir=None):
+    """Write a build statement for the given module.
+
+    Args:
+      module: A module_utils.Module object.
+      action: An Action object.
+      deps: The module's dependencies.
+      output_dir: Optionally, the output dir. Defaults to pytype_output/pyi/.
+      additional_pythonpath_dir: Optionally, a dir in which to look for previous
+        output, in addition to pytype_output/pyi/.
+
+    Returns:
+      The expected output of the build statement.
+    """
+    output_dir = output_dir or self.pyi_dir
+    output = self._output_file(module, output_dir)
+    pythonpath_dirs = [self.pyi_dir]
+    if additional_pythonpath_dir:
+      pythonpath_dirs.append(additional_pythonpath_dir)
+    logging.info('%s %s\n  pythonpath: %s\n  deps: %s\n  output: %s',
+                 action, module.name, pythonpath_dirs, deps, output)
+    with open(self.ninja_file, 'a') as f:
+      f.write('build {output}: {action} {input}{deps}\n'
+              '  pythonpath = {pythonpath}\n'
+              '  module = {module}\n'.format(
+                  output=output,
+                  action=action,
+                  input=module.full_path,
+                  deps=' | ' + ' '.join(deps) if deps else '',
+                  pythonpath=':'.join(pythonpath_dirs),
+                  module=module.name))
+    return output
+
+  def setup_build(self):
+    """Write out the full build.ninja file.
+
+    Returns:
+      All files with build statements.
+    """
+    self.write_ninja_preamble()
+    files = set()  # all files with build statements
+    deps = set()  # the dependencies for the next module
+    for module, action, stage in self.yield_sorted_modules():
+      if files >= self.filenames:
+        logging.info('skipped: %s %s (%s)', action, module.name, stage)
+        continue
+      if action == Action.GENERATE_DEFAULT:
+        # TODO(rechen): generating default pyis here breaks the separation
+        # between staging and execution. We should either generate these files
+        # during the ninja build or map them all to one canonical file.
+        self.generate_default_pyi(module)
+        continue
+      if stage == Stage.SINGLE_PASS:
+        files.add(module.full_path)
+        output_dir = additional_pythonpath_dir = None
+      elif stage == Stage.FIRST_PASS:
+        output_dir = self.pyi_1_dir
+        additional_pythonpath_dir = None
+      else:
+        assert stage == Stage.SECOND_PASS
+        files.add(module.full_path)
+        output_dir = None
+        additional_pythonpath_dir = self.pyi_1_dir
+      # TODO(rechen): Get more precise dependency information from the import
+      # graph, to allow build parallelization.
+      deps = {self.write_build_statement(
+          module, action, deps, output_dir, additional_pythonpath_dir)}
+    return files
+
+  def build(self):
+    """Execute the build.ninja file."""
+    # -k N     keep going until N jobs fail (0 means infinity)
+    # -C DIR   change to DIR before doing anything else
+    # TODO(rechen): The user should be able to customize -k.
+    return subprocess.call(
+        ['ninja', '-k', '0', '-C', os.path.dirname(self.ninja_file)])
 
   def run(self):
     """Run pytype over the project."""
     logging.info('------------- Starting pytype run. -------------')
-    modules = list(self.yield_sorted_modules())
-    files_to_analyze = {m.full_path for m, _ in modules}
+    files_to_analyze = self.setup_build()
     num_sources = len(self.filenames & files_to_analyze)
     print('Analyzing %d sources with %d dependencies' %
           (num_sources, len(files_to_analyze) - num_sources))
-    # set ret = 1 if any invocation of process_module returns an error.
-    ret = 0
-    for module, action in modules:
-      status = self.process_module(module, action)
-      ret = ret or status
-    return ret
+    return self.build()
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -141,6 +141,7 @@ def get_version():
     package_data={'pytype': get_builtin_files()},
     install_requires=[
         'importlab',
+        'ninja',
         'pyyaml (>=3.11)',
         'six'
     ],


Test patch:
diff --git a/pytype/tools/analyze_project/pytype_runner_test.py b/pytype/tools/analyze_project/pytype_runner_test.py
--- a/pytype/tools/analyze_project/pytype_runner_test.py
+++ b/pytype/tools/analyze_project/pytype_runner_test.py
@@ -15,6 +15,7 @@
 # pylint: disable=invalid-name
 Module = module_utils.Module
 Action = pytype_runner.Action
+Stage = pytype_runner.Stage
 # pylint: enable=invalid-name
 
 
@@ -22,6 +23,15 @@
 ImportlabModule = collections.namedtuple('Local', 'path short_path module_name')
 
 
+ExpectedBuildStatement = collections.namedtuple(
+    'ExpectedBuildStatement',
+    ('output', 'action', 'input', 'deps', 'pythonpath', 'module'))
+
+
+# number of lines in the build.ninja preamble
+_PREAMBLE_LENGTH = 4
+
+
 class FakeImportGraph(object):
   """Just enough of the ImportGraph interface to run tests."""
 
@@ -137,18 +147,21 @@ def test_no_protocols_default(self):
 
 
 class TestGetRunCmd(TestBase):
-  """Test PytypeRunner.get_pytype_args()."""
+  """Test PytypeRunner.get_pytype_command_for_ninja()."""
 
   def setUp(self):
     self.runner = make_runner([], [], self.parser.config_from_defaults())
 
+  def get_options(self, args):
+    self.assertEqual(args[0], 'pytype-single')
+    return pytype_config.Options(args[1:])
+
   def get_basic_options(self, report_errors=False):
-    module = Module('foo', 'bar.py', 'bar')
-    args = self.runner.get_pytype_args(module, report_errors)
-    return pytype_config.Options(args)
+    return self.get_options(
+        self.runner.get_pytype_command_for_ninja(report_errors))
 
   def test_pythonpath(self):
-    self.assertEqual(self.get_basic_options().pythonpath, [self.runner.pyi_dir])
+    self.assertEqual(self.get_basic_options().pythonpath, ['$pythonpath'])
 
   def test_python_version(self):
     self.assertEqual(
@@ -156,14 +169,13 @@ def test_python_version(self):
         tuple(int(i) for i in self.runner.python_version.split('.')))
 
   def test_output(self):
-    self.assertEqual(self.get_basic_options().output,
-                     os.path.join(self.runner.pyi_dir, 'bar.pyi'))
+    self.assertEqual(self.get_basic_options().output, '$out')
 
   def test_quick(self):
     self.assertTrue(self.get_basic_options().quick)
 
   def test_module_name(self):
-    self.assertEqual(self.get_basic_options().module_name, 'bar')
+    self.assertEqual(self.get_basic_options().module_name, '$module')
 
   def test_error_reporting(self):
     # Disable error reporting
@@ -175,48 +187,12 @@ def test_error_reporting(self):
     self.assertTrue(options.report_errors)
     self.assertTrue(options.analyze_annotated)
 
-  def test_hidden_dir(self):
-    module = Module('', '.foo/bar.py', '.foo.bar')
-    args = self.runner.get_pytype_args(module, report_errors=False)
-    options = pytype_config.Options(args)
-    self.assertEqual(options.output,
-                     os.path.join(self.runner.pyi_dir, '.foo', 'bar.pyi'))
-
-  def test_hidden_file(self):
-    module = Module('', 'foo/.bar.py', 'foo..bar')
-    args = self.runner.get_pytype_args(module, report_errors=False)
-    options = pytype_config.Options(args)
-    self.assertEqual(options.output,
-                     os.path.join(self.runner.pyi_dir, 'foo', '.bar.pyi'))
-
-  def test_hidden_file_with_path_prefix(self):
-    module = Module('', 'foo/.bar.py', '.bar')
-    args = self.runner.get_pytype_args(module, report_errors=False)
-    options = pytype_config.Options(args)
-    self.assertEqual(options.output,
-                     os.path.join(self.runner.pyi_dir, '.bar.pyi'))
-
-  def test_hidden_dir_with_path_mismatch(self):
-    module = Module('', 'symlinked/foo.py', '.bar')
-    args = self.runner.get_pytype_args(module, report_errors=False)
-    options = pytype_config.Options(args)
-    self.assertEqual(options.output,
-                     os.path.join(self.runner.pyi_dir, '.bar.pyi'))
-
-  def test_path_mismatch(self):
-    module = Module('', 'symlinked/foo.py', 'bar.baz')
-    args = self.runner.get_pytype_args(module, report_errors=False)
-    options = pytype_config.Options(args)
-    self.assertEqual(options.output,
-                     os.path.join(self.runner.pyi_dir, 'bar', 'baz.pyi'))
-
   def test_custom_option(self):
     custom_conf = self.parser.config_from_defaults()
     custom_conf.disable = ['import-error', 'name-error']
     self.runner = make_runner([], [], custom_conf)
-    module = Module('', 'foo.py', 'foo')
-    args = self.runner.get_pytype_args(module, report_errors=True)
-    options = pytype_config.Options(args)
+    args = self.runner.get_pytype_command_for_ninja(report_errors=True)
+    options = self.get_options(args)
     self.assertEqual(options.disable, ['import-error', 'name-error'])
 
 
@@ -252,13 +228,14 @@ def normalize(self, d):
     return file_utils.expand_path(d).rstrip(os.sep) + os.sep
 
   def assert_sorted_modules_equal(self, mod_gen, expected_list):
-    for expected_module, expected_report_errors in expected_list:
+    for expected_mod, expected_report_errors, expected_stage in expected_list:
       try:
-        module, actual_report_errors = next(mod_gen)
+        mod, actual_report_errors, actual_stage = next(mod_gen)
       except StopIteration:
         raise AssertionError('Not enough modules')
-      self.assertEqual(module, Module(*expected_module))
+      self.assertEqual(mod, Module(*expected_mod))
       self.assertEqual(actual_report_errors, expected_report_errors)
+      self.assertEqual(actual_stage, expected_stage)
     try:
       next(mod_gen)
     except StopIteration:
@@ -275,7 +252,7 @@ def test_source(self):
     runner = make_runner([f], [[f]], conf)
     self.assert_sorted_modules_equal(
         runner.yield_sorted_modules(),
-        [((d, 'bar.py', 'bar'), Action.CHECK)])
+        [((d, 'bar.py', 'bar'), Action.CHECK, Stage.SINGLE_PASS)])
 
   def test_source_and_dep(self):
     conf = self.parser.config_from_defaults()
@@ -286,8 +263,8 @@ def test_source_and_dep(self):
     runner = make_runner([src], [[dep], [src]], conf)
     self.assert_sorted_modules_equal(
         runner.yield_sorted_modules(),
-        [((d, 'baz.py', 'baz'), Action.INFER),
-         ((d, 'bar.py', 'bar'), Action.CHECK)])
+        [((d, 'baz.py', 'baz'), Action.INFER, Stage.SINGLE_PASS),
+         ((d, 'bar.py', 'bar'), Action.CHECK, Stage.SINGLE_PASS)])
 
   def test_cycle(self):
     conf = self.parser.config_from_defaults()
@@ -298,10 +275,10 @@ def test_cycle(self):
     runner = make_runner([src], [[dep, src]], conf)
     self.assert_sorted_modules_equal(
         runner.yield_sorted_modules(),
-        [((d, 'baz.py', 'baz'), Action.INFER),
-         ((d, 'bar.py', 'bar'), Action.INFER),
-         ((d, 'baz.py', 'baz'), Action.INFER),
-         ((d, 'bar.py', 'bar'), Action.CHECK)])
+        [((d, 'baz.py', 'baz'), Action.INFER, Stage.FIRST_PASS),
+         ((d, 'bar.py', 'bar'), Action.INFER, Stage.FIRST_PASS),
+         ((d, 'baz.py', 'baz'), Action.INFER, Stage.SECOND_PASS),
+         ((d, 'bar.py', 'bar'), Action.CHECK, Stage.SECOND_PASS)])
 
   def test_non_py_dep(self):
     conf = self.parser.config_from_defaults()
@@ -320,7 +297,242 @@ def test_system_dep(self):
     dep = Module(*mod)
     runner = make_runner([], [[dep]], conf)
     self.assert_sorted_modules_equal(
-        runner.yield_sorted_modules(), [(mod, Action.GENERATE_DEFAULT)])
+        runner.yield_sorted_modules(),
+        [(mod, Action.GENERATE_DEFAULT, Stage.SINGLE_PASS)])
+
+
+class TestNinjaPreamble(TestBase):
+  """Tests for PytypeRunner.write_ninja_preamble."""
+
+  def test_write(self):
+    conf = self.parser.config_from_defaults()
+    with file_utils.Tempdir() as d:
+      conf.output = d.path
+      runner = make_runner([], [], conf)
+      runner.write_ninja_preamble()
+      with open(runner.ninja_file, 'r') as f:
+        preamble = f.read().splitlines()
+    self.assertEqual(len(preamble), _PREAMBLE_LENGTH)
+    # The preamble consists of pairs of lines of the format:
+    # rule {name}
+    #   command = pytype-single {args} $in
+    # Check that all even-numbered lines follow the first pattern, all
+    # odd-numbered lines the second.
+    for i, line in enumerate(preamble):
+      if not i % 2:
+        self.assertRegexpMatches(line, r'rule \w*')
+      else:
+        self.assertRegexpMatches(line, r'  command = pytype-single .* \$in')
+
+
+class TestNinjaBuildStatement(TestBase):
+  """Tests for PytypeRunner.write_build_statement."""
+
+  def write_build_statement(self, *args, **kwargs):
+    conf = self.parser.config_from_defaults()
+    with file_utils.Tempdir() as d:
+      conf.output = d.path
+      runner = make_runner([], [], conf)
+      output = runner.write_build_statement(*args, **kwargs)
+      with open(runner.ninja_file, 'r') as f:
+        return runner, output, f.read().splitlines()
+
+  def assertOutputMatches(self, module, expected_output):
+    runner, output, _ = self.write_build_statement(module, Action.CHECK, set())
+    self.assertEqual(output, os.path.join(runner.pyi_dir, expected_output))
+
+  def test_check(self):
+    _, output, build_statement = self.write_build_statement(
+        Module('', 'foo.py', 'foo'), Action.CHECK, set())
+    self.assertEqual(build_statement[0], 'build %s: check foo.py' % output)
+
+  def test_infer(self):
+    _, output, build_statement = self.write_build_statement(
+        Module('', 'foo.py', 'foo'), Action.INFER, set())
+    self.assertEqual(build_statement[0], 'build %s: infer foo.py' % output)
+
+  def test_deps(self):
+    _, output, _ = self.write_build_statement(
+        Module('', 'foo.py', 'foo'), Action.INFER, set())
+    _, _, build_statement = self.write_build_statement(
+        Module('', 'bar.py', 'bar'), Action.CHECK, {output})
+    self.assertTrue(build_statement[0].endswith(' | ' + output))
+
+  def test_pythonpath(self):
+    runner, _, build_statement = self.write_build_statement(
+        Module('', 'foo.py', 'foo'), Action.CHECK, set())
+    self.assertIn('  pythonpath = ' + runner.pyi_dir, build_statement)
+
+  def test_module(self):
+    _, _, build_statement = self.write_build_statement(
+        Module('', 'foo.py', 'foo'), Action.CHECK, set())
+    self.assertIn('  module = foo', build_statement)
+
+  def test_output_dir(self):
+    with file_utils.Tempdir() as d:
+      _, output, _ = self.write_build_statement(
+          Module('', 'foo.py', 'foo'), Action.CHECK, set(), d.path)
+    self.assertEqual(output, os.path.join(d.path, 'foo.pyi'))
+
+  def test_additional_pythonpath_dir(self):
+    with file_utils.Tempdir() as d:
+      runner, _, build_statement = self.write_build_statement(
+          Module('', 'foo.py', 'foo'), Action.CHECK, set(), None, d.path)
+    pythonpath_line = '  pythonpath = %s:%s' % (runner.pyi_dir, d.path)
+    self.assertIn(pythonpath_line, build_statement)
+
+  def test_hidden_dir(self):
+    self.assertOutputMatches(Module('', '.foo/bar.py', '.foo.bar'),
+                             os.path.join('.foo', 'bar.pyi'))
+
+  def test_hidden_file(self):
+    self.assertOutputMatches(Module('', 'foo/.bar.py', 'foo..bar'),
+                             os.path.join('foo', '.bar.pyi'))
+
+  def test_hidden_file_with_path_prefix(self):
+    self.assertOutputMatches(Module('', 'foo/.bar.py', '.bar'),
+                             os.path.join('.bar.pyi'))
+
+  def test_hidden_dir_with_path_mismatch(self):
+    self.assertOutputMatches(Module('', 'symlinked/foo.py', '.bar'), '.bar.pyi')
+
+  def test_path_mismatch(self):
+    self.assertOutputMatches(Module('', 'symlinked/foo.py', 'bar.baz'),
+                             os.path.join('bar', 'baz.pyi'))
+
+
+class TestNinjaBody(TestBase):
+  """Test PytypeRunner.setup_build."""
+
+  def setUp(self):
+    super(TestNinjaBody, self).setUp()
+    self.conf = self.parser.config_from_defaults()
+
+  def assertBuildStatementMatches(self, build_statement, expected):
+    self.assertEqual(build_statement[0],
+                     'build {output}: {action} {input}{deps}'.format(
+                         output=expected.output,
+                         action=expected.action,
+                         input=expected.input,
+                         deps=expected.deps))
+    self.assertEqual(set(build_statement[1:]),
+                     {'  pythonpath = %s' % expected.pythonpath,
+                      '  module = %s' % expected.module})
+
+  def test_basic(self):
+    src = Module('', 'foo.py', 'foo')
+    dep = Module('', 'bar.py', 'bar')
+    with file_utils.Tempdir() as d:
+      self.conf.output = d.path
+      runner = make_runner([src], [[dep], [src]], self.conf)
+      runner.setup_build()
+      with open(runner.ninja_file, 'r') as f:
+        body = f.read().splitlines()[_PREAMBLE_LENGTH:]
+    self.assertBuildStatementMatches(body[0:3], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_dir, 'bar.pyi'),
+        action=Action.INFER,
+        input='bar.py',
+        deps='',
+        pythonpath=runner.pyi_dir,
+        module='bar'))
+    self.assertBuildStatementMatches(body[3:], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_dir, 'foo.pyi'),
+        action=Action.CHECK,
+        input='foo.py',
+        deps=' | ' + os.path.join(runner.pyi_dir, 'bar.pyi'),
+        pythonpath=runner.pyi_dir,
+        module='foo'))
+
+  def test_generate_default(self):
+    src = Module('', 'foo.py', 'foo')
+    dep = Module('', 'bar.py', 'bar', 'System')
+    with file_utils.Tempdir() as d:
+      self.conf.output = d.path
+      runner = make_runner([src], [[dep], [src]], self.conf)
+      runner.setup_build()
+      with open(runner.ninja_file, 'r') as f:
+        body = f.read().splitlines()[_PREAMBLE_LENGTH:]
+      with open(os.path.join(runner.pyi_dir, 'bar.pyi')) as f:
+        bar_pyi = f.read()
+    self.assertBuildStatementMatches(body, ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_dir, 'foo.pyi'),
+        action=Action.CHECK,
+        input='foo.py',
+        deps='',
+        pythonpath=runner.pyi_dir,
+        module='foo'))
+    self.assertEqual(bar_pyi, pytype_runner.DEFAULT_PYI)
+
+  def test_cycle(self):
+    src = Module('', 'foo.py', 'foo')
+    dep = Module('', 'bar.py', 'bar')
+    with file_utils.Tempdir() as d:
+      self.conf.output = d.path
+      runner = make_runner([src], [[dep, src]], self.conf)
+      runner.setup_build()
+      with open(runner.ninja_file, 'r') as f:
+        body = f.read().splitlines()[_PREAMBLE_LENGTH:]
+    self.assertBuildStatementMatches(body[:3], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_1_dir, 'bar.pyi'),
+        action=Action.INFER,
+        input='bar.py',
+        deps='',
+        pythonpath=runner.pyi_dir,
+        module='bar'))
+    self.assertBuildStatementMatches(body[3:6], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_1_dir, 'foo.pyi'),
+        action=Action.INFER,
+        input='foo.py',
+        deps=' | ' + os.path.join(runner.pyi_1_dir, 'bar.pyi'),
+        pythonpath=runner.pyi_dir,
+        module='foo'))
+    self.assertBuildStatementMatches(body[6:9], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_dir, 'bar.pyi'),
+        action=Action.INFER,
+        input='bar.py',
+        deps=' | ' + os.path.join(runner.pyi_1_dir, 'foo.pyi'),
+        pythonpath=runner.pyi_dir + ':' + runner.pyi_1_dir,
+        module='bar'))
+    self.assertBuildStatementMatches(body[9:], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_dir, 'foo.pyi'),
+        action=Action.CHECK,
+        input='foo.py',
+        deps=' | ' + os.path.join(runner.pyi_dir, 'bar.pyi'),
+        pythonpath=runner.pyi_dir + ':' + runner.pyi_1_dir,
+        module='foo'))
+
+  def test_cycle_with_extra_action(self):
+    src = Module('', 'foo.py', 'foo')
+    dep = Module('', 'bar.py', 'bar')
+    with file_utils.Tempdir() as d:
+      self.conf.output = d.path
+      # When `src` is analyzed before `dep`, the second infer action on `dep`
+      # should be skipped.
+      runner = make_runner([src], [[src, dep]], self.conf)
+      runner.setup_build()
+      with open(runner.ninja_file, 'r') as f:
+        body = f.read().splitlines()[_PREAMBLE_LENGTH:]
+    self.assertBuildStatementMatches(body[:3], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_1_dir, 'foo.pyi'),
+        action=Action.INFER,
+        input='foo.py',
+        deps='',
+        pythonpath=runner.pyi_dir,
+        module='foo'))
+    self.assertBuildStatementMatches(body[3:6], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_1_dir, 'bar.pyi'),
+        action=Action.INFER,
+        input='bar.py',
+        deps=' | ' + os.path.join(runner.pyi_1_dir, 'foo.pyi'),
+        pythonpath=runner.pyi_dir,
+        module='bar'))
+    self.assertBuildStatementMatches(body[6:], ExpectedBuildStatement(
+        output=os.path.join(runner.pyi_dir, 'foo.pyi'),
+        action=Action.CHECK,
+        input='foo.py',
+        deps=' | ' + os.path.join(runner.pyi_1_dir, 'bar.pyi'),
+        pythonpath=runner.pyi_dir + ':' + runner.pyi_1_dir,
+        module='foo'))
 
 
 if __name__ == '__main__':