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

1 import json

2 import os

3 import typing

4

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

6 from ._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

32 class Content:

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

34 raise NotImplementedError()

35

36

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

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

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

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

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

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

317 class 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")