Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pip/_internal/models/link.py: 38%

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

297 statements  

1from __future__ import annotations 

2 

3import datetime 

4import functools 

5import itertools 

6import logging 

7import os 

8import posixpath 

9import re 

10import urllib.parse 

11from collections.abc import Mapping 

12from dataclasses import dataclass 

13from typing import ( 

14 Any, 

15 NamedTuple, 

16 NewType, 

17) 

18 

19from pip._internal.exceptions import InvalidEggFragment 

20from pip._internal.utils.datetime import parse_iso_datetime 

21from pip._internal.utils.filetypes import WHEEL_EXTENSION 

22from pip._internal.utils.hashes import Hashes 

23from pip._internal.utils.misc import ( 

24 pairwise, 

25 redact_auth_from_url, 

26 split_auth_from_netloc, 

27 splitext, 

28) 

29from pip._internal.utils.urls import path_to_url, url_to_path 

30 

31logger = logging.getLogger(__name__) 

32 

33 

34# A single path component: percent-decoded once and reduced to a basename, so it 

35# contains no path separator and is not a ``.`` or ``..`` reference. The empty 

36# string means "no component". 

37PathComponent = NewType("PathComponent", str) 

38 

39 

40def _to_path_component(name: str) -> PathComponent: 

41 """Reduce ``name`` to a single path component, or ``""`` if it has none. 

42 

43 ``os.path.basename`` drops any directory part, drive letter, or separator; 

44 a ``.``, ``..``, or empty result is not a component and becomes ``""``. 

45 """ 

46 name = os.path.basename(name) 

47 if name in ("", os.curdir, os.pardir): 

48 return PathComponent("") 

49 

50 return PathComponent(name) 

51 

52 

53def as_path_component(name: str) -> PathComponent: 

54 """Like ``_to_path_component`` but reject the empty result. 

55 

56 Use where a file is about to be written, so a missing name is an error 

57 rather than a silent fallback to the directory itself. 

58 """ 

59 component = _to_path_component(name) 

60 if not component: 

61 raise ValueError(f"Unexpected file name derived from URL: {name!r}") 

62 

63 return component 

64 

65 

66def join_within_directory(directory: str, component: PathComponent) -> str: 

67 """Join a single path ``component`` onto ``directory``. 

68 

69 ``component`` is a :data:`PathComponent`, so by type it has no separator and 

70 is not a ``.`` or ``..`` reference; the result can never escape ``directory``. 

71 Requiring ``PathComponent`` rather than ``str`` lets the type checker enforce 

72 at the call site that the name was reduced to a safe component beforehand. 

73 """ 

74 return os.path.join(directory, component) 

75 

76 

77# Order matters, earlier hashes have a precedence over later hashes for what 

78# we will pick to use. 

79_SUPPORTED_HASHES = ("sha512", "sha384", "sha256", "sha224", "sha1", "md5") 

80 

81 

82@dataclass(frozen=True) 

83class LinkHash: 

84 """Links to content may have embedded hash values. This class parses those. 

85 

86 `name` must be any member of `_SUPPORTED_HASHES`. 

87 

88 This class can be converted to and from `ArchiveInfo`. While ArchiveInfo intends to 

89 be JSON-serializable to conform to PEP 610, this class contains the logic for 

90 parsing a hash name and value for correctness, and then checking whether that hash 

91 conforms to a schema with `.is_hash_allowed()`.""" 

92 

93 name: str 

94 value: str 

95 

96 _hash_url_fragment_re = re.compile( 

97 # NB: we do not validate that the second group (.*) is a valid hex 

98 # digest. Instead, we simply keep that string in this class, and then check it 

99 # against Hashes when hash-checking is needed. This is easier to debug than 

100 # proactively discarding an invalid hex digest, as we handle incorrect hashes 

101 # and malformed hashes in the same place. 

102 r"[#&]({choices})=([^&]*)".format( 

103 choices="|".join(re.escape(hash_name) for hash_name in _SUPPORTED_HASHES) 

104 ), 

105 ) 

106 

107 def __post_init__(self) -> None: 

108 assert self.name in _SUPPORTED_HASHES 

109 

110 @classmethod 

