Repository: dhatim/python-license-check

Issue:
Add usage mode to support not resolving dependencies
I am conceptually trying to use liccheck in a CI job as follows:
```
pipenv lock -r > requirements.txt
liccheck -r requirements.txt
```

However this fails due to #42 because liccheck tries to resolve all dependencies but they have not actually been installed in any virtualenv. It isn't necessary to resolve dependencies here because the requirements file was generated from the lockfile and has pinned dependencies that have already been resolved. Installing a bunch of dependencies is not desired for what should be a lightweight CI job.

Is it possible to add a usage mode to support this, or does liccheck require all the dependencies to be installed?

Discussion:

Patch:
diff --git a/liccheck/command_line.py b/liccheck/command_line.py
index de01c91..909725b 100644
--- a/liccheck/command_line.py
+++ b/liccheck/command_line.py
@@ -1,7 +1,7 @@
 import argparse
 import collections
 
-from liccheck.requirements import parse_requirements
+from liccheck.requirements import parse_requirements, resolve, resolve_without_deps
 
 try:
     from configparser import ConfigParser, NoOptionError
@@ -108,7 +108,7 @@ class Reason(enum.Enum):
     UNKNOWN = 'UNKNOWN'
 
 
-def get_packages_info(requirement_file):
+def get_packages_info(requirement_file, no_deps=False):
     regex_license = re.compile(r'License: (?P<license>[^\r\n]+)\r?\n')
     regex_classifier = re.compile(r'Classifier: License :: OSI Approved :: (?P<classifier>[^\r\n]+)\r?\n')
 
@@ -149,7 +149,8 @@ def get_packages_info(requirement_file):
             return license[:-len(" license")]
         return license
 
-    packages = [transform(dist) for dist in pkg_resources.working_set.resolve(requirements)]
+    resolve_func = resolve_without_deps if no_deps else resolve
+    packages = [transform(dist) for dist in resolve_func(requirements)]
     # keep only unique values as there are maybe some duplicates
     unique = []
     [unique.append(item) for item in packages if item not in unique]
@@ -204,18 +205,23 @@ def find_parents(package, all, seen):
     return dependency_trees
 
 
-def write_package(package, all):
-    dependency_branches = find_parents(package['name'], all, set())
+def write_package(package, all, no_deps=False):
     licenses = package['licenses'] or 'UNKNOWN'
     print('    {} ({}): {}'.format(package['name'], package['version'], licenses))
+    if not no_deps:
+        write_deps(package, all)
+
+
+def write_deps(package, all):
+    dependency_branches = find_parents(package['name'], all, set())
     print('      dependenc{}:'.format('y' if len(dependency_branches) <= 1 else 'ies'))
     for dependency_branch in dependency_branches:
         print('          {}'.format(dependency_branch))
 
 
-def write_packages(packages, all):
+def write_packages(packages, all, no_deps=False):
     for package in packages:
-        write_package(package, all)
+        write_package(package, all, no_deps)
 
 
 def group_by(items, key):
@@ -226,11 +232,12 @@ def group_by(items, key):
     return res
 
 
-def process(requirement_file, strategy, level=Level.STANDARD, reporting_file=None):
+def process(requirement_file, strategy, level=Level.STANDARD, reporting_file=None, no_deps=False):
     print('gathering licenses...')
-    pkg_info = get_packages_info(requirement_file)
+    pkg_info = get_packages_info(requirement_file, no_deps)
     all = list(pkg_info)
-    print('{} package{} and dependencies.'.format(len(pkg_info), '' if len(pkg_info) <= 1 else 's'))
+    deps_mention = '' if no_deps else ' and dependencies'
+    print('{} package{}{}.'.format(len(pkg_info), '' if len(pkg_info) <= 1 else 's', deps_mention))
     groups = group_by(
         pkg_info, functools.partial(check_package, strategy, level=level))
     ret = 0
@@ -261,13 +268,13 @@ def process(requirement_file, strategy, level=Level.STANDARD, reporting_file=Non
     if groups[Reason.UNAUTHORIZED]:
         print('check unauthorized packages...')
         print(format(groups[Reason.UNAUTHORIZED]))
-        write_packages(groups[Reason.UNAUTHORIZED], all)
+        write_packages(groups[Reason.UNAUTHORIZED], all, no_deps)
         ret = -1
 
     if groups[Reason.UNKNOWN]:
         print('check unknown packages...')
         print(format(groups[Reason.UNKNOWN]))
-        write_packages(groups[Reason.UNKNOWN], all)
+        write_packages(groups[Reason.UNKNOWN], all, no_deps)
         ret = -1
 
     return ret
@@ -308,12 +315,16 @@ def parse_args(args):
         '-R', '--reporting', dest='reporting_txt_file',
         help='path/to/reporting.txt file', nargs='?',
         default=None)
+    parser.add_argument(
+        '--no-deps', dest='no_deps',
+        help="don't check dependencies", action='store_true')
+
     return parser.parse_args(args)
 
 
 def run(args):
     strategy = read_strategy(args.strategy_ini_file)
-    return process(args.requirement_txt_file, strategy, args.level, args.reporting_txt_file)
+    return process(args.requirement_txt_file, strategy, args.level, args.reporting_txt_file, args.no_deps)
 
 
 def main():
