From 812b4bb51855605a36bcfb166f66a121cc67f97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado=20Alonso?= Date: Wed, 23 Jun 2021 17:09:28 +0100 Subject: [PATCH 1/8] CloseSpider can be raised on spider_idle signal handler --- docs/topics/signals.rst | 3 +++ scrapy/core/engine.py | 16 +++++++++++----- scrapy/utils/signal.py | 10 ++++------ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index 3d838fb63..ee9d95836 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -268,6 +268,9 @@ spider_idle You may raise a :exc:`~scrapy.exceptions.DontCloseSpider` exception to prevent the spider from being closed. + Alternatively, you may raise a :exc:`~scrapy.exceptions.CloseSpider` + exception to provide a custom spider closing reason. + This signal does not support returning deferreds from its handlers. :param spider: the spider which has gone idle diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index dd3225082..949d89e76 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -15,7 +15,8 @@ from twisted.python.failure import Failure from scrapy import signals from scrapy.core.scraper import Scraper -from scrapy.exceptions import DontCloseSpider, ScrapyDeprecationWarning +from scrapy.exceptions import DontCloseSpider, ScrapyDeprecationWarning, \ + CloseSpider from scrapy.http import Response, Request from scrapy.settings import BaseSettings from scrapy.spiders import Spider @@ -325,14 +326,19 @@ class ExecutionEngine: Called when a spider gets idle, i.e. when there are no remaining requests to download or schedule. It can be called multiple times. If a handler for the spider_idle signal raises a DontCloseSpider exception, the spider is not closed until the next loop and this function is guaranteed to be called - (at least) once again. + (at least) once again. A handler can raise CloseSpider to provide a custom closing reason """ assert self.spider is not None # typing - res = self.signals.send_catch_log(signals.spider_idle, spider=self.spider, dont_log=DontCloseSpider) - if any(isinstance(x, Failure) and isinstance(x.value, DontCloseSpider) for _, x in res): + expected_ex = (DontCloseSpider, CloseSpider) + res = self.signals.send_catch_log(signals.spider_idle, spider=self.spider, dont_log=expected_ex) + detected_ex = {ex: x.value + for _, x in res for ex in expected_ex + if isinstance(x, Failure) and isinstance(x.value, ex)} + if DontCloseSpider in detected_ex: return None if self.spider_is_idle(): - self.close_spider(self.spider, reason='finished') + reason = detected_ex[CloseSpider].reason if CloseSpider in detected_ex else 'finished' + self.close_spider(self.spider, reason=reason) def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred: """Close (cancel) spider and clear all its outstanding requests""" diff --git a/scrapy/utils/signal.py b/scrapy/utils/signal.py index 115707182..62808f3ce 100644 --- a/scrapy/utils/signal.py +++ b/scrapy/utils/signal.py @@ -1,5 +1,5 @@ """Helper functions for working with signals""" - +import collections import logging from twisted.internet.defer import DeferredList, Deferred @@ -16,15 +16,13 @@ from scrapy.utils.log import failure_to_exc_info logger = logging.getLogger(__name__) -class _IgnoredException(Exception): - pass - - def send_catch_log(signal=Any, sender=Anonymous, *arguments, **named): """Like pydispatcher.robust.sendRobust but it also logs errors and returns Failures instead of exceptions. """ - dont_log = (named.pop('dont_log', _IgnoredException), StopDownload) + dont_log = named.pop('dont_log', ()) + dont_log = tuple(dont_log) if isinstance(dont_log, collections.Sequence) else (dont_log,) + dont_log += (StopDownload, ) spider = named.get('spider', None) responses = [] for receiver in liveReceivers(getAllReceivers(sender, signal)): From ce445f20462463e49bd9dfd417aa7397d4c17f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado=20Alonso?= Date: Thu, 24 Jun 2021 09:56:05 +0100 Subject: [PATCH 2/8] Fix typing --- scrapy/core/engine.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 949d89e76..81a7a5068 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -337,8 +337,9 @@ class ExecutionEngine: if DontCloseSpider in detected_ex: return None if self.spider_is_idle(): - reason = detected_ex[CloseSpider].reason if CloseSpider in detected_ex else 'finished' - self.close_spider(self.spider, reason=reason) + ex = detected_ex.get(CloseSpider, CloseSpider(reason='finished')) + assert isinstance(ex, CloseSpider) # typing + self.close_spider(self.spider, reason=ex.reason) def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred: """Close (cancel) spider and clear all its outstanding requests""" From f35970778b93033ee36379e0b8b55035aacaf918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado=20Alonso?= Date: Tue, 29 Jun 2021 13:21:38 +0100 Subject: [PATCH 3/8] Test case for raising CloseSpider on spider idle signal handler --- tests/test_engine.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_engine.py b/tests/test_engine.py index c200ded90..dc24f50fa 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -26,7 +26,7 @@ from twisted.web import server, static, util from scrapy import signals from scrapy.core.engine import ExecutionEngine -from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.exceptions import ScrapyDeprecationWarning, CloseSpider from scrapy.http import Request from scrapy.item import Item, Field from scrapy.linkextractors import LinkExtractor @@ -113,6 +113,18 @@ class ItemZeroDivisionErrorSpider(TestSpider): } +class ChangeCloseReasonSpider(TestSpider): + @classmethod + def from_crawler(cls, crawler, *args, **kwargs): + spider = cls(*args, **kwargs) + spider._set_crawler(crawler) + crawler.signals.connect(spider.spider_idle, signals.spider_idle) + return spider + + def spider_idle(self): + raise CloseSpider(reason="custom_reason") + + def start_test_site(debug=False): root_dir = os.path.join(tests_datadir, "test_site") r = static.File(root_dir) @@ -251,6 +263,13 @@ class EngineTest(unittest.TestCase): yield self.run.run() self._assert_items_error() + @defer.inlineCallbacks + def test_crawler_change_close_reason_on_idle(self): + self.run = CrawlerRun(ChangeCloseReasonSpider) + yield self.run.run() + self.assertEqual({'spider': self.run.spider, 'reason': 'custom_reason'}, + self.run.signals_caught[signals.spider_closed]) + def _assert_visited_urls(self): must_be_visited = ["/", "/redirect", "/redirected", "/item1.html", "/item2.html", "/item999.html"] From e94d3ac173d2fcd269bdd8f06b6b1633953a225e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado=20Alonso?= Date: Tue, 29 Jun 2021 13:40:43 +0100 Subject: [PATCH 4/8] Expanded doc for idle signal --- docs/topics/signals.rst | 6 +++++- scrapy/core/engine.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index ee9d95836..530af1e37 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -269,7 +269,11 @@ spider_idle prevent the spider from being closed. Alternatively, you may raise a :exc:`~scrapy.exceptions.CloseSpider` - exception to provide a custom spider closing reason. + exception to provide a custom spider closing reason. An + idle handler is the perfect place to put some code that assesses + the final spider results and update the final closing reason + accordingly (e.g. setting it to 'too_few_results' instead of + 'finished'). This signal does not support returning deferreds from its handlers. diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 81a7a5068..0b34d213d 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -326,7 +326,7 @@ class ExecutionEngine: Called when a spider gets idle, i.e. when there are no remaining requests to download or schedule. It can be called multiple times. If a handler for the spider_idle signal raises a DontCloseSpider exception, the spider is not closed until the next loop and this function is guaranteed to be called - (at least) once again. A handler can raise CloseSpider to provide a custom closing reason + (at least) once again. A handler can raise CloseSpider to provide a custom closing reason. """ assert self.spider is not None # typing expected_ex = (DontCloseSpider, CloseSpider) From 7597d860c86e0ddfaf8e3ce57492106c907245f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado?= Date: Thu, 8 Jul 2021 12:39:17 +0100 Subject: [PATCH 5/8] Update scrapy/core/engine.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adrián Chaves --- scrapy/core/engine.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index 0b34d213d..b7f91d744 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -15,8 +15,11 @@ from twisted.python.failure import Failure from scrapy import signals from scrapy.core.scraper import Scraper -from scrapy.exceptions import DontCloseSpider, ScrapyDeprecationWarning, \ - CloseSpider +from scrapy.exceptions import ( + CloseSpider, + DontCloseSpider, + ScrapyDeprecationWarning, +) from scrapy.http import Response, Request from scrapy.settings import BaseSettings from scrapy.spiders import Spider From 6b8f694653b0eec95055d50fafde0ef2e0ec8bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado?= Date: Thu, 8 Jul 2021 12:40:02 +0100 Subject: [PATCH 6/8] Update scrapy/core/engine.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adrián Chaves --- scrapy/core/engine.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index b7f91d744..b04fedce6 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -334,9 +334,12 @@ class ExecutionEngine: assert self.spider is not None # typing expected_ex = (DontCloseSpider, CloseSpider) res = self.signals.send_catch_log(signals.spider_idle, spider=self.spider, dont_log=expected_ex) - detected_ex = {ex: x.value - for _, x in res for ex in expected_ex - if isinstance(x, Failure) and isinstance(x.value, ex)} + detected_ex = { + ex: x.value + for _, x in res + for ex in expected_ex + if isinstance(x, Failure) and isinstance(x.value, ex) + } if DontCloseSpider in detected_ex: return None if self.spider_is_idle(): From eca641aa3da335781c373e3d50deb78837044c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20de=20Prado?= Date: Thu, 8 Jul 2021 12:40:20 +0100 Subject: [PATCH 7/8] Update tests/test_engine.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adrián Chaves --- tests/test_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_engine.py b/tests/test_engine.py index dc24f50fa..92bf45f25 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -26,7 +26,7 @@ from twisted.web import server, static, util from scrapy import signals from scrapy.core.engine import ExecutionEngine -from scrapy.exceptions import ScrapyDeprecationWarning, CloseSpider +from scrapy.exceptions import CloseSpider, ScrapyDeprecationWarning from scrapy.http import Request from scrapy.item import Item, Field from scrapy.linkextractors import LinkExtractor From cb08e3644b081febecd90eec9391367a2d13ee5b Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta <1731933+elacuesta@users.noreply.github.com> Date: Thu, 8 Jul 2021 09:22:21 -0300 Subject: [PATCH 8/8] Remove trailing whitespaces --- scrapy/core/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py index b04fedce6..f9de7ee23 100644 --- a/scrapy/core/engine.py +++ b/scrapy/core/engine.py @@ -17,9 +17,9 @@ from scrapy import signals from scrapy.core.scraper import Scraper from scrapy.exceptions import ( CloseSpider, - DontCloseSpider, + DontCloseSpider, ScrapyDeprecationWarning, -) +) from scrapy.http import Response, Request from scrapy.settings import BaseSettings from scrapy.spiders import Spider