aio-libs / aiobotocore

asyncio support for botocore library using aiohttp
https://aiobotocore.aio-libs.org
Apache License 2.0
1.2k stars 183 forks source link

support moto client wrappers #755

Open dazza-codes opened 4 years ago

dazza-codes commented 4 years ago

This bug arises in pytest with moto 1.3.14 and althoughrequirements-dev.txt has a dev-version, that fix is for something else, i.e. this is irrelevant:

# We need: https://github.com/spulec/moto/pull/2436
moto==1.3.14.dev326

See also:

Below is an exception detail, when testing the following pytest fixtures:

from moto import mock_config
from moto import mock_batch

@pytest.fixture(scope="module")
def aws_region():
    return "us-west-2"

@pytest.fixture
@pytest.mark.moto
def aio_aws_session(event_loop):
    with mock_config():
        aws_session = aiobotocore.get_session(loop=event_loop)
        yield aws_session

@pytest.fixture
@pytest.mark.moto
async def aio_aws_batch_client(aio_aws_session, aws_region):
    with mock_config():
        with mock_batch():
            async with aio_aws_session.create_client("batch", region_name=aws_region) as client:
                yield client

This raises a simple exception when trying to parse a moto response (below) and the source code for botocore seems to match (there is no AWSResponse.raw_headers attr). Maybe there are API version differences between aiobotocore, botocore and moto (at the time of posting this issue). In the project, the requirements pull in the aiobotocore deps for boto3/botocore and moto is the latest release:

aiobotocore==0.11.1
boto==2.49.0
boto3==1.10.14
botocore==1.13.14
moto==1.3.14
$ python --version
Python 3.6.7
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.4 LTS"

The simple test function is:

@pytest.mark.asyncio
async def test_async_aws_batch_client(aio_aws_batch_client):
    assert isinstance(aio_aws_batch_client, BaseClient)
    job_queues = await aio_aws_batch_client.describe_job_queues()
    # AttributeError: 'AWSResponse' object has no attribute 'raw_headers'

The moto job-queues should be an empty list (and it is, see pdb details below).

>       job_queues = await aio_aws_batch_client.describe_job_queues()

tests/aws/test_async_aws_batch.py:56: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/client.py:89: in _make_api_call
    operation_model, request_dict, request_context)
/opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/client.py:110: in _make_request
    request_dict)
/opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/endpoint.py:73: in _send_request
    request, operation_model, context)
/opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/endpoint.py:106: in _get_response
    request, operation_model)
/opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/endpoint.py:154: in _do_get_response
    operation_model)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

http_response = <botocore.awsrequest.AWSResponse object at 0x7eff6ebdc6d8>, operation_model = OperationModel(name=DescribeJobQueues)

    async def convert_to_response_dict(http_response, operation_model):
        """Convert an HTTP response object to a request dict.

        This converts the requests library's HTTP response object to
        a dictionary.

        :type http_response: botocore.vendored.requests.model.Response
        :param http_response: The HTTP response from an AWS service request.

        :rtype: dict
        :return: A response dictionary which will contain the following keys:
            * headers (dict)
            * status_code (int)
            * body (string or file-like object)

        """
        response_dict = {
            # botocore converts keys to str, so make sure that they are in
            # the expected case. See detailed discussion here:
            # https://github.com/aio-libs/aiobotocore/pull/116
            # aiohttp's CIMultiDict camel cases the headers :(
            'headers': HTTPHeaderDict(
                {k.decode('utf-8').lower(): v.decode('utf-8')
>                for k, v in http_response.raw_headers}),
            'status_code': http_response.status_code,
            'context': {
                'operation_name': operation_model.name,
            }
        }
E       AttributeError: 'AWSResponse' object has no attribute 'raw_headers'

/opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/endpoint.py:43: AttributeError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /opt/conda/envs/python-notes/lib/python3.6/site-packages/aiobotocore/endpoint.py(43)convert_to_response_dict()
-> for k, v in http_response.raw_headers}),

(Pdb) http_response
<botocore.awsrequest.AWSResponse object at 0x7fed5d7c62b0>
(Pdb) dir(http_response)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_content', 'content', 'headers', 'raw', 'status_code', 'text', 'url']

