Coverage for src/httpx/_content.py: 98%

194 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-06-16 18:32 +0100

1import json 

2import os 

3import typing 

4 

5from ._streams import Stream, ByteStream, FileStream, IterByteStream 

6from ._urlencode import urldecode, urlencode 

7 

8__all__ = [ 

9 "Content", 

10 "Form", 

11 "File", 

12 "Files", 

13 "JSON", 

14 "MultiPart", 

15 "Text", 

16 "HTML", 

17] 

18 

19# https://github.com/nginx/nginx/blob/master/conf/mime.types 

20_content_types = { 

21 ".json": "application/json", 

22 ".js": "application/javascript", 

23 ".html": "text/html", 

24 ".css": "text/css", 

25 ".png": "image/png", 

26 ".jpeg": "image/jpeg", 

27 ".jpg": "image/jpeg", 

28 ".gif": "image/gif", 

29} 

30 

31 

32class Content: 

33 def encode(self) -> tuple[Stream, str]: 

34 raise NotImplementedError() 

35 

36 

37class Form(typing.Mapping[str, str], Content): 

38 """ 

39 HTML form data, as an immutable multi-dict. 

40 Form parameters, as a multi-dict. 

41 """ 

42 

43 def __init__( 

44 self, 

45 form: ( 

46 typing.Mapping[str, str | typing.Sequence[str]] 

47 | typing.Sequence[tuple[str, str]] 

48 | str 

49 | None 

50 ) = None, 

51 ) -> None: 

52 d: dict[str, list[str]] = {} 

53 

54 if form is None: 

55 d = {} 

56 elif isinstance(form, str): 

57 d = urldecode(form) 

58 elif isinstance(form, typing.Mapping): 

59 # Convert dict inputs like: 

60 # {"a": "123", "b": ["456", "789"]} 

61 # To dict inputs where values are always lists, like: 

62 # {"a": ["123"], "b": ["456", "789"]} 

63 d = {k: [v] if isinstance(v, str) else list(v) for k, v in form.items()} 

64 else: 

65 # Convert list inputs like: 

66 # [("a", "123"), ("a", "456"), ("b", "789")] 

67 # To a dict representation, like: 

68 # {"a": ["123", "456"], "b": ["789"]} 

69 for k, v in form: 

70 d.setdefault(k, []).append(v) 

71 

72 self._dict = d 

73 

74 # Content API 

75 

76 def encode(self) -> tuple[Stream, str]: 

77 stream = ByteStream(str(self).encode("ascii")) 

78 content_type = "application/x-www-form-urlencoded" 

79 return (stream, content_type) 

80 

81 # Dict operations 

82 

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

84 return self._dict.keys() 

85 

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

87 return {k: v[0] for k, v in self._dict.items()}.values() 

88 

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

90 return {k: v[0] for k, v in self._dict.items()}.items() 

91 

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

93 if key in self._dict: 

94 return self._dict[key][0] 

95 return default 

96 

97 # Multi-dict operations 

98 

99 def multi_items(self) -> list[tuple[str, str]]: 

100 multi_items: list[tuple[str, str]] = [] 

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

102 multi_items.extend([(k, i) for i in v]) 

103 return multi_items 

104 

105 def multi_dict(self) -> dict[str, list[str]]: 

106 return {k: list(v) for k, v in self._dict.items()} 

107 

108 def get_list(self, key: str) -> list[str]: 

109 return list(self._dict.get(key, [])) 

110 

111 # Update operations 

112 

113 def copy_set(self, key: str, value: str) -> "Form": 

114 d = self.multi_dict() 

115 d[key] = [value] 

116 return Form(d) 

117 

118 def copy_append(self, key: str, value: str) -> "Form": 

119 d = self.multi_dict() 

120 d[key] = d.get(key, []) + [value] 

121 return Form(d) 

122 

123 def copy_remove(self, key: str) -> "Form": 

124 d = self.multi_dict() 

125 d.pop(key, None) 

