Refactor MediaPipeline.process_item() to async def (#7177)

This commit is contained in:
Andrey Rakhmatullin 2025-12-15 14:02:04 +05:00 committed by GitHub
parent 588f3d4f65
commit c49ae2115a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 148 additions and 137 deletions

View File

@ -74,6 +74,11 @@ Backward-incompatible changes
:class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` or
calls its methods directly. calls its methods directly.
- :meth:`scrapy.pipelines.media.MediaPipeline.process_item` now returns a
coroutine, previously it returned a
:class:`~twisted.internet.defer.Deferred` object. This
change only impacts code that calls this method directly.
.. _release-2.13.4: .. _release-2.13.4:
Scrapy 2.13.4 (2025-11-17) Scrapy 2.13.4 (2025-11-17)

View File

@ -12,6 +12,7 @@ import hashlib
import logging import logging
import mimetypes import mimetypes
import time import time
import warnings
from collections import defaultdict from collections import defaultdict
from contextlib import suppress from contextlib import suppress
from ftplib import FTP from ftplib import FTP
@ -24,7 +25,7 @@ from itemadapter import ItemAdapter
from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.threads import deferToThread from twisted.internet.threads import deferToThread
from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK from scrapy.http.request import NO_CALLBACK
from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline from scrapy.pipelines.media import FileInfo, FileInfoOrError, MediaPipeline
@ -36,7 +37,6 @@ from scrapy.utils.python import to_bytes
from scrapy.utils.request import referer_str from scrapy.utils.request import referer_str
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from os import PathLike from os import PathLike
from twisted.python.failure import Failure from twisted.python.failure import Failure
@ -44,7 +44,6 @@ 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
@ -443,10 +442,18 @@ class FilesPipeline(MediaPipeline):
def __init__( def __init__(
self, self,
store_uri: str | PathLike[str], store_uri: str | PathLike[str],
download_func: Callable[[Request, Spider], Response] | None = None, download_func: None = None,
*, *,
crawler: Crawler, crawler: Crawler,
): ):
if download_func is not None: # pragma: no cover
warnings.warn(
"The download_func argument of FilesPipeline.__init__() is ignored"
" and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
if not (store_uri and (store_uri := _to_string(store_uri))): if not (store_uri and (store_uri := _to_string(store_uri))):
from scrapy.pipelines.images import ImagesPipeline # noqa: PLC0415 from scrapy.pipelines.images import ImagesPipeline # noqa: PLC0415
@ -476,7 +483,7 @@ class FilesPipeline(MediaPipeline):
resolve("FILES_RESULT_FIELD"), self.FILES_RESULT_FIELD resolve("FILES_RESULT_FIELD"), self.FILES_RESULT_FIELD
) )
super().__init__(download_func=download_func, crawler=crawler) super().__init__(crawler=crawler)
@classmethod @classmethod
def from_crawler(cls, crawler: Crawler) -> Self: def from_crawler(cls, crawler: Crawler) -> Self:

View File

@ -8,20 +8,21 @@ from __future__ import annotations
import functools import functools
import hashlib import hashlib
import warnings
from contextlib import suppress from contextlib import suppress
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from itemadapter import ItemAdapter from itemadapter import ItemAdapter
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK from scrapy.http.request import NO_CALLBACK
from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum
from scrapy.utils.python import to_bytes from scrapy.utils.python import to_bytes
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterable from collections.abc import Iterable
from os import PathLike from os import PathLike
from PIL import Image from PIL import Image
@ -29,7 +30,6 @@ 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.pipelines.media import FileInfoOrError, MediaPipeline from scrapy.pipelines.media import FileInfoOrError, MediaPipeline
@ -55,10 +55,18 @@ class ImagesPipeline(FilesPipeline):
def __init__( def __init__(
self, self,
store_uri: str | PathLike[str], store_uri: str | PathLike[str],
download_func: Callable[[Request, Spider], Response] | None = None, download_func: None = None,
*, *,
crawler: Crawler, crawler: Crawler,
): ):
if download_func is not None: # pragma: no cover
warnings.warn(
"The download_func argument of ImagesPipeline.__init__() is ignored"
" and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
try: try:
from PIL import Image, ImageOps # noqa: PLC0415 from PIL import Image, ImageOps # noqa: PLC0415
@ -69,7 +77,7 @@ class ImagesPipeline(FilesPipeline):
"ImagesPipeline requires installing Pillow 8.3.2 or later" "ImagesPipeline requires installing Pillow 8.3.2 or later"
) )
super().__init__(store_uri, download_func=download_func, crawler=crawler) super().__init__(store_uri, crawler=crawler)
settings = crawler.settings settings = crawler.settings
resolve = functools.partial( resolve = functools.partial(

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import functools import functools
import logging import logging
import warnings import warnings
@ -8,28 +9,27 @@ from collections import defaultdict
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast
from twisted import version as twisted_version from twisted import version as twisted_version
from twisted.internet.defer import ( from twisted.internet.defer import Deferred, DeferredList
Deferred,
DeferredList,
inlineCallbacks,
maybeDeferred,
)
from twisted.python.failure import Failure from twisted.python.failure import Failure
from twisted.python.versions import Version from twisted.python.versions import Version
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http.request import NO_CALLBACK, Request from scrapy.http.request import NO_CALLBACK, Request
from scrapy.utils.asyncio import call_later from scrapy.utils.asyncio import call_later, is_asyncio_available
from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.datatypes import SequenceExclude
from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import _DEFER_DELAY, _defer_sleep, deferred_from_coro from scrapy.utils.defer import (
_DEFER_DELAY,
_defer_sleep_async,
deferred_from_coro,
ensure_awaitable,
maybe_deferred_to_future,
)
from scrapy.utils.log import failure_to_exc_info from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.misc import arg_to_iter from scrapy.utils.misc import arg_to_iter
from scrapy.utils.python import global_object_name from scrapy.utils.python import global_object_name
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Generator
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Self from typing_extensions import Self
@ -68,16 +68,22 @@ class MediaPipeline(ABC):
def __init__( def __init__(
self, self,
download_func: Callable[[Request, Spider], Response] | None = None, download_func: None = None,
*, *,
crawler: Crawler, crawler: Crawler,
): ):
if download_func is not None: # pragma: no cover
warnings.warn(
"The download_func argument of MediaPipeline.__init__() is ignored"
" and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self.crawler: Crawler = crawler self.crawler: Crawler = crawler
assert crawler.request_fingerprinter assert crawler.request_fingerprinter
self._fingerprinter: RequestFingerprinterProtocol = ( self._fingerprinter: RequestFingerprinterProtocol = (
crawler.request_fingerprinter crawler.request_fingerprinter
) )
self.download_func = download_func
settings = crawler.settings settings = crawler.settings
resolve = functools.partial( resolve = functools.partial(
@ -119,21 +125,31 @@ class MediaPipeline(ABC):
self.spiderinfo = self.SpiderInfo(self.crawler.spider) self.spiderinfo = self.SpiderInfo(self.crawler.spider)
@_warn_spider_arg @_warn_spider_arg
def process_item( async def process_item(self, item: Any, spider: Spider | None = None) -> Any:
self, item: Any, spider: Spider | None = None
) -> Deferred[list[FileInfoOrError]]:
info = self.spiderinfo info = self.spiderinfo
requests = arg_to_iter(self.get_media_requests(item, info)) requests = arg_to_iter(self.get_media_requests(item, info))
dlist = [self._process_request(r, info, item) for r in requests] coros = [self._process_request(r, info, item) for r in requests]
dfd = cast( results: list[FileInfoOrError] = []
"Deferred[list[FileInfoOrError]]", DeferredList(dlist, consumeErrors=True) if coros:
) if is_asyncio_available():
return dfd.addCallback(self.item_completed, item, info) results_asyncio = await asyncio.gather(*coros, return_exceptions=True)
for res in results_asyncio:
if isinstance(res, BaseException):
results.append((False, Failure(res)))
else:
results.append((True, res))
else:
results = await cast(
"Deferred[list[FileInfoOrError]]",
DeferredList(
(deferred_from_coro(coro) for coro in coros), consumeErrors=True
),
)
return self.item_completed(results, item, info)
@inlineCallbacks async def _process_request(
def _process_request(
self, request: Request, info: SpiderInfo, item: Any self, request: Request, info: SpiderInfo, item: Any
) -> Generator[Deferred[Any], Any, FileInfo]: ) -> FileInfo:
fp = self._fingerprinter.fingerprint(request) fp = self._fingerprinter.fingerprint(request)
eb = request.errback eb = request.errback
@ -142,7 +158,7 @@ class MediaPipeline(ABC):
# Return cached result if request was already seen # Return cached result if request was already seen
if fp in info.downloaded: if fp in info.downloaded:
yield _defer_sleep() await _defer_sleep_async()
cached_result = info.downloaded[fp] cached_result = info.downloaded[fp]
if isinstance(cached_result, Failure): if isinstance(cached_result, Failure):
if eb: if eb:
@ -158,27 +174,27 @@ class MediaPipeline(ABC):
# Check if request is downloading right now to avoid doing it twice # Check if request is downloading right now to avoid doing it twice
if fp in info.downloading: if fp in info.downloading:
return (yield wad) return await maybe_deferred_to_future(wad)
# Download request checking media_to_download hook output first # Download request checking media_to_download hook output first
info.downloading.add(fp) info.downloading.add(fp)
yield _defer_sleep() await _defer_sleep_async()
result: FileInfo | Failure result: FileInfo | Failure
try: try:
file_info = yield maybeDeferred( file_info: FileInfo | None = await ensure_awaitable(
self.media_to_download, request, info, item=item self.media_to_download(request, info, item=item)
) )
if file_info: if file_info:
# got a result without downloading # got a result without downloading
result = file_info result = file_info
else: else:
# download the result # download the result
result = yield self._check_media_to_download(request, info, item=item) result = await self._check_media_to_download(request, info, item=item)
except Exception: except Exception:
result = Failure() result = Failure()
logger.exception(result) logger.exception(result)
self._cache_result_and_execute_waiters(result, fp, info) self._cache_result_and_execute_waiters(result, fp, info)
return (yield wad) # it must return wad at last return await maybe_deferred_to_future(wad) # it must return wad at last
def _modify_media_request(self, request: Request) -> None: def _modify_media_request(self, request: Request) -> None:
if self.handle_httpstatus_list: if self.handle_httpstatus_list:
@ -186,20 +202,13 @@ class MediaPipeline(ABC):
else: else:
request.meta["handle_httpstatus_all"] = True request.meta["handle_httpstatus_all"] = True
@inlineCallbacks async def _check_media_to_download(
def _check_media_to_download( # pylint: disable=inconsistent-return-statements
self, request: Request, info: SpiderInfo, item: Any self, request: Request, info: SpiderInfo, item: Any
) -> Generator[Deferred[Any], Any, FileInfo]: ) -> FileInfo:
try: try:
if self.download_func: self._modify_media_request(request)
# this ugly code was left only to support tests. TODO: remove assert self.crawler.engine
response = yield maybeDeferred(self.download_func, request, info.spider) response = await self.crawler.engine.download_async(request)
else:
self._modify_media_request(request)
assert self.crawler.engine
response = yield deferred_from_coro(
self.crawler.engine.download_async(request)
)
return self.media_downloaded(response, request, info, item=item) return self.media_downloaded(response, request, info, item=item)
except Exception: except Exception:
failure = self.media_failed(Failure(), request, info) failure = self.media_failed(Failure(), request, info)

View File

@ -26,7 +26,7 @@ from twisted.internet.task import Cooperator
from twisted.python import failure from twisted.python import failure
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.asyncio import call_later, is_asyncio_available from scrapy.utils.asyncio import is_asyncio_available
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable from collections.abc import AsyncIterator, Callable
@ -84,15 +84,6 @@ def defer_succeed(result: _T) -> Deferred[_T]:
return d return d
def _defer_sleep() -> Deferred[None]:
"""Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers
before attending pending delayed calls, so do not set delay to zero.
"""
d: Deferred[None] = Deferred()
call_later(_DEFER_DELAY, d.callback, None)
return d
async def _defer_sleep_async() -> None: async def _defer_sleep_async() -> None:
"""Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers """Delay by _DEFER_DELAY so reactor has a chance to go through readers and writers
before attending pending delayed calls, so do not set delay to zero. before attending pending delayed calls, so do not set delay to zero.
@ -100,7 +91,11 @@ async def _defer_sleep_async() -> None:
if is_asyncio_available(): if is_asyncio_available():
await asyncio.sleep(_DEFER_DELAY) await asyncio.sleep(_DEFER_DELAY)
else: else:
await _defer_sleep() from twisted.internet import reactor
d: Deferred[None] = Deferred()
reactor.callLater(_DEFER_DELAY, d.callback, None)
await d
def defer_result(result: Any) -> Deferred[Any]: def defer_result(result: Any) -> Deferred[Any]:

View File

@ -13,6 +13,7 @@ from shutil import rmtree
from tempfile import mkdtemp from tempfile import mkdtemp
from typing import Any from typing import Any
from unittest import mock from unittest import mock
from unittest.mock import MagicMock
from urllib.parse import urlparse from urllib.parse import urlparse
import attr import attr
@ -31,6 +32,7 @@ from scrapy.pipelines.files import (
S3FilesStore, S3FilesStore,
) )
from scrapy.settings import Settings from scrapy.settings import Settings
from scrapy.utils.defer import deferred_f_from_coro_f
from scrapy.utils.spider import DefaultSpider from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from tests.mockserver.ftp import MockFTPServer from tests.mockserver.ftp import MockFTPServer
@ -83,8 +85,8 @@ class TestFilesPipeline:
settings_dict = {"FILES_STORE": self.tempdir} settings_dict = {"FILES_STORE": self.tempdir}
crawler = get_crawler(DefaultSpider, settings_dict=settings_dict) crawler = get_crawler(DefaultSpider, settings_dict=settings_dict)
crawler.spider = crawler._create_spider() crawler.spider = crawler._create_spider()
crawler.engine = MagicMock(download_async=_mocked_download_func)
self.pipeline = FilesPipeline.from_crawler(crawler) self.pipeline = FilesPipeline.from_crawler(crawler)
self.pipeline.download_func = _mocked_download_func
self.pipeline.open_spider() self.pipeline.open_spider()
def teardown_method(self): def teardown_method(self):
@ -160,8 +162,8 @@ class TestFilesPipeline:
fullpath = Path(self.tempdir, "some", "image", "key.jpg") fullpath = Path(self.tempdir, "some", "image", "key.jpg")
assert self.pipeline.store._get_filesystem_path(path) == fullpath assert self.pipeline.store._get_filesystem_path(path) == fullpath
@inlineCallbacks @deferred_f_from_coro_f
def test_file_not_expired(self): async def test_file_not_expired(self):
item_url = "http://example.com/file.pdf" item_url = "http://example.com/file.pdf"
item = _create_item_with_files(item_url) item = _create_item_with_files(item_url)
patchers = [ patchers = [
@ -180,15 +182,15 @@ class TestFilesPipeline:
for p in patchers: for p in patchers:
p.start() p.start()
result = yield self.pipeline.process_item(item) result = await self.pipeline.process_item(item)
assert result["files"][0]["checksum"] == "abc" assert result["files"][0]["checksum"] == "abc"
assert result["files"][0]["status"] == "uptodate" assert result["files"][0]["status"] == "uptodate"
for p in patchers: for p in patchers:
p.stop() p.stop()
@inlineCallbacks @deferred_f_from_coro_f
def test_file_expired(self): async def test_file_expired(self):
item_url = "http://example.com/file2.pdf" item_url = "http://example.com/file2.pdf"
item = _create_item_with_files(item_url) item = _create_item_with_files(item_url)
patchers = [ patchers = [
@ -211,15 +213,15 @@ class TestFilesPipeline:
for p in patchers: for p in patchers:
p.start() p.start()
result = yield self.pipeline.process_item(item) result = await self.pipeline.process_item(item)
assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["checksum"] != "abc"
assert result["files"][0]["status"] == "downloaded" assert result["files"][0]["status"] == "downloaded"
for p in patchers: for p in patchers:
p.stop() p.stop()
@inlineCallbacks @deferred_f_from_coro_f
def test_file_cached(self): async def test_file_cached(self):
item_url = "http://example.com/file3.pdf" item_url = "http://example.com/file3.pdf"
item = _create_item_with_files(item_url) item = _create_item_with_files(item_url)
patchers = [ patchers = [
@ -242,7 +244,7 @@ class TestFilesPipeline:
for p in patchers: for p in patchers:
p.start() p.start()
result = yield self.pipeline.process_item(item) result = await self.pipeline.process_item(item)
assert result["files"][0]["checksum"] != "abc" assert result["files"][0]["checksum"] != "abc"
assert result["files"][0]["status"] == "cached" assert result["files"][0]["status"] == "cached"

View File

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import warnings import warnings
from unittest.mock import MagicMock
import pytest import pytest
from testfixtures import LogCapture from testfixtures import LogCapture
from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.python.failure import Failure from twisted.python.failure import Failure
from scrapy import signals from scrapy import signals
@ -13,17 +13,21 @@ from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK from scrapy.http.request import NO_CALLBACK
from scrapy.pipelines.files import FileException from scrapy.pipelines.files import FileException
from scrapy.pipelines.media import MediaPipeline from scrapy.pipelines.media import MediaPipeline
from scrapy.utils.asyncio import call_later from scrapy.utils.defer import _defer_sleep_async, deferred_f_from_coro_f
from scrapy.utils.log import failure_to_exc_info from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.signal import disconnect_all from scrapy.utils.signal import disconnect_all
from scrapy.utils.spider import DefaultSpider from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
def _mocked_download_func(request, info): async def _mocked_download_func(request):
assert request.callback is NO_CALLBACK assert request.callback is NO_CALLBACK
response = request.meta.get("response") response = request.meta.get("response")
return response() if callable(response) else response if callable(response):
response = await response()
if isinstance(response, Exception):
raise response
return response
class UserDefinedPipeline(MediaPipeline): class UserDefinedPipeline(MediaPipeline):
@ -50,8 +54,8 @@ class TestBaseMediaPipeline:
def setup_method(self): def setup_method(self):
crawler = get_crawler(DefaultSpider, self.settings) crawler = get_crawler(DefaultSpider, self.settings)
crawler.spider = crawler._create_spider() crawler.spider = crawler._create_spider()
crawler.engine = MagicMock(download_async=_mocked_download_func)
self.pipe = self.pipeline_class.from_crawler(crawler) self.pipe = self.pipeline_class.from_crawler(crawler)
self.pipe.download_func = _mocked_download_func
self.pipe.open_spider() self.pipe.open_spider()
self.info = self.pipe.spiderinfo self.info = self.pipe.spiderinfo
self.fingerprint = crawler.request_fingerprinter.fingerprint self.fingerprint = crawler.request_fingerprinter.fingerprint
@ -158,10 +162,10 @@ class TestBaseMediaPipeline:
assert new_item is item assert new_item is item
assert len(log.records) == 0 assert len(log.records) == 0
@inlineCallbacks @deferred_f_from_coro_f
def test_default_process_item(self): async def test_default_process_item(self):
item = {"name": "name"} item = {"name": "name"}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item is item assert new_item is item
@ -206,8 +210,8 @@ class TestMediaPipeline(TestBaseMediaPipeline):
self.pipe._mockcalled.append("request_errback") self.pipe._mockcalled.append("request_errback")
return result return result
@inlineCallbacks @deferred_f_from_coro_f
def test_result_succeed(self): async def test_result_succeed(self):
rsp = Response("http://url1") rsp = Response("http://url1")
req = Request( req = Request(
"http://url1", "http://url1",
@ -215,7 +219,7 @@ class TestMediaPipeline(TestBaseMediaPipeline):
errback=self._errback, errback=self._errback,
) )
item = {"requests": req} item = {"requests": req}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item["results"] == [(True, {})] assert new_item["results"] == [(True, {})]
assert self.pipe._mockcalled == [ assert self.pipe._mockcalled == [
"get_media_requests", "get_media_requests",
@ -224,18 +228,17 @@ class TestMediaPipeline(TestBaseMediaPipeline):
"item_completed", "item_completed",
] ]
@inlineCallbacks @deferred_f_from_coro_f
def test_result_failure(self): async def test_result_failure(self):
self.pipe.LOG_FAILED_RESULTS = False self.pipe.LOG_FAILED_RESULTS = False
exc = Exception("foo") exc = Exception("foo")
fail = Failure(exc)
req = Request( req = Request(
"http://url1", "http://url1",
meta={"response": fail}, meta={"response": exc},
errback=self._errback, errback=self._errback,
) )
item = {"requests": req} item = {"requests": req}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert len(new_item["results"]) == 1 assert len(new_item["results"]) == 1
assert new_item["results"][0][0] is False assert new_item["results"][0][0] is False
assert isinstance(new_item["results"][0][1], Failure) assert isinstance(new_item["results"][0][1], Failure)
@ -248,16 +251,15 @@ class TestMediaPipeline(TestBaseMediaPipeline):
"item_completed", "item_completed",
] ]
@inlineCallbacks @deferred_f_from_coro_f
def test_mix_of_success_and_failure(self): async def test_mix_of_success_and_failure(self):
self.pipe.LOG_FAILED_RESULTS = False self.pipe.LOG_FAILED_RESULTS = False
rsp1 = Response("http://url1") rsp1 = Response("http://url1")
req1 = Request("http://url1", meta={"response": rsp1}) req1 = Request("http://url1", meta={"response": rsp1})
exc = Exception("foo") exc = Exception("foo")
fail = Failure(exc) req2 = Request("http://url2", meta={"response": exc})
req2 = Request("http://url2", meta={"response": fail})
item = {"requests": [req1, req2]} item = {"requests": [req1, req2]}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert len(new_item["results"]) == 2 assert len(new_item["results"]) == 2
assert new_item["results"][0] == (True, {}) assert new_item["results"][0] == (True, {})
assert new_item["results"][1][0] is False assert new_item["results"][1][0] is False
@ -275,12 +277,12 @@ class TestMediaPipeline(TestBaseMediaPipeline):
assert m.count("media_downloaded") == 1 assert m.count("media_downloaded") == 1
assert m.count("media_failed") == 1 assert m.count("media_failed") == 1
@inlineCallbacks @deferred_f_from_coro_f
def test_get_media_requests(self): async def test_get_media_requests(self):
# returns single Request (without callback) # returns single Request (without callback)
req = Request("http://url") req = Request("http://url")
item = {"requests": req} # pass a single item item = {"requests": req} # pass a single item
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item is item assert new_item is item
assert self.fingerprint(req) in self.info.downloaded assert self.fingerprint(req) in self.info.downloaded
@ -288,17 +290,17 @@ class TestMediaPipeline(TestBaseMediaPipeline):
req1 = Request("http://url1") req1 = Request("http://url1")
req2 = Request("http://url2") req2 = Request("http://url2")
item = {"requests": iter([req1, req2])} item = {"requests": iter([req1, req2])}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item is item assert new_item is item
assert self.fingerprint(req1) in self.info.downloaded assert self.fingerprint(req1) in self.info.downloaded
assert self.fingerprint(req2) in self.info.downloaded assert self.fingerprint(req2) in self.info.downloaded
@inlineCallbacks @deferred_f_from_coro_f
def test_results_are_cached_across_multiple_items(self): async def test_results_are_cached_across_multiple_items(self):
rsp1 = Response("http://url1") rsp1 = Response("http://url1")
req1 = Request("http://url1", meta={"response": rsp1}) req1 = Request("http://url1", meta={"response": rsp1})
item = {"requests": req1} item = {"requests": req1}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item is item assert new_item is item
assert new_item["results"] == [(True, {})] assert new_item["results"] == [(True, {})]
@ -307,25 +309,25 @@ class TestMediaPipeline(TestBaseMediaPipeline):
req1.url, meta={"response": Response("http://donot.download.me")} req1.url, meta={"response": Response("http://donot.download.me")}
) )
item = {"requests": req2} item = {"requests": req2}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item is item assert new_item is item
assert self.fingerprint(req1) == self.fingerprint(req2) assert self.fingerprint(req1) == self.fingerprint(req2)
assert new_item["results"] == [(True, {})] assert new_item["results"] == [(True, {})]
@inlineCallbacks @deferred_f_from_coro_f
def test_results_are_cached_for_requests_of_single_item(self): async def test_results_are_cached_for_requests_of_single_item(self):
rsp1 = Response("http://url1") rsp1 = Response("http://url1")
req1 = Request("http://url1", meta={"response": rsp1}) req1 = Request("http://url1", meta={"response": rsp1})
req2 = Request( req2 = Request(
req1.url, meta={"response": Response("http://donot.download.me")} req1.url, meta={"response": Response("http://donot.download.me")}
) )
item = {"requests": [req1, req2]} item = {"requests": [req1, req2]}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item is item assert new_item is item
assert new_item["results"] == [(True, {}), (True, {})] assert new_item["results"] == [(True, {}), (True, {})]
@inlineCallbacks @deferred_f_from_coro_f
def test_wait_if_request_is_downloading(self): async def test_wait_if_request_is_downloading(self):
def _check_downloading(response): def _check_downloading(response):
fp = self.fingerprint(req1) fp = self.fingerprint(req1)
assert fp in self.info.downloading assert fp in self.info.downloading
@ -336,25 +338,24 @@ class TestMediaPipeline(TestBaseMediaPipeline):
rsp1 = Response("http://url") rsp1 = Response("http://url")
def rsp1_func(): async def rsp1_func():
dfd = Deferred().addCallback(_check_downloading) await _defer_sleep_async()
call_later(0.1, dfd.callback, rsp1) _check_downloading(rsp1)
return dfd
def rsp2_func(): async def rsp2_func():
pytest.fail("it must cache rsp1 result and must not try to redownload") pytest.fail("it must cache rsp1 result and must not try to redownload")
req1 = Request("http://url", meta={"response": rsp1_func}) req1 = Request("http://url", meta={"response": rsp1_func})
req2 = Request(req1.url, meta={"response": rsp2_func}) req2 = Request(req1.url, meta={"response": rsp2_func})
item = {"requests": [req1, req2]} item = {"requests": [req1, req2]}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item["results"] == [(True, {}), (True, {})] assert new_item["results"] == [(True, {}), (True, {})]
@inlineCallbacks @deferred_f_from_coro_f
def test_use_media_to_download_result(self): async def test_use_media_to_download_result(self):
req = Request("http://url", meta={"result": "ITSME"}) req = Request("http://url", meta={"result": "ITSME"})
item = {"requests": req} item = {"requests": req}
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert new_item["results"] == [(True, "ITSME")] assert new_item["results"] == [(True, "ITSME")]
assert self.pipe._mockcalled == [ assert self.pipe._mockcalled == [
"get_media_requests", "get_media_requests",
@ -469,45 +470,29 @@ class MediaFailedFailurePipeline(MockedMediaPipeline):
return failure # deprecated return failure # deprecated
class TestMediaFailedFailure: class TestMediaFailedFailure(TestBaseMediaPipeline):
"""Test that media_failed() can return a failure instead of raising.""" """Test that media_failed() can return a failure instead of raising."""
pipeline_class = MediaFailedFailurePipeline pipeline_class = MediaFailedFailurePipeline
settings = None
def setup_method(self):
crawler = get_crawler(DefaultSpider, self.settings)
crawler.spider = crawler._create_spider()
self.pipe = self.pipeline_class.from_crawler(crawler)
self.pipe.download_func = _mocked_download_func
self.pipe.open_spider()
self.info = self.pipe.spiderinfo
self.fingerprint = crawler.request_fingerprinter.fingerprint
def teardown_method(self):
for name, signal in vars(signals).items():
if not name.startswith("_"):
disconnect_all(signal)
def _errback(self, result): def _errback(self, result):
self.pipe._mockcalled.append("request_errback") self.pipe._mockcalled.append("request_errback")
return result return result
@inlineCallbacks @deferred_f_from_coro_f
def test_result_failure(self): async def test_result_failure(self):
self.pipe.LOG_FAILED_RESULTS = False self.pipe.LOG_FAILED_RESULTS = False
exc = Exception("foo") exc = Exception("foo")
fail = Failure(exc)
req = Request( req = Request(
"http://url1", "http://url1",
meta={"response": fail}, meta={"response": exc},
errback=self._errback, errback=self._errback,
) )
item = {"requests": req} item = {"requests": req}
with pytest.warns( with pytest.warns(
ScrapyDeprecationWarning, match="media_failed returned a Failure instance" ScrapyDeprecationWarning, match="media_failed returned a Failure instance"
): ):
new_item = yield self.pipe.process_item(item) new_item = await self.pipe.process_item(item)
assert len(new_item["results"]) == 1 assert len(new_item["results"]) == 1
assert new_item["results"][0][0] is False assert new_item["results"][0][0] is False
assert isinstance(new_item["results"][0][1], Failure) assert isinstance(new_item["results"][0][1], Failure)