blob: e80f4fa6409bc7b08e99ac1d7fb2851bace6191e [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):
216 file_lines = self._read_files_distinfo() or self._read_files_egginfo()
217
218 def make_file(name, hash=None, size_str=None):
219 result = PackagePath(name)
220 result.hash = FileHash(hash) if hash else None
221 result.size = int(size_str) if size_str else None
222 result.dist = self
223 return result
224
225 return file_lines and starmap(make_file, csv.reader(file_lines))
226
227 def _read_files_distinfo(self):
228 """
229 Read the lines of RECORD
230 """
231 text = self.read_text('RECORD')
232 return text and text.splitlines()
233
234 def _read_files_egginfo(self):
235 """
236 SOURCES.txt might contain literal commas, so wrap each line
237 in quotes.
238 """
239 text = self.read_text('SOURCES.txt')
240 return text and map('"{}"'.format, text.splitlines())
241
242 @property
243 def requires(self):
244 """Generated requirements specified for this Distribution"""
245 return self._read_dist_info_reqs() or self._read_egg_info_reqs()
246
247 def _read_dist_info_reqs(self):
248 spec = self.metadata['Requires-Dist']
249 return spec and filter(None, spec.splitlines())
250
251 def _read_egg_info_reqs(self):
252 source = self.read_text('requires.txt')
253 return source and self._deps_from_requires_text(source)
254
255 @classmethod
256 def _deps_from_requires_text(cls, source):
257 section_pairs = cls._read_sections(source.splitlines())
258 sections = {
259 section: list(map(operator.itemgetter('line'), results))
260 for section, results in
261 itertools.groupby(section_pairs, operator.itemgetter('section'))
262 }
263 return cls._convert_egg_info_reqs_to_simple_reqs(sections)
264
265 @staticmethod
266 def _read_sections(lines):
267 section = None
268 for line in filter(None, lines):
269 section_match = re.match(r'\[(.*)\]$', line)
270 if section_match:
271 section = section_match.group(1)
272 continue
273 yield locals()
274
275 @staticmethod
276 def _convert_egg_info_reqs_to_simple_reqs(sections):
277 """
278 Historically, setuptools would solicit and store 'extra'
279 requirements, including those with environment markers,
280 in separate sections. More modern tools expect each
281 dependency to be defined separately, with any relevant
282 extras and environment markers attached directly to that
283 requirement. This method converts the former to the
284 latter. See _test_deps_from_requires_text for an example.
285 """
286 def make_condition(name):
287 return name and 'extra == "{name}"'.format(name=name)
288
289 def parse_condition(section):
290 section = section or ''
291 extra, sep, markers = section.partition(':')
292 if extra and markers:
293 markers = '({markers})'.format(markers=markers)
294 conditions = list(filter(None, [markers, make_condition(extra)]))
295 return '; ' + ' and '.join(conditions) if conditions else ''
296
297 for section, deps in sections.items():
298 for dep in deps:
299 yield dep + parse_condition(section)
300
301
302class DistributionFinder(MetaPathFinder):
303 """
304 A MetaPathFinder capable of discovering installed distributions.
305 """
306
307 @abc.abstractmethod
308 def find_distributions(self, name=None, path=None):
309 """
310 Find distributions.
311
312 Return an iterable of all Distribution instances capable of
313 loading the metadata for packages matching the ``name``
314 (or all names if not supplied) along the paths in the list
315 of directories ``path`` (defaults to sys.path).
316 """
317
318
319class PathDistribution(Distribution):
320 def __init__(self, path):
321 """Construct a distribution from a path to the metadata directory."""
322 self._path = path
323
324 def read_text(self, filename):
Anthony Sottile80878312019-05-29 17:13:12 -0700325 with suppress(FileNotFoundError, IsADirectoryError, KeyError,
326 NotADirectoryError, PermissionError):
Jason R. Coombs1bbf7b62019-05-24 19:59:01 -0400327 return self._path.joinpath(filename).read_text(encoding='utf-8')
328 read_text.__doc__ = Distribution.read_text.__doc__
329
330 def locate_file(self, path):
331 return self._path.parent / path
332
333
334def distribution(package):
335 """Get the ``Distribution`` instance for the given package.
336
337 :param package: The name of the package as a string.
338 :return: A ``Distribution`` instance (or subclass thereof).
339 """
340 return Distribution.from_name(package)
341
342
343def distributions():
344 """Get all ``Distribution`` instances in the current environment.
345
346 :return: An iterable of ``Distribution`` instances.
347 """
348 return Distribution.discover()
349
350
351def metadata(package):
352 """Get the metadata for the package.
353
354 :param package: The name of the distribution package to query.
355 :return: An email.Message containing the parsed metadata.
356 """
357 return Distribution.from_name(package).metadata
358
359
360def version(package):
361 """Get the version string for the named package.
362
363 :param package: The name of the distribution package to query.
364 :return: The version string for the package as defined in the package's
365 "Version" metadata key.
366 """
367 return distribution(package).version
368
369
370def entry_points():
371 """Return EntryPoint objects for all installed packages.
372
373 :return: EntryPoint objects for all installed packages.
374 """
375 eps = itertools.chain.from_iterable(
376 dist.entry_points for dist in distributions())
377 by_group = operator.attrgetter('group')
378 ordered = sorted(eps, key=by_group)
379 grouped = itertools.groupby(ordered, by_group)
380 return {
381 group: tuple(eps)
382 for group, eps in grouped
383 }
384
385
386def files(package):
387 return distribution(package).files
388
389
390def requires(package):
391 """
392 Return a list of requirements for the indicated distribution.
393
394 :return: An iterator of requirements, suitable for
395 packaging.requirement.Requirement.
396 """
397 return distribution(package).requires