Repository: pantsbuild/pants

Issue:
Allow pants to select a target by file(s)
There should be an option to accept literal files, and then select the targets that own those files. Similar to how the `--changed-parent` triggers a diff and the targets are selected based on the result.
The proposed syntax is something like:
``` shell
$ ./pants \
  --owner-of=this/is/a/file/name.java \    # flag triggers owner lookup
  --owner-of=this/is/a/file/name/too.py \  # flag triggers owner lookup
  compile                                  # goal
```
I'd be happy to start working on this right away 😄

Any comments, questions and tips on the implementation are welcome and appreciated.
👖 cc'ing @stuhood 


Discussion:
@alanbato : Thanks! I'll add some mentoring instructions tonight.
Great!
My initial approach currently looks like this:

I'm going to base my work on the `ChangeCalculator` and `TargetRootsCalculator` implementations.
The difference is that while `ChangeCalculator` has to calculate which files were changed, we already have the files from the `--owner-of` arguments.
Then, the "Get the targets from those files" part should work exactly the same, as in, it should be the same code. So, I'm thinking on taking advantage of the `TargetRootsCalculator` `create` method.
Finally, the `--owner-of` option will also be based on the implementation of `--changed`, with the key differences that `--owner-of` can be repeated.

On another note, how should pants behave when both `--changed` and `--owner-of` are specified?
> I'm going to base my work on the ChangeCalculator and TargetRootsCalculator implementations.
The difference is that while ChangeCalculator has to calculate which files were changed, we already have the files from the --owner-of arguments.

Yep! Which I think means that you won't need to touch `ChangeCalculator`... only `TargetRootsCalculator` and friends.

> Finally, the --owner-of option will also be based on the implementation of --changed, with the key differences that --owner-of can be repeated.

Regarding where the options are defined: because `--changed-*` is actually a whole family of options, those options are colocated on a "Subsystem" (ie: a collection of options).

For `--owner-of`, rather than a `Subsystem`, I think that you should put the option directly in the "bootstrap options": https://github.com/pantsbuild/pants/blob/3ae1a086e228cd7fdf4f3e9f1e993cfd47bb95c4/src/python/pants/option/global_options.py#L69-L251

> On another note, how should pants behave when both --changed and --owner-of are specified?

It should fail with a good error message: here is what we currently do when literal specs and `--changed` options are specified: https://github.com/pantsbuild/pants/blob/3ae1a086e228cd7fdf4f3e9f1e993cfd47bb95c4/src/python/pants/init/target_roots_calculator.py#L60-L62

----

Finally, as to testing: I'd suggest starting out by just testing that the option works as expected by doing things like:
```
# hypothetical
$ ./pants --owner-of=src/scala/org/pantsbuild/zinc/analysis/AnalysisMap.scala list
src/scala/org/pantsbuild/zinc/analysis:analysis
```
...which should always be ~equivalent to the output of the existing `list-owners` goal:
```
$ ./pants list-owners -- src/scala/org/pantsbuild/zinc/analysis/AnalysisMap.scala
src/scala/org/pantsbuild/zinc/analysis:analysis
```

Then, add an integration test following a model like: https://github.com/pantsbuild/pants/blob/3ae1a086e228cd7fdf4f3e9f1e993cfd47bb95c4/tests/python/pants_test/engine/legacy/test_list_integration.py#L31-L38 ... which you could even run using your new option! Meta.

----

Thanks for working on this!
Oh, hm... thinking about it more: yea, you'll need to refactor/extract-some-logic-from `ChangeCalculator`.

Resolving patch:
diff --git a/src/python/pants/backend/graph_info/tasks/list_owners.py b/src/python/pants/backend/graph_info/tasks/list_owners.py
--- a/src/python/pants/backend/graph_info/tasks/list_owners.py
+++ b/src/python/pants/backend/graph_info/tasks/list_owners.py
@@ -7,6 +7,7 @@
 
 import json
 
+from pants.base.deprecated import deprecated
 from pants.base.exceptions import TaskError
 from pants.build_graph.source_mapper import LazySourceMapper
 from pants.task.console_task import ConsoleTask
@@ -51,3 +52,7 @@ def console_output(self, targets):
       if owner_info.values():
         for address_spec in owner_info.values()[0]:
           yield address_spec
