Skip to content

Commit 2134805

Browse files
authored
[corehttp] add support for multipart file tuples and for simultaneous data and file input (#34082)
1 parent 3bc8de1 commit 2134805

File tree

9 files changed

+252
-17
lines changed

9 files changed

+252
-17
lines changed

sdk/core/corehttp/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Release History
22

3+
## 1.0.0b3 (2024-02-01)
4+
5+
### Features Added
6+
7+
- Support tuple input for `files` values to `corehttp.rest.HttpRequest` #34082
8+
- Support simultaneous `files` and `data` field entry into `corehttp.rest.HttpRequest` #34082
9+
310
## 1.0.0b2 (2023-11-14)
411

512
### Features Added

sdk/core/corehttp/corehttp/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
# regenerated.
1010
# --------------------------------------------------------------------------
1111

12-
VERSION = "1.0.0b2"
12+
VERSION = "1.0.0b3"

sdk/core/corehttp/corehttp/rest/_helpers.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545
import xml.etree.ElementTree as ET
4646
from ..serialization import CoreJSONEncoder
47+
from ..utils._utils import get_file_items
4748

4849

4950
################################### TYPES SECTION #########################
@@ -54,7 +55,14 @@
5455
ParamsType = Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]]
5556

5657
FileContent = Union[str, bytes, IO[str], IO[bytes]]
57-
FileType = Tuple[Optional[str], FileContent]
58+
FileType = Union[
59+
# file (or bytes)
60+
FileContent,
61+
# (filename, file (or bytes))
62+
Tuple[Optional[str], FileContent],
63+
# (filename, file (or bytes), content_type)
64+
Tuple[Optional[str], FileContent, Optional[str]],
65+
]
5866

5967
FilesType = Union[Mapping[str, FileType], Sequence[Tuple[str, FileType]]]
6068

@@ -73,7 +81,7 @@ def _verify_data_object(name, value):
7381
raise TypeError("Invalid type for data value. Expected primitive type, got {}: {}".format(type(name), name))
7482

7583

76-
def _format_data_helper(data: Union[str, IO]) -> Union[Tuple[None, str], Tuple[Optional[str], IO, str]]:
84+
def _format_data_helper(data: FileType) -> Union[Tuple[Optional[str], str], Tuple[Optional[str], FileContent, str]]:
7785
"""Helper for _format_data.
7886
7987
Format field data according to whether it is a stream or
@@ -84,16 +92,34 @@ def _format_data_helper(data: Union[str, IO]) -> Union[Tuple[None, str], Tuple[O
8492
:rtype: tuple[str, IO, str] or tuple[None, str]
8593
:return: A tuple of (data name, data IO, "application/octet-stream") or (None, data str)
8694
"""
87-
if hasattr(data, "read"):
88-
data = cast(IO, data)
89-
data_name = None
90-
try:
91-
if data.name[0] != "<" and data.name[-1] != ">":
92-
data_name = os.path.basename(data.name)
93-
except (AttributeError, TypeError):
94-
pass
95-
return (data_name, data, "application/octet-stream")
96-
return (None, cast(str, data))
95+
content_type: Optional[str] = None
96+
filename: Optional[str] = None
97+
if isinstance(data, tuple):
98+
if len(data) == 2:
99+
# Filename and file bytes are included
100+
filename, file_bytes = cast(Tuple[Optional[str], FileContent], data)
101+
elif len(data) == 3:
102+
# Filename, file object, and content_type are included
103+
filename, file_bytes, content_type = cast(Tuple[Optional[str], FileContent, str], data)
104+
else:
105+
raise ValueError(
106+
"Unexpected data format. Expected file, or tuple of (filename, file_bytes) or "
107+
"(filename, file_bytes, content_type)."
108+
)
109+
else:
110+
# here we just get the file content
111+
if hasattr(data, "read"):
112+
data = cast(IO, data)
113+
try:
114+
if data.name[0] != "<" and data.name[-1] != ">":
115+
filename = os.path.basename(data.name)
116+
except (AttributeError, TypeError):
117+
pass
118+
content_type = "application/octet-stream"
119+
file_bytes = data
120+
if content_type:
121+
return (filename, file_bytes, content_type)
122+
return (filename, cast(str, file_bytes))
97123

