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

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

413 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 ._functools import method_cache, pass_none 

39from ._itertools import always_iterable, bucket, unique_everseen 

40from ._meta import PackageMetadata, SimplePath 

41from ._typing import md_none 

42from .compat import py39, py311 

43 

44__all__ = [ 

45 'Distribution', 

46 'DistributionFinder', 

47 'PackageMetadata', 

48 'PackageNotFoundError', 

49 'SimplePath', 

50 'distribution', 

51 'distributions', 

52 'entry_points', 

53 'files', 

54 'metadata', 

55 'packages_distributions', 

56 'requires', 

57 'version', 

58] 

59 

60 

61class PackageNotFoundError(ModuleNotFoundError): 

62 """The package was not found.""" 

63 

64 def __str__(self) -> str: 

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

66 

67 @property 

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

69 (name,) = self.args 

70 return name 

71 

72 

73class Sectioned: 

74 """ 

75 A simple entry point config parser for performance 

76 

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

78 ... print(item) 

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

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

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

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

83 

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

85 >>> item = next(res) 

86 >>> item.name 

87 'sec1' 

88 >>> item.value 

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

90 >>> item = next(res) 

91 >>> item.value 

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

93 >>> item = next(res) 

94 >>> item.name 

95 'sec2' 

96 >>> item.value 

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

98 >>> list(res) 

99 [] 

100 """ 

101 

102 _sample = textwrap.dedent( 

103 """ 

104 [sec1] 

105 # comments ignored 

106 a = 1 

107 b = 2 

108 

109 [sec2] 

110 a = 2 

111 """ 

112 ).lstrip() 

113 

114 @classmethod 

115 def section_pairs(cls, text): 

116 return ( 

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

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

119 if section.name is not None 

120 ) 

121 

122 @staticmethod 

123 def read(text, filter_=None): 

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

125 name = None 

126 for value in lines: 

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

128 if section_match: 

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

130 continue 

131 yield Pair(name, value) 

132 

133 @staticmethod 

134 def valid(line: str): 

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

136 

137 

138class _EntryPointMatch(types.SimpleNamespace): 

139 module: str 

140 attr: str 

141 extras: str 

142 

143 

144class EntryPoint: 

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

146 

