This commit is contained in:
Adrian 2026-08-15 11:31:51 -05:00 committed by GitHub
commit 683bbab2fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 408 additions and 58 deletions

View File

@ -137,6 +137,8 @@ def pytest_runtest_setup(item):
"uvloop", "uvloop",
"botocore", "botocore",
"boto3", "boto3",
"aiobotocore",
"aioboto3",
] ]
for module in optional_deps: for module in optional_deps:

View File

@ -261,6 +261,12 @@ storage backend is: ``True``.
This storage backend uses :ref:`delayed file delivery <delayed-file-delivery>`. This storage backend uses :ref:`delayed file delivery <delayed-file-delivery>`.
.. versionchanged:: VERSION
When :ref:`asyncio support is available <using-asyncio>` and aioboto3_ is
installed (it is part of the :ref:`s3 <extras>` extra), feeds are uploaded
using genuinely-asynchronous I/O. Otherwise, the blocking boto3_ client is
run in a separate thread.
.. _topics-feed-storage-gcs: .. _topics-feed-storage-gcs:
@ -819,6 +825,8 @@ source spider in the feed URI:
.. _URIs: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier .. _URIs: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
.. _aioboto3: https://github.com/terricain/aioboto3
.. _Amazon S3: https://aws.amazon.com/s3/ .. _Amazon S3: https://aws.amazon.com/s3/
.. _boto3: https://github.com/boto/boto3
.. _Canned ACL: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl .. _Canned ACL: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
.. _Google Cloud Storage: https://cloud.google.com/storage/ .. _Google Cloud Storage: https://cloud.google.com/storage/

View File

@ -302,6 +302,15 @@ For self-hosting you also might feel the need not to use SSL and not to verify S
To reuse connections for as many files as you check or upload in parallel, set To reuse connections for as many files as you check or upload in parallel, set
:setting:`AWS_MAX_POOL_CONNECTIONS` accordingly. :setting:`AWS_MAX_POOL_CONNECTIONS` accordingly.
.. versionchanged:: VERSION
When :ref:`asyncio support is available <using-asyncio>` and aiobotocore_ is
installed (it is part of the :ref:`s3 <extras>` extra), files are stat'ed and
uploaded using genuinely-asynchronous I/O. Otherwise, the blocking botocore_
client is run in a separate thread.
.. _aiobotocore: https://github.com/aio-libs/aiobotocore
.. _botocore: https://github.com/boto/botocore
.. _canned ACLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl .. _canned ACLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
.. _Minio: https://github.com/minio/minio .. _Minio: https://github.com/minio/minio
.. _Zenko CloudServer: https://www.zenko.io/cloudserver/ .. _Zenko CloudServer: https://www.zenko.io/cloudserver/

View File

