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

74 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-05-27 16:49 +0100

1import typing 

2 

3 

4__all__ = ["Headers"] 

5 

6 

7VALID_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 

23def 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 

29def 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 

36class 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