111 @functools.cache 

112 def find_hash_url_fragment(cls, url: str) -> LinkHash | None: 

113 """Search a string for a checksum algorithm name and encoded output value.""" 

114 match = cls._hash_url_fragment_re.search(url) 

115 if match is None: 

116 return None 

117 name, value = match.groups() 

118 return cls(name=name, value=value) 

119 

120 def as_dict(self) -> dict[str, str]: 

121 return {self.name: self.value} 

122 

123 def as_hashes(self) -> Hashes: 

124 """Return a Hashes instance which checks only for the current hash.""" 

125 return Hashes({self.name: [self.value]}) 

126 

127 def is_hash_allowed(self, hashes: Hashes | None) -> bool: 

128 """ 

129 Return True if the current hash is allowed by `hashes`. 

130 """ 

131 if hashes is None: 

132 return False 

133 return hashes.is_hash_allowed(self.name, hex_digest=self.value) 

134 

135 

136@dataclass(frozen=True) 

137class MetadataFile: 

138 """Information about a core metadata file associated with a distribution.""" 

139 

140 hashes: dict[str, str] | None 

141 

142 def __post_init__(self) -> None: 

143 if self.hashes is not None: 

144 assert all(name in _SUPPORTED_HASHES for name in self.hashes) 

145 

146 

147def supported_hashes(hashes: dict[str, str] | None) -> dict[str, str] | None: 

148 # Remove any unsupported hash types from the mapping. If this leaves no 

149 # supported hashes, return None 

150 if hashes is None: 

151 return None 

152 hashes = {n: v for n, v in hashes.items() if n in _SUPPORTED_HASHES} 

153 if not hashes: 

154 return None 

155 return hashes 

156 

157 

158def _clean_url_path_part(part: str) -> str: 

159 """ 

160 Clean a "part" of a URL path (i.e. after splitting on "@" characters). 

161 """ 

162 # We unquote prior to quoting to make sure nothing is double quoted. 

163 return urllib.parse.quote(urllib.parse.unquote(part)) 

164 

165 

166def _clean_file_url_path(part: str) -> str: 

167 """ 

168 Clean the first part of a URL path that corresponds to a local 

169 filesystem path (i.e. the first part after splitting on "@" characters). 

170 """ 

171 import urllib.request 

172 

173 # We unquote prior to quoting to make sure nothing is double quoted. 

174 # Also, on Windows the path part might contain a drive letter which 

175 # should not be quoted. On Linux where drive letters do not 

176 # exist, the colon should be quoted. We rely on urllib.request 

177 # to do the right thing here. 

178 ret = urllib.request.pathname2url(urllib.request.url2pathname(part)) 

179 if ret.startswith("///"): 

180 # Remove any URL authority section, leaving only the URL path. 

181 ret = ret.removeprefix("//") 

182 return ret 

183 

184 

185# percent-encoded: / 

186_reserved_chars_re = re.compile("(@|%2F)", re.IGNORECASE) 

187 

188 

189def _clean_url_path(path: str, is_local_path: bool) -> str: 

190 """ 

191 Clean the path portion of a URL. 

192 """ 

193 if is_local_path: 

194 clean_func = _clean_file_url_path 

195 else: 

196 clean_func = _clean_url_path_part 

197 

198 # Split on the reserved characters prior to cleaning so that 

199 # revision strings in VCS URLs are properly preserved. 

200 parts = _reserved_chars_re.split(path) 

201 

202 cleaned_parts = [] 

203 for to_clean, reserved in pairwise(itertools.chain(parts, [""])): 

204 cleaned_parts.append(clean_func(to_clean)) 

205 # Normalize %xx escapes (e.g. %2f -> %2F) 

206 cleaned_parts.append(reserved.upper()) 

207 

208 return "".join(cleaned_parts) 

209 

210 

211def _ensure_quoted_url(url: str) -> str: 

212 """ 

213 Make sure a link is fully quoted. 

214 For example, if ' ' occurs in the URL, it will be replaced with "%20", 

215 and without double-quoting other characters. 

216 """ 

217 # Split the URL into parts according to the general structure 

218 # `scheme://netloc/path?query#fragment`. 

