gclient: extract Hook class

This will be useful e.g. to add cwd support for flatten.

No intended behavior change.

Bug: 570091
Change-Id: I014f97739676d55f6d5b37c10afd9221b1d0978d
Reviewed-on: https://chromium-review.googlesource.com/534193
Commit-Queue: Paweł Hajdan Jr. <phajdan.jr@chromium.org>
Reviewed-by: Dirk Pranke <dpranke@chromium.org>
diff --git a/gclient.py b/gclient.py
index f622c4b..97fa624 100755
--- a/gclient.py
+++ b/gclient.py
@@ -188,6 +188,70 @@
   raise GNException("Unsupported type when printing to GN.")
 
 
+class Hook(object):
+  """Descriptor of command ran before/after sync or on demand."""
+
+  def __init__(self, action, pattern=None, name=None):
+    """Constructor.
+
+    Arguments:
+      action (list of basestring): argv of the command to run
+      pattern (basestring regex): noop with git; deprecated
+      name (basestring): optional name; no effect on operation
+    """
+    self._action = gclient_utils.freeze(action)
+    self._pattern = pattern
+    self._name = name
+
+  @staticmethod
+  def from_dict(d):
+    """Creates a Hook instance from a dict like in the DEPS file."""
+    return Hook(d['action'], d.get('pattern'), d.get('name'))
+
+  @property
+  def action(self):
+    return self._action
+
+  @property
+  def pattern(self):
+    return self._pattern
+
+  @property
+  def name(self):
+    return self._name
+
+  def matches(self, file_list):
+    """Returns true if the pattern matches any of files in the list."""
+    if not self._pattern:
+      return True
+    pattern = re.compile(self._pattern)
+    return bool([f for f in file_list if pattern.search(f)])
+
+  def run(self, root):
+    """Executes the hook's command."""
+    cmd = list(self._action)
+    if cmd[0] == 'python':
+      # If the hook specified "python" as the first item, the action is a
+      # Python script.  Run it by starting a new copy of the same
+      # interpreter.
+      cmd[0] = sys.executable
+    try:
+      start_time = time.time()
+      gclient_utils.CheckCallAndFilterAndHeader(
+          cmd, cwd=root, always=True)
+    except (gclient_utils.Error, subprocess2.CalledProcessError) as e:
+      # Use a discrete exit status code of 2 to indicate that a hook action
+      # failed.  Users of this script may wish to treat hook action failures
+      # differently from VC failures.
+      print('Error: %s' % str(e), file=sys.stderr)
+      sys.exit(2)
+    finally:
+      elapsed_time = time.time() - start_time
+      if elapsed_time > 10:
+        print("Hook '%s' took %.2f secs" % (
+            gclient_utils.CommandToStr(cmd), elapsed_time))
+
+
 class GClientKeywords(object):
   class VarImpl(object):
     def __init__(self, custom_vars, local_scope):
@@ -772,7 +836,7 @@
         hooks_to_run.append(hook)
 
     if self.recursion_limit:
