Repository: pybuilder/pybuilder
Issue: @dependents does not inject dependencies from plugin if task is already defined earlier

``` python
@task("compile_docs", "Generates HTML documentation tree with pdoc")
@depends("compile_sources")
@dependents("package")
def pdoc_compile_docs(project, logger):
    pass
```

does not inject `compile_docs` as dependency of `package`


Changed files:
src/unittest/python/reactor_tests.py, src/main/python/pybuilder/reactor.py, src/main/python/pybuilder/execution.py, src/unittest/python/execution_tests.py

diff --git a/src/main/python/pybuilder/execution.py b/src/main/python/pybuilder/execution.py
index 02f9b2a..8a3070d 100644
--- a/src/main/python/pybuilder/execution.py
+++ b/src/main/python/pybuilder/execution.py
@@ -25,10 +25,9 @@
 
 import copy
 import inspect
+import re
 import sys
 import traceback
-
-import re
 import types
 
 from pybuilder.errors import (CircularTaskDependencyException,
@@ -43,9 +42,11 @@ from pybuilder.utils import as_list, Timer, odict
 
 if sys.version_info[0] < 3:  # if major is less than 3
     from .excp_util_2 import raise_exception
+
     getargspec = inspect.getargspec
 else:
     from .excp_util_3 import raise_exception
+
     getargspec = inspect.getfullargspec
 
 
@@ -194,6 +195,7 @@ class ExecutionManager(object):
 
         self._tasks = odict()
         self._task_dependencies = odict()
+        self._dependencies_pending_tasks = {}
 
         self._actions = odict()
         self._execute_before = odict()
@@ -239,6 +241,19 @@ class ExecutionManager(object):
             else:
                 self._tasks[task.name] = task
 
+    def register_late_task_dependencies(self, dependencies):
+        for name in dependencies:
+            self.logger.debug("Registering late dependency of task '%s' on %s", name, dependencies[name])
+            if name not in self._dependencies_pending_tasks:
+                self._dependencies_pending_tasks[name] = []
+            self._dependencies_pending_tasks[name].extend(dependencies[name])
+
+        for name in list(self._dependencies_pending_tasks.keys()):
+            if self.has_task(name):
+                self.logger.debug("Resolved late dependency of task '%s' on %s", name, dependencies[name])
+                self.get_task(name).dependencies.extend(self._dependencies_pending_tasks[name])
+                del self._dependencies_pending_tasks[name]
+
     def execute_initializers(self, environments=None, **keyword_arguments):
         for initializer in self._initializers:
             if not initializer.is_applicable(environments):
@@ -421,8 +436,9 @@ class ExecutionManager(object):
 
     def _should_omit_dependency(self, task, dependency):
         if dependency.optional:
-            if self._exclude_all_optional or dependency.name in self._exclude_optional_tasks \\
-                    or dependency.name in self._exclude_tasks:
+            if self._exclude_all_optional or \\
+                    dependency.name in self._exclude_optional_tasks or \\
+                    dependency.name in self._exclude_tasks:
                 self.logger.debug("Omitting optional dependency '%s' of task '%s'", dependency.name, task.name)
                 return True
         else:
@@ -438,6 +454,11 @@ class ExecutionManager(object):
         self._exclude_tasks = as_task_name_list(exclude_tasks or [])
         self._exclude_all_optional = exclude_all_optional
 
+        self.register_late_task_dependencies({})  # This tries to flush out all remaining pending dependencies
+        for name in self._dependencies_pending_tasks:
+            self.get_task(name)
+        self._dependencies_pending_tasks.clear()
+
         for task in self._tasks.values():
             self._execute_before[task.name] = []
             self._execute_after[task.name] = []
diff --git a/src/main/python/pybuilder/reactor.py b/src/main/python/pybuilder/reactor.py
index 4c037f4..330d296 100644
--- a/src/main/python/pybuilder/reactor.py
+++ b/src/main/python/pybuilder/reactor.py
@@ -204,13 +204,10 @@ class Reactor(object):
         self.collect_tasks_and_actions_and_initializers(plugin_module)
 
     def collect_tasks_and_actions_and_initializers(self, project_module):
-        injected_task_dependencies = dict()
+        injected_task_dependencies = {}
 
         def normalize_candidate_name(candidate):
-            if hasattr(candidate, NAME_ATTRIBUTE):
-                return getattr(candidate, NAME_ATTRIBUTE)
-            elif hasattr(candidate, "__name__"):
-                return candidate.__name__
+            return getattr(candidate, NAME_ATTRIBUTE, candidate.__name__ if hasattr(candidate, "__name__") else None)
 
         def add_task_dependency(names, depends_on, optional):
             for name in as_list(names):
@@ -223,9 +220,9 @@ class Reactor(object):
         for name in dir(project_module):
             candidate = getattr(project_module, name)
 
-            if hasattr(candidate, TASK_ATTRIBUTE) and getattr(candidate, TASK_ATTRIBUTE):
-                dependents = getattr(candidate, DEPENDENTS_ATTRIBUTE) if hasattr(candidate,
-                                                                                 DEPENDENTS_ATTRIBUTE) else None
+            if getattr(candidate, TASK_ATTRIBUTE, None):
+                dependents = getattr(candidate, DEPENDENTS_ATTRIBUTE, None)
+
                 if dependents:
                     dependents = list(as_list(dependents))
                     for d in dependents:
@@ -239,11 +236,10 @@ class Reactor(object):
             candidate = getattr(project_module, name)
             name = normalize_candidate_name(candidate)
 
-            description = getattr(candidate, DESCRIPTION_ATTRIBUTE) if hasattr(
-                candidate, DESCRIPTION_ATTRIBUTE) else ""
+            description = getattr(candidate, DESCRIPTION_ATTRIBUTE, "")
 
-            if hasattr(candidate, TASK_ATTRIBUTE) and getattr(candidate, TASK_ATTRIBUTE):
-                dependencies = getattr(candidate, DEPENDS_ATTRIBUTE) if hasattr(candidate, DEPENDS_ATTRIBUTE) else None
+            if getattr(candidate, TASK_ATTRIBUTE, None):
+                dependencies = getattr(candidate, DEPENDS_ATTRIBUTE, None)
 
                 task_dependencies = list()
                 if dependencies:
@@ -258,36 +254,31 @@ class Reactor(object):
                 # Add injected
                 if name in injected_task_dependencies:
                     task_dependencies.extend(injected_task_dependencies[name])
+                    del injected_task_dependencies[name]
 
                 self.logger.debug("Found task '%s' with dependencies %s", name, task_dependencies)
                 self.execution_manager.register_task(
                     Task(name, candidate, task_dependencies, description))
 
-            elif hasattr(candidate, ACTION_ATTRIBUTE) and getattr(candidate, ACTION_ATTRIBUTE):
-                before = getattr(candidate, BEFORE_ATTRIBUTE) if hasattr(
-                    candidate, BEFORE_ATTRIBUTE) else None
-                after = getattr(candidate, AFTER_ATTRIBUTE) if hasattr(
-                    candidate, AFTER_ATTRIBUTE) else None
+            elif getattr(candidate, ACTION_ATTRIBUTE, None):
+                before = getattr(candidate, BEFORE_ATTRIBUTE, None)
+                after = getattr(candidate, AFTER_ATTRIBUTE, None)
 
-                only_once = False
-                if hasattr(candidate, ONLY_ONCE_ATTRIBUTE):
-                    only_once = getattr(candidate, ONLY_ONCE_ATTRIBUTE)
-                teardown = False
-                if hasattr(candidate, TEARDOWN_ATTRIBUTE):
-                    teardown = getattr(candidate, TEARDOWN_ATTRIBUTE)
+                only_once = getattr(candidate, ONLY_ONCE_ATTRIBUTE, False)
+                teardown = getattr(candidate, TEARDOWN_ATTRIBUTE, False)
 
                 self.logger.debug("Found action %s", name)
                 self.execution_manager.register_action(
                     Action(name, candidate, before, after, description, only_once, teardown))
 
-            elif hasattr(candidate, INITIALIZER_ATTRIBUTE) and getattr(candidate, INITIALIZER_ATTRIBUTE):
-                environments = []
-                if hasattr(candidate, ENVIRONMENTS_ATTRIBUTE):
-                    environments = getattr(candidate, ENVIRONMENTS_ATTRIBUTE)
+            elif getattr(candidate, INITIALIZER_ATTRIBUTE, None):
+                environments = getattr(candidate, ENVIRONMENTS_ATTRIBUTE, [])
 
                 self.execution_manager.register_initializer(
                     Initializer(name, candidate, environments, description))
 
+        self.execution_manager.register_late_task_dependencies(injected_task_dependencies)
+
     def apply_project_attributes(self):
         self.propagate_property("name")
         self.propagate_property("version")
diff --git a/src/unittest/python/execution_tests.py b/src/unittest/python/execution_tests.py
index 5079fb7..07f6aab 100644
--- a/src/unittest/python/execution_tests.py
+++ b/src/unittest/python/execution_tests.py
@@ -215,21 +215,18 @@ class InitializerTest(unittest.TestCase):
         initializer = Initializer("initialzer", self.callable)
         self.assertTrue(initializer.is_applicable("any_environment"))
 
-    def test_should_return_true_when_invoking_is_applicable_with_environment_and_initializer_defines_environment(
-            self):
+    def test_should_return_true_when_invoking_is_applicable_with_environment_and_initializer_defines_environment(self):
         initializer = Initializer(
             "initialzer", self.callable, "any_environment")
         self.assertTrue(initializer.is_applicable("any_environment"))
 
-    def test_should_return_true_when_invoking_is_applicable_with_environments_and_initializer_defines_environment(
-            self):
+    def test_should_return_true_when_invoking_is_applicable_with_environments_and_initializer_defines_environment(self):
         initializer = Initializer(
             "initialzer", self.callable, "any_environment")
         self.assertTrue(initializer.is_applicable(
             ["any_environment", "any_other_environment"]))
 
-    def test_should_return_false_when_invoking_is_applicable_with_environment_and_initializer_defines_environment(
-            self):
+    def test_should_return_false_when_invoking_is_applicable_with_environment_and_initializer_defines_environment(self):
         initializer = Initializer(
             "initialzer", self.callable, "any_environment")
         self.assertFalse(initializer.is_applicable("any_other_environment"))
@@ -593,6 +590,50 @@ class ExecutionManagerResolveDependenciesTest(ExecutionManagerTestBase):
         self.assertEquals([], self.execution_manager._task_dependencies.get("one"))
         self.assertEquals([TaskDependency(one)], self.execution_manager._task_dependencies.get("two"))
 
+    def test_verify_late_dependency(self):
+        one = Mock(name="one", dependencies=[])
+        two = Mock(name="two", dependencies=[TaskDependency("one")])
+        three = Mock(name="three", dependencies=[TaskDependency("one")])
+
+        self.execution_manager.register_task(one, two)
+        self.execution_manager.register_task(three)
+        self.execution_manager.register_late_task_dependencies({"two": [TaskDependency("three")]})
+        self.execution_manager.resolve_dependencies()
+
+        self.assertEquals([], self.execution_manager._task_dependencies.get("one"))
+        self.assertEquals([TaskDependency(one), TaskDependency(three)],
+                          self.execution_manager._task_dependencies.get("two"))
+        self.assertEquals([TaskDependency(one)],
+                          self.execution_manager._task_dependencies.get("three"))
+
+    def test_verify_duplicate_late_dependency_removed(self):
+        one = Mock(name="one", dependencies=[])
+        two = Mock(name="two", dependencies=[TaskDependency("one")])
+        three = Mock(name="three", dependencies=[TaskDependency("one"), TaskDependency("one")])
+
+        self.execution_manager.register_task(one, two)
+        self.execution_manager.register_task(three)
+        self.execution_manager.register_late_task_dependencies(
+            {"two": [TaskDependency("three"), TaskDependency("three")]})
+        self.execution_manager.resolve_dependencies()
+
+        self.assertEquals([], self.execution_manager._task_dependencies.get("one"))
+        self.assertEquals([TaskDependency(one), TaskDependency(three)],
+                          self.execution_manager._task_dependencies.get("two"))
+        self.assertEquals([TaskDependency(one)],
+                          self.execution_manager._task_dependencies.get("three"))
+
+    def test_verify_error_unresolved_late_dependency(self):
+        one = Mock(name="one", dependencies=[])
+        two = Mock(name="two", dependencies=[TaskDependency("one")])
+        three = Mock(name="three", dependencies=[TaskDependency("one")])
+
+        self.execution_manager.register_task(one, two)
+        self.execution_manager.register_task(three)
+        self.execution_manager.register_late_task_dependencies(
+            {"four": [TaskDependency("three")]})
+        self.assertRaises(NoSuchTaskException, self.execution_manager.resolve_dependencies)
+
 
 class ExecutionManagerBuildExecutionPlanTest(ExecutionManagerTestBase):
     def test_should_collect_all_tasks_when_there_are_no_dependencies(self):
diff --git a/src/unittest/python/reactor_tests.py b/src/unittest/python/reactor_tests.py
index 1683747..4e43379 100644
--- a/src/unittest/python/reactor_tests.py
+++ b/src/unittest/python/reactor_tests.py
@@ -167,6 +167,41 @@ class ReactorTest(unittest.TestCase):
                                                           [TaskDependency(task1), TaskDependency(task2, True),
                                                            TaskDependency(task4), TaskDependency(task5)], '')])
 
