Repository: pypa/pip
Pull request: Add --path to pip freeze to support --target installations
`pip freeze` does not support installations in a target directory. This change adds the `--path` argument to `pip freeze` to enable this functionality.

Closes: #6404

Problem statement:
Support --target installations with pip freeze
**What's the problem this feature will solve?**
<!-- What are you trying to do, that you are unable to achieve with pip as it currently stands? -->
List all dependencies with `pip freeze` which have been installed into an arbitrary directory with `pip install --target`.

**Describe the solution you'd like**
<!-- Clear and concise description of what you want to happen. -->
`pip freeze` should have a `--target` option and take a path to an installation path to work out the installed dependencies in that directory. It should probably also honour the `--local` flag and skip globally installed packages if specified, just like with virtualenvs, or implicitly disable global site altogether.

<!-- Provide examples of real world use cases that this would enable and how it solves the problem described above. -->
I would like to install an application's dependencies into a target directory and then generate a `requirements.txt` based on the installed packages. At the moment I can only do this if I install the dependencies with `--user`, inside a virtualenv or by globally installing them. 

**Alternative Solutions**
<!-- Have you tried to workaround the problem using pip or other tools? Or a different approach to solving this issue? Please elaborate here. -->
As far as I know there is no alternative solution to resolve the transitive dependencies and generate a `requirements.txt` based on a target installation directory.

**Additional context**
<!-- Add any other context, links, etc. about the feature here. -->

Hints from issue discussion:

Commit message: Add support for --path in pip freeze

Patch to review:
diff --git a/news/6404.feature b/news/6404.feature
new file mode 100644
index 00000000000..f2f1db58bd1
--- /dev/null
+++ b/news/6404.feature
@@ -0,0 +1,2 @@
+Support ``--target`` installations with ``pip freeze`` with the ``--path``
+argument.
diff --git a/src/pip/_internal/commands/freeze.py b/src/pip/_internal/commands/freeze.py
index dc9c53a6b5d..90c246aaa13 100644
--- a/src/pip/_internal/commands/freeze.py
+++ b/src/pip/_internal/commands/freeze.py
@@ -4,6 +4,7 @@
 
 from pip._internal.cache import WheelCache
 from pip._internal.cli.base_command import Command
+from pip._internal.exceptions import CommandError
 from pip._internal.models.format_control import FormatControl
 from pip._internal.operations.freeze import freeze
 from pip._internal.utils.compat import stdlib_pkgs
@@ -56,6 +57,11 @@ def __init__(self, *args, **kw):
             action='store_true',
             default=False,
             help='Only output packages installed in user-site.')