(Pdb) http_response.headers
{'server': 'amazon.com'}
(Pdb) http_response.content
b'{"jobQueues": []}'
(Pdb) http_response.status_code
200
(Pdb) http_response.text
'{"jobQueues": []}'
(Pdb) http_response.url
'https://batch.us-west-2.amazonaws.com/v1/describejobqueues'

(Pdb) http_response.raw
<moto.core.models.MockRawResponse object at 0x7eff6ed909e8>
(Pdb) dir(http_response.raw)
['__class__', '__del__', '__delattr__', '__dict__', '__dir__', '__doc__', '__enter__', '__eq__', '__exit__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__next__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '_checkClosed', '_checkReadable', '_checkSeekable', '_checkWritable', 'close', 'closed', 'detach', 'fileno', 'flush', 'getbuffer', 'getvalue', 'isatty', 'read', 'read1', 'readable', 'readinto', 'readinto1', 'readline', 'readlines', 'seek', 'seekable', 'stream', 'tell', 'truncate', 'writable', 'write', 'writelines']
(Pdb) http_response.raw.readlines()
[]

Note that the moto response is an botocore.awsrequest.AWSResponse and not a

ozturkberkay commented 1 year ago

This is how I fixed it for moto==4.1.15:

class MockedAWSResponse(AWSResponse):
    raw_headers = {}  # type: ignore

    async def read(self):  # type: ignore
        return self.text

@contextmanager
def patch_async_botocore_moto():
    with ExitStack() as stack: 
        target_botocore = "botocore.awsrequest.AWSResponse"
        patch_botocore = patch(target_botocore, MockedAWSResponse)
        stack.enter_context(patch_botocore)
        target_moto = "moto.core.botocore_stubber.AWSResponse"
        patch_moto = patch(target_moto, MockedAWSResponse)
        stack.enter_context(patch_moto)
        yield
Apakottur commented 1 year ago

Here's the 2023 version of my patch, passes strict mypy and works with a bunch of functions from S3, SQS and CloudFront. moto = "4.2.0" aiobotocore = "2.6.0" types-aiobotocore = "2.6.0"

from collections.abc import Awaitable, Callable, Iterator
from dataclasses import dataclass
from typing import TypeVar

import aiobotocore
import aiobotocore.endpoint
import botocore

T = TypeVar("T")
R = TypeVar("R")

@dataclass
class _PatchedAWSReponseContent:
    """Patched version of `botocore.awsrequest.AWSResponse.content`"""

    content: bytes | Awaitable[bytes]

    def __await__(self) -> Iterator[bytes]:
        async def _generate_async() -> bytes:
            if isinstance(self.content, Awaitable):
                return await self.content
            else:
                return self.content

        return _generate_async().__await__()

    def decode(self, encoding: str) -> str:
        assert isinstance(self.content, bytes)
        return self.content.decode(encoding)

class PatchedAWSResponse:
    """Patched version of `botocore.awsrequest.AWSResponse`"""

    def __init__(self, response: botocore.awsrequest.AWSResponse) -> None:
        self._response = response
        self.status_code = response.status_code
        self.content = _PatchedAWSReponseContent(response.content)
        self.raw = response.raw
        if not hasattr(self.raw, "raw_headers"):
            self.raw.raw_headers = {}

def _factory(
    original: Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]
) -> Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]:
    async def patched_convert_to_response_dict(http_response: botocore.awsrequest.AWSResponse, operation_model: T) -> R:
        return await original(PatchedAWSResponse(http_response), operation_model)  # type: ignore[arg-type]

    return patched_convert_to_response_dict

aiobotocore.endpoint.convert_to_response_dict = _factory(aiobotocore.endpoint.convert_to_response_dict)  # type: ignore[assignment]
takeda commented 1 year ago

Right now the unit tests via Stubber are broken: https://github.com/aio-libs/aiobotocore/issues/939 as well as moto (which was a great surprise when I started work on migrating unit tests from one to the other).

I'm also frustrated that Amazon doesn't provide async support natively. It would be much easier for them to add it to boto3 than for @thehesiod to patch the package up. There's a ticket that was opened for 8 years now: https://github.com/boto/botocore/issues/458. It's not like AWS service is free to use. Stuff like this should be provided by Amazon not volunteers.