219 result = urllib.parse.urlsplit(url) 

220 # If the netloc is empty, then the URL refers to a local filesystem path. 

221 is_local_path = not result.netloc 

222 path = _clean_url_path(result.path, is_local_path=is_local_path) 

223 # Temporarily replace scheme with file to ensure the URL generated by 

224 # urlunsplit() contains an empty netloc (file://) as per RFC 1738. 

225 ret = urllib.parse.urlunsplit(result._replace(scheme="file", path=path)) 

226 ret = result.scheme + ret[4:] # Restore original scheme. 

227 return ret 

228 

229 

230def _absolute_link_url(base_url: str, url: str) -> str: 

231 """ 

232 A faster implementation of urllib.parse.urljoin with a shortcut 

233 for absolute http/https URLs. 

234 """ 

235 if url.startswith(("https://", "http://")): 

236 return url 

237 else: 

238 return urllib.parse.urljoin(base_url, url) 

239 

240 

241@functools.total_ordering 

242class Link: 

243 """Represents a parsed link from a Package Index's simple URL""" 

244 

245 __slots__ = [ 

246 "_parsed_url", 

247 "_url", 

248 "_path", 

249 "_hashes", 

250 "comes_from", 

251 "requires_python", 

252 "yanked_reason", 

253 "metadata_file_data", 

254 "upload_time", 

255 "cache_link_parsing", 

256 "egg_fragment", 

257 ] 

258 

259 def __init__( 

260 self, 

261 url: str, 

262 comes_from: str | None = None, 

263 requires_python: str | None = None, 

264 yanked_reason: str | None = None, 

265 metadata_file_data: MetadataFile | None = None, 

266 upload_time: datetime.datetime | None = None, 

267 cache_link_parsing: bool = True, 

268 hashes: Mapping[str, str] | None = None, 

269 ) -> None: 

270 """ 

271 :param url: url of the resource pointed to (href of the link) 

272 :param comes_from: URL or string indicating where the link was found. 

273 :param requires_python: String containing the `Requires-Python` 

274 metadata field, specified in PEP 345. This may be specified by 

275 a data-requires-python attribute in the HTML link tag, as 

276 described in PEP 503. 

277 :param yanked_reason: the reason the file has been yanked, if the 

278 file has been yanked, or None if the file hasn't been yanked. 

279 This is the value of the "data-yanked" attribute, if present, in 

280 a simple repository HTML link. If the file has been yanked but 

281 no reason was provided, this should be the empty string. See 

282 PEP 592 for more information and the specification. 

283 :param metadata_file_data: the metadata attached to the file, or None if 

284 no such metadata is provided. This argument, if not None, indicates 

285 that a separate metadata file exists, and also optionally supplies 

286 hashes for that file. 

287 :param upload_time: upload time of the file, or None if the information 

288 is not available from the server. 

289 :param cache_link_parsing: A flag that is used elsewhere to determine 

290 whether resources retrieved from this link should be cached. PyPI 

291 URLs should generally have this set to False, for example. 

292 :param hashes: A mapping of hash names to digests to allow us to 

293 determine the validity of a download. 

294 """ 

295 

296 # The comes_from, requires_python, and metadata_file_data arguments are 

297 # only used by classmethods of this class, and are not used in client 

298 # code directly. 

299 

300 # url can be a UNC windows share 

301 if url.startswith("\\\\"): 

302 url = path_to_url(url) 

303 

304 self._parsed_url = urllib.parse.urlsplit(url) 

305 # Store the url as a private attribute to prevent accidentally 

306 # trying to set a new value. 

307 self._url = url 

308 # The .path property is hot, so calculate its value ahead of time. 

309 self._path = urllib.parse.unquote(self._parsed_url.path) 

310 

311 link_hash = LinkHash.find_hash_url_fragment(url) 

312 hashes_from_link = {} if link_hash is None else link_hash.as_dict() 

313 if hashes is None: 

314 self._hashes = hashes_from_link 

315 else: 

316 self._hashes = {**hashes, **hashes_from_link} 

317 

318 self.comes_from = comes_from 

319 self.requires_python = requires_python if requires_python else None 

320 self.yanked_reason = yanked_reason 