+    def test_task_dependencies_with_post_definition_injections(self):
+        import pybuilder.reactor
+
+        with patch("pybuilder.reactor.Task"):
+            @task
+            def task1():
+                pass
+
+            @task
+            @depends(task1)
+            def task2():
+                pass
+
+            @task
+            @depends(task1)
+            @dependents(task2)
+            def task3():
+                pass
+
+            module1 = ModuleType("mock_module_one")
+            module1.task1 = task1
+            module1.task2 = task2
+
+            module2 = ModuleType("mock_module_two")
+            module2.task3 = task3
+
+            self.reactor.collect_tasks_and_actions_and_initializers(module1)
+            pybuilder.reactor.Task.assert_has_calls([call("task1", task1, [], ''),
+                                                     call("task2", task2, [TaskDependency(task1)], '')])
+
+            self.reactor.collect_tasks_and_actions_and_initializers(module2)
+            pybuilder.reactor.Task.assert_has_calls([call("task3", task3, [TaskDependency(task1)], '')])
+            self.execution_manager.register_late_task_dependencies.assert_has_calls(
+                [call({}), call({"task2": [TaskDependency(task3)]})])
+
     def test_should_collect_single_before_action(self):
         @before("spam")
         def action():
@@ -250,6 +285,9 @@ class ReactorTest(unittest.TestCase):
             def register_initializer(self, initializer):
                 self.initializer = initializer
 
+            def register_late_task_dependencies(self, dependencies):
+                pass
+
         execution_manager_mock = ExecutionManagerMock()
         self.reactor.execution_manager = execution_manager_mock