Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/importlib_metadata/__init__.py: 57%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

425 statements  

1""" 

2APIs exposing metadata from third-party Python packages. 

3 

4This codebase is shared between importlib.metadata in the stdlib 

5and importlib_metadata in PyPI. See 

6https://github.com/python/importlib_metadata/wiki/Development-Methodology 

7for more detail. 

8""" 

9 

10from __future__ import annotations 

11 

12import abc 

13import collections 

14import email 

15import functools 

16import itertools 

17import operator 

18import os 

19import pathlib 

20import posixpath 

21import re 

22import sys 

23import textwrap 

24import types 

25from collections.abc import Iterable, Mapping 

26from contextlib import suppress 

27from importlib import import_module 

28from importlib.abc import MetaPathFinder 

29from itertools import starmap 

30from typing import Any 

31 

32from . import _meta 

33from ._collections import FreezableDefaultDict, Pair 

34from ._compat import ( 

35 NullFinder, 

36 install, 

37) 

38from ._context import ExceptionTrap 

39from ._functools import method_cache, noop, pass_none, passthrough 

40from ._itertools import always_iterable, bucket, unique_everseen 

41from ._meta import PackageMetadata, SimplePath 

42from .compat import py311 

43 

44__all__ = [ 

45 'Distribution', 

46 'DistributionFinder', 

47 'MetadataNotFound', 

48 'PackageMetadata', 

49 'PackageNotFoundError', 

50 'PackagePath', 

51 'SimplePath', 

52 'distribution', 

53 'distributions', 

54 'entry_points', 

55 'files', 

56 'metadata', 

57 'packages_distributions', 

58 'requires', 

59 'version', 

60] 

61 

62 

63class PackageNotFoundError(ModuleNotFoundError): 

64 """The package was not found.""" 

65 

66 def __str__(self) -> str: 

67 return f"No package metadata was found for {self.name}" 

68 

69 @property 

70 def name(self) -> str: # type: ignore[override] # make readonly 

71 (name,) = self.args 

72 return name 

73 

74 

75class MetadataNotFound(FileNotFoundError): 

76 """No metadata file is present in the distribution.""" 

77 

78 

79class Sectioned: 

80 """ 

81 A simple entry point config parser for performance 

82 

83 >>> for item in Sectioned.read(Sectioned._sample): 

84 ... print(item) 

85 Pair(name='sec1', value='# comments ignored') 

86 Pair(name='sec1', value='a = 1') 

87 Pair(name='sec1', value='b = 2') 

88 Pair(name='sec2', value='a = 2') 

89 

90 >>> res = Sectioned.section_pairs(Sectioned._sample) 

91 >>> item = next(res) 

92 >>> item.name 

93 'sec1' 

94 >>> item.value 

95 Pair(name='a', value='1') 

96 >>> item = next(res) 

97 >>> item.value 

98 Pair(name='b', value='2') 

99 >>> item = next(res) 

100 >>> item.name 

101 'sec2' 

102 >>> item.value 

103 Pair(name='a', value='2') 

104 >>> list(res) 

105 [] 

106 """ 

107 

108 _sample = textwrap.dedent( 

109 """ 

110 [sec1] 

111 # comments ignored 

112 a = 1 

113 b = 2 

114 

115 [sec2] 

116 a = 2 

117 """ 

118 ).lstrip() 

119 

120 @classmethod 

121 def section_pairs(cls, text): 

122 return ( 

123 section._replace(value=Pair.parse(section.value)) 

124 for section in cls.read(text, filter_=cls.valid) 

125 if section.name is not None 

126 ) 

127 

128 @staticmethod 

129 def read(text, filter_=None): 

130 lines = filter(filter_, map(str.strip, text.splitlines())) 

131 name = None 

132 for value in lines: 

133 section_match = value.startswith('[') and value.endswith(']') 

134 if section_match: 

135 name = value.strip('[]') 

136 continue 

137 yield Pair(name, value) 

138 

139 @staticmethod 

140 def valid(line: str): 

141 return line and not line.startswith('#') 

142 

143 

144class _EntryPointMatch(types.SimpleNamespace): 

145 module: str 

146 attr: str 

147 extras: str 

148 

149 

150class EntryPoint: 

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

152 

