blob: 8cb45ec1ef3a299dd9b21f1fddb61d50324cb7ee [file] [log] [blame]
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04001import io
Jason R. Coombs8ed65032019-09-12 10:29:11 +01002import os
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04003import re
4import abc
5import csv
6import sys
7import email
8import pathlib
Jason R. Coombs8ed65032019-09-12 10:29:11 +01009import zipfile
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040010import operator
11import functools
12import itertools
13import collections
14
15from configparser import ConfigParser
16from contextlib import suppress
17from importlib import import_module
18from importlib.abc import MetaPathFinder
19from itertools import starmap
20
21
22__all__ = [
23 'Distribution',
Jason R. Coombs17499d82019-09-10 14:53:31 +010024 'DistributionFinder',
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040025 'PackageNotFoundError',
26 'distribution',
27 'distributions',
28 'entry_points',
29 'files',
30 'metadata',
31 'requires',
32 'version',
33 ]
34
35
36class PackageNotFoundError(ModuleNotFoundError):
37 """The package was not found."""
38
39
40class EntryPoint(collections.namedtuple('EntryPointBase', 'name value group')):
41 """An entry point as defined by Python packaging conventions.
42
43 See `the packaging docs on entry points
44 <https://packaging.python.org/specifications/entry-points/>`_
45 for more information.
46 """
47
48 pattern = re.compile(
49 r'(?P<module>[\w.]+)\s*'
50 r'(:\s*(?P<attr>[\w.]+))?\s*'
51 r'(?P<extras>\[.*\])?\s*$'
52 )
53 """
54 A regular expression describing the syntax for an entry point,
55 which might look like:
56
57 - module
58 - package.module
59 - package.module:attribute
60 - package.module:object.attribute
61 - package.module:attr [extra1, extra2]
62
63 Other combinations are possible as well.
64
65 The expression is lenient about whitespace around the ':',
66 following the attr, and following any extras.
67 """
68
69 def load(self):
70 """Load the entry point from its definition. If only a module
71 is indicated by the value, return that module. Otherwise,
72 return the named object.
73 """
74 match = self.pattern.match(self.value)
75 module = import_module(match.group('module'))
76 attrs = filter(None, (match.group('attr') or '').split('.'))
77 return functools.reduce(getattr, attrs, module)
78
79 @property
80 def extras(self):
81 match = self.pattern.match(self.value)
82 return list(re.finditer(r'\w+', match.group('extras') or ''))
83
84 @classmethod
85 def _from_config(cls, config):
86 return [
87 cls(name, value, group)
88 for group in config.sections()
89 for name, value in config.items(group)
90 ]
91
92 @classmethod
93 def _from_text(cls, text):
Jason R. Coombs049460d2019-07-28 14:59:24 -040094 config = ConfigParser(delimiters='=')
Anthony Sottile65e58602019-06-07 14:23:39 -070095 # case sensitive: https://stackoverflow.com/q/1611799/812183
96 config.optionxform = str
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040097 try:
98 config.read_string(text)
99 except AttributeError: # pragma: nocover
100 # Python 2 has no read_string
101 config.readfp(io.StringIO(text))
102 return EntryPoint._from_config(config)
103
104 def __iter__(self):
105 """
106 Supply iter so one may construct dicts of EntryPoints easily.
107 """
108 return iter((self.name, self))
109
110
111class PackagePath(pathlib.PurePosixPath):
112 """A reference to a path in a package"""
113
114 def read_text(self, encoding='utf-8'):
115 with self.locate().open(encoding=encoding) as stream:
116 return stream.read()
117
118 def read_binary(self):
119 with self.locate().open('rb') as stream:
120 return stream.read()
121
122 def locate(self):
123 """Return a path-like object for this path"""
124 return self.dist.locate_file(self)
125
126
127class FileHash:
128 def __init__(self, spec):
129 self.mode, _, self.value = spec.partition('=')
130
131 def __repr__(self):
132 return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
133
134
135class Distribution:
136 """A Python distribution package."""
137
138 @abc.abstractmethod
139 def read_text(self, filename):
140 """Attempt to load metadata file given by the name.
141
142 :param filename: The name of the file in the distribution info.
143 :return: The text if found, otherwise None.
144 """
145
146 @abc.abstractmethod
147 def locate_file(self, path):
148 """
149 Given a path to a file in this distribution, return a path
150 to it.
151 """
152
153 @classmethod
154 def from_name(cls, name):
155 """Return the Distribution for the given package name.
156
157 :param name: The name of the distribution package to search for.
158 :return: The Distribution instance (or subclass thereof) for the named
159 package, if found.
160 :raises PackageNotFoundError: When the named package's distribution
161 metadata cannot be found.
162 """
163 for resolver in cls._discover_resolvers():
Jason R. Coombs17499d82019-09-10 14:53:31 +0100164 dists = resolver(DistributionFinder.Context(name=name))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400165 dist = next(dists, None)
166 if dist is not None:
167 return dist
168 else:
169 raise PackageNotFoundError(name)
170
171 @classmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100172 def discover(cls, **kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400173 """Return an iterable of Distribution objects for all packages.
174
Jason R. Coombs17499d82019-09-10 14:53:31 +0100175 Pass a ``context`` or pass keyword arguments for constructing
176 a context.
177
178 :context: A ``DistributionFinder.Context`` object.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400179 :return: Iterable of Distribution objects for all packages.
180 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100181 context = kwargs.pop('context', None)
182 if context and kwargs:
183 raise ValueError("cannot accept context and kwargs")
184 context = context or DistributionFinder.Context(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400185 return itertools.chain.from_iterable(
Jason R. Coombs17499d82019-09-10 14:53:31 +0100186 resolver(context)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400187 for resolver in cls._discover_resolvers()
188 )
189
190 @staticmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100191 def at(path):
192 """Return a Distribution for the indicated metadata path
193
194 :param path: a string or path-like object
195 :return: a concrete Distribution instance for the path
196 """
197 return PathDistribution(pathlib.Path(path))
198
199 @staticmethod
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400200 def _discover_resolvers():
201 """Search the meta_path for resolvers."""
202 declared = (
203 getattr(finder, 'find_distributions', None)
204 for finder in sys.meta_path
205 )
206 return filter(None, declared)
207
208 @property
209 def metadata(self):
210 """Return the parsed metadata for this Distribution.
211
212 The returned object will have keys that name the various bits of
213 metadata. See PEP 566 for details.
214 """
215 text = (
216 self.read_text('METADATA')
217 or self.read_text('PKG-INFO')
218 # This last clause is here to support old egg-info files. Its
219 # effect is to just end up using the PathDistribution's self._path
220 # (which points to the egg-info file) attribute unchanged.
221 or self.read_text('')
222 )
223 return email.message_from_string(text)
224
225 @property
226 def version(self):
227 """Return the 'Version' metadata for the distribution package."""
228 return self.metadata['Version']
229
230 @property
231 def entry_points(self):
232 return EntryPoint._from_text(self.read_text('entry_points.txt'))
233
234 @property
235 def files(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400236 """Files in this distribution.
237
Jason R. Coombs17499d82019-09-10 14:53:31 +0100238 :return: List of PackagePath for this distribution or None
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400239
240 Result is `None` if the metadata file that enumerates files
241 (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
242 missing.
243 Result may be empty if the metadata exists but is empty.
244 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400245 file_lines = self._read_files_distinfo() or self._read_files_egginfo()
246
247 def make_file(name, hash=None, size_str=None):
248 result = PackagePath(name)
249 result.hash = FileHash(hash) if hash else None
250 result.size = int(size_str) if size_str else None
251 result.dist = self
252 return result
253
Jason R. Coombs17499d82019-09-10 14:53:31 +0100254 return file_lines and list(starmap(make_file, csv.reader(file_lines)))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400255
256 def _read_files_distinfo(self):
257 """
258 Read the lines of RECORD
259 """
260 text = self.read_text('RECORD')
261 return text and text.splitlines()
262
263 def _read_files_egginfo(self):
264 """
265 SOURCES.txt might contain literal commas, so wrap each line
266 in quotes.
267 """
268 text = self.read_text('SOURCES.txt')
269 return text and map('"{}"'.format, text.splitlines())
270
271 @property
272 def requires(self):
273 """Generated requirements specified for this Distribution"""
Jason R. Coombs17499d82019-09-10 14:53:31 +0100274 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
275 return reqs and list(reqs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400276
277 def _read_dist_info_reqs(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400278 return self.metadata.get_all('Requires-Dist')
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400279
280 def _read_egg_info_reqs(self):
281 source = self.read_text('requires.txt')
282 return source and self._deps_from_requires_text(source)
283
284 @classmethod
285 def _deps_from_requires_text(cls, source):
286 section_pairs = cls._read_sections(source.splitlines())
287 sections = {
288 section: list(map(operator.itemgetter('line'), results))
289 for section, results in
290 itertools.groupby(section_pairs, operator.itemgetter('section'))
291 }
292 return cls._convert_egg_info_reqs_to_simple_reqs(sections)
293
294 @staticmethod
295 def _read_sections(lines):
296 section = None
297 for line in filter(None, lines):
298 section_match = re.match(r'\[(.*)\]$', line)
299 if section_match:
300 section = section_match.group(1)
301 continue
302 yield locals()
303
304 @staticmethod
305 def _convert_egg_info_reqs_to_simple_reqs(sections):
306 """
307 Historically, setuptools would solicit and store 'extra'
308 requirements, including those with environment markers,
309 in separate sections. More modern tools expect each
310 dependency to be defined separately, with any relevant
311 extras and environment markers attached directly to that
312 requirement. This method converts the former to the
313 latter. See _test_deps_from_requires_text for an example.
314 """
315 def make_condition(name):
316 return name and 'extra == "{name}"'.format(name=name)
317
318 def parse_condition(section):
319 section = section or ''
320 extra, sep, markers = section.partition(':')
321 if extra and markers:
322 markers = '({markers})'.format(markers=markers)
323 conditions = list(filter(None, [markers, make_condition(extra)]))
324 return '; ' + ' and '.join(conditions) if conditions else ''
325
326 for section, deps in sections.items():
327 for dep in deps:
328 yield dep + parse_condition(section)
329
330
331class DistributionFinder(MetaPathFinder):
332 """
333 A MetaPathFinder capable of discovering installed distributions.
334 """
335
Jason R. Coombs17499d82019-09-10 14:53:31 +0100336 class Context:
337
338 name = None
339 """
340 Specific name for which a distribution finder should match.
341 """
342
343 def __init__(self, **kwargs):
344 vars(self).update(kwargs)
345
346 @property
347 def path(self):
348 """
349 The path that a distribution finder should search.
350 """
351 return vars(self).get('path', sys.path)
352
353 @property
354 def pattern(self):
355 return '.*' if self.name is None else re.escape(self.name)
356
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400357 @abc.abstractmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100358 def find_distributions(self, context=Context()):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400359 """
360 Find distributions.
361
362 Return an iterable of all Distribution instances capable of
Jason R. Coombs17499d82019-09-10 14:53:31 +0100363 loading the metadata for packages matching the ``context``,
364 a DistributionFinder.Context instance.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400365 """
366
367
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100368class MetadataPathFinder(DistributionFinder):
369 @classmethod
370 def find_distributions(cls, context=DistributionFinder.Context()):
371 """
372 Find distributions.
373
374 Return an iterable of all Distribution instances capable of
375 loading the metadata for packages matching ``context.name``
376 (or all names if ``None`` indicated) along the paths in the list
377 of directories ``context.path``.
378 """
379 found = cls._search_paths(context.pattern, context.path)
380 return map(PathDistribution, found)
381
382 @classmethod
383 def _search_paths(cls, pattern, paths):
384 """Find metadata directories in paths heuristically."""
385 return itertools.chain.from_iterable(
386 cls._search_path(path, pattern)
387 for path in map(cls._switch_path, paths)
388 )
389
390 @staticmethod
391 def _switch_path(path):
392 PYPY_OPEN_BUG = False
393 if not PYPY_OPEN_BUG or os.path.isfile(path): # pragma: no branch
394 with suppress(Exception):
395 return zipfile.Path(path)
396 return pathlib.Path(path)
397
398 @classmethod
399 def _matches_info(cls, normalized, item):
400 template = r'{pattern}(-.*)?\.(dist|egg)-info'
401 manifest = template.format(pattern=normalized)
402 return re.match(manifest, item.name, flags=re.IGNORECASE)
403
404 @classmethod
405 def _matches_legacy(cls, normalized, item):
406 template = r'{pattern}-.*\.egg[\\/]EGG-INFO'
407 manifest = template.format(pattern=normalized)
408 return re.search(manifest, str(item), flags=re.IGNORECASE)
409
410 @classmethod
411 def _search_path(cls, root, pattern):
412 if not root.is_dir():
413 return ()
414 normalized = pattern.replace('-', '_')
415 return (item for item in root.iterdir()
416 if cls._matches_info(normalized, item)
417 or cls._matches_legacy(normalized, item))
418
419
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400420class PathDistribution(Distribution):
421 def __init__(self, path):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400422 """Construct a distribution from a path to the metadata directory.
423
424 :param path: A pathlib.Path or similar object supporting
425 .joinpath(), __div__, .parent, and .read_text().
426 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400427 self._path = path
428
429 def read_text(self, filename):
Anthony Sottile80878312019-05-29 17:13:12 -0700430 with suppress(FileNotFoundError, IsADirectoryError, KeyError,
431 NotADirectoryError, PermissionError):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400432 return self._path.joinpath(filename).read_text(encoding='utf-8')
433 read_text.__doc__ = Distribution.read_text.__doc__
434
435 def locate_file(self, path):
436 return self._path.parent / path
437
438
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100439def distribution(distribution_name):
440 """Get the ``Distribution`` instance for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400441
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100442 :param distribution_name: The name of the distribution package as a string.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400443 :return: A ``Distribution`` instance (or subclass thereof).
444 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100445 return Distribution.from_name(distribution_name)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400446
447
Jason R. Coombs17499d82019-09-10 14:53:31 +0100448def distributions(**kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400449 """Get all ``Distribution`` instances in the current environment.
450
451 :return: An iterable of ``Distribution`` instances.
452 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100453 return Distribution.discover(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400454
455
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100456def metadata(distribution_name):
457 """Get the metadata for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400458
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100459 :param distribution_name: The name of the distribution package to query.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400460 :return: An email.Message containing the parsed metadata.
461 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100462 return Distribution.from_name(distribution_name).metadata
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400463
464
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100465def version(distribution_name):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400466 """Get the version string for the named package.
467
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100468 :param distribution_name: The name of the distribution package to query.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400469 :return: The version string for the package as defined in the package's
470 "Version" metadata key.
471 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100472 return distribution(distribution_name).version
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400473
474
475def entry_points():
476 """Return EntryPoint objects for all installed packages.
477
478 :return: EntryPoint objects for all installed packages.
479 """
480 eps = itertools.chain.from_iterable(
481 dist.entry_points for dist in distributions())
482 by_group = operator.attrgetter('group')
483 ordered = sorted(eps, key=by_group)
484 grouped = itertools.groupby(ordered, by_group)
485 return {
486 group: tuple(eps)
487 for group, eps in grouped
488 }
489
490
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100491def files(distribution_name):
492 """Return a list of files for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400493
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100494 :param distribution_name: The name of the distribution package to query.
495 :return: List of files composing the distribution.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400496 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100497 return distribution(distribution_name).files
498
499
500def requires(distribution_name):
501 """
502 Return a list of requirements for the named package.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400503
504 :return: An iterator of requirements, suitable for
505 packaging.requirement.Requirement.
506 """
Jason R. Coombs8ed65032019-09-12 10:29:11 +0100507 return distribution(distribution_name).requires