Skip to content

Commit 7bf7f3d

Browse files
authored
support tuples for files (#33948)
1 parent 53bd01f commit 7bf7f3d

File tree

5 files changed

+106
-15
lines changed

5 files changed

+106
-15
lines changed

sdk/core/azure-core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Support tuple input for files to `azure.core.rest.HttpRequest` #33948
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/core/azure-core/azure/core/pipeline/transport/_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
if TYPE_CHECKING:
7777
# We need a transport to define a pipeline, this "if" avoid a circular import
7878
from azure.core.pipeline import Pipeline
79+
from azure.core.rest._helpers import FileContent
7980

8081
_LOGGER = logging.getLogger(__name__)
8182

@@ -249,7 +250,7 @@ def body(self, value: Optional[DataType]) -> None:
249250
self.data = value
250251

251252
@staticmethod
252-
def _format_data(data: Union[str, IO]) -> Union[Tuple[None, str], Tuple[Optional[str], IO, str]]:
253+
def _format_data(data: Union[str, IO]) -> Union[Tuple[Optional[str], str], Tuple[Optional[str], FileContent, str]]:
253254
"""Format field data according to whether it is a stream or
254255
a string for a form-data request.
255256

sdk/core/azure-core/azure/core/rest/_helpers.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,15 @@
6666
ParamsType = Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]]
6767

6868
FileContent = Union[str, bytes, IO[str], IO[bytes]]
69-
FileType = Tuple[Optional[str], FileContent]
69+
70+
FileType = Union[
71+
# file (or bytes)
72+
FileContent,
73+
# (filename, file (or bytes))
74+
Tuple[Optional[str], FileContent],
75+
# (filename, file (or bytes), content_type)
76+
Tuple[Optional[str], FileContent, Optional[str]],
77+
]
7078

7179
FilesType = Union[Mapping[str, FileType], Sequence[Tuple[str, FileType]]]
7280

@@ -104,8 +112,9 @@ def set_urlencoded_body(data, has_files):
104112
return default_headers, body
105113

106114

107-
def set_multipart_body(files):
108-
formatted_files = {f: _format_data_helper(d) for f, d in files.items() if d is not None}
115+
def set_multipart_body(files: FilesType):
116+
file_items = files.items() if isinstance(files, Mapping) else files
117+
formatted_files = {f: _format_data_helper(d) for f, d in file_items if d is not None}
109118
return {}, formatted_files
110119

111120

sdk/core/azure-core/azure/core/utils/_pipeline_transport_rest_shared.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from azure.core.pipeline.transport._base import (
5151
_HttpResponseBase as PipelineTransportHttpResponseBase,
5252
)
53+
from azure.core.rest._helpers import FileType, FileContent
5354

5455
binary_type = str
5556

@@ -333,7 +334,7 @@ def parse_responses(response):
333334
return responses
334335

335336

336-
def _format_data_helper(data: Union[str, IO]) -> Union[Tuple[None, str], Tuple[Optional[str], IO, str]]:
337+
def _format_data_helper(data: "FileType") -> Union[Tuple[Optional[str], str], Tuple[Optional[str], "FileContent", str]]:
337338
"""Helper for _format_data.
338339
339340
Format field data according to whether it is a stream or
@@ -344,16 +345,34 @@ def _format_data_helper(data: Union[str, IO]) -> Union[Tuple[None, str], Tuple[O
344345
:rtype: tuple[str, IO, str] or tuple[None, str]
345346
:return: A tuple of (data name, data IO, "application/octet-stream") or (None, data str)
346347
"""
347-
if hasattr(data, "read"):
348-
data = cast(IO, data)
349-
data_name = None
350-
try:
351-
if data.name[0] != "<" and data.name[-1] != ">":
352-
data_name = os.path.basename(data.name)
353-
except (AttributeError, TypeError):
354-
pass
355-
return (data_name, data, "application/octet-stream")
356-
return (None, cast(str, data))
348+
content_type: Optional[str] = None
349+
filename: Optional[str] = None
350+
if isinstance(data, tuple):
351+
if len(data) == 2:
352+
# Filename and file bytes are included
353+
filename, file_bytes = cast(Tuple[Optional[str], "FileContent"], data)
354+
elif len(data) == 3:
355+
# Filename, file object, and content_type are included
356+
filename, file_bytes, content_type = cast(Tuple[Optional[str], "FileContent", str], data)
357+
else:
358+
raise ValueError(
359+
"Unexpected data format. Expected file, or tuple of (filename, file_bytes) or "
360+
"(filename, file_bytes, content_type)."
361+
)
362+
else:
363+
# here we just get the file content
364+
if hasattr(data, "read"):
365+
data = cast(IO, data)
366+
try:
367+
if data.name[0] != "<" and data.name[-1] != ">":
368+
filename = os.path.basename(data.name)
369+
except (AttributeError, TypeError):
370+
pass
371+
content_type = "application/octet-stream"
372+
file_bytes = data
373+
if content_type:
374+
return (filename, file_bytes, content_type)
375+
return (filename, cast(str, file_bytes))
357376

358377

359378
def _aiohttp_body_helper(

sdk/core/azure-core/tests/test_rest_http_request.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,66 @@ def test_stream_input():
515515
HttpRequest(method="PUT", url="http://www.example.com", content=data_stream) # ensure we can make this HttpRequest
516516

517517

518+
@pytest.fixture
519+
def filebytes():
520+
file_path = os.path.join(os.path.dirname(__file__), "./conftest.py")
521+
return open(file_path, "rb")
522+
523+
524+
def test_multipart_bytes(filebytes):
525+
request = HttpRequest("POST", url="http://example.org", files={"file": filebytes})
526+
527+
assert request.content == {"file": ("conftest.py", filebytes, "application/octet-stream")}
528+
529+
530+
def test_multipart_filename_and_bytes(filebytes):
531+
files = ("specifiedFileName", filebytes)
532+
request = HttpRequest("POST", url="http://example.org", files={"file": files})
533+
534+
assert request.content == {"file": ("specifiedFileName", filebytes)}
535+
536+
537+
def test_multipart_filename_and_bytes_and_content_type(filebytes):
538+
files = ("specifiedFileName", filebytes, "application/json")
539+
request = HttpRequest("POST", url="http://example.org", files={"file": files})
540+
541+
assert request.content == {"file": ("specifiedFileName", filebytes, "application/json")}
542+
543+
544+
def test_multipart_incorrect_tuple_entry(filebytes):
545+
files = ("specifiedFileName", filebytes, "application/json", "extra")
546+
with pytest.raises(ValueError):
547+
HttpRequest("POST", url="http://example.org", files={"file": files})
548+
549+
550+
def test_multipart_tuple_input_single(filebytes):
551+
request = HttpRequest("POST", url="http://example.org", files=[("file", filebytes)])
552+
553+
assert request.content == {"file": ("conftest.py", filebytes, "application/octet-stream")}
554+
555+
556+
def test_multipart_tuple_input_multiple(filebytes):
557+
request = HttpRequest("POST", url="http://example.org", files=[("file", filebytes), ("file2", filebytes)])
558+
559+
assert request.content == {
560+
"file": ("conftest.py", filebytes, "application/octet-stream"),
561+
"file2": ("conftest.py", filebytes, "application/octet-stream"),
562+
}
563+
564+
565+
def test_multipart_tuple_input_multiple_with_filename_and_content_type(filebytes):
566+
request = HttpRequest(
567+
"POST",
568+
url="http://example.org",
569+
files=[("file", ("first file", filebytes, "image/pdf")), ("file2", ("second file", filebytes, "image/png"))],
570+
)
571+
572+
assert request.content == {
573+
"file": ("first file", filebytes, "image/pdf"),
574+
"file2": ("second file", filebytes, "image/png"),
575+
}
576+
577+
518578
# NOTE: For files, we don't allow list of tuples yet, just dict. Will uncomment when we add this capability
519579
# def test_multipart_multiple_files_single_input_content():
520580
# files = [

0 commit comments

Comments
 (0)