diff --git a/backendpy/response.py b/backendpy/response.py index f75fadc..6c6d6e7 100644 --- a/backendpy/response.py +++ b/backendpy/response.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import gzip import os import types @@ -281,7 +282,10 @@ def __init__( status: Status = Status.OK, headers: Optional[Iterable[[bytes, bytes]]] = None, stream: bool = True, - compress: bool = False): + compress: bool = False, + partial: bool = False, + last_modified: Optional[int] = None, + entity_tag: Optional[str] = None): """ Initialize response instance. @@ -290,6 +294,9 @@ def __init__( :param headers: The HTTP response headers :param stream: Determines whether or not to stream the response :param compress: Determines whether or not to compress (gzip) the response + :param partial: Determines whether to support partial response + :param last_modified: Specifies the Last-Modified HTTP header and uses it in the if-range condition check + :param entity_tag: Specifies the ETag HTTP header and uses it in the if-range condition check """ super().__init__( body=b'', @@ -299,6 +306,9 @@ def __init__( compress=compress) self.path = path self.stream = stream + self.partial = partial + self.last_modified = last_modified + self.entity_tag = entity_tag async def __call__(self, request: Request) \ -> tuple[bytes | AsyncGenerator[bytes], @@ -311,23 +321,69 @@ async def __call__(self, request: Request) \ self.headers = list(self.headers) if self.headers else [] content_type, encoding = guess_type(path) - self.headers += [[b'content-type', to_bytes(content_type) if content_type else self.content_type]] - - if self.stream: - self.body = read_file_chunks(path, int(request.app.config['networking']['stream_size'])) - if self.compress: - self.body = self._gzip_stream(self.body) - self.headers += [[b'content-encoding', b'deflate']] + self.headers += [[b'content-type', to_bytes(content_type) if content_type else self.content_type], + [b'accept-ranges', b'bytes' if self.partial else b'none']] + try: + partial = self.partial \ + and 'range' in request.headers \ + and request.headers['range'].startswith('bytes=') \ + and ('if-range' not in request.headers + or (self.entity_tag is not None + and not self.entity_tag.startswith('/W') + and not request.headers['if-range'].startswith('/W') + and request.headers['if-range'] == self.entity_tag) + or (self.entity_tag is None + and self.last_modified is not None + and datetime.datetime.utcfromtimestamp(self.last_modified).replace(microsecond=0) == + datetime.datetime.strptime(request.headers['if-range'], '%a, %d %b %Y %H:%M:%S %Z'))) + except ValueError: + partial = False + if partial: + total_length = (await aiofiles.os.stat(path)).st_size + try: + ranges = request.headers['range'][6:].split(',') + range_ = ranges[0].split('-') + range_start = int(range_[0]) if range_[0] != '' else 0 + range_end = int(range_[1]) if range_[1] != '' else total_length - 1 + is_invalid_range = range_start > range_end or range_end > total_length + except (ValueError, IndexError): + is_invalid_range = True + if is_invalid_range: + self.status = Status.REQUESTED_RANGE_NOT_SATISFIABLE + return self.body, self.status.value, self.headers, False + self.status = Status.PARTIAL_CONTENT + if self.stream: + self.body = read_file_chunks( + path, + chunk_size=int(request.app.config['networking']['stream_size']), + start_index=range_start, + end_index=range_end) else: - file_stat = await aiofiles.os.stat(path) - self.headers += [[b'content-length', to_bytes(file_stat.st_size)]] + self.body = await read_file( + path, + start_index=range_start, + end_index=range_end) + self.headers += [[b'content-range', to_bytes(f'bytes {range_start}-{range_end}/{total_length}')], + [b'content-length', to_bytes(range_end-range_start+1)]] else: - self.body = await read_file(path) - if self.compress: - self.body = self._gzip(self.body) - self.headers += [[b'content-encoding', b'gzip']] - self.headers += [[b'content-length', to_bytes(len(self.body))]] - + if self.stream: + self.body = read_file_chunks(path, int(request.app.config['networking']['stream_size'])) + if self.compress: + self.body = self._gzip_stream(self.body) + self.headers += [[b'content-encoding', b'deflate']] + else: + file_stat = await aiofiles.os.stat(path) + self.headers += [[b'content-length', to_bytes(file_stat.st_size)]] + else: + self.body = await read_file(path) + if self.compress: + self.body = self._gzip(self.body) + self.headers += [[b'content-encoding', b'gzip']] + self.headers += [[b'content-length', to_bytes(len(self.body))]] + if self.last_modified is not None: + self.headers += [[b'last-modified', to_bytes(self.last_modified)]] + if self.entity_tag is not None: + self.headers += [[b'etag', to_bytes(self.entity_tag)]] return self.body, self.status.value, self.headers, self.stream diff --git a/backendpy/utils/file.py b/backendpy/utils/file.py index a66f004..23c0c9a 100644 --- a/backendpy/utils/file.py +++ b/backendpy/utils/file.py @@ -6,7 +6,7 @@ import mimetypes import os import types -from typing import Literal, AnyStr +from typing import Literal, AnyStr, Optional import aiofiles import aiofiles.os @@ -15,14 +15,44 @@ WRITE_MODES = Literal['w', 'w+', 'wb', 'wb+', 'wt', 'wt+'] -async def read_file_chunks(path, chunk_size=32768, mode: READ_MODES = 'rb'): +async def read_file_chunks( + path, + chunk_size=32768, + mode: READ_MODES = 'rb', + start_index: Optional[int] = None, + end_index: Optional[int] = None): async with aiofiles.open(path, mode) as f: + remaining_size = None + if start_index is not None: + await f.seek(start_index) + if end_index is not None: + remaining_size = end_index-start_index+1 + if remaining_size < chunk_size: + chunk_size = remaining_size + elif end_index is not None: + remaining_size = end_index+1 + if remaining_size < chunk_size: + chunk_size = remaining_size while chunk := await f.read(chunk_size): yield chunk + if remaining_size is not None: + remaining_size -= chunk_size + if remaining_size < chunk_size: + chunk_size = remaining_size -async def read_file(path, mode: READ_MODES = 'rb'): +async def read_file( + path, + mode: READ_MODES = 'rb', + start_index: Optional[int] = None, + end_index: Optional[int] = None): async with aiofiles.open(path, mode) as f: + if start_index: + await f.seek(start_index) + if end_index: + return await f.read(end_index-start_index+1) + elif end_index: + return await f.read(end_index+1) return await f.read() diff --git a/setup.cfg b/setup.cfg index f9e1263..d8806cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = backendpy -version = v0.2.4-alpha.5 +version = v0.2.5-alpha.1 author = Savang Co. author_email = backendpy@savang.com description = Async (ASGI) Python web framework