Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Static range requests #1382

Merged
merged 19 commits into from
Nov 18, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Alex Lisovoy
Amy Boyle
Andrei Ursulenko
Andrej Antonov
Andrew Leech
Andrew Svetlov
Andrii Soldatenko
Anton Kasyanov
Expand Down
42 changes: 33 additions & 9 deletions aiohttp/file_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from . import hdrs
from .helpers import create_future
from .web_exceptions import (HTTPNotModified, HTTPOk, HTTPPartialContent,
HTTPRequestRangeNotSatisfiable)
from .web_reqrep import StreamResponse


Expand Down Expand Up @@ -81,8 +83,8 @@ def write_eof():
# See https://github.com/KeepSafe/aiohttp/issues/958 for details

# send headers
headers = ['HTTP/{0.major}.{0.minor} 200 OK\r\n'.format(
request.version)]
headers = ['HTTP/{0.major}.{0.minor} {1} OK\r\n'.format(
request.version, resp.status)]
for hdr, val in resp.headers.items():
headers.append('{}: {}\r\n'.format(hdr, val))
headers.append('\r\n')
Expand All @@ -91,6 +93,7 @@ def write_eof():
out_socket.setblocking(False)
out_fd = out_socket.fileno()
in_fd = fobj.fileno()
offset = fobj.tell()

bheaders = ''.join(headers).encode('utf-8')
headers_length = len(bheaders)
Expand All @@ -100,7 +103,7 @@ def write_eof():
try:
yield from loop.sock_sendall(out_socket, bheaders)
fut = create_future(loop)
self._sendfile_cb(fut, out_fd, in_fd, 0, count, loop, False)
self._sendfile_cb(fut, out_fd, in_fd, offset, count, loop, False)

yield from fut
finally:
Expand Down Expand Up @@ -145,23 +148,44 @@ def send(self, request, filepath):

modsince = request.if_modified_since
if modsince is not None and st.st_mtime <= modsince.timestamp():
from .web_exceptions import HTTPNotModified
raise HTTPNotModified()

ct, encoding = mimetypes.guess_type(str(filepath))
if not ct:
ct = 'application/octet-stream'

resp = self._response_factory()
status = HTTPOk.status_code
file_size = st.st_size
count = file_size

try:
start, end = request.http_range
except ValueError:
raise HTTPRequestRangeNotSatisfiable

# If a range request has been made, convert start, end slice notation
# into file pointer offset and count
if start is not None or end is not None:
status = HTTPPartialContent.status_code
if start is None and end < 0: # return tail of file
start = file_size + end
count = -end
else:
count = (end or file_size) - start

if start + count > file_size:
raise HTTPRequestRangeNotSatisfiable

resp = self._response_factory(status=status)
resp.content_type = ct
if encoding:
resp.headers[hdrs.CONTENT_ENCODING] = encoding
resp.last_modified = st.st_mtime

file_size = st.st_size

resp.content_length = file_size
resp.content_length = count
with filepath.open('rb') as f:
yield from self._sendfile(request, resp, f, file_size)
if start:
f.seek(start)
yield from self._sendfile(request, resp, f, count)

return resp
35 changes: 35 additions & 0 deletions aiohttp/web_reqrep.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io
import json
import math
import re
import time
import warnings
from email.utils import parsedate
Expand Down Expand Up @@ -310,6 +311,40 @@ def cookies(self):
return MappingProxyType(
{key: val.value for key, val in parsed.items()})

@property
def http_range(self, *, _RANGE=hdrs.RANGE):
"""
The content of Range HTTP header.
:returns tuple (start, end): values that can be used for slice
eg. content[start:end]
"""
rng = self.headers.get(_RANGE)
start, end = None, None
if rng is not None:
try:
pattern = r'^bytes=(\d*)-(\d*)$'
start, end = re.findall(pattern, rng)[0]
except IndexError: # pattern was not found in header
raise ValueError("range not in acceptible format")

end = int(end) if end else None
start = int(start) if start else None

if start is None and end is not None:
# end with no start is to return tail of content
end = -end

if start is not None and end is not None:
# end is inclusive in range header, exclusive for slice
end += 1

if start >= end:
raise ValueError('start cannot be after end')