diff --git a/liccheck/requirements.py b/liccheck/requirements.py
index 7f35550..887c6ab 100644
--- a/liccheck/requirements.py
+++ b/liccheck/requirements.py
@@ -28,3 +28,20 @@ def parse_requirements(requirement_file):
             continue
         requirements.append(pkg_resources.Requirement.parse(str(install_req.req)))
     return requirements
+
+
+def resolve_without_deps(requirements):
+    working_set = pkg_resources.working_set
+    for req in requirements:
+        env = pkg_resources.Environment(working_set.entries)
+        dist = env.best_match(
+            req=req,
+            working_set=working_set,
+            installer=None,
+            replace_conflicting=False,
+        )
+        yield dist
+
+
+def resolve(requirements):
+    yield from pkg_resources.working_set.resolve(requirements)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b3eee4d..a6fbfed 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,15 +1,22 @@
 from liccheck.command_line import parse_args, read_strategy, run, Level
-
+import textwrap
 
 def test_parse_arguments():
     args = parse_args(['--sfile', 'my_strategy.ini'])
     assert args.strategy_ini_file == 'my_strategy.ini'
     assert args.requirement_txt_file == './requirements.txt'
     assert args.level is Level.STANDARD
+    assert args.no_deps is False
     args = parse_args(['--sfile', 'my_strategy.ini', '--rfile', 'my_requirements.txt', '--level', 'cautious'])
     assert args.strategy_ini_file == 'my_strategy.ini'
     assert args.requirement_txt_file == 'my_requirements.txt'
     assert args.level is Level.CAUTIOUS
+    assert args.no_deps is False
+    args = parse_args(['--sfile', 'my_strategy.ini', '--rfile', 'my_requirements.txt', '--level', 'cautious', '--no-deps'])
+    assert args.strategy_ini_file == 'my_strategy.ini'
+    assert args.requirement_txt_file == 'my_requirements.txt'
+    assert args.level is Level.CAUTIOUS
+    assert args.no_deps is True
 
 
 def test_read_strategy():
@@ -20,6 +27,31 @@ def test_read_strategy():
     assert len(strategy.UNAUTHORIZED_LICENSES) > 0
 
 
-def test_run():
+def test_run(capsys):
     args = parse_args(['--sfile', 'license_strategy.ini', '--rfile', 'requirements.txt'])
     run(args)
+    captured = capsys.readouterr().out
+    expected = textwrap.dedent(
+        '''\
+        gathering licenses...
+        3 packages and dependencies.
+        check authorized packages...
+        3 packages.
+        '''
+    )
+    assert captured == expected
+
+
+def test_run_without_deps(capsys):
+    args = parse_args(['--sfile', 'license_strategy.ini', '--rfile', 'requirements.txt', '--no-deps'])
+    run(args)
+    captured = capsys.readouterr().out
+    expected = textwrap.dedent(
+        '''\
+        gathering licenses...
+        3 packages.
+        check authorized packages...
+        3 packages.
+        '''
+    )
+    assert captured == expected
diff --git a/tests/test_get_packages_info.py b/tests/test_get_packages_info.py
index a493494..ad73dae 100644
--- a/tests/test_get_packages_info.py
+++ b/tests/test_get_packages_info.py
@@ -22,3 +22,18 @@ def test_requirements_markers(tmpfile):
         assert len(get_packages_info(tmppath)) == 2
     else:
         assert len(get_packages_info(tmppath)) == 1
+
+
+@pytest.mark.parametrize(
+    ('no_deps', 'expected_packages'), (
+        pytest.param(False, ('configparser', 'liccheck', 'semantic-version', 'toml'), id='with deps'),
+        pytest.param(True, ('liccheck',), id='without deps'),
+    )
+)
+def test_deps(tmpfile, no_deps, expected_packages):
+    tmpfh, tmppath = tmpfile
+    tmpfh.write('liccheck\n')
+    tmpfh.close()
+    packages_info = get_packages_info(tmppath, no_deps)
+    packages = tuple(package['name'] for package in packages_info)
+    assert packages == expected_packages
diff --git a/tests/test_write_packages.py b/tests/test_write_packages.py
index ba2be6c..0183356 100644
--- a/tests/test_write_packages.py
+++ b/tests/test_write_packages.py
@@ -38,3 +38,19 @@ def test_write_packages_with_cyclic_dependencies(capsys):
           fixtures << testtools << fixtures
 '''
     assert captured == expected
+
+
+def test_write_packages_without_deps(capsys):
+    packages = [
+        {'name': 'functools32', 'version': '3.2.3-2', 'location': 'path',
+         'dependencies': [], 'licenses': ['PSF license']},
+        {'name': 'jsonschema', 'version': '2.6.0', 'location': 'path',
+         'dependencies': ['functools32'], 'licenses': ['Apache2']},
+        {'name': 'os-faults', 'version': '0.2.0', 'location': 'path',
+         'dependencies': ['jsonschema'], 'licenses': ['Apache2']}]
+
+    write_packages([packages[0]], packages, no_deps=True)
+
+    captured = capsys.readouterr().out
+    expected = "    functools32 (3.2.3-2): ['PSF license']\n"
+    assert captured == expected