From 6b3cca0d548c0c3bcec62902f5b261df4e7c1d1e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 22 Feb 2012 20:02:03 -0500 Subject: - New APIs: ``pyramid.response.FileResponse`` and ``pyramid.response.FileIter``, for usage in views that must serve files "manually". --- CHANGES.txt | 4 +++ TODO.txt | 3 -- docs/api/response.rst | 5 +++ docs/whatsnew-1.3.rst | 4 +++ pyramid/response.py | 74 ++++++++++++++++++++++++++++++++++++++++++ pyramid/static.py | 53 ++---------------------------- pyramid/tests/test_response.py | 27 +++++++++++++++ pyramid/tests/test_static.py | 29 +---------------- 8 files changed, 117 insertions(+), 82 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 39bf59210..ecac7a68f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -36,6 +36,10 @@ Features be preferred over using ``pyramid.view.view_config`` with ``context=HTTPForbidden`` as was previously recommended. +- New APIs: ``pyramid.response.FileResponse`` and + ``pyramid.response.FileIter``, for usage in views that must serve files + "manually". + Backwards Incompatibilities --------------------------- diff --git a/TODO.txt b/TODO.txt index a58260d1f..5d96f7c7d 100644 --- a/TODO.txt +++ b/TODO.txt @@ -4,9 +4,6 @@ Pyramid TODOs Nice-to-Have ------------ -- Expose _FileIter and _FileResponse somehow fbo of - manual-static-view-creators. - - Add docs about upgrading between Pyramid versions (e.g. how to see deprecation warnings). diff --git a/docs/api/response.rst b/docs/api/response.rst index 8020b629a..52978a126 100644 --- a/docs/api/response.rst +++ b/docs/api/response.rst @@ -9,6 +9,11 @@ :members: :inherited-members: +.. autoclass:: FileResponse + :members: + +.. autoclass:: FileIter + Functions ~~~~~~~~~ diff --git a/docs/whatsnew-1.3.rst b/docs/whatsnew-1.3.rst index 101caed94..daa1ffdec 100644 --- a/docs/whatsnew-1.3.rst +++ b/docs/whatsnew-1.3.rst @@ -354,6 +354,10 @@ Minor Feature Additions can be used to replace the respective default values of ``request.application_url`` partially. +- New APIs: :class:`pyramid.response.FileResponse` and + :class:`pyramid.response.FileIter`, for usage in views that must serve + files "manually". + Backwards Incompatibilities --------------------------- diff --git a/pyramid/response.py b/pyramid/response.py index b0c965296..d2b6fc8a7 100644 --- a/pyramid/response.py +++ b/pyramid/response.py @@ -1,13 +1,87 @@ +import mimetypes +from os.path import ( + getmtime, + getsize, + ) + import venusian from webob import Response as _Response from zope.interface import implementer from pyramid.interfaces import IResponse +_BLOCK_SIZE = 4096 * 64 # 256K + @implementer(IResponse) class Response(_Response): pass +class FileResponse(Response): + """ + A Response object that can be used to serve a static file from disk + simply. + + ``path`` is a file path on disk. + + ``request`` must be a Pyramid :term:`request` object if passed. Note + that a request *must* be passed if the response is meant to attempt to + use the ``wsgi.file_wrapper`` feature of the web server that you're using + to serve your Pyramid application. + + ``cache_max_age`` if passed, is the number of seconds that should be used + to HTTP cache this response. + """ + def __init__(self, path, request=None, cache_max_age=None): + super(FileResponse, self).__init__(conditional_response=True) + self.last_modified = getmtime(path) + content_type, content_encoding = mimetypes.guess_type(path, + strict=False) + if content_type is None: + content_type = 'application/octet-stream' + self.content_type = content_type + self.content_encoding = content_encoding + content_length = getsize(path) + f = open(path, 'rb') + app_iter = None + if request is not None: + environ = request.environ + if 'wsgi.file_wrapper' in environ: + app_iter = environ['wsgi.file_wrapper'](f, _BLOCK_SIZE) + if app_iter is None: + app_iter = FileIter(f, _BLOCK_SIZE) + self.app_iter = app_iter + # assignment of content_length must come after assignment of app_iter + self.content_length = content_length + if cache_max_age is not None: + self.cache_expires = cache_max_age + +class FileIter(object): + """ A fixed-block-size iterator for use as a WSGI app_iter. + + ``file`` is a Python file pointer (or at least an object with a ``read`` + method that takes a size hint). + + ``block_size`` is an optional block size for iteration. + """ + def __init__(self, file, block_size=_BLOCK_SIZE): + self.file = file + self.block_size = block_size + + def __iter__(self): + return self + + def next(self): + val = self.file.read(self.block_size) + if not val: + raise StopIteration + return val + + __next__ = next # py3 + + def close(self): + self.file.close() + + class response_adapter(object): """ Decorator activated via a :term:`scan` which treats the function being decorated as a :term:`response adapter` for the set of types or diff --git a/pyramid/static.py b/pyramid/static.py index e91485fad..fbe60b1dd 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -6,8 +6,6 @@ from os.path import ( normcase, normpath, join, - getmtime, - getsize, isdir, exists, ) @@ -30,7 +28,7 @@ from pyramid.httpexceptions import ( ) from pyramid.path import caller_package -from pyramid.response import Response +from pyramid.response import FileResponse from pyramid.traversal import traversal_path_info slash = text_('/') @@ -47,53 +45,6 @@ def init_mimetypes(mimetypes): # has been applied on the Python 2 trunk). init_mimetypes(mimetypes) -_BLOCK_SIZE = 4096 * 64 # 256K - -class _FileResponse(Response): - """ - Serves a static filelike object. - """ - def __init__(self, path, cache_max_age, request): - super(_FileResponse, self).__init__(conditional_response=True) - self.last_modified = getmtime(path) - content_type, content_encoding = mimetypes.guess_type(path, - strict=False) - if content_type is None: - content_type = 'application/octet-stream' - self.content_type = content_type - self.content_encoding = content_encoding - content_length = getsize(path) - f = open(path, 'rb') - environ = request.environ - if 'wsgi.file_wrapper' in environ: - app_iter = environ['wsgi.file_wrapper'](f, _BLOCK_SIZE) - else: - app_iter = _FileIter(f, _BLOCK_SIZE) - self.app_iter = app_iter - # assignment of content_length must come after assignment of app_iter - self.content_length = content_length - if cache_max_age is not None: - self.cache_expires = cache_max_age - -class _FileIter(object): - def __init__(self, file, block_size): - self.file = file - self.block_size = block_size - - def __iter__(self): - return self - - def next(self): - val = self.file.read(self.block_size) - if not val: - raise StopIteration - return val - - __next__ = next # py3 - - def close(self): - self.file.close() - class static_view(object): """ An instance of this class is a callable which can act as a :app:`Pyramid` :term:`view callable`; this view will serve @@ -187,7 +138,7 @@ class static_view(object): if not exists(filepath): return HTTPNotFound(request.url) - return _FileResponse(filepath ,self.cache_max_age, request) + return FileResponse(filepath, request, self.cache_max_age) def add_slash_redirect(self, request): url = request.path_url + '/' diff --git a/pyramid/tests/test_response.py b/pyramid/tests/test_response.py index 24931ec4b..af6eb3532 100644 --- a/pyramid/tests/test_response.py +++ b/pyramid/tests/test_response.py @@ -1,3 +1,4 @@ +import io import unittest from pyramid import testing @@ -16,6 +17,32 @@ class TestResponse(unittest.TestCase): inst = self._getTargetClass()() self.assertTrue(IResponse.providedBy(inst)) +class TestFileIter(unittest.TestCase): + def _makeOne(self, file, block_size): + from pyramid.response import FileIter + return FileIter(file, block_size) + + def test___iter__(self): + f = io.BytesIO(b'abc') + inst = self._makeOne(f, 1) + self.assertEqual(inst.__iter__(), inst) + + def test_iteration(self): + data = b'abcdef' + f = io.BytesIO(b'abcdef') + inst = self._makeOne(f, 1) + r = b'' + for x in inst: + self.assertEqual(len(x), 1) + r+=x + self.assertEqual(r, data) + + def test_close(self): + f = io.BytesIO(b'abc') + inst = self._makeOne(f, 1) + inst.close() + self.assertTrue(f.closed) + class Dummy(object): pass diff --git a/pyramid/tests/test_static.py b/pyramid/tests/test_static.py index 02cd49430..70932143e 100644 --- a/pyramid/tests/test_static.py +++ b/pyramid/tests/test_static.py @@ -1,6 +1,5 @@ import datetime import unittest -import io # 5 years from now (more or less) fiveyrsfuture = datetime.datetime.utcnow() + datetime.timedelta(5*365) @@ -114,7 +113,7 @@ class Test_static_view_use_subpath_False(unittest.TestCase): self.assertTrue(b'static' in response.body) def test_resource_is_file_with_wsgi_file_wrapper(self): - from pyramid.static import _BLOCK_SIZE + from pyramid.response import _BLOCK_SIZE inst = self._makeOne('pyramid.tests:fixtures/static') request = self._makeRequest({'PATH_INFO':'/index.html'}) class _Wrapper(object): @@ -386,32 +385,6 @@ class Test_patch_mimetypes(unittest.TestCase): result = self._callFUT(module) self.assertEqual(result, False) -class Test_FileIter(unittest.TestCase): - def _makeOne(self, file, block_size): - from pyramid.static import _FileIter - return _FileIter(file, block_size) - - def test___iter__(self): - f = io.BytesIO(b'abc') - inst = self._makeOne(f, 1) - self.assertEqual(inst.__iter__(), inst) - - def test_iteration(self): - data = b'abcdef' - f = io.BytesIO(b'abcdef') - inst = self._makeOne(f, 1) - r = b'' - for x in inst: - self.assertEqual(len(x), 1) - r+=x - self.assertEqual(r, data) - - def test_close(self): - f = io.BytesIO(b'abc') - inst = self._makeOne(f, 1) - inst.close() - self.assertTrue(f.closed) - class DummyContext: pass -- cgit v1.2.3 From c2e82a7505bd0cac9304b31c4e84fbafe521e0e1 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 22 Feb 2012 20:22:27 -0500 Subject: allow user to pass content type and encoding, change favicon example to use FileResponse --- docs/narr/assets.rst | 15 +++++++++------ pyramid/response.py | 15 ++++++++++++--- pyramid/tests/test_response.py | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/narr/assets.rst b/docs/narr/assets.rst index 2cc870619..bad666066 100644 --- a/docs/narr/assets.rst +++ b/docs/narr/assets.rst @@ -370,19 +370,22 @@ do so, do things "by hand". First define the view callable. :linenos: import os - from pyramid.response import Response + from pyramid.response import FileResponse def favicon_view(request): here = os.path.dirname(__file__) - icon = open(os.path.join(here, 'static', 'favicon.ico'), 'rb') - return Response(content_type='image/x-icon', app_iter=icon) + icon = os.path.join(here, 'static', 'favicon.ico') + return FileResponse(icon, request=request) The above bit of code within ``favicon_view`` computes "here", which is a path relative to the Python file in which the function is defined. It then uses the Python ``open`` function to obtain a file handle to a file within -"here" named ``static``, and returns a response using the open the file -handle as the response's ``app_iter``. It makes sure to set the right -content_type too. +"here" named ``static``, and returns a :class:`pyramid.response.Fileresponse` +using the file path as the response's ``path`` argument and the request as +the response's ``request`` argument. :class:`pyramid.response.FileResponse` +will serve the file as quickly as possible when it's used this way. It makes +sure to set the right content length and content_type too based on the file +extension of the file you pass. You might register such a view via configuration as a view callable that should be called as the result of a traversal: diff --git a/pyramid/response.py b/pyramid/response.py index d2b6fc8a7..2642d7769 100644 --- a/pyramid/response.py +++ b/pyramid/response.py @@ -30,12 +30,21 @@ class FileResponse(Response): ``cache_max_age`` if passed, is the number of seconds that should be used to HTTP cache this response. + + ``content_type``, if passed, is the content_type of the response. + + ``content_encoding``, if passed is the content_encoding of the response. + It's generally safe to leave this set to ``None`` if you're serving a + binary file. This argument will be ignored if you don't also pass + ``content-type``. """ - def __init__(self, path, request=None, cache_max_age=None): + def __init__(self, path, request=None, cache_max_age=None, + content_type=None, content_encoding=None): super(FileResponse, self).__init__(conditional_response=True) self.last_modified = getmtime(path) - content_type, content_encoding = mimetypes.guess_type(path, - strict=False) + if content_type is None: + content_type, content_encoding = mimetypes.guess_type(path, + strict=False) if content_type is None: content_type = 'application/octet-stream' self.content_type = content_type diff --git a/pyramid/tests/test_response.py b/pyramid/tests/test_response.py index af6eb3532..c5df1fc05 100644 --- a/pyramid/tests/test_response.py +++ b/pyramid/tests/test_response.py @@ -1,4 +1,5 @@ import io +import os import unittest from pyramid import testing @@ -17,6 +18,25 @@ class TestResponse(unittest.TestCase): inst = self._getTargetClass()() self.assertTrue(IResponse.providedBy(inst)) +class TestFileResponse(unittest.TestCase): + def _makeOne(self, file, **kw): + from pyramid.response import FileResponse + return FileResponse(file, **kw) + + def _getPath(self): + here = os.path.dirname(__file__) + return os.path.join(here, 'fixtures', 'minimal.txt') + + def test_with_content_type(self): + path = self._getPath() + r = self._makeOne(path, content_type='image/jpeg') + self.assertEqual(r.content_type, 'image/jpeg') + + def test_without_content_type(self): + path = self._getPath() + r = self._makeOne(path) + self.assertEqual(r.content_type, 'text/plain') + class TestFileIter(unittest.TestCase): def _makeOne(self, file, block_size): from pyramid.response import FileIter -- cgit v1.2.3 From a1317263c3a4dafd366d3a272c37a3ed8ac3613a Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 22 Feb 2012 20:23:26 -0500 Subject: fix --- docs/narr/assets.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/narr/assets.rst b/docs/narr/assets.rst index bad666066..22b38c929 100644 --- a/docs/narr/assets.rst +++ b/docs/narr/assets.rst @@ -380,7 +380,7 @@ do so, do things "by hand". First define the view callable. The above bit of code within ``favicon_view`` computes "here", which is a path relative to the Python file in which the function is defined. It then uses the Python ``open`` function to obtain a file handle to a file within -"here" named ``static``, and returns a :class:`pyramid.response.Fileresponse` +"here" named ``static``, and returns a :class:`pyramid.response.FileResponse` using the file path as the response's ``path`` argument and the request as the response's ``request`` argument. :class:`pyramid.response.FileResponse` will serve the file as quickly as possible when it's used this way. It makes -- cgit v1.2.3 From 662d6ea96a79cdd9943a9e3c871fe07282ee671c Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 22 Feb 2012 21:10:08 -0500 Subject: prep for 1.3a9 --- CHANGES.txt | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index ecac7a68f..bcedb19a1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,5 @@ -Next release -============ +1.3a9 (2012-02-22) +================== Features -------- diff --git a/docs/conf.py b/docs/conf.py index 3e38226c1..79d9946ad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -80,7 +80,7 @@ copyright = '%s, Agendaless Consulting' % datetime.datetime.now().year # other places throughout the built documents. # # The short X.Y version. -version = '1.3a8' +version = '1.3a9' # The full version, including alpha/beta/rc tags. release = version diff --git a/setup.py b/setup.py index 7a5e80c67..05abb437a 100644 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ if not PY3: ]) setup(name='pyramid', - version='1.3a8', + version='1.3a9', description=('The Pyramid web application development framework, a ' 'Pylons project'), long_description=README + '\n\n' + CHANGES, -- cgit v1.2.3