126 return Form(d) 

127 

128 # Accessors & built-ins 

129 

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

131 return self._dict[key][0] 

132 

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

134 return key in self._dict 

135 

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

137 return iter(self.keys()) 

138 

139 def __len__(self) -> int: 

140 return len(self._dict) 

141 

142 def __bool__(self) -> bool: 

143 return bool(self._dict) 

144 

145 def __hash__(self) -> int: 

146 return hash(str(self)) 

147 

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

149 return ( 

150 isinstance(other, Form) and 

151 sorted(self.multi_items()) == sorted(other.multi_items()) 

152 ) 

153 

154 def __str__(self) -> str: 

155 return urlencode(self.multi_dict()) 

156 

157 def __repr__(self) -> str: 

158 return f"<Form {self.multi_items()!r}>" 

159 

160 

161class File(Content): 

162 """ 

163 Wrapper class used for files in uploads and multipart requests. 

164 """ 

165 

166 def __init__(self, path: str): 

167 self._path = path 

168 

169 def name(self) -> str: 

170 return os.path.basename(self._path) 

171 

172 def size(self) -> int: 

173 return os.path.getsize(self._path) 

174 

175 def content_type(self) -> str: 

176 _, ext = os.path.splitext(self._path) 

177 ct = _content_types.get(ext, "application/octet-stream") 

178 if ct.startswith('text/'): 

179 ct += "; charset='utf-8'" 

180 return ct 

181 

182 def encode(self) -> tuple[Stream, str]: 

183 stream = FileStream(self._path) 

184 content_type = self.content_type() 

185 return (stream, content_type) 

186 

187 def __lt__(self, other: typing.Any) -> bool: 

188 return isinstance(other, File) and other._path < self._path 

189 

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

191 return isinstance(other, File) and other._path == self._path 

192 

193 def __repr__(self) -> str: 

194 return f"<File {self._path!r}>" 

195 

196 

197class Files(typing.Mapping[str, File], Content): 

198 """ 

199 File parameters, as a multi-dict. 

200 """ 

201 

202 def __init__( 

203 self, 

204 files: ( 

205 typing.Mapping[str, File | typing.Sequence[File]] 

206 | typing.Sequence[tuple[str, File]] 

207 | None 

208 ) = None, 

209 ) -> None: 

210 d: dict[str, list[File]] = {} 

211 

212 if files is None: 

213 d = {} 

214 elif isinstance(files, typing.Mapping): 

215 d = {k: [v] if isinstance(v, File) else list(v) for k, v in files.items()} 

216 else: 

217 d = {} 

218 for k, v in files: 

219 d.setdefault(k, []).append(v) 

220 

221 self._dict = d 

222 

223 # Standard dict interface 

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

225 return self._dict.keys() 

226 

227 def values(self) -> typing.ValuesView[File]: 

228 return {k: v[0] for k, v in self._dict.items()}.values() 

229 

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

231 return {k: v[0] for k, v in self._dict.items()}.items() 

232 

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

234 if key in self._dict: 

235 return self._dict[key][0] 

236 return None 

237 

238 # Multi dict interface 

239 def multi_items(self) -> list[tuple[str, File]]: 

240 multi_items: list[tuple[str, File]] = [] 

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

242 multi_items.extend([(k, i) for i in v]) 

243 return multi_items 

244 

245 def multi_dict(self) -> dict[str, list[File]]: 

246 return {k: list(v) for k, v in self._dict.items()} 

247 

248 def get_list(self, key: str) -> list[File]: 

249 return list(self._dict.get(key, [])) 

250 

251 # Content interface 

252 def encode(self) -> tuple[Stream, str]: 

253 return MultiPart(files=self).encode() 

254 

255 # Builtins 

256 def __getitem__(self, key: str) -> File: 

257 return self._dict[key][0] 

258 

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

260 return key in self._dict 

261 

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

263 return iter(self.keys()) 

264 

