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