Repository: aio-libs/aiohttp

Issue:
part.set_content_disposition is ignored
## Long story short

Using part.set_content_disposition for some multipart file stuff, couldn't get it to work.
So I checked the part header, which is as I expected and then made a fake writer to check what the payload being sent actually was. The content disposition in the payload doesn't at all match what I have set in the part.

## Expected behaviour

The content disposition in the payload is as set in the part.

## Actual behaviour

It appears to generate a fresh content disposition.

## Steps to reproduce

![Demo](https://i.dabbitpri.me/qq42y4inzq.png)
```py
class Writer:

    def __init__(self):
        self.buffer = ""

    async def write(self, stuff):
        self.buffer += stuff.decode('utf-8','ignore')

    def done(self):
        print(self.buffer[:300])

# Generate multipart
with aiohttp.MultipartWriter('form-data') as mp:
    part = mp.append(open(path, 'rb'))
    part.set_content_disposition(
        'form-data',
        quote_fields=False,
        name='files[]',
        filename=os.path.split(path)[1]
    )
    # Fix aiohttp being retarded
    part._headers['Content-Disposition'] = part._headers['Content-Disposition'].split(" filename*=utf-8", 1)[0]
    print("\n", part._headers['Content-Disposition'], "\n")
    w = Writer()
    await mp.write(w)
    w.done()
```

## Your environment

Aiohttp 3.2.1
Python 3.6
macOS 10.13.4

# The issue (as far as I can tell)

https://github.com/aio-libs/aiohttp/blob/master/aiohttp/multipart.py#L754-L755
It appears there that the payload headers are saved separately and so no matter what you do to the returned part after it's appended, the headers will never actually be updated in the multipart.

# Hotfix (until this issue is resolved)

Create your payload yourself, set headers etc. before you append to the multipart.
eg.
```py
# Hotfix
from multidict import CIMultiDict
payload = aiohttp.payload.get_payload(open(path, 'rb'), headers=CIMultiDict())
payload.set_content_disposition(
    'form-data',
    quote_fields=False,
    name='files[]',
    filename=os.path.split(path)[1]
)
payload._headers['Content-Disposition'] = payload._headers['Content-Disposition'].split(" filename*=utf-8", 1)[0]

# Add part to multipart
part = mp.append(payload)
```


Discussion:
@kxepal ?
You remember this code better than me.
Any updates on the status of this bug? The hotfix given above doesn't seem to work for me---the content-disposition header is still blank.
No progress yet.
Pull Request is welcome!
I stumbled to this same bug today .. please fix
Will do this today. I seems like sort of mistake happended after payloads introduction where payload headers in MultipartWriter.parts lives own life separated from Payload.headers.

Resolving patch:
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
--- a/aiohttp/multipart.py
+++ b/aiohttp/multipart.py
@@ -677,7 +677,7 @@ async def _maybe_release_last_part(self) -> None:
             self._last_part = None
 
 
-_Part = Tuple[Payload, 'MultiMapping[str]', str, str]
+_Part = Tuple[Payload, str, str]
 
 
 class MultipartWriter(Payload):
@@ -812,12 +812,7 @@ def append_payload(self, payload: Payload) -> Payload:
         if size is not None and not (encoding or te_encoding):
             payload.headers[CONTENT_LENGTH] = str(size)
 
-        # render headers
-        headers = ''.join(
-            [k + ': ' + v + '\r\n' for k, v in payload.headers.items()]
-        ).encode('utf-8') + b'\r\n'
-
-        self._parts.append((payload, headers, encoding, te_encoding))  # type: ignore  # noqa
+        self._parts.append((payload, encoding, te_encoding))  # type: ignore
         return payload
 
     def append_json(
@@ -858,13 +853,13 @@ def size(self) -> Optional[int]:
             return 0
 
         total = 0
-        for part, headers, encoding, te_encoding in self._parts:
+        for part, encoding, te_encoding in self._parts:
             if encoding or te_encoding or part.size is None:
                 return None
 
             total += int(
                 2 + len(self._boundary) + 2 +  # b'--'+self._boundary+b'\r\n'
-                part.size + len(headers) +
+                part.size + len(part._binary_headers) +
                 2  # b'\r\n'
             )
 
@@ -877,9 +872,9 @@ async def write(self, writer: Any,
         if not self._parts:
             return
 
-        for part, headers, encoding, te_encoding in self._parts:
+        for part, encoding, te_encoding in self._parts:
             await writer.write(b'--' + self._boundary + b'\r\n')
-            await writer.write(headers)
+            await writer.write(part._binary_headers)
 
             if encoding or te_encoding:
                 w = MultipartPayloadWriter(writer)
diff --git a/aiohttp/payload.py b/aiohttp/payload.py
--- a/aiohttp/payload.py
+++ b/aiohttp/payload.py
@@ -164,6 +164,15 @@ def headers(self) -> Optional[_CIMultiDict]:
         """Custom item headers"""
         return self._headers
 
+    @property
+    def _binary_headers(self) -> bytes:
+        if self.headers is None:
+            # FIXME: This case actually is unreachable.
+            return b''  # pragma: no cover
+        return ''.join(
+            [k + ': ' + v + '\r\n' for k, v in self.headers.items()]
+        ).encode('utf-8') + b'\r\n'
+
     @property
     def encoding(self) -> Optional[str]:
         """Payload encoding"""


Test patch:
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -1008,9 +1008,6 @@ def test_append_multipart(self, writer) -> None:
         part = writer._parts[0][0]
         assert part.headers[CONTENT_TYPE] == 'test/passed'
 
-    async def test_write(self, writer, stream) -> None:
-        await writer.write(stream)
-
     def test_with(self) -> None:
         with aiohttp.MultipartWriter(boundary=':') as writer:
             writer.append('foo')
@@ -1033,6 +1030,25 @@ def test_append_none_not_allowed(self) -> None:
             with aiohttp.MultipartWriter(boundary=':') as writer:
                 writer.append(None)
 
+    async def test_write_preserves_content_disposition(
+        self, buf, stream
+    ) -> None:
+        with aiohttp.MultipartWriter(boundary=':') as writer:
+            part = writer.append(b'foo', headers={CONTENT_TYPE: 'test/passed'})
+            part.set_content_disposition('form-data', filename='bug')
+        await writer.write(stream)
+
+        headers, message = bytes(buf).split(b'\r\n\r\n', 1)
+
+        assert headers == (
+            b'--:\r\n'
+            b'Content-Type: test/passed\r\n'
+            b'Content-Length: 3\r\n'
+            b'Content-Disposition:'
+            b' form-data; filename="bug"; filename*=utf-8\'\'bug'
+        )
+        assert message == b'foo\r\n--:--\r\n'
+
 
 async def test_async_for_reader() -> None:
     data = [