265 def __len__(self) -> int: 

266 return len(self._dict) 

267 

268 def __bool__(self) -> bool: 

269 return bool(self._dict) 

270 

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

272 return ( 

273 isinstance(other, Files) and 

274 sorted(self.multi_items()) == sorted(other.multi_items()) 

275 ) 

276 

277 def __repr__(self) -> str: 

278 return f"<Files {self.multi_items()!r}>" 

279 

280 

281class JSON(Content): 

282 def __init__(self, data: typing.Any) -> None: 

283 self._data = data 

284 

285 def encode(self) -> tuple[Stream, str]: 

286 content = json.dumps( 

287 self._data, 

288 ensure_ascii=False, 

289 separators=(",", ":"), 

290 allow_nan=False 

291 ).encode("utf-8") 

292 stream = ByteStream(content) 

293 content_type = "application/json" 

294 return (stream, content_type) 

295 

296 

297class Text(Content): 

298 def __init__(self, text: str) -> None: 

299 self._text = text 

300 

301 def encode(self) -> tuple[Stream, str]: 

302 stream = ByteStream(self._text.encode("utf-8")) 

303 content_type = "text/plain; charset='utf-8'" 

304 return (stream, content_type) 

305 

306 

307class HTML(Content): 

308 def __init__(self, text: str) -> None: 

309 self._text = text 

310 

311 def encode(self) -> tuple[Stream, str]: 

312 stream = ByteStream(self._text.encode("utf-8")) 

313 content_type = "text/html; charset='utf-8'" 

314 return (stream, content_type) 

315 

316 

317class MultiPart(Content): 

318 def __init__( 

319 self, 

320 form: ( 

321 Form 

322 | typing.Mapping[str, str | typing.Sequence[str]] 

323 | typing.Sequence[tuple[str, str]] 

324 | str 

325 | None 

326 ) = None, 

327 files: ( 

328 Files 

329 | typing.Mapping[str, File | typing.Sequence[File]] 

330 | typing.Sequence[tuple[str, File]] 

331 | None 

332 ) = None, 

333 boundary: str | None = None 

334 ): 

335 self._form = form if isinstance(form , Form) else Form(form) 

336 self._files = files if isinstance(files, Files) else Files(files) 

337 self._boundary = os.urandom(16).hex() if boundary is None else boundary 

338 

339 @property 

340 def form(self) -> Form: 

341 return self._form 

342 

343 @property 

344 def files(self) -> Files: 

345 return self._files 

346 

347 def encode(self) -> tuple[Stream, str]: 

348 stream = IterByteStream(self.iter_bytes()) 

349 content_type = f"multipart/form-data; boundary={self._boundary}" 

350 return (stream, content_type) 

351 

352 def iter_bytes(self) -> typing.Iterator[bytes]: 

353 for key, value in self._form.multi_items(): 

354 # See https://html.spec.whatwg.org/ - LF, CR, and " must be percent escaped. 

355 name = key.translate({10: "%0A", 13: "%0D", 34: "%22"}) 

356 yield ( 

357 f"--{self._boundary}\r\n" 

358 f'Content-Disposition: form-data; name="{name}"\r\n' 

359 f"\r\n" 

360 f"{value}\r\n" 

361 ).encode("utf-8") 

362 

363 for key, file in self._files.multi_items(): 

364 # See https://html.spec.whatwg.org/ - LF, CR, and " must be percent escaped. 

365 name = key.translate({10: "%0A", 13: "%0D", 34: "%22"}) 

366 filename = file.name().translate({10: "%0A", 13: "%0D", 34: "%22"}) 

367 stream, _ = file.encode() 

368 yield ( 

369 f"--{self._boundary}\r\n" 

370 f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' 

371 f"\r\n" 

372 ).encode("utf-8") 

373 for buffer in stream: 

374 yield buffer 

375 yield "\r\n".encode("utf-8") 

376 

377 yield f"--{self._boundary}--\r\n".encode("utf-8")