def _file(value: FileInput, content_type: Union[str, bytes], content_disposition_type: ContentDispositionType, file_name: str = None): if file_name: exact_file_name = ntpath.basename(file_name) if not exact_file_name: raise ValueError( 'Invalid file name: it should be an exact file name without path, for example: "foo.txt"' ) content_disposition_value = f'{content_disposition_type.value}; filename="{exact_file_name}"' else: content_disposition_value = content_disposition_type.value content_type = _ensure_bytes(content_type) if isinstance(value, str): # value is treated as a path content = StreamedContent(content_type, _get_file_provider(value)) elif isinstance(value, BytesIO): async def data_provider(): while True: chunk = value.read(1024 * 64) if not chunk: break yield chunk yield b'' content = StreamedContent(content_type, data_provider) elif callable(value): # value is treated as an async generator async def data_provider(): async for chunk in value(): yield chunk yield b'' content = StreamedContent(content_type, data_provider) elif isinstance(value, bytes): content = Content(content_type, value) elif isinstance(value, bytearray): content = Content(content_type, bytes(value)) else: raise ValueError( 'Invalid value, expected one of: Callable, str, bytes, bytearray, io.BytesIO' ) return Response( 200, [(b'Content-Disposition', content_disposition_value.encode())], content)
async def test_on_writing_paused_awaits(connection): async def dummy_body_generator() -> AsyncIterable[bytes]: for i in range(5): await asyncio.sleep(0.01) yield str(i).encode() if i > 2: # simulate connection pause connection.pause_writing() request = Request("POST", b"https://localhost:3000/foo", []).with_content( StreamedContent(b"text/plain", dummy_body_generator) ) fake_transport = FakeTransport() connection.open = True connection.transport = fake_transport try: await asyncio.wait_for(connection.send(request), 0.1) except TimeoutError: pass assert connection.writing_paused is True assert fake_transport.messages == [ b"POST /foo HTTP/1.1\r\nhost: localhost\r\ncontent-type: " + b"text/plain\r\ntransfer-encoding: chunked\r\n\r\n", b"1\r\n0\r\n", b"1\r\n1\r\n", b"1\r\n2\r\n", b"1\r\n3\r\n", ]
async def test_connection_stops_sending_body_if_server_returns_response(connection): async def dummy_body_generator() -> AsyncIterable[bytes]: for i in range(5): await asyncio.sleep(0.01) yield str(i).encode() if i > 2: # simulate getting a bad request response from the server: connection.headers = get_example_headers() connection.parser = FakeParser(400) connection.on_headers_complete() connection.on_message_complete() request = Request("POST", b"https://localhost:3000/foo", []).with_content( StreamedContent(b"text/plain", dummy_body_generator) ) fake_transport = FakeTransport() connection.open = True connection.transport = fake_transport response = await connection.send(request) assert response.status == 400 # NB: connection stopped writing to transport before the end of the body: assert fake_transport.messages == [ b"POST /foo HTTP/1.1\r\nhost: localhost\r\ncontent-type: " + b"text/plain\r\ntransfer-encoding: chunked\r\n\r\n", b"1\r\n0\r\n", b"1\r\n1\r\n", b"1\r\n2\r\n", b"1\r\n3\r\n", ]
async def test_chunked_encoding_with_generated_content(): async def data_generator(): yield b'{"hello":"world",' yield b'"lorem":' yield b'"ipsum","dolor":"sit"' yield b',"amet":"consectetur"}' content = StreamedContent(b'application/json', data_generator) chunks = [] async for chunk in data_generator(): chunks.append(chunk) gen = (item for item in chunks) async for chunk in write_chunks(content): try: generator_bytes = next(gen) except StopIteration: assert chunk == b'0\r\n\r\n' else: assert chunk == hex(len(generator_bytes))[2:].encode() \ + b'\r\n' + generator_bytes + b'\r\n'
def get_response_for_file(request, resource_path: str, cache_time: int): stat = os.stat(resource_path) file_size = stat.st_size modified_time = stat.st_mtime current_etag = str(modified_time).encode() previous_etag = request.if_none_match headers = [ (b'Last-Modified', unix_timestamp_to_datetime(modified_time)), (b'ETag', current_etag) ] if cache_time > 0: headers.append((b'Cache-Control', b'max-age=' + str(cache_time).encode())) if previous_etag and current_etag == previous_etag: return Response(304, headers, None) if request.method == 'HEAD': headers.append((b'Content-Type', get_mime_type(resource_path))) headers.append((b'Content-Length', str(file_size).encode())) return Response(200, headers, None) return Response(200, headers, StreamedContent(get_mime_type(resource_path), get_file_data(resource_path, file_size)))
async def test_on_connection_lost_send_throws(connection): async def dummy_body_generator() -> AsyncIterable[bytes]: for i in range(5): await asyncio.sleep(0.01) yield str(i).encode() if i > 2: # simulate connection lost connection.connection_lost(None) request = Request("POST", b"https://localhost:3000/foo", []).with_content( StreamedContent(b"text/plain", dummy_body_generator)) fake_transport = FakeTransport() connection.open = True connection.transport = fake_transport with pytest.raises(ConnectionClosedError): await connection.send(request)
def get_response_for_file( files_handler: FilesHandler, request: Request, resource_path: str, cache_time: int, info: Optional[FileInfo] = None, ) -> Response: if info is None: info = FileInfo.from_path(resource_path) current_etag = info.etag.encode() previous_etag = request.if_none_match # is the client requesting a Range of bytes? # NB: ignored if not GET or unit cannot be handled requested_range = _get_requested_range(request) if requested_range: _validate_range(requested_range, info.size) headers = [ (b"Last-Modified", info.modified_time.encode()), (b"ETag", current_etag), (b"Accept-Ranges", b"bytes"), ] if cache_time > 0: headers.append((b"Cache-Control", b"max-age=" + str(cache_time).encode())) if previous_etag and current_etag == previous_etag: # handle HTTP 304 Not Modified return Response(304, headers, None) if request.method == "HEAD": # NB: responses to HEAD requests don't have a body, # and responses with a body in BlackSheep have content-type # and content-length headers set automatically, # depending on their content; therefore here it's necessary to set # content-type and content-length for HEAD # TODO: instead of calling info.mime.encode every time, optimize using a # Dict[str, bytes] - optimize number to encoded string, too, using LRU headers.append((b"Content-Type", info.mime.encode())) headers.append((b"Content-Length", str(info.size).encode())) return Response(200, headers, None) status = 200 mime = get_mime_type_from_name(resource_path).encode() if requested_range and is_requested_range_actual(request, info): # NB: the method can only be GET for range requests, so it cannot # happen to have response 206 partial content with HEAD status = 206 boundary: Optional[bytes] if requested_range.is_multipart: # NB: multipart byteranges return the mime inside the portions boundary = str(uuid.uuid4()).replace("-", "").encode() file_type = mime mime = b"multipart/byteranges; boundary=" + boundary else: boundary = file_type = None single_part = requested_range.parts[0] headers.append( (b"Content-Range", _get_content_range_value(single_part, info.size)) ) content = StreamedContent( mime, get_range_file_getter( files_handler, resource_path, info.size, requested_range, boundary=boundary, file_type=file_type, ), ) else: content = StreamedContent( mime, get_file_getter(files_handler, resource_path, info.size) ) return Response(status, headers, content)
def get_response_for_file(request: Request, resource_path: str, cache_time: int, info: Optional[FileInfo] = None): if not info: info = FileInfo.from_path(resource_path) current_etag = info.etag.encode() previous_etag = request.if_none_match # is the client requesting a Range of bytes? # NB: ignored if not GET or unit cannot be handled requested_range = _get_requested_range(request) if requested_range: _validate_range(requested_range, info.size) headers = [(b'Last-Modified', info.modified_time.encode()), (b'ETag', current_etag), (b'Accept-Ranges', b'bytes')] if cache_time > 0: headers.append( (b'Cache-Control', b'max-age=' + str(cache_time).encode())) if previous_etag and current_etag == previous_etag: # handle HTTP 304 Not Modified return Response(304, headers, None) if request.method == 'HEAD': # NB: responses to HEAD requests don't have a body, # and responses with a body in BlackSheep have content-type and content-length headers set automatically, # depending on their content; therefore here it's necessary to set content-type and content-length for HEAD headers.append((b'Content-Type', info.mime.encode())) headers.append((b'Content-Length', str(info.size).encode())) return Response(200, headers, None) status = 200 mime = get_mime_type(resource_path).encode() if requested_range and is_requested_range_actual(request, info): # NB: the method can only be GET for range requests, so it cannot happen # to have response 206 partial content with HEAD status = 206 if requested_range.is_multipart: # NB: multipart byteranges return the mime inside the portions boundary = str(uuid.uuid4()).replace('-', '').encode() file_type = mime mime = b'multipart/byteranges; boundary=' + boundary else: boundary = file_type = None single_part = requested_range.parts[0] headers.append((b'Content-Range', _get_content_range_value(single_part, info.size))) content = StreamedContent( mime, get_range_file_getter(resource_path, info.size, requested_range, boundary=boundary, file_type=file_type)) else: content = StreamedContent(mime, get_file_getter(resource_path, info.size)) return Response(status, headers, content)