Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1from __future__ import unicode_literals, absolute_import 

2 

3import io 

4import os 

5import re 

6import abc 

7import csv 

8import sys 

9import zipp 

10import operator 

11import functools 

12import itertools 

13import collections 

14 

15from ._compat import ( 

16 install, 

17 NullFinder, 

18 ConfigParser, 

19 suppress, 

20 map, 

21 FileNotFoundError, 

22 IsADirectoryError, 

23 NotADirectoryError, 

24 PermissionError, 

25 pathlib, 

26 PYPY_OPEN_BUG, 

27 ModuleNotFoundError, 

28 MetaPathFinder, 

29 email_message_from_string, 

30 ensure_is_path, 

31 PyPy_repr, 

32 ) 

33from importlib import import_module 

34from itertools import starmap 

35 

36 

37__metaclass__ = type 

38 

39 

40__all__ = [ 

41 'Distribution', 

42 'DistributionFinder', 

43 'PackageNotFoundError', 

44 'distribution', 

45 'distributions', 

46 'entry_points', 

47 'files', 

48 'metadata', 

49 'requires', 

50 'version', 

51 ] 

52 

53 

54class PackageNotFoundError(ModuleNotFoundError): 

55 """The package was not found.""" 

56 

57 

58class EntryPoint( 

59 PyPy_repr, 

60 collections.namedtuple('EntryPointBase', 'name value group')): 

61 """An entry point as defined by Python packaging conventions. 

62 

63 See `the packaging docs on entry points 

64 <https://packaging.python.org/specifications/entry-points/>`_ 

65 for more information. 

66 """ 

67 

68 pattern = re.compile( 

69 r'(?P<module>[\w.]+)\s*' 

70 r'(:\s*(?P<attr>[\w.]+))?\s*' 

71 r'(?P<extras>\[.*\])?\s*$' 

72 ) 

73 """ 

74 A regular expression describing the syntax for an entry point, 

75 which might look like: 

76 

77 - module 

78 - package.module 

79 - package.module:attribute 

80 - package.module:object.attribute 

81 - package.module:attr [extra1, extra2] 

82 

83 Other combinations are possible as well. 

84 

85 The expression is lenient about whitespace around the ':', 

86 following the attr, and following any extras. 

87 """ 

88 

89 def load(self): 

90 """Load the entry point from its definition. If only a module 

91 is indicated by the value, return that module. Otherwise, 

92 return the named object. 

93 """ 

94 match = self.pattern.match(self.value) 

95 module = import_module(match.group('module')) 

96 attrs = filter(None, (match.group('attr') or '').split('.')) 

97 return functools.reduce(getattr, attrs, module) 

98 

99 @property 

100 def extras(self): 

101 match = self.pattern.match(self.value) 

102 return list(re.finditer(r'\w+', match.group('extras') or '')) 

103 

104 @classmethod 

105 def _from_config(cls, config): 

106 return [ 

107 cls(name, value, group) 

108 for group in config.sections() 

109 for name, value in config.items(group) 

110 ] 

111 

112 @classmethod 

113 def _from_text(cls, text): 

114 config = ConfigParser(delimiters='=') 

115 # case sensitive: https://stackoverflow.com/q/1611799/812183 

116 config.optionxform = str 

117 try: 

118 config.read_string(text) 

119 except AttributeError: # pragma: nocover 

120 # Python 2 has no read_string 

121 config.readfp(io.StringIO(text)) 

122 return EntryPoint._from_config(config) 

123 

124 def __iter__(self): 

125 """ 

126 Supply iter so one may construct dicts of EntryPoints easily. 

127 """ 

128 return iter((self.name, self)) 

129 

130 def __reduce__(self): 

131 return ( 

132 self.__class__, 

133 (self.name, self.value, self.group), 

134 ) 

135 

136 

137class PackagePath(pathlib.PurePosixPath): 

138 """A reference to a path in a package""" 

139 

140 def read_text(self, encoding='utf-8'): 

141 with self.locate().open(encoding=encoding) as stream: 

142 return stream.read() 

143 

144 def read_binary(self): 

145 with self.locate().open('rb') as stream: 

146 return stream.read() 

147 

148 def locate(self): 

149 """Return a path-like object for this path""" 

150 return self.dist.locate_file(self) 

151 

152 

153class FileHash: 

