bazelci.py: Print status messages to stderr and flush.

Hopefully resolves log file weirdness on Windows.
Also refactor a lot of code just because we can.
diff --git a/buildkite/bazelci.py b/buildkite/bazelci.py
index 2188f9d..4746c7a 100644
--- a/buildkite/bazelci.py
+++ b/buildkite/bazelci.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python3
+#
 # Copyright 2018 The Bazel Authors. All rights reserved.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,26 +14,55 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from __future__ import print_function
 import argparse
 import base64
 import codecs
-from datetime import datetime
+import functools
 import hashlib
 import json
-import os.path
 import multiprocessing
+import os.path
 import random
 import re
+from shutil import copyfile
 import shutil
+import stat
 import subprocess
 import sys
-import stat
 import tempfile
 import urllib.request
-from shutil import copyfile
 from urllib.parse import urlparse
-random.seed(datetime.now())
+
+# Initialize the random number generator.
+random.seed()
+
+class BuildkiteException(Exception):
+    """
+    Raised whenever something goes wrong and we should exit with an error.
+    """
+    pass
+
+
+class BinaryUploadRaceException(Exception):
+    """
+    Raised when try_publish_binaries wasn't able to publish a set of binaries,
+    because the generation of the current file didn't match the expected value.
+    """
+    pass
+
+
+class BazelTestFailedException(Exception):
+    """
+    Raised when a Bazel test fails.
+    """
+    pass
+
+
+def eprint(*args, **kwargs):
+    """
+    Print to stderr and flush (just in case).
+    """
+    print(*args, flush=True, file=sys.stderr, **kwargs)
 
 
 def downstream_projects():
@@ -181,14 +212,6 @@
     return "https://raw.githubusercontent.com/bazelbuild/continuous-integration/master/buildkite/bazelci.py"
 
 