-      self._pre_deps_hooks = [self.GetHookAction(hook) for hook in
+      self._pre_deps_hooks = [Hook.from_dict(hook) for hook in
                               local_scope.get('pre_deps_hooks', [])]
 
     self.add_dependencies_and_close(
@@ -793,7 +857,7 @@
         self.add_dependency(dep)
     for dep in (orig_deps_to_add or deps_to_add):
       self.add_orig_dependency(dep)
-    self._mark_as_parsed(hooks)
+    self._mark_as_parsed([Hook.from_dict(h) for h in hooks])
 
   def findDepsFromNotAllowedHosts(self):
     """Returns a list of depenecies from not allowed hosts.
@@ -940,18 +1004,6 @@
     self._parsed_url = parsed_url
     self._processed = True
 
-  @staticmethod
-  def GetHookAction(hook_dict):
-    """Turns a parsed 'hook' dict into an executable command."""
-    logging.debug(hook_dict)
-    command = hook_dict['action'][:]
-    if command[0] == 'python':
-      # If the hook specified "python" as the first item, the action is a
-      # Python script.  Run it by starting a new copy of the same
-      # interpreter.
-      command[0] = sys.executable
-    return command
-
   def GetHooks(self, options):
     """Evaluates all hooks, and return them in a flat list.
 
@@ -970,18 +1022,11 @@
       if (options.force or
           gclient_scm.GetScmName(self.parsed_url) in ('git', None) or
           os.path.isdir(os.path.join(self.root.root_dir, self.name, '.git'))):
-        for hook_dict in self.deps_hooks:
-          result.append(self.GetHookAction(hook_dict))
+        result.extend(self.deps_hooks)
       else:
-        # Run hooks on the basis of whether the files from the gclient operation
-        # match each hook's pattern.
-        for hook_dict in self.deps_hooks:
-          pattern = re.compile(hook_dict['pattern'])
-          matching_file_list = [
-              f for f in self.file_list_and_children if pattern.search(f)
-          ]
-          if matching_file_list:
-            result.append(self.GetHookAction(hook_dict))
+        for hook in self.deps_hooks:
+          if hook.matches(self.file_list_and_children):
+            result.append(hook)
     for s in self.dependencies:
       result.extend(s.GetHooks(options))
     return result
@@ -990,21 +1035,7 @@
     assert self.hooks_ran == False
     self._hooks_ran = True
     for hook in self.GetHooks(options):
-      try:
-        start_time = time.time()
-        gclient_utils.CheckCallAndFilterAndHeader(
-            hook, cwd=self.root.root_dir, always=True)
-      except (gclient_utils.Error, subprocess2.CalledProcessError) as e:
-        # Use a discrete exit status code of 2 to indicate that a hook action
-        # failed.  Users of this script may wish to treat hook action failures
-        # differently from VC failures.
-        print('Error: %s' % str(e), file=sys.stderr)
-        sys.exit(2)
-      finally:
-        elapsed_time = time.time() - start_time
-        if elapsed_time > 10:
-          print("Hook '%s' took %.2f secs" % (
-              gclient_utils.CommandToStr(hook), elapsed_time))
+      hook.run(self.root.root_dir)
 
   def RunPreDepsHooks(self):
     assert self.processed
@@ -1015,21 +1046,7 @@
       assert not s.processed
     self._pre_deps_hooks_ran = True
     for hook in self.pre_deps_hooks:
-      try:
-        start_time = time.time()
-        gclient_utils.CheckCallAndFilterAndHeader(
-            hook, cwd=self.root.root_dir, always=True)
-      except (gclient_utils.Error, subprocess2.CalledProcessError) as e:
-        # Use a discrete exit status code of 2 to indicate that a hook action
-        # failed.  Users of this script may wish to treat hook action failures
-        # differently from VC failures.
-        print('Error: %s' % str(e), file=sys.stderr)
-        sys.exit(2)
-      finally:
-        elapsed_time = time.time() - start_time
-        if elapsed_time > 10:
-          print("Hook '%s' took %.2f secs" % (
-              gclient_utils.CommandToStr(hook), elapsed_time))
+      hook.run(self.root.root_dir)
 
 
   def subtree(self, include_all):
@@ -1908,15 +1925,15 @@
         '  # %s' % dep.hierarchy(include_url=False),
         '  {',
     ])
-    if 'name' in hook:
-      s.append('    "name": "%s",' % hook['name'])
-    if 'pattern' in hook:
-      s.append('    "pattern": "%s",' % hook['pattern'])
+    if hook.name is not None:
+      s.append('    "name": "%s",' % hook.name)
+    if hook.pattern is not None:
+      s.append('    "pattern": "%s",' % hook.pattern)
     # TODO(phajdan.jr): actions may contain paths that need to be adjusted,
     # i.e. they may be relative to the dependency path, not solution root.
     s.extend(
         ['    "action": ['] +
-        ['        "%s",' % arg for arg in hook['action']] +
+        ['        "%s",' % arg for arg in hook.action] +
         ['    ]', '  },', '']
     )
   s.extend([']', ''])