123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- import io
- import sys
- import os
- import json
- import re
- import argparse
- import utils
- import requirements
- import pipfile
- import setup_file
- import codecs
- from operator import le, lt, gt, ge, eq, ne
- try:
- import pkg_resources
- except ImportError:
- # try using the version vendored by pip
- try:
- import pip._vendor.pkg_resources as pkg_resources
- except ImportError:
- raise ImportError(
- "Could not import pkg_resources; please install setuptools or pip.")
- PYTHON_MARKER_REGEX = re.compile(r'python_version\s*(?P<operator>==|<=|>=|>|<)\s*[\'"](?P<python_version>.+?)[\'"]')
- SYSTEM_MARKER_REGEX = re.compile(r'sys_platform\s*==\s*[\'"](.+)[\'"]')
- def format_provenance_label(prov_tuple):
- fn, ln1, ln2 = prov_tuple
- if ln1 == ln2:
- return fn + ':' + str(ln1)
- else:
- return fn + ':' + str(ln1) + '-' + str(ln2)
- def create_tree_of_packages_dependencies(
- dist_tree,
- top_level_requirements,
- req_file_path,
- allow_missing=False,
- only_provenance=False
- ):
- """Create packages dependencies tree
- :param dict tree: the package tree
- :param set packages_names: set of select packages to be shown in the output.
- :param req_file_path: the path to the dependencies file
- (e.g. requirements.txt)
- :rtype: dict
- """
- DEPENDENCIES = 'dependencies'
- VERSION = 'version'
- NAME = 'name'
- DIR_VERSION = '0.0.0'
- PACKAGE_FORMAT_VERSION = 'packageFormatVersion'
- LABELS = 'labels'
- PROVENANCE = 'provenance'
- tree = utils.sorted_tree(dist_tree)
- nodes = tree.keys()
- key_tree = dict((k.key, v) for k, v in tree.items())
- lowercase_pkgs_names = [p.name.lower() for p in top_level_requirements]
- tlr_by_key = dict((tlr.name.lower(), tlr) for tlr in top_level_requirements)
- packages_as_dist_obj = [
- p for p in nodes if
- p.key.lower() in lowercase_pkgs_names or
- (p.project_name and p.project_name.lower()) in lowercase_pkgs_names]
- def create_children_recursive(root_package, key_tree, ancestors, all_packages_map):
- root_name = root_package[NAME].lower()
- if root_name not in key_tree:
- msg = 'Required packages missing: ' + root_name
- if allow_missing:
- sys.stderr.write(msg + "\n")
- return
- else:
- sys.exit(msg)
- ancestors = ancestors.copy()
- ancestors.add(root_name)
- children_packages_as_dist = key_tree[root_name]
- for child_dist in children_packages_as_dist:
- child_project_name = child_dist.project_name.lower()
- if child_project_name in ancestors:
- continue
- if DEPENDENCIES not in root_package:
- root_package[DEPENDENCIES] = {}
- if child_project_name in root_package[DEPENDENCIES]:
- continue
- if child_project_name in all_packages_map and child_project_name not in root_package[DEPENDENCIES]:
- root_package[DEPENDENCIES][child_project_name] = 'true'
- continue
- child_package = {
- NAME: child_project_name,
- VERSION: child_dist.installed_version,
- }
- create_children_recursive(child_package, key_tree, ancestors, all_packages_map)
- root_package[DEPENDENCIES][child_project_name] = child_package
- all_packages_map[child_project_name] = 'true'
- return root_package
- def create_dir_as_root():
- name, version = None, None
- if os.path.basename(req_file_path) == 'setup.py':
- with open(req_file_path, "r") as setup_py_file:
- name, version = setup_file.parse_name_and_version(setup_py_file.read())
- dir_as_root = {
- NAME: name or os.path.basename(os.path.dirname(os.path.abspath(req_file_path))),
- VERSION: version or DIR_VERSION,
- DEPENDENCIES: {},
- PACKAGE_FORMAT_VERSION: 'pip:0.0.1'
- }
- return dir_as_root
- def create_package_as_root(package, dir_as_root):
- package_as_root = {
- NAME: package.project_name.lower(),
- # Note: _version is a private field.
- VERSION: package._obj._version,
- }
- return package_as_root
- dir_as_root = create_dir_as_root()
- all_packages_map = {}
- for package in packages_as_dist_obj:
- package_as_root = create_package_as_root(package, dir_as_root)
- if only_provenance:
- package_as_root[LABELS] = {PROVENANCE: format_provenance_label(tlr_by_key[package_as_root[NAME]].provenance)}
- dir_as_root[DEPENDENCIES][package_as_root[NAME]] = package_as_root
- else:
- package_tree = create_children_recursive(package_as_root, key_tree, set([]), all_packages_map)
- dir_as_root[DEPENDENCIES][package_as_root[NAME]] = package_tree
- return dir_as_root
- def satisfies_python_requirement(parsed_operator, py_version_str):
- # TODO: use python semver library to compare versions
- operator_func = {
- ">": gt,
- "==": eq,
- "<": lt,
- "<=": le,
- ">=": ge,
- '!=': ne,
- }[parsed_operator]
- system_py_version_tuple = (sys.version_info[0], sys.version_info[1])
- py_version_tuple = tuple(py_version_str.split('.')) # string tuple
- if py_version_tuple[-1] == '*':
- system_py_version_tuple = system_py_version_tuple[0]
- py_version_tuple = int(py_version_tuple[0]) # int tuple
- else:
- py_version_tuple = tuple(int(x) for x in py_version_tuple) # int tuple
- return operator_func(system_py_version_tuple, py_version_tuple)
- def get_markers_text(requirement):
- if isinstance(requirement, pipfile.PipfileRequirement):
- return requirement.markers
- return requirement.line
- def matches_python_version(requirement):
- """Filter out requirements that should not be installed
- in this Python version.
- See: https://www.python.org/dev/peps/pep-0508/#environment-markers
- """
- markers_text = get_markers_text(requirement)
- if not (markers_text and re.match(".*;.*python_version", markers_text)):
- return True
- cond_text = markers_text.split(";", 1)[1]
- # Gloss over the 'and' case and return true on the first matching python version
- for sub_exp in re.split("\s*(?:and|or)\s*", cond_text):
- match = PYTHON_MARKER_REGEX.search(sub_exp)
- if match:
- match_dict = match.groupdict()
- if len(match_dict) == 2 and satisfies_python_requirement(
- match_dict['operator'],
- match_dict['python_version']
- ):
- return True
- return False
- def matches_environment(requirement):
- """Filter out requirements that should not be installed
- in this environment. Only sys_platform is inspected right now.
- This should be expanded to include other environment markers.
- See: https://www.python.org/dev/peps/pep-0508/#environment-markers
- """
- sys_platform = sys.platform.lower()
- markers_text = get_markers_text(requirement)
- if markers_text and 'sys_platform' in markers_text:
- match = SYSTEM_MARKER_REGEX.findall(markers_text)
- if len(match) > 0:
- return match[0].lower() == sys_platform
- return True
- def is_testable(requirement):
- return requirement.editable == False and requirement.vcs is None
- def detect_encoding_by_bom(path):
- with open(path, 'rb') as f:
- raw = f.read(4) # will read less if the file is smaller
- # BOM_UTF32_LE's start is equal to BOM_UTF16_LE so need to try the former first
- for enc, boms in \
- ('utf-8-sig', (codecs.BOM_UTF8,)), \
- ('utf-32', (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)), \
- ('utf-16', (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
- if any(raw.startswith(bom) for bom in boms):
- return enc
- return None
- def get_requirements_list(requirements_file_path, dev_deps=False):
- # TODO: refactor recognizing the dependency manager to a single place
- if os.path.basename(requirements_file_path) == 'Pipfile':
- with io.open(requirements_file_path, 'r', encoding='utf-8') as f:
- requirements_data = f.read()
- parsed_reqs = pipfile.parse(requirements_data)
- req_list = list(parsed_reqs.get('packages', []))
- if dev_deps:
- req_list.extend(parsed_reqs.get('dev-packages', []))
- if not req_list:
- return []
- else:
- for r in req_list:
- r.provenance = (requirements_file_path, r.provenance[1], r.provenance[2])
- elif os.path.basename(requirements_file_path) == 'setup.py':
- with open(requirements_file_path, 'r') as f:
- setup_py_file_content = f.read()
- requirements_data = setup_file.parse_requirements(setup_py_file_content)
- req_list = list(requirements.parse(requirements_data))
- provenance = setup_file.get_provenance(setup_py_file_content)
- for req in req_list:
- req.provenance = (
- os.path.basename(requirements_file_path),
- provenance,
- provenance
- )
- else:
- # assume this is a requirements.txt formatted file
- # Note: requirements.txt files are unicode and can be in any encoding.
- encoding = detect_encoding_by_bom(requirements_file_path)
- with io.open(requirements_file_path, 'r', encoding=encoding) as f:
- req_list = list(requirements.parse(f))
- req_list = filter(matches_environment, req_list)
- req_list = filter(is_testable, req_list)
- req_list = filter(matches_python_version, req_list)
- req_list = [r for r in req_list if r.name]
- for req in req_list:
- req.name = req.name.lower().replace('_', '-')
- return req_list
- def canonicalize_package_name(name):
- # https://packaging.python.org/guides/distributing-packages-using-setuptools/#name
- name = name.lower().replace('-', '.').replace('_', '.')
- name = re.sub(r'\.+', '.', name)
- return name
- def create_dependencies_tree_by_req_file_path(requirements_file_path,
- allow_missing=False,
- dev_deps=False,
- only_provenance=False,
- allow_empty=False):
- # get all installed packages
- pkgs = list(pkg_resources.working_set)
- # get all installed packages's distribution object
- dist_index = utils.build_dist_index(pkgs)
- # get all installed distributions tree
- dist_tree = utils.construct_tree(dist_index)
- # create a list of dependencies from the dependencies file
- required = get_requirements_list(requirements_file_path, dev_deps=dev_deps)
- if not required and not allow_empty:
- msg = 'No dependencies detected in manifest.'
- sys.exit(msg)
- else:
- installed = [canonicalize_package_name(p) for p in dist_index]
- top_level_requirements = []
- missing_package_names = []
- for r in required:
- if canonicalize_package_name(r.name) not in installed:
- missing_package_names.append(r.name)
- else:
- top_level_requirements.append(r)
- if missing_package_names:
- msg = 'Required packages missing: ' + (', '.join(missing_package_names))
- if allow_missing:
- sys.stderr.write(msg + "\n")
- else:
- sys.exit(msg)
- # build a tree of dependencies
- package_tree = create_tree_of_packages_dependencies(
- dist_tree, top_level_requirements, requirements_file_path, allow_missing, only_provenance)
- print(json.dumps(package_tree))
- def main():
- """Builds the dependency tree from the manifest file (Pipfile or requirements.txt) and
- prints it as JSON. The tree nodes are:
- interface DepTree {
- name: string;
- version?: string;
- dependencies?: {[n: string]: DepTree};
- labels: { provenance?: string };
- }
- The `provenance` label only present for the top-level nodes, indicates the position of the dependency
- version in the original file and is in the format "filename:lineNum" or "filename:lineFrom-lineTo",
- where line numbers are 1-based.
- """
- parser = argparse.ArgumentParser()
- parser.add_argument("requirements",
- help="dependencies file path (requirements.txt or Pipfile)")
- parser.add_argument("--allow-missing",
- action="store_true",
- help="don't fail if some packages listed in the dependencies file " +
- "are not installed")
- parser.add_argument("--dev-deps",
- action="store_true",
- help="resolve dev dependencies")
- parser.add_argument("--only-provenance",
- action="store_true",
- help="only return top level deps with provenance information")
- parser.add_argument("--allow-empty",
- action="store_true",
- help="return empty dep tree instead of throwing")
- args = parser.parse_args()
- create_dependencies_tree_by_req_file_path(
- args.requirements,
- allow_missing=args.allow_missing,
- dev_deps=args.dev_deps,
- only_provenance=args.only_provenance,
- allow_empty=args.allow_empty,
- )
- if __name__ == '__main__':
- sys.exit(main())
|