Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(testing): simulate multipart file upload #2141

Open
wants to merge 59 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d37beaf
Enhancement #2124: added the samesite parameter to unset_cookie.
TigreModerata Jan 16, 2023
f89c31c
Added new option to documentation in cookies.srt
TigreModerata Jan 16, 2023
32daa63
Added tests, CookiesUnsetSameSite class and test_unset_cookies_samesi…
TigreModerata Jan 17, 2023
2b48c6b
Merge branch 'master' into #2124_Enhancement
TigreModerata Jan 17, 2023
6b9b198
Removed unused import logging from test_cookies
TigreModerata Jan 17, 2023
0a17b79
Reverted changes to docs/changes4.0.0.rst
TigreModerata Jan 18, 2023
187aa4f
Reverted changes to docs/changes4.0.0.rst
TigreModerata Jan 18, 2023
9d2fe9e
Added 'files' parameter to _simulate_request; Added tests for file up…
TigreModerata Feb 9, 2023
2b9d0e0
Fixed nested mixed request problems
TigreModerata Feb 9, 2023
835b062
clean up
TigreModerata Feb 10, 2023
3e28a94
updated docstrings
TigreModerata Feb 10, 2023
7920cdd
Fixes
TigreModerata Feb 10, 2023
72d54ee
Merge branch 'master' into SimulateMultipartFile#1010
TigreModerata Feb 10, 2023
ded1ac0
Fixes2
TigreModerata Feb 10, 2023
66d347b
Merge remote-tracking branch 'originTM/SimulateMultipartFile#1010' in…
TigreModerata Feb 10, 2023
574e667
Fixes3
TigreModerata Feb 10, 2023
ffa8d9f
Fixes4
TigreModerata Feb 10, 2023
eadb1f3
urllib3 in minitest requirements (?)
TigreModerata Feb 10, 2023
7493b92
minor fix 5
TigreModerata Feb 10, 2023
c49e369
minor fix 6
TigreModerata Feb 10, 2023
235ed3e
Removed urllib3; creating encoded bodystring in _encode_files.
TigreModerata Feb 12, 2023
94ae979
blued
TigreModerata Feb 12, 2023
c9d4f1b
formatting corrections
TigreModerata Feb 12, 2023
9a9e58d
Corrections after comments
TigreModerata Feb 12, 2023
608efb3
Added test_upload_fileobj
TigreModerata Feb 12, 2023
5c68868
typo fom-data
TigreModerata Feb 13, 2023
f052dca
removed conditional where object type bytes is not possible
TigreModerata Feb 19, 2023
b527de7
added fct testing null data value
TigreModerata Feb 19, 2023
1465241
another unneccesay if removed
TigreModerata Feb 19, 2023
e4cb4c9
Update falcon/testing/client.py
TigreModerata Mar 5, 2023
dba5eb9
Update falcon/testing/client.py
TigreModerata Mar 5, 2023
51ea53e
Update falcon/testing/client.py
TigreModerata Mar 5, 2023
8a66346
typo
TigreModerata Mar 5, 2023
3041583
check for datatype in json changed
TigreModerata Mar 5, 2023
77f350b
Corrections after code-review (part1)
TigreModerata Mar 5, 2023
4bc299a
blued
TigreModerata Mar 5, 2023
df37dbd
unnecessary import removed
TigreModerata Mar 5, 2023
2149b0c
Handling of new data parameter;
TigreModerata Mar 11, 2023
6f8b3ae
blued
TigreModerata Mar 11, 2023
632317c
utf-8 corrected
TigreModerata Mar 11, 2023
3b2edd8
more tests
TigreModerata Mar 11, 2023
bfa6a4c
test for string data
TigreModerata Mar 11, 2023
e1d9834
testing bool and float data types
TigreModerata Mar 11, 2023
514e936
blue & pep8
TigreModerata Mar 11, 2023
dc74456
Docstrings updated
TigreModerata Mar 12, 2023
6d8fd96
data string (not json) treated like body
TigreModerata Mar 26, 2023
eeba35d
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jun 5, 2023
aef3e0d
Update 4.0.0.rst
vytas7 Jul 2, 2023
3c2b4fc
chore(requirements): remove extraneous whitespace from `mintest`
vytas7 Jul 3, 2023
8665a34
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jul 11, 2023
1b7a207
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jul 12, 2023
4572ee8
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Jul 19, 2023
62275cc
refactor: restore some stuff from master, temp remove 1 file for now
vytas7 Jul 19, 2023
5872d3d
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Dec 27, 2023
6845e5a
feat(testing): add a new parameter to simulate form
vytas7 Dec 29, 2023
93fad43
fix(testing): fix a regression wrt passing json to simulate_request
vytas7 Dec 29, 2023
3af2fcc
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Mar 3, 2024
6ae4d06
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 May 7, 2024
6ede18d
Merge branch 'master' into SimulateMultipartFile#1010
vytas7 Aug 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/changes/4.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ Changes to Supported Platforms
- CPython 3.11 is now fully supported. (`#2072 <https://github.com/falconry/falcon/issues/2072>`__)
- End-of-life Python 3.5 & 3.6 are no longer supported. (`#2074 <https://github.com/falconry/falcon/pull/2074>`__)


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any particular reason behind this newline removal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what happened... was that me?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it must have been you, yes. It is coming from you changeset, but it might be some artefact of merging things back and forth on your side, not necessarily something you actively did.