153 See `the packaging docs on entry points 

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

155 for more information. 

156 

157 >>> ep = EntryPoint( 

158 ... name=None, group=None, value='package.module:attr [extra1, extra2]') 

159 >>> ep.module 

160 'package.module' 

161 >>> ep.attr 

162 'attr' 

163 >>> ep.extras 

164 ['extra1', 'extra2'] 

165 

166 If the value package or module are not valid identifiers, a 

167 ValueError is raised on access. 

168 

169 >>> EntryPoint(name=None, group=None, value='invalid-name').module 

170 Traceback (most recent call last): 

171 ... 

172 ValueError: ('Invalid object reference...invalid-name... 

173 >>> EntryPoint(name=None, group=None, value='invalid-name').attr 

174 Traceback (most recent call last): 

175 ... 

176 ValueError: ('Invalid object reference...invalid-name... 

177 >>> EntryPoint(name=None, group=None, value='invalid-name').extras 

178 Traceback (most recent call last): 

179 ... 

180 ValueError: ('Invalid object reference...invalid-name... 

181 

182 The same thing happens on construction. 

183 

184 >>> EntryPoint(name=None, group=None, value='invalid-name') 

185 Traceback (most recent call last): 

186 ... 

187 ValueError: ('Invalid object reference...invalid-name... 

188 

189 """ 

190 

191 pattern = re.compile( 

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

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

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

195 ) 

196 """ 

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

198 which might look like: 

199 

200 - module 

201 - package.module 

202 - package.module:attribute 

203 - package.module:object.attribute 

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

205 

206 Other combinations are possible as well. 

207 

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

209 following the attr, and following any extras. 

210 """ 

211 

212 name: str 

213 value: str 

214 group: str 

215 

216 dist: Distribution | None = None 

217 

218 def __init__(self, name: str, value: str, group: str) -> None: 

219 vars(self).update(name=name, value=value, group=group) 

220 # resolve the value now, raising ValueError if it's invalid 

221 _ = self.module 

222 

223 def load(self) -> Any: 

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

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

226 return the named object. 

227 """ 

228 module = import_module(self.module) 

229 attrs = filter(None, (self.attr or '').split('.')) 

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

231 

232 @property 

233 def module(self) -> str: 

234 return self._match.module 

235 

236 @property 

237 def attr(self) -> str: 

238 return self._match.attr 

239 

240 @property 

241 def extras(self) -> list[str]: 

242 return re.findall(r'\w+', self._match.extras or '') 

243 

244 @functools.cached_property 

245 def _match(self) -> _EntryPointMatch: 

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

247 if not match: 

248 raise ValueError( 

249 'Invalid object reference. ' 

250 'See https://packaging.python.org' 

251 '/en/latest/specifications/entry-points/#data-model', 

252 self.value, 

253 ) 

254 return _EntryPointMatch(**match.groupdict()) 

255 

256 def _for(self, dist): 

257 vars(self).update(dist=dist) 

258 return self 

259 

260 def matches(self, **params): 

261 """ 

262 EntryPoint matches the given parameters. 

263 

264 >>> ep = EntryPoint(group='foo', name='bar', value='bing:bong [extra1, extra2]') 

265 >>> ep.matches(group='foo') 

266 True 

267 >>> ep.matches(name='bar', value='bing:bong [extra1, extra2]') 

268 True 

269 >>> ep.matches(group='foo', name='other') 

270 False 

271 >>> ep.matches() 

272 True 

273 >>> ep.matches(extras=['extra1', 'extra2']) 

274 True 

275 >>> ep.matches(module='bing') 

276 True 

277 >>> ep.matches(attr='bong') 

278 True 

279 """ 

280 self._disallow_dist(params) 

281 attrs = (getattr(self, param) for param in params) 

282 return all(map(operator.eq, params.values(), attrs)) 

283 

284 @staticmethod 

285 def _disallow_dist(params): 

286 """ 

287 Querying by dist is not allowed (dist objects are not comparable). 

288 >>> EntryPoint(name='fan', value='fav', group='fag').matches(dist='foo') 

289 Traceback (most recent call last): 

290 ... 

291 ValueError: "dist" is not suitable for matching... 

292 """ 

293 if "dist" in params: 

294 raise ValueError( 

295 '"dist" is not suitable for matching. ' 

296 "Instead, use Distribution.entry_points.select() on a " 

297 "located distribution." 

298 ) 

299 

300 def _key(self): 

301 return self.name, self.value, self.group 

302 

303 def __lt__(self, other): 

304 return self._key() < other._key() 

305 

306 def __eq__(self, other): 

307 return self._key() == other._key() 

308 

309 def __setattr__(self, name, value): 

310 raise AttributeError("EntryPoint objects are immutable.") 

311 

312 def __repr__(self): 

313 return ( 

314 f'EntryPoint(name={self.name!r}, value={self.value!r}, ' 

315 f'group={self.group!r})' 

316 ) 

317 

318 def __hash__(self) -> int: 

319 return hash(self._key()) 

320 

321 

322class EntryPoints(tuple): 

323 """ 

324 An immutable collection of selectable EntryPoint objects. 

325 """ 

326 

327 __slots__ = () 

328 

329 def __getitem__(self, name: str) -> EntryPoint: # type: ignore[override] # Work with str instead of int 

330 """ 

331 Get the EntryPoint in self matching name. 

332 """ 

333 try: 

334 return next(iter(self.select(name=name))) 

335 except StopIteration: 

336 raise KeyError(name) 

337 

338 def __repr__(self): 

339 """ 

340 Repr with classname and tuple constructor to 

341 signal that we deviate from regular tuple behavior. 

342 """ 

343 return f'{self.__class__.__name__}({tuple(self)!r})' 

344 

345 def select(self, **params) -> EntryPoints: 

346 """ 

347 Select entry points from self that match the 

348 given parameters (typically group and/or name). 

349 """ 

350 return EntryPoints(ep for ep in self if ep.matches(**params)) 

351 

352 @property 

353 def names(self) -> set[str]: 

354 """ 

355 Return the set of all names of all entry points. 

356 """ 

357 return {ep.name for ep in self} 

358 

359 @property 

360 def groups(self) -> set[str]: 

361 """ 

362 Return the set of all groups of all entry points. 

363 """ 

364 return {ep.group for ep in self} 

365 

366 @classmethod 

367 def _from_text_for(cls, text, dist): 

368 return cls(ep._for(dist) for ep in cls._from_text(text)) 

369 

370 @staticmethod 

371 def _from_text(text): 

372 return ( 

373 EntryPoint(name=item.value.name, value=item.value.value, group=item.name) 

374 for item in Sectioned.section_pairs(text or '') 

375 ) 

376 

377 

378class PackagePath(pathlib.PurePosixPath): 

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

380 

381 hash: FileHash | None 

382 size: int 

383 dist: Distribution 

384 

385 def read_text(self, encoding: str = 'utf-8') -> str: 

386 return self.locate().read_text(encoding=encoding) 

387 

388 def read_binary(self) -> bytes: 

389 return self.locate().read_bytes() 

390 

391 def locate(self) -> SimplePath: 

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

393 return self.dist.locate_file(self) 

394 

395 

396class FileHash: 

397 def __init__(self, spec: str) -> None: 

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

399 

400 def __repr__(self) -> str: 

401 return f'<FileHash mode: {self.mode} value: {self.value}>' 

402 

403 

404class Distribution(metaclass=abc.ABCMeta): 

405 """ 

406 An abstract Python distribution package. 

407 

408 Custom providers may derive from this class and define 

409 the abstract methods to provide a concrete implementation 

410 for their environment. Some providers may opt to override 

411 the default implementation of some properties to bypass 

412 the file-reading mechanism. 

413 """ 

414 

415 @abc.abstractmethod 

416 def read_text(self, filename) -> str | None: 

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

418 

419 Python distribution metadata is organized by blobs of text 

420 typically represented as "files" in the metadata directory 

421 (e.g. package-1.0.dist-info). These files include things 

422 like: 

423 

424 - METADATA: The distribution metadata including fields 

425 like Name and Version and Description. 

426 - entry_points.txt: A series of entry points as defined in 

427 `the entry points spec <https://packaging.python.org/en/latest/specifications/entry-points/#file-format>`_. 