98124

99125
def set_urlencoded_body(data, has_files):
@@ -115,9 +141,9 @@ def set_urlencoded_body(data, has_files):
115141
return default_headers, body
116142

117143

118-
def set_multipart_body(files):
119-
formatted_files = {f: _format_data_helper(d) for f, d in files.items() if d is not None}
120-
return {}, formatted_files
144+
def set_multipart_body(files: FilesType):
145+
formatted_files = [(f, _format_data_helper(d)) for f, d in get_file_items(files) if d is not None]
146+
return {}, dict(formatted_files) if isinstance(files, Mapping) else formatted_files
121147

122148

123149
def set_xml_body(content):

sdk/core/corehttp/corehttp/transport/aiohttp/_aiohttp.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .._base_async import AsyncHttpTransport, _handle_non_stream_rest_response
4040
from .._base import _create_connection_config
4141
from ...rest._aiohttp import RestAioHttpTransportResponse
42+
from ...utils._utils import get_file_items
4243

4344

4445
if TYPE_CHECKING:
@@ -136,7 +137,7 @@ def _get_request_data(self, request: RestHttpRequest):
136137
"""
137138
if request._files: # pylint: disable=protected-access
138139
form_data = aiohttp.FormData(request._data or {}) # pylint: disable=protected-access
139-
for form_file, data in request._files.items(): # pylint: disable=protected-access
140+
for form_file, data in get_file_items(request._files): # pylint: disable=protected-access
140141
content_type = data[2] if len(data) > 2 else None
141142
try:
142143
form_data.add_field(form_file, data[1], filename=data[0], content_type=content_type)

sdk/core/corehttp/corehttp/utils/_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@
1515
Tuple,
1616
Union,
1717
Dict,
18+
cast,
19+
Sequence,
20+
TYPE_CHECKING,
1821
)
1922
from urllib.parse import urlparse
2023

24+
if TYPE_CHECKING:
25+
from corehttp.rest._helpers import FileType, FilesType
26+
2127

2228
class _FixedOffset(datetime.tzinfo):
2329
"""Fixed offset in minutes east from UTC.
@@ -150,3 +156,11 @@ def __eq__(self, other: Any) -> bool:
150156

151157
def __repr__(self) -> str:
152158
return str(dict(self.items()))
159+
160+
161+
def get_file_items(files: "FilesType") -> Sequence[Tuple[str, "FileType"]]:
162+
if isinstance(files, Mapping):
163+
# casting because ItemsView technically isn't a Sequence, even
164+
# though realistically it is ordered python 3.7 and after
165+
return cast(Sequence[Tuple[str, "FileType"]], files.items())
166+
return files

sdk/core/corehttp/tests/async_tests/test_rest_http_request_async.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import pytest
1212
from corehttp.rest import HttpRequest
13+
from utils import NamedIo
1314

1415

1516
@pytest.fixture
@@ -95,3 +96,40 @@ async def content():
9596
await assert_aiterator_body(request, b"test 123")
9697
# in this case, request._data is what we end up passing to the requests transport
9798
assert isinstance(request._data, collections.abc.AsyncIterable)
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_multipart_tuple_input_multiple_same_name(client):
103+
request = HttpRequest(
104+
"POST",
105+
url="/multipart/tuple-input-multiple-same-name",
106+
files=[
107+
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
108+
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
109+
],
110+
)
111+
(await client.send_request(request)).raise_for_status()
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_multipart_tuple_input_multiple_same_name_with_tuple_file_value(client):
116+
request = HttpRequest(
117+
"POST",
118+
url="/multipart/tuple-input-multiple-same-name-with-tuple-file-value",
119+
files=[("images", ("foo.png", NamedIo("notMyName.pdf"), "image/png")), ("images", NamedIo("foo.png"))],
120+
)
121+
(await client.send_request(request)).raise_for_status()
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_data_and_file_input_same_name(client):
126+
request = HttpRequest(
127+
"POST",
128+
url="/multipart/data-and-file-input-same-name",
129+
data={"message": "Hello, world!"},
130+
files=[
131+
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
132+
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
133+
],
134+
)
135+
(await client.send_request(request)).raise_for_status()