321 self.metadata_file_data = metadata_file_data 

322 self.upload_time = upload_time 

323 

324 self.cache_link_parsing = cache_link_parsing 

325 self.egg_fragment = self._egg_fragment() 

326 

327 @classmethod 

328 def from_json( 

329 cls, 

330 file_data: dict[str, Any], 

331 page_url: str, 

332 ) -> Link | None: 

333 """ 

334 Convert an pypi json document from a simple repository page into a Link. 

335 """ 

336 file_url = file_data.get("url") 

337 if file_url is None: 

338 return None 

339 

340 url = _ensure_quoted_url(_absolute_link_url(page_url, file_url)) 

341 pyrequire = file_data.get("requires-python") 

342 yanked_reason = file_data.get("yanked") 

343 hashes = file_data.get("hashes", {}) 

344 

345 # PEP 714: Indexes must use the name core-metadata, but 

346 # clients should support the old name as a fallback for compatibility. 

347 metadata_info = file_data.get("core-metadata") 

348 if metadata_info is None: 

349 metadata_info = file_data.get("dist-info-metadata") 

350 

351 if upload_time_data := file_data.get("upload-time"): 

352 upload_time = parse_iso_datetime(upload_time_data) 

353 else: 

354 upload_time = None 

355 

356 # The metadata info value may be a boolean, or a dict of hashes. 

357 if isinstance(metadata_info, dict): 

358 # The file exists, and hashes have been supplied 

359 metadata_file_data = MetadataFile(supported_hashes(metadata_info)) 

360 elif metadata_info: 

361 # The file exists, but there are no hashes 

362 metadata_file_data = MetadataFile(None) 

363 else: 

364 # False or not present: the file does not exist 

365 metadata_file_data = None 

366 

367 # The Link.yanked_reason expects an empty string instead of a boolean. 

368 if yanked_reason and not isinstance(yanked_reason, str): 

369 yanked_reason = "" 

370 # The Link.yanked_reason expects None instead of False. 

371 elif not yanked_reason: 

372 yanked_reason = None 

373 

374 return cls( 

375 url, 

376 comes_from=page_url, 

377 requires_python=pyrequire, 

378 yanked_reason=yanked_reason, 

379 hashes=hashes, 

380 metadata_file_data=metadata_file_data, 

381 upload_time=upload_time, 

382 ) 

383 

384 @classmethod 

385 def from_element( 

386 cls, 

387 anchor_attribs: dict[str, str | None], 

388 page_url: str, 

389 base_url: str, 

390 ) -> Link | None: 

391 """ 

392 Convert an anchor element's attributes in a simple repository page to a Link. 

393 """ 

394 href = anchor_attribs.get("href") 

395 if not href: 

396 return None 

397 

398 url = _ensure_quoted_url(_absolute_link_url(base_url, href)) 

399 pyrequire = anchor_attribs.get("data-requires-python") 

400 yanked_reason = anchor_attribs.get("data-yanked") 

401 

402 # PEP 714: Indexes must use the name data-core-metadata, but 

403 # clients should support the old name as a fallback for compatibility. 

404 metadata_info = anchor_attribs.get("data-core-metadata") 

405 if metadata_info is None: 

406 metadata_info = anchor_attribs.get("data-dist-info-metadata") 

407 # The metadata info value may be the string "true", or a string of 

408 # the form "hashname=hashval" 

409 if metadata_info == "true": 

410 # The file exists, but there are no hashes 

411 metadata_file_data = MetadataFile(None) 

412 elif metadata_info is None: 

413 # The file does not exist 

414 metadata_file_data = None 

415 else: 

416 # The file exists, and hashes have been supplied 

417 hashname, sep, hashval = metadata_info.partition("=") 

418 if sep == "=": 

419 metadata_file_data = MetadataFile(supported_hashes({hashname: hashval})) 

420 else: 

421 # Error - data is wrong. Treat as no hashes supplied. 

422 logger.debug( 

423 "Index returned invalid data-dist-info-metadata value: %s", 

424 metadata_info, 

425 ) 

426 metadata_file_data = MetadataFile(None) 

427 

