Repository: pantsbuild/pants

Issue:
Subsystem dependency options are not recursively fingerprinted for TaskIdentityFingerprintStrategy
Because `TaskIdentityFingerprintStrategy` uses `self.subsystem_dependencies_iter` to collect subsystem scopes to get options for, it doesn't recurse into the closure of subsystem dependencies.

@benjyw suggested that moving option fingerprinting onto `Optionable` would address both cases, and give both tasks and subsystems a fingerprint for free.

Related to #2531; if fingerprinting moved onto `Optionable`, subsystems would also gain the "implementation version" fingerprinting.



Discussion:
@cosmicexplorer : I think that maybe this was fixed as a sideeffect of 0bbe913f1c67f298b3191ed51498b20144536888 ...?
Are you looking at a case that fails before that commit? #5161 would be addressing this particular issue, I think -- I don't see how 0bbe913 would affect the behavior you describe. If this is still useful, I can dredge that pr up this week.
Also, last week @baroquebobcat mentioned to @jsirois I think? (and I butted in) that we've been traversing all possible scopes for all options to get a fingerprint instead of just fingerprinting the scope that actually gets applied, and that fixing this may be a noticeable performance gain. That seems like more motivation to take a crack at it.
I wasn't looking at any case... just thought I remember seeing something in that change that would result in all scopes being fingerprinted transitively. Guess not.
This is blocking me from eliminating the middleman and turning `PexBuilderWrapper` into a subsystem so that folks that need it - and just it - can depend on just it, and not `PythonSetup` and `PythonRepos`. As such, I'm taking this on.

Resolving patch:
diff --git a/src/python/pants/subsystem/subsystem_client_mixin.py b/src/python/pants/subsystem/subsystem_client_mixin.py
--- a/src/python/pants/subsystem/subsystem_client_mixin.py
+++ b/src/python/pants/subsystem/subsystem_client_mixin.py
@@ -66,6 +66,34 @@ def subsystem_dependencies_iter(cls):
       else:
         yield SubsystemDependency(dep, GLOBAL_SCOPE)
 
+  @classmethod
+  def subsystem_closure_iter(cls):
+    """Iterate over the transitive closure of subsystem dependencies of this Optionable.
+
+    :rtype: :class:`collections.Iterator` of :class:`SubsystemDependency`
+    :raises: :class:`pants.subsystem.subsystem_client_mixin.SubsystemClientMixin.CycleException`
+             if a dependency cycle is detected.
+    """
+    seen = set()
+    dep_path = OrderedSet()
+
+    def iter_subsystem_closure(subsystem_cls):
+      if subsystem_cls in dep_path:
+        raise cls.CycleException(list(dep_path) + [subsystem_cls])
+      dep_path.add(subsystem_cls)
+
+      for dep in subsystem_cls.subsystem_dependencies_iter():
+        if dep not in seen:
+          seen.add(dep)
+          yield dep
+          for d in iter_subsystem_closure(dep.subsystem_cls):
+            yield d
+
+      dep_path.remove(subsystem_cls)
+
+    for dep in iter_subsystem_closure(cls):
+      yield dep
+
   class CycleException(Exception):
     """Thrown when a circular subsystem dependency is detected."""
 
@@ -77,8 +105,9 @@ def __init__(self, cycle):
 
   @classmethod
   def known_scope_infos(cls):
-    """Yields ScopeInfo for all known scopes for this optionable, in no particular order.
+    """Yield ScopeInfo for all known scopes for this optionable, in no particular order.
 