@Apakottur I'm glad that it looks like there's a workaround for moto at least, so far it looks like resolved my issue.

lokucrazy commented 1 year ago

Hi I was just wondering if there was gonna be a fix for this. @juftin fix helped me get past it, but I'm not sure I understand if this is something that needs to be fixed or just a weird quirk of everything.

msinto93 commented 1 year ago

Here's the 2023 version of my patch, passes strict mypy and works with a bunch of functions from S3, SQS and CloudFront. moto = "4.2.0" aiobotocore = "2.6.0" types-aiobotocore = "2.6.0"

from collections.abc import Awaitable, Callable, Iterator
from dataclasses import dataclass
from typing import TypeVar

import aiobotocore
import aiobotocore.endpoint
import botocore

T = TypeVar("T")
R = TypeVar("R")

@dataclass
class _PatchedAWSReponseContent:
    """Patched version of `botocore.awsrequest.AWSResponse.content`"""

    content: bytes | Awaitable[bytes]

    def __await__(self) -> Iterator[bytes]:
        async def _generate_async() -> bytes:
            if isinstance(self.content, Awaitable):
                return await self.content
            else:
                return self.content

        return _generate_async().__await__()

    def decode(self, encoding: str) -> str:
        assert isinstance(self.content, bytes)
        return self.content.decode(encoding)

class PatchedAWSResponse:
    """Patched version of `botocore.awsrequest.AWSResponse`"""

    def __init__(self, response: botocore.awsrequest.AWSResponse) -> None:
        self._response = response
        self.status_code = response.status_code
        self.content = _PatchedAWSReponseContent(response.content)
        self.raw = response.raw
        if not hasattr(self.raw, "raw_headers"):
            self.raw.raw_headers = {}

def _factory(
    original: Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]
) -> Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]:
    async def patched_convert_to_response_dict(http_response: botocore.awsrequest.AWSResponse, operation_model: T) -> R:
        return await original(PatchedAWSResponse(http_response), operation_model)  # type: ignore[arg-type]

    return patched_convert_to_response_dict

aiobotocore.endpoint.convert_to_response_dict = _factory(aiobotocore.endpoint.convert_to_response_dict)  # type: ignore[assignment]

This is great @Apakottur, I needed a few slight modifications to work with my tests (mainly had to also patch the response object used in the RetryContext). Sharing below.

This has been tested with calls to DynamoDB and SQS, with the following package versions: moto == "4.2.5" aiobotocore == "2.6.0"

from collections.abc import Awaitable, Callable, Iterator
from dataclasses import dataclass
from typing import TypeVar

import aiobotocore
import aiobotocore.endpoint
import botocore
import botocore.retries.standard

T = TypeVar("T")
R = TypeVar("R")

@dataclass
class _PatchedAWSReponseContent:
    """Patched version of `botocore.awsrequest.AWSResponse.content`"""

    content: bytes | Awaitable[bytes]

    def __await__(self) -> Iterator[bytes]:
        async def _generate_async() -> bytes:
            if isinstance(self.content, Awaitable):
                return await self.content
            else:
                return self.content

        return _generate_async().__await__()

    def decode(self, encoding: str) -> str:
        assert isinstance(self.content, bytes)
        return self.content.decode(encoding)

class PatchedAWSResponse:
    """Patched version of `botocore.awsrequest.AWSResponse`"""

      def __init__(self, response: botocore.awsrequest.AWSResponse) -> None:
          self._response = response
          self.status_code = response.status_code
          self.headers = response.headers
          self.url = response.url
          self.content = _PatchedAWSReponseContent(response.content)
          self.raw = response.raw
          if not hasattr(self.raw, "raw_headers"):
              self.raw.raw_headers = {}

class PatchedRetryContext(botocore.retries.standard.RetryContext):
    """Patched version of `botocore.retries.standard.RetryContext`"""

    def __init__(self, *args, **kwargs):
        if kwargs.get("http_response"):
            kwargs["http_response"] = PatchedAWSResponse(kwargs["http_response"])
        super().__init__(*args, **kwargs)