@ -70,7 +70,7 @@ images = ["Pillow>=8.3.2"]
ipython = ["ipython>=8.15.0"] ipython = ["ipython>=8.15.0"]
ptpython = ["ptpython>=3.0.23"] ptpython = ["ptpython>=3.0.23"]
robotparser = ["robotexclusionrulesparser>=1.6.2"] robotparser = ["robotexclusionrulesparser>=1.6.2"]
s3 = ["boto3>=1.20.0"] s3 = ["aioboto3>=9.0.0", "boto3>=1.20.0"]
twisted-http2 = ["Twisted[http2]>=21.7.0"] twisted-http2 = ["Twisted[http2]>=21.7.0"]
uvloop = [ uvloop = [
"uvloop>=0.16.0; platform_system != 'Windows' and implementation_name != 'pypy'", "uvloop>=0.16.0; platform_system != 'Windows' and implementation_name != 'pypy'",
@ -169,6 +169,8 @@ ignore_errors = true
# usually no type hints # usually no type hints
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"aioboto3",
"aiobotocore.*",
"bpython", "bpython",
"brotli", "brotli",
"brotlicffi", "brotlicffi",
@ -314,6 +316,8 @@ markers = [
"requires_uvloop: marks tests as only enabled when uvloop is known to be working", "requires_uvloop: marks tests as only enabled when uvloop is known to be working",
"requires_botocore: marks tests that need botocore (but not boto3)", "requires_botocore: marks tests that need botocore (but not boto3)",
"requires_boto3: marks tests that need botocore and boto3", "requires_boto3: marks tests that need botocore and boto3",
"requires_aiobotocore: marks tests that need aiobotocore (but not aioboto3)",
"requires_aioboto3: marks tests that need aiobotocore and aioboto3",
"requires_mitmproxy: marks tests that need a mitmdump executable", "requires_mitmproxy: marks tests that need a mitmdump executable",
"requires_internet: marks tests that need real Internet access", "requires_internet: marks tests that need real Internet access",
] ]

View File

@ -28,7 +28,7 @@ from scrapy import Spider, signals
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.extensions.postprocessing import PostProcessingManager from scrapy.extensions.postprocessing import PostProcessingManager
from scrapy.utils.asyncio import is_asyncio_available, run_in_thread from scrapy.utils.asyncio import is_asyncio_available, run_in_thread
from scrapy.utils.boto import _get_max_pool_connections from scrapy.utils.boto import _get_max_pool_connections, is_aioboto3_available
from scrapy.utils.conf import feed_complete_default_values_from_settings from scrapy.utils.conf import feed_complete_default_values_from_settings
from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.defer import deferred_from_coro, ensure_awaitable
from scrapy.utils.ftp import ftp_store_file from scrapy.utils.ftp import ftp_store_file
@ -234,20 +234,23 @@ class S3FeedStorage(BlockingFeedStorage):
self.region_name: str | None = region_name self.region_name: str | None = region_name
self.max_pool_connections: int | None = max_pool_connections self.max_pool_connections: int | None = max_pool_connections
boto3_session = boto3.session.Session() self._client_kwargs: dict[str, Any] = {
self.s3_client = boto3_session.client( "aws_access_key_id": self.access_key,
"s3", "aws_secret_access_key": self.secret_key,
aws_access_key_id=self.access_key, "aws_session_token": self.session_token,
aws_secret_access_key=self.secret_key, "endpoint_url": self.endpoint_url,
aws_session_token=self.session_token, "region_name": self.region_name,
endpoint_url=self.endpoint_url, "config": (
region_name=self.region_name,
config=(
Config(max_pool_connections=self.max_pool_connections) Config(max_pool_connections=self.max_pool_connections)
if self.max_pool_connections is not None if self.max_pool_connections is not None
else None else None
), ),
) }
# Synchronous boto3 client, used when asyncio support or aioboto3 is not
# available (its calls are then run in a thread).
boto3_session = boto3.session.Session()
self.s3_client = boto3_session.client("s3", **self._client_kwargs)
if feed_options and feed_options.get("overwrite", True) is False: if feed_options and feed_options.get("overwrite", True) is False:
logger.warning( logger.warning(
@ -276,6 +279,28 @@ class S3FeedStorage(BlockingFeedStorage):
feed_options=feed_options, feed_options=feed_options,
) )
def store(self, file: IO[bytes]) -> Deferred[None] | None:
if is_asyncio_available() and is_aioboto3_available():
return deferred_from_coro(self._store_async(file))
return super().store(file)
async def _store_async(self, file: IO[bytes]) -> None:
import aioboto3 # noqa: PLC0415
file.seek(0)
extra_args = {"ACL": self.acl} if self.acl else {}
session = aioboto3.Session()
try:
async with session.client("s3", **self._client_kwargs) as client:
await client.upload_fileobj(
Fileobj=file,
Bucket=self.bucketname,
Key=self.keyname,
ExtraArgs=extra_args,
)
finally:
file.close()
def _store_in_thread(self, file: IO[bytes]) -> None: def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0) file.seek(0)
try: try:

View File