428 return cls( 

429 url, 

430 comes_from=page_url, 

431 requires_python=pyrequire, 

432 yanked_reason=yanked_reason, 

433 metadata_file_data=metadata_file_data, 

434 ) 

435 

436 def __str__(self) -> str: 

437 if self.requires_python: 

438 rp = f" (requires-python:{self.requires_python})" 

439 else: 

440 rp = "" 

441 if self.comes_from: 

442 return f"{self.redacted_url} (from {self.comes_from}){rp}" 

443 else: 

444 return self.redacted_url 

445 

446 def __repr__(self) -> str: 

447 return f"<Link {self}>" 

448 

449 def __hash__(self) -> int: 

450 return hash(self.url) 

451 

452 def __eq__(self, other: Any) -> bool: 

453 if not isinstance(other, Link): 

454 return NotImplemented 

455 return self.url == other.url 

456 

457 def __lt__(self, other: Any) -> bool: 

458 if not isinstance(other, Link): 

459 return NotImplemented 

460 return self.url < other.url 

461 

462 @property 

463 def url(self) -> str: 

464 return self._url 

465 

466 @property 

467 def redacted_url(self) -> str: 

468 return redact_auth_from_url(self.url) 

469 

470 @property 

471 def filename(self) -> PathComponent: 

472 name = _to_path_component(posixpath.basename(self.path.rstrip("/"))) 

473 if name: 

474 return name 

475 

476 # No component in the path; fall back to the netloc, dropping any auth. 

477 return _to_path_component(split_auth_from_netloc(self.netloc)[0]) 

478 

479 @property 

480 def file_path(self) -> str: 

481 return url_to_path(self.url) 

482 

483 @property 

484 def scheme(self) -> str: 

485 return self._parsed_url.scheme 

486 

487 @property 

488 def netloc(self) -> str: 

489 """ 

490 This can contain auth information. 

491 """ 

492 return self._parsed_url.netloc 

493 

494 @property 

495 def path(self) -> str: 

496 return self._path 

497 

498 def splitext(self) -> tuple[str, str]: 

499 return splitext(posixpath.basename(self.path.rstrip("/"))) 

500 

501 @property 

502 def ext(self) -> str: 

503 return self.splitext()[1] 

504 

505 @property 

506 def url_without_fragment(self) -> str: 

507 scheme, netloc, path, query, fragment = self._parsed_url 

508 return urllib.parse.urlunsplit((scheme, netloc, path, query, "")) 

509 

510 _egg_fragment_re = re.compile(r"[#&]egg=([^&]*)") 

511 

512 # Per PEP 508. 

513 _project_name_re = re.compile( 

514 r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE 

515 ) 

516 

517 def _egg_fragment(self) -> str | None: 

518 match = self._egg_fragment_re.search(self._url) 

519 if not match: 

520 return None 

521 

522 # An egg fragment looks like a PEP 508 project name, along with 

523 # an optional extras specifier. Anything else is invalid. 

524 project_name = match.group(1) 

525 if not self._project_name_re.match(project_name): 

526 raise InvalidEggFragment(self, project_name) 

527 

528 return project_name 

529 

530 _subdirectory_fragment_re = re.compile(r"[#&]subdirectory=([^&]*)") 

531 

532 @property 

533 def subdirectory_fragment(self) -> str | None: 

534 match = self._subdirectory_fragment_re.search(self._url) 

535 if not match: 

536 return None 

537 return match.group(1) 

538 

539 def metadata_link(self) -> Link | None: 

540 """Return a link to the associated core metadata file (if any).""" 

541 if self.metadata_file_data is None: 

542 return None 

543 metadata_url = f"{self.url_without_fragment}.metadata" 

544 if self.metadata_file_data.hashes is None: 

545 return Link(metadata_url) 

546 return Link(metadata_url, hashes=self.metadata_file_data.hashes) 

547 

548 def as_hashes(self) -> Hashes: 

549 return Hashes({k: [v] for k, v in self._hashes.items()}) 

550 

551 @property 

552 def hash(self) -> str | None: 

553 return next(iter(self._hashes.values()), None) 

554 

555 @property 

556 def hash_name(self) -> str | None: 

557 return next(iter(self._hashes), None) 

