Skip to content

multipart API

multipart

Zero-dependency multipart/form-data parser and encoder.

Parses and encodes multipart/form-data bodies per RFC 7578 / RFC 2046. Designed for HTTP file upload handling without any third-party dependencies.

Part of zerodep: https://github.com/Oaklight/zerodep Copyright (c) 2026 Peng Ding. MIT License.

MultipartError

Bases: Exception

Base exception for the multipart module.

Source code in multipart/multipart.py
class MultipartError(Exception):
    """Base exception for the multipart module."""

MultipartParseError

Bases: MultipartError

Raised when parsing fails (malformed input, missing boundary, etc.).

Source code in multipart/multipart.py
class MultipartParseError(MultipartError):
    """Raised when parsing fails (malformed input, missing boundary, etc.)."""

MultipartEncodeError

Bases: MultipartError

Raised when encoding fails (invalid arguments).

Source code in multipart/multipart.py
class MultipartEncodeError(MultipartError):
    """Raised when encoding fails (invalid arguments)."""

Part dataclass

A single part from a multipart/form-data body.

Attributes:

Name Type Description
name str

Form field name from Content-Disposition.

data bytes

Raw bytes content of this part.

filename str | None

Original filename for file uploads, or None for text fields.

content_type str

MIME type of this part.

headers dict[str, str]

All MIME headers of this part (keys lowercased).

Source code in multipart/multipart.py
@dataclasses.dataclass(frozen=True, slots=True)
class Part:
    """A single part from a multipart/form-data body.

    Attributes:
        name: Form field name from Content-Disposition.
        data: Raw bytes content of this part.
        filename: Original filename for file uploads, or None for text fields.
        content_type: MIME type of this part.
        headers: All MIME headers of this part (keys lowercased).
    """

    name: str
    data: bytes
    filename: str | None = None
    content_type: str = "text/plain"
    headers: dict[str, str] = dataclasses.field(default_factory=dict)

    @property
    def text(self) -> str:
        """Decode data as text using charset from content_type, or UTF-8."""
        charset = _extract_charset(self.content_type)
        return self.data.decode(charset)

    @property
    def is_file(self) -> bool:
        """True if this part has a filename (i.e., is a file upload)."""
        return self.filename is not None

text property

Decode data as text using charset from content_type, or UTF-8.

is_file property

True if this part has a filename (i.e., is a file upload).

extract_boundary(content_type)

Extract the boundary string from a Content-Type header.

Handles both quoted and unquoted boundaries. If the string contains no boundary=, it is returned as-is (assumed to be a bare boundary).

Parameters:

Name Type Description Default
content_type str

Full Content-Type header value or bare boundary string.

required

Returns:

Type Description
str

The boundary string.

Raises:

Type Description
MultipartParseError