sdk/core/corehttp/tests/test_rest_http_request.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from corehttp.rest import HttpRequest
1919
from corehttp.runtime.policies import SansIOHTTPPolicy
2020
from rest_client import MockRestClient
21+
from utils import NamedIo
2122

2223

2324
@pytest.fixture
@@ -448,3 +449,97 @@ def remaining(self):
448449
def test_stream_input():
449450
data_stream = Stream(length=4)
450451
HttpRequest(method="PUT", url="http://www.example.com", content=data_stream) # ensure we can make this HttpRequest
452+
453+
454+
@pytest.fixture
455+
def filebytes():
456+
file_path = os.path.join(os.path.dirname(__file__), "./conftest.py")
457+
return open(file_path, "rb")
458+
459+
460+
def test_multipart_bytes(filebytes):
461+
request = HttpRequest("POST", url="http://example.org", files={"file": filebytes})
462+
463+
assert request.content == {"file": ("conftest.py", filebytes, "application/octet-stream")}
464+
465+
466+
def test_multipart_filename_and_bytes(filebytes):
467+
files = ("specifiedFileName", filebytes)
468+
request = HttpRequest("POST", url="http://example.org", files={"file": files})
469+
470+
assert request.content == {"file": ("specifiedFileName", filebytes)}
471+
472+
473+
def test_multipart_filename_and_bytes_and_content_type(filebytes):
474+
files = ("specifiedFileName", filebytes, "application/json")
475+
request = HttpRequest("POST", url="http://example.org", files={"file": files})
476+
477+
assert request.content == {"file": ("specifiedFileName", filebytes, "application/json")}
478+
479+
480+
def test_multipart_incorrect_tuple_entry(filebytes):
481+
files = ("specifiedFileName", filebytes, "application/json", "extra")
482+
with pytest.raises(ValueError):
483+
HttpRequest("POST", url="http://example.org", files={"file": files})
484+
485+
486+
def test_multipart_tuple_input_single(filebytes):
487+
request = HttpRequest("POST", url="http://example.org", files=[("file", filebytes)])
488+
489+
assert request.content == [("file", ("conftest.py", filebytes, "application/octet-stream"))]
490+
491+
492+
def test_multipart_tuple_input_multiple(filebytes):
493+
request = HttpRequest("POST", url="http://example.org", files=[("file", filebytes), ("file2", filebytes)])
494+
495+
assert request.content == [
496+
("file", ("conftest.py", filebytes, "application/octet-stream")),
497+
("file2", ("conftest.py", filebytes, "application/octet-stream")),
498+
]
499+
500+
501+
def test_multipart_tuple_input_multiple_with_filename_and_content_type(filebytes):
502+
request = HttpRequest(
503+
"POST",
504+
url="http://example.org",
505+
files=[("file", ("first file", filebytes, "image/pdf")), ("file2", ("second file", filebytes, "image/png"))],
506+
)
507+
508+
assert request.content == [
509+
("file", ("first file", filebytes, "image/pdf")),
510+
("file2", ("second file", filebytes, "image/png")),
511+
]
512+
513+
514+
def test_multipart_tuple_input_multiple_same_name(client):
515+
request = HttpRequest(
516+
"POST",
517+
url="/multipart/tuple-input-multiple-same-name",
518+
files=[
519+
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
520+
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
521+
],
522+
)
523+
client.send_request(request).raise_for_status()
524+
525+
526+
def test_multipart_tuple_input_multiple_same_name_with_tuple_file_value(client):
527+
request = HttpRequest(
528+
"POST",
529+
url="/multipart/tuple-input-multiple-same-name-with-tuple-file-value",
530+
files=[("images", ("foo.png", NamedIo("notMyName.pdf"), "image/png")), ("images", NamedIo("foo.png"))],
531+
)
532+
client.send_request(request).raise_for_status()
533+
534+
535+
def test_data_and_file_input_same_name(client):
536+
request = HttpRequest(
537+
"POST",
538+
url="/multipart/data-and-file-input-same-name",
539+
data={"message": "Hello, world!"},
540+
files=[
541+
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
542+
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
543+
],
544+
)
545+
client.send_request(request).raise_for_status()