428 - RECORD: A record of files according to 

429 `this recording spec <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#the-record-file>`_. 

430 

431 A package may provide any set of files, including those 

432 not listed here or none at all. 

433 

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

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

436 """ 

437 

438 @abc.abstractmethod 

439 def locate_file(self, path: str | os.PathLike[str]) -> SimplePath: 

440 """ 

441 Given a path to a file in this distribution, return a SimplePath 

442 to it. 

443 

444 This method is used by callers of ``Distribution.files()`` to 

445 locate files within the distribution. If it's possible for a 

446 Distribution to represent files in the distribution as 

447 ``SimplePath`` objects, it should implement this method 

448 to resolve such objects. 

449 

450 Some Distribution providers may elect not to resolve SimplePath 

451 objects within the distribution by raising a 

452 NotImplementedError, but consumers of such a Distribution would 

453 be unable to invoke ``Distribution.files()``. 

454 """ 

455 

456 @classmethod 

457 def from_name(cls, name: str) -> Distribution: 

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

459 

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

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

462 package, if found. 

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

464 metadata cannot be found. 

465 :raises ValueError: When an invalid value is supplied for name. 

466 """ 

467 if not name: 

468 raise ValueError("A distribution name is required.") 

469 try: 

470 return next(iter(cls._prefer_valid(cls.discover(name=name)))) 

471 except StopIteration: 

472 raise PackageNotFoundError(name) from None 

