Support dev servers defined in a mojoconfig file.

This patch allows one to define local servers to be set up by devtools
(`mojo_run` / `mojo_test`) in a config file. Each server is described as a
dictionary defining the host and the mappings:

    {
      'host': 'https://core.mojoapps.io/',
      'mappings': [
        ('packages/', ['@{BUILD_DIR}/gen/dart-pkg/packages']),
        ('', ['@{BUILD_DIR}']),
      ],
    },

This allows us to support running 'exploded' dart apps, see
https://github.com/domokit/devtools/issues/30 .

This patch also adds an example config file for the setup we will want to use
in the Mojo repo. As of this CL one has to explicitly pass --config-file
mojoconfig to make use of the config file. We will probably want to make
devtools discover this file automatically.

R=qsr@chromium.org

Review URL: https://codereview.chromium.org/1259793008 .

Cr-Mirrored-From: https://github.com/domokit/mojo
Cr-Mirrored-Commit: 7b674fecee63ca43a90d32a3405eea5e2707883e
diff --git a/devtoolslib/http_server.py b/devtoolslib/http_server.py
index 6afd6a0..a74be0d 100644
--- a/devtoolslib/http_server.py
+++ b/devtoolslib/http_server.py
@@ -203,9 +203,6 @@
     http_thread = threading.Thread(target=httpd.serve_forever)
     http_thread.daemon = True
     http_thread.start()
-    print 'Started http://%s:%d to host %s.' % (httpd.server_address[0],
-                                                httpd.server_address[1],
-                                                str(mappings))
     return httpd.server_address
   except socket.error as v:
     error_code = v[0]
diff --git a/devtoolslib/shell_arguments.py b/devtoolslib/shell_arguments.py
index 89d66e4..bd13755 100644
--- a/devtoolslib/shell_arguments.py
+++ b/devtoolslib/shell_arguments.py
@@ -14,6 +14,7 @@
 
 from devtoolslib.android_shell import AndroidShell
 from devtoolslib.linux_shell import LinuxShell
+from devtoolslib.shell_config import ShellConfigurationException
 
 # When spinning up servers for local origins, we want to use predictable ports
 # so that caching works between subsequent runs with the same command line.
@@ -21,11 +22,6 @@
 _MAPPINGS_BASE_PORT = 31841
 
 
-class ShellConfigurationException(Exception):
-  """Represents an error preventing creating a functional shell abstraction."""
-  pass
-
-
 def _is_web_url(dest):
   return True if urlparse.urlparse(dest).scheme else False
 
@@ -170,6 +166,26 @@
   return arguments
 
 
+def _configure_dev_server(shell, shell_args, dev_server_config):
+  """Sets up a dev server on the host according to |dev_server_config|.
+
+  Args:
+    shell: The shell that is being configured.
+    shell_arguments: Current list of shell arguments.
+    dev_server_config: Instance of shell_config.DevServerConfig describing the
+        dev server to be set up.
+
+  Returns:
+    The updated argument list.
+  """
+  server_url = shell.serve_local_directories(dev_server_config.mappings)
+  shell_args.append('--map-origin=%s=%s' % (dev_server_config.host, server_url))
+  print "Configured %s locally as %s" % (dev_server_config.host, server_url)
+  for mapping_prefix, mapping_path in dev_server_config.mappings:
+    print "  /%s -> %s" % (mapping_prefix, mapping_path)
+  return shell_args
+
+
 def get_shell(shell_config, shell_args):
   """
   Produces a shell abstraction configured according to |shell_config|.
@@ -221,4 +237,7 @@
   if shell_config.sky:
     shell_args = _configure_sky(shell_args)
 
+  for dev_server_config in shell_config.dev_servers:
+    shell_args = _configure_dev_server(shell, shell_args, dev_server_config)
+
   return shell, shell_args
diff --git a/devtoolslib/shell_config.py b/devtoolslib/shell_config.py
index acc9c61..702be64 100644
--- a/devtoolslib/shell_config.py
+++ b/devtoolslib/shell_config.py
@@ -9,9 +9,16 @@
 file, etc.
 """
 
+import ast
+
 from devtoolslib import paths
 
 
+class ShellConfigurationException(Exception):
+  """Represents an error preventing creating a functional shell abstraction."""
+  pass
+
+
 class ShellConfig(object):
   """Configuration for the shell abstraction."""
 
@@ -19,8 +26,9 @@
     self.android = None
     self.shell_path = None
     self.origin = None
-    self.map_url_list = None
-    self.map_origin_list = None
+    self.map_url_list = []
+    self.map_origin_list = []
+    self.dev_servers = []
     self.sky = None
     self.verbose = None
 
@@ -33,6 +41,15 @@
     self.use_osmesa = None
 
 