@ -6,6 +6,7 @@ See documentation in topics/media-pipeline.rst
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import functools import functools
import hashlib import hashlib
@ -34,9 +35,14 @@ from scrapy.pipelines.media import (
MediaPipeline, MediaPipeline,
_MediaRequestFiltered, _MediaRequestFiltered,
) )
from scrapy.utils.asyncio import run_in_thread from scrapy.utils.asyncio import is_asyncio_available, run_in_thread
from scrapy.utils.boto import _get_max_pool_connections, is_botocore_available from scrapy.utils.boto import (
_get_max_pool_connections,
is_aiobotocore_available,
is_botocore_available,
)
from scrapy.utils.datatypes import CaseInsensitiveDict from scrapy.utils.datatypes import CaseInsensitiveDict
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import deferred_from_coro, ensure_awaitable from scrapy.utils.defer import deferred_from_coro, ensure_awaitable
from scrapy.utils.ftp import ftp_store_file from scrapy.utils.ftp import ftp_store_file
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
@ -53,6 +59,7 @@ if TYPE_CHECKING:
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Self from typing_extensions import Self
from scrapy import Spider
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
@ -174,30 +181,64 @@ class S3FilesStore:
def __init__(self, uri: str): def __init__(self, uri: str):
if not is_botocore_available(): if not is_botocore_available():
raise NotConfigured("missing botocore library") raise NotConfigured("missing botocore library")
import botocore.session # noqa: PLC0415
from botocore.config import Config # noqa: PLC0415
config = (
Config(max_pool_connections=self.AWS_MAX_POOL_CONNECTIONS)
if self.AWS_MAX_POOL_CONNECTIONS is not None
else None
)
session = botocore.session.get_session()
self.s3_client = session.create_client(
"s3",
aws_access_key_id=self.AWS_ACCESS_KEY_ID,
aws_secret_access_key=self.AWS_SECRET_ACCESS_KEY,
aws_session_token=self.AWS_SESSION_TOKEN,
endpoint_url=self.AWS_ENDPOINT_URL,
region_name=self.AWS_REGION_NAME,
use_ssl=self.AWS_USE_SSL,
verify=self.AWS_VERIFY,
config=config,
)
if not uri.startswith("s3://"): if not uri.startswith("s3://"):
raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'") raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'")
self.bucket, self.prefix = uri[5:].split("/", 1) self.bucket, self.prefix = uri[5:].split("/", 1)
from botocore.config import Config # noqa: PLC0415
self._client_kwargs: dict[str, Any] = {
"aws_access_key_id": self.AWS_ACCESS_KEY_ID,
"aws_secret_access_key": self.AWS_SECRET_ACCESS_KEY,
"aws_session_token": self.AWS_SESSION_TOKEN,
"endpoint_url": self.AWS_ENDPOINT_URL,
"region_name": self.AWS_REGION_NAME,
"use_ssl": self.AWS_USE_SSL,
"verify": self.AWS_VERIFY,
"config": (
Config(max_pool_connections=self.AWS_MAX_POOL_CONNECTIONS)
if self.AWS_MAX_POOL_CONNECTIONS is not None
else None
),
}
# Synchronous botocore client, used when asyncio support or aiobotocore
# is not available (its calls are then run in a thread).
import botocore.session # noqa: PLC0415
session = botocore.session.get_session()
self.s3_client = session.create_client("s3", **self._client_kwargs)
# Asynchronous aiobotocore client. It's created lazily (it must be
# instantiated from within the running event loop), reused across calls
# and closed by close().
self._aio_client: Any = None
self._aio_client_cm: Any = None
self._aio_client_lock = asyncio.Lock()
def _use_async(self) -> bool:
"""Whether to use the genuinely-asynchronous aiobotocore client instead
of running the blocking botocore client in a thread."""
return is_asyncio_available() and is_aiobotocore_available()
async def _get_aio_client(self) -> Any:
async with self._aio_client_lock:
if self._aio_client is None:
from aiobotocore.session import get_session # noqa: PLC0415
self._aio_client_cm = get_session().create_client(
"s3", **self._client_kwargs
)
# pylint: disable-next=unnecessary-dunder-call
self._aio_client = await self._aio_client_cm.__aenter__()
return self._aio_client
async def close(self) -> None:
"""Close the underlying aiobotocore client, if one was created."""
if self._aio_client is not None:
await self._aio_client_cm.__aexit__(None, None, None)
self._aio_client = self._aio_client_cm = None
@staticmethod @staticmethod
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo: def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"') checksum = boto_key["ETag"].strip('"')
@ -208,18 +249,20 @@ class S3FilesStore:
def stat_file( def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]: ) -> Deferred[StatInfo]:
return deferred_from_coro(self._stat_file(path))
return self._get_boto_key(path).addCallback(self._onsuccess) async def _stat_file(self, path: str) -> StatInfo:
def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]:
key_name = f"{self.prefix}{path}" key_name = f"{self.prefix}{path}"
return deferred_from_coro( if self._use_async():
run_in_thread( client = await self._get_aio_client()
boto_key = await client.head_object(Bucket=self.bucket, Key=key_name)
else:
boto_key = await run_in_thread(
self.s3_client.head_object, # type: ignore[attr-defined] self.s3_client.head_object, # type: ignore[attr-defined]
Bucket=self.bucket, Bucket=self.bucket,
Key=key_name, Key=key_name,
) )
) return self._onsuccess(boto_key)
def persist_file( def persist_file(
self, self,
@ -230,21 +273,34 @@ class S3FilesStore:
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
) -> Deferred[Any]: ) -> Deferred[Any]:
"""Upload file to S3 storage""" """Upload file to S3 storage"""
return deferred_from_coro(self._persist_file(path, buf, meta, headers))
async def _persist_file(
self,
path: str,
buf: BytesIO,
meta: dict[str, Any] | None,
headers: dict[str, str] | None,
) -> Any:
key_name = f"{self.prefix}{path}" key_name = f"{self.prefix}{path}"
buf.seek(0) buf.seek(0)
extra = self._headers_to_botocore_kwargs(self.HEADERS) extra = self._headers_to_botocore_kwargs(self.HEADERS)
if headers: if headers:
extra.update(self._headers_to_botocore_kwargs(headers)) extra.update(self._headers_to_botocore_kwargs(headers))
return deferred_from_coro( kwargs: dict[str, Any] = {
run_in_thread( "Bucket": self.bucket,
self.s3_client.put_object, # type: ignore[attr-defined] "Key": key_name,
Bucket=self.bucket, "Body": buf,
Key=key_name, "Metadata": {k: str(v) for k, v in meta.items()} if meta else {},
Body=buf, "ACL": self.POLICY,
Metadata={k: str(v) for k, v in meta.items()} if meta else {}, **extra,
ACL=self.POLICY, }
**extra, if self._use_async():
) client = await self._get_aio_client()
return await client.put_object(**kwargs)
return await run_in_thread(
self.s3_client.put_object, # type: ignore[attr-defined]
**kwargs,
) )
def _headers_to_botocore_kwargs(self, headers: dict[str, Any]) -> dict[str, Any]: def _headers_to_botocore_kwargs(self, headers: dict[str, Any]) -> dict[str, Any]:
@ -518,6 +574,12 @@ class FilesPipeline(MediaPipeline):
store_uri = settings["FILES_STORE"] store_uri = settings["FILES_STORE"]
return cls(store_uri, crawler=crawler) return cls(store_uri, crawler=crawler)
@_warn_spider_arg
async def close_spider(self, spider: Spider | None = None) -> None:
close = getattr(self.store, "close", None)
if close is not None:
await ensure_awaitable(close())
@classmethod @classmethod
def _update_stores(cls, settings: BaseSettings) -> None: def _update_stores(cls, settings: BaseSettings) -> None:
s3store: type[S3FilesStore] = cast( s3store: type[S3FilesStore] = cast(

View File

@ -13,6 +13,14 @@ def is_botocore_available() -> bool:
return find_spec("botocore") is not None return find_spec("botocore") is not None
def is_aiobotocore_available() -> bool:
return find_spec("aiobotocore") is not None
def is_aioboto3_available() -> bool:
return find_spec("aioboto3") is not None
def _get_max_pool_connections(settings: BaseSettings) -> int: def _get_max_pool_connections(settings: BaseSettings) -> int:
"""Return the maximum number of connections that AWS clients may keep in """Return the maximum number of connections that AWS clients may keep in
their connection pool. their connection pool.

View File

@ -7,6 +7,7 @@ import pickle
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest import mock
from urllib.parse import urljoin from urllib.parse import urljoin
import lxml.etree import lxml.etree
@ -426,7 +427,12 @@ class TestBatchDeliveries(TestFeedExportBase):
@pytest.mark.requires_boto3 @pytest.mark.requires_boto3
@inline_callbacks_test @inline_callbacks_test
def test_s3_export(self): def test_s3_export(self, monkeypatch):
# Force the blocking boto3 code path so that the botocore Stubber below
# intercepts the uploads.
monkeypatch.setattr(
"scrapy.extensions.feedexport.is_aioboto3_available", lambda: False
)
bucket = "mybucket" bucket = "mybucket"
items = [ items = [
MyItem({"foo": "bar1", "egg": "spam1"}), MyItem({"foo": "bar1", "egg": "spam1"}),
@ -498,3 +504,54 @@ class TestBatchDeliveries(TestFeedExportBase):
assert ( assert (
crawler.stats.get_value("feedexport/success_count/CustomS3FeedStorage") == 3 crawler.stats.get_value("feedexport/success_count/CustomS3FeedStorage") == 3
) )
@pytest.mark.requires_aioboto3
@pytest.mark.only_asyncio
@inline_callbacks_test
def test_s3_export_async(self, monkeypatch):
"""One batch per item is uploaded through the aioboto3 code path."""
import aioboto3 # noqa: PLC0415
bucket = "mybucket"
items = [
MyItem({"foo": "bar1", "egg": "spam1"}),
MyItem({"foo": "bar2", "egg": "spam2", "baz": "quux2"}),
MyItem({"foo": "bar3", "baz": "quux3"}),
]
upload_fileobj = mock.AsyncMock()
def make_client(self, *args, **kwargs):
client = mock.MagicMock()
client.upload_fileobj = upload_fileobj
client_cm = mock.MagicMock()
client_cm.__aenter__ = mock.AsyncMock(return_value=client)
client_cm.__aexit__ = mock.AsyncMock(return_value=False)
return client_cm
monkeypatch.setattr(aioboto3.Session, "client", make_client)
key = "export.csv"
uri = f"s3://{bucket}/{key}/%(batch_id)d.json"
settings = {
"AWS_ACCESS_KEY_ID": "access_key",
"AWS_SECRET_ACCESS_KEY": "secret_key",
"FEED_EXPORT_BATCH_ITEM_COUNT": 1,
"FEEDS": {uri: {"format": "json"}},
}
class TestSpider(scrapy.Spider):
name = "testspider"
def parse(self, response):
yield from items
TestSpider.start_urls = [self.mockserver.url("/")]
crawler = get_crawler(TestSpider, settings)
yield crawler.crawl()
assert upload_fileobj.await_count == len(items)
for call in upload_fileobj.await_args_list:
assert call.kwargs["Bucket"] == bucket
assert crawler.stats
assert crawler.stats.get_value("feedexport/success_count/S3FeedStorage") == 3

View File

@ -279,7 +279,12 @@ class TestS3FeedStorage:
assert storage.secret_key == "uri_secret" assert storage.secret_key == "uri_secret"
@coroutine_test @coroutine_test
async def test_store(self): async def test_store(self, monkeypatch):
"""The blocking boto3 client is used when asyncio/aioboto3 support is
not available."""
monkeypatch.setattr(
"scrapy.extensions.feedexport.is_aioboto3_available", lambda: False
)
settings = { settings = {
"AWS_ACCESS_KEY_ID": "access_key", "AWS_ACCESS_KEY_ID": "access_key",
"AWS_SECRET_ACCESS_KEY": "secret_key", "AWS_SECRET_ACCESS_KEY": "secret_key",
@ -458,7 +463,10 @@ class TestS3FeedStorage:
assert config.max_pool_connections == expected assert config.max_pool_connections == expected
@coroutine_test @coroutine_test
async def test_store_without_acl(self): async def test_store_without_acl(self, monkeypatch):
monkeypatch.setattr(
"scrapy.extensions.feedexport.is_aioboto3_available", lambda: False
)
storage = S3FeedStorage( storage = S3FeedStorage(
"s3://mybucket/export.csv", "s3://mybucket/export.csv",
"access_key", "access_key",
@ -478,7 +486,10 @@ class TestS3FeedStorage:
assert acl is None assert acl is None
@coroutine_test @coroutine_test
async def test_store_with_acl(self): async def test_store_with_acl(self, monkeypatch):
monkeypatch.setattr(
"scrapy.extensions.feedexport.is_aioboto3_available", lambda: False
)
storage = S3FeedStorage( storage = S3FeedStorage(
"s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl"
) )
@ -491,6 +502,40 @@ class TestS3FeedStorage:
acl = storage.s3_client.upload_fileobj.call_args[1]["ExtraArgs"]["ACL"] acl = storage.s3_client.upload_fileobj.call_args[1]["ExtraArgs"]["ACL"]
assert acl == "custom-acl" assert acl == "custom-acl"
@pytest.mark.requires_aioboto3
@pytest.mark.only_asyncio
@coroutine_test
async def test_store_async(self, monkeypatch):
"""The genuinely-asynchronous aioboto3 client is used when
asyncio/aioboto3 support is available."""
import aioboto3 # noqa: PLC0415
storage = S3FeedStorage(
"s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl"
)
client = mock.MagicMock()
client.upload_fileobj = mock.AsyncMock()
client_cm = mock.MagicMock()
client_cm.__aenter__ = mock.AsyncMock(return_value=client)
client_cm.__aexit__ = mock.AsyncMock(return_value=False)
file = BytesIO(b"test file")
with mock.patch.object(
aioboto3.Session, "client", return_value=client_cm
) as client_call:
await maybe_deferred_to_future(storage.store(file))
client_call.assert_called_once_with("s3", **storage._client_kwargs)
client.upload_fileobj.assert_awaited_once_with(
Fileobj=file,
Bucket="mybucket",
Key="export.csv",
ExtraArgs={"ACL": "custom-acl"},
)
client_cm.__aexit__.assert_awaited_once()
assert file.closed
def test_overwrite_default(self, caplog: pytest.LogCaptureFixture) -> None: def test_overwrite_default(self, caplog: pytest.LogCaptureFixture) -> None:
S3FeedStorage( S3FeedStorage(
"s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl" "s3://mybucket/export.csv", "access_key", "secret_key", "custom-acl"

View File

@ -112,6 +112,20 @@ class TestFilesPipeline:
pipeline.open_spider() pipeline.open_spider()
return pipeline return pipeline
@coroutine_test
async def test_close_spider_closes_store(self):
"""close_spider() closes the store when it exposes a close() method."""
self.pipeline.store = mock.MagicMock()
self.pipeline.store.close = mock.AsyncMock()
await self.pipeline.close_spider()
self.pipeline.store.close.assert_awaited_once_with()
@coroutine_test
async def test_close_spider_without_store_close(self):
"""close_spider() is a no-op for stores without a close() method."""
assert not hasattr(self.pipeline.store, "close")
await self.pipeline.close_spider()
def test_file_path_query_parameters(self): def test_file_path_query_parameters(self):
file_path = self.pipeline.file_path file_path = self.pipeline.file_path
@ -829,7 +843,9 @@ class TestFSFilesStore:
@pytest.mark.requires_botocore @pytest.mark.requires_botocore
class TestS3FilesStore: class TestS3FilesStore:
@inline_callbacks_test @inline_callbacks_test
def test_persist(self): def test_persist(self, monkeypatch):
"""The blocking botocore client is used when asyncio/aiobotocore support
is not available."""
bucket = "mybucket" bucket = "mybucket"
key = "export.csv" key = "export.csv"
uri = f"s3://{bucket}/{key}" uri = f"s3://{bucket}/{key}"
@ -839,6 +855,7 @@ class TestS3FilesStore:
content_type = "image/png" content_type = "image/png"
store = S3FilesStore(uri) store = S3FilesStore(uri)
monkeypatch.setattr(store, "_use_async", lambda: False)
from botocore.stub import Stubber # noqa: PLC0415 from botocore.stub import Stubber # noqa: PLC0415
with Stubber(store.s3_client) as stub: with Stubber(store.s3_client) as stub:
@ -869,13 +886,14 @@ class TestS3FilesStore:
assert buffer.method_calls == [mock.call.seek(0)] assert buffer.method_calls == [mock.call.seek(0)]
@inline_callbacks_test @inline_callbacks_test
def test_persist_without_headers(self): def test_persist_without_headers(self, monkeypatch):
"""Without custom headers only the default ones are sent.""" """Without custom headers only the default ones are sent."""
bucket = "mybucket" bucket = "mybucket"
key = "export.csv" key = "export.csv"
buffer = mock.MagicMock() buffer = mock.MagicMock()
store = S3FilesStore(f"s3://{bucket}/{key}") store = S3FilesStore(f"s3://{bucket}/{key}")
monkeypatch.setattr(store, "_use_async", lambda: False)
from botocore.stub import Stubber # noqa: PLC0415 from botocore.stub import Stubber # noqa: PLC0415
with Stubber(store.s3_client) as stub: with Stubber(store.s3_client) as stub:
@ -922,7 +940,9 @@ class TestS3FilesStore:
store._headers_to_botocore_kwargs({"X-Custom": "value"}) store._headers_to_botocore_kwargs({"X-Custom": "value"})
@inline_callbacks_test @inline_callbacks_test
def test_stat(self): def test_stat(self, monkeypatch):
"""The blocking botocore client is used when asyncio/aiobotocore support
is not available."""
bucket = "mybucket" bucket = "mybucket"
key = "export.csv" key = "export.csv"
uri = f"s3://{bucket}/{key}" uri = f"s3://{bucket}/{key}"
@ -930,6 +950,7 @@ class TestS3FilesStore:
last_modified = datetime(2019, 12, 1) last_modified = datetime(2019, 12, 1)
store = S3FilesStore(uri) store = S3FilesStore(uri)
monkeypatch.setattr(store, "_use_async", lambda: False)
from botocore.stub import Stubber # noqa: PLC0415 from botocore.stub import Stubber # noqa: PLC0415
with Stubber(store.s3_client) as stub: with Stubber(store.s3_client) as stub:
@ -981,6 +1002,113 @@ class TestS3FilesStore:
assert config.max_pool_connections == expected assert config.max_pool_connections == expected
@pytest.mark.requires_aiobotocore
@pytest.mark.only_asyncio
class TestS3FilesStoreAsync:
"""Tests for the genuinely-asynchronous aiobotocore code path of
:class:`~scrapy.pipelines.files.S3FilesStore`."""
@coroutine_test
async def test_persist(self):
bucket = "mybucket"
key = "export.csv"
uri = f"s3://{bucket}/{key}"
buffer = mock.MagicMock()
meta = {"foo": "bar"}
path = ""
content_type = "image/png"
store = S3FilesStore(uri)
assert store._use_async()
client = await store._get_aio_client()
from aiobotocore.stub import Stubber # noqa: PLC0415
with Stubber(client) as stub:
stub.add_response(
"put_object",
expected_params={
"ACL": S3FilesStore.POLICY,
"Body": buffer,
"Bucket": bucket,
"CacheControl": S3FilesStore.HEADERS["Cache-Control"],
"ContentType": content_type,
"Key": key,
"Metadata": meta,
},
service_response={},
)
await maybe_deferred_to_future(
store.persist_file(
path,
buffer,
info=DUMMY_SPIDER_INFO,
meta=meta,
headers={"Content-Type": content_type},
)
)
stub.assert_no_pending_responses()
assert buffer.method_calls == [mock.call.seek(0)]
await store.close()
@coroutine_test
async def test_max_pool_connections(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(S3FilesStore, "AWS_MAX_POOL_CONNECTIONS", 30)
store = S3FilesStore("s3://mybucket/prefix/")
client = await store._get_aio_client()
assert client.meta.config.max_pool_connections == 30
await store.close()
@coroutine_test
async def test_stat(self):
bucket = "mybucket"
key = "export.csv"
uri = f"s3://{bucket}/{key}"
checksum = "3187896a9657a28163abb31667df64c8"
last_modified = datetime(2019, 12, 1)
store = S3FilesStore(uri)
client = await store._get_aio_client()
from aiobotocore.stub import Stubber # noqa: PLC0415
with Stubber(client) as stub:
stub.add_response(
"head_object",
expected_params={"Bucket": bucket, "Key": key},
service_response={
"ETag": f'"{checksum}"',
"LastModified": last_modified,
},
)
file_stats = await maybe_deferred_to_future(
store.stat_file("", info=DUMMY_SPIDER_INFO)
)
assert file_stats == {
"checksum": checksum,
"last_modified": last_modified.timestamp(),
}
stub.assert_no_pending_responses()
await store.close()
@coroutine_test
async def test_client_reused_and_closed(self):
store = S3FilesStore("s3://mybucket/export.csv")
assert store._aio_client is None
client = await store._get_aio_client()
# The client is created lazily and reused across calls.
assert store._aio_client is client
assert await store._get_aio_client() is client
await store.close()
assert store._aio_client is None
# close() is a no-op if there is no open client.
await store.close()
class TestGCSFilesStore: class TestGCSFilesStore:
@staticmethod @staticmethod
def build_gcs_files_store( def build_gcs_files_store(

View File

@ -172,6 +172,7 @@ deps =
{[testenv]deps} {[testenv]deps}
Pillow Pillow
Twisted[http2] Twisted[http2]
aioboto3
boto3 boto3
bpython # optional for shell wrapper tests bpython # optional for shell wrapper tests
google-cloud-storage google-cloud-storage
@ -350,9 +351,10 @@ commands =
[testenv:botocore] [testenv:botocore]
deps = deps =
{[testenv]deps} {[testenv]deps}
aiobotocore
botocore>=1.13.45 botocore>=1.13.45
commands = commands =
pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy} -m requires_botocore pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy} -m "requires_botocore or requires_aiobotocore"
[testenv:min-botocore] [testenv:min-botocore]
basepython = {[min]basepython} basepython = {[min]basepython}