473 

474 @classmethod 

475 def discover( 

476 cls, *, context: DistributionFinder.Context | None = None, **kwargs 

477 ) -> Iterable[Distribution]: 

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

479 

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

481 a context. 

482 

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

484 :return: Iterable of Distribution objects for packages matching 

485 the context. 

486 """ 

487 if context and kwargs: 

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

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

490 return itertools.chain.from_iterable( 

491 resolver(context) for resolver in cls._discover_resolvers() 

492 ) 

493 

494 @staticmethod 

495 def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]: 

496 """ 

497 Prefer (move to the front) distributions that have metadata. 

498 

499 Ref python/importlib_resources#489. 

500 """ 

501 

502 has_metadata = ExceptionTrap(MetadataNotFound).passes( 

503 operator.attrgetter('metadata') 

504 ) 

505 

506 buckets = bucket(dists, has_metadata) 

507 return itertools.chain(buckets[True], buckets[False]) 

508 

509 @staticmethod 

510 def at(path: str | os.PathLike[str]) -> Distribution: 

511 """Return a Distribution for the indicated metadata path. 

512 

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

514 :return: a concrete Distribution instance for the path 

515 """ 

516 return PathDistribution(pathlib.Path(path)) 

517 

518 @staticmethod 

519 def _discover_resolvers(): 

520 """Search the meta_path for resolvers (MetadataPathFinders).""" 

521 declared = ( 

522 getattr(finder, 'find_distributions', None) for finder in sys.meta_path 

523 ) 

524 return filter(None, declared) 

525 

526 @property 

527 def metadata(self) -> _meta.PackageMetadata: 

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

529 

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

531 metadata per the 

532 `Core metadata specifications <https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata>`_. 

533 

534 Custom providers may provide the METADATA file or override this 

535 property. 

536 

537 :raises MetadataNotFound: If no metadata file is present. 

538 """ 

539 

540 text = ( 

541 self.read_text('METADATA') 

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

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

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

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

546 or self.read_text('') 

547 ) 

548 return self._assemble_message(self._ensure_metadata_present(text)) 

549 

550 @staticmethod 

551 def _assemble_message(text: str) -> _meta.PackageMetadata: 

552 # deferred for performance (python/cpython#109829) 

553 from . import _adapters 

554 

555 return _adapters.Message(email.message_from_string(text)) 

556 

557 def _ensure_metadata_present(self, text: str | None) -> str: 

558 if text is not None: 

559 return text 

560 

561 raise MetadataNotFound('No package metadata was found.') 

562 

563 @property 

564 def name(self) -> str: 

565 """Return the 'Name' metadata for the distribution package.""" 

566 return self.metadata['Name'] 

567 

568 @property 

569 def _normalized_name(self): 

570 """Return a normalized version of the name.""" 

571 return Prepared.normalize(self.name) 

572 

573 @property 

574 def version(self) -> str: 

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

576 return self.metadata['Version'] 

577 

578 @property 

579 def entry_points(self) -> EntryPoints: 

580 """ 

581 Return EntryPoints for this distribution. 

582 

583 Custom providers may provide the ``entry_points.txt`` file 

584 or override this property. 

585 """ 

586 return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) 

587 

588 @property 

589 def files(self) -> list[PackagePath] | None: 

590 """Files in this distribution. 

591 

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

593 

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

595 (i.e. RECORD for dist-info, or installed-files.txt or 

596 SOURCES.txt for egg-info) is missing. 

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

598 

599 Custom providers are recommended to provide a "RECORD" file (in 

600 ``read_text``) or override this property to allow for callers to be 

601 able to resolve filenames provided by the package. 

602 """ 

603 

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

605 result = PackagePath(name) 

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

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

608 result.dist = self 

609 return result 

610 

611 @pass_none 

612 def make_files(lines): 

613 # Delay csv import, since Distribution.files is not as widely used 

614 # as other parts of importlib.metadata 

615 import csv 

616 

617 return starmap(make_file, csv.reader(lines)) 

618 

619 @pass_none 

620 def skip_missing_files(package_paths): 

621 return list(filter(lambda path: path.locate().exists(), package_paths)) 

622 

623 return skip_missing_files( 

624 make_files( 

625 self._read_files_distinfo() 

626 or self._read_files_egginfo_installed() 

627 or self._read_files_egginfo_sources() 

628 ) 

629 ) 

630 

631 def _read_files_distinfo(self): 

632 """ 

633 Read the lines of RECORD. 

634 """ 

635 text = self.read_text('RECORD') 

636 return text and text.splitlines() 

637 

638 def _read_files_egginfo_installed(self): 

