Coverage for src/httpx/_headers.py: 93%

1 import typing

2

3

4 __all__ = ["Headers"]

5

6

7 VALID_HEADER_CHARS = (

8 "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

9 "abcdefghijklmnopqrstuvwxyz"

10 "0123456789"

11 "!#$%&'*+-.^_`|~"

12)

13

14

15# TODO...

16#

17# * Comma folded values, eg. `Vary: ...`

18# * Multiple Set-Cookie headers.

19# * Non-ascii support.

20# * Ordering, including `Host` header exception.

21

22

23 def headername(name: str) -> str:

24 if name.strip(VALID_HEADER_CHARS) or not name:

25 raise ValueError("Invalid HTTP header name {key!r}.")

26 return name

27

28

29 def headervalue(value: str) -> str:

30 value = value.strip(" ")

31 if not value or not value.isascii() or not value.isprintable():

32 raise ValueError("Invalid HTTP header value {key!r}.")

33 return value

34

35

36 class Headers(typing.Mapping[str, str]):

37 def __init__(

38 self,

39 headers: typing.Mapping[str, str] | typing.Sequence[tuple[str, str]] | None = None,

40 ) -> None:

41 # {'accept': ('Accept', '*/*')}

42 d: dict[str, str] = {}

43

44 if isinstance(headers, typing.Mapping):

45 # Headers({

46 # 'Content-Length': '1024',

47 # 'Content-Type': 'text/plain; charset=utf-8',

48 # )

49 d = {headername(k): headervalue(v) for k, v in headers.items()}

50 elif headers is not None:

51 # Headers([

52 # ('Location', 'https://www.example.com'),

53 # ('Set-Cookie', 'session_id=3498jj489jhb98jn'),

54 # ])

55 d = {headername(k): headervalue(v) for k, v in headers}

56

57 self._dict = d

58

59 def keys(self) -> typing.KeysView[str]:

60 """

61 Return all the header keys.

62

63 Usage:

64

65 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})

66 assert list(h.keys()) == ["Accept", "User-Agent"]

67 """

68 return self._dict.keys()

69

70 def values(self) -> typing.ValuesView[str]:

71 """

72 Return all the header values.

73

74 Usage:

75

76 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})

77 assert list(h.values()) == ["*/*", "python/httpx"]

78 """

79 return self._dict.values()

80

81 def items(self) -> typing.ItemsView[str, str]:

82 """

83 Return all headers as (key, value) tuples.

84

85 Usage:

86

87 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})

88 assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")]

89 """

90 return self._dict.items()

91

92 def get(self, key: str, default: typing.Any = None) -> typing.Any:

93 """

94 Get a value from the query param for a given key. If the key occurs

95 more than once, then only the first value is returned.

96

97 Usage:

98

99 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})

100 assert h.get("User-Agent") == "python/httpx"

101 """

102 for k, v in self._dict.items():

103 if k.lower() == key.lower():

104 return v

105 return default

106

107 def copy_set(self, key: str, value: str) -> "Headers":

108 """

109 Return a new Headers instance, setting the value of a key.

110

111 Usage:

112

113 h = httpx.Headers({"Expires": "0"})

114 h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT")

115 assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"})

116 """

117 l = []

118 seen = False

119

120 # Either insert...

121 for k, v in self._dict.items():

122 if k.lower() == key.lower():

123 l.append((key, value))

124 seen = True

125 else:

126 l.append((k, v))

127

128 # Or append...

129 if not seen:

130 l.append((key, value))

131

132 return Headers(l)

133

134 def copy_remove(self, key: str) -> "Headers":

135 """

136 Return a new Headers instance, removing the value of a key.

137

138 Usage:

139

140 h = httpx.Headers({"Accept": "*/*"})

141 h = h.copy_remove("Accept")

142 assert h == httpx.Headers({})

143 """

144 h = {k: v for k, v in self._dict.items() if k.lower() != key.lower()}

145 return Headers(h)

146

147 def copy_update(self, update: "Headers" | typing.Mapping[str, str] | None) -> "Headers":

148 """

149 Return a new Headers instance, removing the value of a key.

150

151 Usage:

152

153 h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"})

154 h = h.copy_update({"Accept-Encoding": "gzip"})

155 assert h == httpx.Headers({"Accept": "*/*", "Accept-Encoding": "gzip", "User-Agent": "python/httpx"})

156 """

157 if update is None:

158 return self

159

160 new = update if isinstance(update, Headers) else Headers(update)

161

162 # Remove updated items using a case-insensitive approach...

163 keys = set([key.lower() for key in new.keys()])

164 h = {k: v for k, v in self._dict.items() if k.lower() not in keys}

165

166 # Perform the actual update...

167 h.update(dict(new))

168

169 return Headers(h)

170

171 def __getitem__(self, key: str) -> str:

172 match = key.lower()

173 for k, v in self._dict.items():

174 if k.lower() == match:

175 return v

176 raise KeyError(key)

177

178 def __contains__(self, key: typing.Any) -> bool:

179 match = key.lower()

180 return any(k.lower() == match for k in self._dict.keys())

181

182 def __iter__(self) -> typing.Iterator[str]:

183 return iter(self.keys())

184

185 def __len__(self) -> int:

186 return len(self._dict)

187

188 def __bool__(self) -> bool:

189 return bool(self._dict)

190

191 def __eq__(self, other: typing.Any) -> bool:

192 self_lower = {k.lower(): v for k, v in self.items()}

193 other_lower = {k.lower(): v for k, v in Headers(other).items()}

194 return self_lower == other_lower

195

196 def __repr__(self) -> str:

197 return f"<Headers {dict(self)!r}>"

198

199

200# def parse_content_type(header: str) -> tuple[str, dict[str, str]]:

201# # The Content-Type header is described in RFC 2616 'Content-Type'

202# # https://datatracker.ietf.org/doc/html/rfc2616#section-14.17

203#

204# # The 'type/subtype; parameter' format is described in RFC 2616 'Media Types'

205# # https://datatracker.ietf.org/doc/html/rfc2616#section-3.7

206#

207# # Parameter quoting is described in RFC 2616 'Transfer Codings'

208# # https://datatracker.ietf.org/doc/html/rfc2616#section-3.6

209#

210# header = header.strip()

211# content_type = ''

212# params = {}

213#

214# # Match the content type (up to the first semicolon or end)

215# match = re.match(r'^([^;]+)', header)

216# if match:

217# content_type = match.group(1).strip().lower()

218# rest = header[match.end():]

219# else:

220# return '', {}

221#

222# # Parse parameters, accounting for quoted strings

223# param_pattern = re.compile(r'''

224# ;\s* # Semicolon + optional whitespace

225# (?P<key>[^=;\s]+) # Parameter key

226# = # Equal sign

227# (?P<value> # Parameter value:

228# "(?:[^"\\]|\\.)*" # Quoted string with escapes

229# | # OR

230# [^;]* # Unquoted string (until semicolon)

231# )

232# ''', re.VERBOSE)

233#

234# for match in param_pattern.finditer(rest):

235# key = match.group('key').lower()

236# value = match.group('value').strip()

237# if value.startswith('"') and value.endswith('"'):

238# # Remove surrounding quotes and unescape

239# value = re.sub(r'\\(.)', r'\1', value[1:-1])

240# params[key] = value