blob: e23076654fe456b26bc8e32c5a06755d5dc86cc8 [file] [log] [blame]
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -04001import io
2import re
3import abc
4import csv
5import sys
6import email
7import pathlib
8import operator
9import functools
10import itertools
11import collections
12
13from configparser import ConfigParser
14from contextlib import suppress
15from importlib import import_module
16from importlib.abc import MetaPathFinder
17from itertools import starmap
18
19
20__all__ = [
21 'Distribution',
Jason R. Coombs17499d82019-09-10 14:53:31 +010022 'DistributionFinder',
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040023 'PackageNotFoundError',
24 'distribution',
25 'distributions',
26 'entry_points',
27 'files',
28 'metadata',
29 'requires',
30 'version',
31 ]
32
33
34class PackageNotFoundError(ModuleNotFoundError):
35 """The package was not found."""
36
37
38class EntryPoint(collections.namedtuple('EntryPointBase', 'name value group')):
39 """An entry point as defined by Python packaging conventions.
40
41 See `the packaging docs on entry points
42 <https://packaging.python.org/specifications/entry-points/>`_
43 for more information.
44 """
45
46 pattern = re.compile(
47 r'(?P<module>[\w.]+)\s*'
48 r'(:\s*(?P<attr>[\w.]+))?\s*'
49 r'(?P<extras>\[.*\])?\s*$'
50 )
51 """
52 A regular expression describing the syntax for an entry point,
53 which might look like:
54
55 - module
56 - package.module
57 - package.module:attribute
58 - package.module:object.attribute
59 - package.module:attr [extra1, extra2]
60
61 Other combinations are possible as well.
62
63 The expression is lenient about whitespace around the ':',
64 following the attr, and following any extras.
65 """
66
67 def load(self):
68 """Load the entry point from its definition. If only a module
69 is indicated by the value, return that module. Otherwise,
70 return the named object.
71 """
72 match = self.pattern.match(self.value)
73 module = import_module(match.group('module'))
74 attrs = filter(None, (match.group('attr') or '').split('.'))
75 return functools.reduce(getattr, attrs, module)
76
77 @property
78 def extras(self):
79 match = self.pattern.match(self.value)
80 return list(re.finditer(r'\w+', match.group('extras') or ''))
81
82 @classmethod
83 def _from_config(cls, config):
84 return [
85 cls(name, value, group)
86 for group in config.sections()
87 for name, value in config.items(group)
88 ]
89
90 @classmethod
91 def _from_text(cls, text):
Jason R. Coombs049460d2019-07-28 14:59:24 -040092 config = ConfigParser(delimiters='=')
Anthony Sottile65e58602019-06-07 14:23:39 -070093 # case sensitive: https://stackoverflow.com/q/1611799/812183
94 config.optionxform = str
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -040095 try:
96 config.read_string(text)
97 except AttributeError: # pragma: nocover
98 # Python 2 has no read_string
99 config.readfp(io.StringIO(text))
100 return EntryPoint._from_config(config)
101
102 def __iter__(self):
103 """
104 Supply iter so one may construct dicts of EntryPoints easily.
105 """
106 return iter((self.name, self))
107
108
109class PackagePath(pathlib.PurePosixPath):
110 """A reference to a path in a package"""
111
112 def read_text(self, encoding='utf-8'):
113 with self.locate().open(encoding=encoding) as stream:
114 return stream.read()
115
116 def read_binary(self):
117 with self.locate().open('rb') as stream:
118 return stream.read()
119
120 def locate(self):
121 """Return a path-like object for this path"""
122 return self.dist.locate_file(self)
123
124
125class FileHash:
126 def __init__(self, spec):
127 self.mode, _, self.value = spec.partition('=')
128
129 def __repr__(self):
130 return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
131
132
133class Distribution:
134 """A Python distribution package."""
135
136 @abc.abstractmethod
137 def read_text(self, filename):
138 """Attempt to load metadata file given by the name.
139
140 :param filename: The name of the file in the distribution info.
141 :return: The text if found, otherwise None.
142 """
143
144 @abc.abstractmethod
145 def locate_file(self, path):
146 """
147 Given a path to a file in this distribution, return a path
148 to it.
149 """
150
151 @classmethod
152 def from_name(cls, name):
153 """Return the Distribution for the given package name.
154
155 :param name: The name of the distribution package to search for.
156 :return: The Distribution instance (or subclass thereof) for the named
157 package, if found.
158 :raises PackageNotFoundError: When the named package's distribution
159 metadata cannot be found.
160 """
161 for resolver in cls._discover_resolvers():
Jason R. Coombs17499d82019-09-10 14:53:31 +0100162 dists = resolver(DistributionFinder.Context(name=name))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400163 dist = next(dists, None)
164 if dist is not None:
165 return dist
166 else:
167 raise PackageNotFoundError(name)
168
169 @classmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100170 def discover(cls, **kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400171 """Return an iterable of Distribution objects for all packages.
172
Jason R. Coombs17499d82019-09-10 14:53:31 +0100173 Pass a ``context`` or pass keyword arguments for constructing
174 a context.
175
176 :context: A ``DistributionFinder.Context`` object.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400177 :return: Iterable of Distribution objects for all packages.
178 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100179 context = kwargs.pop('context', None)
180 if context and kwargs:
181 raise ValueError("cannot accept context and kwargs")
182 context = context or DistributionFinder.Context(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400183 return itertools.chain.from_iterable(
Jason R. Coombs17499d82019-09-10 14:53:31 +0100184 resolver(context)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400185 for resolver in cls._discover_resolvers()
186 )
187
188 @staticmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100189 def at(path):
190 """Return a Distribution for the indicated metadata path
191
192 :param path: a string or path-like object
193 :return: a concrete Distribution instance for the path
194 """
195 return PathDistribution(pathlib.Path(path))
196
197 @staticmethod
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400198 def _discover_resolvers():
199 """Search the meta_path for resolvers."""
200 declared = (
201 getattr(finder, 'find_distributions', None)
202 for finder in sys.meta_path
203 )
204 return filter(None, declared)
205
206 @property
207 def metadata(self):
208 """Return the parsed metadata for this Distribution.
209
210 The returned object will have keys that name the various bits of
211 metadata. See PEP 566 for details.
212 """
213 text = (
214 self.read_text('METADATA')
215 or self.read_text('PKG-INFO')
216 # This last clause is here to support old egg-info files. Its
217 # effect is to just end up using the PathDistribution's self._path
218 # (which points to the egg-info file) attribute unchanged.
219 or self.read_text('')
220 )
221 return email.message_from_string(text)
222
223 @property
224 def version(self):
225 """Return the 'Version' metadata for the distribution package."""
226 return self.metadata['Version']
227
228 @property
229 def entry_points(self):
230 return EntryPoint._from_text(self.read_text('entry_points.txt'))
231
232 @property
233 def files(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400234 """Files in this distribution.
235
Jason R. Coombs17499d82019-09-10 14:53:31 +0100236 :return: List of PackagePath for this distribution or None
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400237
238 Result is `None` if the metadata file that enumerates files
239 (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is
240 missing.
241 Result may be empty if the metadata exists but is empty.
242 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400243 file_lines = self._read_files_distinfo() or self._read_files_egginfo()
244
245 def make_file(name, hash=None, size_str=None):
246 result = PackagePath(name)
247 result.hash = FileHash(hash) if hash else None
248 result.size = int(size_str) if size_str else None
249 result.dist = self
250 return result
251
Jason R. Coombs17499d82019-09-10 14:53:31 +0100252 return file_lines and list(starmap(make_file, csv.reader(file_lines)))
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400253
254 def _read_files_distinfo(self):
255 """
256 Read the lines of RECORD
257 """
258 text = self.read_text('RECORD')
259 return text and text.splitlines()
260
261 def _read_files_egginfo(self):
262 """
263 SOURCES.txt might contain literal commas, so wrap each line
264 in quotes.
265 """
266 text = self.read_text('SOURCES.txt')
267 return text and map('"{}"'.format, text.splitlines())
268
269 @property
270 def requires(self):
271 """Generated requirements specified for this Distribution"""
Jason R. Coombs17499d82019-09-10 14:53:31 +0100272 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs()
273 return reqs and list(reqs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400274
275 def _read_dist_info_reqs(self):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400276 return self.metadata.get_all('Requires-Dist')
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400277
278 def _read_egg_info_reqs(self):
279 source = self.read_text('requires.txt')
280 return source and self._deps_from_requires_text(source)
281
282 @classmethod
283 def _deps_from_requires_text(cls, source):
284 section_pairs = cls._read_sections(source.splitlines())
285 sections = {
286 section: list(map(operator.itemgetter('line'), results))
287 for section, results in
288 itertools.groupby(section_pairs, operator.itemgetter('section'))
289 }
290 return cls._convert_egg_info_reqs_to_simple_reqs(sections)
291
292 @staticmethod
293 def _read_sections(lines):
294 section = None
295 for line in filter(None, lines):
296 section_match = re.match(r'\[(.*)\]$', line)
297 if section_match:
298 section = section_match.group(1)
299 continue
300 yield locals()
301
302 @staticmethod
303 def _convert_egg_info_reqs_to_simple_reqs(sections):
304 """
305 Historically, setuptools would solicit and store 'extra'
306 requirements, including those with environment markers,
307 in separate sections. More modern tools expect each
308 dependency to be defined separately, with any relevant
309 extras and environment markers attached directly to that
310 requirement. This method converts the former to the
311 latter. See _test_deps_from_requires_text for an example.
312 """
313 def make_condition(name):
314 return name and 'extra == "{name}"'.format(name=name)
315
316 def parse_condition(section):
317 section = section or ''
318 extra, sep, markers = section.partition(':')
319 if extra and markers:
320 markers = '({markers})'.format(markers=markers)
321 conditions = list(filter(None, [markers, make_condition(extra)]))
322 return '; ' + ' and '.join(conditions) if conditions else ''
323
324 for section, deps in sections.items():
325 for dep in deps:
326 yield dep + parse_condition(section)
327
328
329class DistributionFinder(MetaPathFinder):
330 """
331 A MetaPathFinder capable of discovering installed distributions.
332 """
333
Jason R. Coombs17499d82019-09-10 14:53:31 +0100334 class Context:
335
336 name = None
337 """
338 Specific name for which a distribution finder should match.
339 """
340
341 def __init__(self, **kwargs):
342 vars(self).update(kwargs)
343
344 @property
345 def path(self):
346 """
347 The path that a distribution finder should search.
348 """
349 return vars(self).get('path', sys.path)
350
351 @property
352 def pattern(self):
353 return '.*' if self.name is None else re.escape(self.name)
354
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400355 @abc.abstractmethod
Jason R. Coombs17499d82019-09-10 14:53:31 +0100356 def find_distributions(self, context=Context()):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400357 """
358 Find distributions.
359
360 Return an iterable of all Distribution instances capable of
Jason R. Coombs17499d82019-09-10 14:53:31 +0100361 loading the metadata for packages matching the ``context``,
362 a DistributionFinder.Context instance.
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400363 """
364
365
366class PathDistribution(Distribution):
367 def __init__(self, path):
Jason R. Coombs102e9b42019-09-02 11:08:03 -0400368 """Construct a distribution from a path to the metadata directory.
369
370 :param path: A pathlib.Path or similar object supporting
371 .joinpath(), __div__, .parent, and .read_text().
372 """
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400373 self._path = path
374
375 def read_text(self, filename):
Anthony Sottile80878312019-05-29 17:13:12 -0700376 with suppress(FileNotFoundError, IsADirectoryError, KeyError,
377 NotADirectoryError, PermissionError):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400378 return self._path.joinpath(filename).read_text(encoding='utf-8')
379 read_text.__doc__ = Distribution.read_text.__doc__
380
381 def locate_file(self, path):
382 return self._path.parent / path
383
384
385def distribution(package):
386 """Get the ``Distribution`` instance for the given package.
387
388 :param package: The name of the package as a string.
389 :return: A ``Distribution`` instance (or subclass thereof).
390 """
391 return Distribution.from_name(package)
392
393
Jason R. Coombs17499d82019-09-10 14:53:31 +0100394def distributions(**kwargs):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400395 """Get all ``Distribution`` instances in the current environment.
396
397 :return: An iterable of ``Distribution`` instances.
398 """
Jason R. Coombs17499d82019-09-10 14:53:31 +0100399 return Distribution.discover(**kwargs)
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400400
401
402def metadata(package):
403 """Get the metadata for the package.
404
405 :param package: The name of the distribution package to query.
406 :return: An email.Message containing the parsed metadata.
407 """
408 return Distribution.from_name(package).metadata
409
410
411def version(package):
412 """Get the version string for the named package.
413
414 :param package: The name of the distribution package to query.
415 :return: The version string for the package as defined in the package's
416 "Version" metadata key.
417 """
418 return distribution(package).version
419
420
421def entry_points():
422 """Return EntryPoint objects for all installed packages.
423
424 :return: EntryPoint objects for all installed packages.
425 """
426 eps = itertools.chain.from_iterable(
427 dist.entry_points for dist in distributions())
428 by_group = operator.attrgetter('group')
429 ordered = sorted(eps, key=by_group)
430 grouped = itertools.groupby(ordered, by_group)
431 return {
432 group: tuple(eps)
433 for group, eps in grouped
434 }
435
436
437def files(package):
438 return distribution(package).files
439
440
441def requires(package):
442 """
443 Return a list of requirements for the indicated distribution.
444
445 :return: An iterator of requirements, suitable for
446 packaging.requirement.Requirement.
447 """
448 return distribution(package).requires