#!/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 Bound(object): def __init__(self, bound_type, value, inclusive=False): self.value = value self.inclusive = inclusive self.bound_type = bound_type self.symbols = {'lower': ['<', '<='], 'upper': ['>', '>=']} def val(self, x): # type(self) should be Bound? return x.value if isinstance(x, Bound) else x # TODO: quite sure there's a much nicer way to be doing this sort of thing # probably by defining __lt__, __eq__, __gt__ ourselves # Note: x must be another Bound, we should enforce this somehow? def __lt__(self, x): print('__lt__ self: %s, x: %s' % (self, x)) x = self.val(x) return self.value <= x if self.inclusive else self.value < x def __gt__(self, x): print('__gt__ self: %s, x: %s' % (self, x)) x = self.val(x) return self.value >= x if self.inclusive else self.value > x def __le__(self, x): print('__le__ self: %s, x: %s' % (self, x)) x = self.val(x) return self.value <= x def __ge__(self, x): print('__ge__ self: %s, x: %s' % (self, x)) x = self.val(x) return self.value >= x def _symbol(self): return self.symbols[self.bound_type][self.inclusive] @property def spec(self): return (self._symbol(), self.value) class LowerBound(Bound): def __init__(self, value, inclusive=False): super(LowerBound, self).__init__('lower', value, inclusive) class UpperBound(Bound): def __init__(self, value, inclusive=False): super(UpperBound, self).__init__('upper', value, inclusive) 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 # TODO: This might be better done in Bound, not sure 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.spec elif not self._in_greater_than(version): return self.greater_than.spec 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: dep.conflicts.append((dep.get_bounds_conflict(version), ('==', version))) else: dep.conflicts.append((('==', dep.absolute), ('==', version))) elif version in dep.excludes: dep.conflicts.append((('!=', version), ('==', version))) elif dep.less_than < version: dep.conflicts.append((dep.less_than.spec, ('==', version))) elif dep.greater_than > version: dep.conflicts.append((dep.greater_than.spec, ('==', version))) # < x and == y: conflict, so conflict if y >= x # <= x and == y: no conflict, so conflict if y > x def check_lt(dep, version, inclusive=False): lt_symbol = '<=' if inclusive else '<' lt = LowerBound(version, inclusive) def abs_cmp(x, y): return x > y if inclusive else x >= y if dep.is_unconstrained(): dep.less_than = LowerBound(version, inclusive) elif dep.is_unbounded(): # if dep.absolute >= version: # unless inclusive! # TODO: could probably use our existing bound thing for this if abs_cmp(dep.absolute, version): dep.conflicts.append((('==', dep.absolute), (lt_symbol, version))) elif dep.greater_than >= version: dep.conflicts.append((dep.greater_than.spec, (lt_symbol, version))) else: dep.less_than = LowerBound(version, inclusive) # > x and == y: conflict, so conflict if y <= x # >= x and == y: no conflict, so conflict if y < x def check_gt(dep, version, inclusive=False): gt_symbol = '>=' if inclusive else '>' gt = UpperBound(version, inclusive) # TODO: terrible name def abs_cmp(x, y): return x < y if inclusive else x <= y if dep.is_unconstrained(): dep.greater_than = UpperBound(version, inclusive) elif dep.is_unbounded(): #if dep.absolute <= version: # again unless inclusive #if gt >= dep.absolute: if abs_cmp(dep.absolute, version): dep.conflicts.append((('==', dep.absolute), (gt_symbol, version))) elif dep.less_than <= version: dep.conflicts.append((dep.less_than.spec, (gt_symbol, version))) else: dep.greater_than = UpperBound(version, inclusive) 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, inclusive=False) elif op == '>': check_gt(dep, version, inclusive=False) elif op == '<=': check_lt(dep, version, inclusive=True) elif op == '>=': check_gt(dep, version, inclusive=True) # 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))