sdk/core/corehttp/tests/testserver_tests/coretestserver/coretestserver/test_routes/multipart.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,50 @@ def multipart_request():
146146
body_as_str.encode("ascii"),
147147
content_type="multipart/mixed; boundary=batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed",
148148
)
149+
150+
151+
@multipart_api.route("/tuple-input-multiple-same-name", methods=["POST"])
152+
def tuple_input_multiple_same_name():
153+
assert_with_message("content type", multipart_header_start, request.content_type[: len(multipart_header_start)])
154+
155+
files = request.files.getlist("file")
156+
assert_with_message("num files", 2, len(files))
157+
158+
file1 = files[0]
159+
assert_with_message("file content type", "image/pdf", file1.content_type)
160+
assert_with_message("filename", "firstFileName", file1.filename)
161+
162+
file2 = files[1]
163+
assert_with_message("file content type", "image/png", file2.content_type)
164+
assert_with_message("filename", "secondFileName", file2.filename)
165+
return Response(status=200)
166+
167+
168+
@multipart_api.route("/tuple-input-multiple-same-name-with-tuple-file-value", methods=["POST"])
169+
def test_input_multiple_same_name_with_tuple_file_value():
170+
assert_with_message("content type", multipart_header_start, request.content_type[: len(multipart_header_start)])
171+
172+
images = request.files.getlist("images")
173+
assert_with_message("num images", 2, len(images))
174+
175+
tuple_image = images[0]
176+
assert_with_message("image content type", "image/png", tuple_image.content_type)
177+
assert_with_message("filename", "foo.png", tuple_image.filename)
178+
179+
single_image = images[1]
180+
assert_with_message("file content type", "application/octet-stream", single_image.content_type)
181+
assert_with_message("filename", "foo.png", single_image.filename)
182+
return Response(status=200)
183+
184+
185+
@multipart_api.route("/data-and-file-input-same-name", methods=["POST"])
186+
def data_and_file_input_same_name():
187+
assert_with_message("content type", multipart_header_start, request.content_type[: len(multipart_header_start)])
188+
189+
# use call to this function to check files
190+
tuple_input_multiple_same_name()
191+
192+
assert_with_message("data items num", 1, len(request.form.keys()))
193+
assert_with_message("message", "Hello, world!", request.form["message"])
194+
195+
return Response(status=200)

sdk/core/corehttp/tests/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# -------------------------------------------------------------------------
66
import pytest
77
import types
8+
import io
89

910
############################## LISTS USED TO PARAMETERIZE TESTS ##############################
1011
from corehttp.rest import HttpRequest
@@ -119,3 +120,9 @@ def readonly_checks(response):
119120
if attr == "encoding":
120121
# encoding is the only settable new attr
121122
continue
123+
124+
125+
class NamedIo(io.BytesIO):
126+
def __init__(self, name: str, *args, **kwargs):
127+
super(NamedIo, self).__init__(*args, **kwargs)
128+
self.name = name

0 commit comments

Comments
 (0)