From 706ed0e049fe008cac12f243371b67ee0230a08a Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Tue, 14 Jun 2016 12:34:07 -0300 Subject: [PATCH 001/503] Spider middleware: process_spider_exception on generators --- docs/topics/exceptions.rst | 11 + docs/topics/spider-middleware.rst | 10 +- scrapy/core/spidermw.py | 37 +++- scrapy/exceptions.py | 5 + tests/test_spidermiddleware.py | 340 ++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 14 deletions(-) create mode 100644 tests/test_spidermiddleware.py diff --git a/docs/topics/exceptions.rst b/docs/topics/exceptions.rst index cc02369d4..a3ff72827 100644 --- a/docs/topics/exceptions.rst +++ b/docs/topics/exceptions.rst @@ -62,6 +62,17 @@ remain disabled. Those components include: The exception must be raised in the component's ``__init__`` method. +InvalidOutput +------------- + +.. exception:: InvalidOutput + +This exception can be raised by a downloader or spider middleware to +indicate that some method returned a value not suported by the processing +chain. +See :ref:`topics-spider-middleware` and :ref:`topics-downloader-middleware` +for a list of supported output values. + NotSupported ------------ diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 8360827e8..fc7669437 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -112,11 +112,12 @@ following methods: .. method:: process_spider_exception(response, exception, spider) - This method is called when a spider or :meth:`process_spider_input` - method (from other spider middleware) raises an exception. + This method is called when when a spider or :meth:`process_spider_input`/ + :meth:`process_spider_output` method (from other spider middleware) + raises an exception. :meth:`process_spider_exception` should return either ``None`` or an - iterable of :class:`~scrapy.http.Response`, dict or + iterable of :class:`~scrapy.http.Request`, dict or :class:`~scrapy.item.Item` objects. If it returns ``None``, Scrapy will continue processing this exception, @@ -125,7 +126,8 @@ following methods: exception reaches the engine (where it's logged and discarded). If it returns an iterable the :meth:`process_spider_output` pipeline - kicks in, and no other :meth:`process_spider_exception` will be called. + kicks in, starting with the last non-executed method, and no other + :meth:`process_spider_exception` will be called. :param response: the response being processed when the exception was raised diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index a206e4b0c..0f03a7b36 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -5,6 +5,7 @@ See documentation in docs/topics/spider-middleware.rst """ import six from twisted.python.failure import Failure +from scrapy.exceptions import InvalidOutput from scrapy.middleware import MiddlewareManager from scrapy.utils.defer import mustbe_deferred from scrapy.utils.conf import build_component_list @@ -40,31 +41,47 @@ class SpiderMiddlewareManager(MiddlewareManager): for method in self.methods['process_spider_input']: try: result = method(response=response, spider=spider) - assert result is None, \ - 'Middleware %s must returns None or ' \ - 'raise an exception, got %s ' \ - % (fname(method), type(result)) + if result is not None: + raise InvalidOutput('Middleware {} must return None or raise ' \ + 'an exception, got {}'.format(fname(method), type(result))) except: return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) def process_spider_exception(_failure): exception = _failure.value + # don't handle InvalidOutput exception + if isinstance(exception, InvalidOutput): + return _failure for method in self.methods['process_spider_exception']: result = method(response=response, exception=exception, spider=spider) - assert result is None or _isiterable(result), \ - 'Middleware %s must returns None, or an iterable object, got %s ' % \ - (fname(method), type(result)) + if result is not None and not _isiterable(result): + raise InvalidOutput('Middleware {} must return None or an iterable ' \ + 'object, got {}'.format(fname(method), type(result))) + # stop exception handling by handing control over to the + # process_spider_output chain if an iterable has been returned if result is not None: return result return _failure def process_spider_output(result): + def wrapper(result_iterable): + try: + for r in result_iterable: + yield r + except Exception as ex: + exception_result = process_spider_exception(Failure(ex)) + if exception_result is None or isinstance(exception_result, Failure): + raise + for output in exception_result: + yield output for method in self.methods['process_spider_output']: result = method(response=response, result=result, spider=spider) - assert _isiterable(result), \ - 'Middleware %s must returns an iterable object, got %s ' % \ - (fname(method), type(result)) + if _isiterable(result): + result = wrapper(result) + else: + raise InvalidOutput('Middleware {} must return an iterable object, ' \ + 'got {}'.format(fname(method), type(result))) return result dfd = mustbe_deferred(process_spider_input, response) diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index 4bcecd994..ba7272255 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -11,6 +11,11 @@ class NotConfigured(Exception): """Indicates a missing configuration situation""" pass +class InvalidOutput(TypeError): + """Indicates an invalid value has been returned + by a middleware's processing method""" + pass + # HTTP and crawling class IgnoreRequest(Exception): diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py new file mode 100644 index 000000000..8ec9583d8 --- /dev/null +++ b/tests/test_spidermiddleware.py @@ -0,0 +1,340 @@ + +import logging + +from testfixtures import LogCapture +from twisted.trial.unittest import TestCase +from twisted.internet import defer + +from scrapy.spiders import Spider +from scrapy.item import Item, Field +from scrapy.http import Request +from scrapy.utils.test import get_crawler + + +class TestItem(Item): + value = Field() + + +# ================================================================================ +# exceptions from a spider's parse method +class BaseExceptionFromParseMethodSpider(Spider): + start_urls = ["http://example.com/"] + custom_settings = { + 'SPIDER_MIDDLEWARES': {'tests.test_spidermiddleware.CatchExceptionMiddleware': 540} + } + + +class NotAGeneratorSpider(BaseExceptionFromParseMethodSpider): + """ return value is NOT a generator """ + name = 'not_a_generator' + + def parse(self, response): + raise AssertionError + + +class GeneratorErrorBeforeItemsSpider(BaseExceptionFromParseMethodSpider): + """ return value is a generator; the exception is raised + before the items are yielded: no items should be scraped """ + name = 'generator_error_before_items' + + def parse(self, response): + raise ValueError + for i in range(3): + yield {'value': i} + + +class GeneratorErrorAfterItemsSpider(BaseExceptionFromParseMethodSpider): + """ return value is a generator; the exception is raised + after the items are yielded: 3 items should be scraped """ + name = 'generator_error_after_items' + + def parse(self, response): + for i in range(3): + yield {'value': i} + raise FloatingPointError + + +class CatchExceptionMiddleware(object): + def process_spider_exception(self, response, exception, spider): + """ catch an exception and log it """ + logging.warn('{} exception caught'.format(exception.__class__.__name__)) + return None + + +# ================================================================================ +# exception from a previous middleware's process_spider_input method +# process_spider_input is not expected to return an iterable, so there are no +# separate tests for generator/non-generator implementations +class FromPreviousMiddlewareInputSpider(Spider): + start_urls = ["http://example.com/"] + name = 'not_a_generator_from_previous_middleware_input' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, + 'tests.test_spidermiddleware.RaiseExceptionOnInputMiddleware': 545, + # spider side + } + } + + def parse(self, response): + return None + + +class RaiseExceptionOnInputMiddleware(object): + def process_spider_input(self, response, spider): + raise LookupError + + +# ================================================================================ +# exception from a previous middleware's process_spider_output method (not a generator) +class NotAGeneratorFromPreviousMiddlewareOutputSpider(Spider): + start_urls = ["http://example.com/"] + name = 'not_a_generator_from_previous_middleware_output' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, + 'tests.test_spidermiddleware.RaiseExceptionOnOutputNotAGeneratorMiddleware': 545, + # spider side + } + } + + def parse(self, response): + return [{'value': i} for i in range(3)] + + +class RaiseExceptionOnOutputNotAGeneratorMiddleware(object): + def process_spider_output(self, response, result, spider): + raise UnicodeError + + +# ================================================================================ +# exception from a previous middleware's process_spider_output method (generator) +class GeneratorFromPreviousMiddlewareOutputSpider(Spider): + start_urls = ["http://example.com/"] + name = 'generator_from_previous_middleware_output' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, + 'tests.test_spidermiddleware.RaiseExceptionOnOutputGeneratorMiddleware': 545, + # spider side + } + } + + def parse(self, response): + return [{'value': i} for i in range(10, 13)] + + +class RaiseExceptionOnOutputGeneratorMiddleware(object): + def process_spider_output(self, response, result, spider): + for r in result: + yield r + raise NameError + + +# ================================================================================ +# do something useful from the exception handler +class DoSomethingSpider(Spider): + start_urls = ["http://example.com"] + name = 'do_something' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.DoSomethingMiddleware': 540, + 'tests.test_spidermiddleware.CatchExceptionMiddleware': 545, + # spider side + } + } + + def parse(self, response): + yield {'value': response.url} + raise ImportError + + +class DoSomethingMiddleware(object): + def process_spider_exception(self, response, exception, spider): + return [Request('http://example.org'), {'value': 10}, TestItem(value='asdf')] + + +# ================================================================================ +# don't catch InvalidOutput from scrapy's spider middleware manager +class InvalidReturnValueFromPreviousMiddlewareInputSpider(Spider): + start_urls = ["http://example.com/"] + name = 'invalid_return_value_from_previous_middleware_input' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.InvalidReturnValueInputMiddleware': 540, + 'tests.test_spidermiddleware.CatchExceptionMiddleware': 545, + # spider side + } + } + + def parse(self, response): + return None + + +class InvalidReturnValueInputMiddleware(object): + def process_spider_input(self, response, spider): + return 1.0 # , not None + + +class InvalidReturnValueFromPreviousMiddlewareOutputSpider(Spider): + start_urls = ["http://example.com/"] + name = 'invalid_return_value_from_previous_middleware_output' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, + 'tests.test_spidermiddleware.InvalidReturnValueOutputMiddleware': 545, + # spider side + } + } + + def parse(self, response): + return None + + +class InvalidReturnValueOutputMiddleware(object): + def process_spider_output(self, response, result, spider): + return 1 # , not an iterable + + +# ================================================================================ +# make sure only non already called process_spider_output methods +# are called if process_spider_exception returns an iterable +class ExecutionChainSpider(Spider): + start_urls = ["http://example.com"] + name = 'execution_chain' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # engine side + 'tests.test_spidermiddleware.ThirdMiddleware': 540, + 'tests.test_spidermiddleware.SecondMiddleware': 541, + 'tests.test_spidermiddleware.FirstMiddleware': 542 + # spider side + }, + } + + def parse(self, response): + return None + + +class FirstMiddleware(object): + def process_spider_output(self, response, result, spider): + for r in result: + if isinstance(r, dict): + r['handled_by_first_middleware'] = True + yield r + + def process_spider_exception(self, response, exception, spider): + # log exception, handle control to the next middleware's process_spider_exception + logging.warn('{} exception caught'.format(exception.__class__.__name__)) + return None + + +class SecondMiddleware(object): + def process_spider_output(self, response, result, spider): + for r in result: + if isinstance(r, dict): + r['handled_by_second_middleware'] = True + yield r + raise MemoryError + + +class ThirdMiddleware(object): + def process_spider_output(self, response, result, spider): + for r in result: + if isinstance(r, dict): + r['handled_by_third_middleware'] = True + yield r + + def process_spider_exception(self, response, exception, spider): + # handle control to the next middleware's process_spider_output + return [{'item': i} for i in range(3)] + + +class TestSpiderMiddleware(TestCase): + + @defer.inlineCallbacks + def test_process_spider_exception_from_parse_method(self): + # non-generator return value + crawler = get_crawler(NotAGeneratorSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertIn("AssertionError exception caught", str(log)) + self.assertIn("spider_exceptions/AssertionError", str(log)) + # generator return value, no items before the error + crawler = get_crawler(GeneratorErrorBeforeItemsSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertIn("ValueError exception caught", str(log)) + self.assertIn("spider_exceptions/ValueError", str(log)) + # generator return value, 3 items before the error + crawler = get_crawler(GeneratorErrorAfterItemsSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertIn("'item_scraped_count': 3", str(log)) + self.assertIn("FloatingPointError exception caught", str(log)) + self.assertIn("spider_exceptions/FloatingPointError", str(log)) + + @defer.inlineCallbacks + def test_process_spider_exception_from_previous_middleware_input(self): + crawler = get_crawler(FromPreviousMiddlewareInputSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertIn("LookupError exception caught", str(log)) + + @defer.inlineCallbacks + def test_process_spider_exception_from_previous_middleware_output(self): + # non-generator output value + crawler = get_crawler(NotAGeneratorFromPreviousMiddlewareOutputSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertNotIn("UnicodeError exception caught", str(log)) + # generator output value + crawler = get_crawler(GeneratorFromPreviousMiddlewareOutputSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertIn("'item_scraped_count': 3", str(log)) + self.assertIn("NameError exception caught", str(log)) + + @defer.inlineCallbacks + def test_process_spider_exception_do_something(self): + crawler = get_crawler(DoSomethingSpider) + with LogCapture() as log: + yield crawler.crawl() + self.assertIn("ImportError exception caught", str(log)) + self.assertIn("{'value': 10}", str(log)) + self.assertIn("{'value': 'asdf'}", str(log)) + self.assertIn("{'value': 'http://example.com'}", str(log)) + self.assertIn("{'value': 'http://example.org'}", str(log)) + + @defer.inlineCallbacks + def test_process_spider_exception_invalid_return_value_previous_middleware(self): + """ don't catch InvalidOutput from middleware """ + # on middleware's input + crawler1 = get_crawler(InvalidReturnValueFromPreviousMiddlewareInputSpider) + with LogCapture() as log1: + yield crawler1.crawl() + self.assertNotIn("InvalidOutput exception caught", str(log1)) + self.assertIn("'spider_exceptions/InvalidOutput'", str(log1)) + # on middleware's output + crawler2 = get_crawler(InvalidReturnValueFromPreviousMiddlewareOutputSpider) + with LogCapture() as log2: + yield crawler2.crawl() + self.assertNotIn("InvalidOutput exception caught", str(log2)) + self.assertIn("'spider_exceptions/InvalidOutput'", str(log2)) + + @defer.inlineCallbacks + def test_process_spider_exception_execution_chain(self): + # on middleware's input + crawler1 = get_crawler(ExecutionChainSpider) + with LogCapture() as log1: + yield crawler1.crawl() + self.assertNotIn("handled_by_first_middleware", str(log1)) + self.assertNotIn("handled_by_second_middleware", str(log1)) + self.assertIn("MemoryError exception caught", str(log1)) + self.assertIn("handled_by_third_middleware", str(log1)) From 4090cc3990636337964a6e157679d6be15ba6f3a Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 8 Mar 2017 18:11:20 -0300 Subject: [PATCH 002/503] Spider middleware: use Mockserver to test process_spider_exception --- tests/test_spidermiddleware.py | 42 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 8ec9583d8..3981a8d75 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -9,16 +9,20 @@ from scrapy.spiders import Spider from scrapy.item import Item, Field from scrapy.http import Request from scrapy.utils.test import get_crawler +from tests.mockserver import MockServer class TestItem(Item): value = Field() +class LocalhostSpider(Spider): + start_urls = ['http://localhost:8998'] # tests.mockserver.MockServer + + # ================================================================================ # exceptions from a spider's parse method -class BaseExceptionFromParseMethodSpider(Spider): - start_urls = ["http://example.com/"] +class BaseExceptionFromParseMethodSpider(LocalhostSpider): custom_settings = { 'SPIDER_MIDDLEWARES': {'tests.test_spidermiddleware.CatchExceptionMiddleware': 540} } @@ -65,8 +69,7 @@ class CatchExceptionMiddleware(object): # exception from a previous middleware's process_spider_input method # process_spider_input is not expected to return an iterable, so there are no # separate tests for generator/non-generator implementations -class FromPreviousMiddlewareInputSpider(Spider): - start_urls = ["http://example.com/"] +class FromPreviousMiddlewareInputSpider(LocalhostSpider): name = 'not_a_generator_from_previous_middleware_input' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -88,8 +91,7 @@ class RaiseExceptionOnInputMiddleware(object): # ================================================================================ # exception from a previous middleware's process_spider_output method (not a generator) -class NotAGeneratorFromPreviousMiddlewareOutputSpider(Spider): - start_urls = ["http://example.com/"] +class NotAGeneratorFromPreviousMiddlewareOutputSpider(LocalhostSpider): name = 'not_a_generator_from_previous_middleware_output' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -111,8 +113,7 @@ class RaiseExceptionOnOutputNotAGeneratorMiddleware(object): # ================================================================================ # exception from a previous middleware's process_spider_output method (generator) -class GeneratorFromPreviousMiddlewareOutputSpider(Spider): - start_urls = ["http://example.com/"] +class GeneratorFromPreviousMiddlewareOutputSpider(LocalhostSpider): name = 'generator_from_previous_middleware_output' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -136,8 +137,7 @@ class RaiseExceptionOnOutputGeneratorMiddleware(object): # ================================================================================ # do something useful from the exception handler -class DoSomethingSpider(Spider): - start_urls = ["http://example.com"] +class DoSomethingSpider(LocalhostSpider): name = 'do_something' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -155,13 +155,12 @@ class DoSomethingSpider(Spider): class DoSomethingMiddleware(object): def process_spider_exception(self, response, exception, spider): - return [Request('http://example.org'), {'value': 10}, TestItem(value='asdf')] + return [Request('http://localhost:8998?processed=true'), {'value': 10}, TestItem(value='asdf')] # ================================================================================ # don't catch InvalidOutput from scrapy's spider middleware manager -class InvalidReturnValueFromPreviousMiddlewareInputSpider(Spider): - start_urls = ["http://example.com/"] +class InvalidReturnValueFromPreviousMiddlewareInputSpider(LocalhostSpider): name = 'invalid_return_value_from_previous_middleware_input' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -181,8 +180,7 @@ class InvalidReturnValueInputMiddleware(object): return 1.0 # , not None -class InvalidReturnValueFromPreviousMiddlewareOutputSpider(Spider): - start_urls = ["http://example.com/"] +class InvalidReturnValueFromPreviousMiddlewareOutputSpider(LocalhostSpider): name = 'invalid_return_value_from_previous_middleware_output' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -205,8 +203,7 @@ class InvalidReturnValueOutputMiddleware(object): # ================================================================================ # make sure only non already called process_spider_output methods # are called if process_spider_exception returns an iterable -class ExecutionChainSpider(Spider): - start_urls = ["http://example.com"] +class ExecutionChainSpider(LocalhostSpider): name = 'execution_chain' custom_settings = { 'SPIDER_MIDDLEWARES': { @@ -258,6 +255,13 @@ class ThirdMiddleware(object): class TestSpiderMiddleware(TestCase): + def setUp(self): + self.mockserver = MockServer() + self.mockserver.__enter__() + + def tearDown(self): + self.mockserver.__exit__(None, None, None) + @defer.inlineCallbacks def test_process_spider_exception_from_parse_method(self): # non-generator return value @@ -309,8 +313,8 @@ class TestSpiderMiddleware(TestCase): self.assertIn("ImportError exception caught", str(log)) self.assertIn("{'value': 10}", str(log)) self.assertIn("{'value': 'asdf'}", str(log)) - self.assertIn("{'value': 'http://example.com'}", str(log)) - self.assertIn("{'value': 'http://example.org'}", str(log)) + self.assertIn("{'value': 'http://localhost:8998'}", str(log)) + self.assertIn("{'value': 'http://localhost:8998?processed=true'}", str(log)) @defer.inlineCallbacks def test_process_spider_exception_invalid_return_value_previous_middleware(self): From 9c256cf693d73e854d409d717854b3f354b5e0a9 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 10 Mar 2017 15:41:57 -0300 Subject: [PATCH 003/503] Undocument _InvalidOutput exception --- docs/topics/exceptions.rst | 11 ----------- scrapy/core/spidermw.py | 12 ++++++------ scrapy/exceptions.py | 8 +++++--- tests/test_spidermiddleware.py | 12 ++++++------ 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/docs/topics/exceptions.rst b/docs/topics/exceptions.rst index a3ff72827..cc02369d4 100644 --- a/docs/topics/exceptions.rst +++ b/docs/topics/exceptions.rst @@ -62,17 +62,6 @@ remain disabled. Those components include: The exception must be raised in the component's ``__init__`` method. -InvalidOutput -------------- - -.. exception:: InvalidOutput - -This exception can be raised by a downloader or spider middleware to -indicate that some method returned a value not suported by the processing -chain. -See :ref:`topics-spider-middleware` and :ref:`topics-downloader-middleware` -for a list of supported output values. - NotSupported ------------ diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 0f03a7b36..50677670b 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -5,7 +5,7 @@ See documentation in docs/topics/spider-middleware.rst """ import six from twisted.python.failure import Failure -from scrapy.exceptions import InvalidOutput +from scrapy.exceptions import _InvalidOutput from scrapy.middleware import MiddlewareManager from scrapy.utils.defer import mustbe_deferred from scrapy.utils.conf import build_component_list @@ -42,7 +42,7 @@ class SpiderMiddlewareManager(MiddlewareManager): try: result = method(response=response, spider=spider) if result is not None: - raise InvalidOutput('Middleware {} must return None or raise ' \ + raise _InvalidOutput('Middleware {} must return None or raise ' \ 'an exception, got {}'.format(fname(method), type(result))) except: return scrape_func(Failure(), request, spider) @@ -50,13 +50,13 @@ class SpiderMiddlewareManager(MiddlewareManager): def process_spider_exception(_failure): exception = _failure.value - # don't handle InvalidOutput exception - if isinstance(exception, InvalidOutput): + # don't handle _InvalidOutput exception + if isinstance(exception, _InvalidOutput): return _failure for method in self.methods['process_spider_exception']: result = method(response=response, exception=exception, spider=spider) if result is not None and not _isiterable(result): - raise InvalidOutput('Middleware {} must return None or an iterable ' \ + raise _InvalidOutput('Middleware {} must return None or an iterable ' \ 'object, got {}'.format(fname(method), type(result))) # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned @@ -80,7 +80,7 @@ class SpiderMiddlewareManager(MiddlewareManager): if _isiterable(result): result = wrapper(result) else: - raise InvalidOutput('Middleware {} must return an iterable object, ' \ + raise _InvalidOutput('Middleware {} must return an iterable object, ' \ 'got {}'.format(fname(method), type(result))) return result diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py index ba7272255..96949bdd9 100644 --- a/scrapy/exceptions.py +++ b/scrapy/exceptions.py @@ -11,9 +11,11 @@ class NotConfigured(Exception): """Indicates a missing configuration situation""" pass -class InvalidOutput(TypeError): - """Indicates an invalid value has been returned - by a middleware's processing method""" +class _InvalidOutput(TypeError): + """ + Indicates an invalid value has been returned by a middleware's processing method. + Internal and undocumented, it should not be raised or caught by user code. + """ pass # HTTP and crawling diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 3981a8d75..2d05c335c 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -159,7 +159,7 @@ class DoSomethingMiddleware(object): # ================================================================================ -# don't catch InvalidOutput from scrapy's spider middleware manager +# don't catch _InvalidOutput from scrapy's spider middleware manager class InvalidReturnValueFromPreviousMiddlewareInputSpider(LocalhostSpider): name = 'invalid_return_value_from_previous_middleware_input' custom_settings = { @@ -318,19 +318,19 @@ class TestSpiderMiddleware(TestCase): @defer.inlineCallbacks def test_process_spider_exception_invalid_return_value_previous_middleware(self): - """ don't catch InvalidOutput from middleware """ + """ don't catch _InvalidOutput from middleware """ # on middleware's input crawler1 = get_crawler(InvalidReturnValueFromPreviousMiddlewareInputSpider) with LogCapture() as log1: yield crawler1.crawl() - self.assertNotIn("InvalidOutput exception caught", str(log1)) - self.assertIn("'spider_exceptions/InvalidOutput'", str(log1)) + self.assertNotIn("_InvalidOutput exception caught", str(log1)) + self.assertIn("'spider_exceptions/_InvalidOutput'", str(log1)) # on middleware's output crawler2 = get_crawler(InvalidReturnValueFromPreviousMiddlewareOutputSpider) with LogCapture() as log2: yield crawler2.crawl() - self.assertNotIn("InvalidOutput exception caught", str(log2)) - self.assertIn("'spider_exceptions/InvalidOutput'", str(log2)) + self.assertNotIn("_InvalidOutput exception caught", str(log2)) + self.assertIn("'spider_exceptions/_InvalidOutput'", str(log2)) @defer.inlineCallbacks def test_process_spider_exception_execution_chain(self): From 4cfbe8204480214b65d48caaf080feda30fe91ae Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 10 Mar 2017 15:47:47 -0300 Subject: [PATCH 004/503] Downloader middleware: raise _InvalidOutput Instead of AssertionError, to make it consistent with spider middleware --- scrapy/core/downloader/middleware.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index c3b23e284..cf0c1f869 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -7,6 +7,7 @@ import six from twisted.internet import defer +from scrapy.exceptions import _InvalidOutput from scrapy.http import Request, Response from scrapy.middleware import MiddlewareManager from scrapy.utils.defer import mustbe_deferred @@ -35,9 +36,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): def process_request(request): for method in self.methods['process_request']: response = yield method(request=request, spider=spider) - assert response is None or isinstance(response, (Response, Request)), \ - 'Middleware %s.process_request must return None, Response or Request, got %s' % \ - (six.get_method_self(method).__class__.__name__, response.__class__.__name__) + if response is not None and not isinstance(response, (Response, Request)): + raise _InvalidOutput('Middleware %s.process_request must return None, Response or Request, got %s' % \ + (six.get_method_self(method).__class__.__name__, response.__class__.__name__)) if response: defer.returnValue(response) defer.returnValue((yield download_func(request=request,spider=spider))) @@ -51,9 +52,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): for method in self.methods['process_response']: response = yield method(request=request, response=response, spider=spider) - assert isinstance(response, (Response, Request)), \ - 'Middleware %s.process_response must return Response or Request, got %s' % \ - (six.get_method_self(method).__class__.__name__, type(response)) + if not isinstance(response, (Response, Request)): + raise _InvalidOutput('Middleware %s.process_response must return Response or Request, got %s' % \ + (six.get_method_self(method).__class__.__name__, type(response))) if isinstance(response, Request): defer.returnValue(response) defer.returnValue(response) @@ -64,9 +65,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): for method in self.methods['process_exception']: response = yield method(request=request, exception=exception, spider=spider) - assert response is None or isinstance(response, (Response, Request)), \ - 'Middleware %s.process_exception must return None, Response or Request, got %s' % \ - (six.get_method_self(method).__class__.__name__, type(response)) + if response is not None and not isinstance(response, (Response, Request)): + raise _InvalidOutput('Middleware %s.process_exception must return None, Response or Request, got %s' % \ + (six.get_method_self(method).__class__.__name__, type(response))) if response: defer.returnValue(response) defer.returnValue(_failure) From b040df5ac09cf133cd07b505d20469a56409129c Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Tue, 21 Mar 2017 15:56:18 +0500 Subject: [PATCH 005/503] TST cleanup spider middleware tests --- tests/test_spidermiddleware.py | 73 ++++++++++++++++------------------ 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 2d05c335c..109bcc250 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -255,61 +255,62 @@ class ThirdMiddleware(object): class TestSpiderMiddleware(TestCase): - def setUp(self): - self.mockserver = MockServer() - self.mockserver.__enter__() + @classmethod + def setUpClass(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() - def tearDown(self): - self.mockserver.__exit__(None, None, None) + @classmethod + def tearDownClass(cls): + cls.mockserver.__exit__(None, None, None) @defer.inlineCallbacks - def test_process_spider_exception_from_parse_method(self): - # non-generator return value - crawler = get_crawler(NotAGeneratorSpider) + def crawl_log(self, spider): + crawler = get_crawler(spider) with LogCapture() as log: yield crawler.crawl() + raise defer.returnValue(log) + + @defer.inlineCallbacks + def test_process_spider_exception_from_parse_method_non_generator(self): + # non-generator return value + log = yield self.crawl_log(NotAGeneratorSpider) self.assertIn("AssertionError exception caught", str(log)) self.assertIn("spider_exceptions/AssertionError", str(log)) + + @defer.inlineCallbacks + def test_process_spider_exception_from_parse_method_generator_no_items(self): # generator return value, no items before the error - crawler = get_crawler(GeneratorErrorBeforeItemsSpider) - with LogCapture() as log: - yield crawler.crawl() + log = yield self.crawl_log(GeneratorErrorBeforeItemsSpider) self.assertIn("ValueError exception caught", str(log)) self.assertIn("spider_exceptions/ValueError", str(log)) + + @defer.inlineCallbacks + def test_process_spider_exception_from_parse_method_generator_with_items(self): # generator return value, 3 items before the error - crawler = get_crawler(GeneratorErrorAfterItemsSpider) - with LogCapture() as log: - yield crawler.crawl() + log = yield self.crawl_log(GeneratorErrorAfterItemsSpider) self.assertIn("'item_scraped_count': 3", str(log)) self.assertIn("FloatingPointError exception caught", str(log)) self.assertIn("spider_exceptions/FloatingPointError", str(log)) @defer.inlineCallbacks def test_process_spider_exception_from_previous_middleware_input(self): - crawler = get_crawler(FromPreviousMiddlewareInputSpider) - with LogCapture() as log: - yield crawler.crawl() + log = yield self.crawl_log(FromPreviousMiddlewareInputSpider) self.assertIn("LookupError exception caught", str(log)) @defer.inlineCallbacks def test_process_spider_exception_from_previous_middleware_output(self): # non-generator output value - crawler = get_crawler(NotAGeneratorFromPreviousMiddlewareOutputSpider) - with LogCapture() as log: - yield crawler.crawl() + log = yield self.crawl_log(NotAGeneratorFromPreviousMiddlewareOutputSpider) self.assertNotIn("UnicodeError exception caught", str(log)) # generator output value - crawler = get_crawler(GeneratorFromPreviousMiddlewareOutputSpider) - with LogCapture() as log: - yield crawler.crawl() + log = yield self.crawl_log(GeneratorFromPreviousMiddlewareOutputSpider) self.assertIn("'item_scraped_count': 3", str(log)) self.assertIn("NameError exception caught", str(log)) @defer.inlineCallbacks def test_process_spider_exception_do_something(self): - crawler = get_crawler(DoSomethingSpider) - with LogCapture() as log: - yield crawler.crawl() + log = yield self.crawl_log(DoSomethingSpider) self.assertIn("ImportError exception caught", str(log)) self.assertIn("{'value': 10}", str(log)) self.assertIn("{'value': 'asdf'}", str(log)) @@ -320,25 +321,19 @@ class TestSpiderMiddleware(TestCase): def test_process_spider_exception_invalid_return_value_previous_middleware(self): """ don't catch _InvalidOutput from middleware """ # on middleware's input - crawler1 = get_crawler(InvalidReturnValueFromPreviousMiddlewareInputSpider) - with LogCapture() as log1: - yield crawler1.crawl() + log1 = yield self.crawl_log(InvalidReturnValueFromPreviousMiddlewareInputSpider) self.assertNotIn("_InvalidOutput exception caught", str(log1)) self.assertIn("'spider_exceptions/_InvalidOutput'", str(log1)) # on middleware's output - crawler2 = get_crawler(InvalidReturnValueFromPreviousMiddlewareOutputSpider) - with LogCapture() as log2: - yield crawler2.crawl() + log2 = yield self.crawl_log(InvalidReturnValueFromPreviousMiddlewareOutputSpider) self.assertNotIn("_InvalidOutput exception caught", str(log2)) self.assertIn("'spider_exceptions/_InvalidOutput'", str(log2)) @defer.inlineCallbacks def test_process_spider_exception_execution_chain(self): # on middleware's input - crawler1 = get_crawler(ExecutionChainSpider) - with LogCapture() as log1: - yield crawler1.crawl() - self.assertNotIn("handled_by_first_middleware", str(log1)) - self.assertNotIn("handled_by_second_middleware", str(log1)) - self.assertIn("MemoryError exception caught", str(log1)) - self.assertIn("handled_by_third_middleware", str(log1)) + log = yield self.crawl_log(ExecutionChainSpider) + self.assertNotIn("handled_by_first_middleware", str(log)) + self.assertNotIn("handled_by_second_middleware", str(log)) + self.assertIn("MemoryError exception caught", str(log)) + self.assertIn("handled_by_third_middleware", str(log)) From 4740dca8f260bef83eed849b692b3a2c1aaec6cf Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 24 Jun 2018 20:59:18 -0300 Subject: [PATCH 006/503] Deferred-like process_output/process_exception chain --- scrapy/core/spidermw.py | 58 +++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 50677670b..98e264bd3 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -17,6 +17,11 @@ class SpiderMiddlewareManager(MiddlewareManager): component_name = 'spider middleware' + # List of dicts. Each dict represents a spider middleware and contains the + # 'process_spider_output', 'process_spider_exception' methods. + # The idea is to simulate the behaviour of a Twisted deferred's callback/errback chain + output_methods = [] + @classmethod def _get_mwlist_from_settings(cls, settings): return build_component_list(settings.getwithbase('SPIDER_MIDDLEWARES')) @@ -25,12 +30,12 @@ class SpiderMiddlewareManager(MiddlewareManager): super(SpiderMiddlewareManager, self)._add_middleware(mw) if hasattr(mw, 'process_spider_input'): self.methods['process_spider_input'].append(mw.process_spider_input) - if hasattr(mw, 'process_spider_output'): - self.methods['process_spider_output'].insert(0, mw.process_spider_output) - if hasattr(mw, 'process_spider_exception'): - self.methods['process_spider_exception'].insert(0, mw.process_spider_exception) if hasattr(mw, 'process_start_requests'): self.methods['process_start_requests'].insert(0, mw.process_start_requests) + self.output_methods.insert(0, dict( + process_spider_output=getattr(mw, 'process_spider_output', None), + process_spider_exception=getattr(mw, 'process_spider_exception', None), + )) def scrape_response(self, scrape_func, response, request, spider): fname = lambda f:'%s.%s' % ( @@ -48,45 +53,54 @@ class SpiderMiddlewareManager(MiddlewareManager): return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) - def process_spider_exception(_failure): + def process_spider_exception(_failure, mw_index): exception = _failure.value # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): return _failure - for method in self.methods['process_spider_exception']: - result = method(response=response, exception=exception, spider=spider) - if result is not None and not _isiterable(result): - raise _InvalidOutput('Middleware {} must return None or an iterable ' \ - 'object, got {}'.format(fname(method), type(result))) - # stop exception handling by handing control over to the - # process_spider_output chain if an iterable has been returned - if result is not None: - return result + for index, mw in enumerate(self.output_methods): + if index < mw_index or mw['process_spider_exception'] is None: + continue + result = mw['process_spider_exception'](response=response, exception=exception, spider=spider) + mw_index += 1 + if _isiterable(result): + # stop exception handling by handing control over to the + # process_spider_output chain if an iterable has been returned + return process_spider_output(result, mw_index) + elif result is None: + continue + else: + raise _InvalidOutput('Middleware {} must return None or an iterable, got {}' \ + .format(fname(mw['process_spider_exception']), type(result))) return _failure - def process_spider_output(result): + def process_spider_output(result, mw_index): def wrapper(result_iterable): try: for r in result_iterable: yield r except Exception as ex: - exception_result = process_spider_exception(Failure(ex)) + # process the exception with the method from the next middleware + exception_result = process_spider_exception(Failure(ex), mw_index) if exception_result is None or isinstance(exception_result, Failure): raise for output in exception_result: yield output - for method in self.methods['process_spider_output']: - result = method(response=response, result=result, spider=spider) + for index, mw in enumerate(self.output_methods): + if index < mw_index or mw['process_spider_output'] is None: + continue + result = mw['process_spider_output'](response=response, result=result, spider=spider) + mw_index += 1 if _isiterable(result): result = wrapper(result) else: - raise _InvalidOutput('Middleware {} must return an iterable object, ' \ - 'got {}'.format(fname(method), type(result))) + raise _InvalidOutput('Middleware {} must return an iterable, got {}' \ + .format(fname(mw['process_spider_output']), type(result))) return result dfd = mustbe_deferred(process_spider_input, response) - dfd.addErrback(process_spider_exception) - dfd.addCallback(process_spider_output) + dfd.addErrback(process_spider_exception, mw_index=0) + dfd.addCallback(process_spider_output, mw_index=0) return dfd def process_start_requests(self, start_requests, spider): From ba294351381c0dd81476603246d2cea6c31486be Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Mon, 25 Jun 2018 15:01:12 -0300 Subject: [PATCH 007/503] Default values for OffsiteMiddleware For some reason test_crawl.py seems to be skipping the spider_opened method, which initializes the host_regex instance variable --- scrapy/spidermiddlewares/offsite.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scrapy/spidermiddlewares/offsite.py b/scrapy/spidermiddlewares/offsite.py index 310166cad..3b7f194e4 100644 --- a/scrapy/spidermiddlewares/offsite.py +++ b/scrapy/spidermiddlewares/offsite.py @@ -19,6 +19,9 @@ class OffsiteMiddleware(object): def __init__(self, stats): self.stats = stats + # default values + self.host_regex = re.compile('') # allow all by default + self.domains_seen = set() @classmethod def from_crawler(cls, crawler): @@ -52,7 +55,7 @@ class OffsiteMiddleware(object): """Override this method to implement a different offsite policy""" allowed_domains = getattr(spider, 'allowed_domains', None) if not allowed_domains: - return re.compile('') # allow all by default + return url_pattern = re.compile("^https?://.*$") for domain in allowed_domains: if url_pattern.match(domain): @@ -62,8 +65,9 @@ class OffsiteMiddleware(object): return re.compile(regex) def spider_opened(self, spider): - self.host_regex = self.get_host_regex(spider) - self.domains_seen = set() + host_regex = self.get_host_regex(spider) + if host_regex: + self.host_regex = host_regex class URLWarning(Warning): From df75a0942e004f9645182a0260769f4337f843e5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 1 Jul 2018 13:30:50 -0300 Subject: [PATCH 008/503] Update docs --- docs/topics/spider-middleware.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 0af26be73..dde1786af 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -112,9 +112,8 @@ following methods: .. method:: process_spider_exception(response, exception, spider) - This method is called when when a spider or :meth:`process_spider_input`/ - :meth:`process_spider_output` method (from other spider middleware) - raises an exception. + This method is called when a spider or :meth:`process_spider_output` + method (from a previous spider middleware) raises an exception. :meth:`process_spider_exception` should return either ``None`` or an iterable of :class:`~scrapy.http.Request`, dict or @@ -126,7 +125,7 @@ following methods: exception reaches the engine (where it's logged and discarded). If it returns an iterable the :meth:`process_spider_output` pipeline - kicks in, starting with the last non-executed method, and no other + kicks in, starting from the next spider middleware, and no other :meth:`process_spider_exception` will be called. :param response: the response being processed when the exception was From 735de8167d3e6b0085710d406c8c2976913baa43 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sat, 30 Jun 2018 20:55:17 -0300 Subject: [PATCH 009/503] Test for exceptions on process_spider_input --- tests/test_spider_mw.py | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_spider_mw.py diff --git a/tests/test_spider_mw.py b/tests/test_spider_mw.py new file mode 100644 index 000000000..4a431d379 --- /dev/null +++ b/tests/test_spider_mw.py @@ -0,0 +1,77 @@ + +import logging + +from testfixtures import LogCapture +from twisted.trial.unittest import TestCase +from twisted.internet import defer + +from scrapy import Spider, Request +from scrapy.utils.test import get_crawler +from tests.mockserver import MockServer + + +class CommonTestCase(TestCase): + @classmethod + def setUpClass(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() + + @classmethod + def tearDownClass(cls): + cls.mockserver.__exit__(None, None, None) + + @defer.inlineCallbacks + def crawl_log(self, spider): + crawler = get_crawler(spider) + with LogCapture() as log: + yield crawler.crawl() + raise defer.returnValue(log) + + +class LogExceptionMiddleware(object): + def process_spider_exception(self, response, exception, spider): + logging.warn('Middleware: %s exception caught', exception.__class__.__name__) + return None + + +# ================================================================================ +# (1) exceptions from a spider middleware's process_spider_input method +class ProcessSpiderInputSpider(Spider): + name = 'ProcessSpiderInputSpider' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # spider + __name__ + '.LogExceptionMiddleware': 10, + __name__ + '.FailProcessSpiderInputMiddleware': 8, + __name__ + '.LogExceptionMiddleware': 6, + # engine + } + } + + def start_requests(self): + yield Request('http://localhost:8998', callback=self.parse, errback=self.errback) + + def parse(self, response): + return [{'test': 1}, {'test': 2}] + + def errback(self, failure): + self.logger.warn('Got a Failure on the Request errback') + + +class FailProcessSpiderInputMiddleware: + def process_spider_input(self, response, spider): + logging.warn('Middleware: will raise ZeroDivisionError') + raise ZeroDivisionError() + + +class TestProcessSpiderInputSpider(CommonTestCase): + @defer.inlineCallbacks + def test_process_spider_input_errback(self): + """ + (1) An exception from the process_spider_input chain should not be caught by the + process_spider_exception chain, it should go directly to the Request errback + """ + log = yield self.crawl_log(ProcessSpiderInputSpider) + self.assertNotIn('Middleware: ZeroDivisionError exception caught', str(log)) + self.assertIn('Middleware: will raise ZeroDivisionError', str(log)) + self.assertIn('Got a Failure on the Request errback', str(log)) From 6ed9440ed528ab5c5eece50512e19929e4320b42 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sat, 30 Jun 2018 21:27:10 -0300 Subject: [PATCH 010/503] Tests for exceptions on spider callbacks --- tests/test_spider_mw.py | 72 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/tests/test_spider_mw.py b/tests/test_spider_mw.py index 4a431d379..092546291 100644 --- a/tests/test_spider_mw.py +++ b/tests/test_spider_mw.py @@ -60,11 +60,11 @@ class ProcessSpiderInputSpider(Spider): class FailProcessSpiderInputMiddleware: def process_spider_input(self, response, spider): - logging.warn('Middleware: will raise ZeroDivisionError') - raise ZeroDivisionError() + logging.warn('Middleware: will raise IndexError') + raise IndexError() -class TestProcessSpiderInputSpider(CommonTestCase): +class TestProcessSpiderInput(CommonTestCase): @defer.inlineCallbacks def test_process_spider_input_errback(self): """ @@ -72,6 +72,66 @@ class TestProcessSpiderInputSpider(CommonTestCase): process_spider_exception chain, it should go directly to the Request errback """ log = yield self.crawl_log(ProcessSpiderInputSpider) - self.assertNotIn('Middleware: ZeroDivisionError exception caught', str(log)) - self.assertIn('Middleware: will raise ZeroDivisionError', str(log)) - self.assertIn('Got a Failure on the Request errback', str(log)) + self.assertNotIn("Middleware: IndexError exception caught", str(log)) + self.assertIn("Middleware: will raise IndexError", str(log)) + self.assertIn("Got a Failure on the Request errback", str(log)) + + +# ================================================================================ +# (2) exceptions from a spider callback (generator) +class GeneratorCallbackSpider(Spider): + name = 'GeneratorCallbackSpider' + start_urls = ['http://localhost:8998'] + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # spider + __name__ + '.LogExceptionMiddleware': 10, + # engine + }, + } + + def parse(self, response): + yield {'test': 1} + yield {'test': 2} + raise ImportError() + + +class TestGeneratorCallback(CommonTestCase): + @defer.inlineCallbacks + def test_generator_callback(self): + """ + (2) An exception from a spider's callback should + be caught by the process_spider_exception chain + """ + log = yield self.crawl_log(GeneratorCallbackSpider) + self.assertIn("Middleware: ImportError exception caught", str(log)) + self.assertIn("'item_scraped_count': 2", str(log)) + + +# ================================================================================ +# (3) exceptions from a spider callback (not a generator) +class NotAGeneratorCallbackSpider(Spider): + name = 'NotAGeneratorCallbackSpider' + start_urls = ['http://localhost:8998'] + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # spider + __name__ + '.LogExceptionMiddleware': 10, + # engine + }, + } + + def parse(self, response): + return [{'test': 1}, {'test': 1/0}] + + +class TestNotAGeneratorCallback(CommonTestCase): + @defer.inlineCallbacks + def test_not_a_generator_callback(self): + """ + (3) An exception from a spider's callback should + be caught by the process_spider_exception chain + """ + log = yield self.crawl_log(NotAGeneratorCallbackSpider) + self.assertIn("Middleware: ZeroDivisionError exception caught", str(log)) + self.assertNotIn("item_scraped_count", str(log)) From 4fca9aba851133fcdc12bb46c7ae229d9537079a Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 1 Jul 2018 13:18:29 -0300 Subject: [PATCH 011/503] Recover from a callback exception --- tests/test_spider_mw.py | 137 +++++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 52 deletions(-) diff --git a/tests/test_spider_mw.py b/tests/test_spider_mw.py index 092546291..49a60d08b 100644 --- a/tests/test_spider_mw.py +++ b/tests/test_spider_mw.py @@ -10,22 +10,8 @@ from scrapy.utils.test import get_crawler from tests.mockserver import MockServer -class CommonTestCase(TestCase): - @classmethod - def setUpClass(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def tearDownClass(cls): - cls.mockserver.__exit__(None, None, None) - - @defer.inlineCallbacks - def crawl_log(self, spider): - crawler = get_crawler(spider) - with LogCapture() as log: - yield crawler.crawl() - raise defer.returnValue(log) +# TEST_URL = 'http://example.org' +TEST_URL = 'http://localhost:8998' class LogExceptionMiddleware(object): @@ -34,6 +20,32 @@ class LogExceptionMiddleware(object): return None +# ================================================================================ +# recover from an exception on a spider callback +class RecoverySpider(Spider): + name = 'RecoverySpider' + start_urls = [TEST_URL] + custom_settings = { + 'SPIDER_MIDDLEWARES': { + __name__ + '.RecoveryMiddleware': 10, + }, + } + + def parse(self, response): + yield {'test': 1} + self.logger.warn('DONT_FAIL: %s', response.meta.get('dont_fail')) + if not response.meta.get('dont_fail'): + raise ModuleNotFoundError() + +class RecoveryMiddleware(object): + def process_spider_exception(self, response, exception, spider): + logging.warn('Middleware: %s exception caught', exception.__class__.__name__) + return [ + {'from': 'process_spider_exception'}, + Request(response.url, meta={'dont_fail': True}, dont_filter=True), + ] + + # ================================================================================ # (1) exceptions from a spider middleware's process_spider_input method class ProcessSpiderInputSpider(Spider): @@ -49,7 +61,7 @@ class ProcessSpiderInputSpider(Spider): } def start_requests(self): - yield Request('http://localhost:8998', callback=self.parse, errback=self.errback) + yield Request(TEST_URL, callback=self.parse, errback=self.errback) def parse(self, response): return [{'test': 1}, {'test': 2}] @@ -64,29 +76,14 @@ class FailProcessSpiderInputMiddleware: raise IndexError() -class TestProcessSpiderInput(CommonTestCase): - @defer.inlineCallbacks - def test_process_spider_input_errback(self): - """ - (1) An exception from the process_spider_input chain should not be caught by the - process_spider_exception chain, it should go directly to the Request errback - """ - log = yield self.crawl_log(ProcessSpiderInputSpider) - self.assertNotIn("Middleware: IndexError exception caught", str(log)) - self.assertIn("Middleware: will raise IndexError", str(log)) - self.assertIn("Got a Failure on the Request errback", str(log)) - - # ================================================================================ # (2) exceptions from a spider callback (generator) class GeneratorCallbackSpider(Spider): name = 'GeneratorCallbackSpider' - start_urls = ['http://localhost:8998'] + start_urls = [TEST_URL] custom_settings = { 'SPIDER_MIDDLEWARES': { - # spider __name__ + '.LogExceptionMiddleware': 10, - # engine }, } @@ -96,28 +93,14 @@ class GeneratorCallbackSpider(Spider): raise ImportError() -class TestGeneratorCallback(CommonTestCase): - @defer.inlineCallbacks - def test_generator_callback(self): - """ - (2) An exception from a spider's callback should - be caught by the process_spider_exception chain - """ - log = yield self.crawl_log(GeneratorCallbackSpider) - self.assertIn("Middleware: ImportError exception caught", str(log)) - self.assertIn("'item_scraped_count': 2", str(log)) - - # ================================================================================ # (3) exceptions from a spider callback (not a generator) class NotAGeneratorCallbackSpider(Spider): name = 'NotAGeneratorCallbackSpider' - start_urls = ['http://localhost:8998'] + start_urls = [TEST_URL] custom_settings = { 'SPIDER_MIDDLEWARES': { - # spider __name__ + '.LogExceptionMiddleware': 10, - # engine }, } @@ -125,13 +108,63 @@ class NotAGeneratorCallbackSpider(Spider): return [{'test': 1}, {'test': 1/0}] -class TestNotAGeneratorCallback(CommonTestCase): +# ================================================================================ +class TestSpiderMiddleware(TestCase): + @classmethod + def setUpClass(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() + + @classmethod + def tearDownClass(cls): + cls.mockserver.__exit__(None, None, None) + + @defer.inlineCallbacks + def crawl_log(self, spider): + crawler = get_crawler(spider) + with LogCapture() as log: + yield crawler.crawl() + raise defer.returnValue(log) + + # @defer.inlineCallbacks + # def test_recovery(self): + # """ + # Recover from an exception from a spider's callback. The final item count should be 3 + # (one from the spider before raising the exception, one from the middleware and one + # from the spider when processing the response that was enqueued from the middleware) + # """ + # log = yield self.crawl_log(RecoverySpider) + # self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) + # self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) + # self.assertIn("'item_scraped_count': 3", str(log)) + + @defer.inlineCallbacks + def test_process_spider_input_errback(self): + """ + (1) An exception from the process_spider_input chain should not be caught by the + process_spider_exception chain, it should go directly to the Request errback + """ + log1 = yield self.crawl_log(ProcessSpiderInputSpider) + self.assertNotIn("Middleware: IndexError exception caught", str(log1)) + self.assertIn("Middleware: will raise IndexError", str(log1)) + self.assertIn("Got a Failure on the Request errback", str(log1)) + + @defer.inlineCallbacks + def test_generator_callback(self): + """ + (2) An exception from a spider's callback should + be caught by the process_spider_exception chain + """ + log2 = yield self.crawl_log(GeneratorCallbackSpider) + self.assertIn("Middleware: ImportError exception caught", str(log2)) + self.assertIn("'item_scraped_count': 2", str(log2)) + @defer.inlineCallbacks def test_not_a_generator_callback(self): """ (3) An exception from a spider's callback should be caught by the process_spider_exception chain """ - log = yield self.crawl_log(NotAGeneratorCallbackSpider) - self.assertIn("Middleware: ZeroDivisionError exception caught", str(log)) - self.assertNotIn("item_scraped_count", str(log)) + log3 = yield self.crawl_log(NotAGeneratorCallbackSpider) + self.assertIn("Middleware: ZeroDivisionError exception caught", str(log3)) + self.assertNotIn("item_scraped_count", str(log3)) From 985ab636cfa0825f100b02583bcfd106d1f4cef6 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 1 Jul 2018 17:49:30 -0300 Subject: [PATCH 012/503] Store output methods on the 'methods' dict --- scrapy/core/spidermw.py | 43 +++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 98e264bd3..c9dd8c91e 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -17,11 +17,6 @@ class SpiderMiddlewareManager(MiddlewareManager): component_name = 'spider middleware' - # List of dicts. Each dict represents a spider middleware and contains the - # 'process_spider_output', 'process_spider_exception' methods. - # The idea is to simulate the behaviour of a Twisted deferred's callback/errback chain - output_methods = [] - @classmethod def _get_mwlist_from_settings(cls, settings): return build_component_list(settings.getwithbase('SPIDER_MIDDLEWARES')) @@ -32,10 +27,8 @@ class SpiderMiddlewareManager(MiddlewareManager): self.methods['process_spider_input'].append(mw.process_spider_input) if hasattr(mw, 'process_start_requests'): self.methods['process_start_requests'].insert(0, mw.process_start_requests) - self.output_methods.insert(0, dict( - process_spider_output=getattr(mw, 'process_spider_output', None), - process_spider_exception=getattr(mw, 'process_spider_exception', None), - )) + self.methods['process_spider_output'].insert(0, getattr(mw, 'process_spider_output', None)) + self.methods['process_spider_exception'].insert(0, getattr(mw, 'process_spider_exception', None)) def scrape_response(self, scrape_func, response, request, spider): fname = lambda f:'%s.%s' % ( @@ -53,54 +46,54 @@ class SpiderMiddlewareManager(MiddlewareManager): return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) - def process_spider_exception(_failure, mw_index): + def process_spider_exception(_failure, index): exception = _failure.value # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): return _failure - for index, mw in enumerate(self.output_methods): - if index < mw_index or mw['process_spider_exception'] is None: + for i, method in enumerate(self.methods['process_spider_exception']): + if i < index or method is None: continue - result = mw['process_spider_exception'](response=response, exception=exception, spider=spider) - mw_index += 1 + result = method(response=response, exception=exception, spider=spider) + index += 1 if _isiterable(result): # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned - return process_spider_output(result, mw_index) + return process_spider_output(result, index) elif result is None: continue else: raise _InvalidOutput('Middleware {} must return None or an iterable, got {}' \ - .format(fname(mw['process_spider_exception']), type(result))) + .format(fname(method), type(result))) return _failure - def process_spider_output(result, mw_index): + def process_spider_output(result, index): def wrapper(result_iterable): try: for r in result_iterable: yield r except Exception as ex: # process the exception with the method from the next middleware - exception_result = process_spider_exception(Failure(ex), mw_index) + exception_result = process_spider_exception(Failure(ex), index) if exception_result is None or isinstance(exception_result, Failure): raise for output in exception_result: yield output - for index, mw in enumerate(self.output_methods): - if index < mw_index or mw['process_spider_output'] is None: + for i, method in enumerate(self.methods['process_spider_output']): + if i < index or method is None: continue - result = mw['process_spider_output'](response=response, result=result, spider=spider) - mw_index += 1 + result = method(response=response, result=result, spider=spider) + index += 1 if _isiterable(result): result = wrapper(result) else: raise _InvalidOutput('Middleware {} must return an iterable, got {}' \ - .format(fname(mw['process_spider_output']), type(result))) + .format(fname(method), type(result))) return result dfd = mustbe_deferred(process_spider_input, response) - dfd.addErrback(process_spider_exception, mw_index=0) - dfd.addCallback(process_spider_output, mw_index=0) + dfd.addErrback(process_spider_exception, index=0) + dfd.addCallback(process_spider_output, index=0) return dfd def process_start_requests(self, start_requests, spider): From 0b2870634af6cc14191faafcba7d58a5f3cc3016 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 4 Jul 2018 16:14:51 -0300 Subject: [PATCH 013/503] Do not inherit from object --- tests/test_spider_mw.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_spider_mw.py b/tests/test_spider_mw.py index 49a60d08b..2565ef7af 100644 --- a/tests/test_spider_mw.py +++ b/tests/test_spider_mw.py @@ -14,7 +14,7 @@ from tests.mockserver import MockServer TEST_URL = 'http://localhost:8998' -class LogExceptionMiddleware(object): +class LogExceptionMiddleware: def process_spider_exception(self, response, exception, spider): logging.warn('Middleware: %s exception caught', exception.__class__.__name__) return None @@ -37,7 +37,7 @@ class RecoverySpider(Spider): if not response.meta.get('dont_fail'): raise ModuleNotFoundError() -class RecoveryMiddleware(object): +class RecoveryMiddleware: def process_spider_exception(self, response, exception, spider): logging.warn('Middleware: %s exception caught', exception.__class__.__name__) return [ From 0a0e62272e67aaebe29666017e9b0623b81bf369 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 4 Jul 2018 16:19:19 -0300 Subject: [PATCH 014/503] New tests --- tests/test_spider_mw.py | 170 -------------- tests/test_spidermiddleware.py | 405 ++++++++++----------------------- 2 files changed, 118 insertions(+), 457 deletions(-) delete mode 100644 tests/test_spider_mw.py diff --git a/tests/test_spider_mw.py b/tests/test_spider_mw.py deleted file mode 100644 index 2565ef7af..000000000 --- a/tests/test_spider_mw.py +++ /dev/null @@ -1,170 +0,0 @@ - -import logging - -from testfixtures import LogCapture -from twisted.trial.unittest import TestCase -from twisted.internet import defer - -from scrapy import Spider, Request -from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer - - -# TEST_URL = 'http://example.org' -TEST_URL = 'http://localhost:8998' - - -class LogExceptionMiddleware: - def process_spider_exception(self, response, exception, spider): - logging.warn('Middleware: %s exception caught', exception.__class__.__name__) - return None - - -# ================================================================================ -# recover from an exception on a spider callback -class RecoverySpider(Spider): - name = 'RecoverySpider' - start_urls = [TEST_URL] - custom_settings = { - 'SPIDER_MIDDLEWARES': { - __name__ + '.RecoveryMiddleware': 10, - }, - } - - def parse(self, response): - yield {'test': 1} - self.logger.warn('DONT_FAIL: %s', response.meta.get('dont_fail')) - if not response.meta.get('dont_fail'): - raise ModuleNotFoundError() - -class RecoveryMiddleware: - def process_spider_exception(self, response, exception, spider): - logging.warn('Middleware: %s exception caught', exception.__class__.__name__) - return [ - {'from': 'process_spider_exception'}, - Request(response.url, meta={'dont_fail': True}, dont_filter=True), - ] - - -# ================================================================================ -# (1) exceptions from a spider middleware's process_spider_input method -class ProcessSpiderInputSpider(Spider): - name = 'ProcessSpiderInputSpider' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # spider - __name__ + '.LogExceptionMiddleware': 10, - __name__ + '.FailProcessSpiderInputMiddleware': 8, - __name__ + '.LogExceptionMiddleware': 6, - # engine - } - } - - def start_requests(self): - yield Request(TEST_URL, callback=self.parse, errback=self.errback) - - def parse(self, response): - return [{'test': 1}, {'test': 2}] - - def errback(self, failure): - self.logger.warn('Got a Failure on the Request errback') - - -class FailProcessSpiderInputMiddleware: - def process_spider_input(self, response, spider): - logging.warn('Middleware: will raise IndexError') - raise IndexError() - - -# ================================================================================ -# (2) exceptions from a spider callback (generator) -class GeneratorCallbackSpider(Spider): - name = 'GeneratorCallbackSpider' - start_urls = [TEST_URL] - custom_settings = { - 'SPIDER_MIDDLEWARES': { - __name__ + '.LogExceptionMiddleware': 10, - }, - } - - def parse(self, response): - yield {'test': 1} - yield {'test': 2} - raise ImportError() - - -# ================================================================================ -# (3) exceptions from a spider callback (not a generator) -class NotAGeneratorCallbackSpider(Spider): - name = 'NotAGeneratorCallbackSpider' - start_urls = [TEST_URL] - custom_settings = { - 'SPIDER_MIDDLEWARES': { - __name__ + '.LogExceptionMiddleware': 10, - }, - } - - def parse(self, response): - return [{'test': 1}, {'test': 1/0}] - - -# ================================================================================ -class TestSpiderMiddleware(TestCase): - @classmethod - def setUpClass(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def tearDownClass(cls): - cls.mockserver.__exit__(None, None, None) - - @defer.inlineCallbacks - def crawl_log(self, spider): - crawler = get_crawler(spider) - with LogCapture() as log: - yield crawler.crawl() - raise defer.returnValue(log) - - # @defer.inlineCallbacks - # def test_recovery(self): - # """ - # Recover from an exception from a spider's callback. The final item count should be 3 - # (one from the spider before raising the exception, one from the middleware and one - # from the spider when processing the response that was enqueued from the middleware) - # """ - # log = yield self.crawl_log(RecoverySpider) - # self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) - # self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) - # self.assertIn("'item_scraped_count': 3", str(log)) - - @defer.inlineCallbacks - def test_process_spider_input_errback(self): - """ - (1) An exception from the process_spider_input chain should not be caught by the - process_spider_exception chain, it should go directly to the Request errback - """ - log1 = yield self.crawl_log(ProcessSpiderInputSpider) - self.assertNotIn("Middleware: IndexError exception caught", str(log1)) - self.assertIn("Middleware: will raise IndexError", str(log1)) - self.assertIn("Got a Failure on the Request errback", str(log1)) - - @defer.inlineCallbacks - def test_generator_callback(self): - """ - (2) An exception from a spider's callback should - be caught by the process_spider_exception chain - """ - log2 = yield self.crawl_log(GeneratorCallbackSpider) - self.assertIn("Middleware: ImportError exception caught", str(log2)) - self.assertIn("'item_scraped_count': 2", str(log2)) - - @defer.inlineCallbacks - def test_not_a_generator_callback(self): - """ - (3) An exception from a spider's callback should - be caught by the process_spider_exception chain - """ - log3 = yield self.crawl_log(NotAGeneratorCallbackSpider) - self.assertIn("Middleware: ZeroDivisionError exception caught", str(log3)) - self.assertNotIn("item_scraped_count", str(log3)) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 109bcc250..2565ef7af 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -5,256 +5,111 @@ from testfixtures import LogCapture from twisted.trial.unittest import TestCase from twisted.internet import defer -from scrapy.spiders import Spider -from scrapy.item import Item, Field -from scrapy.http import Request +from scrapy import Spider, Request from scrapy.utils.test import get_crawler from tests.mockserver import MockServer -class TestItem(Item): - value = Field() +# TEST_URL = 'http://example.org' +TEST_URL = 'http://localhost:8998' -class LocalhostSpider(Spider): - start_urls = ['http://localhost:8998'] # tests.mockserver.MockServer - - -# ================================================================================ -# exceptions from a spider's parse method -class BaseExceptionFromParseMethodSpider(LocalhostSpider): - custom_settings = { - 'SPIDER_MIDDLEWARES': {'tests.test_spidermiddleware.CatchExceptionMiddleware': 540} - } - - -class NotAGeneratorSpider(BaseExceptionFromParseMethodSpider): - """ return value is NOT a generator """ - name = 'not_a_generator' - - def parse(self, response): - raise AssertionError - - -class GeneratorErrorBeforeItemsSpider(BaseExceptionFromParseMethodSpider): - """ return value is a generator; the exception is raised - before the items are yielded: no items should be scraped """ - name = 'generator_error_before_items' - - def parse(self, response): - raise ValueError - for i in range(3): - yield {'value': i} - - -class GeneratorErrorAfterItemsSpider(BaseExceptionFromParseMethodSpider): - """ return value is a generator; the exception is raised - after the items are yielded: 3 items should be scraped """ - name = 'generator_error_after_items' - - def parse(self, response): - for i in range(3): - yield {'value': i} - raise FloatingPointError - - -class CatchExceptionMiddleware(object): +class LogExceptionMiddleware: def process_spider_exception(self, response, exception, spider): - """ catch an exception and log it """ - logging.warn('{} exception caught'.format(exception.__class__.__name__)) + logging.warn('Middleware: %s exception caught', exception.__class__.__name__) return None # ================================================================================ -# exception from a previous middleware's process_spider_input method -# process_spider_input is not expected to return an iterable, so there are no -# separate tests for generator/non-generator implementations -class FromPreviousMiddlewareInputSpider(LocalhostSpider): - name = 'not_a_generator_from_previous_middleware_input' +# recover from an exception on a spider callback +class RecoverySpider(Spider): + name = 'RecoverySpider' + start_urls = [TEST_URL] custom_settings = { 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, - 'tests.test_spidermiddleware.RaiseExceptionOnInputMiddleware': 545, - # spider side - } - } - - def parse(self, response): - return None - - -class RaiseExceptionOnInputMiddleware(object): - def process_spider_input(self, response, spider): - raise LookupError - - -# ================================================================================ -# exception from a previous middleware's process_spider_output method (not a generator) -class NotAGeneratorFromPreviousMiddlewareOutputSpider(LocalhostSpider): - name = 'not_a_generator_from_previous_middleware_output' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, - 'tests.test_spidermiddleware.RaiseExceptionOnOutputNotAGeneratorMiddleware': 545, - # spider side - } - } - - def parse(self, response): - return [{'value': i} for i in range(3)] - - -class RaiseExceptionOnOutputNotAGeneratorMiddleware(object): - def process_spider_output(self, response, result, spider): - raise UnicodeError - - -# ================================================================================ -# exception from a previous middleware's process_spider_output method (generator) -class GeneratorFromPreviousMiddlewareOutputSpider(LocalhostSpider): - name = 'generator_from_previous_middleware_output' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, - 'tests.test_spidermiddleware.RaiseExceptionOnOutputGeneratorMiddleware': 545, - # spider side - } - } - - def parse(self, response): - return [{'value': i} for i in range(10, 13)] - - -class RaiseExceptionOnOutputGeneratorMiddleware(object): - def process_spider_output(self, response, result, spider): - for r in result: - yield r - raise NameError - - -# ================================================================================ -# do something useful from the exception handler -class DoSomethingSpider(LocalhostSpider): - name = 'do_something' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.DoSomethingMiddleware': 540, - 'tests.test_spidermiddleware.CatchExceptionMiddleware': 545, - # spider side - } - } - - def parse(self, response): - yield {'value': response.url} - raise ImportError - - -class DoSomethingMiddleware(object): - def process_spider_exception(self, response, exception, spider): - return [Request('http://localhost:8998?processed=true'), {'value': 10}, TestItem(value='asdf')] - - -# ================================================================================ -# don't catch _InvalidOutput from scrapy's spider middleware manager -class InvalidReturnValueFromPreviousMiddlewareInputSpider(LocalhostSpider): - name = 'invalid_return_value_from_previous_middleware_input' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.InvalidReturnValueInputMiddleware': 540, - 'tests.test_spidermiddleware.CatchExceptionMiddleware': 545, - # spider side - } - } - - def parse(self, response): - return None - - -class InvalidReturnValueInputMiddleware(object): - def process_spider_input(self, response, spider): - return 1.0 # , not None - - -class InvalidReturnValueFromPreviousMiddlewareOutputSpider(LocalhostSpider): - name = 'invalid_return_value_from_previous_middleware_output' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.CatchExceptionMiddleware': 540, - 'tests.test_spidermiddleware.InvalidReturnValueOutputMiddleware': 545, - # spider side - } - } - - def parse(self, response): - return None - - -class InvalidReturnValueOutputMiddleware(object): - def process_spider_output(self, response, result, spider): - return 1 # , not an iterable - - -# ================================================================================ -# make sure only non already called process_spider_output methods -# are called if process_spider_exception returns an iterable -class ExecutionChainSpider(LocalhostSpider): - name = 'execution_chain' - custom_settings = { - 'SPIDER_MIDDLEWARES': { - # engine side - 'tests.test_spidermiddleware.ThirdMiddleware': 540, - 'tests.test_spidermiddleware.SecondMiddleware': 541, - 'tests.test_spidermiddleware.FirstMiddleware': 542 - # spider side + __name__ + '.RecoveryMiddleware': 10, }, } def parse(self, response): - return None - - -class FirstMiddleware(object): - def process_spider_output(self, response, result, spider): - for r in result: - if isinstance(r, dict): - r['handled_by_first_middleware'] = True - yield r + yield {'test': 1} + self.logger.warn('DONT_FAIL: %s', response.meta.get('dont_fail')) + if not response.meta.get('dont_fail'): + raise ModuleNotFoundError() +class RecoveryMiddleware: def process_spider_exception(self, response, exception, spider): - # log exception, handle control to the next middleware's process_spider_exception - logging.warn('{} exception caught'.format(exception.__class__.__name__)) - return None + logging.warn('Middleware: %s exception caught', exception.__class__.__name__) + return [ + {'from': 'process_spider_exception'}, + Request(response.url, meta={'dont_fail': True}, dont_filter=True), + ] -class SecondMiddleware(object): - def process_spider_output(self, response, result, spider): - for r in result: - if isinstance(r, dict): - r['handled_by_second_middleware'] = True - yield r - raise MemoryError +# ================================================================================ +# (1) exceptions from a spider middleware's process_spider_input method +class ProcessSpiderInputSpider(Spider): + name = 'ProcessSpiderInputSpider' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + # spider + __name__ + '.LogExceptionMiddleware': 10, + __name__ + '.FailProcessSpiderInputMiddleware': 8, + __name__ + '.LogExceptionMiddleware': 6, + # engine + } + } + + def start_requests(self): + yield Request(TEST_URL, callback=self.parse, errback=self.errback) + + def parse(self, response): + return [{'test': 1}, {'test': 2}] + + def errback(self, failure): + self.logger.warn('Got a Failure on the Request errback') -class ThirdMiddleware(object): - def process_spider_output(self, response, result, spider): - for r in result: - if isinstance(r, dict): - r['handled_by_third_middleware'] = True - yield r - - def process_spider_exception(self, response, exception, spider): - # handle control to the next middleware's process_spider_output - return [{'item': i} for i in range(3)] +class FailProcessSpiderInputMiddleware: + def process_spider_input(self, response, spider): + logging.warn('Middleware: will raise IndexError') + raise IndexError() +# ================================================================================ +# (2) exceptions from a spider callback (generator) +class GeneratorCallbackSpider(Spider): + name = 'GeneratorCallbackSpider' + start_urls = [TEST_URL] + custom_settings = { + 'SPIDER_MIDDLEWARES': { + __name__ + '.LogExceptionMiddleware': 10, + }, + } + + def parse(self, response): + yield {'test': 1} + yield {'test': 2} + raise ImportError() + + +# ================================================================================ +# (3) exceptions from a spider callback (not a generator) +class NotAGeneratorCallbackSpider(Spider): + name = 'NotAGeneratorCallbackSpider' + start_urls = [TEST_URL] + custom_settings = { + 'SPIDER_MIDDLEWARES': { + __name__ + '.LogExceptionMiddleware': 10, + }, + } + + def parse(self, response): + return [{'test': 1}, {'test': 1/0}] + + +# ================================================================================ class TestSpiderMiddleware(TestCase): - @classmethod def setUpClass(cls): cls.mockserver = MockServer() @@ -263,7 +118,7 @@ class TestSpiderMiddleware(TestCase): @classmethod def tearDownClass(cls): cls.mockserver.__exit__(None, None, None) - + @defer.inlineCallbacks def crawl_log(self, spider): crawler = get_crawler(spider) @@ -271,69 +126,45 @@ class TestSpiderMiddleware(TestCase): yield crawler.crawl() raise defer.returnValue(log) - @defer.inlineCallbacks - def test_process_spider_exception_from_parse_method_non_generator(self): - # non-generator return value - log = yield self.crawl_log(NotAGeneratorSpider) - self.assertIn("AssertionError exception caught", str(log)) - self.assertIn("spider_exceptions/AssertionError", str(log)) + # @defer.inlineCallbacks + # def test_recovery(self): + # """ + # Recover from an exception from a spider's callback. The final item count should be 3 + # (one from the spider before raising the exception, one from the middleware and one + # from the spider when processing the response that was enqueued from the middleware) + # """ + # log = yield self.crawl_log(RecoverySpider) + # self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) + # self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) + # self.assertIn("'item_scraped_count': 3", str(log)) @defer.inlineCallbacks - def test_process_spider_exception_from_parse_method_generator_no_items(self): - # generator return value, no items before the error - log = yield self.crawl_log(GeneratorErrorBeforeItemsSpider) - self.assertIn("ValueError exception caught", str(log)) - self.assertIn("spider_exceptions/ValueError", str(log)) - + def test_process_spider_input_errback(self): + """ + (1) An exception from the process_spider_input chain should not be caught by the + process_spider_exception chain, it should go directly to the Request errback + """ + log1 = yield self.crawl_log(ProcessSpiderInputSpider) + self.assertNotIn("Middleware: IndexError exception caught", str(log1)) + self.assertIn("Middleware: will raise IndexError", str(log1)) + self.assertIn("Got a Failure on the Request errback", str(log1)) + @defer.inlineCallbacks - def test_process_spider_exception_from_parse_method_generator_with_items(self): - # generator return value, 3 items before the error - log = yield self.crawl_log(GeneratorErrorAfterItemsSpider) - self.assertIn("'item_scraped_count': 3", str(log)) - self.assertIn("FloatingPointError exception caught", str(log)) - self.assertIn("spider_exceptions/FloatingPointError", str(log)) - + def test_generator_callback(self): + """ + (2) An exception from a spider's callback should + be caught by the process_spider_exception chain + """ + log2 = yield self.crawl_log(GeneratorCallbackSpider) + self.assertIn("Middleware: ImportError exception caught", str(log2)) + self.assertIn("'item_scraped_count': 2", str(log2)) + @defer.inlineCallbacks - def test_process_spider_exception_from_previous_middleware_input(self): - log = yield self.crawl_log(FromPreviousMiddlewareInputSpider) - self.assertIn("LookupError exception caught", str(log)) - - @defer.inlineCallbacks - def test_process_spider_exception_from_previous_middleware_output(self): - # non-generator output value - log = yield self.crawl_log(NotAGeneratorFromPreviousMiddlewareOutputSpider) - self.assertNotIn("UnicodeError exception caught", str(log)) - # generator output value - log = yield self.crawl_log(GeneratorFromPreviousMiddlewareOutputSpider) - self.assertIn("'item_scraped_count': 3", str(log)) - self.assertIn("NameError exception caught", str(log)) - - @defer.inlineCallbacks - def test_process_spider_exception_do_something(self): - log = yield self.crawl_log(DoSomethingSpider) - self.assertIn("ImportError exception caught", str(log)) - self.assertIn("{'value': 10}", str(log)) - self.assertIn("{'value': 'asdf'}", str(log)) - self.assertIn("{'value': 'http://localhost:8998'}", str(log)) - self.assertIn("{'value': 'http://localhost:8998?processed=true'}", str(log)) - - @defer.inlineCallbacks - def test_process_spider_exception_invalid_return_value_previous_middleware(self): - """ don't catch _InvalidOutput from middleware """ - # on middleware's input - log1 = yield self.crawl_log(InvalidReturnValueFromPreviousMiddlewareInputSpider) - self.assertNotIn("_InvalidOutput exception caught", str(log1)) - self.assertIn("'spider_exceptions/_InvalidOutput'", str(log1)) - # on middleware's output - log2 = yield self.crawl_log(InvalidReturnValueFromPreviousMiddlewareOutputSpider) - self.assertNotIn("_InvalidOutput exception caught", str(log2)) - self.assertIn("'spider_exceptions/_InvalidOutput'", str(log2)) - - @defer.inlineCallbacks - def test_process_spider_exception_execution_chain(self): - # on middleware's input - log = yield self.crawl_log(ExecutionChainSpider) - self.assertNotIn("handled_by_first_middleware", str(log)) - self.assertNotIn("handled_by_second_middleware", str(log)) - self.assertIn("MemoryError exception caught", str(log)) - self.assertIn("handled_by_third_middleware", str(log)) + def test_not_a_generator_callback(self): + """ + (3) An exception from a spider's callback should + be caught by the process_spider_exception chain + """ + log3 = yield self.crawl_log(NotAGeneratorCallbackSpider) + self.assertIn("Middleware: ZeroDivisionError exception caught", str(log3)) + self.assertNotIn("item_scraped_count", str(log3)) From e7e18db179f2e45aa38a5bbdb0abba7d983cdce7 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 11 Jul 2018 14:04:35 -0300 Subject: [PATCH 015/503] Fix tests --- tests/test_spidermiddleware.py | 57 +++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 2565ef7af..5622c3179 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -8,10 +8,7 @@ from twisted.internet import defer from scrapy import Spider, Request from scrapy.utils.test import get_crawler from tests.mockserver import MockServer - - -# TEST_URL = 'http://example.org' -TEST_URL = 'http://localhost:8998' +from tests.spiders import MockServerSpider class LogExceptionMiddleware: @@ -21,16 +18,18 @@ class LogExceptionMiddleware: # ================================================================================ -# recover from an exception on a spider callback +# (0) recover from an exception on a spider callback class RecoverySpider(Spider): name = 'RecoverySpider' - start_urls = [TEST_URL] custom_settings = { 'SPIDER_MIDDLEWARES': { __name__ + '.RecoveryMiddleware': 10, }, } + def start_requests(self): + yield Request(self.mockserver.url('/status?n=200')) + def parse(self, response): yield {'test': 1} self.logger.warn('DONT_FAIL: %s', response.meta.get('dont_fail')) @@ -61,10 +60,11 @@ class ProcessSpiderInputSpider(Spider): } def start_requests(self): - yield Request(TEST_URL, callback=self.parse, errback=self.errback) + yield Request(url=self.mockserver.url('/status?n=200'), + callback=self.parse, errback=self.errback) def parse(self, response): - return [{'test': 1}, {'test': 2}] + return {'from': 'callback'} def errback(self, failure): self.logger.warn('Got a Failure on the Request errback') @@ -80,13 +80,15 @@ class FailProcessSpiderInputMiddleware: # (2) exceptions from a spider callback (generator) class GeneratorCallbackSpider(Spider): name = 'GeneratorCallbackSpider' - start_urls = [TEST_URL] custom_settings = { 'SPIDER_MIDDLEWARES': { __name__ + '.LogExceptionMiddleware': 10, }, } + def start_requests(self): + yield Request(self.mockserver.url('/status?n=200')) + def parse(self, response): yield {'test': 1} yield {'test': 2} @@ -97,13 +99,15 @@ class GeneratorCallbackSpider(Spider): # (3) exceptions from a spider callback (not a generator) class NotAGeneratorCallbackSpider(Spider): name = 'NotAGeneratorCallbackSpider' - start_urls = [TEST_URL] custom_settings = { 'SPIDER_MIDDLEWARES': { __name__ + '.LogExceptionMiddleware': 10, }, } + def start_requests(self): + yield Request(self.mockserver.url('/status?n=200')) + def parse(self, response): return [{'test': 1}, {'test': 1/0}] @@ -123,20 +127,20 @@ class TestSpiderMiddleware(TestCase): def crawl_log(self, spider): crawler = get_crawler(spider) with LogCapture() as log: - yield crawler.crawl() + yield crawler.crawl(mockserver=self.mockserver) raise defer.returnValue(log) - # @defer.inlineCallbacks - # def test_recovery(self): - # """ - # Recover from an exception from a spider's callback. The final item count should be 3 - # (one from the spider before raising the exception, one from the middleware and one - # from the spider when processing the response that was enqueued from the middleware) - # """ - # log = yield self.crawl_log(RecoverySpider) - # self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) - # self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) - # self.assertIn("'item_scraped_count': 3", str(log)) + @defer.inlineCallbacks + def test_recovery(self): + """ + (0) Recover from an exception in a spider callback. The final item count should be 2 + (one directly from the recovery middleware and one from the spider when processing + the request that was enqueued from the recovery middleware) + """ + log = yield self.crawl_log(RecoverySpider) + self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) + self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) + self.assertIn("'item_scraped_count': 2", str(log)) @defer.inlineCallbacks def test_process_spider_input_errback(self): @@ -148,21 +152,24 @@ class TestSpiderMiddleware(TestCase): self.assertNotIn("Middleware: IndexError exception caught", str(log1)) self.assertIn("Middleware: will raise IndexError", str(log1)) self.assertIn("Got a Failure on the Request errback", str(log1)) + self.assertIn("{'from': 'errback'}", str(log1)) + self.assertNotIn("{'from': 'callback'}", str(log1)) + self.assertIn("'item_scraped_count': 1", str(log1)) @defer.inlineCallbacks def test_generator_callback(self): """ - (2) An exception from a spider's callback should + (2) An exception from a spider callback (returning a generator) should be caught by the process_spider_exception chain """ log2 = yield self.crawl_log(GeneratorCallbackSpider) self.assertIn("Middleware: ImportError exception caught", str(log2)) - self.assertIn("'item_scraped_count': 2", str(log2)) + self.assertNotIn("item_scraped_count", str(log2)) @defer.inlineCallbacks def test_not_a_generator_callback(self): """ - (3) An exception from a spider's callback should + (3) An exception from a spider callback (returning a list) should be caught by the process_spider_exception chain """ log3 = yield self.crawl_log(NotAGeneratorCallbackSpider) From 0c579b5276f502832f375be316094b4244cf87c5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sat, 14 Jul 2018 19:58:42 -0300 Subject: [PATCH 016/503] Untested experiment --- scrapy/core/spidermw.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index c9dd8c91e..a9aeb6dcc 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -3,6 +3,8 @@ Spider Middleware manager See documentation in docs/topics/spider-middleware.rst """ +from itertools import chain + import six from twisted.python.failure import Failure from scrapy.exceptions import _InvalidOutput @@ -10,9 +12,28 @@ from scrapy.middleware import MiddlewareManager from scrapy.utils.defer import mustbe_deferred from scrapy.utils.conf import build_component_list + def _isiterable(possible_iterator): return hasattr(possible_iterator, '__iter__') + +class MutableChain: + def __init__(self, *args): + self.data = chain(*args) + + def extend(self, iterable): + self.data = chain(self.data, iterable) + + def __iter__(self): + return self.data.__iter__() + + def __next__(self): # py3 + return self.data.__next__() + + def next(self): # py2 + return self.data.next() + + class SpiderMiddlewareManager(MiddlewareManager): component_name = 'spider middleware' @@ -68,28 +89,32 @@ class SpiderMiddlewareManager(MiddlewareManager): return _failure def process_spider_output(result, index): - def wrapper(result_iterable): + # items in this iterable do not need to go through the process_spider_output + # chain, they went through it already from the process_spider_exception method + recovered = MutableChain() + + def evaluate_result(result_iterable, index): try: for r in result_iterable: yield r except Exception as ex: - # process the exception with the method from the next middleware exception_result = process_spider_exception(Failure(ex), index) if exception_result is None or isinstance(exception_result, Failure): raise - for output in exception_result: - yield output + recovered.extend(exception_result) + for i, method in enumerate(self.methods['process_spider_output']): if i < index or method is None: continue result = method(response=response, result=result, spider=spider) index += 1 if _isiterable(result): - result = wrapper(result) + result = evaluate_result(result, index) else: raise _InvalidOutput('Middleware {} must return an iterable, got {}' \ .format(fname(method), type(result))) - return result + + return chain(result, recovered) dfd = mustbe_deferred(process_spider_input, response) dfd.addErrback(process_spider_exception, index=0) From c5fa0ae6bc536d6bc5370c6f3b634c33848971e5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sat, 14 Jul 2018 19:58:42 -0300 Subject: [PATCH 017/503] Untested experiment --- scrapy/core/spidermw.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index c9dd8c91e..8ee42c2cf 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -3,6 +3,8 @@ Spider Middleware manager See documentation in docs/topics/spider-middleware.rst """ +from itertools import chain + import six from twisted.python.failure import Failure from scrapy.exceptions import _InvalidOutput @@ -10,9 +12,31 @@ from scrapy.middleware import MiddlewareManager from scrapy.utils.defer import mustbe_deferred from scrapy.utils.conf import build_component_list + def _isiterable(possible_iterator): return hasattr(possible_iterator, '__iter__') + +class MutableChain: + """ + Thin wrapper around itertools.chain, allowing to add iterables "in-place" + """ + def __init__(self, *args): + self.data = chain(*args) + + def extend(self, *iterables): + self.data = chain(self.data, *iterables) + + def __iter__(self): + return self.data.__iter__() + + def __next__(self): # py3 + return self.data.__next__() + + def next(self): # py2 + return self.data.next() + + class SpiderMiddlewareManager(MiddlewareManager): component_name = 'spider middleware' @@ -68,28 +92,32 @@ class SpiderMiddlewareManager(MiddlewareManager): return _failure def process_spider_output(result, index): - def wrapper(result_iterable): + # items in this iterable do not need to go through the process_spider_output + # chain, they went through it already from the process_spider_exception method + recovered = MutableChain() + + def evaluate_result(result_iterable, index): try: for r in result_iterable: yield r except Exception as ex: - # process the exception with the method from the next middleware exception_result = process_spider_exception(Failure(ex), index) if exception_result is None or isinstance(exception_result, Failure): raise - for output in exception_result: - yield output + recovered.extend(exception_result) + for i, method in enumerate(self.methods['process_spider_output']): if i < index or method is None: continue result = method(response=response, result=result, spider=spider) index += 1 if _isiterable(result): - result = wrapper(result) + result = evaluate_result(result, index) else: raise _InvalidOutput('Middleware {} must return an iterable, got {}' \ .format(fname(method), type(result))) - return result + + return chain(result, recovered) dfd = mustbe_deferred(process_spider_input, response) dfd.addErrback(process_spider_exception, index=0) From cff9e8762512033da181293bab379b485aeffa66 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 15 Jul 2018 16:21:08 -0300 Subject: [PATCH 018/503] Fix tests --- tests/test_spidermiddleware.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 5622c3179..c33eb28ca 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -68,6 +68,7 @@ class ProcessSpiderInputSpider(Spider): def errback(self, failure): self.logger.warn('Got a Failure on the Request errback') + return {'from': 'errback'} class FailProcessSpiderInputMiddleware: @@ -133,14 +134,15 @@ class TestSpiderMiddleware(TestCase): @defer.inlineCallbacks def test_recovery(self): """ - (0) Recover from an exception in a spider callback. The final item count should be 2 - (one directly from the recovery middleware and one from the spider when processing - the request that was enqueued from the recovery middleware) + (0) Recover from an exception in a spider callback. The final item count should be 3 + (one yielded from the callback method before the exception is raised, one directly + from the recovery middleware and one from the spider when processing the request that + was enqueued from the recovery middleware) """ log = yield self.crawl_log(RecoverySpider) self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) - self.assertIn("'item_scraped_count': 2", str(log)) + self.assertIn("'item_scraped_count': 3", str(log)) @defer.inlineCallbacks def test_process_spider_input_errback(self): @@ -164,7 +166,7 @@ class TestSpiderMiddleware(TestCase): """ log2 = yield self.crawl_log(GeneratorCallbackSpider) self.assertIn("Middleware: ImportError exception caught", str(log2)) - self.assertNotIn("item_scraped_count", str(log2)) + self.assertIn("'item_scraped_count': 2", str(log2)) @defer.inlineCallbacks def test_not_a_generator_callback(self): From 60c2ef86f0c40d17219d3e3320072fb5b1ded412 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 15 Jul 2018 16:47:55 -0300 Subject: [PATCH 019/503] Revert "Default values for OffsiteMiddleware" This reverts commit ba294351381c0dd81476603246d2cea6c31486be. --- scrapy/spidermiddlewares/offsite.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/scrapy/spidermiddlewares/offsite.py b/scrapy/spidermiddlewares/offsite.py index 3b7f194e4..310166cad 100644 --- a/scrapy/spidermiddlewares/offsite.py +++ b/scrapy/spidermiddlewares/offsite.py @@ -19,9 +19,6 @@ class OffsiteMiddleware(object): def __init__(self, stats): self.stats = stats - # default values - self.host_regex = re.compile('') # allow all by default - self.domains_seen = set() @classmethod def from_crawler(cls, crawler): @@ -55,7 +52,7 @@ class OffsiteMiddleware(object): """Override this method to implement a different offsite policy""" allowed_domains = getattr(spider, 'allowed_domains', None) if not allowed_domains: - return + return re.compile('') # allow all by default url_pattern = re.compile("^https?://.*$") for domain in allowed_domains: if url_pattern.match(domain): @@ -65,9 +62,8 @@ class OffsiteMiddleware(object): return re.compile(regex) def spider_opened(self, spider): - host_regex = self.get_host_regex(spider) - if host_regex: - self.host_regex = host_regex + self.host_regex = self.get_host_regex(spider) + self.domains_seen = set() class URLWarning(Warning): From b8e8922d5436247f7be66c40e1a16a0acab7986e Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sun, 15 Jul 2018 17:50:55 -0300 Subject: [PATCH 020/503] Simplify stuff. Add more tests. --- scrapy/core/spidermw.py | 10 +++--- tests/test_spidermiddleware.py | 63 ++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 8ee42c2cf..c733402b9 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -75,8 +75,8 @@ class SpiderMiddlewareManager(MiddlewareManager): # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): return _failure - for i, method in enumerate(self.methods['process_spider_exception']): - if i < index or method is None: + for method in self.methods['process_spider_exception'][index:]: + if method is None: continue result = method(response=response, exception=exception, spider=spider) index += 1 @@ -101,13 +101,13 @@ class SpiderMiddlewareManager(MiddlewareManager): for r in result_iterable: yield r except Exception as ex: - exception_result = process_spider_exception(Failure(ex), index) + exception_result = process_spider_exception(Failure(ex), index+1) if exception_result is None or isinstance(exception_result, Failure): raise recovered.extend(exception_result) - for i, method in enumerate(self.methods['process_spider_output']): - if i < index or method is None: + for method in self.methods['process_spider_output'][index:]: + if method is None: continue result = method(response=response, result=result, spider=spider) index += 1 diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index c33eb28ca..645d95059 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -113,6 +113,49 @@ class NotAGeneratorCallbackSpider(Spider): return [{'test': 1}, {'test': 1/0}] +# ================================================================================ +# (4) exceptions from a middleware process_spider_output method (generator) +class GeneratorOutputChainSpider(Spider): + name = 'GeneratorOutputChainSpider' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + __name__ + '.GeneratorFailOutputChainMiddleware': 10, + __name__ + '.GeneratorRecoverOutputChainMiddleware': 5, + }, + } + + def start_requests(self): + yield Request(self.mockserver.url('/status?n=200')) + + def parse(self, response): + yield {'processed': ['parse']} + + +class GeneratorFailOutputChainMiddleware: + def process_spider_output(self, response, result, spider): + for r in result: + r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) + yield r + raise LookupError() + + def process_spider_exception(self, response, exception, spider): + method = '{}.process_spider_exception'.format(self.__class__.__name__) + logging.info('%s: %s caught', method, exception.__class__.__name__) + yield {'processed': [method]} + + +class GeneratorRecoverOutputChainMiddleware: + def process_spider_output(self, response, result, spider): + for r in result: + r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) + yield r + + def process_spider_exception(self, response, exception, spider): + method = '{}.process_spider_exception'.format(self.__class__.__name__) + logging.info('%s: %s caught', method, exception.__class__.__name__) + yield {'processed': [method]} + + # ================================================================================ class TestSpiderMiddleware(TestCase): @classmethod @@ -162,7 +205,8 @@ class TestSpiderMiddleware(TestCase): def test_generator_callback(self): """ (2) An exception from a spider callback (returning a generator) should - be caught by the process_spider_exception chain + be caught by the process_spider_exception chain. Items yielded before the + exception is raised should be processed normally. """ log2 = yield self.crawl_log(GeneratorCallbackSpider) self.assertIn("Middleware: ImportError exception caught", str(log2)) @@ -172,8 +216,23 @@ class TestSpiderMiddleware(TestCase): def test_not_a_generator_callback(self): """ (3) An exception from a spider callback (returning a list) should - be caught by the process_spider_exception chain + be caught by the process_spider_exception chain. No items should be processed. """ log3 = yield self.crawl_log(NotAGeneratorCallbackSpider) self.assertIn("Middleware: ZeroDivisionError exception caught", str(log3)) self.assertNotIn("item_scraped_count", str(log3)) + + @defer.inlineCallbacks + def test_generator_output_chain(self): + """ + (4) An exception from a middleware's process_spider_output method should be sent + to the process_spider_exception method from the next middleware in the chain. + The final item count should be 2 (one from the spider callback and one from the + process_spider_exception chain) + """ + log4 = yield self.crawl_log(GeneratorOutputChainSpider) + self.assertIn("'item_scraped_count': 2", str(log4)) + self.assertIn("GeneratorRecoverOutputChainMiddleware.process_spider_exception: LookupError caught", str(log4)) + self.assertNotIn("GeneratorFailOutputChainMiddleware.process_spider_exception: LookupError caught", str(log4)) + self.assertIn("{'processed': ['parse', 'GeneratorFailOutputChainMiddleware.process_spider_output', 'GeneratorRecoverOutputChainMiddleware.process_spider_output']}", str(log4)) + self.assertIn("{'processed': ['GeneratorRecoverOutputChainMiddleware.process_spider_exception']}", str(log4)) From 56e92d90fda3e812aca270327e513391591a10cc Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Tue, 17 Jul 2018 15:15:38 -0300 Subject: [PATCH 021/503] Update tests --- tests/test_spidermiddleware.py | 54 ++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 645d95059..9bb7f62fd 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -119,8 +119,10 @@ class GeneratorOutputChainSpider(Spider): name = 'GeneratorOutputChainSpider' custom_settings = { 'SPIDER_MIDDLEWARES': { - __name__ + '.GeneratorFailOutputChainMiddleware': 10, - __name__ + '.GeneratorRecoverOutputChainMiddleware': 5, + __name__ + '.GeneratorFailMiddleware': 10, + __name__ + '.GeneratorDoNothingAfterFailureMiddleware': 8, + __name__ + '.GeneratorRecoverMiddleware': 5, + __name__ + '.GeneratorDoNothingAfterRecoveryMiddleware': 3, }, } @@ -128,10 +130,23 @@ class GeneratorOutputChainSpider(Spider): yield Request(self.mockserver.url('/status?n=200')) def parse(self, response): - yield {'processed': ['parse']} + yield {'processed': ['parse-first-item']} + yield {'processed': ['parse-second-item']} -class GeneratorFailOutputChainMiddleware: +class _GeneratorDoNothingMiddleware: + def process_spider_output(self, response, result, spider): + for r in result: + r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) + yield r + + def process_spider_exception(self, response, exception, spider): + method = '{}.process_spider_exception'.format(self.__class__.__name__) + logging.info('%s: %s caught', method, exception.__class__.__name__) + return None + + +class GeneratorFailMiddleware: def process_spider_output(self, response, result, spider): for r in result: r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) @@ -144,7 +159,11 @@ class GeneratorFailOutputChainMiddleware: yield {'processed': [method]} -class GeneratorRecoverOutputChainMiddleware: +class GeneratorDoNothingAfterFailureMiddleware(_GeneratorDoNothingMiddleware): + pass + + +class GeneratorRecoverMiddleware: def process_spider_output(self, response, result, spider): for r in result: r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) @@ -155,6 +174,9 @@ class GeneratorRecoverOutputChainMiddleware: logging.info('%s: %s caught', method, exception.__class__.__name__) yield {'processed': [method]} +class GeneratorDoNothingAfterRecoveryMiddleware(_GeneratorDoNothingMiddleware): + pass + # ================================================================================ class TestSpiderMiddleware(TestCase): @@ -227,12 +249,26 @@ class TestSpiderMiddleware(TestCase): """ (4) An exception from a middleware's process_spider_output method should be sent to the process_spider_exception method from the next middleware in the chain. + The result of the recovery by the process_spider_exception method should be handled + by the process_spider_output method from the next middleware. The final item count should be 2 (one from the spider callback and one from the process_spider_exception chain) """ log4 = yield self.crawl_log(GeneratorOutputChainSpider) self.assertIn("'item_scraped_count': 2", str(log4)) - self.assertIn("GeneratorRecoverOutputChainMiddleware.process_spider_exception: LookupError caught", str(log4)) - self.assertNotIn("GeneratorFailOutputChainMiddleware.process_spider_exception: LookupError caught", str(log4)) - self.assertIn("{'processed': ['parse', 'GeneratorFailOutputChainMiddleware.process_spider_output', 'GeneratorRecoverOutputChainMiddleware.process_spider_output']}", str(log4)) - self.assertIn("{'processed': ['GeneratorRecoverOutputChainMiddleware.process_spider_exception']}", str(log4)) + self.assertIn("GeneratorRecoverMiddleware.process_spider_exception: LookupError caught", str(log4)) + self.assertIn("GeneratorDoNothingAfterFailureMiddleware.process_spider_exception: LookupError caught", str(log4)) + self.assertNotIn("GeneratorFailMiddleware.process_spider_exception: LookupError caught", str(log4)) + self.assertNotIn("GeneratorDoNothingAfterRecoveryMiddleware.process_spider_exception: LookupError caught", str(log4)) + item_from_callback = {'processed': [ + 'parse-first-item', + 'GeneratorFailMiddleware.process_spider_output', + 'GeneratorDoNothingAfterFailureMiddleware.process_spider_output', + 'GeneratorRecoverMiddleware.process_spider_output', + 'GeneratorDoNothingAfterRecoveryMiddleware.process_spider_output']} + item_recovered = {'processed': [ + 'GeneratorRecoverMiddleware.process_spider_exception', + 'GeneratorDoNothingAfterRecoveryMiddleware.process_spider_output']} + self.assertIn(str(item_from_callback), str(log4)) + self.assertIn(str(item_recovered), str(log4)) + self.assertNotIn('parse-second-item', str(log4)) From 610f589662ca9c5929527e15cad2c347c8d5d335 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Tue, 17 Jul 2018 19:13:03 -0300 Subject: [PATCH 022/503] Add callback and errback in the same step --- scrapy/core/spidermw.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index c733402b9..da51bc974 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -70,7 +70,7 @@ class SpiderMiddlewareManager(MiddlewareManager): return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) - def process_spider_exception(_failure, index): + def process_spider_exception(_failure, index=0): exception = _failure.value # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): @@ -91,7 +91,7 @@ class SpiderMiddlewareManager(MiddlewareManager): .format(fname(method), type(result))) return _failure - def process_spider_output(result, index): + def process_spider_output(result, index=0): # items in this iterable do not need to go through the process_spider_output # chain, they went through it already from the process_spider_exception method recovered = MutableChain() @@ -120,8 +120,7 @@ class SpiderMiddlewareManager(MiddlewareManager): return chain(result, recovered) dfd = mustbe_deferred(process_spider_input, response) - dfd.addErrback(process_spider_exception, index=0) - dfd.addCallback(process_spider_output, index=0) + dfd.addCallbacks(callback=process_spider_output, errback=process_spider_exception) return dfd def process_start_requests(self, start_requests, spider): From a3af0bfd56770aab0a056ae6e29efffa8b7d88c4 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 18 Jul 2018 15:15:55 -0300 Subject: [PATCH 023/503] More tests --- scrapy/core/spidermw.py | 21 +++++--- tests/test_spidermiddleware.py | 99 ++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index da51bc974..96488806d 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -64,8 +64,8 @@ class SpiderMiddlewareManager(MiddlewareManager): try: result = method(response=response, spider=spider) if result is not None: - raise _InvalidOutput('Middleware {} must return None or raise ' \ - 'an exception, got {}'.format(fname(method), type(result))) + raise _InvalidOutput('Middleware {} must return None or raise an exception, got {}' \ + .format(fname(method), type(result))) except: return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) @@ -78,8 +78,8 @@ class SpiderMiddlewareManager(MiddlewareManager): for method in self.methods['process_spider_exception'][index:]: if method is None: continue - result = method(response=response, exception=exception, spider=spider) index += 1 + result = method(response=response, exception=exception, spider=spider) if _isiterable(result): # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned @@ -96,9 +96,9 @@ class SpiderMiddlewareManager(MiddlewareManager): # chain, they went through it already from the process_spider_exception method recovered = MutableChain() - def evaluate_result(result_iterable, index): + def evaluate_iterable(iterable, index): try: - for r in result_iterable: + for r in iterable: yield r except Exception as ex: exception_result = process_spider_exception(Failure(ex), index+1) @@ -109,10 +109,17 @@ class SpiderMiddlewareManager(MiddlewareManager): for method in self.methods['process_spider_output'][index:]: if method is None: continue - result = method(response=response, result=result, spider=spider) index += 1 + # the following might fail directly if the output value is not a generator + try: + result = method(response=response, result=result, spider=spider) + except Exception as ex: + exception_result = process_spider_exception(Failure(ex), index+1) + if exception_result is None or isinstance(exception_result, Failure): + raise + return exception_result if _isiterable(result): - result = evaluate_result(result, index) + result = evaluate_iterable(result, index) else: raise _InvalidOutput('Middleware {} must return an iterable, got {}' \ .format(fname(method), type(result))) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 9bb7f62fd..2f431ddc7 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -98,8 +98,8 @@ class GeneratorCallbackSpider(Spider): # ================================================================================ # (3) exceptions from a spider callback (not a generator) -class NotAGeneratorCallbackSpider(Spider): - name = 'NotAGeneratorCallbackSpider' +class NotGeneratorCallbackSpider(Spider): + name = 'NotGeneratorCallbackSpider' custom_settings = { 'SPIDER_MIDDLEWARES': { __name__ + '.LogExceptionMiddleware': 10, @@ -178,6 +178,76 @@ class GeneratorDoNothingAfterRecoveryMiddleware(_GeneratorDoNothingMiddleware): pass +# ================================================================================ +# (5) exceptions from a middleware process_spider_output method (not generator) +class NotGeneratorOutputChainSpider(Spider): + name = 'NotGeneratorOutputChainSpider' + custom_settings = { + 'SPIDER_MIDDLEWARES': { + __name__ + '.NotGeneratorFailMiddleware': 10, + __name__ + '.NotGeneratorDoNothingAfterFailureMiddleware': 8, + __name__ + '.NotGeneratorRecoverMiddleware': 5, + __name__ + '.NotGeneratorDoNothingAfterRecoveryMiddleware': 3, + }, + } + + def start_requests(self): + return [Request(self.mockserver.url('/status?n=200'))] + + def parse(self, response): + return [{'processed': ['parse-first-item']}, {'processed': ['parse-second-item']}] + + +class _NotGeneratorDoNothingMiddleware: + def process_spider_output(self, response, result, spider): + out = [] + for r in result: + r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) + out.append(r) + return out + + def process_spider_exception(self, response, exception, spider): + method = '{}.process_spider_exception'.format(self.__class__.__name__) + logging.info('%s: %s caught', method, exception.__class__.__name__) + return None + + +class NotGeneratorFailMiddleware: + def process_spider_output(self, response, result, spider): + out = [] + for r in result: + r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) + out.append(r) + raise ReferenceError() + return out + + def process_spider_exception(self, response, exception, spider): + method = '{}.process_spider_exception'.format(self.__class__.__name__) + logging.info('%s: %s caught', method, exception.__class__.__name__) + return [{'processed': [method]}] + + +class NotGeneratorDoNothingAfterFailureMiddleware(_NotGeneratorDoNothingMiddleware): + pass + + +class NotGeneratorRecoverMiddleware: + def process_spider_output(self, response, result, spider): + out = [] + for r in result: + r['processed'].append('{}.process_spider_output'.format(self.__class__.__name__)) + out.append(r) + return out + + def process_spider_exception(self, response, exception, spider): + method = '{}.process_spider_exception'.format(self.__class__.__name__) + logging.info('%s: %s caught', method, exception.__class__.__name__) + return [{'processed': [method]}] + +class NotGeneratorDoNothingAfterRecoveryMiddleware(_NotGeneratorDoNothingMiddleware): + pass + + # ================================================================================ class TestSpiderMiddleware(TestCase): @classmethod @@ -240,7 +310,7 @@ class TestSpiderMiddleware(TestCase): (3) An exception from a spider callback (returning a list) should be caught by the process_spider_exception chain. No items should be processed. """ - log3 = yield self.crawl_log(NotAGeneratorCallbackSpider) + log3 = yield self.crawl_log(NotGeneratorCallbackSpider) self.assertIn("Middleware: ZeroDivisionError exception caught", str(log3)) self.assertNotIn("item_scraped_count", str(log3)) @@ -272,3 +342,26 @@ class TestSpiderMiddleware(TestCase): self.assertIn(str(item_from_callback), str(log4)) self.assertIn(str(item_recovered), str(log4)) self.assertNotIn('parse-second-item', str(log4)) + + @defer.inlineCallbacks + def test_not_a_generator_output_chain(self): + """ + (5) An exception from a middleware's process_spider_output method should be sent + to the process_spider_exception method from the next middleware in the chain. + The result of the recovery by the process_spider_exception method should be handled + by the process_spider_output method from the next middleware. + The final item count should be 1 (from the process_spider_exception chain, the items + from the spider callback are lost) + """ + log5 = yield self.crawl_log(NotGeneratorOutputChainSpider) + self.assertIn("'item_scraped_count': 1", str(log5)) + self.assertIn("GeneratorRecoverMiddleware.process_spider_exception: ReferenceError caught", str(log5)) + self.assertIn("GeneratorDoNothingAfterFailureMiddleware.process_spider_exception: ReferenceError caught", str(log5)) + self.assertNotIn("GeneratorFailMiddleware.process_spider_exception: ReferenceError caught", str(log5)) + self.assertNotIn("GeneratorDoNothingAfterRecoveryMiddleware.process_spider_exception: ReferenceError caught", str(log5)) + item_recovered = {'processed': [ + 'NotGeneratorRecoverMiddleware.process_spider_exception', + 'NotGeneratorDoNothingAfterRecoveryMiddleware.process_spider_output']} + self.assertIn(str(item_recovered), str(log5)) + self.assertNotIn('parse-first-item', str(log5)) + self.assertNotIn('parse-second-item', str(log5)) From 6329441c826bec97aeec82d3e7ec0bcd91c60a47 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 18 Jul 2018 16:59:24 -0300 Subject: [PATCH 024/503] ModuleNotFoundError was added in py3.6 --- tests/test_spidermiddleware.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 2f431ddc7..0451dfd27 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -34,7 +34,7 @@ class RecoverySpider(Spider): yield {'test': 1} self.logger.warn('DONT_FAIL: %s', response.meta.get('dont_fail')) if not response.meta.get('dont_fail'): - raise ModuleNotFoundError() + raise TabError() class RecoveryMiddleware: def process_spider_exception(self, response, exception, spider): @@ -275,8 +275,8 @@ class TestSpiderMiddleware(TestCase): was enqueued from the recovery middleware) """ log = yield self.crawl_log(RecoverySpider) - self.assertIn("Middleware: ModuleNotFoundError exception caught", str(log)) - self.assertEqual(str(log).count("Middleware: ModuleNotFoundError exception caught"), 1) + self.assertIn("Middleware: TabError exception caught", str(log)) + self.assertEqual(str(log).count("Middleware: TabError exception caught"), 1) self.assertIn("'item_scraped_count': 3", str(log)) @defer.inlineCallbacks From 71a1406c99e7d4cced0693389e537c98a38104aa Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 18 Jul 2018 17:40:30 -0300 Subject: [PATCH 025/503] Logging changes --- tests/test_spidermiddleware.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 0451dfd27..0f5646a72 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -1,6 +1,4 @@ -import logging - from testfixtures import LogCapture from twisted.trial.unittest import TestCase from twisted.internet import defer @@ -13,7 +11,7 @@ from tests.spiders import MockServerSpider class LogExceptionMiddleware: def process_spider_exception(self, response, exception, spider): - logging.warn('Middleware: %s exception caught', exception.__class__.__name__) + spider.logger.info('Middleware: %s exception caught', exception.__class__.__name__) return None @@ -32,13 +30,13 @@ class RecoverySpider(Spider): def parse(self, response): yield {'test': 1} - self.logger.warn('DONT_FAIL: %s', response.meta.get('dont_fail')) + self.logger.info('DONT_FAIL: %s', response.meta.get('dont_fail')) if not response.meta.get('dont_fail'): raise TabError() class RecoveryMiddleware: def process_spider_exception(self, response, exception, spider): - logging.warn('Middleware: %s exception caught', exception.__class__.__name__) + spider.logger.info('Middleware: %s exception caught', exception.__class__.__name__) return [ {'from': 'process_spider_exception'}, Request(response.url, meta={'dont_fail': True}, dont_filter=True), @@ -67,13 +65,13 @@ class ProcessSpiderInputSpider(Spider): return {'from': 'callback'} def errback(self, failure): - self.logger.warn('Got a Failure on the Request errback') + self.logger.info('Got a Failure on the Request errback') return {'from': 'errback'} class FailProcessSpiderInputMiddleware: def process_spider_input(self, response, spider): - logging.warn('Middleware: will raise IndexError') + spider.logger.info('Middleware: will raise IndexError') raise IndexError() @@ -142,7 +140,7 @@ class _GeneratorDoNothingMiddleware: def process_spider_exception(self, response, exception, spider): method = '{}.process_spider_exception'.format(self.__class__.__name__) - logging.info('%s: %s caught', method, exception.__class__.__name__) + spider.logger.info('%s: %s caught', method, exception.__class__.__name__) return None @@ -155,7 +153,7 @@ class GeneratorFailMiddleware: def process_spider_exception(self, response, exception, spider): method = '{}.process_spider_exception'.format(self.__class__.__name__) - logging.info('%s: %s caught', method, exception.__class__.__name__) + spider.logger.info('%s: %s caught', method, exception.__class__.__name__) yield {'processed': [method]} @@ -171,7 +169,7 @@ class GeneratorRecoverMiddleware: def process_spider_exception(self, response, exception, spider): method = '{}.process_spider_exception'.format(self.__class__.__name__) - logging.info('%s: %s caught', method, exception.__class__.__name__) + spider.logger.info('%s: %s caught', method, exception.__class__.__name__) yield {'processed': [method]} class GeneratorDoNothingAfterRecoveryMiddleware(_GeneratorDoNothingMiddleware): @@ -208,7 +206,7 @@ class _NotGeneratorDoNothingMiddleware: def process_spider_exception(self, response, exception, spider): method = '{}.process_spider_exception'.format(self.__class__.__name__) - logging.info('%s: %s caught', method, exception.__class__.__name__) + spider.logger.info('%s: %s caught', method, exception.__class__.__name__) return None @@ -223,7 +221,7 @@ class NotGeneratorFailMiddleware: def process_spider_exception(self, response, exception, spider): method = '{}.process_spider_exception'.format(self.__class__.__name__) - logging.info('%s: %s caught', method, exception.__class__.__name__) + spider.logger.info('%s: %s caught', method, exception.__class__.__name__) return [{'processed': [method]}] @@ -241,7 +239,7 @@ class NotGeneratorRecoverMiddleware: def process_spider_exception(self, response, exception, spider): method = '{}.process_spider_exception'.format(self.__class__.__name__) - logging.info('%s: %s caught', method, exception.__class__.__name__) + spider.logger.info('%s: %s caught', method, exception.__class__.__name__) return [{'processed': [method]}] class NotGeneratorDoNothingAfterRecoveryMiddleware(_NotGeneratorDoNothingMiddleware): From 20defa2e16628b4b432a1cc44ad37182dfc764ee Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 19 Jul 2018 10:31:06 -0300 Subject: [PATCH 026/503] Better handling of method indexes --- scrapy/core/spidermw.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 96488806d..8607ed620 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -70,20 +70,19 @@ class SpiderMiddlewareManager(MiddlewareManager): return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) - def process_spider_exception(_failure, index=0): + def process_spider_exception(_failure, start_index=0): exception = _failure.value # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): return _failure - for method in self.methods['process_spider_exception'][index:]: + for method_index, method in enumerate(self.methods['process_spider_exception'][start_index:], start=start_index): if method is None: continue - index += 1 result = method(response=response, exception=exception, spider=spider) if _isiterable(result): # stop exception handling by handing control over to the # process_spider_output chain if an iterable has been returned - return process_spider_output(result, index) + return process_spider_output(result, method_index+1) elif result is None: continue else: @@ -91,7 +90,7 @@ class SpiderMiddlewareManager(MiddlewareManager): .format(fname(method), type(result))) return _failure - def process_spider_output(result, index=0): + def process_spider_output(result, start_index=0): # items in this iterable do not need to go through the process_spider_output # chain, they went through it already from the process_spider_exception method recovered = MutableChain() @@ -106,20 +105,19 @@ class SpiderMiddlewareManager(MiddlewareManager): raise recovered.extend(exception_result) - for method in self.methods['process_spider_output'][index:]: + for method_index, method in enumerate(self.methods['process_spider_output'][start_index:], start=start_index): if method is None: continue - index += 1 # the following might fail directly if the output value is not a generator try: result = method(response=response, result=result, spider=spider) except Exception as ex: - exception_result = process_spider_exception(Failure(ex), index+1) + exception_result = process_spider_exception(Failure(ex), method_index+1) if exception_result is None or isinstance(exception_result, Failure): raise return exception_result if _isiterable(result): - result = evaluate_iterable(result, index) + result = evaluate_iterable(result, method_index) else: raise _InvalidOutput('Middleware {} must return an iterable, got {}' \ .format(fname(method), type(result))) From 784eed113021a1a787787a354add242e7abbf6f9 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 20 Jul 2018 19:08:46 -0300 Subject: [PATCH 027/503] Improve test coverage (downloader middleware) --- scrapy/core/downloader/middleware.py | 6 +-- tests/test_downloadermiddleware.py | 64 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index cf0c1f869..2fa277e7d 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -50,8 +50,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): defer.returnValue(response) for method in self.methods['process_response']: - response = yield method(request=request, response=response, - spider=spider) + response = yield method(request=request, response=response, spider=spider) if not isinstance(response, (Response, Request)): raise _InvalidOutput('Middleware %s.process_response must return Response or Request, got %s' % \ (six.get_method_self(method).__class__.__name__, type(response))) @@ -63,8 +62,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): def process_exception(_failure): exception = _failure.value for method in self.methods['process_exception']: - response = yield method(request=request, exception=exception, - spider=spider) + response = yield method(request=request, exception=exception, spider=spider) if response is not None and not isinstance(response, (Response, Request)): raise _InvalidOutput('Middleware %s.process_exception must return None, Response or Request, got %s' % \ (six.get_method_self(method).__class__.__name__, type(response))) diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index fb51392b2..0f420b70d 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -3,6 +3,7 @@ from twisted.python.failure import Failure from scrapy.http import Request, Response from scrapy.spiders import Spider +from scrapy.exceptions import _InvalidOutput from scrapy.core.downloader.middleware import DownloaderMiddlewareManager from scrapy.utils.test import get_crawler from scrapy.utils.python import to_bytes @@ -115,3 +116,66 @@ class ResponseFromProcessRequestTest(ManagerTestCase): self.assertIs(results[0], resp) self.assertFalse(download_func.called) + + +class ProcessRequestInvalidOutput(ManagerTestCase): + """Invalid return value for process_request method should raise an exception""" + + def test_invalid_process_request(self): + req = Request('http://example.com/index.html') + resp = Response('http://example.com/index.html') + + class InvalidProcessRequestMiddleware: + def process_request(self, request, spider): + return 1 + + self.mwman._add_middleware(InvalidProcessRequestMiddleware()) + download_func = mock.MagicMock() + dfd = self.mwman.download(download_func, req, self.spider) + results = [] + dfd.addBoth(results.append) + self.assertIsInstance(results[0], Failure) + self.assertIsInstance(results[0].value, _InvalidOutput) + + +class ProcessResponseInvalidOutput(ManagerTestCase): + """Invalid return value for process_response method should raise an exception""" + + def test_invalid_process_response(self): + req = Request('http://example.com/index.html') + resp = Response('http://example.com/index.html') + + class InvalidProcessResponseMiddleware: + def process_response(self, request, response, spider): + return 1 + + self.mwman._add_middleware(InvalidProcessResponseMiddleware()) + download_func = mock.MagicMock() + dfd = self.mwman.download(download_func, req, self.spider) + results = [] + dfd.addBoth(results.append) + self.assertIsInstance(results[0], Failure) + self.assertIsInstance(results[0].value, _InvalidOutput) + + +class ProcessExceptionInvalidOutput(ManagerTestCase): + """Invalid return value for process_exception method should raise an exception""" + + def test_invalid_process_exception(self): + req = Request('http://example.com/index.html') + resp = Response('http://example.com/index.html') + + class InvalidProcessExceptionMiddleware: + def process_request(self, request, spider): + raise Exception() + + def process_exception(self, request, exception, spider): + return 1 + + self.mwman._add_middleware(InvalidProcessExceptionMiddleware()) + download_func = mock.MagicMock() + dfd = self.mwman.download(download_func, req, self.spider) + results = [] + dfd.addBoth(results.append) + self.assertIsInstance(results[0], Failure) + self.assertIsInstance(results[0].value, _InvalidOutput) From d6d3e87e3a4fd306829a84f934a971fd4e337a26 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 27 Jul 2018 14:47:52 -0300 Subject: [PATCH 028/503] Rename test file --- ..._spidermiddleware.py => test_spidermiddleware_output_chain.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_spidermiddleware.py => test_spidermiddleware_output_chain.py} (100%) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware_output_chain.py similarity index 100% rename from tests/test_spidermiddleware.py rename to tests/test_spidermiddleware_output_chain.py From 801d3c07b4b7e57d50429e714a8255e7747568f4 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 27 Jul 2018 15:06:25 -0300 Subject: [PATCH 029/503] Fix bad exception handling, add tests --- scrapy/core/spidermw.py | 2 + tests/test_spidermiddleware_invalid_values.py | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/test_spidermiddleware_invalid_values.py diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 8607ed620..1b67af130 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -66,6 +66,8 @@ class SpiderMiddlewareManager(MiddlewareManager): if result is not None: raise _InvalidOutput('Middleware {} must return None or raise an exception, got {}' \ .format(fname(method), type(result))) + except _InvalidOutput: + raise except: return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) diff --git a/tests/test_spidermiddleware_invalid_values.py b/tests/test_spidermiddleware_invalid_values.py new file mode 100644 index 000000000..0d9af8951 --- /dev/null +++ b/tests/test_spidermiddleware_invalid_values.py @@ -0,0 +1,82 @@ +from twisted.trial.unittest import TestCase +from twisted.python.failure import Failure + +from scrapy.spiders import Spider +from scrapy.http import Request, Response +from scrapy.exceptions import _InvalidOutput +from scrapy.utils.test import get_crawler +from scrapy.core.spidermw import SpiderMiddlewareManager +from tests import mock + + +class SpiderMiddlewareTestCase(TestCase): + + def setUp(self): + self.request = Request('http://example.com/index.html') + self.response = Response(self.request.url, request=self.request) + self.crawler = get_crawler(Spider) + self.spider = self.crawler._create_spider('foo') + self.mwman = SpiderMiddlewareManager.from_crawler(self.crawler) + + def _scrape_response(self): + """Execute spider mw manager's scrape_response method and return the result. + Raise exception in case of failure. + """ + scrape_func = mock.MagicMock() + dfd = self.mwman.scrape_response(scrape_func, self.response, self.request, self.spider) + # catch deferred result and return the value + results = [] + dfd.addBoth(results.append) + self._wait(dfd) + ret = results[0] + return ret + + +class ProcessSpiderInputInvalidOutput(SpiderMiddlewareTestCase): + """Invalid return value for process_spider_input method""" + + def test_invalid_process_spider_input(self): + + class InvalidProcessSpiderInputMiddleware: + def process_spider_input(self, response, spider): + return 1 + + self.mwman._add_middleware(InvalidProcessSpiderInputMiddleware()) + result = self._scrape_response() + self.assertIsInstance(result, Failure) + self.assertIsInstance(result.value, _InvalidOutput) + + +class ProcessSpiderOutputInvalidOutput(SpiderMiddlewareTestCase): + """Invalid return value for process_spider_output method""" + + def test_invalid_process_spider_output(self): + + class InvalidProcessSpiderOutputMiddleware: + def process_spider_output(self, response, result, spider): + return 1 + + self.mwman._add_middleware(InvalidProcessSpiderOutputMiddleware()) + result = self._scrape_response() + self.assertIsInstance(result, Failure) + self.assertIsInstance(result.value, _InvalidOutput) + + +class ProcessSpiderExceptionInvalidOutput(SpiderMiddlewareTestCase): + """Invalid return value for process_spider_exception method""" + + def test_invalid_process_spider_exception(self): + + class InvalidProcessSpiderOutputExceptionMiddleware: + def process_spider_exception(self, response, exception, spider): + return 1 + + class RaiseExceptionProcessSpiderOutputMiddleware: + def process_spider_output(self, response, result, spider): + raise Exception() + + self.mwman._add_middleware(InvalidProcessSpiderOutputExceptionMiddleware()) + self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) + result = self._scrape_response() + self.assertIsInstance(result, Failure) + self.assertIsInstance(result.value, _InvalidOutput) From 8c55f5eb159ae85d468a89b74ffaff3e824144ab Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 3 Aug 2018 15:16:26 -0300 Subject: [PATCH 030/503] Simplify check for re-raised exception. Add tests. --- scrapy/core/spidermw.py | 4 ++-- ...lid_values.py => test_spidermiddleware.py} | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) rename tests/{test_spidermiddleware_invalid_values.py => test_spidermiddleware.py} (79%) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 1b67af130..4268c91d6 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -103,7 +103,7 @@ class SpiderMiddlewareManager(MiddlewareManager): yield r except Exception as ex: exception_result = process_spider_exception(Failure(ex), index+1) - if exception_result is None or isinstance(exception_result, Failure): + if isinstance(exception_result, Failure): raise recovered.extend(exception_result) @@ -115,7 +115,7 @@ class SpiderMiddlewareManager(MiddlewareManager): result = method(response=response, result=result, spider=spider) except Exception as ex: exception_result = process_spider_exception(Failure(ex), method_index+1) - if exception_result is None or isinstance(exception_result, Failure): + if isinstance(exception_result, Failure): raise return exception_result if _isiterable(result): diff --git a/tests/test_spidermiddleware_invalid_values.py b/tests/test_spidermiddleware.py similarity index 79% rename from tests/test_spidermiddleware_invalid_values.py rename to tests/test_spidermiddleware.py index 0d9af8951..54756f2ff 100644 --- a/tests/test_spidermiddleware_invalid_values.py +++ b/tests/test_spidermiddleware.py @@ -80,3 +80,23 @@ class ProcessSpiderExceptionInvalidOutput(SpiderMiddlewareTestCase): result = self._scrape_response() self.assertIsInstance(result, Failure) self.assertIsInstance(result.value, _InvalidOutput) + + +class ProcessSpiderExceptionReRaise(SpiderMiddlewareTestCase): + """Re raise the exception by returning None""" + + def test_process_spider_exception_return_none(self): + + class ProcessSpiderOutputExceptionReturnNoneMiddleware: + def process_spider_exception(self, response, exception, spider): + return None + + class RaiseExceptionProcessSpiderOutputMiddleware: + def process_spider_output(self, response, result, spider): + 1/0 + + self.mwman._add_middleware(ProcessSpiderOutputExceptionReturnNoneMiddleware()) + self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) + result = self._scrape_response() + self.assertIsInstance(result, Failure) + self.assertIsInstance(result.value, ZeroDivisionError) From 40449fa0eb707bac1ae2b78f0f812372e90f17b7 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 3 Aug 2018 18:20:25 -0300 Subject: [PATCH 031/503] Update docs, add tests, remove FIXME comment --- docs/topics/spider-middleware.rst | 3 +- scrapy/core/scraper.py | 1 - tests/test_spidermiddleware.py | 4 +- tests/test_spidermiddleware_output_chain.py | 43 ++++++++++++++------- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 915833c54..7db623cf4 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -78,7 +78,8 @@ following methods: If it raises an exception, Scrapy won't bother calling any other spider middleware :meth:`process_spider_input` and will call the request - errback. The output of the errback is chained back in the other + errback if there is one, otherwise it will start the :meth:`process_spider_exception` + chain. The output of the errback is chained back in the other direction for :meth:`process_spider_output` to process it, or :meth:`process_spider_exception` if it raised an exception. diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index ee1e95a0c..d7fe721fb 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -135,7 +135,6 @@ class Scraper(object): return self.spidermw.scrape_response( self.call_spider, request_result, request, spider) else: - # FIXME: don't ignore errors in spider middleware dfd = self.call_spider(request_result, request, spider) return dfd.addErrback( self._log_download_errors, request_result, request, spider) diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py index 54756f2ff..832fd3330 100644 --- a/tests/test_spidermiddleware.py +++ b/tests/test_spidermiddleware.py @@ -87,7 +87,7 @@ class ProcessSpiderExceptionReRaise(SpiderMiddlewareTestCase): def test_process_spider_exception_return_none(self): - class ProcessSpiderOutputExceptionReturnNoneMiddleware: + class ProcessSpiderExceptionReturnNoneMiddleware: def process_spider_exception(self, response, exception, spider): return None @@ -95,7 +95,7 @@ class ProcessSpiderExceptionReRaise(SpiderMiddlewareTestCase): def process_spider_output(self, response, result, spider): 1/0 - self.mwman._add_middleware(ProcessSpiderOutputExceptionReturnNoneMiddleware()) + self.mwman._add_middleware(ProcessSpiderExceptionReturnNoneMiddleware()) self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware()) result = self._scrape_response() self.assertIsInstance(result, Failure) diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py index 0f5646a72..6f8727a15 100644 --- a/tests/test_spidermiddleware_output_chain.py +++ b/tests/test_spidermiddleware_output_chain.py @@ -45,8 +45,13 @@ class RecoveryMiddleware: # ================================================================================ # (1) exceptions from a spider middleware's process_spider_input method -class ProcessSpiderInputSpider(Spider): - name = 'ProcessSpiderInputSpider' +class FailProcessSpiderInputMiddleware: + def process_spider_input(self, response, spider): + spider.logger.info('Middleware: will raise IndexError') + raise IndexError() + +class ProcessSpiderInputSpiderWithoutErrback(Spider): + name = 'ProcessSpiderInputSpiderWithoutErrback' custom_settings = { 'SPIDER_MIDDLEWARES': { # spider @@ -58,23 +63,23 @@ class ProcessSpiderInputSpider(Spider): } def start_requests(self): - yield Request(url=self.mockserver.url('/status?n=200'), - callback=self.parse, errback=self.errback) + yield Request(url=self.mockserver.url('/status?n=200'), callback=self.parse) def parse(self, response): return {'from': 'callback'} + +class ProcessSpiderInputSpiderWithErrback(ProcessSpiderInputSpiderWithoutErrback): + name = 'ProcessSpiderInputSpiderWithErrback' + + def start_requests(self): + yield Request(url=self.mockserver.url('/status?n=200'), callback=self.parse, errback=self.errback) + def errback(self, failure): self.logger.info('Got a Failure on the Request errback') return {'from': 'errback'} -class FailProcessSpiderInputMiddleware: - def process_spider_input(self, response, spider): - spider.logger.info('Middleware: will raise IndexError') - raise IndexError() - - # ================================================================================ # (2) exceptions from a spider callback (generator) class GeneratorCallbackSpider(Spider): @@ -278,12 +283,22 @@ class TestSpiderMiddleware(TestCase): self.assertIn("'item_scraped_count': 3", str(log)) @defer.inlineCallbacks - def test_process_spider_input_errback(self): + def test_process_spider_input_without_errback(self): """ - (1) An exception from the process_spider_input chain should not be caught by the - process_spider_exception chain, it should go directly to the Request errback + (1.1) An exception from the process_spider_input chain should be caught by the + process_spider_exception chain from the start if the Request has no errback """ - log1 = yield self.crawl_log(ProcessSpiderInputSpider) + log1 = yield self.crawl_log(ProcessSpiderInputSpiderWithoutErrback) + self.assertIn("Middleware: will raise IndexError", str(log1)) + self.assertIn("Middleware: IndexError exception caught", str(log1)) + + @defer.inlineCallbacks + def test_process_spider_input_with_errback(self): + """ + (1.2) An exception from the process_spider_input chain should not be caught by the + process_spider_exception chain if the Request has an errback + """ + log1 = yield self.crawl_log(ProcessSpiderInputSpiderWithErrback) self.assertNotIn("Middleware: IndexError exception caught", str(log1)) self.assertIn("Middleware: will raise IndexError", str(log1)) self.assertIn("Got a Failure on the Request errback", str(log1)) From 58f5565357ed532970772cd55c2d17d1e00198a9 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 11 Oct 2018 11:23:12 -0300 Subject: [PATCH 033/503] Move MutableChain to scrapy.utils.python --- scrapy/core/spidermw.py | 21 +-------------------- scrapy/utils/python.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 4268c91d6..d776430e5 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -11,32 +11,13 @@ from scrapy.exceptions import _InvalidOutput from scrapy.middleware import MiddlewareManager from scrapy.utils.defer import mustbe_deferred from scrapy.utils.conf import build_component_list +from scrapy.utils.python import MutableChain def _isiterable(possible_iterator): return hasattr(possible_iterator, '__iter__') -class MutableChain: - """ - Thin wrapper around itertools.chain, allowing to add iterables "in-place" - """ - def __init__(self, *args): - self.data = chain(*args) - - def extend(self, *iterables): - self.data = chain(self.data, *iterables) - - def __iter__(self): - return self.data.__iter__() - - def __next__(self): # py3 - return self.data.__next__() - - def next(self): # py2 - return self.data.next() - - class SpiderMiddlewareManager(MiddlewareManager): component_name = 'spider middleware' diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 732ca13a0..7971b4dde 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -9,6 +9,7 @@ import weakref import errno import six from functools import partial, wraps +from itertools import chain import sys from scrapy.utils.decorators import deprecated @@ -387,3 +388,23 @@ if hasattr(sys, "pypy_version_info"): else: def garbage_collect(): gc.collect() + + +class MutableChain(object): + """ + Thin wrapper around itertools.chain, allowing to add iterables "in-place" + """ + def __init__(self, *args): + self.data = chain(*args) + + def extend(self, *iterables): + self.data = chain(self.data, *iterables) + + def __iter__(self): + return self.data.__iter__() + + def __next__(self): # py3 + return self.data.__next__() + + def next(self): # py2 + return self.data.next() From a05eaeed73a469493e78b5a1c5f0b4de2adf41c2 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 11 Oct 2018 11:31:51 -0300 Subject: [PATCH 034/503] Simplify MutableChain --- scrapy/utils/python.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 7971b4dde..1a6bab990 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -403,8 +403,7 @@ class MutableChain(object): def __iter__(self): return self.data.__iter__() - def __next__(self): # py3 - return self.data.__next__() + def __next__(self): + return next(self.data) - def next(self): # py2 - return self.data.next() + next = __next__ From 15f0a890ee9f059111333fdeb6c6c3b5a8dadc07 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 11 Oct 2018 11:34:59 -0300 Subject: [PATCH 035/503] Assign processing methods to a variable before iterating --- scrapy/core/spidermw.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index d776430e5..3fae770a9 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -58,7 +58,8 @@ class SpiderMiddlewareManager(MiddlewareManager): # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): return _failure - for method_index, method in enumerate(self.methods['process_spider_exception'][start_index:], start=start_index): + method_list = self.methods['process_spider_exception'][start_index:] + for method_index, method in enumerate(method_list, start=start_index): if method is None: continue result = method(response=response, exception=exception, spider=spider) @@ -88,7 +89,8 @@ class SpiderMiddlewareManager(MiddlewareManager): raise recovered.extend(exception_result) - for method_index, method in enumerate(self.methods['process_spider_output'][start_index:], start=start_index): + method_list = self.methods['process_spider_output'][start_index:] + for method_index, method in enumerate(method_list, start=start_index): if method is None: continue # the following might fail directly if the output value is not a generator From e0360e5223b618934ee006b4c9ed63012e7e621f Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 11 Oct 2018 11:55:13 -0300 Subject: [PATCH 036/503] Add tests for MutableChain --- tests/test_utils_python.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py index f6133657b..3e1148354 100644 --- a/tests/test_utils_python.py +++ b/tests/test_utils_python.py @@ -9,11 +9,23 @@ import six from scrapy.utils.python import ( memoizemethod_noargs, binary_is_text, equal_attributes, WeakKeyCache, stringify_dict, get_func_args, to_bytes, to_unicode, - without_none_values) + without_none_values, MutableChain) __doctests__ = ['scrapy.utils.python'] +class MutableChainTest(unittest.TestCase): + def test_mutablechain(self): + m = MutableChain(range(2), [2, 3], (4, 5)) + m.extend(range(6, 7)) + m.extend([7, 8]) + m.extend([9, 10], (11, 12)) + self.assertEqual(next(m), 0) + self.assertEqual(m.next(), 1) + self.assertEqual(m.__next__(), 2) + self.assertEqual(list(m), list(range(3, 13))) + + class ToUnicodeTest(unittest.TestCase): def test_converting_an_utf8_encoded_string_to_unicode(self): self.assertEqual(to_unicode(b'lel\xc3\xb1e'), u'lel\xf1e') From a25cf5c82f99f7ae11346a2e565d6255835c3814 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Tue, 20 Nov 2018 16:13:09 +0000 Subject: [PATCH 038/503] function to get unique file queues for any type of base queue --- scrapy/core/queues.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 scrapy/core/queues.py diff --git a/scrapy/core/queues.py b/scrapy/core/queues.py new file mode 100644 index 000000000..96d582fc7 --- /dev/null +++ b/scrapy/core/queues.py @@ -0,0 +1,15 @@ +import uuid +import os.path + + +def unique_files_queue(queue_class): + + class UniqueFilesQueue(queue_class): + def __init__(self, path): + path = path + "-" + uuid.uuid4().hex + while os.path.exists(path): + path = path + "-" + uuid.uuid4().hex + + super().__init__(path) + + return UniqueFilesQueue From 1ce6662a9d7115348788972afce62a5c45199021 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Sat, 24 Nov 2018 20:02:00 +0530 Subject: [PATCH 039/503] Implement Request subclass for json requests --- scrapy/http/__init__.py | 1 + scrapy/http/request/json_request.py | 28 +++++++++++++++ tests/test_http_request.py | 55 ++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 scrapy/http/request/json_request.py diff --git a/scrapy/http/__init__.py b/scrapy/http/__init__.py index f04a9d3e5..4b2f7b33f 100644 --- a/scrapy/http/__init__.py +++ b/scrapy/http/__init__.py @@ -10,6 +10,7 @@ from scrapy.http.headers import Headers from scrapy.http.request import Request from scrapy.http.request.form import FormRequest from scrapy.http.request.rpc import XmlRpcRequest +from scrapy.http.request.json_request import JSONRequest from scrapy.http.response import Response from scrapy.http.response.html import HtmlResponse diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py new file mode 100644 index 000000000..0fdd2ddf1 --- /dev/null +++ b/scrapy/http/request/json_request.py @@ -0,0 +1,28 @@ +""" +This module implements the JSONRequest class which is a more convenient class +(than Request) to generate JSON Requests. + +See documentation in docs/topics/request-response.rst +""" + +import json + +from scrapy.http.request import Request + + +class JSONRequest(Request): + def __init__(self, *args, **kwargs): + if 'method' not in kwargs: + kwargs['method'] = 'POST' + + data = kwargs.pop('data', {}) + kwargs['body'] = json.dumps(data) + super(JSONRequest, self).__init__(*args, **kwargs) + self.headers.setdefault(b'Content-Type', b'application/json') + + def replace(self, *args, **kwargs): + """ Create a new Request with the same attributes except for those + given new values. """ + + kwargs.pop('body', None) + return super(JSONRequest, self).replace(*args, **kwargs) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 58326a384..3f2e4f521 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -2,6 +2,7 @@ import cgi import unittest import re +import json import six from six.moves import xmlrpc_client as xmlrpclib @@ -9,7 +10,7 @@ from six.moves.urllib.parse import urlparse, parse_qs, unquote if six.PY3: from urllib.parse import unquote_to_bytes -from scrapy.http import Request, FormRequest, XmlRpcRequest, Headers, HtmlResponse +from scrapy.http import Request, FormRequest, XmlRpcRequest, JSONRequest, Headers, HtmlResponse from scrapy.utils.python import to_bytes, to_native_str @@ -1147,5 +1148,57 @@ class XmlRpcRequestTest(RequestTest): self._test_request(params=(u'pas£',), encoding='latin1') +class JSONRequestTest(RequestTest): + request_class = JSONRequest + default_method = 'POST' + default_headers = {b'Content-Type': [b'application/json']} + + def test_body(self): + r1 = self.request_class(url="http://www.example.com/") + self.assertEqual(r1.body, '{}') + + r2 = self.request_class(url="http://www.example.com/", body=b"") + self.assertEqual(r2.body, '{}') + + data = { + 'name': 'value', + } + r3 = self.request_class(url="http://www.example.com/", data=data) + self.assertEqual(r3.body, json.dumps(data)) + + r4 = self.request_class(url="http://www.example.com/", body='body1', data=data) + self.assertEqual(r3.body, json.dumps(data)) + + def test_replace(self): + """Test Request.replace() method""" + r1 = self.request_class("http://www.example.com") + hdrs = Headers(r1.headers) + hdrs[b'key'] = b'value' + r2 = r1.replace(body="New body", headers=hdrs) + + # body will not be replaced + self.assertEqual(r1.body, r2.body) + self.assertEqual(r1.url, r2.url) + self.assertEqual((r1.headers, r2.headers), (self.default_headers, hdrs)) + + # Empty attributes (which may fail if not compared properly) + r3 = self.request_class("http://www.example.com", meta={'a': 1}, dont_filter=True) + r4 = r3.replace(url="http://www.example.com/2", meta={}, dont_filter=False) + self.assertEqual(r4.url, "http://www.example.com/2") + self.assertEqual(r4.meta, {}) + assert r4.dont_filter is False + + data1 = { + 'name': 'value1', + } + data2 = { + 'name': 'value2', + } + r5 = self.request_class("http://www.example.com", data=data1) + r6 = r5.replace(url="http://www.example.com/2", data=data2) + self.assertNotEqual(r5.body, r6.body) + self.assertEqual((r5.body, r6.body), (json.dumps(data1), json.dumps(data2))) + + if __name__ == "__main__": unittest.main() From 1b2b8b4bf0c73b4ad143f943584545702d66cbb7 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Tue, 27 Nov 2018 08:57:44 +0530 Subject: [PATCH 040/503] fix tests under py3 --- tests/test_http_request.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 3f2e4f521..a2021bd65 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1155,19 +1155,19 @@ class JSONRequestTest(RequestTest): def test_body(self): r1 = self.request_class(url="http://www.example.com/") - self.assertEqual(r1.body, '{}') + self.assertEqual(r1.body, b'{}') r2 = self.request_class(url="http://www.example.com/", body=b"") - self.assertEqual(r2.body, '{}') + self.assertEqual(r2.body, b'{}') data = { 'name': 'value', } r3 = self.request_class(url="http://www.example.com/", data=data) - self.assertEqual(r3.body, json.dumps(data)) + self.assertEqual(r3.body, to_bytes(json.dumps(data))) r4 = self.request_class(url="http://www.example.com/", body='body1', data=data) - self.assertEqual(r3.body, json.dumps(data)) + self.assertEqual(r3.body, to_bytes(json.dumps(data))) def test_replace(self): """Test Request.replace() method""" @@ -1197,7 +1197,7 @@ class JSONRequestTest(RequestTest): r5 = self.request_class("http://www.example.com", data=data1) r6 = r5.replace(url="http://www.example.com/2", data=data2) self.assertNotEqual(r5.body, r6.body) - self.assertEqual((r5.body, r6.body), (json.dumps(data1), json.dumps(data2))) + self.assertEqual((r5.body, r6.body), (to_bytes(json.dumps(data1)), to_bytes(json.dumps(data2)))) if __name__ == "__main__": From cd619c1d4f3810c96af0ff5c5735c1856dfac95a Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Sat, 8 Dec 2018 22:10:45 +0530 Subject: [PATCH 041/503] removed overriden replace method --- scrapy/http/request/json_request.py | 20 +++++------ tests/test_http_request.py | 51 ++++++++--------------------- 2 files changed, 21 insertions(+), 50 deletions(-) diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 0fdd2ddf1..03a0ab061 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -12,17 +12,13 @@ from scrapy.http.request import Request class JSONRequest(Request): def __init__(self, *args, **kwargs): - if 'method' not in kwargs: - kwargs['method'] = 'POST' + data = kwargs.pop('data', None) + if data: + kwargs['body'] = json.dumps(data) + + if 'method' not in kwargs: + kwargs['method'] = 'POST' - data = kwargs.pop('data', {}) - kwargs['body'] = json.dumps(data) super(JSONRequest, self).__init__(*args, **kwargs) - self.headers.setdefault(b'Content-Type', b'application/json') - - def replace(self, *args, **kwargs): - """ Create a new Request with the same attributes except for those - given new values. """ - - kwargs.pop('body', None) - return super(JSONRequest, self).replace(*args, **kwargs) + self.headers.setdefault('Content-Type', 'application/json') + self.headers.setdefault('Accept', 'application/json, text/javascript, */*; q=0.01') diff --git a/tests/test_http_request.py b/tests/test_http_request.py index a2021bd65..793a583bc 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1150,54 +1150,29 @@ class XmlRpcRequestTest(RequestTest): class JSONRequestTest(RequestTest): request_class = JSONRequest - default_method = 'POST' - default_headers = {b'Content-Type': [b'application/json']} + default_method = 'GET' + default_headers = {b'Content-Type': [b'application/json'], b'Accept': [b'application/json, text/javascript, */*; q=0.01']} - def test_body(self): + def test_data(self): r1 = self.request_class(url="http://www.example.com/") - self.assertEqual(r1.body, b'{}') + self.assertEqual(r1.body, b'') + self.assertEqual(r1.method, 'GET') - r2 = self.request_class(url="http://www.example.com/", body=b"") - self.assertEqual(r2.body, b'{}') + body = b'body' + r2 = self.request_class(url="http://www.example.com/", body=body) + self.assertEqual(r2.body, body) + self.assertEqual(r2.method, 'GET') data = { 'name': 'value', } r3 = self.request_class(url="http://www.example.com/", data=data) self.assertEqual(r3.body, to_bytes(json.dumps(data))) + self.assertEqual(r3.method, 'POST') - r4 = self.request_class(url="http://www.example.com/", body='body1', data=data) - self.assertEqual(r3.body, to_bytes(json.dumps(data))) - - def test_replace(self): - """Test Request.replace() method""" - r1 = self.request_class("http://www.example.com") - hdrs = Headers(r1.headers) - hdrs[b'key'] = b'value' - r2 = r1.replace(body="New body", headers=hdrs) - - # body will not be replaced - self.assertEqual(r1.body, r2.body) - self.assertEqual(r1.url, r2.url) - self.assertEqual((r1.headers, r2.headers), (self.default_headers, hdrs)) - - # Empty attributes (which may fail if not compared properly) - r3 = self.request_class("http://www.example.com", meta={'a': 1}, dont_filter=True) - r4 = r3.replace(url="http://www.example.com/2", meta={}, dont_filter=False) - self.assertEqual(r4.url, "http://www.example.com/2") - self.assertEqual(r4.meta, {}) - assert r4.dont_filter is False - - data1 = { - 'name': 'value1', - } - data2 = { - 'name': 'value2', - } - r5 = self.request_class("http://www.example.com", data=data1) - r6 = r5.replace(url="http://www.example.com/2", data=data2) - self.assertNotEqual(r5.body, r6.body) - self.assertEqual((r5.body, r6.body), (to_bytes(json.dumps(data1)), to_bytes(json.dumps(data2)))) + r4 = self.request_class(url="http://www.example.com/", body=body, data=data) + self.assertEqual(r4.body, to_bytes(json.dumps(data))) + self.assertEqual(r4.method, 'POST') if __name__ == "__main__": From c347acbff6545c428aa2c965cd03f03db6bae1bf Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Sun, 9 Dec 2018 11:27:09 +0530 Subject: [PATCH 042/503] warning if body and data are provided --- scrapy/http/request/json_request.py | 7 ++++++- tests/test_http_request.py | 25 ++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 03a0ab061..3b791eda3 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -6,14 +6,19 @@ See documentation in docs/topics/request-response.rst """ import json +import warnings from scrapy.http.request import Request class JSONRequest(Request): def __init__(self, *args, **kwargs): + body_passed = 'body' in kwargs data = kwargs.pop('data', None) - if data: + if body_passed and data: + warnings.warn('Both body and data passed. data will be ignored') + + elif not body_passed and data: kwargs['body'] = json.dumps(data) if 'method' not in kwargs: diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 793a583bc..e5a85e6fc 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -3,6 +3,7 @@ import cgi import unittest import re import json +import warnings import six from six.moves import xmlrpc_client as xmlrpclib @@ -1153,6 +1154,10 @@ class JSONRequestTest(RequestTest): default_method = 'GET' default_headers = {b'Content-Type': [b'application/json'], b'Accept': [b'application/json, text/javascript, */*; q=0.01']} + def setUp(self): + warnings.simplefilter("always") + super(JSONRequestTest, self).setUp() + def test_data(self): r1 = self.request_class(url="http://www.example.com/") self.assertEqual(r1.body, b'') @@ -1170,9 +1175,23 @@ class JSONRequestTest(RequestTest): self.assertEqual(r3.body, to_bytes(json.dumps(data))) self.assertEqual(r3.method, 'POST') - r4 = self.request_class(url="http://www.example.com/", body=body, data=data) - self.assertEqual(r4.body, to_bytes(json.dumps(data))) - self.assertEqual(r4.method, 'POST') + with warnings.catch_warnings(record=True) as _warnings: + r4 = self.request_class(url="http://www.example.com/", body=body, data=data) + self.assertEqual(r4.body, body) + self.assertEqual(r4.method, 'GET') + self.assertEqual(len(_warnings), 1) + self.assertIn('data will be ignored', str(_warnings[0].message)) + + with warnings.catch_warnings(record=True) as _warnings: + r5 = self.request_class(url="http://www.example.com/", body=b'', data=data) + self.assertEqual(r5.body, b'') + self.assertEqual(r5.method, 'GET') + self.assertEqual(len(_warnings), 1) + self.assertIn('data will be ignored', str(_warnings[0].message)) + + def tearDown(self): + warnings.resetwarnings() + super(JSONRequestTest, self).tearDown() if __name__ == "__main__": From 3c981bf204c739fa77e205b9747d2aff446c99d5 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Sun, 9 Dec 2018 12:56:12 +0530 Subject: [PATCH 043/503] add documentation --- docs/topics/request-response.rst | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index e29914dbf..d957915e7 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -508,6 +508,38 @@ method for this job. Here's an example spider which uses it:: # continue scraping with authenticated session... +JSONRequest +----------- + +The JSONRequest class extends the base :class:`Request` class with functionality for +dealing with JSON requests. + +.. class:: JSONRequest(url, [data, ...]) + + The :class:`JSONRequest` class adds a new argument to the constructor called data. The + remaining arguments are the same as for the :class:`Request` class and are + not documented here. + + Using the :class:`JSONRequest` will set the `Content-Type` header to `application/json` + and `Accept` header to `application/json, text/javascript, */*; q=0.01` + + :param data: is any JSON serializable object that needs to be JSON encoded and assigned to body. + if :attr:`Request.body` argument is provided this parameter will be ignored. + if :attr:`Request.body` argument is not provided and data argument is provided :attr:`Request.method` will be + set to POST automatically. + :type data: JSON serializable object + +JSONRequest usage example +------------------------- + +Sending a JSON POST request with a JSON payload:: + + data = { + 'name1': 'value1', + 'name2': 'value2', + } + yield JSONRequest(url='http://www.example.com/post/action', data=data) + Response objects ================ From ecda69130e97629b15d3b09b1e588cb6777ee94d Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Mon, 10 Dec 2018 22:34:49 +0530 Subject: [PATCH 044/503] allow to send empty data values and docs changes --- docs/topics/request-response.rst | 6 +++--- scrapy/http/request/json_request.py | 8 +++++--- tests/test_http_request.py | 27 +++++++++++++++++++++------ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index d957915e7..02b853fc0 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -520,13 +520,13 @@ dealing with JSON requests. remaining arguments are the same as for the :class:`Request` class and are not documented here. - Using the :class:`JSONRequest` will set the `Content-Type` header to `application/json` - and `Accept` header to `application/json, text/javascript, */*; q=0.01` + Using the :class:`JSONRequest` will set the ``Content-Type`` header to ``application/json`` + and ``Accept`` header to ``application/json, text/javascript, */*; q=0.01`` :param data: is any JSON serializable object that needs to be JSON encoded and assigned to body. if :attr:`Request.body` argument is provided this parameter will be ignored. if :attr:`Request.body` argument is not provided and data argument is provided :attr:`Request.method` will be - set to POST automatically. + set to ``'POST'`` automatically. :type data: JSON serializable object JSONRequest usage example diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 3b791eda3..593dfdcb0 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -13,12 +13,14 @@ from scrapy.http.request import Request class JSONRequest(Request): def __init__(self, *args, **kwargs): - body_passed = 'body' in kwargs + body_passed = kwargs.get('body', None) is not None data = kwargs.pop('data', None) - if body_passed and data: + data_passed = data is not None + + if body_passed and data_passed: warnings.warn('Both body and data passed. data will be ignored') - elif not body_passed and data: + elif not body_passed and data_passed: kwargs['body'] = json.dumps(data) if 'method' not in kwargs: diff --git a/tests/test_http_request.py b/tests/test_http_request.py index e5a85e6fc..5eb655c12 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1175,20 +1175,35 @@ class JSONRequestTest(RequestTest): self.assertEqual(r3.body, to_bytes(json.dumps(data))) self.assertEqual(r3.method, 'POST') + r4 = self.request_class(url="http://www.example.com/", data=[]) + self.assertEqual(r4.body, to_bytes(json.dumps([]))) + self.assertEqual(r4.method, 'POST') + with warnings.catch_warnings(record=True) as _warnings: - r4 = self.request_class(url="http://www.example.com/", body=body, data=data) - self.assertEqual(r4.body, body) - self.assertEqual(r4.method, 'GET') + r5 = self.request_class(url="http://www.example.com/", body=body, data=data) + self.assertEqual(r5.body, body) + self.assertEqual(r5.method, 'GET') self.assertEqual(len(_warnings), 1) self.assertIn('data will be ignored', str(_warnings[0].message)) with warnings.catch_warnings(record=True) as _warnings: - r5 = self.request_class(url="http://www.example.com/", body=b'', data=data) - self.assertEqual(r5.body, b'') - self.assertEqual(r5.method, 'GET') + r6 = self.request_class(url="http://www.example.com/", body=b'', data=data) + self.assertEqual(r6.body, b'') + self.assertEqual(r6.method, 'GET') self.assertEqual(len(_warnings), 1) self.assertIn('data will be ignored', str(_warnings[0].message)) + with warnings.catch_warnings(record=True) as _warnings: + r7 = self.request_class(url="http://www.example.com/", body=None, data=data) + self.assertEqual(r7.body, to_bytes(json.dumps(data))) + self.assertEqual(r7.method, 'POST') + self.assertEqual(len(_warnings), 0) + + with warnings.catch_warnings(record=True) as _warnings: + r8 = self.request_class(url="http://www.example.com/", body=None, data=None) + self.assertEqual(r8.method, 'GET') + self.assertEqual(len(_warnings), 0) + def tearDown(self): warnings.resetwarnings() super(JSONRequestTest, self).tearDown() From 71ef321b68d2fd202de145d0c580387ee59cd2e2 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Wed, 12 Dec 2018 11:12:48 +0530 Subject: [PATCH 045/503] sort_keys while serializing to json --- scrapy/http/request/json_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 593dfdcb0..afc4356a3 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -21,7 +21,7 @@ class JSONRequest(Request): warnings.warn('Both body and data passed. data will be ignored') elif not body_passed and data_passed: - kwargs['body'] = json.dumps(data) + kwargs['body'] = json.dumps(data, sort_keys=True) if 'method' not in kwargs: kwargs['method'] = 'POST' From 8f1507a4a5de2ed55cb0fda198265845a047fedb Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Mon, 17 Dec 2018 23:14:06 +0530 Subject: [PATCH 046/503] dumps_kwargs --- docs/topics/request-response.rst | 10 ++- scrapy/http/request/json_request.py | 21 ++++- tests/test_http_request.py | 114 +++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 02b853fc0..4e6f00bb0 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -514,9 +514,9 @@ JSONRequest The JSONRequest class extends the base :class:`Request` class with functionality for dealing with JSON requests. -.. class:: JSONRequest(url, [data, ...]) +.. class:: JSONRequest(url, [... data]) - The :class:`JSONRequest` class adds a new argument to the constructor called data. The + The :class:`JSONRequest` class adds two new argument to the constructor. The remaining arguments are the same as for the :class:`Request` class and are not documented here. @@ -529,6 +529,12 @@ dealing with JSON requests. set to ``'POST'`` automatically. :type data: JSON serializable object + :param dumps_kwargs: Parameters that will be passed to underlying `json.dumps`_ method which is used to serialize data + into JSON format. + :type dumps_kwargs: dict + +.. _json.dumps: https://docs.python.org/3/library/json.html#json.dumps + JSONRequest usage example ------------------------- diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index afc4356a3..7499610b9 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -13,6 +13,7 @@ from scrapy.http.request import Request class JSONRequest(Request): def __init__(self, *args, **kwargs): + dumps_kwargs = kwargs.pop('dumps_kwargs', {}) body_passed = kwargs.get('body', None) is not None data = kwargs.pop('data', None) data_passed = data is not None @@ -21,7 +22,7 @@ class JSONRequest(Request): warnings.warn('Both body and data passed. data will be ignored') elif not body_passed and data_passed: - kwargs['body'] = json.dumps(data, sort_keys=True) + kwargs['body'] = self.dump(data, **dumps_kwargs) if 'method' not in kwargs: kwargs['method'] = 'POST' @@ -29,3 +30,21 @@ class JSONRequest(Request): super(JSONRequest, self).__init__(*args, **kwargs) self.headers.setdefault('Content-Type', 'application/json') self.headers.setdefault('Accept', 'application/json, text/javascript, */*; q=0.01') + self._dumps_kwargs = dumps_kwargs + + def replace(self, *args, **kwargs): + body_passed = kwargs.get('body', None) is not None + data = kwargs.pop('data', None) + data_passed = data is not None + + if body_passed and data_passed: + warnings.warn('Both body and data passed. data will be ignored') + + elif not body_passed and data_passed: + kwargs['body'] = self.dump(data, **self._dumps_kwargs) + + return super(JSONRequest, self).replace(*args, **kwargs) + + def dump(self, data, **kwargs): + """Convert to JSON """ + return json.dumps(data, sort_keys=True, **kwargs) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 5eb655c12..6dcfa25da 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -14,6 +14,8 @@ if six.PY3: from scrapy.http import Request, FormRequest, XmlRpcRequest, JSONRequest, Headers, HtmlResponse from scrapy.utils.python import to_bytes, to_native_str +from tests import mock + class RequestTest(unittest.TestCase): @@ -1161,24 +1163,49 @@ class JSONRequestTest(RequestTest): def test_data(self): r1 = self.request_class(url="http://www.example.com/") self.assertEqual(r1.body, b'') - self.assertEqual(r1.method, 'GET') body = b'body' r2 = self.request_class(url="http://www.example.com/", body=body) self.assertEqual(r2.body, body) - self.assertEqual(r2.method, 'GET') data = { 'name': 'value', } r3 = self.request_class(url="http://www.example.com/", data=data) self.assertEqual(r3.body, to_bytes(json.dumps(data))) - self.assertEqual(r3.method, 'POST') + # empty data r4 = self.request_class(url="http://www.example.com/", data=[]) self.assertEqual(r4.body, to_bytes(json.dumps([]))) - self.assertEqual(r4.method, 'POST') + def test_data_method(self): + # data is not passed + r1 = self.request_class(url="http://www.example.com/") + self.assertEqual(r1.method, 'GET') + + body = b'body' + r2 = self.request_class(url="http://www.example.com/", body=body) + self.assertEqual(r2.method, 'GET') + + data = { + 'name': 'value', + } + r3 = self.request_class(url="http://www.example.com/", data=data) + self.assertEqual(r3.method, 'POST') + + # method passed explicitly + r4 = self.request_class(url="http://www.example.com/", data=data, method='GET') + self.assertEqual(r4.method, 'GET') + + r5 = self.request_class(url="http://www.example.com/", data=[]) + self.assertEqual(r5.method, 'POST') + + def test_body_data(self): + """ passing both body and data should result a warning """ + body = b'body' + data = { + 'name': 'value', + } with warnings.catch_warnings(record=True) as _warnings: r5 = self.request_class(url="http://www.example.com/", body=body, data=data) self.assertEqual(r5.body, body) @@ -1186,6 +1213,11 @@ class JSONRequestTest(RequestTest): self.assertEqual(len(_warnings), 1) self.assertIn('data will be ignored', str(_warnings[0].message)) + def test_empty_body_data(self): + """ passing any body value and data should result a warning """ + data = { + 'name': 'value', + } with warnings.catch_warnings(record=True) as _warnings: r6 = self.request_class(url="http://www.example.com/", body=b'', data=data) self.assertEqual(r6.body, b'') @@ -1193,17 +1225,91 @@ class JSONRequestTest(RequestTest): self.assertEqual(len(_warnings), 1) self.assertIn('data will be ignored', str(_warnings[0].message)) + def test_body_none_data(self): + data = { + 'name': 'value', + } with warnings.catch_warnings(record=True) as _warnings: r7 = self.request_class(url="http://www.example.com/", body=None, data=data) self.assertEqual(r7.body, to_bytes(json.dumps(data))) self.assertEqual(r7.method, 'POST') self.assertEqual(len(_warnings), 0) + def test_body_data_none(self): with warnings.catch_warnings(record=True) as _warnings: r8 = self.request_class(url="http://www.example.com/", body=None, data=None) self.assertEqual(r8.method, 'GET') self.assertEqual(len(_warnings), 0) + def test_dumps_sort_keys(self): + """ Test that sort_keys=True is passed to json.dumps by default """ + data = { + 'name': 'value', + } + with mock.patch('json.dumps', return_value=b'') as mock_dumps: + self.request_class(url="http://www.example.com/", data=data) + kwargs = mock_dumps.call_args[1] + self.assertEqual(kwargs['sort_keys'], True) + + def test_dumps_kwargs(self): + """ Test that dumps_kwargs are passed to json.dumps """ + data = { + 'name': 'value', + } + dumps_kwargs = { + 'ensure_ascii': True, + 'allow_nan': True, + } + with mock.patch('json.dumps', return_value=b'') as mock_dumps: + self.request_class(url="http://www.example.com/", data=data, dumps_kwargs=dumps_kwargs) + kwargs = mock_dumps.call_args[1] + self.assertEqual(kwargs['ensure_ascii'], True) + self.assertEqual(kwargs['allow_nan'], True) + + def test_replace_data(self): + data1 = { + 'name1': 'value1', + } + data2 = { + 'name2': 'value2', + } + r1 = self.request_class(url="http://www.example.com/", data=data1) + r2 = r1.replace(data=data2) + self.assertEqual(r2.body, to_bytes(json.dumps(data2))) + + def test_replace_sort_keys(self): + """ Test that replace provides sort_keys=True to json.dumps """ + data1 = { + 'name1': 'value1', + } + data2 = { + 'name2': 'value2', + } + r1 = self.request_class(url="http://www.example.com/", data=data1) + with mock.patch('json.dumps', return_value=b'') as mock_dumps: + r1.replace(data=data2) + kwargs = mock_dumps.call_args[1] + self.assertEqual(kwargs['sort_keys'], True) + + def test_replace_dumps_kwargs(self): + """ Test that dumps_kwargs are provided json.dumps when replace is called """ + data1 = { + 'name1': 'value1', + } + data2 = { + 'name2': 'value2', + } + dumps_kwargs = { + 'ensure_ascii': True, + 'allow_nan': True, + } + r1 = self.request_class(url="http://www.example.com/", data=data1, dumps_kwargs=dumps_kwargs) + with mock.patch('json.dumps', return_value=b'') as mock_dumps: + r1.replace(data=data2) + kwargs = mock_dumps.call_args[1] + self.assertEqual(kwargs['ensure_ascii'], True) + self.assertEqual(kwargs['allow_nan'], True) + def tearDown(self): warnings.resetwarnings() super(JSONRequestTest, self).tearDown() From 12ad06b7ac57dd022a4add16259ee8fd64d5ede2 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Mon, 17 Dec 2018 23:17:13 +0530 Subject: [PATCH 047/503] docs change --- docs/topics/request-response.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 4e6f00bb0..6758269b1 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -529,8 +529,8 @@ dealing with JSON requests. set to ``'POST'`` automatically. :type data: JSON serializable object - :param dumps_kwargs: Parameters that will be passed to underlying `json.dumps`_ method which is used to serialize data - into JSON format. + :param dumps_kwargs: Parameters that will be passed to underlying `json.dumps`_ method which is used to serialize + data into JSON format. :type dumps_kwargs: dict .. _json.dumps: https://docs.python.org/3/library/json.html#json.dumps From 24acc50d1894b6566e427f1dfea14e2aa647077e Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Tue, 18 Dec 2018 23:16:14 +0530 Subject: [PATCH 048/503] dumps_kwargs parameter in docs --- docs/topics/request-response.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 6758269b1..37b73edd1 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -514,7 +514,7 @@ JSONRequest The JSONRequest class extends the base :class:`Request` class with functionality for dealing with JSON requests. -.. class:: JSONRequest(url, [... data]) +.. class:: JSONRequest(url, [... data, dumps_kwargs]) The :class:`JSONRequest` class adds two new argument to the constructor. The remaining arguments are the same as for the :class:`Request` class and are From 6c78b3d5ef94791b11c2ce3dfd5cebd757a68b2a Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 3 Jan 2019 13:15:58 -0300 Subject: [PATCH 049/503] Deques can't be sliced, use itertools.islice instead --- scrapy/core/spidermw.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 58bd7c2c8..e07f76bdf 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -3,7 +3,7 @@ Spider Middleware manager See documentation in docs/topics/spider-middleware.rst """ -from itertools import chain +from itertools import chain, islice import six from twisted.python.failure import Failure @@ -58,7 +58,7 @@ class SpiderMiddlewareManager(MiddlewareManager): # don't handle _InvalidOutput exception if isinstance(exception, _InvalidOutput): return _failure - method_list = self.methods['process_spider_exception'][start_index:] + method_list = islice(self.methods['process_spider_exception'], start_index, None) for method_index, method in enumerate(method_list, start=start_index): if method is None: continue @@ -89,7 +89,7 @@ class SpiderMiddlewareManager(MiddlewareManager): raise recovered.extend(exception_result) - method_list = self.methods['process_spider_output'][start_index:] + method_list = islice(self.methods['process_spider_output'], start_index, None) for method_index, method in enumerate(method_list, start=start_index): if method is None: continue From 50a0d87d1e472fcc514f3dc2b028b653b7826a9c Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 3 Jan 2019 17:20:08 -0300 Subject: [PATCH 050/503] Passing keyword arguments to callbacks --- scrapy/core/scraper.py | 2 +- scrapy/http/request/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index ee1e95a0c..7981ce231 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -143,7 +143,7 @@ class Scraper(object): def call_spider(self, result, request, spider): result.request = request dfd = defer_result(result) - dfd.addCallbacks(request.callback or spider.parse, request.errback) + dfd.addCallbacks(request.callback or spider.parse, request.errback, callbackKeywords=request.kwargs) return dfd.addCallback(iterate_spider_output) def handle_spider_error(self, _failure, request, response, spider): diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index cd4360483..7d5cc9dae 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -18,7 +18,7 @@ class Request(object_ref): def __init__(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding='utf-8', priority=0, - dont_filter=False, errback=None, flags=None): + dont_filter=False, errback=None, flags=None, kwargs=None): self._encoding = encoding # this one has to be set first self.method = str(method).upper() @@ -41,6 +41,7 @@ class Request(object_ref): self._meta = dict(meta) if meta else None self.flags = [] if flags is None else list(flags) + self.kwargs = dict(kwargs) if kwargs else None @property def meta(self): From a2b509a42266a2ab3389de64b608e616f88f77e5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 3 Jan 2019 17:38:06 -0300 Subject: [PATCH 051/503] Pass callback kwargs with response.follow --- scrapy/http/response/__init__.py | 5 +++-- scrapy/http/response/text.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 1974259b5..99b04a26e 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -106,7 +106,7 @@ class Response(object_ref): def follow(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding='utf-8', priority=0, - dont_filter=False, errback=None): + dont_filter=False, errback=None, kwargs=None): # type: (...) -> Request """ Return a :class:`~.Request` instance to follow a link ``url``. @@ -132,4 +132,5 @@ class Response(object_ref): encoding=encoding, priority=priority, dont_filter=dont_filter, - errback=errback) + errback=errback, + kwargs=kwargs) diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 74a042f2c..2039621b3 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -123,7 +123,7 @@ class TextResponse(Response): def follow(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding=None, priority=0, - dont_filter=False, errback=None): + dont_filter=False, errback=None, kwargs=None): # type: (...) -> Request """ Return a :class:`~.Request` instance to follow a link ``url``. @@ -154,7 +154,8 @@ class TextResponse(Response): encoding=encoding, priority=priority, dont_filter=dont_filter, - errback=errback + errback=errback, + kwargs=kwargs, ) From 69a1ee79aa43bb1444e5b2a800a2e1702db6866e Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 3 Jan 2019 17:38:29 -0300 Subject: [PATCH 052/503] Copy request.kwargs --- scrapy/http/request/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index 7d5cc9dae..9a155f415 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -93,7 +93,7 @@ class Request(object_ref): given new values. """ for x in ['url', 'method', 'headers', 'body', 'cookies', 'meta', 'flags', - 'encoding', 'priority', 'dont_filter', 'callback', 'errback']: + 'encoding', 'priority', 'dont_filter', 'callback', 'errback', 'kwargs']: kwargs.setdefault(x, getattr(self, x)) cls = kwargs.pop('cls', self.__class__) return cls(*args, **kwargs) From a67f1ce512ffa111a400e2718ebbf3e7bd32d0ae Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 3 Jan 2019 17:49:41 -0300 Subject: [PATCH 053/503] Serialize Request kwargs --- scrapy/utils/reqser.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index 959dddbd5..d537057b1 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -32,7 +32,8 @@ def request_to_dict(request, spider=None): '_encoding': request._encoding, 'priority': request.priority, 'dont_filter': request.dont_filter, - 'flags': request.flags + 'flags': request.flags, + 'kwargs': request.kwargs, } if type(request) is not Request: d['_class'] = request.__module__ + '.' + request.__class__.__name__ @@ -64,7 +65,9 @@ def request_from_dict(d, spider=None): encoding=d['_encoding'], priority=d['priority'], dont_filter=d['dont_filter'], - flags=d.get('flags')) + flags=d.get('flags'), + kwargs=d.get('kwargs'), + ) def _find_method(obj, func): From 770a501fb32b7582acaa5900ac2f41ea46a321cd Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 9 Jan 2019 10:40:03 -0300 Subject: [PATCH 054/503] Test request kwargs (copy, serialization) --- scrapy/http/request/__init__.py | 8 +++++++- tests/test_http_request.py | 5 +++++ tests/test_utils_reqser.py | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index 9a155f415..c016eb727 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -40,8 +40,14 @@ class Request(object_ref): self.dont_filter = dont_filter self._meta = dict(meta) if meta else None + self._kwargs = dict(kwargs) if kwargs else None self.flags = [] if flags is None else list(flags) - self.kwargs = dict(kwargs) if kwargs else None + + @property + def kwargs(self): + if self._kwargs is None: + self._kwargs = {} + return self._kwargs @property def meta(self): diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 58326a384..610893d8a 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -177,6 +177,7 @@ class RequestTest(unittest.TestCase): r1 = self.request_class("http://www.example.com", flags=['f1', 'f2'], callback=somecallback, errback=somecallback) r1.meta['foo'] = 'bar' + r1.kwargs['key'] = 'value' r2 = r1.copy() # make sure copy does not propagate callbacks @@ -189,6 +190,10 @@ class RequestTest(unittest.TestCase): assert r1.flags is not r2.flags, "flags must be a shallow copy, not identical" self.assertEqual(r1.flags, r2.flags) + # make sure kwargs dict is shallow copied + assert r1.kwargs is not r2.kwargs, "kwargs must be a shallow copy, not identical" + self.assertEqual(r1.kwargs, r2.kwargs) + # make sure meta dict is shallow copied assert r1.meta is not r2.meta, "meta must be a shallow copy, not identical" self.assertEqual(r1.meta, r2.meta) diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index dcc070b8f..76de20f22 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -26,6 +26,7 @@ class RequestSerializationTest(unittest.TestCase): encoding='latin-1', priority=20, meta={'a': 'b'}, + kwargs={'k': 'v'}, flags=['testFlag']) self._assert_serializes_ok(r, spider=self.spider) @@ -52,6 +53,7 @@ class RequestSerializationTest(unittest.TestCase): self.assertEqual(r1.headers, r2.headers) self.assertEqual(r1.cookies, r2.cookies) self.assertEqual(r1.meta, r2.meta) + self.assertEqual(r1.kwargs, r2.kwargs) self.assertEqual(r1._encoding, r2._encoding) self.assertEqual(r1.priority, r2.priority) self.assertEqual(r1.dont_filter, r2.dont_filter) From 57e7c769779b9d37058d1d8839215a9c269b8c5b Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 9 Jan 2019 10:40:44 -0300 Subject: [PATCH 055/503] Test callback kwargs --- tests/spiders.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_crawl.py | 8 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/spiders.py b/tests/spiders.py index 7816bf7c7..5a1471072 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -28,6 +28,40 @@ class MetaSpider(MockServerSpider): self.meta['close_reason'] = reason +class KeywordArgumentsSpider(MockServerSpider): + + name = 'kwargs' + checks = set() + + def start_requests(self): + data = {'key': 'value', 'number': 123} + yield Request(self.mockserver.url('/first'), self.parse_first, kwargs=data) + yield Request(self.mockserver.url('/general_with'), self.parse_general, kwargs=data) + yield Request(self.mockserver.url('/general_without'), self.parse_general) + yield Request(self.mockserver.url('/no_kwargs'), self.parse_no_kwargs) + + def parse_first(self, response, key, number): + self.checks.add(key == 'value') + self.checks.add(number == 123) + yield response.follow( + self.mockserver.url('/two'), + self.parse_second, + kwargs={'new_key': 'new_value'}) + + def parse_second(self, response, new_key): + self.checks.add(new_key == 'new_value') + + def parse_general(self, response, **kwargs): + if response.url.endswith('/general_with'): + self.checks.add(kwargs['key'] == 'value') + self.checks.add(kwargs['number'] == 123) + elif response.url.endswith('/general_without'): + self.checks.add(kwargs == {}) + + def parse_no_kwargs(self, response): + pass + + class FollowAllSpider(MetaSpider): name = 'follow' diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 3fc13eeb7..3879a017c 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -8,7 +8,7 @@ from twisted.trial.unittest import TestCase from scrapy.http import Request from scrapy.crawler import CrawlerRunner from scrapy.utils.python import to_unicode -from tests.spiders import FollowAllSpider, DelaySpider, SimpleSpider, \ +from tests.spiders import FollowAllSpider, DelaySpider, SimpleSpider, KeywordArgumentsSpider, \ BrokenStartRequestsSpider, SingleRequestSpider, DuplicateStartRequestsSpider from tests.mockserver import MockServer @@ -23,6 +23,12 @@ class CrawlTestCase(TestCase): def tearDown(self): self.mockserver.__exit__(None, None, None) + @defer.inlineCallbacks + def test_callback_kwargs(self): + crawler = self.runner.create_crawler(KeywordArgumentsSpider) + yield crawler.crawl(mockserver=self.mockserver) + self.assertEqual(crawler.spider.checks, set([True])) + @defer.inlineCallbacks def test_follow_all(self): crawler = self.runner.create_crawler(FollowAllSpider) From 3f914f6d8c369a18e1f856c01b7d1ad2a63f6e49 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Mon, 14 Jan 2019 23:03:14 +0530 Subject: [PATCH 056/503] made jsonrequest dump into private method --- scrapy/http/request/json_request.py | 15 +++++++++------ tests/test_http_request.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 7499610b9..1e2c6b0c6 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -5,6 +5,7 @@ This module implements the JSONRequest class which is a more convenient class See documentation in docs/topics/request-response.rst """ +import copy import json import warnings @@ -13,7 +14,10 @@ from scrapy.http.request import Request class JSONRequest(Request): def __init__(self, *args, **kwargs): - dumps_kwargs = kwargs.pop('dumps_kwargs', {}) + dumps_kwargs = copy.deepcopy(kwargs.pop('dumps_kwargs', {})) + dumps_kwargs['sort_keys'] = True + self._dumps_kwargs = dumps_kwargs + body_passed = kwargs.get('body', None) is not None data = kwargs.pop('data', None) data_passed = data is not None @@ -22,7 +26,7 @@ class JSONRequest(Request): warnings.warn('Both body and data passed. data will be ignored') elif not body_passed and data_passed: - kwargs['body'] = self.dump(data, **dumps_kwargs) + kwargs['body'] = self._dumps(data) if 'method' not in kwargs: kwargs['method'] = 'POST' @@ -30,7 +34,6 @@ class JSONRequest(Request): super(JSONRequest, self).__init__(*args, **kwargs) self.headers.setdefault('Content-Type', 'application/json') self.headers.setdefault('Accept', 'application/json, text/javascript, */*; q=0.01') - self._dumps_kwargs = dumps_kwargs def replace(self, *args, **kwargs): body_passed = kwargs.get('body', None) is not None @@ -41,10 +44,10 @@ class JSONRequest(Request): warnings.warn('Both body and data passed. data will be ignored') elif not body_passed and data_passed: - kwargs['body'] = self.dump(data, **self._dumps_kwargs) + kwargs['body'] = self._dumps(data) return super(JSONRequest, self).replace(*args, **kwargs) - def dump(self, data, **kwargs): + def _dumps(self, data): """Convert to JSON """ - return json.dumps(data, sort_keys=True, **kwargs) + return json.dumps(data, **self._dumps_kwargs) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 6dcfa25da..49f148016 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1292,7 +1292,7 @@ class JSONRequestTest(RequestTest): self.assertEqual(kwargs['sort_keys'], True) def test_replace_dumps_kwargs(self): - """ Test that dumps_kwargs are provided json.dumps when replace is called """ + """ Test that dumps_kwargs are provided to json.dumps when replace is called """ data1 = { 'name1': 'value1', } From bddfeaba4c17040b2986403f8b2ba25d4252e1b5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Tue, 15 Jan 2019 15:35:46 -0300 Subject: [PATCH 057/503] Add Request.kwargs docs --- docs/topics/request-response.rst | 40 +++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index e29914dbf..d12766676 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -24,7 +24,7 @@ below in :ref:`topics-request-response-ref-request-subclasses` and Request objects =============== -.. class:: Request(url[, callback, method='GET', headers, body, cookies, meta, encoding='utf-8', priority=0, dont_filter=False, errback, flags]) +.. class:: Request(url[, callback, method='GET', headers, body, cookies, meta, encoding='utf-8', priority=0, dont_filter=False, errback, flags, kwargs]) A :class:`Request` object represents an HTTP request, which is usually generated in the Spider and executed by the Downloader, and thus generating @@ -126,6 +126,9 @@ Request objects :param flags: Flags sent to the request, can be used for logging or similar purposes. :type flags: list + :param kwargs: A dict with arbitrary data that will be passed as keyword arguments to the Request's callback. + :type kwargs: dict + .. attribute:: Request.url A string containing the URL of this request. Keep in mind that this @@ -165,6 +168,17 @@ Request objects ``copy()`` or ``replace()`` methods, and can also be accessed, in your spider, from the ``response.meta`` attribute. + .. attribute:: Request.kwargs + + A dictionary that contains arbitrary metadata for this request. Its contents + will be passed to the Request's callback as keyword arguments. It is empty + for new Requests, which means by default callbacks only get a :class:`Response` + object as argument. + + This dict is `shallow copied`_ when the request is cloned using the + ``copy()`` or ``replace()`` methods, and can also be accessed, in your + spider, from the ``response.kwargs`` attribute. + .. _shallow copied: https://docs.python.org/2/library/copy.html .. method:: Request.copy() @@ -200,11 +214,9 @@ Example:: self.logger.info("Visited %s", response.url) In some cases you may be interested in passing arguments to those callback -functions so you can receive the arguments later, in the second callback. You -can use the :attr:`Request.meta` attribute for that. - -Here's an example of how to pass an item using this mechanism, to populate -different fields from different pages:: +functions so you can receive the arguments later, in the second callback. +The following two examples show how to achieve this by using the +:attr:`Request.meta` and :attr:`Request.kwargs` attributes respectively:: def parse_page1(self, response): item = MyItem() @@ -219,6 +231,22 @@ different fields from different pages:: item['other_url'] = response.url yield item +:: + + def parse_page1(self, response): + item = MyItem() + item['main_url'] = response.url + request = scrapy.Request("http://www.example.com/some_page.html", + callback=self.parse_page2) + request.kwargs['item'] = item + request.kwargs['foo'] = 'bar' + yield request + + def parse_page2(self, response, item, foo): + item['other_url'] = response.url + item['foo'] = foo + yield item + .. _topics-request-response-ref-errbacks: From d9aa5391327dd34f8d840e7ce2bca1eb8583d932 Mon Sep 17 00:00:00 2001 From: kasun Herath Date: Fri, 25 Jan 2019 21:26:28 +0530 Subject: [PATCH 058/503] enabled sort keys only if not provided --- scrapy/http/request/json_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/http/request/json_request.py b/scrapy/http/request/json_request.py index 1e2c6b0c6..8f7a61a6d 100644 --- a/scrapy/http/request/json_request.py +++ b/scrapy/http/request/json_request.py @@ -15,7 +15,7 @@ from scrapy.http.request import Request class JSONRequest(Request): def __init__(self, *args, **kwargs): dumps_kwargs = copy.deepcopy(kwargs.pop('dumps_kwargs', {})) - dumps_kwargs['sort_keys'] = True + dumps_kwargs.setdefault('sort_keys', True) self._dumps_kwargs = dumps_kwargs body_passed = kwargs.get('body', None) is not None From e3e804cfb0fc05ef3fc569ec6e0af247ce504d06 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Mon, 28 Jan 2019 15:10:34 -0300 Subject: [PATCH 059/503] Styling nitpick :-) --- scrapy/core/downloader/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index a8e6f93a3..7a6a4dfac 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -41,7 +41,7 @@ class DownloaderMiddlewareManager(MiddlewareManager): (six.get_method_self(method).__class__.__name__, response.__class__.__name__)) if response: defer.returnValue(response) - defer.returnValue((yield download_func(request=request,spider=spider))) + defer.returnValue((yield download_func(request=request, spider=spider))) @defer.inlineCallbacks def process_response(response): From 013568097db04396d780d1c91d37027115af7fe2 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Tue, 29 Jan 2019 11:10:06 -0300 Subject: [PATCH 060/503] add FEED_STORAGE_S3_ACL setting --- scrapy/extensions/feedexport.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 22ebf3b3f..eb0802261 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -118,6 +118,7 @@ class S3FeedStorage(BlockingFeedStorage): self.secret_key = u.password or secret_key self.is_botocore = is_botocore() self.keyname = u.path[1:] # remove first "/" + self.policy = settings.get('FEED_STORAGE_S3_ACL', 'private') if self.is_botocore: import botocore.session session = botocore.session.get_session() @@ -137,12 +138,13 @@ class S3FeedStorage(BlockingFeedStorage): file.seek(0) if self.is_botocore: self.s3_client.put_object( - Bucket=self.bucketname, Key=self.keyname, Body=file) + Bucket=self.bucketname, Key=self.keyname, Body=file, + ACL=self.policy) else: conn = self.connect_s3(self.access_key, self.secret_key) bucket = conn.get_bucket(self.bucketname, validate=False) key = bucket.new_key(self.keyname) - key.set_contents_from_file(file) + key.set_contents_from_file(file, policy=self.policy) key.close() From ad83ffdf1f4d69ffb62b243429e7b59d0930524c Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 6 Feb 2019 18:32:46 -0200 Subject: [PATCH 061/503] refactoring --- scrapy/extensions/feedexport.py | 19 ++++++-- tests/test_feedexport.py | 84 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index eb0802261..ca30322be 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -93,7 +93,7 @@ class FileFeedStorage(object): class S3FeedStorage(BlockingFeedStorage): - def __init__(self, uri, access_key=None, secret_key=None): + def __init__(self, uri, access_key=None, secret_key=None, acl=None): # BEGIN Backwards compatibility for initialising without keys (and # without using from_crawler) no_defaults = access_key is None and secret_key is None @@ -118,7 +118,7 @@ class S3FeedStorage(BlockingFeedStorage): self.secret_key = u.password or secret_key self.is_botocore = is_botocore() self.keyname = u.path[1:] # remove first "/" - self.policy = settings.get('FEED_STORAGE_S3_ACL', 'private') + self.acl = acl if self.is_botocore: import botocore.session session = botocore.session.get_session() @@ -132,19 +132,28 @@ class S3FeedStorage(BlockingFeedStorage): @classmethod def from_crawler(cls, crawler, uri): return cls(uri, crawler.settings['AWS_ACCESS_KEY_ID'], - crawler.settings['AWS_SECRET_ACCESS_KEY']) + crawler.settings['AWS_SECRET_ACCESS_KEY'], + crawler.settings.get('FEED_STORAGE_S3_ACL')) def _store_in_thread(self, file): file.seek(0) if self.is_botocore: + kwargs = dict() + if self.acl: + kwargs.update(dict(ACL=self.acl)) + self.s3_client.put_object( Bucket=self.bucketname, Key=self.keyname, Body=file, - ACL=self.policy) + **kwargs) else: conn = self.connect_s3(self.access_key, self.secret_key) bucket = conn.get_bucket(self.bucketname, validate=False) key = bucket.new_key(self.keyname) - key.set_contents_from_file(file, policy=self.policy) + kwargs = dict() + if self.acl: + kwargs.update(dict(policy=self.acl)) + + key.set_contents_from_file(file, **kwargs) key.close() diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index e46c8c14e..b07635cb0 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -18,6 +18,7 @@ from tests import mock from tests.mockserver import MockServer from w3lib.url import path_to_file_uri +import botocore.client import scrapy from scrapy.exporters import CsvItemExporter from scrapy.extensions.feedexport import ( @@ -186,6 +187,89 @@ class S3FeedStorageTest(unittest.TestCase): content = get_s3_content_and_delete(u.hostname, u.path[1:]) self.assertEqual(content, expected_content) + def test_init_without_acl(self): + storage = S3FeedStorage( + 's3://mybucket/export.csv', + 'access_key', + 'secret_key' + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, None) + + def test_init_with_acl(self): + storage = S3FeedStorage( + 's3://mybucket/export.csv', + 'access_key', + 'secret_key', + 'custom-acl' + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, 'custom-acl') + + def test_from_crawler_without_acl(self): + settings = { + 'AWS_ACCESS_KEY_ID': 'access_key', + 'AWS_SECRET_ACCESS_KEY': 'secret_key', + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler( + crawler, + 's3://mybucket/export.csv' + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, None) + + def test_from_crawler_with_acl(self): + settings = { + 'AWS_ACCESS_KEY_ID': 'access_key', + 'AWS_SECRET_ACCESS_KEY': 'secret_key', + 'FEED_STORAGE_S3_ACL': 'custom-acl', + } + crawler = get_crawler(settings_dict=settings) + storage = S3FeedStorage.from_crawler( + crawler, + 's3://mybucket/export.csv' + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, 'custom-acl') + + def test_store_in_thread_without_acl(self): + storage = S3FeedStorage( + 's3://mybucket/export.csv', + 'access_key', + 'secret_key', + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, None) + + with mock.patch('botocore.client.BaseClient._make_api_call') as _make_api_call_mock: + storage._store_in_thread(BytesIO(b'test file')) + operation_name, api_params = _make_api_call_mock.call_args[0] + self.assertEqual(operation_name, 'PutObject') + self.assertNotIn('ACL', api_params) + + def test_store_in_thread_with_acl(self): + storage = S3FeedStorage( + 's3://mybucket/export.csv', + 'access_key', + 'secret_key', + 'custom-acl' + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, 'custom-acl') + + with mock.patch('botocore.client.BaseClient._make_api_call') as _make_api_call_mock: + storage._store_in_thread(BytesIO(b'test file')) + operation_name, api_params = _make_api_call_mock.call_args[0] + self.assertEqual(operation_name, 'PutObject') + self.assertEqual(api_params.get('ACL'), 'custom-acl') + class StdoutFeedStorageTest(unittest.TestCase): From 126207fb7bca21d3d95ed9c66028e82771180370 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 6 Feb 2019 18:38:17 -0200 Subject: [PATCH 062/503] PEP8: use short name for mock method --- tests/test_feedexport.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index b07635cb0..bfac06efc 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -247,9 +247,9 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, None) - with mock.patch('botocore.client.BaseClient._make_api_call') as _make_api_call_mock: + with mock.patch('botocore.client.BaseClient._make_api_call') as m: storage._store_in_thread(BytesIO(b'test file')) - operation_name, api_params = _make_api_call_mock.call_args[0] + operation_name, api_params = m.call_args[0] self.assertEqual(operation_name, 'PutObject') self.assertNotIn('ACL', api_params) @@ -264,9 +264,9 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') - with mock.patch('botocore.client.BaseClient._make_api_call') as _make_api_call_mock: + with mock.patch('botocore.client.BaseClient._make_api_call') as m: storage._store_in_thread(BytesIO(b'test file')) - operation_name, api_params = _make_api_call_mock.call_args[0] + operation_name, api_params = m.call_args[0] self.assertEqual(operation_name, 'PutObject') self.assertEqual(api_params.get('ACL'), 'custom-acl') From e0f34be383e361c75b22da59c97dee1db189937e Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 6 Feb 2019 18:50:19 -0200 Subject: [PATCH 063/503] update docs --- docs/topics/feed-exports.rst | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index b64dbfbfd..661751ed9 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -185,6 +185,10 @@ passed through the following settings: * :setting:`AWS_ACCESS_KEY_ID` * :setting:`AWS_SECRET_ACCESS_KEY` +You can also define a custom ACL for exported objects using this setting: + + * :setting:`FEED_STORAGE_S3_ACL` + .. _topics-feed-storage-stdout: Standard output @@ -205,6 +209,7 @@ These are the settings used for configuring the feed exports: * :setting:`FEED_URI` (mandatory) * :setting:`FEED_FORMAT` * :setting:`FEED_STORAGES` + * :setting:`FEED_STORAGE_S3_ACL` * :setting:`FEED_EXPORTERS` * :setting:`FEED_STORE_EMPTY` * :setting:`FEED_EXPORT_ENCODING` @@ -302,11 +307,22 @@ Default: ``{}`` A dict containing additional feed storage backends supported by your project. The keys are URI schemes and the values are paths to storage classes. +.. setting:: FEED_STORAGE_S3_ACL + +FEED_STORAGE_S3_ACL +------------------- + +Default: ``None`` + +A string containing a custom ACL for feeds exported to Amazon S3 by your project. + +For a complete list of available values, access the `Canned ACL`_ section on Amazon S3 docs. + .. setting:: FEED_STORAGES_BASE FEED_STORAGES_BASE ------------------ - +` Default:: { @@ -366,3 +382,4 @@ format in :setting:`FEED_EXPORTERS`. E.g., to disable the built-in CSV exporter .. _Amazon S3: https://aws.amazon.com/s3/ .. _boto: https://github.com/boto/boto .. _botocore: https://github.com/boto/botocore +.. _Canned ACL: https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl From 7b83ed7c5e1fcd81baf50db3a76f10ade7aa226e Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 6 Feb 2019 18:52:24 -0200 Subject: [PATCH 064/503] remove typo --- docs/topics/feed-exports.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index 661751ed9..25979dfef 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -322,7 +322,7 @@ For a complete list of available values, access the `Canned ACL`_ section on Ama FEED_STORAGES_BASE ------------------ -` + Default:: { From e25b9a2323c169a4032ff07f912299b32de4b2e0 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 6 Feb 2019 18:52:39 -0200 Subject: [PATCH 065/503] calling it feeds instead of objects --- docs/topics/feed-exports.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index 25979dfef..dee0c3ffa 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -185,7 +185,7 @@ passed through the following settings: * :setting:`AWS_ACCESS_KEY_ID` * :setting:`AWS_SECRET_ACCESS_KEY` -You can also define a custom ACL for exported objects using this setting: +You can also define a custom ACL for exported feeds using this setting: * :setting:`FEED_STORAGE_S3_ACL` From dbeb088eea1713ac43f3d23579c36ece5f67563f Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Thu, 7 Feb 2019 09:29:16 -0200 Subject: [PATCH 066/503] trying to fix jessie testenv by adding botocore to requirements and fixing its version --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 0c0f8f7b7..f2f3e1293 100644 --- a/tox.ini +++ b/tox.ini @@ -47,6 +47,7 @@ deps = lxml==3.4.0 Twisted==14.0.2 boto==2.34.0 + botocore==1.12.89 Pillow==2.6.1 cssselect==0.9.1 zope.interface==4.1.1 From 079af889e7d010a79640e6874c3e6dc394b936ae Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Thu, 7 Feb 2019 10:42:59 -0200 Subject: [PATCH 067/503] also testing without botocore --- tests/test_feedexport.py | 53 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index bfac06efc..520ca4a8f 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -237,7 +237,7 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') - def test_store_in_thread_without_acl(self): + def test_store_in_thread_botocore_without_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -253,7 +253,7 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(operation_name, 'PutObject') self.assertNotIn('ACL', api_params) - def test_store_in_thread_with_acl(self): + def test_store_in_thread_botocore_with_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -270,6 +270,55 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(operation_name, 'PutObject') self.assertEqual(api_params.get('ACL'), 'custom-acl') + def test_store_in_thread_not_botocore_without_acl(self): + storage = S3FeedStorage( + 's3://mybucket/export.csv', + 'access_key', + 'secret_key', + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, None) + + storage.is_botocore = False + storage.connect_s3 = mock.MagicMock() + self.assertFalse(storage.is_botocore) + + storage._store_in_thread(BytesIO(b'test file')) + + conn = storage.connect_s3(*storage.connect_s3.call_args) + bucket = conn.get_bucket(*conn.get_bucket.call_args) + key = bucket.new_key(*bucket.new_key.call_args) + self.assertNotIn( + dict(policy='custom-acl'), + key.set_contents_from_file.call_args + ) + + def test_store_in_thread_not_botocore_with_acl(self): + storage = S3FeedStorage( + 's3://mybucket/export.csv', + 'access_key', + 'secret_key', + 'custom-acl' + ) + self.assertEqual(storage.access_key, 'access_key') + self.assertEqual(storage.secret_key, 'secret_key') + self.assertEqual(storage.acl, 'custom-acl') + + storage.is_botocore = False + storage.connect_s3 = mock.MagicMock() + self.assertFalse(storage.is_botocore) + + storage._store_in_thread(BytesIO(b'test file')) + + conn = storage.connect_s3(*storage.connect_s3.call_args) + bucket = conn.get_bucket(*conn.get_bucket.call_args) + key = bucket.new_key(*bucket.new_key.call_args) + self.assertIn( + dict(policy='custom-acl'), + key.set_contents_from_file.call_args + ) + class StdoutFeedStorageTest(unittest.TestCase): From ceae356e62dc2e56465a58b3d9fe00813e289dd6 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Fri, 8 Feb 2019 11:47:35 -0200 Subject: [PATCH 068/503] add FEED_STORAGE_S3_ACL to default_settings.py file --- scrapy/settings/default_settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 3734a0a58..776c5af23 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -158,6 +158,8 @@ FEED_EXPORTERS_BASE = { } FEED_EXPORT_INDENT = 0 +FEED_STORAGE_S3_ACL = None + FILES_STORE_S3_ACL = 'private' FILES_STORE_GCS_ACL = '' From cfd183a9d19563f09487af942c9a635d665a1905 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Fri, 8 Feb 2019 14:49:26 -0200 Subject: [PATCH 069/503] no need to use get here since we're defining a default value in default_settings.py --- scrapy/extensions/feedexport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index ca30322be..2b4594ad8 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -133,7 +133,7 @@ class S3FeedStorage(BlockingFeedStorage): def from_crawler(cls, crawler, uri): return cls(uri, crawler.settings['AWS_ACCESS_KEY_ID'], crawler.settings['AWS_SECRET_ACCESS_KEY'], - crawler.settings.get('FEED_STORAGE_S3_ACL')) + crawler.settings['FEED_STORAGE_S3_ACL']) def _store_in_thread(self, file): file.seek(0) From f824f5b2d17b082dac04505ba27afdfa869a11c7 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Fri, 8 Feb 2019 15:19:57 -0200 Subject: [PATCH 070/503] testing public method store instead of private method _store_in_thread need to mock deferToThread function --- tests/test_feedexport.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 520ca4a8f..e8c32ea43 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -237,7 +237,7 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') - def test_store_in_thread_botocore_without_acl(self): + def test_store_botocore_without_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -247,13 +247,19 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, None) + def _defer(f, *args, **kwargs): + return f(*args, **kwargs) + with mock.patch('botocore.client.BaseClient._make_api_call') as m: - storage._store_in_thread(BytesIO(b'test file')) + with mock.patch('twisted.internet.threads.deferToThread', + new=_defer): + storage.store(BytesIO(b'test file')) + operation_name, api_params = m.call_args[0] self.assertEqual(operation_name, 'PutObject') self.assertNotIn('ACL', api_params) - def test_store_in_thread_botocore_with_acl(self): + def test_store_botocore_with_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -264,13 +270,19 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') + def _defer(f, *args, **kwargs): + return f(*args, **kwargs) + with mock.patch('botocore.client.BaseClient._make_api_call') as m: - storage._store_in_thread(BytesIO(b'test file')) + with mock.patch('twisted.internet.threads.deferToThread', + new=_defer): + storage.store(BytesIO(b'test file')) + operation_name, api_params = m.call_args[0] self.assertEqual(operation_name, 'PutObject') self.assertEqual(api_params.get('ACL'), 'custom-acl') - def test_store_in_thread_not_botocore_without_acl(self): + def test_store_not_botocore_without_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -284,7 +296,11 @@ class S3FeedStorageTest(unittest.TestCase): storage.connect_s3 = mock.MagicMock() self.assertFalse(storage.is_botocore) - storage._store_in_thread(BytesIO(b'test file')) + def _defer(f, *args, **kwargs): + return f(*args, **kwargs) + + with mock.patch('twisted.internet.threads.deferToThread', new=_defer): + storage.store(BytesIO(b'test file')) conn = storage.connect_s3(*storage.connect_s3.call_args) bucket = conn.get_bucket(*conn.get_bucket.call_args) @@ -294,7 +310,7 @@ class S3FeedStorageTest(unittest.TestCase): key.set_contents_from_file.call_args ) - def test_store_in_thread_not_botocore_with_acl(self): + def test_store_not_botocore_with_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -309,7 +325,11 @@ class S3FeedStorageTest(unittest.TestCase): storage.connect_s3 = mock.MagicMock() self.assertFalse(storage.is_botocore) - storage._store_in_thread(BytesIO(b'test file')) + def _defer(f, *args, **kwargs): + return f(*args, **kwargs) + + with mock.patch('twisted.internet.threads.deferToThread', new=_defer): + storage.store(BytesIO(b'test file')) conn = storage.connect_s3(*storage.connect_s3.call_args) bucket = conn.get_bucket(*conn.get_bucket.call_args) From 1eac2a163c2c734594d4f1e7e026eab309b2b0b5 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Fri, 8 Feb 2019 16:50:39 -0200 Subject: [PATCH 071/503] simplifying how we deal with threads.deferToThread calls --- tests/test_feedexport.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index e8c32ea43..0f31ef00e 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -237,6 +237,7 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') + @defer.inlineCallbacks def test_store_botocore_without_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', @@ -247,18 +248,14 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, None) - def _defer(f, *args, **kwargs): - return f(*args, **kwargs) - with mock.patch('botocore.client.BaseClient._make_api_call') as m: - with mock.patch('twisted.internet.threads.deferToThread', - new=_defer): - storage.store(BytesIO(b'test file')) + yield storage.store(BytesIO(b'test file')) operation_name, api_params = m.call_args[0] self.assertEqual(operation_name, 'PutObject') self.assertNotIn('ACL', api_params) + @defer.inlineCallbacks def test_store_botocore_with_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', @@ -270,18 +267,14 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') - def _defer(f, *args, **kwargs): - return f(*args, **kwargs) - with mock.patch('botocore.client.BaseClient._make_api_call') as m: - with mock.patch('twisted.internet.threads.deferToThread', - new=_defer): - storage.store(BytesIO(b'test file')) + yield storage.store(BytesIO(b'test file')) operation_name, api_params = m.call_args[0] self.assertEqual(operation_name, 'PutObject') self.assertEqual(api_params.get('ACL'), 'custom-acl') + @defer.inlineCallbacks def test_store_not_botocore_without_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', @@ -296,11 +289,7 @@ class S3FeedStorageTest(unittest.TestCase): storage.connect_s3 = mock.MagicMock() self.assertFalse(storage.is_botocore) - def _defer(f, *args, **kwargs): - return f(*args, **kwargs) - - with mock.patch('twisted.internet.threads.deferToThread', new=_defer): - storage.store(BytesIO(b'test file')) + yield storage.store(BytesIO(b'test file')) conn = storage.connect_s3(*storage.connect_s3.call_args) bucket = conn.get_bucket(*conn.get_bucket.call_args) @@ -310,6 +299,7 @@ class S3FeedStorageTest(unittest.TestCase): key.set_contents_from_file.call_args ) + @defer.inlineCallbacks def test_store_not_botocore_with_acl(self): storage = S3FeedStorage( 's3://mybucket/export.csv', @@ -325,11 +315,7 @@ class S3FeedStorageTest(unittest.TestCase): storage.connect_s3 = mock.MagicMock() self.assertFalse(storage.is_botocore) - def _defer(f, *args, **kwargs): - return f(*args, **kwargs) - - with mock.patch('twisted.internet.threads.deferToThread', new=_defer): - storage.store(BytesIO(b'test file')) + yield storage.store(BytesIO(b'test file')) conn = storage.connect_s3(*storage.connect_s3.call_args) bucket = conn.get_bucket(*conn.get_bucket.call_args) From 03e61b9908733f085d87da6bd29152389961b81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 1 Feb 2019 13:50:01 +0100 Subject: [PATCH 072/503] Check that spidercls arguments in scrapy.crawler classes are not spider objects --- scrapy/crawler.py | 13 +++++++++++++ tests/test_crawler.py | 13 +++++++++++++ tests/test_downloadermiddleware_httpproxy.py | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 04aee18ed..ee00d27b4 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -7,6 +7,7 @@ import sys from twisted.internet import reactor, defer from zope.interface.verify import verifyClass, DoesNotImplement +from scrapy import Spider from scrapy.core.engine import ExecutionEngine from scrapy.resolver import CachingThreadedResolver from scrapy.interfaces import ISpiderLoader @@ -27,6 +28,10 @@ logger = logging.getLogger(__name__) class Crawler(object): def __init__(self, spidercls, settings=None): + if isinstance(spidercls, Spider): + raise ValueError( + 'The spidercls argument must be a class, not an object') + if isinstance(settings, dict) or settings is None: settings = Settings(settings) @@ -168,6 +173,10 @@ class CrawlerRunner(object): :param dict kwargs: keyword arguments to initialize the spider """ + if isinstance(crawler_or_spidercls, Spider): + raise ValueError( + 'The crawler_or_spidercls argument cannot be a spider object, ' + 'it must be a spider class (or a Crawler object)') crawler = self.create_crawler(crawler_or_spidercls) return self._crawl(crawler, *args, **kwargs) @@ -195,6 +204,10 @@ class CrawlerRunner(object): a spider with this name in a Scrapy project (using spider loader), then creates a Crawler instance for it. """ + if isinstance(crawler_or_spidercls, Spider): + raise ValueError( + 'The crawler_or_spidercls argument cannot be a spider object, ' + 'it must be a spider class (or a Crawler object)') if isinstance(crawler_or_spidercls, Crawler): return crawler_or_spidercls return self._create_crawler(crawler_or_spidercls) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 268948a70..37cea3ad3 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -4,6 +4,7 @@ import warnings from twisted.internet import defer from twisted.trial import unittest +from pytest import raises import scrapy from scrapy.crawler import Crawler, CrawlerRunner, CrawlerProcess @@ -66,6 +67,10 @@ class CrawlerTestCase(BaseCrawlerTest): crawler = Crawler(DefaultSpider) self.assertOptionIsDefault(crawler.settings, 'RETRY_ENABLED') + def test_crawler_rejects_spider_objects(self): + with raises(ValueError): + Crawler(DefaultSpider()) + class SpiderSettingsTestCase(unittest.TestCase): def test_spider_custom_settings(self): @@ -177,6 +182,14 @@ class CrawlerRunnerTestCase(BaseCrawlerTest): self.assertEqual(len(w), 1) self.assertIn('Please use SPIDER_LOADER_CLASS', str(w[0].message)) + def test_crawl_rejects_spider_objects(self): + with raises(ValueError): + CrawlerRunner().crawl(DefaultSpider()) + + def test_create_crawler_rejects_spider_objects(self): + with raises(ValueError): + CrawlerRunner().create_crawler(DefaultSpider()) + class CrawlerProcessTest(BaseCrawlerTest): def test_crawler_process_accepts_dict(self): diff --git a/tests/test_downloadermiddleware_httpproxy.py b/tests/test_downloadermiddleware_httpproxy.py index 537126613..30920b2da 100644 --- a/tests/test_downloadermiddleware_httpproxy.py +++ b/tests/test_downloadermiddleware_httpproxy.py @@ -25,7 +25,7 @@ class TestHttpProxyMiddleware(TestCase): def test_not_enabled(self): settings = Settings({'HTTPPROXY_ENABLED': False}) - crawler = Crawler(spider, settings) + crawler = Crawler(Spider, settings) self.assertRaises(NotConfigured, partial(HttpProxyMiddleware.from_crawler, crawler)) def test_no_environment_proxies(self): From 7c9f0bd86c5f02ea803fa6bf1242d34d9c9f47d5 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Tue, 12 Feb 2019 12:19:30 -0200 Subject: [PATCH 073/503] using named params with optional amazon s3 params --- scrapy/extensions/feedexport.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 2b4594ad8..f6bc460ea 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -131,9 +131,12 @@ class S3FeedStorage(BlockingFeedStorage): @classmethod def from_crawler(cls, crawler, uri): - return cls(uri, crawler.settings['AWS_ACCESS_KEY_ID'], - crawler.settings['AWS_SECRET_ACCESS_KEY'], - crawler.settings['FEED_STORAGE_S3_ACL']) + return cls( + uri=uri, + access_key=crawler.settings['AWS_ACCESS_KEY_ID'], + secret_key=crawler.settings['AWS_SECRET_ACCESS_KEY'], + acl=crawler.settings['FEED_STORAGE_S3_ACL'] + ) def _store_in_thread(self, file): file.seek(0) From c2dede27bd56bd783c45fb7302ca06b7c2c025c0 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Tue, 12 Feb 2019 12:22:05 -0200 Subject: [PATCH 074/503] reduce code with simple ternary operator --- scrapy/extensions/feedexport.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index f6bc460ea..40f985f19 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -141,10 +141,7 @@ class S3FeedStorage(BlockingFeedStorage): def _store_in_thread(self, file): file.seek(0) if self.is_botocore: - kwargs = dict() - if self.acl: - kwargs.update(dict(ACL=self.acl)) - + kwargs = {'ACL': self.acl} if self.acl else {} self.s3_client.put_object( Bucket=self.bucketname, Key=self.keyname, Body=file, **kwargs) @@ -152,10 +149,7 @@ class S3FeedStorage(BlockingFeedStorage): conn = self.connect_s3(self.access_key, self.secret_key) bucket = conn.get_bucket(self.bucketname, validate=False) key = bucket.new_key(self.keyname) - kwargs = dict() - if self.acl: - kwargs.update(dict(policy=self.acl)) - + kwargs = {'policy': self.acl} if self.acl else {} key.set_contents_from_file(file, **kwargs) key.close() From 984e706fd2e06457bcdd1226366d08950ac101b0 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Tue, 12 Feb 2019 12:26:57 -0200 Subject: [PATCH 075/503] using blank string instead of None as default value as proposed by @kmike --- docs/topics/feed-exports.rst | 2 +- scrapy/extensions/feedexport.py | 2 +- scrapy/settings/default_settings.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/topics/feed-exports.rst b/docs/topics/feed-exports.rst index dee0c3ffa..cf70b8aca 100644 --- a/docs/topics/feed-exports.rst +++ b/docs/topics/feed-exports.rst @@ -312,7 +312,7 @@ The keys are URI schemes and the values are paths to storage classes. FEED_STORAGE_S3_ACL ------------------- -Default: ``None`` +Default: ``''`` (empty string) A string containing a custom ACL for feeds exported to Amazon S3 by your project. diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 40f985f19..975fa1229 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -135,7 +135,7 @@ class S3FeedStorage(BlockingFeedStorage): uri=uri, access_key=crawler.settings['AWS_ACCESS_KEY_ID'], secret_key=crawler.settings['AWS_SECRET_ACCESS_KEY'], - acl=crawler.settings['FEED_STORAGE_S3_ACL'] + acl=crawler.settings['FEED_STORAGE_S3_ACL'] or None ) def _store_in_thread(self, file): diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 776c5af23..a800d39ab 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -158,7 +158,7 @@ FEED_EXPORTERS_BASE = { } FEED_EXPORT_INDENT = 0 -FEED_STORAGE_S3_ACL = None +FEED_STORAGE_S3_ACL = '' FILES_STORE_S3_ACL = 'private' FILES_STORE_GCS_ACL = '' From 50bf4c60c480a276651ff703cf9fed8e7f981d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 13 Feb 2019 17:39:20 +0100 Subject: [PATCH 076/503] Document that the main entry point of downloader and spider middlewares is from_crawler() --- docs/topics/downloader-middleware.rst | 8 ++++++-- docs/topics/spider-middleware.rst | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 8dbe249fa..18a0639ce 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -55,8 +55,12 @@ particular setting. See each middleware documentation for more info. Writing your own downloader middleware ====================================== -Each middleware component is a Python class that defines one or -more of the following methods: +Each downloader middleware is a Python class that defines one or more of the +methods defined below. + +The main entry point is the ``from_crawler`` class method, which receives a +:class:`~scrapy.crawler.Crawler` instance. The :class:`~scrapy.crawler.Crawler` +object gives you access, for example, to the :ref:`settings `. .. module:: scrapy.downloadermiddlewares diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 2b7e42771..62b5ca0e8 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -57,8 +57,12 @@ particular setting. See each middleware documentation for more info. Writing your own spider middleware ================================== -Each middleware component is a Python class that defines one or more of the -following methods: +Each spider middleware is a Python class that defines one or more of the +methods defined below. + +The main entry point is the ``from_crawler`` class method, which receives a +:class:`~scrapy.crawler.Crawler` instance. The :class:`~scrapy.crawler.Crawler` +object gives you access, for example, to the :ref:`settings `. .. module:: scrapy.spidermiddlewares From b4d132b9f0824263d83331d0b36870f6f64918e4 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 13 Feb 2019 19:21:14 -0200 Subject: [PATCH 077/503] setting botocore version as described in debian jessie website https://packages.debian.org/en/jessie/python-botocore --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f2f3e1293..584da2dcd 100644 --- a/tox.ini +++ b/tox.ini @@ -47,7 +47,7 @@ deps = lxml==3.4.0 Twisted==14.0.2 boto==2.34.0 - botocore==1.12.89 + botocore==0.62 Pillow==2.6.1 cssselect==0.9.1 zope.interface==4.1.1 From dc0b643832e9f3400e432c2ef7a34e6c75ac8366 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 13 Feb 2019 19:44:50 -0200 Subject: [PATCH 078/503] refactoring tests to avoid mocking private method --- tests/test_feedexport.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 0f31ef00e..c103593f9 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -248,12 +248,9 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, None) - with mock.patch('botocore.client.BaseClient._make_api_call') as m: - yield storage.store(BytesIO(b'test file')) - - operation_name, api_params = m.call_args[0] - self.assertEqual(operation_name, 'PutObject') - self.assertNotIn('ACL', api_params) + storage.s3_client = mock.MagicMock() + yield storage.store(BytesIO(b'test file')) + self.assertNotIn('ACL', storage.s3_client.put_object.call_args[1]) @defer.inlineCallbacks def test_store_botocore_with_acl(self): @@ -267,12 +264,12 @@ class S3FeedStorageTest(unittest.TestCase): self.assertEqual(storage.secret_key, 'secret_key') self.assertEqual(storage.acl, 'custom-acl') - with mock.patch('botocore.client.BaseClient._make_api_call') as m: - yield storage.store(BytesIO(b'test file')) - - operation_name, api_params = m.call_args[0] - self.assertEqual(operation_name, 'PutObject') - self.assertEqual(api_params.get('ACL'), 'custom-acl') + storage.s3_client = mock.MagicMock() + yield storage.store(BytesIO(b'test file')) + self.assertEqual( + storage.s3_client.put_object.call_args[1].get('ACL'), + 'custom-acl' + ) @defer.inlineCallbacks def test_store_not_botocore_without_acl(self): From ea8be627d15aa6fe1beaf50fff666cbeb161d94d Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Wed, 13 Feb 2019 19:53:10 -0200 Subject: [PATCH 079/503] botocore is not supported on debian jessie --- tests/test_feedexport.py | 7 ++++++- tox.ini | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index c103593f9..2bf57e278 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -18,7 +18,6 @@ from tests import mock from tests.mockserver import MockServer from w3lib.url import path_to_file_uri -import botocore.client import scrapy from scrapy.exporters import CsvItemExporter from scrapy.extensions.feedexport import ( @@ -239,6 +238,9 @@ class S3FeedStorageTest(unittest.TestCase): @defer.inlineCallbacks def test_store_botocore_without_acl(self): + if os.getenv('TOX_ENV_NAME') == 'jessie': + raise unittest.SkipTest('botocore is not supported on jessie') + storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', @@ -254,6 +256,9 @@ class S3FeedStorageTest(unittest.TestCase): @defer.inlineCallbacks def test_store_botocore_with_acl(self): + if os.getenv('TOX_ENV_NAME') == 'jessie': + raise unittest.SkipTest('botocore is not supported on jessie') + storage = S3FeedStorage( 's3://mybucket/export.csv', 'access_key', diff --git a/tox.ini b/tox.ini index 584da2dcd..0c0f8f7b7 100644 --- a/tox.ini +++ b/tox.ini @@ -47,7 +47,6 @@ deps = lxml==3.4.0 Twisted==14.0.2 boto==2.34.0 - botocore==0.62 Pillow==2.6.1 cssselect==0.9.1 zope.interface==4.1.1 From 9b8ba4c383df0f3029d1b07ab9647a7d902600f4 Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Thu, 14 Feb 2019 16:20:56 -0200 Subject: [PATCH 080/503] try to import botocore before runing some tests --- tests/test_feedexport.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index 2bf57e278..3ff79c912 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -238,8 +238,10 @@ class S3FeedStorageTest(unittest.TestCase): @defer.inlineCallbacks def test_store_botocore_without_acl(self): - if os.getenv('TOX_ENV_NAME') == 'jessie': - raise unittest.SkipTest('botocore is not supported on jessie') + try: + import botocore + except ImportError: + raise unittest.SkipTest('botocore is required') storage = S3FeedStorage( 's3://mybucket/export.csv', @@ -256,8 +258,10 @@ class S3FeedStorageTest(unittest.TestCase): @defer.inlineCallbacks def test_store_botocore_with_acl(self): - if os.getenv('TOX_ENV_NAME') == 'jessie': - raise unittest.SkipTest('botocore is not supported on jessie') + try: + import botocore + except ImportError: + raise unittest.SkipTest('botocore is required') storage = S3FeedStorage( 's3://mybucket/export.csv', From b02d26fae8892775ad6ef306d80b02e6bc69d12e Mon Sep 17 00:00:00 2001 From: John de la Garza Date: Fri, 15 Feb 2019 16:54:19 -0800 Subject: [PATCH 082/503] rel_has_nofollow: remove redundant if statement --- scrapy/utils/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 5ccfdcd72..6de36d45c 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -116,7 +116,7 @@ def md5sum(file): def rel_has_nofollow(rel): """Return True if link rel attribute has nofollow type""" - return True if rel is not None and 'nofollow' in rel.split() else False + return rel is not None and 'nofollow' in rel.split() def create_instance(objcls, settings, crawler, *args, **kwargs): From e3b15252c80ca3d0872f3068c382a0a3e7cc9db6 Mon Sep 17 00:00:00 2001 From: Matthieu Grandrie Date: Thu, 21 Feb 2019 17:19:58 +0100 Subject: [PATCH 083/503] New constructor arg *restrict_text* for FilteringLinkExtractor. Same as allow and deny args, it holds a string, a regex or an iterable of. Links whose text don't match one of the regex are filtered out. DOC restrict_text in LxmlLinkExtractor --- docs/topics/link-extractors.rst | 6 ++++++ scrapy/linkextractors/__init__.py | 6 +++++- scrapy/linkextractors/lxmlhtml.py | 9 +++++---- scrapy/linkextractors/sgml.py | 13 +++++++------ tests/test_linkextractors.py | 24 ++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/topics/link-extractors.rst b/docs/topics/link-extractors.rst index f40a36d31..713a94e10 100644 --- a/docs/topics/link-extractors.rst +++ b/docs/topics/link-extractors.rst @@ -93,6 +93,12 @@ LxmlLinkExtractor Has the same behaviour as ``restrict_xpaths``. :type restrict_css: str or list + :param restrict_text: a single regular expression (or list of regular expressions) + that the link's text must match in order to be extracted. If not + given (or empty), it will match all links. If a list of regular expressions is + given, the link will be extracted if it matches at least one. + :type restrict_text: a regular expression (or list of) + :param tags: a tag or a list of tags to consider when extracting links. Defaults to ``('a', 'area')``. :type tags: str or list diff --git a/scrapy/linkextractors/__init__.py b/scrapy/linkextractors/__init__.py index 97e8c0af1..ebf3cd7d8 100644 --- a/scrapy/linkextractors/__init__.py +++ b/scrapy/linkextractors/__init__.py @@ -50,7 +50,7 @@ class FilteringLinkExtractor(object): _csstranslator = HTMLTranslator() def __init__(self, link_extractor, allow, deny, allow_domains, deny_domains, - restrict_xpaths, canonicalize, deny_extensions, restrict_css): + restrict_xpaths, canonicalize, deny_extensions, restrict_css, restrict_text): self.link_extractor = link_extractor @@ -70,6 +70,8 @@ class FilteringLinkExtractor(object): if deny_extensions is None: deny_extensions = IGNORED_EXTENSIONS self.deny_extensions = {'.' + e for e in arg_to_iter(deny_extensions)} + self.restrict_text = [x if isinstance(x, _re_type) else re.compile(x) + for x in arg_to_iter(restrict_text)] def _link_allowed(self, link): if not _is_valid_url(link.url): @@ -85,6 +87,8 @@ class FilteringLinkExtractor(object): return False if self.deny_extensions and url_has_any_extension(parsed_url, self.deny_extensions): return False + if self.restrict_text and not _matches(link.text, self.restrict_text): + return False return True def matches(self, url): diff --git a/scrapy/linkextractors/lxmlhtml.py b/scrapy/linkextractors/lxmlhtml.py index a7092f9b8..8f6f93a44 100644 --- a/scrapy/linkextractors/lxmlhtml.py +++ b/scrapy/linkextractors/lxmlhtml.py @@ -97,7 +97,7 @@ class LxmlLinkExtractor(FilteringLinkExtractor): def __init__(self, allow=(), deny=(), allow_domains=(), deny_domains=(), restrict_xpaths=(), tags=('a', 'area'), attrs=('href',), canonicalize=False, unique=True, process_value=None, deny_extensions=None, restrict_css=(), - strip=True): + strip=True, restrict_text=None): tags, attrs = set(arg_to_iter(tags)), set(arg_to_iter(attrs)) tag_func = lambda x: x in tags attr_func = lambda x: x in attrs @@ -111,9 +111,10 @@ class LxmlLinkExtractor(FilteringLinkExtractor): ) super(LxmlLinkExtractor, self).__init__(lx, allow=allow, deny=deny, - allow_domains=allow_domains, deny_domains=deny_domains, - restrict_xpaths=restrict_xpaths, restrict_css=restrict_css, - canonicalize=canonicalize, deny_extensions=deny_extensions) + allow_domains=allow_domains, deny_domains=deny_domains, + restrict_xpaths=restrict_xpaths, restrict_css=restrict_css, + canonicalize=canonicalize, deny_extensions=deny_extensions, + restrict_text=restrict_text) def extract_links(self, response): base_url = get_base_url(response) diff --git a/scrapy/linkextractors/sgml.py b/scrapy/linkextractors/sgml.py index 5fa6b771c..8940a4d77 100644 --- a/scrapy/linkextractors/sgml.py +++ b/scrapy/linkextractors/sgml.py @@ -113,7 +113,7 @@ class SgmlLinkExtractor(FilteringLinkExtractor): def __init__(self, allow=(), deny=(), allow_domains=(), deny_domains=(), restrict_xpaths=(), tags=('a', 'area'), attrs=('href',), canonicalize=False, unique=True, process_value=None, deny_extensions=None, restrict_css=(), - strip=True): + strip=True, restrict_text=()): warnings.warn( "SgmlLinkExtractor is deprecated and will be removed in future releases. " "Please use scrapy.linkextractors.LinkExtractor", @@ -127,13 +127,14 @@ class SgmlLinkExtractor(FilteringLinkExtractor): with warnings.catch_warnings(): warnings.simplefilter('ignore', ScrapyDeprecationWarning) lx = BaseSgmlLinkExtractor(tag=tag_func, attr=attr_func, - unique=unique, process_value=process_value, strip=strip, - canonicalized=canonicalize) + unique=unique, process_value=process_value, strip=strip, + canonicalized=canonicalize) super(SgmlLinkExtractor, self).__init__(lx, allow=allow, deny=deny, - allow_domains=allow_domains, deny_domains=deny_domains, - restrict_xpaths=restrict_xpaths, restrict_css=restrict_css, - canonicalize=canonicalize, deny_extensions=deny_extensions) + allow_domains=allow_domains, deny_domains=deny_domains, + restrict_xpaths=restrict_xpaths, restrict_css=restrict_css, + canonicalize=canonicalize, deny_extensions=deny_extensions, + restrict_text=restrict_text) def extract_links(self, response): base_url = None diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py index 903032b52..c9cd629f4 100644 --- a/tests/test_linkextractors.py +++ b/tests/test_linkextractors.py @@ -479,6 +479,30 @@ class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase): Link(url='http://example.org/item3.html', text=u'Item 3', nofollow=False), ]) + def test_link_restrict_text(self): + html = b""" + Pic of a cat + Pic of a dog + Pic of a cow + """ + response = HtmlResponse("http://example.org/index.html", body=html) + # Simple text inclusion test + lx = self.extractor_cls(restrict_text='dog') + self.assertEqual([link for link in lx.extract_links(response)], [ + Link(url='http://example.org/item2.html', text=u'Pic of a dog', nofollow=False), + ]) + # Unique regex test + lx = self.extractor_cls(restrict_text=r'of.*dog') + self.assertEqual([link for link in lx.extract_links(response)], [ + Link(url='http://example.org/item2.html', text=u'Pic of a dog', nofollow=False), + ]) + # Multiple regex test + lx = self.extractor_cls(restrict_text=[r'of.*dog', r'of.*cat']) + self.assertEqual([link for link in lx.extract_links(response)], [ + Link(url='http://example.org/item1.html', text=u'Pic of a cat', nofollow=False), + Link(url='http://example.org/item2.html', text=u'Pic of a dog', nofollow=False), + ]) + @pytest.mark.xfail def test_restrict_xpaths_with_html_entities(self): super(LxmlLinkExtractorTestCase, self).test_restrict_xpaths_with_html_entities() From 858f5be74728209d8ef71794296814abca4c1c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 1 Mar 2019 16:10:23 +0100 Subject: [PATCH 084/503] =?UTF-8?q?backwards=20=E2=86=92=20backward=20(adj?= =?UTF-8?q?.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 64 ++++++++++++------------- docs/topics/request-response.rst | 2 +- docs/topics/spiders.rst | 2 +- docs/versioning.rst | 2 +- scrapy/cmdline.py | 4 +- scrapy/conf.py | 2 +- scrapy/core/downloader/handlers/http.py | 2 +- scrapy/extensions/feedexport.py | 4 +- scrapy/log.py | 2 +- scrapy/signals.py | 2 +- scrapy/utils/conf.py | 4 +- sep/sep-018.rst | 2 +- tests/test_downloader_handlers.py | 2 +- tests/test_feedexport.py | 2 +- tests/test_utils_conf.py | 2 +- 15 files changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 668473887..7ac1664fe 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -53,7 +53,7 @@ case of Scrapy spiders: callbacks are usually called several times, on different pages. If you're using custom ``Selector`` or ``SelectorList`` subclasses, -a **backwards incompatible** change in parsel may affect your code. +a **backward incompatible** change in parsel may affect your code. See `parsel changelog`_ for a detailed description, as well as for the full list of improvements. @@ -62,7 +62,7 @@ full list of improvements. Telnet console ~~~~~~~~~~~~~~ -**Backwards incompatible**: Scrapy's telnet console now requires username +**Backward incompatible**: Scrapy's telnet console now requires username and password. See :ref:`topics-telnetconsole` for more details. This change fixes a **security issue**; see :ref:`release-1.5.2` release notes for details. @@ -209,7 +209,7 @@ Scrapy 1.5.2 (2019-01-22) exploit it from Scrapy, but it is very easy to trick a browser to do so and elevates the risk for local development environment. - *The fix is backwards incompatible*, it enables telnet user-password + *The fix is backward incompatible*, it enables telnet user-password authentication by default with a random generated password. If you can't upgrade right away, please consider setting :setting:`TELNET_CONSOLE_PORT` out of its default value. @@ -256,15 +256,15 @@ Some highlights: * Better default handling of HTTP 308, 522 and 524 status codes. * Documentation is improved, as usual. -Backwards Incompatible Changes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Backward Incompatible Changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Scrapy 1.5 drops support for Python 3.3. * Default Scrapy User-Agent now uses https link to scrapy.org (:issue:`2983`). - **This is technically backwards-incompatible**; override + **This is technically backward-incompatible**; override :setting:`USER_AGENT` if you relied on old value. * Logging of settings overridden by ``custom_settings`` is fixed; - **this is technically backwards-incompatible** because the logger + **this is technically backward-incompatible** because the logger changes from ``[scrapy.utils.log]`` to ``[scrapy.crawler]``. If you're parsing Scrapy logs, please update your log parsers (:issue:`1343`). * LinkExtractor now ignores ``m4v`` extension by default, this is change @@ -301,11 +301,11 @@ Bug fixes ~~~~~~~~~ - Fix logging of settings overridden by ``custom_settings``; - **this is technically backwards-incompatible** because the logger + **this is technically backward-incompatible** because the logger changes from ``[scrapy.utils.log]`` to ``[scrapy.crawler]``, so please update your log parsers if needed (:issue:`1343`) - Default Scrapy User-Agent now uses https link to scrapy.org (:issue:`2983`). - **This is technically backwards-incompatible**; override + **This is technically backward-incompatible**; override :setting:`USER_AGENT` if you relied on old value. - Fix PyPy and PyPy3 test failures, support them officially (:issue:`2793`, :issue:`2935`, :issue:`2990`, :issue:`3050`, :issue:`2213`, @@ -415,18 +415,18 @@ offset, using the new :setting:`FEED_EXPORT_INDENT` setting. Enjoy! (Or read on for the rest of changes in this release.) -Deprecations and Backwards Incompatible Changes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Deprecations and Backward Incompatible Changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Default to ``canonicalize=False`` in :class:`scrapy.linkextractors.LinkExtractor` (:issue:`2537`, fixes :issue:`1941` and :issue:`1982`): - **warning, this is technically backwards-incompatible** + **warning, this is technically backward-incompatible** - Enable memusage extension by default (:issue:`2539`, fixes :issue:`2187`); - **this is technically backwards-incompatible** so please check if you have + **this is technically backward-incompatible** so please check if you have any non-default ``MEMUSAGE_***`` options set. - ``EDITOR`` environment variable now takes precedence over ``EDITOR`` option defined in settings.py (:issue:`1829`); Scrapy default settings - no longer depend on environment variables. **This is technically a backwards + no longer depend on environment variables. **This is technically a backward incompatible change**. - ``Spider.make_requests_from_url`` is deprecated (:issue:`1728`, fixes :issue:`1495`). @@ -636,10 +636,10 @@ New Features scrapy shell now follow HTTP redirections by default (:issue:`2290`); See :command:`fetch` and :command:`shell` for details. - ``HttpErrorMiddleware`` now logs errors with ``INFO`` level instead of ``DEBUG``; - this is technically **backwards incompatible** so please check your log parsers. + this is technically **backward incompatible** so please check your log parsers. - By default, logger names now use a long-form path, e.g. ``[scrapy.extensions.logstats]``, instead of the shorter "top-level" variant of prior releases (e.g. ``[scrapy]``); - this is **backwards incompatible** if you have log parsers expecting the short + this is **backward incompatible** if you have log parsers expecting the short logger name part. You can switch back to short logger names using :setting:`LOG_SHORT_NAMES` set to ``True``. @@ -750,11 +750,11 @@ Bug fixes ~~~~~~~~~ - DefaultRequestHeaders middleware now runs before UserAgent middleware - (:issue:`2088`). **Warning: this is technically backwards incompatible**, + (:issue:`2088`). **Warning: this is technically backward incompatible**, though we consider this a bug fix. - HTTP cache extension and plugins that use the ``.scrapy`` data directory now work outside projects (:issue:`1581`). **Warning: this is technically - backwards incompatible**, though we consider this a bug fix. + backward incompatible**, though we consider this a bug fix. - ``Selector`` does not allow passing both ``response`` and ``text`` anymore (:issue:`2153`). - Fixed logging of wrong callback name with ``scrapy parse`` (:issue:`2169`). @@ -934,13 +934,13 @@ This 1.1 release brings a lot of interesting features and bug fixes: - Accept XML node names containing dots as valid (:issue:`1533`). - When uploading files or images to S3 (with ``FilesPipeline`` or ``ImagesPipeline``), the default ACL policy is now "private" instead - of "public" **Warning: backwards incompatible!**. + of "public" **Warning: backward incompatible!**. You can use :setting:`FILES_STORE_S3_ACL` to change it. - We've reimplemented ``canonicalize_url()`` for more correct output, especially for URLs with non-ASCII characters (:issue:`1947`). This could change link extractors output compared to previous scrapy versions. This may also invalidate some cache entries you could still have from pre-1.1 runs. - **Warning: backwards incompatible!**. + **Warning: backward incompatible!**. Keep reading for more details on other improvements and bug fixes. @@ -973,7 +973,7 @@ Additional New Features and Enhancements - Support for bpython and configure preferred Python shell via ``SCRAPY_PYTHON_SHELL`` (:issue:`1100`, :issue:`1444`). - Support URLs without scheme (:issue:`1498`) - **Warning: backwards incompatible!** + **Warning: backward incompatible!** - Bring back support for relative file path (:issue:`1710`, :issue:`1550`). - Added :setting:`MEMUSAGE_CHECK_INTERVAL_SECONDS` setting to change default check @@ -1056,7 +1056,7 @@ Bugfixes ~~~~~~~~ - Scrapy does not retry requests that got a ``HTTP 400 Bad Request`` - response anymore (:issue:`1289`). **Warning: backwards incompatible!** + response anymore (:issue:`1289`). **Warning: backward incompatible!** - Support empty password for http_proxy config (:issue:`1274`). - Interpret ``application/x-json`` as ``TextResponse`` (:issue:`1333`). - Support link rel attribute with multiple values (:issue:`1201`). @@ -1646,7 +1646,7 @@ Scrapy 0.24.2 (2014-07-08) Scrapy 0.24.1 (2014-06-27) -------------------------- -- Fix deprecated CrawlerSettings and increase backwards compatibility with +- Fix deprecated CrawlerSettings and increase backward compatibility with .defaults attribute (:commit:`8e3f20a`) @@ -1772,7 +1772,7 @@ Scrapy 0.22.0 (released 2014-01-17) Enhancements ~~~~~~~~~~~~ -- [**Backwards incompatible**] Switched HTTPCacheMiddleware backend to filesystem (:issue:`541`) +- [**Backward incompatible**] Switched HTTPCacheMiddleware backend to filesystem (:issue:`541`) To restore old backend set `HTTPCACHE_STORAGE` to `scrapy.contrib.httpcache.DbmCacheStorage` - Proxy \https:// urls using CONNECT method (:issue:`392`, :issue:`397`) - Add a middleware to crawl ajax crawleable pages as defined by google (:issue:`343`) @@ -2092,7 +2092,7 @@ Scrapy 0.16.1 (released 2012-10-26) ----------------------------------- - fixed LogStats extension, which got broken after a wrong merge before the 0.16 release (:commit:`8c780fd`) -- better backwards compatibility for scrapy.conf.settings (:commit:`3403089`) +- better backward compatibility for scrapy.conf.settings (:commit:`3403089`) - extended documentation on how to access crawler stats from extensions (:commit:`c4da0b5`) - removed .hgtags (no longer needed now that scrapy uses git) (:commit:`d52c188`) - fix dashes under rst headers (:commit:`fa4f7f9`) @@ -2107,7 +2107,7 @@ Scrapy changes: - added :ref:`topics-contracts`, a mechanism for testing spiders in a formal/reproducible way - added options ``-o`` and ``-t`` to the :command:`runspider` command - documented :doc:`topics/autothrottle` and added to extensions installed by default. You still need to enable it with :setting:`AUTOTHROTTLE_ENABLED` -- major Stats Collection refactoring: removed separation of global/per-spider stats, removed stats-related signals (``stats_spider_opened``, etc). Stats are much simpler now, backwards compatibility is kept on the Stats Collector API and signals. +- major Stats Collection refactoring: removed separation of global/per-spider stats, removed stats-related signals (``stats_spider_opened``, etc). Stats are much simpler now, backward compatibility is kept on the Stats Collector API and signals. - added :meth:`~scrapy.contrib.spidermiddleware.SpiderMiddleware.process_start_requests` method to spider middlewares - dropped Signals singleton. Signals should now be accesed through the Crawler.signals attribute. See the signals documentation for more info. - dropped Signals singleton. Signals should now be accesed through the Crawler.signals attribute. See the signals documentation for more info. @@ -2259,7 +2259,7 @@ Code rearranged and removed - Removed (undocumented) spider context extension (from scrapy.contrib.spidercontext) (:rev:`2780`) - removed ``CONCURRENT_SPIDERS`` setting (use scrapyd maxproc instead) (:rev:`2789`) - Renamed attributes of core components: downloader.sites -> downloader.slots, scraper.sites -> scraper.slots (:rev:`2717`, :rev:`2718`) -- Renamed setting ``CLOSESPIDER_ITEMPASSED`` to :setting:`CLOSESPIDER_ITEMCOUNT` (:rev:`2655`). Backwards compatibility kept. +- Renamed setting ``CLOSESPIDER_ITEMPASSED`` to :setting:`CLOSESPIDER_ITEMCOUNT` (:rev:`2655`). Backward compatibility kept. Scrapy 0.12 ----------- @@ -2356,11 +2356,11 @@ API changes - ``scrapy.stats.collector.SimpledbStatsCollector`` to ``scrapy.contrib.statscol.SimpledbStatsCollector`` - default per-command settings are now specified in the ``default_settings`` attribute of command object class (#201) - changed arguments of Item pipeline ``process_item()`` method from ``(spider, item)`` to ``(item, spider)`` - - backwards compatibility kept (with deprecation warning) + - backward compatibility kept (with deprecation warning) - moved ``scrapy.core.signals`` module to ``scrapy.signals`` - - backwards compatibility kept (with deprecation warning) + - backward compatibility kept (with deprecation warning) - moved ``scrapy.core.exceptions`` module to ``scrapy.exceptions`` - - backwards compatibility kept (with deprecation warning) + - backward compatibility kept (with deprecation warning) - added ``handles_request()`` class method to ``BaseSpider`` - dropped ``scrapy.log.exc()`` function (use ``scrapy.log.err()`` instead) - dropped ``component`` argument of ``scrapy.log.msg()`` function @@ -2431,8 +2431,8 @@ New features - Added support for HTTP proxies (``HttpProxyMiddleware``) (:rev:`1781`, :rev:`1785`) - Offsite spider middleware now logs messages when filtering out requests (:rev:`1841`) -Backwards-incompatible changes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Backward-incompatible changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Changed ``scrapy.utils.response.get_meta_refresh()`` signature (:rev:`1804`) - Removed deprecated ``scrapy.item.ScrapedItem`` class - use ``scrapy.item.Item instead`` (:rev:`1838`) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 76360b15f..4511f3469 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -728,7 +728,7 @@ TextResponse objects .. method:: TextResponse.body_as_unicode() The same as :attr:`text`, but available as a method. This method is - kept for backwards compatibility; please prefer ``response.text``. + kept for backward compatibility; please prefer ``response.text``. HtmlResponse objects diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 742a88659..e1d36aa24 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -190,7 +190,7 @@ scrapy.Spider .. method:: log(message, [level, component]) Wrapper that sends a log message through the Spider's :attr:`logger`, - kept for backwards compatibility. For more information see + kept for backward compatibility. For more information see :ref:`topics-logging-from-spiders`. .. method:: closed(reason) diff --git a/docs/versioning.rst b/docs/versioning.rst index 0421ba544..227085f02 100644 --- a/docs/versioning.rst +++ b/docs/versioning.rst @@ -12,7 +12,7 @@ There are 3 numbers in a Scrapy version: *A.B.C* * *A* is the major version. This will rarely change and will signify very large changes. * *B* is the release number. This will include many changes including features - and things that possibly break backwards compatibility, although we strive to + and things that possibly break backward compatibility, although we strive to keep theses cases at a minimum. * *C* is the bugfix release number. diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py index dc6b59fe0..fa2506eb0 100644 --- a/scrapy/cmdline.py +++ b/scrapy/cmdline.py @@ -99,7 +99,7 @@ def execute(argv=None, settings=None): if argv is None: argv = sys.argv - # --- backwards compatibility for scrapy.conf.settings singleton --- + # --- backward compatibility for scrapy.conf.settings singleton --- if settings is None and 'scrapy.conf' in sys.modules: from scrapy import conf if hasattr(conf, 'settings'): @@ -116,7 +116,7 @@ def execute(argv=None, settings=None): settings['EDITOR'] = editor check_deprecated_settings(settings) - # --- backwards compatibility for scrapy.conf.settings singleton --- + # --- backward compatibility for scrapy.conf.settings singleton --- import warnings from scrapy.exceptions import ScrapyDeprecationWarning with warnings.catch_warnings(): diff --git a/scrapy/conf.py b/scrapy/conf.py index 23efc6ffd..6c40edcdd 100644 --- a/scrapy/conf.py +++ b/scrapy/conf.py @@ -1,4 +1,4 @@ -# This module is kept for backwards compatibility, so users can import +# This module is kept for backward compatibility, so users can import # scrapy.conf.settings and get the settings they expect import sys diff --git a/scrapy/core/downloader/handlers/http.py b/scrapy/core/downloader/handlers/http.py index e4a7d8564..e76823623 100644 --- a/scrapy/core/downloader/handlers/http.py +++ b/scrapy/core/downloader/handlers/http.py @@ -3,7 +3,7 @@ from .http10 import HTTP10DownloadHandler from .http11 import HTTP11DownloadHandler as HTTPDownloadHandler -# backwards compatibility +# backward compatibility class HttpDownloadHandler(HTTP10DownloadHandler): def __init__(self, *args, **kwargs): diff --git a/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py index 22ebf3b3f..3b4d809e8 100644 --- a/scrapy/extensions/feedexport.py +++ b/scrapy/extensions/feedexport.py @@ -94,7 +94,7 @@ class FileFeedStorage(object): class S3FeedStorage(BlockingFeedStorage): def __init__(self, uri, access_key=None, secret_key=None): - # BEGIN Backwards compatibility for initialising without keys (and + # BEGIN Backward compatibility for initialising without keys (and # without using from_crawler) no_defaults = access_key is None and secret_key is None if no_defaults: @@ -111,7 +111,7 @@ class S3FeedStorage(BlockingFeedStorage): ) access_key = settings['AWS_ACCESS_KEY_ID'] secret_key = settings['AWS_SECRET_ACCESS_KEY'] - # END Backwards compatibility + # END Backward compatibility u = urlparse(uri) self.bucketname = u.hostname self.access_key = u.username or access_key diff --git a/scrapy/log.py b/scrapy/log.py index 719fceaad..777bd6dc4 100644 --- a/scrapy/log.py +++ b/scrapy/log.py @@ -17,7 +17,7 @@ warnings.warn("Module `scrapy.log` has been deprecated, Scrapy now relies on " ScrapyDeprecationWarning, stacklevel=2) -# Imports and level_names variable kept for backwards-compatibility +# Imports and level_names variable kept for backward-compatibility DEBUG = logging.DEBUG INFO = logging.INFO diff --git a/scrapy/signals.py b/scrapy/signals.py index c0e4bb74e..6b9125302 100644 --- a/scrapy/signals.py +++ b/scrapy/signals.py @@ -20,7 +20,7 @@ item_scraped = object() item_dropped = object() item_error = object() -# for backwards compatibility +# for backward compatibility stats_spider_opened = spider_opened stats_spider_closing = spider_closed stats_spider_closed = spider_closed diff --git a/scrapy/utils/conf.py b/scrapy/utils/conf.py index 435e9a6b3..fbd297340 100644 --- a/scrapy/utils/conf.py +++ b/scrapy/utils/conf.py @@ -42,14 +42,14 @@ def build_component_list(compdict, custom=None, convert=update_classpath): raise ValueError('Invalid value {} for component {}, please provide ' \ 'a real number or None instead'.format(value, name)) - # BEGIN Backwards compatibility for old (base, custom) call signature + # BEGIN Backward compatibility for old (base, custom) call signature if isinstance(custom, (list, tuple)): _check_components(custom) return type(custom)(convert(c) for c in custom) if custom is not None: compdict.update(custom) - # END Backwards compatibility + # END Backward compatibility _validate_values(compdict) compdict = without_none_values(_map_keys(compdict)) diff --git a/sep/sep-018.rst b/sep/sep-018.rst index aca7ac342..fe707923a 100644 --- a/sep/sep-018.rst +++ b/sep/sep-018.rst @@ -211,7 +211,7 @@ spider methods on each event such as: - call additional spider middlewares defined in the ``Spider.middlewares`` attribute - call ``Spider.next_request()`` and ``Spider.start_requests()`` on - ``next_request()`` middleware method (this would implicitly support backwards + ``next_request()`` middleware method (this would implicitly support backward compatibility) Differences with Spider middleware v1 diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index 0d0829793..81235a16f 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -50,7 +50,7 @@ class DummyDH(object): class DummyLazyDH(object): - # Default is lazy for backwards compatibility + # Default is lazy for backward compatibility def __init__(self, crawler): pass diff --git a/tests/test_feedexport.py b/tests/test_feedexport.py index e46c8c14e..b254b9f38 100644 --- a/tests/test_feedexport.py +++ b/tests/test_feedexport.py @@ -161,7 +161,7 @@ class S3FeedStorageTest(unittest.TestCase): aws_credentials['AWS_SECRET_ACCESS_KEY']) self.assertEqual(storage.access_key, 'uri_key') self.assertEqual(storage.secret_key, 'uri_secret') - # Backwards compatibility for initialising without settings + # Backward compatibility for initialising without settings with warnings.catch_warnings(record=True) as w: storage = S3FeedStorage('s3://mybucket/export.csv') self.assertEqual(storage.access_key, 'conf_key') diff --git a/tests/test_utils_conf.py b/tests/test_utils_conf.py index f203c32ef..29937c189 100644 --- a/tests/test_utils_conf.py +++ b/tests/test_utils_conf.py @@ -11,7 +11,7 @@ class BuildComponentListTest(unittest.TestCase): self.assertEqual(build_component_list(d, convert=lambda x: x), ['one', 'four', 'three']) - def test_backwards_compatible_build_dict(self): + def test_backward_compatible_build_dict(self): base = {'one': 1, 'two': 2, 'three': 3, 'five': 5, 'six': None} custom = {'two': None, 'three': 8, 'four': 4} self.assertEqual(build_component_list(base, custom, From 75d6f56c8a731ea4e1c06814a59a0b51741d04a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 1 Mar 2019 16:56:58 +0100 Subject: [PATCH 085/503] Switch from ` to `` where inline code formatting is desired --- docs/contributing.rst | 2 +- docs/news.rst | 130 +++++++++++++------------- docs/topics/api.rst | 4 +- docs/topics/downloader-middleware.rst | 40 ++++---- docs/topics/exporters.rst | 2 +- docs/topics/extensions.rst | 4 +- docs/topics/jobs.rst | 2 +- docs/topics/loaders.rst | 2 +- docs/topics/logging.rst | 2 +- docs/topics/media-pipeline.rst | 2 +- docs/topics/practices.rst | 2 +- docs/topics/request-response.rst | 8 +- docs/topics/selectors.rst | 2 +- docs/topics/settings.rst | 4 +- docs/topics/spider-middleware.rst | 4 +- docs/topics/spiders.rst | 6 +- docs/topics/ubuntu.rst | 4 +- scrapy/crawler.py | 10 +- scrapy/logformatter.py | 18 ++-- scrapy/pipelines/files.py | 6 +- scrapy/utils/ftp.py | 2 +- scrapy/utils/log.py | 2 +- scrapy/utils/python.py | 18 ++-- scrapy/utils/url.py | 8 +- sep/sep-006.rst | 5 +- tests/mocks/dummydbm.py | 2 +- tests/test_command_shell.py | 4 +- 27 files changed, 148 insertions(+), 147 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index cf27337c8..9b508e418 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -55,7 +55,7 @@ guidelines when you're going to report a new bug. * search the `scrapy-users`_ list and `Scrapy subreddit`_ to see if it has been discussed there, or if you're not sure if what you're seeing is a bug. - You can also ask in the `#scrapy` IRC channel. + You can also ask in the ``#scrapy`` IRC channel. * write **complete, reproducible, specific bug reports**. The smaller the test case, the better. Remember that other developers won't have your project to diff --git a/docs/news.rst b/docs/news.rst index 668473887..1849a3ca8 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -149,7 +149,7 @@ Documentation improvements * improved links to beginner resources in the tutorial (:issue:`3367`, :issue:`3468`); * fixed :setting:`RETRY_HTTP_CODES` default values in docs (:issue:`3335`); -* remove unused `DEPTH_STATS` option from docs (:issue:`3245`); +* remove unused ``DEPTH_STATS`` option from docs (:issue:`3245`); * other cleanups (:issue:`3347`, :issue:`3350`, :issue:`3445`, :issue:`3544`, :issue:`3605`). @@ -1313,7 +1313,7 @@ Module Relocations There’s been a large rearrangement of modules trying to improve the general structure of Scrapy. Main changes were separating various subpackages into -new projects and dissolving both `scrapy.contrib` and `scrapy.contrib_exp` +new projects and dissolving both ``scrapy.contrib`` and ``scrapy.contrib_exp`` into top level packages. Backward compatibility was kept among internal relocations, while importing deprecated modules expect warnings indicating their new place. @@ -1344,7 +1344,7 @@ Outsourced packages | | /scrapy-plugins/scrapy-jsonrpc>`_ | +-------------------------------------+-------------------------------------+ -`scrapy.contrib_exp` and `scrapy.contrib` dissolutions +``scrapy.contrib_exp`` and ``scrapy.contrib`` dissolutions +-------------------------------------+-------------------------------------+ | Old location | New location | @@ -1556,7 +1556,7 @@ Code refactoring (:issue:`1078`) - Pydispatch pep8 (:issue:`992`) - Removed unused 'load=False' parameter from walk_modules() (:issue:`871`) -- For consistency, use `job_dir` helper in `SpiderState` extension. +- For consistency, use ``job_dir`` helper in ``SpiderState`` extension. (:issue:`805`) - rename "sflo" local variables to less cryptic "log_observer" (:issue:`775`) @@ -1669,10 +1669,10 @@ Enhancements cache middleware (:issue:`541`, :issue:`500`, :issue:`571`) - Expose current crawler in Scrapy shell (:issue:`557`) - Improve testsuite comparing CSV and XML exporters (:issue:`570`) -- New `offsite/filtered` and `offsite/domains` stats (:issue:`566`) +- New ``offsite/filtered`` and ``offsite/domains`` stats (:issue:`566`) - Support process_links as generator in CrawlSpider (:issue:`555`) - Verbose logging and new stats counters for DupeFilter (:issue:`553`) -- Add a mimetype parameter to `MailSender.send()` (:issue:`602`) +- Add a mimetype parameter to ``MailSender.send()`` (:issue:`602`) - Generalize file pipeline log messages (:issue:`622`) - Replace unencodeable codepoints with html entities in SGMLLinkExtractor (:issue:`565`) - Converted SEP documents to rst format (:issue:`629`, :issue:`630`, @@ -1691,20 +1691,20 @@ Enhancements - Make scrapy.version_info a tuple of integers (:issue:`681`, :issue:`692`) - Infer exporter's output format from filename extensions (:issue:`546`, :issue:`659`, :issue:`760`) -- Support case-insensitive domains in `url_is_from_any_domain()` (:issue:`693`) +- Support case-insensitive domains in ``url_is_from_any_domain()`` (:issue:`693`) - Remove pep8 warnings in project and spider templates (:issue:`698`) -- Tests and docs for `request_fingerprint` function (:issue:`597`) -- Update SEP-19 for GSoC project `per-spider settings` (:issue:`705`) +- Tests and docs for ``request_fingerprint`` function (:issue:`597`) +- Update SEP-19 for GSoC project ``per-spider settings`` (:issue:`705`) - Set exit code to non-zero when contracts fails (:issue:`727`) - Add a setting to control what class is instanciated as Downloader component (:issue:`738`) -- Pass response in `item_dropped` signal (:issue:`724`) -- Improve `scrapy check` contracts command (:issue:`733`, :issue:`752`) -- Document `spider.closed()` shortcut (:issue:`719`) -- Document `request_scheduled` signal (:issue:`746`) +- Pass response in ``item_dropped`` signal (:issue:`724`) +- Improve ``scrapy check`` contracts command (:issue:`733`, :issue:`752`) +- Document ``spider.closed()`` shortcut (:issue:`719`) +- Document ``request_scheduled`` signal (:issue:`746`) - Add a note about reporting security issues (:issue:`697`) - Add LevelDB http cache storage backend (:issue:`626`, :issue:`500`) -- Sort spider list output of `scrapy list` command (:issue:`742`) +- Sort spider list output of ``scrapy list`` command (:issue:`742`) - Multiple documentation enhancemens and fixes (:issue:`575`, :issue:`587`, :issue:`590`, :issue:`596`, :issue:`610`, :issue:`617`, :issue:`618`, :issue:`627`, :issue:`613`, :issue:`643`, @@ -1772,23 +1772,23 @@ Scrapy 0.22.0 (released 2014-01-17) Enhancements ~~~~~~~~~~~~ -- [**Backwards incompatible**] Switched HTTPCacheMiddleware backend to filesystem (:issue:`541`) - To restore old backend set `HTTPCACHE_STORAGE` to `scrapy.contrib.httpcache.DbmCacheStorage` +- [**Backward incompatible**] Switched HTTPCacheMiddleware backend to filesystem (:issue:`541`) + To restore old backend set ``HTTPCACHE_STORAGE`` to ``scrapy.contrib.httpcache.DbmCacheStorage`` - Proxy \https:// urls using CONNECT method (:issue:`392`, :issue:`397`) - Add a middleware to crawl ajax crawleable pages as defined by google (:issue:`343`) - Rename scrapy.spider.BaseSpider to scrapy.spider.Spider (:issue:`510`, :issue:`519`) - Selectors register EXSLT namespaces by default (:issue:`472`) - Unify item loaders similar to selectors renaming (:issue:`461`) -- Make `RFPDupeFilter` class easily subclassable (:issue:`533`) +- Make ``RFPDupeFilter`` class easily subclassable (:issue:`533`) - Improve test coverage and forthcoming Python 3 support (:issue:`525`) - Promote startup info on settings and middleware to INFO level (:issue:`520`) -- Support partials in `get_func_args` util (:issue:`506`, issue:`504`) +- Support partials in ``get_func_args`` util (:issue:`506`, issue:`504`) - Allow running indiviual tests via tox (:issue:`503`) - Update extensions ignored by link extractors (:issue:`498`) - Add middleware methods to get files/images/thumbs paths (:issue:`490`) - Improve offsite middleware tests (:issue:`478`) - Add a way to skip default Referer header set by RefererMiddleware (:issue:`475`) -- Do not send `x-gzip` in default `Accept-Encoding` header (:issue:`469`) +- Do not send ``x-gzip`` in default ``Accept-Encoding`` header (:issue:`469`) - Support defining http error handling using settings (:issue:`466`) - Use modern python idioms wherever you find legacies (:issue:`497`) - Improve and correct documentation @@ -1799,14 +1799,14 @@ Fixes ~~~~~ - Update Selector class imports in CrawlSpider template (:issue:`484`) -- Fix unexistent reference to `engine.slots` (:issue:`464`) -- Do not try to call `body_as_unicode()` on a non-TextResponse instance (:issue:`462`) +- Fix unexistent reference to ``engine.slots`` (:issue:`464`) +- Do not try to call ``body_as_unicode()`` on a non-TextResponse instance (:issue:`462`) - Warn when subclassing XPathItemLoader, previously it only warned on instantiation. (:issue:`523`) - Warn when subclassing XPathSelector, previously it only warned on instantiation. (:issue:`537`) - Multiple fixes to memory stats (:issue:`531`, :issue:`530`, :issue:`529`) -- Fix overriding url in `FormRequest.from_response()` (:issue:`507`) +- Fix overriding url in ``FormRequest.from_response()`` (:issue:`507`) - Fix tests runner under pip 1.5 (:issue:`513`) - Fix logging error when spider name is unicode (:issue:`479`) @@ -1833,7 +1833,7 @@ Enhancements (modifying them had been deprecated for a long time) - :setting:`ITEM_PIPELINES` is now defined as a dict (instead of a list) - Sitemap spider can fetch alternate URLs (:issue:`360`) -- `Selector.remove_namespaces()` now remove namespaces from element's attributes. (:issue:`416`) +- ``Selector.remove_namespaces()`` now remove namespaces from element's attributes. (:issue:`416`) - Paved the road for Python 3.3+ (:issue:`435`, :issue:`436`, :issue:`431`, :issue:`452`) - New item exporter using native python types with nesting support (:issue:`366`) - Tune HTTP1.1 pool size so it matches concurrency defined by settings (:commit:`b43b5f575`) @@ -1844,13 +1844,13 @@ Enhancements - Mock server (used for tests) can listen for HTTPS requests (:issue:`410`) - Remove multi spider support from multiple core components (:issue:`422`, :issue:`421`, :issue:`420`, :issue:`419`, :issue:`423`, :issue:`418`) -- Travis-CI now tests Scrapy changes against development versions of `w3lib` and `queuelib` python packages. +- Travis-CI now tests Scrapy changes against development versions of ``w3lib`` and ``queuelib`` python packages. - Add pypy 2.1 to continuous integration tests (:commit:`ecfa7431`) - Pylinted, pep8 and removed old-style exceptions from source (:issue:`430`, :issue:`432`) - Use importlib for parametric imports (:issue:`445`) - Handle a regression introduced in Python 2.7.5 that affects XmlItemExporter (:issue:`372`) - Bugfix crawling shutdown on SIGINT (:issue:`450`) -- Do not submit `reset` type inputs in FormRequest.from_response (:commit:`b326b87`) +- Do not submit ``reset`` type inputs in FormRequest.from_response (:commit:`b326b87`) - Do not silence download errors when request errback raises an exception (:commit:`684cfc0`) Bugfixes @@ -1865,8 +1865,8 @@ Bugfixes - Improve request-response docs (:issue:`391`) - Improve best practices docs (:issue:`399`, :issue:`400`, :issue:`401`, :issue:`402`) - Improve django integration docs (:issue:`404`) -- Document `bindaddress` request meta (:commit:`37c24e01d7`) -- Improve `Request` class documentation (:issue:`226`) +- Document ``bindaddress`` request meta (:commit:`37c24e01d7`) +- Improve ``Request`` class documentation (:issue:`226`) Other ~~~~~ @@ -1875,7 +1875,7 @@ Other - Add `cssselect`_ python package as install dependency - Drop libxml2 and multi selector's backend support, `lxml`_ is required from now on. - Minimum Twisted version increased to 10.0.0, dropped Twisted 8.0 support. -- Running test suite now requires `mock` python library (:issue:`390`) +- Running test suite now requires ``mock`` python library (:issue:`390`) Thanks @@ -1929,7 +1929,7 @@ Scrapy 0.18.3 (released 2013-10-03) Scrapy 0.18.2 (released 2013-09-03) ----------------------------------- -- Backport `scrapy check` command fixes and backward compatible multi +- Backport ``scrapy check`` command fixes and backward compatible multi crawler process(:issue:`339`) Scrapy 0.18.1 (released 2013-08-27) @@ -1958,31 +1958,31 @@ Scrapy 0.18.0 (released 2013-08-09) - Handle GET parameters for AJAX crawleable urls (:commit:`3fe2a32`) - Use lxml recover option to parse sitemaps (:issue:`347`) - Bugfix cookie merging by hostname and not by netloc (:issue:`352`) -- Support disabling `HttpCompressionMiddleware` using a flag setting (:issue:`359`) -- Support xml namespaces using `iternodes` parser in `XMLFeedSpider` (:issue:`12`) -- Support `dont_cache` request meta flag (:issue:`19`) -- Bugfix `scrapy.utils.gz.gunzip` broken by changes in python 2.7.4 (:commit:`4dc76e`) -- Bugfix url encoding on `SgmlLinkExtractor` (:issue:`24`) -- Bugfix `TakeFirst` processor shouldn't discard zero (0) value (:issue:`59`) +- Support disabling ``HttpCompressionMiddleware`` using a flag setting (:issue:`359`) +- Support xml namespaces using ``iternodes`` parser in ``XMLFeedSpider`` (:issue:`12`) +- Support ``dont_cache`` request meta flag (:issue:`19`) +- Bugfix ``scrapy.utils.gz.gunzip`` broken by changes in python 2.7.4 (:commit:`4dc76e`) +- Bugfix url encoding on ``SgmlLinkExtractor`` (:issue:`24`) +- Bugfix ``TakeFirst`` processor shouldn't discard zero (0) value (:issue:`59`) - Support nested items in xml exporter (:issue:`66`) - Improve cookies handling performance (:issue:`77`) - Log dupe filtered requests once (:issue:`105`) - Split redirection middleware into status and meta based middlewares (:issue:`78`) - Use HTTP1.1 as default downloader handler (:issue:`109` and :issue:`318`) -- Support xpath form selection on `FormRequest.from_response` (:issue:`185`) -- Bugfix unicode decoding error on `SgmlLinkExtractor` (:issue:`199`) +- Support xpath form selection on ``FormRequest.from_response`` (:issue:`185`) +- Bugfix unicode decoding error on ``SgmlLinkExtractor`` (:issue:`199`) - Bugfix signal dispatching on pypi interpreter (:issue:`205`) - Improve request delay and concurrency handling (:issue:`206`) -- Add RFC2616 cache policy to `HttpCacheMiddleware` (:issue:`212`) +- Add RFC2616 cache policy to ``HttpCacheMiddleware`` (:issue:`212`) - Allow customization of messages logged by engine (:issue:`214`) -- Multiples improvements to `DjangoItem` (:issue:`217`, :issue:`218`, :issue:`221`) +- Multiples improvements to ``DjangoItem`` (:issue:`217`, :issue:`218`, :issue:`221`) - Extend Scrapy commands using setuptools entry points (:issue:`260`) -- Allow spider `allowed_domains` value to be set/tuple (:issue:`261`) -- Support `settings.getdict` (:issue:`269`) -- Simplify internal `scrapy.core.scraper` slot handling (:issue:`271`) -- Added `Item.copy` (:issue:`290`) +- Allow spider ``allowed_domains`` value to be set/tuple (:issue:`261`) +- Support ``settings.getdict`` (:issue:`269`) +- Simplify internal ``scrapy.core.scraper`` slot handling (:issue:`271`) +- Added ``Item.copy`` (:issue:`290`) - Collect idle downloader slots (:issue:`297`) -- Add `ftp://` scheme downloader handler (:issue:`329`) +- Add ``ftp://`` scheme downloader handler (:issue:`329`) - Added downloader benchmark webserver and spider tools :ref:`benchmarking` - Moved persistent (on disk) queues to a separate project (queuelib_) which scrapy now depends on - Add scrapy commands using external libraries (:issue:`260`) @@ -2113,7 +2113,7 @@ Scrapy changes: - dropped Signals singleton. Signals should now be accesed through the Crawler.signals attribute. See the signals documentation for more info. - dropped Stats Collector singleton. Stats can now be accessed through the Crawler.stats attribute. See the stats collection documentation for more info. - documented :ref:`topics-api` -- `lxml` is now the default selectors backend instead of `libxml2` +- ``lxml`` is now the default selectors backend instead of ``libxml2`` - ported FormRequest.from_response() to use `lxml`_ instead of `ClientForm`_ - removed modules: ``scrapy.xlib.BeautifulSoup`` and ``scrapy.xlib.ClientForm`` - SitemapSpider: added support for sitemap urls ending in .xml and .xml.gz, even if they advertise a wrong content type (:commit:`10ed28b`) @@ -2206,16 +2206,16 @@ New features and settings - New ``ChunkedTransferMiddleware`` (enabled by default) to support `chunked transfer encoding`_ (:rev:`2769`) - Add boto 2.0 support for S3 downloader handler (:rev:`2763`) - Added `marshal`_ to formats supported by feed exports (:rev:`2744`) -- In request errbacks, offending requests are now received in `failure.request` attribute (:rev:`2738`) +- In request errbacks, offending requests are now received in ``failure.request`` attribute (:rev:`2738`) - Big downloader refactoring to support per domain/ip concurrency limits (:rev:`2732`) - ``CONCURRENT_REQUESTS_PER_SPIDER`` setting has been deprecated and replaced by: - :setting:`CONCURRENT_REQUESTS`, :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, :setting:`CONCURRENT_REQUESTS_PER_IP` - check the documentation for more details - Added builtin caching DNS resolver (:rev:`2728`) - Moved Amazon AWS-related components/extensions (SQS spider queue, SimpleDB stats collector) to a separate project: [scaws](https://github.com/scrapinghub/scaws) (:rev:`2706`, :rev:`2714`) -- Moved spider queues to scrapyd: `scrapy.spiderqueue` -> `scrapyd.spiderqueue` (:rev:`2708`) -- Moved sqlite utils to scrapyd: `scrapy.utils.sqlite` -> `scrapyd.sqlite` (:rev:`2781`) -- Real support for returning iterators on `start_requests()` method. The iterator is now consumed during the crawl when the spider is getting idle (:rev:`2704`) +- Moved spider queues to scrapyd: ``scrapy.spiderqueue`` -> ``scrapyd.spiderqueue`` (:rev:`2708`) +- Moved sqlite utils to scrapyd: ``scrapy.utils.sqlite`` -> ``scrapyd.sqlite`` (:rev:`2781`) +- Real support for returning iterators on ``start_requests()`` method. The iterator is now consumed during the crawl when the spider is getting idle (:rev:`2704`) - Added :setting:`REDIRECT_ENABLED` setting to quickly enable/disable the redirect middleware (:rev:`2697`) - Added :setting:`RETRY_ENABLED` setting to quickly enable/disable the retry middleware (:rev:`2694`) - Added ``CloseSpider`` exception to manually close spiders (:rev:`2691`) @@ -2223,19 +2223,19 @@ New features and settings - Refactored close spider behavior to wait for all downloads to finish and be processed by spiders, before closing the spider (:rev:`2688`) - Added ``SitemapSpider`` (see documentation in Spiders page) (:rev:`2658`) - Added ``LogStats`` extension for periodically logging basic stats (like crawled pages and scraped items) (:rev:`2657`) -- Make handling of gzipped responses more robust (#319, :rev:`2643`). Now Scrapy will try and decompress as much as possible from a gzipped response, instead of failing with an `IOError`. +- Make handling of gzipped responses more robust (#319, :rev:`2643`). Now Scrapy will try and decompress as much as possible from a gzipped response, instead of failing with an ``IOError``. - Simplified !MemoryDebugger extension to use stats for dumping memory debugging info (:rev:`2639`) -- Added new command to edit spiders: ``scrapy edit`` (:rev:`2636`) and `-e` flag to `genspider` command that uses it (:rev:`2653`) +- Added new command to edit spiders: ``scrapy edit`` (:rev:`2636`) and ``-e`` flag to ``genspider`` command that uses it (:rev:`2653`) - Changed default representation of items to pretty-printed dicts. (:rev:`2631`). This improves default logging by making log more readable in the default case, for both Scraped and Dropped lines. - Added :signal:`spider_error` signal (:rev:`2628`) - Added :setting:`COOKIES_ENABLED` setting (:rev:`2625`) -- Stats are now dumped to Scrapy log (default value of :setting:`STATS_DUMP` setting has been changed to `True`). This is to make Scrapy users more aware of Scrapy stats and the data that is collected there. +- Stats are now dumped to Scrapy log (default value of :setting:`STATS_DUMP` setting has been changed to ``True``). This is to make Scrapy users more aware of Scrapy stats and the data that is collected there. - Added support for dynamically adjusting download delay and maximum concurrent requests (:rev:`2599`) - Added new DBM HTTP cache storage backend (:rev:`2576`) - Added ``listjobs.json`` API to Scrapyd (:rev:`2571`) - ``CsvItemExporter``: added ``join_multivalued`` parameter (:rev:`2578`) - Added namespace support to ``xmliter_lxml`` (:rev:`2552`) -- Improved cookies middleware by making `COOKIES_DEBUG` nicer and documenting it (:rev:`2579`) +- Improved cookies middleware by making ``COOKIES_DEBUG`` nicer and documenting it (:rev:`2579`) - Several improvements to Scrapyd and Link extractors Code rearranged and removed @@ -2249,11 +2249,11 @@ Code rearranged and removed - Reduced Scrapy codebase by striping part of Scrapy code into two new libraries: - `w3lib`_ (several functions from ``scrapy.utils.{http,markup,multipart,response,url}``, done in :rev:`2584`) - `scrapely`_ (was ``scrapy.contrib.ibl``, done in :rev:`2586`) -- Removed unused function: `scrapy.utils.request.request_info()` (:rev:`2577`) -- Removed googledir project from `examples/googledir`. There's now a new example project called `dirbot` available on github: https://github.com/scrapy/dirbot +- Removed unused function: ``scrapy.utils.request.request_info()`` (:rev:`2577`) +- Removed googledir project from ``examples/googledir``. There's now a new example project called ``dirbot`` available on github: https://github.com/scrapy/dirbot - Removed support for default field values in Scrapy items (:rev:`2616`) - Removed experimental crawlspider v2 (:rev:`2632`) -- Removed scheduler middleware to simplify architecture. Duplicates filter is now done in the scheduler itself, using the same dupe fltering class as before (`DUPEFILTER_CLASS` setting) (:rev:`2640`) +- Removed scheduler middleware to simplify architecture. Duplicates filter is now done in the scheduler itself, using the same dupe fltering class as before (``DUPEFILTER_CLASS`` setting) (:rev:`2640`) - Removed support for passing urls to ``scrapy crawl`` command (use ``scrapy parse`` instead) (:rev:`2704`) - Removed deprecated Execution Queue (:rev:`2704`) - Removed (undocumented) spider context extension (from scrapy.contrib.spidercontext) (:rev:`2780`) @@ -2289,13 +2289,13 @@ Scrapyd changes - Scrapyd now uses one process per spider - It stores one log file per spider run, and rotate them keeping the lastest 5 logs per spider (by default) - A minimal web ui was added, available at http://localhost:6800 by default -- There is now a `scrapy server` command to start a Scrapyd server of the current project +- There is now a ``scrapy server`` command to start a Scrapyd server of the current project Changes to settings ~~~~~~~~~~~~~~~~~~~ -- added `HTTPCACHE_ENABLED` setting (False by default) to enable HTTP cache middleware -- changed `HTTPCACHE_EXPIRATION_SECS` semantics: now zero means "never expire". +- added ``HTTPCACHE_ENABLED`` setting (False by default) to enable HTTP cache middleware +- changed ``HTTPCACHE_EXPIRATION_SECS`` semantics: now zero means "never expire". Deprecated/obsoleted functionality ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -2326,17 +2326,17 @@ New features and improvements - Splitted Debian package into two packages - the library and the service (#187) - Scrapy log refactoring (#188) - New extension for keeping persistent spider contexts among different runs (#203) -- Added `dont_redirect` request.meta key for avoiding redirects (#233) -- Added `dont_retry` request.meta key for avoiding retries (#234) +- Added ``dont_redirect`` request.meta key for avoiding redirects (#233) +- Added ``dont_retry`` request.meta key for avoiding retries (#234) Command-line tool changes ~~~~~~~~~~~~~~~~~~~~~~~~~ -- New `scrapy` command which replaces the old `scrapy-ctl.py` (#199) - - there is only one global `scrapy` command now, instead of one `scrapy-ctl.py` per project - - Added `scrapy.bat` script for running more conveniently from Windows +- New ``scrapy`` command which replaces the old ``scrapy-ctl.py`` (#199) + - there is only one global ``scrapy`` command now, instead of one ``scrapy-ctl.py`` per project + - Added ``scrapy.bat`` script for running more conveniently from Windows - Added bash completion to command-line tool (#210) -- Renamed command `start` to `runserver` (#209) +- Renamed command ``start`` to ``runserver`` (#209) API changes ~~~~~~~~~~~ diff --git a/docs/topics/api.rst b/docs/topics/api.rst index 985cc0433..ba832ab5d 100644 --- a/docs/topics/api.rst +++ b/docs/topics/api.rst @@ -94,7 +94,7 @@ how you :ref:`configure the downloader middlewares .. method:: crawl(\*args, \**kwargs) Starts the crawler by instantiating its spider class with the given - `args` and `kwargs` arguments, while setting the execution engine in + ``args`` and ``kwargs`` arguments, while setting the execution engine in motion. Returns a deferred that is fired when the crawl is finished. @@ -180,7 +180,7 @@ SpiderLoader API .. method:: load(spider_name) Get the Spider class with the given name. It'll look into the previously - loaded spiders for a spider class with name `spider_name` and will raise + loaded spiders for a spider class with name ``spider_name`` and will raise a KeyError if not found. :param spider_name: spider class name diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 8dbe249fa..e6812eddd 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -41,7 +41,7 @@ previous (or subsequent) middleware being applied. If you want to disable a built-in middleware (the ones defined in :setting:`DOWNLOADER_MIDDLEWARES_BASE` and enabled by default) you must define it -in your project's :setting:`DOWNLOADER_MIDDLEWARES` setting and assign `None` +in your project's :setting:`DOWNLOADER_MIDDLEWARES` setting and assign ``None`` as its value. For example, if you want to disable the user-agent middleware:: DOWNLOADER_MIDDLEWARES = { @@ -357,7 +357,7 @@ HttpCacheMiddleware .. reqmeta:: dont_cache - You can also avoid caching a response on every policy using :reqmeta:`dont_cache` meta key equals `True`. + You can also avoid caching a response on every policy using :reqmeta:`dont_cache` meta key equals ``True``. .. _httpcache-policy-dummy: @@ -390,17 +390,17 @@ runs to avoid downloading unmodified data (to save bandwidth and speed up crawls what is implemented: -* Do not attempt to store responses/requests with `no-store` cache-control directive set -* Do not serve responses from cache if `no-cache` cache-control directive is set even for fresh responses -* Compute freshness lifetime from `max-age` cache-control directive -* Compute freshness lifetime from `Expires` response header -* Compute freshness lifetime from `Last-Modified` response header (heuristic used by Firefox) -* Compute current age from `Age` response header -* Compute current age from `Date` header -* Revalidate stale responses based on `Last-Modified` response header -* Revalidate stale responses based on `ETag` response header -* Set `Date` header for any received response missing it -* Support `max-stale` cache-control directive in requests +* Do not attempt to store responses/requests with ``no-store`` cache-control directive set +* Do not serve responses from cache if ``no-cache`` cache-control directive is set even for fresh responses +* Compute freshness lifetime from ``max-age`` cache-control directive +* Compute freshness lifetime from ``Expires`` response header +* Compute freshness lifetime from ``Last-Modified`` response header (heuristic used by Firefox) +* Compute current age from ``Age`` response header +* Compute current age from ``Date`` header +* Revalidate stale responses based on ``Last-Modified`` response header +* Revalidate stale responses based on ``ETag`` response header +* Set ``Date`` header for any received response missing it +* Support ``max-stale`` cache-control directive in requests This allows spiders to be configured with the full RFC2616 cache policy, but avoid revalidation on a request-by-request basis, while remaining @@ -408,15 +408,15 @@ what is implemented: Example: - Add `Cache-Control: max-stale=600` to Request headers to accept responses that + Add ``Cache-Control: max-stale=600`` to Request headers to accept responses that have exceeded their expiration time by no more than 600 seconds. See also: RFC2616, 14.9.3 what is missing: -* `Pragma: no-cache` support https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 -* `Vary` header support https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.6 +* ``Pragma: no-cache`` support https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 +* ``Vary`` header support https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.6 * Invalidation after updates or deletes https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.10 * ... probably others .. @@ -626,12 +626,12 @@ Default: ``False`` If enabled, will cache pages unconditionally. A spider may wish to have all responses available in the cache, for -future use with `Cache-Control: max-stale`, for instance. The +future use with ``Cache-Control: max-stale``, for instance. The DummyPolicy caches all responses but never revalidates them, and sometimes a more nuanced policy is desirable. -This setting still respects `Cache-Control: no-store` directives in responses. -If you don't want that, filter `no-store` out of the Cache-Control headers in +This setting still respects ``Cache-Control: no-store`` directives in responses. +If you don't want that, filter ``no-store`` out of the Cache-Control headers in responses you feedto the cache middleware. .. setting:: HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS @@ -940,7 +940,7 @@ UserAgentMiddleware Middleware that allows spiders to override the default user agent. - In order for a spider to override the default user agent, its `user_agent` + In order for a spider to override the default user agent, its ``user_agent`` attribute must be set. .. _ajaxcrawl-middleware: diff --git a/docs/topics/exporters.rst b/docs/topics/exporters.rst index 95f7920f8..f5048d2da 100644 --- a/docs/topics/exporters.rst +++ b/docs/topics/exporters.rst @@ -303,7 +303,7 @@ CsvItemExporter The additional keyword arguments of this constructor are passed to the :class:`BaseItemExporter` constructor, and the leftover arguments to the - `csv.writer`_ constructor, so you can use any `csv.writer` constructor + `csv.writer`_ constructor, so you can use any ``csv.writer`` constructor argument to customize this exporter. A typical output of this exporter would be:: diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index c421a5e05..d6e7452a1 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -19,7 +19,7 @@ settings, just like any other Scrapy code. It is customary for extensions to prefix their settings with their own name, to avoid collision with existing (and future) extensions. For example, a hypothetic extension to handle `Google Sitemaps`_ would use settings like -`GOOGLESITEMAP_ENABLED`, `GOOGLESITEMAP_DEPTH`, and so on. +``GOOGLESITEMAP_ENABLED``, ``GOOGLESITEMAP_DEPTH``, and so on. .. _Google Sitemaps: https://en.wikipedia.org/wiki/Sitemaps @@ -368,7 +368,7 @@ Invokes a `Python debugger`_ inside a running Scrapy process when a `SIGUSR2`_ signal is received. After the debugger is exited, the Scrapy process continues running normally. -For more info see `Debugging in Python`. +For more info see `Debugging in Python`_. This extension only works on POSIX-compliant platforms (ie. not Windows). diff --git a/docs/topics/jobs.rst b/docs/topics/jobs.rst index ea684b4cf..1a5d52487 100644 --- a/docs/topics/jobs.rst +++ b/docs/topics/jobs.rst @@ -71,7 +71,7 @@ on cookies. Request serialization --------------------- -Requests must be serializable by the `pickle` module, in order for persistence +Requests must be serializable by the ``pickle`` module, in order for persistence to work, so you should make sure that your requests are serializable. The most common issue here is to use ``lambda`` functions on request callbacks that diff --git a/docs/topics/loaders.rst b/docs/topics/loaders.rst index f3b6aa4a1..1c2f1da4d 100644 --- a/docs/topics/loaders.rst +++ b/docs/topics/loaders.rst @@ -286,7 +286,7 @@ ItemLoader objects given, one is instantiated automatically using the class in :attr:`default_item_class`. - When instantiated with a `selector` or a `response` parameters + When instantiated with a ``selector`` or a ``response`` parameters the :class:`ItemLoader` class provides convenient mechanisms for extracting data from web pages using :ref:`selectors `. diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index 0986929ad..8e280d929 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -243,7 +243,7 @@ scrapy.utils.log module case, its usage is not required but it's recommended. If you plan on configuring the handlers yourself is still recommended you - call this function, passing `install_root_handler=False`. Bear in mind + call this function, passing ``install_root_handler=False``. Bear in mind there won't be any log output set by default in that case. To get you started on manually configuring logging's output, you can use diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index c60b55391..381a2988a 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -132,7 +132,7 @@ For example, the following image URL:: http://www.example.com/image.jpg -Whose `SHA1 hash` is:: +Whose ``SHA1 hash`` is:: 3afec3b4765f8f0a07b78f98c07b83f013567a0a diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 02cfa9b05..298a078a7 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -80,7 +80,7 @@ returned by the :meth:`CrawlerRunner.crawl ` method. Here's an example of its usage, along with a callback to manually stop the -reactor after `MySpider` has finished running. +reactor after ``MySpider`` has finished running. :: diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 76360b15f..8b3ba4f2d 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -50,7 +50,7 @@ Request objects :type meta: dict :param body: the request body. If a ``unicode`` is passed, then it's encoded to - ``str`` using the `encoding` passed (which defaults to ``utf-8``). If + ``str`` using the ``encoding`` passed (which defaults to ``utf-8``). If ``body`` is not given, an empty string is stored. Regardless of the type of this argument, the final value stored will be a ``str`` (never ``unicode`` or ``None``). @@ -610,7 +610,7 @@ Response objects .. attribute:: Response.flags A list that contains flags for this response. Flags are labels used for - tagging Responses. For example: `'cached'`, `'redirected`', etc. And + tagging Responses. For example: ``'cached'``, ``'redirected``', etc. And they're shown on the string representation of the Response (`__str__` method) which is used by the engine for logging. @@ -682,7 +682,7 @@ TextResponse objects ``unicode(response.body)`` is not a correct way to convert response body to unicode: you would be using the system default encoding - (typically `ascii`) instead of the response encoding. + (typically ``ascii``) instead of the response encoding. .. attribute:: TextResponse.encoding @@ -690,7 +690,7 @@ TextResponse objects A string with the encoding of this response. The encoding is resolved by trying the following mechanisms, in order: - 1. the encoding passed in the constructor `encoding` argument + 1. the encoding passed in the constructor ``encoding`` argument 2. the encoding declared in the Content-Type HTTP header. If this encoding is not valid (ie. unknown), it is ignored and the next diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index df1d67ae8..edc18f14d 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -96,7 +96,7 @@ Constructing from response - :class:`~scrapy.http.HtmlResponse` is one of Using selectors --------------- -To explain how to use the selectors we'll use the `Scrapy shell` (which +To explain how to use the selectors we'll use the ``Scrapy shell`` (which provides interactive testing) and an example page located in the Scrapy documentation server: diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 0ac26a9bd..1afa513c8 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -599,7 +599,7 @@ The amount of time (in secs) that the downloader will wait before timing out. DOWNLOAD_MAXSIZE ---------------- -Default: `1073741824` (1024MB) +Default: ``1073741824`` (1024MB) The maximum response size (in bytes) that downloader will download. @@ -620,7 +620,7 @@ If you want to disable it set to 0. DOWNLOAD_WARNSIZE ----------------- -Default: `33554432` (32MB) +Default: ``33554432`` (32MB) The response size (in bytes) that downloader will start to warn. diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst index 2b7e42771..b551aa47d 100644 --- a/docs/topics/spider-middleware.rst +++ b/docs/topics/spider-middleware.rst @@ -43,7 +43,7 @@ previous (or subsequent) middleware being applied. If you want to disable a builtin middleware (the ones defined in :setting:`SPIDER_MIDDLEWARES_BASE`, and enabled by default) you must define it -in your project :setting:`SPIDER_MIDDLEWARES` setting and assign `None` as its +in your project :setting:`SPIDER_MIDDLEWARES` setting and assign ``None`` as its value. For example, if you want to disable the off-site middleware:: SPIDER_MIDDLEWARES = { @@ -200,7 +200,7 @@ DepthMiddleware .. class:: DepthMiddleware DepthMiddleware is used for tracking the depth of each Request inside the - site being scraped. It works by setting `request.meta['depth'] = 0` whenever + site being scraped. It works by setting ``request.meta['depth'] = 0`` whenever there is no value previously set (usually just the first Request) and incrementing it by 1 otherwise. diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 742a88659..09feedefc 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -129,7 +129,7 @@ scrapy.Spider You probably won't need to override this directly because the default implementation acts as a proxy to the :meth:`__init__` method, calling - it with the given arguments `args` and named arguments `kwargs`. + it with the given arguments ``args`` and named arguments ``kwargs``. Nonetheless, this method sets the :attr:`crawler` and :attr:`settings` attributes in the new instance so they can be accessed later inside the @@ -298,13 +298,13 @@ The above example can also be written as follows:: Keep in mind that spider arguments are only strings. The spider will not do any parsing on its own. -If you were to set the `start_urls` attribute from the command line, +If you were to set the ``start_urls`` attribute from the command line, you would have to parse it on your own into a list using something like `ast.literal_eval `_ or `json.loads `_ and then set it as an attribute. -Otherwise, you would cause iteration over a `start_urls` string +Otherwise, you would cause iteration over a ``start_urls`` string (a very common python pitfall) resulting in each character being seen as a separate url. diff --git a/docs/topics/ubuntu.rst b/docs/topics/ubuntu.rst index 81ce800aa..6c993a970 100644 --- a/docs/topics/ubuntu.rst +++ b/docs/topics/ubuntu.rst @@ -22,7 +22,7 @@ To use the packages: sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 627220E7 -2. Create `/etc/apt/sources.list.d/scrapy.list` file using the following command:: +2. Create ``/etc/apt/sources.list.d/scrapy.list`` file using the following command:: echo 'deb http://archive.scrapy.org/ubuntu scrapy main' | sudo tee /etc/apt/sources.list.d/scrapy.list @@ -34,7 +34,7 @@ To use the packages: .. note:: Repeat step 3 if you are trying to upgrade Scrapy. -.. warning:: `python-scrapy` is a different package provided by official debian +.. warning:: ``python-scrapy`` is a different package provided by official debian repositories, it's very outdated and it isn't supported by Scrapy team. .. _Scrapinghub: https://scrapinghub.com/ diff --git a/scrapy/crawler.py b/scrapy/crawler.py index 04aee18ed..2ecc4daad 100644 --- a/scrapy/crawler.py +++ b/scrapy/crawler.py @@ -153,7 +153,7 @@ class CrawlerRunner(object): It will call the given Crawler's :meth:`~Crawler.crawl` method, while keeping track of it so it can be stopped later. - If `crawler_or_spidercls` isn't a :class:`~scrapy.crawler.Crawler` + If ``crawler_or_spidercls`` isn't a :class:`~scrapy.crawler.Crawler` instance, this method will try to create one using this parameter as the spider class given to it. @@ -188,10 +188,10 @@ class CrawlerRunner(object): """ Return a :class:`~scrapy.crawler.Crawler` object. - * If `crawler_or_spidercls` is a Crawler, it is returned as-is. - * If `crawler_or_spidercls` is a Spider subclass, a new Crawler + * If ``crawler_or_spidercls`` is a Crawler, it is returned as-is. + * If ``crawler_or_spidercls`` is a Spider subclass, a new Crawler is constructed for it. - * If `crawler_or_spidercls` is a string, this function finds + * If ``crawler_or_spidercls`` is a string, this function finds a spider with this name in a Scrapy project (using spider loader), then creates a Crawler instance for it. """ @@ -273,7 +273,7 @@ class CrawlerProcess(CrawlerRunner): :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS cache based on :setting:`DNSCACHE_ENABLED` and :setting:`DNSCACHE_SIZE`. - If `stop_after_crawl` is True, the reactor will be stopped after all + If ``stop_after_crawl`` is True, the reactor will be stopped after all crawlers have finished, using :meth:`join`. :param boolean stop_after_crawl: stop or not the reactor when all diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index 075a6d862..65f347dcf 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -13,21 +13,21 @@ CRAWLEDMSG = u"Crawled (%(status)s) %(request)s%(request_flags)s (referer: %(ref class LogFormatter(object): """Class for generating log messages for different actions. - All methods must return a dictionary listing the parameters `level`, `msg` - and `args` which are going to be used for constructing the log message when - calling logging.log. + All methods must return a dictionary listing the parameters ``level``, + ``msg`` and ``args`` which are going to be used for constructing the log + message when calling logging.log. Dictionary keys for the method outputs: - * `level` should be the log level for that action, you can use those + * ``level`` should be the log level for that action, you can use those from the python logging library: logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR and logging.CRITICAL. - * `msg` should be a string that can contain different formatting - placeholders. This string, formatted with the provided `args`, is going - to be the log message for that action. + * ``msg`` should be a string that can contain different formatting + placeholders. This string, formatted with the provided ``args``, is + going to be the log message for that action. - * `args` should be a tuple or dict with the formatting placeholders for - `msg`. The final log message is computed as output['msg'] % + * ``args`` should be a tuple or dict with the formatting placeholders + for ``msg``. The final log message is computed as output['msg'] % output['args']. """ diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py index 510cc23c7..2d8091f5b 100644 --- a/scrapy/pipelines/files.py +++ b/scrapy/pipelines/files.py @@ -255,13 +255,13 @@ class FilesPipeline(MediaPipeline): doing stat of the files and determining if file is new, uptodate or expired. - `new` files are those that pipeline never processed and needs to be + ``new`` files are those that pipeline never processed and needs to be downloaded from supplier site the first time. - `uptodate` files are the ones that the pipeline processed and are still + ``uptodate`` files are the ones that the pipeline processed and are still valid files. - `expired` files are those that pipeline already processed but the last + ``expired`` files are those that pipeline already processed but the last modification was made long time ago, so a reprocessing is recommended to refresh it in case of change. diff --git a/scrapy/utils/ftp.py b/scrapy/utils/ftp.py index f255d436f..9eca6a4da 100644 --- a/scrapy/utils/ftp.py +++ b/scrapy/utils/ftp.py @@ -2,7 +2,7 @@ from ftplib import error_perm from posixpath import dirname def ftp_makedirs_cwd(ftp, path, first_call=True): - """Set the current directory of the FTP connection given in the `ftp` + """Set the current directory of the FTP connection given in the ``ftp`` argument (as a ftplib.FTP object), creating all parent directories if they don't exist. The ftplib.FTP object must be already connected and logged in. """ diff --git a/scrapy/utils/log.py b/scrapy/utils/log.py index 828880709..e07fb8698 100644 --- a/scrapy/utils/log.py +++ b/scrapy/utils/log.py @@ -32,7 +32,7 @@ class TopLevelFormatter(logging.Filter): Since it can't be set for just one logger (it won't propagate for its children), it's going to be set in the root handler, with a parametrized - `loggers` list where it should act. + ``loggers`` list where it should act. """ def __init__(self, loggers=None): diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py index 732ca13a0..aade3d9ac 100644 --- a/scrapy/utils/python.py +++ b/scrapy/utils/python.py @@ -97,8 +97,8 @@ def unicode_to_str(text, encoding=None, errors='strict'): def to_unicode(text, encoding=None, errors='strict'): - """Return the unicode representation of a bytes object `text`. If `text` - is already an unicode object, return it as-is.""" + """Return the unicode representation of a bytes object ``text``. If + ``text`` is already an unicode object, return it as-is.""" if isinstance(text, six.text_type): return text if not isinstance(text, (bytes, six.text_type)): @@ -110,7 +110,7 @@ def to_unicode(text, encoding=None, errors='strict'): def to_bytes(text, encoding=None, errors='strict'): - """Return the binary representation of `text`. If `text` + """Return the binary representation of ``text``. If ``text`` is already a bytes object, return it as-is.""" if isinstance(text, bytes): return text @@ -123,7 +123,7 @@ def to_bytes(text, encoding=None, errors='strict'): def to_native_str(text, encoding=None, errors='strict'): - """ Return str representation of `text` + """ Return str representation of ``text`` (bytes in Python 2.x and unicode in Python 3.x). """ if six.PY2: return to_bytes(text, encoding, errors) @@ -189,7 +189,7 @@ def isbinarytext(text): def binary_is_text(data): - """ Returns `True` if the given ``data`` argument (a ``bytes`` object) + """ Returns ``True`` if the given ``data`` argument (a ``bytes`` object) does not contain unprintable control characters. """ if not isinstance(data, bytes): @@ -314,7 +314,7 @@ class WeakKeyCache(object): @deprecated def stringify_dict(dct_or_tuples, encoding='utf-8', keys_only=True): """Return a (new) dict with unicode keys (and values when "keys_only" is - False) of the given dict converted to strings. `dct_or_tuples` can be a + False) of the given dict converted to strings. ``dct_or_tuples`` can be a dict or a list of tuples, like any dict constructor supports. """ d = {} @@ -357,10 +357,10 @@ def retry_on_eintr(function, *args, **kw): def without_none_values(iterable): - """Return a copy of `iterable` with all `None` entries removed. + """Return a copy of ``iterable`` with all ``None`` entries removed. - If `iterable` is a mapping, return a dictionary where all pairs that have - value `None` have been removed. + If ``iterable`` is a mapping, return a dictionary where all pairs that have + value ``None`` have been removed. """ try: return {k: v for k, v in six.iteritems(iterable) if v is not None} diff --git a/scrapy/utils/url.py b/scrapy/utils/url.py index 657c53815..b3a4be007 100644 --- a/scrapy/utils/url.py +++ b/scrapy/utils/url.py @@ -109,12 +109,12 @@ def strip_url(url, strip_credentials=True, strip_default_port=True, origin_only= """Strip URL string from some of its components: - - `strip_credentials` removes "user:password@" - - `strip_default_port` removes ":80" (resp. ":443", ":21") + - ``strip_credentials`` removes "user:password@" + - ``strip_default_port`` removes ":80" (resp. ":443", ":21") from http:// (resp. https://, ftp://) URLs - - `origin_only` replaces path component with "/", also dropping + - ``origin_only`` replaces path component with "/", also dropping query and fragment components ; it also strips credentials - - `strip_fragment` drops any #fragment component + - ``strip_fragment`` drops any #fragment component """ parsed_url = urlparse(url) diff --git a/sep/sep-006.rst b/sep/sep-006.rst index 366fcf033..eb362e945 100644 --- a/sep/sep-006.rst +++ b/sep/sep-006.rst @@ -10,7 +10,8 @@ Status Obsolete (discarded) SEP-006: Rename of Selectors to Extractors ========================================== -This SEP proposes a more meaningful naming of XPathSelectors or "Selectors" and their `x` method. +This SEP proposes a more meaningful naming of XPathSelectors or "Selectors" and +their ``x`` method. Motivation ========== @@ -57,7 +58,7 @@ Additional changes As the name of the method for performing selection (the ``x`` method) is not descriptive nor mnemotechnic enough and clearly clashes with ``extract`` method (x sounds like a short for extract in english), we propose to rename it to -`select`, `sel` (is shortness if required), or `xpath` after `lxml's +``select``, ``sel`` (is shortness if required), or ``xpath`` after `lxml's `_ ``xpath`` method. Bonus (ItemBuilder) diff --git a/tests/mocks/dummydbm.py b/tests/mocks/dummydbm.py index 40d9293b2..431428331 100644 --- a/tests/mocks/dummydbm.py +++ b/tests/mocks/dummydbm.py @@ -16,7 +16,7 @@ _DATABASES = collections.defaultdict(DummyDB) def open(file, flag='r', mode=0o666): """Open or create a dummy database compatible. - Arguments `flag` and `mode` are ignored. + Arguments ``flag`` and ``mode`` are ignored. """ # return same instance for same file argument return _DATABASES[file] diff --git a/tests/test_command_shell.py b/tests/test_command_shell.py index 36baacfbd..d664b6ade 100644 --- a/tests/test_command_shell.py +++ b/tests/test_command_shell.py @@ -61,7 +61,7 @@ class ShellTest(ProcessTest, SiteTest, unittest.TestCase): @defer.inlineCallbacks def test_fetch_redirect_follow_302(self): - """Test that calling `fetch(url)` follows HTTP redirects by default.""" + """Test that calling ``fetch(url)`` follows HTTP redirects by default.""" url = self.url('/redirect-no-meta-refresh') code = "fetch('{0}')" errcode, out, errout = yield self.execute(['-c', code.format(url)]) @@ -71,7 +71,7 @@ class ShellTest(ProcessTest, SiteTest, unittest.TestCase): @defer.inlineCallbacks def test_fetch_redirect_not_follow_302(self): - """Test that calling `fetch(url, redirect=False)` disables automatic redirects.""" + """Test that calling ``fetch(url, redirect=False)`` disables automatic redirects.""" url = self.url('/redirect-no-meta-refresh') code = "fetch('{0}', redirect=False)" errcode, out, errout = yield self.execute(['-c', code.format(url)]) From 82d239f3b148d9ce69f67bd7a2cb00de7e934aa6 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Wed, 6 Mar 2019 12:08:09 +0530 Subject: [PATCH 086/503] docs for scrapy.logformatter --- docs/topics/logging.rst | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index 0986929ad..a5fecebba 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -193,6 +193,45 @@ to override some of the Scrapy settings regarding logging. Module `logging.handlers `_ Further documentation on available handlers +Custom Log Formats +------------------- + +Custom log format can be set for different actions by extending ``scrapy.logformatter.LogFormatter`` class. + +Each method of ``scrapy.logformatter.LogFormatter`` represents an action. All methods inherited from +``scrapy.logformatter.LogFormatter`` in your custom log formatting class must return a dictionary listing +the parameters ``level``, ``msg`` and ``args`` which are going to be used for constructing the log message. +Listed below is details of what each key represents : + +* ``level`` is the log level for that action, you can use those from the python logging library: + :setting:`logging.DEBUG`, :setting:`logging.INFO`, :setting:`logging.WARNING`, :setting:`logging.ERROR` + and :setting:`logging.CRITICAL`. + +* ``msg`` should be a string that can contain different formatting placeholders. This string, formatted + with the provided ``args``, is going to be the long message for that action. + +* ``args`` should be a tuple or dict with the formatting placeholders for `msg`. The final log message is + computed as ``msg % args``. + +.. note:: To use custom log formatting class, you must mention it in ``settings.py``, by adding a line + ``LOG_FORMATTER = '’`` + +.. class:: scrapy.logformatter.LogFormatter + + The default log formatting class in Scrapy. + + .. method:: crawled (request, response, spider) + + ``crawled`` is called to log message when the crawler finds a webpage. + + .. method:: scraped(item, response, spider) + + ``scraped`` is called to log message when an item scraped by a spider. + + .. method:: dropped(item, exception, response, spider) + + ``dropped`` is called to log message when an item is dropped while it is passing through the item pipeline. + Advanced customization ---------------------- From 184def1060f95767ef767948edeb3bbb4ed0e428 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Thu, 7 Mar 2019 00:09:10 +0530 Subject: [PATCH 087/503] fix a link inside docs --- docs/topics/architecture.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/architecture.rst b/docs/topics/architecture.rst index 4ac39ad2d..2effe94dc 100644 --- a/docs/topics/architecture.rst +++ b/docs/topics/architecture.rst @@ -172,5 +172,5 @@ links: .. _Twisted: https://twistedmatrix.com/trac/ .. _Introduction to Deferreds in Twisted: https://twistedmatrix.com/documents/current/core/howto/defer-intro.html -.. _Twisted - hello, asynchronous programming: http://jessenoller.com/2009/02/11/twisted-hello-asynchronous-programming/ +.. _Twisted - hello, asynchronous programming: http://jessenoller.com/blog/2009/02/11/twisted-hello-asynchronous-programming/ .. _Twisted Introduction - Krondo: http://krondo.com/an-introduction-to-asynchronous-programming-and-twisted/ From 924b67437b92f14601816d02c5d153e7281da6d4 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Thu, 7 Mar 2019 16:40:59 +0530 Subject: [PATCH 088/503] move api docs to source code --- docs/topics/logging.rst | 38 ++++---------------------------------- docs/topics/settings.rst | 9 +++++++++ scrapy/logformatter.py | 36 +++++++++++++++++++++++------------- 3 files changed, 36 insertions(+), 47 deletions(-) diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index a5fecebba..72f24bae6 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -196,41 +196,11 @@ to override some of the Scrapy settings regarding logging. Custom Log Formats ------------------- -Custom log format can be set for different actions by extending ``scrapy.logformatter.LogFormatter`` class. - -Each method of ``scrapy.logformatter.LogFormatter`` represents an action. All methods inherited from -``scrapy.logformatter.LogFormatter`` in your custom log formatting class must return a dictionary listing -the parameters ``level``, ``msg`` and ``args`` which are going to be used for constructing the log message. -Listed below is details of what each key represents : - -* ``level`` is the log level for that action, you can use those from the python logging library: - :setting:`logging.DEBUG`, :setting:`logging.INFO`, :setting:`logging.WARNING`, :setting:`logging.ERROR` - and :setting:`logging.CRITICAL`. - -* ``msg`` should be a string that can contain different formatting placeholders. This string, formatted - with the provided ``args``, is going to be the long message for that action. - -* ``args`` should be a tuple or dict with the formatting placeholders for `msg`. The final log message is - computed as ``msg % args``. - -.. note:: To use custom log formatting class, you must mention it in ``settings.py``, by adding a line - ``LOG_FORMATTER = '’`` +Custom log format can be set for different actions by extending :class:`~scrapy.logformatter.LogFormatter` class +and making :setting:`LOG_FORMATTER` inside ``settings.py`` point to your new class. -.. class:: scrapy.logformatter.LogFormatter - - The default log formatting class in Scrapy. - - .. method:: crawled (request, response, spider) - - ``crawled`` is called to log message when the crawler finds a webpage. - - .. method:: scraped(item, response, spider) - - ``scraped`` is called to log message when an item scraped by a spider. - - .. method:: dropped(item, exception, response, spider) - - ``dropped`` is called to log message when an item is dropped while it is passing through the item pipeline. +.. autoclass:: scrapy.logformatter.LogFormatter + :members: Advanced customization ---------------------- diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 0ac26a9bd..1dfb5b8aa 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -866,6 +866,15 @@ directives. .. _Python datetime documentation: https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior +.. setting:: LOG_FORMATTER + +LOG_FORMATTER +------------- + +Default: ``scrapy.logformatter.LogFormatter`` + +The class to use for formatting log messages for different actions. + .. setting:: LOG_LEVEL LOG_LEVEL diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index 075a6d862..0bb8aee58 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -13,25 +13,29 @@ CRAWLEDMSG = u"Crawled (%(status)s) %(request)s%(request_flags)s (referer: %(ref class LogFormatter(object): """Class for generating log messages for different actions. - All methods must return a dictionary listing the parameters `level`, `msg` - and `args` which are going to be used for constructing the log message when - calling logging.log. + All methods must return a dictionary listing the parameters ``level``, ``msg`` + and ``args`` which are going to be used for constructing the log message when + calling ``logging.log``. Dictionary keys for the method outputs: - * `level` should be the log level for that action, you can use those - from the python logging library: logging.DEBUG, logging.INFO, - logging.WARNING, logging.ERROR and logging.CRITICAL. - * `msg` should be a string that can contain different formatting - placeholders. This string, formatted with the provided `args`, is going - to be the log message for that action. + * ``level`` is the log level for that action, you can use those from the + `python logging library `_ : + ``logging.DEBUG``, ``logging.INFO``, ``logging.WARNING``, ``logging.ERROR`` + and ``logging.CRITICAL``. + + * ``msg`` should be a string that can contain different formatting placeholders. This string, formatted + with the provided ``args``, is going to be the long message for that action. + + * ``args`` should be a tuple or dict with the formatting placeholders for ``msg``. The final log message is + computed as ``msg % args``. - * `args` should be a tuple or dict with the formatting placeholders for - `msg`. The final log message is computed as output['msg'] % - output['args']. """ def crawled(self, request, response, spider): + """ + ``crawled`` is called to log message when the crawler finds a webpage. + """ request_flags = ' %s' % str(request.flags) if request.flags else '' response_flags = ' %s' % str(response.flags) if response.flags else '' return { @@ -40,7 +44,7 @@ class LogFormatter(object): 'args': { 'status': response.status, 'request': request, - 'request_flags' : request_flags, + 'request_flags': request_flags, 'referer': referer_str(request), 'response_flags': response_flags, # backward compatibility with Scrapy logformatter below 1.4 version @@ -49,6 +53,9 @@ class LogFormatter(object): } def scraped(self, item, response, spider): + """ + ``scraped`` is called to log message when an item is scraped by a spider. + """ if isinstance(response, Failure): src = response.getErrorMessage() else: @@ -63,6 +70,9 @@ class LogFormatter(object): } def dropped(self, item, exception, response, spider): + """ + ``dropped`` is called to log message when an item is dropped while it is passing through the item pipeline. + """ return { 'level': logging.WARNING, 'msg': DROPPEDMSG, From 4ef38d925e0ded380a7cebabe3aab2340d4f3d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 8 Mar 2019 14:21:00 +0100 Subject: [PATCH 089/503] Remove the unexisting retry_complete signal from the documentation --- docs/topics/downloader-middleware.rst | 2 -- scrapy/downloadermiddlewares/retry.py | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 8dbe249fa..9988ab18b 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -834,8 +834,6 @@ RetryMiddleware Failed pages are collected on the scraping process and rescheduled at the end, once the spider has finished crawling all regular (non failed) pages. -Once there are no more failed pages to retry, this middleware sends a signal -(retry_complete), so other extensions could connect to that signal. The :class:`RetryMiddleware` can be configured through the following settings (see the settings documentation for more info): diff --git a/scrapy/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py index 07e979628..dbc605a4c 100644 --- a/scrapy/downloadermiddlewares/retry.py +++ b/scrapy/downloadermiddlewares/retry.py @@ -7,9 +7,7 @@ RETRY_TIMES - how many times to retry a failed page RETRY_HTTP_CODES - which HTTP response codes to retry Failed pages are collected on the scraping process and rescheduled at the end, -once the spider has finished crawling all regular (non failed) pages. Once -there is no more failed pages to retry this middleware sends a signal -(retry_complete), so other extensions could connect to that signal. +once the spider has finished crawling all regular (non failed) pages. """ import logging From e108e3adbfa1a1f9bdd2a84180f18e5e43a39d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 8 Mar 2019 15:13:11 +0100 Subject: [PATCH 090/503] Clarify the documentation of DEPTH_PRIORITY further --- docs/topics/settings.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 0ac26a9bd..229a9e956 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -331,16 +331,16 @@ Default: ``0`` Scope: ``scrapy.spidermiddlewares.depth.DepthMiddleware`` -An integer that is used to adjust the request priority based on its depth: +An integer that is used to adjust the :attr:`~scrapy.http.Request.priority` of +a :class:`~scrapy.http.Request` based on its depth. -- if zero (default), no priority adjustment is made from depth -- **a positive value will decrease the priority, i.e. higher depth - requests will be processed later** ; this is commonly used when doing - breadth-first crawls (BFO) -- a negative value will increase priority, i.e., higher depth requests - will be processed sooner (DFO) +The priority of a request is adjusted as follows:: -See also: :ref:`faq-bfo-dfo` about tuning Scrapy for BFO or DFO. + request.priority = request.priority - ( depth * DEPTH_PRIORITY ) + +As depth increases, positive values of ``DEPTH_PRIORITY`` decrease request +priority (BFO), while negative values increase request priority (DFO). See +also :ref:`faq-bfo-dfo`. .. note:: From b1063d9b2ca1a6bfb947fdd8b0158633184114ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 8 Mar 2019 17:22:49 +0100 Subject: [PATCH 091/503] Use the description from README.rst on index.rst --- docs/index.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 0a96aa88e..cedde8f38 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,13 @@ Scrapy |version| documentation ============================== -This documentation contains everything you need to know about Scrapy. +Scrapy is a fast high-level `web crawling`_ and `web scraping`_ framework, used +to crawl websites and extract structured data from their pages. It can be used +for a wide range of purposes, from data mining to monitoring and automated +testing. + +.. _web crawling: https://en.wikipedia.org/wiki/Web_crawler +.. _web scraping: https://en.wikipedia.org/wiki/Web_scraping Getting help ============ From 91aec8b3bb805e9595f1b778fb14f703c6acf2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 8 Mar 2019 18:19:30 +0100 Subject: [PATCH 092/503] Update developer-tools.rst Fixes #3674 --- docs/topics/developer-tools.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/developer-tools.rst b/docs/topics/developer-tools.rst index c1976258d..82857c9da 100644 --- a/docs/topics/developer-tools.rst +++ b/docs/topics/developer-tools.rst @@ -233,7 +233,7 @@ also request each page to get every quote on the site:: name = 'quote' allowed_domains = ['quotes.toscrape.com'] page = 1 - start_urls = ['http://quotes.toscrape.com/api/quotes?page=1] + start_urls = ['http://quotes.toscrape.com/api/quotes?page=1'] def parse(self, response): data = json.loads(response.text) From 82049e9c41f878d84f0fe10f827c6fe2a33f7ba6 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Sun, 10 Mar 2019 20:14:55 +0530 Subject: [PATCH 093/503] make suggested changes. --- docs/topics/logging.rst | 10 ++++++---- docs/topics/settings.rst | 4 ++-- scrapy/logformatter.py | 26 +++++++++++++++++--------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index 72f24bae6..006530a8c 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -193,11 +193,13 @@ to override some of the Scrapy settings regarding logging. Module `logging.handlers `_ Further documentation on available handlers -Custom Log Formats -------------------- +.. _custom-log-formats: -Custom log format can be set for different actions by extending :class:`~scrapy.logformatter.LogFormatter` class -and making :setting:`LOG_FORMATTER` inside ``settings.py`` point to your new class. +Custom Log Formats +------------------ + +A custom log format can be set for different actions by extending :class:`~scrapy.logformatter.LogFormatter` class +and making :setting:`LOG_FORMATTER` point to your new class. .. autoclass:: scrapy.logformatter.LogFormatter :members: diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 1dfb5b8aa..a36c0b34c 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -871,9 +871,9 @@ directives. LOG_FORMATTER ------------- -Default: ``scrapy.logformatter.LogFormatter`` +Default: :class:`scrapy.logformatter.LogFormatter` -The class to use for formatting log messages for different actions. +The class to use for :ref:`formatting log messages ` for different actions. .. setting:: LOG_LEVEL diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index 0bb8aee58..17c69cba8 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -30,12 +30,24 @@ class LogFormatter(object): * ``args`` should be a tuple or dict with the formatting placeholders for ``msg``. The final log message is computed as ``msg % args``. + Here is an example on how to create a custom log formatter to lower the severity level of the log message + when an item is dropped from the pipeline:: + + class PoliteLogFormatter(logformatter.LogFormatter): + def dropped(self, item, exception, response, spider): + return { + 'level': logging.INFO, # lowering the level from logging.WARNING + 'msg': u"Dropped: %(exception)s" + os.linesep + "%(item)s", + 'args': { + 'exception': exception, + 'item': item, + } + } + """ def crawled(self, request, response, spider): - """ - ``crawled`` is called to log message when the crawler finds a webpage. - """ + """Logs a message when the crawler finds a webpage.""" request_flags = ' %s' % str(request.flags) if request.flags else '' response_flags = ' %s' % str(response.flags) if response.flags else '' return { @@ -53,9 +65,7 @@ class LogFormatter(object): } def scraped(self, item, response, spider): - """ - ``scraped`` is called to log message when an item is scraped by a spider. - """ + """Logs a message when an item is scraped by a spider.""" if isinstance(response, Failure): src = response.getErrorMessage() else: @@ -70,9 +80,7 @@ class LogFormatter(object): } def dropped(self, item, exception, response, spider): - """ - ``dropped`` is called to log message when an item is dropped while it is passing through the item pipeline. - """ + """Logs a message when an item is dropped while it is passing through the item pipeline.""" return { 'level': logging.WARNING, 'msg': DROPPEDMSG, From e9cd4ee03aa41e27bea0408b10970ec5bedf35d3 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Sun, 10 Mar 2019 20:37:56 +0530 Subject: [PATCH 094/503] fix list alignment and line width --- scrapy/logformatter.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/scrapy/logformatter.py b/scrapy/logformatter.py index 17c69cba8..717120242 100644 --- a/scrapy/logformatter.py +++ b/scrapy/logformatter.py @@ -19,19 +19,18 @@ class LogFormatter(object): Dictionary keys for the method outputs: - * ``level`` is the log level for that action, you can use those from the - `python logging library `_ : - ``logging.DEBUG``, ``logging.INFO``, ``logging.WARNING``, ``logging.ERROR`` - and ``logging.CRITICAL``. + * ``level`` is the log level for that action, you can use those from the + `python logging library `_ : + ``logging.DEBUG``, ``logging.INFO``, ``logging.WARNING``, ``logging.ERROR`` + and ``logging.CRITICAL``. + * ``msg`` should be a string that can contain different formatting placeholders. + This string, formatted with the provided ``args``, is going to be the long message + for that action. + * ``args`` should be a tuple or dict with the formatting placeholders for ``msg``. + The final log message is computed as ``msg % args``. - * ``msg`` should be a string that can contain different formatting placeholders. This string, formatted - with the provided ``args``, is going to be the long message for that action. - - * ``args`` should be a tuple or dict with the formatting placeholders for ``msg``. The final log message is - computed as ``msg % args``. - - Here is an example on how to create a custom log formatter to lower the severity level of the log message - when an item is dropped from the pipeline:: + Here is an example on how to create a custom log formatter to lower the severity level of + the log message when an item is dropped from the pipeline:: class PoliteLogFormatter(logformatter.LogFormatter): def dropped(self, item, exception, response, spider): From 43fd6229684b3ccca564524fc92faf009a8c4c97 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 13 Mar 2019 10:21:50 +0000 Subject: [PATCH 095/503] Rule.process_request: optionally take a Response object --- scrapy/spiders/crawl.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index e5ac72e18..5aec0fd83 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -24,12 +24,23 @@ class Rule(object): self.callback = callback self.cb_kwargs = cb_kwargs or {} self.process_links = process_links - self.process_request = process_request + self.process_request_function = process_request if follow is None: self.follow = False if callback else True else: self.follow = follow + def process_request(self, request, response): + """ + Wrapper around the request processing function to maintain backward compatibility + with functions that do not take a Response object as parameter. + """ + argcount = self.process_request_function.__code__.co_argcount + if getattr(self.process_request_function, '__self__', None): + argcount = argcount - 1 + args = [request] if argcount == 1 else [request, response] + return self.process_request_function(*args) + class CrawlSpider(Spider): @@ -65,7 +76,7 @@ class CrawlSpider(Spider): for link in links: seen.add(link) r = self._build_request(n, link) - yield rule.process_request(r) + yield rule.process_request(r, response) def _response_downloaded(self, response): rule = self._rules[response.meta['rule']] @@ -93,7 +104,7 @@ class CrawlSpider(Spider): for rule in self._rules: rule.callback = get_method(rule.callback) rule.process_links = get_method(rule.process_links) - rule.process_request = get_method(rule.process_request) + rule.process_request_function = get_method(rule.process_request_function) @classmethod def from_crawler(cls, crawler, *args, **kwargs): From 22fda61d62a2b230b0e8588eabb0d71cb77141b7 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 13 Mar 2019 10:54:38 +0000 Subject: [PATCH 096/503] Rule.process_request: tests --- tests/test_spider.py | 98 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/test_spider.py b/tests/test_spider.py index fefdaa403..5e20e0d99 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -263,6 +263,104 @@ class CrawlSpiderTest(SpiderTest): 'http://example.org/about.html', 'http://example.org/nofollow.html']) + def test_process_request(self): + + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) + + def process_request_change_domain(request): + return request.replace(url=request.url.replace('.org', '.com')) + + class _CrawlSpider(self.spider_class): + name="test" + allowed_domains=['example.org'] + rules = ( + Rule(LinkExtractor(), process_request=process_request_change_domain), + ) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + self.assertEqual(len(output), 3) + self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertEqual([r.url for r in output], + ['http://example.com/somepage/item/12.html', + 'http://example.com/about.html', + 'http://example.com/nofollow.html']) + + def test_process_request_with_response(self): + + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) + + def process_request_meta_response_class(request, response): + request.meta['response_class'] = response.__class__.__name__ + return request + + class _CrawlSpider(self.spider_class): + name="test" + allowed_domains=['example.org'] + rules = ( + Rule(LinkExtractor(), process_request=process_request_meta_response_class), + ) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + self.assertEqual(len(output), 3) + self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertEqual([r.url for r in output], + ['http://example.org/somepage/item/12.html', + 'http://example.org/about.html', + 'http://example.org/nofollow.html']) + self.assertEqual([r.meta['response_class'] for r in output], + ['HtmlResponse', 'HtmlResponse', 'HtmlResponse']) + + def test_process_request_instance_method(self): + + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) + + class _CrawlSpider(self.spider_class): + name="test" + allowed_domains=['example.org'] + rules = ( + Rule(LinkExtractor(), process_request='process_request_upper'), + ) + + def process_request_upper(self, request): + return request.replace(url=request.url.upper()) + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + self.assertEqual(len(output), 3) + self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertEqual([r.url for r in output], + ['http://EXAMPLE.ORG/SOMEPAGE/ITEM/12.HTML', + 'http://EXAMPLE.ORG/ABOUT.HTML', + 'http://EXAMPLE.ORG/NOFOLLOW.HTML']) + + def test_process_request_instance_method_with_response(self): + + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) + + class _CrawlSpider(self.spider_class): + name="test" + allowed_domains=['example.org'] + rules = ( + Rule(LinkExtractor(), process_request='process_request_meta_response_class'), + ) + + def process_request_meta_response_class(self, request, response): + request.meta['response_class'] = response.__class__.__name__ + return request + + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + self.assertEqual(len(output), 3) + self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertEqual([r.url for r in output], + ['http://example.org/somepage/item/12.html', + 'http://example.org/about.html', + 'http://example.org/nofollow.html']) + self.assertEqual([r.meta['response_class'] for r in output], + ['HtmlResponse', 'HtmlResponse', 'HtmlResponse']) + def test_follow_links_attribute_population(self): crawler = get_crawler() spider = self.spider_class.from_crawler(crawler, 'example.com') From b30ca379b6785c7ceb75e12285fe7865b4f607d1 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 13 Mar 2019 11:02:51 +0000 Subject: [PATCH 097/503] Rule.process_request: docs --- docs/topics/spiders.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 742a88659..24b6f7ec9 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -402,10 +402,12 @@ Crawling rules of links extracted from each response using the specified ``link_extractor``. This is mainly used for filtering purposes. - ``process_request`` is a callable, or a string (in which case a method from - the spider object with that name will be used) which will be called with - every request extracted by this rule, and must return a request or None (to - filter out the request). + ``process_request`` is a callable (or a string, in which case a method from + the spider object with that name will be used) which will be called for + every request extracted by this rule. This callable should take a Request object + as first positional argument and, optionally, the Response object from which the + Request originated as second positional argument. It must return a request or None + (to filter out the request). CrawlSpider example ~~~~~~~~~~~~~~~~~~~ From 83ec947fe732035e147c21df352e199ce2cce5c8 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 13 Mar 2019 11:23:51 +0000 Subject: [PATCH 098/503] Rule.process_request defaults to None in the docs --- scrapy/spiders/crawl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index 5aec0fd83..ad86fc19d 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -19,12 +19,12 @@ def identity(x): class Rule(object): - def __init__(self, link_extractor, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=identity): + def __init__(self, link_extractor, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=None): self.link_extractor = link_extractor self.callback = callback self.cb_kwargs = cb_kwargs or {} self.process_links = process_links - self.process_request_function = process_request + self.process_request_function = process_request or identity if follow is None: self.follow = False if callback else True else: From a2ff647aace899982bd494d73fea5a7c35ded722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 15 Mar 2019 06:36:15 +0100 Subject: [PATCH 099/503] Remove docs/topics/ubuntu.rst --- docs/topics/ubuntu.rst | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 docs/topics/ubuntu.rst diff --git a/docs/topics/ubuntu.rst b/docs/topics/ubuntu.rst deleted file mode 100644 index 6c993a970..000000000 --- a/docs/topics/ubuntu.rst +++ /dev/null @@ -1,41 +0,0 @@ -:orphan: Ubuntu packages are obsolete - -.. _topics-ubuntu: - -=============== -Ubuntu packages -=============== - -.. versionadded:: 0.10 - -`Scrapinghub`_ publishes apt-gettable packages which are generally fresher than -those in Ubuntu, and more stable too since they're continuously built from -`GitHub repo`_ (master & stable branches) and so they contain the latest bug -fixes. - -.. caution:: These packages are currently not updated and may not work on - Ubuntu 16.04 and above, see :issue:`2076` and :issue:`2137`. - -To use the packages: - -1. Import the GPG key used to sign Scrapy packages into APT keyring:: - - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 627220E7 - -2. Create ``/etc/apt/sources.list.d/scrapy.list`` file using the following command:: - - echo 'deb http://archive.scrapy.org/ubuntu scrapy main' | sudo tee /etc/apt/sources.list.d/scrapy.list - -3. Update package lists and install the scrapy package: - - .. parsed-literal:: - - sudo apt-get update && sudo apt-get install scrapy - -.. note:: Repeat step 3 if you are trying to upgrade Scrapy. - -.. warning:: ``python-scrapy`` is a different package provided by official debian - repositories, it's very outdated and it isn't supported by Scrapy team. - -.. _Scrapinghub: https://scrapinghub.com/ -.. _GitHub repo: https://github.com/scrapy/scrapy From 01ed605d02013b1d7955369562b2443d2a561599 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 15 Mar 2019 16:54:14 +0000 Subject: [PATCH 100/503] PEP8 changes to test_spider.py --- tests/test_spider.py | 60 +++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/tests/test_spider.py b/tests/test_spider.py index 5e20e0d99..c9af7a2d7 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -105,11 +105,11 @@ class SpiderTest(unittest.TestCase): def test_logger(self): spider = self.spider_class('example.com') - with LogCapture() as l: + with LogCapture() as lc: spider.logger.info('test log msg') - l.check(('example.com', 'INFO', 'test log msg')) + lc.check(('example.com', 'INFO', 'test log msg')) - record = l.records[0] + record = lc.records[0] self.assertIn('spider', record.__dict__) self.assertIs(record.spider, spider) @@ -190,12 +190,11 @@ class CrawlSpiderTest(SpiderTest): def test_process_links(self): - response = HtmlResponse("http://example.org/somepage/index.html", - body=self.test_body) + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) class _CrawlSpider(self.spider_class): - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_links="dummy_process_links"), ) @@ -208,24 +207,24 @@ class CrawlSpiderTest(SpiderTest): self.assertEqual(len(output), 3) self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) self.assertEqual([r.url for r in output], - ['http://example.org/somepage/item/12.html', - 'http://example.org/about.html', - 'http://example.org/nofollow.html']) + ['http://example.org/somepage/item/12.html', + 'http://example.org/about.html', + 'http://example.org/nofollow.html']) def test_process_links_filter(self): - response = HtmlResponse("http://example.org/somepage/index.html", - body=self.test_body) + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) class _CrawlSpider(self.spider_class): import re - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_links="filter_process_links"), ) _test_regex = re.compile('nofollow') + def filter_process_links(self, links): return [link for link in links if not self._test_regex.search(link.url)] @@ -235,17 +234,16 @@ class CrawlSpiderTest(SpiderTest): self.assertEqual(len(output), 2) self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) self.assertEqual([r.url for r in output], - ['http://example.org/somepage/item/12.html', - 'http://example.org/about.html']) + ['http://example.org/somepage/item/12.html', + 'http://example.org/about.html']) def test_process_links_generator(self): - response = HtmlResponse("http://example.org/somepage/index.html", - body=self.test_body) + response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) class _CrawlSpider(self.spider_class): - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_links="dummy_process_links"), ) @@ -259,9 +257,9 @@ class CrawlSpiderTest(SpiderTest): self.assertEqual(len(output), 3) self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) self.assertEqual([r.url for r in output], - ['http://example.org/somepage/item/12.html', - 'http://example.org/about.html', - 'http://example.org/nofollow.html']) + ['http://example.org/somepage/item/12.html', + 'http://example.org/about.html', + 'http://example.org/nofollow.html']) def test_process_request(self): @@ -271,8 +269,8 @@ class CrawlSpiderTest(SpiderTest): return request.replace(url=request.url.replace('.org', '.com')) class _CrawlSpider(self.spider_class): - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_request=process_request_change_domain), ) @@ -295,8 +293,8 @@ class CrawlSpiderTest(SpiderTest): return request class _CrawlSpider(self.spider_class): - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_request=process_request_meta_response_class), ) @@ -317,8 +315,8 @@ class CrawlSpiderTest(SpiderTest): response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) class _CrawlSpider(self.spider_class): - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_request='process_request_upper'), ) @@ -340,8 +338,8 @@ class CrawlSpiderTest(SpiderTest): response = HtmlResponse("http://example.org/somepage/index.html", body=self.test_body) class _CrawlSpider(self.spider_class): - name="test" - allowed_domains=['example.org'] + name = "test" + allowed_domains = ['example.org'] rules = ( Rule(LinkExtractor(), process_request='process_request_meta_response_class'), ) From 645e8d16a4c966b50bd39667aaef28dc1eeb43b8 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 15 Mar 2019 22:20:36 +0000 Subject: [PATCH 101/503] Count keyword argument checks --- tests/spiders.py | 21 +++++++++++++-------- tests/test_crawl.py | 3 ++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/spiders.py b/tests/spiders.py index 5a1471072..7b4707f62 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -31,7 +31,7 @@ class MetaSpider(MockServerSpider): class KeywordArgumentsSpider(MockServerSpider): name = 'kwargs' - checks = set() + checks = list() def start_requests(self): data = {'key': 'value', 'number': 123} @@ -41,25 +41,30 @@ class KeywordArgumentsSpider(MockServerSpider): yield Request(self.mockserver.url('/no_kwargs'), self.parse_no_kwargs) def parse_first(self, response, key, number): - self.checks.add(key == 'value') - self.checks.add(number == 123) + self.checks.append(key == 'value') + self.checks.append(number == 123) + self.crawler.stats.inc_value('boolean_checks', 2) yield response.follow( self.mockserver.url('/two'), self.parse_second, kwargs={'new_key': 'new_value'}) def parse_second(self, response, new_key): - self.checks.add(new_key == 'new_value') + self.checks.append(new_key == 'new_value') + self.crawler.stats.inc_value('boolean_checks') def parse_general(self, response, **kwargs): if response.url.endswith('/general_with'): - self.checks.add(kwargs['key'] == 'value') - self.checks.add(kwargs['number'] == 123) + self.checks.append(kwargs['key'] == 'value') + self.checks.append(kwargs['number'] == 123) + self.crawler.stats.inc_value('boolean_checks', 2) elif response.url.endswith('/general_without'): - self.checks.add(kwargs == {}) + self.checks.append(kwargs == {}) + self.crawler.stats.inc_value('boolean_checks') def parse_no_kwargs(self, response): - pass + self.checks.append(response.url.endswith('/no_kwargs')) + self.crawler.stats.inc_value('boolean_checks') class FollowAllSpider(MetaSpider): diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 3879a017c..9a39b8cb4 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -27,7 +27,8 @@ class CrawlTestCase(TestCase): def test_callback_kwargs(self): crawler = self.runner.create_crawler(KeywordArgumentsSpider) yield crawler.crawl(mockserver=self.mockserver) - self.assertEqual(crawler.spider.checks, set([True])) + self.assertTrue(all(crawler.spider.checks)) + self.assertEqual(len(crawler.spider.checks), crawler.stats.get_value('boolean_checks')) @defer.inlineCallbacks def test_follow_all(self): From 6760bca74b1f51ce83ed73318d5ddcef03c9d129 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 15 Mar 2019 22:32:45 +0000 Subject: [PATCH 102/503] Rename Request.kwargs to Request.cb_kwargs --- docs/topics/request-response.rst | 16 ++++++++-------- scrapy/core/scraper.py | 4 +++- scrapy/http/request/__init__.py | 14 +++++++------- scrapy/http/response/__init__.py | 4 ++-- scrapy/http/response/text.py | 4 ++-- scrapy/utils/reqser.py | 4 ++-- tests/spiders.py | 6 +++--- tests/test_http_request.py | 8 ++++---- tests/test_utils_reqser.py | 4 ++-- 9 files changed, 33 insertions(+), 31 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index d12766676..b3f849540 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -24,7 +24,7 @@ below in :ref:`topics-request-response-ref-request-subclasses` and Request objects =============== -.. class:: Request(url[, callback, method='GET', headers, body, cookies, meta, encoding='utf-8', priority=0, dont_filter=False, errback, flags, kwargs]) +.. class:: Request(url[, callback, method='GET', headers, body, cookies, meta, encoding='utf-8', priority=0, dont_filter=False, errback, flags, cb_kwargs]) A :class:`Request` object represents an HTTP request, which is usually generated in the Spider and executed by the Downloader, and thus generating @@ -126,8 +126,8 @@ Request objects :param flags: Flags sent to the request, can be used for logging or similar purposes. :type flags: list - :param kwargs: A dict with arbitrary data that will be passed as keyword arguments to the Request's callback. - :type kwargs: dict + :param cb_kwargs: A dict with arbitrary data that will be passed as keyword arguments to the Request's callback. + :type cb_kwargs: dict .. attribute:: Request.url @@ -168,7 +168,7 @@ Request objects ``copy()`` or ``replace()`` methods, and can also be accessed, in your spider, from the ``response.meta`` attribute. - .. attribute:: Request.kwargs + .. attribute:: Request.cb_kwargs A dictionary that contains arbitrary metadata for this request. Its contents will be passed to the Request's callback as keyword arguments. It is empty @@ -177,7 +177,7 @@ Request objects This dict is `shallow copied`_ when the request is cloned using the ``copy()`` or ``replace()`` methods, and can also be accessed, in your - spider, from the ``response.kwargs`` attribute. + spider, from the ``response.cb_kwargs`` attribute. .. _shallow copied: https://docs.python.org/2/library/copy.html @@ -216,7 +216,7 @@ Example:: In some cases you may be interested in passing arguments to those callback functions so you can receive the arguments later, in the second callback. The following two examples show how to achieve this by using the -:attr:`Request.meta` and :attr:`Request.kwargs` attributes respectively:: +:attr:`Request.meta` and :attr:`Request.cb_kwargs` attributes respectively:: def parse_page1(self, response): item = MyItem() @@ -238,8 +238,8 @@ The following two examples show how to achieve this by using the item['main_url'] = response.url request = scrapy.Request("http://www.example.com/some_page.html", callback=self.parse_page2) - request.kwargs['item'] = item - request.kwargs['foo'] = 'bar' + request.cb_kwargs['item'] = item + request.cb_kwargs['foo'] = 'bar' yield request def parse_page2(self, response, item, foo): diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py index 7981ce231..08dd1acc5 100644 --- a/scrapy/core/scraper.py +++ b/scrapy/core/scraper.py @@ -143,7 +143,9 @@ class Scraper(object): def call_spider(self, result, request, spider): result.request = request dfd = defer_result(result) - dfd.addCallbacks(request.callback or spider.parse, request.errback, callbackKeywords=request.kwargs) + dfd.addCallbacks(callback=request.callback or spider.parse, + errback=request.errback, + callbackKeywords=request.cb_kwargs) return dfd.addCallback(iterate_spider_output) def handle_spider_error(self, _failure, request, response, spider): diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py index c016eb727..f5935c4ef 100644 --- a/scrapy/http/request/__init__.py +++ b/scrapy/http/request/__init__.py @@ -18,7 +18,7 @@ class Request(object_ref): def __init__(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding='utf-8', priority=0, - dont_filter=False, errback=None, flags=None, kwargs=None): + dont_filter=False, errback=None, flags=None, cb_kwargs=None): self._encoding = encoding # this one has to be set first self.method = str(method).upper() @@ -40,14 +40,14 @@ class Request(object_ref): self.dont_filter = dont_filter self._meta = dict(meta) if meta else None - self._kwargs = dict(kwargs) if kwargs else None + self._cb_kwargs = dict(cb_kwargs) if cb_kwargs else None self.flags = [] if flags is None else list(flags) @property - def kwargs(self): - if self._kwargs is None: - self._kwargs = {} - return self._kwargs + def cb_kwargs(self): + if self._cb_kwargs is None: + self._cb_kwargs = {} + return self._cb_kwargs @property def meta(self): @@ -99,7 +99,7 @@ class Request(object_ref): given new values. """ for x in ['url', 'method', 'headers', 'body', 'cookies', 'meta', 'flags', - 'encoding', 'priority', 'dont_filter', 'callback', 'errback', 'kwargs']: + 'encoding', 'priority', 'dont_filter', 'callback', 'errback', 'cb_kwargs']: kwargs.setdefault(x, getattr(self, x)) cls = kwargs.pop('cls', self.__class__) return cls(*args, **kwargs) diff --git a/scrapy/http/response/__init__.py b/scrapy/http/response/__init__.py index 99b04a26e..b0a526b72 100644 --- a/scrapy/http/response/__init__.py +++ b/scrapy/http/response/__init__.py @@ -106,7 +106,7 @@ class Response(object_ref): def follow(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding='utf-8', priority=0, - dont_filter=False, errback=None, kwargs=None): + dont_filter=False, errback=None, cb_kwargs=None): # type: (...) -> Request """ Return a :class:`~.Request` instance to follow a link ``url``. @@ -133,4 +133,4 @@ class Response(object_ref): priority=priority, dont_filter=dont_filter, errback=errback, - kwargs=kwargs) + cb_kwargs=cb_kwargs) diff --git a/scrapy/http/response/text.py b/scrapy/http/response/text.py index 2039621b3..339913d4e 100644 --- a/scrapy/http/response/text.py +++ b/scrapy/http/response/text.py @@ -123,7 +123,7 @@ class TextResponse(Response): def follow(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding=None, priority=0, - dont_filter=False, errback=None, kwargs=None): + dont_filter=False, errback=None, cb_kwargs=None): # type: (...) -> Request """ Return a :class:`~.Request` instance to follow a link ``url``. @@ -155,7 +155,7 @@ class TextResponse(Response): priority=priority, dont_filter=dont_filter, errback=errback, - kwargs=kwargs, + cb_kwargs=cb_kwargs, ) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index d537057b1..e7016b92a 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -33,7 +33,7 @@ def request_to_dict(request, spider=None): 'priority': request.priority, 'dont_filter': request.dont_filter, 'flags': request.flags, - 'kwargs': request.kwargs, + 'cb_kwargs': request.cb_kwargs, } if type(request) is not Request: d['_class'] = request.__module__ + '.' + request.__class__.__name__ @@ -66,7 +66,7 @@ def request_from_dict(d, spider=None): priority=d['priority'], dont_filter=d['dont_filter'], flags=d.get('flags'), - kwargs=d.get('kwargs'), + cb_kwargs=d.get('cb_kwargs'), ) diff --git a/tests/spiders.py b/tests/spiders.py index 7b4707f62..a06985837 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -35,8 +35,8 @@ class KeywordArgumentsSpider(MockServerSpider): def start_requests(self): data = {'key': 'value', 'number': 123} - yield Request(self.mockserver.url('/first'), self.parse_first, kwargs=data) - yield Request(self.mockserver.url('/general_with'), self.parse_general, kwargs=data) + yield Request(self.mockserver.url('/first'), self.parse_first, cb_kwargs=data) + yield Request(self.mockserver.url('/general_with'), self.parse_general, cb_kwargs=data) yield Request(self.mockserver.url('/general_without'), self.parse_general) yield Request(self.mockserver.url('/no_kwargs'), self.parse_no_kwargs) @@ -47,7 +47,7 @@ class KeywordArgumentsSpider(MockServerSpider): yield response.follow( self.mockserver.url('/two'), self.parse_second, - kwargs={'new_key': 'new_value'}) + cb_kwargs={'new_key': 'new_value'}) def parse_second(self, response, new_key): self.checks.append(new_key == 'new_value') diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 610893d8a..c1949a28c 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -177,7 +177,7 @@ class RequestTest(unittest.TestCase): r1 = self.request_class("http://www.example.com", flags=['f1', 'f2'], callback=somecallback, errback=somecallback) r1.meta['foo'] = 'bar' - r1.kwargs['key'] = 'value' + r1.cb_kwargs['key'] = 'value' r2 = r1.copy() # make sure copy does not propagate callbacks @@ -190,9 +190,9 @@ class RequestTest(unittest.TestCase): assert r1.flags is not r2.flags, "flags must be a shallow copy, not identical" self.assertEqual(r1.flags, r2.flags) - # make sure kwargs dict is shallow copied - assert r1.kwargs is not r2.kwargs, "kwargs must be a shallow copy, not identical" - self.assertEqual(r1.kwargs, r2.kwargs) + # make sure cb_kwargs dict is shallow copied + assert r1.cb_kwargs is not r2.cb_kwargs, "cb_kwargs must be a shallow copy, not identical" + self.assertEqual(r1.cb_kwargs, r2.cb_kwargs) # make sure meta dict is shallow copied assert r1.meta is not r2.meta, "meta must be a shallow copy, not identical" diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index 76de20f22..e1601b76b 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -26,7 +26,7 @@ class RequestSerializationTest(unittest.TestCase): encoding='latin-1', priority=20, meta={'a': 'b'}, - kwargs={'k': 'v'}, + cb_kwargs={'k': 'v'}, flags=['testFlag']) self._assert_serializes_ok(r, spider=self.spider) @@ -53,7 +53,7 @@ class RequestSerializationTest(unittest.TestCase): self.assertEqual(r1.headers, r2.headers) self.assertEqual(r1.cookies, r2.cookies) self.assertEqual(r1.meta, r2.meta) - self.assertEqual(r1.kwargs, r2.kwargs) + self.assertEqual(r1.cb_kwargs, r2.cb_kwargs) self.assertEqual(r1._encoding, r2._encoding) self.assertEqual(r1.priority, r2.priority) self.assertEqual(r1.dont_filter, r2.dont_filter) From 92bbc5290d2b381ea60d68442a887d1ba020874e Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Sat, 16 Mar 2019 05:41:40 +0000 Subject: [PATCH 103/503] Rule.process_request - Renaming --- scrapy/spiders/crawl.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index ad86fc19d..c01f75798 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -24,22 +24,22 @@ class Rule(object): self.callback = callback self.cb_kwargs = cb_kwargs or {} self.process_links = process_links - self.process_request_function = process_request or identity + self.process_request = process_request or identity if follow is None: self.follow = False if callback else True else: self.follow = follow - def process_request(self, request, response): + def _process_request(self, request, response): """ Wrapper around the request processing function to maintain backward compatibility with functions that do not take a Response object as parameter. """ - argcount = self.process_request_function.__code__.co_argcount - if getattr(self.process_request_function, '__self__', None): + argcount = self.process_request.__code__.co_argcount + if hasattr(self.process_request, '__self__'): argcount = argcount - 1 args = [request] if argcount == 1 else [request, response] - return self.process_request_function(*args) + return self.process_request(*args) class CrawlSpider(Spider): @@ -75,8 +75,8 @@ class CrawlSpider(Spider): links = rule.process_links(links) for link in links: seen.add(link) - r = self._build_request(n, link) - yield rule.process_request(r, response) + request = self._build_request(n, link) + yield rule._process_request(request, response) def _response_downloaded(self, response): rule = self._rules[response.meta['rule']] @@ -104,7 +104,7 @@ class CrawlSpider(Spider): for rule in self._rules: rule.callback = get_method(rule.callback) rule.process_links = get_method(rule.process_links) - rule.process_request_function = get_method(rule.process_request_function) + rule.process_request = get_method(rule.process_request) @classmethod def from_crawler(cls, crawler, *args, **kwargs): From 044318920a463d2e04efe2a4c65d8f72d1b5ecb6 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Sun, 17 Mar 2019 16:54:28 +0530 Subject: [PATCH 104/503] doc for creating custom cache storage backend. --- docs/topics/downloader-middleware.rst | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 0d976077b..f913b059d 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -496,6 +496,59 @@ In order to use this storage backend: .. _LevelDB: https://github.com/google/leveldb .. _leveldb python bindings: https://pypi.python.org/pypi/leveldb +.. _httpcache-storage-custom: + +Implementing custom cache storage backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can implement custom cache storage backend by creating a Python class that +defines the methods described below. + +.. module:: scrapy.extensions.httpcache + +.. class:: CacheStorage + + .. method:: open_spider(spider) + + This method gets called after a spider has been opened for crawling. + + :param spider: the spider which has been opened + :type spider: :class:`~scrapy.spiders.Spider` object + + .. method:: close_spider(spider) + + This method gets called after a spider has been closed. + + :param spider: the spider which has been closed + :type spider: :class:`~scrapy.spiders.Spider` object + + .. method:: retrieve_response(spider, request) + + Returns response if present in cache, or ``None`` otherwise. + + :param spider: the spider which generated the request + :type spider: :class:`~scrapy.spiders.Spider` object + + :param request: the request to find cached reponse for + :type request: :class:`~scrapy.http.Request` object + + .. method:: store_response(spider, request, response) + + Stores the given response in the cache. + + :param spider: the spider for which the response is intended + :type spider: :class:`~scrapy.spiders.Spider` object + + :param request: corresponding request the spider generated + :type request: :class:`~scrapy.http.Request` object + + :param response: the response to store in the cache + :type response: :class:`~scrapy.http.Response` object + +In order to use your storage backend, set: + +* :setting:`HTTPCACHE_STORAGE` to path of your custom storage class. + HTTPCache middleware settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 70aa5b1333a981b3b5eae70e29144c4c25fcfd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Hern=C3=A1ndez?= Date: Wed, 20 Mar 2019 15:32:20 +0100 Subject: [PATCH 105/503] Fix numeration --- docs/topics/selectors.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/selectors.rst b/docs/topics/selectors.rst index edc18f14d..282a585d4 100644 --- a/docs/topics/selectors.rst +++ b/docs/topics/selectors.rst @@ -436,7 +436,7 @@ The following examples show how these methods map to each other. >>> response.css('a::attr(href)').extract() ['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html'] -2. ``Selector.get()`` is the same as ``Selector.extract()``:: +3. ``Selector.get()`` is the same as ``Selector.extract()``:: >>> response.css('a::attr(href)')[0].get() 'image1.html' From 821f5bb26077d7f9a6b2b1a72f210f81779f5393 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Mon, 3 Dec 2018 11:00:03 +0000 Subject: [PATCH 106/503] First implementation handle exception use O(N) instead of O(NlogN) here we have request as struct additional check for meptiness small performance improvement do not consume another request test number of responses mark requests back to 3 slots test case raise exceptions in case of missed meta add marks to requests and work only with your own requests only disk queue should obtain signals separate functions for slot rasd/write use signlas without variable stop crawler get signals in correct place logic test for download-aware priority queue update comment for structure ensure text type transform slot name to path use implicit structure use unicode type implicitly use real crawler add signals more slot accounting simple implementation of pop small slot accounting code no need for custom len function ability to call super in py27 add slots generic tests for downloader aware queue dummy implementation of crawler aware priority queue move common logic to base class rename class pass crawler to pqclass constructor do not copy quelib.PriorityQueue code add comment about new class remove obsolete function modify behaviour of queuelib.PriorityQueue to dodge very complex priority better way to get name remove obsolete commentary check boundaries function for priority convertion with known limits correct import path move file do not switch on by deffault as ip concurrency not supported set scheduler slot in case of empty slot use constant single place for added urls single place for constants use as default queue correct format for error text test migration from old version with on disk queue in these tests we have only two inflection points - jobdir and priority_queue_cls we do not need separate mock spider, use usual one do not rely on order of dict elements, imply order of list test round robiness of priority queue add comments and requirements for our magick function remove debug logging put queues into slot as we fabricate priorities we do not need special types anymore fabricate priority for priority queue more versatile priorities Scheduler class is not inflection point wrap correct types check for emptinees before initialization tests for new priority queue correct default type for startprios use exact values put common settings to base class test priorities for disk scheduler test dequeue for disk scheduler test length for disk scheduler setUp/tearDown methods for on disk schedulers new methods remove excessive line base class to handle scheduler creation correct method names test priorities deque test close scheduler on test end enqueue some requests test template for scheduler use downloader slot I/O implementation for RoundRobin queue round-robin implementation without I/O and slot detection wrappers for every disk queue class --- scrapy/core/downloader/__init__.py | 8 +- scrapy/core/queues.py | 15 -- scrapy/core/scheduler.py | 17 +- scrapy/pqueues.py | 246 ++++++++++++++++++++++ tests/test_scheduler.py | 315 +++++++++++++++++++++++++++++ 5 files changed, 578 insertions(+), 23 deletions(-) delete mode 100644 scrapy/core/queues.py create mode 100644 scrapy/pqueues.py create mode 100644 tests/test_scheduler.py diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 59c3ad074..4695d75f4 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -75,6 +75,8 @@ def _get_concurrency_delay(concurrency, spider, settings): class Downloader(object): + DOWNLOAD_SLOT = 'download_slot' + def __init__(self, crawler): self.settings = crawler.settings self.signals = crawler.signals @@ -111,8 +113,8 @@ class Downloader(object): return key, self.slots[key] def _get_slot_key(self, request, spider): - if 'download_slot' in request.meta: - return request.meta['download_slot'] + if self.DOWNLOAD_SLOT in request.meta: + return request.meta[self.DOWNLOAD_SLOT] key = urlparse_cached(request).hostname or '' if self.ip_concurrency: @@ -122,7 +124,7 @@ class Downloader(object): def _enqueue_request(self, request, spider): key, slot = self._get_slot(request, spider) - request.meta['download_slot'] = key + request.meta[self.DOWNLOAD_SLOT] = key def _deactivate(response): slot.active.remove(request) diff --git a/scrapy/core/queues.py b/scrapy/core/queues.py deleted file mode 100644 index 96d582fc7..000000000 --- a/scrapy/core/queues.py +++ /dev/null @@ -1,15 +0,0 @@ -import uuid -import os.path - - -def unique_files_queue(queue_class): - - class UniqueFilesQueue(queue_class): - def __init__(self, path): - path = path + "-" + uuid.uuid4().hex - while os.path.exists(path): - path = path + "-" + uuid.uuid4().hex - - super().__init__(path) - - return UniqueFilesQueue diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index eb790a67e..d40f3aa0c 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) class Scheduler(object): def __init__(self, dupefilter, jobdir=None, dqclass=None, mqclass=None, - logunser=False, stats=None, pqclass=None): + logunser=False, stats=None, pqclass=None, crawler=None): self.df = dupefilter self.dqdir = self._dqdir(jobdir) self.pqclass = pqclass @@ -21,6 +21,7 @@ class Scheduler(object): self.mqclass = mqclass self.logunser = logunser self.stats = stats + self.crawler = crawler @classmethod def from_crawler(cls, crawler): @@ -32,14 +33,15 @@ class Scheduler(object): mqclass = load_object(settings['SCHEDULER_MEMORY_QUEUE']) logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', settings.getbool('SCHEDULER_DEBUG')) return cls(dupefilter, jobdir=job_dir(settings), logunser=logunser, - stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, mqclass=mqclass) + stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, + mqclass=mqclass, crawler=crawler) def has_pending_requests(self): return len(self) > 0 def open(self, spider): self.spider = spider - self.mqs = self.pqclass(self._newmq) + self.mqs = create_instance(self.pqclass, None, self.crawler, self._newmq) self.dqs = self._dq() if self.dqdir else None return self.df.open() @@ -111,7 +113,7 @@ class Scheduler(object): return self.mqclass() def _newdq(self, priority): - return self.dqclass(join(self.dqdir, 'p%s' % priority)) + return self.dqclass(join(self.dqdir, 'p%s' % (priority, ))) def _dq(self): activef = join(self.dqdir, 'active.json') @@ -120,7 +122,12 @@ class Scheduler(object): prios = json.load(f) else: prios = () - q = self.pqclass(self._newdq, startprios=prios) + + q = create_instance(self.pqclass, + None, + self.crawler, + self._newdq, + startprios=prios) if q: logger.info("Resuming crawl (%(queuesize)d requests scheduled)", {'queuesize': len(q)}, extra={'spider': self.spider}) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py new file mode 100644 index 000000000..75073b7a4 --- /dev/null +++ b/scrapy/pqueues.py @@ -0,0 +1,246 @@ +from collections import deque +import hashlib +import logging +from six import text_type +from six.moves.urllib.parse import urlparse + +from queuelib import PriorityQueue + +from scrapy.core.downloader import Downloader +from scrapy.http import Request +from scrapy.signals import request_reached_downloader, response_downloaded + + +logger = logging.getLogger(__name__) + + +SCHEDULER_SLOT_META_KEY = Downloader.DOWNLOAD_SLOT + + +def _get_from_request(request, key, default=None): + if isinstance(request, dict): + return request.get(key, default) + + if isinstance(request, Request): + return getattr(request, key, default) + + raise ValueError('Bad type of request "%s"' % (request.__class__, )) + + +def _scheduler_slot_read(request, default=None): + meta = _get_from_request(request, 'meta', dict()) + slot = meta.get(SCHEDULER_SLOT_META_KEY, default) + return slot + + +def _scheduler_slot_write(request, slot): + meta = _get_from_request(request, 'meta', None) + if not isinstance(meta, dict): + raise ValueError('No meta attribute in %s' % (request, )) + meta[SCHEDULER_SLOT_META_KEY] = slot + + +def _scheduler_slot(request): + + slot = _scheduler_slot_read(request, None) + if slot is None: + url = _get_from_request(request, 'url') + slot = urlparse(url).hostname or '' + _scheduler_slot_write(request, slot) + + return slot + + +def _pathable(x): + pathable_slot = "".join([c if c.isalnum() or c in '-._' else '_' for c in x]) + + """ + as we replace some letters we can get collision for different slots + add we add unique part + """ + unique_slot = hashlib.md5(x.encode('utf8')).hexdigest() + + return '-'.join([pathable_slot, unique_slot]) + + +class PrioritySlot: + __slots__ = ('priority', 'slot') + + def __init__(self, priority=0, slot=None): + self.priority = priority + self.slot = slot + + def __hash__(self): + return hash((self.priority, self.slot)) + + def __eq__(self, other): + return (self.priority, self.slot) == (other.priority, other.slot) + + def __lt__(self, other): + return (self.priority, self.slot) < (other.priority, other.slot) + + def __str__(self): + return '_'.join([text_type(self.priority), _pathable(text_type(self.slot))]) + + +class PriorityAsTupleQueue(PriorityQueue): + """ + Python structures is not directly (de)serialized (to)from json. + We need this modified queue to transform custom structure (from)to + json serializable structures + """ + def __init__(self, qfactory, startprios=()): + + super(PriorityAsTupleQueue, self).__init__( + qfactory, + [PrioritySlot(priority=p[0], slot=p[1]) for p in startprios] + ) + + def close(self): + startprios = super(PriorityAsTupleQueue, self).close() + return [(s.priority, s.slot) for s in startprios] + + def is_empty(self): + return not self.queues or len(self) == 0 + + +class SlotBasedPriorityQueue(object): + + def __init__(self, qfactory, startprios={}): + self.pqueues = dict() # slot -> priority queue + self.qfactory = qfactory # factory for creating new internal queues + + if not startprios: + return + + if not isinstance(startprios, dict): + raise ValueError("Looks like your priorities file malforfemed. " + "Possible reason: You run scrapy with previous " + "version. Interrupted it. Updated scrapy. And " + "run again.") + + for slot, prios in startprios.items(): + self.pqueues[slot] = PriorityAsTupleQueue(self.qfactory, prios) + + def pop_slot(self, slot): + queue = self.pqueues[slot] + request = queue.pop() + is_empty = queue.is_empty() + if is_empty: + del self.pqueues[slot] + + return request, is_empty + + def push_slot(self, request, priority): + slot = _scheduler_slot(request) + is_new = False + if slot not in self.pqueues: + is_new = True + self.pqueues[slot] = PriorityAsTupleQueue(self.qfactory) + self.pqueues[slot].push(request, PrioritySlot(priority=priority, slot=slot)) + return slot, is_new + + def close(self): + startprios = dict() + for slot, queue in self.pqueues.items(): + prios = queue.close() + startprios[slot] = prios + self.pqueues.clear() + return startprios + + def __len__(self): + return sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0 + + +class RoundRobinPriorityQueue(SlotBasedPriorityQueue): + + def __init__(self, qfactory, startprios={}): + super(RoundRobinPriorityQueue, self).__init__(qfactory, startprios) + self._slots = deque() + for slot in self.pqueues: + self._slots.append(slot) + + def push(self, request, priority): + slot, is_new = self.push_slot(request, priority) + if is_new: + self._slots.append(slot) + + def pop(self): + if not self._slots: + return + + slot = self._slots.popleft() + request, is_empty = self.pop_slot(slot) + + if not is_empty: + self._slots.append(slot) + + return request + + def close(self): + self._slots.clear() + return super(RoundRobinPriorityQueue, self).close() + + +class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): + + _DOWNLOADER_AWARE_PQ_ID = 'DOWNLOADER_AWARE_PQ_ID' + + @classmethod + def from_crawler(cls, crawler, qfactory, startprios={}): + return cls(crawler, qfactory, startprios) + + def __init__(self, crawler, qfactory, startprios={}): + super(DownloaderAwarePriorityQueue, self).__init__(qfactory, startprios) + self._slots = {slot: 0 for slot in self.pqueues} + crawler.signals.connect(self.on_response_download, + signal=response_downloaded) + crawler.signals.connect(self.on_request_reached_downloader, + signal=request_reached_downloader) + + def mark(self, request): + meta = _get_from_request(request, 'meta', None) + if not isinstance(meta, dict): + raise ValueError('No meta attribute in %s' % (request, )) + meta[self._DOWNLOADER_AWARE_PQ_ID] = id(self) + + def check_mark(self, request): + return request.meta.get(self._DOWNLOADER_AWARE_PQ_ID, None) == id(self) + + def pop(self): + slots = [(d, s) for s,d in self._slots.items() if s in self.pqueues] + + if not slots: + return + + slot = min(slots)[1] + request, _ = self.pop_slot(slot) + self.mark(request) + return request + + def push(self, request, priority): + slot, _ = self.push_slot(request, priority) + if slot not in self._slots: + self._slots[slot] = 0 + + def on_response_download(self, response, request, spider): + if not self.check_mark(request): + return + + slot = _scheduler_slot_read(request) + if slot not in self._slots or self._slots[slot] <= 0: + raise ValueError('Get response for wrong slot "%s"' % (slot, )) + self._slots[slot] = self._slots[slot] - 1 + if self._slots[slot] == 0 and slot not in self.pqueues: + del self._slots[slot] + + def on_request_reached_downloader(self, request, spider): + if not self.check_mark(request): + return + + slot = _scheduler_slot_read(request) + self._slots[slot] = self._slots.get(slot, 0) + 1 + + def close(self): + self._slots.clear() + return super(DownloaderAwarePriorityQueue, self).close() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 000000000..fd86e8d8c --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,315 @@ +import contextlib +import shutil +import tempfile +import unittest + +from scrapy.crawler import Crawler +from scrapy.core.scheduler import Scheduler +from scrapy.http import Request +from scrapy.pqueues import _scheduler_slot_read, _scheduler_slot_write +from scrapy.signals import request_reached_downloader, response_downloaded +from scrapy.spiders import Spider + +class MockCrawler(Crawler): + def __init__(self, priority_queue_cls, jobdir): + + settings = dict(LOG_UNSERIALIZABLE_REQUESTS=False, + SCHEDULER_DISK_QUEUE='scrapy.squeues.PickleLifoDiskQueue', + SCHEDULER_MEMORY_QUEUE='scrapy.squeues.LifoMemoryQueue', + SCHEDULER_PRIORITY_QUEUE=priority_queue_cls, + JOBDIR=jobdir, + DUPEFILTER_CLASS='scrapy.dupefilters.BaseDupeFilter') + super(MockCrawler, self).__init__(Spider, settings) + + +class SchedulerHandler: + priority_queue_cls = None + jobdir = None + + def create_scheduler(self): + self.mock_crawler = MockCrawler(self.priority_queue_cls, self.jobdir) + self.scheduler = Scheduler.from_crawler(self.mock_crawler) + self.spider = Spider(name='spider') + self.scheduler.open(self.spider) + + def close_scheduler(self): + self.scheduler.close('finished') + self.mock_crawler.stop() + + def setUp(self): + self.create_scheduler() + + def tearDown(self): + self.close_scheduler() + + +_PRIORITIES = [("http://foo.com/a", -2), + ("http://foo.com/d", 1), + ("http://foo.com/b", -1), + ("http://foo.com/c", 0), + ("http://foo.com/e", 2)] + + +_URLS = {"http://foo.com/a", "http://foo.com/b", "http://foo.com/c"} + + +class BaseSchedulerInMemoryTester(SchedulerHandler): + def test_length(self): + self.assertFalse(self.scheduler.has_pending_requests()) + self.assertEqual(len(self.scheduler), 0) + + for url in _URLS: + self.scheduler.enqueue_request(Request(url)) + + self.assertTrue(self.scheduler.has_pending_requests()) + self.assertEqual(len(self.scheduler), len(_URLS)) + + def test_dequeue(self): + for url in _URLS: + self.scheduler.enqueue_request(Request(url)) + + urls = set() + while self.scheduler.has_pending_requests(): + urls.add(self.scheduler.next_request().url) + + self.assertEqual(urls, _URLS) + + def test_dequeue_priorities(self): + for url, priority in _PRIORITIES: + self.scheduler.enqueue_request(Request(url, priority=priority)) + + priorities = list() + while self.scheduler.has_pending_requests(): + priorities.append(self.scheduler.next_request().priority) + + self.assertEqual(priorities, sorted([x[1] for x in _PRIORITIES], key=lambda x: -x)) + + +class BaseSchedulerOnDiskTester(SchedulerHandler): + + def setUp(self): + self.jobdir = tempfile.mkdtemp() + self.create_scheduler() + + def tearDown(self): + self.close_scheduler() + + shutil.rmtree(self.jobdir) + self.jobdir = None + + def test_length(self): + self.assertFalse(self.scheduler.has_pending_requests()) + self.assertEqual(len(self.scheduler), 0) + + for url in _URLS: + self.scheduler.enqueue_request(Request(url)) + + self.close_scheduler() + self.create_scheduler() + + self.assertTrue(self.scheduler.has_pending_requests()) + self.assertEqual(len(self.scheduler), len(_URLS)) + + def test_dequeue(self): + for url in _URLS: + self.scheduler.enqueue_request(Request(url)) + + self.close_scheduler() + self.create_scheduler() + + urls = set() + while self.scheduler.has_pending_requests(): + urls.add(self.scheduler.next_request().url) + + self.assertEqual(urls, _URLS) + + def test_dequeue_priorities(self): + for url, priority in _PRIORITIES: + self.scheduler.enqueue_request(Request(url, priority=priority)) + + self.close_scheduler() + self.create_scheduler() + + priorities = list() + while self.scheduler.has_pending_requests(): + priorities.append(self.scheduler.next_request().priority) + + self.assertEqual(priorities, sorted([x[1] for x in _PRIORITIES], key=lambda x: -x)) + + +class TestSchedulerInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): + priority_queue_cls = 'queuelib.PriorityQueue' + + +class TestSchedulerOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): + priority_queue_cls = 'queuelib.PriorityQueue' + + +_SLOTS = [("http://foo.com/a", 'a'), + ("http://foo.com/b", 'a'), + ("http://foo.com/c", 'b'), + ("http://foo.com/d", 'b'), + ("http://foo.com/e", 'c'), + ("http://foo.com/f", 'c')] + + +class TestSchedulerWithRoundRobinInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): + priority_queue_cls = 'scrapy.pqueues.RoundRobinPriorityQueue' + + def test_round_robin(self): + for url, slot in _SLOTS: + request = Request(url) + _scheduler_slot_write(request, slot) + self.scheduler.enqueue_request(request) + + slots = list() + while self.scheduler.has_pending_requests(): + slots.append(_scheduler_slot_read(self.scheduler.next_request())) + + for i in range(0, len(_SLOTS), 2): + self.assertNotEqual(slots[i], slots[i+1]) + + def test_is_meta_set(self): + url = "http://foo.com/a" + request = Request(url) + if _scheduler_slot_read(request): + _scheduler_slot_write(request, None) + self.scheduler.enqueue_request(request) + self.assertIsNotNone(_scheduler_slot_read(request, None), None) + + +class TestSchedulerWithRoundRobinOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): + priority_queue_cls = 'scrapy.pqueues.RoundRobinPriorityQueue' + + def test_round_robin(self): + for url, slot in _SLOTS: + request = Request(url) + _scheduler_slot_write(request, slot) + self.scheduler.enqueue_request(request) + + self.close_scheduler() + self.create_scheduler() + + slots = list() + while self.scheduler.has_pending_requests(): + slots.append(_scheduler_slot_read(self.scheduler.next_request())) + + for i in range(0, len(_SLOTS), 2): + self.assertNotEqual(slots[i], slots[i+1]) + + def test_is_meta_set(self): + url = "http://foo.com/a" + request = Request(url) + if _scheduler_slot_read(request): + _scheduler_slot_write(request, None) + self.scheduler.enqueue_request(request) + + self.close_scheduler() + self.create_scheduler() + + self.assertIsNotNone(_scheduler_slot_read(request, None), None) + + +@contextlib.contextmanager +def mkdtemp(): + dir = tempfile.mkdtemp() + try: + yield dir + finally: + shutil.rmtree(dir) + + +def _migration(): + + with mkdtemp() as tmp_dir: + prev_scheduler_handler = SchedulerHandler() + prev_scheduler_handler.priority_queue_cls = 'queuelib.PriorityQueue' + prev_scheduler_handler.jobdir = tmp_dir + + prev_scheduler_handler.create_scheduler() + for url in _URLS: + prev_scheduler_handler.scheduler.enqueue_request(Request(url)) + prev_scheduler_handler.close_scheduler() + + next_scheduler_handler = SchedulerHandler() + next_scheduler_handler.priority_queue_cls = 'scrapy.pqueues.RoundRobinPriorityQueue' + next_scheduler_handler.jobdir = tmp_dir + + next_scheduler_handler.create_scheduler() + + +class TestMigration(unittest.TestCase): + def test_migration(self): + self.assertRaises(ValueError, _migration) + + +class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): + priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' + + def test_logic(self): + for url, slot in _SLOTS: + request = Request(url) + _scheduler_slot_write(request, slot) + self.scheduler.enqueue_request(request) + + slots = list() + requests = list() + while self.scheduler.has_pending_requests(): + request = self.scheduler.next_request() + slots.append(_scheduler_slot_read(request)) + self.mock_crawler.signals.send_catch_log( + signal=request_reached_downloader, + request=request, + spider=self.spider + ) + requests.append(request) + self.assertEqual(len(slots), len(_SLOTS)) + + for request in requests: + self.mock_crawler.signals.send_catch_log(signal=response_downloaded, + request=request, + response=None, + spider=self.spider) + + unique_slots = len(set(s for _, s in _SLOTS)) + for i in range(0, len(_SLOTS), unique_slots): + part = slots[i:i + unique_slots] + self.assertEqual(len(part), len(set(part))) + + +class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): + priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' + def test_logic(self): + for url, slot in _SLOTS: + request = Request(url) + _scheduler_slot_write(request, slot) + self.scheduler.enqueue_request(request) + + self.close_scheduler() + self.create_scheduler() + + slots = list() + requests = list() + while self.scheduler.has_pending_requests(): + request = self.scheduler.next_request() + slots.append(_scheduler_slot_read(request)) + self.mock_crawler.signals.send_catch_log( + signal=request_reached_downloader, + request=request, + spider=self.spider + ) + requests.append(request) + + self.assertEqual(self.scheduler.mqs._slots, {}) + self.assertEqual(len(slots), len(_SLOTS)) + + for request in requests: + self.mock_crawler.signals.send_catch_log(signal=response_downloaded, + request=request, + response=None, + spider=self.spider) + + unique_slots = len(set(s for _, s in _SLOTS)) + for i in range(0, len(_SLOTS), unique_slots): + part = slots[i:i + unique_slots] + self.assertEqual(len(part), len(set(part))) From afdb69ea6daac8bd4f580d6c20bf9e93b741957b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 3 Dec 2018 16:36:05 +0100 Subject: [PATCH 107/503] Add a troubleshooting section to the installation instructions Its initial content covers the workaround for #2473. --- docs/intro/install.rst | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/intro/install.rst b/docs/intro/install.rst index 4a9aa3cfb..daec7fcb7 100644 --- a/docs/intro/install.rst +++ b/docs/intro/install.rst @@ -30,7 +30,8 @@ dependencies depending on your operating system, so be sure to check the We strongly recommend that you install Scrapy in :ref:`a dedicated virtualenv `, to avoid conflicting with your system packages. -For more detailed and platform specifics instructions, read on. +For more detailed and platform specifics instructions, as well as +troubleshooting information, read on. Things that are good to know @@ -247,6 +248,34 @@ that setuptools was unable to pick up one PyPy-specific dependency. To fix this issue, run ``pip install 'PyPyDispatcher>=2.1.0'``. +.. _intro-install-troubleshooting: + +Troubleshooting +=============== + +AttributeError: 'module' object has no attribute 'OP_NO_TLSv1_1' +---------------------------------------------------------------- + +After you install or upgrade Scrapy, Twisted or pyOpenSSL, you may get an +exception with the following traceback:: + + […] + File "[…]/site-packages/twisted/protocols/tls.py", line 63, in + from twisted.internet._sslverify import _setAcceptableProtocols + File "[…]/site-packages/twisted/internet/_sslverify.py", line 38, in + TLSVersion.TLSv1_1: SSL.OP_NO_TLSv1_1, + AttributeError: 'module' object has no attribute 'OP_NO_TLSv1_1' + +The reason you get this exception is that your system or virtual environment +has a version of pyOpenSSL that your version of Twisted does not support. + +To install a version of pyOpenSSL that your version of Twisted supports, +reinstall Twisted with the :code:`tls` extra option:: + + pip install twisted[tls] + +For details, see `Issue #2473 `_. + .. _Python: https://www.python.org/ .. _pip: https://pip.pypa.io/en/latest/installing/ .. _lxml: http://lxml.de/ From 9c314800e4b195df41e5c0aba0d9ffe4bcffec8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 3 Dec 2018 17:14:10 +0100 Subject: [PATCH 108/503] Document the SCRAPY_PROJECT environment variable Fixes #1109 --- docs/topics/commands.rst | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index ef9c45196..97f8311de 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -37,7 +37,7 @@ Scrapy also understands, and can be configured through, a number of environment variables. Currently these are: * ``SCRAPY_SETTINGS_MODULE`` (see :ref:`topics-settings-module-envvar`) -* ``SCRAPY_PROJECT`` +* ``SCRAPY_PROJECT`` (see :ref:`topics-project-envvar`) * ``SCRAPY_PYTHON_SHELL`` (see :ref:`topics-shell`) .. _topics-project-structure: @@ -71,6 +71,33 @@ the project settings. Here is an example:: [settings] default = myproject.settings +.. _topics-project-envvar: + +Sharing the root directory between projects +=========================================== + +A project root directory, the one that contains the ``scrapy.cfg``, may be +shared by multiple Scrapy projects, each with its own settings module. + +In that case, you must define one or more aliases for those settings modules +under ``[settings]`` in your ``scrapy.cfg`` file:: + + [settings] + default = myproject1.settings + project1 = myproject1.settings + project2 = myproject2.settings + +By default, the ``scrapy`` command-line tool will use the ``default`` settings. +Use the ``SCRAPY_PROJECT`` environment variable to specify a different project +for ``scrapy`` to use:: + + $ scrapy settings --get BOT_NAME + Project 1 Bot + $ export SCRAPY_PROJECT=project2 + $ scrapy settings --get BOT_NAME + Project 2 Bot + + Using the ``scrapy`` tool ========================= From f56079f6c71a77c1f70510cf291cd808617933cd Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Wed, 5 Dec 2018 10:02:42 +0000 Subject: [PATCH 109/503] Test cleanups PEP8 fixes no need to close implicitly do not use pytest need to put it into class remove round-robin queue additional check for empty queue use pytest tmpdir fixture --- scrapy/pqueues.py | 50 ++++------------ tests/test_scheduler.py | 128 ++++++++++++---------------------------- 2 files changed, 50 insertions(+), 128 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 75073b7a4..287a8de35 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -1,4 +1,3 @@ -from collections import deque import hashlib import logging from six import text_type @@ -71,16 +70,17 @@ class PrioritySlot: self.slot = slot def __hash__(self): - return hash((self.priority, self.slot)) + return hash((self.priority, self.slot)) def __eq__(self, other): - return (self.priority, self.slot) == (other.priority, other.slot) + return (self.priority, self.slot) == (other.priority, other.slot) def __lt__(self, other): - return (self.priority, self.slot) < (other.priority, other.slot) + return (self.priority, self.slot) < (other.priority, other.slot) def __str__(self): - return '_'.join([text_type(self.priority), _pathable(text_type(self.slot))]) + return '_'.join([text_type(self.priority), + _pathable(text_type(self.slot))]) class PriorityAsTupleQueue(PriorityQueue): @@ -135,9 +135,10 @@ class SlotBasedPriorityQueue(object): slot = _scheduler_slot(request) is_new = False if slot not in self.pqueues: - is_new = True self.pqueues[slot] = PriorityAsTupleQueue(self.qfactory) - self.pqueues[slot].push(request, PrioritySlot(priority=priority, slot=slot)) + queue = self.pqueues[slot] + is_new = queue.is_empty() + queue.push(request, PrioritySlot(priority=priority, slot=slot)) return slot, is_new def close(self): @@ -152,36 +153,6 @@ class SlotBasedPriorityQueue(object): return sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0 -class RoundRobinPriorityQueue(SlotBasedPriorityQueue): - - def __init__(self, qfactory, startprios={}): - super(RoundRobinPriorityQueue, self).__init__(qfactory, startprios) - self._slots = deque() - for slot in self.pqueues: - self._slots.append(slot) - - def push(self, request, priority): - slot, is_new = self.push_slot(request, priority) - if is_new: - self._slots.append(slot) - - def pop(self): - if not self._slots: - return - - slot = self._slots.popleft() - request, is_empty = self.pop_slot(slot) - - if not is_empty: - self._slots.append(slot) - - return request - - def close(self): - self._slots.clear() - return super(RoundRobinPriorityQueue, self).close() - - class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): _DOWNLOADER_AWARE_PQ_ID = 'DOWNLOADER_AWARE_PQ_ID' @@ -191,7 +162,8 @@ class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): return cls(crawler, qfactory, startprios) def __init__(self, crawler, qfactory, startprios={}): - super(DownloaderAwarePriorityQueue, self).__init__(qfactory, startprios) + super(DownloaderAwarePriorityQueue, self).__init__(qfactory, + startprios) self._slots = {slot: 0 for slot in self.pqueues} crawler.signals.connect(self.on_response_download, signal=response_downloaded) @@ -208,7 +180,7 @@ class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): return request.meta.get(self._DOWNLOADER_AWARE_PQ_ID, None) == id(self) def pop(self): - slots = [(d, s) for s,d in self._slots.items() if s in self.pqueues] + slots = [(d, s) for s, d in self._slots.items() if s in self.pqueues] if not slots: return diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index fd86e8d8c..e1cf5842d 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,4 +1,3 @@ -import contextlib import shutil import tempfile import unittest @@ -10,15 +9,18 @@ from scrapy.pqueues import _scheduler_slot_read, _scheduler_slot_write from scrapy.signals import request_reached_downloader, response_downloaded from scrapy.spiders import Spider + class MockCrawler(Crawler): def __init__(self, priority_queue_cls, jobdir): - settings = dict(LOG_UNSERIALIZABLE_REQUESTS=False, - SCHEDULER_DISK_QUEUE='scrapy.squeues.PickleLifoDiskQueue', - SCHEDULER_MEMORY_QUEUE='scrapy.squeues.LifoMemoryQueue', - SCHEDULER_PRIORITY_QUEUE=priority_queue_cls, - JOBDIR=jobdir, - DUPEFILTER_CLASS='scrapy.dupefilters.BaseDupeFilter') + settings = dict( + LOG_UNSERIALIZABLE_REQUESTS=False, + SCHEDULER_DISK_QUEUE='scrapy.squeues.PickleLifoDiskQueue', + SCHEDULER_MEMORY_QUEUE='scrapy.squeues.LifoMemoryQueue', + SCHEDULER_PRIORITY_QUEUE=priority_queue_cls, + JOBDIR=jobdir, + DUPEFILTER_CLASS='scrapy.dupefilters.BaseDupeFilter' + ) super(MockCrawler, self).__init__(Spider, settings) @@ -82,7 +84,8 @@ class BaseSchedulerInMemoryTester(SchedulerHandler): while self.scheduler.has_pending_requests(): priorities.append(self.scheduler.next_request().priority) - self.assertEqual(priorities, sorted([x[1] for x in _PRIORITIES], key=lambda x: -x)) + self.assertEqual(priorities, + sorted([x[1] for x in _PRIORITIES], key=lambda x: -x)) class BaseSchedulerOnDiskTester(SchedulerHandler): @@ -134,7 +137,8 @@ class BaseSchedulerOnDiskTester(SchedulerHandler): while self.scheduler.has_pending_requests(): priorities.append(self.scheduler.next_request().priority) - self.assertEqual(priorities, sorted([x[1] for x in _PRIORITIES], key=lambda x: -x)) + self.assertEqual(priorities, + sorted([x[1] for x in _PRIORITIES], key=lambda x: -x)) class TestSchedulerInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): @@ -153,75 +157,15 @@ _SLOTS = [("http://foo.com/a", 'a'), ("http://foo.com/f", 'c')] -class TestSchedulerWithRoundRobinInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): - priority_queue_cls = 'scrapy.pqueues.RoundRobinPriorityQueue' +class TestMigration(unittest.TestCase): - def test_round_robin(self): - for url, slot in _SLOTS: - request = Request(url) - _scheduler_slot_write(request, slot) - self.scheduler.enqueue_request(request) + def setUp(self): + self.tmpdir = tempfile.mkdtemp() - slots = list() - while self.scheduler.has_pending_requests(): - slots.append(_scheduler_slot_read(self.scheduler.next_request())) + def tearDown(self): + shutil.rmtree(self.tmpdir) - for i in range(0, len(_SLOTS), 2): - self.assertNotEqual(slots[i], slots[i+1]) - - def test_is_meta_set(self): - url = "http://foo.com/a" - request = Request(url) - if _scheduler_slot_read(request): - _scheduler_slot_write(request, None) - self.scheduler.enqueue_request(request) - self.assertIsNotNone(_scheduler_slot_read(request, None), None) - - -class TestSchedulerWithRoundRobinOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): - priority_queue_cls = 'scrapy.pqueues.RoundRobinPriorityQueue' - - def test_round_robin(self): - for url, slot in _SLOTS: - request = Request(url) - _scheduler_slot_write(request, slot) - self.scheduler.enqueue_request(request) - - self.close_scheduler() - self.create_scheduler() - - slots = list() - while self.scheduler.has_pending_requests(): - slots.append(_scheduler_slot_read(self.scheduler.next_request())) - - for i in range(0, len(_SLOTS), 2): - self.assertNotEqual(slots[i], slots[i+1]) - - def test_is_meta_set(self): - url = "http://foo.com/a" - request = Request(url) - if _scheduler_slot_read(request): - _scheduler_slot_write(request, None) - self.scheduler.enqueue_request(request) - - self.close_scheduler() - self.create_scheduler() - - self.assertIsNotNone(_scheduler_slot_read(request, None), None) - - -@contextlib.contextmanager -def mkdtemp(): - dir = tempfile.mkdtemp() - try: - yield dir - finally: - shutil.rmtree(dir) - - -def _migration(): - - with mkdtemp() as tmp_dir: + def _migration(self, tmp_dir): prev_scheduler_handler = SchedulerHandler() prev_scheduler_handler.priority_queue_cls = 'queuelib.PriorityQueue' prev_scheduler_handler.jobdir = tmp_dir @@ -232,18 +176,18 @@ def _migration(): prev_scheduler_handler.close_scheduler() next_scheduler_handler = SchedulerHandler() - next_scheduler_handler.priority_queue_cls = 'scrapy.pqueues.RoundRobinPriorityQueue' + next_scheduler_handler.priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' next_scheduler_handler.jobdir = tmp_dir next_scheduler_handler.create_scheduler() - -class TestMigration(unittest.TestCase): def test_migration(self): - self.assertRaises(ValueError, _migration) + with self.assertRaises(ValueError): + self._migration(self.tmpdir) -class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): +class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, + unittest.TestCase): priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' def test_logic(self): @@ -266,10 +210,12 @@ class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, unit self.assertEqual(len(slots), len(_SLOTS)) for request in requests: - self.mock_crawler.signals.send_catch_log(signal=response_downloaded, - request=request, - response=None, - spider=self.spider) + self.mock_crawler.signals.send_catch_log( + signal=response_downloaded, + request=request, + response=None, + spider=self.spider + ) unique_slots = len(set(s for _, s in _SLOTS)) for i in range(0, len(_SLOTS), unique_slots): @@ -277,8 +223,10 @@ class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, unit self.assertEqual(len(part), len(set(part))) -class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): +class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, + unittest.TestCase): priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' + def test_logic(self): for url, slot in _SLOTS: request = Request(url) @@ -304,10 +252,12 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, unittest self.assertEqual(len(slots), len(_SLOTS)) for request in requests: - self.mock_crawler.signals.send_catch_log(signal=response_downloaded, - request=request, - response=None, - spider=self.spider) + self.mock_crawler.signals.send_catch_log( + signal=response_downloaded, + request=request, + response=None, + spider=self.spider + ) unique_slots = len(set(s for _, s in _SLOTS)) for i in range(0, len(_SLOTS), unique_slots): From 7efba101946af93397ec3c2323b920644e20ce04 Mon Sep 17 00:00:00 2001 From: Lucy Wang Date: Mon, 10 Dec 2018 14:44:15 +0800 Subject: [PATCH 110/503] remove "sudo: false" now that travis no longer supports it https://changelog.travis-ci.com/deprecation-container-based-linux-build-environment-82037 --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4218d13bf..08b0bf119 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,4 @@ language: python -sudo: false branches: only: - master From 0e06b9a81672ec432d2fccc3cbacc823ea47b656 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 14 Dec 2018 14:35:18 +0000 Subject: [PATCH 111/503] use urlparse_cached where it is possible --- scrapy/pqueues.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 287a8de35..ff7ec8c8a 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -8,6 +8,7 @@ from queuelib import PriorityQueue from scrapy.core.downloader import Downloader from scrapy.http import Request from scrapy.signals import request_reached_downloader, response_downloaded +from scrapy.utils.httpobj import urlparse_cached logger = logging.getLogger(__name__) @@ -41,11 +42,26 @@ def _scheduler_slot_write(request, slot): def _scheduler_slot(request): - slot = _scheduler_slot_read(request, None) - if slot is None: - url = _get_from_request(request, 'url') + if isinstance(request, dict): + meta = request.get('meta', dict()) + elif isinstance(request, Request): + meta = request.meta + else: + raise ValueError('Bad type of request "%s"' % (request.__class__, )) + + slot = meta.get(SCHEDULER_SLOT_META_KEY, None) + + if slot is not None: + return slot + + if isinstance(request, dict): + url = request.get('url', None) slot = urlparse(url).hostname or '' - _scheduler_slot_write(request, slot) + elif isinstance(request, Request): + url = request.url + slot = urlparse_cached(request).hostname or '' + + meta[SCHEDULER_SLOT_META_KEY] = slot return slot From 484927b08caff66ea622f8553468c831154df30a Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 14 Dec 2018 14:38:28 +0000 Subject: [PATCH 112/503] less complex implementation --- scrapy/pqueues.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index ff7ec8c8a..538678345 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -28,16 +28,11 @@ def _get_from_request(request, key, default=None): def _scheduler_slot_read(request, default=None): - meta = _get_from_request(request, 'meta', dict()) - slot = meta.get(SCHEDULER_SLOT_META_KEY, default) - return slot + return request.meta.get(SCHEDULER_SLOT_META_KEY, default) def _scheduler_slot_write(request, slot): - meta = _get_from_request(request, 'meta', None) - if not isinstance(meta, dict): - raise ValueError('No meta attribute in %s' % (request, )) - meta[SCHEDULER_SLOT_META_KEY] = slot + request.meta[SCHEDULER_SLOT_META_KEY] = slot def _scheduler_slot(request): From 6af964cc0b47c570e035a3486b9f8aebd349bd84 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 14 Dec 2018 14:54:24 +0000 Subject: [PATCH 113/503] common indentation for comment --- scrapy/pqueues.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 538678345..31e90ff12 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -96,9 +96,9 @@ class PrioritySlot: class PriorityAsTupleQueue(PriorityQueue): """ - Python structures is not directly (de)serialized (to)from json. - We need this modified queue to transform custom structure (from)to - json serializable structures + Python structures is not directly (de)serialized (to)from json. + We need this modified queue to transform custom structure (from)to + json serializable structures """ def __init__(self, qfactory, startprios=()): From a46613afa8acd136f4ba62df2ced2f3c87679512 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 14 Dec 2018 14:55:06 +0000 Subject: [PATCH 114/503] use regular comments --- scrapy/pqueues.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 31e90ff12..75fc198d0 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -64,10 +64,8 @@ def _scheduler_slot(request): def _pathable(x): pathable_slot = "".join([c if c.isalnum() or c in '-._' else '_' for c in x]) - """ - as we replace some letters we can get collision for different slots - add we add unique part - """ + # as we replace some letters we can get collision for different slots + # add we add unique part unique_slot = hashlib.md5(x.encode('utf8')).hexdigest() return '-'.join([pathable_slot, unique_slot]) From a23e1894b3a09e1daf49dd9592546b2d21bc9a72 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 14 Dec 2018 16:18:34 +0000 Subject: [PATCH 115/503] Fix boto problem another way to fix boto problem Revert "fix for travis ci based on https://github.com/boto/boto/issues/3717" This reverts commit 150d2564ff0ea994652da7f5be333d72e0b38d93. fix for travis ci based on https://github.com/boto/boto/issues/3717 --- tests/requirements-py2.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/requirements-py2.txt b/tests/requirements-py2.txt index 790f29d34..f5bcfda60 100644 --- a/tests/requirements-py2.txt +++ b/tests/requirements-py2.txt @@ -11,3 +11,4 @@ testfixtures # optional for shell wrapper tests bpython ipython<6.0 +google-compute-engine From d970be64cc47c382bd615cd547e7e94c17e27b48 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Mon, 17 Dec 2018 13:52:11 +0000 Subject: [PATCH 116/503] Integration test integration testing only everything is working, not logic of PQ use method create slot attribute in constructor corect class for test case stop crawler in teardown method use class correct entity naming python 2 adaptation integration test with crawler and spider --- tests/test_scheduler.py | 46 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index e1cf5842d..9bdc82b30 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -2,12 +2,17 @@ import shutil import tempfile import unittest +from twisted.internet import defer +from twisted.trial.unittest import TestCase + from scrapy.crawler import Crawler from scrapy.core.scheduler import Scheduler from scrapy.http import Request from scrapy.pqueues import _scheduler_slot_read, _scheduler_slot_write from scrapy.signals import request_reached_downloader, response_downloaded from scrapy.spiders import Spider +from scrapy.utils.test import get_crawler +from tests.mockserver import MockServer class MockCrawler(Crawler): @@ -223,6 +228,13 @@ class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, self.assertEqual(len(part), len(set(part))) +def _is_slots_unique(base_slots, result_slots): + unique_slots = len(set(s for _, s in base_slots)) + for i in range(0, len(result_slots), unique_slots): + part = result_slots[i:i + unique_slots] + assert len(part) == len(set(part)) + + class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' @@ -259,7 +271,33 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, spider=self.spider ) - unique_slots = len(set(s for _, s in _SLOTS)) - for i in range(0, len(_SLOTS), unique_slots): - part = slots[i:i + unique_slots] - self.assertEqual(len(part), len(set(part))) + _is_slots_unique(_SLOTS, slots) + + +class StartUrlsSpider(Spider): + + def __init__(self, start_urls): + self.start_urls = start_urls + + +class TestIntegrationWithDownloaderAwareOnDisk(TestCase): + def setUp(self): + self.crawler = get_crawler( + StartUrlsSpider, + {'SCHEDULER_PRIORITY_QUEUE': 'scrapy.pqueues.DownloaderAwarePriorityQueue', + 'DUPEFILTER_CLASS': 'scrapy.dupefilters.BaseDupeFilter'} + ) + + @defer.inlineCallbacks + def tearDown(self): + yield self.crawler.stop() + + @defer.inlineCallbacks + def test_integration_downloader_aware_priority_queue(self): + with MockServer() as mockserver: + + url = mockserver.url("/status?n=200", is_secure=False) + slots = [url] * 6 + yield self.crawler.crawl(slots) + self.assertEqual(self.crawler.stats.get_value('downloader/response_count'), + len(slots)) From 7d3175ac8433f964ebbb80ebd67f9899cf059100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gra=C3=B1a?= Date: Thu, 20 Dec 2018 19:23:23 -0300 Subject: [PATCH 117/503] Fix boto import error under Jessie testing environment --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index 08b0bf119..a201f97b1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,6 +42,11 @@ install: virtualenv --python="$PYPY_VERSION/bin/pypy3" "$HOME/virtualenvs/$PYPY_VERSION" source "$HOME/virtualenvs/$PYPY_VERSION/bin/activate" fi + if [ "$TOXENV" = "jessie" ]; then + # Not used directly but allows boto GCE plugins to load. + # https://github.com/GoogleCloudPlatform/compute-image-packages/issues/262 + pip install google-compute-engine + fi - pip install -U tox twine wheel codecov script: tox From 6ff2574c277ba1eda31fb43f86f43d5b7b4bef09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gra=C3=B1a?= Date: Thu, 20 Dec 2018 19:39:29 -0300 Subject: [PATCH 118/503] Needs to be installed within tox env --- .travis.yml | 5 ----- tox.ini | 3 +++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index a201f97b1..08b0bf119 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,11 +42,6 @@ install: virtualenv --python="$PYPY_VERSION/bin/pypy3" "$HOME/virtualenvs/$PYPY_VERSION" source "$HOME/virtualenvs/$PYPY_VERSION/bin/activate" fi - if [ "$TOXENV" = "jessie" ]; then - # Not used directly but allows boto GCE plugins to load. - # https://github.com/GoogleCloudPlatform/compute-image-packages/issues/262 - pip install google-compute-engine - fi - pip install -U tox twine wheel codecov script: tox diff --git a/tox.ini b/tox.ini index e5543fe2a..0c0f8f7b7 100644 --- a/tox.ini +++ b/tox.ini @@ -51,6 +51,9 @@ deps = cssselect==0.9.1 zope.interface==4.1.1 -rtests/requirements-py2.txt +# Not used directly but allows boto GCE plugins to load. +# https://github.com/GoogleCloudPlatform/compute-image-packages/issues/262 + google-compute-engine==2.8.12 [testenv:trunk] basepython = python2.7 From 4163a7a1c7ac11c8d4db70f371c26181b90d8dfd Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 21 Dec 2018 09:10:32 +0000 Subject: [PATCH 119/503] no need for this --- tests/requirements-py2.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/requirements-py2.txt b/tests/requirements-py2.txt index f5bcfda60..790f29d34 100644 --- a/tests/requirements-py2.txt +++ b/tests/requirements-py2.txt @@ -11,4 +11,3 @@ testfixtures # optional for shell wrapper tests bpython ipython<6.0 -google-compute-engine From 987c2ae4a964e45120c245235c9b0c49dc36b71f Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Tue, 25 Dec 2018 09:13:09 +0000 Subject: [PATCH 120/503] test ip concurrency incompatibility with DAPQ --- tests/test_scheduler.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9bdc82b30..17b706bd7 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -301,3 +301,20 @@ class TestIntegrationWithDownloaderAwareOnDisk(TestCase): yield self.crawler.crawl(slots) self.assertEqual(self.crawler.stats.get_value('downloader/response_count'), len(slots)) + + +class TestIncompatibility(unittest.TestCase): + + def _incompatible(self): + settings = dict( + SCHEDULER_PRIORITY_QUEUE='scrapy.pqueues.DownloaderAwarePriorityQueue', + CONCURRENT_REQUESTS_PER_IP=1 + ) + crawler = Crawler(Spider, settings) + scheduler = Scheduler.from_crawler(crawler) + spider = Spider(name='spider') + scheduler.open(spider) + + def test_incompatibility(self): + with self.assertRaises(ValueError): + self._incompatible() From 8e8ce301b1a56e40f7e9c322a7b73b8dcfcefc43 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Tue, 25 Dec 2018 09:14:09 +0000 Subject: [PATCH 121/503] check CONCURRENT_REQUESTS_PER_IP is not set --- scrapy/pqueues.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 75fc198d0..d9effc9d1 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -171,6 +171,14 @@ class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): return cls(crawler, qfactory, startprios) def __init__(self, crawler, qfactory, startprios={}): + ip_concurrency_key = 'CONCURRENT_REQUESTS_PER_IP' + ip_concurrency = crawler.settings.getint(ip_concurrency_key, 0) + + if ip_concurrency > 0: + raise ValueError('"%s" does not support %s=%d' % (self.__class__, + ip_concurrency_key, + ip_concurrency)) + super(DownloaderAwarePriorityQueue, self).__init__(qfactory, startprios) self._slots = {slot: 0 for slot in self.pqueues} From 338b78d796de6c93af0f4bcb762f82f5a14b87cd Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Tue, 25 Dec 2018 09:44:20 +0000 Subject: [PATCH 122/503] Add documentation add section to broad-crawl topic reword in accord with broad-crawl topic add documentation for new priority queue --- docs/topics/broad-crawls.rst | 11 +++++++++++ docs/topics/settings.rst | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index eb02086dc..37f7a8748 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -39,6 +39,17 @@ you need to keep in mind when using Scrapy for doing broad crawls, along with concrete suggestions of Scrapy settings to tune in order to achieve an efficient broad crawl. +Use proper :setting:`SCHEDULER_PRIORITY_QUEUE` +============================================== + +Default scrapy's scheduler priority queue is ``'queuelib.PriorityQueue'``. +It works best during single domain crawl. And it does not work well with crawling +many different domains in parallel + +To apply recommended priority queue use:: + + SCHEDULER_PRIORITY_QUEUE = 'scrapy.pqueues.DownloaderAwarePriorityQueue' + Increase concurrency ==================== diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 47b6cf13d..7b9ff7e39 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1144,7 +1144,12 @@ SCHEDULER_PRIORITY_QUEUE ------------------------ Default: ``'queuelib.PriorityQueue'`` -Type of priority queue used by scheduler. +Type of priority queue used by scheduler. Another available type is +``scrapy.pqueues.DownloaderAwarePriorityQueue``. +``scrapy.pqueues.DownloaderAwarePriorityQueue`` is works better than +``'queuelib.PriorityQueue'`` when you crawl many different domains in parallel. +But ``scrapy.pqueues.DownloaderAwarePriorityQueue`` +does not work together with :setting:`CONCURRENT_REQUESTS_PER_IP`. .. setting:: SPIDER_CONTRACTS From 7c148fce5acc100f5f01719db374578bfca2512a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 8 Mar 2019 15:40:16 +0100 Subject: [PATCH 123/503] Implement Item.deepcopy() --- docs/topics/items.rst | 44 +++++++++++++++++++++++++++++++++---------- scrapy/item.py | 8 ++++++++ tests/test_item.py | 8 ++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/topics/items.rst b/docs/topics/items.rst index ae44aecd3..d744fd9ea 100644 --- a/docs/topics/items.rst +++ b/docs/topics/items.rst @@ -40,6 +40,7 @@ objects. Here is an example:: name = scrapy.Field() price = scrapy.Field() stock = scrapy.Field() + tags = scrapy.Field() last_updated = scrapy.Field(serializer=str) .. note:: Those familiar with `Django`_ will notice that Scrapy Items are @@ -155,19 +156,42 @@ To access all populated values, just use the typical `dict API`_:: >>> product.items() [('price', 1000), ('name', 'Desktop PC')] + +Copying items +------------- + +To copy an item, you must first decide whether you want a shallow copy or a +deep copy. + +If your item contains mutable_ values like lists or dictionaries, a shallow +copy will keep references to the same mutable values across all different +copies. + +.. _mutable: https://docs.python.org/glossary.html#term-mutable + +For example, if you have an item with a list of tags, and you create a shallow +copy of that item, both the original item and the copy have the same list of +tags. Adding a tag to the list of one of the items will add the tag to the +other item as well. + +If that is not the desired behavior, use a deep copy instead. + +See the `documentation of the copy module`_ for more information. + +.. _documentation of the copy module: https://docs.python.org/library/copy.html + +To create a shallow copy of an item, you can either call +:meth:`~scrapy.item.Item.copy` on an existing item +(``product2 = product.copy()``) or instantiate your item class from an existing +item (``product2 = Product(product)``). + +To create a deep copy, call :meth:`~scrapy.item.Item.deepcopy` instead +(``product2 = product.deepcopy()``). + + Other common tasks ------------------ -Copying items:: - - >>> product2 = Product(product) - >>> print(product2) - Product(name='Desktop PC', price=1000) - - >>> product3 = product2.copy() - >>> print(product3) - Product(name='Desktop PC', price=1000) - Creating dicts from items:: >>> dict(product) # create a dict from all populated values diff --git a/scrapy/item.py b/scrapy/item.py index aa05e9c69..031b80a2d 100644 --- a/scrapy/item.py +++ b/scrapy/item.py @@ -6,6 +6,7 @@ See documentation in docs/topics/item.rst from pprint import pformat from collections import MutableMapping +from copy import deepcopy from abc import ABCMeta import six @@ -96,6 +97,13 @@ class DictItem(MutableMapping, BaseItem): def copy(self): return self.__class__(self) + def deepcopy(self): + """Return a `deep copy`_ of this item. + + .. _deep copy: https://docs.python.org/library/copy.html#copy.deepcopy + """ + return deepcopy(self) + @six.add_metaclass(ItemMeta) class Item(DictItem): diff --git a/tests/test_item.py b/tests/test_item.py index 2c1eb0dd3..010d3b141 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -249,6 +249,14 @@ class ItemTest(unittest.TestCase): copied_item['name'] = copied_item['name'].upper() self.assertNotEqual(item['name'], copied_item['name']) + def test_deepcopy(self): + class TestItem(Item): + tags = Field() + item = TestItem({'tags': ['tag1']}) + copied_item = item.deepcopy() + item['tags'].append('tag2') + assert item['tags'] != copied_item['tags'] + class ItemMetaTest(unittest.TestCase): From 9a0fe8bf2dc108b1f2c50aaef211b39981e65c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Hern=C3=A1ndez=20Cabot?= Date: Wed, 20 Mar 2019 16:13:31 +0100 Subject: [PATCH 124/503] remove duplicated entry in gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7392ed31e..ff6e2ea65 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ htmlcov/ .pytest_cache/ .coverage.* .cache/ -.pytest_cache/ # Windows Thumbs.db From bbf24b7a1ce2e91eab57d1b8524d398822a1ddd1 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 22 Mar 2019 18:02:31 -0300 Subject: [PATCH 125/503] Rule.process_request: use scrapy.utils.python.get_func_args --- scrapy/spiders/crawl.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index c01f75798..f474b0a18 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -10,6 +10,7 @@ import six from scrapy.http import Request, HtmlResponse from scrapy.utils.spider import iterate_spider_output +from scrapy.utils.python import get_func_args from scrapy.spiders import Spider @@ -35,10 +36,8 @@ class Rule(object): Wrapper around the request processing function to maintain backward compatibility with functions that do not take a Response object as parameter. """ - argcount = self.process_request.__code__.co_argcount - if hasattr(self.process_request, '__self__'): - argcount = argcount - 1 - args = [request] if argcount == 1 else [request, response] + arg_count = len(get_func_args(self.process_request)) + args = [request] if arg_count == 1 else [request, response] return self.process_request(*args) From 56929e77d98391255b77ffd3350abb49da18009e Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 22 Mar 2019 18:34:55 -0300 Subject: [PATCH 126/503] Rule.process_request: deprecate the use of functions taking only one argument --- scrapy/spiders/crawl.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index f474b0a18..f469891d0 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -6,16 +6,19 @@ See documentation in docs/topics/spiders.rst """ import copy +import warnings + import six +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request, HtmlResponse from scrapy.utils.spider import iterate_spider_output from scrapy.utils.python import get_func_args from scrapy.spiders import Spider -def identity(x): - return x +def _identity(request, response): + return request class Rule(object): @@ -25,19 +28,21 @@ class Rule(object): self.callback = callback self.cb_kwargs = cb_kwargs or {} self.process_links = process_links - self.process_request = process_request or identity - if follow is None: - self.follow = False if callback else True - else: - self.follow = follow + self.process_request = process_request or _identity + self.follow = follow if follow is not None else not callback def _process_request(self, request, response): """ - Wrapper around the request processing function to maintain backward compatibility - with functions that do not take a Response object as parameter. + Wrapper around the request processing function to maintain backward + compatibility with functions that do not take a Response object """ arg_count = len(get_func_args(self.process_request)) - args = [request] if arg_count == 1 else [request, response] + if arg_count == 1: + args = [request] + msg = 'Rule.process_request should accept two arguments (request, response), accepting only one is deprecated' + warnings.warn(msg, category=ScrapyDeprecationWarning, stacklevel=2) + else: + args = [request, response] return self.process_request(*args) From 174ba3cc5671cdc9e66cb29275986ff7481affc5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 22 Mar 2019 19:16:18 -0300 Subject: [PATCH 127/503] Rule.process_request: update docs --- docs/topics/spiders.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 24b6f7ec9..30e15906e 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -403,11 +403,11 @@ Crawling rules This is mainly used for filtering purposes. ``process_request`` is a callable (or a string, in which case a method from - the spider object with that name will be used) which will be called for - every request extracted by this rule. This callable should take a Request object - as first positional argument and, optionally, the Response object from which the - Request originated as second positional argument. It must return a request or None - (to filter out the request). + the spider object with that name will be used) which will be called for every + :class:`~scrapy.http.Request` extracted by this rule. This callable should + take said request as first argument and the :class:`~scrapy.http.Response` + from which the request originated as second argument. It must return a + ``Request`` object or ``None`` (to filter out the request). CrawlSpider example ~~~~~~~~~~~~~~~~~~~ From 1b4385b7e3f78694c0378455644b539d80d293a2 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 22 Mar 2019 19:46:17 -0300 Subject: [PATCH 128/503] Rule.process_request: move deprecation warnings and compiling code, update tests --- scrapy/spiders/crawl.py | 35 +++++++++++++++++++---------------- tests/test_spider.py | 38 ++++++++++++++++++++++---------------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/scrapy/spiders/crawl.py b/scrapy/spiders/crawl.py index f469891d0..6db3a1e06 100644 --- a/scrapy/spiders/crawl.py +++ b/scrapy/spiders/crawl.py @@ -21,6 +21,13 @@ def _identity(request, response): return request +def _get_method(method, spider): + if callable(method): + return method + elif isinstance(method, six.string_types): + return getattr(spider, method, None) + + class Rule(object): def __init__(self, link_extractor, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=None): @@ -29,20 +36,24 @@ class Rule(object): self.cb_kwargs = cb_kwargs or {} self.process_links = process_links self.process_request = process_request or _identity + self.process_request_argcount = None self.follow = follow if follow is not None else not callback + def _compile(self, spider): + self.callback = _get_method(self.callback, spider) + self.process_links = _get_method(self.process_links, spider) + self.process_request = _get_method(self.process_request, spider) + self.process_request_argcount = len(get_func_args(self.process_request)) + if self.process_request_argcount == 1: + msg = 'Rule.process_request should accept two arguments (request, response), accepting only one is deprecated' + warnings.warn(msg, category=ScrapyDeprecationWarning, stacklevel=2) + def _process_request(self, request, response): """ Wrapper around the request processing function to maintain backward compatibility with functions that do not take a Response object """ - arg_count = len(get_func_args(self.process_request)) - if arg_count == 1: - args = [request] - msg = 'Rule.process_request should accept two arguments (request, response), accepting only one is deprecated' - warnings.warn(msg, category=ScrapyDeprecationWarning, stacklevel=2) - else: - args = [request, response] + args = [request] if self.process_request_argcount == 1 else [request, response] return self.process_request(*args) @@ -98,17 +109,9 @@ class CrawlSpider(Spider): yield request_or_item def _compile_rules(self): - def get_method(method): - if callable(method): - return method - elif isinstance(method, six.string_types): - return getattr(self, method, None) - self._rules = [copy.copy(r) for r in self.rules] for rule in self._rules: - rule.callback = get_method(rule.callback) - rule.process_links = get_method(rule.process_links) - rule.process_request = get_method(rule.process_request) + rule._compile(self) @classmethod def from_crawler(cls, crawler, *args, **kwargs): diff --git a/tests/test_spider.py b/tests/test_spider.py index c9af7a2d7..83fb68c2f 100644 --- a/tests/test_spider.py +++ b/tests/test_spider.py @@ -275,14 +275,17 @@ class CrawlSpiderTest(SpiderTest): Rule(LinkExtractor(), process_request=process_request_change_domain), ) - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) - self.assertEqual([r.url for r in output], - ['http://example.com/somepage/item/12.html', - 'http://example.com/about.html', - 'http://example.com/nofollow.html']) + with warnings.catch_warnings(record=True) as cw: + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + self.assertEqual(len(output), 3) + self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertEqual([r.url for r in output], + ['http://example.com/somepage/item/12.html', + 'http://example.com/about.html', + 'http://example.com/nofollow.html']) + self.assertEqual(len(cw), 1) + self.assertEqual(cw[0].category, ScrapyDeprecationWarning) def test_process_request_with_response(self): @@ -324,14 +327,17 @@ class CrawlSpiderTest(SpiderTest): def process_request_upper(self, request): return request.replace(url=request.url.upper()) - spider = _CrawlSpider() - output = list(spider._requests_to_follow(response)) - self.assertEqual(len(output), 3) - self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) - self.assertEqual([r.url for r in output], - ['http://EXAMPLE.ORG/SOMEPAGE/ITEM/12.HTML', - 'http://EXAMPLE.ORG/ABOUT.HTML', - 'http://EXAMPLE.ORG/NOFOLLOW.HTML']) + with warnings.catch_warnings(record=True) as cw: + spider = _CrawlSpider() + output = list(spider._requests_to_follow(response)) + self.assertEqual(len(output), 3) + self.assertTrue(all(map(lambda r: isinstance(r, Request), output))) + self.assertEqual([r.url for r in output], + ['http://EXAMPLE.ORG/SOMEPAGE/ITEM/12.HTML', + 'http://EXAMPLE.ORG/ABOUT.HTML', + 'http://EXAMPLE.ORG/NOFOLLOW.HTML']) + self.assertEqual(len(cw), 1) + self.assertEqual(cw[0].category, ScrapyDeprecationWarning) def test_process_request_instance_method_with_response(self): From 90934959d07db881aec5933fd7b77bcd2dccfa4f Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Thu, 27 Dec 2018 17:12:24 +0500 Subject: [PATCH 129/503] actually apply __slots__ suggestion [wip] refactoring * SlotPriorityQueues doesn't care about objects inside, it is now just a container for multiple priority queues * assorted variable renames * don't inherit DownloaderAwarePriorityQueue from SlotBasedPriorityQueue * apply @whalebot-helmsman's suggestions for __slots__ and meta issues more bike-shedding * remove mutable default arguments * more verbose variable names remove unneeded code * PriorityAsTupleQueue.is_empty does the same as len(self) == 0 * custom PriorityAsTupleQueue.close is not needed after a switch to namedtuples * is_new and is_empty return values are unused * "url" local variable is unused PrioritySlot.__str__ shouldn't return unicode in Python 2 also, do some bike-shedding: _pathable -> _path_safe use namedtuple for PrioritySlot cleanup: _get_from_request does the same here Request.meta is always a dict --- scrapy/pqueues.py | 180 ++++++++++++++++++---------------------- tests/test_scheduler.py | 2 +- 2 files changed, 82 insertions(+), 100 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index d9effc9d1..3ef896b99 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -1,6 +1,6 @@ import hashlib import logging -from six import text_type +from collections import namedtuple from six.moves.urllib.parse import urlparse from queuelib import PriorityQueue @@ -17,12 +17,12 @@ logger = logging.getLogger(__name__) SCHEDULER_SLOT_META_KEY = Downloader.DOWNLOAD_SLOT -def _get_from_request(request, key, default=None): +def _get_request_meta(request): if isinstance(request, dict): - return request.get(key, default) + return request.setdefault('meta', {}) if isinstance(request, Request): - return getattr(request, key, default) + return request.meta raise ValueError('Bad type of request "%s"' % (request.__class__, )) @@ -35,15 +35,8 @@ def _scheduler_slot_write(request, slot): request.meta[SCHEDULER_SLOT_META_KEY] = slot -def _scheduler_slot(request): - - if isinstance(request, dict): - meta = request.get('meta', dict()) - elif isinstance(request, Request): - meta = request.meta - else: - raise ValueError('Bad type of request "%s"' % (request.__class__, )) - +def _set_scheduler_slot(request): + meta = _get_request_meta(request) slot = meta.get(SCHEDULER_SLOT_META_KEY, None) if slot is not None: @@ -53,43 +46,29 @@ def _scheduler_slot(request): url = request.get('url', None) slot = urlparse(url).hostname or '' elif isinstance(request, Request): - url = request.url slot = urlparse_cached(request).hostname or '' meta[SCHEDULER_SLOT_META_KEY] = slot - return slot -def _pathable(x): - pathable_slot = "".join([c if c.isalnum() or c in '-._' else '_' for c in x]) - +def _path_safe(text): + """ Return a filesystem-safe version of a string ``text`` """ + pathable_slot = "".join([c if c.isalnum() or c in '-._' else '_' + for c in text]) # as we replace some letters we can get collision for different slots # add we add unique part - unique_slot = hashlib.md5(x.encode('utf8')).hexdigest() - + unique_slot = hashlib.md5(text.encode('utf8')).hexdigest() return '-'.join([pathable_slot, unique_slot]) -class PrioritySlot: - __slots__ = ('priority', 'slot') - - def __init__(self, priority=0, slot=None): - self.priority = priority - self.slot = slot - - def __hash__(self): - return hash((self.priority, self.slot)) - - def __eq__(self, other): - return (self.priority, self.slot) == (other.priority, other.slot) - - def __lt__(self, other): - return (self.priority, self.slot) < (other.priority, other.slot) +class PrioritySlot(namedtuple("PrioritySlot", ["priority", "slot"])): + """ ``(priority, slot)`` tuple which uses a path-safe slot name + when converting to str """ + __slots__ = () def __str__(self): - return '_'.join([text_type(self.priority), - _pathable(text_type(self.slot))]) + return '%s_%s' % (self.priority, _path_safe(str(self.slot))) class PriorityAsTupleQueue(PriorityQueue): @@ -99,78 +78,65 @@ class PriorityAsTupleQueue(PriorityQueue): json serializable structures """ def __init__(self, qfactory, startprios=()): - + startprios = [PrioritySlot(priority=p[0], slot=p[1]) + for p in startprios] super(PriorityAsTupleQueue, self).__init__( - qfactory, - [PrioritySlot(priority=p[0], slot=p[1]) for p in startprios] - ) - - def close(self): - startprios = super(PriorityAsTupleQueue, self).close() - return [(s.priority, s.slot) for s in startprios] - - def is_empty(self): - return not self.queues or len(self) == 0 + qfactory=qfactory, + startprios=startprios) -class SlotBasedPriorityQueue(object): +class SlotPriorityQueues(object): + """ Container for multiple priority queues. """ + def __init__(self, pqfactory, slot_startprios=None): + """ + ``pqfactory`` is a factory for creating new PriorityQueues. + It must be a function which accepts a single optional ``startprios`` + argument, with a list of priorities to create queues for. - def __init__(self, qfactory, startprios={}): - self.pqueues = dict() # slot -> priority queue - self.qfactory = qfactory # factory for creating new internal queues - - if not startprios: - return - - if not isinstance(startprios, dict): - raise ValueError("Looks like your priorities file malforfemed. " - "Possible reason: You run scrapy with previous " - "version. Interrupted it. Updated scrapy. And " - "run again.") - - for slot, prios in startprios.items(): - self.pqueues[slot] = PriorityAsTupleQueue(self.qfactory, prios) + ``slot_startprios`` is a ``{slot: startprios}`` dict. + """ + self.pqfactory = pqfactory + self.pqueues = {} # slot -> priority queue + for slot, startprios in (slot_startprios or {}).items(): + self.pqueues[slot] = self.pqfactory(startprios) def pop_slot(self, slot): + """ Pop an object from a priority queue for this slot """ queue = self.pqueues[slot] request = queue.pop() - is_empty = queue.is_empty() - if is_empty: + if len(queue) == 0: del self.pqueues[slot] + return request - return request, is_empty - - def push_slot(self, request, priority): - slot = _scheduler_slot(request) - is_new = False + def push_slot(self, slot, obj, priority): + """ Push an object to a priority queue for this slot """ if slot not in self.pqueues: - self.pqueues[slot] = PriorityAsTupleQueue(self.qfactory) + self.pqueues[slot] = self.pqfactory() queue = self.pqueues[slot] - is_new = queue.is_empty() - queue.push(request, PrioritySlot(priority=priority, slot=slot)) - return slot, is_new + queue.push(obj, priority) def close(self): - startprios = dict() - for slot, queue in self.pqueues.items(): - prios = queue.close() - startprios[slot] = prios + active = {slot: queue.close() + for slot, queue in self.pqueues.items()} self.pqueues.clear() - return startprios + return active def __len__(self): return sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0 + def __contains__(self, slot): + return slot in self.pqueues -class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): + +class DownloaderAwarePriorityQueue(object): _DOWNLOADER_AWARE_PQ_ID = 'DOWNLOADER_AWARE_PQ_ID' @classmethod - def from_crawler(cls, crawler, qfactory, startprios={}): + def from_crawler(cls, crawler, qfactory, startprios=None): return cls(crawler, qfactory, startprios) - def __init__(self, crawler, qfactory, startprios={}): + def __init__(self, crawler, qfactory, startprios=None): ip_concurrency_key = 'CONCURRENT_REQUESTS_PER_IP' ip_concurrency = crawler.settings.getint(ip_concurrency_key, 0) @@ -179,16 +145,25 @@ class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): ip_concurrency_key, ip_concurrency)) - super(DownloaderAwarePriorityQueue, self).__init__(qfactory, - startprios) - self._slots = {slot: 0 for slot in self.pqueues} + def pqfactory(startprios=()): + return PriorityAsTupleQueue(qfactory, startprios) + + if startprios and not isinstance(startprios, dict): + raise ValueError("DownloaderAwarePriorityQueue accepts " + "``startprios`` as a dict; %r instance is passed." + " Only a crawl started with the same priority " + "queue class can be resumed." % startprios.__class__) + self._slot_pqueues = SlotPriorityQueues(pqfactory, + slot_startprios=startprios) + + self._active_downloads = {slot: 0 for slot in self._slot_pqueues.pqueues} crawler.signals.connect(self.on_response_download, signal=response_downloaded) crawler.signals.connect(self.on_request_reached_downloader, signal=request_reached_downloader) def mark(self, request): - meta = _get_from_request(request, 'meta', None) + meta = _get_request_meta(request) if not isinstance(meta, dict): raise ValueError('No meta attribute in %s' % (request, )) meta[self._DOWNLOADER_AWARE_PQ_ID] = id(self) @@ -197,39 +172,46 @@ class DownloaderAwarePriorityQueue(SlotBasedPriorityQueue): return request.meta.get(self._DOWNLOADER_AWARE_PQ_ID, None) == id(self) def pop(self): - slots = [(d, s) for s, d in self._slots.items() if s in self.pqueues] + slots = [(active_downloads, slot) + for slot, active_downloads in self._active_downloads.items() + if slot in self._slot_pqueues] if not slots: return slot = min(slots)[1] - request, _ = self.pop_slot(slot) + request = self._slot_pqueues.pop_slot(slot) self.mark(request) return request def push(self, request, priority): - slot, _ = self.push_slot(request, priority) - if slot not in self._slots: - self._slots[slot] = 0 + slot = _set_scheduler_slot(request) + priority_slot = PrioritySlot(priority=priority, slot=slot) + self._slot_pqueues.push_slot(slot, request, priority_slot) + if slot not in self._active_downloads: + self._active_downloads[slot] = 0 def on_response_download(self, response, request, spider): if not self.check_mark(request): return slot = _scheduler_slot_read(request) - if slot not in self._slots or self._slots[slot] <= 0: + if slot not in self._active_downloads or self._active_downloads[slot] <= 0: raise ValueError('Get response for wrong slot "%s"' % (slot, )) - self._slots[slot] = self._slots[slot] - 1 - if self._slots[slot] == 0 and slot not in self.pqueues: - del self._slots[slot] + self._active_downloads[slot] = self._active_downloads[slot] - 1 + if self._active_downloads[slot] == 0 and slot not in self._slot_pqueues: + del self._active_downloads[slot] def on_request_reached_downloader(self, request, spider): if not self.check_mark(request): return slot = _scheduler_slot_read(request) - self._slots[slot] = self._slots.get(slot, 0) + 1 + self._active_downloads[slot] = self._active_downloads.get(slot, 0) + 1 def close(self): - self._slots.clear() - return super(DownloaderAwarePriorityQueue, self).close() + self._active_downloads.clear() + return self._slot_pqueues.close() + + def __len__(self): + return len(self._slot_pqueues) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 17b706bd7..5dd35f45c 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -260,7 +260,7 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, ) requests.append(request) - self.assertEqual(self.scheduler.mqs._slots, {}) + self.assertEqual(self.scheduler.mqs._active_downloads, {}) self.assertEqual(len(slots), len(_SLOTS)) for request in requests: From 757f53a32461ef0c3d2fe4caf64197f67271b5f3 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Wed, 9 Jan 2019 10:00:13 +0000 Subject: [PATCH 130/503] Address Lucy's comments add tests to check correctness of slot setermination unmark requests after downloading shorter better exception message --- scrapy/pqueues.py | 15 ++++++++++++--- tests/test_scheduler.py | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 3ef896b99..d8eed010f 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -36,6 +36,12 @@ def _scheduler_slot_write(request, slot): def _set_scheduler_slot(request): + """ + >>> _set_scheduler_slot({'url':'http://foo.com'}) == _set_scheduler_slot({'url':'http://bar.com'}) + False + >>> _set_scheduler_slot({'url':'http://foo.com'}) == _set_scheduler_slot({'url':'http://foo.com'}) + True + """ meta = _get_request_meta(request) slot = meta.get(SCHEDULER_SLOT_META_KEY, None) @@ -141,9 +147,8 @@ class DownloaderAwarePriorityQueue(object): ip_concurrency = crawler.settings.getint(ip_concurrency_key, 0) if ip_concurrency > 0: - raise ValueError('"%s" does not support %s=%d' % (self.__class__, - ip_concurrency_key, - ip_concurrency)) + raise ValueError('"%s" does not support setting %s' % (self.__class__, + ip_concurrency_key)) def pqfactory(startprios=()): return PriorityAsTupleQueue(qfactory, startprios) @@ -171,6 +176,9 @@ class DownloaderAwarePriorityQueue(object): def check_mark(self, request): return request.meta.get(self._DOWNLOADER_AWARE_PQ_ID, None) == id(self) + def unmark(self, request): + del request.meta[self._DOWNLOADER_AWARE_PQ_ID] + def pop(self): slots = [(active_downloads, slot) for slot, active_downloads in self._active_downloads.items() @@ -194,6 +202,7 @@ class DownloaderAwarePriorityQueue(object): def on_response_download(self, response, request, spider): if not self.check_mark(request): return + self.unmark(request) slot = _scheduler_slot_read(request) if slot not in self._active_downloads or self._active_downloads[slot] <= 0: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 5dd35f45c..3fb70a110 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -248,8 +248,8 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, self.close_scheduler() self.create_scheduler() - slots = list() - requests = list() + slots = [] + requests = [] while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() slots.append(_scheduler_slot_read(request)) From 3b1db71dac8716878ff1b94ee0d1095e5c80795f Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Wed, 9 Jan 2019 12:14:40 +0000 Subject: [PATCH 131/503] New signal update signature documentation for new signal utilize new signal correct signal handler signature emit new signal test another signal new signal rename test file faster test rename test case tests for signal emitting in bad cases --- docs/topics/signals.rst | 17 +++++++++ scrapy/core/downloader/__init__.py | 3 ++ scrapy/pqueues.py | 6 +-- scrapy/signals.py | 1 + tests/test_request_left.py | 59 ++++++++++++++++++++++++++++++ tests/test_scheduler.py | 8 ++-- 6 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 tests/test_request_left.py diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index ff07b9d55..f13e8270c 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -295,6 +295,23 @@ request_reached_downloader :param spider: the spider that yielded the request :type spider: :class:`~scrapy.spiders.Spider` object +request_left_downloader +--------------------------- + +.. signal:: request_left_downloader +.. function:: request_left_downloader(request, spider) + + Sent when a :class:`~scrapy.http.Request` left downloader even in case of + failure. + + The signal does not support returning deferreds from their handlers. + + :param request: the request that reached downloader + :type request: :class:`~scrapy.http.Request` object + + :param spider: the spider that yielded the request + :type spider: :class:`~scrapy.spiders.Spider` object + response_received ----------------- diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index 4695d75f4..d856a2f37 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -188,6 +188,9 @@ class Downloader(object): def finish_transferring(_): slot.transferring.remove(request) self._process_queue(spider, slot) + self.signals.send_catch_log(signal=signals.request_left_downloader, + request=request, + spider=spider) return _ return dfd.addBoth(finish_transferring) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index d8eed010f..6a9feb599 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -7,7 +7,7 @@ from queuelib import PriorityQueue from scrapy.core.downloader import Downloader from scrapy.http import Request -from scrapy.signals import request_reached_downloader, response_downloaded +from scrapy.signals import request_reached_downloader, request_left_downloader from scrapy.utils.httpobj import urlparse_cached @@ -163,7 +163,7 @@ class DownloaderAwarePriorityQueue(object): self._active_downloads = {slot: 0 for slot in self._slot_pqueues.pqueues} crawler.signals.connect(self.on_response_download, - signal=response_downloaded) + signal=request_left_downloader) crawler.signals.connect(self.on_request_reached_downloader, signal=request_reached_downloader) @@ -199,7 +199,7 @@ class DownloaderAwarePriorityQueue(object): if slot not in self._active_downloads: self._active_downloads[slot] = 0 - def on_response_download(self, response, request, spider): + def on_response_download(self, request, spider): if not self.check_mark(request): return self.unmark(request) diff --git a/scrapy/signals.py b/scrapy/signals.py index c0e4bb74e..2ea986b8c 100644 --- a/scrapy/signals.py +++ b/scrapy/signals.py @@ -14,6 +14,7 @@ spider_error = object() request_scheduled = object() request_dropped = object() request_reached_downloader = object() +request_left_downloader = object() response_received = object() response_downloaded = object() item_scraped = object() diff --git a/tests/test_request_left.py b/tests/test_request_left.py new file mode 100644 index 000000000..ddeca0499 --- /dev/null +++ b/tests/test_request_left.py @@ -0,0 +1,59 @@ +from twisted.internet import defer +from twisted.trial.unittest import TestCase +from scrapy.signals import request_left_downloader +from scrapy.spiders import Spider +from scrapy.utils.test import get_crawler +from tests.mockserver import MockServer + +class SignalCatcherSpider(Spider): + name = 'signal_catcher' + + def __init__(self, crawler, url, *args, **kwargs): + super(SignalCatcherSpider, self).__init__(*args, **kwargs) + crawler.signals.connect(self.on_response_download, + signal=request_left_downloader) + self.catched_times = 0 + self.start_urls = [url] + + @classmethod + def from_crawler(cls, crawler, *args, **kwargs): + spider = cls(crawler, *args, **kwargs) + return spider + + def on_response_download(self, request, spider): + self.catched_times = self.catched_times + 1 + + +class TestCatching(TestCase): + + def setUp(self): + self.mockserver = MockServer() + self.mockserver.__enter__() + + def tearDown(self): + self.mockserver.__exit__(None, None, None) + + @defer.inlineCallbacks + def test_success(self): + crawler = get_crawler(SignalCatcherSpider) + yield crawler.crawl(self.mockserver.url("/status?n=200")) + self.assertEqual(crawler.spider.catched_times, 1) + + @defer.inlineCallbacks + def test_timeout(self): + crawler = get_crawler(SignalCatcherSpider, + {'DOWNLOAD_TIMEOUT': 0.1}) + yield crawler.crawl(self.mockserver.url("/delay?n=0.2")) + self.assertEqual(crawler.spider.catched_times, 1) + + @defer.inlineCallbacks + def test_disconnect(self): + crawler = get_crawler(SignalCatcherSpider) + yield crawler.crawl(self.mockserver.url("/drop")) + self.assertEqual(crawler.spider.catched_times, 1) + + @defer.inlineCallbacks + def test_noconnect(self): + crawler = get_crawler(SignalCatcherSpider) + yield crawler.crawl('http://thereisdefinetelynosuchdomain.com') + self.assertEqual(crawler.spider.catched_times, 1) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 3fb70a110..1bcc1e5a8 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -9,7 +9,7 @@ from scrapy.crawler import Crawler from scrapy.core.scheduler import Scheduler from scrapy.http import Request from scrapy.pqueues import _scheduler_slot_read, _scheduler_slot_write -from scrapy.signals import request_reached_downloader, response_downloaded +from scrapy.signals import request_reached_downloader, request_left_downloader from scrapy.spiders import Spider from scrapy.utils.test import get_crawler from tests.mockserver import MockServer @@ -216,9 +216,8 @@ class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, for request in requests: self.mock_crawler.signals.send_catch_log( - signal=response_downloaded, + signal=request_left_downloader, request=request, - response=None, spider=self.spider ) @@ -265,9 +264,8 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, for request in requests: self.mock_crawler.signals.send_catch_log( - signal=response_downloaded, + signal=request_left_downloader, request=request, - response=None, spider=self.spider ) From 83eb5376458ce1d444e8ad7911730ee0c58c8544 Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Thu, 17 Jan 2019 07:38:15 +0500 Subject: [PATCH 132/503] assorted cleanups: comments, docstrings, etc scheduler cleanup Scheduler no longer converts requests to dicts; PriorityQueue instances always work with Request instances; converting Requests to dicts is now Priority Queue responsibility. minor cleanup --- docs/topics/settings.rst | 6 +- scrapy/core/scheduler.py | 99 +++++++++++++---- scrapy/pqueues.py | 158 +++++++++++++++------------- scrapy/settings/default_settings.py | 2 +- scrapy/squeues.py | 11 +- 5 files changed, 175 insertions(+), 101 deletions(-) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 7b9ff7e39..6e13e64d6 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1142,13 +1142,13 @@ Type of in-memory queue used by scheduler. Other available type is: SCHEDULER_PRIORITY_QUEUE ------------------------ -Default: ``'queuelib.PriorityQueue'`` +Default: ``'scrapy.pqueues.ScrapyPriorityQueue'`` Type of priority queue used by scheduler. Another available type is ``scrapy.pqueues.DownloaderAwarePriorityQueue``. ``scrapy.pqueues.DownloaderAwarePriorityQueue`` is works better than -``'queuelib.PriorityQueue'`` when you crawl many different domains in parallel. -But ``scrapy.pqueues.DownloaderAwarePriorityQueue`` +``scrapy.pqueues.ScrapyPriorityQueue`` when you crawl many different +domains in parallel. But ``scrapy.pqueues.DownloaderAwarePriorityQueue`` does not work together with :setting:`CONCURRENT_REQUESTS_PER_IP`. .. setting:: SPIDER_CONTRACTS diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index d40f3aa0c..c385fafe1 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -1,17 +1,44 @@ import os import json import logging +import warnings from os.path import join, exists -from scrapy.utils.reqser import request_to_dict, request_from_dict +from queuelib import PriorityQueue + from scrapy.utils.misc import load_object, create_instance from scrapy.utils.job import job_dir +from scrapy.utils.deprecate import ScrapyDeprecationWarning + logger = logging.getLogger(__name__) class Scheduler(object): + """ + Scrapy Scheduler. It allows to enqueue requests and then get + a next request to download. Scheduler is also handling duplication + filtering, via dupefilter. + Prioritization and queueing is not performed by the Scheduler. + User sets ``priority`` field for each Request, and a PriorityQueue + (defined by :setting:`SCHEDULER_PRIORITY_QUEUE`) uses these priorities + to dequeue requests in a desired order. + + Scheduler uses two PriorityQueue instances, configured to work in-memory + and on-disk (optional). When on-disk queue is present, it is used by + default, and an in-memory queue is used as a fallback for cases where + a disk queue can't handle a request (can't serialize it). + + :setting:`SCHEDULER_MEMORY_QUEUE` and + :setting:`SCHEDULER_DISK_QUEUE` allow to specify lower-level queue classes + which PriorityQueue instances would be instantiated with, to keep requests + on disk and in memory respectively. + + Overall, Scheduler is an object which holds several PriorityQueue instances + (in-memory and on-disk) and implements fallback logic for them. + Also, it handles dupefilters. + """ def __init__(self, dupefilter, jobdir=None, dqclass=None, mqclass=None, logunser=False, stats=None, pqclass=None, crawler=None): self.df = dupefilter @@ -29,9 +56,19 @@ class Scheduler(object): dupefilter_cls = load_object(settings['DUPEFILTER_CLASS']) dupefilter = create_instance(dupefilter_cls, settings, crawler) pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE']) + if pqclass is PriorityQueue: + # backwards compatibility + warnings.warn("SCHEDULER_PRIORITY_QUEUE='queuelib.PriorityQueue'" + " is no longer supported because of API changes; " + "please use 'scrapy.pqueues.ScrapyPriorityQueue'", + ScrapyDeprecationWarning) + from scrapy.pqueues import ScrapyPriorityQueue + pqclass = ScrapyPriorityQueue + dqclass = load_object(settings['SCHEDULER_DISK_QUEUE']) mqclass = load_object(settings['SCHEDULER_MEMORY_QUEUE']) - logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', settings.getbool('SCHEDULER_DEBUG')) + logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', + settings.getbool('SCHEDULER_DEBUG')) return cls(dupefilter, jobdir=job_dir(settings), logunser=logunser, stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, mqclass=mqclass, crawler=crawler) @@ -41,15 +78,19 @@ class Scheduler(object): def open(self, spider): self.spider = spider - self.mqs = create_instance(self.pqclass, None, self.crawler, self._newmq) + + # in-memory PriorityQueue instance + self.mqs = self._mq() + + # on-disk PriorityQueue instance self.dqs = self._dq() if self.dqdir else None + return self.df.open() def close(self, reason): if self.dqs: - prios = self.dqs.close() - with open(join(self.dqdir, 'active.json'), 'w') as f: - json.dump(prios, f) + state = self.dqs.close() + self._write_dqs_state(self.dqdir, state) return self.df.close(reason) def enqueue_request(self, request): @@ -66,7 +107,7 @@ class Scheduler(object): return True def next_request(self): - request = self.mqs.pop() + request = self._mqpop() if request: self.stats.inc_value('scheduler/dequeued/memory', spider=self.spider) else: @@ -84,8 +125,7 @@ class Scheduler(object): if self.dqs is None: return try: - reqd = request_to_dict(request, self.spider) - self.dqs.push(reqd, -request.priority) + self.dqs.push(request, -request.priority) except ValueError as e: # non serializable request if self.logunser: msg = ("Unable to serialize request: %(request)s - reason:" @@ -105,37 +145,54 @@ class Scheduler(object): def _dqpop(self): if self.dqs: - d = self.dqs.pop() - if d: - return request_from_dict(d, self.spider) + return self.dqs.pop() + + def _mqpop(self): + return self.mqs.pop() def _newmq(self, priority): + """ Factory for creating memory queues. """ return self.mqclass() def _newdq(self, priority): - return self.dqclass(join(self.dqdir, 'p%s' % (priority, ))) + """ Factory for creating disk queues. """ + path = join(self.dqdir, 'p%s' % (priority, )) + return self.dqclass(path) + + def _mq(self): + """ Create a new priority queue instance, with in-memory storage """ + return create_instance(self.pqclass, None, self.crawler, self._newmq, + serialize=False) def _dq(self): - activef = join(self.dqdir, 'active.json') - if exists(activef): - with open(activef) as f: - prios = json.load(f) - else: - prios = () - + """ Create a new priority queue instance, with disk storage """ + state = self._read_dqs_state(self.dqdir) q = create_instance(self.pqclass, None, self.crawler, self._newdq, - startprios=prios) + state, + serialize=True) if q: logger.info("Resuming crawl (%(queuesize)d requests scheduled)", {'queuesize': len(q)}, extra={'spider': self.spider}) return q def _dqdir(self, jobdir): + """ Return a folder name to keep disk queue state at """ if jobdir: dqdir = join(jobdir, 'requests.queue') if not exists(dqdir): os.makedirs(dqdir) return dqdir + + def _read_dqs_state(self, dqdir): + path = join(dqdir, 'active.json') + if not exists(path): + return () + with open(path) as f: + return json.load(f) + + def _write_dqs_state(self, dqdir, state): + with open(join(dqdir, 'active.json'), 'w') as f: + json.dump(state, f) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 6a9feb599..622f6bbc5 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -1,10 +1,10 @@ import hashlib import logging from collections import namedtuple -from six.moves.urllib.parse import urlparse from queuelib import PriorityQueue +from scrapy.utils.reqser import request_to_dict, request_from_dict from scrapy.core.downloader import Downloader from scrapy.http import Request from scrapy.signals import request_reached_downloader, request_left_downloader @@ -17,16 +17,6 @@ logger = logging.getLogger(__name__) SCHEDULER_SLOT_META_KEY = Downloader.DOWNLOAD_SLOT -def _get_request_meta(request): - if isinstance(request, dict): - return request.setdefault('meta', {}) - - if isinstance(request, Request): - return request.meta - - raise ValueError('Bad type of request "%s"' % (request.__class__, )) - - def _scheduler_slot_read(request, default=None): return request.meta.get(SCHEDULER_SLOT_META_KEY, default) @@ -37,24 +27,17 @@ def _scheduler_slot_write(request, slot): def _set_scheduler_slot(request): """ - >>> _set_scheduler_slot({'url':'http://foo.com'}) == _set_scheduler_slot({'url':'http://bar.com'}) - False - >>> _set_scheduler_slot({'url':'http://foo.com'}) == _set_scheduler_slot({'url':'http://foo.com'}) - True + >>> request = Request('http://example.com') + >>> _set_scheduler_slot(request) + 'example.com' + >>> _scheduler_slot_read(request) + 'example.com' """ - meta = _get_request_meta(request) - slot = meta.get(SCHEDULER_SLOT_META_KEY, None) - + slot = _scheduler_slot_read(request, None) if slot is not None: return slot - - if isinstance(request, dict): - url = request.get('url', None) - slot = urlparse(url).hostname or '' - elif isinstance(request, Request): - slot = urlparse_cached(request).hostname or '' - - meta[SCHEDULER_SLOT_META_KEY] = slot + slot = urlparse_cached(request).hostname or '' + _scheduler_slot_write(request, slot) return slot @@ -68,30 +51,25 @@ def _path_safe(text): return '-'.join([pathable_slot, unique_slot]) -class PrioritySlot(namedtuple("PrioritySlot", ["priority", "slot"])): - """ ``(priority, slot)`` tuple which uses a path-safe slot name - when converting to str """ +class _Priority(namedtuple("_Priority", ["priority", "slot"])): + """ Slot-specific priority. It is a hack - ``(priority, slot)`` tuple + which can be used instead of int priorities in queues: + + * they are ordered in the same way - order is still by priority value, + min(prios) works; + * str(p) representation is guaranteed to be different when slots + are different - this is important because str(p) is used to create + queue files on disk; + * they have readable str(p) representation which is safe + to use as a file name. + """ __slots__ = () def __str__(self): return '%s_%s' % (self.priority, _path_safe(str(self.slot))) -class PriorityAsTupleQueue(PriorityQueue): - """ - Python structures is not directly (de)serialized (to)from json. - We need this modified queue to transform custom structure (from)to - json serializable structures - """ - def __init__(self, qfactory, startprios=()): - startprios = [PrioritySlot(priority=p[0], slot=p[1]) - for p in startprios] - super(PriorityAsTupleQueue, self).__init__( - qfactory=qfactory, - startprios=startprios) - - -class SlotPriorityQueues(object): +class _SlotPriorityQueues(object): """ Container for multiple priority queues. """ def __init__(self, pqfactory, slot_startprios=None): """ @@ -134,44 +112,78 @@ class SlotPriorityQueues(object): return slot in self.pqueues -class DownloaderAwarePriorityQueue(object): - - _DOWNLOADER_AWARE_PQ_ID = 'DOWNLOADER_AWARE_PQ_ID' +class ScrapyPriorityQueue(PriorityQueue): + """ + PriorityQueue which works with scrapy.Request instances and + can optionally convert them to/from dicts before/after putting to a queue. + """ + def __init__(self, crawler, qfactory, startprios=(), serialize=False): + super(ScrapyPriorityQueue, self).__init__(qfactory, startprios) + self.serialize = serialize + self.spider = crawler.spider @classmethod - def from_crawler(cls, crawler, qfactory, startprios=None): - return cls(crawler, qfactory, startprios) + def from_crawler(cls, crawler, qfactory, startprios=(), serialize=False): + return cls(crawler, qfactory, startprios, serialize) - def __init__(self, crawler, qfactory, startprios=None): - ip_concurrency_key = 'CONCURRENT_REQUESTS_PER_IP' - ip_concurrency = crawler.settings.getint(ip_concurrency_key, 0) + def push(self, request, priority=0): + if self.serialize: + request = request_to_dict(request, self.spider) + super(ScrapyPriorityQueue, self).push(request, priority) - if ip_concurrency > 0: - raise ValueError('"%s" does not support setting %s' % (self.__class__, - ip_concurrency_key)) + def pop(self): + request = super(ScrapyPriorityQueue, self).pop() + if request and self.serialize: + request = request_from_dict(request, self.spider) + return request + + +class DownloaderAwarePriorityQueue(object): + """ PriorityQueue which takes Downlaoder activity in account: + domains (slots) with the least amount of active downloads are dequeued + first. + """ + _DOWNLOADER_AWARE_PQ_ID = '_DOWNLOADER_AWARE_PQ_ID' + + @classmethod + def from_crawler(cls, crawler, qfactory, slot_startprios=None, serialize=False): + return cls(crawler, qfactory, slot_startprios, serialize) + + def __init__(self, crawler, qfactory, slot_startprios=None, serialize=False): + if crawler.settings.getint('CONCURRENT_REQUESTS_PER_IP') != 0: + raise ValueError('"%s" does not support CONCURRENT_REQUESTS_PER_IP' + % (self.__class__,)) + + if slot_startprios and not isinstance(slot_startprios, dict): + raise ValueError("DownloaderAwarePriorityQueue accepts " + "``slot_startprios`` as a dict; %r instance " + "is passed. Most likely, it means the state is" + "created by an incompatible priority queue. " + "Only a crawl started with the same priority " + "queue class can be resumed." % + slot_startprios.__class__) + + slot_startprios = { + slot: [_Priority(p, slot) for p in startprios] + for slot, startprios in (slot_startprios or {}).items()} def pqfactory(startprios=()): - return PriorityAsTupleQueue(qfactory, startprios) - - if startprios and not isinstance(startprios, dict): - raise ValueError("DownloaderAwarePriorityQueue accepts " - "``startprios`` as a dict; %r instance is passed." - " Only a crawl started with the same priority " - "queue class can be resumed." % startprios.__class__) - self._slot_pqueues = SlotPriorityQueues(pqfactory, - slot_startprios=startprios) + return ScrapyPriorityQueue(crawler, qfactory, startprios, serialize) + self._slot_pqueues = _SlotPriorityQueues(pqfactory, slot_startprios) self._active_downloads = {slot: 0 for slot in self._slot_pqueues.pqueues} crawler.signals.connect(self.on_response_download, signal=request_left_downloader) crawler.signals.connect(self.on_request_reached_downloader, signal=request_reached_downloader) + self.serialize = serialize + # There are two PriorityQueues at the same time (memory and disk-based), + # and they both listen to Downloader signals. To filter out signals + # coming from the other queue, each queue keeps track of its own + # requests using mark / unmark / check_mark methods. def mark(self, request): - meta = _get_request_meta(request) - if not isinstance(meta, dict): - raise ValueError('No meta attribute in %s' % (request, )) - meta[self._DOWNLOADER_AWARE_PQ_ID] = id(self) + request.meta[self._DOWNLOADER_AWARE_PQ_ID] = id(self) def check_mark(self, request): return request.meta.get(self._DOWNLOADER_AWARE_PQ_ID, None) == id(self) @@ -194,7 +206,7 @@ class DownloaderAwarePriorityQueue(object): def push(self, request, priority): slot = _set_scheduler_slot(request) - priority_slot = PrioritySlot(priority=priority, slot=slot) + priority_slot = _Priority(priority=priority, slot=slot) self._slot_pqueues.push_slot(slot, request, priority_slot) if slot not in self._active_downloads: self._active_downloads[slot] = 0 @@ -206,8 +218,8 @@ class DownloaderAwarePriorityQueue(object): slot = _scheduler_slot_read(request) if slot not in self._active_downloads or self._active_downloads[slot] <= 0: - raise ValueError('Get response for wrong slot "%s"' % (slot, )) - self._active_downloads[slot] = self._active_downloads[slot] - 1 + raise ValueError('Got response for a wrong slot "%s"' % (slot, )) + self._active_downloads[slot] -= 1 if self._active_downloads[slot] == 0 and slot not in self._slot_pqueues: del self._active_downloads[slot] @@ -220,7 +232,9 @@ class DownloaderAwarePriorityQueue(object): def close(self): self._active_downloads.clear() - return self._slot_pqueues.close() + active = self._slot_pqueues.close() + return {slot: [p.priority for p in startprios] + for slot, startprios in active.items()} def __len__(self): return len(self._slot_pqueues) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index ca004aedd..365b405cb 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -244,7 +244,7 @@ ROBOTSTXT_OBEY = False SCHEDULER = 'scrapy.core.scheduler.Scheduler' SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleLifoDiskQueue' SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.LifoMemoryQueue' -SCHEDULER_PRIORITY_QUEUE = 'queuelib.PriorityQueue' +SCHEDULER_PRIORITY_QUEUE = 'scrapy.pqueues.ScrapyPriorityQueue' SPIDER_LOADER_CLASS = 'scrapy.spiderloader.SpiderLoader' SPIDER_LOADER_WARN_ONLY = False diff --git a/scrapy/squeues.py b/scrapy/squeues.py index d2074a457..30cc926e5 100644 --- a/scrapy/squeues.py +++ b/scrapy/squeues.py @@ -7,6 +7,7 @@ from six.moves import cPickle as pickle from queuelib import queue + def _serializable_queue(queue_class, serialize, deserialize): class SerializableQueue(queue_class): @@ -22,6 +23,7 @@ def _serializable_queue(queue_class, serialize, deserialize): return SerializableQueue + def _pickle_serialize(obj): try: return pickle.dumps(obj, protocol=2) @@ -31,13 +33,14 @@ def _pickle_serialize(obj): except (pickle.PicklingError, AttributeError, TypeError) as e: raise ValueError(str(e)) -PickleFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, \ + +PickleFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, _pickle_serialize, pickle.loads) -PickleLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, \ +PickleLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, _pickle_serialize, pickle.loads) -MarshalFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, \ +MarshalFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, marshal.dumps, marshal.loads) -MarshalLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, \ +MarshalLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, marshal.dumps, marshal.loads) FifoMemoryQueue = queue.FifoMemoryQueue LifoMemoryQueue = queue.LifoMemoryQueue From 443fb98a4776f4196662bb48918f1471758b7ae7 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Tue, 5 Mar 2019 12:44:07 +0000 Subject: [PATCH 133/503] Use downloader directly rename variable remove old write function remove unused imports remove old read function remove unused function use mock methods mock downloader close downloader add parse method use new PQ class create mock downloader use downloader directly remove mark/unmark mechanism --- scrapy/pqueues.py | 103 ++++++++++------------------------------ tests/test_scheduler.py | 87 +++++++++++++++++++++------------ 2 files changed, 81 insertions(+), 109 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 622f6bbc5..0681e6729 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -5,42 +5,11 @@ from collections import namedtuple from queuelib import PriorityQueue from scrapy.utils.reqser import request_to_dict, request_from_dict -from scrapy.core.downloader import Downloader -from scrapy.http import Request -from scrapy.signals import request_reached_downloader, request_left_downloader -from scrapy.utils.httpobj import urlparse_cached logger = logging.getLogger(__name__) -SCHEDULER_SLOT_META_KEY = Downloader.DOWNLOAD_SLOT - - -def _scheduler_slot_read(request, default=None): - return request.meta.get(SCHEDULER_SLOT_META_KEY, default) - - -def _scheduler_slot_write(request, slot): - request.meta[SCHEDULER_SLOT_META_KEY] = slot - - -def _set_scheduler_slot(request): - """ - >>> request = Request('http://example.com') - >>> _set_scheduler_slot(request) - 'example.com' - >>> _scheduler_slot_read(request) - 'example.com' - """ - slot = _scheduler_slot_read(request, None) - if slot is not None: - return slot - slot = urlparse_cached(request).hostname or '' - _scheduler_slot_write(request, slot) - return slot - - def _path_safe(text): """ Return a filesystem-safe version of a string ``text`` """ pathable_slot = "".join([c if c.isalnum() or c in '-._' else '_' @@ -138,6 +107,25 @@ class ScrapyPriorityQueue(PriorityQueue): return request +class DownloaderInterface(object): + + def __init__(self, crawler): + self.downloader = crawler.engine.downloader + + def stats(self, possible_slots): + return [(self._active_downloads(slot), slot) + for slot in possible_slots] + + def get_slot_key(self, request): + return self.downloader._get_slot_key(request, None) + + def _active_downloads(self, slot): + """ Return a number of requests in a Downloader for a given slot """ + if slot not in self.downloader.slots: + return 0 + return len(self.downloader.slots[slot].active) + + class DownloaderAwarePriorityQueue(object): """ PriorityQueue which takes Downlaoder activity in account: domains (slots) with the least amount of active downloads are dequeued @@ -170,68 +158,25 @@ class DownloaderAwarePriorityQueue(object): def pqfactory(startprios=()): return ScrapyPriorityQueue(crawler, qfactory, startprios, serialize) self._slot_pqueues = _SlotPriorityQueues(pqfactory, slot_startprios) - - self._active_downloads = {slot: 0 for slot in self._slot_pqueues.pqueues} - crawler.signals.connect(self.on_response_download, - signal=request_left_downloader) - crawler.signals.connect(self.on_request_reached_downloader, - signal=request_reached_downloader) self.serialize = serialize - - # There are two PriorityQueues at the same time (memory and disk-based), - # and they both listen to Downloader signals. To filter out signals - # coming from the other queue, each queue keeps track of its own - # requests using mark / unmark / check_mark methods. - def mark(self, request): - request.meta[self._DOWNLOADER_AWARE_PQ_ID] = id(self) - - def check_mark(self, request): - return request.meta.get(self._DOWNLOADER_AWARE_PQ_ID, None) == id(self) - - def unmark(self, request): - del request.meta[self._DOWNLOADER_AWARE_PQ_ID] + self._downloader_interface = DownloaderInterface(crawler) def pop(self): - slots = [(active_downloads, slot) - for slot, active_downloads in self._active_downloads.items() - if slot in self._slot_pqueues] + stats = self._downloader_interface.stats(self._slot_pqueues.pqueues) - if not slots: + if not stats: return - slot = min(slots)[1] + slot = min(stats)[1] request = self._slot_pqueues.pop_slot(slot) - self.mark(request) return request def push(self, request, priority): - slot = _set_scheduler_slot(request) + slot = self._downloader_interface.get_slot_key(request) priority_slot = _Priority(priority=priority, slot=slot) self._slot_pqueues.push_slot(slot, request, priority_slot) - if slot not in self._active_downloads: - self._active_downloads[slot] = 0 - - def on_response_download(self, request, spider): - if not self.check_mark(request): - return - self.unmark(request) - - slot = _scheduler_slot_read(request) - if slot not in self._active_downloads or self._active_downloads[slot] <= 0: - raise ValueError('Got response for a wrong slot "%s"' % (slot, )) - self._active_downloads[slot] -= 1 - if self._active_downloads[slot] == 0 and slot not in self._slot_pqueues: - del self._active_downloads[slot] - - def on_request_reached_downloader(self, request, spider): - if not self.check_mark(request): - return - - slot = _scheduler_slot_read(request) - self._active_downloads[slot] = self._active_downloads.get(slot, 0) + 1 def close(self): - self._active_downloads.clear() active = self._slot_pqueues.close() return {slot: [p.priority for p in startprios] for slot, startprios in active.items()} diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 1bcc1e5a8..75c0b7530 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,20 +1,50 @@ import shutil import tempfile import unittest +import collections from twisted.internet import defer from twisted.trial.unittest import TestCase from scrapy.crawler import Crawler +from scrapy.core.downloader import Downloader from scrapy.core.scheduler import Scheduler from scrapy.http import Request -from scrapy.pqueues import _scheduler_slot_read, _scheduler_slot_write -from scrapy.signals import request_reached_downloader, request_left_downloader from scrapy.spiders import Spider +from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.test import get_crawler from tests.mockserver import MockServer +MockEngine = collections.namedtuple('MockEngine', ['downloader']) +MockSlot = collections.namedtuple('MockSlot', ['active']) + + +class MockDownloader: + def __init__(self): + self.slots = dict() + + def _set_slot_key(self, slot, request, spider): + request.meta[Downloader.DOWNLOAD_SLOT] = slot + + def _get_slot_key(self, request, spider): + if Downloader.DOWNLOAD_SLOT in request.meta: + return request.meta[Downloader.DOWNLOAD_SLOT] + + return urlparse_cached(request).hostname or '' + + def increment(self, slot_key): + slot = self.slots.setdefault(slot_key, MockSlot(active=list())) + slot.active.append(1) + + def decrement(self, slot_key): + slot = self.slots.get(slot_key) + slot.active.pop() + + def close(self): + pass + + class MockCrawler(Crawler): def __init__(self, priority_queue_cls, jobdir): @@ -27,6 +57,7 @@ class MockCrawler(Crawler): DUPEFILTER_CLASS='scrapy.dupefilters.BaseDupeFilter' ) super(MockCrawler, self).__init__(Spider, settings) + self.engine = MockEngine(downloader=MockDownloader()) class SchedulerHandler: @@ -42,6 +73,7 @@ class SchedulerHandler: def close_scheduler(self): self.scheduler.close('finished') self.mock_crawler.stop() + self.mock_crawler.engine.downloader.close() def setUp(self): self.create_scheduler() @@ -147,11 +179,11 @@ class BaseSchedulerOnDiskTester(SchedulerHandler): class TestSchedulerInMemory(BaseSchedulerInMemoryTester, unittest.TestCase): - priority_queue_cls = 'queuelib.PriorityQueue' + priority_queue_cls = 'scrapy.pqueues.ScrapyPriorityQueue' class TestSchedulerOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): - priority_queue_cls = 'queuelib.PriorityQueue' + priority_queue_cls = 'scrapy.pqueues.ScrapyPriorityQueue' _SLOTS = [("http://foo.com/a", 'a'), @@ -172,7 +204,7 @@ class TestMigration(unittest.TestCase): def _migration(self, tmp_dir): prev_scheduler_handler = SchedulerHandler() - prev_scheduler_handler.priority_queue_cls = 'queuelib.PriorityQueue' + prev_scheduler_handler.priority_queue_cls = 'scrapy.pqueues.ScrapyPriorityQueue' prev_scheduler_handler.jobdir = tmp_dir prev_scheduler_handler.create_scheduler() @@ -196,30 +228,25 @@ class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' def test_logic(self): + downloader = self.mock_crawler.engine.downloader for url, slot in _SLOTS: request = Request(url) - _scheduler_slot_write(request, slot) + downloader._set_slot_key(slot, request, None) self.scheduler.enqueue_request(request) slots = list() requests = list() while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() - slots.append(_scheduler_slot_read(request)) - self.mock_crawler.signals.send_catch_log( - signal=request_reached_downloader, - request=request, - spider=self.spider - ) + slot = downloader._get_slot_key(request, None) + slots.append(slot) + downloader.increment(slot) requests.append(request) self.assertEqual(len(slots), len(_SLOTS)) for request in requests: - self.mock_crawler.signals.send_catch_log( - signal=request_left_downloader, - request=request, - spider=self.spider - ) + slot = downloader._get_slot_key(request, None) + self.mock_crawler.engine.downloader.decrement(slot) unique_slots = len(set(s for _, s in _SLOTS)) for i in range(0, len(_SLOTS), unique_slots): @@ -239,9 +266,11 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' def test_logic(self): + downloader = self.mock_crawler.engine.downloader + for url, slot in _SLOTS: request = Request(url) - _scheduler_slot_write(request, slot) + downloader._set_slot_key(slot, request, None) self.scheduler.enqueue_request(request) self.close_scheduler() @@ -249,27 +278,22 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, slots = [] requests = [] + downloader = self.mock_crawler.engine.downloader while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() - slots.append(_scheduler_slot_read(request)) - self.mock_crawler.signals.send_catch_log( - signal=request_reached_downloader, - request=request, - spider=self.spider - ) + slot = downloader._get_slot_key(request, None) + slots.append(slot) + downloader.increment(slot) requests.append(request) - self.assertEqual(self.scheduler.mqs._active_downloads, {}) self.assertEqual(len(slots), len(_SLOTS)) for request in requests: - self.mock_crawler.signals.send_catch_log( - signal=request_left_downloader, - request=request, - spider=self.spider - ) + slot = downloader._get_slot_key(request, None) + downloader.decrement(slot) _is_slots_unique(_SLOTS, slots) + self.assertEqual(sum(len(s.active) for s in downloader.slots.values()), 0) class StartUrlsSpider(Spider): @@ -277,6 +301,9 @@ class StartUrlsSpider(Spider): def __init__(self, start_urls): self.start_urls = start_urls + def parse(self, response): + pass + class TestIntegrationWithDownloaderAwareOnDisk(TestCase): def setUp(self): From 989bba6cb340fcc1ddb32e75ade567864d8b3884 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Thu, 7 Mar 2019 09:00:14 +0000 Subject: [PATCH 134/503] Revert "new signal" This reverts commit 646164fd7d6dd52061804d2df7424cff929bf739. remove tests Revert "emit new signal" This reverts commit fcde0c6880678957a76af6083b6248f430a00fcf. Revert "documentation for new signal" This reverts commit 8aeb9f696ece95c16499a96767a7afa3d9c4abf4. --- docs/topics/signals.rst | 17 --------- scrapy/core/downloader/__init__.py | 3 -- scrapy/signals.py | 1 - tests/test_request_left.py | 59 ------------------------------ 4 files changed, 80 deletions(-) delete mode 100644 tests/test_request_left.py diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst index f13e8270c..ff07b9d55 100644 --- a/docs/topics/signals.rst +++ b/docs/topics/signals.rst @@ -295,23 +295,6 @@ request_reached_downloader :param spider: the spider that yielded the request :type spider: :class:`~scrapy.spiders.Spider` object -request_left_downloader ---------------------------- - -.. signal:: request_left_downloader -.. function:: request_left_downloader(request, spider) - - Sent when a :class:`~scrapy.http.Request` left downloader even in case of - failure. - - The signal does not support returning deferreds from their handlers. - - :param request: the request that reached downloader - :type request: :class:`~scrapy.http.Request` object - - :param spider: the spider that yielded the request - :type spider: :class:`~scrapy.spiders.Spider` object - response_received ----------------- diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py index d856a2f37..4695d75f4 100644 --- a/scrapy/core/downloader/__init__.py +++ b/scrapy/core/downloader/__init__.py @@ -188,9 +188,6 @@ class Downloader(object): def finish_transferring(_): slot.transferring.remove(request) self._process_queue(spider, slot) - self.signals.send_catch_log(signal=signals.request_left_downloader, - request=request, - spider=spider) return _ return dfd.addBoth(finish_transferring) diff --git a/scrapy/signals.py b/scrapy/signals.py index 2ea986b8c..c0e4bb74e 100644 --- a/scrapy/signals.py +++ b/scrapy/signals.py @@ -14,7 +14,6 @@ spider_error = object() request_scheduled = object() request_dropped = object() request_reached_downloader = object() -request_left_downloader = object() response_received = object() response_downloaded = object() item_scraped = object() diff --git a/tests/test_request_left.py b/tests/test_request_left.py deleted file mode 100644 index ddeca0499..000000000 --- a/tests/test_request_left.py +++ /dev/null @@ -1,59 +0,0 @@ -from twisted.internet import defer -from twisted.trial.unittest import TestCase -from scrapy.signals import request_left_downloader -from scrapy.spiders import Spider -from scrapy.utils.test import get_crawler -from tests.mockserver import MockServer - -class SignalCatcherSpider(Spider): - name = 'signal_catcher' - - def __init__(self, crawler, url, *args, **kwargs): - super(SignalCatcherSpider, self).__init__(*args, **kwargs) - crawler.signals.connect(self.on_response_download, - signal=request_left_downloader) - self.catched_times = 0 - self.start_urls = [url] - - @classmethod - def from_crawler(cls, crawler, *args, **kwargs): - spider = cls(crawler, *args, **kwargs) - return spider - - def on_response_download(self, request, spider): - self.catched_times = self.catched_times + 1 - - -class TestCatching(TestCase): - - def setUp(self): - self.mockserver = MockServer() - self.mockserver.__enter__() - - def tearDown(self): - self.mockserver.__exit__(None, None, None) - - @defer.inlineCallbacks - def test_success(self): - crawler = get_crawler(SignalCatcherSpider) - yield crawler.crawl(self.mockserver.url("/status?n=200")) - self.assertEqual(crawler.spider.catched_times, 1) - - @defer.inlineCallbacks - def test_timeout(self): - crawler = get_crawler(SignalCatcherSpider, - {'DOWNLOAD_TIMEOUT': 0.1}) - yield crawler.crawl(self.mockserver.url("/delay?n=0.2")) - self.assertEqual(crawler.spider.catched_times, 1) - - @defer.inlineCallbacks - def test_disconnect(self): - crawler = get_crawler(SignalCatcherSpider) - yield crawler.crawl(self.mockserver.url("/drop")) - self.assertEqual(crawler.spider.catched_times, 1) - - @defer.inlineCallbacks - def test_noconnect(self): - crawler = get_crawler(SignalCatcherSpider) - yield crawler.crawl('http://thereisdefinetelynosuchdomain.com') - self.assertEqual(crawler.spider.catched_times, 1) From 8afffb7234b282dd8bd28eec2e4eb8e3f86b5723 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 22 Mar 2019 09:12:23 +0000 Subject: [PATCH 135/503] Tests Cleanup add doctest for function no need in this variables move common assertion inside function rename variable rename variables rename function use function this is not a method of public API correct name for test Update docs/topics/settings.rst Co-Authored-By: whalebot-helmsman --- docs/topics/settings.rst | 4 +- tests/test_scheduler.py | 82 ++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 6e13e64d6..cf454f4ec 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1146,9 +1146,9 @@ Default: ``'scrapy.pqueues.ScrapyPriorityQueue'`` Type of priority queue used by scheduler. Another available type is ``scrapy.pqueues.DownloaderAwarePriorityQueue``. -``scrapy.pqueues.DownloaderAwarePriorityQueue`` is works better than +``scrapy.pqueues.DownloaderAwarePriorityQueue`` works better than ``scrapy.pqueues.ScrapyPriorityQueue`` when you crawl many different -domains in parallel. But ``scrapy.pqueues.DownloaderAwarePriorityQueue`` +domains in parallel. But currently ``scrapy.pqueues.DownloaderAwarePriorityQueue`` does not work together with :setting:`CONCURRENT_REQUESTS_PER_IP`. .. setting:: SPIDER_CONTRACTS diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 75c0b7530..eaf748d35 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -24,9 +24,6 @@ class MockDownloader: def __init__(self): self.slots = dict() - def _set_slot_key(self, slot, request, spider): - request.meta[Downloader.DOWNLOAD_SLOT] = slot - def _get_slot_key(self, request, spider): if Downloader.DOWNLOAD_SLOT in request.meta: return request.meta[Downloader.DOWNLOAD_SLOT] @@ -186,12 +183,12 @@ class TestSchedulerOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase): priority_queue_cls = 'scrapy.pqueues.ScrapyPriorityQueue' -_SLOTS = [("http://foo.com/a", 'a'), - ("http://foo.com/b", 'a'), - ("http://foo.com/c", 'b'), - ("http://foo.com/d", 'b'), - ("http://foo.com/e", 'c'), - ("http://foo.com/f", 'c')] +_URLS_WITH_SLOTS = [("http://foo.com/a", 'a'), + ("http://foo.com/b", 'a'), + ("http://foo.com/c", 'b'), + ("http://foo.com/d", 'b'), + ("http://foo.com/e", 'c'), + ("http://foo.com/f", 'c')] class TestMigration(unittest.TestCase): @@ -228,37 +225,52 @@ class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' def test_logic(self): - downloader = self.mock_crawler.engine.downloader - for url, slot in _SLOTS: + for url, slot in _URLS_WITH_SLOTS: request = Request(url) - downloader._set_slot_key(slot, request, None) + request.meta[Downloader.DOWNLOAD_SLOT] = slot self.scheduler.enqueue_request(request) - slots = list() + downloader = self.mock_crawler.engine.downloader + dequeued_slots = list() requests = list() while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() slot = downloader._get_slot_key(request, None) - slots.append(slot) + dequeued_slots.append(slot) downloader.increment(slot) requests.append(request) - self.assertEqual(len(slots), len(_SLOTS)) for request in requests: slot = downloader._get_slot_key(request, None) self.mock_crawler.engine.downloader.decrement(slot) - unique_slots = len(set(s for _, s in _SLOTS)) - for i in range(0, len(_SLOTS), unique_slots): - part = slots[i:i + unique_slots] - self.assertEqual(len(part), len(set(part))) + self.assertTrue(_is_scheduling_fair(list(s for u, s in _URLS_WITH_SLOTS), + dequeued_slots)) -def _is_slots_unique(base_slots, result_slots): - unique_slots = len(set(s for _, s in base_slots)) - for i in range(0, len(result_slots), unique_slots): - part = result_slots[i:i + unique_slots] - assert len(part) == len(set(part)) +def _is_scheduling_fair(enqueued_slots, dequeued_slots): + """ + We enqueued same number of requests for every slot. + Assert correct order, e.g. + + >>> enqueued = ['a', 'b', 'c'] * 2 + >>> correct = ['a', 'c', 'b', 'b', 'a', 'c'] + >>> incorrect = ['a', 'a', 'b', 'c', 'c', 'b'] + >>> _is_scheduling_fair(enqueued, correct) + True + >>> _is_scheduling_fair(enqueued, incorrect) + False + """ + if len(dequeued_slots) != len(enqueued_slots): + return False + + slots_number = len(set(enqueued_slots)) + for i in range(0, len(dequeued_slots), slots_number): + part = dequeued_slots[i:i + slots_number] + if len(part) != len(set(part)): + return False + + return True class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, @@ -266,33 +278,31 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' def test_logic(self): - downloader = self.mock_crawler.engine.downloader - for url, slot in _SLOTS: + for url, slot in _URLS_WITH_SLOTS: request = Request(url) - downloader._set_slot_key(slot, request, None) + request.meta[Downloader.DOWNLOAD_SLOT] = slot self.scheduler.enqueue_request(request) self.close_scheduler() self.create_scheduler() - slots = [] + dequeued_slots = list() requests = [] downloader = self.mock_crawler.engine.downloader while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() slot = downloader._get_slot_key(request, None) - slots.append(slot) + dequeued_slots.append(slot) downloader.increment(slot) requests.append(request) - self.assertEqual(len(slots), len(_SLOTS)) - for request in requests: slot = downloader._get_slot_key(request, None) downloader.decrement(slot) - _is_slots_unique(_SLOTS, slots) + self.assertTrue(_is_scheduling_fair(list(s for u, s in _URLS_WITH_SLOTS), + dequeued_slots)) self.assertEqual(sum(len(s.active) for s in downloader.slots.values()), 0) @@ -305,7 +315,7 @@ class StartUrlsSpider(Spider): pass -class TestIntegrationWithDownloaderAwareOnDisk(TestCase): +class TestIntegrationWithDownloaderAwareInMemory(TestCase): def setUp(self): self.crawler = get_crawler( StartUrlsSpider, @@ -322,10 +332,10 @@ class TestIntegrationWithDownloaderAwareOnDisk(TestCase): with MockServer() as mockserver: url = mockserver.url("/status?n=200", is_secure=False) - slots = [url] * 6 - yield self.crawler.crawl(slots) + start_urls = [url] * 6 + yield self.crawler.crawl(start_urls) self.assertEqual(self.crawler.stats.get_value('downloader/response_count'), - len(slots)) + len(start_urls)) class TestIncompatibility(unittest.TestCase): From df574de8cc5c58618f6075ca3afb14059a9e30ed Mon Sep 17 00:00:00 2001 From: Lucy Wang Date: Sat, 23 Mar 2019 00:54:39 +0800 Subject: [PATCH 136/503] improve tests and fix some lint warnings (#6) * refactor downloader-aware test cases * fix lint * add doctest for _path_safe * remove unused code * better doctest --- scrapy/pqueues.py | 12 +++++++-- tests/test_scheduler.py | 57 ++++++++++++++++------------------------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/scrapy/pqueues.py b/scrapy/pqueues.py index 0681e6729..6ecd1b51a 100644 --- a/scrapy/pqueues.py +++ b/scrapy/pqueues.py @@ -11,7 +11,16 @@ logger = logging.getLogger(__name__) def _path_safe(text): - """ Return a filesystem-safe version of a string ``text`` """ + """ + Return a filesystem-safe version of a string ``text`` + + >>> _path_safe('simple.org').startswith('simple.org') + True + >>> _path_safe('dash-underscore_.org').startswith('dash-underscore_.org') + True + >>> _path_safe('some@symbol?').startswith('some_symbol_') + True + """ pathable_slot = "".join([c if c.isalnum() or c in '-._' else '_' for c in text]) # as we replace some letters we can get collision for different slots @@ -131,7 +140,6 @@ class DownloaderAwarePriorityQueue(object): domains (slots) with the least amount of active downloads are dequeued first. """ - _DOWNLOADER_AWARE_PQ_ID = '_DOWNLOADER_AWARE_PQ_ID' @classmethod def from_crawler(cls, crawler, qfactory, slot_startprios=None, serialize=False): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index eaf748d35..e0e3600e5 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -20,7 +20,7 @@ MockEngine = collections.namedtuple('MockEngine', ['downloader']) MockSlot = collections.namedtuple('MockSlot', ['active']) -class MockDownloader: +class MockDownloader(object): def __init__(self): self.slots = dict() @@ -57,7 +57,7 @@ class MockCrawler(Crawler): self.engine = MockEngine(downloader=MockDownloader()) -class SchedulerHandler: +class SchedulerHandler(object): priority_queue_cls = None jobdir = None @@ -220,34 +220,6 @@ class TestMigration(unittest.TestCase): self._migration(self.tmpdir) -class TestSchedulerWithDownloaderAwareInMemory(BaseSchedulerInMemoryTester, - unittest.TestCase): - priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' - - def test_logic(self): - for url, slot in _URLS_WITH_SLOTS: - request = Request(url) - request.meta[Downloader.DOWNLOAD_SLOT] = slot - self.scheduler.enqueue_request(request) - - downloader = self.mock_crawler.engine.downloader - dequeued_slots = list() - requests = list() - while self.scheduler.has_pending_requests(): - request = self.scheduler.next_request() - slot = downloader._get_slot_key(request, None) - dequeued_slots.append(slot) - downloader.increment(slot) - requests.append(request) - - for request in requests: - slot = downloader._get_slot_key(request, None) - self.mock_crawler.engine.downloader.decrement(slot) - - self.assertTrue(_is_scheduling_fair(list(s for u, s in _URLS_WITH_SLOTS), - dequeued_slots)) - - def _is_scheduling_fair(enqueued_slots, dequeued_slots): """ We enqueued same number of requests for every slot. @@ -273,31 +245,33 @@ def _is_scheduling_fair(enqueued_slots, dequeued_slots): return True -class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, - unittest.TestCase): +class DownloaderAwareSchedulerTestMixin(object): priority_queue_cls = 'scrapy.pqueues.DownloaderAwarePriorityQueue' + reopen = False def test_logic(self): - for url, slot in _URLS_WITH_SLOTS: request = Request(url) request.meta[Downloader.DOWNLOAD_SLOT] = slot self.scheduler.enqueue_request(request) - self.close_scheduler() - self.create_scheduler() + if self.reopen: + self.close_scheduler() + self.create_scheduler() dequeued_slots = list() requests = [] downloader = self.mock_crawler.engine.downloader while self.scheduler.has_pending_requests(): request = self.scheduler.next_request() + # pylint: disable=protected-access slot = downloader._get_slot_key(request, None) dequeued_slots.append(slot) downloader.increment(slot) requests.append(request) for request in requests: + # pylint: disable=protected-access slot = downloader._get_slot_key(request, None) downloader.decrement(slot) @@ -306,10 +280,23 @@ class TestSchedulerWithDownloaderAwareOnDisk(BaseSchedulerOnDiskTester, self.assertEqual(sum(len(s.active) for s in downloader.slots.values()), 0) +class TestSchedulerWithDownloaderAwareInMemory(DownloaderAwareSchedulerTestMixin, + BaseSchedulerInMemoryTester, + unittest.TestCase): + pass + + +class TestSchedulerWithDownloaderAwareOnDisk(DownloaderAwareSchedulerTestMixin, + BaseSchedulerOnDiskTester, + unittest.TestCase): + reopen = True + + class StartUrlsSpider(Spider): def __init__(self, start_urls): self.start_urls = start_urls + super(StartUrlsSpider, self).__init__(start_urls) def parse(self, response): pass From 31b8a6b33aed9e77a4d37a5c83b1545202207cad Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Mon, 25 Mar 2019 08:53:15 +0000 Subject: [PATCH 137/503] report warnings --- tests/test_crawler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 268948a70..d9ec9ee8d 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -1,5 +1,4 @@ import logging -import tempfile import warnings from twisted.internet import defer @@ -37,7 +36,11 @@ class CrawlerTestCase(BaseCrawlerTest): self.assertIsInstance(spiders, sl_cls) self.crawler.spiders - self.assertEqual(len(w), 1, "Warn deprecated access only once") + is_one_warning = len(w) == 1 + if not is_one_warning: + for warning in w: + print(warning) + self.assertTrue(is_one_warning, "Warn deprecated access only once") def test_populate_spidercls_settings(self): spider_settings = {'TEST1': 'spider', 'TEST2': 'spider'} From 73e4ff5304d273404a147d06726a8ae8cae1c925 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Mon, 25 Mar 2019 13:48:58 +0000 Subject: [PATCH 138/503] report warnings --- tests/test_crawler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_crawler.py b/tests/test_crawler.py index 8c4bbe0d9..e811c5757 100644 --- a/tests/test_crawler.py +++ b/tests/test_crawler.py @@ -182,8 +182,12 @@ class CrawlerRunnerTestCase(BaseCrawlerTest): 'SPIDER_MANAGER_CLASS': 'tests.test_crawler.CustomSpiderLoader' }) self.assertIsInstance(runner.spider_loader, CustomSpiderLoader) - self.assertEqual(len(w), 1) + is_one_warning = len(w) == 1 + if not is_one_warning: + for warning in w: + print(warning) self.assertIn('Please use SPIDER_LOADER_CLASS', str(w[0].message)) + self.assertTrue(is_one_warning) def test_crawl_rejects_spider_objects(self): with raises(ValueError): From 431f18a9a1a87ac8b789e3c260016e510ab48ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 1 Feb 2019 13:22:38 +0100 Subject: [PATCH 139/503] Document FilesPipeline.file_path and ImagesPipeline.file_path --- docs/topics/media-pipeline.rst | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst index c60b55391..c97b4c3c2 100644 --- a/docs/topics/media-pipeline.rst +++ b/docs/topics/media-pipeline.rst @@ -392,6 +392,36 @@ See here the methods that you can override in your custom Files Pipeline: .. class:: FilesPipeline + .. method:: file_path(request, response, info) + + This method is called once per downloaded item. It returns the + download path of the file originating from the specified + :class:`response `. + + In addition to ``response``, this method receives the original + :class:`request ` and + :class:`info `. + + You can override this method to customize the download path of each file. + + For example, if file URLs end like regular paths (e.g. + ``https://example.com/a/b/c/foo.png``), you can use the following + approach to download all files into the ``files`` folder with their + original filenames (e.g. ``files/foo.png``):: + + import os + from urllib.parse import urlparse + + from scrapy.pipelines.files import FilesPipeline + + class MyFilesPipeline(FilesPipeline): + + def file_path(self, request, response, info): + return 'files/' + os.path.basename(urlparse(request.url).path) + + By default the :meth:`file_path` method returns + ``full/.``. + .. method:: FilesPipeline.get_media_requests(item, info) As seen on the workflow, the pipeline will get the URLs of the images to @@ -475,6 +505,36 @@ See here the methods that you can override in your custom Images Pipeline: The :class:`ImagesPipeline` is an extension of the :class:`FilesPipeline`, customizing the field names and adding custom behavior for images. + .. method:: file_path(request, response, info) + + This method is called once per downloaded item. It returns the + download path of the file originating from the specified + :class:`response `. + + In addition to ``response``, this method receives the original + :class:`request ` and + :class:`info `. + + You can override this method to customize the download path of each file. + + For example, if file URLs end like regular paths (e.g. + ``https://example.com/a/b/c/foo.png``), you can use the following + approach to download all files into the ``files`` folder with their + original filenames (e.g. ``files/foo.png``):: + + import os + from urllib.parse import urlparse + + from scrapy.pipelines.images import ImagesPipeline + + class MyImagesPipeline(ImagesPipeline): + + def file_path(self, request, response, info): + return 'files/' + os.path.basename(urlparse(request.url).path) + + By default the :meth:`file_path` method returns + ``full/.``. + .. method:: ImagesPipeline.get_media_requests(item, info) Works the same way as :meth:`FilesPipeline.get_media_requests` method, From 2fd8b7c28c255148f6d7320cb97292ab2d569eff Mon Sep 17 00:00:00 2001 From: Maram Sumanth Date: Wed, 27 Mar 2019 00:45:53 +0530 Subject: [PATCH 140/503] [MRG+1] redirect_reasons in Request.meta (#3687) --- docs/topics/downloader-middleware.rst | 16 ++++++++++++++-- docs/topics/request-response.rst | 1 + scrapy/downloadermiddlewares/redirect.py | 2 ++ tests/test_downloadermiddleware_redirect.py | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 0d976077b..f2f3ef466 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -733,6 +733,17 @@ RedirectMiddleware The urls which the request goes through (while being redirected) can be found in the ``redirect_urls`` :attr:`Request.meta ` key. +.. reqmeta:: redirect_reasons + +The reason behind each redirect in :reqmeta:`redirect_urls` can be found in the +``redirect_reasons`` :attr:`Request.meta ` key. For +example: ``[301, 302, 307, 'meta refresh']``. + +The format of a reason depends on the middleware that handled the corresponding +redirect. For example, :class:`RedirectMiddleware` indicates the triggering +response status code as an integer, while :class:`MetaRefreshMiddleware` +always uses the ``'meta refresh'`` string as reason. + The :class:`RedirectMiddleware` can be configured through the following settings (see the settings documentation for more info): @@ -796,8 +807,9 @@ settings (see the settings documentation for more info): * :setting:`METAREFRESH_ENABLED` * :setting:`METAREFRESH_MAXDELAY` -This middleware obey :setting:`REDIRECT_MAX_TIMES` setting, :reqmeta:`dont_redirect` -and :reqmeta:`redirect_urls` request meta keys as described for :class:`RedirectMiddleware` +This middleware obey :setting:`REDIRECT_MAX_TIMES` setting, :reqmeta:`dont_redirect`, +:reqmeta:`redirect_urls` and :reqmeta:`redirect_reasons` request meta keys as described +for :class:`RedirectMiddleware` MetaRefreshMiddleware settings diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index ca59b46d8..ac6fe6e3f 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -299,6 +299,7 @@ Those are: * :reqmeta:`dont_merge_cookies` * :reqmeta:`cookiejar` * :reqmeta:`dont_cache` +* :reqmeta:`redirect_reasons` * :reqmeta:`redirect_urls` * :reqmeta:`bindaddress` * :reqmeta:`dont_obey_robotstxt` diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index 30cae3fee..cb59d3fd2 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -34,6 +34,8 @@ class BaseRedirectMiddleware(object): redirected.meta['redirect_ttl'] = ttl - 1 redirected.meta['redirect_urls'] = request.meta.get('redirect_urls', []) + \ [request.url] + redirected.meta['redirect_reasons'] = request.meta.get('redirect_reasons', []) + \ + [reason] redirected.dont_filter = request.dont_filter redirected.priority = request.priority + self.priority_adjust logger.debug("Redirecting (%(reason)s) to %(redirected)s from %(request)s", diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 74137b4cd..6c81c94ca 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -139,6 +139,16 @@ class RedirectMiddlewareTest(unittest.TestCase): self.assertEqual(req3.url, 'http://scrapytest.org/redirected2') self.assertEqual(req3.meta['redirect_urls'], ['http://scrapytest.org/first', 'http://scrapytest.org/redirected']) + def test_redirect_reasons(self): + req1 = Request('http://scrapytest.org/first') + rsp1 = Response('http://scrapytest.org/first', headers={'Location': '/redirected1'}, status=301) + req2 = self.mw.process_response(req1, rsp1, self.spider) + rsp2 = Response('http://scrapytest.org/redirected1', headers={'Location': '/redirected2'}, status=301) + req3 = self.mw.process_response(req2, rsp2, self.spider) + + self.assertEqual(req2.meta['redirect_reasons'], [301]) + self.assertEqual(req3.meta['redirect_reasons'], [301, 301]) + def test_spider_handling(self): smartspider = self.crawler._create_spider('smarty') smartspider.handle_httpstatus_list = [404, 301, 302] @@ -259,6 +269,15 @@ class MetaRefreshMiddlewareTest(unittest.TestCase): self.assertEqual(req3.url, 'http://scrapytest.org/redirected2') self.assertEqual(req3.meta['redirect_urls'], ['http://scrapytest.org/first', 'http://scrapytest.org/redirected']) + def test_redirect_reasons(self): + req1 = Request('http://scrapytest.org/first') + rsp1 = HtmlResponse('http://scrapytest.org/first', body=self._body(url='/redirected')) + req2 = self.mw.process_response(req1, rsp1, self.spider) + rsp2 = HtmlResponse('http://scrapytest.org/redirected', body=self._body(url='/redirected1')) + req3 = self.mw.process_response(req2, rsp2, self.spider) + + self.assertEqual(req2.meta['redirect_reasons'], ['meta refresh']) + self.assertEqual(req3.meta['redirect_reasons'], ['meta refresh', 'meta refresh']) if __name__ == "__main__": unittest.main() From ce837b0f740e989520cce58d1606a528b598503e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 25 Mar 2019 18:04:04 +0100 Subject: [PATCH 141/503] Update the documentation policies: Ask to use docstrings to document API members --- docs/contributing.rst | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 9b508e418..aac0f4496 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -165,18 +165,18 @@ Scrapy: Documentation policies ====================== -* **Don't** use docstrings for documenting classes, or methods which are - already documented in the official (sphinx) documentation. Alternatively, - **do** provide a docstring, but make sure sphinx documentation uses - autodoc_ extension to pull the docstring. For example, the - :meth:`ItemLoader.add_value` method should be either - documented only in the sphinx documentation (not as a docstring), or - it should have a docstring which is pulled to sphinx documentation using - autodoc_ extension. +For reference documentation of API members (classes, methods, etc.) use +docstrings and make sure that the Sphinx documentation uses the autodoc_ +extension to pull the docstrings. API reference documentation should be +IDE-friendly: short, to the point, and it may provide short examples. -* **Do** use docstrings for documenting functions not present in the official - (sphinx) documentation, such as functions from ``scrapy.utils`` package and - its sub-modules. +Other types of documentation, such as tutorials or topics, should be covered in +files within the ``docs/`` directory. This includes documentation that is +specific to an API member, but goes beyond API reference documentation. + +In any case, if something is covered in a docstring, use the autodoc_ +extension to pull the docstring into the documentation instead of duplicating +the docstring in files within the ``docs/`` directory. .. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html From 845bae6637239c859c9952c23f42902e36d10f6b Mon Sep 17 00:00:00 2001 From: Mikhail Korobov Date: Wed, 27 Mar 2019 08:49:19 +0000 Subject: [PATCH 142/503] Update docs/topics/broad-crawls.rst Co-Authored-By: whalebot-helmsman --- docs/topics/broad-crawls.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index 37f7a8748..64c8883b1 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -42,7 +42,7 @@ efficient broad crawl. Use proper :setting:`SCHEDULER_PRIORITY_QUEUE` ============================================== -Default scrapy's scheduler priority queue is ``'queuelib.PriorityQueue'``. +Default scrapy's scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. It works best during single domain crawl. And it does not work well with crawling many different domains in parallel From 9c9bca4e1c7984089c44f3a44e7594e06307b12f Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Wed, 27 Mar 2019 18:29:48 +0530 Subject: [PATCH 143/503] make suggested changes. --- docs/topics/downloader-middleware.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index f913b059d..dfbcdb8fa 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -349,7 +349,7 @@ HttpCacheMiddleware * :ref:`httpcache-storage-leveldb` You can change the HTTP cache storage backend with the :setting:`HTTPCACHE_STORAGE` - setting. Or you can also implement your own storage backend. + setting. Or you can also :ref:`implement your own storage backend. ` Scrapy ships with two HTTP cache policies: @@ -498,10 +498,10 @@ In order to use this storage backend: .. _httpcache-storage-custom: -Implementing custom cache storage backend -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Writing your own storage backend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can implement custom cache storage backend by creating a Python class that +You can implement a cache storage backend by creating a Python class that defines the methods described below. .. module:: scrapy.extensions.httpcache @@ -510,14 +510,16 @@ defines the methods described below. .. method:: open_spider(spider) - This method gets called after a spider has been opened for crawling. + This method gets called after a spider has been opened for crawling. It handles + the :signal:`open_spider ` signal. :param spider: the spider which has been opened :type spider: :class:`~scrapy.spiders.Spider` object .. method:: close_spider(spider) - This method gets called after a spider has been closed. + This method gets called after a spider has been closed. It handles + the :signal:`close_spider ` signal. :param spider: the spider which has been closed :type spider: :class:`~scrapy.spiders.Spider` object @@ -539,7 +541,7 @@ defines the methods described below. :param spider: the spider for which the response is intended :type spider: :class:`~scrapy.spiders.Spider` object - :param request: corresponding request the spider generated + :param request: the corresponding request the spider generated :type request: :class:`~scrapy.http.Request` object :param response: the response to store in the cache @@ -547,7 +549,7 @@ defines the methods described below. In order to use your storage backend, set: -* :setting:`HTTPCACHE_STORAGE` to path of your custom storage class. +* :setting:`HTTPCACHE_STORAGE` to the Python import path of your custom storage class. HTTPCache middleware settings From 8528f5065f99046b149b5e1901d6cbe5296f048a Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Wed, 27 Mar 2019 14:42:26 -0300 Subject: [PATCH 144/503] [Doc] Update cb_kwargs example --- docs/topics/request-response.rst | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index b3f849540..61789be0f 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -233,19 +233,19 @@ The following two examples show how to achieve this by using the :: - def parse_page1(self, response): - item = MyItem() - item['main_url'] = response.url - request = scrapy.Request("http://www.example.com/some_page.html", - callback=self.parse_page2) - request.cb_kwargs['item'] = item - request.cb_kwargs['foo'] = 'bar' + def parse(self, response): + request = scrapy.Request('http://www.example.com/index.html', + callback=self.parse_page2, + cb_kwargs=dict(main_url=response.url)) + request.cb_kwargs['foo'] = 'bar' # add more arguments for the callback yield request - def parse_page2(self, response, item, foo): - item['other_url'] = response.url - item['foo'] = foo - yield item + def parse_page2(self, response, main_url, foo): + yield dict( + main_url=main_url, + other_url=response.url, + foo=foo, + ) .. _topics-request-response-ref-errbacks: From 70a4d93aa324fb276e60d641b37bdc6eb707b1cb Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 28 Mar 2019 10:40:41 -0300 Subject: [PATCH 145/503] Callback kwargs: more tests --- tests/spiders.py | 22 ++++++++++++++++++++++ tests/test_crawl.py | 21 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/spiders.py b/tests/spiders.py index a06985837..8c8d50ff5 100644 --- a/tests/spiders.py +++ b/tests/spiders.py @@ -39,6 +39,9 @@ class KeywordArgumentsSpider(MockServerSpider): yield Request(self.mockserver.url('/general_with'), self.parse_general, cb_kwargs=data) yield Request(self.mockserver.url('/general_without'), self.parse_general) yield Request(self.mockserver.url('/no_kwargs'), self.parse_no_kwargs) + yield Request(self.mockserver.url('/default'), self.parse_default, cb_kwargs=data) + yield Request(self.mockserver.url('/takes_less'), self.parse_takes_less, cb_kwargs=data) + yield Request(self.mockserver.url('/takes_more'), self.parse_takes_more, cb_kwargs=data) def parse_first(self, response, key, number): self.checks.append(key == 'value') @@ -66,6 +69,25 @@ class KeywordArgumentsSpider(MockServerSpider): self.checks.append(response.url.endswith('/no_kwargs')) self.crawler.stats.inc_value('boolean_checks') + def parse_default(self, response, key, number=None, default=99): + self.checks.append(response.url.endswith('/default')) + self.checks.append(key == 'value') + self.checks.append(number == 123) + self.checks.append(default == 99) + self.crawler.stats.inc_value('boolean_checks', 4) + + def parse_takes_less(self, response, key): + """ + Should raise + TypeError: parse_takes_less() got an unexpected keyword argument 'number' + """ + + def parse_takes_more(self, response, key, number, other): + """ + Should raise + TypeError: parse_takes_more() missing 1 required positional argument: 'other' + """ + class FollowAllSpider(MetaSpider): diff --git a/tests/test_crawl.py b/tests/test_crawl.py index 9a39b8cb4..2b3e56ee9 100644 --- a/tests/test_crawl.py +++ b/tests/test_crawl.py @@ -4,6 +4,7 @@ import logging from testfixtures import LogCapture from twisted.internet import defer from twisted.trial.unittest import TestCase +import six from scrapy.http import Request from scrapy.crawler import CrawlerRunner @@ -15,6 +16,8 @@ from tests.mockserver import MockServer class CrawlTestCase(TestCase): + maxDiff = None + def setUp(self): self.mockserver = MockServer() self.mockserver.__enter__() @@ -26,9 +29,25 @@ class CrawlTestCase(TestCase): @defer.inlineCallbacks def test_callback_kwargs(self): crawler = self.runner.create_crawler(KeywordArgumentsSpider) - yield crawler.crawl(mockserver=self.mockserver) + with LogCapture() as log: + yield crawler.crawl(mockserver=self.mockserver) self.assertTrue(all(crawler.spider.checks)) self.assertEqual(len(crawler.spider.checks), crawler.stats.get_value('boolean_checks')) + # check exceptions for argument mismatch + exceptions = {} + for line in log.records: + for key in ('takes_less', 'takes_more'): + if key in line.getMessage(): + exceptions[key] = line + self.assertEqual(exceptions['takes_less'].exc_info[0], TypeError) + self.assertEqual(str(exceptions['takes_less'].exc_info[1]), "parse_takes_less() got an unexpected keyword argument 'number'") + self.assertEqual(exceptions['takes_more'].exc_info[0], TypeError) + # py2 and py3 messages are different + exc_message = str(exceptions['takes_more'].exc_info[1]) + if six.PY2: + self.assertEqual(exc_message, "parse_takes_more() takes exactly 5 arguments (4 given)") + elif six.PY3: + self.assertEqual(exc_message, "parse_takes_more() missing 1 required positional argument: 'other'") @defer.inlineCallbacks def test_follow_all(self): From 3efe3bea1cbb5ae83c024fc6dc8e1776a47a345f Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 28 Mar 2019 14:16:03 -0300 Subject: [PATCH 146/503] Update docs about cb_kwargs and meta --- docs/topics/request-response.rst | 41 ++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index dd0db8156..05ca8d6c1 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -215,24 +215,12 @@ Example:: In some cases you may be interested in passing arguments to those callback functions so you can receive the arguments later, in the second callback. -The following two examples show how to achieve this by using the -:attr:`Request.meta` and :attr:`Request.cb_kwargs` attributes respectively:: - - def parse_page1(self, response): - item = MyItem() - item['main_url'] = response.url - request = scrapy.Request("http://www.example.com/some_page.html", - callback=self.parse_page2) - request.meta['item'] = item - yield request - - def parse_page2(self, response): - item = response.meta['item'] - item['other_url'] = response.url - yield item +The following example shows how to achieve this by using the +:attr:`Request.cb_kwargs` attribute: :: + # pass information to the next callback using the Request.cb_kwargs attribute def parse(self, response): request = scrapy.Request('http://www.example.com/index.html', callback=self.parse_page2, @@ -247,6 +235,29 @@ The following two examples show how to achieve this by using the foo=foo, ) +.. caution:: :attr:`Request.cb_kwargs` was introduced in version ``1.7``. + Prior to that, :attr:`Request.meta` was the recommended option for passing + information around callbacks. However, after ``1.7`` :attr:`Request.cb_kwargs` + became the preferred way of passing user information, leaving :attr:`Request.meta` + to be used by internal components like spider or downloader middlewares. + The following example, which uses :attr:`Request.meta`, is only kept for historical + reasons. + +:: + + # pass information to the next callback using the Request.meta attribute + def parse_page1(self, response): + item = MyItem() + item['main_url'] = response.url + request = scrapy.Request("http://www.example.com/some_page.html", + callback=self.parse_page2) + request.meta['item'] = item + yield request + + def parse_page2(self, response): + item = response.meta['item'] + item['other_url'] = response.url + yield item .. _topics-request-response-ref-errbacks: From e8af6331b5ff62d71ff80eddcc52b85c25482c0e Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 28 Mar 2019 14:56:31 -0300 Subject: [PATCH 147/503] Add cb_kwargs option to the parse command --- docs/topics/commands.rst | 3 +++ scrapy/commands/parse.py | 37 ++++++++++++++++++++++++------------- tests/test_command_parse.py | 14 ++++++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index 97f8311de..6644d65e4 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -461,6 +461,9 @@ Supported options: * ``--meta`` or ``-m``: additional request meta that will be passed to the callback request. This must be a valid json string. Example: --meta='{"foo" : "bar"}' +* ``--cb_kwargs``: additional keyword arguments that will be passed to the callback. + This must be a valid json string. Example: --cb_kwargs='{"foo" : "bar"}' + * ``--pipelines``: process items through pipelines * ``--rules`` or ``-r``: use :class:`~scrapy.spiders.CrawlSpider` diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index 69418a478..2486f3f23 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -51,12 +51,13 @@ class Command(ScrapyCommand): help="use this callback for parsing, instead looking for a callback") parser.add_option("-m", "--meta", dest="meta", help="inject extra meta into the Request, it must be a valid raw json string") + parser.add_option("--cb_kwargs", dest="cb_kwargs", + help="inject extra cb_kwargs into the Request, it must be a valid raw json string") parser.add_option("-d", "--depth", dest="depth", type="int", default=1, help="maximum depth for parsing requests [default: %default]") parser.add_option("-v", "--verbose", dest="verbose", action="store_true", help="print each depth level one by one") - @property def max_level(self): levels = list(self.items.keys()) + list(self.requests.keys()) @@ -111,10 +112,11 @@ class Command(ScrapyCommand): if not opts.nolinks: self.print_requests(colour=colour) - def run_callback(self, response, cb): + def run_callback(self, response, callback, cb_kwargs=None): + cb_kwargs = cb_kwargs or {} items, requests = [], [] - for x in iterate_spider_output(cb(response)): + for x in iterate_spider_output(callback(response, **cb_kwargs)): if isinstance(x, (BaseItem, dict)): items.append(x) elif isinstance(x, Request): @@ -142,8 +144,7 @@ class Command(ScrapyCommand): else: self.spidercls = spidercls_for_request(spider_loader, Request(url)) if not self.spidercls: - logger.error('Unable to find spider for: %(url)s', - {'url': url}) + logger.error('Unable to find spider for: %(url)s', {'url': url}) # Request requires callback argument as callable or None, not string request = Request(url, None) @@ -160,7 +161,7 @@ class Command(ScrapyCommand): {'url': url}) def prepare_request(self, spider, request, opts): - def callback(response): + def callback(response, **cb_kwargs): # memorize first request if not self.first_response: self.first_response = response @@ -175,7 +176,7 @@ class Command(ScrapyCommand): if not cb: logger.error('Cannot find a rule that matches %(url)r in spider: %(spider)s', - {'url': response.url, 'spider': spider.name}) + {'url': response.url, 'spider': spider.name}) return else: cb = 'parse' @@ -192,7 +193,7 @@ class Command(ScrapyCommand): # parse items and requests depth = response.meta['_depth'] - items, requests = self.run_callback(response, cb) + items, requests = self.run_callback(response, cb, cb_kwargs) if opts.pipelines: itemproc = self.pcrawler.engine.scraper.itemproc for item in items: @@ -207,10 +208,14 @@ class Command(ScrapyCommand): req.callback = callback return requests - #update request meta if any extra meta was passed through the --meta/-m opts. + # update request meta if any extra meta was passed through the --meta/-m opts. if opts.meta: request.meta.update(opts.meta) + # update cb_kwargs if any extra cb_kwargs was passed through the --cb_kwargs option. + if opts.cb_kwargs: + request.cb_kwargs.update(opts.cb_kwargs) + request.meta['_depth'] = 1 request.meta['_callback'] = request.callback request.callback = callback @@ -221,23 +226,29 @@ class Command(ScrapyCommand): self.process_spider_arguments(opts) self.process_request_meta(opts) + self.process_request_cb_kwargs(opts) def process_spider_arguments(self, opts): - try: opts.spargs = arglist_to_dict(opts.spargs) except ValueError: raise UsageError("Invalid -a value, use -a NAME=VALUE", print_help=False) def process_request_meta(self, opts): - if opts.meta: try: opts.meta = json.loads(opts.meta) except ValueError: - raise UsageError("Invalid -m/--meta value, pass a valid json string to -m or --meta. " \ - "Example: --meta='{\"foo\" : \"bar\"}'", print_help=False) + raise UsageError("Invalid -m/--meta value, pass a valid json string to -m or --meta. " + "Example: --meta='{\"foo\" : \"bar\"}'", print_help=False) + def process_request_cb_kwargs(self, opts): + if opts.cb_kwargs: + try: + opts.cb_kwargs = json.loads(opts.cb_kwargs) + except ValueError: + raise UsageError("Invalid --cb_kwargs value, pass a valid json string to --cb_kwargs. " + "Example: --cb_kwargs='{\"foo\" : \"bar\"}'", print_help=False) def run(self, args, opts): # parse arguments diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 02037b866..1404005fb 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -43,6 +43,12 @@ class MySpider(scrapy.Spider): else: self.logger.debug('It Works!') + def parse_request_with_cb_kwargs(self, response, foo=None, key=None): + if foo == 'bar' and key == 'value': + self.logger.debug('It Works!') + else: + self.logger.debug('It Does Not Work :(') + def parse_request_without_meta(self, response): foo = response.meta.get('foo', 'bar') @@ -120,6 +126,14 @@ ITEM_PIPELINES = {'%s.pipelines.MyPipeline': 1} self.url('/html')]) self.assertIn("DEBUG: It Works!", _textmode(stderr)) + @defer.inlineCallbacks + def test_request_with_cb_kwargs(self): + raw_json_string = '{"foo" : "bar", "key": "value"}' + _, _, stderr = yield self.execute(['--spider', self.spider_name, + '--cb_kwargs', raw_json_string, + '-c', 'parse_request_with_cb_kwargs', + self.url('/html')]) + self.assertIn("DEBUG: It Works!", _textmode(stderr)) @defer.inlineCallbacks def test_request_without_meta(self): From 8fb077694fcaa50a8625c8e2e8d0068add2b056d Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Thu, 28 Mar 2019 15:18:00 -0300 Subject: [PATCH 148/503] Request.cb_kwargs: Update docs --- docs/topics/debug.rst | 13 +++++-------- docs/topics/jobs.rst | 9 +++++---- docs/topics/leaks.rst | 14 ++++++++------ docs/topics/request-response.rst | 13 ++++++------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/docs/topics/debug.rst b/docs/topics/debug.rst index f93aa2c72..0aaad0c77 100644 --- a/docs/topics/debug.rst +++ b/docs/topics/debug.rst @@ -28,16 +28,15 @@ Consider the following scrapy spider below:: item = MyItem() # populate `item` fields # and extract item_details_url - yield scrapy.Request(item_details_url, self.parse_details, meta={'item': item}) + yield scrapy.Request(item_details_url, self.parse_details, cb_kwargs={'item': item}) - def parse_details(self, response): - item = response.meta['item'] + def parse_details(self, response, item): # populate more `item` fields return item Basically this is a simple spider which parses two pages of items (the start_urls). Items also have a details page with additional information, so we -use the ``meta`` functionality of :class:`~scrapy.http.Request` to pass a +use the ``cb_kwargs`` functionality of :class:`~scrapy.http.Request` to pass a partially populated item. @@ -100,8 +99,7 @@ Fortunately, the :command:`shell` is your bread and butter in this case (see from scrapy.shell import inspect_response - def parse_details(self, response): - item = response.meta.get('item', None) + def parse_details(self, response, item=None): if item: # populate more `item` fields return item @@ -134,8 +132,7 @@ Logging is another useful option for getting information about your spider run. Although not as convenient, it comes with the advantage that the logs will be available in all future runs should they be necessary again:: - def parse_details(self, response): - item = response.meta.get('item', None) + def parse_details(self, response, item=None): if item: # populate more `item` fields return item diff --git a/docs/topics/jobs.rst b/docs/topics/jobs.rst index 1a5d52487..9fd311c69 100644 --- a/docs/topics/jobs.rst +++ b/docs/topics/jobs.rst @@ -81,7 +81,8 @@ So, for example, this won't work:: def some_callback(self, response): somearg = 'test' - return scrapy.Request('http://www.example.com', callback=lambda r: self.other_callback(r, somearg)) + return scrapy.Request('http://www.example.com', + callback=lambda r: self.other_callback(r, somearg)) def other_callback(self, response, somearg): print("the argument passed is: %s" % somearg) @@ -90,10 +91,10 @@ But this will:: def some_callback(self, response): somearg = 'test' - return scrapy.Request('http://www.example.com', callback=self.other_callback, meta={'somearg': somearg}) + return scrapy.Request('http://www.example.com', + callback=self.other_callback, cb_kwargs={'somearg': somearg}) - def other_callback(self, response): - somearg = response.meta['somearg'] + def other_callback(self, response, somearg): print("the argument passed is: %s" % somearg) If you wish to log the requests that couldn't be serialized, you can set the diff --git a/docs/topics/leaks.rst b/docs/topics/leaks.rst index af14d14e8..8278e9849 100644 --- a/docs/topics/leaks.rst +++ b/docs/topics/leaks.rst @@ -27,10 +27,11 @@ Common causes of memory leaks It happens quite often (sometimes by accident, sometimes on purpose) that the Scrapy developer passes objects referenced in Requests (for example, using the -:attr:`~scrapy.http.Request.meta` attribute or the request callback function) -and that effectively bounds the lifetime of those referenced objects to the -lifetime of the Request. This is, by far, the most common cause of memory leaks -in Scrapy projects, and a quite difficult one to debug for newcomers. +:attr:`~scrapy.http.Request.cb_kwargs` or :attr:`~scrapy.http.Request.meta` +attributes or the request callback function) and that effectively bounds the +lifetime of those referenced objects to the lifetime of the Request. This is, +by far, the most common cause of memory leaks in Scrapy projects, and a quite +difficult one to debug for newcomers. In big projects, the spiders are typically written by different people and some of those spiders could be "leaking" and thus affecting the rest of the other @@ -48,7 +49,8 @@ Too Many Requests? By default Scrapy keeps the request queue in memory; it includes :class:`~scrapy.http.Request` objects and all objects -referenced in Request attributes (e.g. in :attr:`~scrapy.http.Request.meta`). +referenced in Request attributes (e.g. in :attr:`~scrapy.http.Request.cb_kwargs` +and :attr:`~scrapy.http.Request.meta`). While not necessarily a leak, this can take a lot of memory. Enabling :ref:`persistent job queue ` could help keeping memory usage in control. @@ -101,7 +103,7 @@ Let's see a concrete example of a hypothetical case of memory leaks. Suppose we have some spider with a line similar to this one:: return Request("http://www.somenastyspider.com/product.php?pid=%d" % product_id, - callback=self.parse, meta={referer: response}) + callback=self.parse, cb_kwargs={'referer': response}) That line is passing a response reference inside a request which effectively ties the response lifetime to the requests' one, and that would definitely diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 05ca8d6c1..f299c2cff 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -186,12 +186,12 @@ Request objects Return a new Request which is a copy of this Request. See also: :ref:`topics-request-response-ref-request-callback-arguments`. - .. method:: Request.replace([url, method, headers, body, cookies, meta, encoding, dont_filter, callback, errback]) + .. method:: Request.replace([url, method, headers, body, cookies, meta, flags, encoding, priority, dont_filter, callback, errback, cb_kwargs]) Return a Request object with the same members, except for those members given new values by whichever keyword arguments are specified. The - attribute :attr:`Request.meta` is copied by default (unless a new value - is given in the ``meta`` argument). See also + :attr:`Request.cb_kwargs` and :attr:`Request.meta` attributes are copied by default + (unless new values are given as arguments). See also :ref:`topics-request-response-ref-request-callback-arguments`. .. _topics-request-response-ref-request-callback-arguments: @@ -237,11 +237,10 @@ The following example shows how to achieve this by using the .. caution:: :attr:`Request.cb_kwargs` was introduced in version ``1.7``. Prior to that, :attr:`Request.meta` was the recommended option for passing - information around callbacks. However, after ``1.7`` :attr:`Request.cb_kwargs` + information around callbacks. However, after ``1.7``, using :attr:`Request.cb_kwargs` became the preferred way of passing user information, leaving :attr:`Request.meta` - to be used by internal components like spider or downloader middlewares. - The following example, which uses :attr:`Request.meta`, is only kept for historical - reasons. + to be populated by internal components like spider or downloader middlewares. + The following :attr:`Request.meta` example is only kept for historical reasons. :: From 46b9ab0c58354deb1045c20f3bc061526d69f356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 29 Mar 2019 10:28:36 +0000 Subject: [PATCH 149/503] Update docs/topics/broad-crawls.rst Co-Authored-By: whalebot-helmsman --- docs/topics/broad-crawls.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index 64c8883b1..68a24a4d2 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -42,7 +42,7 @@ efficient broad crawl. Use proper :setting:`SCHEDULER_PRIORITY_QUEUE` ============================================== -Default scrapy's scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. +Scrapy’s default scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. It works best during single domain crawl. And it does not work well with crawling many different domains in parallel From e3df6be360a58f016e31d5bfa2e04cd2e5d1965b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 29 Mar 2019 10:28:52 +0000 Subject: [PATCH 150/503] Update docs/topics/broad-crawls.rst Co-Authored-By: whalebot-helmsman --- docs/topics/broad-crawls.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index 68a24a4d2..b149d7f4a 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -43,7 +43,7 @@ Use proper :setting:`SCHEDULER_PRIORITY_QUEUE` ============================================== Scrapy’s default scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. -It works best during single domain crawl. And it does not work well with crawling +It works best during single-domain crawl. It does not work well with crawling many different domains in parallel To apply recommended priority queue use:: From bd228f1d962c7f4759536d8cda278857de7d5234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 29 Mar 2019 10:29:04 +0000 Subject: [PATCH 151/503] Update docs/topics/broad-crawls.rst Co-Authored-By: whalebot-helmsman --- docs/topics/broad-crawls.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index b149d7f4a..a01f28248 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -46,7 +46,7 @@ Scrapy’s default scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQ It works best during single-domain crawl. It does not work well with crawling many different domains in parallel -To apply recommended priority queue use:: +To apply the recommended priority queue use:: SCHEDULER_PRIORITY_QUEUE = 'scrapy.pqueues.DownloaderAwarePriorityQueue' From 1ee99e1f4240af6a7a72fe7c58b89d7bce1cd09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 29 Mar 2019 10:29:15 +0000 Subject: [PATCH 152/503] Update docs/topics/settings.rst Co-Authored-By: whalebot-helmsman --- docs/topics/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index ed94146f4..4a5439bfc 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -1157,7 +1157,7 @@ SCHEDULER_PRIORITY_QUEUE ------------------------ Default: ``'scrapy.pqueues.ScrapyPriorityQueue'`` -Type of priority queue used by scheduler. Another available type is +Type of priority queue used by the scheduler. Another available type is ``scrapy.pqueues.DownloaderAwarePriorityQueue``. ``scrapy.pqueues.DownloaderAwarePriorityQueue`` works better than ``scrapy.pqueues.ScrapyPriorityQueue`` when you crawl many different From 2b4bcfaf494073520e84bbf301d5141a2e19a3e6 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 29 Mar 2019 10:30:26 +0000 Subject: [PATCH 153/503] remove comment --- scrapy/core/scheduler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index c385fafe1..9d0258db2 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -57,7 +57,6 @@ class Scheduler(object): dupefilter = create_instance(dupefilter_cls, settings, crawler) pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE']) if pqclass is PriorityQueue: - # backwards compatibility warnings.warn("SCHEDULER_PRIORITY_QUEUE='queuelib.PriorityQueue'" " is no longer supported because of API changes; " "please use 'scrapy.pqueues.ScrapyPriorityQueue'", From 554d8728227a9ea96e5ea3a8a4fd782d42fdbd66 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 29 Mar 2019 10:31:15 +0000 Subject: [PATCH 154/503] remove spacing --- scrapy/core/scheduler.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index 9d0258db2..d87d2ffdc 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -77,13 +77,8 @@ class Scheduler(object): def open(self, spider): self.spider = spider - - # in-memory PriorityQueue instance self.mqs = self._mq() - - # on-disk PriorityQueue instance self.dqs = self._dq() if self.dqdir else None - return self.df.open() def close(self, reason): From f08f841d0bebd097358889c0c98f83f051828f15 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 29 Mar 2019 10:35:49 +0000 Subject: [PATCH 155/503] remove small single use method --- scrapy/core/scheduler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py index d87d2ffdc..975aede0c 100644 --- a/scrapy/core/scheduler.py +++ b/scrapy/core/scheduler.py @@ -101,7 +101,7 @@ class Scheduler(object): return True def next_request(self): - request = self._mqpop() + request = self.mqs.pop() if request: self.stats.inc_value('scheduler/dequeued/memory', spider=self.spider) else: @@ -141,9 +141,6 @@ class Scheduler(object): if self.dqs: return self.dqs.pop() - def _mqpop(self): - return self.mqs.pop() - def _newmq(self, priority): """ Factory for creating memory queues. """ return self.mqclass() From ef743983a98ae0891abf9aca4c9b19cb44861c49 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 29 Mar 2019 10:38:13 +0000 Subject: [PATCH 156/503] change wording --- docs/topics/broad-crawls.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index a01f28248..6e50c0bc7 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -39,7 +39,7 @@ you need to keep in mind when using Scrapy for doing broad crawls, along with concrete suggestions of Scrapy settings to tune in order to achieve an efficient broad crawl. -Use proper :setting:`SCHEDULER_PRIORITY_QUEUE` +Use the right :setting:`SCHEDULER_PRIORITY_QUEUE` ============================================== Scrapy’s default scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. @@ -96,7 +96,7 @@ When doing broad crawls you are often only interested in the crawl rates you get and any errors found. These stats are reported by Scrapy when using the ``INFO`` log level. In order to save CPU (and log storage requirements) you should not use ``DEBUG`` log level when preforming large broad crawls in -production. Using ``DEBUG`` level when developing your (broad) crawler may be +production. Using ``DEBUG`` level when developing your (broad) crawler may be fine though. To set the log level use:: From 1c6733454e14a3c237ed602b65ae5e0a8a78dee5 Mon Sep 17 00:00:00 2001 From: Vostretsov Nikita Date: Fri, 29 Mar 2019 10:44:55 +0000 Subject: [PATCH 157/503] added underlines --- docs/topics/broad-crawls.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/broad-crawls.rst b/docs/topics/broad-crawls.rst index 6e50c0bc7..b887b98af 100644 --- a/docs/topics/broad-crawls.rst +++ b/docs/topics/broad-crawls.rst @@ -40,7 +40,7 @@ concrete suggestions of Scrapy settings to tune in order to achieve an efficient broad crawl. Use the right :setting:`SCHEDULER_PRIORITY_QUEUE` -============================================== +================================================= Scrapy’s default scheduler priority queue is ``'scrapy.pqueues.ScrapyPriorityQueue'``. It works best during single-domain crawl. It does not work well with crawling From f5e0b6b89ace437af850e0225651329101a59862 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 29 Mar 2019 14:03:26 -0300 Subject: [PATCH 158/503] parse command: rename cb_kwargs option to cbkwargs --- scrapy/commands/parse.py | 18 +++++++++--------- tests/test_command_parse.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index 2486f3f23..e948d6406 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -51,8 +51,8 @@ class Command(ScrapyCommand): help="use this callback for parsing, instead looking for a callback") parser.add_option("-m", "--meta", dest="meta", help="inject extra meta into the Request, it must be a valid raw json string") - parser.add_option("--cb_kwargs", dest="cb_kwargs", - help="inject extra cb_kwargs into the Request, it must be a valid raw json string") + parser.add_option("--cbkwargs", dest="cbkwargs", + help="inject extra cbkwargs into the Request, it must be a valid raw json string") parser.add_option("-d", "--depth", dest="depth", type="int", default=1, help="maximum depth for parsing requests [default: %default]") parser.add_option("-v", "--verbose", dest="verbose", action="store_true", @@ -212,9 +212,9 @@ class Command(ScrapyCommand): if opts.meta: request.meta.update(opts.meta) - # update cb_kwargs if any extra cb_kwargs was passed through the --cb_kwargs option. - if opts.cb_kwargs: - request.cb_kwargs.update(opts.cb_kwargs) + # update cb_kwargs if any extra values were was passed through the --cbkwargs option. + if opts.cbkwargs: + request.cb_kwargs.update(opts.cbkwargs) request.meta['_depth'] = 1 request.meta['_callback'] = request.callback @@ -243,12 +243,12 @@ class Command(ScrapyCommand): "Example: --meta='{\"foo\" : \"bar\"}'", print_help=False) def process_request_cb_kwargs(self, opts): - if opts.cb_kwargs: + if opts.cbkwargs: try: - opts.cb_kwargs = json.loads(opts.cb_kwargs) + opts.cbkwargs = json.loads(opts.cbkwargs) except ValueError: - raise UsageError("Invalid --cb_kwargs value, pass a valid json string to --cb_kwargs. " - "Example: --cb_kwargs='{\"foo\" : \"bar\"}'", print_help=False) + raise UsageError("Invalid --cbkwargs value, pass a valid json string to --cbkwargs. " + "Example: --cbkwargs='{\"foo\" : \"bar\"}'", print_help=False) def run(self, args, opts): # parse arguments diff --git a/tests/test_command_parse.py b/tests/test_command_parse.py index 1404005fb..c18a6ce9f 100644 --- a/tests/test_command_parse.py +++ b/tests/test_command_parse.py @@ -130,7 +130,7 @@ ITEM_PIPELINES = {'%s.pipelines.MyPipeline': 1} def test_request_with_cb_kwargs(self): raw_json_string = '{"foo" : "bar", "key": "value"}' _, _, stderr = yield self.execute(['--spider', self.spider_name, - '--cb_kwargs', raw_json_string, + '--cbkwargs', raw_json_string, '-c', 'parse_request_with_cb_kwargs', self.url('/html')]) self.assertIn("DEBUG: It Works!", _textmode(stderr)) From ccb56a317ee249978496e918ee5b74d83c2d7199 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 29 Mar 2019 14:12:26 -0300 Subject: [PATCH 159/503] Update docs about cb_kwargs and meta --- docs/topics/request-response.rst | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index f299c2cff..4e81ce878 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -190,8 +190,8 @@ Request objects Return a Request object with the same members, except for those members given new values by whichever keyword arguments are specified. The - :attr:`Request.cb_kwargs` and :attr:`Request.meta` attributes are copied by default - (unless new values are given as arguments). See also + :attr:`Request.cb_kwargs` and :attr:`Request.meta` attributes are shallow + copied by default (unless new values are given as arguments). See also :ref:`topics-request-response-ref-request-callback-arguments`. .. _topics-request-response-ref-request-callback-arguments: @@ -220,7 +220,6 @@ The following example shows how to achieve this by using the :: - # pass information to the next callback using the Request.cb_kwargs attribute def parse(self, response): request = scrapy.Request('http://www.example.com/index.html', callback=self.parse_page2, @@ -236,27 +235,10 @@ The following example shows how to achieve this by using the ) .. caution:: :attr:`Request.cb_kwargs` was introduced in version ``1.7``. - Prior to that, :attr:`Request.meta` was the recommended option for passing - information around callbacks. However, after ``1.7``, using :attr:`Request.cb_kwargs` - became the preferred way of passing user information, leaving :attr:`Request.meta` - to be populated by internal components like spider or downloader middlewares. - The following :attr:`Request.meta` example is only kept for historical reasons. - -:: - - # pass information to the next callback using the Request.meta attribute - def parse_page1(self, response): - item = MyItem() - item['main_url'] = response.url - request = scrapy.Request("http://www.example.com/some_page.html", - callback=self.parse_page2) - request.meta['item'] = item - yield request - - def parse_page2(self, response): - item = response.meta['item'] - item['other_url'] = response.url - yield item + Prior to that, using :attr:`Request.meta` was recommended for passing + information around callbacks. After ``1.7``, :attr:`Request.cb_kwargs` + became the preferred way for handling user information, leaving :attr:`Request.meta` + for communication with components like middlewares and extensions. .. _topics-request-response-ref-errbacks: From 294ef51bb24782a0527892ea93bb4876daa7ca50 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 29 Mar 2019 16:12:55 -0300 Subject: [PATCH 160/503] parse command: update docs about passing callback keyword arguments --- docs/topics/commands.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/commands.rst b/docs/topics/commands.rst index 6644d65e4..a93bee06b 100644 --- a/docs/topics/commands.rst +++ b/docs/topics/commands.rst @@ -461,8 +461,8 @@ Supported options: * ``--meta`` or ``-m``: additional request meta that will be passed to the callback request. This must be a valid json string. Example: --meta='{"foo" : "bar"}' -* ``--cb_kwargs``: additional keyword arguments that will be passed to the callback. - This must be a valid json string. Example: --cb_kwargs='{"foo" : "bar"}' +* ``--cbkwargs``: additional keyword arguments that will be passed to the callback. + This must be a valid json string. Example: --cbkwargs='{"foo" : "bar"}' * ``--pipelines``: process items through pipelines From 0522fe35c334141e90741644fec368cdbd12044e Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Fri, 29 Mar 2019 16:15:34 -0300 Subject: [PATCH 161/503] parse command: improve option description --- scrapy/commands/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py index e948d6406..d4f2234b0 100644 --- a/scrapy/commands/parse.py +++ b/scrapy/commands/parse.py @@ -52,7 +52,7 @@ class Command(ScrapyCommand): parser.add_option("-m", "--meta", dest="meta", help="inject extra meta into the Request, it must be a valid raw json string") parser.add_option("--cbkwargs", dest="cbkwargs", - help="inject extra cbkwargs into the Request, it must be a valid raw json string") + help="inject extra callback kwargs into the Request, it must be a valid raw json string") parser.add_option("-d", "--depth", dest="depth", type="int", default=1, help="maximum depth for parsing requests [default: %default]") parser.add_option("-v", "--verbose", dest="verbose", action="store_true", From 07ff9248a5fd2eac4f53da92766dcb5a7ca48569 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Mon, 1 Apr 2019 12:31:26 -0300 Subject: [PATCH 162/503] [Docs] CrawlSpider: add note about link text --- docs/topics/spiders.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 8c4049f85..5417ef129 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -377,7 +377,10 @@ Crawling rules .. class:: Rule(link_extractor, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=None) ``link_extractor`` is a :ref:`Link Extractor ` object which - defines how links will be extracted from each crawled page. + defines how links will be extracted from each crawled page. Each produced link will + be used to generate a :class:`~scrapy.http.Request` object, which will contain the + link's text in its ``meta`` attribute. + The link text can be accessed from the callback method though ``response.meta['link_text']`` ``callback`` is a callable or a string (in which case a method from the spider object with that name will be used) to be called for each link extracted with @@ -438,6 +441,7 @@ Let's now take a look at an example CrawlSpider with rules:: item['id'] = response.xpath('//td[@id="item_id"]/text()').re(r'ID: (\d+)') item['name'] = response.xpath('//td[@id="item_name"]/text()').get() item['description'] = response.xpath('//td[@id="item_description"]/text()').get() + item['link_text'] = response.meta['link_text'] return item From 8ebbc731b2bdf8e2a2b5a2f0673da838369f31b5 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Mon, 1 Apr 2019 16:15:03 -0300 Subject: [PATCH 163/503] [Docs] Rephrase Rule docs --- docs/topics/spiders.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 5417ef129..7290bb844 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -380,13 +380,13 @@ Crawling rules defines how links will be extracted from each crawled page. Each produced link will be used to generate a :class:`~scrapy.http.Request` object, which will contain the link's text in its ``meta`` attribute. - The link text can be accessed from the callback method though ``response.meta['link_text']`` ``callback`` is a callable or a string (in which case a method from the spider object with that name will be used) to be called for each link extracted with - the specified link_extractor. This callback receives a response as its first - argument and must return a list containing :class:`~scrapy.item.Item` and/or - :class:`~scrapy.http.Request` objects (or any subclass of them). + the specified link extractor. This callback receives a :class:`~scrapy.http.Response` + as its first argument and must return either a single instance or an iterable of + :class:`~scrapy.item.Item`, ``dict`` and/or :class:`~scrapy.http.Request` objects + (or any subclass of them). .. warning:: When writing crawl spider rules, avoid using ``parse`` as callback, since the :class:`CrawlSpider` uses the ``parse`` method From 7a38623cecc6c60d7ffe14c75d1fe679bb04b774 Mon Sep 17 00:00:00 2001 From: Eugenio Lacuesta Date: Mon, 1 Apr 2019 17:09:49 -0300 Subject: [PATCH 164/503] [Docs] Clarify comment about meta dictionary --- docs/topics/spiders.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 7290bb844..3cd051cdf 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -379,7 +379,7 @@ Crawling rules ``link_extractor`` is a :ref:`Link Extractor ` object which defines how links will be extracted from each crawled page. Each produced link will be used to generate a :class:`~scrapy.http.Request` object, which will contain the - link's text in its ``meta`` attribute. + link's text in its ``meta`` dictionary (under the ``link_text`` key). ``callback`` is a callable or a string (in which case a method from the spider object with that name will be used) to be called for each link extracted with From 7acf4eec792f155a8b0e92c3bb1efa1ff5882ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 2 Apr 2019 18:36:03 +0200 Subject: [PATCH 165/503] Deprecate the scrapy.utils.gz.is_gzipped function --- scrapy/utils/gz.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scrapy/utils/gz.py b/scrapy/utils/gz.py index ec3949651..b3fb16b1e 100644 --- a/scrapy/utils/gz.py +++ b/scrapy/utils/gz.py @@ -9,6 +9,9 @@ from gzip import GzipFile import six import re +from scrapy.utils.decorators import deprecated + + # - Python>=3.5 GzipFile's read() has issues returning leftover # uncompressed data when input is corrupted # (regression or bug-fix compared to Python 3.4) @@ -53,6 +56,7 @@ def gunzip(data): _is_gzipped = re.compile(br'^application/(x-)?gzip\b', re.I).search _is_octetstream = re.compile(br'^(application|binary)/octet-stream\b', re.I).search +@deprecated def is_gzipped(response): """Return True if the response is gzipped, or False otherwise""" ctype = response.headers.get('Content-Type', b'') From 6336e1d1f31da8611a0b63dcb536529e9027d51b Mon Sep 17 00:00:00 2001 From: float13 <43447704+float13@users.noreply.github.com> Date: Fri, 5 Apr 2019 00:54:46 -0400 Subject: [PATCH 166/503] grammar fix - delete unneeded apostrophe in "lets" --- docs/intro/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index 41e61542a..b2f952fe2 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -511,7 +511,7 @@ We can try extracting it in the shell:: 'Next ' This gets the anchor element, but we want the attribute ``href``. For that, -Scrapy supports a CSS extension that let's you select the attribute contents, +Scrapy supports a CSS extension that lets you select the attribute contents, like this:: >>> response.css('li.next a::attr(href)').get() From d711ecfc18a01084f74bb2b9dc01c8bcb4772580 Mon Sep 17 00:00:00 2001 From: float13 <43447704+float13@users.noreply.github.com> Date: Fri, 5 Apr 2019 00:56:51 -0400 Subject: [PATCH 167/503] grammar fix - delete extra word "shell" --- docs/intro/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index b2f952fe2..fc10adbe1 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -205,7 +205,7 @@ Extracting data --------------- The best way to learn how to extract data with Scrapy is trying selectors -using the shell :ref:`Scrapy shell `. Run:: +using the :ref:`Scrapy shell `. Run:: scrapy shell 'http://quotes.toscrape.com/page/1/' From 77e3695686d9a46841778248f4c1a2da336b54f7 Mon Sep 17 00:00:00 2001 From: float13 <43447704+float13@users.noreply.github.com> Date: Fri, 5 Apr 2019 01:04:59 -0400 Subject: [PATCH 168/503] grammar fix - add apostrophe-s to browser --- docs/intro/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index fc10adbe1..a97f96801 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -296,7 +296,7 @@ expressions`_:: In order to find the proper CSS selectors to use, you might find useful opening the response page from the shell in your web browser using ``view(response)``. -You can use your browser developer tools to inspect the HTML and come up +You can use your browser's developer tools to inspect the HTML and come up with a selector (see section about :ref:`topics-developer-tools`). `Selector Gadget`_ is also a nice tool to quickly find CSS selector for From a101d5fe5c215bfbb09732fb506d4cd016624d80 Mon Sep 17 00:00:00 2001 From: float13 <43447704+float13@users.noreply.github.com> Date: Fri, 5 Apr 2019 01:12:20 -0400 Subject: [PATCH 169/503] text edit - delete 2 extra words --- docs/intro/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index a97f96801..8bd2d27dd 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -297,7 +297,7 @@ expressions`_:: In order to find the proper CSS selectors to use, you might find useful opening the response page from the shell in your web browser using ``view(response)``. You can use your browser's developer tools to inspect the HTML and come up -with a selector (see section about :ref:`topics-developer-tools`). +with a selector (see :ref:`topics-developer-tools`). `Selector Gadget`_ is also a nice tool to quickly find CSS selector for visually selected elements, which works in many browsers. From 3a493b60661760b26ebc9dd2f4c5c7e4b8df93c9 Mon Sep 17 00:00:00 2001 From: Kostis Anagnostopoulos Date: Fri, 5 Apr 2019 11:52:00 +0200 Subject: [PATCH 170/503] fix: do not catch system exceptions like KeyboardInterrupt --- scrapy/contracts/__init__.py | 2 +- scrapy/core/spidermw.py | 2 +- scrapy/utils/defer.py | 4 ++-- scrapy/utils/misc.py | 2 +- tests/mockserver.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index 259220a72..536bbdafb 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -94,7 +94,7 @@ class ContractsManager(object): try: output = cb(response) output = list(iterate_spider_output(output)) - except: + except Exception: case = _create_testcase(method, 'callback') results.addError(case, sys.exc_info()) diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index e07f76bdf..b5f9837ff 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -49,7 +49,7 @@ class SpiderMiddlewareManager(MiddlewareManager): .format(fname(method), type(result))) except _InvalidOutput: raise - except: + except Exception: return scrape_func(Failure(), request, spider) return scrape_func(response, request, spider) diff --git a/scrapy/utils/defer.py b/scrapy/utils/defer.py index bcf209511..69d621830 100644 --- a/scrapy/utils/defer.py +++ b/scrapy/utils/defer.py @@ -48,7 +48,7 @@ def mustbe_deferred(f, *args, **kw): # exception in Scrapy - see #125 except IgnoreRequest as e: return defer_fail(failure.Failure(e)) - except: + except Exception: return defer_fail(failure.Failure()) else: return defer_result(result) @@ -102,5 +102,5 @@ def iter_errback(iterable, errback, *a, **kw): yield next(it) except StopIteration: break - except: + except Exception: errback(failure.Failure(), *a, **kw) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 6de36d45c..ddaa7f7bf 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -86,7 +86,7 @@ def extract_regex(regex, text, encoding='utf-8'): try: strings = [regex.search(text).group('extract')] # named group - except: + except Exception: strings = regex.findall(text) # full regex or numbered groups strings = flatten(strings) diff --git a/tests/mockserver.py b/tests/mockserver.py index bf62fe907..3fa4bc0f0 100644 --- a/tests/mockserver.py +++ b/tests/mockserver.py @@ -177,7 +177,7 @@ class Root(Resource): try: from tests import tests_datadir self.putChild(b"files", File(os.path.join(tests_datadir, 'test_site/files/'))) - except: + except Exception: pass self.putChild(b"redirect-to", RedirectTo()) From 35ce92a4199b29a30e58026880b45002a1e4591e Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Fri, 5 Apr 2019 11:43:21 -0300 Subject: [PATCH 171/503] fix typo (Response -> Request) check docs for more information https://github.com/scrapy/scrapy/blob/b5c552d17ff9e9629434712c3d0595c02853bcfc/docs/topics/spider-middleware.rst --- scrapy/templates/project/module/middlewares.py.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/templates/project/module/middlewares.py.tmpl b/scrapy/templates/project/module/middlewares.py.tmpl index 5debe1cd2..97b5db2e1 100644 --- a/scrapy/templates/project/module/middlewares.py.tmpl +++ b/scrapy/templates/project/module/middlewares.py.tmpl @@ -39,7 +39,7 @@ class ${ProjectName}SpiderMiddleware(object): # Called when a spider or process_spider_input() method # (from other spider middleware) raises an exception. - # Should return either None or an iterable of Response, dict + # Should return either None or an iterable of Request, dict # or Item objects. pass From a8f83ab9675ec4f0bcb90b2eb3f06c593f32732f Mon Sep 17 00:00:00 2001 From: Kostis Anagnostopoulos Date: Sat, 6 Apr 2019 14:58:32 +0200 Subject: [PATCH 172/503] doc: document LOGSTATS_INTERVAL setting --- docs/topics/settings.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 062c4b2ca..145dcc136 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -897,6 +897,16 @@ Default: ``False`` If ``True``, the logs will just contain the root path. If it is set to ``False`` then it displays the component responsible for the log output +.. setting:: LOGSTATS_INTERVAL + +LOGSTATS_INTERVAL +----------------- + +Default: ``60.0`` + +The interval (in seconds) between each logging printout of the stats +by :class:`~extensions.logstats.LogStats`. + .. setting:: MEMDEBUG_ENABLED MEMDEBUG_ENABLED From aa46e1995cd5cb1099aba17535372b538bd656b3 Mon Sep 17 00:00:00 2001 From: Maram Sumanth Date: Sun, 7 Apr 2019 00:33:40 +0530 Subject: [PATCH 173/503] [MRG+1] Show elapsed time in statscollector (#3638) * Update corestats.py * Update corestats.py * corrected tests * Update corestats.py * Update scrapy/extensions/corestats.py --- scrapy/extensions/corestats.py | 6 +++++- tests/test_closespider.py | 6 +----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scrapy/extensions/corestats.py b/scrapy/extensions/corestats.py index 3d9a307b7..8cc5e18ac 100644 --- a/scrapy/extensions/corestats.py +++ b/scrapy/extensions/corestats.py @@ -24,7 +24,11 @@ class CoreStats(object): self.stats.set_value('start_time', datetime.datetime.utcnow(), spider=spider) def spider_closed(self, spider, reason): - self.stats.set_value('finish_time', datetime.datetime.utcnow(), spider=spider) + finish_time = datetime.datetime.utcnow() + elapsed_time = finish_time - self.stats.get_value('start_time') + elapsed_time_seconds = elapsed_time.total_seconds() + self.stats.set_value('elapsed_time_seconds', elapsed_time_seconds, spider=spider) + self.stats.set_value('finish_time', finish_time, spider=spider) self.stats.set_value('finish_reason', reason, spider=spider) def item_scraped(self, item, spider): diff --git a/tests/test_closespider.py b/tests/test_closespider.py index 0eb1b7944..4a56425b7 100644 --- a/tests/test_closespider.py +++ b/tests/test_closespider.py @@ -53,9 +53,5 @@ class TestCloseSpider(TestCase): yield crawler.crawl(total=1000000, mockserver=self.mockserver) reason = crawler.spider.meta['close_reason'] self.assertEqual(reason, 'closespider_timeout') - stats = crawler.stats - start = stats.get_value('start_time') - stop = stats.get_value('finish_time') - diff = stop - start - total_seconds = diff.seconds + diff.microseconds + total_seconds = crawler.stats.get_value('elapsed_time_seconds') self.assertTrue(total_seconds >= close_on) From e6048d55f905ca0be2b32f9b566d257f34752c71 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Tue, 9 Apr 2019 17:34:20 +0530 Subject: [PATCH 174/503] changes parameter name --- scrapy/extensions/httpcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py index 1b5e05b1b..35c77add8 100644 --- a/scrapy/extensions/httpcache.py +++ b/scrapy/extensions/httpcache.py @@ -31,7 +31,7 @@ class DummyPolicy(object): def should_cache_response(self, response, request): return response.status not in self.ignore_http_codes - def is_cached_response_fresh(self, response, request): + def is_cached_response_fresh(self, cachedresponse, request): return True def is_cached_response_valid(self, cachedresponse, response, request): From 4cfdc14974313f23d7bb8be9311195f9cfa74968 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Tue, 9 Apr 2019 17:52:02 +0530 Subject: [PATCH 175/503] fixes a link in comment --- scrapy/extensions/httpcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py index 1b5e05b1b..03bba1530 100644 --- a/scrapy/extensions/httpcache.py +++ b/scrapy/extensions/httpcache.py @@ -70,7 +70,7 @@ class RFC2616Policy(object): return True def should_cache_response(self, response, request): - # What is cacheable - https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec14.9.1 + # What is cacheable - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 # Response cacheability - https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4 # Status code 206 is not included because cache can not deal with partial contents cc = self._parse_cachecontrol(response) From d27c2c68ba4f201c18255a6ec8a735f140b13773 Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Wed, 10 Apr 2019 12:56:50 +0200 Subject: [PATCH 176/503] Wrap scrapy check in environment --- scrapy/commands/check.py | 21 +++++++++++---------- scrapy/utils/misc.py | 27 +++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index b8a9ef989..b29587fa7 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -6,7 +6,7 @@ from unittest import TextTestRunner, TextTestResult as _TextTestResult from scrapy.commands import ScrapyCommand from scrapy.contracts import ContractsManager -from scrapy.utils.misc import load_object +from scrapy.utils.misc import load_object, set_environ from scrapy.utils.conf import build_component_list @@ -68,16 +68,17 @@ class Command(ScrapyCommand): spider_loader = self.crawler_process.spider_loader - for spidername in args or spider_loader.list(): - spidercls = spider_loader.load(spidername) - spidercls.start_requests = lambda s: conman.from_spider(s, result) + with set_environ(SCRAPY_CHECK=True): + for spidername in args or spider_loader.list(): + spidercls = spider_loader.load(spidername) + spidercls.start_requests = lambda s: conman.from_spider(s, result) - tested_methods = conman.tested_methods_from_spidercls(spidercls) - if opts.list: - for method in tested_methods: - contract_reqs[spidercls.name].append(method) - elif tested_methods: - self.crawler_process.crawl(spidercls) + tested_methods = conman.tested_methods_from_spidercls(spidercls) + if opts.list: + for method in tested_methods: + contract_reqs[spidercls.name].append(method) + elif tested_methods: + self.crawler_process.crawl(spidercls) # start checks if opts.list: diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index ddaa7f7bf..7a2cd18ea 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -1,6 +1,8 @@ """Helper functions which don't fit anywhere else""" +import os import re import hashlib +from contextlib import contextmanager from importlib import import_module from pkgutil import iter_modules @@ -10,7 +12,6 @@ from w3lib.html import replace_entities from scrapy.utils.python import flatten, to_unicode from scrapy.item import BaseItem - _ITERABLE_SINGLE_VALUES = dict, BaseItem, six.text_type, bytes @@ -40,7 +41,7 @@ def load_object(path): except ValueError: raise ValueError("Error loading object '%s': not a full path" % path) - module, name = path[:dot], path[dot+1:] + module, name = path[:dot], path[dot + 1:] mod = import_module(module) try: @@ -85,9 +86,9 @@ def extract_regex(regex, text, encoding='utf-8'): regex = re.compile(regex, re.UNICODE) try: - strings = [regex.search(text).group('extract')] # named group + strings = [regex.search(text).group('extract')] # named group except Exception: - strings = regex.findall(text) # full regex or numbered groups + strings = regex.findall(text) # full regex or numbered groups strings = flatten(strings) if isinstance(text, six.text_type): @@ -142,3 +143,21 @@ def create_instance(objcls, settings, crawler, *args, **kwargs): return objcls.from_settings(settings, *args, **kwargs) else: return objcls(*args, **kwargs) + + +@contextmanager +def set_environ(**kwargs): + """Temporarily set environment variables inside the context manager and + fully restore previous environment afterwards + """ + + original_env = {k: os.environ.get(k) for k in kwargs} + os.environ.update(kwargs) + try: + yield + finally: + for k, v in original_env: + if v is None: + del os.environ[k] + else: + os.environ[k] = v From 50730ed2280dec6384986d34999cd277d7568ff9 Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Wed, 10 Apr 2019 13:01:01 +0200 Subject: [PATCH 177/503] Try it with a string --- scrapy/commands/check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index b29587fa7..ab73e85e7 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -68,7 +68,7 @@ class Command(ScrapyCommand): spider_loader = self.crawler_process.spider_loader - with set_environ(SCRAPY_CHECK=True): + with set_environ(SCRAPY_CHECK='true'): for spidername in args or spider_loader.list(): spidercls = spider_loader.load(spidername) spidercls.start_requests = lambda s: conman.from_spider(s, result) From 07adca34e1378b11dff9e3f11d3760c54f5fa1ef Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Wed, 10 Apr 2019 13:01:46 +0200 Subject: [PATCH 178/503] Fix 'Too many values to unpack' --- scrapy/utils/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index 7a2cd18ea..cdd5a11c9 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -156,7 +156,7 @@ def set_environ(**kwargs): try: yield finally: - for k, v in original_env: + for k, v in original_env.items(): if v is None: del os.environ[k] else: From fbb42fe14ed23aaba37b42d12c7adbf513f9089e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 17 Apr 2019 08:25:22 +0200 Subject: [PATCH 179/503] Cover PEP 257 in the documentation policies --- docs/contributing.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index aac0f4496..c31a17609 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -167,8 +167,9 @@ Documentation policies For reference documentation of API members (classes, methods, etc.) use docstrings and make sure that the Sphinx documentation uses the autodoc_ -extension to pull the docstrings. API reference documentation should be -IDE-friendly: short, to the point, and it may provide short examples. +extension to pull the docstrings. API reference documentation should follow +docstring conventions (`PEP 257`_) and be IDE-friendly: short, to the point, +and it may provide short examples. Other types of documentation, such as tutorials or topics, should be covered in files within the ``docs/`` directory. This includes documentation that is @@ -237,5 +238,6 @@ And their unit-tests are in:: .. _AUTHORS: https://github.com/scrapy/scrapy/blob/master/AUTHORS .. _tests/: https://github.com/scrapy/scrapy/tree/master/tests .. _open issues: https://github.com/scrapy/scrapy/issues +.. _PEP 257: https://www.python.org/dev/peps/pep-0257/ .. _pull request: https://help.github.com/send-pull-requests/ .. _tox: https://pypi.python.org/pypi/tox From 5a6fb3daa6e6a15effe9377dbcc85e67bec9aec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 29 Mar 2019 17:10:16 +0100 Subject: [PATCH 180/503] Use pytest-xdist --- docs/contributing.rst | 26 ++++++++++++++++++++++++++ tests/requirements-py2.txt | 5 +++-- tests/requirements-py3.txt | 5 +++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index aac0f4496..b462ae331 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -205,6 +205,29 @@ To run a specific test (say ``tests/test_loader.py``) use: ``tox -- tests/test_loader.py`` +To run the tests on a specific tox_ environment, use ``-e `` with an +environment name from ``tox.ini``. For example, to run the tests with Python +3.6 use:: + + tox -e py36 + +You can also specify a comma-separated list of environmets, and use `tox’s +parallel mode`_ to run the tests on multiple environments in parallel:: + + tox -e py27,py36 -p auto + +To pass command-line options to pytest_, add them after ``--`` in your call to +tox_. Using ``--`` overrides the default positional arguments defined in +``tox.ini``, so you must include those default positional arguments +(``scrapy tests``) after ``--`` as well:: + + tox -- scrapy tests -x # stop after first failure + +You can also use the `pytest-xdist`_ plugin. For example, to run all tests on +the Python 3.6 tox_ environment using all your CPU cores:: + + tox -e py36 -- scrapy tests -n auto + To see coverage report install `coverage`_ (``pip install coverage``) and run: ``coverage report`` @@ -238,4 +261,7 @@ And their unit-tests are in:: .. _tests/: https://github.com/scrapy/scrapy/tree/master/tests .. _open issues: https://github.com/scrapy/scrapy/issues .. _pull request: https://help.github.com/send-pull-requests/ +.. _pytest: https://docs.pytest.org/en/latest/usage.html +.. _pytest-xdist: https://docs.pytest.org/en/3.0.0/xdist.html .. _tox: https://pypi.python.org/pypi/tox +.. _tox’s parallel mode: https://tox.readthedocs.io/en/latest/example/basic.html#parallel-mode diff --git a/tests/requirements-py2.txt b/tests/requirements-py2.txt index 790f29d34..be809b151 100644 --- a/tests/requirements-py2.txt +++ b/tests/requirements-py2.txt @@ -2,9 +2,10 @@ mock mitmproxy==0.10.1 netlib==0.10.1 -pytest==2.9.2 +pytest +pytest-cov pytest-twisted -pytest-cov==2.2.1 +pytest-xdist jmespath brotlipy testfixtures diff --git a/tests/requirements-py3.txt b/tests/requirements-py3.txt index 7c1aacd81..ed7bf0be0 100644 --- a/tests/requirements-py3.txt +++ b/tests/requirements-py3.txt @@ -1,6 +1,7 @@ -pytest==3.6.3 +pytest +pytest-cov pytest-twisted -pytest-cov==2.5.1 +pytest-xdist testfixtures jmespath leveldb; sys_platform != "win32" From 29739989478bb1b10467b036df91326071b1ffbc Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Thu, 18 Apr 2019 14:50:02 +0200 Subject: [PATCH 181/503] Add set_environ test --- tests/test_utils_misc/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py index fcb7772ab..738120a0b 100644 --- a/tests/test_utils_misc/__init__.py +++ b/tests/test_utils_misc/__init__.py @@ -3,7 +3,7 @@ import os import unittest from scrapy.item import Item, Field -from scrapy.utils.misc import arg_to_iter, create_instance, load_object, walk_modules +from scrapy.utils.misc import arg_to_iter, create_instance, load_object, walk_modules, set_environ from tests import mock @@ -130,5 +130,12 @@ class UtilsMiscTestCase(unittest.TestCase): with self.assertRaises(ValueError): create_instance(m, None, None) + def test_set_environ(self): + assert os.environ.get('some_test_environ') is None + with set_environ(some_test_environ='test_value'): + assert os.environ.get('some_test_environ') == 'test_value' + assert os.environ.get('some_test_environ') is None + + if __name__ == "__main__": unittest.main() From 6d52708579be05c29c58d6ccc63486f761466d18 Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Thu, 18 Apr 2019 15:19:23 +0200 Subject: [PATCH 182/503] Add reset case --- tests/test_utils_misc/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py index 738120a0b..eeb995e48 100644 --- a/tests/test_utils_misc/__init__.py +++ b/tests/test_utils_misc/__init__.py @@ -136,6 +136,12 @@ class UtilsMiscTestCase(unittest.TestCase): assert os.environ.get('some_test_environ') == 'test_value' assert os.environ.get('some_test_environ') is None + os.environ['some_test_environ'] = 'test' + assert os.environ.get('some_test_environ') == 'test' + with set_environ(some_test_environ='test_value'): + assert os.environ.get('some_test_environ') == 'test_value' + assert os.environ.get('some_test_environ') == 'test' + if __name__ == "__main__": unittest.main() From 935387aaea2f0bdc28504b40dfc9ccbbb437bec4 Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Thu, 18 Apr 2019 22:10:23 +0200 Subject: [PATCH 183/503] Revert some non-changes --- scrapy/utils/misc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index cdd5a11c9..f51012e3d 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -41,7 +41,7 @@ def load_object(path): except ValueError: raise ValueError("Error loading object '%s': not a full path" % path) - module, name = path[:dot], path[dot + 1:] + module, name = path[:dot], path[dot+1:] mod = import_module(module) try: @@ -86,9 +86,9 @@ def extract_regex(regex, text, encoding='utf-8'): regex = re.compile(regex, re.UNICODE) try: - strings = [regex.search(text).group('extract')] # named group + strings = [regex.search(text).group('extract')] # named group except Exception: - strings = regex.findall(text) # full regex or numbered groups + strings = regex.findall(text) # full regex or numbered groups strings = flatten(strings) if isinstance(text, six.text_type): From 7809c0b14e3ad62aea8e62c7309997ecb64fbbf1 Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Sat, 20 Apr 2019 09:25:01 +0200 Subject: [PATCH 184/503] Revert another non-change comment --- scrapy/utils/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index f51012e3d..b2164d4a8 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -88,7 +88,7 @@ def extract_regex(regex, text, encoding='utf-8'): try: strings = [regex.search(text).group('extract')] # named group except Exception: - strings = regex.findall(text) # full regex or numbered groups + strings = regex.findall(text) # full regex or numbered groups strings = flatten(strings) if isinstance(text, six.text_type): From 122ca6211935039825aff900e98bc1fbdb4dc0d6 Mon Sep 17 00:00:00 2001 From: Vandenn Date: Thu, 2 May 2019 23:59:01 +0800 Subject: [PATCH 185/503] doc: update configure_logging docs to discourage use with CrawlerProcess --- docs/topics/logging.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/topics/logging.rst b/docs/topics/logging.rst index 8e280d929..dea0528db 100644 --- a/docs/topics/logging.rst +++ b/docs/topics/logging.rst @@ -238,9 +238,10 @@ scrapy.utils.log module .. autofunction:: configure_logging - ``configure_logging`` is automatically called when using Scrapy commands, - but needs to be called explicitly when running custom scripts. In that - case, its usage is not required but it's recommended. + ``configure_logging`` is automatically called when using Scrapy commands + or :class:`~scrapy.crawler.CrawlerProcess`, but needs to be called explicitly + when running custom scripts using :class:`~scrapy.crawler.CrawlerRunner`. + In that case, its usage is not required but it's recommended. If you plan on configuring the handlers yourself is still recommended you call this function, passing ``install_root_handler=False``. Bear in mind From 8bd207a2f639216eb51f61ae312dfd22f4b39781 Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Sun, 28 Apr 2019 21:47:47 +0200 Subject: [PATCH 186/503] Add documentation --- docs/topics/contracts.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/topics/contracts.rst b/docs/topics/contracts.rst index 70f20d4ed..3aa32cba8 100644 --- a/docs/topics/contracts.rst +++ b/docs/topics/contracts.rst @@ -120,3 +120,22 @@ get the failures pretty printed:: for header in self.args: if header not in response.headers: raise ContractFail('X-CustomHeader not present') + + +Detecting check run +=================== +It is not encouraged to created different behaviour when running test. +However, sometimes it is useful to know when a spider is started by scrapy check. +It can for example be needed to enforce less settings to be set, or to disable some +uploading of result data. When scrapy check is runned the ``SCRAPY_CHECK`` environment +variable is set. This can be retrieved via ``os.environ``:: + + import os + import scrapy + + class ExampleSpider(scrapy.Spider): + name = 'example' + + def __init__(self): + if os.environ.get('SCRAPY_CHECK'): + # Do some scraper adjustments when check is running \ No newline at end of file From f6485e669772a940c3c319c71dbcca7bd747d57a Mon Sep 17 00:00:00 2001 From: Matthijs Vos Date: Fri, 3 May 2019 13:53:45 +0200 Subject: [PATCH 187/503] Restore alphabetic order and two lines between import and code --- scrapy/utils/misc.py | 1 + tests/test_utils_misc/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py index b2164d4a8..f638adb25 100644 --- a/scrapy/utils/misc.py +++ b/scrapy/utils/misc.py @@ -12,6 +12,7 @@ from w3lib.html import replace_entities from scrapy.utils.python import flatten, to_unicode from scrapy.item import BaseItem + _ITERABLE_SINGLE_VALUES = dict, BaseItem, six.text_type, bytes diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py index eeb995e48..e109d5343 100644 --- a/tests/test_utils_misc/__init__.py +++ b/tests/test_utils_misc/__init__.py @@ -3,12 +3,13 @@ import os import unittest from scrapy.item import Item, Field -from scrapy.utils.misc import arg_to_iter, create_instance, load_object, walk_modules, set_environ +from scrapy.utils.misc import arg_to_iter, create_instance, load_object, set_environ, walk_modules from tests import mock __doctests__ = ['scrapy.utils.misc'] + class UtilsMiscTestCase(unittest.TestCase): def test_load_object(self): From bc1a92921364de8f4616feb0ea7dcb7b6d42d2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 3 May 2019 14:42:12 +0200 Subject: [PATCH 188/503] Improve the documentation about detecting check runs --- docs/topics/contracts.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/topics/contracts.rst b/docs/topics/contracts.rst index 3aa32cba8..9337375bb 100644 --- a/docs/topics/contracts.rst +++ b/docs/topics/contracts.rst @@ -122,13 +122,12 @@ get the failures pretty printed:: raise ContractFail('X-CustomHeader not present') -Detecting check run -=================== -It is not encouraged to created different behaviour when running test. -However, sometimes it is useful to know when a spider is started by scrapy check. -It can for example be needed to enforce less settings to be set, or to disable some -uploading of result data. When scrapy check is runned the ``SCRAPY_CHECK`` environment -variable is set. This can be retrieved via ``os.environ``:: +Detecting check runs +==================== + +When ``scrapy check`` is running, the ``SCRAPY_CHECK`` environment variable is +set to the ``true`` string. You can use `os.environ`_ to perform any change to +your spiders or your settings when ``scrapy check`` is used:: import os import scrapy @@ -138,4 +137,6 @@ variable is set. This can be retrieved via ``os.environ``:: def __init__(self): if os.environ.get('SCRAPY_CHECK'): - # Do some scraper adjustments when check is running \ No newline at end of file + pass # Do some scraper adjustments when a check is running + +.. _os.environ: https://docs.python.org/3/library/os.html#os.environ From 5814344adfc315a63f43237f02ed75db52765b7d Mon Sep 17 00:00:00 2001 From: Jeffallan <23423962+Jeffallan@users.noreply.github.com> Date: Sat, 4 May 2019 14:15:47 -0500 Subject: [PATCH 189/503] Update telnetconsole.rst Change spelling of bellow to below. --- docs/topics/telnetconsole.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index bf2ffa443..1eb705f05 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -45,7 +45,7 @@ the console you need to type:: >>> By default Username is ``scrapy`` and Password is autogenerated. The -autogenerated Password can be seen on scrapy logs like the example bellow:: +autogenerated Password can be seen on scrapy logs like the example below:: 2018-10-16 14:35:21 [scrapy.extensions.telnet] INFO: Telnet Password: 16f92501e8a59326 From 3a7850fa158148e6c6096add09b555e46949bd51 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 5 May 2019 18:45:40 +0530 Subject: [PATCH 190/503] Update contributing.rst --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 2fbe30a00..51b5da59e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -262,7 +262,7 @@ And their unit-tests are in:: .. _tests/: https://github.com/scrapy/scrapy/tree/master/tests .. _open issues: https://github.com/scrapy/scrapy/issues .. _PEP 257: https://www.python.org/dev/peps/pep-0257/ -.. _pull request: https://help.github.com/send-pull-requests/ +.. _pull request: https://help.github.com/en/articles/creating-a-pull-request .. _pytest: https://docs.pytest.org/en/latest/usage.html .. _pytest-xdist: https://docs.pytest.org/en/3.0.0/xdist.html .. _tox: https://pypi.python.org/pypi/tox From 611249bb7f3a7bb5a92a67d90d6c97f17494768f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 8 May 2019 12:52:29 +0200 Subject: [PATCH 191/503] Implement the METAREFRESH_IGNORE_TAGS setting --- docs/topics/downloader-middleware.rst | 10 ++++++++++ scrapy/downloadermiddlewares/redirect.py | 4 +++- scrapy/settings/default_settings.py | 1 + scrapy/utils/response.py | 4 ++-- tests/test_downloadermiddleware_redirect.py | 19 +++++++++++++++++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index f2f3ef466..fa65f66ed 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -805,6 +805,7 @@ The :class:`MetaRefreshMiddleware` can be configured through the following settings (see the settings documentation for more info): * :setting:`METAREFRESH_ENABLED` +* :setting:`METAREFRESH_IGNORE_TAGS` * :setting:`METAREFRESH_MAXDELAY` This middleware obey :setting:`REDIRECT_MAX_TIMES` setting, :reqmeta:`dont_redirect`, @@ -826,6 +827,15 @@ Default: ``True`` Whether the Meta Refresh middleware will be enabled. +.. setting:: METAREFRESH_IGNORE_TAGS + +METAREFRESH_IGNORE_TAGS +^^^^^^^^^^^^^^^^^^^^^^^ + +Default: ``['script', 'noscript']`` + +Meta tags within these tags are ignored. + .. setting:: METAREFRESH_MAXDELAY METAREFRESH_MAXDELAY diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py index cb59d3fd2..49468a2e4 100644 --- a/scrapy/downloadermiddlewares/redirect.py +++ b/scrapy/downloadermiddlewares/redirect.py @@ -88,6 +88,7 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware): def __init__(self, settings): super(MetaRefreshMiddleware, self).__init__(settings) + self._ignore_tags = settings.getlist('METAREFRESH_IGNORE_TAGS') self._maxdelay = settings.getint('REDIRECT_MAX_METAREFRESH_DELAY', settings.getint('METAREFRESH_MAXDELAY')) @@ -96,7 +97,8 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware): not isinstance(response, HtmlResponse): return response - interval, url = get_meta_refresh(response) + interval, url = get_meta_refresh(response, + ignore_tags=self._ignore_tags) if url and interval < self._maxdelay: redirected = self._redirect_request_using_get(request, url) return self._redirect(redirected, request, spider, 'meta refresh') diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 9986827d8..1ce1516e5 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -221,6 +221,7 @@ MEMUSAGE_NOTIFY_MAIL = [] MEMUSAGE_WARNING_MB = 0 METAREFRESH_ENABLED = True +METAREFRESH_IGNORE_TAGS = ['script', 'noscript'] METAREFRESH_MAXDELAY = 100 NEWSPIDER_MODULE = '' diff --git a/scrapy/utils/response.py b/scrapy/utils/response.py index bf276b5ca..122af28b0 100644 --- a/scrapy/utils/response.py +++ b/scrapy/utils/response.py @@ -31,12 +31,12 @@ def get_base_url(response): _metaref_cache = weakref.WeakKeyDictionary() -def get_meta_refresh(response): +def get_meta_refresh(response, ignore_tags=('script', 'noscript')): """Parse the http-equiv refrsh parameter from the given response""" if response not in _metaref_cache: text = response.text[0:4096] _metaref_cache[response] = html.get_meta_refresh(text, response.url, - response.encoding, ignore_tags=('script', 'noscript')) + response.encoding, ignore_tags=ignore_tags) return _metaref_cache[response] diff --git a/tests/test_downloadermiddleware_redirect.py b/tests/test_downloadermiddleware_redirect.py index 6c81c94ca..0e841489d 100644 --- a/tests/test_downloadermiddleware_redirect.py +++ b/tests/test_downloadermiddleware_redirect.py @@ -279,5 +279,24 @@ class MetaRefreshMiddlewareTest(unittest.TestCase): self.assertEqual(req2.meta['redirect_reasons'], ['meta refresh']) self.assertEqual(req3.meta['redirect_reasons'], ['meta refresh', 'meta refresh']) + def test_ignore_tags_default(self): + req = Request(url='http://example.org') + body = ('''''') + rsp = HtmlResponse(req.url, body=body.encode()) + response = self.mw.process_response(req, rsp, self.spider) + assert isinstance(response, Response) + + def test_ignore_tags_empty_list(self): + crawler = get_crawler(Spider, {'METAREFRESH_IGNORE_TAGS': []}) + mw = MetaRefreshMiddleware.from_crawler(crawler) + req = Request(url='http://example.org') + body = ('''''') + rsp = HtmlResponse(req.url, body=body.encode()) + req2 = mw.process_response(req, rsp, self.spider) + assert isinstance(req2, Request) + self.assertEqual(req2.url, 'http://example.org/newpage') + if __name__ == "__main__": unittest.main() From e667ca76820a53ac3abf34604fc284761f936bb9 Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Fri, 24 May 2019 21:45:53 +0900 Subject: [PATCH 192/503] Account for mangling when serializing requests with private callbacks --- scrapy/utils/reqser.py | 6 +++++- tests/test_utils_reqser.py | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index 959dddbd5..8c99763cf 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -75,7 +75,11 @@ def _find_method(obj, func): pass else: if func_self is obj: - return six.get_method_function(func).__name__ + name = six.get_method_function(func).__name__ + if name.startswith('__'): + classname = obj.__class__.__name__.lstrip('_') + name = '_%s%s' % (classname, name) + return name raise ValueError("Function %s is not a method of: %s" % (func, obj)) diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index dcc070b8f..f7191fcef 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -68,6 +68,12 @@ class RequestSerializationTest(unittest.TestCase): errback=self.spider.handle_error) self._assert_serializes_ok(r, spider=self.spider) + def test_private_callback_serialization(self): + r = Request("http://www.example.com", + callback=self.spider._TestSpider__parse_item_private, + errback=self.spider.handle_error) + self._assert_serializes_ok(r, spider=self.spider) + def test_unserializable_callback1(self): r = Request("http://www.example.com", callback=lambda x: x) self.assertRaises(ValueError, request_to_dict, r) @@ -87,6 +93,9 @@ class TestSpider(Spider): def handle_error(self, failure): pass + def __parse_item_private(self, response): + pass + class CustomRequest(Request): pass From 7d36fa7435d2147c7dfd6a87733187823431b61c Mon Sep 17 00:00:00 2001 From: Capi Etheriel Date: Fri, 24 May 2019 10:32:55 -0300 Subject: [PATCH 193/503] Fix documentation for spiderloader --- docs/topics/api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/api.rst b/docs/topics/api.rst index ba832ab5d..e1623287d 100644 --- a/docs/topics/api.rst +++ b/docs/topics/api.rst @@ -154,7 +154,7 @@ Settings API SpiderLoader API ================ -.. module:: scrapy.loader +.. module:: scrapy.spiderloader :synopsis: The spider loader .. class:: SpiderLoader From 0ee2284fcc23ccb2b8a4da8fb561a232cc328fe0 Mon Sep 17 00:00:00 2001 From: Capi Etheriel Date: Fri, 24 May 2019 11:11:15 -0300 Subject: [PATCH 194/503] Add 429 to RETRY_HTTP_CODES --- scrapy/settings/default_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index 9986827d8..2afa7b321 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -238,7 +238,7 @@ REFERRER_POLICY = 'scrapy.spidermiddlewares.referer.DefaultReferrerPolicy' RETRY_ENABLED = True RETRY_TIMES = 2 # initial response + 2 retries = 3 requests -RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408] +RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429] RETRY_PRIORITY_ADJUST = -1 ROBOTSTXT_OBEY = False From 144afcee7973ab97d6c8d89fec007046cc878e3d Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Sat, 25 May 2019 00:52:00 +0900 Subject: [PATCH 195/503] Use regex to check for private methods --- scrapy/utils/reqser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index 8c99763cf..07c51aaff 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -2,12 +2,16 @@ Helper functions for serializing (and deserializing) requests. """ import six +import re from scrapy.http import Request from scrapy.utils.python import to_unicode, to_native_str from scrapy.utils.misc import load_object +private_name_regex = re.compile('^__[^_](.*[^_])?_?$') + + def request_to_dict(request, spider=None): """Convert Request object to a dict. @@ -76,7 +80,7 @@ def _find_method(obj, func): else: if func_self is obj: name = six.get_method_function(func).__name__ - if name.startswith('__'): + if private_name_regex.search(name): classname = obj.__class__.__name__.lstrip('_') name = '_%s%s' % (classname, name) return name From 461682fc3dca72d9a34ddc22ad1896787c9dc518 Mon Sep 17 00:00:00 2001 From: Claudio Salazar Date: Sat, 25 May 2019 11:01:19 +0200 Subject: [PATCH 196/503] Whitelist form methods in FormRequest.from_response method --- scrapy/http/request/form.py | 7 ++++++- tests/test_http_request.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index c2413b431..2182b9b53 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -18,6 +18,7 @@ from scrapy.utils.response import get_base_url class FormRequest(Request): + valid_form_methods = ['GET', 'POST', 'DIALOG'] def __init__(self, *args, **kwargs): formdata = kwargs.pop('formdata', None) @@ -48,7 +49,11 @@ class FormRequest(Request): form = _get_form(response, formname, formid, formnumber, formxpath) formdata = _get_inputs(form, formdata, dont_click, clickdata, response) url = _get_form_url(form, kwargs.pop('url', None)) - method = kwargs.pop('method', form.method) + + method = kwargs.pop('method', form.method).upper() + if method not in cls.valid_form_methods: + raise ValueError('Invalid form method in chosen form') + return cls(url=url, method=method, formdata=formdata, **kwargs) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 49f148016..8fdafb286 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1100,6 +1100,19 @@ class FormRequestTest(RequestTest): self.assertRaises(ValueError, self.request_class.from_response, response, formcss="input[name='abc']") + def test_from_response_valid_form_methods(self): + body = """
+ +
""" + + for method in self.request_class.valid_form_methods: + response = _buildresponse(body % method) + r1 = self.request_class.from_response(response) + self.assertEqual(r1.method, method) + + response = _buildresponse(body % 'UNKNOWN') + self.assertRaises(ValueError, self.request_class.from_response, response) + def _buildresponse(body, **kwargs): kwargs.setdefault('body', body) From da82ede8a0751cf3e8496f35252eb0dcef4f197e Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Sat, 25 May 2019 17:19:10 +0530 Subject: [PATCH 197/503] describe method as a command --- docs/topics/downloader-middleware.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index dfbcdb8fa..236150059 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -526,7 +526,7 @@ defines the methods described below. .. method:: retrieve_response(spider, request) - Returns response if present in cache, or ``None`` otherwise. + Return response if present in cache, or ``None`` otherwise. :param spider: the spider which generated the request :type spider: :class:`~scrapy.spiders.Spider` object @@ -536,7 +536,7 @@ defines the methods described below. .. method:: store_response(spider, request, response) - Stores the given response in the cache. + Store the given response in the cache. :param spider: the spider for which the response is intended :type spider: :class:`~scrapy.spiders.Spider` object From 18f01ea6ecf1dba77b25d8d1f62c80ed0f9a13f5 Mon Sep 17 00:00:00 2001 From: mar-heaven <775650117@qq.com> Date: Mon, 27 May 2019 17:15:30 +0800 Subject: [PATCH 198/503] remove a "is" When I translated in Chinese, I found a needless "is" --- docs/topics/spiders.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index 8c4049f85..79eecfc3e 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -657,7 +657,7 @@ SitemapSpider .. attribute:: sitemap_follow - A list of regexes of sitemap that should be followed. This is is only + A list of regexes of sitemap that should be followed. This is only for sites that use `Sitemap index files`_ that point to other sitemap files. From 72b7d3e90ac2d21ffdd0c44878ec1a5a5d0fa5ce Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Mon, 27 May 2019 23:30:23 +0900 Subject: [PATCH 199/503] Make the regex align to the spec better; add unit tests for name variations --- scrapy/utils/reqser.py | 2 +- tests/test_utils_reqser.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index 07c51aaff..04665a2d4 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -9,7 +9,7 @@ from scrapy.utils.python import to_unicode, to_native_str from scrapy.utils.misc import load_object -private_name_regex = re.compile('^__[^_](.*[^_])?_?$') +private_name_regex = re.compile('^__.*[^_]_?$') def request_to_dict(request, spider=None): diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index f7191fcef..b49450ac5 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -3,7 +3,7 @@ import unittest from scrapy.http import Request, FormRequest from scrapy.spiders import Spider -from scrapy.utils.reqser import request_to_dict, request_from_dict +from scrapy.utils.reqser import request_to_dict, request_from_dict, private_name_regex class RequestSerializationTest(unittest.TestCase): @@ -74,6 +74,28 @@ class RequestSerializationTest(unittest.TestCase): errback=self.spider.handle_error) self._assert_serializes_ok(r, spider=self.spider) + def test_private_callback_name_matching(self): + self.assertTrue(private_name_regex.search('__a')) + self.assertTrue(private_name_regex.search('__a_')) + self.assertTrue(private_name_regex.search('__a_a')) + self.assertTrue(private_name_regex.search('__a_a_')) + self.assertTrue(private_name_regex.search('__a__a')) + self.assertTrue(private_name_regex.search('__a__a_')) + self.assertTrue(private_name_regex.search('__a___a')) + self.assertTrue(private_name_regex.search('__a___a_')) + self.assertTrue(private_name_regex.search('___a')) + self.assertTrue(private_name_regex.search('___a_')) + self.assertTrue(private_name_regex.search('___a_a')) + self.assertTrue(private_name_regex.search('___a_a_')) + self.assertTrue(private_name_regex.search('____a_a_')) + + self.assertFalse(private_name_regex.search('_a')) + self.assertFalse(private_name_regex.search('_a_')) + self.assertFalse(private_name_regex.search('__a__')) + self.assertFalse(private_name_regex.search('__')) + self.assertFalse(private_name_regex.search('___')) + self.assertFalse(private_name_regex.search('____')) + def test_unserializable_callback1(self): r = Request("http://www.example.com", callback=lambda x: x) self.assertRaises(ValueError, request_to_dict, r) From 9af91a26b035a10e9303227ad9ddd5e043725514 Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Tue, 28 May 2019 01:40:26 +0900 Subject: [PATCH 200/503] Replace regex usage --- scrapy/utils/reqser.py | 10 +++++----- tests/test_utils_reqser.py | 40 +++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index 04665a2d4..40223661f 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -2,16 +2,12 @@ Helper functions for serializing (and deserializing) requests. """ import six -import re from scrapy.http import Request from scrapy.utils.python import to_unicode, to_native_str from scrapy.utils.misc import load_object -private_name_regex = re.compile('^__.*[^_]_?$') - - def request_to_dict(request, spider=None): """Convert Request object to a dict. @@ -71,6 +67,10 @@ def request_from_dict(d, spider=None): flags=d.get('flags')) +def _is_private_method(name): + return name.startswith('__') and not name.endswith('__') + + def _find_method(obj, func): if obj: try: @@ -80,7 +80,7 @@ def _find_method(obj, func): else: if func_self is obj: name = six.get_method_function(func).__name__ - if private_name_regex.search(name): + if _is_private_method(name): classname = obj.__class__.__name__.lstrip('_') name = '_%s%s' % (classname, name) return name diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index b49450ac5..fad5b6003 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -3,7 +3,7 @@ import unittest from scrapy.http import Request, FormRequest from scrapy.spiders import Spider -from scrapy.utils.reqser import request_to_dict, request_from_dict, private_name_regex +from scrapy.utils.reqser import request_to_dict, request_from_dict, _is_private_method class RequestSerializationTest(unittest.TestCase): @@ -75,26 +75,26 @@ class RequestSerializationTest(unittest.TestCase): self._assert_serializes_ok(r, spider=self.spider) def test_private_callback_name_matching(self): - self.assertTrue(private_name_regex.search('__a')) - self.assertTrue(private_name_regex.search('__a_')) - self.assertTrue(private_name_regex.search('__a_a')) - self.assertTrue(private_name_regex.search('__a_a_')) - self.assertTrue(private_name_regex.search('__a__a')) - self.assertTrue(private_name_regex.search('__a__a_')) - self.assertTrue(private_name_regex.search('__a___a')) - self.assertTrue(private_name_regex.search('__a___a_')) - self.assertTrue(private_name_regex.search('___a')) - self.assertTrue(private_name_regex.search('___a_')) - self.assertTrue(private_name_regex.search('___a_a')) - self.assertTrue(private_name_regex.search('___a_a_')) - self.assertTrue(private_name_regex.search('____a_a_')) + self.assertTrue(_is_private_method('__a')) + self.assertTrue(_is_private_method('__a_')) + self.assertTrue(_is_private_method('__a_a')) + self.assertTrue(_is_private_method('__a_a_')) + self.assertTrue(_is_private_method('__a__a')) + self.assertTrue(_is_private_method('__a__a_')) + self.assertTrue(_is_private_method('__a___a')) + self.assertTrue(_is_private_method('__a___a_')) + self.assertTrue(_is_private_method('___a')) + self.assertTrue(_is_private_method('___a_')) + self.assertTrue(_is_private_method('___a_a')) + self.assertTrue(_is_private_method('___a_a_')) + self.assertTrue(_is_private_method('____a_a_')) - self.assertFalse(private_name_regex.search('_a')) - self.assertFalse(private_name_regex.search('_a_')) - self.assertFalse(private_name_regex.search('__a__')) - self.assertFalse(private_name_regex.search('__')) - self.assertFalse(private_name_regex.search('___')) - self.assertFalse(private_name_regex.search('____')) + self.assertFalse(_is_private_method('_a')) + self.assertFalse(_is_private_method('_a_')) + self.assertFalse(_is_private_method('__a__')) + self.assertFalse(_is_private_method('__')) + self.assertFalse(_is_private_method('___')) + self.assertFalse(_is_private_method('____')) def test_unserializable_callback1(self): r = Request("http://www.example.com", callback=lambda x: x) From bcad8947e8192448ab3bd59489444efb567f8793 Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Mon, 3 Jun 2019 20:41:02 +0900 Subject: [PATCH 201/503] Support inherited private method names --- scrapy/utils/reqser.py | 9 +++++++-- tests/test_utils_reqser.py | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index 40223661f..d1f472e6e 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -81,8 +81,13 @@ def _find_method(obj, func): if func_self is obj: name = six.get_method_function(func).__name__ if _is_private_method(name): - classname = obj.__class__.__name__.lstrip('_') - name = '_%s%s' % (classname, name) + qualname = getattr(func, '__qualname__', None) + if qualname is None: + classname = obj.__class__.__name__.lstrip('_') + name = '_%s%s' % (classname, name) + else: + splits = qualname.split('.') + name = '_%s%s' % (splits[-2], splits[-1]) return name raise ValueError("Function %s is not a method of: %s" % (func, obj)) diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index fad5b6003..31577bc8c 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import unittest +import sys from scrapy.http import Request, FormRequest from scrapy.spiders import Spider @@ -74,6 +75,14 @@ class RequestSerializationTest(unittest.TestCase): errback=self.spider.handle_error) self._assert_serializes_ok(r, spider=self.spider) + def test_mixin_private_callback_serialization(self): + if sys.version_info[0] < 3: + return + r = Request("http://www.example.com", + callback=self.spider._TestSpiderMixin__mixin_callback, + errback=self.spider.handle_error) + self._assert_serializes_ok(r, spider=self.spider) + def test_private_callback_name_matching(self): self.assertTrue(_is_private_method('__a')) self.assertTrue(_is_private_method('__a_')) @@ -106,7 +115,12 @@ class RequestSerializationTest(unittest.TestCase): self.assertRaises(ValueError, request_to_dict, r) -class TestSpider(Spider): +class TestSpiderMixin(object): + def __mixin_callback(self, response): + pass + + +class TestSpider(Spider, TestSpiderMixin): name = 'test' def parse_item(self, response): From ea209a0ea7815f68a04a5ccab79a2b4f4a146647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Mon, 3 Jun 2019 19:21:40 +0200 Subject: [PATCH 202/503] Fix module double indexing issues in the documentation --- docs/topics/stats.rst | 3 +-- docs/topics/telnetconsole.rst | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/topics/stats.rst b/docs/topics/stats.rst index dd0c6216b..38648ec55 100644 --- a/docs/topics/stats.rst +++ b/docs/topics/stats.rst @@ -75,8 +75,7 @@ available in Scrapy which extend the basic Stats Collector. You can select which Stats Collector to use through the :setting:`STATS_CLASS` setting. The default Stats Collector used is the :class:`MemoryStatsCollector`. -.. module:: scrapy.statscollectors - :synopsis: Stats Collectors +.. currentmodule:: scrapy.statscollectors MemoryStatsCollector -------------------- diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index 1eb705f05..7db7e4f6b 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -1,12 +1,11 @@ +.. currentmodule:: scrapy.extensions.telnet + .. _topics-telnetconsole: ============== Telnet Console ============== -.. module:: scrapy.extensions.telnet - :synopsis: The Telnet Console - Scrapy comes with a built-in telnet console for inspecting and controlling a Scrapy running process. The telnet console is just a regular python shell running inside the Scrapy process, so you can do literally anything from it. From c7b5ad0e20dc736a8a08b62134c9418439fb7077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Fri, 4 Jan 2019 18:17:35 +0100 Subject: [PATCH 203/503] Add a Sphinx extension to generate documentation coverage information --- docs/Makefile | 3 +++ docs/conf.py | 9 ++++++++- docs/contributing.rst | 9 +++++++++ docs/requirements.txt | 2 +- tox.ini | 6 ++++++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 187f03c4c..ff68bf1ae 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -82,6 +82,9 @@ pydoc-topics: build @echo "Building finished; now copy build/pydoc-topics/pydoc_topics.py " \ "into the Lib/ directory" +coverage: BUILDER = coverage +coverage: build + htmlview: html $(PYTHON) -c "import webbrowser, os; webbrowser.open('file://' + \ os.path.realpath('build/html/index.html'))" diff --git a/docs/conf.py b/docs/conf.py index a54a6bbe9..832626f6b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,8 @@ sys.path.insert(0, path.dirname(path.dirname(__file__))) # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'scrapydocs', - 'sphinx.ext.autodoc' + 'sphinx.ext.autodoc', + 'sphinx.ext.coverage', ] # Add any paths that contain templates here, relative to this directory. @@ -218,3 +219,9 @@ linkcheck_ignore = [ 'http://localhost:\d+', 'http://hg.scrapy.org', 'http://directory.google.com/' ] + + +# Options for the Coverage extension +# ---------------------------------- +coverage_ignore_pyobjects = [ +] diff --git a/docs/contributing.rst b/docs/contributing.rst index 51b5da59e..b4f91ea8d 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -99,6 +99,15 @@ Well-written patches should: the documentation changes in the same patch. See `Documentation policies`_ below. +* if you're adding a private API, please add a regular expression to the + ``coverage_ignore_pyobjects`` variable of ``docs/conf.py`` to exclude the new + private API from documentation coverage checks. + + To see if your private API is skipped properly, generate a documentation + coverage report as follows:: + + tox -e docs-coverage + .. _submitting-patches: Submitting patches diff --git a/docs/requirements.txt b/docs/requirements.txt index 8e7611d21..379da9994 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -Sphinx>=1.6 +Sphinx>=2.1 sphinx_rtd_theme \ No newline at end of file diff --git a/tox.ini b/tox.ini index 0c0f8f7b7..157a8b3ed 100644 --- a/tox.ini +++ b/tox.ini @@ -105,6 +105,12 @@ deps = {[docs]deps} commands = sphinx-build -W -b html . {envtmpdir}/html +[testenv:docs-coverage] +changedir = {[docs]changedir} +deps = {[docs]deps} +commands = + sphinx-build -b coverage . {envtmpdir}/coverage + [testenv:docs-links] changedir = {[docs]changedir} deps = {[docs]deps} From c81e15ed6ede552c499ae3ac4e03af27b1f9ed89 Mon Sep 17 00:00:00 2001 From: Artem Kuchumov Date: Wed, 5 Jun 2019 13:15:23 +0500 Subject: [PATCH 204/503] Tutorial: scrapy shell example should say "text" not "title" (#3807) Tutorial: scrapy shell example should say "text" not "title" --- docs/intro/tutorial.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/intro/tutorial.rst b/docs/intro/tutorial.rst index 8bd2d27dd..a190ce407 100644 --- a/docs/intro/tutorial.rst +++ b/docs/intro/tutorial.rst @@ -379,11 +379,11 @@ variable, so that we can run our CSS selectors directly on a particular quote:: >>> quote = response.css("div.quote")[0] -Now, let's extract ``title``, ``author`` and the ``tags`` from that quote +Now, let's extract ``text``, ``author`` and the ``tags`` from that quote using the ``quote`` object we just created:: - >>> title = quote.css("span.text::text").get() - >>> title + >>> text = quote.css("span.text::text").get() + >>> text '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”' >>> author = quote.css("small.author::text").get() >>> author From 9c81721c407ff41ef9dce2c33e26ac477355cf1f Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Wed, 5 Jun 2019 23:43:56 +0900 Subject: [PATCH 205/503] Add tests for private method name mangling --- scrapy/utils/reqser.py | 18 +++++++++++------- tests/test_utils_reqser.py | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/scrapy/utils/reqser.py b/scrapy/utils/reqser.py index d1f472e6e..3c463cfed 100644 --- a/scrapy/utils/reqser.py +++ b/scrapy/utils/reqser.py @@ -71,6 +71,16 @@ def _is_private_method(name): return name.startswith('__') and not name.endswith('__') +def _mangle_private_name(obj, func, name): + qualname = getattr(func, '__qualname__', None) + if qualname is None: + classname = obj.__class__.__name__.lstrip('_') + return '_%s%s' % (classname, name) + else: + splits = qualname.split('.') + return '_%s%s' % (splits[-2], splits[-1]) + + def _find_method(obj, func): if obj: try: @@ -81,13 +91,7 @@ def _find_method(obj, func): if func_self is obj: name = six.get_method_function(func).__name__ if _is_private_method(name): - qualname = getattr(func, '__qualname__', None) - if qualname is None: - classname = obj.__class__.__name__.lstrip('_') - name = '_%s%s' % (classname, name) - else: - splits = qualname.split('.') - name = '_%s%s' % (splits[-2], splits[-1]) + return _mangle_private_name(obj, func, name) return name raise ValueError("Function %s is not a method of: %s" % (func, obj)) diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index 31577bc8c..7f9e31daa 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -2,9 +2,11 @@ import unittest import sys +import six + from scrapy.http import Request, FormRequest from scrapy.spiders import Spider -from scrapy.utils.reqser import request_to_dict, request_from_dict, _is_private_method +from scrapy.utils.reqser import request_to_dict, request_from_dict, _is_private_method, _mangle_private_name class RequestSerializationTest(unittest.TestCase): @@ -105,6 +107,18 @@ class RequestSerializationTest(unittest.TestCase): self.assertFalse(_is_private_method('___')) self.assertFalse(_is_private_method('____')) + def _assert_mangles_to(self, obj, name): + self.assertEqual( + _mangle_private_name(obj, getattr(obj, name), name), + name + ) + + def test_private_name_mangling(self): + self._assert_mangles_to( + self.spider, '_TestSpider__parse_item_private') + self._assert_mangles_to( + self.spider, '_TestSpiderMixin__mixin_callback') + def test_unserializable_callback1(self): r = Request("http://www.example.com", callback=lambda x: x) self.assertRaises(ValueError, request_to_dict, r) From 3dd3e8c29863683d60f9c4f74aacac3103703061 Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Wed, 5 Jun 2019 23:49:54 +0900 Subject: [PATCH 206/503] Restrict different class mangling tests to Py 3+ --- tests/test_utils_reqser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index 7f9e31daa..57dc5db53 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -116,8 +116,9 @@ class RequestSerializationTest(unittest.TestCase): def test_private_name_mangling(self): self._assert_mangles_to( self.spider, '_TestSpider__parse_item_private') - self._assert_mangles_to( - self.spider, '_TestSpiderMixin__mixin_callback') + if sys.version_info[0] >= 3: + self._assert_mangles_to( + self.spider, '_TestSpiderMixin__mixin_callback') def test_unserializable_callback1(self): r = Request("http://www.example.com", callback=lambda x: x) From 6af1dc89aa5988ebbfbef90afdafa84736f3993c Mon Sep 17 00:00:00 2001 From: Andrew Baxter Date: Thu, 6 Jun 2019 04:25:19 +0900 Subject: [PATCH 207/503] Fix mangling test --- tests/test_utils_reqser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py index 57dc5db53..e5a09dcf1 100644 --- a/tests/test_utils_reqser.py +++ b/tests/test_utils_reqser.py @@ -108,8 +108,9 @@ class RequestSerializationTest(unittest.TestCase): self.assertFalse(_is_private_method('____')) def _assert_mangles_to(self, obj, name): + func = getattr(obj, name) self.assertEqual( - _mangle_private_name(obj, getattr(obj, name), name), + _mangle_private_name(obj, func, func.__name__), name ) From 0c50879568dee2363df5cbe25e9bdd7adaed5da4 Mon Sep 17 00:00:00 2001 From: Claudio Salazar Date: Thu, 6 Jun 2019 22:10:59 +0200 Subject: [PATCH 208/503] Change behavior to use method GET when there are unknown methods in the form --- scrapy/http/request/form.py | 2 +- tests/test_http_request.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py index 2182b9b53..8b29aae4b 100644 --- a/scrapy/http/request/form.py +++ b/scrapy/http/request/form.py @@ -52,7 +52,7 @@ class FormRequest(Request): method = kwargs.pop('method', form.method).upper() if method not in cls.valid_form_methods: - raise ValueError('Invalid form method in chosen form') + method = 'GET' return cls(url=url, method=method, formdata=formdata, **kwargs) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 8fdafb286..258b48dce 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -1107,11 +1107,12 @@ class FormRequestTest(RequestTest): for method in self.request_class.valid_form_methods: response = _buildresponse(body % method) - r1 = self.request_class.from_response(response) - self.assertEqual(r1.method, method) + r = self.request_class.from_response(response) + self.assertEqual(r.method, method) response = _buildresponse(body % 'UNKNOWN') - self.assertRaises(ValueError, self.request_class.from_response, response) + r = self.request_class.from_response(response) + self.assertEqual(r.method, 'GET') def _buildresponse(body, **kwargs): From bd8a10384b462dd56b33668e8b92e4a148fd6fba Mon Sep 17 00:00:00 2001 From: Sortafreel Date: Fri, 7 Jun 2019 01:50:03 +0300 Subject: [PATCH 209/503] Add values (if there're any) when initiating items from dicts https://github.com/scrapy/scrapy/issues/3804 --- scrapy/loader/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index a7c75a46a..295a8e42d 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -35,6 +35,8 @@ class ItemLoader(object): self.parent = parent self._local_item = context['item'] = item self._local_values = defaultdict(list) + for field_name, value in item.items(): + self.add_value(field_name, value) @property def _values(self): From 754f52b02781097c8ca6835e057815c7653062d4 Mon Sep 17 00:00:00 2001 From: Sortafreel Date: Fri, 7 Jun 2019 03:20:45 +0300 Subject: [PATCH 210/503] Preprocess values if item built from dict. https://github.com/scrapy/scrapy/issues/3804 --- scrapy/loader/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 295a8e42d..7c7f66866 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -35,8 +35,9 @@ class ItemLoader(object): self.parent = parent self._local_item = context['item'] = item self._local_values = defaultdict(list) + # Preprocess values if item built from dict for field_name, value in item.items(): - self.add_value(field_name, value) + self._values[field_name] = self._process_input_value(field_name, value) @property def _values(self): From c7ba72b5dc9da3435eb1ec303b991d05ba40ba1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 4 Jun 2019 17:10:14 +0200 Subject: [PATCH 211/503] Skip scrapy.contracts private APIs in the documentation coverage report --- docs/conf.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 832626f6b..bf222b361 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -224,4 +224,17 @@ linkcheck_ignore = [ # Options for the Coverage extension # ---------------------------------- coverage_ignore_pyobjects = [ + # Contract’s add_pre_hook and add_post_hook are not documented because + # they should be transparent to contract developers, for whom pre_hook and + # post_hook should be the actual concern. + r'\bContract\.add_(pre|post)_hook$', + + # ContractsManager is an internal class, developers are not expected to + # interact with it directly in any way. + r'\bContractsManager\b$', + + # For default contracts we only want to document their general purpose in + # their constructor, the methods they reimplement to achieve that purpose + # should be irrelevant to developers using those contracts. + r'\w+Contract\.(adjust_request_args|(pre|post)_process)$', ] From a1bca6a8e722af53241e51bbf758e7bd67671801 Mon Sep 17 00:00:00 2001 From: sortafreel Date: Tue, 11 Jun 2019 07:36:29 +0300 Subject: [PATCH 212/503] Add tests. --- scrapy/loader/__init__.py | 1 + tests/test_loader.py | 65 ++++++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 7c7f66866..20f0f90c3 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -36,6 +36,7 @@ class ItemLoader(object): self._local_item = context['item'] = item self._local_values = defaultdict(list) # Preprocess values if item built from dict + # Values need to be added to item._values if added them from dict (not with add_values) for field_name, value in item.items(): self._values[field_name] = self._process_input_value(field_name, value) diff --git a/tests/test_loader.py b/tests/test_loader.py index 8b58e4dbd..eb4a01572 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -419,6 +419,29 @@ class BasicItemLoaderTest(unittest.TestCase): self.assertEqual(item['url'], u'rabbit.hole') self.assertEqual(item['summary'], u'rabbithole') + def test_create_item_from_dict(self): + class TestItem(Item): + title = Field() + + class TestItemLoader(ItemLoader): + default_item_class = TestItem + + input_item = {'title': 'Test item title 1'} + il = TestItemLoader(item=input_item) + # Getting output value mustn't remove value from item + self.assertEqual(il.load_item(), { + 'title': 'Test item title 1', + }) + self.assertEqual(il.get_output_value('title'), 'Test item title 1') + self.assertEqual(il.load_item(), { + 'title': 'Test item title 1', + }) + + input_item = {'title': 'Test item title 2'} + il = TestItemLoader(item=input_item) + # Values from dict must be added to item _values + self.assertEqual(il._values.get('title'), 'Test item title 2') + class ProcessorsTest(unittest.TestCase): @@ -709,28 +732,28 @@ class SubselectorLoaderTest(unittest.TestCase): class SelectJmesTestCase(unittest.TestCase): - test_list_equals = { - 'simple': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), - 'invalid': ('foo.bar.baz', {"foo": {"bar": "baz"}}, None), - 'top_level': ('foo', {"foo": {"bar": "baz"}}, {"bar": "baz"}), - 'double_vs_single_quote_string': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), - 'dict': ( - 'foo.bar[*].name', - {"foo": {"bar": [{"name": "one"}, {"name": "two"}]}}, - ['one', 'two'] - ), - 'list': ('[1]', [1, 2], 2) - } + test_list_equals = { + 'simple': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), + 'invalid': ('foo.bar.baz', {"foo": {"bar": "baz"}}, None), + 'top_level': ('foo', {"foo": {"bar": "baz"}}, {"bar": "baz"}), + 'double_vs_single_quote_string': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), + 'dict': ( + 'foo.bar[*].name', + {"foo": {"bar": [{"name": "one"}, {"name": "two"}]}}, + ['one', 'two'] + ), + 'list': ('[1]', [1, 2], 2) + } - def test_output(self): - for l in self.test_list_equals: - expr, test_list, expected = self.test_list_equals[l] - test = SelectJmes(expr)(test_list) - self.assertEqual( - test, - expected, - msg='test "{}" got {} expected {}'.format(l, test, expected) - ) + def test_output(self): + for l in self.test_list_equals: + expr, test_list, expected = self.test_list_equals[l] + test = SelectJmes(expr)(test_list) + self.assertEqual( + test, + expected, + msg='test "{}" got {} expected {}'.format(l, test, expected) + ) if __name__ == "__main__": From 7dad2f7b130c426f2a8aee320ccbc378752a9568 Mon Sep 17 00:00:00 2001 From: sortafreel Date: Tue, 11 Jun 2019 07:43:03 +0300 Subject: [PATCH 213/503] Add more tests. --- tests/test_loader.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_loader.py b/tests/test_loader.py index eb4a01572..241630ab3 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -442,6 +442,20 @@ class BasicItemLoaderTest(unittest.TestCase): # Values from dict must be added to item _values self.assertEqual(il._values.get('title'), 'Test item title 2') + input_item = {'title': [u'Test item title 3', u'Test item 4']} + il = TestItemLoader(item=input_item) + # Same rules must work for lists + self.assertEqual(il._values.get('title'), + [u'Test item title 3', u'Test item 4']) + self.assertEqual(il.load_item(), { + 'title': [u'Test item title 3', u'Test item 4'], + }) + self.assertEqual(il.get_output_value('title'), + [u'Test item title 3', u'Test item 4']) + self.assertEqual(il.load_item(), { + 'title': [u'Test item title 3', u'Test item 4'], + }) + class ProcessorsTest(unittest.TestCase): From 0da972339bb174156b08a3ae34ece7fddea1e48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 11 Jun 2019 14:11:38 +0200 Subject: [PATCH 214/503] Require Twisted<=19.2.0 for Python 3.4 --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bd666e93c..4dc6d18c1 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,8 @@ setup( ], python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', install_requires=[ - 'Twisted>=13.1.0', + 'Twisted>=13.1.0;python_version!="3.4"', + 'Twisted>=13.1.0,<=19.2.0;python_version=="3.4"', 'w3lib>=1.17.0', 'queuelib', 'lxml', From fe0f80f2f422d4047a8b6230d66eb853d443d90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Tue, 11 Jun 2019 15:50:41 +0200 Subject: [PATCH 215/503] Set the cloned directory as PYTHONPATH in appveyor.yml --- appveyor.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 93cfd469e..7fd636864 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,7 +12,8 @@ branches: install: - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - "SET TOX_TESTENV_PASSENV=HOME USERPROFILE HOMEPATH HOMEDRIVE" + - "SET PYTHONPATH=%APPVEYOR_BUILD_FOLDER%" + - "SET TOX_TESTENV_PASSENV=HOME HOMEDRIVE HOMEPATH PYTHONPATH USERPROFILE" - "pip install -U tox" build: false From cdeccac6d6ccd0034a5f007ed371c1d481b32c26 Mon Sep 17 00:00:00 2001 From: sortafreel Date: Tue, 11 Jun 2019 17:38:06 +0300 Subject: [PATCH 216/503] Linting (return previous indentation). --- tests/test_loader.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/test_loader.py b/tests/test_loader.py index 241630ab3..5a8ee1b2e 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -746,28 +746,28 @@ class SubselectorLoaderTest(unittest.TestCase): class SelectJmesTestCase(unittest.TestCase): - test_list_equals = { - 'simple': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), - 'invalid': ('foo.bar.baz', {"foo": {"bar": "baz"}}, None), - 'top_level': ('foo', {"foo": {"bar": "baz"}}, {"bar": "baz"}), - 'double_vs_single_quote_string': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), - 'dict': ( - 'foo.bar[*].name', - {"foo": {"bar": [{"name": "one"}, {"name": "two"}]}}, - ['one', 'two'] - ), - 'list': ('[1]', [1, 2], 2) - } + test_list_equals = { + 'simple': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), + 'invalid': ('foo.bar.baz', {"foo": {"bar": "baz"}}, None), + 'top_level': ('foo', {"foo": {"bar": "baz"}}, {"bar": "baz"}), + 'double_vs_single_quote_string': ('foo.bar', {"foo": {"bar": "baz"}}, "baz"), + 'dict': ( + 'foo.bar[*].name', + {"foo": {"bar": [{"name": "one"}, {"name": "two"}]}}, + ['one', 'two'] + ), + 'list': ('[1]', [1, 2], 2) + } - def test_output(self): - for l in self.test_list_equals: - expr, test_list, expected = self.test_list_equals[l] - test = SelectJmes(expr)(test_list) - self.assertEqual( - test, - expected, - msg='test "{}" got {} expected {}'.format(l, test, expected) - ) + def test_output(self): + for l in self.test_list_equals: + expr, test_list, expected = self.test_list_equals[l] + test = SelectJmes(expr)(test_list) + self.assertEqual( + test, + expected, + msg='test "{}" got {} expected {}'.format(l, test, expected) + ) if __name__ == "__main__": From b8900ec6a698cb4e27424de57ca5593f1c7300e7 Mon Sep 17 00:00:00 2001 From: Anubhav Patel Date: Mon, 17 Jun 2019 00:06:44 +0530 Subject: [PATCH 217/503] removes unused var --- tests/test_downloadermiddleware.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_downloadermiddleware.py b/tests/test_downloadermiddleware.py index 0f420b70d..03564e748 100644 --- a/tests/test_downloadermiddleware.py +++ b/tests/test_downloadermiddleware.py @@ -123,7 +123,6 @@ class ProcessRequestInvalidOutput(ManagerTestCase): def test_invalid_process_request(self): req = Request('http://example.com/index.html') - resp = Response('http://example.com/index.html') class InvalidProcessRequestMiddleware: def process_request(self, request, spider): @@ -143,7 +142,6 @@ class ProcessResponseInvalidOutput(ManagerTestCase): def test_invalid_process_response(self): req = Request('http://example.com/index.html') - resp = Response('http://example.com/index.html') class InvalidProcessResponseMiddleware: def process_response(self, request, response, spider): @@ -163,7 +161,6 @@ class ProcessExceptionInvalidOutput(ManagerTestCase): def test_invalid_process_exception(self): req = Request('http://example.com/index.html') - resp = Response('http://example.com/index.html') class InvalidProcessExceptionMiddleware: def process_request(self, request, spider): From 8d1e0e09bb6fdeb4f1348b408a268c92dc9e7a8f Mon Sep 17 00:00:00 2001 From: Mabel Villalba Date: Thu, 20 Jun 2019 10:06:06 +0200 Subject: [PATCH 218/503] [itemloader-errors] added error message in get_value --- scrapy/loader/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 20f0f90c3..5055de015 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -106,11 +106,17 @@ class ItemLoader(object): value = arg_to_iter(value) value = flatten(extract_regex(regex, x) for x in value) - for proc in processors: + for _proc in processors: if value is None: break - proc = wrap_loader_context(proc, self.context) - value = proc(value) + proc = wrap_loader_context(_proc, self.context) + try: + value = proc(value) + except Exception as e: + raise ValueError( + "Error with processor %s value=%r error='%s: %s'" % + (_proc.__class__.__name__, value, type(e).__name__, + str(e))) return value def load_item(self): From 663352b2a5250c377bbbe2d53c5d5b7da3a1836a Mon Sep 17 00:00:00 2001 From: Mabel Villalba Date: Thu, 20 Jun 2019 10:10:16 +0200 Subject: [PATCH 219/503] [itemloader-errors] added error message to _process_input_value --- scrapy/loader/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 5055de015..fc8b10bee 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -155,9 +155,15 @@ class ItemLoader(object): return proc def _process_input_value(self, field_name, value): - proc = self.get_input_processor(field_name) - proc = wrap_loader_context(proc, self.context) - return proc(value) + _proc = self.get_input_processor(field_name) + proc = wrap_loader_context(_proc, self.context) + try: + return proc(value) + except Exception as e: + raise ValueError( + "Error with inputput processor %s: field=%r value=%r " + "error='%s: %s'" % (_proc.__class__.__name__, field_name, + value, type(e).__name__, str(e))) def _get_item_field_attr(self, field_name, key, default=None): if isinstance(self.item, Item): From f4f2b1695c4d7bc69e5cb19c33a3a47f69bd1e8d Mon Sep 17 00:00:00 2001 From: Victor Torres Date: Mon, 24 Jun 2019 07:38:05 -0300 Subject: [PATCH 220/503] Fix a memory leak on the Media Pipeline (Files and Images) (#3813) We're storing exceptions captured by Twisted on the media pipeline cache, but we're also using the defer.returnValue method with our own methods decorated with @defer.inlineCallbacks. The defer.returnValue method passes returned values forward by throwing a defer._DefGen_Return exception, which in its turn extends the BaseException class and is captured by Twisted. This way, the latest exception stored in the Failure's object may also have an HtmlResponse object in its __context__ attribute. As the Response object also keeps track of the Request object that has originated it, you could figure it out how many RAM we're wasting here. This could easily lead to a Memory Leak problem when running spiders with Media Pipeline enabled and a particular Request set that tends to raise a significant number of exceptions. Example triggers: - media requests with 404 status responses - user land exceptins coming from custom middlewares - etc. --- scrapy/pipelines/media.py | 26 +++++++++++- tests/test_pipeline_media.py | 77 +++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py index 404bbf5bf..95dca9a3f 100644 --- a/scrapy/pipelines/media.py +++ b/scrapy/pipelines/media.py @@ -3,7 +3,7 @@ from __future__ import print_function import functools import logging from collections import defaultdict -from twisted.internet.defer import Deferred, DeferredList +from twisted.internet.defer import Deferred, DeferredList, _DefGen_Return from twisted.python.failure import Failure from scrapy.settings import Settings @@ -139,6 +139,30 @@ class MediaPipeline(object): result.cleanFailure() result.frames = [] result.stack = None + + # This code fixes a memory leak by avoiding to keep references to + # the Request and Response objects on the Media Pipeline cache. + # + # Twisted inline callbacks pass return values using the function + # twisted.internet.defer.returnValue, which encapsulates the return + # value inside a _DefGen_Return base exception. + # + # What happens when the media_downloaded callback raises another + # exception, for example a FileException('download-error') when + # the Response status code is not 200 OK, is that it stores the + # _DefGen_Return exception on the FileException context. + # + # To avoid keeping references to the Response and therefore Request + # objects on the Media Pipeline cache, we should wipe the context of + # the exception encapsulated by the Twisted Failure when its a + # _DefGen_Return instance. + # + # This problem does not occur in Python 2.7 since we don't have + # Exception Chaining (https://www.python.org/dev/peps/pep-3134/). + context = getattr(result.value, '__context__', None) + if isinstance(context, _DefGen_Return): + setattr(result.value, '__context__', None) + info.downloading.remove(fp) info.downloaded[fp] = result # cache result for wad in info.waiting.pop(fp): diff --git a/tests/test_pipeline_media.py b/tests/test_pipeline_media.py index 5f6a6d9e6..28e39cefa 100644 --- a/tests/test_pipeline_media.py +++ b/tests/test_pipeline_media.py @@ -1,15 +1,19 @@ from __future__ import print_function + +import sys + from testfixtures import LogCapture from twisted.trial import unittest from twisted.python.failure import Failure from twisted.internet import reactor -from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.internet.defer import Deferred, inlineCallbacks, returnValue from scrapy.http import Request, Response from scrapy.settings import Settings from scrapy.spiders import Spider from scrapy.utils.request import request_fingerprint from scrapy.pipelines.media import MediaPipeline +from scrapy.pipelines.files import FileException from scrapy.utils.log import failure_to_exc_info from scrapy.utils.signal import disconnect_all from scrapy import signals @@ -90,6 +94,77 @@ class BaseMediaPipelineTestCase(unittest.TestCase): self.pipe._modify_media_request(request) assert request.meta == {'handle_httpstatus_all': True} + def test_should_remove_req_res_references_before_caching_the_results(self): + """Regression test case to prevent a memory leak in the Media Pipeline. + + The memory leak is triggered when an exception is raised when a Response + scheduled by the Media Pipeline is being returned. For example, when a + FileException('download-error') is raised because the Response status + code is not 200 OK. + + It happens because we are keeping a reference to the Response object + inside the FileException context. This is caused by the way Twisted + return values from inline callbacks. It raises a custom exception + encapsulating the original return value. + + The solution is to remove the exception context when this context is a + _DefGen_Return instance, the BaseException used by Twisted to pass the + returned value from those inline callbacks. + + Maybe there's a better and more reliable way to test the case described + here, but it would be more complicated and involve running - or at least + mocking - some async steps from the Media Pipeline. The current test + case is simple and detects the problem very fast. On the other hand, it + would not detect another kind of leak happening due to old object + references being kept inside the Media Pipeline cache. + + This problem does not occur in Python 2.7 since we don't have Exception + Chaining (https://www.python.org/dev/peps/pep-3134/). + """ + # Create sample pair of Request and Response objects + request = Request('http://url') + response = Response('http://url', body=b'', request=request) + + # Simulate the Media Pipeline behavior to produce a Twisted Failure + try: + # Simulate a Twisted inline callback returning a Response + # The returnValue method raises an exception encapsulating the value + returnValue(response) + except BaseException as exc: + def_gen_return_exc = exc + try: + # Simulate the media_downloaded callback raising a FileException + # This usually happens when the status code is not 200 OK + raise FileException('download-error') + except Exception as exc: + file_exc = exc + # Simulate Twisted capturing the FileException + # It encapsulates the exception inside a Twisted Failure + failure = Failure(file_exc) + + # The Failure should encapsulate a FileException ... + self.assertEqual(failure.value, file_exc) + # ... and if we're running on Python 3 ... + if sys.version_info.major >= 3: + # ... it should have the returnValue exception set as its context + self.assertEqual(failure.value.__context__, def_gen_return_exc) + + # Let's calculate the request fingerprint and fake some runtime data... + fp = request_fingerprint(request) + info = self.pipe.spiderinfo + info.downloading.add(fp) + info.waiting[fp] = [] + + # When calling the method that caches the Request's result ... + self.pipe._cache_result_and_execute_waiters(failure, fp, info) + # ... it should store the Twisted Failure ... + self.assertEqual(info.downloaded[fp], failure) + # ... encapsulating the original FileException ... + self.assertEqual(info.downloaded[fp].value, file_exc) + # ... but it should not store the returnValue exception on its context + context = getattr(info.downloaded[fp].value, '__context__', None) + self.assertIsNone(context) + class MockedMediaPipeline(MediaPipeline): From 8a3b15eb91169ab262e4dca60105f56467ecd1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Chaves?= Date: Wed, 27 Mar 2019 08:50:33 +0100 Subject: [PATCH 221/503] Document how to select dynamically-loaded content --- docs/index.rst | 4 + docs/topics/dynamic-content.rst | 246 ++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 docs/topics/dynamic-content.rst diff --git a/docs/index.rst b/docs/index.rst index cedde8f38..6d5f9e77d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -158,6 +158,7 @@ Solving specific problems topics/practices topics/broad-crawls topics/developer-tools + topics/dynamic-content topics/leaks topics/media-pipeline topics/deploy @@ -183,6 +184,9 @@ Solving specific problems :doc:`topics/developer-tools` Learn how to scrape with your browser's developer tools. +:doc:`topics/dynamic-content` + Read webpage data that is loaded dynamically. + :doc:`topics/leaks` Learn how to find and get rid of memory leaks in your crawler. diff --git a/docs/topics/dynamic-content.rst b/docs/topics/dynamic-content.rst new file mode 100644 index 000000000..8b5dacf56 --- /dev/null +++ b/docs/topics/dynamic-content.rst @@ -0,0 +1,246 @@ +.. _topics-dynamic-content: + +==================================== +Selecting dynamically-loaded content +==================================== + +Some webpages show the desired data when you load them in a web browser. +However, when you download them using Scrapy, you cannot reach the desired data +using :ref:`selectors `. + +When this happens, the recommended approach is to +:ref:`find the data source ` and extract the data +from it. + +If you fail to do that, and you can nonetheless access the desired data through +the :ref:`DOM ` from your web browser, see +:ref:`topics-javascript-rendering`. + +.. _topics-finding-data-source: + +Finding the data source +======================= + +To extract the desired data, you must first find its source location. + +If the data is in a non-text-based format, such as an image or a PDF document, +use the :ref:`network tool ` of your web browser to find +the corresponding request, and :ref:`reproduce it +`. + +If your web browser lets you select the desired data as text, the data may be +defined in embedded JavaScript code, or loaded from an external resource in a +text-based format. + +In that case, you can use a tool like wgrep_ to find the URL of that resource. + +If the data turns out to come from the original URL itself, you must +:ref:`inspect the source code of the webpage ` to +determine where the data is located. + +If the data comes from a different URL, you will need to :ref:`reproduce the +corresponding request `. + +.. _topics-inspecting-source: + +Inspecting the source code of a webpage +======================================= + +Sometimes you need to inspect the source code of a webpage (not the +:ref:`DOM `) to determine where some desired data is located. + +Use Scrapy’s :command:`fetch` command to download the webpage contents as seen +by Scrapy:: + + scrapy fetch --nolog https://example.com > response.html + +If the desired data is in embedded JavaScript code within a ``