pipdeptree/pipdeptree.py

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())