+
+  @deprecated("1.10.0.dev0", "Run './pants --owner-of=<file> list' instead")
+  def execute(self):
+    super(ListOwners, self).execute()
diff --git a/src/python/pants/init/target_roots_calculator.py b/src/python/pants/init/target_roots_calculator.py
--- a/src/python/pants/init/target_roots_calculator.py
+++ b/src/python/pants/init/target_roots_calculator.py
@@ -1,5 +1,5 @@
 # coding=utf-8
-# Copyright 2016 Pants project contributors (see CONTRIBUTORS.md).
+# Copyright 2018 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
 from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
@@ -128,27 +128,45 @@ def create(cls, options, session, symbol_table, build_root=None):
     changed_options = options.for_scope('changed')
     changed_request = ChangedRequest.from_options(changed_options)
 
+    # Determine the `--owner-of=` arguments provided from the global options
+    owned_files = options.for_global_scope().owner_of
+
     logger.debug('spec_roots are: %s', spec_roots)
     logger.debug('changed_request is: %s', changed_request)
+    logger.debug('owned_files are: %s', owned_files)
     scm = get_scm()
     change_calculator = ChangeCalculator(session, symbol_table, scm) if scm else None
+    owner_calculator = OwnerCalculator(session, symbol_table) if owned_files else None
+    targets_specified = sum(1 for item
+                         in (changed_request.is_actionable(), owned_files, spec_roots)
+                         if item)
+
+    if targets_specified > 1:
+      # We've been provided a more than one of: a change request, an owner request, or spec roots.
+      raise InvalidSpecConstraint(
+        'Multiple target selection methods provided. Please use only one of '
+        '--changed-*, --owner-of, or target specs'
+      )
 
     if change_calculator and changed_request.is_actionable():
-      if spec_roots:
-        # We've been provided spec roots (e.g. `./pants list ::`) AND a changed request. Error out.
-        raise InvalidSpecConstraint('cannot provide changed parameters and target specs!')
-
       # We've been provided no spec roots (e.g. `./pants list`) AND a changed request. Compute
       # alternate target roots.
       changed_addresses = change_calculator.changed_target_addresses(changed_request)
       logger.debug('changed addresses: %s', changed_addresses)
       return TargetRoots(tuple(SingleAddress(a.spec_path, a.target_name) for a in changed_addresses))
 
+    if owner_calculator and owned_files:
+      # We've been provided no spec roots (e.g. `./pants list`) AND a owner request. Compute
+      # alternate target roots.
+      owner_addresses = owner_calculator.owner_target_addresses(owned_files)
+      logger.debug('owner addresses: %s', owner_addresses)
+      return TargetRoots(tuple(SingleAddress(a.spec_path, a.target_name) for a in owner_addresses))
+
     return TargetRoots(spec_roots)
 
 
 class ChangeCalculator(object):
-  """A ChangeCalculator that find the target addresses of changed files based on scm."""
+  """A ChangeCalculator that finds the target addresses of changed files based on scm."""
 
   def __init__(self, scheduler, symbol_table, scm, workspace=None, changes_since=None,
                diffspec=None):
@@ -211,3 +229,29 @@ def iter_changed_target_addresses(self, changed_request):
 
   def changed_target_addresses(self, changed_request):
     return list(self.iter_changed_target_addresses(changed_request))
+
+
+class OwnerCalculator(object):
+  """An OwnerCalculator that finds the target addresses of the files passed down as arguments
+  to --owner-of
+  """
+
+  def __init__(self, scheduler, symbol_table):
+    """
+    :param scheduler: The `Scheduler` instance to use for computing file to target mapping
+    :param symbol_table: The symbol table.
+    """
+    self._scheduler = scheduler
+    self._symbol_table = symbol_table
+    self._mapper = EngineSourceMapper(self._scheduler)
+
+  def iter_owner_target_addresses(self, owned_files):
+    """Given an list of owned files, compute and yield all affected target addresses"""
+    owner_addresses = set(address
+                          for address
+                          in self._mapper.iter_target_addresses_for_sources(owned_files))
+    for address in owner_addresses:
+      yield address
+
+  def owner_target_addresses(self, owner_request):
+    return list(self.iter_owner_target_addresses(owner_request))
diff --git a/src/python/pants/option/global_options.py b/src/python/pants/option/global_options.py
--- a/src/python/pants/option/global_options.py
+++ b/src/python/pants/option/global_options.py
@@ -183,6 +183,11 @@ def register_bootstrap_options(cls, register):
     register('--subproject-roots', type=list, advanced=True, fromfile=True, default=[],
              help='Paths that correspond with build roots for any subproject that this '
                   'project depends on.')
