1import mimetypes
2import os
3from typing import Any
4
5from plain.utils.encoding import force_bytes
6from plain.utils.itercompat import is_iterable
7
8
9def encode_multipart(boundary: str, data: dict[str, Any]) -> bytes:
10 """
11 Encode multipart POST data from a dictionary of form values.
12
13 The key will be used as the form data name; the value will be transmitted
14 as content. If the value is a file, the contents of the file will be sent
15 as an application/octet-stream; otherwise, str(value) will be sent.
16 """
17 lines: list[bytes] = []
18
19 def to_bytes(s: str) -> bytes:
20 return force_bytes(s, "utf-8")
21
22 # Not by any means perfect, but good enough for our purposes.
23 def is_file(thing: Any) -> bool:
24 return hasattr(thing, "read") and callable(thing.read)
25
26 # Each bit of the multipart form data could be either a form value or a
27 # file, or a *list* of form values and/or files. Remember that HTTP field
28 # names can be duplicated!
29 for key, value in data.items():
30 if value is None:
31 raise TypeError(
32 f"Cannot encode None for key '{key}' as POST data. Did you mean "
33 "to pass an empty string or omit the value?"
34 )
35 elif is_file(value):
36 lines.extend(encode_file(boundary, key, value))
37 elif not isinstance(value, str) and is_iterable(value):
38 for item in value:
39 if is_file(item):
40 lines.extend(encode_file(boundary, key, item))
41 else:
42 lines.extend(
43 to_bytes(val)
44 for val in [
45 f"--{boundary}",
46 f'Content-Disposition: form-data; name="{key}"',
47 "",
48 item,
49 ]
50 )
51 else:
52 lines.extend(
53 to_bytes(val)
54 for val in [
55 f"--{boundary}",
56 f'Content-Disposition: form-data; name="{key}"',
57 "",
58 value,
59 ]
60 )
61
62 lines.extend(
63 [
64 to_bytes(f"--{boundary}--"),
65 b"",
66 ]
67 )
68 return b"\r\n".join(lines)
69
70
71def encode_file(boundary: str, key: str, file: Any) -> list[bytes]:
72 def to_bytes(s: str) -> bytes:
73 return force_bytes(s, "utf-8")
74
75 # file.name might not be a string. For example, it's an int for
76 # tempfile.TemporaryFile().
77 file_has_string_name = hasattr(file, "name") and isinstance(file.name, str)
78 filename = os.path.basename(file.name) if file_has_string_name else ""
79
80 if hasattr(file, "content_type"):
81 content_type = file.content_type
82 elif filename:
83 content_type = mimetypes.guess_type(filename)[0]
84 else:
85 content_type = None
86
87 if content_type is None:
88 content_type = "application/octet-stream"
89 filename = filename or key
90 return [
91 to_bytes(f"--{boundary}"),
92 to_bytes(
93 f'Content-Disposition: form-data; name="{key}"; filename="{filename}"'
94 ),
95 to_bytes(f"Content-Type: {content_type}"),
96 b"",
97 to_bytes(file.read()),
98 ]