Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/w3lib/_url.py: 74%
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
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
1from __future__ import annotations
3import dataclasses
4import encodings.idna
5import functools
6import ipaddress
7import os
8import re
9import string
10import sys
11import unicodedata
12from typing import TYPE_CHECKING
13from urllib.parse import ParseResult, scheme_chars, uses_netloc, uses_params
15from w3lib._infra import _ASCII_TAB_OR_NEWLINE, _C0_CONTROL_OR_SPACE
17if TYPE_CHECKING:
18 from collections.abc import Generator
19 from urllib.parse import _QueryType
21_IS_WINDOWS = os.name == "nt"
24_FS_ENCODING = sys.getfilesystemencoding()
25_FS_ERRORS = sys.getfilesystemencodeerrors()
27# https://url.spec.whatwg.org/
28# https://url.spec.whatwg.org/commit-snapshots/a46cb9188a48c2c9d80ba32a9b1891652d6b4900/#default-port
29_DEFAULT_PORTS = {
30 "ftp": 21,
31 "file": None,
32 "http": 80,
33 "https": 443,
34 "ws": 80,
35 "wss": 443,
36}
37_SPECIAL_SCHEMES = set(_DEFAULT_PORTS.keys())
39# constants from RFC 3986, Section 2.2 and 2.3
40RFC3986_GEN_DELIMS = b":/?#[]@"
41RFC3986_SUB_DELIMS = b"!$&'()*+,;="
42RFC3986_RESERVED = RFC3986_GEN_DELIMS + RFC3986_SUB_DELIMS
43RFC3986_UNRESERVED = (string.ascii_letters + string.digits + "-._~").encode("ascii")
44EXTRA_SAFE_CHARS = b"|" # see https://github.com/scrapy/w3lib/pull/25
46RFC3986_USERINFO_SAFE_CHARS = RFC3986_UNRESERVED + RFC3986_SUB_DELIMS + b":"
47_SAFE_CHARS = RFC3986_RESERVED + RFC3986_UNRESERVED + EXTRA_SAFE_CHARS + b"%"
48_PATH_SAFE_CHARS = _SAFE_CHARS.replace(b"#", b"")
49_PATH_SAFE_CHARS_STR = _PATH_SAFE_CHARS.decode()
50_USES_NETLOC = frozenset(uses_netloc)
51_SCHEME_CHARS = frozenset(scheme_chars)
52_USES_PARAMS = frozenset(uses_params)
53_ASCII_TAB_OR_NEWLINE_TRANSLATION_TABLE = str.maketrans("", "", _ASCII_TAB_OR_NEWLINE)
54_C0_CONTROL_OR_SPACE_RE = re.compile(rf"[{_C0_CONTROL_OR_SPACE}]")
55_SCHEME_RE = re.compile(rf"^([a-zA-Z][{re.escape(scheme_chars)}]*):")
57_IPV_FUTURE_RE = re.compile(r"\Av[a-fA-F0-9]+\..+\Z")
58# "\" terminates the authority of a special-scheme URL just like "/" under the
59# URL living standard, so it belongs with the other authority delimiters here.
60_NETLOC_DELIMS_RE = re.compile(r"[/?#@:\\]")
61_NETLOC_STRIP_CHARS = str.maketrans("", "", "@:#?")
62# https://url.spec.whatwg.org/#forbidden-host-code-point
63_FORBIDDEN_HOST_CODE_POINT_RE = re.compile(r"[\x00\t\n\r #/:<>?@[\\\]^|]")
64# https://url.spec.whatwg.org/#forbidden-domain-code-point
65_FORBIDDEN_DOMAIN_CODE_POINT_RE = re.compile(r"[\x00-\x20#%/:<>?@[\\\]^|\x7f]")
66_OPAQUE_HOST_SAFE_CHARS = RFC3986_SUB_DELIMS + b"%"
69def _strip(input_string: str) -> str:
70 if not input_string:
71 return input_string
73 if not _C0_CONTROL_OR_SPACE_RE.search(input_string):
74 return input_string
76 return input_string.strip(_C0_CONTROL_OR_SPACE).translate(
77 _ASCII_TAB_OR_NEWLINE_TRANSLATION_TABLE
78 )
81@functools.cache
82def _hex_encode_table() -> bytes:
83 """Build a lookup table for percent-encoded byte values.
85 | byte | encoding |
86 |------|----------|
87 | 0 | %00 |
88 | 1 | %01 |
89 | ... | ... |
90 | 255 | %FF |
92 Each entry is exactly 3 bytes: b"%HH".
94 Returns:
95 A bytes object of length 256 * 3 containing all percent encodings.
96 """
97 return b"".join(f"%{i:02X}".encode() for i in range(256))
100@functools.cache
101def _hex_decode_table() -> bytes:
102 """Build a lookup table for decoding hex ASCII characters.
104 | ASCII | value |
105 |--------|--------------|
106 | '0'-'9'| 0-9 |
107 | 'A'-'F'| 10-15 |
108 | 'a'-'f'| 10-15 |
109 | other | 255 (invalid)|
111 Returns:
112 A bytes object of length 256 containing nibble values.
113 """
114 table = bytearray([255]) * 256
115 table[48:58] = bytes(range(10)) # '0'-'9'
116 table[65:71] = bytes(range(10, 16)) # 'A'-'F'
117 table[97:103] = bytes(range(10, 16)) # 'a'-'f'
118 return bytes(table)
121@functools.cache
122def _safe_table(safe: bytes = RFC3986_UNRESERVED) -> bytes:
123 """Build a lookup table marking safe (non-encoded) bytes.
125 | byte | is allowed? |
126 |------|-------------|
127 | 0 | 0 |
128 | 32 | 1 (if safe) |
129 | 65 | 1 |
130 | 255 | 0 |
132 Returns:
133 A bytes object of length 256 acting as a boolean mask (0/1).
134 """
135 table = bytearray(256)
136 for b in safe:
137 table[b] = 1
138 return bytes(table)
141@functools.cache
142def _quote_table(safe: bytes = b"", quote_plus: bool = False) -> tuple[bytes, ...]:
143 """Precompute encoding rules for all 256 byte values.
145 Decision table:
146 | condition | output |
147 |-------------------------------|--------|
148 | byte in safe | as-is |
149 | byte == 32 and quote_plus | "+" |
150 | otherwise | "%HH" |
152 Example mapping:
153 | byte | char | output |
154 |------|------|--------|
155 | 65 | A | b"A" |
156 | 32 | space| b"+" |
157 | 255 | N/A | b"%FF" |
159 Returns:
160 A 256-entry tuple mapping byte value (index) -> encoded bytes.
161 """
162 hex_table = _hex_encode_table()
163 allowed = _safe_table(RFC3986_UNRESERVED + safe) if safe else _safe_table()
164 output: list[bytes] = [b""] * 256
166 for idx, byte in enumerate(range(256)):
167 if allowed[byte]:
168 output[idx] = chr(byte).encode()
169 elif quote_plus and byte == 32: # ord(' ')
170 output[idx] = b"+"
171 else:
172 offset = byte * 3
173 output[idx] = hex_table[offset : offset + 3]
175 return tuple(output)
178def _quote(data: bytes, safe: bytes = b"", quote_plus: bool = False) -> bytes:
179 """Fast URL-style quoting using a precomputed table.
181 Args:
182 data: Input bytes.
183 safe: Additional unescaped bytes.
184 quote_plus: Encode space as '+' if True.
186 Returns:
187 Percent-encoded bytes.
188 """
189 if not data: # pragma: no cover
190 return b""
192 transform_table = _quote_table(safe, quote_plus)
193 return b"".join([transform_table[byte] for byte in data])
196def _quote_into(
197 data: bytes, output: bytearray, safe: bytes = b"", quote_plus: bool = False
198) -> None:
199 if not data: # pragma: no cover
200 return
202 transform_table = _quote_table(safe, quote_plus)
203 output += b"".join([transform_table[byte] for byte in data])
206def _unquote(data: bytes | bytearray | str) -> bytes:
207 if not data:
208 return b""
210 if isinstance(data, str):
211 data = data.encode()
213 first_percent = data.find(b"%")
215 if first_percent < 0:
216 return bytes(data)
218 hex_decode_table = _hex_decode_table()
220 data_length = len(data)
221 # stop at len - 2 because "%HH" decoding reads 2 extra bytes after '%'
222 decode_limit = data_length - 2
224 output = bytearray(data_length)
225 output[:first_percent] = data[:first_percent]
227 input_index = first_percent
228 output_index = first_percent
230 while input_index < decode_limit:
231 current_byte = data[input_index]
233 if current_byte == 37: # ord('%')
234 # Decoding "%HH" sequence
235 # Step 1: read two hex characters after '%'
236 # Example: "%4F" -> '4' and 'F'
237 high_nibble = hex_decode_table[data[input_index + 1]]
238 low_nibble = hex_decode_table[data[input_index + 2]]
240 # Step 2: validate both characters are valid hex digits
241 # hex_decode_table returns 255 for invalid input
242 # bitwise OR catches any invalid nibble quickly
243 if (high_nibble | low_nibble) != 255:
244 # Step 3: combine two 4-bit nibbles into one byte
245 # (high_nibble << 4) + low_nibble
246 # Example: 0x4 and 0xF -> 0x4F
247 output[output_index] = (high_nibble << 4) | low_nibble
248 input_index += 3 # skip past "%HH" in input
249 output_index += 1 # advance output position by one decoded byte
250 continue
252 output[output_index] = current_byte
253 input_index += 1
254 output_index += 1
256 while input_index < data_length: # tail
257 output[output_index] = data[input_index]
258 input_index += 1
259 output_index += 1
261 return bytes(output[:output_index])
264def _unquote_plus(
265 data: bytes | bytearray | str,
266) -> bytes:
267 # This function is intentionally duplicated from `_unquote` for performance.
268 # The duplication avoids extra branching for '+' handling in hot loop.
269 if not data:
270 return b""
272 if isinstance(data, str): # pragma: no cover
273 data = data.encode()
275 first_percent = data.find(b"%")
276 first_plus = data.find(b"+")
278 first_special = min(first_plus, first_percent)
280 if first_special < 0:
281 first_special = max(first_percent, first_plus)
283 if first_special < 0:
284 return bytes(data)
286 hex_decode_table = _hex_decode_table()
288 data_length = len(data)
289 decode_limit = data_length - 2
291 output = bytearray(data_length)
292 output[:first_special] = data[:first_special]
294 input_index = first_special
295 output_index = first_special
297 while input_index < decode_limit:
298 current_byte = data[input_index]
300 if current_byte == 43: # ord('+')
301 output[output_index] = 32 # ord(' ')
302 input_index += 1
303 output_index += 1
304 continue
306 if current_byte == 37: # ord('%')
307 high_nibble = hex_decode_table[data[input_index + 1]]
308 low_nibble = hex_decode_table[data[input_index + 2]]
310 if (high_nibble | low_nibble) != 255:
311 output[output_index] = (high_nibble << 4) | low_nibble
312 input_index += 3
313 output_index += 1
314 continue
316 output[output_index] = current_byte
317 input_index += 1
318 output_index += 1
320 while input_index < data_length: # tail
321 current_byte = data[input_index]
323 if current_byte == 43: # ord('+')
324 output[output_index] = 32 # ord(' ')
325 else:
326 output[output_index] = current_byte
328 input_index += 1
329 output_index += 1
331 return bytes(output[:output_index])
334def _parse_qs(
335 qs: str | bytes,
336 keep_blank_values: bool = False,
337 separator: bytes = b"&",
338) -> dict[bytes, list[bytes]]:
339 """Reimplementation of urllib.parse.parse_qs which:
340 - Doesn't use _coerce_args or _coerce_result
341 - Works directly on bytes internally (no type coercion layer)
342 - Returns bytes keys/values only"""
343 if not qs: # pragma: no cover
344 return {}
346 if isinstance(qs, str): # pragma: no cover
347 qs = qs.encode()
349 result: dict[bytes, list[bytes]] = {}
351 for field in qs.split(separator):
352 if not field:
353 continue
355 key, sep, value = field.partition(b"=")
357 if not keep_blank_values and (not sep or not value):
358 continue
360 key = _unquote_plus(key)
361 value = _unquote_plus(value)
363 if key in result:
364 result[key].append(value)
365 else:
366 result[key] = [value]
368 return result
371def _parse_qsl(
372 qs: str | bytes,
373 keep_blank_values: bool = False,
374 separator: bytes = b"&",
375) -> list[tuple[bytes, bytes]]:
376 """Reimplementation of urllib.parse.parse_qsl which:
377 - Doesn't use _coerce_args or _coerce_result
378 - Works directly on bytes internally (no type coercion layer)
379 - Returns only bytes tuples"""
380 # This function is intentionally duplicated from `_parse_qs` for performance.
381 if not qs:
382 return []
384 if isinstance(qs, str):
385 qs = qs.encode()
387 result: list[tuple[bytes, bytes]] = []
389 for field in qs.split(separator):
390 if not field:
391 continue
393 key, sep, value = field.partition(b"=")
395 if not keep_blank_values and (not sep or not value):
396 continue
398 result.append((_unquote_plus(key), _unquote_plus(value)))
400 return result
403def _urlencode(query: _QueryType, separator: bytes = b"&") -> bytes:
404 if hasattr(query, "items"): # pragma: no cover
405 query = query.items() # type: ignore[assignment]
407 if not query: # pragma: no cover
408 return b""
410 result: list[bytes] = []
411 tmp_buf = bytearray()
413 for key, value in query: # type: ignore[str-unpack]
414 _quote_into(
415 key if isinstance(key, bytes) else str(key).encode(),
416 output=tmp_buf,
417 quote_plus=True,
418 )
419 tmp_buf.append(61) # ord('=')
420 _quote_into(
421 value if isinstance(value, bytes) else str(value).encode(),
422 output=tmp_buf,
423 quote_plus=True,
424 )
425 result.append(bytes(tmp_buf))
426 tmp_buf.clear()
428 return separator.join(result)
431def _split_params(scheme: str, url: str) -> tuple[str, str]:
432 """Split the params from the path, as urlib.parse.urlparse does."""
433 if scheme in _USES_PARAMS:
434 # Only a ";" in the last segment starts the params; one in an earlier
435 # segment is an ordinary path character.
436 semi_idx = url.find(";", url.rfind("/") + 1)
438 if semi_idx != -1:
439 return url[:semi_idx], url[semi_idx + 1 :]
441 return url, ""
444def _urlparse(
445 url: str,
446 scheme: str = "",
447 allow_fragments: bool = True,
448) -> ParseResult:
449 """Reimplementation of urlib.parse.urlparse but without _coerce_args/_coerce_result."""
450 if not url: # pragma: no cover
451 return ParseResult(scheme, "", "", "", "", "")
453 scheme, netloc, url, query, fragment = _urlsplit(url, scheme, allow_fragments)
454 url, params = _split_params(scheme, url)
456 return ParseResult(scheme, netloc, url, params, query, fragment)
459def _urlunparse(
460 scheme: str,
461 netloc: str,
462 url: str,
463 params: str,
464 query: str,
465 fragment: str,
466) -> str:
467 """Reimplementation of urlib.parse.urlunparse but without _coerce_args/_coerce_result."""
468 if params:
469 url = f"{url};{params}"
470 return _urlunsplit(scheme, netloc, url, query, fragment)
473def _urlunsplit(scheme: str, netloc: str, url: str, query: str, fragment: str) -> str:
474 """Reimplementation of urlib.parse.urlunsplit but without _coerce_args/_coerce_result."""
476 if netloc:
477 if url and url[:1] != "/":
478 url = f"/{url}"
479 url = f"//{netloc}{url}"
480 elif url[:2] == "//" or (
481 scheme and scheme in _USES_NETLOC and (not url or url[:1] == "/")
482 ):
483 url = f"//{url}"
485 if scheme:
486 scheme = f"{scheme}:"
488 if query:
489 query = f"?{query}"
491 if fragment:
492 fragment = f"#{fragment}"
494 return f"{scheme}{url}{query}{fragment}"
497@dataclasses.dataclass(slots=True, eq=False, repr=False)
498class _SplitResult: # pylint: disable=too-many-instance-attributes
499 scheme: str
500 netloc: str
501 path: str
502 query: str
503 fragment: str
505 username: str | None = None
506 password: str | None = None
507 hostname: str | None = None
508 port: str | int | None = None
510 def __post_init__(self) -> None:
511 if self.hostname is not None:
512 hostname, delim, zone = self.hostname.partition("%")
513 self.hostname = f"{hostname.lower()}{delim}{zone}"
515 if self.port is not None:
516 if isinstance(self.port, str) and not (
517 self.port.isascii() and self.port.isdigit()
518 ):
519 raise ValueError(
520 f"Port could not be cast to integer value as {self.port}"
521 )
522 self.port = int(self.port)
524 if self.port not in range(65535 + 1):
525 raise ValueError("Port out of range 0-65535")
527 def __iter__(self) -> Generator[str]:
528 yield self.scheme
529 yield self.netloc
530 yield self.path
531 yield self.query
532 yield self.fragment
534 def __len__(self) -> int:
535 return 5 # pragma: no cover
537 def __getitem__(self, index: int) -> str: # pragma: no cover
538 match index:
539 case 0:
540 return self.scheme
541 case 1:
542 return self.netloc
543 case 2:
544 return self.path
545 case 3:
546 return self.query
547 case 4:
548 return self.fragment
549 raise IndexError
552def _checknetloc(netloc: str) -> None:
553 """
554 Validate that NFKC normalization does not introduce reserved URL characters.
556 Raises:
557 ValueError: If normalization introduces reserved delimiters.
558 """
559 if not netloc or netloc.isascii():
560 return
562 # IDNA uses NFKC equivalence. Remove already-valid delimiters before
563 # normalization so we only detect newly introduced ones.
564 cleaned, normalized = _nfkc_netloc(netloc)
566 if cleaned == normalized:
567 return
569 if _NETLOC_DELIMS_RE.search(normalized):
570 raise ValueError(
571 f"netloc {netloc!r} contains invalid characters under NFKC normalization"
572 )
575def _check_bracketed_netloc(netloc: str) -> None:
576 """
577 Validate bracket usage in a URL netloc.
579 Raises:
580 ValueError: If bracket placement or host syntax is invalid.
582 This is basically a backport of https://github.com/python/cpython/issues/105704
583 """
584 hostname_and_port = netloc.rpartition("@")[2]
586 before_bracket, has_open_bracket, bracketed = hostname_and_port.partition("[")
588 if not has_open_bracket:
589 # Brackets confined to the userinfo leave a regular host.
590 if "]" in hostname_and_port:
591 raise ValueError("Invalid IPv6 URL")
592 return
594 # No data is allowed before '['.
595 if before_bracket:
596 raise ValueError("Invalid IPv6 URL")
598 hostname, _, port = bracketed.partition("]")
600 # Only ':<port>' may follow ']'.
601 if port and not port.startswith(":"):
602 raise ValueError("Invalid IPv6 URL")
603 # port validation done after, in `_SplitResult.__post_init__`
605 _check_bracketed_host(hostname)
608def _check_bracketed_host(hostname: str) -> None:
609 """
610 Validate a bracketed host according to RFC 3986 / WHATWG URL rules.
612 Raises:
613 ValueError: If the host is invalid.
614 """
615 # IPvFuture: v<HEXDIG>.<address>
616 if hostname.startswith(("v", "V")):
617 if not _IPV_FUTURE_RE.fullmatch(hostname):
618 raise ValueError("IPvFuture address is invalid")
619 return
621 # ip_address() raises ValueError if invalid.
622 ip = ipaddress.ip_address(hostname)
624 # Bracketed IPv4 literals are forbidden.
625 if isinstance(ip, ipaddress.IPv4Address):
626 raise ValueError("An IPv4 address cannot be in brackets") # noqa: TRY004
629@functools.lru_cache
630def _urlsplit( # pylint: disable=too-many-locals,too-many-statements
631 url: str,
632 scheme: str = "",
633 allow_fragments: bool = True,
634) -> _SplitResult:
635 """Reimplementation of urllib.parse.urlsplit which:
636 - Doesn't use _coerce_args or _coerce_result
637 - Does manual single-pass scanning instead of repeated .find/.split calls
638 (where it's beneficial)
639 - Have reduced string allocations by slicing once using computed indices
640 - Avoids extra computations as much as possible
641 """
642 if not url:
643 return _SplitResult(scheme, "", "", "", "")
645 # urllib.parse.urlsplit removes every ASCII tab and newline from anywhere in
646 # the URL before parsing (the WHATWG "remove all ASCII tab or newline"
647 # step). This reimplementation only stripped leading C0/space, so a tab or
648 # newline embedded in the authority survived: parse_url("http://exa\tmple.com")
649 # reported host "exa\tmple.com" while urlsplit and browsers drop the tab and
650 # resolve "example.com". The membership checks keep the common tab-free path
651 # free of translate calls and string allocations.
652 if "\t" in url or "\n" in url or "\r" in url:
653 url = url.translate(_ASCII_TAB_OR_NEWLINE_TRANSLATION_TABLE)
654 if "\t" in scheme or "\n" in scheme or "\r" in scheme:
655 scheme = scheme.translate(_ASCII_TAB_OR_NEWLINE_TRANSLATION_TABLE)
656 url, scheme = url.lstrip(_C0_CONTROL_OR_SPACE), scheme.strip(_C0_CONTROL_OR_SPACE)
658 netloc = query = fragment = ""
660 if m := _SCHEME_RE.match(url):
661 scheme = m.group(1).lower()
662 url = url[m.end() :]
664 # The URL living standard treats "\" like "/" for special-scheme URLs, but
665 # only in the authority and path; a "\" in the query or fragment is left
666 # alone. Without this, "http://evil.com\@good.com/" is read as userinfo
667 # "evil.com\" plus host "good.com", while a browser ends the authority at
668 # the "\" and connects to "evil.com".
669 if scheme in _SPECIAL_SCHEMES and "\\" in url:
670 cut = len(url)
671 question_idx = url.find("?")
672 if question_idx != -1:
673 cut = question_idx
674 hash_idx = url.find("#")
675 if hash_idx != -1 and hash_idx < cut:
676 cut = hash_idx
677 url = url[:cut].replace("\\", "/") + url[cut:]
679 # The scan skips the leading "//" of an authority; for URLs without an
680 # authority it must start at 0, otherwise a "?" or "#" at index 0 or 1
681 # (e.g. relative URLs like "a?b" or "a#f") is never recorded.
682 scan_start = 2 if url[:2] == "//" else 0
683 # Multiple str.find() are faster than a single manual loop.
684 slash_pos = url.find("/", scan_start)
685 question_pos = url.find("?", scan_start)
686 hash_pos = url.find("#", scan_start)
687 open_br_pos = url.find("[", scan_start)
688 closing_br_pos = url.find("]", scan_start)
690 if url[:2] == "//":
691 delim = len(url)
693 if 0 < slash_pos < delim:
694 delim = slash_pos
695 if 0 < question_pos < delim:
696 delim = question_pos
697 if 0 < hash_pos < delim:
698 delim = hash_pos
700 # Brackets only delimit an IPv6 host when they fall inside the
701 # authority. A "[" or "]" in the path, query or fragment is an
702 # ordinary character and must not drive IPv6 host parsing.
703 if not 2 <= open_br_pos < delim:
704 open_br_pos = -1
705 if not 2 <= closing_br_pos < delim:
706 closing_br_pos = -1
708 if (open_br_pos != -1) != (closing_br_pos != -1):
709 raise ValueError("Invalid IPv6 URL")
711 netloc = url[2:delim]
712 if open_br_pos != -1 and closing_br_pos != -1:
713 _check_bracketed_netloc(netloc)
715 url = url[delim:]
717 if question_pos != -1:
718 question_pos -= delim
719 if hash_pos != -1:
720 hash_pos -= delim
721 _checknetloc(netloc)
723 if allow_fragments and hash_pos != -1:
724 url, fragment = url[:hash_pos], url[hash_pos + 1 :]
726 if question_pos != -1:
727 url, query = url[:question_pos], url[question_pos + 1 :]
729 username = password = hostname = port = None
730 userinfo, have_info, hostinfo = netloc.rpartition("@")
732 if have_info:
733 username, _, password = userinfo.partition(":")
734 password = password if _ else None
736 if "[" in hostinfo:
737 hostname, _, port = hostinfo.partition("[")[2].partition("]")
738 port = port.partition(":")[2]
739 else:
740 hostname, _, port = hostinfo.partition(":")
742 return _SplitResult(
743 scheme,
744 netloc,
745 url,
746 query,
747 fragment,
748 username,
749 password,
750 hostname,
751 port or None,
752 )
755def _url2pathname(url: str) -> str:
756 """Reimplementation of urllib.request.url2pathname but with faster _unquote"""
757 if not url:
758 return ""
760 # These branches are handled by `_urlparse`
761 if url[:3] == "///": # pragma: no cover
762 url = url[2:]
763 elif url[:12] == "//localhost/": # pragma: no cover
764 url = url[11:]
766 if not _IS_WINDOWS:
767 if "%" not in url:
768 return url
770 return _unquote(url).decode(_FS_ENCODING, _FS_ERRORS)
772 if url[:3] == "///":
773 url = url[1:]
774 url = url.replace(":", "|")
775 if "|" not in url:
776 return _unquote(url.replace("/", "\\").encode()).decode(
777 _FS_ENCODING, _FS_ERRORS
778 )
779 comp = url.split("|")
780 if len(comp) != 2 or comp[0][-1] not in string.ascii_letters:
781 raise OSError(f"Bad URL: {url}")
782 drive = comp[0][-1].upper()
783 tail = _unquote(comp[1].replace("/", "\\")).decode(_FS_ENCODING, _FS_ERRORS)
784 return f"{drive}:{tail}"
787@functools.lru_cache
788def _idna(input_string: str) -> tuple[bytes, str]:
789 """Cached IDNA encoding using Python's built-in 'idna' codec.
791 IDNA processing in CPython is implemented in pure Python (not C), which
792 makes it relatively slow and allocation-heavy. The only lower-level
793 optimisation involved is Unicode normalization (NFKC), which may use
794 optimized internal paths, but IDNA itself remains Python-level logic.
795 """
796 if input_string.isascii():
797 return input_string.encode(), input_string
799 _, normalized = _nfkc_netloc(input_string)
801 if "ß" not in normalized and "ς" not in normalized:
802 encoded = normalized.encode("idna")
803 else:
804 labels = encodings.idna.dots.split(normalized)
805 trailing_dot = len(labels) > 1 and not labels[-1]
806 if trailing_dot:
807 labels.pop()
808 encoded = b".".join(_idna_label(label) for label in labels)
809 if trailing_dot:
810 encoded += b"."
811 return encoded, encoded.decode()
814# The deviation characters "ß" and "ς" are mapped to "ss" and "σ" by IDNA 2003,
815# i.e. by Python's idna codec, but kept by the nontransitional UTS #46
816# processing of the URL living standard, so "faß.example" is "xn--fa-hia.example"
817# in browsers, not the different domain "fass.example".
818# https://www.unicode.org/reports/tr46/#Deviations
819_IDNA_DEVIATION_RE = re.compile("([ßς])")
822def _idna_label(label: str) -> bytes:
823 if "ß" not in label and "ς" not in label:
824 return encodings.idna.ToASCII(label)
825 label = "".join(
826 piece if piece in {"ß", "ς"} else encodings.idna.nameprep(piece)
827 for piece in _IDNA_DEVIATION_RE.split(label)
828 )
829 encoded = b"xn--" + label.encode("punycode")
830 if len(encoded) > 63:
831 raise UnicodeError("label too long")
832 return encoded
835def _idna_bytes(input_string: str) -> bytes:
836 return _idna(input_string)[0]
839@functools.lru_cache
840def _opaque_host(host: str) -> bytes:
841 """Return *host*, the host of a non-special URL, percent-encoded."""
842 if _FORBIDDEN_HOST_CODE_POINT_RE.search(host):
843 raise ValueError(f"{host!r} contains a forbidden host code point")
844 return _quote(host.encode(), _OPAQUE_HOST_SAFE_CHARS)
847def _domain(host: str) -> str:
848 """Return *host*, the host of a special URL, percent-decoded and
849 lowercased, as the URL living standard does before IDNA and IPv4 parsing,
850 e.g. "%31%32%37.0.0.1" as "127.0.0.1"."""
851 if "%" in host:
852 host = _unquote(host).decode("utf-8", "replace")
853 if host.isascii():
854 return host.lower()
855 # str.lower() turns a final "Σ" into "ς", which _idna() keeps, while
856 # UTS #46 maps "Σ" to "σ".
857 return host.replace("Σ", "σ").lower()
860def _check_domain(host: str) -> None:
861 """Raise ValueError if *host*, the IDNA-encoded host of a special URL,
862 contains a forbidden domain code point.
864 A percent-decoded host can contain delimiters such as "/" or "@" that would
865 change where the authority ends if serialized as is.
866 """
867 if _FORBIDDEN_DOMAIN_CODE_POINT_RE.search(host):
868 raise ValueError(f"{host!r} contains a forbidden domain code point")
871# https://url.spec.whatwg.org/#ipv4-number-parser
872# A number is hexadecimal after "0x", octal after any other leading "0", and
873# decimal otherwise. The radix is matched by hand because int() also takes
874# signs, underscores, whitespace and non-ASCII digits.
875_IPV4_NUMBER_RE = re.compile(r"(0[xX])([0-9a-fA-F]*)|(0[0-7]*)|([1-9][0-9]*)")
876_IPV4_HEX_LAST_PART_RE = re.compile(r"0[xX][0-9a-fA-F]*")
877_IPV4_LAST_CHARS = frozenset("0123456789abcdefxABCDEFX.")
880def _ipv4_number(part: str) -> int | None:
881 match = _IPV4_NUMBER_RE.fullmatch(part)
882 if match is None:
883 return None
884 _, hex_digits, octal, decimal = match.groups()
885 if hex_digits is not None:
886 return int(hex_digits or "0", 16)
887 if octal is not None:
888 return int(octal, 8)
889 return int(decimal)
892@functools.lru_cache
893def _ipv4_host(host: str) -> str:
894 """Return *host*, the ASCII host of a special URL, serialized as an IPv4
895 address if it ends in a number, e.g. "0x7f.1" as "127.0.0.1".
897 Browsers and getaddrinfo() read such a host as an address, so leaving it
898 as written would report a host other than the one connected to. Raises
899 ValueError if it ends in a number but is not a valid IPv4 address.
900 """
901 # Most hosts end in a letter that no number ends in.
902 if host[-1:] not in _IPV4_LAST_CHARS:
903 return host
904 # https://url.spec.whatwg.org/#ends-in-a-number-checker
905 parts = host.split(".")
906 if parts[-1] == "" and len(parts) > 1:
907 parts.pop()
908 last = parts[-1]
909 if not (
910 (last.isascii() and last.isdigit()) or _IPV4_HEX_LAST_PART_RE.fullmatch(last)
911 ):
912 return host
913 # https://url.spec.whatwg.org/#concept-ipv4-parser
914 if len(parts) > 4:
915 raise ValueError(f"{host!r} is not a valid IPv4 address")
916 numbers = []
917 for part in parts:
918 number = _ipv4_number(part)
919 if number is None:
920 raise ValueError(f"{host!r} is not a valid IPv4 address")
921 numbers.append(number)
922 if any(number > 255 for number in numbers[:-1]) or numbers[-1] >= 256 ** (
923 5 - len(numbers)
924 ):
925 raise ValueError(f"{host!r} is not a valid IPv4 address")
926 address = numbers[-1]
927 for i, number in enumerate(numbers[:-1]):
928 address += number * 256 ** (3 - i)
929 return str(ipaddress.IPv4Address(address))
932@functools.lru_cache
933def _nfkc_netloc(netloc: str) -> tuple[str, str]:
934 cleaned = netloc.translate(_NETLOC_STRIP_CHARS)
935 normalized = unicodedata.normalize("NFKC", cleaned)
936 return cleaned, normalized