#!/usr/bin/env python # # Find the build and runtime dependencies for a given Python package # # Copyright (C) 2014 Codethink Limited # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; version 2 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from __future__ import print_function import sys import subprocess import os import pkg_resources import xmlrpclib DEBUG = False # TODO: I'm guessing these things are probably standard somewhere def warn(*args, **kwargs): print('%s:' % sys.argv[0], *args, file=sys.stderr, **kwargs) def error(*args, **kwargs): warn(*args, **kwargs) sys.exit(1) def debug(s): if DEBUG: print(s) # TODO: this is copied from pip_lorry, it should be shared. def specs_satisfied(version, specs): def mapping_error(op): # We parse ops with requirements-parser, so any invalid user input # should be detected there. This really guards against # the pip developers adding some new operation to a requirement. error("Invalid op in spec: %s" % op) opmap = {'==' : lambda x, y: x == y, '!=' : lambda x, y: x != y, '<=' : lambda x, y: x <= y, '>=' : lambda x, y: x >= y, '<': lambda x, y: x < y, '>' : lambda x, y: x > y} def get_op_func(op): return opmap[op] if op in opmap else lambda x, y: mapping_error(op) return all([get_op_func(op)(version, sv) for (op, sv) in specs]) def conflict(specs, spec): # Return whether spec_x conflicts with spec_y # examples: ('==', '0.1') conflicts with ('==', '0.2') # ('==', '0.1') conflicts with ('<', 0.1) # ('==', '0.1') conflicts with ('>', 0.1) # ('==', '0.1') conflicts with ('!=', 0.1) # ('<', '0.1') conflicts with ('>', '0.1') # ('<=', 0.1) conflicts with ('>=', 0.2) # ('<', '0.1') conflicts with ('>', '0.2') pass class Dependency(object): def __init__(self): # non-inclusive boundaries self.less_than = None self.greater_than = None self.absolute = None self.excludes = [] # A list of conclicting specs for this dependency self.conflicts = [] def is_unbounded(self): return self.less_than == None and self.greater_than == None def _in_less_than(self, version): return True if self.less_than == None else version < self.less_than def _in_greater_than(self, version): return True if self.greater_than == None else version > self.greater_than def in_bounds(self, version): return self.is_unbounded() or (self._in_less_than(version) and self._in_greater_than(version)) def get_bounds_conflict(self, version): if not self._in_less_than(version): return ('<', self.less_than) elif not self._in_greater_than(version): return ('>', self.greater_than) else: return None def is_unconstrained(self): return (self.is_unbounded() and self.excludes == [] and self.absolute == None) def set_absolute_version(self, version): self.absolute = version def check_eqs(dep, version): if dep.is_unconstrained(): dep.set_absolute_version(version) elif version not in dep.excludes: if dep.absolute in [version, None]: if dep.in_bounds(version): dep.set_absolute_version(version) else: warn('conflict! == %s conflicts with %s' % (version, dep.get_bounds_conflict(version))) dep.conflicts.append((dep.get_bounds_conflict(version), ('==', version))) else: warn('conflict! == %s conflicts with == %s' % (version, dep.absolute)) dep.conflicts.append((('==', dep.absolute), ('==', version))) elif version in dep.excludes: warn('conflict! != %s conflicts with == %s' % (version, version)) dep.conflicts.append((('!=', version), ('==', version))) elif version > dep.less_than: # conflict warn('42 conflict! < %s conflicts with == %s' % (dep.less_than, version)) dep.conflicts.append((('<', dep.less_than), ('==', version))) elif version < dep.greater_than: # conflict warn('conflict! > %s conflicts with == %s' % (dep.greater_than, version)) dep.conflicts.append((('>', dep.greater_than), ('==', version))) def check_lt(dep, version): if dep.is_unconstrained(): dep.less_than = version elif dep.is_unbounded(): if dep.absolute >= version: dep.conflicts.append((('==', dep.absolute), ('<', version))) elif dep.greater_than >= version: # conflict #(our minimum version is greater # than this greater_than version) warn('conflict! > %s conflicts with < %s' % (dep.greater_than, version)) dep.conflicts.append((('>', dep.greater_than), ('<', version))) else: dep.less_than = version def check_gt(dep, version): if dep.is_unconstrained(): dep.greater_than = version elif dep.is_unbounded(): if dep.absolute <= version: dep.conflicts.append((('==', dep.absolute), ('>', version))) elif dep.less_than <= version: # conflict (our maximum version is less than this # less_than version) warn('conflict! < %s conflicts with > %s' % (dep.less_than, version)) dep.conflicts.append((('<', dep.less_than), ('>', version))) def resolve_version_constraints(requirements): build_deps = {} for r in requirements: print('%s %s' % (r.project_name, r.specs)) if r.project_name not in build_deps: build_deps[r.project_name] = Dependency() for (op, version) in r.specs: version = pkg_resources.parse_version(version) #if no_conflict(requirements_versions_map[r.project_name], s): # requirements_versions_map[r.project_name] += s dep = build_deps[r.project_name] print('dep.excludes: %s' % str(dep.excludes)) # TODO: replace with function table if op == '==': check_eqs(dep, version) elif op == '!=': if dep.absolute != version: dep.excludes.append(version) else: dep.conflicts.append((('==', dep.absolute), ('!=', version))) elif op == '<': check_lt(dep, version) elif op == '>': check_gt(dep, version) # Resolve versions #client = xmlrpclib.ServerProxy(PYPI_URL) #releases = client.package_releases(requirement.name) return build_deps def find_build_deps(source, name, version=None): debug('source: %s' % source) debug('name: %s' % name) debug('version: %s' % version) # This amounts to running python setup.py egg_info and checking # the resulting egg_info dir for a file called setup_requires.txt # So it's $name.egg_info p = subprocess.Popen(['python', 'setup.py', 'egg_info'], cwd=source, stdout=subprocess.PIPE) if p.wait() != 0: error('egg_info command failed') egg_dir = '%s.egg-info' % name build_deps_file = os.path.join(source, egg_dir, 'setup_requires.txt') build_deps = {} # Check whether there's a setup_requires.txt if not os.path.isfile(build_deps_file): print('%s has no build dependencies' % name) else: with open(build_deps_file) as f: build_deps = resolve_version_constraints( pkg_resources.parse_requirements(f)) return build_deps if __name__ == '__main__': if len(sys.argv) not in [3, 4]: print('usage: %s PACKAGE_SOURCE_DIR NAME [VERSION]' % sys.argv[0]) sys.exit(1) # Ignore the issue of dependency conflicts for now # First, given a source return build dependencies in json from for name, dep in find_build_deps(*sys.argv[1:]).iteritems(): print('%s less_than: %s greater_than: %s absolute: %s excludes: %s' % (name, str(dep.less_than), str(dep.greater_than), str(dep.absolute), dep.excludes)) print('conflicts: %s' % str(dep.conflicts))