154 def __init__(self, spec): 

155 self.mode, _, self.value = spec.partition('=') 

156 

157 def __repr__(self): 

158 return '<FileHash mode: {} value: {}>'.format(self.mode, self.value) 

159 

160 

161class Distribution: 

162 """A Python distribution package.""" 

163 

164 @abc.abstractmethod 

165 def read_text(self, filename): 

166 """Attempt to load metadata file given by the name. 

167 

168 :param filename: The name of the file in the distribution info. 

169 :return: The text if found, otherwise None. 

170 """ 

171 

172 @abc.abstractmethod 

173 def locate_file(self, path): 

174 """ 

175 Given a path to a file in this distribution, return a path 

176 to it. 

177 """ 

178 

179 @classmethod 

180 def from_name(cls, name): 

181 """Return the Distribution for the given package name. 

182 

183 :param name: The name of the distribution package to search for. 

184 :return: The Distribution instance (or subclass thereof) for the named 

185 package, if found. 

186 :raises PackageNotFoundError: When the named package's distribution 

187 metadata cannot be found. 

188 """ 

189 for resolver in cls._discover_resolvers(): 

190 dists = resolver(DistributionFinder.Context(name=name)) 

191 dist = next(dists, None) 

192 if dist is not None: 

193 return dist 

194 else: 

195 raise PackageNotFoundError(name) 

196 

197 @classmethod 

198 def discover(cls, **kwargs): 

199 """Return an iterable of Distribution objects for all packages. 

200 

201 Pass a ``context`` or pass keyword arguments for constructing 

202 a context. 

203 

204 :context: A ``DistributionFinder.Context`` object. 

205 :return: Iterable of Distribution objects for all packages. 

206 """ 

207 context = kwargs.pop('context', None) 

208 if context and kwargs: 

209 raise ValueError("cannot accept context and kwargs") 

210 context = context or DistributionFinder.Context(**kwargs) 

211 return itertools.chain.from_iterable( 

212 resolver(context) 

213 for resolver in cls._discover_resolvers() 

214 ) 

215 

216 @staticmethod 

217 def at(path): 

218 """Return a Distribution for the indicated metadata path 

219 

220 :param path: a string or path-like object 

221 :return: a concrete Distribution instance for the path 

222 """ 

223 return PathDistribution(ensure_is_path(path)) 

224 

225 @staticmethod 

226 def _discover_resolvers(): 

227 """Search the meta_path for resolvers.""" 

228 declared = ( 

229 getattr(finder, 'find_distributions', None) 

230 for finder in sys.meta_path 

231 ) 

232 return filter(None, declared) 

233 

234 @property 

235 def metadata(self): 

236 """Return the parsed metadata for this Distribution. 

237 

238 The returned object will have keys that name the various bits of 

239 metadata. See PEP 566 for details. 

240 """ 

241 text = ( 

242 self.read_text('METADATA') 

243 or self.read_text('PKG-INFO') 

244 # This last clause is here to support old egg-info files. Its 

245 # effect is to just end up using the PathDistribution's self._path 

246 # (which points to the egg-info file) attribute unchanged. 

247 or self.read_text('') 

248 ) 

249 return email_message_from_string(text) 

250 

251 @property 

252 def version(self): 

253 """Return the 'Version' metadata for the distribution package.""" 

254 return self.metadata['Version'] 

255 

256 @property 

257 def entry_points(self): 

258 return EntryPoint._from_text(self.read_text('entry_points.txt')) 

259 

260 @property 

261 def files(self): 

262 """Files in this distribution. 

263 

264 :return: List of PackagePath for this distribution or None 

265 

266 Result is `None` if the metadata file that enumerates files 

267 (i.e. RECORD for dist-info or SOURCES.txt for egg-info) is 

268 missing. 

269 Result may be empty if the metadata exists but is empty. 

270 """ 

271 file_lines = self._read_files_distinfo() or self._read_files_egginfo() 

272 

273 def make_file(name, hash=None, size_str=None): 

274 result = PackagePath(name) 

275 result.hash = FileHash(hash) if hash else None 

276 result.size = int(size_str) if size_str else None 

277 result.dist = self 

278 return result 

279 

280 return file_lines and list(starmap(make_file, csv.reader(file_lines))) 

281 

282 def _read_files_distinfo(self): 