639 """ 

640 Read installed-files.txt and return lines in a similar 

641 CSV-parsable format as RECORD: each file must be placed 

642 relative to the site-packages directory and must also be 

643 quoted (since file names can contain literal commas). 

644 

645 This file is written when the package is installed by pip, 

646 but it might not be written for other installation methods. 

647 Assume the file is accurate if it exists. 

648 """ 

649 text = self.read_text('installed-files.txt') 

650 # Prepend the .egg-info/ subdir to the lines in this file. 

651 # But this subdir is only available from PathDistribution's 

652 # self._path. 

653 subdir = getattr(self, '_path', None) 

654 if not text or not subdir: 

655 return 

656 

657 paths = ( 

658 py311 

659 .relative_fix((subdir / name).resolve()) 

660 .relative_to(self.locate_file('').resolve(), walk_up=True) 

661 .as_posix() 

662 for name in text.splitlines() 

663 ) 

664 return map('"{}"'.format, paths) 

665 

666 def _read_files_egginfo_sources(self): 

667 """ 

668 Read SOURCES.txt and return lines in a similar CSV-parsable 

669 format as RECORD: each file name must be quoted (since it 

670 might contain literal commas). 

671 

672 Note that SOURCES.txt is not a reliable source for what 

673 files are installed by a package. This file is generated 

674 for a source archive, and the files that are present 

675 there (e.g. setup.py) may not correctly reflect the files 

676 that are present after the package has been installed. 

677 """ 

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

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

680 

681 @property 

682 def requires(self) -> list[str] | None: 

683 """Generated requirements specified for this Distribution""" 

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

685 return reqs and list(reqs) 

686 

687 def _read_dist_info_reqs(self): 

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

689 

690 def _read_egg_info_reqs(self): 

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

692 return pass_none(self._deps_from_requires_text)(source) 

693 

694 @classmethod 

695 def _deps_from_requires_text(cls, source): 

696 return cls._convert_egg_info_reqs_to_simple_reqs(Sectioned.read(source)) 

697 

698 @staticmethod 

699 def _convert_egg_info_reqs_to_simple_reqs(sections): 

700 """ 

701 Historically, setuptools would solicit and store 'extra' 

702 requirements, including those with environment markers, 

703 in separate sections. More modern tools expect each 

704 dependency to be defined separately, with any relevant 

705 extras and environment markers attached directly to that 

706 requirement. This method converts the former to the 

707 latter. See _test_deps_from_requires_text for an example. 

708 """ 

709 

710 def make_condition(name): 

711 return name and f'extra == "{name}"' 

712 

713 def quoted_marker(section): 

714 section = section or '' 

715 extra, _sep, markers = section.partition(':') 

716 if extra and markers: 

717 markers = f'({markers})' 

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

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

720 

721 def url_req_space(req): 

722 """ 

723 PEP 508 requires a space between the url_spec and the quoted_marker. 

724 Ref python/importlib_metadata#357. 

725 """ 

726 # '@' is uniquely indicative of a url_req. 

727 return ' ' * ('@' in req) 

728 

729 for section in sections: 

730 space = url_req_space(section.value) 

731 yield section.value + space + quoted_marker(section.name) 

732 

733 @property 

734 def origin(self): 

735 return self._load_json('direct_url.json') 

736 

737 def _load_json(self, filename): 

738 # Deferred for performance (python/importlib_metadata#503) 

739 import json 

740 

741 return pass_none(json.loads)( 

742 self.read_text(filename), 

743 object_hook=lambda data: types.SimpleNamespace(**data), 

744 ) 

745 

746 

747class DistributionFinder(MetaPathFinder): 

748 """ 

749 A MetaPathFinder capable of discovering installed distributions. 

750 

751 Custom providers should implement this interface in order to 

752 supply metadata. 

753 """ 

754 

755 class Context: 

756 """ 

757 Keyword arguments presented by the caller to 

758 ``distributions()`` or ``Distribution.discover()`` 

759 to narrow the scope of a search for distributions 

760 in all DistributionFinders. 

761 

762 Each DistributionFinder may expect any parameters 

763 and should attempt to honor the canonical 

764 parameters defined below when appropriate. 

765 

766 This mechanism gives a custom provider a means to 

767 solicit additional details from the caller beyond 

768 "name" and "path" when searching distributions. 

769 For example, imagine a provider that exposes suites 

770 of packages in either a "public" or "private" ``realm``. 

771 A caller may wish to query only for distributions in 

772 a particular realm and could call 

773 ``distributions(realm="private")`` to signal to the 

774 custom provider to only include distributions from that 

775 realm. 

776 """ 

777 

778 name = None 

779 """ 

780 Specific name for which a distribution finder should match. 

781 A name of ``None`` matches all distributions. 

782 """ 