-def eprint(*args, **kwargs):
-    """
-    Print to stderr and exit the process.
-    """
-    print(*args, file=sys.stderr, **kwargs)
-    exit(1)
-
-
 def platforms_info():
     """
     Returns a map containing all supported platform names as keys, with the
@@ -257,16 +280,16 @@
 
 
 def print_collapsed_group(name):
-    print("\n--- {0}\n".format(name))
+    eprint("\n--- {0}\n".format(name))
 
 
 def print_expanded_group(name):
-    print("\n+++ {0}\n".format(name))
+    eprint("\n+++ {0}\n".format(name))
 
 
 def execute_commands(config, platform, git_repository, use_but, save_but,
                      build_only, test_only):
-    exit_code = -1
+    fail_pipeline = False
     tmpdir = None
     bazel_binary = "bazel"
     try:
@@ -287,23 +310,29 @@
                 upload_bazel_binary()
         if not build_only:
             bep_file = os.path.join(tmpdir, "build_event_json_file.json")
-            exit_code = execute_bazel_test(bazel_binary, platform, config.get("test_flags", []),
-                                           config.get("test_targets", None), bep_file)
+            try:
+                execute_bazel_test(bazel_binary, platform, config.get("test_flags", []),
+                                   config.get("test_targets", None), bep_file)
+            except BazelTestFailedException:
+                fail_pipeline = True
             print_test_summary(bep_file)
-            if has_flaky_tests(bep_file) and exit_code == 0:
-                # Fail the pipeline if there were any flaky tests.
-                exit_code = 1
+
+            # Fail the pipeline if there were any flaky tests.
+            if has_flaky_tests(bep_file):
+                fail_pipeline = True
+
             upload_test_logs(bep_file, tmpdir)
     finally:
         if tmpdir:
             shutil.rmtree(tmpdir)
         cleanup(platform)
-        if exit_code > -1:
-            exit(exit_code)
+
+    if fail_pipeline:
+        raise BuildkiteException("At least one test failed or was flaky.")
 
 
 def show_image(url, alt):
-    print("\033]1338;url='\"{0}\"';alt='\"{1}\"'\a\n".format(url, alt))
+    eprint("\033]1338;url='\"{0}\"';alt='\"{1}\"'\a\n".format(url, alt))
 
 
 def print_test_summary(bep_file):
@@ -311,18 +340,18 @@
     if failed:
         print_expanded_group("Failed Tests")
         for label, _ in failed:
-            print(label)
+            eprint(label)
     timed_out = test_logs_for_status(bep_file, status="TIMEOUT")
     if timed_out:
         print_expanded_group("Timed out Tests")
         for label, _ in timed_out:
-            print(label)
+            eprint(label)
     flaky = test_logs_for_status(bep_file, status="FLAKY")
     if flaky:
         print_expanded_group("Flaky Tests")
         show_image(flaky_test_meme_url(), "Flaky Tests")
         for label, _ in flaky:
-            print(label)
+            eprint(label)
 
 
 def has_flaky_tests(bep_file):
@@ -450,7 +479,10 @@
     caching_flags = []
     if not remote_enabled(flags):
         caching_flags = remote_caching_flags(platform)
-    return execute_command([bazel_binary, "test"] + common_flags + caching_flags + flags + targets, fail_if_nonzero=False)
+    try:
+        execute_command([bazel_binary, "test"] + common_flags + caching_flags + flags + targets)
+    except subprocess.CalledProcessError as e:
+        raise BazelTestFailedException("bazel test failed with exit code {}".format(e.returncode))
 
 
 def upload_test_logs(bep_file, tmpdir):
@@ -487,7 +519,8 @@
                 new_paths.append(new_path)
                 attempt = attempt + 1
             except IOError as err:
-                print(err)
+                # Log error and ignore.
+                eprint(err)
     return new_paths
 
 
@@ -526,9 +559,8 @@
 
 
 def execute_command(args, shell=False, fail_if_nonzero=True):
-    print(" ".join(args))
-    res = subprocess.run(args, shell=shell, check=fail_if_nonzero)
-    return res.returncode
+    eprint(" ".join(args))
+    return subprocess.run(args, shell=shell, check=fail_if_nonzero).returncode
 
 
 def print_project_pipeline(platform_configs, project_name, http_config,
@@ -650,10 +682,9 @@
 
 def print_bazel_postsubmit_pipeline(configs, http_config):
     if not configs:
-        eprint("Bazel postsubmit pipeline configuration is empty.")
+        raise BuildkiteException("Bazel postsubmit pipeline configuration is empty.")
     if set(configs.keys()) != set(supported_platforms()):
-        eprint("Bazel postsubmit pipeline needs to build Bazel on all " +
-               "supported platforms.")
+        raise BuildkiteException("Bazel postsubmit pipeline needs to build Bazel on all supported platforms.")
 
     pipeline_steps = []
     for platform, config in configs.items():
@@ -696,12 +727,12 @@
             ["gsutil", "stat", bazelci_builds_metadata_url()])
         match = re.search("Generation:[ ]*([0-9]+)", output.decode("utf-8"))
         if not match:
-            eprint("Couldn't parse generation. gsutil output format changed?")
+            raise BuildkiteException("Couldn't parse generation. gsutil output format changed?")
         generation = match.group(1)
 
         match = re.search("Hash \(md5\):[ ]*([^\s]+)", output.decode("utf-8"))
         if not match:
-            eprint("Couldn't parse md5 hash. gsutil output format changed?")
+            raise BuildkiteException("Couldn't parse md5 hash. gsutil output format changed?")
         expected_md5hash = base64.b64decode(match.group(1))
 
         output = subprocess.check_output(
@@ -745,10 +776,16 @@
         info_file = os.path.join(tmpdir, "info.json")
         with open(info_file, mode="w", encoding="utf-8") as fp:
             json.dump(info, fp)
-        exitcode = execute_command(["gsutil", "-h", "x-goog-if-generation-match:" + expected_generation,
-                                    "-h", "Content-Type:application/json", "cp", "-a",
-                                    "public-read", info_file, bazelci_builds_metadata_url()])
-        return exitcode == 0
+
+        try:
+            execute_command([
+                "gsutil",
+                "-h", "x-goog-if-generation-match:" + expected_generation,
+                "-h", "Content-Type:application/json",
+                "cp", "-a", "public-read",
+                info_file, bazelci_builds_metadata_url()])
+        except subprocess.CalledProcessError:
+            raise BinaryUploadRaceException()
     finally:
         shutil.rmtree(tmpdir)
 
@@ -757,31 +794,41 @@
     """
     Publish Bazel binaries to GCS.
     """
-    attempt = 0
-    while attempt < 5:
+    current_build_number = os.environ.get("BUILDKITE_BUILD_NUMBER", None)
+    if not current_build_number:
+        raise BuildkiteException("Not running inside Buildkite")
+    current_build_number = int(current_build_number)
+
+    for _ in range(5):
         latest_generation, latest_build_number = latest_generation_and_build_number()
 
-        current_build_number = os.environ.get("BUILDKITE_BUILD_NUMBER", None)
-        if not current_build_number:
-            eprint("Not running inside Buildkite")
-        current_build_number = int(current_build_number)
         if current_build_number <= latest_build_number:
-            print(("Current build '{0}' is not newer than latest published '{1}'. " +
-                   "Skipping publishing of binaries.").format(current_build_number,
-                                                              latest_build_number))
+            eprint(("Current build '{0}' is not newer than latest published '{1}'. " +
+                    "Skipping publishing of binaries.").format(current_build_number,
+                                                               latest_build_number))
             break
 
-        if try_publish_binaries(current_build_number, latest_generation):
-            print("Successfully updated '{0}' to binaries from build {1}."
-                  .format(bazelci_builds_metadata_url(), current_build_number))
-            break
-        attempt = attempt + 1
+        try:
+            try_publish_binaries(current_build_number, latest_generation)
+        except BinaryUploadRaceException:
+            # Retry.
+            continue
+
+        eprint("Successfully updated '{0}' to binaries from build {1}."
+               .format(bazelci_builds_metadata_url(), current_build_number))
+        break
+    else:
+        raise BuildkiteException("Could not publish binaries, ran out of attempts.")
 
 
-if __name__ == "__main__":
+def main(argv=None):
+    if argv is None:
+        argv = sys.argv
+
     parser = argparse.ArgumentParser(description='Bazel Continuous Integration Script')
 
     subparsers = parser.add_subparsers(dest="subparsers_name")
+
     bazel_postsubmit_pipeline = subparsers.add_parser("bazel_postsubmit_pipeline")
     bazel_postsubmit_pipeline.add_argument("--http_config", type=str)
     bazel_postsubmit_pipeline.add_argument("--git_repository", type=str)
@@ -805,20 +852,28 @@
 
     args = parser.parse_args()
 
-    if args.subparsers_name == "bazel_postsubmit_pipeline":
-        configs = fetch_configs(args.http_config)
-        print_bazel_postsubmit_pipeline(configs.get("platforms", None),
-                                        args.http_config)
-    elif args.subparsers_name == "project_pipeline":
-        configs = fetch_configs(args.http_config)
-        print_project_pipeline(configs.get("platforms", None), args.project_name,
-                               args.http_config, args.git_repository, args.use_but)
-    elif args.subparsers_name == "runner":
-        configs = fetch_configs(args.http_config)
-        execute_commands(configs.get("platforms", None)[args.platform],
-                         args.platform, args.git_repository, args.use_but, args.save_but,
-                         args.build_only, args.test_only)
-    elif args.subparsers_name == "publish_binaries":
-        publish_binaries()
-    else:
-        parser.print_help()
+    try:
+        if args.subparsers_name == "bazel_postsubmit_pipeline":
+            configs = fetch_configs(args.http_config)
+            print_bazel_postsubmit_pipeline(configs.get("platforms", None), args.http_config)
+        elif args.subparsers_name == "project_pipeline":
+            configs = fetch_configs(args.http_config)
+            print_project_pipeline(configs.get("platforms", None), args.project_name,
+                                   args.http_config, args.git_repository, args.use_but)
+        elif args.subparsers_name == "runner":
+            configs = fetch_configs(args.http_config)
+            execute_commands(configs.get("platforms", None)[args.platform],
+                             args.platform, args.git_repository, args.use_but, args.save_but,
+                             args.build_only, args.test_only)
+        elif args.subparsers_name == "publish_binaries":
+            publish_binaries()
+        else:
+            parser.print_help()
+            return 2
+    except BuildkiteException as e:
+        eprint(str(e))
+        return 1
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main())