If Content-Type is multipart/* but has no boundary.

Source code in multipart/multipart.py
def extract_boundary(content_type: str) -> str:
    """Extract the boundary string from a Content-Type header.

    Handles both quoted and unquoted boundaries.  If the string contains
    no ``boundary=``, it is returned as-is (assumed to be a bare boundary).

    Args:
        content_type: Full Content-Type header value or bare boundary string.

    Returns:
        The boundary string.

    Raises:
        MultipartParseError: If Content-Type is multipart/* but has no boundary.
    """
    # Try to find boundary= in the header
    match = re.search(
        r"boundary\s*=\s*(?:\"([^\"]*)\"|([^\s;]+))",
        content_type,
        re.IGNORECASE,
    )
    if match:
        return match.group(1) if match.group(1) is not None else match.group(2)

    # If it looks like a Content-Type header but has no boundary, error
    if "multipart/" in content_type.lower():
        raise MultipartParseError(
            "Content-Type is multipart/* but contains no boundary parameter"
        )

    # Treat the whole string as a bare boundary value
    return content_type.strip()

parse_multipart(body, content_type, *, max_part_size=_DEFAULT_MAX_PART_SIZE, max_parts=_DEFAULT_MAX_PARTS)

Parse a multipart/form-data request body into a list of parts.

Parameters:

Name Type Description Default
body bytes

The raw request body bytes.

required
content_type str

Full Content-Type header value (e.g. "multipart/form-data; boundary=abc123"), or just the boundary string itself.

required
max_part_size int

Maximum size in bytes for any single part. Set to 0 to disable.

_DEFAULT_MAX_PART_SIZE
max_parts int

Maximum number of parts allowed. Set to 0 to disable.

_DEFAULT_MAX_PARTS

Returns:

Type Description
list[Part]

List of Part objects in the order they appeared.

Raises:

Type Description
MultipartParseError

If the body is malformed or limits are exceeded.

Source code in multipart/multipart.py
def parse_multipart(
    body: bytes,
    content_type: str,
    *,
    max_part_size: int = _DEFAULT_MAX_PART_SIZE,
    max_parts: int = _DEFAULT_MAX_PARTS,
) -> list[Part]:
    """Parse a multipart/form-data request body into a list of parts.

    Args:
        body: The raw request body bytes.
        content_type: Full Content-Type header value
            (e.g. ``"multipart/form-data; boundary=abc123"``), or just the
            boundary string itself.
        max_part_size: Maximum size in bytes for any single part.
            Set to 0 to disable.
        max_parts: Maximum number of parts allowed.
            Set to 0 to disable.

    Returns:
        List of Part objects in the order they appeared.

    Raises:
        MultipartParseError: If the body is malformed or limits are exceeded.
    """
    boundary = extract_boundary(content_type)
    return list(_iter_parts(body, boundary, max_part_size, max_parts))

encode_multipart(fields=None, files=None, *, boundary=None)

Encode form fields and files as a multipart/form-data body.

Parameters:

Name Type Description Default
fields dict[str, str | bytes] | list[tuple[str, str | bytes]] | None

Text form fields as {name: value} dict or [(name, value)] list. Values can be str or bytes.

None
files dict[str, bytes | tuple[str, bytes] | tuple[str, bytes, str]] | list[tuple[str, bytes | tuple[str, bytes] | tuple[str, bytes, str]]] | None

File uploads as dict or list of tuples. Each value can be:

  • bytes -- raw content (auto filename, octet-stream)
  • (filename, bytes) -- named file
  • (filename, bytes, content_type) -- named file with MIME type
None
boundary str | None

Optional boundary string. If None, a random one is generated.

None

Returns:

Type Description
bytes

(body_bytes, content_type_header) where content_type_header is

str

the full "multipart/form-data; boundary=..." string.

Raises:

Type Description
MultipartEncodeError

If arguments are invalid.

Source code in multipart/multipart.py
def encode_multipart(
    fields: dict[str, str | bytes] | list[tuple[str, str | bytes]] | None = None,
    files: (
        dict[str, bytes | tuple[str, bytes] | tuple[str, bytes, str]]
        | list[tuple[str, bytes | tuple[str, bytes] | tuple[str, bytes, str]]]
        | None
    ) = None,
    *,
    boundary: str | None = None,
) -> tuple[bytes, str]:
    """Encode form fields and files as a multipart/form-data body.

    Args:
        fields: Text form fields as ``{name: value}`` dict or
            ``[(name, value)]`` list.  Values can be str or bytes.
        files: File uploads as dict or list of tuples.  Each value can be:

            - ``bytes`` -- raw content (auto filename, octet-stream)
            - ``(filename, bytes)`` -- named file
            - ``(filename, bytes, content_type)`` -- named file with MIME type
        boundary: Optional boundary string.  If None, a random one is
            generated.

    Returns:
        ``(body_bytes, content_type_header)`` where content_type_header is
        the full ``"multipart/form-data; boundary=..."`` string.

    Raises:
        MultipartEncodeError: If arguments are invalid.
    """
    if boundary is None:
        boundary = os.urandom(16).hex()

    if len(boundary) > _MAX_BOUNDARY_LEN:
        raise MultipartEncodeError(
            f"Boundary exceeds maximum length of {_MAX_BOUNDARY_LEN}"
        )

    buf = bytearray()
    delim = f"--{boundary}\r\n".encode()

    # Encode text fields
    field_items: list[tuple[str, str | bytes]] = []
    if isinstance(fields, dict):
        field_items = list(fields.items())
    elif fields is not None:
        field_items = list(fields)

    for name, value in field_items:
        buf += delim
        cd = f'Content-Disposition: form-data; name="{_quote_name(name)}"\r\n'
        buf += cd.encode()
        buf += b"\r\n"
        if isinstance(value, bytes):
            buf += value
        else:
            buf += str(value).encode()
        buf += b"\r\n"

    # Encode file uploads
    file_items: list[tuple[str, Any]] = []
    if isinstance(files, dict):
        file_items = list(files.items())
    elif files is not None:
        file_items = list(files)

    for name, file_val in file_items:
        buf += delim
        filename: str
        data: bytes
        ct: str

        if isinstance(file_val, bytes):
            filename = name
            data = file_val
            ct = "application/octet-stream"
        elif isinstance(file_val, tuple):
            if len(file_val) == 2:
                filename, data = file_val  # type: ignore[misc]
                ct = "application/octet-stream"
            elif len(file_val) == 3:
                filename, data, ct = file_val  # type: ignore[misc]
            else:
                raise MultipartEncodeError(
                    f"File tuple for '{name}' must have 2 or 3 elements, "
                    f"got {len(file_val)}"
                )
        else:
            raise MultipartEncodeError(
                f"File value for '{name}' must be bytes or tuple, "
                f"got {type(file_val).__name__}"
            )

        buf += (
            f'Content-Disposition: form-data; name="{_quote_name(name)}"; '
            f'filename="{_quote_name(filename)}"\r\n'
        ).encode()
        buf += f"Content-Type: {ct}\r\n".encode()
        buf += b"\r\n"
        buf += data
        buf += b"\r\n"

    buf += f"--{boundary}--\r\n".encode()

    content_type_header = f"multipart/form-data; boundary={boundary}"
    return bytes(buf), content_type_header