+        self.cmd_opts.add_option(
+            '--path',
+            dest='path',
+            action='append',
+            help='Use the specified installation path for listing packages')
         self.cmd_opts.add_option(
             '--all',
             dest='freeze_all',
@@ -77,11 +83,17 @@ def run(self, options, args):
         if not options.freeze_all:
             skip.update(DEV_PKGS)
 
+        if options.path and (options.user or options.local):
+            raise CommandError(
+                "Cannot combine '--path' with '--user' or '--local'"
+            )
+
         freeze_kwargs = dict(
             requirement=options.requirements,
             find_links=options.find_links,
             local_only=options.local,
             user_only=options.user,
+            paths=options.path,
             skip_regex=options.skip_requirements_regex,
             isolated=options.isolated_mode,
             wheel_cache=wheel_cache,
diff --git a/src/pip/_internal/operations/freeze.py b/src/pip/_internal/operations/freeze.py
index 0c4c76107c9..bfb45a0835e 100644
--- a/src/pip/_internal/operations/freeze.py
+++ b/src/pip/_internal/operations/freeze.py
@@ -39,6 +39,7 @@ def freeze(
     find_links=None,  # type: Optional[List[str]]
     local_only=None,  # type: Optional[bool]
     user_only=None,  # type: Optional[bool]
+    paths=None,  # type: Optional[List[str]]
     skip_regex=None,  # type: Optional[str]
     isolated=False,  # type: bool
     wheel_cache=None,  # type: Optional[WheelCache]
@@ -57,7 +58,8 @@ def freeze(
     installations = {}  # type: Dict[str, FrozenRequirement]
     for dist in get_installed_distributions(local_only=local_only,
                                             skip=(),
-                                            user_only=user_only):
+                                            user_only=user_only,
+                                            paths=paths):
         try:
             req = FrozenRequirement.from_dist(dist)
         except RequirementParseError:
diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py
index ca7a529387c..fb16795531e 100644
--- a/src/pip/_internal/utils/misc.py
+++ b/src/pip/_internal/utils/misc.py
@@ -369,12 +369,15 @@ def dist_is_editable(dist):
     return False
 
 
-def get_installed_distributions(local_only=True,
-                                skip=stdlib_pkgs,
-                                include_editables=True,
-                                editables_only=False,
-                                user_only=False):
-    # type: (bool, Container[str], bool, bool, bool) -> List[Distribution]
+def get_installed_distributions(
+        local_only=True,  # type: bool
+        skip=stdlib_pkgs,  # type: Container[str]
+        include_editables=True,  # type: bool
+        editables_only=False,  # type: bool
+        user_only=False,  # type: bool
+        paths=None  # type: Optional[List[str]]
+):
+    # type: (...) -> List[Distribution]
     """
     Return a list of installed Distribution objects.
 
@@ -391,7 +394,14 @@ def get_installed_distributions(local_only=True,
     If ``user_only`` is True , only report installations in the user
     site directory.
 
+    If ``paths`` is set, only report the distributions present at the
+    specified list of locations.
     """
+    if paths:
+        working_set = pkg_resources.WorkingSet(paths)
+    else:
+        working_set = pkg_resources.working_set
+
     if local_only:
         local_test = dist_is_local
     else:
@@ -419,7 +429,7 @@ def user_test(d):
             return True
 
     # because of pkg_resources vendoring, mypy cannot find stub in typeshed
-    return [d for d in pkg_resources.working_set  # type: ignore
+    return [d for d in working_set  # type: ignore
             if local_test(d) and
             d.key not in skip and
             editable_test(d) and
diff --git a/tests/functional/test_freeze.py b/tests/functional/test_freeze.py
index 735d71f4ebc..f5422483655 100644
--- a/tests/functional/test_freeze.py
+++ b/tests/functional/test_freeze.py
@@ -652,3 +652,52 @@ def test_freeze_user(script, virtualenv, data):
         <BLANKLINE>""")
     _check_output(result.stdout, expected)
     assert 'simple2' not in result.stdout
+
+
+def test_freeze_path(tmpdir, script, data):
+    """
+    Test freeze with --path.
+    """
+    script.pip('install', '--find-links', data.find_links,
+               '--target', tmpdir, 'simple==2.0')
+    result = script.pip('freeze', '--path', tmpdir)
+    expected = textwrap.dedent("""\
+        simple==2.0
+        <BLANKLINE>""")
+    _check_output(result.stdout, expected)
+
+
+def test_freeze_path_exclude_user(tmpdir, script, data):
+    """
+    Test freeze with --path and make sure packages from --user are not picked
+    up.
+    """
+    script.pip_install_local('--find-links', data.find_links,
+                             '--user', 'simple2')
+    script.pip('install', '--find-links', data.find_links,
+               '--target', tmpdir, 'simple==2.0')
+    result = script.pip('freeze', '--path', tmpdir)
+    expected = textwrap.dedent("""\
+        simple==2.0
+        <BLANKLINE>""")
+    _check_output(result.stdout, expected)
+
+
+def test_freeze_path_multiple(tmpdir, script, data):
+    """
+    Test freeze with multiple --path arguments.
+    """
+    path1 = tmpdir / "path1"
+    os.mkdir(path1)
+    path2 = tmpdir / "path2"
+    os.mkdir(path2)
+    script.pip('install', '--find-links', data.find_links,
+               '--target', path1, 'simple==2.0')
+    script.pip('install', '--find-links', data.find_links,
+               '--target', path2, 'simple2==3.0')
+    result = script.pip('freeze', '--path', path1, '--path', path2)
+    expected = textwrap.dedent("""\
+        simple==2.0
+        simple2==3.0
+        <BLANKLINE>""")
+    _check_output(result.stdout, expected)


Code review comments:
diff_hunk: @@ -56,6 +57,11 @@ def __init__(self, *args, **kw):
             action='store_true',
             default=False,
             help='Only output packages installed in user-site.')
+        self.cmd_opts.add_option(
+            '--path',
+            dest='path',
+            action='append',
+            help='Use the specified installation path for listing packages')
path: src/pip/_internal/commands/freeze.py
text: @user1:
I would end this with "(can be used multiple times)", and then end with a period. I also think it might be clearer to begin the sentence with "Restrict to the ..." or "Use only ..." to make clear that these paths aren't being considered in addition to what is already considered.