def _factory(
    original: Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]
) -> Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]:
    async def patched_convert_to_response_dict(http_response: botocore.awsrequest.AWSResponse, operation_model: T) -> R:
        return await original(PatchedAWSResponse(http_response), operation_model)  # type: ignore[arg-type]

    return patched_convert_to_response_dict

aiobotocore.endpoint.convert_to_response_dict = _factory(aiobotocore.endpoint.convert_to_response_dict)  # type: ignore[assignment]
botocore.retries.standard.RetryContext = PatchedRetryContext
earonesty commented 1 year ago

raw_headers isn't technically supported by aws standard, so probably it should be removed from aiobotocore anyway

thehesiod commented 1 year ago

raw headers is necessary as aiohttp munges the response header names which breaks the AWS API calls. I expressed to the aiohttp group they should stop doing this but they decided instead to expose this attribute :(

mikeedjones commented 3 months ago

Here's the 2023 version of my patch, passes strict mypy and works with a bunch of functions from S3, SQS and CloudFront. moto = "4.2.0" aiobotocore = "2.6.0" types-aiobotocore = "2.6.0"

from collections.abc import Awaitable, Callable, Iterator
...

This is great @Apakottur, I needed a few slight modifications to work with my tests (mainly had to also patch the response object used in the RetryContext). Sharing below.

This has been tested with calls to DynamoDB and SQS, with the following package versions: moto == "4.2.5" aiobotocore == "2.6.0"

from collections.abc import Awaitable, Callable, Iterator
...

Thanks for this!

Wrapped up with the mock_aws() context manager for anyone else using @Apakottur, @msinto93 solution with pytest:


# Attempt to import optional dependencies
from collections.abc import Awaitable, Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Generator, TypeVar

import aiobotocore
import aiobotocore.endpoint
import botocore
import botocore.retries.standard

import pytest
from moto import mock_aws

T = TypeVar("T")
R = TypeVar("R")

@dataclass
class _PatchedAWSReponseContent:
    """Patched version of `botocore.awsrequest.AWSResponse.content`"""

    content: bytes | Awaitable[bytes]

    def __await__(self) -> Iterator[bytes]:
        async def _generate_async() -> bytes:
            if isinstance(self.content, Awaitable):
                return await self.content
            else:
                return self.content

        return _generate_async().__await__()

    def decode(self, encoding: str) -> str:
        assert isinstance(self.content, bytes)
        return self.content.decode(encoding)

class PatchedAWSResponse:
    """Patched version of `botocore.awsrequest.AWSResponse`"""

    def __init__(self, response: botocore.awsrequest.AWSResponse) -> None:
        self._response = response
        self.status_code = response.status_code
        self.headers = response.headers
        self.url = response.url
        self.content = _PatchedAWSReponseContent(response.content)
        self.raw = response.raw
        if not hasattr(self.raw, "raw_headers"):
            self.raw.raw_headers = {}

class PatchedRetryContext(botocore.retries.standard.RetryContext):
    """Patched version of `botocore.retries.standard.RetryContext`"""

    def __init__(self, *args, **kwargs):
        if kwargs.get("http_response"):
            kwargs["http_response"] = PatchedAWSResponse(kwargs["http_response"])
        super().__init__(*args, **kwargs)

def _factory(
    original: Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]],
) -> Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]:
    async def patched_convert_to_response_dict(http_response: botocore.awsrequest.AWSResponse, operation_model: T) -> R:
        return await original(PatchedAWSResponse(http_response), operation_model)  # type: ignore[arg-type]

    return patched_convert_to_response_dict

