Repository: raimon49/pip-licenses

Issue:
Retrieve licenses from other environment
Somehow related to #107 

Currently, we can only get packages from the environments we are running pip-licenses on. This is a problem for two reasons

- As said in mentioned issue, we cannot use it as a pre-commit hook easily. There is the possibility to use a hook within the activated environment, but it's clearly not following its guidelines
- Using this libraries implies installing it in your venv, and thus contaminating it by installing packages you wouldn't need otherwise. It's no great given the purpose of this lib is to identify licenses of installed packages

I think there is a simple fix to this.

Indeed, thepckages are discovered via `importlib` `distributions()` function. As it can be seen here : https://github.com/raimon49/pip-licenses/blob/master/piplicenses.py#L193

By looking at the documentation and code of importlib, it seems to me that `distributions` should accept a `path`  argument with a list of folders to search into.
See
https://importlib-metadata.readthedocs.io/en/latest/api.html#importlib_metadata.distributions

and https://github.com/python/importlib_metadata/blob/700f2c7d74543e3695163d5487155b92e6f04d65/importlib_metadata/__init__.py#L809
 
`distributions(**kwargs)` calls `Distribution.discover(**kwargs)` which expects either a `Context` object or its args for creating one (see https://github.com/python/importlib_metadata/blob/700f2c7d74543e3695163d5487155b92e6f04d65/importlib_metadata/__init__.py#L387 )

All in all, calling `distributions([folder_path])` will retrieve all packages metadata in the given `folder_path` which could be a CLI parameter.

What do you think ?



Comments:

Patch:
diff --git a/README.md b/README.md
index eeeb287..7517b50 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ Dump the software license list of Python packages installed with pip.
 * [Usage](#usage)
 * [Command\-Line Options](#command-line-options)
     * [Common options](#common-options)
+        * [Option: python](#option-python)
         * [Option: from](#option-from)
         * [Option: order](#option-order)
         * [Option: format](#option-format)
@@ -97,6 +98,21 @@ Execute the command with your venv (or virtualenv) environment.
 
 ### Common options
 
+#### Option: python
+
+By default, this tools finds the packages from the environment pip-licenses is launched from, by searching in current python's `sys.path` folders. In the case you want to search for packages in an other environment (e.g. if you want to run pip-licenses from its own isolated environment), you can specify a path to a python executable. The packages will be searched for in the given python's `sys.path`, free of pip-licenses dependencies.
+
+```bash
+(venv) $ pip-licenses --with-system | grep pip
+ pip                       22.3.1       MIT License
+ pip-licenses              4.1.0        MIT License
+```
+
+```bash
+(venv) $ pip-licenses --python=</path/to/other/env>/bin/python --with-system | grep pip
+ pip                       23.0.1       MIT License 
+```
+
 #### Option: from
 
 By default, this tool finds the license from [Trove Classifiers](https://pypi.org/classifiers/) or package Metadata. Some Python packages declare their license only in Trove Classifiers.
diff --git a/piplicenses.py b/piplicenses.py
index 3bac56e..488c338 100644
--- a/piplicenses.py
+++ b/piplicenses.py
@@ -30,7 +30,9 @@ from __future__ import annotations
 
 import argparse
 import codecs
+import os
 import re
+import subprocess
 import sys
 from collections import Counter
 from enum import Enum, auto
@@ -194,7 +196,21 @@ def get_packages(
 
         return pkg_info
 
-    pkgs = importlib_metadata.distributions()
+    def get_python_sys_path(executable: str) -> list[str]:
+        script = "import sys; print(' '.join(filter(bool, sys.path)))"
+        output = subprocess.run(
+            [executable, "-c", script],
+            capture_output=True,
+            env={**os.environ, "PYTHONPATH": "", "VIRTUAL_ENV": ""},
+        )
+        return output.stdout.decode().strip().split()
+
+    if args.python == sys.executable:
+        search_paths = sys.path
+    else:
+        search_paths = get_python_sys_path(args.python)
+
+    pkgs = importlib_metadata.distributions(path=search_paths)
     ignore_pkgs_as_lower = [pkg.lower() for pkg in args.ignore_packages]
     pkgs_as_lower = [pkg.lower() for pkg in args.packages]
 
@@ -785,6 +801,17 @@ def create_parser() -> CompatibleArgumentParser:
         "-v", "--version", action="version", version="%(prog)s " + __version__
     )
 
+    common_options.add_argument(
+        "--python",
+        type=str,
+        default=sys.executable,
+        metavar="PYTHON_EXEC",
+        help="R| path to python executable to search distributions from\n"
+        "Package will be searched in the selected python's sys.path\n"
+        "By default, will search packages for current env executable\n"
+        "(default: sys.executable)",
+    )
+
     common_options.add_argument(
         "--from",
         dest="from_",


Test patch:
diff --git a/test_piplicenses.py b/test_piplicenses.py
index f470377..426f31a 100644
--- a/test_piplicenses.py
+++ b/test_piplicenses.py
@@ -4,11 +4,14 @@ from __future__ import annotations
 
 import copy
 import email
+import json
 import re
 import sys
 import unittest
+import venv
 from enum import Enum, auto
 from importlib.metadata import Distribution
+from types import SimpleNamespace
 from typing import TYPE_CHECKING, Any, List
 
 import docutils.frontend
@@ -39,6 +42,7 @@ from piplicenses import (
     factory_styled_table_with_args,
     find_license_from_classifier,
     get_output_fields,
+    get_packages,
     get_sortby,
     output_colored,
     save_if_needs,
@@ -780,6 +784,26 @@ def test_allow_only(monkeypatch) -> None:
     )
 
 
+def test_different_python() -> None:
+    import tempfile
+
+    class TempEnvBuild(venv.EnvBuilder):
+        def post_setup(self, context: SimpleNamespace) -> None:
+            self.context = context
+
+    with tempfile.TemporaryDirectory() as target_dir_path:
+        venv_builder = TempEnvBuild(with_pip=True)
+        venv_builder.create(str(target_dir_path))
+        python_exec = venv_builder.context.env_exe
+        python_arg = f"--python={python_exec}"
+        args = create_parser().parse_args([python_arg, "-s", "-f=json"])
+        pkgs = get_packages(args)
+        package_names = sorted(p["name"] for p in pkgs)
+        print(package_names)
+
+    assert package_names == ["pip", "setuptools"]
+
+
 def test_fail_on(monkeypatch) -> None:
     licenses = ("MIT license",)
     allow_only_args = ["--fail-on={}".format(";".join(licenses))]