v0.166.0
 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    ]