558 

559 @property 

560 def show_url(self) -> str: 

561 return posixpath.basename(self._url.split("#", 1)[0].split("?", 1)[0]) 

562 

563 @property 

564 def is_file(self) -> bool: 

565 return self.scheme == "file" 

566 

567 def is_existing_dir(self) -> bool: 

568 return self.is_file and os.path.isdir(self.file_path) 

569 

570 @property 

571 def is_wheel(self) -> bool: 

572 return self.ext == WHEEL_EXTENSION 

573 

574 @property 

575 def is_vcs(self) -> bool: 

576 from pip._internal.vcs import vcs 

577 

578 return self.scheme in vcs.all_schemes 

579 

580 @property 

581 def is_yanked(self) -> bool: 

582 return self.yanked_reason is not None 

583 

584 @property 

585 def has_hash(self) -> bool: 

586 return bool(self._hashes) 

587 

588 def is_hash_allowed(self, hashes: Hashes | None) -> bool: 

589 """ 

590 Return True if the link has a hash and it is allowed by `hashes`. 

591 """ 

592 if hashes is None: 

593 return False 

594 return any(hashes.is_hash_allowed(k, v) for k, v in self._hashes.items()) 

595 

596 

597class _CleanResult(NamedTuple): 

598 """Convert link for equivalency check. 

599 

600 This is used in the resolver to check whether two URL-specified requirements 

601 likely point to the same distribution and can be considered equivalent. This 

602 equivalency logic avoids comparing URLs literally, which can be too strict 

603 (e.g. "a=1&b=2" vs "b=2&a=1") and produce conflicts unexpecting to users. 

604 

605 Currently this does three things: 

606 

607 1. Drop the basic auth part. This is technically wrong since a server can 

608 serve different content based on auth, but if it does that, it is even 

609 impossible to guarantee two URLs without auth are equivalent, since 

610 the user can input different auth information when prompted. So the 

611 practical solution is to assume the auth doesn't affect the response. 

612 2. Parse the query to avoid the ordering issue. Note that ordering under the 

613 same key in the query are NOT cleaned; i.e. "a=1&a=2" and "a=2&a=1" are 

614 still considered different. 

615 3. Explicitly drop most of the fragment part, except ``subdirectory=`` and 

616 hash values, since it should have no impact the downloaded content. Note 

617 that this drops the "egg=" part historically used to denote the requested 

618 project (and extras), which is wrong in the strictest sense, but too many 

619 people are supplying it inconsistently to cause superfluous resolution 

620 conflicts, so we choose to also ignore them. 

621 """ 

622 

623 parsed: urllib.parse.SplitResult 

624 query: dict[str, list[str]] 

625 subdirectory: str 

626 hashes: dict[str, str] 

627 

628 

629def _clean_link(link: Link) -> _CleanResult: 

630 parsed = link._parsed_url 

631 netloc = parsed.netloc.rsplit("@", 1)[-1] 

632 # According to RFC 8089, an empty host in file: means localhost. 

633 if parsed.scheme == "file" and not netloc: 

634 netloc = "localhost" 

635 fragment = urllib.parse.parse_qs(parsed.fragment) 

636 if "egg" in fragment: 

637 logger.debug("Ignoring egg= fragment in %s", link) 

638 try: 

639 # If there are multiple subdirectory values, use the first one. 

640 # This matches the behavior of Link.subdirectory_fragment. 

641 subdirectory = fragment["subdirectory"][0] 

642 except (IndexError, KeyError): 

643 subdirectory = "" 

644 # If there are multiple hash values under the same algorithm, use the 

645 # first one. This matches the behavior of Link.hash_value. 

646 hashes = {k: fragment[k][0] for k in _SUPPORTED_HASHES if k in fragment} 

647 return _CleanResult( 

648 parsed=parsed._replace(netloc=netloc, query="", fragment=""), 

649 query=urllib.parse.parse_qs(parsed.query), 

650 subdirectory=subdirectory, 

651 hashes=hashes, 

652 ) 

653 

654 

655@functools.cache 

656def links_equivalent(link1: Link, link2: Link) -> bool: 

657 return _clean_link(link1) == _clean_link(link2)