783 

784 def __init__(self, **kwargs): 

785 vars(self).update(kwargs) 

786 

787 @property 

788 def path(self) -> list[str]: 

789 """ 

790 The sequence of directory path that a distribution finder 

791 should search. 

792 

793 Typically refers to Python installed package paths such as 

794 "site-packages" directories and defaults to ``sys.path``. 

795 """ 

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

797 

798 @abc.abstractmethod 

799 def find_distributions(self, context=Context()) -> Iterable[Distribution]: 

800 """ 

801 Find distributions. 

802 

803 Return an iterable of all Distribution instances capable of 

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

805 a DistributionFinder.Context instance. 

806 """ 

807 

808 

809@passthrough 

810def _clear_after_fork(cached): 

811 """Ensure ``func`` clears cached state after ``fork`` when supported. 

812 

813 ``FastPath`` caches zip-backed ``pathlib.Path`` objects that retain a 

814 reference to the parent's open ``ZipFile`` handle. Re-using a cached 

815 instance in a forked child can therefore resurrect invalid file pointers 

816 and trigger ``BadZipFile``/``OSError`` failures (python/importlib_metadata#520). 

817 Registering ``cache_clear`` with ``os.register_at_fork`` keeps each process 

818 on its own cache. 

819 """ 

820 getattr(os, 'register_at_fork', noop)(after_in_child=cached.cache_clear) 

821 

822 

823class FastPath: 

824 """ 

825 Micro-optimized class for searching a root for children. 

826 

827 Root is a path on the file system that may contain metadata 

828 directories either as natural directories or within a zip file. 

829 

830 >>> FastPath('').children() 

831 ['...'] 

832 

833 FastPath objects are cached and recycled for any given root. 

834 

835 >>> FastPath('foobar') is FastPath('foobar') 

836 True 

837 """ 

838 

839 @_clear_after_fork # type: ignore[misc] 

840 @functools.lru_cache 

841 def __new__(cls, root): 

842 return super().__new__(cls) 

843 

844 def __init__(self, root): 

845 self.root = root 

846 

847 def joinpath(self, child): 

848 return pathlib.Path(self.root, child) 

849 

850 def children(self): 

851 with suppress(Exception): 

852 return os.listdir(self.root or '.') 

853 with suppress(Exception): 

854 return self.zip_children() 

855 return [] 

856 

857 def zip_children(self): 

858 # deferred for performance (python/importlib_metadata#502) 

859 from zipp.compat.overlay import zipfile 

860 

861 zip_path = zipfile.Path(self.root) 

862 names = zip_path.root.namelist() 

863 self.joinpath = zip_path.joinpath 

864 

865 return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names) 

866 

867 def search(self, name): 

868 return self.lookup(self.mtime).search(name) 

869 

870 @property 

871 def mtime(self): 

872 with suppress(OSError): 

873 return os.stat(self.root).st_mtime 

874 self.lookup.cache_clear() 

875 

876 @method_cache 

877 def lookup(self, mtime): 

878 return Lookup(self) 

879 

880 

881class Lookup: 

882 """ 

883 A micro-optimized class for searching a (fast) path for metadata. 

884 """ 

885 

886 def __init__(self, path: FastPath): 

887 """ 

888 Calculate all of the children representing metadata. 

889 

890 From the children in the path, calculate early all of the 

891 children that appear to represent metadata (infos) or legacy 

892 metadata (eggs). 

893 """ 

894 

895 base = os.path.basename(path.root).lower() 

896 base_is_egg = base.endswith(".egg") 

897 self.infos = FreezableDefaultDict(list) 

898 self.eggs = FreezableDefaultDict(list) 

899 

900 for child in path.children(): 

901 low = child.lower() 

902 if low.endswith((".dist-info", ".egg-info")): 

903 # rpartition is faster than splitext and suitable for this purpose. 

904 name = low.rpartition(".")[0].partition("-")[0] 

905 normalized = Prepared.normalize(name) 

906 self.infos[normalized].append(path.joinpath(child)) 

907 elif base_is_egg and low == "egg-info": 

908 name = base.rpartition(".")[0].partition("-")[0] 

909 legacy_normalized = Prepared.legacy_normalize(name) 

910 self.eggs[legacy_normalized].append(path.joinpath(child)) 

911 

912 self.infos.freeze() 

913 self.eggs.freeze() 

914 

915 def search(self, prepared: Prepared): 

916 """ 

917 Yield all infos and eggs matching the Prepared query. 

918 """ 

919 infos = ( 

920 self.infos[prepared.normalized] 

921 if prepared 

922 else itertools.chain.from_iterable(self.infos.values()) 

923 ) 