+class DevServerConfig(object):
+  """Configuration for a development server running on a host and available to
+  the shell.
+  """
+  def __init__(self):
+    self.host = None
+    self.mappings = None
+
+
 def add_shell_arguments(parser):
   """Adds argparse arguments allowing to configure shell abstraction using
   configure_shell() below.
@@ -68,22 +85,37 @@
                              help='Configure the native viewport service '
                              'for off-screen rendering.')
 
-  # Arguments allowing to indicate the configuration we are targeting when
-  # running within a Chromium-like checkout. These will go away once we have
-  # devtools config files, see https://github.com/domokit/devtools/issues/28.
-  chromium_config_group = parser.add_argument_group('Chromium configuration',
+  config_file_group = parser.add_argument_group('Configuration file',
+      'These arguments allow to modify the behavior regarding the mojoconfig '
+      'file.')
+  config_file_group.add_argument('--config-file', type=file,
+                                 help='Path of the configuration file to use.')
+
+  # Arguments allowing to indicate the build directory we are targeting when
+  # running within a Chromium-like checkout (e.g. Mojo checkout). These will go
+  # away once we have devtools config files, see
+  # https://github.com/domokit/devtools/issues/28.
+  chromium_checkout_group = parser.add_argument_group(
+      'Chromium-like checkout configuration',
       'These arguments allow to infer paths to tools and build results '
-      'when running withing a Chromium-like checkout')
-  debug_group = chromium_config_group.add_mutually_exclusive_group()
+      'when running within a Chromium-like checkout')
+  debug_group = chromium_checkout_group.add_mutually_exclusive_group()
   debug_group.add_argument('--debug', help='Debug build (default)',
                            default=True, action='store_true')
   debug_group.add_argument('--release', help='Release build', default=False,
                            dest='debug', action='store_false')
-  chromium_config_group.add_argument('--target-cpu',
+  chromium_checkout_group.add_argument('--target-cpu',
                                      help='CPU architecture to run for.',
                                      choices=['x64', 'x86', 'arm'])
 
 
+def _read_config_file(config_file, aliases):
+  spec = config_file.read()
+  for alias_pattern, alias_value in aliases:
+    spec = spec.replace(alias_pattern, alias_value)
+  return ast.literal_eval(spec)
+
+
 def get_shell_config(script_args):
   """Processes command-line options defined in add_shell_arguments(), applying
   any inferred default paths and produces an instance of ShellConfig.
@@ -95,7 +127,6 @@
   # (--debug/--release, etc.), if running within a Chromium-like checkout.
   inferred_paths = paths.infer_paths(script_args.android, script_args.debug,
                                      script_args.target_cpu)
-
   shell_config = ShellConfig()
 
   shell_config.android = script_args.android
@@ -118,4 +149,31 @@
   if (shell_config.android and not shell_config.origin and
       inferred_paths['build_dir_path']):
     shell_config.origin = inferred_paths['build_dir_path']
+
+  # Read the mojoconfig file.
+  if script_args.config_file:
+    config_file_aliases = []
+    if inferred_paths['build_dir_path']:
+      config_file_aliases.append(('@{BUILD_DIR}',
+                                  inferred_paths['build_dir_path']))
+
+    mojoconfig = None
+    try:
+      mojoconfig = _read_config_file(script_args.config_file,
+                                     config_file_aliases)
+    except SyntaxError:
+      raise ShellConfigurationException('Failed to parse the mojoconfig file.')
+
+    if 'dev_servers' in mojoconfig:
+      try:
+        for dev_server_spec in mojoconfig['dev_servers']:
+          dev_server_config = DevServerConfig()
+          dev_server_config.host = dev_server_spec['host']
+          dev_server_config.mappings = []
+          for prefix, path in dev_server_spec['mappings']:
+            dev_server_config.mappings.append((prefix, path))
+          shell_config.dev_servers.append(dev_server_config)
+      except (ValueError, KeyError):
+        raise ShellConfigurationException('Failed to parse dev_servers in '
+                                          'the mojoconfig file.')
   return shell_config
diff --git a/mojo_run b/mojo_run
index 1457fb2..554eeab 100755
--- a/mojo_run
+++ b/mojo_run
@@ -65,11 +65,11 @@
                       _DEFAULT_WINDOW_MANAGER)
 
   script_args, shell_args = parser.parse_known_args()
-  config = shell_config.get_shell_config(script_args)
 
   try:
+    config = shell_config.get_shell_config(script_args)
     shell, shell_args = shell_arguments.get_shell(config, shell_args)
-  except shell_arguments.ShellConfigurationException as e:
+  except shell_config.ShellConfigurationException as e:
     print e
     return 1
 
diff --git a/mojo_test b/mojo_test
index e2100dd..2cf84b9 100755
--- a/mojo_test
+++ b/mojo_test
@@ -59,11 +59,11 @@
   shell_config.add_shell_arguments(parser)
 
   script_args, shell_args = parser.parse_known_args()
-  config = shell_config.get_shell_config(script_args)
 
   try:
+    config = shell_config.get_shell_config(script_args)
     shell, common_shell_args = shell_arguments.get_shell(config, shell_args)
-  except shell_arguments.ShellConfigurationException as e:
+  except shell_config.ShellConfigurationException as e:
     print e
     return 1