diff options
author | cce <devnull@localhost> | 2005-12-29 18:16:59 +0000 |
---|---|---|
committer | cce <devnull@localhost> | 2005-12-29 18:16:59 +0000 |
commit | 5c466f7cef6a16ab03067dbbc61d940f26a186a5 (patch) | |
tree | e73906c72aad99fd11fa9f2361eb0f5949e7c407 | |
parent | c4a7ac89559ee520845b7a1652c85e363fb0e66a (diff) | |
download | paste-5c466f7cef6a16ab03067dbbc61d940f26a186a5.tar.gz |
- added Range.parse to httpheaders
- renamed Expires.time to Expires.parse for consistency
- updated FileApp/DataApp to return 206 on Partial Content
- all HttpHeader(environ) return strings (empty string when not found)
so that checks like 'if header-part in HttpHeader(collection)'
works without having to check for None
- updated FileApp to use Range header (instead of having its own copy)
-rw-r--r-- | paste/fileapp.py | 38 | ||||
-rw-r--r-- | paste/httpheaders.py | 116 | ||||
-rw-r--r-- | tests/test_fileapp.py | 17 | ||||
-rw-r--r-- | tests/test_httpheaders.py | 26 |
4 files changed, 127 insertions, 70 deletions
diff --git a/paste/fileapp.py b/paste/fileapp.py index 1c62ed4..77baa0c 100644 --- a/paste/fileapp.py +++ b/paste/fileapp.py @@ -8,8 +8,9 @@ if-modified-since request header. """ import os, time, mimetypes -from httpexceptions import HTTPBadRequest, HTTPForbidden -from httpheaders import get_header, Expires, \ +from httpexceptions import HTTPBadRequest, HTTPForbidden, \ + HTTPRequestRangeNotSatisfiable +from httpheaders import get_header, Expires, Range, \ ContentType, AcceptRanges, CacheControl, ContentDisposition, \ ContentLength, ContentRange, LastModified, IfModifiedSince @@ -70,7 +71,7 @@ class DataApp(object): self.set_content(content) def cache_control(self, **kwargs): - self.expires = CacheControl.apply(self.headers, **kwargs) + self.expires = CacheControl.apply(self.headers, **kwargs) or None return self def set_content(self, content): @@ -91,7 +92,7 @@ class DataApp(object): Expires.update(headers, delta=self.expires) try: - client_clock = IfModifiedSince.time(environ) + client_clock = IfModifiedSince.parse(environ) if client_clock >= int(self.last_modified): # the client has a recent copy #@@: all entity headers should be removed, not just these @@ -103,19 +104,13 @@ class DataApp(object): return exce.wsgi_application(environ, start_response) (lower,upper) = (0, self.content_length - 1) - if 'HTTP_RANGE' in environ: - range = environ['HTTP_RANGE'].split(",")[0] - range = range.strip().lower().replace(" ","") - if not range.startswith("bytes=") or 1 != range.count("-"): - return HTTPBadRequest(( - "A malformed range request was given.\r\n" - " Range: %s\r\n") % range - ).wsgi_application(environ, start_response) - (lower,upper) = range[len("bytes="):].split("-") - upper = upper and int(upper) or (self.content_length - 1) - lower = lower and int(lower) or 0 - if upper >= self.content_length or lower >= self.content_length: - return HTTPBadRequest(( + range = Range.parse(environ) + print range + if range and 'bytes' == range[0] and 1 == len(range[1]): + (lower,upper) = range[1][0] + upper = upper or (self.content_length - 1) + if upper >= self.content_length or lower > upper: + return HTTPRequestRangeNotSatisfiable(( "Range request was made beyond the end of the content,\r\n" "which is %s long.\r\n Range: %s\r\n") % ( self.content_length, range) @@ -126,8 +121,10 @@ class DataApp(object): ContentRange.update(headers, # @@: make parameterized version "%d-%d/%d" % (lower, upper, self.content_length)) - - start_response('200 OK',headers) + if lower == 0 and upper == self.content_length - 1: + start_response('200 OK', headers) + else: + start_response('206 Partial Content', headers) if self.content is not None: return [self.content[lower:upper+1]] assert self.__class__ != DataApp, "DataApp must call set_content" @@ -163,7 +160,8 @@ class FileApp(DataApp): self.last_modified = stat.st_mtime def __call__(self, environ, start_response): - if 'max-age=0' in environ.get("HTTP_CACHE_CONTROL",''): + print CacheControl(environ), "\n" + if 'max-age=0' in CacheControl(environ): self.update(force=True) # RFC 2616 13.2.6 else: self.update() diff --git a/paste/httpheaders.py b/paste/httpheaders.py index 3623704..c65f83a 100644 --- a/paste/httpheaders.py +++ b/paste/httpheaders.py @@ -230,7 +230,8 @@ class HTTPHeader(object): 'response': 3, 'entity': 4 }[self.category] self.extensions = {} _headers[self.name.lower()] = self - self._environ_name = 'HTTP_'+ self.name.upper().replace("-","_") + self._environ_name = getattr(self, '_environ_name', + 'HTTP_'+ self.name.upper().replace("-","_")) self._headers_name = self.name.lower() assert self.version in ('1.1','1.0','0.9') assert isinstance(self,(SingleValueHeader,MultiValueHeader, @@ -263,7 +264,7 @@ class HTTPHeader(object): def format(self, *values): """ produce a return value appropriate for this kind of header """ if not values: - return None + return '' raise NotImplementedError() def __call__(self, *args, **kwargs): @@ -305,8 +306,8 @@ class HTTPHeader(object): if dict == type(args[0]): assert 1 == len(args) and 'wsgi.version' in args[0] value = args[0].get(self._environ_name) - if value is None: - return None + if not value: + return '' return self.format(value) for item in args: assert not type(item) in (dict, list) @@ -340,7 +341,7 @@ class HTTPHeader(object): could be a list for ``MultiEntryHeader`` instances). """ value = self.__call__(*args, **kwargs) - if value is None: + if not value: self.remove(connection) return if type(collection) == dict: @@ -385,7 +386,7 @@ class SingleValueHeader(HTTPHeader): def format(self, *values): if not values: - return None + return '' assert len(values) == 1, "more than one value: %s" % repr(values) return str(values[0]).strip() @@ -397,7 +398,7 @@ class MultiValueHeader(HTTPHeader): def format(self, *values): if not values: - return None + return [] return ", ".join([str(v).strip() for v in values]) class MultiEntryHeader(HTTPHeader): @@ -417,7 +418,7 @@ class MultiEntryHeader(HTTPHeader): def format(self, *values): if not values: - return None + return '' return list([str(v).strip() for v in values]) def tuples(self, *args, **kwargs): @@ -499,17 +500,16 @@ class DateHeader(SingleValueHeader): time += delta return (formatdate(time),) - def time(self, *args, **kwargs): + def parse(self, *args, **kwargs): """ return the time value (in seconds since 1970) """ value = self.__call__(*args, **kwargs) - if value is None: - return None - try: - return mktime_tz(parsedate_tz(value)) - except TypeError: - raise HTTPBadRequest(( - "Received an ill-formed timestamp for %s: %s\r\n") % - (self.name, value)) + if value: + try: + return mktime_tz(parsedate_tz(value)) + except TypeError: + raise HTTPBadRequest(( + "Received an ill-formed timestamp for %s: %s\r\n") % + (self.name, value)) # # Following are specific HTTP headers. Since these classes are mostly @@ -629,28 +629,14 @@ class CacheControl(MultiValueHeader): CacheControl = CacheControl('Cache-Control','general') -class SingleValueCGIHeader(SingleValueHeader): - """ - This is a base class for Content-Type and Content-Length headers, - which besides their HTTP_ entries may also have a CGI version. - The logic is to only use the CGI version when the HTTP_ version is - missing. Hopefully this can be removed. - """ - def __call__(self, *args, **kwargs): - if args and dict == type(args[0]): - if not args[0].get(self._environ_name): - cgi_name = self._environ_name[5:] - return self.format(args[0].get(cgi_name)) - return SingleValueHeader.__call__(self, *args, **kwargs) - -class ContentType(SingleValueCGIHeader): +class ContentType(SingleValueHeader): """ Content-Type, RFC 2616 section 14.17 - If the 'Content-Type' does not appear in the ``environ`` the - corresponding CGI variable is searched. + Unlike other headers, use the CGI variable instead. """ version = '1.0' + _environ_name = 'CONTENT_TYPE' # common mimetype constants UNKNOWN = 'application/octet-stream' @@ -673,11 +659,15 @@ class ContentType(SingleValueCGIHeader): return (result,) ContentType = ContentType('Content-Type','entity') -class ContentLength(SingleValueCGIHeader): +class ContentLength(SingleValueHeader): """ Content-Length, RFC 2616 section 14.13 + + Unlike other headers, use the CGI variable instead. """ version = "1.0" + _environ_name = 'CONTENT_LENGTH' + ContentLength = ContentLength('Content-Length','entity') class ContentDisposition(SingleValueHeader): @@ -749,8 +739,8 @@ class IfModifiedSince(DateHeader): If-Modified-Since, RFC 2616 section 14.25 """ version = '1.0' - def time(self, *args, **kwargs): - value = DateHeader.time(self, *args, **kwargs) + def parse(self, *args, **kwargs): + value = DateHeader.parse(self, *args, **kwargs) if value and value > now(): raise HTTPBadRequest(( "Please check your system clock.\r\n" @@ -759,6 +749,56 @@ class IfModifiedSince(DateHeader): return value IfModifiedSince = IfModifiedSince('If-Modified-Since','request') +class Range(MultiValueHeader): + """ + Range, RFC 2616 section 14.35 + + According to section 14.16, the response to this message should be a + 206 Partial Content and that if multiple non-overlapping byte ranges + are requested (it is an error to request multiple overlapping + ranges) the result should be sent as multipart/byteranges mimetype. + + The server should respond with '416 Requested Range Not Satisifiable' + if the requested ranges are out-of-bounds. The specification also + indicates that a syntax error in the Range request should result in + the header being ignored rather than a '400 Bad Request'. + """ + version = '1.1' + def parse(self, *args, **kwargs): + """ + Returns a tuple (units, list), where list is a sequence of + (begin, end) tuples; and end is None if it was not provided. + """ + value = self.__call__(*args, **kwargs) + if not value: + return None + ranges = [] + last_end = -1 + try: + (units, range) = value.split("=") + units = units.strip().lower() + for item in range.split(","): + (begin, end) = item.split("-") + if not begin.strip(): + begin = 0 + else: + begin = int(begin) + if begin <= last_end: + raise ValueError() + if not end.strip(): + end = None + else: + end = int(end) + last_end = end + ranges.append((begin,end)) + except ValueError: + # In this case where the Range header is malformed, + # section 14.16 says to treat the request as if the + # Range header was not present. How do I log this? + return None + return (units, ranges) +Range = Range('Range','request') + # # For now, construct a minimalistic version of the field-names; at a # later date more complicated headers may sprout content constructors. @@ -800,7 +840,7 @@ for (name, category, version, style, comment) in \ ,("Pragma" ,'general' ,'1.0','multi-value','RFC 2616 $14.32') ,("Proxy-Authenticate" ,'response','1.1','multi-value','RFC 2616 $14.33') ,("Proxy-Authorization",'request' ,'1.1','singular' ,'RFC 2616 $14.34') -,("Range" ,'request' ,'1.1','multi-value','RFC 2616 $14.35') +#,("Range" ,'request' ,'1.1','multi-value','RFC 2616 $14.35') ,("Referer" ,'request' ,'1.0','singular' ,'RFC 2616 $14.36') ,("Retry-After" ,'response','1.1','singular' ,'RFC 2616 $14.37') ,("Server" ,'response','1.0','singular' ,'RFC 2616 $14.38') diff --git a/tests/test_fileapp.py b/tests/test_fileapp.py index 90b0e5c..c02da52 100644 --- a/tests/test_fileapp.py +++ b/tests/test_fileapp.py @@ -120,6 +120,7 @@ def test_file(): os.unlink(tempfile) def _excercize_range(build,content): + # full content request, but using ranges' res = build("bytes=0-%d" % (len(content)-1)) assert res.header('accept-ranges') == 'bytes' assert res.body == content @@ -130,21 +131,22 @@ def _excercize_range(build,content): res = build("bytes=0-") assert res.body == content assert res.header('content-length') == str(len(content)) - res = build("bytes=0-9") + # partial content requests + res = build("bytes=0-9", status=206) assert res.body == content[:10] assert res.header('content-length') == '10' - res = build("bytes=%d-" % (len(content)-1)) + res = build("bytes=%d-" % (len(content)-1), status=206) assert res.body == 'Z' assert res.header('content-length') == '1' - res = build("bytes=%d-%d" % (3,17)) + res = build("bytes=%d-%d" % (3,17), status=206) assert res.body == content[3:18] assert res.header('content-length') == '15' def test_range(): content = string.letters * 5 - def build(range): + def build(range, status=200): app = DataApp(content) - return TestApp(app).get("/",headers={'Range': range}) + return TestApp(app).get("/",headers={'Range': range}, status=status) _excercize_range(build,content) def test_file_range(): @@ -157,9 +159,10 @@ def test_file_range(): file.write(content) file.close() try: - def build(range): + def build(range, status=200): app = fileapp.FileApp(tempfile) - return TestApp(app).get("/",headers={'Range': range}) + return TestApp(app).get("/",headers={'Range': range}, + status=status) _excercize_range(build,content) for size in (13,len(string.letters),len(string.letters)-1): fileapp.BLOCK_SIZE = size diff --git a/tests/test_httpheaders.py b/tests/test_httpheaders.py index c8d25ba..495553d 100644 --- a/tests/test_httpheaders.py +++ b/tests/test_httpheaders.py @@ -26,14 +26,16 @@ def test_environ(): } def test_environ_cgi(): - environ = {'CONTENT_TYPE': 'server/supplied', 'wsgi.version': '1.0', - 'HTTP_CONTENT_TYPE': 'text/plain', 'CONTENT_LENGTH': '200'} + environ = {'CONTENT_TYPE': 'text/plain', 'wsgi.version': '1.0', + 'HTTP_CONTENT_TYPE': 'ignored/invalid', + 'CONTENT_LENGTH': '200'} assert 'text/plain' == ContentType(environ) assert '200' == ContentLength(environ) ContentType.update(environ,'new/type') assert 'new/type' == ContentType(environ) ContentType.delete(environ) - assert 'server/supplied' == ContentType(environ) + assert '' == ContentType(environ) + assert 'ignored/invalid' == environ['HTTP_CONTENT_TYPE'] def test_response_headers(): collection = [('via', 'bing')] @@ -61,8 +63,8 @@ def test_cache_control(): headers = [] CacheControl.apply(headers,max_age=60) assert 'public, max-age=60' == CacheControl(headers) - assert Expires.time(headers) > time.time() - assert Expires.time(headers) < time.time() + 60 + assert Expires.parse(headers) > time.time() + assert Expires.parse(headers) < time.time() + 60 def test_content_disposition(): assert 'attachment' == ContentDisposition() @@ -87,6 +89,20 @@ def test_content_disposition(): ('Content-Disposition', 'attachment; filename="test.txt"') ] +def test_range(): + assert ('bytes',[(0,300)]) == Range.parse("bytes=0-300") + assert ('bytes',[(0,300)]) == Range.parse("bytes = -300") + assert ('bytes',[(0,None)]) == Range.parse("bytes= -") + assert ('bytes',[(0,None)]) == Range.parse("bytes=0 - ") + assert ('bytes',[(300,None)]) == Range.parse(" BYTES=300-") + assert ('bytes',[(4,5),(6,7)]) == Range.parse(" Bytes = 4 - 5,6 - 07 ") + assert ('bytes',[(0,5),(7,None)]) == Range.parse(" bytes=-5,7-") + assert ('bytes',[(0,5),(7,None)]) == Range.parse(" bytes=-5,7-") + assert ('bytes',[(0,5),(7,None)]) == Range.parse(" bytes=-5,7-") + assert None == Range.parse("") + assert None == Range.parse("bytes=0,300") + assert None == Range.parse("bytes=-7,5-") + def test_copy(): environ = {'HTTP_VIA':'bing', 'wsgi.version': '1.0' } response_headers = [] |