924 eggs = ( 

925 self.eggs[prepared.legacy_normalized] 

926 if prepared 

927 else itertools.chain.from_iterable(self.eggs.values()) 

928 ) 

929 return itertools.chain(infos, eggs) 

930 

931 

932class Prepared: 

933 """ 

934 A prepared search query for metadata on a possibly-named package. 

935 

936 Pre-calculates the normalization to prevent repeated operations. 

937 

938 >>> none = Prepared(None) 

939 >>> none.normalized 

940 >>> none.legacy_normalized 

941 >>> bool(none) 

942 False 

943 >>> sample = Prepared('Sample__Pkg-name.foo') 

944 >>> sample.normalized 

945 'sample_pkg_name_foo' 

946 >>> sample.legacy_normalized 

947 'sample__pkg_name.foo' 

948 >>> bool(sample) 

949 True 

950 """ 

951 

952 normalized = None 

953 legacy_normalized = None 

954 

955 def __init__(self, name: str | None): 

956 self.name = name 

957 if name is None: 

958 return 

959 self.normalized = self.normalize(name) 

960 self.legacy_normalized = self.legacy_normalize(name) 

961 

962 @staticmethod 

963 def normalize(name): 

964 """ 

965 PEP 503 normalization plus dashes as underscores. 

966 

967 Specifically avoids ``re.sub`` as prescribed for performance 

968 benefits (see python/cpython#143658). 

969 """ 

970 value = name.lower().replace("-", "_").replace(".", "_") 

971 # Condense repeats 

972 while "__" in value: 

973 value = value.replace("__", "_") 

974 return value 

975 

976 @staticmethod 

977 def legacy_normalize(name): 

978 """ 

979 Normalize the package name as found in the convention in 

980 older packaging tools versions and specs. 

981 """ 

982 return name.lower().replace('-', '_') 

983 

984 def __bool__(self): 

985 return bool(self.name) 

986 

987 

988@install 

989class MetadataPathFinder(NullFinder, DistributionFinder): 

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

991 

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

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

994 """ 

995 

996 @classmethod 

997 def find_distributions( 

998 cls, context=DistributionFinder.Context() 

999 ) -> Iterable[PathDistribution]: 

1000 """ 

1001 Find distributions. 

1002 

1003 Return an iterable of all Distribution instances capable of 

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

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

1006 of directories ``context.path``. 

1007 """ 

1008 found = cls._search_paths(context.name, context.path) 

1009 return map(PathDistribution, found) 

1010 

1011 @classmethod 

1012 def _search_paths(cls, name, paths): 

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

1014 prepared = Prepared(name) 

1015 return itertools.chain.from_iterable( 

1016 path.search(prepared) for path in map(FastPath, paths) 

1017 ) 

1018 

1019 @classmethod 

1020 def invalidate_caches(cls) -> None: 

1021 FastPath.__new__.cache_clear() 

1022 

1023 

1024class PathDistribution(Distribution): 

1025 def __init__(self, path: SimplePath) -> None: 

1026 """Construct a distribution. 

1027 

1028 :param path: SimplePath indicating the metadata directory. 

1029 """ 

1030 self._path = path 

1031 

1032 def read_text(self, filename: str | os.PathLike[str]) -> str | None: 

1033 with suppress( 

1034 FileNotFoundError, 

1035 IsADirectoryError, 

1036 KeyError, 

1037 NotADirectoryError, 

1038 PermissionError, 

1039 ): 

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

1041 

1042 return None 

1043 

1044 read_text.__doc__ = Distribution.read_text.__doc__ 

1045 

1046 def locate_file(self, path: str | os.PathLike[str]) -> SimplePath: 

1047 return self._path.parent / path 

1048 

1049 @property 

1050 def _normalized_name(self): 

1051 """ 

1052 Performance optimization: where possible, resolve the 

1053 normalized name from the file system path. 

1054 """ 

1055 stem = os.path.basename(str(self._path)) 

1056 return ( 

1057 pass_none(Prepared.normalize)(self._name_from_stem(stem)) 

1058 or super()._normalized_name 

1059 ) 

1060 

1061 @staticmethod 

1062 def _name_from_stem(stem): 

1063 """ 

1064 >>> PathDistribution._name_from_stem('foo-3.0.egg-info') 

1065 'foo' 

1066 >>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info') 

1067 'CherryPy' 

1068 >>> PathDistribution._name_from_stem('face.egg-info') 

1069 'face' 

1070 >>> PathDistribution._name_from_stem('foo.bar') 

