282 lines
9.2 KiB
Python
282 lines
9.2 KiB
Python
from __future__ import print_function
|
|
import sys
|
|
from itertools import chain, tee
|
|
from collections import defaultdict
|
|
import argparse
|
|
|
|
import pip
|
|
|
|
|
|
__version__ = '0.4.3'
|
|
|
|
|
|
flatten = chain.from_iterable
|
|
|
|
|
|
def req_version(req):
|
|
"""Builds the version string for the requirement instance
|
|
|
|
:param req: requirement object
|
|
:returns: the version in desired format
|
|
:rtype: string or NoneType
|
|
|
|
"""
|
|
return ''.join(req.specs[0]) if req.specs else None
|
|
|
|
|
|
def top_pkg_name(pkg):
|
|
"""Builds the package name for top level package
|
|
|
|
This just prints the name and the version of the package which may
|
|
not necessarily match with the output of `pip freeze` which also
|
|
includes info such as VCS source for editable packages
|
|
|
|
:param pkg: pkg_resources.Distribution
|
|
:returns: the package name and version in the desired format
|
|
:rtype: string
|
|
|
|
"""
|
|
return '{0}=={1}'.format(pkg.project_name, pkg.version)
|
|
|
|
|
|
def non_top_pkg_name(req, pkg):
|
|
"""Builds the package name for a non-top level package
|
|
|
|
For the dependencies of the top level packages, the installed
|
|
version as well as the version required by it's parent package
|
|
will be specified with it's name
|
|
|
|
:param req: requirements instance
|
|
:param pkg: pkg_resources.Distribution
|
|
:returns: the package name and version in the desired format
|
|
:rtype: string
|
|
|
|
"""
|
|
vers = []
|
|
req_ver = req_version(req)
|
|
if req_ver:
|
|
vers.append(('required', req_ver))
|
|
if pkg:
|
|
vers.append(('installed', pkg.version))
|
|
if not vers:
|
|
return req.key
|
|
ver_str = ', '.join(['{0}: {1}'.format(k, v) for k, v in vers])
|
|
return '{0} [{1}]'.format(pkg.project_name, ver_str)
|
|
|
|
|
|
def top_pkg_src(pkg):
|
|
"""Returns the frozen package name
|
|
|
|
The may or may not be the same as the package name.
|
|
|
|
:param pkg: pkg_resources.Distribution
|
|
:returns: frozen name of the package
|
|
:rtype: string
|
|
|
|
"""
|
|
return str(pip.FrozenRequirement.from_dist(pkg, [])).strip()
|
|
|
|
|
|
def non_top_pkg_src(_req, pkg):
|
|
"""Returns frozen package name for non top level package
|
|
|
|
:param _req: the requirements instance
|
|
:param pkg: pkg_resources.Distribution
|
|
:returns: frozen name of the package
|
|
:rtype: string
|
|
|
|
"""
|
|
return top_pkg_src(pkg)
|
|
|
|
|
|
def has_multi_versions(reqs):
|
|
vers = (req_version(r) for r in reqs)
|
|
return len(set(vers)) > 1
|
|
|
|
|
|
def confusing_deps(req_map):
|
|
"""Returns group of dependencies that are possibly confusing
|
|
|
|
eg. if pkg1 requires pkg3>=1.0 and pkg2 requires pkg3>=1.0,<=2.0
|
|
|
|
:param dict req_map: mapping of pkgs with the list of their deps
|
|
:returns: groups of dependencies paired with their top level pkgs
|
|
:rtype: list of list of pairs
|
|
|
|
"""
|
|
deps = defaultdict(list)
|
|
for p, rs in req_map.items():
|
|
for r in rs:
|
|
deps[r.key].append((p, r))
|
|
return [ps for r, ps in deps.items()
|
|
if len(ps) > 1
|
|
and has_multi_versions(d for p, d in ps)]
|
|
|
|
|
|
def render_tree(pkgs, pkg_index, req_map, list_all,
|
|
top_pkg_str, non_top_pkg_str, bullets=True):
|
|
"""Renders a package dependency tree as a string
|
|
|
|
:param list pkgs: pkg_resources.Distribution instances
|
|
:param dict pkg_index: mapping of pkgs with their respective keys
|
|
:param dict req_map: mapping of pkgs with the list of their deps
|
|
:param bool list_all: whether to show globally installed pkgs
|
|
if inside a virtualenv with global access
|
|
:param function top_pkg_str: function to render a top level
|
|
package as string
|
|
:param function non_top_pkg_str: function to render a non-top
|
|
level package as string
|
|
:param bool bullets: whether or not to show bullets for child
|
|
dependencies [default: True]
|
|
:returns: dependency tree encoded as string
|
|
:rtype: str
|
|
|
|
"""
|
|
non_top = set(r.key for r in flatten(req_map.values()))
|
|
top = [p for p in pkgs if p.key not in non_top]
|
|
|
|
def aux(pkg, indent=0, chain=None):
|
|
if chain is None:
|
|
chain = [pkg.project_name]
|
|
|
|
# In this function, pkg can either be a Distribution or
|
|
# Requirement instance
|
|
if indent > 0:
|
|
# this is definitely a Requirement (due to positive
|
|
# indent) so we need to find the Distribution instance for
|
|
# it from the pkg_index
|
|
dist = pkg_index.get(pkg.key)
|
|
# FixMe! Some dependencies are not present in the result of
|
|
# `pip.get_installed_distributions`
|
|
# eg. `testresources`. This is a hack around it.
|
|
name = pkg.project_name if dist is None else non_top_pkg_str(pkg, dist)
|
|
result = [' '*indent + ('-' if bullets else ' ') + ' ' + name]
|
|
else:
|
|
result = [top_pkg_str(pkg)]
|
|
|
|
# FixMe! in case of some pkg not present in list of all
|
|
# packages, eg. `testresources`, this will fail
|
|
if pkg.key in pkg_index:
|
|
pkg_deps = pkg_index[pkg.key].requires()
|
|
filtered_deps = [
|
|
aux(d, indent=indent+2, chain=chain+[d.project_name])
|
|
for d in pkg_deps
|
|
if d.project_name not in chain]
|
|
result += list(flatten(filtered_deps))
|
|
return result
|
|
|
|
lines = flatten([aux(p) for p in (pkgs if list_all else top)])
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def cyclic_deps(pkgs, pkg_index):
|
|
"""Generator that produces cyclic dependencies
|
|
|
|
:param list pkgs: pkg_resources.Distribution instances
|
|
:param dict pkg_index: mapping of pkgs with their respective keys
|
|
:returns: generator that yields str representation of cyclic
|
|
dependencies
|
|
:rtype: generator
|
|
|
|
"""
|
|
def aux(pkg, chain):
|
|
if pkg.key in pkg_index:
|
|
for d in pkg_index[pkg.key].requires():
|
|
if d.project_name in chain:
|
|
yield ' => '.join([str(p) for p in chain] + [str(d)])
|
|
else:
|
|
for cycle in aux(d, chain=chain+[d.project_name]):
|
|
yield cycle
|
|
|
|
for cycle in flatten([aux(p, chain=[]) for p in pkgs]):
|
|
yield cycle
|
|
|
|
|
|
def peek_into(iterator):
|
|
"""Peeks into an iterator to check if it's empty
|
|
|
|
:param iterator: an iterator
|
|
:returns: tuple of boolean representing whether the iterator is
|
|
empty or not and the iterator itself.
|
|
:rtype: tuple
|
|
|
|
"""
|
|
a, b = tee(iterator)
|
|
is_empty = False
|
|
try:
|
|
next(a)
|
|
except StopIteration:
|
|
is_empty = True
|
|
return is_empty, b
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=(
|
|
'Dependency tree of the installed python packages'
|
|
))
|
|
parser.add_argument('-f', '--freeze', action='store_true',
|
|
help='Print names so as to write freeze files')
|
|
parser.add_argument('-a', '--all', action='store_true',
|
|
help='list all deps at top level')
|
|
parser.add_argument('-l', '--local-only',
|
|
action='store_true', help=(
|
|
'If in a virtualenv that has global access '
|
|
'donot show globally installed packages'
|
|
))
|
|
parser.add_argument('-w', '--nowarn', action='store_true',
|
|
help=(
|
|
'Inhibit warnings about possibly '
|
|
'confusing packages'
|
|
))
|
|
args = parser.parse_args()
|
|
|
|
default_skip = ['setuptools', 'pip', 'python', 'distribute']
|
|
skip = default_skip + ['pipdeptree']
|
|
pkgs = pip.get_installed_distributions(local_only=args.local_only,
|
|
skip=skip)
|
|
|
|
pkg_index = dict((p.key, p) for p in pkgs)
|
|
req_map = dict((p, p.requires()) for p in pkgs)
|
|
|
|
# show warnings about possibly confusing deps if found and
|
|
# warnings are enabled
|
|
if not args.nowarn:
|
|
confusing = confusing_deps(req_map)
|
|
if confusing:
|
|
print('Warning!!! Possible confusing dependencies found:', file=sys.stderr)
|
|
for xs in confusing:
|
|
for i, (p, d) in enumerate(xs):
|
|
if d.key in skip:
|
|
continue
|
|
pkg = top_pkg_name(p)
|
|
req = non_top_pkg_name(d, pkg_index[d.key])
|
|
tmpl = ' {0} -> {1}' if i > 0 else '* {0} -> {1}'
|
|
print(tmpl.format(pkg, req), file=sys.stderr)
|
|
print('-'*72, file=sys.stderr)
|
|
|
|
is_empty, cyclic = peek_into(cyclic_deps(pkgs, pkg_index))
|
|
if not is_empty:
|
|
print('Warning!!! Cyclic dependencies found:', file=sys.stderr)
|
|
for xs in cyclic:
|
|
print('- {0}'.format(xs), file=sys.stderr)
|
|
print('-'*72, file=sys.stderr)
|
|
|
|
if args.freeze:
|
|
top_pkg_str, non_top_pkg_str = top_pkg_src, non_top_pkg_src
|
|
else:
|
|
top_pkg_str, non_top_pkg_str = top_pkg_name, non_top_pkg_name
|
|
|
|
tree = render_tree(pkgs,
|
|
pkg_index=pkg_index,
|
|
req_map=req_map,
|
|
list_all=args.all,
|
|
top_pkg_str=top_pkg_str,
|
|
non_top_pkg_str=non_top_pkg_str,
|
|
bullets=not args.freeze)
|
|
print(tree)
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|