283 """ 

284 Read the lines of RECORD 

285 """ 

286 text = self.read_text('RECORD') 

287 return text and text.splitlines() 

288 

289 def _read_files_egginfo(self): 

290 """ 

291 SOURCES.txt might contain literal commas, so wrap each line 

292 in quotes. 

293 """ 

294 text = self.read_text('SOURCES.txt') 

295 return text and map('"{}"'.format, text.splitlines()) 

296 

297 @property 

298 def requires(self): 

299 """Generated requirements specified for this Distribution""" 

300 reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() 

301 return reqs and list(reqs) 

302 

303 def _read_dist_info_reqs(self): 

304 return self.metadata.get_all('Requires-Dist') 

305 

306 def _read_egg_info_reqs(self): 

307 source = self.read_text('requires.txt') 

308 return source and self._deps_from_requires_text(source) 

309 

310 @classmethod 

311 def _deps_from_requires_text(cls, source): 

312 section_pairs = cls._read_sections(source.splitlines()) 

313 sections = { 

314 section: list(map(operator.itemgetter('line'), results)) 

315 for section, results in 

316 itertools.groupby(section_pairs, operator.itemgetter('section')) 

317 } 

318 return cls._convert_egg_info_reqs_to_simple_reqs(sections) 

319 

320 @staticmethod 

321 def _read_sections(lines): 

322 section = None 

323 for line in filter(None, lines): 

324 section_match = re.match(r'\[(.*)\]$', line) 

325 if section_match: 

326 section = section_match.group(1) 

327 continue 

328 yield locals() 

329 

330 @staticmethod 

331 def _convert_egg_info_reqs_to_simple_reqs(sections): 

332 """ 

333 Historically, setuptools would solicit and store 'extra' 

334 requirements, including those with environment markers, 

335 in separate sections. More modern tools expect each 

336 dependency to be defined separately, with any relevant 

337 extras and environment markers attached directly to that 

338 requirement. This method converts the former to the 

339 latter. See _test_deps_from_requires_text for an example. 

340 """ 

341 def make_condition(name): 

342 return name and 'extra == "{name}"'.format(name=name) 

343 

344 def parse_condition(section): 

345 section = section or '' 

346 extra, sep, markers = section.partition(':') 

347 if extra and markers: 

348 markers = '({markers})'.format(markers=markers) 

349 conditions = list(filter(None, [markers, make_condition(extra)])) 

350 return '; ' + ' and '.join(conditions) if conditions else '' 

351 

352 for section, deps in sections.items(): 

353 for dep in deps: 

354 yield dep + parse_condition(section) 

355 

356 

357class DistributionFinder(MetaPathFinder): 

358 """ 

359 A MetaPathFinder capable of discovering installed distributions. 

360 """ 

361 

362 class Context: 

363 

364 name = None 

365 """ 

366 Specific name for which a distribution finder should match. 

367 """ 

368 

369 def __init__(self, **kwargs): 

370 vars(self).update(kwargs) 

371 

372 @property 

373 def path(self): 

374 """ 

375 The path that a distribution finder should search. 

376 """ 

377 return vars(self).get('path', sys.path) 

378 

379 @property 

380 def pattern(self): 

381 return '.*' if self.name is None else re.escape(self.name) 

382 

383 @abc.abstractmethod 

384 def find_distributions(self, context=Context()): 

385 """ 

386 Find distributions. 

387 

388 Return an iterable of all Distribution instances capable of 

389 loading the metadata for packages matching the ``context``, 

390 a DistributionFinder.Context instance. 

391 """ 

392 

393 

394@install 

395class MetadataPathFinder(NullFinder, DistributionFinder): 

396 """A degenerate finder for distribution packages on the file system. 

397 

398 This finder supplies only a find_distributions() method for versions 

399 of Python that do not have a PathFinder find_distributions(). 

400 """ 

401 

402 def find_distributions(self, context=DistributionFinder.Context()): 

403 """ 

404 Find distributions. 

405 

406 Return an iterable of all Distribution instances capable of 

407 loading the metadata for packages matching ``context.name`` 

408 (or all names if ``None`` indicated) along the paths in the list 

409 of directories ``context.path``. 

410 """ 

411 found = self._search_paths(context.pattern, context.path) 

412 return map(PathDistribution, found) 

413 

414 @classmethod 

415 def _search_paths(cls, pattern, paths): 

