1
# -*- coding: utf-8 -*-
3
# Copyright (C) 2012-2016 The Python Software Foundation.
4
# See LICENSE.txt and CONTRIBUTORS.txt.
6
"""PEP 376 implementation."""
8
from __future__ import unicode_literals
20
from . import DistlibException, resources
21
from .compat import StringIO
22
from .version import get_scheme, UnsupportedVersionError
23
from .metadata import Metadata, METADATA_FILENAME, WHEEL_METADATA_FILENAME
24
from .util import (parse_requirement, cached_property, parse_name_and_version,
25
read_exports, write_exports, CSVReader, CSVWriter)
28
__all__ = ['Distribution', 'BaseInstalledDistribution',
29
'InstalledDistribution', 'EggInfoDistribution',
33
logger = logging.getLogger(__name__)
35
EXPORTS_FILENAME = 'pydist-exports.json'
36
COMMANDS_FILENAME = 'pydist-commands.json'
38
DIST_FILES = ('INSTALLER', METADATA_FILENAME, 'RECORD', 'REQUESTED',
39
'RESOURCES', EXPORTS_FILENAME, 'SHARED')
41
DISTINFO_EXT = '.dist-info'
46
A simple cache mapping names and .dist-info paths to distributions
50
Initialise an instance. There is normally one for each DistributionPath.
54
self.generated = False
58
Clear the cache, setting it to its initial state.
62
self.generated = False
66
Add a distribution to the cache.
67
:param dist: The distribution to add.
69
if dist.path not in self.path:
70
self.path[dist.path] = dist
71
self.name.setdefault(dist.key, []).append(dist)
74
class DistributionPath(object):
76
Represents a set of distributions installed on a path (typically sys.path).
78
def __init__(self, path=None, include_egg=False):
80
Create an instance from a path, optionally including legacy (distutils/
81
setuptools/distribute) distributions.
82
:param path: The path to use, as a list of directories. If not specified,
84
:param include_egg: If True, this instance will look for and return legacy
85
distributions as well as those based on PEP 376.
90
self._include_dist = True
91
self._include_egg = include_egg
93
self._cache = _Cache()
94
self._cache_egg = _Cache()
95
self._cache_enabled = True
96
self._scheme = get_scheme('default')
98
def _get_cache_enabled(self):
99
return self._cache_enabled
101
def _set_cache_enabled(self, value):
102
self._cache_enabled = value
104
cache_enabled = property(_get_cache_enabled, _set_cache_enabled)
106
def clear_cache(self):
108
Clears the internal cache.
111
self._cache_egg.clear()
114
def _yield_distributions(self):
116
Yield .dist-info and/or .egg(-info) distributions.
118
# We need to check if we've seen some resources already, because on
119
# some Linux systems (e.g. some Debian/Ubuntu variants) there are
120
# symlinks which alias other files in the environment.
122
for path in self.path:
123
finder = resources.finder_for_path(path)
127
if not r or not r.is_container:
129
rset = sorted(r.resources)
131
r = finder.find(entry)
132
if not r or r.path in seen:
134
if self._include_dist and entry.endswith(DISTINFO_EXT):
135
possible_filenames = [METADATA_FILENAME, WHEEL_METADATA_FILENAME]
136
for metadata_filename in possible_filenames:
137
metadata_path = posixpath.join(entry, metadata_filename)
138
pydist = finder.find(metadata_path)
144
with contextlib.closing(pydist.as_stream()) as stream:
145
metadata = Metadata(fileobj=stream, scheme='legacy')
146
logger.debug('Found %s', r.path)
148
yield new_dist_class(r.path, metadata=metadata,
150
elif self._include_egg and entry.endswith(('.egg-info',
152
logger.debug('Found %s', r.path)
154
yield old_dist_class(r.path, self)
156
def _generate_cache(self):
158
Scan the path for distributions and populate the cache with
159
those that are found.
161
gen_dist = not self._cache.generated
162
gen_egg = self._include_egg and not self._cache_egg.generated
163
if gen_dist or gen_egg:
164
for dist in self._yield_distributions():
165
if isinstance(dist, InstalledDistribution):
166
self._cache.add(dist)
168
self._cache_egg.add(dist)
171
self._cache.generated = True
173
self._cache_egg.generated = True
176
def distinfo_dirname(cls, name, version):
178
The *name* and *version* parameters are converted into their
179
filename-escaped form, i.e. any ``'-'`` characters are replaced
180
with ``'_'`` other than the one in ``'dist-info'`` and the one
181
separating the name from the version number.
183
:parameter name: is converted to a standard distribution name by replacing
184
any runs of non- alphanumeric characters with a single
187
:parameter version: is converted to a standard version string. Spaces
188
become dots, and all other non-alphanumeric characters
189
(except dots) become dashes, with runs of multiple
190
dashes condensed to a single dash.
191
:type version: string
192
:returns: directory name
194
name = name.replace('-', '_')
195
return '-'.join([name, version]) + DISTINFO_EXT
197
def get_distributions(self):
199
Provides an iterator that looks for distributions and returns
200
:class:`InstalledDistribution` or
201
:class:`EggInfoDistribution` instances for each one of them.
203
:rtype: iterator of :class:`InstalledDistribution` and
204
:class:`EggInfoDistribution` instances
206
if not self._cache_enabled:
207
for dist in self._yield_distributions():
210
self._generate_cache()
212
for dist in self._cache.path.values():
215
if self._include_egg:
216
for dist in self._cache_egg.path.values():
219
def get_distribution(self, name):
221
Looks for a named distribution on the path.
223
This function only returns the first result found, as no more than one
224
value is expected. If nothing is found, ``None`` is returned.
226
:rtype: :class:`InstalledDistribution`, :class:`EggInfoDistribution`
231
if not self._cache_enabled:
232
for dist in self._yield_distributions():
237
self._generate_cache()
239
if name in self._cache.name:
240
result = self._cache.name[name][0]
241
elif self._include_egg and name in self._cache_egg.name:
242
result = self._cache_egg.name[name][0]
245
def provides_distribution(self, name, version=None):
247
Iterates over all distributions to find which distributions provide *name*.
248
If a *version* is provided, it will be used to filter the results.
250
This function only returns the first result found, since no more than
251
one values are expected. If the directory is not found, returns ``None``.
253
:parameter version: a version specifier that indicates the version
254
required, conforming to the format in ``PEP-345``
257
:type version: string
260
if not version is None:
262
matcher = self._scheme.matcher('%s (%s)' % (name, version))
264
raise DistlibException('invalid name or version: %r, %r' %
267
for dist in self.get_distributions():
268
provided = dist.provides
271
p_name, p_ver = parse_name_and_version(p)
277
if p_name == name and matcher.match(p_ver):
281
def get_file_path(self, name, relative_path):
283
Return the path to a resource file.
285
dist = self.get_distribution(name)
287
raise LookupError('no distribution named %r found' % name)
288
return dist.get_resource_path(relative_path)
290
def get_exported_entries(self, category, name=None):
292
Return all of the exported entries in a particular category.
294
:param category: The category to search for entries.
295
:param name: If specified, only entries with that name are returned.
297
for dist in self.get_distributions():
309
class Distribution(object):
311
A base class for distributions, whether installed or from indexes.
312
Either way, it must have some metadata, so that's all that's needed
316
build_time_dependency = False
318
Set to True if it's known to be only a build-time dependency (i.e.
319
not needed after installation).
323
"""A boolean that indicates whether the ``REQUESTED`` metadata file is
324
present (in other words, whether the package was installed by user
325
request or it was installed as a dependency)."""
327
def __init__(self, metadata):
329
Initialise an instance.
330
:param metadata: The instance of :class:`Metadata` describing this
333
self.metadata = metadata
334
self.name = metadata.name
335
self.key = self.name.lower() # for case-insensitive comparisons
336
self.version = metadata.version
339
self.extras = None # additional features requested
340
self.context = None # environment marker overrides
341
self.download_urls = set()
345
def source_url(self):
347
The source archive download URL for this distribution.
349
return self.metadata.source_url
351
download_url = source_url # Backward compatibility
354
def name_and_version(self):
356
A utility property which displays the name and version in parentheses.
358
return '%s (%s)' % (self.name, self.version)
363
A set of distribution names and versions provided by this distribution.
364
:return: A set of "name (version)" strings.
366
plist = self.metadata.provides
367
s = '%s (%s)' % (self.name, self.version)
372
def _get_requirements(self, req_attr):
374
logger.debug('Getting requirements from metadata %r', md.todict())
375
reqts = getattr(md, req_attr)
376
return set(md.get_requirements(reqts, extras=self.extras,
380
def run_requires(self):
381
return self._get_requirements('run_requires')
384
def meta_requires(self):
385
return self._get_requirements('meta_requires')
388
def build_requires(self):
389
return self._get_requirements('build_requires')
392
def test_requires(self):
393
return self._get_requirements('test_requires')
396
def dev_requires(self):
397
return self._get_requirements('dev_requires')
399
def matches_requirement(self, req):
401
Say if this instance matches (fulfills) a requirement.
402
:param req: The requirement to match.
404
:return: True if it matches, else False.
406
# Requirement may contain extras - parse to lose those
407
# from what's passed to the matcher
408
r = parse_requirement(req)
409
scheme = get_scheme(self.metadata.scheme)
411
matcher = scheme.matcher(r.requirement)
412
except UnsupportedVersionError:
413
# XXX compat-mode if cannot read the version
414
logger.warning('could not read version %r - using name only',
416
name = req.split()[0]
417
matcher = scheme.matcher(name)
419
name = matcher.key # case-insensitive
422
for p in self.provides:
423
p_name, p_ver = parse_name_and_version(p)
427
result = matcher.match(p_ver)
429
except UnsupportedVersionError:
435
Return a textual representation of this instance,
438
suffix = ' [%s]' % self.source_url
441
return '<Distribution %s (%s)%s>' % (self.name, self.version, suffix)
443
def __eq__(self, other):
445
See if this distribution is the same as another.
446
:param other: The distribution to compare with. To be equal to one
447
another. distributions must have the same type, name,
448
version and source_url.
449
:return: True if it is the same, else False.
451
if type(other) is not type(self):
454
result = (self.name == other.name and
455
self.version == other.version and
456
self.source_url == other.source_url)
461
Compute hash in a way which matches the equality test.
463
return hash(self.name) + hash(self.version) + hash(self.source_url)
466
class BaseInstalledDistribution(Distribution):
468
This is the base class for installed distributions (whether PEP 376 or
474
def __init__(self, metadata, path, env=None):
476
Initialise an instance.
477
:param metadata: An instance of :class:`Metadata` which describes the
478
distribution. This will normally have been initialised
479
from a metadata file in the ``path``.
480
:param path: The path of the ``.dist-info`` or ``.egg-info``
481
directory for the distribution.
482
:param env: This is normally the :class:`DistributionPath`
483
instance where this distribution was found.
485
super(BaseInstalledDistribution, self).__init__(metadata)
489
def get_hash(self, data, hasher=None):
491
Get the hash of some data, using a particular hash algorithm, if
494
:param data: The data to be hashed.
496
:param hasher: The name of a hash implementation, supported by hashlib,
497
or ``None``. Examples of valid values are ``'sha1'``,
498
``'sha224'``, ``'sha384'``, '``sha256'``, ``'md5'`` and
499
``'sha512'``. If no hasher is specified, the ``hasher``
500
attribute of the :class:`InstalledDistribution` instance
501
is used. If the hasher is determined to be ``None``, MD5
502
is used as the hashing algorithm.
503
:returns: The hash of the data. If a hasher was explicitly specified,
504
the returned hash will be prefixed with the specified hasher
514
hasher = getattr(hashlib, hasher)
515
prefix = '%s=' % self.hasher
516
digest = hasher(data).digest()
517
digest = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
518
return '%s%s' % (prefix, digest)
521
class InstalledDistribution(BaseInstalledDistribution):
523
Created with the *path* of the ``.dist-info`` directory provided to the
524
constructor. It reads the metadata contained in ``pydist.json`` when it is
525
instantiated., or uses a passed in Metadata instance (useful for when
526
dry-run mode is being used).
531
def __init__(self, path, metadata=None, env=None):
532
self.finder = finder = resources.finder_for_path(path)
534
import pdb; pdb.set_trace ()
535
if env and env._cache_enabled and path in env._cache.path:
536
metadata = env._cache.path[path].metadata
537
elif metadata is None:
538
r = finder.find(METADATA_FILENAME)
539
# Temporary - for Wheel 0.23 support
541
r = finder.find(WHEEL_METADATA_FILENAME)
542
# Temporary - for legacy support
544
r = finder.find('METADATA')
546
raise ValueError('no %s found in %s' % (METADATA_FILENAME,
548
with contextlib.closing(r.as_stream()) as stream:
549
metadata = Metadata(fileobj=stream, scheme='legacy')
551
super(InstalledDistribution, self).__init__(metadata, path, env)
553
if env and env._cache_enabled:
557
r = finder.find('REQUESTED')
558
except AttributeError:
559
import pdb; pdb.set_trace ()
560
self.requested = r is not None
563
return '<InstalledDistribution %r %s at %r>' % (
564
self.name, self.version, self.path)
567
return "%s %s" % (self.name, self.version)
569
def _get_records(self):
571
Get the list of installed files for the distribution
572
:return: A list of tuples of path, hash and size. Note that hash and
573
size might be ``None`` for some entries. The path is exactly
574
as stored in the file (which is as in PEP 376).
577
r = self.get_distinfo_resource('RECORD')
578
with contextlib.closing(r.as_stream()) as stream:
579
with CSVReader(stream=stream) as record_reader:
580
# Base location is parent dir of .dist-info dir
581
#base_location = os.path.dirname(self.path)
582
#base_location = os.path.abspath(base_location)
583
for row in record_reader:
584
missing = [None for i in range(len(row), 3)]
585
path, checksum, size = row + missing
586
#if not os.path.isabs(path):
587
# path = path.replace('/', os.sep)
588
# path = os.path.join(base_location, path)
589
results.append((path, checksum, size))
595
Return the information exported by this distribution.
596
:return: A dictionary of exports, mapping an export category to a dict
597
of :class:`ExportEntry` instances describing the individual
598
export entries, and keyed by name.
601
r = self.get_distinfo_resource(EXPORTS_FILENAME)
603
result = self.read_exports()
606
def read_exports(self):
608
Read exports data from a file in .ini format.
610
:return: A dictionary of exports, mapping an export category to a list
611
of :class:`ExportEntry` instances describing the individual
615
r = self.get_distinfo_resource(EXPORTS_FILENAME)
617
with contextlib.closing(r.as_stream()) as stream:
618
result = read_exports(stream)
621
def write_exports(self, exports):
623
Write a dictionary of exports to a file in .ini format.
624
:param exports: A dictionary of exports, mapping an export category to
625
a list of :class:`ExportEntry` instances describing the
626
individual export entries.
628
rf = self.get_distinfo_file(EXPORTS_FILENAME)
629
with open(rf, 'w') as f:
630
write_exports(exports, f)
632
def get_resource_path(self, relative_path):
634
NOTE: This API may change in the future.
636
Return the absolute path to a resource file with the given relative
639
:param relative_path: The path, relative to .dist-info, of the resource
641
:return: The absolute path where the resource is to be found.
643
r = self.get_distinfo_resource('RESOURCES')
644
with contextlib.closing(r.as_stream()) as stream:
645
with CSVReader(stream=stream) as resources_reader:
646
for relative, destination in resources_reader:
647
if relative == relative_path:
649
raise KeyError('no resource file with relative path %r '
650
'is installed' % relative_path)
652
def list_installed_files(self):
654
Iterates over the ``RECORD`` entries and returns a tuple
655
``(path, hash, size)`` for each line.
657
:returns: iterator of (path, hash, size)
659
for result in self._get_records():
662
def write_installed_files(self, paths, prefix, dry_run=False):
664
Writes the ``RECORD`` file, using the ``paths`` iterable passed in. Any
665
existing ``RECORD`` file is silently overwritten.
667
prefix is used to determine when to write absolute paths.
669
prefix = os.path.join(prefix, '')
670
base = os.path.dirname(self.path)
671
base_under_prefix = base.startswith(prefix)
672
base = os.path.join(base, '')
673
record_path = self.get_distinfo_file('RECORD')
674
logger.info('creating %s', record_path)
677
with CSVWriter(record_path) as writer:
679
if os.path.isdir(path) or path.endswith(('.pyc', '.pyo')):
680
# do not put size and hash, as in PEP-376
681
hash_value = size = ''
683
size = '%d' % os.path.getsize(path)
684
with open(path, 'rb') as fp:
685
hash_value = self.get_hash(fp.read())
686
if path.startswith(base) or (base_under_prefix and
687
path.startswith(prefix)):
688
path = os.path.relpath(path, base)
689
writer.writerow((path, hash_value, size))
691
# add the RECORD file itself
692
if record_path.startswith(base):
693
record_path = os.path.relpath(record_path, base)
694
writer.writerow((record_path, '', ''))
697
def check_installed_files(self):
699
Checks that the hashes and sizes of the files in ``RECORD`` are
700
matched by the files themselves. Returns a (possibly empty) list of
701
mismatches. Each entry in the mismatch list will be a tuple consisting
702
of the path, 'exists', 'size' or 'hash' according to what didn't match
703
(existence is checked first, then size, then hash), the expected
704
value and the actual value.
707
base = os.path.dirname(self.path)
708
record_path = self.get_distinfo_file('RECORD')
709
for path, hash_value, size in self.list_installed_files():
710
if not os.path.isabs(path):
711
path = os.path.join(base, path)
712
if path == record_path:
714
if not os.path.exists(path):
715
mismatches.append((path, 'exists', True, False))
716
elif os.path.isfile(path):
717
actual_size = str(os.path.getsize(path))
718
if size and actual_size != size:
719
mismatches.append((path, 'size', size, actual_size))
721
if '=' in hash_value:
722
hasher = hash_value.split('=', 1)[0]
726
with open(path, 'rb') as f:
727
actual_hash = self.get_hash(f.read(), hasher)
728
if actual_hash != hash_value:
729
mismatches.append((path, 'hash', hash_value, actual_hash))
733
def shared_locations(self):
735
A dictionary of shared locations whose keys are in the set 'prefix',
736
'purelib', 'platlib', 'scripts', 'headers', 'data' and 'namespace'.
737
The corresponding value is the absolute path of that category for
738
this distribution, and takes into account any paths selected by the
739
user at installation time (e.g. via command-line arguments). In the
740
case of the 'namespace' key, this would be a list of absolute paths
741
for the roots of namespace packages in this distribution.
743
The first time this property is accessed, the relevant information is
744
read from the SHARED file in the .dist-info directory.
747
shared_path = os.path.join(self.path, 'SHARED')
748
if os.path.isfile(shared_path):
749
with codecs.open(shared_path, 'r', encoding='utf-8') as f:
750
lines = f.read().splitlines()
752
key, value = line.split('=', 1)
753
if key == 'namespace':
754
result.setdefault(key, []).append(value)
759
def write_shared_locations(self, paths, dry_run=False):
761
Write shared location information to the SHARED file in .dist-info.
762
:param paths: A dictionary as described in the documentation for
763
:meth:`shared_locations`.
764
:param dry_run: If True, the action is logged but no file is actually
766
:return: The path of the file written to.
768
shared_path = os.path.join(self.path, 'SHARED')
769
logger.info('creating %s', shared_path)
773
for key in ('prefix', 'lib', 'headers', 'scripts', 'data'):
775
if os.path.isdir(paths[key]):
776
lines.append('%s=%s' % (key, path))
777
for ns in paths.get('namespace', ()):
778
lines.append('namespace=%s' % ns)
780
with codecs.open(shared_path, 'w', encoding='utf-8') as f:
781
f.write('\n'.join(lines))
784
def get_distinfo_resource(self, path):
785
if path not in DIST_FILES:
786
raise DistlibException('invalid path for a dist-info file: '
787
'%r at %r' % (path, self.path))
788
finder = resources.finder_for_path(self.path)
790
raise DistlibException('Unable to get a finder for %s' % self.path)
791
return finder.find(path)
793
def get_distinfo_file(self, path):
795
Returns a path located under the ``.dist-info`` directory. Returns a
796
string representing the path.
798
:parameter path: a ``'/'``-separated path relative to the
799
``.dist-info`` directory or an absolute path;
800
If *path* is an absolute path and doesn't start
801
with the ``.dist-info`` directory path,
802
a :class:`DistlibException` is raised
806
# Check if it is an absolute path # XXX use relpath, add tests
807
if path.find(os.sep) >= 0:
808
# it's an absolute path?
809
distinfo_dirname, path = path.split(os.sep)[-2:]
810
if distinfo_dirname != self.path.split(os.sep)[-1]:
811
raise DistlibException(
812
'dist-info file %r does not belong to the %r %s '
813
'distribution' % (path, self.name, self.version))
815
# The file must be relative
816
if path not in DIST_FILES:
817
raise DistlibException('invalid path for a dist-info file: '
818
'%r at %r' % (path, self.path))
820
return os.path.join(self.path, path)
822
def list_distinfo_files(self):
824
Iterates over the ``RECORD`` entries and returns paths for each line if
825
the path is pointing to a file located in the ``.dist-info`` directory
826
or one of its subdirectories.
828
:returns: iterator of paths
830
base = os.path.dirname(self.path)
831
for path, checksum, size in self._get_records():
832
# XXX add separator or use real relpath algo
833
if not os.path.isabs(path):
834
path = os.path.join(base, path)
835
if path.startswith(self.path):
838
def __eq__(self, other):
839
return (isinstance(other, InstalledDistribution) and
840
self.path == other.path)
842
# See http://docs.python.org/reference/datamodel#object.__hash__
843
__hash__ = object.__hash__
846
class EggInfoDistribution(BaseInstalledDistribution):
847
"""Created with the *path* of the ``.egg-info`` directory or file provided
848
to the constructor. It reads the metadata contained in the file itself, or
849
if the given path happens to be a directory, the metadata is read from the
850
file ``PKG-INFO`` under that directory."""
852
requested = True # as we have no way of knowing, assume it was
853
shared_locations = {}
855
def __init__(self, path, env=None):
856
def set_name_and_version(s, n, v):
858
s.key = n.lower() # for case-insensitive comparisons
863
if env and env._cache_enabled and path in env._cache_egg.path:
864
metadata = env._cache_egg.path[path].metadata
865
set_name_and_version(self, metadata.name, metadata.version)
867
metadata = self._get_metadata(path)
869
# Need to be set before caching
870
set_name_and_version(self, metadata.name, metadata.version)
872
if env and env._cache_enabled:
873
env._cache_egg.add(self)
874
super(EggInfoDistribution, self).__init__(metadata, path, env)
876
def _get_metadata(self, path):
879
def parse_requires_data(data):
880
"""Create a list of dependencies from a requires.txt file.
882
*data*: the contents of a setuptools-produced requires.txt file.
885
lines = data.splitlines()
888
if line.startswith('['):
889
logger.warning('Unexpected line: quitting requirement scan: %r',
892
r = parse_requirement(line)
894
logger.warning('Not recognised as a requirement: %r', line)
897
logger.warning('extra requirements in requires.txt are '
899
if not r.constraints:
902
cons = ', '.join('%s%s' % c for c in r.constraints)
903
reqs.append('%s (%s)' % (r.name, cons))
906
def parse_requires_path(req_path):
907
"""Create a list of dependencies from a requires.txt file.
909
*req_path*: the path to a setuptools-produced requires.txt file.
914
with codecs.open(req_path, 'r', 'utf-8') as fp:
915
reqs = parse_requires_data(fp.read())
920
if path.endswith('.egg'):
921
if os.path.isdir(path):
922
meta_path = os.path.join(path, 'EGG-INFO', 'PKG-INFO')
923
metadata = Metadata(path=meta_path, scheme='legacy')
924
req_path = os.path.join(path, 'EGG-INFO', 'requires.txt')
925
requires = parse_requires_path(req_path)
927
# FIXME handle the case where zipfile is not available
928
zipf = zipimport.zipimporter(path)
930
zipf.get_data('EGG-INFO/PKG-INFO').decode('utf8'))
931
metadata = Metadata(fileobj=fileobj, scheme='legacy')
933
data = zipf.get_data('EGG-INFO/requires.txt')
934
requires = parse_requires_data(data.decode('utf-8'))
937
elif path.endswith('.egg-info'):
938
if os.path.isdir(path):
939
req_path = os.path.join(path, 'requires.txt')
940
requires = parse_requires_path(req_path)
941
path = os.path.join(path, 'PKG-INFO')
942
metadata = Metadata(path=path, scheme='legacy')
944
raise DistlibException('path must end with .egg-info or .egg, '
948
metadata.add_requirements(requires)
952
return '<EggInfoDistribution %r %s at %r>' % (
953
self.name, self.version, self.path)
956
return "%s %s" % (self.name, self.version)
958
def check_installed_files(self):
960
Checks that the hashes and sizes of the files in ``RECORD`` are
961
matched by the files themselves. Returns a (possibly empty) list of
962
mismatches. Each entry in the mismatch list will be a tuple consisting
963
of the path, 'exists', 'size' or 'hash' according to what didn't match
964
(existence is checked first, then size, then hash), the expected
965
value and the actual value.
968
record_path = os.path.join(self.path, 'installed-files.txt')
969
if os.path.exists(record_path):
970
for path, _, _ in self.list_installed_files():
971
if path == record_path:
973
if not os.path.exists(path):
974
mismatches.append((path, 'exists', True, False))
977
def list_installed_files(self):
979
Iterates over the ``installed-files.txt`` entries and returns a tuple
980
``(path, hash, size)`` for each line.
982
:returns: a list of (path, hash, size)
991
return hashlib.md5(content).hexdigest()
994
return os.stat(path).st_size
996
record_path = os.path.join(self.path, 'installed-files.txt')
998
if os.path.exists(record_path):
999
with codecs.open(record_path, 'r', encoding='utf-8') as f:
1002
p = os.path.normpath(os.path.join(self.path, line))
1003
# "./" is present as a marker between installed files
1004
# and installation metadata files
1005
if not os.path.exists(p):
1006
logger.warning('Non-existent file: %s', p)
1007
if p.endswith(('.pyc', '.pyo')):
1009
#otherwise fall through and fail
1010
if not os.path.isdir(p):
1011
result.append((p, _md5(p), _size(p)))
1012
result.append((record_path, None, None))
1015
def list_distinfo_files(self, absolute=False):
1017
Iterates over the ``installed-files.txt`` entries and returns paths for
1018
each line if the path is pointing to a file located in the
1019
``.egg-info`` directory or one of its subdirectories.
1021
:parameter absolute: If *absolute* is ``True``, each returned path is
1022
transformed into a local absolute path. Otherwise the
1023
raw value from ``installed-files.txt`` is returned.
1024
:type absolute: boolean
1025
:returns: iterator of paths
1027
record_path = os.path.join(self.path, 'installed-files.txt')
1029
with codecs.open(record_path, 'r', encoding='utf-8') as f:
1036
p = os.path.normpath(os.path.join(self.path, line))
1037
if p.startswith(self.path):
1043
def __eq__(self, other):
1044
return (isinstance(other, EggInfoDistribution) and
1045
self.path == other.path)
1047
# See http://docs.python.org/reference/datamodel#object.__hash__
1048
__hash__ = object.__hash__
1050
new_dist_class = InstalledDistribution
1051
old_dist_class = EggInfoDistribution
1054
class DependencyGraph(object):
1056
Represents a dependency graph between distributions.
1058
The dependency relationships are stored in an ``adjacency_list`` that maps
1059
distributions to a list of ``(other, label)`` tuples where ``other``
1060
is a distribution and the edge is labeled with ``label`` (i.e. the version
1061
specifier, if such was provided). Also, for more efficient traversal, for
1062
every distribution ``x``, a list of predecessors is kept in
1063
``reverse_list[x]``. An edge from distribution ``a`` to
1064
distribution ``b`` means that ``a`` depends on ``b``. If any missing
1065
dependencies are found, they are stored in ``missing``, which is a
1066
dictionary that maps distributions to a list of requirements that were not
1067
provided by any other distributions.
1071
self.adjacency_list = {}
1072
self.reverse_list = {}
1075
def add_distribution(self, distribution):
1076
"""Add the *distribution* to the graph.
1078
:type distribution: :class:`distutils2.database.InstalledDistribution`
1079
or :class:`distutils2.database.EggInfoDistribution`
1081
self.adjacency_list[distribution] = []
1082
self.reverse_list[distribution] = []
1083
#self.missing[distribution] = []
1085
def add_edge(self, x, y, label=None):
1086
"""Add an edge from distribution *x* to distribution *y* with the given
1089
:type x: :class:`distutils2.database.InstalledDistribution` or
1090
:class:`distutils2.database.EggInfoDistribution`
1091
:type y: :class:`distutils2.database.InstalledDistribution` or
1092
:class:`distutils2.database.EggInfoDistribution`
1093
:type label: ``str`` or ``None``
1095
self.adjacency_list[x].append((y, label))
1096
# multiple edges are allowed, so be careful
1097
if x not in self.reverse_list[y]:
1098
self.reverse_list[y].append(x)
1100
def add_missing(self, distribution, requirement):
1102
Add a missing *requirement* for the given *distribution*.
1104
:type distribution: :class:`distutils2.database.InstalledDistribution`
1105
or :class:`distutils2.database.EggInfoDistribution`
1106
:type requirement: ``str``
1108
logger.debug('%s missing %r', distribution, requirement)
1109
self.missing.setdefault(distribution, []).append(requirement)
1111
def _repr_dist(self, dist):
1112
return '%s %s' % (dist.name, dist.version)
1114
def repr_node(self, dist, level=1):
1115
"""Prints only a subgraph"""
1116
output = [self._repr_dist(dist)]
1117
for other, label in self.adjacency_list[dist]:
1118
dist = self._repr_dist(other)
1119
if label is not None:
1120
dist = '%s [%s]' % (dist, label)
1121
output.append(' ' * level + str(dist))
1122
suboutput = self.repr_node(other, level + 1)
1123
subs = suboutput.split('\n')
1124
output.extend(subs[1:])
1125
return '\n'.join(output)
1127
def to_dot(self, f, skip_disconnected=True):
1128
"""Writes a DOT output for the graph to the provided file *f*.
1130
If *skip_disconnected* is set to ``True``, then all distributions
1131
that are not dependent on any other distribution are skipped.
1133
:type f: has to support ``file``-like operations
1134
:type skip_disconnected: ``bool``
1138
f.write("digraph dependencies {\n")
1139
for dist, adjs in self.adjacency_list.items():
1140
if len(adjs) == 0 and not skip_disconnected:
1141
disconnected.append(dist)
1142
for other, label in adjs:
1143
if not label is None:
1144
f.write('"%s" -> "%s" [label="%s"]\n' %
1145
(dist.name, other.name, label))
1147
f.write('"%s" -> "%s"\n' % (dist.name, other.name))
1148
if not skip_disconnected and len(disconnected) > 0:
1149
f.write('subgraph disconnected {\n')
1150
f.write('label = "Disconnected"\n')
1151
f.write('bgcolor = red\n')
1153
for dist in disconnected:
1154
f.write('"%s"' % dist.name)
1159
def topological_sort(self):
1161
Perform a topological sort of the graph.
1162
:return: A tuple, the first element of which is a topologically sorted
1163
list of distributions, and the second element of which is a
1164
list of distributions that cannot be sorted because they have
1165
circular dependencies and so form a cycle.
1168
# Make a shallow copy of the adjacency list
1170
for k, v in self.adjacency_list.items():
1173
# See what we can remove in this run
1175
for k, v in list(alist.items())[:]:
1180
# What's left in alist (if anything) is a cycle.
1182
# Remove from the adjacency list of others
1183
for k, v in alist.items():
1184
alist[k] = [(d, r) for d, r in v if d not in to_remove]
1185
logger.debug('Moving to result: %s',
1186
['%s (%s)' % (d.name, d.version) for d in to_remove])
1187
result.extend(to_remove)
1188
return result, list(alist.keys())
1191
"""Representation of the graph"""
1193
for dist, adjs in self.adjacency_list.items():
1194
output.append(self.repr_node(dist))
1195
return '\n'.join(output)
1198
def make_graph(dists, scheme='default'):
1199
"""Makes a dependency graph from the given distributions.
1201
:parameter dists: a list of distributions
1202
:type dists: list of :class:`distutils2.database.InstalledDistribution` and
1203
:class:`distutils2.database.EggInfoDistribution` instances
1204
:rtype: a :class:`DependencyGraph` instance
1206
scheme = get_scheme(scheme)
1207
graph = DependencyGraph()
1208
provided = {} # maps names to lists of (version, dist) tuples
1210
# first, build the graph and find out what's provided
1212
graph.add_distribution(dist)
1214
for p in dist.provides:
1215
name, version = parse_name_and_version(p)
1216
logger.debug('Add to provided: %s, %s, %s', name, version, dist)
1217
provided.setdefault(name, []).append((version, dist))
1219
# now make the edges
1221
requires = (dist.run_requires | dist.meta_requires |
1222
dist.build_requires | dist.dev_requires)
1223
for req in requires:
1225
matcher = scheme.matcher(req)
1226
except UnsupportedVersionError:
1227
# XXX compat-mode if cannot read the version
1228
logger.warning('could not read version %r - using name only',
1230
name = req.split()[0]
1231
matcher = scheme.matcher(name)
1233
name = matcher.key # case-insensitive
1236
if name in provided:
1237
for version, provider in provided[name]:
1239
match = matcher.match(version)
1240
except UnsupportedVersionError:
1244
graph.add_edge(dist, provider, req)
1248
graph.add_missing(dist, req)
1252
def get_dependent_dists(dists, dist):
1253
"""Recursively generate a list of distributions from *dists* that are
1254
dependent on *dist*.
1256
:param dists: a list of distributions
1257
:param dist: a distribution, member of *dists* for which we are interested
1259
if dist not in dists:
1260
raise DistlibException('given distribution %r is not a member '
1261
'of the list' % dist.name)
1262
graph = make_graph(dists)
1264
dep = [dist] # dependent distributions
1265
todo = graph.reverse_list[dist] # list of nodes we should inspect
1270
for succ in graph.reverse_list[d]:
1274
dep.pop(0) # remove dist from dep, was there to prevent infinite loops
1278
def get_required_dists(dists, dist):
1279
"""Recursively generate a list of distributions from *dists* that are
1282
:param dists: a list of distributions
1283
:param dist: a distribution, member of *dists* for which we are interested
1285
if dist not in dists:
1286
raise DistlibException('given distribution %r is not a member '
1287
'of the list' % dist.name)
1288
graph = make_graph(dists)
1290
req = [] # required distributions
1291
todo = graph.adjacency_list[dist] # list of nodes we should inspect
1296
for pred in graph.adjacency_list[d]:
1303
def make_dist(name, version, **kwargs):
1305
A convenience method for making a dist given just a name and version.
1307
summary = kwargs.pop('summary', 'Placeholder for summary')
1308
md = Metadata(**kwargs)
1310
md.version = version
1311
md.summary = summary or 'Plaeholder for summary'
1312
return Distribution(md)