Skip to content

Commit

Permalink
feat(API): Support for closing filelike response stream objects (#961)
Browse files Browse the repository at this point in the history
Fixes #960
  • Loading branch information
hozn authored and kgriffs committed Jan 27, 2017
1 parent 8176dc5 commit 08389c9
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 4 deletions.
5 changes: 1 addition & 4 deletions falcon/api.py
Expand Up @@ -690,10 +690,7 @@ def _get_body(self, resp, wsgi_file_wrapper=None):
iterable = wsgi_file_wrapper(stream,
self._STREAM_BLOCK_SIZE)
else:
iterable = iter(
lambda: stream.read(self._STREAM_BLOCK_SIZE),
b''
)
iterable = helpers.CloseableStreamIterator(stream, self._STREAM_BLOCK_SIZE)
else:
iterable = stream

Expand Down
38 changes: 38 additions & 0 deletions falcon/api_helpers.py
Expand Up @@ -16,6 +16,8 @@

from functools import wraps

import six

from falcon import util


Expand Down Expand Up @@ -167,3 +169,39 @@ def new_fn(req, resp, exception):
resp.content_type = media_type

return new_fn


class CloseableStreamIterator(six.Iterator):
"""Iterator that wraps a file-like stream with support for close().
This iterator can be used to read from an underlying file-like stream
in block_size-chunks until the response from the stream is an empty
byte string.
This class is used to wrap WSGI response streams when a
wsgi_file_wrapper is not provided by the server. The fact that it
also supports closing the underlying stream allows use of (e.g.)
Python tempfile resources that would be deleted upon close.
Args:
stream (object): Readable file-like stream object.
block_size (int): Number of bytes to read per iteration.
"""

def __init__(self, stream, block_size):
self.stream = stream
self.block_size = block_size

def __iter__(self):
return self

def __next__(self):
data = self.stream.read(self.block_size)
if data == b'':
raise StopIteration
else:
return data

def close(self):
if hasattr(self.stream, 'close') and callable(self.stream.close):
self.stream.close()
63 changes: 63 additions & 0 deletions tests/test_hello.py
Expand Up @@ -69,6 +69,40 @@ def on_head(self, req, resp):
self.on_get(req, resp)


class ClosingBytesIO(io.BytesIO):

close_called = False

def close(self):
super(ClosingBytesIO, self).close()
self.close_called = True


class NonClosingBytesIO(io.BytesIO):

# Not callable; test that CloseableStreamIterator ignores it
close = False


class ClosingFilelikeHelloResource(object):
sample_status = '200 OK'
sample_unicode = (u'Hello World! \x80' +
six.text_type(testing.rand_string(0, 0)))

sample_utf8 = sample_unicode.encode('utf-8')

def __init__(self, stream_factory):
self.called = False
self.stream = stream_factory(self.sample_utf8)
self.stream_len = len(self.sample_utf8)

def on_get(self, req, resp):
self.called = True
self.req, self.resp = req, resp
resp.status = falcon.HTTP_200
resp.set_stream(self.stream, self.stream_len)


class NoStatusResource(object):
def on_get(self, req, resp):
pass
Expand Down Expand Up @@ -158,6 +192,35 @@ def test_filelike(self):
self.assertEqual(actual_len, expected_len)
self.assertEqual(len(result.content), expected_len)

for file_wrapper in (None, FileWrapper):
result = self.simulate_get('/filelike', file_wrapper=file_wrapper)
self.assertTrue(resource.called)

expected_len = resource.resp.stream_len
actual_len = int(result.headers['content-length'])
self.assertEqual(actual_len, expected_len)
self.assertEqual(len(result.content), expected_len)

@ddt.data(
(ClosingBytesIO, True), # Implements close()
(NonClosingBytesIO, False), # Has a non-callable "close" attr
)
@ddt.unpack
def test_filelike_closing(self, stream_factory, assert_closed):
resource = ClosingFilelikeHelloResource(stream_factory)
self.api.add_route('/filelike-closing', resource)

result = self.simulate_get('/filelike-closing', file_wrapper=None)
self.assertTrue(resource.called)

expected_len = resource.resp.stream_len
actual_len = int(result.headers['content-length'])
self.assertEqual(actual_len, expected_len)
self.assertEqual(len(result.content), expected_len)

if assert_closed:
self.assertTrue(resource.stream.close_called)

def test_filelike_using_helper(self):
resource = HelloResource('stream, stream_len, filelike, use_helper')
self.api.add_route('/filelike-helper', resource)
Expand Down

0 comments on commit 08389c9

Please sign in to comment.