416 """Find metadata directories in paths heuristically.""" 

417 return itertools.chain.from_iterable( 

418 cls._search_path(path, pattern) 

419 for path in map(cls._switch_path, paths) 

420 ) 

421 

422 @staticmethod 

423 def _switch_path(path): 

424 if not PYPY_OPEN_BUG or os.path.isfile(path): # pragma: no branch 

425 with suppress(Exception): 

426 return zipp.Path(path) 

427 return pathlib.Path(path) 

428 

429 @classmethod 

430 def _matches_info(cls, normalized, item): 

431 template = r'{pattern}(-.*)?\.(dist|egg)-info' 

432 manifest = template.format(pattern=normalized) 

433 return re.match(manifest, item.name, flags=re.IGNORECASE) 

434 

435 @classmethod 

436 def _matches_legacy(cls, normalized, item): 

437 template = r'{pattern}-.*\.egg[\\/]EGG-INFO' 

438 manifest = template.format(pattern=normalized) 

439 return re.search(manifest, str(item), flags=re.IGNORECASE) 

440 

441 @classmethod 

442 def _search_path(cls, root, pattern): 

443 if not root.is_dir(): 

444 return () 

445 normalized = pattern.replace('-', '_') 

446 return (item for item in root.iterdir() 

447 if cls._matches_info(normalized, item) 

448 or cls._matches_legacy(normalized, item)) 

449 

450 

451class PathDistribution(Distribution): 

452 def __init__(self, path): 

453 """Construct a distribution from a path to the metadata directory. 

454 

455 :param path: A pathlib.Path or similar object supporting 

456 .joinpath(), __div__, .parent, and .read_text(). 

457 """ 

458 self._path = path 

459 

460 def read_text(self, filename): 

461 with suppress(FileNotFoundError, IsADirectoryError, KeyError, 

462 NotADirectoryError, PermissionError): 

463 return self._path.joinpath(filename).read_text(encoding='utf-8') 

464 read_text.__doc__ = Distribution.read_text.__doc__ 

465 

466 def locate_file(self, path): 

467 return self._path.parent / path 

468 

469 

470def distribution(distribution_name): 

471 """Get the ``Distribution`` instance for the named package. 

472 

473 :param distribution_name: The name of the distribution package as a string. 

474 :return: A ``Distribution`` instance (or subclass thereof). 

475 """ 

476 return Distribution.from_name(distribution_name) 

477 

478 

479def distributions(**kwargs): 

480 """Get all ``Distribution`` instances in the current environment. 

481 

482 :return: An iterable of ``Distribution`` instances. 

483 """ 

484 return Distribution.discover(**kwargs) 

485 

486 

487def metadata(distribution_name): 

488 """Get the metadata for the named package. 

489 

490 :param distribution_name: The name of the distribution package to query. 

491 :return: An email.Message containing the parsed metadata. 

492 """ 

493 return Distribution.from_name(distribution_name).metadata 

494 

495 

496def version(distribution_name): 

497 """Get the version string for the named package. 

498 

499 :param distribution_name: The name of the distribution package to query. 

500 :return: The version string for the package as defined in the package's 

501 "Version" metadata key. 

502 """ 

503 return distribution(distribution_name).version 

504 

505 

506def entry_points(): 

507 """Return EntryPoint objects for all installed packages. 

508 

509 :return: EntryPoint objects for all installed packages. 

510 """ 

511 eps = itertools.chain.from_iterable( 

512 dist.entry_points for dist in distributions()) 

513 by_group = operator.attrgetter('group') 

514 ordered = sorted(eps, key=by_group) 

515 grouped = itertools.groupby(ordered, by_group) 

516 return { 

517 group: tuple(eps) 

518 for group, eps in grouped 

519 } 

520 

521 

522def files(distribution_name): 

523 """Return a list of files for the named package. 

524 

525 :param distribution_name: The name of the distribution package to query. 

526 :return: List of files composing the distribution. 

527 """ 

528 return distribution(distribution_name).files 

529 

530 

531def requires(distribution_name): 

532 """ 

533 Return a list of requirements for the named package. 

534 

535 :return: An iterator of requirements, suitable for 

536 packaging.requirement.Requirement. 

537 """ 

538 return distribution(distribution_name).requires 

539 

540 

541__version__ = version(__name__)