Ticket #1493: twisted.web.static.4.diff

File twisted.web.static.4.diff, 24.4 KB (added by brubelsabs, 13 years ago)

improvements for the suggestions from exarkun

  • test/test_web.py

     
    1212from zope.interface import implements
    1313
    1414class DummyRequest:
     15    """
     16    Represents a dummy or fake request.
     17
     18    @type headers: C{dict}
     19    @ivar headers: Holds all request header fields in
     20    I{'field-name':'field-value'} manner
     21    @type outgoingHeaders: C{dict}
     22    @ivar outgoingHeaders: Holds all response header fields in
     23    I{'field-name':'field-value'} manner
     24    @type responseCode: C{http.STATUS_CODE}
     25    @ivar responseCode: Carry the HTTP status code which will be respond the
     26    request
     27    @type written: C{string}
     28    @ivar written: Serves as a container where the request is rendered into
     29    """
     30
    1531    uri='http://dummy/'
    1632    method = 'GET'
    1733
    18     def getHeader(self, h):
    19         return None
    2034
     35    def getHeader(self, name):
     36        """
     37        Returns the header I{field value} if present, otherwise an
     38        AttributeError will be raised.
     39
     40        The seperating colon is not returned. The field name is mapped to
     41        lower case before the look up.
     42
     43        @return: The I{field value} from a HTTP header field.
     44        @raise AttributeError: if there is no such header field with name
     45        C{name}.
     46        """
     47        return self.headers.get(name.lower(), None)
     48
     49
    2150    def registerProducer(self, prod,s):
    2251        self.go = 1
    2352        while self.go:
     
    2655    def unregisterProducer(self):
    2756        self.go = 0
    2857
     58
    2959    def __init__(self, postpath, session=None):
    3060        self.sitepath = []
    3161        self.written = []
     
    3666        self.protoSession = session or server.Session(0, self)
    3767        self.args = {}
    3868        self.outgoingHeaders = {}
     69        self.headers = {}
     70        self.responseCode = None
    3971
     72
    4073    def setHeader(self, name, value):
    4174        """TODO: make this assert on write() if the header is content-length
    4275        """
     
    5487        self.finished = self.finished + 1
    5588    def addArg(self, name, value):
    5689        self.args[name] = [value]
     90
     91
    5792    def setResponseCode(self, code):
     93        """
     94        Set the HTTP status response code, but takes care that this is written
     95        before any data is written.
     96        """
    5897        assert not self.written, "Response code cannot be set after data has been written: %s." % "@@@@".join(self.written)
     98        self.responseCode = code
     99
     100
    59101    def setLastModified(self, when):
    60102        assert not self.written, "Last-Modified cannot be set after data has been written: %s." % "@@@@".join(self.written)
    61103    def setETag(self, tag):
     
    65107    def testListEntities(self):
    66108        r = resource.Resource()
    67109        self.failUnlessEqual([], r.listEntities())
    68        
    69110
     111
    70112class SimpleResource(resource.Resource):
    71113    def render(self, request):
    72114        if http.CACHED in (request.setLastModified(10),
     
    153195
    154196        self.failIf(self.clock.calls)
    155197        self.failIf(loop.running)
    156        
    157198
    158199
     200
    159201# Conditional requests:
    160202# If-None-Match, If-Modified-Since
    161203
     
    203245        for l in ["GET / HTTP/1.1",
    204246                  "Accept: text/html"]:
    205247            self.channel.lineReceived(l)
    206    
     248
    207249    def tearDown(self):
    208250        self.channel.connectionLost(None)
    209251
     
    535577    clientproto = 'HTTP/1.0'
    536578    sentLength = None
    537579
    538     def __init__(self, *a, **kw):
    539         DummyRequest.__init__(self, *a, **kw)
    540         self.headers = {}
    541 
    542     def getHeader(self, h):
    543         return self.headers.get(h.lower(), None)
    544 
    545580    def getClientIP(self):
    546581        return self.client
    547582
  • test/test_static.py

     
    1 from twisted.trial import unittest
    21import os
    3 from twisted.web import static
    42
    5 class FakeRequest:
    6     method = 'GET'
     3from twisted.python import log
     4from twisted.trial import unittest
     5from twisted.web import static, http, error
     6from twisted.web.test.test_web import DummyRequest
    77
    8     _headers = None
    9     _setHeaders = None
    10     _written = ''
    118
    12     def __init__(self):
    13         self._headers = {}
    14         self._setHeaders = {}
    159
    16     def getHeader(self, k):
    17         if self._headers is None:
    18             return None
    19         return self._headers.get(k)
     10class RangeTests(unittest.TestCase):
     11    """
     12    Accommodates several unit tests for the Range-Header implementation.
    2013
    21     def setHeader(self, k, v):
    22         self._setHeaders.setdefault(k, []).append(v)
     14    Each test a temp file with a fixed payload of 64 bytes is created. Then a
     15    C{resource} and a C{DummyRequest} are build up using that file as target.
     16    The request for example may be modified in each test method, so the
     17    resource may be tested in various ways.
     18    @type file: File
     19    @ivar file: Binary temp file (64 Bytes) which is served.
     20    @type resource: static.File
     21    @ivar resource: A leaf web resource using C{self.file} as content.
     22    @type request: DummyRequest
     23    @ivar request: A fake request, requesting C{self.resource}.
     24    @type catcher: List
     25    @ivar catcher: List which gathers all log information.
     26    """
     27    def setUp(self):
     28        self.file = file(self.mktemp(), 'w')
     29        self.payload = ('\xf8u\xf3E\x8c7\xce\x00\x9e\xb6a0y0S\xf0\xef\xac\xb7'
     30                        '\xbe\xb5\x17M\x1e\x136k{\x1e\xbe\x0c\x07\x07\t\xd0'
     31                        '\xbckY\xf5I\x0b\xb8\x88oZ\x1d\x85b\x1a\xcdk\xf2\x1d'
     32                        '&\xfd%\xdd\x82q/A\x10Y\x8b')
     33        self.file.write(self.payload)
     34        self.file.flush()
     35        self.resource = static.File(self.file.name)
     36        self.resource.isLeaf = 1
     37        self.request = DummyRequest([''])
     38        self.request.uri = self.file.name
     39        self.file.seek(0)
     40        self.catcher = []
     41        log.addObserver(self.catcher.append)
    2342
    24     def setLastModified(self, x):
    25         pass
    26     def registerProducer(self, producer, x):
    27         producer.resumeProducing()
    28     def unregisterProducer(self):
    29         pass
    30     def finish(self):
    31         pass
    3243
    33     def write(self, data):
    34         self._written = self._written + data
     44    def tearDown(self):
     45        self.file.close()
     46        log.removeObserver(self.catcher.append)
    3547
    36 class Range(unittest.TestCase):
    37     todo = (unittest.FailTest, 'No range support yet.')
    3848
     49    def _assertLogged(self, expected):
     50        """
     51        Asserts that a given log occured with an expected message.
     52        """
     53
     54        log_item = self.catcher.pop()
     55        self.assertEquals(log_item["message"][0], expected)
     56        self.assert_(len(self.catcher) == 0, "An additional log occured: " +
     57                     repr(log_item))
     58
     59
     60    def test_bodyLength(self):
     61        """
     62        A correct response to a range request is as long as the length of
     63        the requested range.
     64
     65        If multiple ranges requests requested in one request, only the first
     66        one is evaluated and answered. This is correct behaviour to RFC 2616.
     67        """
     68        self.request.headers['range'] = 'bytes=0-43'
     69        self.resource.render(self.request)
     70        self.assertEquals(len(''.join(self.request.written)), 44)
     71
     72
     73    def test_bytesUnit(self):
     74        """
     75        A correct range request starts with an Bytes-Unit followed by a '='
     76        character and then followed by a specific range starting with an
     77        identifier as Bytes-Unit. In RFC 2616 only 'bytes' is defined as
     78        Bytes-Unit.
     79        """
     80        self.request.headers['range'] = 'foobar=0-43'
     81        self.resource.render(self.request)
     82        expected = ("Warning: ignoring malformed Range-Header due to:\n\t400 "
     83                    "Unsupported Bytes-Unit: 'foobar'\n\tProceed without this"
     84                    " header field")
     85        self._assertLogged(expected)
     86        self.assertEquals(''.join(self.request.written), self.payload)
     87        self.assertEquals(self.request.responseCode, http.OK)
     88
     89    def test_bodyContent(self):
     90        """
     91        A correct response to a range header request: bytes=A-B starts with
     92        A'th byte and ends with (including) the B'th byte. The first byte of
     93        a page is numbered with 0.
     94        """
     95        self.request.headers['range'] = 'bytes=3-43'
     96        self.resource.render(self.request)
     97        self.assertEquals(''.join(self.request.written), self.payload[3:44])
     98
     99
     100    def test_contentLength(self):
     101        """
     102        The Content-Length reponse header field should match the body length
     103        which in turn should match the length of the requested range.
     104        """
     105
     106        self.request.headers['range'] = 'bytes=0-43'
     107        self.resource.render(self.request)
     108        self.assertEquals(self.request.outgoingHeaders['content-length'], '44')
     109
     110
     111    def test_contentRange(self):
     112        """
     113        The response header field Content-Range of a range header request is
     114        correct if the range delivered is repeated and followed by a slash with
     115        the overall size of the document.
     116        """
     117
     118        self.request.headers['range'] = 'bytes=0-43'
     119        self.resource.render(self.request)
     120        self.assertEquals(self.request.outgoingHeaders['content-range'],
     121                          'bytes 0-43/64')
     122
     123
     124    def test_statusCodePartialContent(self):
     125        """
     126        If a range header request could be processed without any errors, the
     127        status code: 206 has to be submitted instead of 200.
     128        """
     129
     130        self.request.headers['range'] = 'bytes=0-43'
     131        self.resource.render(self.request)
     132        self.assertEquals(self.request.responseCode, http.PARTIAL_CONTENT)
     133
     134
     135    def test_statusCodeRequestedRangeNotSatisfiable(self):
     136        """
     137        AFAIK: Status code 416 is only submitted if the range is not
     138        satisfiable due to an invalid range. A range is invalid if the start
     139        byte is smaller than the end byte or the start byte is greater than
     140        the length of.
     141        """
     142        self.request.headers['range'] = 'bytes=20-13'
     143        self.resource.render(self.request)
     144        self.assertEquals(self.request.responseCode,
     145                          http.REQUESTED_RANGE_NOT_SATISFIABLE)
     146
     147
     148    def test_invalidStartBytePos(self):
     149        """
     150        If a range header requests has a "first-byte-pos value greater than
     151        the current length of the selected resource" (as the RFC 2616 says)
     152        we should return a 416 status code.
     153        """
     154
     155        self.request.headers['range'] = 'bytes=67-108'
     156        self.resource.render(self.request)
     157        self.assertEquals(self.request.responseCode,
     158                          http.REQUESTED_RANGE_NOT_SATISFIABLE)
     159        self.assertEquals(len(''.join(self.request.written)), 0)
     160
     161
     162    def test_firstByteSupport(self):
     163        """
     164        If the last-byte-pos is omitted, then it is assumed to be just
     165        the last byte of the ressource.
     166        """
     167
     168        self.request.headers['range'] = 'bytes=23-'
     169        self.resource.render(self.request)
     170        self.assertEquals(''.join(self.request.written), self.payload[23:])
     171        self.assertEquals(len(''.join(self.request.written)), 41)
     172        self.assertEquals(self.request.outgoingHeaders['content-range'],
     173                          'bytes 23-63/64')
     174        self.assertEquals(self.request.outgoingHeaders['content-length'], '41')
     175
     176
     177    def test_lastByteSupport(self):
     178        """
     179        If the first-byte-pos is omitted, then it is assumed to be just
     180        the pos  size - last-byte-pos. That is, the last-byte-pos is the
     181        length of the delivered byte range: the last-byte-pos'th bytes of
     182        the ressource.
     183        """
     184
     185        self.request.headers['range'] = 'bytes=-17'
     186        self.resource.render(self.request)
     187        self.assertEquals(''.join(self.request.written), self.payload[-17:])
     188        self.assertEquals(len(''.join(self.request.written)), 17)
     189        self.assertEquals(self.request.outgoingHeaders['content-range'],
     190                          'bytes 47-63/64')
     191        self.assertEquals(self.request.outgoingHeaders['content-length'], '17')
     192
     193
     194    def test_invalidByteRangeNoStartNoEnd(self):
     195        """
     196        A byte range containing no first-byte-pos nor last-byte-pos is
     197        syntactically invalid and must be ignored.
     198        """
     199
     200        self.request.headers['range'] = 'bytes=-'
     201        self.resource.render(self.request)
     202        self.assertEquals(self.request.responseCode, http.OK)
     203        self.assertEquals(len(''.join(self.request.written)), 64)
     204        self.assertEquals(''.join(self.request.written), self.payload)
     205        expected = ("Warning: ignoring malformed Range-Header due to:\n\t400 "
     206                    "Invalid Byte-Range: '-'\n\tProceed without this header "
     207                    "field")
     208        self._assertLogged(expected)
     209
     210
     211    def test_multipleRanges(self):
     212        """
     213        If multiple ranges are given, only the first byte range is delivered.
     214        """
     215
     216        self.request.headers['range'] = 'bytes=23-43,17-28'
     217        self.resource.render(self.request)
     218        self.assertEquals(self.request.responseCode, http.PARTIAL_CONTENT)
     219        self.assertEquals(len(''.join(self.request.written)), 21)
     220        self.assertEquals(''.join(self.request.written), self.payload[23:44])
     221
     222
     223    def test_invalidByteRangeContainingNaNs(self):
     224        """
     225        If a byte range is syntactically invalid it should be ignored.
     226        """
     227
     228        self.request.headers['range'] = 'bytes=-abc#'
     229        self.resource.render(self.request)
     230        self.assertEquals(self.request.responseCode, http.OK)
     231        self.assertEquals(len(''.join(self.request.written)), 64)
     232        self.assertEquals(''.join(self.request.written), self.payload)
     233        expected = ("Warning: ignoring malformed Range-Header due to:\n\t400 "
     234                    "Invalid Byte-Range: '-abc#'\n\tProceed without this "
     235                    "header field")
     236        self._assertLogged(expected)
     237
     238
     239
     240class FileTests(unittest.TestCase):
     241    """
     242    Accommodates several test for the File class.
     243    """
     244
     245
    39246    def setUp(self):
    40         self.tmpdir = self.mktemp()
    41         os.mkdir(self.tmpdir)
    42         name = os.path.join(self.tmpdir, 'junk')
    43         f = file(name, 'w')
    44         f.write(8000 * 'x')
    45         f.close()
    46         self.file = static.File(name)
    47         self.request = FakeRequest()
     247        self.file = file(self.mktemp(), 'w')
     248        self.file.write("payload")
     249        self.file.flush()
     250        self.resource = static.File(self.file.name)
     251        self.resource.isLeaf = 1
     252        self.request = DummyRequest([''])
     253        self.request.uri = self.file.name
     254        self.file.seek(0)
     255        self.catcher = []
     256        log.addObserver(self.catcher.append)
    48257
    49     def testBodyLength(self):
    50         self.request._headers['range'] = 'bytes=0-1999'
    51         self.file.render(self.request)
    52         self.assertEquals(len(self.request._written), 2000)
    53258
    54     def testContentLength(self):
    55         """Content-Length of a request is correct."""
    56         self.request._headers['range'] = 'bytes=0-1999'
    57         self.file.render(self.request)
    58         self.assertEquals(self.request._setHeaders['content-length'], ['2000'])
     259    def tearDown(self):
     260        self.file.close()
     261        log.removeObserver(self.catcher.append)
    59262
    60     def testContentRange(self):
    61         """Content-Range of a request is correct."""
    62         self.request._headers['range'] = 'bytes=0-1999'
    63         self.file.render(self.request)
    64         self.assertEquals(self.request._setHeaders.get('content-range'), ['bytes 0-1999/8000'])
     263
     264    def test_malformedRangeHeadersInvalidByteUnit(self):
     265        """
     266        A correct range header field could have just 'bytes' as Bytes-Unit,
     267        nothing else.
     268        """
     269        self.request.headers['range'] = 'unkown=foo-bar'
     270        self.assertRaises(error.MalformedHeader,
     271                          self.resource._doRangeRequest,
     272                          self.request,
     273                          self.file)
     274
     275
     276    def test_malformedRangeHeadersInvalidStartByte(self):
     277        """
     278        A Range-Specifier starts either with a number, or with a '-'.
     279        """
     280        self.request.headers['range'] = 'bytes=foo-23'
     281        self.assertRaises(error.MalformedHeader,
     282                          self.resource._doRangeRequest,
     283                          self.request,
     284                          self.file)
     285
     286    def test_malformedRangeHeadersInvalidLastByte(self):
     287        """
     288        A Range-Specifier ends with a number or with an end of line.
     289        """
     290        self.request.headers['range'] = 'bytes=23-bar'
     291        self.assertRaises(error.MalformedHeader,
     292                          self.resource._doRangeRequest,
     293                          self.request,
     294                          self.file)
     295
     296
     297    def test_malformedRangeHeadersMissingEqualChar(self):
     298        """
     299        The Bytes-Unit and the Byte-Range-Set is always seperated by an
     300        equal character: '=', nothing else.
     301        """
     302        self.request.headers['range'] = 'bytes:23-234'
     303        self.assertRaises(error.MalformedHeader,
     304                          self.resource._doRangeRequest,
     305                          self.request,
     306                          self.file)
  • error.py

     
    1212
    1313class Error(Exception):
    1414    def __init__(self, code, message = None, response = None):
     15        """
     16        Initializes a basic exception.
     17
     18        @param code: Refers to an HTTP status code for example http.NOT_FOUND.
     19        If no message is given the given code is mapped to a descriptive
     20        string and used instead.
     21        """
     22
    1523        message = message or http.responses.get(code)
    1624        Exception.__init__(self, code, message, response)
    1725        self.status = code
    1826        self.response = response
    19    
     27
     28
    2029    def __str__(self):
    2130        return '%s %s' % (self[0], self[1])
    2231
     32
     33
     34class MalformedHeader(Error):
     35    """
     36    Indicates a malformed HTTP header, e.g. when a request header has invalid
     37    syntax in e.g. the range-header field.
     38   
     39    If no HTTP status code is given then C{http.BAD_REQUEST} is used instead.
     40    """
     41
     42    def __init__(self, code = None, message = None, response = None):
     43        """
     44        Creates a malformed header error with http.BAD_REQUEST as standard
     45        status code.
     46        """
     47        if (not code):
     48            code = http.BAD_REQUEST
     49        Error.__init__(self, code, message, response)
     50
     51
     52
    2353class PageRedirect(Error):
    2454    """A request that resulted in a http redirect """
    2555    def __init__(self, code, message = None, response = None, location = None):
  • static.py

     
    150150    type = types.get(ext, defaultType)
    151151    return type, enc
    152152
     153
     154
    153155class File(resource.Resource, styles.Versioned, filepath.FilePath):
    154156    """
    155157    File is a resource that represents a plain non-interpreted file
     
    287289        return self.getsize()
    288290
    289291
     292    def _doRangeRequest(self, request, file):
     293        """
     294        Performs (simple) Range-Header requests. Simple means, that only
     295        the first byte range is handled.
     296
     297        @param file: file handle for the corresponding ressource
     298        @type file: file object
     299        @raise MalformedHeader: If the given Byte-Ranges-Specifier was invalid
     300        @return: content-length and the number of the byte to where to stop.
     301        @rtype: C{int}, C{int}
     302        """
     303
     304        size = self.getFileSize()
     305        range = request.getHeader('range')
     306        if not ('=' in range):
     307            raise error.MalformedHeader(message="Missing '=' seperator in " +
     308                                                "range field: " + repr(range))
     309        bytesrange = string.split(range, '=')
     310        if not (bytesrange[0] == 'bytes'):
     311            raise error.MalformedHeader(message="Unsupported Bytes-Unit: " +
     312                                        repr(bytesrange[0]))
     313        first_byte_pos, last_byte_pos = bytesrange[1].split(',')[0].split('-')
     314        if (not first_byte_pos ) and (not last_byte_pos):
     315            raise error.MalformedHeader(message="Invalid Byte-Range: " +
     316                                        repr(bytesrange[1]))
     317        try:
     318            if first_byte_pos:
     319                start = int(first_byte_pos)
     320            if last_byte_pos:
     321                stop = int(last_byte_pos)
     322        except ValueError, e:
     323            raise error.MalformedHeader(message="Invalid Byte-Range: " +
     324                                      repr(range.split('=')[1].split(',')[0]))
     325        if first_byte_pos:
     326            if last_byte_pos and (stop < size):
     327                stop = stop + 1
     328            else:
     329                stop = size
     330        else:
     331            lastbytes = stop
     332            if size < lastbytes:
     333                lastbytes = size
     334            start = size - lastbytes
     335            stop = size
     336        if start <= size:
     337            file.seek(start)
     338        content_length = stop - start
     339        if content_length <= 0:
     340            request.setResponseCode(http.REQUESTED_RANGE_NOT_SATISFIABLE)
     341            content_length = size
     342            request.method = 'HEAD' # no msg body will be transferred
     343        else:
     344            request.setResponseCode(http.PARTIAL_CONTENT)
     345            request.setHeader('content-range',
     346                              "bytes %s-%s/%s" % (str(start),
     347                                                  str(stop-1),
     348                                                  str(size)))
     349        return content_length, stop
     350
     351
    290352    def render(self, request):
    291353        """You know what you doing."""
    292354        self.restat()
     
    303365        if self.isdir():
    304366            return self.redirect(request)
    305367
    306         #for content-length
    307         fsize = size = self.getFileSize()
     368        request.setHeader('accept-ranges','bytes')
    308369
    309 #         request.setHeader('accept-ranges','bytes')
    310 
    311370        if self.type:
    312371            request.setHeader('content-type', self.type)
    313372        if self.encoding:
     
    325384        if request.setLastModified(self.getmtime()) is http.CACHED:
    326385            return ''
    327386
    328 # Commented out because it's totally broken. --jknight 11/29/04
    329 #         try:
    330 #             range = request.getHeader('range')
    331 #
    332 #             if range is not None:
    333 #                 # This is a request for partial data...
    334 #                 bytesrange = string.split(range, '=')
    335 #                 assert bytesrange[0] == 'bytes',\
    336 #                        "Syntactically invalid http range header!"
    337 #                 start, end = string.split(bytesrange[1],'-')
    338 #                 if start:
    339 #                     f.seek(int(start))
    340 #                 if end:
    341 #                     end = int(end)
    342 #                     size = end
    343 #                 else:
    344 #                     end = size
    345 #                 request.setResponseCode(http.PARTIAL_CONTENT)
    346 #                 request.setHeader('content-range',"bytes %s-%s/%s " % (
    347 #                     str(start), str(end), str(size)))
    348 #                 #content-length should be the actual size of the stuff we're
    349 #                 #sending, not the full size of the on-server entity.
    350 #                 fsize = end - int(start)
    351 #
    352 #             request.setHeader('content-length', str(fsize))
    353 #         except:
    354 #             traceback.print_exc(file=log.logfile)
     387        # set the stop byte, and content-length
     388        content_length = stop = self.getFileSize()
    355389
    356         request.setHeader('content-length', str(fsize))
     390        if request.getHeader('range') is not None:
     391            try:
     392                content_length, stop = self._doRangeRequest(request, f)
     393            except error.MalformedHeader, e:
     394                log.msg("Warning: ignoring malformed Range-Header due to:\n" +
     395                        "\t" + str(e) + "\n\tProceed without this header field")
     396                request.setResponseCode(http.OK)
     397                content_length = stop = self.getFileSize()
     398
     399        request.setHeader('content-length', str(content_length))
    357400        if request.method == 'HEAD':
    358401            return ''
    359402
    360403        # return data
    361         FileTransfer(f, size, request)
     404        FileTransfer(f, stop, request)
    362405        # and make sure the connection doesn't get closed
    363406        return server.NOT_DONE_YET
    364407
     408
    365409    def redirect(self, request):
    366410        return redirectTo(addSlash(request), request)
    367411