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
« prev ^ index » next coverage.py v7.6.12, created at 2025-06-16 18:32 +0100
1import json
2import os
3import typing
5from ._streams import Stream, ByteStream, FileStream, IterByteStream
6from ._urlencode import urldecode, urlencode
8__all__ = [
9 "Content",
10 "Form",
11 "File",
12 "Files",
13 "JSON",
14 "MultiPart",
15 "Text",
16 "HTML",
17]
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}
32class Content:
33 def encode(self) -> tuple[Stream, str]:
34 raise NotImplementedError()
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 """
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]] = {}
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)
72 self._dict = d
74 # Content API
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)
81 # Dict operations
83 def keys(self) -> typing.KeysView[str]:
84 return self._dict.keys()
86 def values(self) -> typing.ValuesView[str]:
87 return {k: v[0] for k, v in self._dict.items()}.values()
89 def items(self) -> typing.ItemsView[str, str]:
90 return {k: v[0] for k, v in self._dict.items()}.items()
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
97 # Multi-dict operations
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
105 def multi_dict(self) -> dict[str, list[str]]:
106 return {k: list(v) for k, v in self._dict.items()}
108 def get_list(self, key: str) -> list[str]:
109 return list(self._dict.get(key, []))
111 # Update operations
113 def copy_set(self, key: str, value: str) -> "Form":
114 d = self.multi_dict()
115 d[key] = [value]
116 return Form(d)
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)
123 def copy_remove(self, key: str) -> "Form":
124 d = self.multi_dict()
125 d.pop(key, None)
126 return Form(d)
128 # Accessors & built-ins
130 def __getitem__(self, key: str) -> str:
131 return self._dict[key][0]
133 def __contains__(self, key: typing.Any) -> bool:
134 return key in self._dict
136 def __iter__(self) -> typing.Iterator[str]:
137 return iter(self.keys())
139 def __len__(self) -> int:
140 return len(self._dict)
142 def __bool__(self) -> bool:
143 return bool(self._dict)
145 def __hash__(self) -> int:
146 return hash(str(self))
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 )
154 def __str__(self) -> str:
155 return urlencode(self.multi_dict())
157 def __repr__(self) -> str:
158 return f"<Form {self.multi_items()!r}>"
161class File(Content):
162 """
163 Wrapper class used for files in uploads and multipart requests.
164 """
166 def __init__(self, path: str):
167 self._path = path
169 def name(self) -> str:
170 return os.path.basename(self._path)
172 def size(self) -> int:
173 return os.path.getsize(self._path)
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
182 def encode(self) -> tuple[Stream, str]:
183 stream = FileStream(self._path)
184 content_type = self.content_type()
185 return (stream, content_type)
187 def __lt__(self, other: typing.Any) -> bool:
188 return isinstance(other, File) and other._path < self._path
190 def __eq__(self, other: typing.Any) -> bool:
191 return isinstance(other, File) and other._path == self._path
193 def __repr__(self) -> str:
194 return f"<File {self._path!r}>"
197class Files(typing.Mapping[str, File], Content):
198 """
199 File parameters, as a multi-dict.
200 """
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]] = {}
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)
221 self._dict = d
223 # Standard dict interface
224 def keys(self) -> typing.KeysView[str]:
225 return self._dict.keys()
227 def values(self) -> typing.ValuesView[File]:
228 return {k: v[0] for k, v in self._dict.items()}.values()
230 def items(self) -> typing.ItemsView[str, File]:
231 return {k: v[0] for k, v in self._dict.items()}.items()
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
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
245 def multi_dict(self) -> dict[str, list[File]]:
246 return {k: list(v) for k, v in self._dict.items()}
248 def get_list(self, key: str) -> list[File]:
249 return list(self._dict.get(key, []))
251 # Content interface
252 def encode(self) -> tuple[Stream, str]:
253 return MultiPart(files=self).encode()
255 # Builtins
256 def __getitem__(self, key: str) -> File:
257 return self._dict[key][0]
259 def __contains__(self, key: typing.Any) -> bool:
260 return key in self._dict
262 def __iter__(self) -> typing.Iterator[str]:
263 return iter(self.keys())
265 def __len__(self) -> int:
266 return len(self._dict)
268 def __bool__(self) -> bool:
269 return bool(self._dict)
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 )
277 def __repr__(self) -> str:
278 return f"<Files {self.multi_items()!r}>"
281class JSON(Content):
282 def __init__(self, data: typing.Any) -> None:
283 self._data = data
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)
297class Text(Content):
298 def __init__(self, text: str) -> None:
299 self._text = text
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)
307class HTML(Content):
308 def __init__(self, text: str) -> None:
309 self._text = text
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)
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
339 @property
340 def form(self) -> Form:
341 return self._form
343 @property
344 def files(self) -> Files:
345 return self._files
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)
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")
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")
377 yield f"--{self._boundary}--\r\n".encode("utf-8")