+    :rtype: set of :class:`pants.option.scope.ScopeInfo`
     :raises: :class:`pants.subsystem.subsystem_client_mixin.SubsystemClientMixin.CycleException`
              if a dependency cycle is detected.
     """
diff --git a/src/python/pants/task/task.py b/src/python/pants/task/task.py
--- a/src/python/pants/task/task.py
+++ b/src/python/pants/task/task.py
@@ -291,8 +291,7 @@ def fingerprint(self):
     hasher.update(self.stable_name().encode('utf-8'))
     hasher.update(self._options_fingerprint(self.options_scope).encode('utf-8'))
     hasher.update(self.implementation_version_str().encode('utf-8'))
-    # TODO: this is not recursive, but should be: see #2739
-    for dep in self.subsystem_dependencies_iter():
+    for dep in self.subsystem_closure_iter():
       hasher.update(self._options_fingerprint(dep.options_scope()).encode('utf-8'))
     return hasher.hexdigest() if PY3 else hasher.hexdigest().decode('utf-8')
 


Test patch:
diff --git a/tests/python/pants_test/subsystem/test_subsystem.py b/tests/python/pants_test/subsystem/test_subsystem.py
--- a/tests/python/pants_test/subsystem/test_subsystem.py
+++ b/tests/python/pants_test/subsystem/test_subsystem.py
@@ -224,3 +224,49 @@ def subsystem_dependencies(cls):
 
     dep_scopes = {dep.options_scope() for dep in SubsystemA.subsystem_dependencies_iter()}
     self.assertEqual({'b', 'dummy.a'}, dep_scopes)
+
+  def test_subsystem_closure_iter(self):
+    class SubsystemA(Subsystem):
+      options_scope = 'a'
+
+    class SubsystemB(Subsystem):
+      options_scope = 'b'
+
+      @classmethod
+      def subsystem_dependencies(cls):
+        return (SubsystemA,)
+
+    class SubsystemC(Subsystem):
+      options_scope = 'c'
+
+      @classmethod
+      def subsystem_dependencies(cls):
+        return (SubsystemA, SubsystemB.scoped(cls))
+
+    class SubsystemD(Subsystem):
+      options_scope = 'd'
+
+      @classmethod
+      def subsystem_dependencies(cls):
+        return (SubsystemC, SubsystemB)
+
+    dep_scopes = {dep.options_scope() for dep in SubsystemD.subsystem_closure_iter()}
+    self.assertEqual({'c', 'a', 'b.c', 'b'}, dep_scopes)
+
+  def test_subsystem_closure_iter_cycle(self):
+    class SubsystemA(Subsystem):
+      options_scope = 'a'
+
+      @classmethod
+      def subsystem_dependencies(cls):
+        return (SubsystemB,)
+
+    class SubsystemB(Subsystem):
+      options_scope = 'b'
+
+      @classmethod
+      def subsystem_dependencies(cls):
+        return (SubsystemA,)
+
+    with self.assertRaises(SubsystemClientMixin.CycleException):
+      list(SubsystemB.subsystem_closure_iter())
diff --git a/tests/python/pants_test/task/test_task.py b/tests/python/pants_test/task/test_task.py
--- a/tests/python/pants_test/task/test_task.py
+++ b/tests/python/pants_test/task/test_task.py
@@ -106,6 +106,14 @@ def register_options(cls, register):
     register('--fake-option', type=bool)
 
 
+class SubsystemWithDependencies(Subsystem):
+  options_scope = 'subsystem-with-deps'
+
+  @classmethod
+  def subsystem_dependencies(cls):
+    return super(SubsystemWithDependencies, cls).subsystem_dependencies() + (FakeSubsystem,)
+
+
 class AnotherFakeTask(Task):
   options_scope = 'another-fake-task'
 
@@ -128,6 +136,19 @@ def supports_passthru_args(cls):
     return False
 
 
+class TaskWithTransitiveSubsystemDependencies(Task):
+  options_scope = 'task-with-transitive-subsystem-deps'
+
+  @classmethod
+  def subsystem_dependencies(cls):
+    return super(TaskWithTransitiveSubsystemDependencies, cls).subsystem_dependencies() + (
+      SubsystemWithDependencies,
+    )
+
+  def execute(self):
+    pass
+
+
 class TaskTest(TaskTestBase):
 
   _filename = 'f'
@@ -612,3 +633,20 @@ def test_fingerprint_passthru_args(self):
       passthru_args=['asdf'],
     )
     self.assertEqual(different_task_with_passthru_fp, different_task_with_same_opts_fp)
+
+  def test_fingerprint_transitive(self):
+    fp1 = self._synth_fp(cls=TaskWithTransitiveSubsystemDependencies)
+
+    # A transitive but non-fingerprintable option
+    self.set_options_for_scope(FakeSubsystem.options_scope, **{'fake-option': True})
+    fp2 = self._synth_fp(cls=TaskWithTransitiveSubsystemDependencies)
+    self.assertEqual(fp1, fp2)
+
+    # A transitive fingerprintable option
+    option_spec = {
+      FakeSubsystem.options_scope: {'fake-option': bool},
+    }
+    self.set_options_for_scope(FakeSubsystem.options_scope, **{'fake-option': True})
+    fp3 = self._synth_fp(cls=TaskWithTransitiveSubsystemDependencies,
+                         options_fingerprintable=option_spec)
+    self.assertNotEqual(fp1, fp3)