diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index 9fd8d92ed8aa..b585ef64ca3c 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -22,6 +22,8 @@ - The `text` property on `azure.core.rest.HttpResponse` and `azure.core.rest.AsyncHttpResponse` has changed to a method, which also takes an `encoding` parameter. - Removed `iter_text` and `iter_lines` from `azure.core.rest.HttpResponse` and `azure.core.rest.AsyncHttpResponse` +- `azure.core.rest.HttpResponse` and `azure.core.rest.AsyncHttpResponse` are now abstract base classes. They should not be initialized directly, instead +your transport responses should inherit from them and implement them. ### Bugs Fixed diff --git a/sdk/core/azure-core/azure/core/_pipeline_client_async.py b/sdk/core/azure-core/azure/core/_pipeline_client_async.py index ad8b758d07af..9b3674af4703 100644 --- a/sdk/core/azure-core/azure/core/_pipeline_client_async.py +++ b/sdk/core/azure-core/azure/core/_pipeline_client_async.py @@ -25,7 +25,7 @@ # -------------------------------------------------------------------------- import logging -from collections.abc import Iterable +import collections.abc from typing import Any, Awaitable from .configuration import Configuration from .pipeline import AsyncPipeline @@ -62,6 +62,26 @@ _LOGGER = logging.getLogger(__name__) +class _AsyncContextManager(collections.abc.Awaitable): + + def __init__(self, wrapped: collections.abc.Awaitable): + super().__init__() + self.wrapped = wrapped + self.response = None + + def __await__(self): + return self.wrapped.__await__() + + async def __aenter__(self): + self.response = await self + return self.response + + async def __aexit__(self, *args): + await self.response.__aexit__(*args) + + async def close(self): + await self.response.close() + class AsyncPipelineClient(PipelineClientBase): """Service client core methods. @@ -125,7 +145,7 @@ def _build_pipeline(self, config, **kwargs): # pylint: disable=no-self-use config.proxy_policy, ContentDecodePolicy(**kwargs) ] - if isinstance(per_call_policies, Iterable): + if isinstance(per_call_policies, collections.abc.Iterable): policies.extend(per_call_policies) else: policies.append(per_call_policies) @@ -134,7 +154,7 @@ def _build_pipeline(self, config, **kwargs): # pylint: disable=no-self-use config.retry_policy, config.authentication_policy, config.custom_hook_policy]) - if isinstance(per_retry_policies, Iterable): + if isinstance(per_retry_policies, collections.abc.Iterable): policies.extend(per_retry_policies) else: policies.append(per_retry_policies) @@ -143,13 +163,13 @@ def _build_pipeline(self, config, **kwargs): # pylint: disable=no-self-use DistributedTracingPolicy(**kwargs), config.http_logging_policy or HttpLoggingPolicy(**kwargs)]) else: - if isinstance(per_call_policies, Iterable): + if isinstance(per_call_policies, collections.abc.Iterable): per_call_policies_list = list(per_call_policies) else: per_call_policies_list = [per_call_policies] per_call_policies_list.extend(policies) policies = per_call_policies_list - if isinstance(per_retry_policies, Iterable): + if isinstance(per_retry_policies, collections.abc.Iterable): per_retry_policies_list = list(per_retry_policies) else: per_retry_policies_list = [per_retry_policies] @@ -188,7 +208,7 @@ async def _make_pipeline_call(self, request, **kwargs): # the body is loaded. instead of doing response.read(), going to set the body # to the internal content rest_response._content = response.body() # pylint: disable=protected-access - await rest_response.close() + await rest_response._set_read_checks() # pylint: disable=protected-access except Exception as exc: await rest_response.close() raise exc @@ -222,6 +242,5 @@ def send_request( :return: The response of your network call. Does not do error handling on your response. :rtype: ~azure.core.rest.AsyncHttpResponse """ - from .rest._rest_py3 import _AsyncContextManager wrapped = self._make_pipeline_call(request, stream=stream, **kwargs) return _AsyncContextManager(wrapped=wrapped) diff --git a/sdk/core/azure-core/azure/core/pipeline/_tools.py b/sdk/core/azure-core/azure/core/pipeline/_tools.py index a8beebd75b99..9e036bf43d98 100644 --- a/sdk/core/azure-core/azure/core/pipeline/_tools.py +++ b/sdk/core/azure-core/azure/core/pipeline/_tools.py @@ -46,21 +46,20 @@ def to_rest_request(pipeline_transport_request): def to_rest_response(pipeline_transport_response): from .transport._requests_basic import RequestsTransportResponse from ..rest._requests_basic import RestRequestsTransportResponse - from ..rest import HttpResponse if isinstance(pipeline_transport_response, RequestsTransportResponse): response_type = RestRequestsTransportResponse else: - response_type = HttpResponse + raise ValueError("Unknown transport response") response = response_type( request=to_rest_request(pipeline_transport_response.request), internal_response=pipeline_transport_response.internal_response, + block_size=pipeline_transport_response.block_size ) - response._connection_data_block_size = pipeline_transport_response.block_size # pylint: disable=protected-access return response def get_block_size(response): try: - return response._connection_data_block_size # pylint: disable=protected-access + return response._block_size # pylint: disable=protected-access except AttributeError: return response.block_size diff --git a/sdk/core/azure-core/azure/core/pipeline/_tools_async.py b/sdk/core/azure-core/azure/core/pipeline/_tools_async.py index de59dfdd86ed..8eaf4a46ec0f 100644 --- a/sdk/core/azure-core/azure/core/pipeline/_tools_async.py +++ b/sdk/core/azure-core/azure/core/pipeline/_tools_async.py @@ -55,14 +55,13 @@ def _get_response_type(pipeline_transport_response): return RestTrioRequestsTransportResponse except ImportError: pass - from ..rest import AsyncHttpResponse - return AsyncHttpResponse + raise ValueError("Unknown transport response") def to_rest_response(pipeline_transport_response): response_type = _get_response_type(pipeline_transport_response) response = response_type( request=to_rest_request(pipeline_transport_response.request), internal_response=pipeline_transport_response.internal_response, + block_size=pipeline_transport_response.block_size, ) - response._connection_data_block_size = pipeline_transport_response.block_size # pylint: disable=protected-access return response diff --git a/sdk/core/azure-core/azure/core/rest/_aiohttp.py b/sdk/core/azure-core/azure/core/rest/_aiohttp.py index 055e569b8166..1cd830f069c7 100644 --- a/sdk/core/azure-core/azure/core/rest/_aiohttp.py +++ b/sdk/core/azure-core/azure/core/rest/_aiohttp.py @@ -28,8 +28,7 @@ from itertools import groupby from typing import AsyncIterator from multidict import CIMultiDict -from . import HttpRequest, AsyncHttpResponse -from ._helpers_py3 import iter_raw_helper, iter_bytes_helper +from ._http_response_impl_async import AsyncHttpResponseImpl from ..pipeline.transport._aiohttp import AioHttpStreamDownloadGenerator class _ItemsView(collections.abc.ItemsView): @@ -115,42 +114,26 @@ def get(self, key, default=None): values = ", ".join(values) return values or default -class RestAioHttpTransportResponse(AsyncHttpResponse): +class RestAioHttpTransportResponse(AsyncHttpResponseImpl): def __init__( self, *, - request: HttpRequest, internal_response, + decompress: bool = True, + **kwargs ): - super().__init__(request=request, internal_response=internal_response) - self.status_code = internal_response.status - self.headers = _CIMultiDict(internal_response.headers) # type: ignore - self.reason = internal_response.reason - self.content_type = internal_response.headers.get('content-type') - - async def iter_raw(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will not decompress in the process - - :return: An async iterator of bytes from the response - :rtype: AsyncIterator[bytes] - """ - async for part in iter_raw_helper(AioHttpStreamDownloadGenerator, self): - yield part - await self.close() - - async def iter_bytes(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will decompress in the process - - :return: An async iterator of bytes from the response - :rtype: AsyncIterator[bytes] - """ - async for part in iter_bytes_helper( - AioHttpStreamDownloadGenerator, - self, - content=self._content - ): - yield part - await self.close() + headers = _CIMultiDict(internal_response.headers) + super().__init__( + internal_response=internal_response, + status_code=internal_response.status, + headers=headers, + content_type=headers.get('content-type'), + reason=internal_response.reason, + stream_download_generator=AioHttpStreamDownloadGenerator, + content=None, + **kwargs + ) + self._decompress = decompress def __getstate__(self): state = self.__dict__.copy() @@ -165,6 +148,7 @@ async def close(self) -> None: :return: None :rtype: None """ - self.is_closed = True - self._internal_response.close() - await asyncio.sleep(0) + if not self.is_closed: + self._is_closed = True + self._internal_response.close() + await asyncio.sleep(0) diff --git a/sdk/core/azure-core/azure/core/rest/_helpers.py b/sdk/core/azure-core/azure/core/rest/_helpers.py index 3aba4c5a8fda..613ab723120a 100644 --- a/sdk/core/azure-core/azure/core/rest/_helpers.py +++ b/sdk/core/azure-core/azure/core/rest/_helpers.py @@ -35,12 +35,12 @@ Union, Mapping, Sequence, - List, Tuple, IO, Any, Dict, Iterable, + MutableMapping, ) import xml.etree.ElementTree as ET import six @@ -66,8 +66,6 @@ ParamsType = Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]] -HeadersType = Mapping[str, str] - FileContent = Union[str, bytes, IO[str], IO[bytes]] FileType = Union[ Tuple[Optional[str], FileContent], @@ -129,8 +127,8 @@ def set_xml_body(content): return headers, body def _shared_set_content_body(content): - # type: (Any) -> Tuple[HeadersType, Optional[ContentTypeBase]] - headers = {} # type: HeadersType + # type: (Any) -> Tuple[MutableMapping[str, str], Optional[ContentTypeBase]] + headers = {} # type: MutableMapping[str, str] if isinstance(content, ET.Element): # XML body diff --git a/sdk/core/azure-core/azure/core/rest/_helpers_py3.py b/sdk/core/azure-core/azure/core/rest/_helpers_py3.py index 90948012db2a..d50d2aa88019 100644 --- a/sdk/core/azure-core/azure/core/rest/_helpers_py3.py +++ b/sdk/core/azure-core/azure/core/rest/_helpers_py3.py @@ -30,20 +30,14 @@ Iterable, Tuple, Union, - Callable, - Optional, - AsyncIterator as AsyncIteratorType + MutableMapping, ) -from ..exceptions import StreamConsumedError, StreamClosedError -from ._helpers import ( - _shared_set_content_body, - HeadersType -) +from ._helpers import _shared_set_content_body ContentType = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] def set_content_body(content: ContentType) -> Tuple[ - HeadersType, ContentType + MutableMapping[str, str], ContentType ]: headers, body = _shared_set_content_body(content) if body is not None: @@ -54,48 +48,3 @@ def set_content_body(content: ContentType) -> Tuple[ "Unexpected type for 'content': '{}'. ".format(type(content)) + "We expect 'content' to either be str, bytes, or an Iterable / AsyncIterable" ) - -def _stream_download_helper( - decompress: bool, - stream_download_generator: Callable, - response, -) -> AsyncIteratorType[bytes]: - if response.is_stream_consumed: - raise StreamConsumedError(response) - if response.is_closed: - raise StreamClosedError(response) - - response.is_stream_consumed = True - return stream_download_generator( - pipeline=None, - response=response, - decompress=decompress, - ) - -async def iter_bytes_helper( - stream_download_generator: Callable, - response, - content: Optional[bytes], -) -> AsyncIteratorType[bytes]: - if content: - chunk_size = response._connection_data_block_size # pylint: disable=protected-access - for i in range(0, len(content), chunk_size): - yield content[i : i + chunk_size] - else: - async for part in _stream_download_helper( - decompress=True, - stream_download_generator=stream_download_generator, - response=response, - ): - yield part - -async def iter_raw_helper( - stream_download_generator: Callable, - response, -) -> AsyncIteratorType[bytes]: - async for part in _stream_download_helper( - decompress=False, - stream_download_generator=stream_download_generator, - response=response, - ): - yield part diff --git a/sdk/core/azure-core/azure/core/rest/_http_response_impl.py b/sdk/core/azure-core/azure/core/rest/_http_response_impl.py new file mode 100644 index 000000000000..67a57a676e08 --- /dev/null +++ b/sdk/core/azure-core/azure/core/rest/_http_response_impl.py @@ -0,0 +1,323 @@ +# -------------------------------------------------------------------------- +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the ""Software""), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +# -------------------------------------------------------------------------- +from json import loads +from typing import cast, TYPE_CHECKING +from ._helpers import ( + get_charset_encoding, + decode_to_text, +) +from ..exceptions import HttpResponseError, ResponseNotReadError, StreamConsumedError, StreamClosedError +try: + from ._rest_py3 import ( + _HttpResponseBase, + HttpResponse as _HttpResponse, + HttpRequest as _HttpRequest + ) +except (SyntaxError, ImportError): + from ._rest import ( # type: ignore + _HttpResponseBase, + HttpResponse as _HttpResponse, + HttpRequest as _HttpRequest + ) + +if TYPE_CHECKING: + from typing import Any, Optional, Iterator, MutableMapping, Callable + + +class _HttpResponseBaseImpl(_HttpResponseBase): # pylint: disable=too-many-instance-attributes + """Base Implementation class for azure.core.rest.HttpRespone and azure.core.rest.AsyncHttpResponse + + Since the rest responses are abstract base classes, we need to implement them for each of our transport + responses. This is the base implementation class shared by HttpResponseImpl and AsyncHttpResponseImpl. + The transport responses will be built on top of HttpResponseImpl and AsyncHttpResponseImpl + + :keyword request: The request that led to the response + :type request: ~azure.core.rest.HttpRequest + :keyword any internal_response: The response we get directly from the transport. For example, for our requests + transport, this will be a requests.Response. + :keyword optional[int] block_size: The block size we are using in our transport + :keyword int status_code: The status code of the response + :keyword str reason: The HTTP reason + :keyword str content_type: The content type of the response + :keyword MutableMapping[str, str] headers: The response headers + :keyword Callable stream_download_generator: The stream download generator that we use to stream the response. + """ + + def __init__(self, **kwargs): + # type: (Any) -> None + super(_HttpResponseBaseImpl, self).__init__() + self._request = kwargs.pop("request") + self._internal_response = kwargs.pop("internal_response") + self._block_size = kwargs.pop("block_size", None) or 4096 # type: int + self._status_code = kwargs.pop("status_code") # type: int + self._reason = kwargs.pop("reason") # type: str + self._content_type = kwargs.pop("content_type") # type: str + self._headers = kwargs.pop("headers") # type: MutableMapping[str, str] + self._stream_download_generator = kwargs.pop("stream_download_generator") # type: Callable + self._is_closed = False + self._is_stream_consumed = False + self._json = None # this is filled in ContentDecodePolicy, when we deserialize + self._content = None # type: Optional[bytes] + self._text = None # type: Optional[str] + + @property + def request(self): + # type: (...) -> _HttpRequest + """The request that resulted in this response. + + :rtype: ~azure.core.rest.HttpRequest + """ + return self._request + + @property + def url(self): + # type: (...) -> str + """The URL that resulted in this response. + + :rtype: str + """ + return self.request.url + + @property + def is_closed(self): + # type: (...) -> bool + """Whether the network connection has been closed yet. + + :rtype: bool + """ + return self._is_closed + + @property + def is_stream_consumed(self): + # type: (...) -> bool + """Whether the stream has been consumed""" + return self._is_stream_consumed + + @property + def status_code(self): + # type: (...) -> int + """The status code of this response. + + :rtype: int + """ + return self._status_code + + @property + def headers(self): + # type: (...) -> MutableMapping[str, str] + """The response headers. + + :rtype: MutableMapping[str, str] + """ + return self._headers + + @property + def content_type(self): + # type: (...) -> Optional[str] + """The content type of the response. + + :rtype: optional[str] + """ + return self._content_type + + @property + def reason(self): + # type: (...) -> str + """The reason phrase for this response. + + :rtype: str + """ + return self._reason + + @property + def encoding(self): + # type: (...) -> Optional[str] + """Returns the response encoding. + + :return: The response encoding. We either return the encoding set by the user, + or try extracting the encoding from the response's content type. If all fails, + we return `None`. + :rtype: optional[str] + """ + try: + return self._encoding + except AttributeError: + self._encoding = get_charset_encoding(self) # type: Optional[str] + return self._encoding + + @encoding.setter + def encoding(self, value): + # type: (str) -> None + """Sets the response encoding""" + self._encoding = value + self._text = None # clear text cache + self._json = None # clear json cache as well + + def text(self, encoding=None): + # type: (Optional[str]) -> str + """Returns the response body as a string + + :param optional[str] encoding: The encoding you want to decode the text with. Can + also be set independently through our encoding property + :return: The response's content decoded as a string. + """ + if encoding: + return decode_to_text(encoding, self.content) + if self._text: + return self._text + self._text = decode_to_text(self.encoding, self.content) + return self._text + + def json(self): + # type: (...) -> Any + """Returns the whole body as a json object. + + :return: The JSON deserialized response body + :rtype: any + :raises json.decoder.JSONDecodeError or ValueError (in python 2.7) if object is not JSON decodable: + """ + # this will trigger errors if response is not read in + self.content # pylint: disable=pointless-statement + if not self._json: + self._json = loads(self.text()) + return self._json + + def _stream_download_check(self): + if self.is_stream_consumed: + raise StreamConsumedError(self) + if self.is_closed: + raise StreamClosedError(self) + + self._is_stream_consumed = True + + def raise_for_status(self): + # type: (...) -> None + """Raises an HttpResponseError if the response has an error status code. + + If response is good, does nothing. + """ + if cast(int, self.status_code) >= 400: + raise HttpResponseError(response=self) + + @property + def content(self): + # type: (...) -> bytes + """Return the response's content in bytes.""" + if self._content is None: + raise ResponseNotReadError(self) + return self._content + + def __repr__(self): + # type: (...) -> str + content_type_str = ( + ", Content-Type: {}".format(self.content_type) if self.content_type else "" + ) + return "".format( + self.status_code, self.reason, content_type_str + ) + +class HttpResponseImpl(_HttpResponseBaseImpl, _HttpResponse): + """HttpResponseImpl built on top of our HttpResponse protocol class. + + Since ~azure.core.rest.HttpResponse is an abstract base class, we need to + implement HttpResponse for each of our transports. This is an implementation + that each of the sync transport responses can be built on. + + :keyword request: The request that led to the response + :type request: ~azure.core.rest.HttpRequest + :keyword any internal_response: The response we get directly from the transport. For example, for our requests + transport, this will be a requests.Response. + :keyword optional[int] block_size: The block size we are using in our transport + :keyword int status_code: The status code of the response + :keyword str reason: The HTTP reason + :keyword str content_type: The content type of the response + :keyword MutableMapping[str, str] headers: The response headers + :keyword Callable stream_download_generator: The stream download generator that we use to stream the response. + """ + + def __enter__(self): + # type: (...) -> HttpResponseImpl + return self + + def close(self): + # type: (...) -> None + if not self.is_closed: + self._is_closed = True + self._internal_response.close() + + def __exit__(self, *args): + # type: (...) -> None + self.close() + + def _set_read_checks(self): + self._is_stream_consumed = True + self.close() + + def read(self): + # type: (...) -> bytes + """ + Read the response's bytes. + + """ + if self._content is None: + self._content = b"".join(self.iter_bytes()) + self._set_read_checks() + return self.content + + def iter_bytes(self): + # type: () -> Iterator[bytes] + """Iterates over the response's bytes. Will decompress in the process. + + :return: An iterator of bytes from the response + :rtype: Iterator[str] + """ + if self._content is not None: + chunk_size = cast(int, self._block_size) + for i in range(0, len(self.content), chunk_size): + yield self.content[i : i + chunk_size] + else: + self._stream_download_check() + for part in self._stream_download_generator( + response=self, + pipeline=None, + decompress=True, + ): + yield part + self.close() + + def iter_raw(self): + # type: () -> Iterator[bytes] + """Iterates over the response's bytes. Will not decompress in the process. + + :return: An iterator of bytes from the response + :rtype: Iterator[str] + """ + self._stream_download_check() + for part in self._stream_download_generator( + response=self, pipeline=None, decompress=False + ): + yield part + self.close() diff --git a/sdk/core/azure-core/azure/core/rest/_http_response_impl_async.py b/sdk/core/azure-core/azure/core/rest/_http_response_impl_async.py new file mode 100644 index 000000000000..8ea5a1d03d25 --- /dev/null +++ b/sdk/core/azure-core/azure/core/rest/_http_response_impl_async.py @@ -0,0 +1,116 @@ +# -------------------------------------------------------------------------- +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the ""Software""), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +# -------------------------------------------------------------------------- +from typing import AsyncIterator +from ._rest_py3 import AsyncHttpResponse as _AsyncHttpResponse +from ._http_response_impl import _HttpResponseBaseImpl + +class AsyncHttpResponseImpl(_HttpResponseBaseImpl, _AsyncHttpResponse): + """AsyncHttpResponseImpl built on top of our HttpResponse protocol class. + + Since ~azure.core.rest.AsyncHttpResponse is an abstract base class, we need to + implement HttpResponse for each of our transports. This is an implementation + that each of the sync transport responses can be built on. + + :keyword request: The request that led to the response + :type request: ~azure.core.rest.HttpRequest + :keyword any internal_response: The response we get directly from the transport. For example, for our requests + transport, this will be a requests.Response. + :keyword optional[int] block_size: The block size we are using in our transport + :keyword int status_code: The status code of the response + :keyword str reason: The HTTP reason + :keyword str content_type: The content type of the response + :keyword MutableMapping[str, str] headers: The response headers + :keyword Callable stream_download_generator: The stream download generator that we use to stream the response. + """ + + async def _set_read_checks(self): + self._is_stream_consumed = True + await self.close() + + async def read(self) -> bytes: + """Read the response's bytes into memory. + + :return: The response's bytes + :rtype: bytes + """ + if self._content is None: + parts = [] + async for part in self.iter_bytes(): + parts.append(part) + self._content = b"".join(parts) + await self._set_read_checks() + return self._content + + async def iter_raw(self) -> AsyncIterator[bytes]: + """Asynchronously iterates over the response's bytes. Will not decompress in the process + :return: An async iterator of bytes from the response + :rtype: AsyncIterator[bytes] + """ + self._stream_download_check() + async for part in self._stream_download_generator( + response=self, pipeline=None, decompress=False + ): + yield part + await self.close() + + async def iter_bytes(self) -> AsyncIterator[bytes]: + """Asynchronously iterates over the response's bytes. Will decompress in the process + :return: An async iterator of bytes from the response + :rtype: AsyncIterator[bytes] + """ + if self._content is not None: + for i in range(0, len(self.content), self._block_size): + yield self.content[i : i + self._block_size] + else: + self._stream_download_check() + async for part in self._stream_download_generator( + response=self, + pipeline=None, + decompress=True + ): + yield part + await self.close() + + async def close(self) -> None: + """Close the response. + + :return: None + :rtype: None + """ + if not self.is_closed: + self._is_closed = True + await self._internal_response.close() + + async def __aexit__(self, *args) -> None: + await self.close() + + def __repr__(self) -> str: + content_type_str = ( + ", Content-Type: {}".format(self.content_type) if self.content_type else "" + ) + return "".format( + self.status_code, self.reason, content_type_str + ) diff --git a/sdk/core/azure-core/azure/core/rest/_requests_asyncio.py b/sdk/core/azure-core/azure/core/rest/_requests_asyncio.py index b21545a79804..e50139dc235f 100644 --- a/sdk/core/azure-core/azure/core/rest/_requests_asyncio.py +++ b/sdk/core/azure-core/azure/core/rest/_requests_asyncio.py @@ -23,41 +23,20 @@ # IN THE SOFTWARE. # # -------------------------------------------------------------------------- -from typing import AsyncIterator import asyncio -from ._helpers_py3 import iter_bytes_helper, iter_raw_helper -from . import AsyncHttpResponse -from ._requests_basic import _RestRequestsTransportResponseBase, _has_content +from ._http_response_impl_async import AsyncHttpResponseImpl +from ._requests_basic import _RestRequestsTransportResponseBase from ..pipeline.transport._requests_asyncio import AsyncioStreamDownloadGenerator -class RestAsyncioRequestsTransportResponse(AsyncHttpResponse, _RestRequestsTransportResponseBase): # type: ignore +class RestAsyncioRequestsTransportResponse(AsyncHttpResponseImpl, _RestRequestsTransportResponseBase): # type: ignore """Asynchronous streaming of data from the response. """ - async def iter_raw(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will not decompress in the process - - :return: An async iterator of bytes from the response - :rtype: AsyncIterator[bytes] - """ - - async for part in iter_raw_helper(AsyncioStreamDownloadGenerator, self): - yield part - await self.close() - - async def iter_bytes(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will decompress in the process - - :return: An async iterator of bytes from the response - :rtype: AsyncIterator[bytes] - """ - async for part in iter_bytes_helper( - AsyncioStreamDownloadGenerator, - self, - content=self.content if _has_content(self) else None - ): - yield part - await self.close() + def __init__(self, **kwargs): + super().__init__( + stream_download_generator=AsyncioStreamDownloadGenerator, + **kwargs + ) async def close(self) -> None: """Close the response. @@ -65,19 +44,7 @@ async def close(self) -> None: :return: None :rtype: None """ - self.is_closed = True - self._internal_response.close() - await asyncio.sleep(0) - - async def read(self) -> bytes: - """Read the response's bytes into memory. - - :return: The response's bytes - :rtype: bytes - """ - if not _has_content(self): - parts = [] - async for part in self.iter_bytes(): # type: ignore - parts.append(part) - self._internal_response._content = b"".join(parts) # pylint: disable=protected-access - return self.content + if not self.is_closed: + self._is_closed = True + self._internal_response.close() + await asyncio.sleep(0) diff --git a/sdk/core/azure-core/azure/core/rest/_requests_basic.py b/sdk/core/azure-core/azure/core/rest/_requests_basic.py index 5f7a0c4b2244..46e898cda44f 100644 --- a/sdk/core/azure-core/azure/core/rest/_requests_basic.py +++ b/sdk/core/azure-core/azure/core/rest/_requests_basic.py @@ -28,11 +28,9 @@ except ImportError: import collections # type: ignore -from typing import TYPE_CHECKING, cast from requests.structures import CaseInsensitiveDict -from ..exceptions import ResponseNotReadError, StreamConsumedError, StreamClosedError -from ._rest import _HttpResponseBase, HttpResponse +from ._http_response_impl import _HttpResponseBaseImpl, HttpResponseImpl from ..pipeline.transport._requests_basic import StreamDownloadGenerator class _ItemsView(collections.ItemsView): @@ -58,94 +56,27 @@ def items(self): """Return a new view of the dictionary's items.""" return _ItemsView(self) - -if TYPE_CHECKING: - from typing import Iterator, Optional - -def _has_content(response): - try: - response.content # pylint: disable=pointless-statement - return True - except ResponseNotReadError: - return False - -class _RestRequestsTransportResponseBase(_HttpResponseBase): +class _RestRequestsTransportResponseBase(_HttpResponseBaseImpl): def __init__(self, **kwargs): - super(_RestRequestsTransportResponseBase, self).__init__(**kwargs) - self.status_code = self._internal_response.status_code - self.headers = _CaseInsensitiveDict(self._internal_response.headers) - self.reason = self._internal_response.reason - self.content_type = self._internal_response.headers.get('content-type') - - @property - def content(self): - # type: () -> bytes - if not self._internal_response._content_consumed: # pylint: disable=protected-access - # if we just call .content, requests will read in the content. - # we want to read it in our own way - raise ResponseNotReadError(self) - - try: - return self._internal_response.content - except RuntimeError: - # requests throws a RuntimeError if the content for a response is already consumed - raise ResponseNotReadError(self) - -def _stream_download_helper(decompress, response): - if response.is_stream_consumed: - raise StreamConsumedError(response) - if response.is_closed: - raise StreamClosedError(response) + internal_response = kwargs.pop("internal_response") + content = None + if internal_response._content_consumed: + content = internal_response.content + headers = _CaseInsensitiveDict(internal_response.headers) + super(_RestRequestsTransportResponseBase, self).__init__( + internal_response=internal_response, + status_code=internal_response.status_code, + headers=headers, + reason=internal_response.reason, + content_type=headers.get('content-type'), + content=content, + **kwargs + ) + +class RestRequestsTransportResponse(HttpResponseImpl, _RestRequestsTransportResponseBase): - response.is_stream_consumed = True - stream_download = StreamDownloadGenerator( - pipeline=None, - response=response, - decompress=decompress, - ) - for part in stream_download: - yield part - -class RestRequestsTransportResponse(HttpResponse, _RestRequestsTransportResponseBase): - - def iter_bytes(self): - # type: () -> Iterator[bytes] - """Iterates over the response's bytes. Will decompress in the process - :return: An iterator of bytes from the response - :rtype: Iterator[str] - """ - if _has_content(self): - chunk_size = cast(int, self._connection_data_block_size) - for i in range(0, len(self.content), chunk_size): - yield self.content[i : i + chunk_size] - else: - for part in _stream_download_helper( - decompress=True, - response=self, - ): - yield part - self.close() - - def iter_raw(self): - # type: () -> Iterator[bytes] - """Iterates over the response's bytes. Will not decompress in the process - :return: An iterator of bytes from the response - :rtype: Iterator[str] - """ - for raw_bytes in _stream_download_helper( - decompress=False, - response=self, - ): - yield raw_bytes - self.close() - - def read(self): - # type: () -> bytes - """Read the response's bytes. - - :return: The read in bytes - :rtype: bytes - """ - if not _has_content(self): - self._internal_response._content = b"".join(self.iter_bytes()) # pylint: disable=protected-access - return self.content + def __init__(self, **kwargs): + super(RestRequestsTransportResponse, self).__init__( + stream_download_generator=StreamDownloadGenerator, + **kwargs + ) diff --git a/sdk/core/azure-core/azure/core/rest/_requests_trio.py b/sdk/core/azure-core/azure/core/rest/_requests_trio.py index 9806380ef04f..c8e11222897a 100644 --- a/sdk/core/azure-core/azure/core/rest/_requests_trio.py +++ b/sdk/core/azure-core/azure/core/rest/_requests_trio.py @@ -23,55 +23,23 @@ # IN THE SOFTWARE. # # -------------------------------------------------------------------------- -from typing import AsyncIterator import trio -from . import AsyncHttpResponse -from ._requests_basic import _RestRequestsTransportResponseBase, _has_content -from ._helpers_py3 import iter_bytes_helper, iter_raw_helper +from ._http_response_impl_async import AsyncHttpResponseImpl +from ._requests_basic import _RestRequestsTransportResponseBase from ..pipeline.transport._requests_trio import TrioStreamDownloadGenerator -class RestTrioRequestsTransportResponse(AsyncHttpResponse, _RestRequestsTransportResponseBase): # type: ignore +class RestTrioRequestsTransportResponse(AsyncHttpResponseImpl, _RestRequestsTransportResponseBase): # type: ignore """Asynchronous streaming of data from the response. """ - async def iter_raw(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will not decompress in the process - :return: An async iterator of bytes from the response - :rtype: AsyncIterator[bytes] - """ - async for part in iter_raw_helper(TrioStreamDownloadGenerator, self): - yield part - await self.close() - - async def iter_bytes(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will decompress in the process - - :return: An async iterator of bytes from the response - :rtype: AsyncIterator[bytes] - """ - - async for part in iter_bytes_helper( - TrioStreamDownloadGenerator, - self, - content=self.content if _has_content(self) else None - ): - yield part - await self.close() - - async def read(self) -> bytes: - """Read the response's bytes into memory. - - :return: The response's bytes - :rtype: bytes - """ - if not _has_content(self): - parts = [] - async for part in self.iter_bytes(): # type: ignore - parts.append(part) - self._internal_response._content = b"".join(parts) # pylint: disable=protected-access - return self.content + def __init__(self, **kwargs): + super().__init__( + stream_download_generator=TrioStreamDownloadGenerator, + **kwargs + ) async def close(self) -> None: - self.is_closed = True - self._internal_response.close() - await trio.sleep(0) + if not self.is_closed: + self._is_closed = True + self._internal_response.close() + await trio.sleep(0) diff --git a/sdk/core/azure-core/azure/core/rest/_rest.py b/sdk/core/azure-core/azure/core/rest/_rest.py index d021c26b569e..734d28017ae2 100644 --- a/sdk/core/azure-core/azure/core/rest/_rest.py +++ b/sdk/core/azure-core/azure/core/rest/_rest.py @@ -23,12 +23,10 @@ # IN THE SOFTWARE. # # -------------------------------------------------------------------------- +import abc import copy -from json import loads -from typing import TYPE_CHECKING, cast - -from azure.core.exceptions import HttpResponseError +from typing import TYPE_CHECKING from ..utils._utils import _case_insensitive_dict from ._helpers import ( @@ -37,11 +35,8 @@ set_multipart_body, set_urlencoded_body, _format_parameters_helper, - get_charset_encoding, - decode_to_text, HttpRequestBackcompatMixin, ) -from ..exceptions import ResponseNotReadError if TYPE_CHECKING: from typing import ( Iterable, @@ -50,14 +45,17 @@ Iterator, Union, Dict, + MutableMapping, ) - from ._helpers import HeadersType ByteStream = Iterable[bytes] ContentType = Union[str, bytes, ByteStream] - from ._helpers import HeadersType, ContentTypeBase as ContentType - + from ._helpers import ContentTypeBase as ContentType +try: + ABC = abc.ABC +except AttributeError: # Python 2.7, abc exists, but not ABC + ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore ################################## CLASSES ###################################### @@ -126,7 +124,7 @@ def __init__(self, method, url, **kwargs): ) def _set_body(self, **kwargs): - # type: (Any) -> HeadersType + # type: (Any) -> MutableMapping[str, str] """Sets the body of the request, and returns the default headers """ content = kwargs.pop("content", None) @@ -185,30 +183,73 @@ def __deepcopy__(self, memo=None): except (ValueError, TypeError): return copy.copy(self) -class _HttpResponseBase(object): # pylint: disable=too-many-instance-attributes - - def __init__(self, **kwargs): - # type: (Any) -> None - self.request = kwargs.pop("request") - self._internal_response = kwargs.pop("internal_response") - self.status_code = None - self.headers = {} # type: HeadersType - self.reason = None - self.is_closed = False - self.is_stream_consumed = False - self.content_type = None - self._json = None # this is filled in ContentDecodePolicy, when we deserialize - self._connection_data_block_size = None # type: Optional[int] - self._content = None # type: Optional[bytes] - self._text = None # type: Optional[str] +class _HttpResponseBase(ABC): @property - def url(self): + @abc.abstractmethod + def request(self): + # type: (...) -> HttpRequest + """The request that resulted in this response. + + :rtype: ~azure.core.rest.HttpRequest + """ + + @property + @abc.abstractmethod + def status_code(self): + # type: (...) -> int + """The status code of this response. + + :rtype: int + """ + + @property + @abc.abstractmethod + def headers(self): + # type: (...) -> Optional[MutableMapping[str, str]] + """The response headers. Must be case-insensitive. + + :rtype: MutableMapping[str, str] + """ + + @property + @abc.abstractmethod + def reason(self): # type: (...) -> str - """Returns the URL that resulted in this response""" - return self.request.url + """The reason phrase for this response. + + :rtype: str + """ + + @property + @abc.abstractmethod + def content_type(self): + # type: (...) -> Optional[str] + """The content type of the response. + + :rtype: str + """ + + @property + @abc.abstractmethod + def is_closed(self): + # type: (...) -> bool + """Whether the network connection has been closed yet. + + :rtype: bool + """ @property + @abc.abstractmethod + def is_stream_consumed(self): + # type: (...) -> bool + """Whether the stream has been consumed. + + :rtype: bool + """ + + @property + @abc.abstractmethod def encoding(self): # type: (...) -> Optional[str] """Returns the response encoding. @@ -218,32 +259,36 @@ def encoding(self): we return `None`. :rtype: optional[str] """ - try: - return self._encoding - except AttributeError: - self._encoding = get_charset_encoding(self) # type: Optional[str] - return self._encoding @encoding.setter def encoding(self, value): # type: (str) -> None - """Sets the response encoding""" - self._encoding = value - self._text = None # clear text cache + """Sets the response encoding. + + :rtype: None + """ + + @property + @abc.abstractmethod + def url(self): + # type: (...) -> str + """The URL that resulted in this response. + + :rtype: str + """ + @abc.abstractmethod def text(self, encoding=None): # type: (Optional[str]) -> str - """Returns the response body as a string + """Returns the response body as a string. :param optional[str] encoding: The encoding you want to decode the text with. Can also be set independently through our encoding property :return: The response's content decoded as a string. + :rtype: str """ - if self._text is None or encoding: - encoding_to_pass = encoding or self.encoding - self._text = decode_to_text(encoding_to_pass, self.content) - return self._text + @abc.abstractmethod def json(self): # type: (...) -> Any """Returns the whole body as a json object. @@ -252,108 +297,82 @@ def json(self): :rtype: any :raises json.decoder.JSONDecodeError or ValueError (in python 2.7) if object is not JSON decodable: """ - # this will trigger errors if response is not read in - self.content # pylint: disable=pointless-statement - if not self._json: - self._json = loads(self.text()) - return self._json + @abc.abstractmethod def raise_for_status(self): # type: (...) -> None """Raises an HttpResponseError if the response has an error status code. If response is good, does nothing. + + :rtype: None + :raises ~azure.core.HttpResponseError if the object has an error status code.: """ - if cast(int, self.status_code) >= 400: - raise HttpResponseError(response=self) @property + @abc.abstractmethod def content(self): # type: (...) -> bytes - """Return the response's content in bytes.""" - if self._content is None: - raise ResponseNotReadError(self) - return self._content + """Return the response's content in bytes. + + :rtype: bytes + """ - def __repr__(self): - # type: (...) -> str - content_type_str = ( - ", Content-Type: {}".format(self.content_type) if self.content_type else "" - ) - return "".format( - self.status_code, self.reason, content_type_str - ) -class HttpResponse(_HttpResponseBase): # pylint: disable=too-many-instance-attributes - """**Provisional** object that represents an HTTP response. +class HttpResponse(_HttpResponseBase): + """**Provisional** abstract base class for HTTP responses. **This object is provisional**, meaning it may be changed in a future release. + Use this abstract base class to create your own transport responses. - It is returned from your client's `send_request` method if you pass in - an :class:`~azure.core.rest.HttpRequest` + Responses implementing this ABC are returned from your client's `send_request` method + if you pass in an :class:`~azure.core.rest.HttpRequest` >>> from azure.core.rest import HttpRequest >>> request = HttpRequest('GET', 'http://www.example.com') >>> response = client.send_request(request) - - :keyword request: The request that resulted in this response. - :paramtype request: ~azure.core.rest.HttpRequest - :ivar int status_code: The status code of this response - :ivar mapping headers: The case-insensitive response headers. - While looking up headers is case-insensitive, when looking up - keys in `header.keys()`, we recommend using lowercase. - :ivar str reason: The reason phrase for this response - :ivar bytes content: The response content in bytes. - :ivar str url: The URL that resulted in this response - :ivar str encoding: The response encoding. Is settable, by default - is the response Content-Type header - :ivar str text: The response body as a string. - :ivar request: The request that resulted in this response. - :vartype request: ~azure.core.rest.HttpRequest - :ivar str content_type: The content type of the response - :ivar bool is_closed: Whether the network connection has been closed yet - :ivar bool is_stream_consumed: When getting a stream response, checks - whether the stream has been fully consumed """ + @abc.abstractmethod def __enter__(self): # type: (...) -> HttpResponse - return self + """Enter this response""" + @abc.abstractmethod def close(self): # type: (...) -> None - self.is_closed = True - self._internal_response.close() + """Close this response""" + @abc.abstractmethod def __exit__(self, *args): # type: (...) -> None - self.close() + """Exit this response""" + @abc.abstractmethod def read(self): # type: (...) -> bytes - """ - Read the response's bytes. + """Read the response's bytes. + :return: The read in bytes + :rtype: bytes """ - if self._content is None: - self._content = b"".join(self.iter_bytes()) - return self.content + @abc.abstractmethod def iter_raw(self): # type: () -> Iterator[bytes] - """Iterate over the raw response bytes + """Iterates over the response's bytes. Will not decompress in the process. + + :return: An iterator of bytes from the response + :rtype: Iterator[str] """ - raise NotImplementedError() + @abc.abstractmethod def iter_bytes(self): # type: () -> Iterator[bytes] - """Iterate over the response bytes - """ - raise NotImplementedError() + """Iterates over the response's bytes. Will decompress in the process. - def _close_stream(self): - # type: (...) -> None - self.is_stream_consumed = True - self.close() + :return: An iterator of bytes from the response + :rtype: Iterator[str] + """ diff --git a/sdk/core/azure-core/azure/core/rest/_rest_py3.py b/sdk/core/azure-core/azure/core/rest/_rest_py3.py index e66cad7d34ee..2d96956434c1 100644 --- a/sdk/core/azure-core/azure/core/rest/_rest_py3.py +++ b/sdk/core/azure-core/azure/core/rest/_rest_py3.py @@ -23,10 +23,8 @@ # IN THE SOFTWARE. # # -------------------------------------------------------------------------- +import abc import copy -import collections -import collections.abc -from json import loads from typing import ( Any, AsyncIterable, @@ -34,51 +32,24 @@ Iterable, Iterator, Optional, Union, - cast, + MutableMapping, ) - -from azure.core.exceptions import HttpResponseError - from ..utils._utils import _case_insensitive_dict from ._helpers import ( ParamsType, FilesType, - HeadersType, set_json_body, set_multipart_body, set_urlencoded_body, _format_parameters_helper, - get_charset_encoding, - decode_to_text, HttpRequestBackcompatMixin, ) from ._helpers_py3 import set_content_body -from ..exceptions import ResponseNotReadError ContentType = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] -class _AsyncContextManager(collections.abc.Awaitable): - - def __init__(self, wrapped: collections.abc.Awaitable): - super().__init__() - self.wrapped = wrapped - self.response = None - - def __await__(self): - return self.wrapped.__await__() - - async def __aenter__(self): - self.response = await self - return self.response - - async def __aexit__(self, *args): - await self.response.__aexit__(*args) - - async def close(self): - await self.response.close() - ################################## CLASSES ###################################### class HttpRequest(HttpRequestBackcompatMixin): @@ -123,7 +94,7 @@ def __init__( url: str, *, params: Optional[ParamsType] = None, - headers: Optional[HeadersType] = None, + headers: Optional[MutableMapping[str, str]] = None, json: Any = None, content: Optional[ContentType] = None, data: Optional[dict] = None, @@ -160,10 +131,10 @@ def _set_body( data: Optional[dict] = None, files: Optional[FilesType] = None, json: Any = None, - ) -> HeadersType: + ) -> MutableMapping[str, str]: """Sets the body of the request, and returns the default headers """ - default_headers = {} # type: HeadersType + default_headers = {} # type: MutableMapping[str, str] if data is not None and not isinstance(data, dict): # should we warn? content = data @@ -206,33 +177,75 @@ def __deepcopy__(self, memo=None) -> "HttpRequest": except (ValueError, TypeError): return copy.copy(self) -class _HttpResponseBase: # pylint: disable=too-many-instance-attributes +class _HttpResponseBase(abc.ABC): + """Base abstract base class for HttpResponses. + """ - def __init__( - self, - *, - request: HttpRequest, - **kwargs - ): - self.request = request - self._internal_response = kwargs.pop("internal_response") - self.status_code = None - self.headers = _case_insensitive_dict({}) - self.reason = None - self.is_closed = False - self.is_stream_consumed = False - self.content_type = None - self._connection_data_block_size = None - self._json = None # this is filled in ContentDecodePolicy, when we deserialize - self._content = None # type: Optional[bytes] - self._text = None # type: Optional[str] + @property + @abc.abstractmethod + def request(self) -> HttpRequest: + """The request that resulted in this response. + + :rtype: ~azure.core.rest.HttpRequest + """ + ... @property - def url(self) -> str: - """Returns the URL that resulted in this response""" - return self.request.url + @abc.abstractmethod + def status_code(self) -> int: + """The status code of this response. + + :rtype: int + """ + ... @property + @abc.abstractmethod + def headers(self) -> MutableMapping[str, str]: + """The response headers. Must be case-insensitive. + + :rtype: MutableMapping[str, str] + """ + ... + + @property + @abc.abstractmethod + def reason(self) -> str: + """The reason phrase for this response. + + :rtype: str + """ + ... + + @property + @abc.abstractmethod + def content_type(self) -> Optional[str]: + """The content type of the response. + + :rtype: str + """ + ... + + @property + @abc.abstractmethod + def is_closed(self) -> bool: + """Whether the network connection has been closed yet. + + :rtype: bool + """ + ... + + @property + @abc.abstractmethod + def is_stream_consumed(self) -> bool: + """Whether the stream has been consumed. + + :rtype: bool + """ + ... + + @property + @abc.abstractmethod def encoding(self) -> Optional[str]: """Returns the response encoding. @@ -241,30 +254,45 @@ def encoding(self) -> Optional[str]: we return `None`. :rtype: optional[str] """ - try: - return self._encoding - except AttributeError: - self._encoding: Optional[str] = get_charset_encoding(self) - return self._encoding + ... @encoding.setter - def encoding(self, value: str) -> None: - """Sets the response encoding""" - self._encoding = value - self._text = None # clear text cache + def encoding(self, value: Optional[str]) -> None: + """Sets the response encoding. + :rtype: None + """ + + @property + @abc.abstractmethod + def url(self) -> str: + """The URL that resulted in this response. + + :rtype: str + """ + ... + + @property + @abc.abstractmethod + def content(self) -> bytes: + """Return the response's content in bytes. + + :rtype: bytes + """ + ... + + @abc.abstractmethod def text(self, encoding: Optional[str] = None) -> str: - """Returns the response body as a string + """Returns the response body as a string. :param optional[str] encoding: The encoding you want to decode the text with. Can also be set independently through our encoding property :return: The response's content decoded as a string. + :rtype: str """ - if self._text is None or encoding: - encoding_to_pass = encoding or self.encoding - self._text = decode_to_text(encoding_to_pass, self.content) - return self._text + ... + @abc.abstractmethod def json(self) -> Any: """Returns the whole body as a json object. @@ -272,101 +300,73 @@ def json(self) -> Any: :rtype: any :raises json.decoder.JSONDecodeError or ValueError (in python 2.7) if object is not JSON decodable: """ - # this will trigger errors if response is not read in - self.content # pylint: disable=pointless-statement - if self._json is None: - self._json = loads(self.text()) - return self._json + ... + @abc.abstractmethod def raise_for_status(self) -> None: """Raises an HttpResponseError if the response has an error status code. If response is good, does nothing. - """ - if cast(int, self.status_code) >= 400: - raise HttpResponseError(response=self) - @property - def content(self) -> bytes: - """Return the response's content in bytes.""" - if self._content is None: - raise ResponseNotReadError(self) - return self._content + :rtype: None + :raises ~azure.core.HttpResponseError if the object has an error status code.: + """ + ... class HttpResponse(_HttpResponseBase): - """**Provisional** object that represents an HTTP response. + """**Provisional** abstract base class for HTTP responses. **This object is provisional**, meaning it may be changed in a future release. + Use this abstract base class to create your own transport responses. - It is returned from your client's `send_request` method if you pass in - an :class:`~azure.core.rest.HttpRequest` + Responses implementing this ABC are returned from your client's `send_request` method + if you pass in an :class:`~azure.core.rest.HttpRequest` >>> from azure.core.rest import HttpRequest >>> request = HttpRequest('GET', 'http://www.example.com') >>> response = client.send_request(request) - - :keyword request: The request that resulted in this response. - :paramtype request: ~azure.core.rest.HttpRequest - :ivar int status_code: The status code of this response - :ivar mapping headers: The case-insensitive response headers. - While looking up headers is case-insensitive, when looking up - keys in `header.keys()`, we recommend using lowercase. - :ivar str reason: The reason phrase for this response - :ivar bytes content: The response content in bytes. - :ivar str url: The URL that resulted in this response - :ivar str encoding: The response encoding. Is settable, by default - is the response Content-Type header - :ivar str text: The response body as a string. - :ivar request: The request that resulted in this response. - :vartype request: ~azure.core.rest.HttpRequest - :ivar str content_type: The content type of the response - :ivar bool is_closed: Whether the network connection has been closed yet - :ivar bool is_stream_consumed: When getting a stream response, checks - whether the stream has been fully consumed """ + @abc.abstractmethod def __enter__(self) -> "HttpResponse": - return self - - def close(self) -> None: - """Close the response - - :return: None - :rtype: None - """ - self.is_closed = True - self._internal_response.close() + ... + @abc.abstractmethod def __exit__(self, *args) -> None: - self.close() + ... + @abc.abstractmethod + def close(self) -> None: + ... + + @abc.abstractmethod def read(self) -> bytes: """Read the response's bytes. :return: The read in bytes :rtype: bytes """ - if self._content is None: - self._content = b"".join(self.iter_bytes()) - return self.content + ... + @abc.abstractmethod def iter_raw(self) -> Iterator[bytes]: - """Iterates over the response's bytes. Will not decompress in the process + """Iterates over the response's bytes. Will not decompress in the process. :return: An iterator of bytes from the response :rtype: Iterator[str] """ - raise NotImplementedError() + ... + @abc.abstractmethod def iter_bytes(self) -> Iterator[bytes]: - """Iterates over the response's bytes. Will decompress in the process + """Iterates over the response's bytes. Will decompress in the process. :return: An iterator of bytes from the response :rtype: Iterator[str] """ - raise NotImplementedError() + ... def __repr__(self) -> str: content_type_str = ( @@ -377,52 +377,33 @@ def __repr__(self) -> str: ) class AsyncHttpResponse(_HttpResponseBase): - """**Provisional** object that represents an Async HTTP response. + """**Provisional** abstract base class for Async HTTP responses. **This object is provisional**, meaning it may be changed in a future release. + Use this abstract base class to create your own transport responses. - It is returned from your async client's `send_request` method if you pass in - an :class:`~azure.core.rest.HttpRequest` + Responses implementing this ABC are returned from your async client's `send_request` + method if you pass in an :class:`~azure.core.rest.HttpRequest` >>> from azure.core.rest import HttpRequest >>> request = HttpRequest('GET', 'http://www.example.com') >>> response = await client.send_request(request) - - :keyword request: The request that resulted in this response. - :paramtype request: ~azure.core.rest.HttpRequest - :ivar int status_code: The status code of this response - :ivar mapping headers: The response headers - :ivar str reason: The reason phrase for this response - :ivar bytes content: The response content in bytes. - :ivar str url: The URL that resulted in this response - :ivar str encoding: The response encoding. Is settable, by default - is the response Content-Type header - :ivar str text: The response body as a string. - :ivar request: The request that resulted in this response. - :vartype request: ~azure.core.rest.HttpRequest - :ivar str content_type: The content type of the response - :ivar bool is_closed: Whether the network connection has been closed yet - :ivar bool is_stream_consumed: When getting a stream response, checks - whether the stream has been fully consumed """ + @abc.abstractmethod async def read(self) -> bytes: """Read the response's bytes into memory. :return: The response's bytes :rtype: bytes """ - if self._content is None: - parts = [] - async for part in self.iter_bytes(): - parts.append(part) - self._content = b"".join(parts) - return self._content + ... + @abc.abstractmethod async def iter_raw(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will not decompress in the process + """Asynchronously iterates over the response's bytes. Will not decompress in the process. :return: An async iterator of bytes from the response :rtype: AsyncIterator[bytes] @@ -431,8 +412,9 @@ async def iter_raw(self) -> AsyncIterator[bytes]: # getting around mypy behavior, see https://github.com/python/mypy/issues/10732 yield # pylint: disable=unreachable + @abc.abstractmethod async def iter_bytes(self) -> AsyncIterator[bytes]: - """Asynchronously iterates over the response's bytes. Will decompress in the process + """Asynchronously iterates over the response's bytes. Will decompress in the process. :return: An async iterator of bytes from the response :rtype: AsyncIterator[bytes] @@ -441,22 +423,10 @@ async def iter_bytes(self) -> AsyncIterator[bytes]: # getting around mypy behavior, see https://github.com/python/mypy/issues/10732 yield # pylint: disable=unreachable + @abc.abstractmethod async def close(self) -> None: - """Close the response. - - :return: None - :rtype: None - """ - self.is_closed = True - await self._internal_response.close() + ... + @abc.abstractmethod async def __aexit__(self, *args) -> None: - await self.close() - - def __repr__(self) -> str: - content_type_str = ( - ", Content-Type: {}".format(self.content_type) if self.content_type else "" - ) - return "".format( - self.status_code, self.reason, content_type_str - ) + ... diff --git a/sdk/core/azure-core/tests/async_tests/test_rest_asyncio_transport.py b/sdk/core/azure-core/tests/async_tests/test_rest_asyncio_transport.py index 9f5052cdb4bd..11a2ca3f52ca 100644 --- a/sdk/core/azure-core/tests/async_tests/test_rest_asyncio_transport.py +++ b/sdk/core/azure-core/tests/async_tests/test_rest_asyncio_transport.py @@ -5,10 +5,11 @@ # ------------------------------------------------------------------------- from azure.core.pipeline.transport import AsyncioRequestsTransport from azure.core.rest import HttpRequest +from azure.core.rest._requests_asyncio import RestAsyncioRequestsTransportResponse from rest_client_async import AsyncTestRestClient import pytest - +from utils import readonly_checks @pytest.mark.asyncio async def test_async_gen_data(port): @@ -39,3 +40,14 @@ async def test_send_data(port): response = await client.send_request(request) assert response.json()['data'] == "azerty" + +@pytest.mark.asyncio +async def test_readonly(port): + """Make sure everything that is readonly is readonly""" + async with AsyncioRequestsTransport() as transport: + client = AsyncTestRestClient(port, transport=transport) + response = await client.send_request(HttpRequest("GET", "/health")) + response.raise_for_status() + + assert isinstance(response, RestAsyncioRequestsTransportResponse) + readonly_checks(response) diff --git a/sdk/core/azure-core/tests/async_tests/test_rest_http_response_async.py b/sdk/core/azure-core/tests/async_tests/test_rest_http_response_async.py index 40587252a14f..7239dcd7a547 100644 --- a/sdk/core/azure-core/tests/async_tests/test_rest_http_response_async.py +++ b/sdk/core/azure-core/tests/async_tests/test_rest_http_response_async.py @@ -8,8 +8,10 @@ # Thank you httpx for your wonderful tests! import io import pytest -from azure.core.rest import HttpRequest +from azure.core.rest import HttpRequest, AsyncHttpResponse +from azure.core.rest._aiohttp import RestAioHttpTransportResponse from azure.core.exceptions import HttpResponseError +from utils import readonly_checks @pytest.fixture def send_request(client): @@ -294,4 +296,17 @@ async def test_text_and_encoding(send_request): # "/multipart/non-seekable-filelike", # files=files, # ) -# await send_request(request) \ No newline at end of file +# await send_request(request) + +def test_initialize_response_abc(): + with pytest.raises(TypeError) as ex: + AsyncHttpResponse() + assert "Can't instantiate abstract class" in str(ex) + +@pytest.mark.asyncio +async def test_readonly(send_request): + """Make sure everything that is readonly is readonly""" + response = await send_request(HttpRequest("GET", "/health")) + + assert isinstance(response, RestAioHttpTransportResponse) + readonly_checks(response) diff --git a/sdk/core/azure-core/tests/async_tests/test_rest_trio_transport.py b/sdk/core/azure-core/tests/async_tests/test_rest_trio_transport.py index ba9981df6697..18eb6ad7a84b 100644 --- a/sdk/core/azure-core/tests/async_tests/test_rest_trio_transport.py +++ b/sdk/core/azure-core/tests/async_tests/test_rest_trio_transport.py @@ -5,8 +5,9 @@ # ------------------------------------------------------------------------- from azure.core.pipeline.transport import TrioRequestsTransport from azure.core.rest import HttpRequest +from azure.core.rest._requests_trio import RestTrioRequestsTransportResponse from rest_client_async import AsyncTestRestClient - +from utils import readonly_checks import pytest @@ -39,3 +40,15 @@ async def test_send_data(port): response = await client.send_request(request) assert response.json()['data'] == "azerty" + +@pytest.mark.trio +async def test_readonly(port): + """Make sure everything that is readonly is readonly""" + async with TrioRequestsTransport() as transport: + request = HttpRequest('GET', 'http://localhost:{}/health'.format(port)) + client = AsyncTestRestClient(port, transport=transport) + response = await client.send_request(HttpRequest("GET", "/health")) + response.raise_for_status() + + assert isinstance(response, RestTrioRequestsTransportResponse) + readonly_checks(response) diff --git a/sdk/core/azure-core/tests/test_rest_http_response.py b/sdk/core/azure-core/tests/test_rest_http_response.py index f3abec23a30a..db411943ec1c 100644 --- a/sdk/core/azure-core/tests/test_rest_http_response.py +++ b/sdk/core/azure-core/tests/test_rest_http_response.py @@ -11,9 +11,11 @@ import io import sys import pytest -from azure.core.rest import HttpRequest +from azure.core.rest import HttpRequest, HttpResponse +from azure.core.rest._requests_basic import RestRequestsTransportResponse from azure.core.exceptions import HttpResponseError import xml.etree.ElementTree as ET +from utils import readonly_checks @pytest.fixture def send_request(client): @@ -311,3 +313,28 @@ def test_text_and_encoding(send_request): # assert latin-1 changes text decoding without changing encoding property assert response.text("latin-1") == u'ð\x9f\x91©' == response.content.decode("latin-1") assert response.encoding == "utf-16" + +def test_passing_encoding_to_text(send_request): + response = send_request( + request=HttpRequest("GET", "/encoding/emoji"), + ) + assert response.content == u"👩".encode("utf-8") + assert response.text() == u"👩" + + # pass in different encoding + assert response.text("latin-1") == u'ð\x9f\x91©' + + # check response.text() still gets us the old value + assert response.text() == u"👩" + +def test_initialize_response_abc(): + with pytest.raises(TypeError) as ex: + HttpResponse() + assert "Can't instantiate abstract class" in str(ex) + +def test_readonly(send_request): + """Make sure everything that is readonly is readonly""" + response = send_request(HttpRequest("GET", "/health")) + + assert isinstance(response, RestRequestsTransportResponse) + readonly_checks(response) diff --git a/sdk/core/azure-core/tests/test_rest_stream_responses.py b/sdk/core/azure-core/tests/test_rest_stream_responses.py index a62a64e9b121..841927430b83 100644 --- a/sdk/core/azure-core/tests/test_rest_stream_responses.py +++ b/sdk/core/azure-core/tests/test_rest_stream_responses.py @@ -82,7 +82,6 @@ def test_iter_bytes(client): @pytest.mark.skip(reason="We've gotten rid of iter_text for now") def test_iter_text(client): request = HttpRequest("GET", "/basic/string") - with client.send_request(request, stream=True) as response: content = "" for part in response.iter_text(): diff --git a/sdk/core/azure-core/tests/utils.py b/sdk/core/azure-core/tests/utils.py index ab3be4d12d1d..f025a124d745 100644 --- a/sdk/core/azure-core/tests/utils.py +++ b/sdk/core/azure-core/tests/utils.py @@ -41,3 +41,45 @@ def create_http_request(http_request, *args, **kwargs): **kwargs ) return http_request(*args, **kwargs) + +def readonly_checks(response): + assert isinstance(response.request, RestHttpRequest) + with pytest.raises(AttributeError): + response.request = None + + assert isinstance(response.status_code, int) + with pytest.raises(AttributeError): + response.status_code = 200 + + assert response.headers + with pytest.raises(AttributeError): + response.headers = {"hello": "world"} + + assert response.reason == "OK" + with pytest.raises(AttributeError): + response.reason = "Not OK" + + assert response.content_type == 'text/html; charset=utf-8' + with pytest.raises(AttributeError): + response.content_type = "bad content type" + + assert response.is_closed + with pytest.raises(AttributeError): + response.is_closed = False + + assert response.is_stream_consumed + with pytest.raises(AttributeError): + response.is_stream_consumed = False + + # you can set encoding + assert response.encoding == "utf-8" + response.encoding = "blah" + assert response.encoding == "blah" + + assert isinstance(response.url, str) + with pytest.raises(AttributeError): + response.url = "http://fakeurl" + + assert response.content is not None + with pytest.raises(AttributeError): + response.content = b"bad"