if start is end is None: # No valid range supplied
raise ValueError('No start or end of range specified')
return start, end

@property
def content(self):
"""Return raw payload stream."""
Expand Down
126 changes: 125 additions & 1 deletion tests/test_web_sendfile_functional.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import os
import pathlib

import pytest

import aiohttp
Expand Down Expand Up @@ -323,3 +322,128 @@ def test_static_file_huge(loop, test_client, tmpdir):
off += len(chunk)
cnt += 1
f.close()


@asyncio.coroutine
def test_static_file_range(loop, test_client, sender):
filepath = (pathlib.Path(__file__).parent /
'software_development_in_picture.jpg')

@asyncio.coroutine
def handler(request):
resp = yield from sender(chunk_size=16).send(request, filepath)
return resp

app = web.Application(loop=loop)
app.router.add_get('/', handler)
client = yield from test_client(lambda loop: app)

with filepath.open('rb') as f:
content = f.read()

# Ensure the whole file requested in parts is correct
responses = yield from asyncio.gather(
client.get('/', headers={'Range': 'bytes=0-999'}),
client.get('/', headers={'Range': 'bytes=1000-1999'}),
client.get('/', headers={'Range': 'bytes=2000-'}),
loop=loop
)
assert len(responses) == 3
assert responses[0].status == 206, \
"failed 'bytes=0-999': %s" % responses[0].reason
assert responses[1].status == 206, \
"failed 'bytes=1000-1999': %s" % responses[1].reason
assert responses[2].status == 206, \
"failed 'bytes=2000-': %s" % responses[2].reason

body = yield from asyncio.gather(
*(resp.read() for resp in responses),
loop=loop
)

assert len(body[0]) == 1000, \
"failed 'bytes=0-999', received %d bytes" % len(body[0])
assert len(body[1]) == 1000, \
"failed 'bytes=1000-1999', received %d bytes" % len(body[1])
responses[0].close()
responses[1].close()
responses[2].close()

assert content == b"".join(body)


@asyncio.coroutine
def test_static_file_range_tail(loop, test_client, sender):
filepath = (pathlib.Path(__file__).parent /
'software_development_in_picture.jpg')

@asyncio.coroutine
def handler(request):
resp = yield from sender(chunk_size=16).send(request, filepath)
return resp

app = web.Application(loop=loop)
app.router.add_get('/', handler)
client = yield from test_client(lambda loop: app)

with filepath.open('rb') as f:
content = f.read()

# Ensure the tail of the file is correct
resp = yield from client.get('/', headers={'Range': 'bytes=-500'})
assert resp.status == 206, resp.reason
body4 = yield from resp.read()
resp.close()
assert content[-500:] == body4


@asyncio.coroutine
def test_static_file_invalid_range(loop, test_client, sender):
filepath = (pathlib.Path(__file__).parent /
'software_development_in_picture.jpg')

@asyncio.coroutine
def handler(request):
resp = yield from sender(chunk_size=16).send(request, filepath)
return resp

app = web.Application(loop=loop)
app.router.add_get('/', handler)
client = yield from test_client(lambda loop: app)

flen = filepath.stat().st_size

# range must be in bytes
resp = yield from client.get('/', headers={'Range': 'blocks=0-10'})
assert resp.status == 416, 'Range must be in bytes'
resp.close()

# Range end is inclusive
resp = yield from client.get('/', headers={'Range': 'bytes=0-%d' % flen})
assert resp.status == 416, 'Range end must be inclusive'
resp.close()

# start > end
resp = yield from client.get('/', headers={'Range': 'bytes=100-0'})
assert resp.status == 416, "Range start can't be greater than end"
resp.close()

# start > end
resp = yield from client.get('/', headers={'Range': 'bytes=10-9'})
assert resp.status == 416, "Range start can't be greater than end"
resp.close()

# non-number range
resp = yield from client.get('/', headers={'Range': 'bytes=a-f'})
assert resp.status == 416, 'Range must be integers'
resp.close()

# double dash range
resp = yield from client.get('/', headers={'Range': 'bytes=0--10'})
assert resp.status == 416, 'double dash in range'
resp.close()

# no range
resp = yield from client.get('/', headers={'Range': 'bytes=-'})
assert resp.status == 416, 'no range given'
resp.close()