diff options
| -rw-r--r-- | pkg_resources.py | 209 | ||||
| -rw-r--r-- | setuptools/tests/test_resources.py | 61 |
2 files changed, 202 insertions, 68 deletions
diff --git a/pkg_resources.py b/pkg_resources.py index f3669f6e..24897ab0 100644 --- a/pkg_resources.py +++ b/pkg_resources.py @@ -19,6 +19,7 @@ __all__ = [ 'resource_stream', 'resource_filename', 'set_extraction_path', 'cleanup_resources', 'parse_requirements', 'parse_version', 'compatible_platforms', 'get_platform', + 'ResolutionError', 'VersionConflict', 'DistributionNotFound', 'Distribution', 'Requirement', # 'glob_resources' ] @@ -27,17 +28,16 @@ import sys, os, zipimport, time, re def _sort_dists(dists): tmp = [(dist.version,dist) for dist in dists] tmp.sort() - tmp.reverse() - dists[:] = [d for v,d in tmp] - - - - - - + dists[::-1] = [d for v,d in tmp] +class ResolutionError(ImportError): + """Abstract base for dependency resolution errors""" +class VersionConflict(ResolutionError): + """An already-installed version conflicts with the requested version""" +class DistributionNotFound(ResolutionError): + """A requested distribution was not found""" _provider_factories = {} @@ -76,7 +76,7 @@ def compatible_platforms(provided,required): return True # easy case # XXX all the tricky cases go here - + return False @@ -137,7 +137,7 @@ class AvailableDistributions(object): was first imported.) You may explicitly set `platform` to ``None`` if you wish to map *all* - distributions, not just those compatible with the running platform. + distributions, not just those compatible with a single platform. """ self._distmap = {} @@ -179,10 +179,10 @@ class AvailableDistributions(object): search_path = sys.path add = self.add for item in search_path: - source = get_dist_source(item) + source = get_distro_source(item) for dist in source.iter_distributions(requirement): if compatible_platforms(dist.platform, platform): - add(dist) + add(dist) # XXX should also check python version! def __getitem__(self,key): """Return a newest-to-oldest list of distributions for the given key @@ -213,6 +213,77 @@ class AvailableDistributions(object): """Remove `dist` from the distribution map""" self._distmap[dist.key].remove(dist) + def best_match(self,requirement,path=None): + """Find distribution best matching `requirement` and usable on `path` + + If a distribution that's already installed on `path` is unsuitable, + a VersionConflict is raised. If one or more suitable distributions are + already installed, the leftmost distribution (i.e., the one first in + the search path) is returned. Otherwise, the available distribution + with the highest version number is returned, or a deferred distribution + object is returned if a suitable ``obtain()`` method exists. If there + is no way to meet the requirement, None is returned. + """ + if path is None: + path = sys.path + + distros = self.get(requirement.key, ()) + find = dict([(dist.path,dist) for dist in distros]).get + + for item in path: + dist = find(item) + if dist is not None: + if dist in requirement: + return dist + else: + raise VersionConflict(dist,requirement) # XXX add more info + + for dist in distros: + if dist in requirement: + return dist + + return self.obtain(requirement) # as a last resort, try and download + + def resolve(self, requirements, path=None): + """List all distributions needed to (recursively) meet requirements""" + + if path is None: + path = sys.path + + requirements = list(requirements)[::1] # set up the stack + processed = {} # set of processed requirements + best = {} # key -> dist + + while requirements: + + req = requirements.pop() + if req in processed: + # Ignore cyclic or redundant dependencies + continue + + dist = best.get(req.key) + + if dist is None: + # Find the best distribution and add it to the map + dist = best[req.key] = self.best_match(req,path) + if dist is None: + raise DistributionNotFound(req) # XXX put more info here + + elif dist not in requirement: + # Oops, the "best" so far conflicts with a dependency + raise VersionConflict(req,dist) # XXX put more info here + + requirements.extend(dist.depends(req.options)[::-1]) + processed[req] = True + + return best.values() # return list of distros to install + + + def obtain(self, requirement): + """Obtain a distro that matches requirement (e.g. via download)""" + return None # override this in subclasses + + class ResourceManager: """Manage resource extraction and packages""" @@ -244,6 +315,17 @@ class ResourceManager: self, resource_name ) + + + + + + + + + + + def get_cache_path(self, archive_name, names=()): """Return absolute location in cache for `archive_name` and `names` @@ -331,35 +413,35 @@ def require(*requirements): `requirements` must be a string or a (possibly-nested) sequence thereof, specifying the distributions and versions required. - XXX THIS IS DRAFT CODE FOR DESIGN PURPOSES ONLY RIGHT NOW + + XXX This doesn't work yet, because: + + * get_distro_source() isn't implemented + * Distribution.depends() isn't implemented + * Distribution.install_on() isn't implemented + * Requirement.options isn't implemented + * AvailableDistributions.resolve() is untested + * AvailableDistributions.scan() is untested + + There may be other things missing as well, but this definitely won't work + as long as any of the above items remain unimplemented. """ - all_distros = AvailableDistributions() - installed = {} - all_requirements = {} - def _require(requirements,source=None): - for req in parse_requirements(requirements): - name,vers = req # XXX - key = name.lower() - all_requirements.setdefault(key,[]).append((req,source)) - if key in installed and not req.matches(installed[key]): - raise ImportError( - "The installed %s distribution does not match" # XXX - ) # XXX should this be a subclass of ImportError? - all_distros[key] = distros = [ - dist for dist in all_distros.get(key,[]) - if req.matches(dist) - ] - if not distros: - raise ImportError( - "No %s distribution matches all criteria for " % name - ) # XXX should this be a subclass of ImportError? - for key in all_requirements.keys(): # XXX sort them - pass - # find "best" distro for key and install it - # after _require()-ing its requirements + requirements = parse_requirements(requirements) + + for dist in AvailableDistributions().resolve(requirements): + dist.install_on(sys.path) + + + + + + + + + + - _require(requirements) @@ -669,31 +751,12 @@ class Distribution(object): self.py_version = py_version self.platform = platform self.path = path_str - self.normalized_path = os.path.normpath(os.path.normcase(path_str)) def installed_on(self,path=None): """Is this distro installed on `path`? (defaults to ``sys.path``)""" if path is None: path = sys.path - if self.path in path or self.normalized_path in path: - return True - for item in path: - normalized = os.path.normpath(os.path.normcase(item)) - if normalized == self.normalized_path: - return True - return False - - - - - - - - - - - - + return self.path in path #@classmethod def from_filename(cls,filename,metadata=None): @@ -711,6 +774,9 @@ class Distribution(object): ) from_filename = classmethod(from_filename) + + + # These properties have to be lazy so that we don't have to load any # metadata until/unless it's actually needed. (i.e., some distributions # may not know their name or version without loading PKG-INFO) @@ -736,6 +802,22 @@ class Distribution(object): + + + + + + + + + + + + + + + + def parse_requirements(strs): """Yield ``Requirement`` objects for each specification in `strs` @@ -818,6 +900,17 @@ class Requirement: return last + #@staticmethod + def parse(s): + reqs = list(parse_requirements(s)) + if reqs: + if len(reqs)==1: + return reqs[0] + raise ValueError("Expected only one requirement", s) + raise ValueError("No requirements found", s) + + parse = staticmethod(parse) + state_machine = { # =>< '<' : '--T', diff --git a/setuptools/tests/test_resources.py b/setuptools/tests/test_resources.py index 52197fe9..c90b907d 100644 --- a/setuptools/tests/test_resources.py +++ b/setuptools/tests/test_resources.py @@ -39,6 +39,47 @@ class DistroTests(TestCase): [dist.version for dist in ad['FooPkg']], ['1.9','1.4','1.2'] ) + path = [] + req, = parse_requirements("FooPkg>=1.3") + + # Nominal case: no distros on path, should yield all applicable + self.assertEqual(ad.best_match(req,path).version, '1.9') + + # If a matching distro is already installed, should return only that + path.append("FooPkg-1.4-py2.4-win32.egg") + self.assertEqual(ad.best_match(req,path).version, '1.4') + + # If the first matching distro is unsuitable, it's a version conflict + path.insert(0,"FooPkg-1.2-py2.4.egg") + self.assertRaises(VersionConflict, ad.best_match, req, path) + + # If more than one match on the path, the first one takes precedence + path.insert(0,"FooPkg-1.4-py2.4-win32.egg") + self.assertEqual(ad.best_match(req,path).version, '1.4') + + + + + + + + + + + + + + + + + + + + + + + + def checkFooPkg(self,d): self.assertEqual(d.name, "FooPkg") self.assertEqual(d.key, "foopkg") @@ -83,7 +124,7 @@ class DistroTests(TestCase): class RequirementsTests(TestCase): def testBasics(self): - r = Requirement("Twisted", [('>=','1.2')]) + r = Requirement.parse("Twisted>=1.2") self.assertEqual(str(r),"Twisted>=1.2") self.assertEqual(repr(r),"Requirement('Twisted', [('>=', '1.2')])") self.assertEqual(r, Requirement("Twisted", [('>=','1.2')])) @@ -142,15 +183,15 @@ class ParseTests(TestCase): list(parse_requirements('Twisted >=1.2, \ # more\n<2.0')), [Requirement('Twisted',[('>=','1.2'),('<','2.0')])] ) - self.assertRaises(ValueError,lambda:list(parse_requirements(">=2.3"))) - self.assertRaises(ValueError,lambda:list(parse_requirements("x\\"))) - self.assertRaises(ValueError,lambda:list(parse_requirements("x==2 q"))) - - - - - - + self.assertEqual( + Requirement.parse("FooBar==1.99a3"), + Requirement("FooBar", [('==','1.99a3')]) + ) + self.assertRaises(ValueError,Requirement.parse,">=2.3") + self.assertRaises(ValueError,Requirement.parse,"x\\") + self.assertRaises(ValueError,Requirement.parse,"x==2 q") + self.assertRaises(ValueError,Requirement.parse,"X==1\nY==2") + self.assertRaises(ValueError,Requirement.parse,"#") |