1071 """ 

1072 filename, ext = os.path.splitext(stem) 

1073 if ext not in ('.dist-info', '.egg-info'): 

1074 return 

1075 name, _sep, _rest = filename.partition('-') 

1076 return name 

1077 

1078 

1079def distribution(distribution_name: str) -> Distribution: 

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

1081 

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

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

1084 """ 

1085 return Distribution.from_name(distribution_name) 

1086 

1087 

1088def distributions(**kwargs) -> Iterable[Distribution]: 

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

1090 

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

1092 """ 

1093 return Distribution.discover(**kwargs) 

1094 

1095 

1096def metadata(distribution_name: str) -> _meta.PackageMetadata: 

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

1098 

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

1100 :return: A PackageMetadata containing the parsed metadata. 

1101 :raises MetadataNotFound: If no metadata file is present in the distribution. 

1102 """ 

1103 return Distribution.from_name(distribution_name).metadata 

1104 

1105 

1106def version(distribution_name: str) -> str: 

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

1108 

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

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

1111 "Version" metadata key. 

1112 """ 

1113 return distribution(distribution_name).version 

1114 

1115 

1116_unique = functools.partial( 

1117 unique_everseen, 

1118 key=operator.attrgetter('_normalized_name'), 

1119) 

1120""" 

1121Wrapper for ``distributions`` to return unique distributions by name. 

1122""" 

1123 

1124 

1125def entry_points(**params) -> EntryPoints: 

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

1127 

1128 Pass selection parameters (group or name) to filter the 

1129 result to entry points matching those properties (see 

1130 EntryPoints.select()). 

1131 

1132 :return: EntryPoints for all installed packages. 

1133 """ 

1134 eps = itertools.chain.from_iterable( 

1135 dist.entry_points for dist in _unique(distributions()) 

1136 ) 

1137 return EntryPoints(eps).select(**params) 

1138 

1139 

1140def files(distribution_name: str) -> list[PackagePath] | None: 

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

1142 

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

1144 :return: List of files composing the distribution. 

1145 """ 

1146 return distribution(distribution_name).files 

1147 

1148 

1149def requires(distribution_name: str) -> list[str] | None: 

1150 """ 

1151 Return a list of requirements for the named package. 

1152 

1153 :return: An iterable of requirements, suitable for 

1154 packaging.requirement.Requirement. 

1155 """ 

1156 return distribution(distribution_name).requires 

1157 

1158 

1159def packages_distributions() -> Mapping[str, list[str]]: 

1160 """ 

1161 Return a mapping of top-level packages to their 

1162 distributions. 

1163 

1164 >>> import collections.abc 

1165 >>> pkgs = packages_distributions() 

1166 >>> all(isinstance(dist, collections.abc.Sequence) for dist in pkgs.values()) 

1167 True 

1168 """ 

1169 pkg_to_dist = collections.defaultdict(list) 

1170 for dist in distributions(): 

1171 for pkg in _top_level_declared(dist) or _top_level_inferred(dist): 

1172 pkg_to_dist[pkg].append(dist.metadata['Name']) 

1173 return dict(pkg_to_dist) 

1174 

1175 

1176def _top_level_declared(dist): 

1177 return (dist.read_text('top_level.txt') or '').split() 

1178 

1179 

1180def _topmost(name: PackagePath) -> str | None: 

1181 """ 

1182 Return the top-most parent as long as there is a parent. 

1183 """ 

1184 top, *rest = name.parts 

1185 return top if rest else None 

1186 

1187 

1188def _get_toplevel_name(name: PackagePath) -> str: 

1189 """ 

1190 Infer a possibly importable module name from a name presumed on 

1191 sys.path. 

1192 

1193 >>> _get_toplevel_name(PackagePath('foo.py')) 

1194 'foo' 

1195 >>> _get_toplevel_name(PackagePath('foo')) 

1196 'foo' 

1197 >>> _get_toplevel_name(PackagePath('foo.pyc')) 

1198 'foo' 

1199 >>> _get_toplevel_name(PackagePath('foo/__init__.py')) 

1200 'foo' 

1201 >>> _get_toplevel_name(PackagePath('foo.pth')) 

1202 'foo.pth' 

1203 >>> _get_toplevel_name(PackagePath('foo.dist-info')) 

1204 'foo.dist-info' 

1205 """ 

1206 # Defer import of inspect for performance (python/cpython#118761) 

1207 import inspect 

1208 

1209 return _topmost(name) or inspect.getmodulename(name) or str(name) 

1210 

1211 

1212def _top_level_inferred(dist): 

1213 opt_names = set(map(_get_toplevel_name, always_iterable(dist.files))) 

1214 

1215 def importable_name(name): 

1216 return '.' not in name 

1217 

1218 return filter(importable_name, opt_names)