Open dazza-codes opened 4 years 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
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]
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.
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.
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
raw_headers isn't technically supported by aws standard, so probably it should be removed from aiobotocore anyway
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 :(
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
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
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
.
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'
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:See also:
lowercase_dict
function alreadyBelow is an exception detail, when testing the following pytest fixtures:
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:The simple test function is:
The moto job-queues should be an empty list (and it is, see pdb details below).
Note that the moto response is an
botocore.awsrequest.AWSResponse
and not a:type http_response: botocore.vendored.requests.model.Response