Coverage for src/httpx/_urlencode.py: 100%

1 import re

2

3 __all__ = ["quote", "unquote", "urldecode", "urlencode"]

4

5

6# Matchs a sequence of one or more '%xx' escapes.

7 PERCENT_ENCODED_REGEX = re.compile("(%[A-Fa-f0-9][A-Fa-f0-9])+")

8

9# https://datatracker.ietf.org/doc/html/rfc3986#section-2.3

10 SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"

11

12

13 def urlencode(multidict, safe=SAFE):

14 pairs = []

15 for key, values in multidict.items():

16 pairs.extend([(key, value) for value in values])

17

18 safe += "+"

19 pairs = [(k.replace(" ", "+"), v.replace(" ", "+")) for k, v in pairs]

20

21 return "&".join(

22 f"{quote(key, safe)}={quote(val, safe)}"

23 for key, val in pairs

24 )

25

26

27 def urldecode(string):

28 parts = [part.partition("=") for part in string.split("&") if part]

29 pairs = [

30 (unquote(key), unquote(val))

31 for key, _, val in parts

32 ]

33

34 pairs = [(k.replace("+", " "), v.replace("+", " ")) for k, v in pairs]

35

36 ret = {}

37 for k, v in pairs:

38 ret.setdefault(k, []).append(v)

39 return ret

40

41

42 def quote(string, safe=SAFE):

43 # Fast path if the string is already safe.

44 if not string.strip(safe):

45 return string

46

47 # Replace any characters not in the safe set with '%xx' escape sequences.

48 return "".join([

49 char if char in safe else percent(char)

50 for char in string

51 ])

52

53

54 def unquote(string):

55 # Fast path if the string is not quoted.

56 if '%' not in string:

57 return string

58

59 # Unquote.

60 parts = []

61 current_position = 0

62 for match in re.finditer(PERCENT_ENCODED_REGEX, string):

63 start_position, end_position = match.start(), match.end()

64 matched_text = match.group(0)

65 # Include any text up to the '%xx' escape sequence.

66 if start_position != current_position:

67 leading_text = string[current_position: start_position]

68 parts.append(leading_text)

69

70 # Decode the '%xx' escape sequence.

71 hex = matched_text.replace('%', '')

72 decoded = bytes.fromhex(hex).decode('utf-8')

73 parts.append(decoded)

74 current_position = end_position

75

76 # Include any text after the final '%xx' escape sequence.

77 if current_position != len(string):

78 trailing_text = string[current_position:]

79 parts.append(trailing_text)

80

81 return "".join(parts)

82

83

84 def percent(c):

85 return ''.join(f"%{b:02X}" for b in c.encode("utf-8"))