@contextmanager
def mock_aio_aws(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:

    # Patch aiobotocore and botocore
    monkeypatch.setattr(
        aiobotocore.endpoint, "convert_to_response_dict", _factory(aiobotocore.endpoint.convert_to_response_dict)
    )
    monkeypatch.setattr(botocore.retries.standard, "RetryContext", PatchedRetryContext)
    with mock_aws():
        yield

Which can be used like:

import boto3
import pytest
from botocore.exceptions import ClientError

from .test_utils import mock_aio_aws

@pytest.fixture()
def mock_aws(monkeypatch):
    with mock_aio_aws(monkeypatch):
        yield
harvey251 commented 2 months ago

Just building on whats before

I needed to remove the self.headers["x-amz-crc32"] = None and I rewrote the context manager so it can be a drop in replacement so now you can just do from moto_patch import mock_aio_aws as mock_aws without passing around monkeypatch

moto_patch.py

# Attempt to import optional dependencies
from collections.abc import Awaitable, Callable, Generator, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TypeVar
from unittest.mock import patch

import aiobotocore
import aiobotocore.endpoint
import botocore
import botocore.retries.standard
from moto import mock_aws

T = TypeVar("T")
R = TypeVar("R")

@dataclass
class _PatchedAWSReponseContent:
    """Patched version of `botocore.awsrequest.AWSResponse.content`"""

    content: bytes | Awaitable[bytes]

    def __await__(self) -> Iterator[bytes]:
        async def _generate_async() -> bytes:
            if isinstance(self.content, Awaitable):
                return await self.content
            return self.content

        return _generate_async().__await__()

    def decode(self, encoding: str) -> str:
        assert isinstance(self.content, bytes)
        return self.content.decode(encoding)

class PatchedAWSResponse:
    """Patched version of `botocore.awsrequest.AWSResponse`"""

    def __init__(self, response: botocore.awsrequest.AWSResponse) -> None:
        self._response = response
        self.status_code = response.status_code

        #  '317822581'{'server': 'amazon.com', 'date': 'Thu, 29 Aug 2024 17:10:05 GMT', 'x-amzn-requestid': 'Rlz2JkR24Tzbh5GEFyIBCKempp5HjXw6uh17z5J5EtoGhW4Udr97', 'x-amz-crc32': '317822581'}
        self.headers = response.headers
        self.headers["x-amz-crc32"] = None
        self.url = response.url
        self.content = _PatchedAWSReponseContent(response.content)
        self._content = self.content
        self.raw = response.raw
        self.text = response.text
        if not hasattr(self.raw, "raw_headers"):
            self.raw.raw_headers = {}

class PatchedRetryContext(botocore.retries.standard.RetryContext):
    """Patched version of `botocore.retries.standard.RetryContext`"""

    def __init__(self, *args, **kwargs):
        if kwargs.get("http_response"):
            kwargs["http_response"] = PatchedAWSResponse(kwargs["http_response"])
        super().__init__(*args, **kwargs)

def _factory(
    original: Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]],
) -> Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]:
    async def patched_convert_to_response_dict(http_response: botocore.awsrequest.AWSResponse, operation_model: T) -> R:
        return await original(PatchedAWSResponse(http_response), operation_model)  # type: ignore[arg-type]

    return patched_convert_to_response_dict

@contextmanager
def mock_aio_aws() -> Generator[None, None, None]:
    with (
        patch(
            "aiobotocore.endpoint.convert_to_response_dict", new=_factory(aiobotocore.endpoint.convert_to_response_dict)
        ),
        patch("botocore.retries.standard.RetryContext", new=PatchedRetryContext),
        mock_aws(),
    ):
        yield
rdbisme commented 1 month ago

Can we revisit this to make them part of, I don't know, moto? It's a bit disappointing that thousands of people need to backport this to their codebase.

I'm pretty sure this is 100% required when using s3fs.

olly-writes-code commented 1 month ago

I was trying to use @harvey251's approach for fsspec but I can't get it play nice :/

Patch

from collections.abc import Awaitable, Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Generator, TypeVar

import aiobotocore
import aiobotocore.endpoint
import botocore
import botocore.retries.standard
from moto import mock_aws

import pytest

T = TypeVar("T")
R = TypeVar("R")

@dataclass
class _PatchedAWSReponseContent:
    """Patched version of `botocore.awsrequest.AWSResponse.content`"""

    content: bytes | Awaitable[bytes]

    def __await__(self) -> Iterator[bytes]:
        async def _generate_async() -> bytes:
            if isinstance(self.content, Awaitable):
                return await self.content
            else:
                return self.content

        return _generate_async().__await__()

    def decode(self, encoding: str) -> str:
        assert isinstance(self.content, bytes)
        return self.content.decode(encoding)