+    register('--owner-of', type=list, default=[], metavar='<path>',
+             help='Select the targets that own these files. '
+                  'This is the third target calculation strategy along with the --changed '
+                  'options and specifying the targets directly. These three types of target '
+                  'selection are mutually exclusive.')
 
     # These logging options are registered in the bootstrap phase so that plugins can log during
     # registration and not so that their values can be interpolated in configs.


Test patch:
diff --git a/tests/python/pants_test/engine/legacy/BUILD b/tests/python/pants_test/engine/legacy/BUILD
--- a/tests/python/pants_test/engine/legacy/BUILD
+++ b/tests/python/pants_test/engine/legacy/BUILD
@@ -53,6 +53,15 @@ python_tests(
   timeout = 600,
 )
 
+python_tests(
+  name = 'owners_integration',
+  sources = [ 'test_owners_integration.py' ],
+  dependencies = [
+    'tests/python/pants_test:int-test',
+  ],
+  tags = {'integration'},
+)
+
 python_tests(
   name = 'dependees_integration',
   sources = ['test_dependees_integration.py'],
diff --git a/tests/python/pants_test/engine/legacy/test_graph.py b/tests/python/pants_test/engine/legacy/test_graph.py
--- a/tests/python/pants_test/engine/legacy/test_graph.py
+++ b/tests/python/pants_test/engine/legacy/test_graph.py
@@ -39,6 +39,7 @@ class GraphTestBase(unittest.TestCase):
   def _make_setup_args(self, specs):
     options = mock.Mock(target_specs=specs)
     options.for_scope.return_value = mock.Mock(diffspec=None, changes_since=None)
+    options.for_global_scope.return_value = mock.Mock(owner_of=None)
     return options
 
   def _default_build_file_aliases(self):
diff --git a/tests/python/pants_test/engine/legacy/test_owners_integration.py b/tests/python/pants_test/engine/legacy/test_owners_integration.py
new file mode 100644
--- /dev/null
+++ b/tests/python/pants_test/engine/legacy/test_owners_integration.py
@@ -0,0 +1,47 @@
+# coding=utf-8
+# Copyright 2018 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
+                        unicode_literals, with_statement)
+
+from pants_test.pants_run_integration_test import PantsRunIntegrationTest
+
+
+class ListOwnersIntegrationTest(PantsRunIntegrationTest):
+  def test_owner_of_success(self):
+    pants_run = self.do_command('--owner-of=testprojects/tests/python/pants/dummies/test_pass.py',
+                                'list',
+                                success=True)
+    self.assertEquals(
+      pants_run.stdout_data.strip(),
+      'testprojects/tests/python/pants/dummies:passing_target'
+    )
+
+  def test_owner_list_not_owned(self):
+    pants_run = self.do_command('--owner-of=testprojects/tests/python/pants/dummies/test_nonexistent.py',
+                                'list',
+                                success=True)
+    self.assertIn('WARNING: No targets were matched in', pants_run.stderr_data)
+  
+  def test_owner_list_two_target_specs(self):
+    # Test that any of these combinations fail with the same error message.
+    expected_error = ('Multiple target selection methods provided. Please use only one of '
+                      '--changed-*, --owner-of, or target specs')
+    pants_run_1 = self.do_command('--owner-of=testprojects/tests/python/pants/dummies/test_pass.py',
+                                  '--changed-parent=master',
+                                  'list',
+                                  success=False)
+    self.assertIn(expected_error, pants_run_1.stderr_data)
+
+    pants_run_2 = self.do_command('--owner-of=testprojects/tests/python/pants/dummies/test_pass.py',
+                                  'list',
+                                  'testprojects/tests/python/pants/dummies:passing_target',
+                                  success=False)
+    self.assertIn(expected_error, pants_run_2.stderr_data)
+
+    pants_run_3 = self.do_command('--changed-parent=master',
+                                  'list',
+                                  'testprojects/tests/python/pants/dummies:passing_target',
+                                  success=False)
+    self.assertIn(expected_error, pants_run_3.stderr_data)
\ No newline at end of file