.. towncrier release notes start

Contributors to this Release
Expand Down
162 changes: 160 additions & 2 deletions falcon/testing/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import datetime as dt
import inspect
import json as json_module
import os
import time
from typing import Dict
from typing import Optional
Expand All @@ -30,6 +31,10 @@
import warnings
import wsgiref.validate

from urllib3.filepost import encode_multipart_formdata
from urllib3.filepost import RequestField


from falcon.asgi_spec import ScopeType
from falcon.constants import COMBINED_METHODS
from falcon.constants import MEDIA_JSON
Expand Down Expand Up @@ -437,6 +442,7 @@ def simulate_request(
content_type=None,
body=None,
json=None,
files=None,
file_wrapper=None,
wsgierrors=None,
params=None,
Expand Down Expand Up @@ -528,6 +534,15 @@ def simulate_request(
overrides `body` and sets the Content-Type header to
``'application/json'``, overriding any value specified by either
the `content_type` or `headers` arguments.
files(dict): same as the files parameter in requests,
dictionary of ``'name': file-like-objects`` (or ``{'name': file-tuple}``)
for multipart encoding upload.
``file-tuple``: can be a 2-tuple ``('filename', fileobj)``,
3-tuple ``('filename', fileobj, 'content_type')``
or a 4-tuple ``('filename', fileobj, 'content_type', custom_headers)``,
vytas7 marked this conversation as resolved.
Show resolved Hide resolved
where ``'content-type'`` is a string defining the content
type of the given file and ``custom_headers`` a dict-like
object containing additional headers to add for the file.
file_wrapper (callable): Callable that returns an iterable,
to be used as the value for *wsgi.file_wrapper* in the
WSGI environ (default: ``None``). This can be used to test
Expand Down Expand Up @@ -575,6 +590,7 @@ def simulate_request(
content_type=content_type,
body=body,
json=json,
files=files,
params=params,
params_csv=params_csv,
protocol=protocol,
Expand All @@ -598,6 +614,7 @@ def simulate_request(
headers,
body,
json,
files,
extras,
)

Expand Down Expand Up @@ -651,6 +668,7 @@ async def _simulate_request_asgi(
content_type=None,
body=None,
json=None,
files=None,
params=None,
params_csv=True,
protocol='http',
Expand Down Expand Up @@ -736,6 +754,15 @@ async def _simulate_request_asgi(
overrides `body` and sets the Content-Type header to
``'application/json'``, overriding any value specified by either
the `content_type` or `headers` arguments.
files(dict): same as the files parameter in requests,
vytas7 marked this conversation as resolved.
Show resolved Hide resolved
dictionary of ``'name': file-like-objects`` (or ``{'name': file-tuple}``)
for multipart encoding upload.
``file-tuple``: can be a 2-tuple ``('filename', fileobj)``,
3-tuple ``('filename', fileobj, 'content_type')``
or a 4-tuple ``('filename', fileobj, 'content_type', custom_headers)``,
where ``'content-type'`` is a string defining the content
type of the given file and ``custom_headers`` a dict-like
object containing additional headers to add for the file.
host(str): A string to use for the hostname part of the fully
qualified request URL (default: 'falconframework.org')
remote_addr (str): A string to use as the remote IP address for the
Expand Down Expand Up @@ -774,6 +801,7 @@ async def _simulate_request_asgi(
headers,
body,
json,
files,
extras,
)

Expand Down Expand Up @@ -2133,8 +2161,133 @@ async def __aexit__(self, exc_type, exc, tb):
await self._task_req


def _prepare_data_fields(data):
CaselIT marked this conversation as resolved.
Show resolved Hide resolved
"""Prepare data fields for request body.

Args:
data: dict or list of tuples with json data from the request

Returns: list of 2-tuples (field-name(str), value(bytes)
TigreModerata marked this conversation as resolved.
Show resolved Hide resolved

"""
fields = []
new_fields = []
if data and not isinstance(data, (list, dict)):
raise ValueError('Data must not be a list of tuples or dict.')
TigreModerata marked this conversation as resolved.
Show resolved Hide resolved
elif data and isinstance(data, dict):
fields = list(data.items())
elif data:
fields = list(data)
# Append data to the other multipart parts
for field, val in fields:
if isinstance(val, str) or not hasattr(val, '__iter__'):
val = [val]
for v in val:
if v:
# Don't call str() on bytestrings: in Py3 it all goes wrong.
if not isinstance(v, bytes):
v = str(v)

new_fields.append(
(
field.decode('utf-8') if isinstance(field, bytes) else field,
v.encode('utf-8') if isinstance(v, str) else v,
)
)
return new_fields


def _prepare_files(k, v):
"""Prepare file attributes for body of request form.

Args:
k: (str), file-name
v: fileobj or tuple (filename, data, content_type?, headers?)

Returns: file_name, file_data, file_content_type, file_header
TigreModerata marked this conversation as resolved.
Show resolved Hide resolved

"""
file_content_type = None
file_header = None
if isinstance(v, (tuple, list)):
if len(v) == 2:
file_name, file_data = v
elif len(v) == 3:
file_name, file_data, file_content_type = v
TigreModerata marked this conversation as resolved.
Show resolved Hide resolved
else:
file_name, file_data, file_content_type, file_header = v
if (
len(v) >= 3
and file_content_type
and file_content_type.startswith('multipart/mixed')
):
file_data, assigned_type = _encode_files(
json_module.loads(file_data.decode())
)
file_content_type = 'multipart/mixed; ' + (assigned_type.split('; ')[1])
else:
# if v is not a tuple or iterable it has to be a filelike obj
name = getattr(v, 'name', None)
if name and isinstance(name, str) and name[0] != '<' and name[-1] != '>':
file_name = os.path.basename(name)
else:
file_name = k
file_data = v
return file_name, file_data, file_content_type, file_header


def _encode_files(files, data=None):
"""Build the body for a multipart/form-data request.

Will successfully encode files when passed as a dict or a list of
tuples. Order is retained if data is a list of tuples but arbitrary
if parameters are supplied as a dict.
The tuples may be 2-tuples (filename, fileobj),
3-tuples (filename, fileobj, contentype)
or 4-tuples (filename, fileobj, contentype, custom_headers).
Allows for content_type = ``multipart/mixed`` for submission of nested files"""

new_fields = _prepare_data_fields(data)

if not isinstance(files, (dict, list)):
raise ValueError('cannot encode objects that are not 2-tuples')
elif isinstance(files, dict):
files = list(files.items())

for (k, v) in files:
content_disposition = None
file_name, file_data, file_content_type, file_header = _prepare_files(k, v)

if not file_data:
continue
elif hasattr(file_data, 'read'):
fdata = file_data.read()
else:
fdata = file_data
if file_header and 'Content-Disposition' in file_header.keys():
content_disposition = file_header['Content-Disposition']
rf = RequestField(name=k, filename=file_name, data=fdata, headers=file_header)
rf.make_multipart(
content_type=file_content_type, content_disposition=content_disposition
)
new_fields.append(rf)

body, content_type = encode_multipart_formdata(new_fields)

return body, content_type


def _prepare_sim_args(
path, query_string, params, params_csv, content_type, headers, body, json, extras
path,
query_string,
params,
params_csv,
content_type,
headers,
body,
json,
files,
extras,
):
if not path.startswith('/'):
raise ValueError("path must start with '/'")
Expand Down Expand Up @@ -2163,7 +2316,12 @@ def _prepare_sim_args(
headers = headers or {}
headers['Content-Type'] = content_type

if json is not None:
if files is not None:
body, content_type = _encode_files(files, json)
headers = headers or {}
headers['Content-Type'] = content_type

elif json is not None:
body = json_module.dumps(json, ensure_ascii=False)
headers = headers or {}
headers['Content-Type'] = MEDIA_JSON
Expand Down
1 change: 1 addition & 0 deletions requirements/mintest
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pytest
pyyaml
requests
ujson
urllib3
1 change: 1 addition & 0 deletions requirements/tests
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ coverage >= 4.1
pytest
pyyaml
requests
urllib3
# TODO(vytas): Check if testtools still brings anything to the table, and
# re-enable if/when unittest2 is adjusted to support CPython 3.10.
testtools; python_version < '3.10'
Expand Down
Binary file added tests/files/falcon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions tests/files/loremipsum.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to add separate files? Maybe we could just use inline strings, or use existing source or image files?
Otherwise we need to surface these new files in MANIFEST.in to get them included in the sdist.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't really know... I wanted to make sure all goes well mainly with bigger stuff (the image), but now that I'm sure I'll remove them.

incididunt ut labore et dolore magna aliqua. Dolor sed viverra ipsum nunc
aliquet bibendum enim. In massa tempor nec feugiat. Nunc aliquet bibendum enim
facilisis gravida. Nisl nunc mi ipsum faucibus vitae aliquet nec ullamcorper.
Amet luctus venenatis lectus magna fringilla. Volutpat maecenas volutpat blandit
aliquam etiam erat velit scelerisque in. Egestas egestas fringilla phasellus
faucibus scelerisque eleifend. Sagittis orci a scelerisque purus semper eget
duis. Nulla pharetra diam sit amet nisl suscipit. Sed adipiscing diam donec
adipiscing tristique risus nec feugiat in. Fusce ut placerat orci nulla.
Pharetra vel turpis nunc eget lorem dolor. Tristique senectus et netus et
malesuada.
1 change: 0 additions & 1 deletion tests/test_media_multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@
b'--boundary--\r\n'
)


EXAMPLES = {
'5b11af82ab65407ba8cdccf37d2a9c4f': EXAMPLE1,
'---------------------------1574247108204320607285918568': EXAMPLE2,
Expand Down
Loading