class PatchedAWSResponse:
    """Patched version of `botocore.awsrequest.AWSResponse`"""

    def __init__(self, response: botocore.awsrequest.AWSResponse) -> None:
        self._response = response
        self.status_code = response.status_code
        self.headers = response.headers
        self.headers["x-amz-crc32"] = None
        self.url = response.url
        self.content = _PatchedAWSReponseContent(response.content)
        self.raw = response.raw
        if not hasattr(self.raw, "raw_headers"):
            self.raw.raw_headers = {}

class PatchedRetryContext(botocore.retries.standard.RetryContext):
    """Patched version of `botocore.retries.standard.RetryContext`"""

    def __init__(self, *args, **kwargs):
        if kwargs.get("http_response"):
            kwargs["http_response"] = PatchedAWSResponse(kwargs["http_response"])
        super().__init__(*args, **kwargs)

def _factory(
    original: Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]],
) -> Callable[[botocore.awsrequest.AWSResponse, T], Awaitable[R]]:
    async def patched_convert_to_response_dict(
        http_response: botocore.awsrequest.AWSResponse, operation_model: T
    ) -> R:
        return await original(PatchedAWSResponse(http_response), operation_model)  # type: ignore[arg-type]

    return patched_convert_to_response_dict

@contextmanager
def mock_aio_aws(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
    # Patch aiobotocore and botocore
    monkeypatch.setattr(
        aiobotocore.endpoint,
        "convert_to_response_dict",
        _factory(aiobotocore.endpoint.convert_to_response_dict),
    )
    monkeypatch.setattr(botocore.retries.standard, "RetryContext", PatchedRetryContext)
    with mock_aws():
        yield

Test that fails

import boto3
import fsspec

import pytest
from moto_patch import mock_aio_aws

@pytest.fixture()
def mock_aws(monkeypatch):
    with mock_aio_aws(monkeypatch):
        yield

def test_fsspec_s3(mock_aws):
    # Create a mock S3 bucket
    conn = boto3.client("s3", region_name="us-east-1")
    conn.create_bucket(Bucket="test-bucket")

    # Write data to a file in the mock S3 bucket using fsspec
    with fsspec.open("s3://test-bucket/test.txt", "w") as f:
        f.write("Hello, mocked S3!")

    # Read data from the mock S3 bucket using fsspec
    with fsspec.open("s3://test-bucket/test.txt", "r") as f:
        content = f.read()

    assert content == "Hello, mocked S3!"

Error

self = <s3fs.core.S3FileSystem object at 0x149f914d0>, path = 'test-bucket/test.txt', bucket = 'test-bucket'
key = 'test.txt', refresh = False, version_id = None

    async def _info(self, path, bucket=None, key=None, refresh=False, version_id=None):
        path = self._strip_protocol(path)
        bucket, key, path_version_id = self.split_path(path)
        fullpath = "/".join((bucket, key))

        if version_id is not None:
            if not self.version_aware:
                raise ValueError(
                    "version_id cannot be specified if the "
                    "filesystem is not version aware"
                )
        if path in ["/", ""]:
            return {"name": path, "size": 0, "type": "directory"}
        version_id = _coalesce_version_id(path_version_id, version_id)
        if not refresh:
            out = self._ls_from_cache(fullpath)
            if out is not None:
                if self.version_aware and version_id is not None:
                    # If cached info does not match requested version_id,
                    # fallback to calling head_object
                    out = [
                        o
                        for o in out
                        if o["name"] == fullpath and version_id == o.get("VersionId")
                    ]
                    if out:
                        return out[0]
                else:
                    out = [o for o in out if o["name"] == fullpath]
                    if out:
                        return out[0]
                    return {"name": path, "size": 0, "type": "directory"}
        if key:
            try:
                out = await self._call_s3(
                    "head_object",
                    self.kwargs,
                    Bucket=bucket,
                    Key=key,
                    **version_id_kw(version_id),
                    **self.req_kw,
                )
                return {
                    "ETag": out.get("ETag", ""),
                    "LastModified": out.get("LastModified", ""),
>                   "size": out["ContentLength"],
                    "name": "/".join([bucket, key]),
                    "type": "file",
                    "StorageClass": out.get("StorageClass", "STANDARD"),
                    "VersionId": out.get("VersionId"),
                    "ContentType": out.get("ContentType"),
                }
E               KeyError: 'ContentLength'