From f2ef797a1514a30e8dbb66e363100ef8c624811b Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 13:40:30 -0400 Subject: first cut; still missing features as documented in TODO.txt --- TODO.txt | 14 +- pyramid/static.py | 271 ++++++++++++------ pyramid/tests/test_config/test_views.py | 15 +- pyramid/tests/test_integration.py | 205 ++++++++++---- pyramid/tests/test_static.py | 479 +++++++++++++------------------- pyramid/tests/test_traversal.py | 3 + pyramid/tests/test_view.py | 62 +---- pyramid/traversal.py | 3 +- pyramid/view.py | 16 -- 9 files changed, 559 insertions(+), 509 deletions(-) diff --git a/TODO.txt b/TODO.txt index 5305dcc57..bdc3e036a 100644 --- a/TODO.txt +++ b/TODO.txt @@ -81,10 +81,16 @@ Future - 1.3: - Eliminate non-deployment-non-scaffold-related Paste dependency: ``paste.urlparser.StaticURLParser`` (cutnpaste or reimplement, possibly - using chrisrossi's happy stuff as a base). paste.urlparser/paste.fileapp - features missing from happy.static: ``wsgi.file_wrapper`` support - (FileApp.get), 'HEAD' method support (FileApp.get), ETAG and if-none-match - support (DataApp.get), handling file permission exceptions (FileApp.get), + using chrisrossi's happy stuff as a base). Still need: + + ``wsgi.file_wrapper`` support (FileApp.get) + + 'HEAD' method support (FileApp.get) + + handling file permission exceptions (FileApp.get). + + Features we won't supportL ETAG and if-none-match + support (DataApp.get); replace with if-modified-since handling. - 1.3: use zope.registry rather than zope.component. diff --git a/pyramid/static.py b/pyramid/static.py index 1291ae58f..3bad8bdc2 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -1,82 +1,95 @@ -import os -import pkg_resources +from datetime import datetime, timedelta +from os.path import normcase, normpath, join, getmtime, getsize, isdir, exists +from pkg_resources import resource_exists, resource_filename, resource_isdir +import mimetypes -from paste import httpexceptions -from paste import request -from paste.httpheaders import ETAG -from paste.urlparser import StaticURLParser +from repoze.lru import lru_cache from pyramid.asset import resolve_asset_spec +from pyramid.httpexceptions import HTTPNotFound +from pyramid.httpexceptions import HTTPMovedPermanently from pyramid.path import caller_package -from pyramid.request import call_app_with_subpath_as_path_info - -class PackageURLParser(StaticURLParser): - """ This probably won't work with zipimported resources """ - def __init__(self, package_name, resource_name, root_resource=None, - cache_max_age=None): - self.package_name = package_name - self.resource_name = os.path.normpath(resource_name) - if root_resource is None: - root_resource = self.resource_name - self.root_resource = root_resource - self.cache_max_age = cache_max_age - - def __call__(self, environ, start_response): - path_info = environ.get('PATH_INFO', '') - if not path_info: - return self.add_slash(environ, start_response) - if path_info == '/': - # @@: This should obviously be configurable - filename = 'index.html' - else: - filename = request.path_info_pop(environ) - resource = os.path.normcase(os.path.normpath( - self.resource_name + '/' + filename)) - if not resource.startswith(self.root_resource): - # Out of bounds - return self.not_found(environ, start_response) - if not pkg_resources.resource_exists(self.package_name, resource): - return self.not_found(environ, start_response) - if pkg_resources.resource_isdir(self.package_name, resource): - # @@: Cache? - return self.__class__( - self.package_name, resource, root_resource=self.resource_name, - cache_max_age=self.cache_max_age)(environ, start_response) - pi = environ.get('PATH_INFO') - if pi and pi != '/': - return self.error_extra_path(environ, start_response) - full = pkg_resources.resource_filename(self.package_name, resource) - if_none_match = environ.get('HTTP_IF_NONE_MATCH') - if if_none_match: - mytime = os.stat(full).st_mtime - if str(mytime) == if_none_match: - headers = [] - ETAG.update(headers, mytime) - start_response('304 Not Modified', headers) - return [''] # empty body - - fa = self.make_app(full) - if self.cache_max_age: - fa.cache_control(max_age=self.cache_max_age) - return fa(environ, start_response) - - def not_found(self, environ, start_response, debug_message=None): - comment=('SCRIPT_NAME=%r; PATH_INFO=%r; looking in package %s; ' - 'subdir %s ;debug: %s' % (environ.get('SCRIPT_NAME'), - environ.get('PATH_INFO'), - self.package_name, - self.resource_name, - debug_message or '(none)')) - exc = httpexceptions.HTTPNotFound( - 'The resource at %s could not be found' - % request.construct_url(environ), - comment=comment) - return exc.wsgi_application(environ, start_response) - - def __repr__(self): - return '<%s %s:%s at %s>' % (self.__class__.__name__, self.package_name, - self.root_resource, id(self)) - +from pyramid.response import Response +from pyramid.traversal import traversal_path +from pyramid.traversal import quote_path_segment + +DEFAULT_CHUNKSIZE = 1<<16 # 64 kilobytes + +def init_mimetypes(mimetypes): + # this is a function so it can be unittested + if hasattr(mimetypes, 'init'): + mimetypes.init() + return True + return False + +# See http://bugs.python.org/issue5853 which is a recursion bug +# that seems to effect Python 2.6, Python 2.6.1, and 2.6.2 (a fix +# has been applied on the Python 2 trunk). +init_mimetypes(mimetypes) + +class FileResponse(Response): + """ + Serves a static filelike object. + """ + def __init__(self, path, request, expires, chunksize=DEFAULT_CHUNKSIZE): + super(FileResponse, self).__init__() + self.request = request + self.last_modified = datetime.utcfromtimestamp(getmtime(path)) + + # Check 'If-Modified-Since' request header + # Browser might already have in cache + modified_since = request.if_modified_since + if modified_since is not None: + if self.last_modified <= modified_since: + self.status = 304 + return + + # Provide partial response if requested + content_length = getsize(path) + request_range = self._get_range(content_length) + if request_range is not None: + start, end = request_range + if start >= content_length: + self.status_int = 416 # Request range not satisfiable + return + + self.status_int = 206 # Partial Content + self.headers['Content-Range'] = 'bytes %d-%d/%d' % ( + start, end-1, content_length) + content_length = end - start + + self.date = datetime.utcnow() + self.app_iter = _file_iter(path, chunksize, request_range) + self.content_type = mimetypes.guess_type(path, strict=False)[0] + + self.content_length = content_length + if expires is not None: + self.expires = self.date + expires + + def _get_range(self, content_length): + # WebOb earlier than 0.9.7 has broken range parser implementation. + # The current released version at this time is 0.9.6, so we do this + # ourselves. (It is fixed on trunk, though.) + request = self.request + range_header = request.headers.get('Range', None) + if range_header is None: + return None + + # Refuse to parse multiple byte ranges. They are just plain silly. + if ',' in range_header: + return None + + unit, range_s = range_header.split('=', 1) + if unit != 'bytes': + # Other units are not supported + return None + + if range_s.startswith('-'): + start = content_length - int(range_s[1:]) + return start, content_length + + start, end = map(int, range_s.split('-')) + return start, end + 1 class static_view(object): """ An instance of this class is a callable which can act as a @@ -120,24 +133,110 @@ class static_view(object): package-relative directory. However, if the ``root_dir`` is absolute, configuration will not be able to override the assets it contains. """ - + + FileResponse = FileResponse # override point + def __init__(self, root_dir, cache_max_age=3600, package_name=None, - use_subpath=False): + use_subpath=False, index='index.html', + chunksize=DEFAULT_CHUNKSIZE): # package_name is for bw compat; it is preferred to pass in a # package-relative path as root_dir # (e.g. ``anotherpackage:foo/static``). + if isinstance(cache_max_age, int): + cache_max_age = timedelta(seconds=cache_max_age) + self.expires = cache_max_age if package_name is None: package_name = caller_package().__name__ - package_name, root_dir = resolve_asset_spec(root_dir, package_name) - if package_name is None: - app = StaticURLParser(root_dir, cache_max_age=cache_max_age) - else: - app = PackageURLParser( - package_name, root_dir, cache_max_age=cache_max_age) - self.app = app + package_name, docroot = resolve_asset_spec(root_dir, package_name) self.use_subpath = use_subpath + self.package_name = package_name + self.docroot = docroot + self.norm_docroot = normcase(normpath(docroot)) + self.chunksize = chunksize + self.index = index def __call__(self, context, request): if self.use_subpath: - return call_app_with_subpath_as_path_info(request, self.app) - return request.get_response(self.app) + path_tuple = request.subpath + else: + path_tuple = traversal_path(request.path_info) + + path = secure_path(path_tuple) + + if path is None: + # belt-and-suspenders security; this should never be true + # unless someone screws up the traversal_path code + # (request.subpath is computed via traversal_path too) + return HTTPNotFound('Out of bounds: %s' % request.url) + + if self.package_name: # package resource + + resource_path ='%s/%s' % (self.docroot.rstrip('/'), path) + if resource_isdir(self.package_name, resource_path): + if not request.path_url.endswith('/'): + return self.add_slash_redirect(request) + resource_path = '%s/%s' % (resource_path.rstrip('/'),self.index) + if not resource_exists(self.package_name, resource_path): + return HTTPNotFound(request.url) + filepath = resource_filename(self.package_name, resource_path) + + else: # filesystem file + + # os.path.normpath converts / to \ on windows + filepath = normcase(normpath(join(self.norm_docroot, path))) + if isdir(filepath): + if not request.path_url.endswith('/'): + return self.add_slash_redirect(request) + filepath = join(filepath, self.index) + if not exists(filepath): + return HTTPNotFound(request.url) + + return self.FileResponse(filepath, request,self.expires,self.chunksize) + + def add_slash_redirect(self, request): + url = request.path_url + '/' + qs = request.query_string + if qs: + url = url + '?' + qs + return HTTPMovedPermanently(url) + +def _file_iter(path, chunksize, content_range=None): + file = open(path, 'rb') + + if content_range is not None: + + class ByteReader(object): + def __init__(self, n_bytes): + self.bytes_left = n_bytes + + def __call__(self): + b = file.read(min(self.bytes_left, chunksize)) + self.bytes_left -= len(b) + return b + + start, end = content_range + file.seek(start) + get_bytes = ByteReader(end - start) + + else: + def get_bytes(): + return file.read(chunksize) + + try: + buf = get_bytes() + while buf: + yield buf + buf = get_bytes() + finally: + if hasattr(file, 'close'): + file.close() + +@lru_cache(1000) +def secure_path(path_tuple): + if '' in path_tuple: + return None + for item in path_tuple: + for val in ['.', '/']: + if item.startswith(val): + return None + return '/'.join([quote_path_segment(x) for x in path_tuple]) diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 872528c6c..484d49c2f 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -1415,19 +1415,19 @@ class TestViewsConfigurationMixin(unittest.TestCase): def test_add_static_view_here_no_utility_registered(self): from pyramid.renderers import null_renderer from zope.interface import Interface - from pyramid.static import PackageURLParser from pyramid.interfaces import IView from pyramid.interfaces import IViewClassifier config = self._makeOne(autocommit=True) - config.add_static_view('static', 'files', - renderer=null_renderer) + config.add_static_view('static', 'files', renderer=null_renderer) request_type = self._getRouteRequestIface(config, 'static/') self._assertRoute(config, 'static/', 'static/*subpath') wrapped = config.registry.adapters.lookup( (IViewClassifier, request_type, Interface), IView, name='') - request = self._makeRequest(config) + from pyramid.request import Request + request = Request.blank('/static/minimal.pt') + request.subpath = ('minimal.pt', ) result = wrapped(None, request) - self.assertEqual(result.__class__, PackageURLParser) + self.assertEqual(result.status, '200 OK') def test_add_static_view_package_relative(self): from pyramid.interfaces import IStaticURLInfo @@ -3346,7 +3346,6 @@ class TestStaticURLInfo(unittest.TestCase): self.assertEqual(config.route_args, ('view/', 'view/*subpath')) self.assertEqual(config.view_kw['permission'], NO_PERMISSION_REQUIRED) self.assertEqual(config.view_kw['view'].__class__, static_view) - self.assertEqual(config.view_kw['view'].app.cache_max_age, 1) def test_add_viewname_with_permission(self): config = DummyConfig() @@ -3416,10 +3415,6 @@ class DummyRequest: self.environ = environ self.params = {} self.cookies = {} - def copy(self): - return self - def get_response(self, app): - return app class DummyContext: pass diff --git a/pyramid/tests/test_integration.py b/pyramid/tests/test_integration.py index 239db58ba..91f4e43c1 100644 --- a/pyramid/tests/test_integration.py +++ b/pyramid/tests/test_integration.py @@ -42,82 +42,177 @@ class WGSIAppPlusViewConfigTests(unittest.TestCase): self.assertEqual(view.__original_view__, wsgiapptest) here = os.path.dirname(__file__) -staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=True) -class TestStaticApp(unittest.TestCase): +class TestStaticAppBase(object): + def _makeRequest(self, extra=None): + if extra is None: + extra = {} + from pyramid.request import Request + from StringIO import StringIO + kw = {'PATH_INFO':'', + 'SCRIPT_NAME':'', + 'SERVER_NAME':'localhost', + 'SERVER_PORT':'80', + 'REQUEST_METHOD':'GET', + 'wsgi.version':(1,0), + 'wsgi.url_scheme':'http', + 'wsgi.input':StringIO()} + kw.update(extra) + request = Request(kw) + return request + + def _assertBody(self, body, filename): + self.assertEqual( + body.replace('\r', ''), + open(filename, 'r').read() + ) + +class TestStaticAppTests(TestStaticAppBase): def test_basic(self): - from webob import Request + request = self._makeRequest() context = DummyContext() - from StringIO import StringIO - request = Request({'PATH_INFO':'', - 'SCRIPT_NAME':'', - 'SERVER_NAME':'localhost', - 'SERVER_PORT':'80', - 'REQUEST_METHOD':'GET', - 'wsgi.version':(1,0), - 'wsgi.url_scheme':'http', - 'wsgi.input':StringIO()}) request.subpath = ('minimal.pt',) - result = staticapp(context, request) + result = self.staticapp(context, request) self.assertEqual(result.status, '200 OK') - self.assertEqual( - result.body.replace('\r', ''), - open(os.path.join(here, 'fixtures/minimal.pt'), 'r').read()) + self._assertBody(result.body, os.path.join(here, 'fixtures/minimal.pt')) + + def test_not_modified(self): + request = self._makeRequest() + context = DummyContext() + request.subpath = ('minimal.pt',) + request.if_modified_since = pow(2, 32)-1 + result = self.staticapp(context, request) + self.assertEqual(result.status, '304 Not Modified') # CR only def test_file_in_subdir(self): - from webob import Request + request = self._makeRequest() context = DummyContext() - from StringIO import StringIO - request = Request({'PATH_INFO':'', - 'SCRIPT_NAME':'', - 'SERVER_NAME':'localhost', - 'SERVER_PORT':'80', - 'REQUEST_METHOD':'GET', - 'wsgi.version':(1,0), - 'wsgi.url_scheme':'http', - 'wsgi.input':StringIO()}) request.subpath = ('static', 'index.html',) - result = staticapp(context, request) + result = self.staticapp(context, request) self.assertEqual(result.status, '200 OK') - self.assertEqual( - result.body.replace('\r', ''), - open(os.path.join(here, 'fixtures/static/index.html'), 'r').read()) + self._assertBody(result.body, + os.path.join(here, 'fixtures/static/index.html')) - def test_redirect_to_subdir(self): - from webob import Request + def test_directory_noslash_redir(self): + request = self._makeRequest({'PATH_INFO':'/static'}) context = DummyContext() - from StringIO import StringIO - request = Request({'PATH_INFO':'', - 'SCRIPT_NAME':'', - 'SERVER_NAME':'localhost', - 'SERVER_PORT':'80', - 'REQUEST_METHOD':'GET', - 'wsgi.version':(1,0), - 'wsgi.url_scheme':'http', - 'wsgi.input':StringIO()}) request.subpath = ('static',) - result = staticapp(context, request) + result = self.staticapp(context, request) self.assertEqual(result.status, '301 Moved Permanently') self.assertEqual(result.location, 'http://localhost/static/') - def test_redirect_to_subdir_with_existing_script_name(self): - from webob import Request + def test_directory_noslash_redir_preserves_qs(self): + request = self._makeRequest({'PATH_INFO':'/static', + 'QUERY_STRING':'a=1&b=2'}) + context = DummyContext() + request.subpath = ('static',) + result = self.staticapp(context, request) + self.assertEqual(result.status, '301 Moved Permanently') + self.assertEqual(result.location, 'http://localhost/static/?a=1&b=2') + + def test_directory_noslash_redir_with_scriptname(self): + request = self._makeRequest({'SCRIPT_NAME':'/script_name', + 'PATH_INFO':'/static'}) context = DummyContext() - from StringIO import StringIO - request = Request({'PATH_INFO':'/static', - 'SCRIPT_NAME':'/script_name', - 'SERVER_NAME':'localhost', - 'SERVER_PORT':'80', - 'REQUEST_METHOD':'GET', - 'wsgi.version':(1,0), - 'wsgi.url_scheme':'http', - 'wsgi.input':StringIO()}) request.subpath = ('static',) - result = staticapp(context, request) + result = self.staticapp(context, request) self.assertEqual(result.status, '301 Moved Permanently') - self.assertEqual(result.location, + self.assertEqual(result.location, 'http://localhost/script_name/static/') + def test_directory_withslash(self): + request = self._makeRequest({'PATH_INFO':'/static/'}) + context = DummyContext() + request.subpath = ('static',) + result = self.staticapp(context, request) + self.assertEqual(result.status, '200 OK') + self._assertBody(result.body, + os.path.join(here, 'fixtures/static/index.html')) + + def test_range_inclusive(self): + request = self._makeRequest({'HTTP_RANGE':'bytes=1-2'}) + context = DummyContext() + request.subpath = ('static', 'index.html') + result = self.staticapp(context, request) + self.assertEqual(result.status, '206 Partial Content') + self.assertEqual(result.body, 'ht') + + def test_range_tilend(self): + request = self._makeRequest({'HTTP_RANGE':'bytes=-5'}) + context = DummyContext() + request.subpath = ('static', 'index.html') + result = self.staticapp(context, request) + self.assertEqual(result.status, '206 Partial Content') + self.assertEqual(result.body, 'tml>\n') # CR only + + def test_range_notbytes(self): + request = self._makeRequest({'HTTP_RANGE':'kilohertz=10'}) + context = DummyContext() + request.subpath = ('static', 'index.html') + result = self.staticapp(context, request) + self.assertEqual(result.status, '200 OK') + self._assertBody(result.body, + os.path.join(here, 'fixtures/static/index.html')) + + def test_range_multiple(self): + request = self._makeRequest({'HTTP_RANGE':'bytes=10,11'}) + context = DummyContext() + request.subpath = ('static', 'index.html') + result = self.staticapp(context, request) + self.assertEqual(result.status, '200 OK') + self._assertBody(result.body, + os.path.join(here, 'fixtures/static/index.html')) + + def test_range_oob(self): + request = self._makeRequest({'HTTP_RANGE':'bytes=1000-1002'}) + context = DummyContext() + request.subpath = ('static', 'index.html') + result = self.staticapp(context, request) + self.assertEqual(result.status_int, 416) + + def test_notfound(self): + request = self._makeRequest() + context = DummyContext() + request.subpath = ('static', 'wontbefound.x') + result = self.staticapp(context, request) + self.assertEqual(result.status, '404 Not Found') + + def test_oob_doubledot(self): + request = self._makeRequest() + context = DummyContext() + request.subpath = ('..', 'test_integration.py') + result = self.staticapp(context, request) + self.assertEqual(result.status, '404 Not Found') + + def test_oob_slash(self): + request = self._makeRequest() + context = DummyContext() + request.subpath = ('/', 'test_integration.py') + result = self.staticapp(context, request) + self.assertEqual(result.status, '404 Not Found') + + def test_oob_empty(self): + request = self._makeRequest() + context = DummyContext() + request.subpath = ('', 'test_integration.py') + result = self.staticapp(context, request) + self.assertEqual(result.status, '404 Not Found') + +class TestStaticAppUsingAbsPath(unittest.TestCase, TestStaticAppTests): + staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=True) + + +class TestStaticAppUsingResourcePath(unittest.TestCase, TestStaticAppTests): + staticapp = static_view('pyramid.tests:fixtures', use_subpath=True) + +class TestStaticAppNoSubpath(unittest.TestCase, TestStaticAppBase): + staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=False) + def test_basic(self): + request = self._makeRequest({'PATH_INFO':'/minimal.pt'}) + context = DummyContext() + result = self.staticapp(context, request) + self.assertEqual(result.status, '200 OK') + self._assertBody(result.body, os.path.join(here, 'fixtures/minimal.pt')) class IntegrationBase(unittest.TestCase): root_factory = None diff --git a/pyramid/tests/test_static.py b/pyramid/tests/test_static.py index 357ec7551..8385b6f71 100644 --- a/pyramid/tests/test_static.py +++ b/pyramid/tests/test_static.py @@ -1,16 +1,16 @@ import unittest -from pyramid.testing import cleanUp +import datetime -class TestPackageURLParser(unittest.TestCase): +class Test_static_view_use_subpath_False(unittest.TestCase): def _getTargetClass(self): - from pyramid.static import PackageURLParser - return PackageURLParser + from pyramid.static import static_view + return static_view def _makeOne(self, *arg, **kw): return self._getTargetClass()(*arg, **kw) - - def _makeEnviron(self, **kw): + def _makeRequest(self, kw=None): + from pyramid.request import Request environ = { 'wsgi.url_scheme':'http', 'wsgi.version':(1,0), @@ -20,332 +20,251 @@ class TestPackageURLParser(unittest.TestCase): 'SCRIPT_NAME':'', 'REQUEST_METHOD':'GET', } - environ.update(kw) - return environ + if kw is not None: + environ.update(kw) + return Request(environ=environ) - def test_ctor_allargs(self): - import os.path - inst = self._makeOne('package', 'resource/name', root_resource='root', - cache_max_age=100) - self.assertEqual(inst.package_name, 'package') - self.assertEqual(inst.resource_name, os.path.join('resource', 'name')) - self.assertEqual(inst.root_resource, 'root') - self.assertEqual(inst.cache_max_age, 100) - def test_ctor_defaultargs(self): - import os.path - inst = self._makeOne('package', 'resource/name') + inst = self._makeOne('package:resource_name') self.assertEqual(inst.package_name, 'package') - self.assertEqual(inst.resource_name, os.path.join('resource', 'name')) - self.assertEqual(inst.root_resource, os.path.join('resource', 'name')) - self.assertEqual(inst.cache_max_age, None) + self.assertEqual(inst.docroot, 'resource_name') + self.assertEqual(inst.expires, datetime.timedelta(seconds=3600)) + self.assertEqual(inst.index, 'index.html') def test_call_adds_slash_path_info_empty(self): - environ = self._makeEnviron(PATH_INFO='') - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('301 Moved Permanently' in body) - self.assertTrue('http://example.com:6543/' in body) + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest({'PATH_INFO':''}) + context = DummyContext() + response = inst(context, request) + response.prepare(request.environ) + self.assertEqual(response.status, '301 Moved Permanently') + self.assertTrue('http://example.com:6543/' in response.body) def test_path_info_slash_means_index_html(self): - environ = self._makeEnviron() - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('static' in body) + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + context = DummyContext() + response = inst(context, request) + self.assertTrue('static' in response.body) def test_resource_out_of_bounds(self): - environ = self._makeEnviron() - inst = self._makeOne('pyramid.tests', 'fixtures/static') - inst.root_resource = 'abcdef' - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('404 Not Found' in body) - self.assertTrue('http://example.com:6543/' in body) + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest({'PATH_INFO':'/subdir/../../minimal.pt'}) + context = DummyContext() + response = inst(context, request) + self.assertEqual(response.status, '404 Not Found') def test_resource_doesnt_exist(self): - environ = self._makeEnviron(PATH_INFO='/notthere') - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('404 Not Found' in body) - self.assertTrue('http://example.com:6543/' in body) + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest({'PATH_INFO':'/notthere'}) + context = DummyContext() + response = inst(context, request) + self.assertEqual(response.status, '404 Not Found') def test_resource_isdir(self): - environ = self._makeEnviron(PATH_INFO='/subdir/') - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('subdir' in body) + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest({'PATH_INFO':'/subdir/'}) + context = DummyContext() + response = inst(context, request) + self.assertTrue('subdir' in response.body) def test_resource_is_file(self): - environ = self._makeEnviron(PATH_INFO='/index.html') - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('static' in body) - - def test_resource_has_extra_path_info(self): - environ = self._makeEnviron(PATH_INFO='/static/index.html/more') - inst = self._makeOne('pyramid.tests', 'fixtures') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue("The trailing path '/more' is not allowed" in body) + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest({'PATH_INFO':'/index.html'}) + context = DummyContext() + response = inst(context, request) + self.assertTrue('static' in response.body) def test_resource_is_file_with_cache_max_age(self): - environ = self._makeEnviron(PATH_INFO='/index.html') - inst = self._makeOne('pyramid.tests', 'fixtures/static', - cache_max_age=600) - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('static' in body) - self.assertEqual(len(sr.headerlist), 8) - header_names = [ x[0] for x in sr.headerlist ] + inst = self._makeOne('pyramid.tests:fixtures/static', cache_max_age=600) + request = self._makeRequest({'PATH_INFO':'/index.html'}) + context = DummyContext() + response = inst(context, request) + self.assertTrue('static' in response.body) + self.assertEqual(len(response.headerlist), 5) + header_names = [ x[0] for x in response.headerlist ] header_names.sort() self.assertEqual(header_names, - ['Accept-Ranges', 'Cache-Control', - 'Content-Length', 'Content-Range', - 'Content-Type', 'ETag', 'Expires', 'Last-Modified']) + ['Content-Length', 'Content-Type', 'Date', 'Expires', + 'Last-Modified']) def test_resource_is_file_with_no_cache_max_age(self): - environ = self._makeEnviron(PATH_INFO='/index.html') - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('static' in body) - self.assertEqual(len(sr.headerlist), 6) - header_names = [ x[0] for x in sr.headerlist ] + inst = self._makeOne('pyramid.tests:fixtures/static', + cache_max_age=None) + request = self._makeRequest({'PATH_INFO':'/index.html'}) + context = DummyContext() + response = inst(context, request) + self.assertTrue('static' in response.body) + self.assertEqual(len(response.headerlist), 4) + header_names = [ x[0] for x in response.headerlist ] header_names.sort() - self.assertEqual(header_names, - ['Accept-Ranges', 'Content-Length', 'Content-Range', - 'Content-Type', 'ETag', 'Last-Modified']) - - def test_with_root_resource(self): - environ = self._makeEnviron(PATH_INFO='/static/index.html') - inst = self._makeOne('pyramid.tests', 'fixtures', - root_resource='fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - body = response[0] - self.assertTrue('static' in body) - - def test_if_none_match(self): - class DummyEq(object): - def __eq__(self, other): - return True - dummy_eq = DummyEq() - environ = self._makeEnviron(HTTP_IF_NONE_MATCH=dummy_eq) - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - response = inst(environ, sr) - self.assertEqual(len(sr.headerlist), 1) - self.assertEqual(sr.status, '304 Not Modified') - self.assertEqual(sr.headerlist[0][0], 'ETag') - self.assertEqual(response[0], '') - - def test_if_none_match_miss(self): - class DummyEq(object): - def __eq__(self, other): - return False - dummy_eq = DummyEq() - environ = self._makeEnviron(HTTP_IF_NONE_MATCH=dummy_eq) - inst = self._makeOne('pyramid.tests', 'fixtures/static') - sr = DummyStartResponse() - inst(environ, sr) - self.assertEqual(len(sr.headerlist), 6) - self.assertEqual(sr.status, '200 OK') - - def test_repr(self): - import os.path - inst = self._makeOne('pyramid.tests', 'fixtures/static') - self.assertTrue( - repr(inst).startswith( - 'static' in response.body) - def test_relpath_notsubpath(self): - path = 'fixtures' - view = self._makeOne(path) + def test_resource_out_of_bounds(self): + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + request.subpath = ('subdir', '..', '..', 'minimal.pt') context = DummyContext() - request = DummyRequest() - request.subpath = ['__init__.py'] - request.environ = self._makeEnviron() - response = view(context, request) - self.assertTrue(not hasattr(request, 'copied')) - self.assertEqual(response.root_resource, 'fixtures') - self.assertEqual(response.resource_name, 'fixtures') - self.assertEqual(response.package_name, 'pyramid.tests') - self.assertEqual(response.cache_max_age, 3600) + response = inst(context, request) + self.assertEqual(response.status, '404 Not Found') - def test_relpath_withpackage_subpath(self): - view = self._makeOne('another:fixtures', use_subpath=True) + def test_resource_doesnt_exist(self): + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + request.subpath = ('notthere,') context = DummyContext() - request = DummyRequest() - request.subpath = ['__init__.py'] - request.environ = self._makeEnviron() - response = view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(response.root_resource, 'fixtures') - self.assertEqual(response.resource_name, 'fixtures') - self.assertEqual(response.package_name, 'another') - self.assertEqual(response.cache_max_age, 3600) + response = inst(context, request) + self.assertEqual(response.status, '404 Not Found') - def test_relpath_withpackage_name_subpath(self): - view = self._makeOne('fixtures', package_name='another', - use_subpath=True) + def test_resource_isdir(self): + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + request.subpath = ('subdir',) context = DummyContext() - request = DummyRequest() - request.subpath = ['__init__.py'] - request.environ = self._makeEnviron() - response = view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(response.root_resource, 'fixtures') - self.assertEqual(response.resource_name, 'fixtures') - self.assertEqual(response.package_name, 'another') - self.assertEqual(response.cache_max_age, 3600) + response = inst(context, request) + self.assertTrue('subdir' in response.body) - def test_no_subpath_preserves_path_info_and_script_name_subpath(self): - view = self._makeOne('fixtures', package_name='another', - use_subpath=True) + def test_resource_is_file(self): + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + request.subpath = ('index.html',) context = DummyContext() - request = DummyRequest() - request.subpath = () - request.environ = self._makeEnviron(PATH_INFO='/path_info', - SCRIPT_NAME='/script_name') - view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(request.environ['PATH_INFO'], '/') - self.assertEqual(request.environ['SCRIPT_NAME'], - '/script_name/path_info') + response = inst(context, request) + self.assertTrue('static' in response.body) - def test_with_subpath_path_info_ends_with_slash_subpath(self): - view = self._makeOne('fixtures', package_name='another', - use_subpath=True) + def test_resource_is_file_with_cache_max_age(self): + inst = self._makeOne('pyramid.tests:fixtures/static', cache_max_age=600) + request = self._makeRequest() + request.subpath = ('index.html',) context = DummyContext() - request = DummyRequest() - request.subpath = ('subpath',) - request.environ = self._makeEnviron(PATH_INFO='/path_info/subpath/') - view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(request.environ['PATH_INFO'], '/subpath/') - self.assertEqual(request.environ['SCRIPT_NAME'], '/path_info') + response = inst(context, request) + self.assertTrue('static' in response.body) + self.assertEqual(len(response.headerlist), 5) + header_names = [ x[0] for x in response.headerlist ] + header_names.sort() + self.assertEqual(header_names, + ['Content-Length', 'Content-Type', 'Date', 'Expires', + 'Last-Modified']) - def test_with_subpath_original_script_name_preserved(self): - view = self._makeOne('fixtures', package_name='another', - use_subpath=True) + def test_resource_is_file_with_no_cache_max_age(self): + inst = self._makeOne('pyramid.tests:fixtures/static', + cache_max_age=None) + request = self._makeRequest() + request.subpath = ('index.html',) + context = DummyContext() + response = inst(context, request) + self.assertTrue('static' in response.body) + self.assertEqual(len(response.headerlist), 4) + header_names = [ x[0] for x in response.headerlist ] + header_names.sort() + self.assertEqual( + header_names, + ['Content-Length', 'Content-Type', 'Date', 'Last-Modified']) + + def test_resource_notmodified(self): + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + request.if_modified_since = pow(2, 32) -1 + request.subpath = ('index.html',) context = DummyContext() - request = DummyRequest() - request.subpath = ('subpath',) - request.environ = self._makeEnviron(PATH_INFO='/path_info/subpath/', - SCRIPT_NAME='/scriptname') - view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(request.environ['PATH_INFO'], '/subpath/') - self.assertEqual(request.environ['SCRIPT_NAME'], - '/scriptname/path_info') + response = inst(context, request) + self.assertEqual(response.status, '304 Not Modified') - def test_with_subpath_new_script_name_fixes_trailing_slashes(self): - view = self._makeOne('fixtures', package_name='another', - use_subpath=True) + def test_not_found(self): + inst = self._makeOne('pyramid.tests:fixtures/static') + request = self._makeRequest() + request.subpath = ('notthere.html',) context = DummyContext() - request = DummyRequest() - request.subpath = ('sub', 'path') - request.environ = self._makeEnviron(PATH_INFO='/path_info//sub//path//') - view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(request.environ['PATH_INFO'], '/sub/path/') - self.assertEqual(request.environ['SCRIPT_NAME'], '/path_info') + response = inst(context, request) + self.assertEqual(response.status, '404 Not Found') + +class Test_patch_mimetypes(unittest.TestCase): + def _callFUT(self, module): + from pyramid.static import init_mimetypes + return init_mimetypes(module) + + def test_has_init(self): + class DummyMimetypes(object): + def init(self): + self.initted = True + module = DummyMimetypes() + result = self._callFUT(module) + self.assertEqual(result, True) + self.assertEqual(module.initted, True) + + def test_missing_init(self): + class DummyMimetypes(object): + pass + module = DummyMimetypes() + result = self._callFUT(module) + self.assertEqual(result, False) -class DummyStartResponse: - def __call__(self, status, headerlist, exc_info=None): - self.status = status - self.headerlist = headerlist - self.exc_info = exc_info - class DummyContext: pass -class DummyRequest: - def __init__(self, environ=None): - if environ is None: - environ = {} - self.environ = environ - - def get_response(self, application): - return application - - def copy(self): - self.copied = True - return self - diff --git a/pyramid/tests/test_traversal.py b/pyramid/tests/test_traversal.py index 0ead63703..95caf21be 100644 --- a/pyramid/tests/test_traversal.py +++ b/pyramid/tests/test_traversal.py @@ -19,6 +19,9 @@ class TraversalPathTests(unittest.TestCase): def test_twodots(self): self.assertEqual(self._callFUT('foo/../bar'), (u'bar',)) + def test_twodots_at_start(self): + self.assertEqual(self._callFUT('../../bar'), (u'bar',)) + def test_element_urllquoted(self): self.assertEqual(self._callFUT('/foo/space%20thing/bar'), (u'foo', u'space thing', u'bar')) diff --git a/pyramid/tests/test_view.py b/pyramid/tests/test_view.py index 434bbb9ce..7f66a7563 100644 --- a/pyramid/tests/test_view.py +++ b/pyramid/tests/test_view.py @@ -548,27 +548,6 @@ class Test_default_exceptionresponse_view(unittest.TestCase): result = self._callFUT(context, request) self.assertEqual(result, 'abc') -class Test_patch_mimetypes(unittest.TestCase): - def _callFUT(self, module): - from pyramid.view import init_mimetypes - return init_mimetypes(module) - - def test_has_init(self): - class DummyMimetypes(object): - def init(self): - self.initted = True - module = DummyMimetypes() - result = self._callFUT(module) - self.assertEqual(result, True) - self.assertEqual(module.initted, True) - - def test_missing_init(self): - class DummyMimetypes(object): - pass - module = DummyMimetypes() - result = self._callFUT(module) - self.assertEqual(result, False) - class Test_static(unittest.TestCase): def setUp(self): from zope.deprecation import __show__ @@ -578,38 +557,14 @@ class Test_static(unittest.TestCase): from zope.deprecation import __show__ __show__.on() - def _getTargetClass(self): + def _makeOne(self, path, package_name): from pyramid.view import static - return static - - def _makeOne(self, path, package_name=None): - return self._getTargetClass()(path, package_name=package_name) + return static(path, package_name) - def _makeEnviron(self, **extras): - environ = { - 'wsgi.url_scheme':'http', - 'wsgi.version':(1,0), - 'SERVER_NAME':'localhost', - 'SERVER_PORT':'8080', - 'REQUEST_METHOD':'GET', - } - environ.update(extras) - return environ - - - def test_relpath_subpath(self): + def test_it(self): path = 'fixtures' - view = self._makeOne(path) - context = DummyContext() - request = DummyRequest() - request.subpath = ['__init__.py'] - request.environ = self._makeEnviron() - response = view(context, request) - self.assertEqual(request.copied, True) - self.assertEqual(response.root_resource, 'fixtures') - self.assertEqual(response.resource_name, 'fixtures') - self.assertEqual(response.package_name, 'pyramid.tests') - self.assertEqual(response.cache_max_age, 3600) + view = self._makeOne(path, None) + self.assertEqual(view.docroot, 'fixtures') class ExceptionResponse(Exception): status = '404 Not Found' @@ -632,13 +587,6 @@ class DummyRequest: environ = {} self.environ = environ - def get_response(self, application): - return application - - def copy(self): - self.copied = True - return self - from pyramid.interfaces import IResponse from zope.interface import implements diff --git a/pyramid/traversal.py b/pyramid/traversal.py index 2004fcad2..4beb27af3 100644 --- a/pyramid/traversal.py +++ b/pyramid/traversal.py @@ -479,7 +479,8 @@ def traversal_path(path): if not segment or segment=='.': continue elif segment == '..': - del clean[-1] + if clean: + del clean[-1] else: try: segment = segment.decode('utf-8') diff --git a/pyramid/view.py b/pyramid/view.py index 96b7c6413..8b8ac58ce 100644 --- a/pyramid/view.py +++ b/pyramid/view.py @@ -1,4 +1,3 @@ -import mimetypes import venusian from zope.interface import providedBy @@ -14,21 +13,6 @@ from pyramid.path import caller_package from pyramid.static import static_view from pyramid.threadlocal import get_current_registry -def init_mimetypes(mimetypes): - # this is a function so it can be unittested - if hasattr(mimetypes, 'init'): - mimetypes.init() - return True - return False - -# See http://bugs.python.org/issue5853 which is a recursion bug -# that seems to effect Python 2.6, Python 2.6.1, and 2.6.2 (a fix -# has been applied on the Python 2 trunk). This workaround should -# really be in Paste if anywhere, but it's easiest to just do it -# here and get it over with to avoid needing to deal with any -# fallout. -init_mimetypes(mimetypes) - _marker = object() class static(static_view): -- cgit v1.2.3 From a49168ce3b3799c559ddcf8d7df182cef8fdf32e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 16:49:10 -0400 Subject: use webtest for static file testing --- pyramid/static.py | 26 ++- pyramid/tests/pkgs/static_abspath/__init__.py | 7 + pyramid/tests/pkgs/static_assetspec/__init__.py | 3 + pyramid/tests/test_integration.py | 248 ++++++++++-------------- 4 files changed, 128 insertions(+), 156 deletions(-) create mode 100644 pyramid/tests/pkgs/static_abspath/__init__.py create mode 100644 pyramid/tests/pkgs/static_assetspec/__init__.py diff --git a/pyramid/static.py b/pyramid/static.py index 3bad8bdc2..aa7784246 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -4,6 +4,7 @@ from pkg_resources import resource_exists, resource_filename, resource_isdir import mimetypes from repoze.lru import lru_cache +from webob import UTC from pyramid.asset import resolve_asset_spec from pyramid.httpexceptions import HTTPNotFound @@ -34,22 +35,30 @@ class FileResponse(Response): def __init__(self, path, request, expires, chunksize=DEFAULT_CHUNKSIZE): super(FileResponse, self).__init__() self.request = request - self.last_modified = datetime.utcfromtimestamp(getmtime(path)) + last_modified = datetime.fromtimestamp(getmtime(path), tz=UTC) - # Check 'If-Modified-Since' request header - # Browser might already have in cache + # Check 'If-Modified-Since' request header Browser might already have + # in cache modified_since = request.if_modified_since if modified_since is not None: - if self.last_modified <= modified_since: + if last_modified <= modified_since: + self.content_type = None + self.content_length = None self.status = 304 return + content_type = mimetypes.guess_type(path, strict=False)[0] + if content_type is None: + content_type = 'application/octet-stream' + # Provide partial response if requested content_length = getsize(path) request_range = self._get_range(content_length) if request_range is not None: start, end = request_range if start >= content_length: + self.content_type = content_type + self.content_length = None self.status_int = 416 # Request range not satisfiable return @@ -60,9 +69,14 @@ class FileResponse(Response): self.date = datetime.utcnow() self.app_iter = _file_iter(path, chunksize, request_range) - self.content_type = mimetypes.guess_type(path, strict=False)[0] - self.content_length = content_length + if content_length: + self.content_length = content_length + self.content_type = content_type + self.last_modified = last_modified + else: + self.content_length = None + if expires is not None: self.expires = self.date + expires diff --git a/pyramid/tests/pkgs/static_abspath/__init__.py b/pyramid/tests/pkgs/static_abspath/__init__.py new file mode 100644 index 000000000..812cca467 --- /dev/null +++ b/pyramid/tests/pkgs/static_abspath/__init__.py @@ -0,0 +1,7 @@ +import os + +def includeme(config): + here = here = os.path.dirname(__file__) + fixtures = os.path.normpath(os.path.join(here, '..', '..', 'fixtures')) + config.add_static_view('/', fixtures) + diff --git a/pyramid/tests/pkgs/static_assetspec/__init__.py b/pyramid/tests/pkgs/static_assetspec/__init__.py new file mode 100644 index 000000000..cd6195397 --- /dev/null +++ b/pyramid/tests/pkgs/static_assetspec/__init__.py @@ -0,0 +1,3 @@ +def includeme(config): + config.add_static_view('/', 'pyramid.tests:fixtures') + diff --git a/pyramid/tests/test_integration.py b/pyramid/tests/test_integration.py index 91f4e43c1..6ab9d7339 100644 --- a/pyramid/tests/test_integration.py +++ b/pyramid/tests/test_integration.py @@ -41,172 +41,134 @@ class WGSIAppPlusViewConfigTests(unittest.TestCase): (IViewClassifier, IRequest, INothing), IView, name='') self.assertEqual(view.__original_view__, wsgiapptest) -here = os.path.dirname(__file__) +class IntegrationBase(object): + root_factory = None + package = None + def setUp(self): + from pyramid.config import Configurator + config = Configurator(root_factory=self.root_factory, + package=self.package) + config.include(self.package) + app = config.make_wsgi_app() + from webtest import TestApp + self.testapp = TestApp(app) + self.config = config -class TestStaticAppBase(object): - def _makeRequest(self, extra=None): - if extra is None: - extra = {} - from pyramid.request import Request - from StringIO import StringIO - kw = {'PATH_INFO':'', - 'SCRIPT_NAME':'', - 'SERVER_NAME':'localhost', - 'SERVER_PORT':'80', - 'REQUEST_METHOD':'GET', - 'wsgi.version':(1,0), - 'wsgi.url_scheme':'http', - 'wsgi.input':StringIO()} - kw.update(extra) - request = Request(kw) - return request + def tearDown(self): + self.config.end() + +here = os.path.dirname(__file__) +class TestStaticAppBase(IntegrationBase): def _assertBody(self, body, filename): self.assertEqual( body.replace('\r', ''), open(filename, 'r').read() ) -class TestStaticAppTests(TestStaticAppBase): def test_basic(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('minimal.pt',) - result = self.staticapp(context, request) - self.assertEqual(result.status, '200 OK') - self._assertBody(result.body, os.path.join(here, 'fixtures/minimal.pt')) + res = self.testapp.get('/minimal.pt', status=200) + self._assertBody(res.body, os.path.join(here, 'fixtures/minimal.pt')) def test_not_modified(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('minimal.pt',) - request.if_modified_since = pow(2, 32)-1 - result = self.staticapp(context, request) - self.assertEqual(result.status, '304 Not Modified') # CR only + self.testapp.extra_environ = { + 'HTTP_IF_MODIFIED_SINCE':httpdate(pow(2, 32)-1)} + res = self.testapp.get('/minimal.pt', status=304) + self.assertEqual(res.body, '') def test_file_in_subdir(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('static', 'index.html',) - result = self.staticapp(context, request) - self.assertEqual(result.status, '200 OK') - self._assertBody(result.body, - os.path.join(here, 'fixtures/static/index.html')) + fn = os.path.join(here, 'fixtures/static/index.html') + res = self.testapp.get('/static/index.html', status=200) + self._assertBody(res.body, fn) def test_directory_noslash_redir(self): - request = self._makeRequest({'PATH_INFO':'/static'}) - context = DummyContext() - request.subpath = ('static',) - result = self.staticapp(context, request) - self.assertEqual(result.status, '301 Moved Permanently') - self.assertEqual(result.location, 'http://localhost/static/') + res = self.testapp.get('/static', status=301) + self.assertEqual(res.headers['Location'], 'http://localhost/static/') def test_directory_noslash_redir_preserves_qs(self): - request = self._makeRequest({'PATH_INFO':'/static', - 'QUERY_STRING':'a=1&b=2'}) - context = DummyContext() - request.subpath = ('static',) - result = self.staticapp(context, request) - self.assertEqual(result.status, '301 Moved Permanently') - self.assertEqual(result.location, 'http://localhost/static/?a=1&b=2') + res = self.testapp.get('/static?a=1&b=2', status=301) + self.assertEqual(res.headers['Location'], + 'http://localhost/static/?a=1&b=2') def test_directory_noslash_redir_with_scriptname(self): - request = self._makeRequest({'SCRIPT_NAME':'/script_name', - 'PATH_INFO':'/static'}) - context = DummyContext() - request.subpath = ('static',) - result = self.staticapp(context, request) - self.assertEqual(result.status, '301 Moved Permanently') - self.assertEqual(result.location, + self.testapp.extra_environ = {'SCRIPT_NAME':'/script_name'} + res = self.testapp.get('/static', status=301) + self.assertEqual(res.headers['Location'], 'http://localhost/script_name/static/') def test_directory_withslash(self): - request = self._makeRequest({'PATH_INFO':'/static/'}) - context = DummyContext() - request.subpath = ('static',) - result = self.staticapp(context, request) - self.assertEqual(result.status, '200 OK') - self._assertBody(result.body, - os.path.join(here, 'fixtures/static/index.html')) + fn = os.path.join(here, 'fixtures/static/index.html') + res = self.testapp.get('/static/', status=200) + self._assertBody(res.body, fn) def test_range_inclusive(self): - request = self._makeRequest({'HTTP_RANGE':'bytes=1-2'}) - context = DummyContext() - request.subpath = ('static', 'index.html') - result = self.staticapp(context, request) - self.assertEqual(result.status, '206 Partial Content') - self.assertEqual(result.body, 'ht') + self.testapp.extra_environ = {'HTTP_RANGE':'bytes=1-2'} + res = self.testapp.get('/static/index.html', status=206) + self.assertEqual(res.body, 'ht') def test_range_tilend(self): - request = self._makeRequest({'HTTP_RANGE':'bytes=-5'}) - context = DummyContext() - request.subpath = ('static', 'index.html') - result = self.staticapp(context, request) - self.assertEqual(result.status, '206 Partial Content') - self.assertEqual(result.body, 'tml>\n') # CR only + self.testapp.extra_environ = {'HTTP_RANGE':'bytes=-5'} + res = self.testapp.get('/static/index.html', status=206) + self.assertEqual(res.body, 'tml>\n') def test_range_notbytes(self): - request = self._makeRequest({'HTTP_RANGE':'kilohertz=10'}) - context = DummyContext() - request.subpath = ('static', 'index.html') - result = self.staticapp(context, request) - self.assertEqual(result.status, '200 OK') - self._assertBody(result.body, + self.testapp.extra_environ = {'HTTP_RANGE':'kHz=-5'} + res = self.testapp.get('/static/index.html', status=200) + self._assertBody(res.body, os.path.join(here, 'fixtures/static/index.html')) def test_range_multiple(self): - request = self._makeRequest({'HTTP_RANGE':'bytes=10,11'}) - context = DummyContext() - request.subpath = ('static', 'index.html') - result = self.staticapp(context, request) - self.assertEqual(result.status, '200 OK') - self._assertBody(result.body, + res = self.testapp.get('/static/index.html', + [('HTTP_RANGE', 'bytes=10,11')], + status=200) + self._assertBody(res.body, os.path.join(here, 'fixtures/static/index.html')) def test_range_oob(self): - request = self._makeRequest({'HTTP_RANGE':'bytes=1000-1002'}) - context = DummyContext() - request.subpath = ('static', 'index.html') - result = self.staticapp(context, request) - self.assertEqual(result.status_int, 416) + self.testapp.extra_environ = {'HTTP_RANGE':'bytes=1000-1002'} + self.testapp.get('/static/index.html', status=416) def test_notfound(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('static', 'wontbefound.x') - result = self.staticapp(context, request) - self.assertEqual(result.status, '404 Not Found') + self.testapp.get('/static/wontbefound.html', status=404) def test_oob_doubledot(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('..', 'test_integration.py') - result = self.staticapp(context, request) - self.assertEqual(result.status, '404 Not Found') + self.testapp.get('/static/../../test_integration.py', status=404) def test_oob_slash(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('/', 'test_integration.py') - result = self.staticapp(context, request) - self.assertEqual(result.status, '404 Not Found') + self.testapp.get('/%2F/test_integration.py', status=404) + # XXX pdb this - def test_oob_empty(self): - request = self._makeRequest() - context = DummyContext() - request.subpath = ('', 'test_integration.py') - result = self.staticapp(context, request) - self.assertEqual(result.status, '404 Not Found') +class TestStaticAppUsingAbsPath(TestStaticAppBase, unittest.TestCase): + package = 'pyramid.tests.pkgs.static_abspath' -class TestStaticAppUsingAbsPath(unittest.TestCase, TestStaticAppTests): - staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=True) +class TestStaticAppUsingAssetSpec(TestStaticAppBase, unittest.TestCase): + package = 'pyramid.tests.pkgs.static_assetspec' +class TestStaticAppNoSubpath(unittest.TestCase): + staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=False) + def _makeRequest(self, extra=None): + if extra is None: + extra = {} + from pyramid.request import Request + from StringIO import StringIO + kw = {'PATH_INFO':'', + 'SCRIPT_NAME':'', + 'SERVER_NAME':'localhost', + 'SERVER_PORT':'80', + 'REQUEST_METHOD':'GET', + 'wsgi.version':(1,0), + 'wsgi.url_scheme':'http', + 'wsgi.input':StringIO()} + kw.update(extra) + request = Request(kw) + return request -class TestStaticAppUsingResourcePath(unittest.TestCase, TestStaticAppTests): - staticapp = static_view('pyramid.tests:fixtures', use_subpath=True) + def _assertBody(self, body, filename): + self.assertEqual( + body.replace('\r', ''), + open(filename, 'r').read() + ) -class TestStaticAppNoSubpath(unittest.TestCase, TestStaticAppBase): - staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=False) def test_basic(self): request = self._makeRequest({'PATH_INFO':'/minimal.pt'}) context = DummyContext() @@ -214,25 +176,7 @@ class TestStaticAppNoSubpath(unittest.TestCase, TestStaticAppBase): self.assertEqual(result.status, '200 OK') self._assertBody(result.body, os.path.join(here, 'fixtures/minimal.pt')) -class IntegrationBase(unittest.TestCase): - root_factory = None - package = None - def setUp(self): - from pyramid.config import Configurator - config = Configurator(root_factory=self.root_factory, - package=self.package) - config.begin() - config.include(self.package) - config.commit() - app = config.make_wsgi_app() - from webtest import TestApp - self.testapp = TestApp(app) - self.config = config - - def tearDown(self): - self.config.end() - -class TestFixtureApp(IntegrationBase): +class TestFixtureApp(IntegrationBase, unittest.TestCase): package = 'pyramid.tests.pkgs.fixtureapp' def test_another(self): res = self.testapp.get('/another.html', status=200) @@ -252,7 +196,7 @@ class TestFixtureApp(IntegrationBase): def test_protected(self): self.testapp.get('/protected.html', status=403) -class TestStaticPermApp(IntegrationBase): +class TestStaticPermApp(IntegrationBase, unittest.TestCase): package = 'pyramid.tests.pkgs.staticpermapp' root_factory = 'pyramid.tests.pkgs.staticpermapp:RootFactory' def test_allowed(self): @@ -283,7 +227,7 @@ class TestStaticPermApp(IntegrationBase): result.body.replace('\r', ''), open(os.path.join(here, 'fixtures/static/index.html'), 'r').read()) -class TestCCBug(IntegrationBase): +class TestCCBug(IntegrationBase, unittest.TestCase): # "unordered" as reported in IRC by author of # http://labs.creativecommons.org/2010/01/13/cc-engine-and-web-non-frameworks/ package = 'pyramid.tests.pkgs.ccbugapp' @@ -295,7 +239,7 @@ class TestCCBug(IntegrationBase): res = self.testapp.get('/licenses/1/v1/juri', status=200) self.assertEqual(res.body, 'juri') -class TestHybridApp(IntegrationBase): +class TestHybridApp(IntegrationBase, unittest.TestCase): # make sure views registered for a route "win" over views registered # without one, even though the context of the non-route view may # be more specific than the route view. @@ -338,14 +282,14 @@ class TestHybridApp(IntegrationBase): res = self.testapp.get('/error_sub', status=200) self.assertEqual(res.body, 'supressed2') -class TestRestBugApp(IntegrationBase): +class TestRestBugApp(IntegrationBase, unittest.TestCase): # test bug reported by delijati 2010/2/3 (http://pastebin.com/d4cc15515) package = 'pyramid.tests.pkgs.restbugapp' def test_it(self): res = self.testapp.get('/pet', status=200) self.assertEqual(res.body, 'gotten') -class TestForbiddenAppHasResult(IntegrationBase): +class TestForbiddenAppHasResult(IntegrationBase, unittest.TestCase): # test that forbidden exception has ACLDenied result attached package = 'pyramid.tests.pkgs.forbiddenapp' def test_it(self): @@ -360,7 +304,7 @@ class TestForbiddenAppHasResult(IntegrationBase): self.assertTrue( result.endswith("for principals ['system.Everyone']")) -class TestViewDecoratorApp(IntegrationBase): +class TestViewDecoratorApp(IntegrationBase, unittest.TestCase): package = 'pyramid.tests.pkgs.viewdecoratorapp' def _configure_mako(self): tmpldir = os.path.join(os.path.dirname(__file__), @@ -381,7 +325,7 @@ class TestViewDecoratorApp(IntegrationBase): res = self.testapp.get('/second', status=200) self.assertTrue('OK2' in res.body) -class TestViewPermissionBug(IntegrationBase): +class TestViewPermissionBug(IntegrationBase, unittest.TestCase): # view_execution_permitted bug as reported by Shane at http://lists.repoze.org/pipermail/repoze-dev/2010-October/003603.html package = 'pyramid.tests.pkgs.permbugapp' def test_test(self): @@ -391,7 +335,7 @@ class TestViewPermissionBug(IntegrationBase): def test_x(self): self.testapp.get('/x', status=403) -class TestDefaultViewPermissionBug(IntegrationBase): +class TestDefaultViewPermissionBug(IntegrationBase, unittest.TestCase): # default_view_permission bug as reported by Wiggy at http://lists.repoze.org/pipermail/repoze-dev/2010-October/003602.html package = 'pyramid.tests.pkgs.defpermbugapp' def test_x(self): @@ -411,7 +355,7 @@ from pyramid.tests.pkgs.exceptionviewapp.models import \ excroot = {'anexception':AnException(), 'notanexception':NotAnException()} -class TestExceptionViewsApp(IntegrationBase): +class TestExceptionViewsApp(IntegrationBase, unittest.TestCase): package = 'pyramid.tests.pkgs.exceptionviewapp' root_factory = lambda *arg: excroot def test_root(self): @@ -570,7 +514,7 @@ class WSGIApp2AppTest(unittest.TestCase): self.assertTrue('Hello' in res.body) if os.name != 'java': # uses chameleon - class RendererScanAppTest(IntegrationBase): + class RendererScanAppTest(IntegrationBase, unittest.TestCase): package = 'pyramid.tests.pkgs.rendererscanapp' def test_root(self): res = self.testapp.get('/one', status=200) @@ -600,3 +544,7 @@ class DummyRequest: def get_response(self, application): return application(None, None) +def httpdate(ts): + import datetime + ts = datetime.datetime.utcfromtimestamp(ts) + return ts.strftime("%a, %d %b %Y %H:%M:%S GMT") -- cgit v1.2.3 From c7446c55d5ada29605c399e2200098219aac9854 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 17:04:01 -0400 Subject: use webob to serve conditional content --- pyramid/static.py | 107 +++----------------------------------- pyramid/tests/test_integration.py | 4 +- pyramid/tests/test_static.py | 16 +++++- 3 files changed, 23 insertions(+), 104 deletions(-) diff --git a/pyramid/static.py b/pyramid/static.py index aa7784246..e7d41630e 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -32,79 +32,19 @@ class FileResponse(Response): """ Serves a static filelike object. """ - def __init__(self, path, request, expires, chunksize=DEFAULT_CHUNKSIZE): - super(FileResponse, self).__init__() - self.request = request - last_modified = datetime.fromtimestamp(getmtime(path), tz=UTC) - - # Check 'If-Modified-Since' request header Browser might already have - # in cache - modified_since = request.if_modified_since - if modified_since is not None: - if last_modified <= modified_since: - self.content_type = None - self.content_length = None - self.status = 304 - return - + def __init__(self, path, expires, chunksize=DEFAULT_CHUNKSIZE): + super(FileResponse, self).__init__(conditional_response=True) + self.last_modified = datetime.fromtimestamp(getmtime(path), tz=UTC) + self.date = datetime.utcnow() + self.app_iter = open(path, 'rb') content_type = mimetypes.guess_type(path, strict=False)[0] if content_type is None: content_type = 'application/octet-stream' - - # Provide partial response if requested - content_length = getsize(path) - request_range = self._get_range(content_length) - if request_range is not None: - start, end = request_range - if start >= content_length: - self.content_type = content_type - self.content_length = None - self.status_int = 416 # Request range not satisfiable - return - - self.status_int = 206 # Partial Content - self.headers['Content-Range'] = 'bytes %d-%d/%d' % ( - start, end-1, content_length) - content_length = end - start - - self.date = datetime.utcnow() - self.app_iter = _file_iter(path, chunksize, request_range) - - if content_length: - self.content_length = content_length - self.content_type = content_type - self.last_modified = last_modified - else: - self.content_length = None - + self.content_type = content_type + self.content_length = getsize(path) if expires is not None: self.expires = self.date + expires - def _get_range(self, content_length): - # WebOb earlier than 0.9.7 has broken range parser implementation. - # The current released version at this time is 0.9.6, so we do this - # ourselves. (It is fixed on trunk, though.) - request = self.request - range_header = request.headers.get('Range', None) - if range_header is None: - return None - - # Refuse to parse multiple byte ranges. They are just plain silly. - if ',' in range_header: - return None - - unit, range_s = range_header.split('=', 1) - if unit != 'bytes': - # Other units are not supported - return None - - if range_s.startswith('-'): - start = content_length - int(range_s[1:]) - return start, content_length - - start, end = map(int, range_s.split('-')) - return start, end + 1 - 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 @@ -205,7 +145,7 @@ class static_view(object): if not exists(filepath): return HTTPNotFound(request.url) - return self.FileResponse(filepath, request,self.expires,self.chunksize) + return self.FileResponse(filepath ,self.expires, self.chunksize) def add_slash_redirect(self, request): url = request.path_url + '/' @@ -214,37 +154,6 @@ class static_view(object): url = url + '?' + qs return HTTPMovedPermanently(url) -def _file_iter(path, chunksize, content_range=None): - file = open(path, 'rb') - - if content_range is not None: - - class ByteReader(object): - def __init__(self, n_bytes): - self.bytes_left = n_bytes - - def __call__(self): - b = file.read(min(self.bytes_left, chunksize)) - self.bytes_left -= len(b) - return b - - start, end = content_range - file.seek(start) - get_bytes = ByteReader(end - start) - - else: - def get_bytes(): - return file.read(chunksize) - - try: - buf = get_bytes() - while buf: - yield buf - buf = get_bytes() - finally: - if hasattr(file, 'close'): - file.close() - @lru_cache(1000) def secure_path(path_tuple): if '' in path_tuple: diff --git a/pyramid/tests/test_integration.py b/pyramid/tests/test_integration.py index 6ab9d7339..e1389481c 100644 --- a/pyramid/tests/test_integration.py +++ b/pyramid/tests/test_integration.py @@ -146,9 +146,7 @@ class TestStaticAppUsingAssetSpec(TestStaticAppBase, unittest.TestCase): class TestStaticAppNoSubpath(unittest.TestCase): staticapp = static_view(os.path.join(here, 'fixtures'), use_subpath=False) - def _makeRequest(self, extra=None): - if extra is None: - extra = {} + def _makeRequest(self, extra): from pyramid.request import Request from StringIO import StringIO kw = {'PATH_INFO':'', diff --git a/pyramid/tests/test_static.py b/pyramid/tests/test_static.py index 8385b6f71..90bc91244 100644 --- a/pyramid/tests/test_static.py +++ b/pyramid/tests/test_static.py @@ -108,7 +108,10 @@ class Test_static_view_use_subpath_False(unittest.TestCase): request.if_modified_since = pow(2, 32) -1 context = DummyContext() response = inst(context, request) - self.assertEqual(response.status, '304 Not Modified') + start_response = DummyStartResponse() + app_iter = response(request.environ, start_response) + self.assertEqual(start_response.status, '304 Not Modified') + self.assertEqual(list(app_iter), []) def test_not_found(self): inst = self._makeOne('pyramid.tests:fixtures/static') @@ -234,7 +237,10 @@ class Test_static_view_use_subpath_True(unittest.TestCase): request.subpath = ('index.html',) context = DummyContext() response = inst(context, request) - self.assertEqual(response.status, '304 Not Modified') + start_response = DummyStartResponse() + app_iter = response(request.environ, start_response) + self.assertEqual(start_response.status, '304 Not Modified') + self.assertEqual(list(app_iter), []) def test_not_found(self): inst = self._makeOne('pyramid.tests:fixtures/static') @@ -268,3 +274,9 @@ class Test_patch_mimetypes(unittest.TestCase): class DummyContext: pass +class DummyStartResponse: + status = () + headers = () + def __call__(self, status, headers): + self.status = status + self.headers = headers -- cgit v1.2.3 From df85f3d7d754f44202bb92dc3e290cf2be550cfb Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 17:07:05 -0400 Subject: remove unused chunksize --- pyramid/static.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pyramid/static.py b/pyramid/static.py index e7d41630e..93d399f54 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -14,8 +14,6 @@ from pyramid.response import Response from pyramid.traversal import traversal_path from pyramid.traversal import quote_path_segment -DEFAULT_CHUNKSIZE = 1<<16 # 64 kilobytes - def init_mimetypes(mimetypes): # this is a function so it can be unittested if hasattr(mimetypes, 'init'): @@ -32,7 +30,7 @@ class FileResponse(Response): """ Serves a static filelike object. """ - def __init__(self, path, expires, chunksize=DEFAULT_CHUNKSIZE): + def __init__(self, path, expires): super(FileResponse, self).__init__(conditional_response=True) self.last_modified = datetime.fromtimestamp(getmtime(path), tz=UTC) self.date = datetime.utcnow() @@ -91,8 +89,7 @@ class static_view(object): FileResponse = FileResponse # override point def __init__(self, root_dir, cache_max_age=3600, package_name=None, - use_subpath=False, index='index.html', - chunksize=DEFAULT_CHUNKSIZE): + use_subpath=False, index='index.html'): # package_name is for bw compat; it is preferred to pass in a # package-relative path as root_dir # (e.g. ``anotherpackage:foo/static``). @@ -106,7 +103,6 @@ class static_view(object): self.package_name = package_name self.docroot = docroot self.norm_docroot = normcase(normpath(docroot)) - self.chunksize = chunksize self.index = index def __call__(self, context, request): @@ -145,7 +141,7 @@ class static_view(object): if not exists(filepath): return HTTPNotFound(request.url) - return self.FileResponse(filepath ,self.expires, self.chunksize) + return self.FileResponse(filepath ,self.expires) def add_slash_redirect(self, request): url = request.path_url + '/' -- cgit v1.2.3 From fe1548d160ff881f50410709547bbf895733ea10 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 18:30:18 -0400 Subject: dont set explicit date header; we no longer need the request to be passed to FileResponse --- TODO.txt | 2 ++ pyramid/static.py | 17 +++++++---------- pyramid/tests/test_static.py | 20 ++++++++++---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/TODO.txt b/TODO.txt index bdc3e036a..982af67a5 100644 --- a/TODO.txt +++ b/TODO.txt @@ -7,6 +7,8 @@ Should-Have - Consider adding exclog to all scaffolds to print tracebacks to the console while the debug toolbar is enabled. +- Add cache_max_age=3600 to add_static_view of all scaffolds. + Nice-to-Have ------------ diff --git a/pyramid/static.py b/pyramid/static.py index 93d399f54..d342fad9e 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime from os.path import normcase, normpath, join, getmtime, getsize, isdir, exists from pkg_resources import resource_exists, resource_filename, resource_isdir import mimetypes @@ -30,18 +30,17 @@ class FileResponse(Response): """ Serves a static filelike object. """ - def __init__(self, path, expires): + def __init__(self, path, cache_max_age): super(FileResponse, self).__init__(conditional_response=True) - self.last_modified = datetime.fromtimestamp(getmtime(path), tz=UTC) - self.date = datetime.utcnow() + self.last_modified = getmtime(path) self.app_iter = open(path, 'rb') content_type = mimetypes.guess_type(path, strict=False)[0] if content_type is None: content_type = 'application/octet-stream' self.content_type = content_type self.content_length = getsize(path) - if expires is not None: - self.expires = self.date + expires + if cache_max_age is not None: + self.cache_expires = cache_max_age class static_view(object): """ An instance of this class is a callable which can act as a @@ -93,9 +92,7 @@ class static_view(object): # package_name is for bw compat; it is preferred to pass in a # package-relative path as root_dir # (e.g. ``anotherpackage:foo/static``). - if isinstance(cache_max_age, int): - cache_max_age = timedelta(seconds=cache_max_age) - self.expires = cache_max_age + self.cache_max_age = cache_max_age if package_name is None: package_name = caller_package().__name__ package_name, docroot = resolve_asset_spec(root_dir, package_name) @@ -141,7 +138,7 @@ class static_view(object): if not exists(filepath): return HTTPNotFound(request.url) - return self.FileResponse(filepath ,self.expires) + return self.FileResponse(filepath ,self.cache_max_age) def add_slash_redirect(self, request): url = request.path_url + '/' diff --git a/pyramid/tests/test_static.py b/pyramid/tests/test_static.py index 90bc91244..81f25b95d 100644 --- a/pyramid/tests/test_static.py +++ b/pyramid/tests/test_static.py @@ -28,7 +28,7 @@ class Test_static_view_use_subpath_False(unittest.TestCase): inst = self._makeOne('package:resource_name') self.assertEqual(inst.package_name, 'package') self.assertEqual(inst.docroot, 'resource_name') - self.assertEqual(inst.expires, datetime.timedelta(seconds=3600)) + self.assertEqual(inst.cache_max_age, 3600) self.assertEqual(inst.index, 'index.html') def test_call_adds_slash_path_info_empty(self): @@ -85,8 +85,8 @@ class Test_static_view_use_subpath_False(unittest.TestCase): header_names = [ x[0] for x in response.headerlist ] header_names.sort() self.assertEqual(header_names, - ['Content-Length', 'Content-Type', 'Date', 'Expires', - 'Last-Modified']) + ['Cache-Control', 'Content-Length', 'Content-Type', + 'Expires', 'Last-Modified']) def test_resource_is_file_with_no_cache_max_age(self): inst = self._makeOne('pyramid.tests:fixtures/static', @@ -95,12 +95,12 @@ class Test_static_view_use_subpath_False(unittest.TestCase): context = DummyContext() response = inst(context, request) self.assertTrue('static' in response.body) - self.assertEqual(len(response.headerlist), 4) + self.assertEqual(len(response.headerlist), 3) header_names = [ x[0] for x in response.headerlist ] header_names.sort() self.assertEqual( header_names, - ['Content-Length', 'Content-Type', 'Date', 'Last-Modified']) + ['Content-Length', 'Content-Type', 'Last-Modified']) def test_resource_notmodified(self): inst = self._makeOne('pyramid.tests:fixtures/static') @@ -148,7 +148,7 @@ class Test_static_view_use_subpath_True(unittest.TestCase): inst = self._makeOne('package:resource_name') self.assertEqual(inst.package_name, 'package') self.assertEqual(inst.docroot, 'resource_name') - self.assertEqual(inst.expires, datetime.timedelta(seconds=3600)) + self.assertEqual(inst.cache_max_age, 3600) self.assertEqual(inst.index, 'index.html') def test_call_adds_slash_path_info_empty(self): @@ -212,8 +212,8 @@ class Test_static_view_use_subpath_True(unittest.TestCase): header_names = [ x[0] for x in response.headerlist ] header_names.sort() self.assertEqual(header_names, - ['Content-Length', 'Content-Type', 'Date', 'Expires', - 'Last-Modified']) + ['Cache-Control', 'Content-Length', 'Content-Type', + 'Expires', 'Last-Modified']) def test_resource_is_file_with_no_cache_max_age(self): inst = self._makeOne('pyramid.tests:fixtures/static', @@ -223,12 +223,12 @@ class Test_static_view_use_subpath_True(unittest.TestCase): context = DummyContext() response = inst(context, request) self.assertTrue('static' in response.body) - self.assertEqual(len(response.headerlist), 4) + self.assertEqual(len(response.headerlist), 3) header_names = [ x[0] for x in response.headerlist ] header_names.sort() self.assertEqual( header_names, - ['Content-Length', 'Content-Type', 'Date', 'Last-Modified']) + ['Content-Length', 'Content-Type', 'Last-Modified']) def test_resource_notmodified(self): inst = self._makeOne('pyramid.tests:fixtures/static') -- cgit v1.2.3 From 0833f63e0b0ca54fd55685767e576e91fd5fb315 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 18:33:18 -0400 Subject: unused imports; garden --- TODO.txt | 4 +--- pyramid/static.py | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/TODO.txt b/TODO.txt index 982af67a5..47b6a41aa 100644 --- a/TODO.txt +++ b/TODO.txt @@ -87,11 +87,9 @@ Future ``wsgi.file_wrapper`` support (FileApp.get) - 'HEAD' method support (FileApp.get) - handling file permission exceptions (FileApp.get). - Features we won't supportL ETAG and if-none-match + Features we won't support: ETAG and if-none-match support (DataApp.get); replace with if-modified-since handling. - 1.3: use zope.registry rather than zope.component. diff --git a/pyramid/static.py b/pyramid/static.py index d342fad9e..3d4fb90f5 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -1,10 +1,8 @@ -from datetime import datetime from os.path import normcase, normpath, join, getmtime, getsize, isdir, exists from pkg_resources import resource_exists, resource_filename, resource_isdir import mimetypes from repoze.lru import lru_cache -from webob import UTC from pyramid.asset import resolve_asset_spec from pyramid.httpexceptions import HTTPNotFound -- cgit v1.2.3 From c0235a45697e3498d952ea1f4ffd5a9b60f2a361 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 18:34:29 -0400 Subject: garden --- TODO.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TODO.txt b/TODO.txt index 47b6a41aa..ca67e4883 100644 --- a/TODO.txt +++ b/TODO.txt @@ -85,12 +85,11 @@ Future ``paste.urlparser.StaticURLParser`` (cutnpaste or reimplement, possibly using chrisrossi's happy stuff as a base). Still need: - ``wsgi.file_wrapper`` support (FileApp.get) - handling file permission exceptions (FileApp.get). - Features we won't support: ETAG and if-none-match - support (DataApp.get); replace with if-modified-since handling. + Features we won't support: ETAG and if-none-match support (DataApp.get); + replace with if-modified-since handling, ``wsgi.file_wrapper`` support + (FileApp.get). - 1.3: use zope.registry rather than zope.component. -- cgit v1.2.3 From 77ef7adab7dedb130c7fb64badf4015c029a4ce2 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 18:36:32 -0400 Subject: unused hook point --- pyramid/static.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyramid/static.py b/pyramid/static.py index 3d4fb90f5..2a6dabe71 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -83,8 +83,6 @@ class static_view(object): absolute, configuration will not be able to override the assets it contains. """ - FileResponse = FileResponse # override point - def __init__(self, root_dir, cache_max_age=3600, package_name=None, use_subpath=False, index='index.html'): # package_name is for bw compat; it is preferred to pass in a -- cgit v1.2.3 From a829814915b7bfa25e3a70c11ed6fd96da465b4e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 18:37:30 -0400 Subject: unused hook point --- pyramid/static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/static.py b/pyramid/static.py index 2a6dabe71..5d1bfbcfa 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -134,7 +134,7 @@ class static_view(object): if not exists(filepath): return HTTPNotFound(request.url) - return self.FileResponse(filepath ,self.cache_max_age) + return FileResponse(filepath ,self.cache_max_age) def add_slash_redirect(self, request): url = request.path_url + '/' -- cgit v1.2.3 From 1455baef486d063432d326ff8fc0f37509813179 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 21:11:42 -0400 Subject: introduce a _FileIter --- TODO.txt | 11 +++++------ pyramid/static.py | 40 +++++++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/TODO.txt b/TODO.txt index ca67e4883..0a4da963d 100644 --- a/TODO.txt +++ b/TODO.txt @@ -83,13 +83,12 @@ Future - 1.3: - Eliminate non-deployment-non-scaffold-related Paste dependency: ``paste.urlparser.StaticURLParser`` (cutnpaste or reimplement, possibly - using chrisrossi's happy stuff as a base). Still need: + using chrisrossi's happy stuff as a base). - handling file permission exceptions (FileApp.get). - - Features we won't support: ETAG and if-none-match support (DataApp.get); - replace with if-modified-since handling, ``wsgi.file_wrapper`` support - (FileApp.get). + Features we no longer support: ETAG and if-none-match support + (DataApp.get); replace with if-modified-since handling, + ``wsgi.file_wrapper`` support (FileApp.get), returning 403 when handling + file permission exceptions (FileApp.get). - 1.3: use zope.registry rather than zope.component. diff --git a/pyramid/static.py b/pyramid/static.py index 5d1bfbcfa..357fe8014 100644 --- a/pyramid/static.py +++ b/pyramid/static.py @@ -24,22 +24,48 @@ def init_mimetypes(mimetypes): # has been applied on the Python 2 trunk). init_mimetypes(mimetypes) -class FileResponse(Response): +class _FileResponse(Response): """ Serves a static filelike object. """ def __init__(self, path, cache_max_age): - super(FileResponse, self).__init__(conditional_response=True) + super(_FileResponse, self).__init__(conditional_response=True) self.last_modified = getmtime(path) - self.app_iter = open(path, 'rb') content_type = mimetypes.guess_type(path, strict=False)[0] if content_type is None: content_type = 'application/octet-stream' self.content_type = content_type - self.content_length = getsize(path) + content_length = getsize(path) + self.app_iter = _FileIter(open(path, 'rb'), content_length) + # 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): + block_size = 4096 * 64 # (256K) + + def __init__(self, file, size=None): + self.file = file + self.size = size + + def __iter__(self): + return self + + def next(self): + chunk_size = self.block_size + if self.size is not None: + if chunk_size > self.size: + chunk_size = self.size + self.size -= chunk_size + data = self.file.read(chunk_size) + if not data: + raise StopIteration + return data + + 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 @@ -104,7 +130,7 @@ class static_view(object): else: path_tuple = traversal_path(request.path_info) - path = secure_path(path_tuple) + path = _secure_path(path_tuple) if path is None: # belt-and-suspenders security; this should never be true @@ -134,7 +160,7 @@ class static_view(object): if not exists(filepath): return HTTPNotFound(request.url) - return FileResponse(filepath ,self.cache_max_age) + return _FileResponse(filepath ,self.cache_max_age) def add_slash_redirect(self, request): url = request.path_url + '/' @@ -144,7 +170,7 @@ class static_view(object): return HTTPMovedPermanently(url) @lru_cache(1000) -def secure_path(path_tuple): +def _secure_path(path_tuple): if '' in path_tuple: return None for item in path_tuple: -- cgit v1.2.3 From 9bd24900619a78578685d617ebcf3d0a4dcb8f4b Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 21:14:52 -0400 Subject: legalize header --- pyramid/tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/tests/test_integration.py b/pyramid/tests/test_integration.py index e1389481c..3e2a6e452 100644 --- a/pyramid/tests/test_integration.py +++ b/pyramid/tests/test_integration.py @@ -119,7 +119,7 @@ class TestStaticAppBase(IntegrationBase): def test_range_multiple(self): res = self.testapp.get('/static/index.html', - [('HTTP_RANGE', 'bytes=10,11')], + [('HTTP_RANGE', 'bytes=10-11,11-12')], status=200) self._assertBody(res.body, os.path.join(here, 'fixtures/static/index.html')) -- cgit v1.2.3 From 315f755fd8c04e90d01c14a97afae8778cf45f05 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 6 Sep 2011 21:18:58 -0400 Subject: garden --- CHANGES.txt | 14 ++++++++++++++ TODO.txt | 9 --------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index ab02749d2..a4f3a4a43 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -10,6 +10,20 @@ Internal - Fixed test suite; on some systems tests would fail due to indeterminate test run ordering and a double-push-single-pop of a shared test variable. +- Replaced use of ``paste.urlparser.StaticURLParser`` with a derivative of Chris Rossi's "happy" static file serving code. + +Behavior Differences +-------------------- + +- An ETag header is no longer set when serving a static file. A + Last-Modified header is set instead. + +- Static file serving no longer supports the ``wsgi.file_wrapper`` extension. + +- Instead of returning a ``403 Forbidden`` error when a static file is served + that cannot be accessed by the Pyramid process' user due to file + permissions, an IOError (or similar) will be raised. + 1.2a5 (2011-09-04) ================== diff --git a/TODO.txt b/TODO.txt index 0a4da963d..4da189bce 100644 --- a/TODO.txt +++ b/TODO.txt @@ -81,15 +81,6 @@ Future - 1.3: Add a default-view-config-params decorator that can be applied to a class which names defaults for method-based view_config decorator options. -- 1.3: - Eliminate non-deployment-non-scaffold-related Paste dependency: - ``paste.urlparser.StaticURLParser`` (cutnpaste or reimplement, possibly - using chrisrossi's happy stuff as a base). - - Features we no longer support: ETAG and if-none-match support - (DataApp.get); replace with if-modified-since handling, - ``wsgi.file_wrapper`` support (FileApp.get), returning 403 when handling - file permission exceptions (FileApp.get). - - 1.3: use zope.registry rather than zope.component. - 1.4: Remove ``chameleon_text`` / ``chameleon_zpt`` deprecated functions -- cgit v1.2.3