147 See `the packaging docs on entry points 

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

149 for more information. 

150 

151 >>> ep = EntryPoint( 

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

153 >>> ep.module 

154 'package.module' 

155 >>> ep.attr 

156 'attr' 

157 >>> ep.extras 

158 ['extra1', 'extra2'] 

159 

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

161 ValueError is raised on access. 

162 

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

164 Traceback (most recent call last): 

165 ... 

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

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

168 Traceback (most recent call last): 

169 ... 

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

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

172 Traceback (most recent call last): 

173 ... 

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

175 

176 The same thing happens on construction. 

177 

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

179 Traceback (most recent call last): 

180 ... 

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

182 

183 """ 

184 

185 pattern = re.compile( 

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

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

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

189 ) 

190 """ 

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

192 which might look like: 

193 

194 - module 

195 - package.module 

196 - package.module:attribute 

197 - package.module:object.attribute 

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

199 

200 Other combinations are possible as well. 

201 

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

203 following the attr, and following any extras. 

204 """ 

205 

206 name: str 

207 value: str 

208 group: str 

209 

210 dist: Distribution | None = None 

211 

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

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

214 self.module 

215 

216 def load(self) -> Any: 

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

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

219 return the named object. 

220 """ 

221 module = import_module(self.module) 

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

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

224 

225 @property 

226 def module(self) -> str: 

227 return self._match.module 

228 

229 @property 

230 def attr(self) -> str: 

231 return self._match.attr 

232 

233 @property 

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

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

236 

237 @functools.cached_property 

238 def _match(self) -> _EntryPointMatch: 

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

240 if not match: 

241 raise ValueError( 

242 'Invalid object reference. ' 

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

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

245 self.value, 

246 ) 

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

248 

249 def _for(self, dist): 

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

251 return self 

252 

253 def matches(self, **params): 

254 """ 

255 EntryPoint matches the given parameters. 

256 

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

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

259 True 

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

261 True 

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

263 False 

264 >>> ep.matches() 

265 True 

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

267 True 

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

269 True 

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

271 True 

272 """ 

273 self._disallow_dist(params) 

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

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

276 

277 @staticmethod 

278 def _disallow_dist(params): 

279 """ 

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

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

282 Traceback (most recent call last): 

283 ... 

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

285 """ 

286 if "dist" in params: 

287 raise ValueError( 

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

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

290 "located distribution." 

291 ) 

292 

293 def _key(self): 

294 return self.name, self.value, self.group 

295 

296 def __lt__(self, other): 

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

298 

299 def __eq__(self, other): 

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

301 

302 def __setattr__(self, name, value): 

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

304 

305 def __repr__(self): 

306 return ( 

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

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

309 ) 

310 

311 def __hash__(self) -> int: 

312 return hash(self._key()) 

313 

314 

315class EntryPoints(tuple): 

316 """ 

317 An immutable collection of selectable EntryPoint objects. 

318 """ 

319 

320 __slots__ = () 

321 

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

323 """ 

324 Get the EntryPoint in self matching name. 

325 """ 

326 try: 

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

328 except StopIteration: 

329 raise KeyError(name) 

330 

331 def __repr__(self): 

332 """ 

333 Repr with classname and tuple constructor to 

334 signal that we deviate from regular tuple behavior. 

335 """ 

336 return '%s(%r)' % (self.__class__.__name__, tuple(self)) 

337 

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

339 """ 

340 Select entry points from self that match the 

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

342 """ 

343 return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params)) 

344 

345 @property 

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

347 """ 

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

349 """ 

350 return {ep.name for ep in self} 

351 

352 @property 

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

354 """ 

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

356 """ 

357 return {ep.group for ep in self} 

358 

359 @classmethod 

360 def _from_text_for(cls, text, dist): 

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

362 

363 @staticmethod 

364 def _from_text(text): 

365 return ( 

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

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

368 ) 

369 

370 

371class PackagePath(pathlib.PurePosixPath): 

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

373 

374 hash: FileHash | None 

375 size: int 

376 dist: Distribution 

377 

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

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

380 

381 def read_binary(self) -> bytes: 

382 return self.locate().read_bytes() 

383 

384 def locate(self) -> SimplePath: 

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

386 return self.dist.locate_file(self) 

387 

388 

389class FileHash: 

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

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

392 

393 def __repr__(self) -> str: 

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

395 

396 

397class Distribution(metaclass=abc.ABCMeta): 

398 """ 

399 An abstract Python distribution package. 

400 

401 Custom providers may derive from this class and define 

402 the abstract methods to provide a concrete implementation 

403 for their environment. Some providers may opt to override 

404 the default implementation of some properties to bypass 

405 the file-reading mechanism. 

406 """ 

407 

408 @abc.abstractmethod 

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

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

411 

412 Python distribution metadata is organized by blobs of text 

413 typically represented as "files" in the metadata directory 

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

415 like: 

416 

417 - METADATA: The distribution metadata including fields 

418 like Name and Version and Description. 

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

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

421 - RECORD: A record of files according to 

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

423 

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

425 not listed here or none at all. 

426 

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

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

429 """ 

430 

431 @abc.abstractmethod 

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

433 """ 

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

435 to it. 

436 

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

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

439 Distribution to represent files in the distribution as 

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

441 to resolve such objects. 

442 

443 Some Distribution providers may elect not to resolve SimplePath 

444 objects within the distribution by raising a 

445 NotImplementedError, but consumers of such a Distribution would 

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

447 """ 

448 

449 @classmethod 

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

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

452 

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

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

455 package, if found. 

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

457 metadata cannot be found. 

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

459 """ 

460 if not name: 

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

462 try: 

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

464 except StopIteration: 

465 raise PackageNotFoundError(name) 

466 

467 @classmethod 

468 def discover( 

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

470 ) -> Iterable[Distribution]: 

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

472 

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

474 a context. 

475 

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

477 :return: Iterable of Distribution objects for packages matching 

478 the context. 

479 """ 

480 if context and kwargs: 

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

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

483 return itertools.chain.from_iterable( 

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

485 ) 

486 

487 @staticmethod 

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

489 """ 

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

491 

492 Ref python/importlib_resources#489. 

493 """ 

494 buckets = bucket(dists, lambda dist: bool(dist.metadata)) 

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

496 

497 @staticmethod 

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

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

500 

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

502 :return: a concrete Distribution instance for the path 

503 """ 

504 return PathDistribution(pathlib.Path(path)) 

505 

506 @staticmethod 

507 def _discover_resolvers(): 

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

509 declared = ( 

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

511 ) 

512 return filter(None, declared) 

513 

514 @property 

515 def metadata(self) -> _meta.PackageMetadata | None: 

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

517 

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

519 metadata per the 

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

521 

522 Custom providers may provide the METADATA file or override this 

523 property. 

524 """ 

525 

526 text = ( 

527 self.read_text('METADATA') 

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

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

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

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

532 or self.read_text('') 

533 ) 

534 return self._assemble_message(text) 

535 

536 @staticmethod 

537 @pass_none 

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

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

540 from . import _adapters 

541 

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

543 

544 @property 

545 def name(self) -> str: 

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

547 return md_none(self.metadata)['Name'] 

548 

549 @property 

550 def _normalized_name(self): 

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

552 return Prepared.normalize(self.name) 

553 

554 @property 

555 def version(self) -> str: 

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

557 return md_none(self.metadata)['Version'] 

558 

559 @property 

560 def entry_points(self) -> EntryPoints: 

561 """ 

562 Return EntryPoints for this distribution. 

563 

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

565 or override this property. 

566 """ 

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

568 

569 @property 

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

571 """Files in this distribution. 

572 

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

574 

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

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

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

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

579 

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

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

582 able to resolve filenames provided by the package. 

583 """ 

584 

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

586 result = PackagePath(name) 

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

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

589 result.dist = self 

590 return result 

591 

592 @pass_none 

593 def make_files(lines): 

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

595 # as other parts of importlib.metadata 

596 import csv 

597 

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

599 

600 @pass_none 

601 def skip_missing_files(package_paths): 

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

603 

604 return skip_missing_files( 

605 make_files( 

606 self._read_files_distinfo() 

607 or self._read_files_egginfo_installed() 

608 or self._read_files_egginfo_sources() 

609 ) 

610 ) 

611 

612 def _read_files_distinfo(self): 

613 """ 

614 Read the lines of RECORD. 

615 """ 

616 text = self.read_text('RECORD') 

617 return text and text.splitlines() 

618 

619 def _read_files_egginfo_installed(self): 

620 """ 

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

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

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

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

625 

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

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

628 Assume the file is accurate if it exists. 

629 """ 

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

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

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

633 # self._path. 

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

635 if not text or not subdir: 

636 return 

637 

638 paths = ( 

639 py311.relative_fix((subdir / name).resolve()) 

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

641 .as_posix() 

642 for name in text.splitlines() 

643 ) 

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

645 

646 def _read_files_egginfo_sources(self): 

647 """ 

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

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

650 might contain literal commas). 

651 

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

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

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

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

656 that are present after the package has been installed. 

657 """ 

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

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

660 

661 @property 

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

663 """Generated requirements specified for this Distribution""" 

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

665 return reqs and list(reqs) 

666 

667 def _read_dist_info_reqs(self): 

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

669 

670 def _read_egg_info_reqs(self): 

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

672 return pass_none(self._deps_from_requires_text)(source) 

673 

674 @classmethod 

675 def _deps_from_requires_text(cls, source): 

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

677 

678 @staticmethod 

679 def _convert_egg_info_reqs_to_simple_reqs(sections): 

680 """ 

681 Historically, setuptools would solicit and store 'extra' 

682 requirements, including those with environment markers, 

683 in separate sections. More modern tools expect each 

684 dependency to be defined separately, with any relevant 

685 extras and environment markers attached directly to that 

686 requirement. This method converts the former to the 

687 latter. See _test_deps_from_requires_text for an example. 

688 """ 

689 

690 def make_condition(name): 

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

692 

693 def quoted_marker(section): 

694 section = section or '' 

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

696 if extra and markers: 

697 markers = f'({markers})' 

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

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

700 

701 def url_req_space(req): 

702 """ 

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

704 Ref python/importlib_metadata#357. 

705 """ 

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

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

708 

709 for section in sections: 

710 space = url_req_space(section.value) 

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

712 

713 @property 

714 def origin(self): 

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

716 

717 def _load_json(self, filename): 

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

719 import json 

720 

721 return pass_none(json.loads)( 

722 self.read_text(filename), 

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

724 ) 

725 

726 

727class DistributionFinder(MetaPathFinder): 

728 """ 

729 A MetaPathFinder capable of discovering installed distributions. 

730 

731 Custom providers should implement this interface in order to 

732 supply metadata. 

733 """ 

734 

735 class Context: 

736 """ 

737 Keyword arguments presented by the caller to 

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

739 to narrow the scope of a search for distributions 

740 in all DistributionFinders. 

741 

742 Each DistributionFinder may expect any parameters 

743 and should attempt to honor the canonical 

744 parameters defined below when appropriate. 

745 

746 This mechanism gives a custom provider a means to 

747 solicit additional details from the caller beyond 

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

749 For example, imagine a provider that exposes suites 

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

751 A caller may wish to query only for distributions in 

752 a particular realm and could call 

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

754 custom provider to only include distributions from that 

755 realm. 

756 """ 

757 

758 name = None 

759 """ 

760 Specific name for which a distribution finder should match. 

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

762 """ 

763 

764 def __init__(self, **kwargs): 

765 vars(self).update(kwargs) 

766 

767 @property 

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

769 """ 

770 The sequence of directory path that a distribution finder 

771 should search. 

772 

773 Typically refers to Python installed package paths such as 

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

775 """ 

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

777 

778 @abc.abstractmethod 

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

780 """ 

781 Find distributions. 

782 

783 Return an iterable of all Distribution instances capable of 

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

785 a DistributionFinder.Context instance. 

786 """ 

787 

788 

789class FastPath: 

790 """ 

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

792 

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

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

795 

796 >>> FastPath('').children() 

797 ['...'] 

798 

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

800 

801 >>> FastPath('foobar') is FastPath('foobar') 

802 True 

803 """ 

804 

805 @functools.lru_cache() # type: ignore[misc] 

806 def __new__(cls, root): 

807 return super().__new__(cls) 

808 

809 def __init__(self, root): 

810 self.root = root 

811 

812 def joinpath(self, child): 

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

814 

815 def children(self): 

816 with suppress(Exception): 

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

818 with suppress(Exception): 

819 return self.zip_children() 

820 return [] 

821 

822 def zip_children(self): 

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

824 from zipp.compat.overlay import zipfile 

825 

826 zip_path = zipfile.Path(self.root) 

827 names = zip_path.root.namelist() 

828 self.joinpath = zip_path.joinpath 

829 

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

831 

832 def search(self, name): 

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

834 

835 @property 

836 def mtime(self): 

837 with suppress(OSError): 

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

839 self.lookup.cache_clear() 

840 

841 @method_cache 

842 def lookup(self, mtime): 

843 return Lookup(self) 

844 

845 

846class Lookup: 

847 """ 

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

849 """ 

850 

851 def __init__(self, path: FastPath): 

852 """ 

853 Calculate all of the children representing metadata. 

854 

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

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

857 metadata (eggs). 

858 """ 

859 

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

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

862 self.infos = FreezableDefaultDict(list) 

863 self.eggs = FreezableDefaultDict(list) 

864 

865 for child in path.children(): 

866 low = child.lower() 

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

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

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

870 normalized = Prepared.normalize(name) 

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

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

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

874 legacy_normalized = Prepared.legacy_normalize(name) 

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

876 

877 self.infos.freeze() 

878 self.eggs.freeze() 

879 

880 def search(self, prepared: Prepared): 

881 """ 

882 Yield all infos and eggs matching the Prepared query. 

883 """ 

884 infos = ( 

885 self.infos[prepared.normalized] 

886 if prepared 

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

888 ) 

889 eggs = ( 

890 self.eggs[prepared.legacy_normalized] 

891 if prepared 

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

893 ) 

894 return itertools.chain(infos, eggs) 

895 

896 

897class Prepared: 

898 """ 

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

900 

901 Pre-calculates the normalization to prevent repeated operations. 

902 

903 >>> none = Prepared(None) 

904 >>> none.normalized 

905 >>> none.legacy_normalized 

906 >>> bool(none) 

907 False 

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

909 >>> sample.normalized 

910 'sample_pkg_name_foo' 

911 >>> sample.legacy_normalized 

912 'sample__pkg_name.foo' 

913 >>> bool(sample) 

914 True 

915 """ 

916 

917 normalized = None 

918 legacy_normalized = None 

919 

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

921 self.name = name 

922 if name is None: 

923 return 

924 self.normalized = self.normalize(name) 

925 self.legacy_normalized = self.legacy_normalize(name) 

926 

927 @staticmethod 

928 def normalize(name): 

929 """ 

930 PEP 503 normalization plus dashes as underscores. 

931 """ 

932 return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') 

933 

934 @staticmethod 

935 def legacy_normalize(name): 

936 """ 

937 Normalize the package name as found in the convention in 

938 older packaging tools versions and specs. 

939 """ 

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

941 

942 def __bool__(self): 

943 return bool(self.name) 

944 

945 

946@install 

947class MetadataPathFinder(NullFinder, DistributionFinder): 

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

949 

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

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

952 """ 

953 

954 @classmethod 

955 def find_distributions( 

956 cls, context=DistributionFinder.Context() 

957 ) -> Iterable[PathDistribution]: 

958 """ 

959 Find distributions. 

960 

961 Return an iterable of all Distribution instances capable of 

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

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

964 of directories ``context.path``. 

965 """ 

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

967 return map(PathDistribution, found) 

968 

969 @classmethod 

970 def _search_paths(cls, name, paths): 

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

972 prepared = Prepared(name) 

973 return itertools.chain.from_iterable( 

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

975 ) 

976 

977 @classmethod 

978 def invalidate_caches(cls) -> None: 

979 FastPath.__new__.cache_clear() 

980 

981 

982class PathDistribution(Distribution): 

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

984 """Construct a distribution. 

985 

986 :param path: SimplePath indicating the metadata directory. 

987 """ 

988 self._path = path 

989 

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

991 with suppress( 

992 FileNotFoundError, 

993 IsADirectoryError, 

994 KeyError, 

995 NotADirectoryError, 

996 PermissionError, 

997 ): 

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

999 

1000 return None 

1001 

1002 read_text.__doc__ = Distribution.read_text.__doc__ 

1003 

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

1005 return self._path.parent / path 

1006 

1007 @property 

1008 def _normalized_name(self): 

1009 """ 

1010 Performance optimization: where possible, resolve the 

1011 normalized name from the file system path. 

1012 """ 

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

1014 return ( 

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

1016 or super()._normalized_name 

1017 ) 

1018 

1019 @staticmethod 

1020 def _name_from_stem(stem): 

1021 """ 

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

1023 'foo' 

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

1025 'CherryPy' 

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

1027 'face' 

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

1029 """ 

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

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

1032 return 

1033 name, sep, rest = filename.partition('-') 

1034 return name 

1035 

1036 

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

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

1039 

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

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

1042 """ 

1043 return Distribution.from_name(distribution_name) 

1044 

1045 

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

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

1048 

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

1050 """ 

1051 return Distribution.discover(**kwargs) 

1052 

1053 

1054def metadata(distribution_name: str) -> _meta.PackageMetadata | None: 

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

1056 

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

1058 :return: A PackageMetadata containing the parsed metadata. 

1059 """ 

1060 return Distribution.from_name(distribution_name).metadata 

1061 

1062 

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

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

1065 

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

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

1068 "Version" metadata key. 

1069 """ 

1070 return distribution(distribution_name).version 

1071 

1072 

1073_unique = functools.partial( 

1074 unique_everseen, 

1075 key=py39.normalized_name, 

1076) 

1077""" 

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

1079""" 

1080 

1081 

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

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

1084 

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

1086 result to entry points matching those properties (see 

1087 EntryPoints.select()). 

1088 

1089 :return: EntryPoints for all installed packages. 

1090 """ 

1091 eps = itertools.chain.from_iterable( 

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

1093 ) 

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

1095 

1096 

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

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

1099 

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

1101 :return: List of files composing the distribution. 

1102 """ 

1103 return distribution(distribution_name).files 

1104 

1105 

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

1107 """ 

1108 Return a list of requirements for the named package. 

1109 

1110 :return: An iterable of requirements, suitable for 

1111 packaging.requirement.Requirement. 

1112 """ 

1113 return distribution(distribution_name).requires 

1114 

1115 

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

1117 """ 

1118 Return a mapping of top-level packages to their 

1119 distributions. 

1120 

1121 >>> import collections.abc 

1122 >>> pkgs = packages_distributions() 

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

1124 True 

1125 """ 

1126 pkg_to_dist = collections.defaultdict(list) 

1127 for dist in distributions(): 

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

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

1130 return dict(pkg_to_dist) 

1131 

1132 

1133def _top_level_declared(dist): 

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

1135 

1136 

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

1138 """ 

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

1140 """ 

1141 top, *rest = name.parts 

1142 return top if rest else None 

1143 

1144 

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

1146 """ 

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

1148 sys.path. 

1149 

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

1151 'foo' 

1152 >>> _get_toplevel_name(PackagePath('foo')) 

1153 'foo' 

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

1155 'foo' 

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

1157 'foo' 

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

1159 'foo.pth' 

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

1161 'foo.dist-info' 

1162 """ 

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

1164 import inspect 

1165 

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

1167 

1168 

1169def _top_level_inferred(dist): 

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

1171 

1172 def importable_name(name): 

1173 return '.' not in name 

1174 

1175 return filter(importable_name, opt_names)